2.3.0 — the index train: local-first recall index, incremental sweep, stemming + alias expansion
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>
This commit is contained in:
Jason Stedwell
2026-07-29 00:08:48 -05:00
parent 882d21dd96
commit b4e923c2e7
19 changed files with 611 additions and 72 deletions
+61 -3
View File
@@ -27,7 +27,9 @@ def check(name, cond, detail=""):
class Harness:
def __init__(self, base):
import tempfile
self.base = base
self.state_dir = tempfile.mkdtemp()
def http(self, method, url, body=None, headers=None):
data = body.encode() if isinstance(body, str) else body
@@ -52,8 +54,10 @@ class Harness:
return None if body == "<<MISSING>>" else body
def echo(self, *args):
# ECHO_STATE_DIR isolation matters since 2.3: the recall index is LOCAL-first,
# and without this the suite would write into the real ~/.echo-memory.
env = dict(os.environ, ECHO_BASE=self.base, ECHO_KEY=KEY, ECHO_VERIFY="1",
ECHO_TODAY="2026-06-21")
ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=self.state_dir)
return subprocess.run([sys.executable, str(args[0]), *args[1:]],
capture_output=True, text=True, env=env)
@@ -230,8 +234,9 @@ def main():
check("v1.5 recall finds a session log by body term",
"_agent/sessions/2026-06-15-1200-mpm-review.md" in r.stdout, r.stdout)
rix2 = h.ground("_agent/index/recall-index.json") or ""
check("v1.5 recall index carries doc meta (schema 2)",
'"schema": 2' in rix2 or '"schema":2' in rix2, rix2[:200])
check("v2.3 recall snapshot is schema 3 with build stamp + content hashes",
('"schema": 3' in rix2 or '"schema":3' in rix2)
and '"built"' in rix2 and '"h"' in rix2, rix2[:200])
# 14. recency-aware ranking: same term, fresher note wins.
h.seed("resources/concepts/old-idea.md",
@@ -402,6 +407,59 @@ def main():
r.returncode == 2 and sess_path in (h.ground("_agent/heartbeat/last-session.md") or ""),
r.stderr)
# ---------------- v2.3 index train --------------------------------------
# 20. local-first recall index: capture updates the LOCAL index with ZERO
# vault snapshot writes — the vault copy only changes on sweep/session-end.
snap_before = h.ground("_agent/index/recall-index.json") or ""
r = h.echo(ECHO, "capture", "Quixotic Widget", "--kind", "concept", "-")
r = subprocess.run([sys.executable, str(ECHO), "capture", "Quixotic Widget2", "--kind",
"concept", "-"], input="A gadget for quixotry deployment.\n",
capture_output=True, text=True,
env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY,
ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=h.state_dir))
snap_after = h.ground("_agent/index/recall-index.json") or ""
check("v2.3 capture does NOT write the vault snapshot", snap_before == snap_after)
local_files = [p for p in Path(h.state_dir).glob("recall-index-*.json")]
check("v2.3 capture maintains the LOCAL index", len(local_files) == 1,
str(local_files))
r = h.echo(ECHO, "recall", "quixotry")
check("v2.3 locally-indexed capture is recallable",
"resources/concepts/quixotic-widget2.md" in r.stdout, r.stdout[:400])
# 21. stemming: a morphologically different query still hits (deployment ~ deploying).
r = h.echo(ECHO, "recall", "deploying quixotry")
check("v2.3 stemmed recall bridges inflections",
"resources/concepts/quixotic-widget2.md" in r.stdout, r.stdout[:400])
# 22. alias query expansion: an alias-phrased query surfaces the entity even
# though the alias never appears in any note body.
r = subprocess.run([sys.executable, str(ECHO), "capture", "Kappa Engine", "--kind",
"concept", "-", "--aliases", "turbokappa"],
input="Compression tuning notes for the kappa engine core.\n",
capture_output=True, text=True,
env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY,
ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=h.state_dir))
r = h.echo(ECHO, "recall", "turbokappa compression")
check("v2.3 alias expansion surfaces the aliased entity",
"resources/concepts/kappa-engine.md" in r.stdout, r.stdout[:400])
# 23. incremental fast sweep: an out-of-band edit (seeded directly, as if by
# Obsidian) and a deletion are trued up without a full rebuild.
h.seed("resources/concepts/quixotic-widget.md",
"---\ntype: concept\nstatus: active\ncreated: 2026-06-21\nupdated: 2026-06-21\n"
"tags: [concept]\n---\n# Quixotic Widget\n\nNow mentions zanzibar explicitly.\n")
h.http("DELETE", f"{base}/vault/resources/concepts/quixotic-widget2.md")
r = h.echo(SWEEP, "--fast", "--all-shards")
check("v2.3 fast sweep reports its work",
"fast-sweep:" in r.stdout and "reindexed" in r.stdout, r.stdout + r.stderr)
r = h.echo(ECHO, "recall", "zanzibar")
check("v2.3 fast sweep picked up the out-of-band edit",
"resources/concepts/quixotic-widget.md" in r.stdout, r.stdout[:400])
idx5 = h.ground("_agent/index/entities.json") or ""
check("v2.3 fast sweep drops deleted notes from the entity index",
"quixotic-widget2" not in idx5)
check("v2.3 entity index carries content hashes", '"h"' in idx5, idx5[:300])
print(f"\n{len(failures)} failure(s)" if failures else "\nall feature tests passed")
return 1 if failures else 0
finally: