Skip to content

Commit 187e13b

Browse files
committed
feat(gossip): hallways-aware chatter node selection
1 parent c5665e2 commit 187e13b

2 files changed

Lines changed: 141 additions & 6 deletions

File tree

mempalace/gossip.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .config import MempalaceConfig, normalize_wing_name
3030
from .knowledge_graph import KnowledgeGraph
3131
from .palace_graph import build_graph as _build_graph
32+
from .hallways import list_hallways
3233

3334
logger = logging.getLogger("mempalace_gossip")
3435

@@ -372,18 +373,83 @@ def detect_topic(self, text: str, priority: Optional[str] = None) -> tuple[str,
372373
return "general", priority
373374
return "general", "normal"
374375

376+
def _get_hallway_context(
377+
self, message: GossipMessage
378+
) -> tuple[dict[str, float], set[str]]:
379+
"""Return (hall_scores, related_entities) derived from within-wing hallways.
380+
381+
Hallways are entity-pair co-occurrence records built at mine time. When a
382+
gossip message mentions an entity that co-occurs with other entities in
383+
the source wing, we use those co-occurrences to boost chatter nodes that
384+
live in the same rooms/halls or cover the related entities.
385+
"""
386+
if not message.source_wing:
387+
return {}, set()
388+
try:
389+
hallways = list_hallways(
390+
wing=message.source_wing, config=self.mempalace_config
391+
)
392+
except Exception:
393+
logger.debug("gossip: could not load hallways", exc_info=True)
394+
return {}, set()
395+
396+
entities = {message.subject.lower(), message.obj.lower()}
397+
hall_scores: dict[str, float] = {}
398+
related: set[str] = set()
399+
400+
for h in hallways:
401+
a = (h.get("entity_a") or "").lower()
402+
b = (h.get("entity_b") or "").lower()
403+
if a in entities or b in entities:
404+
other = b if a in entities else a
405+
related.add(other)
406+
count = h.get("co_occurrence_count") or 1
407+
for room in h.get("rooms") or []:
408+
room_key = room.lower()
409+
# Accumulate a small boost per co-occurrence in this room.
410+
hall_scores[room_key] = hall_scores.get(room_key, 0.0) + min(
411+
0.15, 0.05 + count * 0.01
412+
)
413+
414+
return hall_scores, related
415+
375416
def select_chatter_nodes(
376417
self,
377418
message: GossipMessage,
378419
fanout: Optional[int] = None,
379420
) -> list[ChatterNode]:
380-
"""Rank and select chatter nodes for a given message."""
421+
"""Rank and select chatter nodes for a given message.
422+
423+
Selection combines the base specialty/topic score with within-wing
424+
hallway context: chatter nodes in the source wing whose hall/room
425+
appears in co-occurrence records for the subject/object get a boost, as
426+
do nodes whose specialties overlap with related entities.
427+
"""
381428
fanout = fanout if fanout is not None else self.config.get("fanout", 5)
382-
scored = [
383-
(node, node.match_score(message.text, message.source_wing))
384-
for node in self._chatter_nodes
385-
]
386-
scored = [(n, s) for n, s in scored if s > 0]
429+
hall_scores, related_entities = self._get_hallway_context(message)
430+
431+
scored = []
432+
for node in self._chatter_nodes:
433+
score = node.match_score(message.text, message.source_wing)
434+
435+
# Hallway-aware boosts (only for nodes in the source wing).
436+
if message.source_wing and normalize_wing_name(
437+
message.source_wing
438+
) == normalize_wing_name(node.wing):
439+
if node.hall and node.hall.lower() in hall_scores:
440+
score += hall_scores[node.hall.lower()]
441+
442+
# Also boost if a related entity matches a specialty.
443+
if related_entities and node.specialties:
444+
overlaps = related_entities & {
445+
s.lower() for s in node.specialties
446+
}
447+
score += min(0.3, len(overlaps) * 0.1)
448+
449+
score = max(0.0, min(2.0, score))
450+
if score > 0:
451+
scored.append((node, score))
452+
387453
scored.sort(key=lambda x: x[1], reverse=True)
388454
return [n for n, _ in scored[:fanout]]
389455

tests/test_gossip.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
load_gossip_config,
2020
save_gossip_config,
2121
)
22+
import mempalace.gossip as gossip_mod
2223
from mempalace.knowledge_graph import KnowledgeGraph
2324

2425

@@ -304,3 +305,71 @@ def test_gossip_config_is_json_serializable():
304305
with open(path, "r", encoding="utf-8") as f:
305306
on_disk = json.load(f)
306307
assert on_disk["chatter_nodes"][0]["id"] == saved["chatter_nodes"][0]["id"]
308+
309+
310+
def test_select_chatter_nodes_uses_hallway_room(monkeypatch):
311+
"""A hallway matching the subject and the node's hall/room boosts selection."""
312+
kg_path = _temp_db()
313+
try:
314+
protocol = GossipProtocol(kg_path=kg_path)
315+
316+
def _fake_list_hallways(wing=None, config=None):
317+
if wing != "orkid":
318+
return []
319+
return [
320+
{
321+
"id": "hallway_orkid_audit_risk_abc12345",
322+
"wing": "orkid",
323+
"entity_a": "audit",
324+
"entity_b": "risk",
325+
"co_occurrence_count": 4,
326+
"rooms": ["security"],
327+
},
328+
{
329+
"id": "hallway_orkid_audit_compliance_abc12345",
330+
"wing": "orkid",
331+
"entity_a": "audit",
332+
"entity_b": "compliance",
333+
"co_occurrence_count": 2,
334+
"rooms": ["contracts"],
335+
},
336+
]
337+
338+
monkeypatch.setattr(gossip_mod, "list_hallways", _fake_list_hallways)
339+
340+
msg = GossipMessage(
341+
subject="audit",
342+
predicate="found",
343+
obj="risk",
344+
source_wing="orkid",
345+
priority="high",
346+
)
347+
nodes = protocol.select_chatter_nodes(msg, fanout=3)
348+
ids = [n.id for n in nodes]
349+
350+
# chatter_security is in hall "security" and has specialties audit/risk/compliance.
351+
assert "chatter_security" in ids
352+
assert ids[0] == "chatter_security"
353+
finally:
354+
Path(kg_path).unlink(missing_ok=True)
355+
356+
357+
def test_select_chatter_nodes_without_hallways(monkeypatch):
358+
"""When hallways are empty, selection falls back to base scoring."""
359+
kg_path = _temp_db()
360+
try:
361+
protocol = GossipProtocol(kg_path=kg_path)
362+
monkeypatch.setattr(gossip_mod, "list_hallways", lambda *a, **kw: [])
363+
364+
msg = GossipMessage(
365+
subject="audit",
366+
predicate="found",
367+
obj="risk",
368+
source_wing="orkid",
369+
priority="high",
370+
)
371+
nodes = protocol.select_chatter_nodes(msg, fanout=3)
372+
# chatter_security still wins on specialty/topic match.
373+
assert any(n.id == "chatter_security" for n in nodes)
374+
finally:
375+
Path(kg_path).unlink(missing_ok=True)

0 commit comments

Comments
 (0)