From 347464146d94f2b54cc616f541a197947e8c515b Mon Sep 17 00:00:00 2001 From: Wahaj Ahmed Date: Sat, 25 Apr 2026 14:32:10 +0200 Subject: [PATCH] fix(tunnels): normalize wing names in topic tunnel lookup for hyphenated dirs (#1194) --- mempalace/palace_graph.py | 53 ++++++++++++++++++++++++++---- tests/test_palace_graph_tunnels.py | 45 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index 5e4cec7..5e6ccbf 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -17,6 +17,7 @@ No external graph DB needed — built from ChromaDB metadata. import hashlib import json +import logging import os import threading import time @@ -27,6 +28,22 @@ from .config import MempalaceConfig from .palace import get_collection as _get_palace_collection from .palace import mine_lock +logger = logging.getLogger("mempalace_graph") + + +def _normalize_wing(wing: str | None) -> str | None: + """Normalize a wing name for consistent lookup. + + ``init`` stores wing names with hyphens and spaces replaced by underscores + (e.g. ``mempalace_public``). Callers that pass the raw directory name + (``mempalace-public``) would silently miss. This helper aligns the lookup + key with the stored metadata. + """ + if wing is None: + return None + return wing.lower().replace(" ", "_").replace("-", "_") + + # Module-level graph cache with TTL and write-invalidation. # Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes. _graph_cache_lock = threading.Lock() @@ -225,15 +242,18 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None): """ nodes, edges = build_graph(col, config) + norm_a = _normalize_wing(wing_a) + norm_b = _normalize_wing(wing_b) + tunnels = [] for room, data in nodes.items(): wings = data["wings"] if len(wings) < 2: continue - if wing_a and wing_a not in wings: + if norm_a and norm_a not in wings: continue - if wing_b and wing_b not in wings: + if norm_b and norm_b not in wings: continue tunnels.append( @@ -246,6 +266,15 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None): } ) + if not tunnels and (wing_a or wing_b): + logger.warning( + "No tunnels found for wing filter(s): wing_a=%r (normalized=%r), wing_b=%r (normalized=%r)", + wing_a, + norm_a, + wing_b, + norm_b, + ) + tunnels.sort(key=lambda x: -x["count"]) return tunnels[:50] @@ -426,6 +455,9 @@ def create_tunnel( target_wing = _require_name(target_wing, "target_wing") target_room = _require_name(target_room, "target_room") + source_wing = _normalize_wing(source_wing) + target_wing = _normalize_wing(target_wing) + tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room) tunnel = { @@ -466,9 +498,14 @@ def list_tunnels(wing: str = None): Returns tunnels where ``wing`` appears as either source or target (tunnels are symmetric, so either endpoint is a valid filter match). """ + norm_wing = _normalize_wing(wing) tunnels = _load_tunnels() - if wing: - tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing] + if norm_wing: + tunnels = [ + t + for t in tunnels + if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing + ] return tunnels @@ -487,6 +524,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None): Given a location (wing/room), finds all tunnels leading from or to it, and optionally fetches the connected drawer content. """ + norm_wing = _normalize_wing(wing) or wing tunnels = _load_tunnels() connections = [] @@ -494,7 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None): src = t["source"] tgt = t["target"] - if src["wing"] == wing and src["room"] == room: + if src["wing"] == norm_wing and src["room"] == room: connections.append( { "direction": "outgoing", @@ -505,7 +543,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None): "tunnel_id": t["id"], } ) - elif tgt["wing"] == wing and tgt["room"] == room: + elif tgt["wing"] == norm_wing and tgt["room"] == room: connections.append( { "direction": "incoming", @@ -517,6 +555,9 @@ def follow_tunnels(wing: str, room: str, col=None, config=None): } ) + if not connections: + logger.warning("No explicit tunnels found for %s/%s", wing, room) + # If we have a collection, fetch drawer content for connected items if col and connections: drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")] diff --git a/tests/test_palace_graph_tunnels.py b/tests/test_palace_graph_tunnels.py index 4ce3f56..934a10e 100644 --- a/tests/test_palace_graph_tunnels.py +++ b/tests/test_palace_graph_tunnels.py @@ -329,3 +329,48 @@ class TestTopicTunnels: tunnels = palace_graph.list_tunnels() kinds = sorted(t["kind"] for t in tunnels) assert kinds == ["explicit", "topic"] + + +class TestHyphenatedWingNormalization: + """Wing names with hyphens or spaces are normalized to underscores on init. + + Tunnel helpers must apply the same normalization at lookup time so that + ``mempalace-public`` resolves to ``mempalace_public`` and matches the + metadata written by ``room_detector_local.py``. + """ + + def test_list_tunnels_filters_hyphenated_wing(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + + palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users") + + assert len(palace_graph.list_tunnels("mempalace-public")) == 1 + assert len(palace_graph.list_tunnels("mempalace_public")) == 1 + + def test_follow_tunnels_matches_hyphenated_wing(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + + palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users") + + by_hyphen = palace_graph.follow_tunnels("mempalace-public", "auth") + by_under = palace_graph.follow_tunnels("mempalace_public", "auth") + assert len(by_hyphen) == 1 + assert len(by_under) == 1 + assert by_hyphen[0]["connected_wing"] == "wing_people" + + def test_create_tunnel_normalizes_wing_names(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + + t = palace_graph.create_tunnel("my-project", "src", "your-project", "dst", label="cross") + assert t["source"]["wing"] == "my_project" + assert t["target"]["wing"] == "your_project" + assert len(palace_graph.list_tunnels("my_project")) == 1 + assert len(palace_graph.list_tunnels("my-project")) == 1 + + def test_find_tunnels_warns_on_empty_result(self, tmp_path, monkeypatch, caplog): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + # No data in collection, so build_graph returns empty nodes + with caplog.at_level("WARNING", logger="mempalace_graph"): + result = palace_graph.find_tunnels("nonexistent-wing") + assert result == [] + assert "No tunnels found" in caplog.text