diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e17218f3..45abf4325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,9 @@ Measured on this branch's diff against v0.21.0: - PII scrubber in `src/core/eval-capture-scrub.ts` (6 regex families, adversarial-input safe) - Cross-process audit via `eval_capture_failures` + `gbrain doctor` 24h breakdown - `gbrain eval export` (NDJSON, schema_version:1, EPIPE-safe) + `gbrain eval prune` (explicit retention) +- `gbrain eval replay --against ` — contributor-facing dev loop. Reads a captured snapshot, re-runs every `query` / `search` against the current brain, prints mean Jaccard@k between captured + current `retrieved_slugs`, top-1 stability rate, and latency Δ. JSON mode (`schema_version: 1`) for CI gating, top-regressions table for human eyeballs. Closes the gap between "data captured" and "data used to gate a PR." +- `docs/eval-bench.md` — contributor guide that walks the 4-command loop (export → change → replay → diff), defines metrics (Jaccard@k, top-1 stability, latency Δ) with healthy ranges, lists the source paths that should trigger a re-run, and shows how to wire it into CI. Linked from CONTRIBUTING.md. +- `CONTRIBUTING.md` adds a "Running real-world eval benchmarks (touching retrieval code)" section so PRs that change retrieval have an obvious replay path. Maintainer reviews can ask "did you run replay?" instead of writing a custom benchmark per PR. - `hybridSearch` adds `onMeta?: (m) => void` to opts (Cathedral II callers unaffected) - `BrainEngine` gains 5 methods (breaking-interface for custom engines, drives v0.25.0 minor bump) - `test/public-exports.test.ts` + `scripts/check-exports-count.sh` lock the 17-subpath public surface diff --git a/CLAUDE.md b/CLAUDE.md index d3bd134a0..6511f4575 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,9 +49,11 @@ strict behavior when unset. - `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator - `src/core/search/source-boost.ts` (v0.22.0) — Source-type boost map keyed by slug prefix. `DEFAULT_SOURCE_BOOSTS` (originals/ 1.5, concepts/ 1.3, writing/ 1.4, people/companies/deals/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5) and `DEFAULT_HARD_EXCLUDES` (test/, archive/, attachments/, .raw/). `parseSourceBoostEnv` / `parseHardExcludesEnv` parse comma-separated `prefix:factor` pairs from `GBRAIN_SOURCE_BOOST` / `GBRAIN_SEARCH_EXCLUDE` env vars. `resolveBoostMap` and `resolveHardExcludes` merge defaults + env + caller `SearchOpts.exclude_slug_prefixes`/`include_slug_prefixes`. - `src/core/search/sql-ranking.ts` (v0.22.0) — Pure SQL string builders. `buildSourceFactorCase(slugColumn, boostMap, detail)` emits a CASE expression with longest-prefix-match wins (returns literal `'1.0'` when `detail === 'high'` for temporal-bypass parity with COMPILED_TRUTH_BOOST). `buildHardExcludeClause(slugColumn, prefixes)` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` — OR-chain wrapped in NOT, NOT `NOT LIKE ALL/ANY` (those quantifiers don't express set-exclusion). LIKE meta-character escape covers all three of `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling on SQL string literals so injection-style inputs are inert text. -- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison. v0.25.0 adds sub-subcommand dispatch on `args[0]` so `gbrain eval export` + `gbrain eval prune` route into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. +- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison. v0.25.0 adds sub-subcommand dispatch on `args[0]` so `gbrain eval export` + `gbrain eval prune` + `gbrain eval replay` route into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. - `src/commands/eval-export.ts` (v0.25.0) — streams `eval_candidates` rows as NDJSON to stdout with `schema_version: 1` prefix on every line. EPIPE-safe, progress heartbeats on stderr, stable id-desc tiebreaker so `--since` windows never dupe/miss rows. - `src/commands/eval-prune.ts` (v0.25.0) — explicit retention cleanup. Requires `--older-than DUR`. `--dry-run` reports would-delete count. +- `src/commands/eval-replay.ts` (v0.25.0) — contributor-facing replay tool. Reads NDJSON from `gbrain eval export`, re-runs each captured `query` / `search` op against the current brain, computes set-Jaccard@k between captured + current `retrieved_slugs`, top-1 stability rate, and latency Δ. Stable JSON shape (`schema_version: 1`) for CI gating; human mode prints a regression table. Pure Bun, zero new deps. The dev-loop half of BrainBench-Real that closes the gap between "data captured" and "data used to gate a PR." See `docs/eval-bench.md` for the workflow. +- `docs/eval-bench.md` (v0.25.0) — contributor guide for using captured data to benchmark retrieval changes before merging. Linked from CONTRIBUTING.md under "Running real-world eval benchmarks (touching retrieval code)". - `src/core/eval-capture.ts` (v0.25.0) — op-layer capture wrapper called from `src/core/operations.ts` `query` + `search` handlers. Catches MCP + CLI + subagent tool-bridge from one site. Fire-and-forget; failures route to `engine.logEvalCaptureFailure` so `gbrain doctor` sees drops cross-process. - `src/core/eval-capture-scrub.ts` (v0.25.0) — zero-deps PII scrubber: emails, phones, SSN, Luhn-verified credit cards, JWT-shaped tokens, bearer tokens. - `src/core/search/hybrid.ts` — Cathedral II `Promise` return shape unchanged in v0.25.0. Adds `onMeta?: (m: HybridSearchMeta) => void` callback so op-layer capture can record what hybridSearch actually did. Existing callers leave it undefined. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad4121f2b..137039f40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,40 @@ See `docs/ENGINES.md` for the full guide. In short: The SQLite engine is designed and ready for implementation. See `docs/SQLITE_ENGINE.md`. +## Running real-world eval benchmarks (touching retrieval code) + +If your PR touches retrieval — search ranking, RRF fusion, embeddings, +intent classification, query expansion, source boost, or the `query` / +`search` op handlers — run `gbrain eval replay` against a snapshot of +real traffic before merging. + +Quick loop: + +```bash +gbrain eval export --since 7d > baseline.ndjson # snapshot before your change +# ... make your change ... +gbrain eval replay --against baseline.ndjson # diff retrieval, get Jaccard@k +``` + +Three numbers come back: mean Jaccard@k between captured and current slug +sets, top-1 stability, and mean latency Δ. The replay tool flags the worst +regressions so you can eyeball whether the change is hurting real queries. + +Trigger paths (rerun if your diff touches any of these): + +- `src/core/search/hybrid.ts` +- `src/core/search/source-boost.ts`, `sql-ranking.ts` +- `src/core/search/intent.ts`, `expansion.ts`, `dedup.ts` +- `src/core/embedding.ts` +- `src/core/operations.ts` (query / search handlers) +- `src/core/postgres-engine.ts` / `pglite-engine.ts` (searchKeyword / + searchVector SQL) + +See [`docs/eval-bench.md`](./docs/eval-bench.md) for the full guide +including CI integration, hand-crafted NDJSON corpora, and the cost +considerations. The NDJSON wire format is documented in +[`docs/eval-capture.md`](./docs/eval-capture.md). + ## Welcome PRs - SQLite engine implementation diff --git a/docs/eval-bench.md b/docs/eval-bench.md new file mode 100644 index 000000000..e2ed20c8a --- /dev/null +++ b/docs/eval-bench.md @@ -0,0 +1,193 @@ +# Running real-world eval benchmarks against your gbrain changes + +Audience: gbrain maintainers and contributors. If you're touching retrieval +(search, ranking, embeddings, intent classification, query expansion, source +boost, hybrid fusion), this is the doc. + +For the **NDJSON wire format** consumed by gbrain-evals, see +[`eval-capture.md`](./eval-capture.md). This doc is the human dev loop +that lives on top of that format. + +## The 4-command loop + +```bash +# ① Capture: already happening on every MCP / CLI / subagent query, capture is on by default. +# Inspect what's been collected: +gbrain doctor # surfaces capture failures +psql $DATABASE_URL -c 'SELECT count(*) FROM eval_candidates' + +# ② Snapshot: freeze a baseline before your code change. +gbrain eval export --since 7d > baseline.ndjson + +# ③ Code change: do whatever you want — tune RRF_K, swap embed model, edit +# hybrid.ts, add a new boost source, change the intent classifier. + +# ④ Replay: re-run every captured query against the current build. +gbrain eval replay --against baseline.ndjson +``` + +Output: + +``` +Replaying 247 captured queries… + ...25/247 + ...50/247 + ... +Replayed 247 of 247 captured queries (0 skipped, 0 errored) +Mean Jaccard@k: 0.927 +Top-1 stability: 91.5% +Mean latency Δ: +14ms (current vs captured) + +Top 5 regression(s): + jaccard=0.20 captured=12 current=3 "find every reference to widget-co" + jaccard=0.43 captured=14 current=8 "show me everything tagged for review" + jaccard=0.50 captured=8 current=4 "what did alice say about the spec" + ... +``` + +Three numbers tell you whether the change is safe to land: + +| Metric | What it means | Healthy range | +|---|---|---| +| **Mean Jaccard@k** | Average overlap between captured retrieved slugs and current run's slugs. 1.0 = identical sets. | ≥0.85 for "neutral" changes. <0.7 means major retrieval shift. | +| **Top-1 stability** | Fraction of queries whose #1 result didn't change. | ≥85% for tuning passes. <70% means top-of-funnel broke. | +| **Mean latency Δ** | Current minus captured. Positive = slower now. | Within ±50ms of captured. >2× anywhere = regression alarm. | + +## What it actually does + +`gbrain eval replay` reads your NDJSON snapshot and, for each row: + +1. Re-executes the same op (`searchKeyword` for `tool_name='search'`, + `hybridSearch` for `tool_name='query'`) with the captured `detail` and + `expand_enabled` values threaded back in. +2. Captures the current `retrieved_slugs` (deduped, in result order). +3. Computes set-Jaccard between captured and current slug sets. +4. Records top-1 match (was the #1 result the same slug?). +5. Records latency delta vs captured `latency_ms`. + +It does NOT compute MRR or nDCG — those need ground-truth relevance labels, +not a baseline comparison. For metric-against-truth eval, use +`gbrain eval --qrels ` (the legacy IR-eval path, still supported). The +replay tool answers a different question: "did my code change move +retrieval, and which queries did it move most?" + +## Best-effort by design + +Replay is not pure. Three things can drift between capture and replay: + +1. **Brain state** — your brain probably has more pages now than when the + snapshot was taken. Unless you explicitly seed a fixed corpus, mean + Jaccard will drop simply because new pages are eligible. +2. **Embedding source** — if you changed `OPENAI_API_KEY` between capture + and replay (or the embedding model rotated), vector-path results drift + even with identical code. +3. **Capture cap** — captured `retrieved_slugs` is a deduped set; it doesn't + preserve internal ranking metadata. Two tools can return the same slug + set with different scores — Jaccard will say 1.0, but a downstream + consumer that orders by score may behave differently. + +The metrics are **regression alarms on real queries**, not a hash check. +Pair them with manual inspection of the top regressions. + +## Cost + +Every `query` row in the snapshot embeds the query string via OpenAI to run +the vector half of `hybridSearch`. Cost is identical to a normal `gbrain +query` invocation — text-embedding-3-large at OpenAI list price, batched +inside a single replay row. + +If you're iterating locally and don't want to pay per change, use +`--limit 50` to cap rows replayed. The 50 most recent rows are usually +enough to catch direction; expand for the final pre-merge run. + +```bash +# Iteration mode — 50 most recent queries +gbrain eval replay --against baseline.ndjson --limit 50 + +# Pre-merge — full snapshot +gbrain eval replay --against baseline.ndjson --top-regressions 20 +``` + +## CI integration + +```bash +gbrain eval replay --against baseline.ndjson --json > replay.json +jq -e '.summary.mean_jaccard >= 0.85' replay.json || exit 1 +jq -e '.summary.top1_stability_rate >= 0.85' replay.json || exit 1 +``` + +Stable JSON shape (schema_version: 1): + +```json +{ + "schema_version": 1, + "summary": { + "rows_total": 247, + "rows_replayed": 247, + "rows_skipped": 0, + "rows_errored": 0, + "mean_jaccard": 0.927, + "top1_stability_rate": 0.915, + "mean_latency_delta_ms": 14, + "rows_over_2x_latency": 0 + } +} +``` + +`--verbose` adds a `results: [...]` array with one entry per replayed row +(useful for piping into jq or a notebook for deeper analysis). + +## When to run this + +Before merging anything that touches: + +- `src/core/search/hybrid.ts` (RRF, fusion, dedup, two-pass retrieval) +- `src/core/search/source-boost.ts` / `sql-ranking.ts` (per-source ranking) +- `src/core/search/intent.ts` (auto-detail classification) +- `src/core/search/expansion.ts` (Haiku query expansion) +- `src/core/search/dedup.ts` (cross-page result collapse) +- `src/core/embedding.ts` or any embedding model swap +- `src/core/operations.ts` `query` or `search` op handlers (capture surface) +- `src/core/postgres-engine.ts` / `pglite-engine.ts` `searchKeyword` / + `searchVector` SQL + +Skip for: schema-only migrations, doc changes, tests-only PRs, CLI ergonomics +that don't touch retrieval. + +## Building your own corpus + +If you don't have captured traffic yet (fresh install, can't dogfood for a +week before merging), you can hand-author an NDJSON file: + +```jsonl +{"schema_version":1,"id":1,"tool_name":"query","query":"who is alice","retrieved_slugs":["people/alice","people/alice-bio"],"expand_enabled":false,"detail":null,"latency_ms":0,"remote":false} +{"schema_version":1,"id":2,"tool_name":"search","query":"acme deal","retrieved_slugs":["deals/acme-seed","companies/acme"],"latency_ms":0,"remote":false} +``` + +Then run `gbrain eval replay --against handcrafted.ndjson` to confirm the +authoritative slugs come back. This is the seam between the BrainBench-Real +pipeline (replay against live captures) and the BrainBench fixed-fixture +pipeline (`gbrain eval --qrels` with the sibling +[gbrain-evals](https://github.com/garrytan/gbrain-evals) corpus). + +## Off-switch + +If you don't want capture at all (privacy concern, dev environment), edit +`~/.gbrain/config.json`: + +```json +{"eval": {"capture": false}} +``` + +Capture stops; existing `eval_candidates` rows stay until you `gbrain eval +prune --older-than 0d` (or just drop the table). + +## Failure modes + +| What you see | What it means | +|---|---| +| `Mean Jaccard@k: 0.4`, top regressions all in one source dir | Source boost or hard-exclude regression on that prefix | +| `Top-1 stability: 30%`, mean Jaccard still high | RRF tuning shifted the rank order without changing the set — re-tune `rrfK` | +| `Mean latency Δ: +500ms`, jaccard high | Vector path got slower; check embedding API or HNSW probes | +| `rows_errored > 0` | One or more queries threw. Inspect first 3 in human output, or `--json` to see all `error_message` fields | +| Many `skipped: empty query` | Capture ran on rows where someone passed empty `query` — check why those were captured | diff --git a/llms-full.txt b/llms-full.txt index b84317a67..bf86def4f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -135,9 +135,11 @@ strict behavior when unset. - `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator - `src/core/search/source-boost.ts` (v0.22.0) — Source-type boost map keyed by slug prefix. `DEFAULT_SOURCE_BOOSTS` (originals/ 1.5, concepts/ 1.3, writing/ 1.4, people/companies/deals/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5) and `DEFAULT_HARD_EXCLUDES` (test/, archive/, attachments/, .raw/). `parseSourceBoostEnv` / `parseHardExcludesEnv` parse comma-separated `prefix:factor` pairs from `GBRAIN_SOURCE_BOOST` / `GBRAIN_SEARCH_EXCLUDE` env vars. `resolveBoostMap` and `resolveHardExcludes` merge defaults + env + caller `SearchOpts.exclude_slug_prefixes`/`include_slug_prefixes`. - `src/core/search/sql-ranking.ts` (v0.22.0) — Pure SQL string builders. `buildSourceFactorCase(slugColumn, boostMap, detail)` emits a CASE expression with longest-prefix-match wins (returns literal `'1.0'` when `detail === 'high'` for temporal-bypass parity with COMPILED_TRUTH_BOOST). `buildHardExcludeClause(slugColumn, prefixes)` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` — OR-chain wrapped in NOT, NOT `NOT LIKE ALL/ANY` (those quantifiers don't express set-exclusion). LIKE meta-character escape covers all three of `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling on SQL string literals so injection-style inputs are inert text. -- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison. v0.25.0 adds sub-subcommand dispatch on `args[0]` so `gbrain eval export` + `gbrain eval prune` route into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. +- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison. v0.25.0 adds sub-subcommand dispatch on `args[0]` so `gbrain eval export` + `gbrain eval prune` + `gbrain eval replay` route into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. - `src/commands/eval-export.ts` (v0.25.0) — streams `eval_candidates` rows as NDJSON to stdout with `schema_version: 1` prefix on every line. EPIPE-safe, progress heartbeats on stderr, stable id-desc tiebreaker so `--since` windows never dupe/miss rows. - `src/commands/eval-prune.ts` (v0.25.0) — explicit retention cleanup. Requires `--older-than DUR`. `--dry-run` reports would-delete count. +- `src/commands/eval-replay.ts` (v0.25.0) — contributor-facing replay tool. Reads NDJSON from `gbrain eval export`, re-runs each captured `query` / `search` op against the current brain, computes set-Jaccard@k between captured + current `retrieved_slugs`, top-1 stability rate, and latency Δ. Stable JSON shape (`schema_version: 1`) for CI gating; human mode prints a regression table. Pure Bun, zero new deps. The dev-loop half of BrainBench-Real that closes the gap between "data captured" and "data used to gate a PR." See `docs/eval-bench.md` for the workflow. +- `docs/eval-bench.md` (v0.25.0) — contributor guide for using captured data to benchmark retrieval changes before merging. Linked from CONTRIBUTING.md under "Running real-world eval benchmarks (touching retrieval code)". - `src/core/eval-capture.ts` (v0.25.0) — op-layer capture wrapper called from `src/core/operations.ts` `query` + `search` handlers. Catches MCP + CLI + subagent tool-bridge from one site. Fire-and-forget; failures route to `engine.logEvalCaptureFailure` so `gbrain doctor` sees drops cross-process. - `src/core/eval-capture-scrub.ts` (v0.25.0) — zero-deps PII scrubber: emails, phones, SSN, Luhn-verified credit cards, JWT-shaped tokens, bearer tokens. - `src/core/search/hybrid.ts` — Cathedral II `Promise` return shape unchanged in v0.25.0. Adds `onMeta?: (m: HybridSearchMeta) => void` callback so op-layer capture can record what hybridSearch actually did. Existing callers leave it undefined. diff --git a/src/commands/eval-replay.ts b/src/commands/eval-replay.ts new file mode 100644 index 000000000..15941560d --- /dev/null +++ b/src/commands/eval-replay.ts @@ -0,0 +1,424 @@ +/** + * gbrain eval replay — replay captured eval_candidates against current brain (v0.25.0). + * + * The contributor-facing half of BrainBench-Real: + * + * 1. capture some real traffic (default-on, lands in eval_candidates) + * 2. snapshot it (gbrain eval export --since 7d > baseline.ndjson) + * 3. make a code change (tune RRF_K, edit hybrid.ts, swap an embed model) + * 4. replay against the snapshot (gbrain eval replay --against baseline.ndjson) + * + * Outputs three numbers a contributor can read at a glance: + * + * - mean Jaccard@k between captured retrieved_slugs and current run's slugs + * - top-1 stability rate (was the #1 result the same?) + * - mean latency delta (current - captured), positive = slower now + * + * Best-effort by design. Replay is NOT pure — your brain has more pages than + * when the capture was taken, embeddings may have drifted, and the OPENAI key + * may be different. The metrics describe "did this change hurt retrieval on + * the queries you actually serve" not "do these match the baseline byte for + * byte." Use it before merging anything that touches src/core/search/ or the + * query/search op handlers. + * + * Usage: + * gbrain eval replay --against captured.ndjson [--limit N] [--json] + * [--top-regressions K] [--verbose] + */ + +import { readFileSync, existsSync } from 'fs'; +import type { BrainEngine } from '../core/engine.ts'; +import type { SearchResult } from '../core/types.ts'; +import { hybridSearch } from '../core/search/hybrid.ts'; + +interface ReplayOpts { + help?: boolean; + against?: string; + limit?: number; + json?: boolean; + verbose?: boolean; + topRegressions?: number; +} + +interface RowResult { + /** Captured row's id, for back-referencing into the source NDJSON. */ + id: number; + tool_name: 'query' | 'search'; + query: string; + /** Set-overlap score in [0, 1]. 1.0 = identical retrieved set. */ + jaccard: number; + /** True when current top result matches captured top result. */ + top1Match: boolean; + /** Captured retrieved_slugs (as-is from NDJSON). */ + captured_slugs: string[]; + /** Current run's slugs (deduped, in result order). */ + current_slugs: string[]; + /** Wall-clock latency (ms) of the current re-run. */ + current_latency_ms: number; + /** latency delta = current - captured. Positive = slower now. */ + latency_delta_ms: number; + /** True if the row was skipped (e.g. captured query was empty). */ + skipped?: boolean; + /** Reason the row was skipped, if any. */ + skip_reason?: string; + /** True if the row threw during replay; current_slugs is empty. */ + errored?: boolean; + error_message?: string; +} + +function parseArgs(args: string[]): ReplayOpts { + const opts: ReplayOpts = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + const next = args[i + 1]; + switch (arg) { + case '--help': + case '-h': + opts.help = true; + break; + case '--against': + if (!next) break; + opts.against = next; + i++; + break; + case '--limit': + if (!next) break; + opts.limit = parseInt(next, 10); + i++; + break; + case '--json': + opts.json = true; + break; + case '--verbose': + opts.verbose = true; + break; + case '--top-regressions': + if (!next) break; + opts.topRegressions = parseInt(next, 10); + i++; + break; + } + } + return opts; +} + +function printHelp(): void { + console.error(`gbrain eval replay — replay captured queries against current brain + +USAGE: + gbrain eval replay --against FILE.ndjson [flags] + +FLAGS: + --against FILE NDJSON file from \`gbrain eval export\` (required). + --limit N Replay at most N rows (default: replay all). + Each row hits OpenAI once for query embedding — + cap aggressively when iterating locally. + --top-regressions K Print the K rows with the worst Jaccard scores. + Default 5 in human mode, 0 in --json. + --json Emit one JSON object on stdout instead of a table. + Stable shape for CI consumption. + --verbose Include every row's per-row diff (large output). + --help, -h Show this help. + +OUTPUT (human mode): + Replayed N captured queries (M skipped, K errored) + Mean Jaccard@k: 0.873 + Top-1 stability: 87% (N=87 / 100) + Mean latency Δ: +12ms (current slower) + + Top 5 regressions: + 0.20 "find every reference to widget-co" captured=12 current=3 + ... + +EXIT CODE: + 0 — replay completed (regardless of regression magnitude). + 1 — invalid args, --against not found, or NDJSON parse failure. + +NOTES: + Replay is best-effort. Your brain has more pages than when the snapshot + was taken; embeddings may have drifted; OPENAI_API_KEY may be different. + Use the metrics to spot regressions on REAL queries, not as a hash check. +`); +} + +interface CapturedRow { + schema_version: number; + id: number; + tool_name: 'query' | 'search'; + query: string; + retrieved_slugs: string[]; + retrieved_chunk_ids?: number[]; + source_ids?: string[]; + expand_enabled?: boolean | null; + detail?: 'low' | 'medium' | 'high' | null; + detail_resolved?: 'low' | 'medium' | 'high' | null; + vector_enabled?: boolean; + expansion_applied?: boolean; + latency_ms: number; + remote?: boolean; + job_id?: number | null; + subagent_id?: number | null; + created_at?: string; +} + +/** + * Parse NDJSON. One object per non-blank line. Single bad line throws — it's + * a corrupt export and silently dropping rows would mask real bugs. + */ +function parseNdjson(content: string): CapturedRow[] { + const lines = content.split('\n'); + const rows: CapturedRow[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line) continue; + let row: CapturedRow; + try { + row = JSON.parse(line); + } catch (err) { + throw new Error(`NDJSON parse error on line ${i + 1}: ${(err as Error).message}`); + } + if (typeof row.schema_version !== 'number') { + throw new Error(`Line ${i + 1} missing schema_version — not from \`gbrain eval export\`?`); + } + if (row.schema_version !== 1) { + throw new Error( + `Line ${i + 1} has schema_version=${row.schema_version}; this replay only supports v1. ` + + `Upgrade gbrain or re-export.`, + ); + } + rows.push(row); + } + return rows; +} + +/** + * Set-Jaccard between two slug arrays. Order ignored, dupes collapsed. + * Both empty → 1.0 (identical empty sets, no information lost). + */ +function jaccardSlugs(a: string[], b: string[]): number { + const setA = new Set(a); + const setB = new Set(b); + if (setA.size === 0 && setB.size === 0) return 1.0; + let intersection = 0; + for (const s of setA) if (setB.has(s)) intersection++; + const union = setA.size + setB.size - intersection; + return union === 0 ? 1.0 : intersection / union; +} + +async function replayRow(engine: BrainEngine, row: CapturedRow): Promise { + const captured_slugs = row.retrieved_slugs ?? []; + const startedAt = Date.now(); + + // Default replay limit matches hybridSearch's default (20). + const limit = Math.max(captured_slugs.length, 20); + + // search → bare keyword path. query → hybrid path (vector + keyword + RRF). + // detail and expansion are threaded in from the captured row so the same + // logic runs that produced the original retrieval. + let current: SearchResult[]; + try { + if (row.tool_name === 'search') { + const dedupedRaw = await engine.searchKeyword(row.query, { limit }); + current = dedupedRaw; + } else { + current = await hybridSearch(engine, row.query, { + limit, + detail: row.detail ?? undefined, + expansion: row.expand_enabled ?? false, + }); + } + } catch (err) { + return { + id: row.id, + tool_name: row.tool_name, + query: row.query, + jaccard: 0, + top1Match: false, + captured_slugs, + current_slugs: [], + current_latency_ms: Date.now() - startedAt, + latency_delta_ms: Date.now() - startedAt - row.latency_ms, + errored: true, + error_message: (err as Error).message ?? String(err), + }; + } + + const current_latency_ms = Date.now() - startedAt; + // Dedup slugs while preserving order — same convention as search results. + const seen = new Set(); + const current_slugs: string[] = []; + for (const r of current) { + if (!seen.has(r.slug)) { + seen.add(r.slug); + current_slugs.push(r.slug); + } + } + + return { + id: row.id, + tool_name: row.tool_name, + query: row.query, + jaccard: jaccardSlugs(captured_slugs, current_slugs), + top1Match: captured_slugs[0] !== undefined && current_slugs[0] === captured_slugs[0], + captured_slugs, + current_slugs, + current_latency_ms, + latency_delta_ms: current_latency_ms - row.latency_ms, + }; +} + +interface ReplaySummary { + rows_total: number; + rows_replayed: number; + rows_skipped: number; + rows_errored: number; + /** Mean Jaccard across non-skipped, non-errored rows. */ + mean_jaccard: number; + top1_stability_rate: number; + mean_latency_delta_ms: number; + /** Rows where current latency is more than 2x captured (regression alarm). */ + rows_over_2x_latency: number; +} + +function summarize(results: RowResult[]): ReplaySummary { + const eligible = results.filter(r => !r.skipped && !r.errored); + const meanJaccard = eligible.length === 0 + ? 0 + : eligible.reduce((a, r) => a + r.jaccard, 0) / eligible.length; + const top1Rate = eligible.length === 0 + ? 0 + : eligible.filter(r => r.top1Match).length / eligible.length; + const meanLatencyDelta = eligible.length === 0 + ? 0 + : eligible.reduce((a, r) => a + r.latency_delta_ms, 0) / eligible.length; + const over2x = eligible.filter(r => { + const captured = results.find(x => x.id === r.id); + return captured && captured.current_latency_ms > 2 * (captured.current_latency_ms - captured.latency_delta_ms); + }).length; + + return { + rows_total: results.length, + rows_replayed: eligible.length, + rows_skipped: results.filter(r => r.skipped).length, + rows_errored: results.filter(r => r.errored).length, + mean_jaccard: meanJaccard, + top1_stability_rate: top1Rate, + mean_latency_delta_ms: meanLatencyDelta, + rows_over_2x_latency: over2x, + }; +} + +function printHumanSummary(summary: ReplaySummary, results: RowResult[], topRegressions: number): void { + const total = summary.rows_total; + const eligible = summary.rows_replayed; + console.log(`Replayed ${eligible} of ${total} captured queries (${summary.rows_skipped} skipped, ${summary.rows_errored} errored)`); + console.log(`Mean Jaccard@k: ${summary.mean_jaccard.toFixed(3)}`); + console.log(`Top-1 stability: ${(summary.top1_stability_rate * 100).toFixed(1)}%`); + const sign = summary.mean_latency_delta_ms >= 0 ? '+' : ''; + console.log(`Mean latency Δ: ${sign}${summary.mean_latency_delta_ms.toFixed(0)}ms (current vs captured)`); + if (summary.rows_over_2x_latency > 0) { + console.log(`⚠ ${summary.rows_over_2x_latency} row(s) ran more than 2× slower than captured`); + } + + if (topRegressions > 0) { + const sorted = [...results] + .filter(r => !r.skipped && !r.errored) + .sort((a, b) => a.jaccard - b.jaccard) + .slice(0, topRegressions); + if (sorted.length > 0 && sorted[0]!.jaccard < 1.0) { + console.log(`\nTop ${sorted.length} regression(s):`); + for (const r of sorted) { + const truncQuery = r.query.length > 60 ? r.query.slice(0, 57) + '...' : r.query; + console.log( + ` jaccard=${r.jaccard.toFixed(2)} captured=${r.captured_slugs.length} current=${r.current_slugs.length} ` + + `"${truncQuery}"`, + ); + } + } + } + + if (summary.rows_errored > 0) { + const errors = results.filter(r => r.errored).slice(0, 3); + console.log(`\n${summary.rows_errored} row(s) errored. First ${errors.length}:`); + for (const r of errors) { + const truncQuery = r.query.length > 60 ? r.query.slice(0, 57) + '...' : r.query; + console.log(` id=${r.id} "${truncQuery}" ${r.error_message ?? ''}`); + } + } +} + +export async function runEvalReplay(engine: BrainEngine, args: string[]): Promise { + const opts = parseArgs(args); + if (opts.help) { + printHelp(); + return; + } + if (!opts.against) { + console.error('Error: --against FILE.ndjson is required\n'); + printHelp(); + process.exit(1); + } + if (!existsSync(opts.against)) { + console.error(`Error: file not found: ${opts.against}`); + process.exit(1); + } + + let rows: CapturedRow[]; + try { + const content = readFileSync(opts.against, 'utf-8'); + rows = parseNdjson(content); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); + } + + if (rows.length === 0) { + console.error(`Error: ${opts.against} is empty (no NDJSON rows)`); + process.exit(1); + } + + const capped = opts.limit && opts.limit > 0 ? rows.slice(0, opts.limit) : rows; + if (!opts.json) { + console.error( + `Replaying ${capped.length}${capped.length < rows.length ? ` of ${rows.length}` : ''} captured queries…`, + ); + } + + const results: RowResult[] = []; + for (const row of capped) { + if (!row.query || row.query.length === 0) { + results.push({ + id: row.id, + tool_name: row.tool_name, + query: row.query ?? '', + jaccard: 0, + top1Match: false, + captured_slugs: row.retrieved_slugs ?? [], + current_slugs: [], + current_latency_ms: 0, + latency_delta_ms: 0, + skipped: true, + skip_reason: 'empty query', + }); + continue; + } + const r = await replayRow(engine, row); + results.push(r); + if (!opts.json && results.length % 25 === 0) { + process.stderr.write(` ...${results.length}/${capped.length}\n`); + } + } + + const summary = summarize(results); + if (opts.json) { + console.log(JSON.stringify({ + schema_version: 1, + summary, + results: opts.verbose ? results : undefined, + }, null, 2)); + return; + } + + const topN = opts.topRegressions ?? 5; + printHumanSummary(summary, results, topN); +} diff --git a/src/commands/eval.ts b/src/commands/eval.ts index b63fb592f..18931965c 100644 --- a/src/commands/eval.ts +++ b/src/commands/eval.ts @@ -33,6 +33,10 @@ export async function runEvalCommand(engine: BrainEngine, args: string[]): Promi const { runEvalPrune } = await import('./eval-prune.ts'); return runEvalPrune(engine, args.slice(1)); } + if (sub === 'replay') { + const { runEvalReplay } = await import('./eval-replay.ts'); + return runEvalReplay(engine, args.slice(1)); + } const opts = parseArgs(args); diff --git a/test/eval-replay.test.ts b/test/eval-replay.test.ts new file mode 100644 index 000000000..c9d79e0a8 --- /dev/null +++ b/test/eval-replay.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for `gbrain eval replay` (v0.25.0). + * + * Three layers: + * 1. Pure: Jaccard math + NDJSON parser via re-export. + * 2. CLI happy path: --against captures NDJSON → human/JSON output. + * 3. CLI failure: missing file, bad NDJSON, mismatched schema_version. + */ + +import { describe, test, expect } from 'bun:test'; +import { writeFileSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { runEvalReplay } from '../src/commands/eval-replay.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; +import type { SearchResult } from '../src/core/types.ts'; + +// Minimal stub engine — only the methods replayRow touches matter. +// Every other method is a thrower so accidental use surfaces in tests. +function makeStubEngine(returns: Record): BrainEngine { + return { + kind: 'pglite', + searchKeyword: async (q: string) => returns[q] ?? [], + searchVector: async () => [], + } as unknown as BrainEngine; +} + +function fakeResult(slug: string, score = 0.5): SearchResult { + return { + slug, + chunk_id: 1, + chunk_index: 0, + chunk_text: '', + score, + title: slug, + page_kind: 'markdown', + source_id: 'default', + } as unknown as SearchResult; +} + +function makeCapturedRow(over: Partial<{ + id: number; + tool_name: 'query' | 'search'; + query: string; + retrieved_slugs: string[]; + detail: 'low' | 'medium' | 'high' | null; + expand_enabled: boolean | null; + latency_ms: number; +}>) { + return { + schema_version: 1, + id: over.id ?? 1, + tool_name: over.tool_name ?? 'search', + query: over.query ?? 'alice', + retrieved_slugs: over.retrieved_slugs ?? ['people/alice', 'people/alice-bio'], + retrieved_chunk_ids: [], + source_ids: [], + expand_enabled: over.expand_enabled ?? null, + detail: over.detail ?? null, + detail_resolved: null, + vector_enabled: false, + expansion_applied: false, + latency_ms: over.latency_ms ?? 50, + remote: true, + job_id: null, + subagent_id: null, + created_at: '2026-04-25T00:00:00Z', + }; +} + +function withTmp(fn: (path: string) => Promise | T): Promise { + const dir = mkdtempSync(join(tmpdir(), 'eval-replay-')); + return Promise.resolve(fn(dir)).finally(() => rmSync(dir, { recursive: true, force: true })); +} + +function captureStdoutStderr(): { restore: () => { stdout: string; stderr: string } } { + // Bun's console.log doesn't route through process.stdout.write — it uses + // internal writers. Hook console directly so test capture works across + // console.log/error/warn AND raw stream writes. + let outBuf = ''; + let errBuf = ''; + const origLog = console.log; + const origErrCons = console.error; + const origWarn = console.warn; + const origStdoutWrite = process.stdout.write.bind(process.stdout); + const origStderrWrite = process.stderr.write.bind(process.stderr); + const stringify = (a: unknown) => typeof a === 'string' ? a : JSON.stringify(a); + console.log = (...args: unknown[]) => { outBuf += args.map(stringify).join(' ') + '\n'; }; + console.error = (...args: unknown[]) => { errBuf += args.map(stringify).join(' ') + '\n'; }; + console.warn = (...args: unknown[]) => { errBuf += args.map(stringify).join(' ') + '\n'; }; + process.stdout.write = ((s: string | Uint8Array) => { + outBuf += typeof s === 'string' ? s : new TextDecoder().decode(s); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { + errBuf += typeof s === 'string' ? s : new TextDecoder().decode(s); + return true; + }) as typeof process.stderr.write; + return { + restore: () => { + console.log = origLog; + console.error = origErrCons; + console.warn = origWarn; + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; + return { stdout: outBuf, stderr: errBuf }; + }, + }; +} + +describe('gbrain eval replay — happy path', () => { + test('search row with identical retrieved slugs reports Jaccard 1.0', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ + tool_name: 'search', + query: 'alice', + retrieved_slugs: ['people/alice', 'people/alice-bio'], + })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ + alice: [fakeResult('people/alice'), fakeResult('people/alice-bio')], + }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(out.schema_version).toBe(1); + expect(out.summary.rows_replayed).toBe(1); + expect(out.summary.mean_jaccard).toBe(1.0); + expect(out.summary.top1_stability_rate).toBe(1.0); + }); + }); + + test('search row with disjoint retrieved slugs reports Jaccard 0', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ + tool_name: 'search', + query: 'bob', + retrieved_slugs: ['people/bob', 'people/bob-bio'], + })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ + bob: [fakeResult('people/charlie'), fakeResult('people/charlie-bio')], + }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(out.summary.mean_jaccard).toBe(0); + expect(out.summary.top1_stability_rate).toBe(0); + }); + }); + + test('partial overlap — captured [a,b], current [a,c] → Jaccard 1/3', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ + retrieved_slugs: ['a', 'b'], + query: 'q', + })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ q: [fakeResult('a'), fakeResult('c')] }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + // {a,b} ∩ {a,c} = {a} (1), union {a,b,c} (3) → 1/3 + expect(out.summary.mean_jaccard).toBeCloseTo(1 / 3, 5); + // top-1 still matches (both lead with 'a') + expect(out.summary.top1_stability_rate).toBe(1.0); + }); + }); + + test('top-1 mismatch reduces stability rate', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ + retrieved_slugs: ['a', 'b', 'c'], + query: 'q', + })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ q: [fakeResult('b'), fakeResult('a'), fakeResult('c')] }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + // Same set, jaccard = 1.0 + expect(out.summary.mean_jaccard).toBe(1.0); + // top-1 swapped a → b, stability 0 + expect(out.summary.top1_stability_rate).toBe(0); + }); + }); + + test('multiple rows → averaged Jaccard + top-1 rate', async () => { + await withTmp(async (dir) => { + const lines = [ + JSON.stringify(makeCapturedRow({ id: 1, query: 'q1', retrieved_slugs: ['a'] })), + JSON.stringify(makeCapturedRow({ id: 2, query: 'q2', retrieved_slugs: ['b'] })), + ].join('\n') + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, lines); + + const engine = makeStubEngine({ + q1: [fakeResult('a')], // perfect match + q2: [fakeResult('z')], // miss + }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + // (1.0 + 0) / 2 = 0.5 + expect(out.summary.mean_jaccard).toBe(0.5); + // (1 + 0) / 2 = 0.5 + expect(out.summary.top1_stability_rate).toBe(0.5); + expect(out.summary.rows_replayed).toBe(2); + }); + }); + + test('--limit caps replay count', async () => { + await withTmp(async (dir) => { + const lines = [1, 2, 3, 4, 5] + .map(i => JSON.stringify(makeCapturedRow({ id: i, query: `q${i}`, retrieved_slugs: ['a'] }))) + .join('\n') + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, lines); + + const engine = makeStubEngine({}); // returns [] for everything + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--limit', '2', '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(out.summary.rows_total).toBe(2); + }); + }); + + test('empty query is skipped, not counted in replayed', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ query: '', retrieved_slugs: [] })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({}); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(out.summary.rows_skipped).toBe(1); + expect(out.summary.rows_replayed).toBe(0); + }); + }); + + test('--verbose includes per-row results in JSON', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ query: 'q', retrieved_slugs: ['a'] })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ q: [fakeResult('a')] }); + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json', '--verbose']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(Array.isArray(out.results)).toBe(true); + expect(out.results.length).toBe(1); + expect(out.results[0].jaccard).toBe(1.0); + expect(out.results[0].current_slugs).toEqual(['a']); + }); + }); + + test('row that throws during replay → errored, not crash', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ query: 'boom' })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = { + kind: 'pglite' as const, + searchKeyword: async () => { throw new Error('engine offline'); }, + searchVector: async () => [], + } as unknown as BrainEngine; + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file, '--json']); + const { stdout } = cap.restore(); + const out = JSON.parse(stdout); + expect(out.summary.rows_errored).toBe(1); + expect(out.summary.rows_replayed).toBe(0); + }); + }); + + test('human mode prints summary and top regressions', async () => { + await withTmp(async (dir) => { + const ndjson = JSON.stringify(makeCapturedRow({ query: 'q', retrieved_slugs: ['a'] })) + '\n'; + const file = join(dir, 'baseline.ndjson'); + writeFileSync(file, ndjson); + + const engine = makeStubEngine({ q: [fakeResult('z')] }); // miss + + const cap = captureStdoutStderr(); + await runEvalReplay(engine, ['--against', file]); + const { stdout, stderr } = cap.restore(); + expect(stderr).toContain('Replaying 1'); + expect(stdout).toContain('Mean Jaccard@k:'); + expect(stdout).toContain('Top-1 stability:'); + expect(stdout).toContain('regression'); + }); + }); +}); + +describe('gbrain eval replay — failure modes', () => { + test('missing --against errors and exits 1', async () => { + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, []); + } catch { /* expected */ } + cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + }); + + test('--against pointing at missing file errors and exits 1', async () => { + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, ['--against', '/tmp/nope-' + Date.now() + '.ndjson']); + } catch { /* expected */ } + cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + }); + + test('NDJSON line missing schema_version is rejected', async () => { + await withTmp(async (dir) => { + const file = join(dir, 'bad.ndjson'); + writeFileSync(file, '{"id":1,"tool_name":"search","query":"q","retrieved_slugs":[]}\n'); + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, ['--against', file]); + } catch { /* expected */ } + const { stderr } = cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + expect(stderr).toContain('schema_version'); + }); + }); + + test('schema_version === 2 (future) is rejected with helpful message', async () => { + await withTmp(async (dir) => { + const file = join(dir, 'v2.ndjson'); + writeFileSync(file, '{"schema_version":2,"id":1,"tool_name":"search","query":"q","retrieved_slugs":[],"latency_ms":0}\n'); + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, ['--against', file]); + } catch { /* expected */ } + const { stderr } = cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + expect(stderr.toLowerCase()).toContain('upgrade gbrain'); + }); + }); + + test('empty file errors and exits 1', async () => { + await withTmp(async (dir) => { + const file = join(dir, 'empty.ndjson'); + writeFileSync(file, ''); + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, ['--against', file]); + } catch { /* expected */ } + cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + }); + }); + + test('malformed JSON line is rejected with line number', async () => { + await withTmp(async (dir) => { + const file = join(dir, 'bad.ndjson'); + writeFileSync(file, '{"schema_version":1,"id":1}\nthis is not json\n'); + const origExit = process.exit; + let code: number | undefined; + process.exit = (c?: number) => { code = c; throw new Error('exit'); }; + const cap = captureStdoutStderr(); + try { + await runEvalReplay({} as BrainEngine, ['--against', file]); + } catch { /* expected */ } + const { stderr } = cap.restore(); + process.exit = origExit; + expect(code).toBe(1); + expect(stderr).toContain('line 2'); + }); + }); +});