Skip to content

Commit df53d15

Browse files
committed
feat(gossip): echo-chamber attenuation with path vector and salience suppression
1 parent a4a7272 commit df53d15

2 files changed

Lines changed: 159 additions & 4 deletions

File tree

mempalace/gossip.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
"randomness_factor": 0.3,
5757
"redundancy_handling": "deduplicate",
5858
"gossip_decay": "exponential",
59+
"echo_chamber_reinforcement_count": 3,
60+
"echo_chamber_attenuation": 0.5,
61+
"echo_chamber_similarity_threshold": 0.8,
5962
"chatter_nodes": [],
6063
"topics": [],
6164
}
@@ -75,6 +78,9 @@
7578
"randomness_factor": 0.3,
7679
"redundancy_handling": "deduplicate",
7780
"gossip_decay": "exponential",
81+
"echo_chamber_reinforcement_count": 3,
82+
"echo_chamber_attenuation": 0.5,
83+
"echo_chamber_similarity_threshold": 0.8,
7884
"chatter_nodes": [
7985
{
8086
"id": "chatter_orkid_tech",
@@ -367,6 +373,7 @@ class GossipMessage:
367373
max_hops: int = 3
368374
ttl_seconds: int = 60
369375
gossip_probability: Optional[float] = None
376+
path: list[str] = field(default_factory=list)
370377
_started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
371378

372379
@property
@@ -376,8 +383,11 @@ def text(self) -> str:
376383
def is_expired(self) -> bool:
377384
return (datetime.now(timezone.utc) - self._started_at).total_seconds() > self.ttl_seconds
378385

379-
def child(self, wing: str, room: Optional[str] = None) -> GossipMessage:
380-
"""Return a copy with incremented hop count."""
386+
def child(self, wing: str, room: Optional[str] = None, node_id: Optional[str] = None) -> GossipMessage:
387+
"""Return a copy with incremented hop count and updated path."""
388+
new_path = list(self.path)
389+
if node_id:
390+
new_path.append(node_id)
381391
return GossipMessage(
382392
subject=self.subject,
383393
predicate=self.predicate,
@@ -391,6 +401,7 @@ def child(self, wing: str, room: Optional[str] = None) -> GossipMessage:
391401
max_hops=self.max_hops,
392402
ttl_seconds=self.ttl_seconds,
393403
gossip_probability=self.gossip_probability,
404+
path=new_path,
394405
_started_at=self._started_at,
395406
)
396407

@@ -649,8 +660,51 @@ def _resolve_targets(
649660
unique.append(t)
650661
return unique
651662

663+
def _attenuate_echo_chamber(
664+
self, message: GossipMessage, node: ChatterNode, probability: float
665+
) -> float:
666+
"""Dampen probability when the node has already seen similar messages.
667+
668+
Each path entry is either a node id or ``node_id:message_text``. The
669+
similarity threshold determines whether a prior visit counts as an echo.
670+
"""
671+
threshold = float(
672+
self.config.get("echo_chamber_similarity_threshold", 0.8)
673+
)
674+
reinforcement_count = int(
675+
self.config.get("echo_chamber_reinforcement_count", 3)
676+
)
677+
attenuation = float(self.config.get("echo_chamber_attenuation", 0.5))
678+
679+
current_text = message.text
680+
current_tokens = set(current_text.lower().split())
681+
echo_visits = 0
682+
683+
for entry in message.path or []:
684+
if not entry:
685+
continue
686+
if ":" in entry:
687+
stored_id, stored_text = entry.split(":", 1)
688+
else:
689+
stored_id = entry
690+
stored_text = current_text
691+
if stored_id != node.id:
692+
continue
693+
stored_tokens = set(stored_text.lower().split())
694+
union = current_tokens | stored_tokens
695+
if not union:
696+
continue
697+
if len(current_tokens & stored_tokens) / len(union) >= threshold:
698+
echo_visits += 1
699+
700+
if echo_visits >= reinforcement_count:
701+
return 0.0
702+
for _ in range(echo_visits):
703+
probability *= attenuation
704+
return probability
705+
652706
def _should_forward(self, message: GossipMessage, node: ChatterNode) -> bool:
653-
"""Apply gossip probability, chatter level, amplification, and TTL checks."""
707+
"""Apply gossip probability, chatter level, amplification, TTL, and echo checks."""
654708
if message.is_expired():
655709
return False
656710
if message.hops >= getattr(message, "max_hops", self.config.get("max_hops", 3)):
@@ -670,6 +724,7 @@ def _should_forward(self, message: GossipMessage, node: ChatterNode) -> bool:
670724
amp = float(self.config.get("amplification_factor", 1.5))
671725
probability = min(1.0, probability * amp)
672726

727+
probability = self._attenuate_echo_chamber(message, node, probability)
673728
return random.random() < probability
674729

675730
def _compute_valid_to(self, message: GossipMessage) -> Optional[str]:
@@ -756,7 +811,7 @@ def _propagate_message(
756811
{"wing": target_wing, "room": target_room}
757812
)
758813
any_success = True
759-
children.append(message.child(target_wing, target_room))
814+
children.append(message.child(target_wing, target_room, node_id=node.id))
760815
except Exception as exc:
761816
logger.debug(
762817
"gossip: kg add failed for %s/%s", target_wing, target_room, exc_info=True
@@ -849,6 +904,15 @@ def chatter_status(self) -> dict[str, Any]:
849904
"randomness_factor": self.config.get("randomness_factor", 0.3),
850905
"noise_tolerance": self.config.get("noise_tolerance", 0.2),
851906
"amplification_factor": self.config.get("amplification_factor", 1.5),
907+
"echo_chamber_reinforcement_count": self.config.get(
908+
"echo_chamber_reinforcement_count", 3
909+
),
910+
"echo_chamber_attenuation": self.config.get(
911+
"echo_chamber_attenuation", 0.5
912+
),
913+
"echo_chamber_similarity_threshold": self.config.get(
914+
"echo_chamber_similarity_threshold", 0.8
915+
),
852916
"chatter_nodes": [asdict(n) for n in self._chatter_nodes],
853917
"topics": self.config.get("topics", []),
854918
"channels": self.config.get("channels", {}),

tests/test_gossip.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,97 @@ def test_chatter_status_includes_channels():
710710
assert status["amplification_factor"] == 1.5
711711

712712

713+
def test_echo_chamber_attenuates_repeated_similar_messages():
714+
"""A node that has already carried similar messages is attenuated, then
715+
suppressed once the reinforcement count is reached."""
716+
kg_path = _temp_db()
717+
try:
718+
protocol = GossipProtocol(kg_path=kg_path, config=EXAMPLE_GOSSIP_CONFIG)
719+
node = protocol._chatter_nodes[0]
720+
message = GossipMessage(
721+
subject="audit",
722+
predicate="found",
723+
obj="risk",
724+
source_wing="orkid",
725+
priority="high",
726+
path=[f"{node.id}:audit found risk"],
727+
)
728+
729+
# One prior similar visit should reduce the probability.
730+
with unittest.mock.patch.object(random, "random", return_value=0.0):
731+
assert protocol._should_forward(message, node) is True
732+
733+
# The message is still forwardable because probability after one
734+
# attenuation (0.7 * 0.5 = 0.35) is above the patched random value of 0.0.
735+
# With the default reinforcement_count=3 it should not yet be suppressed.
736+
assert protocol._attenuate_echo_chamber(message, node, 0.7) > 0
737+
finally:
738+
Path(kg_path).unlink(missing_ok=True)
739+
740+
741+
def test_echo_chamber_suppresses_after_reinforcement_count():
742+
"""Once a node has seen the same message enough times it is fully suppressed."""
743+
kg_path = _temp_db()
744+
try:
745+
config = dict(EXAMPLE_GOSSIP_CONFIG)
746+
config["echo_chamber_reinforcement_count"] = 2
747+
config["echo_chamber_attenuation"] = 0.5
748+
protocol = GossipProtocol(kg_path=kg_path, config=config)
749+
node = protocol._chatter_nodes[0]
750+
message = GossipMessage(
751+
subject="audit",
752+
predicate="found",
753+
obj="risk",
754+
source_wing="orkid",
755+
priority="high",
756+
# Two prior similar visits meet the reinforcement count.
757+
path=[
758+
f"{node.id}:audit found risk",
759+
f"{node.id}:audit found risk",
760+
],
761+
)
762+
763+
assert protocol._attenuate_echo_chamber(message, node, 0.7) == 0.0
764+
with unittest.mock.patch.object(random, "random", return_value=0.0):
765+
assert protocol._should_forward(message, node) is False
766+
finally:
767+
Path(kg_path).unlink(missing_ok=True)
768+
769+
770+
def test_echo_chamber_similarity_threshold_ignores_dissimilar():
771+
"""A dissimilar prior visit should not trigger attenuation."""
772+
kg_path = _temp_db()
773+
try:
774+
config = dict(EXAMPLE_GOSSIP_CONFIG)
775+
config["echo_chamber_similarity_threshold"] = 0.8
776+
protocol = GossipProtocol(kg_path=kg_path, config=config)
777+
node = protocol._chatter_nodes[0]
778+
message = GossipMessage(
779+
subject="audit",
780+
predicate="found",
781+
obj="risk",
782+
source_wing="orkid",
783+
priority="high",
784+
# The stored text is completely different from the current text.
785+
path=[f"{node.id}:completely unrelated message text"],
786+
)
787+
788+
original = 0.7
789+
attenuated = protocol._attenuate_echo_chamber(message, node, original)
790+
# No echo visits, so the probability is unchanged.
791+
assert attenuated == original
792+
finally:
793+
Path(kg_path).unlink(missing_ok=True)
794+
795+
796+
def test_chatter_status_includes_echo_chamber_config():
797+
protocol = GossipProtocol(config=EXAMPLE_GOSSIP_CONFIG)
798+
status = protocol.chatter_status()
799+
assert status["echo_chamber_reinforcement_count"] == 3
800+
assert status["echo_chamber_attenuation"] == 0.5
801+
assert status["echo_chamber_similarity_threshold"] == 0.8
802+
803+
713804
def test_random_walk_swap_only_picks_off_radius_nodes():
714805
"""Random walk replacements must be outside the source's gossip radius."""
715806
protocol = GossipProtocol(config=EXAMPLE_GOSSIP_CONFIG)

0 commit comments

Comments
 (0)