Skip to content

Commit 6ca0b83

Browse files
committed
feat(gossip): priority channels, noise tolerance, amplification, random walk
1 parent c5665e2 commit 6ca0b83

2 files changed

Lines changed: 189 additions & 11 deletions

File tree

mempalace/gossip.py

Lines changed: 149 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"chatter_frequency_ms": 1000,
4848
"noise_tolerance": 0.2,
4949
"amplification_factor": 1.5,
50+
"randomness_factor": 0.3,
51+
"redundancy_handling": "deduplicate",
52+
"gossip_decay": "exponential",
5053
"chatter_nodes": [
5154
{
5255
"id": "chatter_orkid_tech",
@@ -159,10 +162,42 @@
159162
},
160163
],
161164
"channels": {
162-
"critical": {"latency_ms": 5, "reliability": 0.99, "capacity": "unlimited"},
163-
"high": {"latency_ms": 50, "reliability": 0.95, "capacity": "high"},
164-
"medium": {"latency_ms": 200, "reliability": 0.90, "capacity": "medium"},
165-
"low": {"latency_ms": 1000, "reliability": 0.85, "capacity": "low"},
165+
"critical": {
166+
"latency_ms": 5,
167+
"reliability": 0.99,
168+
"capacity": "unlimited",
169+
"ttl_seconds": 30,
170+
"fanout": 7,
171+
"gossip_probability": 0.95,
172+
"max_hops": 3,
173+
},
174+
"high": {
175+
"latency_ms": 50,
176+
"reliability": 0.95,
177+
"capacity": "high",
178+
"ttl_seconds": 60,
179+
"fanout": 5,
180+
"gossip_probability": 0.85,
181+
"max_hops": 3,
182+
},
183+
"medium": {
184+
"latency_ms": 200,
185+
"reliability": 0.90,
186+
"capacity": "medium",
187+
"ttl_seconds": 120,
188+
"fanout": 4,
189+
"gossip_probability": 0.75,
190+
"max_hops": 3,
191+
},
192+
"low": {
193+
"latency_ms": 1000,
194+
"reliability": 0.85,
195+
"capacity": "low",
196+
"ttl_seconds": 300,
197+
"fanout": 2,
198+
"gossip_probability": 0.60,
199+
"max_hops": 2,
200+
},
166201
},
167202
}
168203

@@ -294,9 +329,11 @@ class GossipMessage:
294329
source_room: Optional[str] = None
295330
priority: str = "normal"
296331
topic: Optional[str] = None
332+
channel: str = "medium"
297333
hops: int = 0
298334
max_hops: int = 3
299335
ttl_seconds: int = 60
336+
gossip_probability: Optional[float] = None
300337
_started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
301338

302339
@property
@@ -316,9 +353,11 @@ def child(self, wing: str, room: Optional[str] = None) -> GossipMessage:
316353
source_room=room,
317354
priority=self.priority,
318355
topic=self.topic,
356+
channel=self.channel,
319357
hops=self.hops + 1,
320358
max_hops=self.max_hops,
321359
ttl_seconds=self.ttl_seconds,
360+
gossip_probability=self.gossip_probability,
322361
_started_at=self._started_at,
323362
)
324363

@@ -372,20 +411,85 @@ def detect_topic(self, text: str, priority: Optional[str] = None) -> tuple[str,
372411
return "general", priority
373412
return "general", "normal"
374413

414+
def _channel_for_priority(self, priority: str) -> str:
415+
"""Map a message priority to a channel key."""
416+
mapping = {
417+
"critical": "critical",
418+
"high": "high",
419+
"medium": "medium",
420+
"low": "low",
421+
}
422+
return mapping.get(priority, "medium")
423+
424+
def _channel_params(self, message: GossipMessage) -> dict[str, Any]:
425+
"""Return channel-overridden propagation parameters."""
426+
channel = self.config.get("channels", {}).get(message.channel, {})
427+
defaults = {
428+
"ttl_seconds": self.config.get("ttl_seconds", 60),
429+
"fanout": self.config.get("fanout", 5),
430+
"gossip_probability": self.config.get("gossip_probability", 0.7),
431+
"max_hops": self.config.get("max_hops", 3),
432+
"randomness_factor": self.config.get("randomness_factor", 0.3),
433+
"noise_tolerance": self.config.get("noise_tolerance", 0.2),
434+
}
435+
if not isinstance(channel, dict):
436+
return defaults
437+
return {**defaults, **{k: v for k, v in channel.items() if k in defaults}}
438+
439+
def _random_walk_swap(
440+
self, message: GossipMessage, selected: list[ChatterNode]
441+
) -> list[ChatterNode]:
442+
"""Occasionally replace selected nodes with random off-radius nodes."""
443+
params = self._channel_params(message)
444+
randomness = float(params.get("randomness_factor", 0.3))
445+
max_steps = 5
446+
447+
available = [n for n in self._chatter_nodes if n not in selected]
448+
if not available:
449+
return selected
450+
451+
swapped = list(selected)
452+
swaps = 0
453+
for i in range(len(swapped)):
454+
if random.random() < randomness and swaps < max_steps:
455+
replacement = random.choice(available)
456+
swapped[i] = replacement
457+
available.remove(replacement)
458+
swaps += 1
459+
return swapped
460+
375461
def select_chatter_nodes(
376462
self,
377463
message: GossipMessage,
378464
fanout: Optional[int] = None,
379465
) -> list[ChatterNode]:
380-
"""Rank and select chatter nodes for a given message."""
381-
fanout = fanout if fanout is not None else self.config.get("fanout", 5)
466+
"""Rank and select chatter nodes for a given message.
467+
468+
Selection applies the channel's fanout and noise tolerance, then
469+
performs a random-walk swap so gossip can occasionally escape the
470+
normal routing radius while still respecting the fanout budget.
471+
"""
472+
params = self._channel_params(message)
473+
fanout = (
474+
fanout
475+
if fanout is not None
476+
else params.get("fanout", self.config.get("fanout", 5))
477+
)
478+
noise_tolerance = float(params.get("noise_tolerance", 0.2))
479+
382480
scored = [
383481
(node, node.match_score(message.text, message.source_wing))
384482
for node in self._chatter_nodes
385483
]
386-
scored = [(n, s) for n, s in scored if s > 0]
484+
# Noise tolerance: drop nodes that are not relevant enough.
485+
scored = [(n, s) for n, s in scored if s >= noise_tolerance]
387486
scored.sort(key=lambda x: x[1], reverse=True)
388-
return [n for n, _ in scored[:fanout]]
487+
selected = [n for n, _ in scored[:fanout]]
488+
489+
# Random walk: occasionally swap a selected node for a random one.
490+
selected = self._random_walk_swap(message, selected)
491+
492+
return selected
389493

390494
def _resolve_targets(
391495
self,
@@ -437,14 +541,26 @@ def _resolve_targets(
437541
return unique
438542

439543
def _should_forward(self, message: GossipMessage, node: ChatterNode) -> bool:
440-
"""Apply gossip probability, chatter level, and TTL checks."""
544+
"""Apply gossip probability, chatter level, amplification, and TTL checks."""
441545
if message.is_expired():
442546
return False
443547
if message.hops >= getattr(message, "max_hops", self.config.get("max_hops", 3)):
444548
return False
445-
base = self.config.get("gossip_probability", 0.7)
549+
550+
params = self._channel_params(message)
551+
base = (
552+
message.gossip_probability
553+
if message.gossip_probability is not None
554+
else float(params.get("gossip_probability", self.config.get("gossip_probability", 0.7)))
555+
)
446556
level_mult = CHATTER_LEVEL_MULTIPLIER.get(node.chatter_level, 1.0)
447557
probability = min(1.0, base * level_mult)
558+
559+
# Amplification factor boosts high-priority information.
560+
if message.priority in {"critical", "high"}:
561+
amp = float(self.config.get("amplification_factor", 1.5))
562+
probability = min(1.0, probability * amp)
563+
448564
return random.random() < probability
449565

450566
def _compute_valid_to(self, message: GossipMessage) -> Optional[str]:
@@ -476,7 +592,8 @@ def propagate(
476592
detected_topic, detected_priority = self.detect_topic(text, priority)
477593
priority = priority or detected_priority
478594

479-
message = GossipMessage(
595+
# Build a preliminary message to resolve the channel and its parameters.
596+
preliminary = GossipMessage(
480597
subject=subject,
481598
predicate=predicate,
482599
obj=obj,
@@ -487,6 +604,22 @@ def propagate(
487604
ttl_seconds=self.config.get("ttl_seconds", 60),
488605
max_hops=max_hops if max_hops is not None else self.config.get("max_hops", 3),
489606
)
607+
channel = self._channel_for_priority(priority)
608+
params = self.config.get("channels", {}).get(channel, {})
609+
610+
message = GossipMessage(
611+
subject=subject,
612+
predicate=predicate,
613+
obj=obj,
614+
source_wing=source_wing,
615+
source_room=source_room,
616+
priority=priority,
617+
topic=detected_topic,
618+
channel=channel,
619+
ttl_seconds=params.get("ttl_seconds", preliminary.ttl_seconds),
620+
max_hops=params.get("max_hops", preliminary.max_hops),
621+
gossip_probability=params.get("gossip_probability"),
622+
)
490623

491624
selected = self.select_chatter_nodes(message, fanout=fanout)
492625
report: dict[str, Any] = {
@@ -556,8 +689,13 @@ def chatter_status(self) -> dict[str, Any]:
556689
"max_hops": self.config.get("max_hops", 3),
557690
"ttl_seconds": self.config.get("ttl_seconds", 60),
558691
"fanout": self.config.get("fanout", 5),
692+
"gossip_probability": self.config.get("gossip_probability", 0.7),
693+
"randomness_factor": self.config.get("randomness_factor", 0.3),
694+
"noise_tolerance": self.config.get("noise_tolerance", 0.2),
695+
"amplification_factor": self.config.get("amplification_factor", 1.5),
559696
"chatter_nodes": [asdict(n) for n in self._chatter_nodes],
560697
"topics": self.config.get("topics", []),
698+
"channels": self.config.get("channels", {}),
561699
}
562700

563701

tests/test_gossip.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,3 +304,43 @@ def test_gossip_config_is_json_serializable():
304304
with open(path, "r", encoding="utf-8") as f:
305305
on_disk = json.load(f)
306306
assert on_disk["chatter_nodes"][0]["id"] == saved["chatter_nodes"][0]["id"]
307+
308+
309+
def test_propagate_uses_priority_channel_for_ttl_and_fanout():
310+
kg_path = _temp_db()
311+
try:
312+
protocol = GossipProtocol(kg_path=kg_path)
313+
# Critical messages get the critical channel.
314+
report = protocol.propagate(
315+
"audit",
316+
"found",
317+
"security vulnerability",
318+
source_wing="orkid",
319+
priority="critical",
320+
)
321+
assert report["priority"] == "critical"
322+
# The test environment has 7 nodes; critical fanout is 7, so all may fire.
323+
assert report["triples_written"] >= 1
324+
325+
# Low-priority messages use the low channel with a smaller fanout.
326+
low = protocol.propagate(
327+
"idea",
328+
"is",
329+
"spark",
330+
source_wing="brutal-marketing",
331+
priority="low",
332+
)
333+
assert low["priority"] == "low"
334+
assert low["triples_written"] >= 0
335+
finally:
336+
Path(kg_path).unlink(missing_ok=True)
337+
338+
339+
def test_chatter_status_includes_channels():
340+
protocol = GossipProtocol()
341+
status = protocol.chatter_status()
342+
assert "channels" in status
343+
assert "critical" in status["channels"]
344+
assert "low" in status["channels"]
345+
assert status["noise_tolerance"] == 0.2
346+
assert status["amplification_factor"] == 1.5

0 commit comments

Comments
 (0)