Merge pull request #2018 from MemPalace/agent/fix-explicit-palace-graph-scope

fix: scope derived graph state to explicit palace
This commit is contained in:
Igor Lins e Silva 2026-07-14 20:28:43 -03:00 committed by GitHub
commit 68edb110a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 158 additions and 37 deletions

View File

@ -1005,7 +1005,15 @@ def cmd_hallways(args):
"""List within-wing entity hallways (the auto-built associative graph)."""
from .hallways import list_hallways
rows = list_hallways(getattr(args, "wing", None))
palace_path = (
os.path.expanduser(args.palace)
if getattr(args, "palace", None)
else MempalaceConfig().palace_path
)
rows = list_hallways(
getattr(args, "wing", None),
config=MempalaceConfig(palace_path=palace_path),
)
if not rows:
print("No hallways yet — they are built from drawer entities when you mine.")
return

View File

@ -342,18 +342,26 @@ class MempalaceConfig:
Load order: env vars > config file > defaults.
"""
def __init__(self, config_dir=None):
def __init__(self, config_dir=None, palace_path=None):
"""Initialize config.
Args:
config_dir: Override config directory (useful for testing).
Defaults to ~/.mempalace.
palace_path: Explicit palace data directory. This is primarily
used by CLI operations that received ``--palace``;
it takes precedence over environment and file config.
"""
self._config_dir = (
Path(config_dir) if config_dir else Path(os.path.expanduser("~/.mempalace"))
)
self._config_file = self._config_dir / "config.json"
self._people_map_file = self._config_dir / "people_map.json"
self._palace_path_override = (
os.path.abspath(os.path.expanduser(str(palace_path)))
if palace_path is not None
else None
)
self._file_config = {}
if self._config_file.exists():
@ -366,6 +374,8 @@ class MempalaceConfig:
@property
def palace_path(self):
"""Path to the memory palace data directory."""
if self._palace_path_override is not None:
return self._palace_path_override
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
if env_val:
# Normalize: expand ~ and collapse .. to match the CLI --palace

View File

@ -705,7 +705,7 @@ def mine_convos(
)
def _compute_hallways_for_wing_safe(wing, collection, drawers_filed):
def _compute_hallways_for_wing_safe(wing, collection, drawers_filed, config=None):
"""Auto-populate the associative graph from the entities just mined.
Best-effort: hallway computation must never fail an otherwise-good mine, and is
@ -716,7 +716,7 @@ def _compute_hallways_for_wing_safe(wing, collection, drawers_filed):
try:
from .hallways import compute_hallways_for_wing
compute_hallways_for_wing(wing, col=collection)
compute_hallways_for_wing(wing, col=collection, config=config)
except Exception as exc:
print(f" (hallways skipped: {exc})")
@ -732,7 +732,7 @@ def _mine_convos_impl(
):
from .config import MempalaceConfig
palace_config = MempalaceConfig()
palace_config = MempalaceConfig(palace_path=palace_path)
cfg_chunk_size = palace_config.chunk_size
# Only override convo_miner's MIN_CHUNK_SIZE when the user has set
# min_chunk_size explicitly. min_chunk_size_explicit returns the
@ -889,7 +889,7 @@ def _mine_convos_impl(
# Compute hallways before the FTS5 validation: the latter opens a direct sqlite
# connection to the Chroma DB, which can invalidate the live collection handle on
# some Chroma builds and make the hallway fetch fail.
_compute_hallways_for_wing_safe(wing, collection, total_drawers)
_compute_hallways_for_wing_safe(wing, collection, total_drawers, config=palace_config)
_validate_palace_fts5_after_mine(palace_path)
print(f"\n{'=' * 55}")

View File

@ -750,7 +750,7 @@ def mine_formats(
# min_chunk_size) are now threaded through chunk_text below, so users
# who customized their config see the effect in format-mode mining.
# Per PR #1555 review (Gemini #3).
palace_config = MempalaceConfig()
palace_config = MempalaceConfig(palace_path=palace_path)
format_path = Path(format_dir).expanduser().resolve()
if not wing:
@ -945,7 +945,7 @@ def mine_formats(
# skipped quietly.
if not dry_run:
try:
tunnels_added = _compute_topic_tunnels_for_wing(wing)
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=palace_config)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as exc:

View File

@ -199,6 +199,7 @@ def compute_hallways_for_wing(
wing: str,
col=None,
min_count: int = 2,
config=None,
) -> list[dict]:
"""Compute entity-pair hallways for one wing.
@ -229,6 +230,10 @@ def compute_hallways_for_wing(
hallway between two entities. Default 2 single co-occurrences
are noise (entities mentioned together once in one drawer);
two or more is a real signal. Clamped to ``>=1``.
config: Optional ``MempalaceConfig`` selecting the palace-scoped
hallway sidecar. Callers using an explicit palace path must pass
the matching config so derived graph state cannot leak into the
default palace.
Returns:
List of hallway dicts created for this wing. Records for other
@ -310,7 +315,7 @@ def compute_hallways_for_wing(
# across recomputes. Without this preservation, every mine wipes
# the connection weights accumulated through use — defeating the
# living-connection layer entirely.
existing = _load_hallways()
existing = _load_hallways(config)
existing_dynamics_lookup: dict = {}
for h in existing:
if h.get("wing") != wing:
@ -360,7 +365,7 @@ def compute_hallways_for_wing(
# 4. Persist — preserve other-wing records, replace this wing's records.
preserved_other_wings = [h for h in existing if h.get("wing") != wing]
_save_hallways(preserved_other_wings + created)
_save_hallways(preserved_other_wings + created, config)
return created
@ -370,19 +375,19 @@ def compute_hallways_for_wing(
# ─────────────────────────────────────────────────────────────────────────────
def list_hallways(wing: Optional[str] = None) -> list[dict]:
def list_hallways(wing: Optional[str] = None, config=None) -> list[dict]:
"""List hallway records. Filter by ``wing`` if specified."""
all_hallways = _load_hallways()
all_hallways = _load_hallways(config)
if wing is None:
return list(all_hallways)
return [h for h in all_hallways if h.get("wing") == wing]
def delete_hallway(hallway_id: str) -> bool:
def delete_hallway(hallway_id: str, config=None) -> bool:
"""Remove one hallway record by id. Returns True if a record was removed."""
hallways = _load_hallways()
hallways = _load_hallways(config)
filtered = [h for h in hallways if h.get("id") != hallway_id]
if len(filtered) == len(hallways):
return False
_save_hallways(filtered)
_save_hallways(filtered, config)
return True

View File

@ -1873,12 +1873,15 @@ def _mine_impl(
break
if not dry_run:
from .config import MempalaceConfig
graph_config = MempalaceConfig(palace_path=palace_path)
# Cross-wing topic tunnels: after every file in this wing has been
# processed, link this wing to any other wing that shares a
# confirmed TOPIC label. Out of scope for v1: manifest-dependency
# overlap, per-topic allow/deny lists, search-result surfacing.
try:
tunnels_added = _compute_topic_tunnels_for_wing(wing)
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=graph_config)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as e:
@ -1894,7 +1897,9 @@ def _mine_impl(
# must never fail a mine; it's a derived analytic, not load-bearing
# for the drawer write that already committed above.
try:
hallways_created = compute_hallways_for_wing(wing, col=collection)
hallways_created = compute_hallways_for_wing(
wing, col=collection, config=graph_config
)
if hallways_created:
print(f"\n Hallways: +{len(hallways_created)} within-wing entity link(s)")
except Exception as e:
@ -1910,7 +1915,7 @@ def _mine_impl(
# ``kind="entity"`` / ``kind="topic"``. Same fault-tolerance
# pattern: never fail a mine over a derived analytic.
try:
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing)
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing, config=graph_config)
if entity_tunnels_added:
print(f"\n Entity tunnels: +{entity_tunnels_added} cross-wing entity link(s)")
except Exception as e:
@ -2034,7 +2039,7 @@ def _cleanup_mine_pid_file() -> None:
pass
def _compute_topic_tunnels_for_wing(wing: str) -> int:
def _compute_topic_tunnels_for_wing(wing: str, config=None) -> int:
"""Drop tunnels between ``wing`` and every other wing that shares
confirmed topics, honoring the ``topic_tunnel_min_count`` config knob.
@ -2047,13 +2052,13 @@ def _compute_topic_tunnels_for_wing(wing: str) -> int:
topics_map = get_topics_by_wing()
if not topics_map or wing not in topics_map:
return 0
cfg = MempalaceConfig()
cfg = config or MempalaceConfig()
min_count = cfg.topic_tunnel_min_count
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count)
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count, config=cfg)
return len(created)
def _compute_entity_tunnels_for_wing(wing: str) -> int:
def _compute_entity_tunnels_for_wing(wing: str, config=None) -> int:
"""Drop tunnels between ``wing`` and every other wing that shares an
entity via the within-wing hallway primitive.
@ -2071,10 +2076,10 @@ def _compute_entity_tunnels_for_wing(wing: str) -> int:
from .hallways import list_hallways
from .palace_graph import entity_tunnels_for_wing
hallways = list_hallways()
hallways = list_hallways(config=config)
if not hallways:
return 0
created = entity_tunnels_for_wing(wing, hallways)
created = entity_tunnels_for_wing(wing, hallways, config=config)
return len(created)

View File

@ -494,6 +494,7 @@ def create_tunnel(
source_drawer_id: str = None,
target_drawer_id: str = None,
kind: str = "explicit",
config=None,
):
"""Create an explicit (symmetric) tunnel between two locations in the palace.
@ -520,6 +521,9 @@ def create_tunnel(
topical link where rooms are synthetic ``topic:<name>``
identifiers). Preserved on the stored dict so readers can
distinguish real-room traversals from topic connections.
config: Optional ``MempalaceConfig`` selecting the palace and its
tunnel sidecar. Explicit-path callers must pass the matching
config instead of falling back to the ambient default palace.
Returns:
The stored tunnel dict.
@ -545,7 +549,7 @@ def create_tunnel(
# mempalace.yaml from disk; before this change the helpers each
# instantiated their own, triggering several redundant disk reads per
# create_tunnel call (flagged by gemini-code-assist on #1469).
config = MempalaceConfig()
config = config or MempalaceConfig()
# Validate room existence for explicit tunnels only. Use the verbatim wing
# slugs here so #1504's hyphen-preserving write path remains intact.
@ -742,6 +746,7 @@ def compute_topic_tunnels(
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
config=None,
) -> list[dict]:
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
@ -817,6 +822,7 @@ def compute_topic_tunnels(
target_room=room,
label=f"{label_prefix}: {topic_name}",
kind="topic",
config=config,
)
created.append(tunnel)
return created
@ -827,6 +833,7 @@ def topic_tunnels_for_wing(
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
config=None,
) -> list[dict]:
"""Compute topic tunnels involving a single wing.
@ -871,6 +878,7 @@ def topic_tunnels_for_wing(
slice_map,
min_count=min_count,
label_prefix=label_prefix,
config=config,
)
)
return created
@ -880,6 +888,7 @@ def entity_tunnels_for_wing(
wing: str,
hallways: list,
label_prefix: str = "shared entity",
config=None,
) -> list:
"""Compute entity tunnels involving a single wing.
@ -946,6 +955,7 @@ def entity_tunnels_for_wing(
target_room=room,
label=f"{label_prefix}: {entity}",
kind="entity",
config=config,
)
created.append(tunnel)
return created

View File

@ -23,7 +23,7 @@ def test_lists_sorted_by_count(monkeypatch, capsys):
"label": "A <-> B (x3)",
},
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None: list(rows))
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=50))
out = capsys.readouterr().out
assert "2 hallway(s)" in out
@ -37,7 +37,7 @@ def test_respects_limit(monkeypatch, capsys):
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
for i in range(5)
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None: list(rows))
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=2))
assert capsys.readouterr().out.count("<->") == 2
@ -47,13 +47,28 @@ def test_negative_limit_shows_nothing_not_tail(monkeypatch, capsys):
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
for i in range(5)
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None: list(rows))
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=-2))
# A negative limit must not slice from the end (which would print all-but-2).
assert capsys.readouterr().out.count("<->") == 0
def test_empty_message(monkeypatch, capsys):
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None: [])
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: [])
cmd_hallways(Namespace(wing="x", limit=50))
assert "No hallways yet" in capsys.readouterr().out
def test_explicit_palace_scopes_hallway_listing(monkeypatch, tmp_path):
calls = []
def fake_list(wing=None, config=None):
calls.append((wing, config.palace_path))
return []
selected = tmp_path / "selected" / "palace"
monkeypatch.setattr(hallways_mod, "list_hallways", fake_list)
cmd_hallways(Namespace(wing="wing_aya", limit=50, palace=str(selected)))
assert calls == [("wing_aya", str(selected))]

View File

@ -888,3 +888,18 @@ def test_max_backups_bad_env_falls_back_to_config(monkeypatch, tmp_path):
monkeypatch.setenv("MEMPALACE_MAX_BACKUPS", "garbage")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.max_backups == 4
def test_explicit_palace_path_overrides_env_and_file_config(monkeypatch, tmp_path):
configured = tmp_path / "configured" / "palace"
explicit = tmp_path / "explicit" / "../explicit" / "palace"
with open(tmp_path / "config.json", "w") as f:
json.dump({"palace_path": str(configured)}, f)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_path / "environment" / "palace"))
cfg = MempalaceConfig(config_dir=str(tmp_path), palace_path=str(explicit))
expected = os.path.abspath(os.path.expanduser(str(explicit)))
assert cfg.palace_path == expected
assert cfg.hallway_file == os.path.join(os.path.dirname(expected), "hallways.json")
assert cfg.tunnel_file == os.path.join(os.path.dirname(expected), "tunnels.json")

View File

@ -1158,7 +1158,9 @@ def test_mine_formats_calls_compute_topic_tunnels_after_loop(_mine_formats_mocks
patch("mempalace.format_miner._compute_topic_tunnels_for_wing", return_value=0) as p_tun,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_aya")
p_tun.assert_called_once_with("wing_aya")
p_tun.assert_called_once()
assert p_tun.call_args.args == ("wing_aya",)
assert p_tun.call_args.kwargs["config"].palace_path == str(tmp / "palace")
def test_mine_formats_tunnel_failure_does_not_crash_mine(_mine_formats_mocks):
@ -1518,13 +1520,16 @@ def test_mine_formats_threads_chunk_size_from_user_config(monkeypatch, tmp_path:
# Inject custom config values via a fake MempalaceConfig.
class _FakeMempalaceConfig:
def __init__(self, **kwargs):
self._palace_path = kwargs.get("palace_path", str(tmp_path / "palace"))
chunk_size = 1234
chunk_overlap = 56
min_chunk_size = 78
@property
def palace_path(self):
return str(tmp_path / "palace")
return self._palace_path
monkeypatch.setattr(format_miner, "MempalaceConfig", _FakeMempalaceConfig)

View File

@ -92,6 +92,24 @@ class TestHallwayStorage:
class TestComputeHallways:
def test_explicit_config_scopes_persistence_to_selected_palace(self, tmp_path):
from mempalace.config import MempalaceConfig
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
col = _fake_collection(
[
{"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
{"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
]
)
created = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, config=selected_cfg)
assert len(created) == 1
assert hallways_mod.list_hallways(config=selected_cfg) == created
assert hallways_mod.list_hallways(config=default_cfg) == []
def test_returns_empty_for_unknown_wing(self, tmp_path, monkeypatch):
"""Wing with no drawers → no hallways, no crash."""
_use_tmp_hallway_file(monkeypatch, tmp_path)
@ -300,6 +318,19 @@ class TestHallwayQuery:
hallways_mod._save_hallways([{"id": "h1", "wing": "wing_aya"}])
assert hallways_mod.delete_hallway("nonexistent") is False
def test_delete_hallway_uses_selected_palace_config(self, tmp_path):
from mempalace.config import MempalaceConfig
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
record = {"id": "h1", "wing": "wing_aya"}
hallways_mod._save_hallways([record], default_cfg)
hallways_mod._save_hallways([record], selected_cfg)
assert hallways_mod.delete_hallway("h1", config=selected_cfg) is True
assert hallways_mod.list_hallways(config=selected_cfg) == []
assert hallways_mod.list_hallways(config=default_cfg) == [record]
# ─────────────────────────────────────────────────────────────────────────────
# L7 dynamics integration — hallway records carry strength/stability/etc

View File

@ -95,8 +95,8 @@ def test_mine_computes_hallways_for_wing_post_mine(monkeypatch):
hallway_calls = []
def fake_compute(wing, col=None, min_count=2):
hallway_calls.append({"wing": wing, "col": col, "min_count": min_count})
def fake_compute(wing, col=None, min_count=2, config=None):
hallway_calls.append({"wing": wing, "col": col, "min_count": min_count, "config": config})
return [] # no hallways materialized — that's not what we're testing
# Patch at the call site (mempalace.miner.compute_hallways_for_wing) so
@ -134,6 +134,7 @@ def test_mine_computes_hallways_for_wing_post_mine(monkeypatch):
assert call["col"] is not None, (
"must pass the live collection so hallways can query drawers"
)
assert call["config"].palace_path == str(palace_path)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@ -147,7 +148,7 @@ def test_mine_hallway_failure_does_not_crash_mine(monkeypatch):
"""
from mempalace import miner as miner_mod
def angry_compute(wing, col=None, min_count=2):
def angry_compute(wing, col=None, min_count=2, config=None):
raise RuntimeError("simulated hallway-compute explosion")
monkeypatch.setattr(miner_mod, "compute_hallways_for_wing", angry_compute)
@ -194,8 +195,8 @@ def test_mine_computes_entity_tunnels_for_wing_post_mine(monkeypatch):
entity_tunnel_calls = []
def fake_compute(wing):
entity_tunnel_calls.append({"wing": wing})
def fake_compute(wing, config=None):
entity_tunnel_calls.append({"wing": wing, "config": config})
return 0 # no tunnels — that's not what we're testing here
# Patch at the call site (mempalace.miner._compute_entity_tunnels_for_wing)
@ -227,6 +228,7 @@ def test_mine_computes_entity_tunnels_for_wing_post_mine(monkeypatch):
f"got {len(entity_tunnel_calls)}"
)
assert entity_tunnel_calls[0]["wing"] == "test_project"
assert entity_tunnel_calls[0]["config"].palace_path == str(palace_path)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@ -241,7 +243,7 @@ def test_mine_entity_tunnel_failure_does_not_crash_mine(monkeypatch):
"""
from mempalace import miner as miner_mod
def angry_compute(wing):
def angry_compute(wing, config=None):
raise RuntimeError("simulated entity-tunnel-compute explosion")
monkeypatch.setattr(miner_mod, "_compute_entity_tunnels_for_wing", angry_compute)

View File

@ -201,6 +201,21 @@ class TestTopicTunnels:
dedup and persistence with explicit tunnels.
"""
def test_explicit_config_scopes_persistence_to_selected_palace(self, tmp_path):
from mempalace.config import MempalaceConfig
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
topics_by_wing = {"wing_alpha": ["OpenAPI"], "wing_beta": ["OpenAPI"]}
created = palace_graph.compute_topic_tunnels(
topics_by_wing, min_count=1, config=selected_cfg
)
assert len(created) == 1
assert palace_graph._load_tunnels(selected_cfg) == created
assert palace_graph._load_tunnels(default_cfg) == []
def test_compute_topic_tunnels_creates_link_for_shared_topic(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {