1
0
forked from jason/echo

ver 1.5.1 — duplicate-gate precision (common tokens, cross-kind)

Live 1.5.0 use caught the gate blocking a decision note over the single
vault-common token "echo" (min-normalized overlap gives any one-token
entity a 1.0 score). New echo_index.gate_candidates() + token_df():

- gate blocks same-kind candidates only; cross-kind name collisions
  (a decision titled after its project) warn instead of blocking
- a lone shared token blocks only when unique to that entity (df == 1);
  multi-token overlaps still block

fuzzy_candidates (advisory warnings) and exit-76/--merge-into/--force
semantics unchanged. +3 offline unit tests, gate e2e cases restructured
+2 new; verified live both directions. All suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 13:03:07 -05:00
parent be3fd36ebf
commit 3b6c3053cb
10 changed files with 155 additions and 15 deletions
@@ -89,7 +89,7 @@ python3 "$ECHO" triage --list --json # structured inbox listing
```
- **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind``person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown.
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `ECHO_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Resolve it deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `ECHO_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Two precision rules (1.5.1) keep it from over-firing: the gate only blocks on a **same-kind** candidate (a decision or meeting named after a project/person is intentional naming — those surface as warnings only), and a **single shared token blocks only when it's unique to that entity** (a vault-common word like a project family name can't gate everything that mentions it; multi-token overlaps still block). Resolve a gate stop deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
- **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "echo memory" surfaces the project `echo`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes.
- **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose.
- **`link`** when two existing notes are related but not yet connected; `capture` already auto-links what it detects.
@@ -22,6 +22,7 @@ from __future__ import annotations
import json
import re
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -236,6 +237,56 @@ def fuzzy_candidates(index: dict, mention: str, limit: int = 5):
return [(slug, e, score) for score, _, slug, e in scored[:limit]]
def _entity_tokens(slug: str, e: dict) -> set[str]:
toks: set[str] = set()
for name in (slug, e.get("title", ""), *e.get("aliases", [])):
toks |= _sig_tokens(name)
return toks
def token_df(index: dict) -> Counter:
"""Vault-wide token document frequency: token -> how many entities carry it in their
slug/title/aliases. A token shared by several entities ("echo" in an echo-centric
vault) is a weak duplicate signal on its own; df lets the gate discount it."""
df: Counter = Counter()
for slug, e in index.get("entities", {}).items():
for t in _entity_tokens(slug, e):
df[t] += 1
return df
def gate_candidates(index: dict, mention: str, kind: str | None = None,
threshold: float = 0.6):
"""The subset of fuzzy_candidates strong enough to BLOCK a create (capture's
duplicate gate). Stricter than the advisory warning list, by two rules learned
from live use (1.5.1):
* same-kind only — a cross-kind name collision (a decision titled after its
project, a meeting named for a company) is usually intentional naming, not a
duplicate. Cross-kind lookalikes still surface as warnings, never as a block.
* no single-common-token blocks — score = overlap/min(|tokens|) means an entity
whose ONE distinctive token appears in the mention scores 1.0, so any title
containing a vault-common word ("echo") would be blocked by every such entity.
A lone shared token only blocks when it is unique to that entity (df == 1).
Returns [(slug, entry, score)] like fuzzy_candidates."""
mtoks = _sig_tokens(mention)
if not mtoks:
return []
df = token_df(index)
out = []
for slug, e, score in fuzzy_candidates(index, mention):
if score < threshold:
continue
if kind and e.get("kind") and e["kind"] != kind:
continue
overlap = mtoks & _entity_tokens(slug, e)
if len(overlap) == 1 and df[next(iter(overlap))] > 1:
continue
out.append((slug, e, score))
return out
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
aliases=None) -> dict:
ents = index.setdefault("entities", {})
@@ -228,12 +228,14 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
# an existing entity under a different name. STOP before creating the duplicate —
# after the fact, the warning arrives too late (the parallel note already exists).
# gate_candidates applies the 1.5.1 precision rules (same-kind only; a lone shared
# token blocks only when unique to that entity) so vault-common words can't gate.
gate_hits = []
if not existing_reachable and not force and not dry_run:
gate_hits = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
"kind": c.get("kind"), "score": sc}
for s, c, sc in idx_mod.fuzzy_candidates(index, title)
if sc >= DUP_GATE]
for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind,
threshold=DUP_GATE)]
if gate_hits:
if as_json:
env = echo_output.envelope("duplicate-gate", {
@@ -255,14 +257,14 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if existing_reachable:
return done("update", existing["path"], dry=True)
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
cands = idx_mod.fuzzy_candidates(index, title)
near = [c.get("path") for _, c, _ in cands][:3]
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
dry=True, near=near)
if not as_json and not force and any(sc >= DUP_GATE for _, _, sc in cands):
if (not as_json and not force
and idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)):
# (in --json mode the near_duplicates field carries this; keep stdout clean)
print("note: a real run would STOP at the duplicate gate (candidate score "
f">= {DUP_GATE}) — use --merge-into or --force.", file=real_stdout)
print("note: a real run would STOP at the duplicate gate — use --merge-into "
"or --force.", file=real_stdout)
return plan
near_dupes: list[str] = []
@@ -148,6 +148,51 @@ def test_fuzzy_candidates_ignores_common_and_short_tokens() -> None:
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"]