Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions mempalace/hallways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -341,17 +353,14 @@ 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,
"entity_a": entity_a,
"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",
}
Expand Down Expand Up @@ -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
27 changes: 27 additions & 0 deletions mempalace/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions mempalace/palace_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
85 changes: 85 additions & 0 deletions tests/test_hallways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading