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:
+1
-1
@@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Added
|
### 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ def create_tunnel(
|
|||||||
label: str = "",
|
label: str = "",
|
||||||
source_drawer_id: str = None,
|
source_drawer_id: str = None,
|
||||||
target_drawer_id: str = None,
|
target_drawer_id: str = None,
|
||||||
|
kind: str = "explicit",
|
||||||
):
|
):
|
||||||
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
||||||
|
|
||||||
@@ -382,6 +383,11 @@ def create_tunnel(
|
|||||||
label: Description of the connection.
|
label: Description of the connection.
|
||||||
source_drawer_id: Optional specific drawer ID.
|
source_drawer_id: Optional specific drawer ID.
|
||||||
target_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:
|
Returns:
|
||||||
The stored tunnel dict.
|
The stored tunnel dict.
|
||||||
@@ -401,6 +407,7 @@ def create_tunnel(
|
|||||||
"source": {"wing": source_wing, "room": source_room},
|
"source": {"wing": source_wing, "room": source_room},
|
||||||
"target": {"wing": target_wing, "room": target_room},
|
"target": {"wing": target_wing, "room": target_room},
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"kind": kind,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
if source_drawer_id:
|
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``.
|
# ``~/.mempalace/known_entities.json`` under ``topics_by_wing``.
|
||||||
#
|
#
|
||||||
# Tunnels are created via the existing ``create_tunnel`` API so they share
|
# Tunnels are created via the existing ``create_tunnel`` API so they share
|
||||||
# storage and dedup with explicit tunnels. The room is the topic name —
|
# storage and dedup with explicit tunnels. The room is a synthetic
|
||||||
# this matches the "two wings share an idea" mental model and keeps the
|
# ``topic:<original-casing>`` identifier — the ``topic:`` prefix namespaces
|
||||||
# graph homogeneous.
|
# 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:
|
def _normalize_topic(name: str) -> str:
|
||||||
@@ -521,6 +534,16 @@ def _normalize_topic(name: str) -> str:
|
|||||||
return str(name).strip().lower()
|
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(
|
def compute_topic_tunnels(
|
||||||
topics_by_wing: dict,
|
topics_by_wing: dict,
|
||||||
min_count: int = 1,
|
min_count: int = 1,
|
||||||
@@ -586,13 +609,15 @@ def compute_topic_tunnels(
|
|||||||
for key in sorted(shared_keys):
|
for key in sorted(shared_keys):
|
||||||
# Prefer the casing from whichever wing sorts first — both
|
# Prefer the casing from whichever wing sorts first — both
|
||||||
# are valid; this just keeps the displayed room consistent.
|
# 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(
|
tunnel = create_tunnel(
|
||||||
source_wing=wa,
|
source_wing=wa,
|
||||||
source_room=room,
|
source_room=room,
|
||||||
target_wing=wb,
|
target_wing=wb,
|
||||||
target_room=room,
|
target_room=room,
|
||||||
label=f"{label_prefix}: {room}",
|
label=f"{label_prefix}: {topic_name}",
|
||||||
|
kind="topic",
|
||||||
)
|
)
|
||||||
created.append(tunnel)
|
created.append(tunnel)
|
||||||
return created
|
return created
|
||||||
|
|||||||
+4
-1
@@ -536,7 +536,10 @@ def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch):
|
|||||||
listed = palace_graph.list_tunnels()
|
listed = palace_graph.list_tunnels()
|
||||||
assert len(listed) == 1
|
assert len(listed) == 1
|
||||||
rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]}
|
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"]}
|
wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]}
|
||||||
assert wings == {"wing_one", "wing_two"}
|
assert wings == {"wing_one", "wing_two"}
|
||||||
|
|
||||||
|
|||||||
@@ -156,9 +156,15 @@ class TestTopicTunnels:
|
|||||||
assert len(created) == 1
|
assert len(created) == 1
|
||||||
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
|
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
|
||||||
assert created[0]["target"]["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).
|
# Room is namespaced with the ``topic:`` prefix so it can't collide
|
||||||
assert created[0]["source"]["room"] == "OpenAPI"
|
# 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 "OpenAPI" in created[0]["label"]
|
||||||
|
assert "topic:OpenAPI" not in created[0]["label"]
|
||||||
|
|
||||||
# Tunnel is retrievable via the standard list_tunnels API.
|
# Tunnel is retrievable via the standard list_tunnels API.
|
||||||
listed = palace_graph.list_tunnels()
|
listed = palace_graph.list_tunnels()
|
||||||
@@ -187,7 +193,7 @@ class TestTopicTunnels:
|
|||||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
|
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
|
||||||
# Two shared topics × one wing pair = two tunnels.
|
# Two shared topics × one wing pair = two tunnels.
|
||||||
rooms = sorted(t["source"]["room"] for t in created)
|
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):
|
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
|
||||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||||
@@ -258,3 +264,38 @@ class TestTopicTunnels:
|
|||||||
# not multiply the stored tunnels.
|
# not multiply the stored tunnels.
|
||||||
assert first[0]["id"] == second[0]["id"]
|
assert first[0]["id"] == second[0]["id"]
|
||||||
assert len(palace_graph.list_tunnels()) == 1
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user