b4e923c2e7
Build and Push Docker Image / build (push) Successful in 14s
One schema-bump release (recall-index schema 3, entity-index schema 2; old recall indexes rebuild automatically, no vault migration): - Local-first recall index: the live BM25 index lives in the machine state dir (keyed by endpoint hash); capture's upkeep is a zero-network atomic file write, no advisory lock — replacing the O(vault) GET/PUT-whole-index round trip per write. The vault copy is a snapshot (sweep + session-end) used to seed fresh machines / catch up after another client swept (ECHO_RECALL_SYNC_HOURS, default 24). The read path never PUTs the vault. - Incremental sweep: entity + recall meta carry 16-char content hashes; `sweep --fast` fetches only new/gone/hash-missing notes plus a rotating weekday shard (--all-shards forces everything); deletions drop from both indexes; index-only so no --apply gate. `load` auto-runs it past ECHO_FAST_SWEEP_DAYS (7); doctor reports index freshness. Obsidian-side edits now reach the indexes without manual maintenance. - Stemming + alias expansion: echo_stem.py (conservative Porter-lite, unit- tested families + over-stemming guards) applied at index AND query time; a query fuzzy-matching an entity folds its title/alias vocabulary into the BM25 query at half weight — expansion can only boost docs containing the terms. capture hashes the note's FINAL content (auto-link reordered first). Eval gold set 8 -> 14 queries (6 paraphrases): recall@5/MRR 1.00/1.00 vs keyword baseline 0.75/0.79. +3 unit tests, +10 e2e; test harnesses now isolate ECHO_STATE_DIR. All seven suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
12 KiB
Python
270 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline regression tests for the ECHO tooling. No network, no vault.
|
|
|
|
Run: python3 test_echo_client.py (or: pytest test_echo_client.py)
|
|
|
|
Covers echo.py body/scalar normalization, the routing-view consistency checker's
|
|
helpers, and — as the guard for improvement #4 — asserts the shipped docs are in
|
|
sync with routing.json.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import echo # noqa: E402
|
|
import check_routing # noqa: E402
|
|
import echo_index # noqa: E402
|
|
import echo_links # noqa: E402
|
|
|
|
|
|
def test_heading_replace_body_is_newline_guarded() -> None:
|
|
assert echo.normalize_patch_body(b"- [x] done\n", "replace", "heading") == b"\n- [x] done\n"
|
|
|
|
|
|
def test_heading_append_body_ends_with_newline() -> None:
|
|
assert echo.normalize_patch_body(b"- item", "append", "heading") == b"- item\n"
|
|
|
|
|
|
def test_frontmatter_plain_string_becomes_json_scalar() -> None:
|
|
assert json.loads(echo.normalize_json_scalar("Fabrication Lead")) == "Fabrication Lead"
|
|
|
|
|
|
def test_frontmatter_existing_json_is_preserved() -> None:
|
|
assert echo.normalize_json_scalar('"2026-06-20"') == b'"2026-06-20"'
|
|
|
|
|
|
def test_read_many_dedups_and_maps_each_path() -> None:
|
|
# Concurrency helper contract, no network: monkeypatch the per-file fetch.
|
|
orig = echo.get_text
|
|
echo.get_text = lambda p: f"T:{p}"
|
|
try:
|
|
assert echo.read_many(["a", "b", "a"]) == {"a": "T:a", "b": "T:b"}
|
|
finally:
|
|
echo.get_text = orig
|
|
|
|
|
|
def test_read_many_empty_returns_empty_dict() -> None:
|
|
assert echo.read_many([]) == {}
|
|
|
|
|
|
def test_read_many_tolerates_unreadable_file_as_none() -> None:
|
|
orig = echo.get_text
|
|
echo.get_text = lambda p: None if p == "bad" else f"T:{p}"
|
|
try:
|
|
assert echo.read_many(["ok", "bad"]) == {"ok": "T:ok", "bad": None}
|
|
finally:
|
|
echo.get_text = orig
|
|
|
|
|
|
def test_plugin_description_under_500_chars() -> None:
|
|
# Marketplace cap — the description has silently regressed past it before (a 525-char
|
|
# relapse + an earlier "fix description length" commit). build.py fails the build on this;
|
|
# this guards it in CI so it's caught before packaging.
|
|
manifest = Path(__file__).resolve().parents[3] / ".claude-plugin" / "plugin.json"
|
|
desc = json.loads(manifest.read_text(encoding="utf-8")).get("description", "")
|
|
assert 0 < len(desc) < 500, f"plugin description is {len(desc)} chars (must be under 500)"
|
|
|
|
|
|
def test_stem_extracts_literal_directory_prefix() -> None:
|
|
assert check_routing.stem(r"^projects/active/[^/]+\.md$") == "projects/active/"
|
|
assert check_routing.stem(r"^areas/(business|personal)/[^/]+\.md$") == "areas/"
|
|
assert check_routing.stem(r"^journal/daily/\d{4}-\d{2}-\d{2}\.md$") == "journal/daily/"
|
|
|
|
|
|
def test_concretize_substitutes_placeholders() -> None:
|
|
assert check_routing.concretize("projects/<lifecycle>/<slug>.md") == "projects/active/sample.md"
|
|
assert check_routing.concretize("_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md") == "_agent/sessions/2026-01-02-0900-sample.md"
|
|
assert check_routing.concretize("journal/weekly/YYYY-Www.md") == "journal/weekly/2026-W01.md"
|
|
|
|
|
|
def test_looks_like_path_filters_non_paths() -> None:
|
|
assert check_routing.looks_like_path("inbox/captures/inbox.md")
|
|
assert check_routing.looks_like_path("_agent/locks/vault.lock")
|
|
assert not check_routing.looks_like_path("application/vnd.olrapi.document-map+json")
|
|
assert not check_routing.looks_like_path("Operator Preferences::Fact / Pattern")
|
|
assert not check_routing.looks_like_path("status: active")
|
|
|
|
|
|
def test_docs_are_in_sync_with_routing_json() -> None:
|
|
# The guard for improvement #4: SKILL.md / routing-map.md / api-reference.md
|
|
# must stay consistent with routing.json.
|
|
assert check_routing.main() == 0
|
|
|
|
|
|
def test_index_slugify_and_derive_path() -> None:
|
|
assert echo_index.slugify("Bob Smith!") == "bob-smith"
|
|
assert echo_index.derive_path("person", "bob-smith") == "resources/people/bob-smith.md"
|
|
assert echo_index.derive_path("project", "echo") == "projects/active/echo.md"
|
|
assert echo_index.derive_path("area", "ops", domain="business") == "areas/business/ops.md"
|
|
assert echo_index.derive_path("decision", "x", date="2026-01-02") == "decisions/by-date/2026-01-02-x.md"
|
|
|
|
|
|
def test_index_resolve_matches_alias_and_title() -> None:
|
|
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
|
"kind": "person", "title": "Bob Smith", "aliases": ["bob"]}}}
|
|
assert echo_index.resolve(index, "Bob Smith")[0] == "bob-smith"
|
|
assert echo_index.resolve(index, "bob")[0] == "bob-smith"
|
|
assert echo_index.resolve(index, "nobody")[0] is None
|
|
|
|
|
|
def test_kind_for_path() -> None:
|
|
assert echo_index.kind_for_path("resources/people/x.md") == "person"
|
|
assert echo_index.kind_for_path("projects/on-hold/x.md") == "project"
|
|
assert echo_index.kind_for_path("journal/daily/2026-01-01.md") is None
|
|
|
|
|
|
def test_derive_aliases_produces_punctuation_variants() -> None:
|
|
al = echo_index.derive_aliases("ECHO Memory")
|
|
assert "echo-memory" in al and "echo memory" in al
|
|
assert echo_index.derive_aliases("") == []
|
|
|
|
|
|
def test_upsert_folds_in_title_variants_and_drops_slug() -> None:
|
|
index = {"entities": {}}
|
|
e = echo_index.upsert(index, "echo-memory", "projects/active/echo-memory.md",
|
|
"project", "Echo Memory")
|
|
assert "echo memory" in e["aliases"] # space variant auto-derived
|
|
assert "echo-memory" not in e["aliases"] # equals the slug -> not stored redundantly
|
|
|
|
|
|
def test_fuzzy_candidates_finds_shortened_name() -> None:
|
|
# The real bug: a project slugged "echo" must surface for the mention "echo memory".
|
|
index = {"entities": {"echo": {"path": "projects/active/echo.md", "kind": "project",
|
|
"title": "echo", "aliases": []}}}
|
|
cands = echo_index.fuzzy_candidates(index, "echo memory")
|
|
assert cands and cands[0][0] == "echo"
|
|
# exact resolve still misses (it's exact-only) — fuzzy is the safety net:
|
|
assert echo_index.resolve(index, "echo memory")[0] is None
|
|
|
|
|
|
def test_fuzzy_candidates_ignores_common_and_short_tokens() -> None:
|
|
index = {"entities": {"data": {"path": "x.md", "kind": "concept",
|
|
"title": "data", "aliases": []}}}
|
|
assert echo_index.fuzzy_candidates(index, "data pipeline") == []
|
|
|
|
|
|
def test_gate_candidates_blocks_same_kind_rare_token() -> None:
|
|
# The blocking case the gate exists for: same kind, and the shared token is unique
|
|
# to that one entity — "Robert Smith" vs the person bob-smith must gate.
|
|
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
|
"kind": "person", "title": "Bob Smith",
|
|
"aliases": []}}}
|
|
gated = echo_index.gate_candidates(index, "Robert Smith", kind="person")
|
|
assert gated and gated[0][0] == "bob-smith"
|
|
|
|
|
|
def test_gate_candidates_skips_cross_kind() -> None:
|
|
# A company/decision named after a person or project is intentional naming, not a
|
|
# duplicate — cross-kind lookalikes warn but never block.
|
|
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
|
"kind": "person", "title": "Bob Smith",
|
|
"aliases": []}}}
|
|
assert echo_index.gate_candidates(index, "Smith Consulting", kind="company") == []
|
|
# ...but the advisory candidate list still surfaces it:
|
|
assert echo_index.fuzzy_candidates(index, "Smith Consulting")
|
|
|
|
|
|
def test_gate_candidates_skips_single_vault_common_token() -> None:
|
|
# The live 1.5.0 false positive: "echo" appears in many entity names, so any title
|
|
# containing it scored 1.0 against every single-token "echo" entity. A lone shared
|
|
# token only blocks when it is unique to that entity (df == 1).
|
|
ents = {
|
|
"echo": {"path": "projects/active/echo.md", "kind": "project",
|
|
"title": "echo", "aliases": []},
|
|
"echo-v-05": {"path": "projects/archived/echo-v.05.md", "kind": "project",
|
|
"title": "echo-v.05", "aliases": []},
|
|
}
|
|
assert echo_index.gate_candidates({"entities": ents}, "echo handbook",
|
|
kind="project") == []
|
|
# multi-token overlap still blocks, even when the individual tokens are common:
|
|
ents["echo-memory-system"] = {"path": "projects/active/echo-memory-system.md",
|
|
"kind": "project", "title": "echo memory system",
|
|
"aliases": []}
|
|
ents["echo-memory-notes"] = {"path": "resources/concepts/echo-memory-notes.md",
|
|
"kind": "concept", "title": "echo memory notes",
|
|
"aliases": []}
|
|
gated = echo_index.gate_candidates({"entities": ents}, "echo memory platform",
|
|
kind="project")
|
|
assert [s for s, _, _ in gated] == ["echo-memory-system"]
|
|
|
|
|
|
def test_aliases_in_frontmatter_flow_and_block() -> None:
|
|
flow = '---\ntype: project\naliases: ["echo memory", "echo plugin"]\n---\n# x\n'
|
|
assert echo_index.aliases_in_frontmatter(flow) == ["echo memory", "echo plugin"]
|
|
block = "---\ntype: project\naliases:\n - echo memory\n - echo plugin\n---\n# x\n"
|
|
assert echo_index.aliases_in_frontmatter(block) == ["echo memory", "echo plugin"]
|
|
assert echo_index.aliases_in_frontmatter("# no frontmatter\n") == []
|
|
|
|
|
|
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
|
|
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
|
|
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
|
|
assert changed and "[[resources/companies/acme]]" in new
|
|
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
|
|
assert not changed2 and again == new
|
|
|
|
|
|
def test_links_create_related_section_when_absent() -> None:
|
|
text = "---\ntype: concept\n---\n\n# Alpha\n\nbody\n"
|
|
new, changed = echo_links.add_related(text, "resources/concepts/beta.md")
|
|
assert changed and "## Related" in new and "[[resources/concepts/beta]]" in new
|
|
|
|
|
|
def test_stemmer_conflates_inflection_families() -> None:
|
|
import echo_stem
|
|
families = [
|
|
["deploy", "deploys", "deployed", "deploying", "deployment"],
|
|
["navigate", "navigation", "navigating", "navigational"],
|
|
["renew", "renews", "renewing"],
|
|
["prefer", "prefers", "preferring", "preferred"],
|
|
["penalty", "penalties"],
|
|
["saturate", "saturation", "saturated"],
|
|
["frequency", "frequencies"],
|
|
["expand", "expanded", "expanding"],
|
|
["meet", "meeting", "meetings"],
|
|
["service", "services"],
|
|
]
|
|
for fam in families:
|
|
stems = {echo_stem.stem(w) for w in fam}
|
|
assert len(stems) == 1, f"{fam} -> {stems}"
|
|
|
|
|
|
def test_stemmer_leaves_short_and_risky_tokens_alone() -> None:
|
|
import echo_stem
|
|
# over-stemming merges unrelated words — worse than a missed match
|
|
for w in ("sing", "thing", "was", "is", "bus", "this", "echo", "vault", "mpm"):
|
|
assert echo_stem.stem(w) == w, f"{w} mangled to {echo_stem.stem(w)}"
|
|
# documented non-conflations (the -al / d~s irregulars full Porter also skips)
|
|
assert echo_stem.stem("renewal") != echo_stem.stem("renew")
|
|
assert echo_stem.stem("expansion") != echo_stem.stem("expand")
|
|
|
|
|
|
def test_content_hash_stable_and_short() -> None:
|
|
import echo_index as ix
|
|
a = ix.content_hash("hello world")
|
|
assert a == ix.content_hash("hello world") and len(a) == 16
|
|
assert a != ix.content_hash("hello world!")
|
|
|
|
|
|
def _run_all() -> int:
|
|
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
failed = 0
|
|
for test in tests:
|
|
try:
|
|
test()
|
|
print(f"ok {test.__name__}")
|
|
except AssertionError as exc:
|
|
failed += 1
|
|
print(f"FAIL {test.__name__}: {exc}")
|
|
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_run_all())
|