diff --git a/CHANGELOG.md b/CHANGELOG.md index efd233e..fa7c90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- **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) +- **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:` 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) --- diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index 526b591..a243311 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -362,6 +362,7 @@ def create_tunnel( label: str = "", source_drawer_id: str = None, target_drawer_id: str = None, + kind: str = "explicit", ): """Create an explicit (symmetric) tunnel between two locations in the palace. @@ -382,6 +383,11 @@ def create_tunnel( label: Description of the connection. source_drawer_id: Optional specific drawer ID. target_drawer_id: Optional specific drawer ID. + kind: Tunnel category — ``"explicit"`` (default, user-created link + between real rooms) or ``"topic"`` (auto-generated cross-wing + topical link where rooms are synthetic ``topic:`` + identifiers). Preserved on the stored dict so readers can + distinguish real-room traversals from topic connections. Returns: The stored tunnel dict. @@ -401,6 +407,7 @@ def create_tunnel( "source": {"wing": source_wing, "room": source_room}, "target": {"wing": target_wing, "room": target_room}, "label": label, + "kind": kind, "created_at": datetime.now(timezone.utc).isoformat(), } if source_drawer_id: @@ -511,9 +518,15 @@ def follow_tunnels(wing: str, room: str, col=None, config=None): # ``~/.mempalace/known_entities.json`` under ``topics_by_wing``. # # Tunnels are created via the existing ``create_tunnel`` API so they share -# storage and dedup with explicit tunnels. The room is the topic name — -# this matches the "two wings share an idea" mental model and keeps the -# graph homogeneous. +# storage and dedup with explicit tunnels. The room is a synthetic +# ``topic:`` identifier — the ``topic:`` prefix namespaces +# these tunnels away from literal folder-derived rooms so a wing with an +# auto-detected "Angular" folder room and a "shared topic: Angular" tunnel +# remain distinct at ``follow_tunnels`` / ``list_tunnels`` time. The prefix +# is also visible to any LLM scanning the tunnel list. The ``kind: "topic"`` +# field on the stored dict gives callers a machine-readable discriminator. + +TOPIC_ROOM_PREFIX = "topic:" def _normalize_topic(name: str) -> str: @@ -521,6 +534,16 @@ def _normalize_topic(name: str) -> str: return str(name).strip().lower() +def topic_room(name: str) -> str: + """Return the synthetic room identifier for a topic tunnel. + + Prefixing avoids collisions with literal folder-derived rooms of the + same name (e.g. a wing that has both an "Angular" folder room and an + "Angular" topic tunnel). + """ + return f"{TOPIC_ROOM_PREFIX}{name}" + + def compute_topic_tunnels( topics_by_wing: dict, min_count: int = 1, @@ -586,13 +609,15 @@ def compute_topic_tunnels( for key in sorted(shared_keys): # Prefer the casing from whichever wing sorts first — both # are valid; this just keeps the displayed room consistent. - room = topics_a[key] if topics_a[key] else topics_b[key] + topic_name = topics_a[key] if topics_a[key] else topics_b[key] + room = topic_room(topic_name) tunnel = create_tunnel( source_wing=wa, source_room=room, target_wing=wb, target_room=room, - label=f"{label_prefix}: {room}", + label=f"{label_prefix}: {topic_name}", + kind="topic", ) created.append(tunnel) return created diff --git a/tests/test_miner.py b/tests/test_miner.py index 9b4f127..7f142bb 100644 --- a/tests/test_miner.py +++ b/tests/test_miner.py @@ -536,7 +536,10 @@ def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch): listed = palace_graph.list_tunnels() assert len(listed) == 1 rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]} - assert rooms == {"foo"} + # Topic tunnels use a ``topic:`` synthetic room so they can't + # collide with literal folder-derived rooms of the same name. + assert rooms == {"topic:foo"} + assert listed[0]["kind"] == "topic" wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]} assert wings == {"wing_one", "wing_two"} diff --git a/tests/test_palace_graph_tunnels.py b/tests/test_palace_graph_tunnels.py index 5048ad5..2713338 100644 --- a/tests/test_palace_graph_tunnels.py +++ b/tests/test_palace_graph_tunnels.py @@ -156,9 +156,15 @@ class TestTopicTunnels: assert len(created) == 1 assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"} assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"} - # Room is the topic itself (case preserved from the first wing). - assert created[0]["source"]["room"] == "OpenAPI" + # Room is namespaced with the ``topic:`` prefix so it can't collide + # with a literal folder-derived room of the same name. Casing of the + # topic is preserved for display. + assert created[0]["source"]["room"] == "topic:OpenAPI" + assert created[0]["target"]["room"] == "topic:OpenAPI" + assert created[0]["kind"] == "topic" + # Label carries the human-readable topic without the prefix. assert "OpenAPI" in created[0]["label"] + assert "topic:OpenAPI" not in created[0]["label"] # Tunnel is retrievable via the standard list_tunnels API. listed = palace_graph.list_tunnels() @@ -187,7 +193,7 @@ class TestTopicTunnels: created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2) # Two shared topics × one wing pair = two tunnels. rooms = sorted(t["source"]["room"] for t in created) - assert rooms == ["Angular", "OpenAPI"] + assert rooms == ["topic:Angular", "topic:OpenAPI"] def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch): _use_tmp_tunnel_file(monkeypatch, tmp_path) @@ -258,3 +264,38 @@ class TestTopicTunnels: # not multiply the stored tunnels. assert first[0]["id"] == second[0]["id"] assert len(palace_graph.list_tunnels()) == 1 + + def test_topic_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch): + """Regression: a literal "Angular" folder-room and a topic tunnel + for "Angular" must resolve to distinct endpoints so ``follow_tunnels`` + from the real room doesn't accidentally surface topic connections + (issue raised in review of #1184).""" + _use_tmp_tunnel_file(monkeypatch, tmp_path) + + # Explicit tunnel anchored at a literal "Angular" room in wing_alpha. + palace_graph.create_tunnel( + "wing_alpha", "Angular", "wing_gamma", "frontend", label="explicit" + ) + # Topic tunnel between the same wings that share the "Angular" topic. + palace_graph.compute_topic_tunnels( + {"wing_alpha": ["Angular"], "wing_beta": ["Angular"]}, min_count=1 + ) + + # follow_tunnels on the literal Angular room only sees the explicit link. + literal = palace_graph.follow_tunnels("wing_alpha", "Angular") + assert len(literal) == 1 + assert literal[0]["connected_wing"] == "wing_gamma" + + # The topic tunnel is stored under the namespaced room. + topical = palace_graph.follow_tunnels("wing_alpha", "topic:Angular") + assert len(topical) == 1 + assert topical[0]["connected_wing"] == "wing_beta" + + def test_topic_tunnels_carry_kind_field(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + palace_graph.create_tunnel("wing_a", "auth", "wing_b", "users", label="x") + palace_graph.compute_topic_tunnels({"wing_a": ["Redis"], "wing_b": ["Redis"]}, min_count=1) + + tunnels = palace_graph.list_tunnels() + kinds = sorted(t["kind"] for t in tunnels) + assert kinds == ["explicit", "topic"]