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
@@ -168,7 +168,10 @@ def kind_for_path(path: str) -> str | None:
def empty_index() -> dict:
return {"schema": 1, "updated": echo.today(), "entities": {}}
# Schema 2 (2.3): entries may carry `h`, a short content hash of the note body —
# the incremental sweep's change detector. Tolerated-absent (schema-1 entries
# simply get fetched and hashed on their first fast sweep).
return {"schema": 2, "updated": echo.today(), "entities": {}}
def load() -> dict:
@@ -287,13 +290,20 @@ def gate_candidates(index: dict, mention: str, kind: str | None = None,
return out
def content_hash(text: str) -> str:
import hashlib
return hashlib.sha1(str(text).encode("utf-8", "replace")).hexdigest()[:16]
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
aliases=None) -> dict:
aliases=None, h: str | None = None) -> dict:
ents = index.setdefault("entities", {})
e = ents.get(slug, {})
e["path"] = path
e["kind"] = kind
e["title"] = title or e.get("title") or slug
if h:
e["h"] = h
# Keep any prior + explicit aliases, and ALWAYS fold in the safe title variants so a
# hyphen/space/case form of the name resolves even if no one passed --aliases. Drop any
# alias that just equals the slug (resolve already checks the slug).