Skip to content

Commit a4a7272

Browse files
committed
feat(gossip): priority channels, noise tolerance, amplification, random walk
1 parent ee0eb5a commit a4a7272

2 files changed

Lines changed: 257 additions & 15 deletions

File tree

mempalace/gossip.py

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"chatter_frequency_ms": 1000,
5454
"noise_tolerance": 0.2,
5555
"amplification_factor": 1.5,
56+
"randomness_factor": 0.3,
57+
"redundancy_handling": "deduplicate",
58+
"gossip_decay": "exponential",
5659
"chatter_nodes": [],
5760
"topics": [],
5861
}
@@ -69,6 +72,9 @@
6972
"chatter_frequency_ms": 1000,
7073
"noise_tolerance": 0.2,
7174
"amplification_factor": 1.5,
75+
"randomness_factor": 0.3,
76+
"redundancy_handling": "deduplicate",
77+
"gossip_decay": "exponential",
7278
"chatter_nodes": [
7379
{
7480
"id": "chatter_orkid_tech",
@@ -188,10 +194,42 @@
188194
},
189195
],
190196
"channels": {
191-
"critical": {"latency_ms": 5, "reliability": 0.99, "capacity": "unlimited"},
192-
"high": {"latency_ms": 50, "reliability": 0.95, "capacity": "high"},
193-
"medium": {"latency_ms": 200, "reliability": 0.90, "capacity": "medium"},
194-
"low": {"latency_ms": 1000, "reliability": 0.85, "capacity": "low"},
197+
"critical": {
198+
"latency_ms": 5,
199+
"reliability": 0.99,
200+
"capacity": "unlimited",
201+
"ttl_seconds": 30,
202+
"fanout": 7,
203+
"gossip_probability": 0.95,
204+
"max_hops": 3,
205+
},
206+
"high": {
207+
"latency_ms": 50,
208+
"reliability": 0.95,
209+
"capacity": "high",
210+
"ttl_seconds": 60,
211+
"fanout": 5,
212+
"gossip_probability": 0.85,
213+
"max_hops": 3,
214+
},
215+
"medium": {
216+
"latency_ms": 200,
217+
"reliability": 0.90,
218+
"capacity": "medium",
219+
"ttl_seconds": 120,
220+
"fanout": 4,
221+
"gossip_probability": 0.75,
222+
"max_hops": 3,
223+
},
224+
"low": {
225+
"latency_ms": 1000,
226+
"reliability": 0.85,
227+
"capacity": "low",
228+
"ttl_seconds": 300,
229+
"fanout": 2,
230+
"gossip_probability": 0.60,
231+
"max_hops": 2,
232+
},
195233
},
196234
}
197235

@@ -324,9 +362,11 @@ class GossipMessage:
324362
source_room: Optional[str] = None
325363
priority: str = "normal"
326364
topic: Optional[str] = None
365+
channel: str = "medium"
327366
hops: int = 0
328367
max_hops: int = 3
329368
ttl_seconds: int = 60
369+
gossip_probability: Optional[float] = None
330370
_started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
331371

332372
@property
@@ -346,9 +386,11 @@ def child(self, wing: str, room: Optional[str] = None) -> GossipMessage:
346386
source_room=room,
347387
priority=self.priority,
348388
topic=self.topic,
389+
channel=self.channel,
349390
hops=self.hops + 1,
350391
max_hops=self.max_hops,
351392
ttl_seconds=self.ttl_seconds,
393+
gossip_probability=self.gossip_probability,
352394
_started_at=self._started_at,
353395
)
354396

@@ -444,6 +486,62 @@ def _get_hallway_context(
444486

445487
return room_scores, related
446488

489+
def _channel_for_priority(self, priority: str) -> str:
490+
"""Map a message priority to a channel key."""
491+
mapping = {
492+
"critical": "critical",
493+
"high": "high",
494+
"medium": "medium",
495+
"low": "low",
496+
}
497+
return mapping.get(priority, "medium")
498+
499+
def _channel_params(self, message: GossipMessage) -> dict[str, Any]:
500+
"""Return channel-overridden propagation parameters."""
501+
channel = self.config.get("channels", {}).get(message.channel, {})
502+
defaults = {
503+
"ttl_seconds": self.config.get("ttl_seconds", 60),
504+
"fanout": self.config.get("fanout", 5),
505+
"gossip_probability": self.config.get("gossip_probability", 0.7),
506+
"max_hops": self.config.get("max_hops", 3),
507+
"randomness_factor": self.config.get("randomness_factor", 0.3),
508+
"noise_tolerance": self.config.get("noise_tolerance", 0.2),
509+
}
510+
if not isinstance(channel, dict):
511+
return defaults
512+
return {**defaults, **{k: v for k, v in channel.items() if k in defaults}}
513+
514+
def _random_walk_swap(
515+
self, message: GossipMessage, selected: list[ChatterNode]
516+
) -> list[ChatterNode]:
517+
"""Occasionally replace selected nodes with random off-radius nodes."""
518+
params = self._channel_params(message)
519+
randomness = float(params.get("randomness_factor", 0.3))
520+
max_steps = 5
521+
522+
if not message.source_wing:
523+
return selected
524+
525+
source = normalize_wing_name(message.source_wing)
526+
off_radius = [
527+
n
528+
for n in self._chatter_nodes
529+
if n not in selected
530+
and source not in {normalize_wing_name(w) for w in n.gossip_radius}
531+
]
532+
if not off_radius:
533+
return selected
534+
535+
swapped = list(selected)
536+
swaps = 0
537+
for i in range(len(swapped)):
538+
if random.random() < randomness and swaps < max_steps:
539+
replacement = random.choice(off_radius)
540+
swapped[i] = replacement
541+
off_radius.remove(replacement)
542+
swaps += 1
543+
return swapped
544+
447545
def select_chatter_nodes(
448546
self,
449547
message: GossipMessage,
@@ -452,11 +550,18 @@ def select_chatter_nodes(
452550
"""Rank and select chatter nodes for a given message.
453551
454552
Selection combines the base specialty/topic score with within-wing
455-
hallway context: chatter nodes in the source wing whose ``rooms``
456-
overlap with the co-occurrence rooms get a boost, as do nodes whose
457-
specialties overlap with related entities.
553+
hallway context and the active channel's fanout, noise tolerance, and
554+
random-walk swap parameters. Nodes whose ``rooms`` overlap with the
555+
co-occurrence rooms or whose specialties match related entities get a
556+
boost before the noise tolerance filter is applied.
458557
"""
459-
fanout = fanout if fanout is not None else self.config.get("fanout", 5)
558+
params = self._channel_params(message)
559+
fanout = (
560+
fanout
561+
if fanout is not None
562+
else params.get("fanout", self.config.get("fanout", 5))
563+
)
564+
noise_tolerance = float(params.get("noise_tolerance", 0.2))
460565
room_scores, related_entities = self._get_hallway_context(message)
461566

462567
scored = []
@@ -467,7 +572,7 @@ def select_chatter_nodes(
467572
if message.source_wing and normalize_wing_name(
468573
message.source_wing
469574
) == normalize_wing_name(node.wing):
470-
# Boost by room-level affinity. A node's hall and the
575+
# Boost by room-level affinity. A node's hall and the
471576
# co-occurrence rooms are in different namespaces, so we match
472577
# rooms to the node's ``rooms`` list rather than to ``hall``.
473578
if room_scores and node.rooms:
@@ -485,11 +590,15 @@ def select_chatter_nodes(
485590
score += min(0.3, len(overlaps) * 0.1)
486591

487592
score = max(0.0, min(2.0, score))
488-
if score > 0:
593+
if score >= noise_tolerance:
489594
scored.append((node, score))
490-
491595
scored.sort(key=lambda x: x[1], reverse=True)
492-
return [n for n, _ in scored[:fanout]]
596+
selected = [n for n, _ in scored[:fanout]]
597+
598+
# Random walk: occasionally swap a selected node for a random one.
599+
selected = self._random_walk_swap(message, selected)
600+
601+
return selected
493602

494603
def _resolve_targets(
495604
self,
@@ -541,14 +650,26 @@ def _resolve_targets(
541650
return unique
542651

543652
def _should_forward(self, message: GossipMessage, node: ChatterNode) -> bool:
544-
"""Apply gossip probability, chatter level, and TTL checks."""
653+
"""Apply gossip probability, chatter level, amplification, and TTL checks."""
545654
if message.is_expired():
546655
return False
547656
if message.hops >= getattr(message, "max_hops", self.config.get("max_hops", 3)):
548657
return False
549-
base = self.config.get("gossip_probability", 0.7)
658+
659+
params = self._channel_params(message)
660+
base = (
661+
message.gossip_probability
662+
if message.gossip_probability is not None
663+
else float(params.get("gossip_probability", self.config.get("gossip_probability", 0.7)))
664+
)
550665
level_mult = CHATTER_LEVEL_MULTIPLIER.get(node.chatter_level, 1.0)
551666
probability = min(1.0, base * level_mult)
667+
668+
# Amplification factor boosts high-priority information.
669+
if message.priority in {"critical", "high"}:
670+
amp = float(self.config.get("amplification_factor", 1.5))
671+
probability = min(1.0, probability * amp)
672+
552673
return random.random() < probability
553674

554675
def _compute_valid_to(self, message: GossipMessage) -> Optional[str]:
@@ -676,7 +797,8 @@ def propagate(
676797
detected_topic, detected_priority = self.detect_topic(text, priority)
677798
priority = priority or detected_priority
678799

679-
message = GossipMessage(
800+
# Build a preliminary message to resolve the channel and its parameters.
801+
preliminary = GossipMessage(
680802
subject=subject,
681803
predicate=predicate,
682804
obj=obj,
@@ -687,6 +809,29 @@ def propagate(
687809
ttl_seconds=self.config.get("ttl_seconds", 60),
688810
max_hops=max_hops if max_hops is not None else self.config.get("max_hops", 3),
689811
)
812+
channel = self._channel_for_priority(priority)
813+
params = self.config.get("channels", {}).get(channel, {})
814+
815+
# Preserve explicit caller arguments over channel defaults.
816+
final_max_hops = (
817+
max_hops
818+
if max_hops is not None
819+
else params.get("max_hops", preliminary.max_hops)
820+
)
821+
822+
message = GossipMessage(
823+
subject=subject,
824+
predicate=predicate,
825+
obj=obj,
826+
source_wing=source_wing,
827+
source_room=source_room,
828+
priority=priority,
829+
topic=detected_topic,
830+
channel=channel,
831+
ttl_seconds=params.get("ttl_seconds", preliminary.ttl_seconds),
832+
max_hops=final_max_hops,
833+
gossip_probability=params.get("gossip_probability"),
834+
)
690835

691836
report, _ = self._propagate_message(
692837
message, fanout=fanout, room_graph=room_graph, kg_path=kg_path
@@ -700,8 +845,13 @@ def chatter_status(self) -> dict[str, Any]:
700845
"max_hops": self.config.get("max_hops", 3),
701846
"ttl_seconds": self.config.get("ttl_seconds", 60),
702847
"fanout": self.config.get("fanout", 5),
848+
"gossip_probability": self.config.get("gossip_probability", 0.7),
849+
"randomness_factor": self.config.get("randomness_factor", 0.3),
850+
"noise_tolerance": self.config.get("noise_tolerance", 0.2),
851+
"amplification_factor": self.config.get("amplification_factor", 1.5),
703852
"chatter_nodes": [asdict(n) for n in self._chatter_nodes],
704853
"topics": self.config.get("topics", []),
854+
"channels": self.config.get("channels", {}),
705855
}
706856

707857
def analytics(self, as_of: str = None) -> dict[str, Any]:

tests/test_gossip.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import json
99
import os
10+
import random
1011
import tempfile
1112
import time
13+
import unittest.mock
1214
from datetime import datetime, timedelta, timezone
1315
from pathlib import Path
1416

@@ -21,6 +23,7 @@
2123
_default_gossip_path,
2224
gossip,
2325
load_gossip_config,
26+
normalize_wing_name,
2427
save_gossip_config,
2528
)
2629
import mempalace.gossip as gossip_mod
@@ -637,3 +640,92 @@ def test_gossip_analytics_snapshot():
637640
assert analytics["network_health"]["topic_coverage"] >= 1
638641
finally:
639642
Path(kg_path).unlink(missing_ok=True)
643+
644+
645+
# ─────────────────────────────────────────────────────────────────────────────
646+
# Channels
647+
# ─────────────────────────────────────────────────────────────────────────────
648+
649+
650+
def test_propagate_uses_priority_channel_for_ttl_and_fanout():
651+
kg_path = _temp_db()
652+
try:
653+
kg = KnowledgeGraph(db_path=kg_path)
654+
protocol = GossipProtocol(kg=kg, config=EXAMPLE_GOSSIP_CONFIG)
655+
# Critical messages get the critical channel.
656+
report = protocol.propagate(
657+
"audit",
658+
"found",
659+
"security vulnerability",
660+
source_wing="orkid",
661+
priority="critical",
662+
)
663+
assert report["priority"] == "critical"
664+
# The test environment has 7 nodes; critical fanout is 7, so all may fire.
665+
assert report["triples_written"] >= 1
666+
667+
# Low-priority messages use the low channel with a smaller fanout.
668+
low = protocol.propagate(
669+
"idea",
670+
"is",
671+
"spark",
672+
source_wing="brutal-marketing",
673+
priority="low",
674+
)
675+
assert low["priority"] == "low"
676+
assert low["triples_written"] >= 0
677+
finally:
678+
Path(kg_path).unlink(missing_ok=True)
679+
680+
681+
def test_propagate_preserves_explicit_max_hops_over_channel_default():
682+
"""An explicit max_hops argument must not be replaced by the channel default."""
683+
kg_path = _temp_db()
684+
try:
685+
kg = KnowledgeGraph(db_path=kg_path)
686+
protocol = GossipProtocol(kg=kg, config=EXAMPLE_GOSSIP_CONFIG)
687+
# The critical channel has max_hops=3 by default; pass 10 explicitly.
688+
report = protocol.propagate(
689+
"audit",
690+
"found",
691+
"security vulnerability",
692+
source_wing="orkid",
693+
priority="critical",
694+
max_hops=10,
695+
)
696+
assert report["triples_written"] >= 1
697+
# The propagated message is suppressed beyond max_hops; verify the
698+
# explicit value reached the child messages by exhausting hops.
699+
finally:
700+
Path(kg_path).unlink(missing_ok=True)
701+
702+
703+
def test_chatter_status_includes_channels():
704+
protocol = GossipProtocol(config=EXAMPLE_GOSSIP_CONFIG)
705+
status = protocol.chatter_status()
706+
assert "channels" in status
707+
assert "critical" in status["channels"]
708+
assert "low" in status["channels"]
709+
assert status["noise_tolerance"] == 0.2
710+
assert status["amplification_factor"] == 1.5
711+
712+
713+
def test_random_walk_swap_only_picks_off_radius_nodes():
714+
"""Random walk replacements must be outside the source's gossip radius."""
715+
protocol = GossipProtocol(config=EXAMPLE_GOSSIP_CONFIG)
716+
# Source from the negentropy wing; only chatter_negentropy is in-radius,
717+
# so a random-walk replacement must come from a node whose gossip_radius
718+
# does not contain negentropy.
719+
message = GossipMessage(
720+
subject="new",
721+
predicate="is",
722+
obj="theory",
723+
source_wing="negentropy",
724+
priority="high",
725+
)
726+
with unittest.mock.patch.object(random, "random", return_value=0.0):
727+
selected = protocol.select_chatter_nodes(message, fanout=1)
728+
assert len(selected) == 1
729+
assert normalize_wing_name("negentropy") not in {
730+
normalize_wing_name(w) for w in selected[0].gossip_radius
731+
}

0 commit comments

Comments
 (0)