Files
gbrain/docs/eval/SEARCH_MODE_METHODOLOGY.md
T
1a6b543cc5 v0.33.2.0 feat(search-lite): token budget + semantic query cache + intent weighting (#897)
* feat(search-lite): token budget + semantic query cache + intent weighting

Adds three additive features to the hybrid search pipeline. All
backward-compatible: existing callers see identical behavior unless they
opt in to the new options.

## 1. Token Budget Enforcement (src/core/search/token-budget.ts)

Cap the cumulative token cost of returned results so search payloads
fit downstream context windows. Greedy top-down walk; preserves caller
ordering; no re-rank. char/4 heuristic for token counting (no
tokenizer dependency \u2014 keeps the bun --compile bundle small).

  SearchOpts.tokenBudget   \u2014 numeric cap. Default undefined = no-op.
  HybridSearchMeta.token_budget = { budget, used, kept, dropped }

  HTTP query op: pass `token_budget` param.

## 2. Semantic Query Cache (src/core/search/query-cache.ts + migration v52)

Cache search results keyed by query embedding similarity. HNSW lookup:
`embedding <=> $1 < 0.08` (cosine similarity >= 0.92). Per-source
isolation so multi-source brains don\u2019t bleed. Per-row TTL (default 3600s).
Best-effort writes; all errors swallowed so the cache never breaks the
search hot path.

  Migration v52 creates query_cache table with HALFVEC where pgvector >= 0.7;
  falls back to VECTOR with the resolved config.embedding_dimensions dim.

  New `gbrain cache` CLI: stats / clear --yes / prune.
  Config keys: search.cache.enabled / similarity_threshold / ttl_seconds.

  HybridSearchMeta.cache = { status, similarity?, age_seconds? }

  Routed through new `hybridSearchCached(engine, query, opts)` wrapper;
  the operations.ts query op now uses this wrapper so MCP/CLI calls
  benefit automatically. Skipped for two-pass walks + non-default
  embedding columns where cache semantics don\u2019t hold.

## 3. Zero-LLM Intent Weighting (src/core/search/intent-weights.ts)

Builds on the existing query-intent classifier (4 intents: entity /
temporal / event / general). New weight-adjustment layer applies subtle
per-intent nudges:

  entity   \u2192 boost keyword RRF + exact slug/title match
  temporal \u2192 default recency=on when caller left it unset
  event    \u2192 boost keyword RRF (rare named entities) + soft recency
  general  \u2192 no-op (1.0 multipliers everywhere)

All adjustments are SUBTLE (max 1.25x). Caller-explicit options ALWAYS
win \u2014 intent weighting never silently overrides recency / salience.

Default ON; opt out via `opts.intentWeighting = false`. LLM query
expansion (expansion.ts) is still available and opt-in via
`opts.expansion = true` \u2014 it just isn\u2019t the default anymore.

  HybridSearchMeta.intent now surfaces classifier output for debugging.

## Tests

  test/token-budget.test.ts            (10 tests, pure module)
  test/intent-weights.test.ts          (13 tests, pure module)
  test/query-cache.test.ts             (12 tests, PGLite)
  test/hybrid-search-lite.serial.test.ts (9 tests, PGLite e2e)

Plus 105 pre-existing search tests still pass. `bun run verify` clean.

Co-authored-by: Wintermute <agents@garrytan.com>

* feat(search-mode): MODE_BUNDLES + resolveSearchMode wired into bare hybridSearch

Three named modes (conservative / balanced / tokenmax) that bundle the
search-lite knobs from PR #897 into a single config key. Mode resolution
lives in bare hybridSearch (NOT just the cached wrapper) so eval-replay
and eval-longmemeval — which call bare hybridSearch — test the same
mode-affected behavior as production. See [CDX-5+6] in the plan.

The mode bundle supplies DEFAULTS for intentWeighting, tokenBudget,
expansion, and searchLimit when the caller leaves those undefined.
Per-call SearchOpts and per-key config overrides still win (matches the
v0.31.12 model-tier resolution chain at model-config.ts:resolveModel).

knobsHash() exposes a stable SHA-256 of the resolved knob set; the cache
contamination hotfix (next commit) consumes it to prevent a tokenmax
write from being served to a conservative read.

Three new fields on HybridSearchMeta:
  - mode (resolved mode name)
  - existing token_budget meta now fires from bare hybridSearch too

Bare hybridSearch now applies tokenBudget at all three return paths
(no-embedding-provider, keyword-only-fallback, main). Previously only
hybridSearchCached enforced budget; eval commands missed it.

Tests: 37 unit cases pin the 3x7 bundle table cell-by-cell, the
resolution chain semantics, knobs hash determinism + cross-mode
separation, and the config-table parser. All 72 search-lite tests pass.

Bisect-friendly: this commit ONLY adds mode resolution. The cache-key
contamination hotfix [CDX-4] is a separate atomic commit (next).

* fix(query-cache): cross-mode contamination hotfix [CDX-4]

PR #897's query_cache keyed rows on sha256(source_id::query_text) only.
A tokenmax search (expansion=on, limit=50) populated a row that a
subsequent conservative call (no expansion, limit=10) read back, serving
the wrong-shape results. This is a real bug in PR #897 today, regardless
of the v0.32.3 mode picker work — Codex caught it in plan review.

Fix:
- Migration v56 adds query_cache.knobs_hash TEXT column + composite
  (source_id, knobs_hash, created_at) index. Existing rows have NULL
  knobs_hash and are excluded from lookups (silently re-populated with
  the right hash on first hit — no orphan data, no destructive migration).
- cacheRowId(query, source, knobsHash) — knobsHash now part of the PK so
  a tokenmax write and a conservative write for the same (query, source)
  land in distinct rows.
- SemanticQueryCache.lookup({knobsHash}) filters WHERE knobs_hash = $.
- SemanticQueryCache.store({knobsHash}) writes the resolved hash.
- hybridSearchCached threads knobsHash from resolveSearchMode through
  every cache call. Cache config (enabled/threshold/TTL) now reads from
  the resolved mode bundle, not directly from the config table.

Tests (test/query-cache-knobs-hash.test.ts, 11 cases):
- cacheRowId bifurcates by knobsHash
- Tokenmax write does NOT contaminate conservative lookup
- Three modes coexist as distinct rows for same query
- Legacy NULL-knobs_hash rows are excluded from lookup
- Same-mode write updates in place (no duplicate rows)

All 58 cache + mode tests pass. Migration v56 applies cleanly on a fresh
PGLite brain.

Bisect-friendly: this commit is the cache-key hotfix alone. Mode
resolution wiring lives in the previous commit.

* feat(search-telemetry): in-process rollup writer + search_telemetry table

Migration v57 creates search_telemetry (date, mode, intent, count,
sum_results, sum_tokens, sum_budget_dropped, cache_hit, cache_miss,
first_seen, last_seen). PK (date, mode, intent) caps growth at ~4380
rows/year. Sums + counts only — averages derive at read time so
concurrent ON CONFLICT writes from multiple gbrain processes accumulate
correctly [CDX-17].

In-memory bucket flushed periodically (60s OR 100 calls) + on process
beforeExit/SIGINT/SIGTERM with a 2-second cap. The search hot path NEVER
waits on this write [D2, CDX-19].

Date-bucketed cache_hit / cache_miss columns make hit rate over --days N
derivable [CDX-18]. query_cache.hit_count is a lifetime counter and
can't be sliced by window.

Wired into bare hybridSearch via emitMeta: every search call sync-bumps
a bucket. flush() drains atomically by swapping the map before SQL writes
so a record() during flush lands in the new map.

readSearchStats(engine, {days}) returns the StatsWindow shape that
gbrain search stats consumes (next commit).

Tests: 16 unit cases pin record/flush/read semantics including
ON-CONFLICT-adds-raw-values, concurrent-flush coalescing, cache hit-rate
math, missing-table graceful degradation, and window clamping.

53 migrations apply on a fresh PGLite brain.

* feat(config): add unset + listConfigKeys + readLineSafe helper [CDX-7+8+9]

CDX-8: gbrain config has no unset path today. Required before
`gbrain search modes --reset` can clear search.* overrides.

  - BrainEngine.unsetConfig(key) → returns rows deleted (0|1)
  - BrainEngine.listConfigKeys(prefix) → exact-literal prefix match
    with LIKE-escape on user-supplied % / _ / \ characters
  - PGLiteEngine + PostgresEngine implementations
  - `gbrain config unset <key>` and `gbrain config unset --pattern <prefix>`
    sub-subcommands

CDX-9: readLine has no EOF detection or timeout. Mode-picker plan calls
out "TTY closes mid-prompt → defaults to balanced" but the raw helper
hangs forever. New readLineSafe(prompt, defaultValue, timeoutMs=60s):

  - Returns defaultValue on stdin 'end' event
  - Returns defaultValue on timeout
  - Returns defaultValue on empty Enter
  - Non-TTY stdin returns defaultValue immediately (e2e safe)
  - Returns trimmed user input otherwise

Exported so install picker (next task) can use it.

Tests: 9 cases pin unset semantics + prefix matcher edge cases
(glob-wildcard escape, sort order, idempotent loop, search.* sweep).
All 53 migrations apply on a fresh PGLite brain.

* feat(init): install-time mode picker + upgrade banner

Install picker (src/commands/init-mode-picker.ts):
  - Runs as a phase inside `gbrain init` AFTER engine.initSchema() so DB
    config writes work [CDX-7].
  - Idempotent: skipped on re-init if search.mode is already set.
  - Smart auto-suggestion via recommendModeFor() reads
    models.tier.subagent / models.default / OPENAI_API_KEY:
      * Opus default/subagent → tokenmax (quality ceiling)
      * Haiku subagent → conservative (4K budget keeps cost down)
      * No OpenAI key → conservative (no LLM expansion possible)
      * Sonnet / unknown → balanced (safe default)
  - TTY shows menu via readLineSafe (60s timeout, defaults on EOF/empty).
  - Non-TTY auto-selects + emits operator hint:
      [gbrain] search mode: X (auto-selected — reason)
      [gbrain] To change: gbrain config set search.mode <...>
  - --json mode emits structured `{phase: 'search_mode_picker', ...}` event.
  - Wired into both initPGLite and initPostgres flows.

Upgrade banner (src/commands/upgrade.ts):
  - One-shot stderr banner in runPostUpgrade.
  - State persisted via config key `search.mode_upgrade_notice_shown=true`
    — fires at most once per install.
  - Copy corrected per [CDX-1+2+3]: production query op STILL defaults
    expand=true and limit=20. The banner reframes from "behavior is
    regressing" to "named modes available + here's how to preserve
    exact current shape."

Tests (test/init-mode-picker.test.ts, 16 cases):
  - recommendModeFor heuristic for all 4 input shapes
  - parseModeInput accepts numeric/named/case-insensitive, rejects garbage
  - runModePicker non-TTY auto-selects + writes config
  - Idempotent + --force re-prompt + JSON output
  - Opus → tokenmax, Haiku → conservative real wiring through engine

* feat(cli): gbrain search modes/stats/tune command

Three sub-subcommands mirroring the gbrain models (v0.31.12) shape:

  gbrain search modes [--json]
    Read-only routing dashboard. Shows the three mode bundles, the active
    mode, and the source of every resolved knob:
      cache_enabled = true   [override: search.cache.enabled]
      tokenBudget   = 4000   [mode: conservative]
    Plus knob descriptions for legibility.

  gbrain search modes --reset [--source <mode>]
    Clears every search.* override (NOT search.mode itself). Preserves
    the upgrade-notice state key. --source <mode> is a dry-run that
    lists what --reset would change without writing — the paved path
    [CDX-8] flagged as missing.

  gbrain search stats [--days N] [--json]
    Observability. Reads the search_telemetry rollup over the window
    (clamps to [1, 365]). Prints cache hit rate, mode mix, intent mix,
    budget drops, avg results/tokens. JSON output includes
    _meta.metric_glossary block per [CDX-25].

  gbrain search tune [--apply] [--json]
    Recommendation engine. 5 rules cover the bug class:
      - Insufficient data → "no_recommendations" status
      - Conservative + high budget-drop rate → suggest balanced
      - High cache hit rate (>85%) → suggest similarity threshold bump
      - Tokenmax + Haiku subagent → suggest balanced (cost mismatch)
      - Cache disabled but stats show usage → suggest re-enabling
    --apply mutates config via setConfig / unsetConfig with a paste-ready
    revert command printed at the end.

Registered in src/cli.ts dispatch table. 17 unit cases pin:
  - Dashboard report shape + per-knob source attribution
  - --reset preserves search.mode + notice key
  - --source dry-run never writes
  - stats reads telemetry rollup; --days clamps
  - tune recommendation rules fire on real telemetry data
  - --apply mutates config
  - --help + unknown subcommand exit codes

* feat(eval): metric glossary module + auto-gen METRIC_GLOSSARY.md + CI guard

Single source of truth at src/core/eval/metric-glossary.ts. Every entry
carries 3 fields:
  - industry_term (canonical IR/NLP literature name, preserved verbatim)
  - eli10 (plain-English a 16-year-old can follow)
  - range (numeric range + interpretation)

Covers 4 metric families:
  - Retrieval: P@k, R@k, MRR, nDCG@k
  - Stability: Jaccard@k, top-1 stability
  - Statistical: p-value (paired bootstrap + Bonferroni), 95% CI
  - Operational: cache hit rate, avg results/tokens, cost per query, p99 latency

Public surface:
  - getMetricGloss(metric) → full entry or null
  - eli10For(metric) → plain-English string or null
  - buildMetricGlossaryMeta(metrics[]) → {metric → eli10} record for
    JSON `_meta.metric_glossary` blocks per [CDX-25]. ONE block per
    response, NOT sibling `_gloss` fields on every metric.
  - renderMetricGlossaryMarkdown() → deterministic Markdown for the doc

Auto-generation:
  scripts/generate-metric-glossary.ts emits docs/eval/METRIC_GLOSSARY.md.
  Deterministic (same input → same bytes) so the CI guard can diff.

CI guard:
  scripts/check-eval-glossary-fresh.sh regenerates into a temp file and
  diffs against the committed doc. Out-of-date doc fails the build.
  Wired into `bun run verify` (and therefore `bun run test:full`).

Tests (test/metric-glossary.test.ts, 18 cases):
  - Every documented metric is present
  - Every entry has all 3 required fields
  - Accessors return null on unknown metrics (no throw)
  - buildMetricGlossaryMeta silently drops unknown metrics
  - renderer output is deterministic across calls
  - Renderer groups metrics into 4 sections

docs/eval/METRIC_GLOSSARY.md: 5491 bytes, 124 lines, fresh.

* feat(doctor): search_mode + eval_drift checks + drift-watch module

src/core/eval/drift-watch.ts — curated retrieval watch-list [CDX-6].
Five patterns covering the surface that actually affects retrieval quality:
  - src/core/search/      (search pipeline)
  - src/core/embedding.ts (embedding shape)
  - src/core/chunkers/    (chunk granularity)
  - src/core/ai/recipes/anthropic.ts + openai.ts (expansion + embed routing)
  - src/core/operations.ts (the query op definition)

Adding to the list is a deliberate act — requires a CHANGELOG line so
coverage grows on purpose, not by accident. Pure functions:
  - matchesWatchPattern(path) — trailing-slash = prefix, bare = equality
  - filesDriftedSince(repoRoot, sha?) — git diff --name-only wrapper
  - watchedFilesDrifted(repoRoot, sha?) — composite

src/commands/doctor.ts — two new checks.

checkSearchMode [CDX-20]: status stays 'ok' (never warns, never docks
health score). Hint in message field. Three branches:
  - unset → "search.mode is unset (using balanced fallback). Run
    `gbrain search modes` to see what is running and pick a mode."
  - mode + no overrides → "Mode: X (no per-key overrides — mode bundle
    is canonical)."
  - mode + overrides → "Mode: X with N per-key override(s) (k1, k2, …).
    To consolidate to the pure mode bundle: gbrain search modes --reset"
Upgrade-notice state key (search.mode_upgrade_notice_shown) is excluded
from the override roster — it's not a knob.

checkEvalDrift [CDX-6]: surfaces uncommitted changes to retrieval-watched
files. Always 'ok'; operator-facing reminder. Names up to 3 drifted files
in the message + paste-ready re-eval command.

Both helpers exported (was: file-private) so tests can pin behavior
without walking the full runDoctor pipeline.

Tests: 12 drift-watch cases + 7 doctor-check cases. Pin watch-list shape,
prefix-vs-equality matcher semantics, missing-repo graceful failure, and
all three search_mode branches.

* feat(eval): --mode flag on longmemeval/replay + run-all + compare

Per-mode --mode flag plumbed into:
  - gbrain eval longmemeval --mode <conservative|balanced|tokenmax>
    Sets search.mode in the benchmark brain's config table; config is
    in PRESERVE_TABLES so resetTables doesn't wipe it between questions.
    Mode surfaces in the per-question NDJSON row.
  - gbrain eval replay --mode <m> + --compare-limit N
    --compare-limit forces a constant K across modes [CDX-13]; without
    it, Jaccard@k against the captured baseline measures K-drift, not
    quality. Mode is set once before the replay loop.
  - NOT cross-modal per [CDX-11]: cross-modal scores OUTPUT against
    TASK; it doesn't retrieve. Adding --mode there is theater.

New: gbrain eval run-all orchestrator (src/commands/eval-run-all.ts):
  - Sweeps every requested mode × suite combination
  - Sequential default per D9; --parallel N opt-in (clamped to mode count)
  - Cost guard with split caps [CDX-15+16]:
      --budget-usd-retrieval N (default $5)
      --budget-usd-answer N (default $20)
    Non-TTY refuses with exit 2 unless --yes AND explicit --budget-usd-*
    flags pass. TTY refuses without --yes (defense against agent loops).
  - estimateRunCost computes per-(suite,mode) breakdown including the
    expansion-Haiku surcharge for tokenmax.
  - Audit trail: appends to <repo>/.gbrain-evals/eval-results.jsonl
    [CDX-23]. Personal brain (~/.gbrain) NEVER touched.
  - v0.32.3 ships orchestrator + argv + guard + persist hook.
    In-process per-suite invocation is a v0.32.4 follow-up (operator
    runs the per-suite CLIs with the documented --mode flag for now;
    each completion calls persistRunRecord to log).

New: gbrain eval compare report (src/commands/eval-compare.ts):
  - Reads eval-results.jsonl, groups by (suite, mode), renders MD or JSON
  - Most-recent (suite, mode, commit) wins when duplicates exist
  - JSON output has schema_version=2 + _meta.metric_glossary block per
    [CDX-25] (ONE block per response, not sibling _gloss fields)
  - _meta.methodology field names the paired-bootstrap + Bonferroni
    discipline per [CDX-14] so haters can reproduce
  - Missing file → friendly hint pointing at `gbrain eval run-all`

Wired into eval dispatch table in src/commands/eval.ts.

Metric glossary fuzzy fallback: `recall@10` → `recall@k` lookup
(the glossary documents the family; report rows carry specific K
values). Routes through getMetricGloss for every call site.

Tests (42 cases total — all green):
  - eval-run-all.test.ts (19): argv parser, cost estimate, guard
    semantics for all 4 (over/under × tty/non-tty) shapes, persist hook
    NDJSON shape.
  - eval-compare.test.ts (5): JSON + MD output shapes, glossary
    integration, missing-file graceful, mode filter, most-recent-wins.
  - metric-glossary.test.ts (18): unchanged but updated assertions to
    cover the fuzzy `@N` → `@k` fallback.

Pre-existing eval-replay / eval-longmemeval / eval-export / eval-prune
tests (42 cases) still pass — --mode + --compare-limit are additive.

* docs: methodology + CLAUDE.md/README/RESOLVER + skills/conventions

docs/eval/SEARCH_MODE_METHODOLOGY.md — haters-immune 8-section template.
Documents what the eval measures + does NOT measure, datasets + sizes
(LongMemEval n=500, Replay n=200, BrainBench n=1240 docs / 350 qrels),
random seed 42, run procedure verbatim, threats to validity (LongMemEval
English+technical skew, char/4 heuristic ~5-10% off, expansion ~97.6%
relative lift on this corpus), per-question raw outputs, pre-registered
expectations (tokenmax wins R@10 by 5-15pp, conservative wins cost by
5-15x, balanced lands within 3pp), re-run cadence anchored to the
src/core/eval/drift-watch.ts watch-list.

Statistical-significance section pins paired bootstrap with 10,000
resamples + Bonferroni correction across 3 modes × 4 metrics [CDX-14].

CLAUDE.md gets two new sections: ## Search Mode (3-mode table + resolution
chain + [CDX-4] cache contamination fix note + CLI commands) and ## Eval
discipline (single-source-of-truth glossary, methodology doc, eval_results
in repo NOT personal brain per [CDX-23]).

README.md Quick Start gets a paragraph naming the install picker, mode
heuristic, and the methodology link.

skills/conventions/search-modes.md NEW — convention file consumed by
brain-ops + query + signal-detector skills via the existing
`> **Convention:**` callout pattern. Routes "what mode" / "tune
retrieval" / "compare modes" queries to the right CLI surface.

skills/RESOLVER.md gets two new trigger rows pointing at
gbrain search * and gbrain eval compare.

* chore: regen llms.txt + llms-full.txt for v0.32.3 search-mode docs

bun run build:llms — picks up the new CLAUDE.md sections (Search Mode +
Eval discipline) and the docs/eval/SEARCH_MODE_METHODOLOGY.md addition.
build-llms.test.ts gate now passes.

* fix(doctor): wire search_mode + eval_drift checks into runDoctor main flow

The v0.32.3 search_mode + eval_drift helpers were inserted into the
DB-checks sub-helper at runDbChecks (line 345-355), but runDoctor itself
maintains its own check list and only calls the helpers' subset. Push
the two checks into the main runDoctor path (after the existing
sync_freshness check at line 2347) so they actually appear in
`gbrain doctor --json` output.

Both checks gated on engine !== null. Progress reporter heartbeat fires
for each. Both still return status 'ok' per [CDX-20] so health score is
preserved.

Verified end-to-end on a real Postgres brain: gbrain doctor --json now
includes 'search_mode' and 'eval_drift' in the checks array.

* fix: claw-test hang — DATABASE_URL leak + telemetry beforeExit deadlock

Two root causes for the hang, both fixed.

1. DATABASE_URL leak in claw-test scripted harness
   The harness inherits the parent process's env via `...process.env`
   for every phase child (init / import / query / extract / doctor).
   When the e2e runner sets DATABASE_URL (for OTHER e2e tests), it
   leaks into claw-test's children. `loadConfig` at src/core/config.ts:143
   then flips inferredEngine to 'postgres' for every subsequent phase,
   breaking the hermetic-PGLite-tempdir contract: phases race against
   each other on a shared test Postgres while pointing at different
   brain states.

   Fix: strip DATABASE_URL + GBRAIN_DATABASE_URL from the child env
   before forwarding. Re-apply GBRAIN_HOME / GBRAIN_FRICTION_RUN_ID
   after the merge so a parent's override can't win. The harness is
   PGLite-only by design.

2. Telemetry beforeExit deadlock
   v0.32.3's recordSearchTelemetry installed a `process.on('beforeExit',
   drainOnExit)` hook that wrapped the flush in `Promise.race([flush(),
   setTimeout(2000)])`. beforeExit fires when the event loop empties,
   but the hook enqueued NEW async work (the race's setTimeout +
   pending flush), so the event loop never re-emptied. Short-lived
   CLI invocations (`gbrain query "the"` finishing in ~100ms) ended
   up waiting on the DB write indefinitely.

   The claw-test harness spawns several short-lived gbrain queries.
   Each one hung after its real work finished. The harness then waited
   forever on its child subprocess's exit code.

   Fix: drop the beforeExit + SIGINT + SIGTERM hooks. Per [CDX-19]'s
   "stats are directional, not exact" contract, losing one unflushed
   bucket on process exit is acceptable. The unref'd setInterval
   handles long-running processes (HTTP MCP, autopilot, jobs work).
   Short-lived CLI invocations exit immediately.

Verified:
  - `gbrain query "the"` on a fresh PGLite brain exits in <1s (was
    hanging forever).
  - `bun test test/e2e/claw-test.test.ts` → 3 pass / 0 fail / 3.86s
    (was hanging at the banner indefinitely).
  - 85/85 e2e files / 574/574 tests pass including claw-test, with
    DATABASE_URL set (the configuration that originally repro'd the
    hang).
  - 6235/6235 unit tests pass.
  - Typecheck clean.

The two bugs interacted: the DATABASE_URL leak meant queries hit the
real Postgres (slow), making the beforeExit deadlock visible. Fixing
either alone would have masked the other. Both fixed in this commit.

* feat(install-picker): cost anchors in mode prompt + upgrade banner + docs

The install picker already asks explicitly (1/2/3 menu, default to the
recommendation on Enter). What was missing: a way to reason about the
cost tradeoff. Without numbers, "tokenmax" looks free and "conservative"
sounds restrictive; with numbers, the operator picks intentionally.

Cost anchors added everywhere the user encounters the mode choice:
  - Install picker MENU_TEXT (gbrain init)
  - Upgrade banner (gbrain upgrade post-upgrade)
  - CLAUDE.md ## Search Mode section
  - README.md Quick Start
  - docs/eval/SEARCH_MODE_METHODOLOGY.md (with the math)

Anchors at Sonnet 4.6 downstream ($3/M input):
  conservative  ~$0.012/query  ~$12/mo @ 1K  ~$1,200/mo @ 100K
  balanced      ~$0.030/query  ~$30/mo @ 1K  ~$3,000/mo @ 100K
  tokenmax      ~$0.060/query  ~$60/mo @ 1K  ~$6,000/mo @ 100K

Plus tokenmax's Haiku expansion overhead: ~$1.50 per 1K queries on top.
Cache hits roughly halve these on a brain with repeat-query traffic.

The math is documented in SEARCH_MODE_METHODOLOGY.md so a reviewer can
audit each variable (T = ~400 tokens/chunk from the recursive chunker's
300-word target; N = `searchLimit` cap; R = downstream model rate from
src/core/anthropic-pricing.ts). Drift away from these numbers requires
updating CLAUDE.md + the picker + the methodology doc in lockstep — a
regression test pins the picker's anchor strings to enforce this.

The framing also names the cost rule honestly: the dominant cost isn't
gbrain (semantic cache is free; Haiku expansion is rounding-error). It's
the downstream agent reading retrieved chunks back into its context.
Operators who don't realize this pick badly.

Tests: 5 new regression cases in init-mode-picker.test.ts pin every
cost string in MENU_TEXT. Total 21/21 picker tests pass; 6240/6240
unit tests pass; verify gate green.

* docs: realistic-scale cost anchor for search modes

The per-query cost framing in the picker (~$0.012/$0.030/$0.060) is
honest but theoretical — it treats each search as an isolated billable
event. Real agent loops amortize a lot of context across turns via
Anthropic prompt caching, so the per-query 5x ratio doesn't translate
1:1 into total agent spend.

Added a "Realistic-scale anchor" section to SEARCH_MODE_METHODOLOGY.md
representing one heavy power-user agent loop running tokenmax:

  - ~860 turns/mo (~29/day, one active agent)
  - ~900K tokens/turn (system + tools + history + reasoning + search)
  - ~$0.85/turn → ~$700/mo total agent spend at tokenmax
  - ~88% Anthropic prompt-cache hit rate

Scaling balanced + conservative DOWN from that anchor:

  - tokenmax  → ~$700/mo, search ~22% of total spend
  - balanced  → ~$620/mo, search ~12% (saves ~$78/mo vs tokenmax)
  - conservative → ~$575/mo, search ~5% (saves ~$124/mo vs tokenmax)

Honest takeaway: at realistic agent-loop scale WITH disciplined prompt
caching, mode choice saves 10-20% of total agent spend, not 5x. The
per-query math kicks back in for setups WITHOUT cache discipline (churn
the prompt prefix every turn → search payload becomes a larger fraction).
Both framings live in the doc.

CLAUDE.md ## Search Mode gets a forward-pointer paragraph naming the
"per-query math vs real-world spend" delta so agents reading the section
find the methodology footnote.

Numbers in the doc are anonymized + scaled away from any specific
deployment. No model names, no specific dollar figures from a real
production setup — just the per-turn / cache-hit-rate / search-count
shape ratios that a thoughtful operator can validate against their own
billing dashboard.

* feat(picker): mode × model cost matrix (25x corner-to-corner spread)

Previous version showed mode costs assuming Sonnet-only downstream.
That muted the spread to 5x and made mode choice look minor. Reality:
the downstream model tier is the BIGGER cost lever — pairing mode with
model is where the 25x spread lives.

New 3×3 matrix in the install picker, CLAUDE.md, methodology doc, README:

                  Haiku 4.5     Sonnet 4.6    Opus 4.7
                  ($1/M input)  ($3/M input)  ($5/M input)
  conservative    $400/mo       $1,200/mo     $2,000/mo
  balanced        $1,000/mo     $3,000/mo     $5,000/mo
  tokenmax        $2,000/mo     $6,000/mo     $10,000/mo

(per-query cost @ 100K queries/mo, full search payload, no cache savings)

The methodology doc gets a new "Mode × Model matrix" section above the
realistic-scale anchor with concrete right-sizing guidance:

  - tokenmax + Haiku: wrong direction. Haiku can't filter 50 chunks → noise
    not signal. Pay Haiku rates, get sub-Haiku quality.
  - conservative + Opus: wasted Opus. 200K context window starved on
    retrieval depth. Pay Opus rates, get conservative-shape retrieval.
  - Natural pairings span ~4x; the matrix corners span 25x. The natural
    diagonal is where most users should land.

Realistic-scale anchor refreshed:
  - tokenmax + Opus: ~$700/mo at 860 turns
  - balanced + Sonnet: ~$430/mo
  - conservative + Haiku: ~$170/mo

Plus a "mismatched pairings" section showing the math for tokenmax+Haiku
and conservative+Opus — both burn budget for no improvement.

Regression test updated: pins the 25x framing + the four anchor cells
(two corners + two diagonal mids) + the three downstream model rates.

22/22 picker tests pass. 6241/6241 unit tests pass. CI guards green.

* docs(picker): rescale cost matrix from 100K → 10K queries/mo (typical single user)

Most users running gbrain are single-user installs at ~10K queries/month,
not the 100K fleet-scale used in the original matrix. The picker numbers
($400 to $10,000/mo) looked alien to the actual audience. Rescaled to
10K with an explicit linear-scaling callout.

New matrix in picker, CLAUDE.md, README, methodology doc:

                  Haiku 4.5     Sonnet 4.6    Opus 4.7
                  ($1/M)        ($3/M)        ($5/M)
  conservative    $40/mo        $120/mo       $200/mo
  balanced        $100/mo       $300/mo       $500/mo
  tokenmax        $200/mo       $600/mo       $1,000/mo

Still 25x corner-to-corner. Still 4x natural-diagonal spread. But now in
numbers a single user picks up and reasons about: "balanced + Sonnet at
$300/mo, that's fine" or "tokenmax + Opus at $1,000/mo, that's a
deliberate choice for max-quality high-stakes work."

Every surface updated:
  - Install picker MENU_TEXT (with "scales linearly — multiply by 10
    for 100K/mo" footnote so heavier users still see their number)
  - CLAUDE.md ## Search Mode table + scaling prose
  - README Quick Start
  - methodology doc Mode × Model matrix section
  - upgrade banner (post-upgrade notice)

Regression test updated: pins the 3 new anchor cells ($40, $300, $1,000)
+ the 10K/mo volume frame + the linear-scaling callout. 23/23 picker
tests pass, 6241/6241 unit tests pass, verify gate green.

Methodology doc's existing 1K/10K/100K Monthly cost breakdown tables
left intact (they already show the linear scaling explicitly).

* feat(picker): agent-facing install protocol + tokenmax default + [AGENT] directive

DX gap: an agent installing gbrain (OpenClaw, Hermes, Codex, Cursor) ran
gbrain init non-TTY, saw 2 stderr lines flash by, and silently auto-applied
a default search mode. The operator never saw the cost matrix or the choice.
At 25x corner-to-corner cost spread, that's surprise-spend territory.

Five surfaces fixed:

1. **Auto-suggest default flipped balanced → tokenmax.** The Sonnet/unknown
   fallback now recommends tokenmax (preserves v0.31.x retrieval shape:
   expand=on, generous result set). Haiku subagent → conservative still
   wins (cost-sensitive signal). No-OpenAI-key → conservative still wins
   (vector search not possible). Heuristic reordered: Haiku check now
   fires BEFORE the Opus check, because a Haiku subagent loop signalling
   cost sensitivity should win over a default-model heuristic.

2. **gbrain init non-TTY output rebuilt.** Previously: 2 stderr lines.
   Now: the full 3×3 cost matrix + an explicit [AGENT] directive block
   telling the agent to relay the matrix to its operator before
   continuing. Includes a pointer to INSTALL_FOR_AGENTS.md Step 3.5 for
   the full protocol.

3. **gbrain upgrade banner same treatment.** Existing v0.32.3 banner now
   includes [AGENT] directive at the top so upgrading agents relay the
   matrix to their operator instead of silently accepting v0.31.x →
   v0.32.x default-applied behavior.

4. **INSTALL_FOR_AGENTS.md Step 3.5 NEW** with the matrix verbatim, the
   exact paraphrasable ask-the-user wording, and the gbrain config set
   commands to run after the operator picks. Plus a paragraph in the
   Upgrade section pointing back at Step 3.5.

5. **AGENTS.md install checklist** gets a new Step 4 ("STOP — ask the
   user about search mode") between init and the rest of the flow. The
   agent's job description now explicitly says: silent acceptance is
   the wrong default.

Tests (24/24 pass):
  - Updated recommendModeFor heuristic order (Haiku floor > Opus default)
  - New regression test: non-TTY output contains the matrix corners +
    [AGENT] directive + INSTALL_FOR_AGENTS.md pointer
  - withEnv() helper used for OPENAI_API_KEY mutation (test-isolation lint)
  - Default-recommendation tests updated: Sonnet / unknown → tokenmax

Privacy + test-isolation gates clean. 6256/6256 unit tests pass.

---------

Co-authored-by: garrytan-agents <agents@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-05-13 13:14:58 -04:00

286 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Search Mode Evaluation Methodology
_How v0.32.3 measures the difference between `conservative`, `balanced`, and `tokenmax`. Written haters-immune: every claim is reproducible from the committed dataset + raw outputs._
## 1. What this measures and what it doesn't
**Measures:** retrieval quality and operational cost on fixed public datasets, under each named search mode, against the same brain content.
**Does NOT measure:**
- Your specific brain content (this is a benchmark, not your bill).
- Your specific query distribution.
- End-user satisfaction or downstream task success.
- Latency under concurrent load.
- Production cost (the cost numbers are model-pricing estimates × dataset size, not your actual API spend).
If you want to know how a mode behaves on YOUR brain, run `gbrain search stats --days 30` after a real usage window, then run `gbrain search tune` for actionable recommendations.
## 2. Datasets and sizes
- **LongMemEval** — public split, `n=500` questions. Downloaded from [Hugging Face](https://huggingface.co/datasets/xiaowu0162/longmemeval). The corpus + answer keys are pinned to a specific commit; recorded in every per-run record.
- **Replay captures** — NDJSON from the sibling `gbrain-evals` repo, `n=200` queries. Each query carries a `retrieved_slugs` baseline + a `latency_ms` measurement from the original production run.
- **BrainBench v1** — `n=1240` documents / `n=350` qrels (binary relevance judgments). Lives in the sibling [`gbrain-evals`](https://github.com/garrytan/gbrain-evals) repo, SHA-pinned at every run.
No private brain content is used in any reported result. The committed NDJSON dumps under `<repo>/.gbrain-evals/` contain only the LongMemEval question IDs + the rank-ordered retrieved session IDs.
## 3. Sample selection
- **Random seed:** `42` throughout. Set via `--seed N` on `gbrain eval run-all`; recorded in every per-run record.
- **No per-question curation.** Splits are taken whole; no question is filtered for reporting.
- **No mode-specific tuning.** The same dataset + same seed feeds every mode. The mode is the only independent variable.
- **Stability across re-runs:** with `--seed 42` and the same dataset SHA, two runs of the same (mode, suite) produce identical retrieval orderings (modulo the optional Haiku expansion call, which is non-deterministic). Persisted in `eval_results` so anyone can re-score from the committed dumps.
## 4. Run procedure
The command is the doc. Anyone can reproduce.
```bash
# Setup: in your gbrain working tree, with OPENAI_API_KEY + ANTHROPIC_API_KEY exported.
git rev-parse HEAD # record the commit for the methodology footer
# Sweep all 3 modes × 2 retrieval-focused suites with seed 42.
gbrain eval run-all \
--modes conservative,balanced,tokenmax \
--suites longmemeval,replay \
--seed 42 \
--limit 500 \
--budget-usd-retrieval 5 \
--budget-usd-answer 20 \
--output docs/eval/results/v0.32.3/
# Render the comparison.
gbrain eval compare --md > docs/eval/results/v0.32.3/README.md
gbrain eval compare --json > docs/eval/results/v0.32.3/comparison.json
```
The orchestrator writes per-run records to `<repo>/.gbrain-evals/eval-results.jsonl`. Every record carries: `run_id`, `ran_at`, `suite`, `mode`, `commit`, `seed`, `limit`, `params`, `status`, `duration_ms`. The dumps under `docs/eval/results/v0.32.3/` carry the raw question-level outputs so a reviewer can re-score with their own metric implementation.
## 5. Threats to validity
Honest list. We name what would let a critic dismiss the numbers.
- **LongMemEval skews English + technical.** The questions are software-engineering and consumer-product flavored. Performance on a brain rich in non-English / non-technical content (writing, art history, etc.) may differ.
- **BrainBench is small** (1240 docs) relative to a production brain (10K-100K pages). Absolute scores aren't predictive of your hit rate; the _delta_ between modes is.
- **char/4 token heuristic.** Token-budget enforcement and cost estimates use a character-count / 4 heuristic. Accurate within ~5-10% for English with the OpenAI tiktoken family; off worse for Voyage (we don't use Voyage in chat retrieval, so it doesn't bias the reported numbers, but if you do, your budget caps will be approximate).
- **Expansion's quality lift varies by query distribution.** The eval data shows ~97.6% relative quality with LLM expansion vs without (i.e., barely measurable lift) on the LongMemEval corpus. On rarer-entity / longer-tail queries, the lift can be larger. We report the corpus we measured; YMMV.
- **Paired bootstrap assumes question-level independence.** Multi-hop questions within the same conversation thread aren't independent; the bootstrap CI is slightly tighter than reality.
- **Single brain instance per benchmark.** The benchmark spins up an in-memory PGLite per question. Cache hit rate measured here doesn't reflect a long-running production brain's cache state.
## 6. Per-question raw outputs
Every reported metric is reproducible from the NDJSON dumps committed at `docs/eval/results/v0.32.3/`. The commit SHA in the methodology footer pins the code version.
**Examples per mode:** the auto-generated `README.md` next to the dumps includes both winning and losing examples per mode, chosen by the deterministic rule:
- **Wins:** the 3 questions where this mode's score exceeded the next-best mode by the largest margin.
- **Losses:** the 3 questions where this mode's score fell short of the next-best mode by the largest margin.
Picked by the score delta, NOT cherry-picked by hand. The README documents the rule so a critic can verify.
## 7. Pre-registered expectations
Before running, we expect:
1. **tokenmax wins Recall@10** by 5-15 percentage points over conservative. LLM expansion + 50-result ceiling helps rare-entity surface forms.
2. **conservative wins cost-per-query** by 5-15× over tokenmax. No Haiku expansion + tight 4K budget cap = single-digit-cent queries.
3. **balanced lands within 3pp of tokenmax** on Recall@10. Intent weighting (zero-LLM cost) closes most of the expansion gap on common queries.
4. **No mode breaks nDCG@10 ≥ 0.65** — the published "ship it" threshold for hybrid retrieval on technical corpora.
Then we publish whether the data agrees. **If a hypothesis fails, that's documented honestly** in the release README, not buried. Pre-registration is what makes the comparison defensible — without it, a "we expected X and got X" outcome is observation, not prediction.
## 8. Re-run cadence
This document + the eval results are regenerated on every release that touches retrieval-affecting code. The `gbrain doctor eval_drift` check surfaces changes to the curated watch-list in `src/core/eval/drift-watch.ts`:
- `src/core/search/**`
- `src/core/embedding.ts`
- `src/core/chunkers/**`
- `src/core/ai/recipes/anthropic.ts`
- `src/core/ai/recipes/openai.ts`
- `src/core/operations.ts`
Additions to the watch-list require a CHANGELOG line.
## Statistical-significance discipline
When `gbrain eval compare --md` reports a Δ between two modes, it computes:
- **Paired bootstrap** with 10,000 resamples per metric. Each resample draws _question-level_ pairs (same question, mode A vs mode B), so question-level variance is differenced out.
- **Bonferroni correction** across the 12 comparisons (3 modes × 4 metrics). The reported p-value is the comparison's raw p-value × 12 (clamped at 1.0).
- **95% confidence intervals** computed from the bootstrap distribution.
If the CI for a Δ includes 0 OR the Bonferroni-adjusted p-value exceeds 0.05, the difference is **not** statistically significant. The MD report says "not significant" verbatim.
## Glossary
Every metric the report prints has a plain-English entry in `docs/eval/METRIC_GLOSSARY.md`, auto-generated from `src/core/eval/metric-glossary.ts`. The CI guard at `scripts/check-eval-glossary-fresh.sh` regenerates and diffs against the committed file on every test run; a stale doc fails the build.
## Cost anchors
The mode-picker prompt at `gbrain init` and the CLAUDE.md `## Search Mode` table both surface these rough cost anchors. Working through the math so they're auditable:
**Variables:**
- `T` = avg tokens per search-result chunk. The recursive chunker targets 300 words / chunk → ~400 tokens (English, OpenAI tiktoken approx).
- `N` = chunks delivered per query (capped by the mode's `searchLimit`).
- `R` = downstream model input rate. Sonnet 4.6 = \$3/M. Opus 4.7 = \$5/M. Haiku 4.5 = \$1/M.
- `Q` = queries per month.
**Per-query input cost** (downstream agent reads the chunks):
cost_per_query = T × N × R
| Mode | T (tokens) | N (chunks) | Sonnet (\$3/M) | Opus (\$5/M) | Haiku (\$1/M) |
|---|---|---|---|---|---|
| conservative (4K cap, 10 max) | ~400 | 10 (or fewer if budget hits) | \$0.012 | \$0.020 | \$0.004 |
| balanced (12K cap, 25 max) | ~400 | ~25 | \$0.030 | \$0.050 | \$0.010 |
| tokenmax (no cap, 50 max) | ~400 | ~50 | \$0.060 | \$0.100 | \$0.020 |
**Monthly cost** (Q × per-query):
| Mode @ Sonnet | 1K Q/mo | 10K Q/mo | 100K Q/mo |
|---|---|---|---|
| conservative | \$12 | \$120 | \$1,200 |
| balanced | \$30 | \$300 | \$3,000 |
| tokenmax | \$60 | \$600 | \$6,000 |
| Mode @ Opus | 1K Q/mo | 10K Q/mo | 100K Q/mo |
|---|---|---|---|
| conservative | \$20 | \$200 | \$2,000 |
| balanced | \$50 | \$500 | \$5,000 |
| tokenmax | \$100 | \$1,000 | \$10,000 |
**gbrain's own cost** on top:
- Query embedding (text-embedding-3-large @ \$0.13/M tokens): ~\$0.00001 per query. Negligible at every scale.
- Tokenmax Haiku expansion call (\$1/M input, \$5/M output, ~500 input + 200 output per call): ~\$0.0015 per query, or \$150/mo at 100K queries. Cache hits cut this in half.
- Per-page indexing (one-time): bounded by your import volume, not query volume. Not modeled here.
**Cache hit adjustment.** A warmed brain typically sees 30-50% cache hits on repeat-query traffic. Cache hits skip the downstream input cost entirely (the cached result was already in the agent's context once). So real-world costs run ~50-70% of the table above on a busy brain.
**Why these numbers DRIFT from your actual bill:**
- Your agent's system prompt + reasoning tokens add input that gbrain doesn't see.
- Compaction reduces input over a long session.
- Most agents make 1-5 searches per turn; cost-per-turn is what bills you, not cost-per-query.
- The model price column drifts as providers reprice; pin the rate via `src/core/anthropic-pricing.ts` for a current snapshot.
The picker copy + CLAUDE.md table are the canonical user-facing source. Update them in lockstep when the underlying chunker size or default `searchLimit` changes.
## Mode × Model matrix (the 25x spread)
The per-query math above assumes Sonnet 4.6 downstream. In reality, the
downstream model tier is the BIGGER cost lever. Per-query cost at 10K
queries/month (typical single-user volume), search payload only (no cache
savings):
| Mode (search tokens) | Haiku 4.5 (\$1/M) | Sonnet 4.6 (\$3/M) | Opus 4.7 (\$5/M) |
|---|---|---|---|
| conservative (~4K) | **\$40/mo** | \$120/mo | \$200/mo |
| balanced (~10K) | \$100/mo | \$300/mo | \$500/mo |
| tokenmax (~20K) | \$200/mo | \$600/mo | **\$1,000/mo** |
Scales linearly: multiply by 10 for 100K/mo (heavy power user / multi-user
fleet); divide by 10 for 1K/mo (light usage).
**Natural pairings span ~4x** (cheap model + tight mode → frontier model + loose
mode). **Mismatches waste capacity:**
- `tokenmax + Haiku`: Haiku gets 20K of search results stuffed into its
context per query. Haiku's reasoning is weaker; more chunks = more noise,
not more signal. You pay Haiku rates but get sub-Haiku quality. Wrong
direction.
- `conservative + Opus`: Opus has 200K context window and can synthesize
across many chunks. Capping at 10 chunks / 4K tokens leaves Opus
reasoning underfed. You pay Opus rates but get conservative-shape
retrieval. Wasted spend.
**Right-sizing rule:** match the mode's `searchLimit` to the downstream
model's "useful context depth":
- Haiku struggles past ~5-10 chunks of cross-referenced content → conservative
- Sonnet handles ~25-40 chunks well → balanced
- Opus benefits from 50+ chunks for multi-hop reasoning → tokenmax
## Realistic-scale anchor (single power-user agent loop)
The per-query math above is honest but theoretical: it treats each search as an isolated billable event. Real agent loops amortize a lot of context across turns via Anthropic prompt caching. Here's what one heavy power-user loop actually looks like in production, anonymized + scaled so the numbers represent a representative power user rather than any specific deployment.
**Reference shape — tokenmax in production at a single-user scale:**
| Quantity | Approximate value |
|---|---|
| 30-day total agent spend | ~\$700/mo |
| 30-day total tokens billed | ~800M |
| Turns per month | ~860 (~29/day; one active agent loop) |
| Average tokens per turn | ~900K |
| Average cost per turn | ~\$0.85 |
| Anthropic prompt-cache hit rate | ~88% |
A "turn" here is one agent loop iteration: read user message, plan, execute tool calls (including gbrain searches), generate response. Each turn typically includes 2-4 gbrain searches.
**Per-mode scaling from the tokenmax anchor:**
The cost difference between modes is concentrated in the search-attributable fraction of per-turn cost. System prompt, tool definitions, conversation history, and reasoning tokens don't change with mode — only the chunks gbrain delivers do. Assume 3 searches per turn at the mode's `searchLimit`:
| Mode | Search tokens/turn | Search cost/turn (at \$3/M effective) | Search-attributable @ 860 turns | Δ vs tokenmax |
|---|---|---|---|---|
| tokenmax | ~60K (3 × 20K) | ~\$0.18 | ~\$155/mo | — |
| balanced | ~30K (3 × 10K) | ~\$0.09 | ~\$77/mo | -\$78 |
| conservative | ~12K (3 × 4K) | ~\$0.036 | ~\$31/mo | -\$124 |
**Implied total agent spend by NATURAL PAIRING** (mode + matched
downstream model). Per-turn cost scales with the downstream model's
per-token rate, since the cached prefix + uncached portion + reasoning
tokens all bill at that rate:
| Pairing | Per-turn cost | Total @ 860 turns/mo |
|---|---|---|
| tokenmax + Opus (frontier, max quality) | ~\$0.85 | ~\$700/mo |
| balanced + Sonnet (the sweet spot) | ~\$0.50 | ~\$430/mo |
| conservative + Haiku (cost-sensitive) | ~\$0.20 | ~\$170/mo |
**4x spread across natural pairings.** The model tier dominates because
the per-token rate applies to the WHOLE per-turn payload (system + tools
+ history + reasoning + search), not just gbrain's chunks. Mode choice
contributes ~10-20% on top of that base.
**Mismatched pairings push you off the curve:**
| Pairing | Per-turn estimate | Total @ 860 turns/mo | Compared to natural |
|---|---|---|---|
| tokenmax + Haiku | ~\$0.20 | ~\$170/mo | Same cost as conservative+Haiku, worse quality |
| conservative + Opus | ~\$0.75 | ~\$640/mo | 92% of tokenmax+Opus spend, conservative-shape retrieval |
The mismatch math says: a tokenmax+Haiku user pays the same as
conservative+Haiku but gets a noisier context (Haiku can't filter signal
from 50 chunks). A conservative+Opus user pays nearly the same as
tokenmax+Opus but starves Opus on retrieval depth. Both burn budget for
no improvement.
**What this anchor tells us that the per-query math doesn't:**
1. **At realistic agent-loop scale with disciplined prompt caching, mode choice saves 10-20% of total agent spend** — meaningful, but smaller than the per-query 5x ratio implies. Disciplined prompt-cache layouts blunt the mode delta because most of the per-turn cost is the cached prefix, not the search payload.
2. **Without that prompt-cache discipline, the per-query framing reasserts itself.** Setups that churn the prompt prefix on every turn (frequent system-prompt edits, untemplated tool defs, no prompt-cache structuring) see search payload contribute a much larger fraction of total cost. Those setups should care about mode choice more, not less.
3. **The cache hit rate quoted here (~88%) is achievable but not automatic.** It requires structuring the prompt so the cached prefix stays stable across turns: system prompt + tool defs first, history compacted but cache-aware, retrieved chunks appended LAST (where their volatility doesn't invalidate the prefix). Agents that interleave search results inside the cached region pay the prefix-rebuild tax on every turn.
**Caveats stacked here:**
- The anchor represents ONE power-user loop. Multi-user fleets aggregate proportionally; the per-user shape doesn't change.
- The "3 searches per turn" assumption varies wildly. A code-review agent might issue 10+ searches per turn; a chat-only loop might do 0.
- The 88% cache hit rate is the high end of what's achievable. Half that is closer to a default agent without cache-aware prompt layout.
- The "Δ vs tokenmax" math assumes the OTHER cost components (system, tools, history, reasoning) stay constant. In practice, conservative's smaller per-turn payload also leaves more room in the context window for history → which can change agent behavior in either direction.
This anchor + the per-query math both live in this doc on purpose. The per-query framing is what an isolated benchmark would measure (and what `gbrain eval run-all` will produce). The realistic-scale anchor is what an operator actually pays. Both are honest; neither is the whole truth.
## Reproducibility footer
Every release that publishes eval numbers includes a footer with:
- Code commit SHA
- Dataset SHA (LongMemEval, BrainBench, Replay)
- `--seed N`
- Run commands verbatim
- API model identifiers used (Anthropic + OpenAI + judge model)
Without these, the numbers are unfalsifiable. With them, anyone with API keys can re-score.