1
0
forked from jason/echo

Retire ROADMAP-1.0; rewrite run_eval.py as current-version metrics harness

Knocks two items off ROADMAP-2.0 (§4 eval refresh, §5 roadmap retirement):

- run_eval.py: the stale 0.6-vs-0.7 A/B is replaced (old version in git
  history) with a four-part current-version eval against the mock —
  retrieval (hybrid+priors vs keyword/entities-only baseline: R@5 1.00
  vs 0.75, MRR 1.00 vs 0.75, session/journal queries 2/2 vs 0/2,
  freshness top-1 correct vs wrong), dedup (0 dupes gate-on vs 3
  gate-off, 0 false blocks), write safety (0 silent failures across 4
  fault scenarios), durability (3/3 offline writes queued+landed, 0
  lost, 0 re-flush dupes). Results in eval/results/latest.json.
- README: metrics table published; repo-layout eval descriptor updated.
- eval/README: new harness documented; A/B framing marked historical.
- ROADMAP-1.0.md removed (fully shipped; story lives in the README
  version table + git history); dangling docstring pointer fixed in
  echo_concurrency.py; ROADMAP-2.0 checkboxes ticked.

All suites green; artifact rebuilt (1.5.1, docstring-only source change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 13:30:01 -05:00
parent 919e411136
commit 993abdc846
9 changed files with 405 additions and 595 deletions
+14
View File
@@ -380,6 +380,20 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the
--- ---
## Eval metrics (v1.5.1, 2026-07-03)
From the credential-free harness (`eval/run_eval.py` against the deterministic mock — no live vault; full detail in `eval/results/latest.json`). Baseline = pre-1.5 behavior: keyword substring ranking over entity notes only, no gate.
| Metric | v1.5.1 | pre-1.5 baseline |
|---|---|---|
| Retrieval recall@5 / MRR (8-query gold set) | **1.00 / 1.00** | 0.75 / 0.75 |
| Queries answerable only from sessions/journal | **2/2** | 0/2 (not in corpus) |
| Freshness: live note outranks stale archived twin | **yes** | no |
| Duplicate notes created (3 renamed-entity captures) | **0** (gate blocks) | 3 |
| Legitimate captures wrongly blocked | **0** | 0 |
| Silent write failures (4 fault scenarios) | **0** (failures loud) | — |
| Offline writes lost / duplicated on re-flush | **0 / 0** (3/3 queued + landed) | — |
## Version history ## Version history
| Version | Highlights | | Version | Highlights |
-176
View File
@@ -1,176 +0,0 @@
# echo-memory — Roadmap to v1.0
> Status: **draft / in progress.** Current shipping version: **0.9.0 (schema 3)**.
> Target: **1.0.0 (schema 4)** — *"memory you can actually trust and retrieve."*
This roadmap turns the 10 code-review suggestions into a sequenced plan and tracks the
scaffold for each. It is the single index: every v1.0 workstream maps to one or more
files under `echo-memory.plugin.src/skills/echo-memory/scripts/` (plus tests and CI).
---
## 1.0 thesis
0.x made the plugin **correct and self-describing**: status-checked writes, idempotent
appends, a routing manifest the linter enforces, an entity index, and one-call
`capture/recall`. 1.0 makes it **trustworthy and genuinely recall-driven**:
1. You can **retrieve** a fact even when you phrase the query differently than you stored it. *(H1)*
2. Nothing is **lost** when the backend is down. *(H2)*
3. Two clients writing at once **cannot corrupt** the index or clobber state. *(H3)*
4. Every feature is **proven** by tests that model the real API, gated in CI. *(H4)*
5. Memory **accrues on its own** instead of only when the agent remembers to write. *(H5)*
…on a base that is secure (M1), clean-linking (M2), self-diagnosing (M3),
machine-parseable (M4), and maintainable (M5).
---
## Non-negotiable constraints (these bound every solution)
These are inherited from the 0.x design and **must not regress**:
| Constraint | Consequence for 1.0 |
|---|---|
| **Pure-Python stdlib, no third-party deps** | H1 recall = local BM25 + graph fusion, **not** an embedding API. No `numpy`, `keyring`, `requests`. |
| **Cross-platform (Win/macOS/Linux)** | No bash, no platform `date`; `pathlib`, `os`, UTF-8-safe streams. |
| **Plugin is the single source of truth; vault holds data only** | Derived artifacts (recall index) live in `_agent/index/` (machine-maintained, rebuildable) — never new control docs in the vault. |
| **Fail loud, never silent** | New write paths keep status-checking and non-zero exits. |
| **Local state can't depend on the vault being up** | H2 queue/cache lives in `ECHO_STATE_DIR` (default `~/.echo-memory/`), not the vault. |
---
## Build order vs. scaffold order
The user asked to **scaffold heaviest → lightest** (H1→M5). That is the order the
files below are stubbed. The **recommended build/merge order is different** — safety
and proof first, so the heavy features land on a tested, secure base:
```
Phase A (land first): M1 secrets · H4 tests+CI
Phase B (correctness): H3 concurrency · M2 link/slug quality
Phase C (durability): H2 offline queue + cache
Phase D (intelligence): H1 hybrid recall · H5 reflection capture
Phase E (polish): M3 doctor · M4 --json · M5 hygiene → tag 1.0.0
```
Scaffolds are independent stubs, so stubbing heaviest-first is safe; only the
*integration* (wiring into `echo.py`, schema bump, migration) follows Phase order.
---
## Workstreams (heaviest → lightest)
Each item: **goal · why · scope · files · storage/schema · acceptance · depends-on · lift.**
### H1 — Hybrid/semantic recall · lift: ●●●●●
- **Goal:** `recall "X"` returns relevant notes even when the wording differs, ranked, with scored multi-hop graph expansion.
- **Why:** Recall is the product. Today it's keyword `/search/simple` + fixed one-hop (`echo_ops.py:88`). Differently-phrased memory is unfindable → dead weight.
- **Scope:** local BM25 index over note bodies (stdlib only); fuse lexical score + graph distance (decay per hop); relevance threshold; configurable hops.
- **Files:** `scripts/echo_recall.py` (new) → later replaces `echo_ops.recall`.
- **Storage/schema:** `_agent/index/recall-index.json` (postings + doc stats), machine-maintained, rebuilt by `sweep.py`. **Schema 3→4.**
- **Acceptance:** retrieval precision/recall measured in eval beats keyword-only baseline; recall returns scored, deduped hits + neighbourhood; index rebuildable offline.
- **Depends-on:** entity index (have), H4 eval harness for the precision/recall numbers.
### H2 — Offline durability: write-ahead queue + read cache · lift: ●●●●●
- **Goal:** No durable write is lost when the API/Obsidian is down; cold-start degrades to last-known context instead of "no memory."
- **Why:** Today vault-unreachable = silently proceed without memory (`SKILL.md:383`). 502 (Obsidian not running) is the most common real failure.
- **Scope:** append-only NDJSON outbox; replay on next reachable session (idempotency makes replay safe); last-known-good read cache for `load`.
- **Files:** `scripts/echo_queue.py` (new); integration hook `safe_request()` wrapping `echo.request`.
- **Storage/schema:** local `ECHO_STATE_DIR/outbox.ndjson` + `ECHO_STATE_DIR/cache/`. No vault schema change.
- **Acceptance:** kill the API mid-session → writes queue; restart → replay lands exactly once (no dupes); `load` serves cache when offline and says so.
- **Depends-on:** existing idempotent-append discipline; H4 for fault-injection tests.
### H3 — Concurrency correctness · lift: ●●●●○
- **Goal:** Make the advertised multi-writer (Claude + CoWork) story actually safe.
- **Why:** Index is full-file load→PUT with no lock (`echo_index.py:105`, `echo_ops.py:230`) → concurrent `capture` silently drops an entity. Lock is manual/advisory only (`echo.py:293`).
- **Scope:** auto-lock context manager around `capture`/`scope set`/index writes; atomic index update (re-read under lock, merge, save); per-resource locks; crash-safe TTL reclaim (have).
- **Files:** `scripts/echo_concurrency.py` (new); refactor `echo_index.save` + `echo_ops.capture` to route through it.
- **Storage/schema:** reuse `_agent/locks/`. No schema change.
- **Acceptance:** two concurrent captures both land in the index; no lost entries; lock auto-released on normal + error exit.
- **Depends-on:** none (pure refactor); H4 to prove it.
### H4 — High-fidelity tests + CI + current eval · lift: ●●●●○
- **Goal:** Prove every 0.9 + 1.0 feature against an API model that reproduces real failure modes; gate on CI.
- **Why:** Mock PATCH is "naive append" (`mock_olrapi.py:138`) — can't model heading replace/insert; eval still benchmarks **0.6 vs 0.7** (two versions stale).
- **Scope:** higher-fidelity mock (real heading/frontmatter PATCH semantics) or containerized real Obsidian Local REST API; refresh eval to measure retrieval precision/recall, link correctness, dup rate; wire `test_echo_client.py` + `check_routing.py` + `test_features.py` + new module tests into GitHub Actions.
- **Files:** `.github/workflows/ci.yml` (new); `eval/mock_olrapi_hifi.py` (new); `scripts/test_v1_scaffold.py` (interface guard, new); refreshed `eval/run_eval.py`.
- **Acceptance:** CI green on every push; eval reports current-version metrics; mock reproduces the 40080 invalid-target and replace/insert paths.
- **Depends-on:** none — **foundational, build in Phase A.**
### H5 — Automatic session-reflection capture · lift: ●●●●○
- **Goal:** At session end, propose durable captures extracted from the conversation for one-tap confirm — memory that fills itself.
- **Why:** Memory only accrues when the agent decides to write. "As useful as possible" means not depending on that judgment firing.
- **Scope:** model-side extraction emits a JSON proposal set; script dedups against the entity index, previews, and applies on confirm (respects "show before large writes" safety rule).
- **Files:** `scripts/echo_reflect.py` (new); `/echo-reflect` command (later).
- **Storage/schema:** none new (routes through `capture`).
- **Acceptance:** given a transcript, proposals are deduped vs index, previewed, and only applied on explicit confirm; nothing written without go-ahead.
- **Depends-on:** H1 index/recall for dedup quality; H3 for safe concurrent apply.
### M1 — Harden secret handling · lift: ●●○○○ *(do now — Phase A)*
- **Goal:** Stop shipping a live bearer token in source, docs, and every `.plugin` zip.
- **Why:** Token hardcoded at `echo.py:70` and repeated through `api-reference.md`/README — committed to git, only rotatable by rebuild. Violates the plugin's own "never store secrets" rule.
- **Scope:** resolve key env → local `ECHO_STATE_DIR/credentials` (0600) → deprecated baked default **with a loud warning**; scrub literal from docs to a placeholder; document rotation.
- **Files:** `scripts/echo_secrets.py` (new); `echo.py` `KEY=``echo_secrets.resolve_key()`.
- **Acceptance:** no live token in tracked source/docs/package; `ECHO_KEY` still overrides; rotation documented.
- **Depends-on:** none.
### M2 — Tame auto-link false positives & slug collisions · lift: ●●○○○
- **Goal:** Keep the graph (which recall depends on) clean.
- **Why:** Auto-link fires on any ≥3-char name/alias word-match (`echo_ops.py:236`) → a concept "API" or alias "rs" links everywhere; `slugify` truncates to 40 chars with no collision check (`echo_index.py:58`) → distinct titles silently share a note.
- **Scope:** raise alias floor / require multi-token or kind-aware match / stopword guard; collision-aware slug disambiguation in `derive_path`.
- **Files:** `scripts/echo_quality.py` (new); patches to `echo_links`/`echo_index`.
- **Acceptance:** known false-positive cases no longer link; colliding titles get distinct slugs; covered by tests.
- **Depends-on:** none.
### M3 — `echo.py doctor` + complete `load` fallback · lift: ●●○○○
- **Goal:** One-call readiness check; make `load` self-sufficient.
- **Why:** No single "is everything OK" check. `cmd_load` reads the heartbeat but never falls back to listing `_agent/sessions/` though `SKILL.md:122` documents it (`echo.py:395`).
- **Scope:** doctor = Python version + reachability + auth + marker/`schema_version` + lint summary, green/red; `load` lists recent sessions when heartbeat missing/stale.
- **Files:** `scripts/echo_doctor.py` (new); patch `echo.cmd_load`.
- **Acceptance:** `doctor` prints actionable status and exits non-zero on any red; `load` orients with no heartbeat.
- **Depends-on:** none.
### M4 — Machine output (`--json`) + `--dry-run` · lift: ●○○○○
- **Goal:** Let the agent act on structured results and preview writes.
- **Why:** `capture/recall/resolve/scope` print prose; agent re-parses free text.
- **Scope:** `--json` result envelope (action, path, links_added, hits); `--dry-run` for `capture`/`scope set`.
- **Files:** `scripts/echo_output.py` (new helpers); thread through `echo_ops`/`echo.py`.
- **Acceptance:** every high-level op emits valid JSON under `--json`; `--dry-run` writes nothing.
- **Depends-on:** none.
### M5 — Repo hygiene & manifest completeness · lift: ●○○○○
- **Goal:** Remove "which tree is canonical?" traps; complete the manifest.
- **Why:** Two plugin trees (`codex plugin/` vs `echo-memory.plugin.src/`) have already drifted (codex lacks the 0.9 modules); stack of versioned `.plugin` artifacts; sparse `plugin.json`.
- **Scope:** consolidate/mark canonical tree; prune old artifacts; add `license`/`homepage`/command registration to `plugin.json`; refresh version-history + eval references.
- **Files:** `MAINTENANCE.md` (new checklist); `plugin.json`.
- **Acceptance:** one canonical source tree; complete manifest; docs reference current version.
- **Depends-on:** none.
---
## Schema 3 → 4 migration (owned by H1/H2)
- Add `_agent/index/recall-index.json` (H1), rebuilt by `sweep.py`.
- `migrate.py`: add `[3→4]` step (create recall-index placeholder; no destructive moves).
- `routing.json`: add a route for `_agent/index/recall-index.json` (already covered by the generic `^_agent/index/[^/]+\.json$` route — verify in `check_routing.py`).
- Bump `CURRENT_SCHEMA` to 4 in `sweep.py`/`migrate.py`; `vault_lint.py` unaffected.
---
## Scaffold status
| # | Workstream | Lift | Scaffold file(s) | State |
|---|---|---|---|---|
| H1 | Hybrid recall | ●●●●● | `scripts/echo_recall.py` | **✅ WIRED** — BM25 + vault crawl + `recall-index.json` persistence + graph fusion; maintained on `capture`, rebuilt by `sweep`; replaces `echo_ops.recall`; **schema 4**. Covered by `test_features.py`. |
| H2 | Offline queue/cache | ●●●●● | `scripts/echo_queue.py`, `eval/test_offline_queue.py` | **✅ WIRED** — write verbs (put/post/append/patch/delete) queue on outage and report "queued"; `flush` (+ flush-on-`load`) replays idempotently and **re-bases to the current endpoint** (survives base migration); `load` degrades to last-known-good cache and flags OFFLINE. Proven end-to-end (down→queue→up→flush→land, idempotent, cache fallback). Follow-up: route capture's internal index/link writes through the queue for full offline-atomic captures. |
| H3 | Concurrency | ●●●●○ | `scripts/echo_concurrency.py` | **✅ WIRED** — `vault_lock` CM (auto acquire/release, crash-safe, advisory) + `atomic_index_update` (lock + fresh re-read-merge). `capture` entity write and `echo_recall.update_note` both routed through it. Proven by `test_features.py` (merge-no-clobber + lock-release). Follow-up: deeper resolve-level title-collision detection; true concurrent stress test. |
| H4 | Tests + CI | ●●●●○ | `.github/workflows/ci.yml`, `eval/mock_olrapi_hifi.py`, `eval/test_patch_semantics.py`, `scripts/test_v1_scaffold.py` | **✅ hi-fi PATCH mock + semantics test + CI matrix (Win/macOS/Linux × 3.10/3.12) live.** Remaining: refresh `run_eval.py` (still 0.6-vs-0.7) → publish current metrics. |
| H5 | Reflection capture | ●●●●○ | `scripts/echo_reflect.py`, `eval/test_reflect.py`, `commands/echo-reflect.md` | **✅ WIRED** — `validate`/`classify`/`preview`/`apply` + `echo.py reflect` (dry-run unless `--apply`, reads file or stdin) + `/echo-reflect` command. Dedups proposals vs the entity index, drops below-confidence, routes `inbox`, applies via `capture` (indexed/linked/logged, self-lock-guarded). Proven by `test_reflect.py`. |
| M1 | Secret handling | ●●○○○ | `scripts/echo_secrets.py` | **✅ WIRED** — `resolve_key` (env → `~/.echo-memory/credentials` → deprecated fallback + loud warning), `write-key` CLI, docs scrubbed. Live token now only in `echo.py` `DEFAULT_KEY` (remove at 1.0; operator env already sets `ECHO_KEY`). |
| M2 | Link/slug quality | ●●○○○ | `scripts/echo_quality.py` | **✅ WIRED** — `is_confident_link` gates `capture` auto-linking (rejects <4-char / common tokens, trusts multi-word names); `safe_slug` disambiguates a colliding 40-char-truncated slug in the create path. Follow-up: resolve-level title collision (two long titles → same slug → false merge). |
| M3 | Doctor + load fallback | ●●○○○ | `scripts/echo_doctor.py`, `commands/echo-doctor.md` | **✅ WIRED** — `echo.py doctor` (Python/reachability/auth/bootstrap/schema/key-source) + `/echo-doctor`; `cmd_load` now falls back to a recent-sessions listing when the heartbeat pointer is absent. Verified against the live vault. |
| M4 | `--json` / `--dry-run` | ●○○○○ | `scripts/echo_output.py` | **✅ WIRED** — `capture --json` emits a clean result envelope (helper chatter redirected) and `--dry-run` previews the create/update plan without writing; `resolve` already emits JSON. Proven by `test_features.py`. Follow-up: `--json` for recall/scope/link. |
| M5 | Hygiene | ●○○○○ | `plugin.json`, `.gitignore`, `MAINTENANCE.md` | **✅ DONE** — `plugin.json`**1.0.0** + `license`; `.gitignore` (`.DS_Store`, `__pycache__`, local `.echo-memory/` state, eval output). Remaining (operator judgment, not code): consolidate/retire the drifted `codex plugin/` tree and prune old `.plugin` artifacts — see `MAINTENANCE.md`. |
**Wiring:** scaffolds are **not** imported by the live `echo.py` yet — 0.9.0 behavior is unchanged. Integration happens per Phase, each gated by its tests (H4).
+11 -10
View File
@@ -51,21 +51,22 @@ regenerated after 2.0 if needed). What remains for 2.0:
documenting what a Codex/other-runtime port needs (manifest shape, skill entry documenting what a Codex/other-runtime port needs (manifest shape, skill entry
point, script invocation). point, script invocation).
## 4. Eval refresh — publish current-version metrics (from MAINTENANCE Docs freshness; ROADMAP-1.0 H4 leftover) ## 4. Eval refresh — publish current-version metrics (from MAINTENANCE Docs freshness; ROADMAP-1.0 H4 leftover) — ✅ DONE
`eval/run_eval.py` still benchmarks **0.6 vs 0.7**. The current-version suites `run_eval.py` was rewritten from the stale 0.6-vs-0.7 A/B into the current-version
(`test_features` et al.) prove correctness but publish no *metrics*. metrics harness (retrieval / dedup / write-safety / durability); numbers published in
the README. Re-run it per release and refresh the README table.
- [ ] Refresh `run_eval.py` to measure the current version: retrieval precision/recall - [x] Refresh `run_eval.py` to measure the current version**done 2026-07-03**:
(BM25 + freshness/status fusion vs keyword baseline), duplicate rate with the retrieval P@5/R@5/MRR + session/journal answerability + freshness top-1 (vs a
gate on/off, offline-queue durability, silent-write-failure rate. keyword/entities-only baseline), duplicate rate gate-on/off, silent-write-failure
- [ ] Publish the numbers in the README (replacing the 0.6-vs-0.7 claims) and archive rate under fault injection, offline-queue durability. `results/latest.json`.
the old A/B harness docs as historical. - [x] Publish the numbers in the README and mark the old A/B harness historical
(git history) — **done 2026-07-03**.
## 5. Repo hygiene odds & ends ## 5. Repo hygiene odds & ends
- [ ] Retire `ROADMAP-1.0.md` (fully shipped; keep in git history, drop from the tree) - [x] Retire `ROADMAP-1.0.md` (fully shipped; in git history) — **done 2026-07-03**.
once this roadmap and the README version table carry the story.
- [ ] Root README "Repository layout" section updated for the post-2.0 tree - [ ] Root README "Repository layout" section updated for the post-2.0 tree
(no versioned zips, no codex tree, skills-only plugin). (no versioned zips, no codex tree, skills-only plugin).
- [ ] Confirm `echo-improvements-prompt.md` (the 1.5.0 field report) either moves to a - [ ] Confirm `echo-improvements-prompt.md` (the 1.5.0 field report) either moves to a
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ advisory lock around a write burst, with crash-safe release, and (b) an atomic
index-update that re-reads UNDER the lock before saving, so concurrent captures merge index-update that re-reads UNDER the lock before saving, so concurrent captures merge
instead of clobber. instead of clobber.
ACCEPTANCE (see ROADMAP-1.0.md > H3): ACCEPTANCE (v1.0 roadmap H3, shipped; roadmap retired — see git history):
- two concurrent captures both land in the index (no lost entries) - two concurrent captures both land in the index (no lost entries)
- lock auto-released on normal AND error exit - lock auto-released on normal AND error exit
""" """
+19 -5
View File
@@ -1,9 +1,23 @@
# echo-memory eval — 0.6 vs 0.7 A/B harness # echo-memory eval — current-version metrics harness
A reproducible, credential-free A/B comparison of the plugin **before** (0.6: raw-curl A reproducible, credential-free eval of the shipped plugin against the deterministic
recipes from `SKILL.md`) and **after** (the shipped `scripts/echo.py` client) the mock REST API. `run_eval.py` measures the four things the plugin claims (results print
hardening work. It quantifies the claims in the comparative analysis: token cost of the as a table and land in `results/latest.json`):
I/O layer, and the rate of **silent write failures**.
1. **Retrieval** — hybrid recall (BM25 over entities **and** sessions/journal, fused
with freshness/status priors) vs a keyword-only baseline modeling pre-1.5 recall
(per-word substring ranking over entity notes only). Precision@5 / recall@5 / MRR,
session/journal answerability, freshness top-1.
2. **Dedup** — duplicate notes created replaying a capture stream with the pre-write
gate ON (default) vs OFF (`ECHO_DUP_GATE=99` ≈ pre-1.5 warn-only), plus legitimate
captures wrongly blocked (must be 0).
3. **Write safety** — silent write failures under fault injection (transient 503,
phantom PUT, PATCH to a missing heading, replayed append). Must be 0; must be loud.
4. **Durability** — writes during an outage queue, replay exactly once, never
duplicate on re-flush.
> The original **0.6-vs-0.7 A/B harness** (token cost + silent-failure comparison that
> motivated the 0.7 client) lives in git history — `git log -- eval/run_eval.py`.
## Current test suites (run these for any change) ## Current test suites (run these for any change)
+36 -144
View File
@@ -1,154 +1,46 @@
{ {
"params": { "version": "1.5.1",
"port": 8799, "date": "2026-07-03",
"cpt": 4.0, "retrieval": {
"recovery": 1500, "current (hybrid+priors, full corpus)": {
"detect_cost": 80 "precision_at_5": 0.25,
"recall_at_5": 1.0,
"mrr": 1.0,
"session_journal_queries_answered": "2/2",
"freshness_top1_correct": true
}, },
"rows": [ "baseline (keyword, entities only)": {
{ "precision_at_5": 0.2,
"scenario": "agent-log-missing-heading", "recall_at_5": 0.75,
"fault": "bad heading -> 400 invalid-target", "mrr": 0.75,
"06": { "session_journal_queries_answered": "0/2",
"emit_tokens": 82, "freshness_top1_correct": false
"claimed_ok": true,
"detected": false,
"persisted": false,
"duplicates": 0,
"silent_failure": true
},
"07": {
"emit_tokens": 22,
"claimed_ok": false,
"detected": true,
"persisted": false,
"duplicates": 0,
"silent_failure": false
} }
}, },
{ "dedup": {
"scenario": "scope-switch", "gate ON (v1.5.1 default)": {
"fault": "(none)", "duplicate_notes_created": 0,
"06": { "legitimate_captures_blocked": 0,
"emit_tokens": 84, "referring_attempts": 3,
"claimed_ok": true, "legit_attempts": 2
"detected": false,
"persisted": true,
"duplicates": 0,
"silent_failure": false
}, },
"07": { "gate OFF (pre-1.5, warn-only)": {
"emit_tokens": 24, "duplicate_notes_created": 3,
"claimed_ok": true, "legitimate_captures_blocked": 0,
"detected": false, "referring_attempts": 3,
"persisted": true, "legit_attempts": 2
"duplicates": 0,
"silent_failure": false
} }
}, },
{ "write_safety": {
"scenario": "inbox-capture-replayed", "fault_scenarios": 4,
"fault": "duplicate attempt (retry/replay)", "silent_write_failures": 0,
"06": { "failures_surfaced_loud": 2
"emit_tokens": 125,
"claimed_ok": true,
"detected": false,
"persisted": true,
"duplicates": 1,
"silent_failure": true
}, },
"07": { "durability": {
"emit_tokens": 33, "writes_during_outage": 3,
"claimed_ok": true, "reported_queued": 3,
"detected": false, "landed_after_flush": 3,
"persisted": true, "lost": 0,
"duplicates": 0, "duplicated_on_reflush": 0
"silent_failure": false
}
},
{
"scenario": "session-log-flaky-network",
"fault": "one-time 503 (transient)",
"06": {
"emit_tokens": 64,
"claimed_ok": true,
"detected": false,
"persisted": false,
"duplicates": 0,
"silent_failure": true
},
"07": {
"emit_tokens": 17,
"claimed_ok": true,
"detected": false,
"persisted": true,
"duplicates": 0,
"silent_failure": false
}
},
{
"scenario": "heartbeat-phantom-write",
"fault": "accepted-but-not-persisted",
"06": {
"emit_tokens": 61,
"claimed_ok": true,
"detected": false,
"persisted": false,
"duplicates": 0,
"silent_failure": true
},
"07": {
"emit_tokens": 14,
"claimed_ok": false,
"detected": true,
"persisted": false,
"duplicates": 0,
"silent_failure": false
}
},
{
"scenario": "cold-start-load-6-reads",
"fault": "(none)",
"06": {
"emit_tokens": 192,
"claimed_ok": true,
"detected": false,
"persisted": true,
"duplicates": 0,
"silent_failure": false
},
"07": {
"emit_tokens": 64,
"claimed_ok": true,
"detected": false,
"persisted": true,
"duplicates": 0,
"silent_failure": false
}
}
],
"totals": {
"0.6": {
"gen_tokens": 608,
"silent_failures": 4,
"detected_failures": 0,
"duplicates": 1,
"effective_tokens": 6608
},
"0.7": {
"gen_tokens": 174,
"silent_failures": 0,
"detected_failures": 2,
"duplicates": 0,
"effective_tokens": 334
}
},
"silent_error_free": {
"0.6": "1/5",
"0.7": "5/5"
},
"writes_persisted": {
"0.6": "2/5",
"0.7": "3/5"
} }
} }
+320 -255
View File
@@ -1,41 +1,52 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""run_eval.py — A/B eval of echo-memory 0.6 (raw curl, documented recipes) vs """run_eval.py — current-version metrics for echo-memory (v1.5+).
0.7 (the shipped echo.py client) over representative memory operations, with
fault injection.
Design Replaces the historical 0.6-vs-0.7 A/B harness (see git history for that version).
------ Measures the four things the plugin now claims, against the deterministic mock
* A mock Obsidian REST API (mock_olrapi.py) gives deterministic behavior + faults, REST API (mock_olrapi.py) — no credentials, no live vault:
so no credentials are needed and the real vault is never touched.
* The 0.7 side runs the ACTUAL shipped scripts/echo.py (status-checked, retry, verify,
idempotent append).
* The 0.6 side faithfully models the documented raw-curl recipes: it performs the
same HTTP but does NOT inspect status, does NOT retry, does NOT verify, and does
NOT dedupe — exactly what SKILL.md 0.6 told the model to emit.
* Ground truth is read back independently from the mock after each method, so a
"silent failure" (method reported success but the vault is actually wrong, and
nobody noticed) is detected regardless of what the method claimed.
* Each (scenario, method) runs against a freshly reset + re-seeded server, so faults
(e.g. one-time 503) are identical for both methods.
Metrics 1. RETRIEVAL — hybrid recall (BM25 over entities + sessions/journal, fused with
------- freshness/status priors) vs a keyword-only baseline modeling the
* gen_tokens : output tokens the MODEL must generate for the op (len(emitted)/CPT). pre-1.5 recall (per-word substring ranking over ENTITY notes only).
* silent_failure : claimed success BUT ground truth is wrong (lost write or duplicate). Metrics: precision@5, recall@5, MRR, session/journal queries
* detected : the method surfaced the failure (loud, retryable) instead of hiding it. answered, freshness top-1 correctness.
* effective_tokens = gen_tokens + silent_failures*RECOVERY + detected*DETECT_COST 2. DEDUP — duplicate notes created replaying a capture stream with the
(RECOVERY/DETECT_COST are labeled assumptions, tune via env). pre-write gate ON (default) vs OFF (ECHO_DUP_GATE=99, i.e. the
pre-1.5 warn-only behavior). Also counts legitimate captures
wrongly blocked (must be 0).
3. WRITE SAFETY— silent write failures (claimed success but ground truth wrong)
across fault injection: transient 503, phantom PUT, PATCH to a
missing heading, replayed append. Must be 0; failures must be LOUD.
4. DURABILITY — writes issued while the vault is unreachable must queue, replay
exactly once when it returns, and never duplicate on re-flush.
Usage: python3 run_eval.py [--port 8799] [--cpt 4] [--recovery 1500] [--detect-cost 80] Ground truth is always read back independently via the mock's __debug__ endpoint.
Results print as a table and land in results/latest.json.
Usage: python3 run_eval.py [--port 8797]
""" """
import os, sys, json, time, subprocess, tempfile, argparse, urllib.request, urllib.error from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
ECHO = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" / "echo.py" SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"
ECHO = SCRIPTS / "echo.py"
SWEEP = SCRIPTS / "sweep.py"
KEY = "test-key-not-a-real-secret" KEY = "test-key-not-a-real-secret"
TODAY = "2026-07-03"
# ----- tiny HTTP helpers (used for setup, ground truth, and the 0.6 model) -----
def http(method, url, body=None, headers=None): def http(method, url, body=None, headers=None):
data = body.encode() if isinstance(body, str) else body data = body.encode() if isinstance(body, str) else body
req = urllib.request.Request(url, data=data, method=method, req = urllib.request.Request(url, data=data, method=method,
@@ -45,259 +56,313 @@ def http(method, url, body=None, headers=None):
return r.status, r.read().decode("utf-8", "replace") return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace") return e.code, e.read().decode("utf-8", "replace")
except Exception as e: except Exception as e: # noqa: BLE001
return 0, str(e) return 0, str(e)
class Eval:
def __init__(self, base, cpt): class Harness:
self.base, self.cpt = base, cpt def __init__(self, base, state_dir):
def reset(self): http("POST", f"{self.base}/__debug__reset") self.base = base
def seed(self, path, content): http("PUT", f"{self.base}/vault/{path}", content) self.state_dir = state_dir
def reset(self):
http("POST", f"{self.base}/__debug__reset")
def seed(self, path, content):
http("PUT", f"{self.base}/vault/{path}", content)
def ground(self, path): def ground(self, path):
st, body = http("GET", f"{self.base}/__debug__?path={path}") _, body = http("GET", f"{self.base}/__debug__?path={path}")
return None if body == "<<MISSING>>" else body return None if body == "<<MISSING>>" else body
def toks(self, text): return round(len(text) / self.cpt)
def echo(self, *args):
env = dict(os.environ, ECHO_BASE=self.base, ECHO_KEY=KEY, ECHO_VERIFY="1", ECHO_LOCK_TTL="900")
p = subprocess.run([sys.executable, str(ECHO), *args], capture_output=True, text=True, env=env)
return p.returncode
# ---- faithful 0.6 recipe text (what the model emitted), for token accounting ---- def run(self, script, *args, env_extra=None, base=None):
def recipe_get(path): env = dict(os.environ, ECHO_BASE=base or self.base, ECHO_KEY=KEY,
return (f'curl -s -H "Authorization: Bearer {KEY}" ' ECHO_VERIFY="1", ECHO_TODAY=TODAY, ECHO_STATE_DIR=self.state_dir,
f'"https://echoapi.alwisp.com/vault/{path}"') **(env_extra or {}))
def recipe_put(path): return subprocess.run([sys.executable, str(script), *args],
return (f"cat > /tmp/obs_file.md << 'EOF'\n<body>\nEOF\n\n" capture_output=True, text=True, env=env)
f'curl -s -X PUT -H "Authorization: Bearer {KEY}" '
f'-H "Content-Type: text/markdown" --data-binary @/tmp/obs_file.md '
f'"https://echoapi.alwisp.com/vault/{path}"')
def recipe_post(path):
return (f"cat > /tmp/obs_entry.md << 'EOF'\n<line>\nEOF\n\n"
f'curl -s -X POST -H "Authorization: Bearer {KEY}" '
f'-H "Content-Type: text/markdown" --data-binary @/tmp/obs_entry.md '
f'"https://echoapi.alwisp.com/vault/{path}"')
def recipe_patch(path, target):
return (f"cat > /tmp/obs_patch.md << 'EOF'\n<body>\nEOF\n\n"
f'curl -s -X PATCH -H "Authorization: Bearer {KEY}" '
f'-H "Operation: append" -H "Target-Type: heading" -H "Target: {target}" '
f'-H "Content-Type: text/markdown" --data-binary @/tmp/obs_patch.md '
f'"https://echoapi.alwisp.com/vault/{path}"')
def tmpfile(content):
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False); f.write(content); f.close() def note(fm_type, title, body, status="active", created=TODAY, updated=TODAY, tags=None):
tags_s = "[" + ", ".join(tags or [fm_type]) + "]"
return (f"---\ntype: {fm_type}\nstatus: {status}\ncreated: {created}\n"
f"updated: {updated}\ntags: {tags_s}\nagent_written: true\nsource_notes: []\n---\n\n"
f"# {title}\n\n{body}\n\n## Related\n")
# ---------------------------------------------------------------- 1. RETRIEVAL
ENTITY_CORPUS = {
"resources/people/maria-lopez.md": note(
"person", "Maria Lopez", "Runs procurement at Vantage Systems. Prefers email over calls."),
"resources/companies/vantage-systems.md": note(
"company", "Vantage Systems", "Enterprise procurement platform vendor; the contract renews in Q3."),
"resources/concepts/bm25-ranking.md": note(
"concept", "BM25 Ranking", "Lexical retrieval scoring with term frequency saturation and document length normalization."),
"resources/concepts/graph-expansion.md": note(
"concept", "Graph Expansion", "Expanding a hit into its linked neighbourhood along Related links and source notes."),
"projects/active/website-redesign.md": note(
"project", "Website Redesign",
"Rebuild the marketing site. Navigation cleanup and new hero copy.",
status="active", created="2026-06-25", updated="2026-07-01"),
"projects/archived/old-website.md": note(
"project", "Old Website",
"Legacy marketing site. Navigation used nested dropdown menus. Navigation was the top complaint.",
status="archived", created="2024-03-01", updated="2024-08-10"),
"_agent/memory/semantic/tooling-preferences.md": note(
"semantic-memory", "Tooling Preferences",
"The operator prefers uv over pip for Python tooling, and ruff for linting."),
}
TIME_SERIES_CORPUS = {
"_agent/sessions/2026-06-20-1000-vantage-contract-review.md": note(
"session-log", "Vantage contract review",
"Reviewed the Vantage contract. Decided to renegotiate the SLA penalty clause before renewal.",
status="complete", created="2026-06-20", updated="2026-06-20", tags=["session-log"]),
"journal/daily/2026-06-20.md": (
"---\ntype: daily-note\ncreated: 2026-06-20\nupdated: 2026-06-20\ntags: [daily]\n---\n\n"
"# 2026-06-20\n\n## Agent Log\n- 2026-06-20: renegotiation kickoff email sent to Vantage\n"),
}
# (query, gold paths, only answerable from sessions/journal)
QUERIES = [
("uv or pip for python tooling", {"_agent/memory/semantic/tooling-preferences.md"}, False),
("procurement contact", {"resources/people/maria-lopez.md",
"resources/companies/vantage-systems.md"}, False),
("SLA penalty clause decision",
{"_agent/sessions/2026-06-20-1000-vantage-contract-review.md"}, True),
("marketing site navigation", {"projects/active/website-redesign.md",
"projects/archived/old-website.md"}, False),
("term frequency saturation", {"resources/concepts/bm25-ranking.md"}, False),
("contract renewal Q3", {"resources/companies/vantage-systems.md"}, False),
("renegotiation kickoff", {"journal/daily/2026-06-20.md"}, True),
("graph neighbourhood expansion", {"resources/concepts/graph-expansion.md"}, False),
]
FRESHNESS_QUERY = "marketing site navigation"
FRESHNESS_TOP1 = "projects/active/website-redesign.md"
def keyword_baseline(query, corpus, k=5):
"""Models pre-1.5 keyword recall: per-word substring counting over ENTITY note
bodies only (sessions/journal were not in the corpus before 1.5)."""
words = [w for w in re.findall(r"[a-z0-9]+", query.lower()) if len(w) > 2]
scored = []
for path, text in corpus.items():
body = text.lower()
s = sum(body.count(w) for w in words)
if s > 0:
scored.append((s, path))
scored.sort(key=lambda x: (-x[0], x[1]))
return [p for _, p in scored[:k]]
def metrics_for(ranked_by_query):
p_sum = r_sum = mrr_sum = 0.0
ts_answered = ts_total = 0
for (q, gold, ts_only), ranked in ranked_by_query:
top = ranked[:5]
hits = [p for p in top if p in gold]
p_sum += len(hits) / 5
r_sum += len(hits) / len(gold)
rr = 0.0
for i, p in enumerate(top):
if p in gold:
rr = 1.0 / (i + 1)
break
mrr_sum += rr
if ts_only:
ts_total += 1
ts_answered += int(bool(hits))
n = len(ranked_by_query)
return {"precision_at_5": round(p_sum / n, 3), "recall_at_5": round(r_sum / n, 3),
"mrr": round(mrr_sum / n, 3),
"session_journal_queries_answered": f"{ts_answered}/{ts_total}"}
def eval_retrieval(h):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
for path, text in {**ENTITY_CORPUS, **TIME_SERIES_CORPUS}.items():
h.seed(path, text)
r = h.run(SWEEP, "--apply")
assert r.returncode == 0, f"sweep failed: {r.stdout}{r.stderr}"
current_ranked, baseline_ranked = [], []
fresh_top1_current = fresh_top1_baseline = None
for q, gold, ts_only in QUERIES:
r = h.run(ECHO, "recall", q, "--json", "--limit", "5")
try:
primary = [hit["path"] for hit in json.loads(r.stdout).get("primary", [])]
except Exception: # noqa: BLE001
primary = []
current_ranked.append(((q, gold, ts_only), primary))
base = keyword_baseline(q, ENTITY_CORPUS)
baseline_ranked.append(((q, gold, ts_only), base))
if q == FRESHNESS_QUERY:
fresh_top1_current = primary[0] if primary else None
fresh_top1_baseline = base[0] if base else None
cur = metrics_for(current_ranked)
cur["freshness_top1_correct"] = fresh_top1_current == FRESHNESS_TOP1
bas = metrics_for(baseline_ranked)
bas["freshness_top1_correct"] = fresh_top1_baseline == FRESHNESS_TOP1
return {"current (hybrid+priors, full corpus)": cur,
"baseline (keyword, entities only)": bas}
# ------------------------------------------------------------------- 2. DEDUP
# Attempts referring to an EXISTING entity under another name; a create == duplicate.
DUP_ATTEMPTS = [
("Robert Smith", "person", "resources/people/robert-smith.md"),
("Echo Memory", "project", "projects/active/echo-memory.md"),
("Acme Consulting", "company", "resources/companies/acme-consulting.md"),
]
# Legitimate NEW entities; a block == false positive (must create in both modes).
LEGIT_ATTEMPTS = [
("Echo Review Meeting", "meeting",
f"resources/meetings/{TODAY}-echo-review-meeting.md"), # cross-kind lookalike
("Zephyr", "concept", "resources/concepts/zephyr.md"), # unrelated
]
def _dedup_pass(h, gate_env):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
for title, kind in [("Bob Smith", "person"), ("Echo", "project"), ("Acme", "company")]:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
dupes = blocked_legit = 0
for title, kind, would_be in DUP_ATTEMPTS:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
dupes += int(h.ground(would_be) is not None)
for title, kind, path in LEGIT_ATTEMPTS:
h.run(ECHO, "capture", title, "--kind", kind, env_extra=gate_env)
blocked_legit += int(h.ground(path) is None)
return {"duplicate_notes_created": dupes, "legitimate_captures_blocked": blocked_legit,
"referring_attempts": len(DUP_ATTEMPTS), "legit_attempts": len(LEGIT_ATTEMPTS)}
def eval_dedup(h):
return {"gate ON (v1.5.1 default)": _dedup_pass(h, {}),
"gate OFF (pre-1.5, warn-only)": _dedup_pass(h, {"ECHO_DUP_GATE": "99"})}
# ------------------------------------------------------------- 3. WRITE SAFETY
def eval_write_safety(h):
h.reset()
h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
silent = loud = 0
ops = 0
def bodyfile(text):
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
f.write(text)
f.close()
return f.name return f.name
# --------------------------------------------------------------------------- # transient 503 on first write ("flaky" marker) -> client must retry and land it
# Scenarios. Each defines seed(), and a run for each method that returns: ops += 1
# {emit: <text model generates>, claimed_ok: bool, detected: bool} r = h.run(ECHO, "put", "resources/concepts/flaky-note.md", bodyfile("# Flaky\nok\n"))
# plus a ground-truth check producing {persisted, duplicates}. landed = h.ground("resources/concepts/flaky-note.md") is not None
# --------------------------------------------------------------------------- if r.returncode == 0 and not landed:
def scenarios(ev): silent += 1
out = [] if r.returncode != 0:
loud += 1
# S1 — agent-log append where the heading is MISSING (-> 400 invalid-target) # phantom PUT ("phantom" marker: 200 but not persisted) -> verify must fail LOUD
def s1(): ops += 1
path, tgt = "journal/daily/2026-06-19.md", "2026-06-19::Agent Log" r = h.run(ECHO, "put", "resources/concepts/phantom-note.md", bodyfile("# Phantom\n"))
line = "- 2026-06-19: EVALMARK-s1" landed = h.ground("resources/concepts/phantom-note.md") is not None
def seed(): ev.seed(path, "---\ntype: daily-note\n---\n\n# 2026-06-19\n\n## Notes\n") if r.returncode == 0 and not landed:
def m06(): silent += 1
http("PATCH", f"{ev.base}/vault/{path}", line, if r.returncode != 0 and not landed:
{"Operation": "append", "Target-Type": "heading", "Target": tgt}) # status ignored loud += 1
return {"emit": recipe_patch(path, tgt), "claimed_ok": True, "detected": False}
def m07():
rc = ev.echo("patch", path, "append", "heading", tgt, tmpfile(line))
return {"emit": f'"$ECHO" patch {path} append heading "{tgt}" /tmp/x.md',
"claimed_ok": rc == 0, "detected": rc != 0}
def gt():
c = ev.ground(path) or ""
return {"persisted": "EVALMARK-s1" in c, "duplicates": max(0, c.count("EVALMARK-s1") - 1)}
return dict(name="agent-log-missing-heading", fault="bad heading -> 400 invalid-target",
seed=seed, m06=m06, m07=m07, gt=gt)
out.append(s1())
# S2 — scope switch (no fault; pure token comparison on a PATCH replace) # PATCH to a missing heading -> 400 must be surfaced, nothing written
def s2(): ops += 1
path, tgt = "_agent/context/current-context.md", "Current Context::Scope" h.seed("resources/concepts/target.md", "# Target\n\n## Real\nx\n")
def seed(): ev.seed(path, "---\ntype: context-bundle\n---\n\n# Current Context\n\n## Scope\nold scope\n") r = h.run(ECHO, "patch", "resources/concepts/target.md", "append", "heading",
def m06(): "Target::Nope", bodyfile("lost?"))
http("PATCH", f"{ev.base}/vault/{path}", "EVALMARK-s2 new scope", if r.returncode == 0 and "lost?" not in (h.ground("resources/concepts/target.md") or ""):
{"Operation": "replace", "Target-Type": "heading", "Target": tgt}) silent += 1
return {"emit": recipe_patch(path, tgt), "claimed_ok": True, "detected": False} if r.returncode != 0:
def m07(): loud += 1
rc = ev.echo("patch", path, "replace", "heading", tgt, tmpfile("EVALMARK-s2 new scope"))
return {"emit": f'"$ECHO" patch {path} replace heading "{tgt}" /tmp/x.md',
"claimed_ok": rc == 0, "detected": rc != 0}
def gt():
c = ev.ground(path) or ""
return {"persisted": "EVALMARK-s2" in c, "duplicates": 0}
return dict(name="scope-switch", fault="(none)", seed=seed, m06=m06, m07=m07, gt=gt)
out.append(s2())
# S3 — inbox capture issued TWICE (retry/replay): dedup vs duplicate line # replayed append -> whole-line idempotency, no duplicate
def s3(): ops += 1
path = "inbox/captures/inbox.md"; line = "- 2026-06-19: EVALMARK-s3" h.run(ECHO, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
def seed(): ev.seed(path, "# Inbox\n") h.run(ECHO, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
def m06(): n = (h.ground("inbox/captures/inbox.md") or "").count("replay me")
http("POST", f"{ev.base}/vault/{path}", line + "\n") # no idempotency check if n != 1:
http("POST", f"{ev.base}/vault/{path}", line + "\n") silent += 1
return {"emit": recipe_post(path) + "\n# (repeated on retry)\n" + recipe_post(path),
"claimed_ok": True, "detected": False}
def m07():
rc1 = ev.echo("append", path, line)
rc2 = ev.echo("append", path, line) # second is skipped by read-before-POST
return {"emit": f'"$ECHO" append {path} "{line}"\n"$ECHO" append {path} "{line}"',
"claimed_ok": rc1 == 0 and rc2 == 0, "detected": False}
def gt():
c = ev.ground(path) or ""
return {"persisted": "EVALMARK-s3" in c, "duplicates": max(0, c.count("EVALMARK-s3") - 1)}
return dict(name="inbox-capture-replayed", fault="duplicate attempt (retry/replay)",
seed=seed, m06=m06, m07=m07, gt=gt)
out.append(s3())
# S4 — session-log PUT under a flaky network (one-time 503 then success) return {"fault_scenarios": ops, "silent_write_failures": silent,
def s4(): "failures_surfaced_loud": loud}
path = "_agent/sessions/2026-06-19-1430-flaky-eval.md"
body = "---\ntype: session-log\n---\n\n# Session\nEVALMARK-s4\n"
def seed(): pass # file does not pre-exist
def m06():
http("PUT", f"{ev.base}/vault/{path}", body) # single shot, no retry, status ignored
return {"emit": recipe_put(path), "claimed_ok": True, "detected": False}
def m07():
rc = ev.echo("put", path, tmpfile(body)) # echo.py retries the 503 once
return {"emit": f'"$ECHO" put {path} /tmp/x.md', "claimed_ok": rc == 0, "detected": rc != 0}
def gt():
c = ev.ground(path) or ""
return {"persisted": "EVALMARK-s4" in c, "duplicates": 0}
return dict(name="session-log-flaky-network", fault="one-time 503 (transient)",
seed=seed, m06=m06, m07=m07, gt=gt)
out.append(s4())
# S5 — heartbeat PUT accepted-but-not-persisted (proxy hiccup): verify catches it
def s5():
path = "_agent/heartbeat/phantom-eval.md"; body = "EVALMARK-s5 @ 2026-06-19T14:30:00Z\n"
def seed(): pass
def m06():
http("PUT", f"{ev.base}/vault/{path}", body) # 200 returned; no read-back verify
return {"emit": recipe_put(path), "claimed_ok": True, "detected": False}
def m07():
rc = ev.echo("put", path, tmpfile(body)) # verify GET -> 404 -> die
return {"emit": f'"$ECHO" put {path} /tmp/x.md', "claimed_ok": rc == 0, "detected": rc != 0}
def gt():
c = ev.ground(path) or ""
return {"persisted": "EVALMARK-s5" in c, "duplicates": 0}
return dict(name="heartbeat-phantom-write", fault="accepted-but-not-persisted",
seed=seed, m06=m06, m07=m07, gt=gt)
out.append(s5())
# S6 — cold-start load: 6 reads (no fault; pure token comparison) # --------------------------------------------------------------- 4. DURABILITY
def s6(): def eval_durability(h, dead_base):
paths = ["_agent/echo-vault.md", "_agent/memory/semantic/operator-preferences.md", h.reset()
"_agent/context/current-context.md", "_agent/heartbeat/last-session.md", h.seed("_agent/echo-vault.md", "---\nschema_version: 4\n---\n# marker\n")
"journal/daily/2026-06-19.md", "inbox/captures/inbox.md"] queued_ok = 0
def seed(): for i in range(3):
for p in paths: ev.seed(p, f"# {p}\nEVALMARK-s6\n") r = h.run(ECHO, "append", "inbox/captures/inbox.md",
def m06(): f"- {TODAY}: offline capture {i}", base=dead_base)
for p in paths: http("GET", f"{ev.base}/vault/{p}") queued_ok += int(r.returncode == 0 and "queued" in r.stdout)
return {"emit": "\n".join(recipe_get(p) for p in paths), "claimed_ok": True, "detected": False} # vault returns: flush replays; a second flush must not duplicate
def m07(): h.run(ECHO, "flush")
for p in paths: ev.echo("get", p) h.run(ECHO, "flush")
return {"emit": "\n".join(f'"$ECHO" get {p}' for p in paths), "claimed_ok": True, "detected": False} inbox = h.ground("inbox/captures/inbox.md") or ""
def gt(): return {"persisted": True, "duplicates": 0} landed = sum(1 for i in range(3) if f"offline capture {i}" in inbox)
return dict(name="cold-start-load-6-reads", fault="(none)", seed=seed, m06=m06, m07=m07, gt=gt) dupes = sum(inbox.count(f"offline capture {i}") - 1
out.append(s6()) for i in range(3) if f"offline capture {i}" in inbox)
return {"writes_during_outage": 3, "reported_queued": queued_ok,
"landed_after_flush": landed, "lost": 3 - landed, "duplicated_on_reflush": dupes}
return out
def run_method(ev, scn, key):
ev.reset(); scn["seed"]()
res = scn[key]()
g = scn["gt"]()
# a write op "should persist"; reads (s6) and replays are judged by their own gt
bad = (not g["persisted"] and scn["name"] != "cold-start-load-6-reads") or g["duplicates"] > 0
silent = bool(res["claimed_ok"] and bad)
return {"emit_tokens": ev.toks(res["emit"]), "claimed_ok": res["claimed_ok"],
"detected": res["detected"], "persisted": g["persisted"],
"duplicates": g["duplicates"], "silent_failure": silent}
# ------------------------------------------------------------------------ main
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8799) ap.add_argument("--port", type=int, default=8797)
ap.add_argument("--cpt", type=float, default=4.0, help="chars per token")
ap.add_argument("--recovery", type=int, default=1500, help="token penalty per silent failure (recovery loop)")
ap.add_argument("--detect-cost", type=int, default=80, help="token cost to re-issue a corrected call after a detected failure")
a = ap.parse_args() a = ap.parse_args()
base = f"http://127.0.0.1:{a.port}" base = f"http://127.0.0.1:{a.port}"
srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)], srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
state = tempfile.mkdtemp(prefix="echo-eval-state-")
try: try:
for _ in range(50): # wait for ready for _ in range(50):
try: try:
urllib.request.urlopen(f"{base}/__debug__reset", data=b"", timeout=1); break urllib.request.urlopen(f"{base}/__debug__reset", data=b"", timeout=1)
except Exception: time.sleep(0.1) break
ev = Eval(base, a.cpt) except Exception: # noqa: BLE001
rows, agg = [], {"06": {}, "07": {}} time.sleep(0.1)
for scn in scenarios(ev): h = Harness(base, state)
r06 = run_method(ev, scn, "m06") results = {
r07 = run_method(ev, scn, "m07") "version": "1.5.1", "date": TODAY,
rows.append({"scenario": scn["name"], "fault": scn["fault"], "06": r06, "07": r07}) "retrieval": eval_retrieval(h),
"dedup": eval_dedup(h),
"write_safety": eval_write_safety(h),
"durability": eval_durability(h, dead_base=f"http://127.0.0.1:{a.port + 1}"),
}
out = HERE / "results" / "latest.json"
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(results, indent=2), encoding="utf-8")
def totals(side): print(f"\n== echo-memory eval — v{results['version']} ({results['date']}) ==\n")
gen = sum(r[side]["emit_tokens"] for r in rows) for section, data in results.items():
sil = sum(1 for r in rows if r[side]["silent_failure"]) if section in ("version", "date"):
det = sum(1 for r in rows if r[side]["detected"]) continue
dup = sum(r[side]["duplicates"] for r in rows) print(f"## {section}")
eff = gen + sil * a.recovery + det * a.detect_cost for k, v in data.items():
return dict(gen_tokens=gen, silent_failures=sil, detected_failures=det, if isinstance(v, dict):
duplicates=dup, effective_tokens=eff) print(f" {k}:")
T06, T07 = totals("06"), totals("07") for k2, v2 in v.items():
print(f" {k2:38} {v2}")
# ---- report ---- else:
line = "=" * 78 print(f" {k:40} {v}")
print(f"\n{line}\nECHO MEMORY — 0.6 vs 0.7 A/B EVAL") print()
print(f"(mock OLRAPI; chars/token={a.cpt}, recovery-penalty={a.recovery}, detect-cost={a.detect_cost})\n{line}") print(f"results -> {out}")
hdr = f"{'scenario':28} {'fault':32} {'gen06':>5} {'gen07':>5} {'silent06':>8} {'silent07':>8}" return 0
print(hdr); print("-" * len(hdr))
for r in rows:
print(f"{r['scenario']:28} {r['fault']:32} "
f"{r['06']['emit_tokens']:>5} {r['07']['emit_tokens']:>5} "
f"{('YES' if r['06']['silent_failure'] else '-'):>8} "
f"{('YES' if r['07']['silent_failure'] else '-'):>8}")
print("-" * len(hdr))
def pct(old, new): return f"{(old-new)/old*100:+.0f}%" if old else "n/a"
print(f"\n{'metric':30} {'0.6':>10} {'0.7':>10} {'delta':>10}")
print("-" * 62)
for label, k in [("generated tokens", "gen_tokens"),
("silent failures", "silent_failures"),
("duplicate lines", "duplicates"),
("detected (loud) failures", "detected_failures"),
("effective tokens (incl. recovery)", "effective_tokens")]:
o, n = T06[k], T07[k]
d = pct(o, n) if "token" in k else f"{n-o:+d}"
print(f"{label:30} {o:>10} {n:>10} {d:>10}")
wrows = [r for r in rows if r['scenario'] != 'cold-start-load-6-reads']
denom = len(wrows)
sf06 = sum(1 for r in wrows if not r['06']['silent_failure']) # silent-error-free
sf07 = sum(1 for r in wrows if not r['07']['silent_failure'])
pr06 = sum(1 for r in wrows if r['06']['persisted']) # write actually landed
pr07 = sum(1 for r in wrows if r['07']['persisted'])
print("-" * 62)
print(f"{'silent-error-free ops':30} {str(sf06)+'/'+str(denom):>10} {str(sf07)+'/'+str(denom):>10}")
print(f"{'writes actually persisted':30} {str(pr06)+'/'+str(denom):>10} {str(pr07)+'/'+str(denom):>10}")
print(f" (the 2 un-persisted 0.7 ops are bad-heading + phantom: not persistable in a single")
print(f" call, but 0.7 fails LOUD so the agent's ensure-heading/retry path can recover.)")
print(f"\nNOTE: recovery-penalty and detect-cost are tunable ASSUMPTIONS, not measured.")
print(f" gen tokens are a chars/{a.cpt} proxy for output tokens of the I/O layer only.")
print(f" This harness measures mechanics, not model reasoning quality.\n")
report = {"params": vars(a), "rows": rows, "totals": {"0.6": T06, "0.7": T07},
"silent_error_free": {"0.6": f"{sf06}/{denom}", "0.7": f"{sf07}/{denom}"},
"writes_persisted": {"0.6": f"{pr06}/{denom}", "0.7": f"{pr07}/{denom}"}}
(HERE / "results" / "latest.json").write_text(json.dumps(report, indent=2))
print(f"wrote {HERE/'results'/'latest.json'}")
finally: finally:
srv.terminate() srv.terminate()
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())