diff --git a/mempalace/hallways.py b/mempalace/hallways.py index cd079cfc62..25e63d0475 100644 --- a/mempalace/hallways.py +++ b/mempalace/hallways.py @@ -182,6 +182,18 @@ def _parse_entities(value) -> list[str]: return result +def _hallway_label(entity_a: str, entity_b: str, count: int, rooms: list) -> str: + """Human-readable hallway label. Single source of truth so freshly-computed + and re-keyed/merged records format identically.""" + room_summary = ", ".join(rooms[:3]) if rooms else "(no room tags)" + if len(rooms) > 3: + room_summary += f", +{len(rooms) - 3} more" + return ( + f"{entity_a} \u2194 {entity_b} (co-occur in {count} drawers across " + f"{len(rooms) or 'no'} room{'s' if len(rooms) != 1 else ''}: {room_summary})" + ) + + def _hallway_id(wing: str, entity_a: str, entity_b: str) -> str: """Deterministic id derived from wing + sorted entity pair. @@ -341,9 +353,6 @@ def compute_hallways_for_wing( continue entity_a, entity_b = key rooms = sorted(pair_rooms.get(key, set())) - room_summary = ", ".join(rooms[:3]) if rooms else "(no room tags)" - if len(rooms) > 3: - room_summary += f", +{len(rooms) - 3} more" record = { "id": _hallway_id(wing, entity_a, entity_b), "wing": wing, @@ -351,7 +360,7 @@ def compute_hallways_for_wing( "entity_b": entity_b, "co_occurrence_count": count, "rooms": rooms, - "label": f"{entity_a} ↔ {entity_b} (co-occur in {count} drawers across {len(rooms) or 'no'} room{'s' if len(rooms) != 1 else ''}: {room_summary})", + "label": _hallway_label(entity_a, entity_b, count, rooms), "created_at": created_at, "created_by": "auto", } @@ -391,3 +400,65 @@ def delete_hallway(hallway_id: str, config=None) -> bool: return False _save_hallways(filtered, config) return True + + +def _merge_hallways(dst: dict, src: dict) -> None: + """Fold ``src`` into ``dst`` in place when two hallways collapse onto the + same (wing, entity-pair). Counts/rooms combine; dynamics keep the stronger + and more-recent signal so the #1578 living-connection layer is not reset.""" + count = int(dst.get("co_occurrence_count", 0)) + int(src.get("co_occurrence_count", 0)) + rooms = sorted(set(dst.get("rooms") or []) | set(src.get("rooms") or [])) + dst["co_occurrence_count"] = count + dst["rooms"] = rooms + dst["label"] = _hallway_label(dst.get("entity_a"), dst.get("entity_b"), count, rooms) + dst["strength"] = max(float(dst.get("strength", 1.0)), float(src.get("strength", 1.0))) + dst["stability"] = max(float(dst.get("stability", 1.0)), float(src.get("stability", 1.0))) + if str(src.get("last_activated", "")) > str(dst.get("last_activated", "")): + dst["last_activated"] = src.get("last_activated") + dst["access_count"] = int(dst.get("access_count", 0)) + int(src.get("access_count", 0)) + + +def rekey_hallway_wings(rename_map: dict, config=None) -> int: + """Re-key the ``wing`` field on hallway records after a wing rename. + + ``rename_map`` maps ``{old_wing: new_wing}``. Every hallway whose wing is a + key is rewritten to the target wing and its id regenerated (ids embed the + wing via ``_hallway_id``). When re-keyed records collide with an existing + record on the same ``(wing, sorted entity pair)`` — two source wings + normalizing to one target, or the target already holding that pair — they + are merged via ``_merge_hallways`` rather than duplicated. + + A rename does not change drawer membership or entity co-occurrence, so + re-keying (rather than recomputing from drawers) is both sufficient and + preserves the accumulated dynamics fields. Returns the number of records + whose wing changed. + """ + if not rename_map: + return 0 + hallways = _load_hallways(config) + index: dict = {} + order: list = [] + changed = 0 + + def _add(rec: dict) -> None: + a, b = sorted([rec.get("entity_a"), rec.get("entity_b")]) + k = (rec.get("wing"), a, b) + if k in index: + _merge_hallways(index[k], rec) + else: + index[k] = rec + order.append(k) + + for h in hallways: + old = h.get("wing") + if old in rename_map: + new = rename_map[old] + a, b = sorted([h.get("entity_a"), h.get("entity_b")]) + h["wing"] = new + h["id"] = _hallway_id(new, a, b) + changed += 1 + _add(h) + + if changed: + _save_hallways([index[k] for k in order], config) + return changed diff --git a/mempalace/migrate.py b/mempalace/migrate.py index 9f3444d9b1..790755ba60 100644 --- a/mempalace/migrate.py +++ b/mempalace/migrate.py @@ -582,10 +582,37 @@ def migrate_wing_names(palace_path: str, dry_run: bool = False, confirm: bool = _apply_wing_updates(closets, c_updates) _apply_topics_by_wing_renames(topic_renames) + # Re-key the associative graph so hallways/tunnels follow their drawers. + # A wing-name migration is a pure rename: drawer membership and entity + # co-occurrence are unchanged, so re-keying the graph records (rather than + # recomputing them from drawers) is sufficient and preserves the #1578 + # dynamics layer. Use an explicit config derived from this migration's + # palace_path so side-files resolve beside the selected palace instead of + # falling back to the ambient/default palace. + rename_map = {old: new for (old, new) in d_summary} + rename_map.update({old: new for (old, new) in c_summary}) + h_changed = t_changed = 0 + try: + from .hallways import rekey_hallway_wings + from .palace_graph import rekey_tunnel_wings + + graph_config = MempalaceConfig(palace_path=palace_path) + h_changed = rekey_hallway_wings(rename_map, config=graph_config) + t_changed = rekey_tunnel_wings(rename_map, config=graph_config) + except Exception as exc: + print( + " WARNING: graph re-key failed; hallways/tunnels may still " + f"reference the old wing ({exc})" + ) + parts = [f"{len(d_updates)} drawer(s)"] if c_updates: parts.append(f"{len(c_updates)} closet(s)") if topic_renames: parts.append(f"{len(topic_renames)} topic key(s)") + if h_changed: + parts.append(f"{h_changed} hallway(s)") + if t_changed: + parts.append(f"{t_changed} tunnel(s)") print(f"\n Migrated {', '.join(parts)}.\n") return True diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index 7119217943..33c3d48579 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -641,6 +641,67 @@ def delete_tunnel(tunnel_id: str): return {"deleted": tunnel_id} +def _merge_tunnels(dst: dict, src: dict) -> None: + """Fold ``src`` into ``dst`` in place when two tunnels collapse onto the same + canonical id after a re-key. Dynamics keep the stronger and more-recent + signal so learned weights are not reset.""" + dst["strength"] = max(float(dst.get("strength", 1.0)), float(src.get("strength", 1.0))) + dst["stability"] = max(float(dst.get("stability", 1.0)), float(src.get("stability", 1.0))) + if str(src.get("last_activated", "")) > str(dst.get("last_activated", "")): + dst["last_activated"] = src.get("last_activated") + dst["access_count"] = int(dst.get("access_count", 0)) + int(src.get("access_count", 0)) + dst["updated_at"] = datetime.now(timezone.utc).isoformat() + + +def rekey_tunnel_wings(rename_map: dict, config=None) -> int: + """Re-key tunnel endpoint wings after a wing rename. + + ``rename_map`` maps ``{old_wing: new_wing}``. Each endpoint whose wing is a + key is rewritten and the canonical id regenerated. A tunnel whose two + endpoints collapse to the same ``wing/room`` after the rename is dropped (it + is no longer a cross-endpoint link); tunnels that collide on the regenerated + id are merged via ``_merge_tunnels``. Covers both entity and topic tunnels + since they share ``tunnels.json``. Returns the number of tunnels whose + endpoints moved or were dropped. + """ + if not rename_map: + return 0 + with mine_lock(_get_tunnel_file(config)): + tunnels = _load_tunnels(config) + index: dict = {} + order: list = [] + changed = 0 + for t in tunnels: + src = dict(t.get("source") or {}) + tgt = dict(t.get("target") or {}) + moved = src.get("wing") in rename_map or tgt.get("wing") in rename_map + if moved: + changed += 1 + if src.get("wing") in rename_map: + src["wing"] = rename_map[src["wing"]] + if tgt.get("wing") in rename_map: + tgt["wing"] = rename_map[tgt["wing"]] + t["source"] = src + t["target"] = tgt + if _endpoint_key(src.get("wing"), src.get("room")) == _endpoint_key( + tgt.get("wing"), tgt.get("room") + ): + # endpoints coincide — no longer a tunnel; drop it + continue + t["id"] = _canonical_tunnel_id( + src.get("wing"), src.get("room"), tgt.get("wing"), tgt.get("room") + ) + tid = t.get("id") + if tid in index: + _merge_tunnels(index[tid], t) + else: + index[tid] = t + order.append(tid) + if changed: + _save_tunnels([index[k] for k in order], config) + return changed + + def follow_tunnels(wing: str, room: str, col=None, config=None): """Follow explicit tunnels from a room — returns connected drawers. diff --git a/tests/test_hallways.py b/tests/test_hallways.py index fa89597c8a..fa8744c1f8 100644 --- a/tests/test_hallways.py +++ b/tests/test_hallways.py @@ -508,3 +508,88 @@ def test_recompute_preserves_dynamics_when_existing_record_has_reversed_entity_o ) assert after[0]["access_count"] == 33 assert after[0]["stability"] == 1.9 + + +# ───────────────────────────────────────────────────────────────────────────── +# rekey_hallway_wings — follow drawers after a wing rename (#1938) +# ───────────────────────────────────────────────────────────────────────────── + + +def _hallway(wing, a, b, count=2, rooms=("r",), **dyn): + rec = { + "id": hallways_mod._hallway_id(wing, *sorted([a, b])), + "wing": wing, + "entity_a": a, + "entity_b": b, + "co_occurrence_count": count, + "rooms": list(rooms), + "label": hallways_mod._hallway_label(a, b, count, list(rooms)), + "created_at": "2026-01-01T00:00:00+00:00", + "created_by": "auto", + } + rec.update(dyn) + return rec + + +class TestRekeyHallwayWings: + def test_noop_when_map_empty(self, tmp_path, monkeypatch): + _use_tmp_hallway_file(monkeypatch, tmp_path) + hallways_mod._save_hallways([_hallway("_alpha", "Alice", "Bob")]) + assert hallways_mod.rekey_hallway_wings({}) == 0 + assert hallways_mod.list_hallways()[0]["wing"] == "_alpha" + + def test_plain_rename_regenerates_id_and_preserves_dynamics(self, tmp_path, monkeypatch): + _use_tmp_hallway_file(monkeypatch, tmp_path) + hallways_mod._save_hallways( + [ + _hallway( + "_alpha", + "Alice", + "Bob", + strength=7.5, + access_count=42, + last_activated="2030-01-01T00:00:00+00:00", + ) + ] + ) + assert hallways_mod.rekey_hallway_wings({"_alpha": "alpha"}) == 1 + recs = hallways_mod.list_hallways() + assert len(recs) == 1 + r = recs[0] + assert r["wing"] == "alpha" + assert r["id"] == hallways_mod._hallway_id("alpha", "Alice", "Bob") + # dynamics carried across the rename, not reset + assert r["strength"] == 7.5 + assert r["access_count"] == 42 + assert r["last_activated"] == "2030-01-01T00:00:00+00:00" + + def test_collision_merges_counts_rooms_and_dynamics(self, tmp_path, monkeypatch): + _use_tmp_hallway_file(monkeypatch, tmp_path) + hallways_mod._save_hallways( + [ + _hallway( + "gamma", "Alice", "Bob", count=2, rooms=("r1",), strength=3.0, access_count=5 + ), + _hallway( + "_gamma", "Alice", "Bob", count=3, rooms=("r2",), strength=9.0, access_count=10 + ), + ] + ) + assert hallways_mod.rekey_hallway_wings({"_gamma": "gamma"}) == 1 + recs = hallways_mod.list_hallways() + assert len(recs) == 1 # merged, not duplicated + r = recs[0] + assert r["wing"] == "gamma" + assert r["co_occurrence_count"] == 5 # 2 + 3 + assert r["rooms"] == ["r1", "r2"] # unioned + assert r["strength"] == 9.0 # max + assert r["access_count"] == 15 # summed + assert "co-occur in 5 drawers" in r["label"] + + def test_untouched_wing_left_alone(self, tmp_path, monkeypatch): + _use_tmp_hallway_file(monkeypatch, tmp_path) + hallways_mod._save_hallways( + [_hallway("_alpha", "Alice", "Bob"), _hallway("beta", "Alice", "Carol")] + ) + hallways_mod.rekey_hallway_wings({"_alpha": "alpha"}) + assert sorted(h["wing"] for h in hallways_mod.list_hallways()) == ["alpha", "beta"] diff --git a/tests/test_migrate_wings.py b/tests/test_migrate_wings.py index 229823ad94..2ada87a007 100644 --- a/tests/test_migrate_wings.py +++ b/tests/test_migrate_wings.py @@ -151,3 +151,159 @@ def test_migrate_is_idempotent(tmp_path): # second run finds nothing left to normalize assert migrate_wing_names(str(palace), confirm=True) is False assert _wing_ids(palace, "y") == {"drawer__y_r_1"} + + +# --- graph re-key: hallways/tunnels follow drawers after a rename (#1938) --- + + +def _seed_entities(palace, rows): + """Seed drawers carrying semicolon-joined ``entities`` metadata.""" + from mempalace.palace import get_collection + + col = get_collection(str(palace), create=True) + col.upsert( + ids=[r["id"] for r in rows], + documents=[r["doc"] for r in rows], + metadatas=[r["meta"] for r in rows], + embeddings=[[float(i + 1)] * 8 for i in range(len(rows))], + ) + return col + + +def test_migrate_rekeys_hallways_and_tunnels(tmp_path, monkeypatch): + palace = tmp_path / "palace" + palace.mkdir() + # graph side-files resolve as siblings of the configured palace_path + monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(palace)) + + from mempalace import palace_graph + from mempalace.hallways import compute_hallways_for_wing, list_hallways + + col = _seed_entities( + palace, + [ + { + "id": "a1", + "doc": "d", + "meta": { + "wing": "_alpha", + "room": "r", + "entities": "Alice;Bob", + "source_file": "a1", + }, + }, + { + "id": "a2", + "doc": "d", + "meta": { + "wing": "_alpha", + "room": "r", + "entities": "Alice;Bob", + "source_file": "a2", + }, + }, + { + "id": "b1", + "doc": "d", + "meta": { + "wing": "beta", + "room": "r", + "entities": "Alice;Carol", + "source_file": "b1", + }, + }, + { + "id": "b2", + "doc": "d", + "meta": { + "wing": "beta", + "room": "r", + "entities": "Alice;Carol", + "source_file": "b2", + }, + }, + ], + ) + compute_hallways_for_wing("_alpha", col=col) + compute_hallways_for_wing("beta", col=col) + palace_graph.entity_tunnels_for_wing("_alpha", list_hallways()) + palace_graph.entity_tunnels_for_wing("beta", list_hallways()) + + def tun_wings(): + return { + w + for t in palace_graph.list_tunnels() + for w in (t["source"]["wing"], t["target"]["wing"]) + } + + # pre-condition: graph references the un-normalized wing + assert "_alpha" in {h["wing"] for h in list_hallways()} + assert "_alpha" in tun_wings() + + assert migrate_wing_names(str(palace), confirm=True) is True + + hall_wings = {h["wing"] for h in list_hallways()} + assert "_alpha" not in hall_wings and "alpha" in hall_wings + assert "_alpha" not in tun_wings() and "alpha" in tun_wings() + + +def test_migrate_merges_colliding_hallways(tmp_path, monkeypatch): + palace = tmp_path / "palace" + palace.mkdir() + monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(palace)) + + from mempalace.hallways import compute_hallways_for_wing, list_hallways + + col = _seed_entities( + palace, + [ + { + "id": "g1", + "doc": "d", + "meta": { + "wing": "gamma", + "room": "r", + "entities": "Alice;Bob", + "source_file": "g1", + }, + }, + { + "id": "g2", + "doc": "d", + "meta": { + "wing": "gamma", + "room": "r", + "entities": "Alice;Bob", + "source_file": "g2", + }, + }, + { + "id": "g3", + "doc": "d", + "meta": { + "wing": "_gamma", + "room": "r", + "entities": "Alice;Bob", + "source_file": "g3", + }, + }, + { + "id": "g4", + "doc": "d", + "meta": { + "wing": "_gamma", + "room": "r", + "entities": "Alice;Bob", + "source_file": "g4", + }, + }, + ], + ) + compute_hallways_for_wing("gamma", col=col) + compute_hallways_for_wing("_gamma", col=col) + + migrate_wing_names(str(palace), confirm=True) + + recs = list_hallways() + assert [h["wing"] for h in recs] == ["gamma"] # single merged record + assert recs[0]["co_occurrence_count"] == 4 # 2 + 2 summed diff --git a/tests/test_palace_graph_tunnels.py b/tests/test_palace_graph_tunnels.py index f96afd135b..11c2e0ce50 100644 --- a/tests/test_palace_graph_tunnels.py +++ b/tests/test_palace_graph_tunnels.py @@ -944,3 +944,61 @@ def test_recreate_tunnel_initializes_dynamics_for_legacy_records(self, tmp_path, assert recreated["stability"] == DEFAULT_STABILITY assert recreated["access_count"] == 0 assert "last_activated" in recreated + + +# ───────────────────────────────────────────────────────────────────────────── +# rekey_tunnel_wings — follow drawers after a wing rename (#1938) +# ───────────────────────────────────────────────────────────────────────────── + + +def _tunnel(sw, sr, tw, tr, **dyn): + rec = { + "id": palace_graph._canonical_tunnel_id(sw, sr, tw, tr), + "source": {"wing": sw, "room": sr}, + "target": {"wing": tw, "room": tr}, + "label": f"link {sw}->{tw}", + "kind": "entity", + "created_at": "2026-01-01T00:00:00+00:00", + } + rec.update(dyn) + return rec + + +class TestRekeyTunnelWings: + def test_noop_when_map_empty(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + palace_graph._save_tunnels([_tunnel("_alpha", "entity:Alice", "beta", "entity:Alice")]) + assert palace_graph.rekey_tunnel_wings({}) == 0 + + def test_rekeys_endpoint_and_regenerates_id(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + palace_graph._save_tunnels( + [ + _tunnel( + "_alpha", "entity:Alice", "beta", "entity:Alice", strength=4.0, access_count=8 + ) + ] + ) + assert palace_graph.rekey_tunnel_wings({"_alpha": "alpha"}) == 1 + tuns = palace_graph.list_tunnels() + assert len(tuns) == 1 + wings = sorted([tuns[0]["source"]["wing"], tuns[0]["target"]["wing"]]) + assert wings == ["alpha", "beta"] + assert tuns[0]["id"] == palace_graph._canonical_tunnel_id( + "alpha", "entity:Alice", "beta", "entity:Alice" + ) + assert tuns[0]["strength"] == 4.0 # dynamics preserved + + def test_drops_self_referential_tunnel(self, tmp_path, monkeypatch): + # a tunnel between two wings that normalize to the SAME target is no + # longer a cross-endpoint link and must be dropped + _use_tmp_tunnel_file(monkeypatch, tmp_path) + palace_graph._save_tunnels([_tunnel("_alpha", "entity:Alice", "alpha_", "entity:Alice")]) + assert palace_graph.rekey_tunnel_wings({"_alpha": "alpha", "alpha_": "alpha"}) == 1 + assert palace_graph.list_tunnels() == [] + + def test_untouched_tunnel_left_alone(self, tmp_path, monkeypatch): + _use_tmp_tunnel_file(monkeypatch, tmp_path) + palace_graph._save_tunnels([_tunnel("beta", "entity:X", "delta", "entity:X")]) + palace_graph.rekey_tunnel_wings({"_alpha": "alpha"}) + assert len(palace_graph.list_tunnels()) == 1