# ECHO Performance Test Suite

A timing harness for the operations the **1.1.0** (connection pooling + concurrent
`read_many` + shared cache) and **1.2.0** (fuzzy `resolve` / alias learning) updates
targeted. It reuses the live `echo` client module, so it measures the real pooled /
concurrent code path — not a reimplementation.

This is a **manual / pre-release** tool, not a CI gate: it depends on the live
configured endpoint (`echo.BASE` / `$ECHO_BASE`) and WAN latency. Gates are **relative ratios**
(portable), with absolute milliseconds kept as informational context.

See `PERF-TEST-PLAN.md` for the design rationale behind each metric.

## Run

```bash
cd eval/perf
python3 bench.py --list                 # list metrics
python3 bench.py all                     # the read-only safe set (no vault writes)
python3 bench.py read-full -n 10         # full-vault serial-vs-concurrent read
python3 bench.py subject-pull            # recall APTA / CapMetro / hubs (resolve+search+recall split)
python3 bench.py bulk-put -k 50          # write metric -> uses the _bench namespace
python3 bench.py worker-sweep            # ECHO_WORKERS = 1,2,4,8,16 scaling curve
python3 bench.py resolve                 # 1.2.0 fuzzy correctness table + timing
python3 bench.py soak --soak-seconds 120 # stability / leak watch
```

Configure this machine first (owner/endpoint/key):
`python3 ../../scripts/echo.py config init` then edit the file, or `export ECHO_BASE=... ECHO_KEY=...`.

## Metrics

| Metric | Plan § | Reads/Writes | What it proves |
|---|---|---|---|
| `read-full` | 3.1 | read-only | full-vault concurrent vs serial speedup (1.1.0 headline) |
| `subject-pull` | 3.2 | read-only | `recall` cost for APTA/CapMetro/hubs, split into resolve floor + search + expansion |
| `expand-graph` | 3.2a | read-only | recall()'s graph layer: serial vs concurrent neighbourhood expansion + ranking-parity guard (pins the real recall bottleneck) |
| `bulk-get` | 3.3 | writes seed | concurrent vs serial read at a controlled K |
| `bulk-put` | 3.3 | **writes** | read-back-verified PUT throughput (puts/sec) |
| `bulk-append` | 3.3 | **writes** | append throughput + idempotent-skip path (no O(file) blowup) |
| `pool-warmup` | 4.1 | read-only | cold vs warm single GET — keep-alive is alive |
| `worker-sweep` | 4.2 | read-only | concurrency scaling knee; validates default workers=8 |
| `resolve` | 4.3 | read-only | fuzzy-resolve timing **and** correctness (anti-duplicate guard) |
| `index` | 4.4 | read-only | `sweep.py` index rebuild + link symmetrize time |
| `lint` | 4.5 | read-only | `vault_lint.py` full-vault time vs 1.1.0 baseline |
| `lock` | 4.6 | **writes** lock | advisory-lock acquire/contend/release timing + semantics |
| `queue` | 4.7 | none (bogus base) | offline write-ahead enqueue timing (see caveat) |
| `cache` | 4.8 | read-only | `read_many` dedups a duplicated path list |
| `soak` | 4.10 | read-only | stability over a window; leak/drift detector |

`all` runs only the read-only set. Write metrics must be requested by name.

## Isolation & cleanup

Write metrics touch **only** `_agent/_bench/<run-id>/`, take the advisory lock for
the duration (and abort if another session holds it), and delete the namespace at
the end. Pass `--keep` to preserve it for debugging. Nothing is written to
`projects/`, `resources/`, `inbox/`, or `journal/`.

## Output

Each run writes `results/<date>-<commit>.json` (machine-readable, for trend diffing)
and prints a human table. The JSON header captures `ECHO_WORKERS`, `ECHO_TIMEOUT`,
note count, git commit, and date so two runs are comparable.

## Gating

`baselines.json` holds relative-ratio gates (e.g. `read-full.speedup_ratio >= 1.8`,
`pool-warmup.cold_over_warm_ratio >= 1.5`). Run once to capture a baseline, then
tune the thresholds to the observed numbers before treating a `FAIL` as blocking.
`--no-gate` reports numbers without pass/fail.

## Finding: recall() bottleneck is the graph layer, not BM25

A profiled `recall("operator preferences")` decomposes as: `load_index()` ~500 ms (the
BM25 index is already persisted + incrementally maintained, n_docs=119), `score()`
0.1 ms, and `expand_graph()` **~3,000 ms** — the graph BFS was fetching each neighbour
serially (one GET per node, ~126 nodes). The fix (shipped in `echo_recall.py`) makes the
BFS breadth-first by hop and fetches each frontier through the existing `read_many`
concurrency. Result: `expand_graph` 3.2–4.2x faster with byte-identical ranking and
scores; end-to-end `recall()` ~1.7x (e.g. APTA 3.8 s -> 2.2 s). The `expand-graph`
metric + gate above regression-guard this so it can't quietly revert to serial.

## Caveats

- **`resolve` verdicts are PASS/INFO only.** The correctness table in `fixtures.py`
  encodes expected slugs against the live vault; until confirmed, mismatches report
  `INFO` (tune-me), not `FAIL`. Promote to FAIL once the table is verified.
- **`queue` times the enqueue path only.** A true flush-replay needs a throwaway
  sandbox vault so the replay doesn't write to production; that's deferred (the
  plan's synthetic-vault item). The metric still proves enqueue works and reports
  queue depth.
- **Absolute ms depend on where you run from.** WAN latency to the configured
  endpoint dominates single-request times; that's why gates are ratios.
- **Synthetic-vault scaling (100/500/1000 notes) is deferred** per decision — the
  suite benchmarks the real ~190-note vault today.
