Skip to content

Commit 865a36b

Browse files
committed
feat(graph): namespace topic-tunnel rooms with "topic:" prefix + kind field
Previously a cross-wing topic tunnel for "Angular" stored the room as "Angular" — colliding with a wing's literal folder-derived "Angular" room at follow_tunnels/list_tunnels read time, and exposing raw topic strings (which may contain characters rejected by sanitize_name) to the MCP surface. Topic tunnels now store their room as "topic:<original-casing>" and carry kind="topic" on the stored dict. Explicit tunnels get kind="explicit" (default). follow_tunnels("wing", "Angular") on a literal Angular room no longer surfaces topic connections for the same name, and any LLM scanning list_tunnels has a visible discriminator.
1 parent fe051ad commit 865a36b

4 files changed

Lines changed: 79 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1010

1111
### Added
1212

13-
- **Cross-wing topic tunnels.** When two wings have confirmed `TOPIC` labels in common (the LLM-refine bucket from `mempalace init --llm`), the miner now drops a symmetric tunnel between them at mine time so the palace graph reflects shared themes (frameworks, vendors, recurring concepts). Tunnels are routed through the existing `create_tunnel` storage so they share dedup and persistence with explicit tunnels. Threshold is configurable via `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` env var or `topic_tunnel_min_count` in `~/.mempalace/config.json` (default `1`). Manifest-dependency overlap and per-topic allow/deny lists remain out of scope. (#1180)
13+
- **Cross-wing topic tunnels.** When two wings have confirmed `TOPIC` labels in common (the LLM-refine bucket from `mempalace init --llm`), the miner now drops a symmetric tunnel between them at mine time so the palace graph reflects shared themes (frameworks, vendors, recurring concepts). Tunnels are routed through the existing `create_tunnel` storage so they share dedup and persistence with explicit tunnels. Topic tunnels are stored under a synthetic `topic:<name>` room and tagged with `kind: "topic"` on the stored dict — this keeps them distinct from literal folder-derived rooms of the same name (a wing with both an `Angular` folder room and an `Angular` topic tunnel no longer collides at `follow_tunnels` read time) and gives LLMs scanning `list_tunnels` a visible discriminator. Threshold is configurable via `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` env var or `topic_tunnel_min_count` in `~/.mempalace/config.json` (default `1`). Manifest-dependency overlap and per-topic allow/deny lists remain out of scope. (#1180)
1414

1515
---
1616

mempalace/palace_graph.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ def create_tunnel(
362362
label: str = "",
363363
source_drawer_id: str = None,
364364
target_drawer_id: str = None,
365+
kind: str = "explicit",
365366
):
366367
"""Create an explicit (symmetric) tunnel between two locations in the palace.
367368
@@ -382,6 +383,11 @@ def create_tunnel(
382383
label: Description of the connection.
383384
source_drawer_id: Optional specific drawer ID.
384385
target_drawer_id: Optional specific drawer ID.
386+
kind: Tunnel category — ``"explicit"`` (default, user-created link
387+
between real rooms) or ``"topic"`` (auto-generated cross-wing
388+
topical link where rooms are synthetic ``topic:<name>``
389+
identifiers). Preserved on the stored dict so readers can
390+
distinguish real-room traversals from topic connections.
385391
386392
Returns:
387393
The stored tunnel dict.
@@ -401,6 +407,7 @@ def create_tunnel(
401407
"source": {"wing": source_wing, "room": source_room},
402408
"target": {"wing": target_wing, "room": target_room},
403409
"label": label,
410+
"kind": kind,
404411
"created_at": datetime.now(timezone.utc).isoformat(),
405412
}
406413
if source_drawer_id:
@@ -511,16 +518,32 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
511518
# ``~/.mempalace/known_entities.json`` under ``topics_by_wing``.
512519
#
513520
# Tunnels are created via the existing ``create_tunnel`` API so they share
514-
# storage and dedup with explicit tunnels. The room is the topic name —
515-
# this matches the "two wings share an idea" mental model and keeps the
516-
# graph homogeneous.
521+
# storage and dedup with explicit tunnels. The room is a synthetic
522+
# ``topic:<original-casing>`` identifier — the ``topic:`` prefix namespaces
523+
# these tunnels away from literal folder-derived rooms so a wing with an
524+
# auto-detected "Angular" folder room and a "shared topic: Angular" tunnel
525+
# remain distinct at ``follow_tunnels`` / ``list_tunnels`` time. The prefix
526+
# is also visible to any LLM scanning the tunnel list. The ``kind: "topic"``
527+
# field on the stored dict gives callers a machine-readable discriminator.
528+
529+
TOPIC_ROOM_PREFIX = "topic:"
517530

518531

519532
def _normalize_topic(name: str) -> str:
520533
"""Lowercase + strip topics for case-insensitive overlap detection."""
521534
return str(name).strip().lower()
522535

523536

537+
def topic_room(name: str) -> str:
538+
"""Return the synthetic room identifier for a topic tunnel.
539+
540+
Prefixing avoids collisions with literal folder-derived rooms of the
541+
same name (e.g. a wing that has both an "Angular" folder room and an
542+
"Angular" topic tunnel).
543+
"""
544+
return f"{TOPIC_ROOM_PREFIX}{name}"
545+
546+
524547
def compute_topic_tunnels(
525548
topics_by_wing: dict,
526549
min_count: int = 1,
@@ -586,13 +609,15 @@ def compute_topic_tunnels(
586609
for key in sorted(shared_keys):
587610
# Prefer the casing from whichever wing sorts first — both
588611
# are valid; this just keeps the displayed room consistent.
589-
room = topics_a[key] if topics_a[key] else topics_b[key]
612+
topic_name = topics_a[key] if topics_a[key] else topics_b[key]
613+
room = topic_room(topic_name)
590614
tunnel = create_tunnel(
591615
source_wing=wa,
592616
source_room=room,
593617
target_wing=wb,
594618
target_room=room,
595-
label=f"{label_prefix}: {room}",
619+
label=f"{label_prefix}: {topic_name}",
620+
kind="topic",
596621
)
597622
created.append(tunnel)
598623
return created

tests/test_miner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,10 @@ def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch):
536536
listed = palace_graph.list_tunnels()
537537
assert len(listed) == 1
538538
rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]}
539-
assert rooms == {"foo"}
539+
# Topic tunnels use a ``topic:<name>`` synthetic room so they can't
540+
# collide with literal folder-derived rooms of the same name.
541+
assert rooms == {"topic:foo"}
542+
assert listed[0]["kind"] == "topic"
540543
wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]}
541544
assert wings == {"wing_one", "wing_two"}
542545

tests/test_palace_graph_tunnels.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,15 @@ def test_compute_topic_tunnels_creates_link_for_shared_topic(self, tmp_path, mon
156156
assert len(created) == 1
157157
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
158158
assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"}
159-
# Room is the topic itself (case preserved from the first wing).
160-
assert created[0]["source"]["room"] == "OpenAPI"
159+
# Room is namespaced with the ``topic:`` prefix so it can't collide
160+
# with a literal folder-derived room of the same name. Casing of the
161+
# topic is preserved for display.
162+
assert created[0]["source"]["room"] == "topic:OpenAPI"
163+
assert created[0]["target"]["room"] == "topic:OpenAPI"
164+
assert created[0]["kind"] == "topic"
165+
# Label carries the human-readable topic without the prefix.
161166
assert "OpenAPI" in created[0]["label"]
167+
assert "topic:OpenAPI" not in created[0]["label"]
162168

163169
# Tunnel is retrievable via the standard list_tunnels API.
164170
listed = palace_graph.list_tunnels()
@@ -187,7 +193,7 @@ def test_compute_topic_tunnels_above_threshold_creates_per_topic_links(
187193
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
188194
# Two shared topics × one wing pair = two tunnels.
189195
rooms = sorted(t["source"]["room"] for t in created)
190-
assert rooms == ["Angular", "OpenAPI"]
196+
assert rooms == ["topic:Angular", "topic:OpenAPI"]
191197

192198
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
193199
_use_tmp_tunnel_file(monkeypatch, tmp_path)
@@ -258,3 +264,38 @@ def test_compute_topic_tunnels_dedupe_on_recompute(self, tmp_path, monkeypatch):
258264
# not multiply the stored tunnels.
259265
assert first[0]["id"] == second[0]["id"]
260266
assert len(palace_graph.list_tunnels()) == 1
267+
268+
def test_topic_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch):
269+
"""Regression: a literal "Angular" folder-room and a topic tunnel
270+
for "Angular" must resolve to distinct endpoints so ``follow_tunnels``
271+
from the real room doesn't accidentally surface topic connections
272+
(issue raised in review of #1184)."""
273+
_use_tmp_tunnel_file(monkeypatch, tmp_path)
274+
275+
# Explicit tunnel anchored at a literal "Angular" room in wing_alpha.
276+
palace_graph.create_tunnel(
277+
"wing_alpha", "Angular", "wing_gamma", "frontend", label="explicit"
278+
)
279+
# Topic tunnel between the same wings that share the "Angular" topic.
280+
palace_graph.compute_topic_tunnels(
281+
{"wing_alpha": ["Angular"], "wing_beta": ["Angular"]}, min_count=1
282+
)
283+
284+
# follow_tunnels on the literal Angular room only sees the explicit link.
285+
literal = palace_graph.follow_tunnels("wing_alpha", "Angular")
286+
assert len(literal) == 1
287+
assert literal[0]["connected_wing"] == "wing_gamma"
288+
289+
# The topic tunnel is stored under the namespaced room.
290+
topical = palace_graph.follow_tunnels("wing_alpha", "topic:Angular")
291+
assert len(topical) == 1
292+
assert topical[0]["connected_wing"] == "wing_beta"
293+
294+
def test_topic_tunnels_carry_kind_field(self, tmp_path, monkeypatch):
295+
_use_tmp_tunnel_file(monkeypatch, tmp_path)
296+
palace_graph.create_tunnel("wing_a", "auth", "wing_b", "users", label="x")
297+
palace_graph.compute_topic_tunnels({"wing_a": ["Redis"], "wing_b": ["Redis"]}, min_count=1)
298+
299+
tunnels = palace_graph.list_tunnels()
300+
kinds = sorted(t["kind"] for t in tunnels)
301+
assert kinds == ["explicit", "topic"]

0 commit comments

Comments
 (0)