Running real-world eval benchmarks against your modusbrain changes
Audience: modusbrain 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 modusbrain-evals, seeeval-capture.md. This doc is the human dev loop
that lives on top of that format.
v0.41 update — the LOOP is now real
Before v0.41, you could capture eval rows and replay them but nothing stitched them into a gate.modusbrain bench publish + modusbrain eval gate
close the loop. Two gates:
- Regression gate (
--baseline X.baseline.ndjson): replays a baseline you captured against your current brain. Catches: “did my refactor break search?” Compares jaccard / top-1 stability / latency multiplier. - Correctness gate (
--qrels Y.qrels.json): runs known-right queries against your current brain via barehybridSearch. Catches: “is my retrieval actually any good?” Computes recall@K, first-relevant-hit-rate, expected_top1-hit-rate.
pass. At least
one is required.
The full LOOP for your own brain
Privacy posture (D9)
Public baselines inmodusbrain-evals are hermetic-synthetic ONLY. Real
user captures stay local in ~/.modusbrain/baselines/. The boundary is
enforced at the file source, not by post-hoc scrubbing. If you publish a
baseline to modusbrain-evals, generate it from a fixture-seeded test brain
(placeholder names like alice-example, widget-co-example) — never
from a real user’s eval_candidates table.
Deterministic-pipeline disclosure
modusbrain eval gate --qrels uses bare hybridSearch (not the production
query op handler). This is deliberate: gates need to be deterministic in
CI. Production retrieval differs via the query cache, salience freshness,
expansion, etc. The gate measures retrieval quality with a fixed pipeline;
your users may see different results when the cache is warm.
.qrels.json shape
Two equivalent representations per entry:
source_id='default'):
source_id, a hit from the wrong source could false-pass the
gate. The compare everywhere is ${source_id}::${slug} strings.
Example GitHub Actions workflow
Prerequisite: turn on contributor mode
Capture is off by default for production users (privacy-positive — no surprise data accumulation). Contributors flip it on with one line:~/.modusbrain/config.json:
The 4-command loop
What it actually does
modusbrain eval replay reads your NDJSON snapshot and, for each row:
- Re-executes the same op (
searchKeywordfortool_name='search',hybridSearchfortool_name='query') with the captureddetailandexpand_enabledvalues threaded back in. - Captures the current
retrieved_slugs(deduped, in result order). - Computes set-Jaccard between captured and current slug sets.
- Records top-1 match (was the #1 result the same slug?).
- Records latency delta vs captured
latency_ms.
modusbrain eval --qrels <path> (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?”
For a third evaluation axis — public benchmark, ground-truth labels, full
question-answer pipeline (not just retrieval) — modusbrain eval longmemeval <dataset.jsonl> (v0.28.8) runs the LongMemEval benchmark against modusbrain’s
hybrid retrieval. Each question gets a clean in-memory PGLite, its haystack
imported, the question asked, the hypothesis emitted as JSONL — exactly the
shape LongMemEval’s evaluate_qa.py consumes. Your ~/.modusbrain brain is
never opened. See ## Public benchmarks: LongMemEval below.
Best-effort by design
Replay is not pure. Three things can drift between capture and replay:- 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.
- Embedding source — if you changed
OPENAI_API_KEYbetween capture and replay (or the embedding model rotated), vector-path results drift even with identical code. - Capture cap — captured
retrieved_slugsis 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.
Cost
Everyquery row in the snapshot embeds the query string via OpenAI to run
the vector half of hybridSearch. Cost is identical to a normal modusbrain 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.
CI integration
--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.tsor any embedding model swapsrc/core/operations.tsqueryorsearchop handlers (capture surface)src/core/postgres-engine.ts/pglite-engine.tssearchKeyword/searchVectorSQL
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:modusbrain 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 (modusbrain eval --qrels with the sibling
modusbrain-evals corpus).
Off-switch
Two ways to disable capture:~/.modusbrain/config.json:
eval_candidates rows stay until you modusbrain eval prune --older-than 0d (or just drop the table).
Failure modes
Public benchmarks: LongMemEval (v0.28.8)
modusbrain eval longmemeval runs the public LongMemEval
benchmark directly against modusbrain’s hybrid retrieval. Different evaluation
axis from eval replay: public dataset with ground-truth labels, end-to-end
question-answer pipeline, hermetic per-question brains.
Architecture (read this if you’re touching the harness)
- One in-memory PGLite per benchmark run via
createBenchmarkBrain+withBenchmarkBrain. Your~/.modusbrainis never opened. - Between questions:
TRUNCATEover runtime-enumeratedpg_tables, NOT a hardcoded list — schema migrations don’t silently leak data across questions. Infrastructure tables (sources,config,modusbrain_cycle_locks,subagent_rate_leases) are preserved across resets. - Sanitization parity: re-uses
INJECTION_PATTERNSfromsrc/core/think/sanitize.tsso adding a new injection pattern automatically covers takes AND benchmarks. One source of truth. - Retrieved chat content is wrapped in
<chat_session id="..." date="...">framing; the answer-gen system prompt declares the content UNTRUSTED. Same posture as<take>framing. - LLM injection seam:
runEvalLongMemEval(args, {client?: ThinkLLMClient}). Tests stub the client so the full pipeline runs hermetically without any API key.
Flags
Numbers
p50 25.9ms / p99 30.3ms warm reset+import+search on Apple Silicon (per thetest/eval-longmemeval.test.ts perf gate). Per-question cost well under the
500ms speed gate. 500 questions = ~13s of overhead plus your retrieval and
LLM latency.
Measuring brain consistency over time (v0.32.6)
modusbrain eval suspected-contradictions is a complementary measurement
instrument: it samples retrieval results for unmarked semantic
contradictions (e.g., compiled_truth vs chat content, intra-page chunk
vs active take). Where LongMemEval measures retrieval correctness on a
fixed labeled set, the contradiction probe measures how often a real
brain surfaces conflicting answers.
Recommended nightly cadence
eval_contradictions_cache) makes re-runs near-zero
cost until you bump PROMPT_VERSION. Trend-track via:
modusbrain doctor’s contradictions check with paste-ready resolution
commands per high-severity finding.
See also
docs/contradictions.md— architecture, severity rubric, action criteria.- CHANGELOG
## [0.32.6]— full release notes including the bigger-swing decision criteria gated on Wilson CI lower-bound.
v0.40.1.0 Track D — Eval infrastructure
Three eval surfaces grew non-trivial capabilities in v0.40.1.0. This section covers the dev loop that uses them and the gates they enforce.modusbrain eval longmemeval --by-type — per-question-type R@k breakdown
LongMemEval has always computed per-question-type recall internally; v0.40.1.0
surfaces it in machine-readable form. Two additive changes:
- Every per-question JSONL row now includes a
question: stringfield so themodusbrain eval cross-modal --batchconsumer (below) can read it without joining back against the source dataset. - New
--by-typeflag emits a final aggregate line keyed byquestion_type:
--resume-from is the same path as --output, the
summary is rebuilt from the file (each per-row includes question_type and
recall_hit) so the final aggregate covers all resumed questions, not just
this run’s slice. The prior summary at the file tail is replaced, not
appended — a brain that resumes 5 times across a 500-question run ends with
exactly ONE summary at the tail.
Optional gate. --by-type-floor 0.85 exits non-zero when any
question_type’s rate falls below 0.85. Default: informational only.
Hermetic retrieval gate — test/eval-replay-gate.test.ts
The v0.40.1.0 Track D structural fix for “PRs touching src/core/search/
silently regress retrieval.” Replaces the original “replay against captured
eval_candidates” design (which Codex caught as non-functional in CI — see
the v0.41+: contributor-mode CI capture TODO in TODOS.md for the deferred
real-query version).
How it works:
- Hand-curated qrels fixture at
test/fixtures/eval-baselines/qrels-search.jsonwith PLACEHOLDER names only (no real people / companies per CLAUDE.md privacy rule). - The test seeds a PGLite engine with synthetic pages whose embeddings are
basis vectors (the same
basisEmbedding(idx)pattern astest/e2e/search-quality.test.ts). No API keys, no DATABASE_URL. - For each qrels query, calls
engine.searchVector(basisEmbedding(dim))and computestop1_match_rateandrecall@10. Asserts both meet floors (>= 0.80and>= 0.85by default). - Lives in the unit-shard test matrix (
.github/workflows/test.yml) so it runs on every PR viabun test, NOT in the E2E fixed-file workflow.
Refreshing the qrels fixture (the Why: discipline, D4)
When CI fails because a legitimate ranking change moved expected slugs, the
fix is to edit qrels-search.json directly. Always include a Why: line
in the commit body so future maintainers can read the audit trail. Without
the Why:, the gate degrades to a rubber stamp within months. The convention
is informational (not a commit-hook block), but enforce it in PR review.
Example commit body:
Env-overrides for floors
modusbrain eval cross-modal --batch — batch quality scoring
Single-task cross-modal eval scores one (task, output) pair. Batch mode runs
the same scoring over an entire LongMemEval JSONL output, with cost guardrails.
- Default
--cycles 1in batch mode (single-task default is 3 in TTY) to bound cost. Pass--cycles 3to match single-task strictness. --concurrent 3runs up to 3 questions in parallel x 3 model slots each = 9 simultaneous API calls. Below tier-1 rate limits for all three providers.--max-usd FLOATrefuses to start if the pre-flight cost estimate exceeds the cap, unless--yesbypasses (required for non-interactive cron / CI).- Filters
kind: "by_type_summary"rows automatically (the LongMemEval--by-typesummary line is metadata, not a question). --batchis mutually exclusive with--task; fail-fast usage error if both are set.- Exit precedence (fail-loud): ERROR > FAIL > INCONCLUSIVE > PASS.
- Per-question receipts land in a tempdir and are deleted at end of batch; the summary inlines per-question verdicts so the audit trail is self-contained.
Nightly cross-modal quality probe (opt-in, autopilot)
src/core/cycle/nightly-quality-probe.ts ships a phase that runs the longmemeval
- cross-modal pipeline once per 24h. Disabled by default to avoid surprise API spend. Enable per-host:
--phase nightly_quality_probe wiring into the autopilot scheduler is
deferred to a v0.41+ follow-up (see TODOS.md). For now the phase is callable
in isolation; the test harness exercises it via DI stubs.
~/.modusbrain/audit/quality-probe-YYYY-Www.jsonl— one event per run with outcome (pass / fail / inconclusive / error / budget_exceeded / rate_limited / no_embedding_key), pass/fail/inconclusive/error counts, est_cost_usd, fixture_sha8. ISO-week rotation (mirrors slug-fallback audit).modusbrain doctorsurfacesnightly_quality_probe_health:- SKIPPED (disabled) — with paste-ready enable command.
- OK (enabled, no events yet) — autopilot hasn’t fired its first run.
- OK (last 7d all PASS) — with timestamp of latest run.
- WARN — any FAIL / ERROR / BUDGET_EXCEEDED in the window, with outcome counts and the latest run’s reason.