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.
This commit is contained in:
Igor Lins e Silva
2026-04-24 23:05:56 -03:00
parent fe051adc73
commit 865a36bc5c
4 changed files with 79 additions and 10 deletions
+1 -1
View File
@@ -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:<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)
---
+30 -5
View File
@@ -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:<name>``
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:<original-casing>`` 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
+4 -1
View File
@@ -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:<name>`` 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"}
+44 -3
View File
@@ -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"]