项目文件夹

文件
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

932 行
42 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Tests for explicit tunnel helpers in mempalace.palace_graph."""
import logging
import os
import stat
import sys
from unittest.mock import MagicMock, patch
import pytest
with patch.dict("sys.modules", {"chromadb": MagicMock()}):
import mempalace.palace_graph as palace_graph
def _use_tmp_tunnel_file(monkeypatch, tmp_path):
"""Redirect both the tunnel-file resolver and the legacy-file check at the
tmp_path so existing tests stay in the configured-path branch and don't
accidentally trip the new legacy-file warning branch in _load_tunnels.
Also neutralizes ``_get_collection`` so the endpoint-existence validation
added for #1468 falls through to the "can't verify, allow" branch by
default. Tests that exercise the validation path supply their own stub
via a subsequent monkeypatch.setattr.
"""
tunnel_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_get_tunnel_file", lambda *a, **kw: str(tunnel_file))
monkeypatch.setattr(
palace_graph,
"_legacy_tunnel_file",
lambda: str(tmp_path / "legacy-tunnels.json"),
)
monkeypatch.setattr(palace_graph, "_get_collection", lambda *a, **kw: None)
return tunnel_file
class TestTunnelStorage:
def test_load_tunnels_missing_file_returns_empty_list(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
assert palace_graph._load_tunnels() == []
def test_load_tunnels_corrupt_file_returns_empty_list(self, tmp_path, monkeypatch):
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnel_file.write_text("{not valid json", encoding="utf-8")
assert palace_graph._load_tunnels() == []
def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnels = [
{
"id": "abc123",
"source": {"wing": "wing_code", "room": "auth"},
"target": {"wing": "wing_people", "room": "users"},
"label": "same concept",
}
]
palace_graph._save_tunnels(tunnels)
assert palace_graph._load_tunnels() == tunnels
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX file-permission bits only apply on Unix-like systems",
)
def test_save_tunnels_restricts_permissions(self, tmp_path, monkeypatch):
"""Regression for #1165 — tunnels.json reveals cross-wing links and
must not be world-readable on shared Linux/multi-user systems."""
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph._save_tunnels(
[
{
"id": "x",
"source": {"wing": "a", "room": "r1"},
"target": {"wing": "b", "room": "r2"},
"label": "",
}
]
)
file_mode = stat.S_IMODE(os.stat(tunnel_file).st_mode)
assert file_mode == 0o600, f"tunnels.json mode is {oct(file_mode)}, expected 0o600"
parent_mode = stat.S_IMODE(os.stat(tunnel_file.parent).st_mode)
assert parent_mode == 0o700, (
f"tunnels.json parent dir mode is {oct(parent_mode)}, expected 0o700"
)
class TestExplicitTunnels:
def test_normalize_wing_uses_shared_rule_and_trims_empty(self):
assert palace_graph._normalize_wing(" Mempalace-Public ") == "mempalace_public"
assert palace_graph._normalize_wing(" ") is None
assert palace_graph._normalize_wing(None) is None
# Non-string inputs (corrupt or hand-edited tunnels.json) return None
# instead of raising — keeps read-path filters robust to bad records.
assert palace_graph._normalize_wing(42) is None
assert palace_graph._normalize_wing(["x"]) is None
def test_create_tunnel_deduplicates_reverse_order_and_updates_label(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
first = palace_graph.create_tunnel(
"wing_code", "auth", "wing_people", "users", label="same concept"
)
second = palace_graph.create_tunnel(
"wing_people", "users", "wing_code", "auth", label="updated label"
)
assert first["id"] == second["id"]
assert len(palace_graph.list_tunnels()) == 1
assert second["label"] == "updated label"
assert second["created_at"] == first["created_at"]
assert "updated_at" in second
def test_create_tunnel_rejects_empty_names(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
with pytest.raises(ValueError):
palace_graph.create_tunnel("", "auth", "wing_people", "users")
def test_list_tunnels_filters_by_either_side(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("wing_code", "auth", "wing_people", "users", label="A")
palace_graph.create_tunnel("wing_ops", "deploy", "wing_people", "users", label="B")
assert len(palace_graph.list_tunnels()) == 2
assert len(palace_graph.list_tunnels("wing_people")) == 2
assert len(palace_graph.list_tunnels("wing_code")) == 1
def test_delete_tunnel_removes_saved_tunnel(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
tunnel = palace_graph.create_tunnel(
"wing_code", "auth", "wing_people", "users", label="same concept"
)
assert palace_graph.delete_tunnel(tunnel["id"]) == {"deleted": tunnel["id"]}
assert palace_graph.list_tunnels() == []
def test_follow_tunnels_returns_direction_and_preview(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel(
"wing_code",
"auth",
"wing_people",
"users",
label="same concept",
target_drawer_id="drawer_users_1",
)
col = MagicMock()
col.get.return_value = {
"ids": ["drawer_users_1"],
"documents": ["A" * 400],
"metadatas": [{}],
}
outgoing = palace_graph.follow_tunnels("wing_code", "auth", col=col)
assert len(outgoing) == 1
assert outgoing[0]["direction"] == "outgoing"
assert outgoing[0]["connected_wing"] == "wing_people"
assert outgoing[0]["connected_room"] == "users"
assert outgoing[0]["drawer_id"] == "drawer_users_1"
assert len(outgoing[0]["drawer_preview"]) == 300
incoming = palace_graph.follow_tunnels("wing_people", "users", col=col)
assert len(incoming) == 1
assert incoming[0]["direction"] == "incoming"
assert incoming[0]["connected_wing"] == "wing_code"
def test_follow_tunnels_returns_connections_even_if_collection_lookup_fails(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel(
"wing_code",
"auth",
"wing_people",
"users",
label="same concept",
target_drawer_id="drawer_users_1",
)
col = MagicMock()
col.get.side_effect = RuntimeError("boom")
connections = palace_graph.follow_tunnels("wing_code", "auth", col=col)
assert len(connections) == 1
assert "drawer_preview" not in connections[0]
class TestTopicTunnels:
"""Cross-wing topic tunnels (issue #1180).
When two wings share confirmed TOPIC labels above a configurable
threshold, a symmetric tunnel is created between them. Tunnels are
routed through the existing ``create_tunnel`` storage so they share
dedup and persistence with explicit tunnels.
"""
def test_compute_topic_tunnels_creates_link_for_shared_topic(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI"],
"wing_beta": ["OpenAPI", "Kubernetes"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
assert len(created) == 1
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"}
# Room is namespaced with the ``topic:`` prefix so it can't collide
# 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 "topic:OpenAPI" not in created[0]["label"]
# Tunnel is retrievable via the standard list_tunnels API.
listed = palace_graph.list_tunnels()
assert len(listed) == 1
assert listed[0]["id"] == created[0]["id"]
def test_compute_topic_tunnels_no_link_below_threshold(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI"],
"wing_beta": ["OpenAPI", "Kubernetes"],
}
# min_count=2 requires two overlapping topics — only one shared.
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
assert created == []
assert palace_graph.list_tunnels() == []
def test_compute_topic_tunnels_above_threshold_creates_per_topic_links(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI", "Postgres"],
"wing_beta": ["Angular", "OpenAPI", "Redis"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
# Two shared topics × one wing pair = two tunnels.
rooms = sorted(t["source"]["room"] for t in created)
assert rooms == ["topic:Angular", "topic:OpenAPI"]
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["openapi"],
"wing_beta": ["OpenAPI"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
assert len(created) == 1
def test_compute_topic_tunnels_empty_input_is_noop(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
assert palace_graph.compute_topic_tunnels({}) == []
assert palace_graph.compute_topic_tunnels({"wing_a": []}) == []
assert palace_graph.list_tunnels() == []
def test_compute_topic_tunnels_three_wings_pairwise(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_a": ["foo"],
"wing_b": ["foo"],
"wing_c": ["foo"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
# 3 wings sharing the same topic → C(3,2) = 3 pairs → 3 tunnels.
assert len(created) == 3
endpoint_pairs = {
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
}
assert endpoint_pairs == {
("wing_a", "wing_b"),
("wing_a", "wing_c"),
("wing_b", "wing_c"),
}
def test_topic_tunnels_for_wing_only_links_that_wing(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_a": ["foo", "bar"],
"wing_b": ["foo"],
"wing_c": ["bar"],
}
# wing_a should link to both b (via foo) and c (via bar).
created = palace_graph.topic_tunnels_for_wing("wing_a", topics_by_wing)
endpoint_pairs = {
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
}
assert endpoint_pairs == {("wing_a", "wing_b"), ("wing_a", "wing_c")}
# The b-c pair is NOT created because wing_a's incremental pass
# only computes pairs that include wing_a.
assert len(palace_graph.list_tunnels()) == 2
def test_topic_tunnels_for_wing_unknown_wing_is_noop(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {"wing_a": ["foo"], "wing_b": ["foo"]}
assert palace_graph.topic_tunnels_for_wing("wing_missing", topics_by_wing) == []
assert palace_graph.list_tunnels() == []
def test_topic_tunnels_for_wing_matches_across_slug_forms(self, tmp_path, monkeypatch):
"""The wing arg and ``topics_by_wing`` keys may carry different slug
forms (hyphen vs underscore). ``topic_tunnels_for_wing`` resolves
the lookup through ``normalize_wing_name`` so a caller passing
``"my-wing"`` against a registry keyed by ``"my_wing"`` still wires
up the topic tunnels."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {"my_wing": ["Angular"], "wing_people": ["Angular"]}
created = palace_graph.topic_tunnels_for_wing("my-wing", topics_by_wing)
assert len(created) == 1
def test_compute_topic_tunnels_dedupe_on_recompute(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["OpenAPI"],
"wing_beta": ["OpenAPI"],
}
first = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
second = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
# create_tunnel is symmetric/dedupe — repeated computation should
# not multiply the stored tunnels.
assert first[0]["id"] == second[0]["id"]
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"]
def test_compute_topic_tunnels_normalizes_wing_keys(self, tmp_path, monkeypatch):
"""Auto-generated topic tunnels canonicalize the wing slug so two
mining runs with mixed forms (``my-wing`` then ``my_wing``) produce
a single deduped record. Only user-issued ``create_tunnel`` calls
preserve verbatim slugs (#1504); the topic-tunnel auto-generator
owns its own slugs and stays canonical."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.compute_topic_tunnels(
{"my-wing": ["Angular"], "wing_people": ["Angular"]}, min_count=1
)
palace_graph.compute_topic_tunnels(
{"my_wing": ["Angular"], "wing_people": ["Angular"]}, min_count=1
)
tunnels = palace_graph.list_tunnels()
assert len(tunnels) == 1
stored_wings = {tunnels[0]["source"]["wing"], tunnels[0]["target"]["wing"]}
assert stored_wings == {"my_wing", "wing_people"}
class TestHyphenatedWingNormalization:
"""Wing names may reach ``tunnels.json`` in either form:
* ``mempalace mine`` without ``--wing`` derives the slug from the dir
name through ``normalize_wing_name`` → stored as ``mempalace_public``.
* ``mempalace mine --wing my-wing`` (or any explicit slug) is stored
verbatim by ``create_tunnel`` (regression #1504) → ``my-wing``.
Read-path helpers (``list_tunnels`` / ``follow_tunnels``) must accept
queries in either form and match both storage forms — normalization
is applied on both the stored value and the query key at comparison
time, never at write time.
"""
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_preserves_hyphenated_wing_names(self, tmp_path, monkeypatch):
"""Regression for #1504: wings created via ``mempalace mine --wing my-wing``
keep the hyphen in metadata, so ``create_tunnel`` must store the slug
verbatim. Read-path normalization in ``list_tunnels``/``follow_tunnels``
keeps both query forms working."""
_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
def test_read_path_skips_records_with_null_endpoints(self, tmp_path, monkeypatch):
"""A hand-edited ``tunnels.json`` may carry ``"source": null`` or
``"target": null``. The read-path filters must skip such rows
instead of crashing the whole iteration with ``AttributeError``."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph._save_tunnels(
[
{
"id": "broken",
"source": None,
"target": None,
"label": "corrupt",
"kind": "explicit",
"created_at": "2026-05-01T00:00:00+00:00",
},
{
"id": "ok",
"source": {"wing": "wing_a", "room": "r1"},
"target": {"wing": "wing_b", "room": "r2"},
"label": "good",
"kind": "explicit",
"created_at": "2026-05-02T00:00:00+00:00",
},
]
)
# Both filters must skip the broken record and return the good one.
assert {t["id"] for t in palace_graph.list_tunnels("wing_a")} == {"ok"}
connections = palace_graph.follow_tunnels("wing_a", "r1")
assert [c["tunnel_id"] for c in connections] == ["ok"]
def test_pre_1504_underscore_tunnels_remain_findable(self, tmp_path, monkeypatch):
"""A ``tunnels.json`` written before #1504 stored wings in normalized
underscore form (the write-path normalization is now gone). Read-path
queries with either hyphen or underscore must still find those
records after the fix."""
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph._save_tunnels(
[
{
"id": "pre_1504_record",
"source": {"wing": "mempalace_public", "room": "auth"},
"target": {"wing": "wing_people", "room": "users"},
"label": "pre-#1504 record",
"kind": "explicit",
"created_at": "2026-05-10T00:00:00+00:00",
}
]
)
assert tunnel_file.exists()
assert len(palace_graph.list_tunnels("mempalace_public")) == 1
assert len(palace_graph.list_tunnels("mempalace-public")) == 1
assert len(palace_graph.follow_tunnels("mempalace_public", "auth")) == 1
assert len(palace_graph.follow_tunnels("mempalace-public", "auth")) == 1
# =============================================================================
# Regression: tunnel file follows palace_path config (#1467)
# =============================================================================
class TestTunnelFileFollowsConfig:
"""Bug A: prior to 3.3.6 the tunnel file was hardcoded at
``~/.mempalace/tunnels.json`` regardless of MempalaceConfig.palace_path.
Under any profile-isolated $HOME (subagent profiles, sandboxes, multi-tenant
hosts) tunnels would write to a different file than drawers, so
``create_tunnel`` would appear to succeed while the tunnel was invisible
to every other process touching the configured palace.
"""
def test_default_tunnel_file_unchanged(self):
"""Regression: with default config, tunnel_file resolves to
``~/.mempalace/tunnels.json`` so existing single-user installs are
not silently relocated."""
from mempalace.config import DEFAULT_PALACE_PATH, MempalaceConfig
cfg = MempalaceConfig()
# Default palace_path is ~/.mempalace/palace, so tunnel is sibling.
expected = os.path.join(os.path.dirname(DEFAULT_PALACE_PATH), "tunnels.json")
assert cfg.tunnel_file == expected
assert palace_graph._get_tunnel_file(cfg) == expected
def test_tunnel_file_follows_palace_path(self, tmp_path):
"""Custom palace_path → tunnel sits beside the palace, not at the
hardcoded legacy location."""
from mempalace.config import MempalaceConfig
custom_dir = tmp_path / "custom-palace"
cfg = MempalaceConfig(config_dir=tmp_path)
cfg._file_config["palace_path"] = str(custom_dir)
assert cfg.tunnel_file == str(tmp_path / "tunnels.json")
assert palace_graph._get_tunnel_file(cfg) == str(tmp_path / "tunnels.json")
def test_load_tunnels_warns_on_orphaned_legacy_file(self, tmp_path, monkeypatch, caplog):
"""When the configured tunnel file is missing but a legacy file
exists at a different path, _load_tunnels logs a one-line warning
naming both paths and returns []. Critically, it does NOT
auto-migrate — silent merging risks clobbering newer data."""
configured = tmp_path / "configured" / "tunnels.json"
legacy = tmp_path / "legacy" / "tunnels.json"
legacy.parent.mkdir(parents=True)
legacy.write_text(
'[{"id":"orphan","source":{"wing":"a","room":"r"},'
'"target":{"wing":"b","room":"r"},"label":""}]',
encoding="utf-8",
)
monkeypatch.setattr(palace_graph, "_get_tunnel_file", lambda *a, **kw: str(configured))
monkeypatch.setattr(palace_graph, "_legacy_tunnel_file", lambda: str(legacy))
with caplog.at_level(logging.WARNING, logger="mempalace_graph"):
result = palace_graph._load_tunnels()
assert result == [], "must not auto-migrate from legacy file"
assert str(legacy) in caplog.text
assert str(configured) in caplog.text
def test_no_legacy_warning_when_paths_match(self, tmp_path, monkeypatch, caplog):
"""If configured and legacy resolve to the same path (default install),
we must not emit a misleading 'legacy file ignored' warning when the
file simply doesn't exist yet."""
same = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_get_tunnel_file", lambda *a, **kw: str(same))
monkeypatch.setattr(palace_graph, "_legacy_tunnel_file", lambda: str(same))
with caplog.at_level(logging.WARNING, logger="mempalace_graph"):
assert palace_graph._load_tunnels() == []
assert "Legacy tunnels file" not in caplog.text
# =============================================================================
# Regression: create_tunnel validates explicit-tunnel endpoints (#1468)
# =============================================================================
class _StubCollection:
"""Minimal chroma-collection stub for endpoint-validation tests."""
def __init__(self, existing_rooms):
# existing_rooms is a set of (wing, room) tuples
self.existing_rooms = set(existing_rooms)
self.calls = []
def get(self, where=None, limit=None, include=None):
self.calls.append(where)
# Parse the {"$and": [{"wing": W}, {"room": R}]} where clause we issue.
wing = room = None
for clause in (where or {}).get("$and", []):
if "wing" in clause:
wing = clause["wing"]
if "room" in clause:
room = clause["room"]
if (wing, room) in self.existing_rooms:
return {"ids": ["drawer-1"]}
return {"ids": []}
class TestCreateTunnelEndpointValidation:
"""Bug B: pre-3.3.6 ``create_tunnel`` only validated wing/room names
were non-empty strings, never that the rooms actually existed in the
chroma index. Combined with Bug A's read-bubble, callers could
successfully create tunnels pointing at phantom endpoints and a
follow-up ``list_tunnels`` would self-confirm via the same isolated
file. The fix queries chroma for at least one drawer in each endpoint
before persisting an explicit tunnel."""
def test_create_tunnel_rejects_nonexistent_target_room(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
col = _StubCollection({("wing_code", "auth")}) # only source exists
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: col)
with pytest.raises(ValueError) as exc_info:
palace_graph.create_tunnel("wing_code", "auth", "wing_people", "phantom")
msg = str(exc_info.value)
assert "phantom" in msg
assert "wing_people" in msg
# And nothing was persisted.
assert palace_graph.list_tunnels() == []
def test_create_tunnel_rejects_nonexistent_source_room(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
col = _StubCollection({("wing_people", "users")}) # only target exists
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: col)
with pytest.raises(ValueError) as exc_info:
palace_graph.create_tunnel("wing_code", "phantom", "wing_people", "users")
msg = str(exc_info.value)
assert "phantom" in msg
assert "wing_code" in msg
def test_create_tunnel_succeeds_when_both_rooms_exist(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
col = _StubCollection({("wing_code", "auth"), ("wing_people", "users")})
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: col)
t = palace_graph.create_tunnel(
"wing_code", "auth", "wing_people", "users", label="verified"
)
assert t["label"] == "verified"
assert len(palace_graph.list_tunnels()) == 1
def test_create_tunnel_skips_validation_when_collection_unreachable(
self, tmp_path, monkeypatch
):
"""When chroma is unreachable (palace not yet created, transient
failure, tests without a real backend), validation is skipped
rather than fail-closed — matches the tolerance pattern used
throughout palace_graph._get_collection()."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: None)
t = palace_graph.create_tunnel("wing_code", "any", "wing_people", "any", label="cold-start")
assert t["label"] == "cold-start"
def test_create_tunnel_tolerates_collection_query_exception(self, tmp_path, monkeypatch):
"""Permission errors / temporary chroma faults during the
validation query must not block legitimate writes."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
class _AngryCollection:
def get(self, **kw):
raise RuntimeError("simulated chroma fault")
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: _AngryCollection())
# Should not raise — fall back to "allow" rather than fail-closed.
t = palace_graph.create_tunnel("wing_code", "x", "wing_people", "y", label="best-effort")
assert t["label"] == "best-effort"
def test_compute_topic_tunnels_skips_endpoint_validation(self, tmp_path, monkeypatch):
"""Topic tunnels use synthetic ``topic:<name>`` room identifiers
that don't correspond to real chroma rooms. The endpoint-existence
check must skip kind != 'explicit', otherwise auto-derived
cross-wing graph edges would all be rejected."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
# Empty collection — would reject if validation ran.
col = _StubCollection(set())
monkeypatch.setattr(palace_graph, "_get_collection", lambda config=None: col)
# Two wings share a topic — should produce a topic-tunnel even
# though neither "topic:auth" room exists in the stub collection.
topics_by_wing = {
"wing_code": ["auth", "logging"],
"wing_people": ["auth", "schema"],
}
palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
tunnels = palace_graph.list_tunnels()
assert tunnels, "topic tunnels must persist regardless of room validation"
assert all(t.get("kind") == "topic" for t in tunnels)
# And the validation query was never invoked for topic tunnels.
assert col.calls == []
class TestEntityTunnels:
"""Cross-wing entity tunnels (the Wing → Drawer-entities → Hallway →
Tunnel sequence from the v4 architecture vision).
When an entity has within-wing hallways in two or more wings, an entity
tunnel bridges those wings, anchored on the entity. This is the
architectural counterpart to ``compute_topic_tunnels`` — same storage,
same dedup, but the substrate is hallway records (entity-grounded)
rather than raw topic words.
Endpoints use the synthetic room id ``entity:<name>`` so they can't
collide with literal folder-derived rooms of the same name (mirrors
the ``topic:<name>`` convention).
Topic tunnels are NOT removed by this work — both systems run in
parallel for one release cycle so existing palaces don't lose
tunnels between mines. Deprecation is a separate follow-up PR.
"""
def test_entity_tunnels_creates_cross_wing_tunnel_for_shared_entity(
self, tmp_path, monkeypatch
):
"""Ben appears in a hallway in wing_aya AND in wing_mempalace →
exactly one entity tunnel between those two wings, anchored on Ben."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_aya", "entity_a": "Ben", "entity_b": "Aya"},
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
]
created = palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
assert len(created) == 1
tunnel = created[0]
assert {tunnel["source"]["wing"], tunnel["target"]["wing"]} == {
"wing_aya",
"wing_mempalace",
}
# Endpoint is the synthetic entity room — same casing as the
# stored entity, prefixed with ``entity:`` to prevent collision
# with a literal folder room of the same name.
assert tunnel["source"]["room"] == "entity:Ben"
assert tunnel["target"]["room"] == "entity:Ben"
assert tunnel["kind"] == "entity"
# Label carries the entity name without the prefix.
assert "Ben" in tunnel["label"]
assert "entity:Ben" not in tunnel["label"]
def test_entity_tunnels_skips_entities_in_only_one_wing(self, tmp_path, monkeypatch):
"""Aya has hallways only in wing_aya → no tunnel for her."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_aya", "entity_a": "Aya", "entity_b": "Lumi"},
{"wing": "wing_aya", "entity_a": "Aya", "entity_b": "Ever"},
]
created = palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
assert created == []
def test_entity_tunnels_counts_entity_in_either_pair_position(self, tmp_path, monkeypatch):
"""Entity may appear as entity_a in one hallway and entity_b in
another. Both positions count toward 'this entity is in this wing'."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
# Ben is entity_b in wing_aya
{"wing": "wing_aya", "entity_a": "Aya", "entity_b": "Ben"},
# Ben is entity_a in wing_mempalace
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
]
created = palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
assert len(created) == 1
assert "Ben" in created[0]["label"]
def test_entity_tunnels_three_wings_pairwise_from_focus_wing(self, tmp_path, monkeypatch):
"""When Ben has hallways in {wing_aya, wing_mp, wing_lt} and we
compute for wing_aya, we create wing_aya↔wing_mp AND wing_aya↔wing_lt
only — NOT wing_mp↔wing_lt. The cross-wing tunnel between the two
other wings is the job of whichever of those two mines next.
"""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_aya", "entity_a": "Ben", "entity_b": "Aya"},
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
{"wing": "wing_lantern", "entity_a": "Ben", "entity_b": "Lumi"},
]
created = palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
assert len(created) == 2
target_wings = set()
for t in created:
other = (
t["target"]["wing"] if t["source"]["wing"] == "wing_aya" else t["source"]["wing"]
)
target_wings.add(other)
assert target_wings == {"wing_mempalace", "wing_lantern"}
def test_entity_tunnels_idempotent_on_rerun(self, tmp_path, monkeypatch):
"""Re-running on the same hallway data must not duplicate tunnels.
``create_tunnel`` dedup-on-canonical-id is the guarantee — this test
pins the contract."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_aya", "entity_a": "Ben", "entity_b": "Aya"},
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
]
palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
listed = palace_graph.list_tunnels()
# Only one stored tunnel even after two passes.
entity_tunnels = [t for t in listed if t.get("kind") == "entity"]
assert len(entity_tunnels) == 1
def test_entity_tunnels_retrievable_via_list_tunnels(self, tmp_path, monkeypatch):
"""Once written, entity tunnels appear in the standard
``list_tunnels`` query — readers don't need a separate API."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_aya", "entity_a": "Ben", "entity_b": "Aya"},
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
]
created = palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
listed = palace_graph.list_tunnels()
assert {t["id"] for t in listed} == {t["id"] for t in created}
def test_entity_tunnels_empty_hallways_is_noop(self, tmp_path, monkeypatch):
"""No hallways → no tunnels. No crash."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
assert palace_graph.entity_tunnels_for_wing("wing_aya", []) == []
def test_entity_tunnels_unknown_wing_is_noop(self, tmp_path, monkeypatch):
"""Wing that doesn't appear in any hallway → no tunnels (the wing
has no entities to bridge from)."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
hallways = [
{"wing": "wing_other", "entity_a": "Ben", "entity_b": "Aya"},
]
assert palace_graph.entity_tunnels_for_wing("wing_nonexistent", hallways) == []
def test_entity_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch):
"""A literal folder room ``Ben`` (created via explicit tunnel) and the
synthetic entity room ``entity:Ben`` must produce distinct tunnels —
not get deduped together by id collision."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
# Pre-create an explicit tunnel with a literal "Ben" room.
palace_graph.create_tunnel(
"wing_aya", "Ben", "wing_mempalace", "Ben", label="literal", kind="explicit"
)
hallways = [
{"wing": "wing_aya", "entity_a": "Ben", "entity_b": "Aya"},
{"wing": "wing_mempalace", "entity_a": "Ben", "entity_b": "Igor"},
]
palace_graph.entity_tunnels_for_wing("wing_aya", hallways)
listed = palace_graph.list_tunnels()
# Two distinct tunnels: one literal "Ben" → "Ben", one
# "entity:Ben" → "entity:Ben". Different canonical IDs.
assert len(listed) == 2
kinds = {t.get("kind") for t in listed}
assert kinds == {"explicit", "entity"}
class TestTunnelDynamicsIntegration:
"""Tunnel records produced by ``create_tunnel`` must carry the L7
dynamics fields (strength, stability, last_activated, access_count).
Plus: re-creating a tunnel with the same canonical ID (the dedup path)
must PRESERVE accumulated dynamics — otherwise every relabeling event
resets the connection weights."""
def test_new_tunnel_carries_all_dynamics_fields(self, tmp_path, monkeypatch):
from mempalace.dynamics import DEFAULT_STABILITY, DEFAULT_STRENGTH
_use_tmp_tunnel_file(monkeypatch, tmp_path)
t = palace_graph.create_tunnel(
"wing_a", "room_x", "wing_b", "room_y", label="initial", kind="explicit"
)
assert t["strength"] == DEFAULT_STRENGTH, (
f"new tunnel should carry default strength; got {t}"
)
assert t["stability"] == DEFAULT_STABILITY, (
f"new tunnel should carry default stability; got {t}"
)
assert t["access_count"] == 0
assert "last_activated" in t
# last_activated anchored to created_at so decay starts from creation.
assert t["last_activated"] == t["created_at"]
def test_recreate_tunnel_preserves_accumulated_dynamics(self, tmp_path, monkeypatch):
"""create_tunnel deduplicates on canonical ID. When called twice
with the same endpoints, it preserves created_at and adds
updated_at. It must ALSO preserve accumulated dynamics — otherwise
a label-update event would wipe the connection's L7 state."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
first = palace_graph.create_tunnel("wing_a", "room_x", "wing_b", "room_y", label="initial")
# Simulate user activity bumping the tunnel's dynamics.
stored = palace_graph._load_tunnels()
for t in stored:
if t["id"] == first["id"]:
t["strength"] = 2.7
t["access_count"] = 12
t["stability"] = 1.5
palace_graph._save_tunnels(stored)
# Recreate same tunnel with a new label — should preserve dynamics.
second = palace_graph.create_tunnel(
"wing_a", "room_x", "wing_b", "room_y", label="updated_label"
)
assert second["id"] == first["id"]
assert second["label"] == "updated_label"
assert second["strength"] == 2.7, (
f"recreate reset tunnel strength — L7 dynamics undermined; got {second}"
)
assert second["access_count"] == 12
assert second["stability"] == 1.5
def test_recreate_tunnel_initializes_dynamics_for_legacy_records(self, tmp_path, monkeypatch):
"""If an existing tunnel record was created before L7 (no dynamics
fields), a recreate event should backfill the defaults rather than
leave the fields missing."""
from mempalace.dynamics import DEFAULT_STABILITY, DEFAULT_STRENGTH
_use_tmp_tunnel_file(monkeypatch, tmp_path)
legacy_tunnel = {
"id": palace_graph._canonical_tunnel_id("wing_a", "room_x", "wing_b", "room_y"),
"source": {"wing": "wing_a", "room": "room_x"},
"target": {"wing": "wing_b", "room": "room_y"},
"label": "legacy",
"kind": "explicit",
"created_at": "2026-04-01T00:00:00+00:00",
# NO strength / stability / last_activated / access_count
}
palace_graph._save_tunnels([legacy_tunnel])
# Recreate — should add the missing dynamics fields.
recreated = palace_graph.create_tunnel(
"wing_a", "room_x", "wing_b", "room_y", label="updated"
)
assert recreated["strength"] == DEFAULT_STRENGTH
assert recreated["stability"] == DEFAULT_STABILITY
assert recreated["access_count"] == 0
assert "last_activated" in recreated