Files
gbrain/docs/incidents/2026-05-20-lsd-cost-explosion.md
Garry Tan 83c4ca0564 v0.39.0.0 feat: brainstorm cost cathedral (P1-P7) + page_links schema fix (#1283)
* feat(brainstorm): T1 cost guardrails + judge chunking + far-set cap

Ports PR #1234 with a typed-error swap (Q2). Brings:

- `--max-cost`, `--max-far-set`, `--strict-budget`, `--judge-model`,
  `--max-ideas-per-judge-call` CLI flags on `gbrain brainstorm` / `lsd`
- Domain-bank prefix-cap + shuffle + final-trim to `m` by distance score
- Judge auto-chunks idea sets > 100 across multiple LLM calls
- UTF-16 surrogate sanitization on cross prompts
- Phase-0.5 hard cost ceiling + mid-run cost guard

Phase-1 diff from PR #1234: per-cross error-rethrow uses inline typed
`BudgetExhausted` instead of string-match on the error message. Phase 2
of the wave will move the class to `src/core/budget/budget-tracker.ts`
and the orchestrator will import it.

Postmortem doc + 12-case regression test included verbatim from #1234.

T1 of the brainstorm cost cathedral plan
(~/.claude/plans/system-instruction-you-are-working-rippling-moth.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(budget): T2 BudgetTracker + BudgetExhausted + audit-week helper

The keystone primitive for the v0.37.x budget cathedral. One class,
one typed error, one schema-stable audit JSONL. Replaces three parallel
copies (brainstorm orchestrator inline class, cycle/budget-meter,
eval-contradictions cost-prompt/tracker) — those adapt to this one in
T5/T6.

Contracts pinned by 26 unit tests:
  - TX1: record() throws BudgetExhausted(reason:'cost') when cumulative
    spend > cap. A single underestimated call cannot leak past the cap.
  - TX2: reserve() hard-fails with BudgetExhausted(reason:'no_pricing')
    when cap is set + model is missing from pricing maps. When cap is
    unset, legacy warn-once behavior is preserved.
  - A3 amended: extractUsageFromError(err, fallback) returns err.usage
    when SDK provides it, else the pessimistic fallback (caller passes
    maxOutputTokens, not the optimistic pre-call estimate).
  - onExhausted callback fires once, synchronously, before the throw
    propagates. Callbacks do sync I/O (writeFileSync) for checkpoint
    persistence.
  - Audit JSONL is schema-stable: every line carries schema_version=1.
    Reorderings tolerated, field renames are breaking.

Also ships src/core/audit-week-file.ts — the shared ISO-week filename
helper consumed by every audit writer in T4. Year-boundary correctness
pinned by 5 cases including 2020-W53 (the 53-week year), 2025-W01
rolling in from 2024-12-30 (Monday), and the GBRAIN_AUDIT_DIR override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gateway): T3 withBudgetTracker + AsyncLocalStorage composition

TX5: every gateway.chat / embed / rerank call now auto-composes the
active BudgetTracker via a module-internal AsyncLocalStorage. No
per-call injection seam, no flag plumbing — callers wrap their
entrypoint in `withBudgetTracker(tracker, async () => { ... })` and
every downstream LLM call honors the cap.

Outside any scope, the gateway is a budget no-op (back-compat with the
pre-v0.37 contract).

Wiring:
  - chat(): reserves on entry using prompt-char heuristic + opts.maxTokens.
    Records actual usage from result.usage on success; on failure, charges
    the pessimistic A3-amended fallback so the cap is real.
  - embed(): reserves total estimated input tokens (chars / chars-per-token).
    Records the same total in try/finally; SDK doesn't surface per-batch
    embed token counts.
  - rerank(): reserves and records query + docs char count.
    Reranker pricing isn't in the canonical map yet, so reserve() takes
    the warn-once path under no-cap and the TX2 hard-fail under cap.

6 unit cases pin the contract: chat auto-composes, outside-scope is
no-op, nested scope restores outer, over-cap reserve throws BEFORE
provider call (proves circuit breaker), TX1 mid-run cumulative cap
fires via record(), parallel Promise.all scopes do not bleed trackers.

All 255 existing gateway tests and 50 brainstorm tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(audit): T4 migrate 4 audit writers to shared isoWeekFilename helper

Q1: extract the ISO-week filename math into one canonical helper
(src/core/audit-week-file.ts, landed in T2) and migrate every audit
JSONL writer in the codebase to consume it.

Sites migrated:
  - src/core/minions/handlers/shell-audit.ts  (shell-jobs-YYYY-Www.jsonl)
  - src/core/facts/phantom-audit.ts            (phantoms-YYYY-Www.jsonl)
  - src/core/audit-slug-fallback.ts            (slug-fallback-YYYY-Www.jsonl)
  - src/core/cycle/budget-meter.ts             (dream-budget-YYYY-Www.jsonl)

Each call site had its own copy of the ISO-week-from-Date algorithm.
They mostly agreed but subtle drift was already accumulating (one used
local time, one approximated the Thursday-anchor formula, etc.). One
helper, one set of regression tests, no drift.

Compute helpers (computeAuditFilename, computePhantomAuditFilename,
computeSlugFallbackAuditFilename) are preserved as thin wrappers so
existing import sites and tests don't break.

All audit + slug-fallback + phantom + budget-meter tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cycle): T5 BudgetMeter schema_version=1 + golden fixture (A2 amended)

Adapter pass: the existing BudgetMeter keeps its public shape
(`BudgetMeter`, `SubmitEstimate`, `BudgetCheckResult`) verbatim so every
dream-cycle call site keeps working without rewires. The audit JSONL
grew one new field on every line: `schema_version: 1`.

A2 amended: the codex outside-voice review relaxed the byte-stable
contract to schema-stable. Field reorderings are tolerated; the
documented set (schema_version, ts, phase, event, model, label,
plus per-event cost or token fields) is what every consumer can rely
on. Renames or removals are breaking.

test/fixtures/dream-budget-schema-v1.jsonl carries one canonical row
per event variant (submit / submit_denied / submit_unpriced) as
documentation of the schema. The new in-suite case in
test/budget-meter.test.ts walks every emitted line and asserts the
fields are present + the right type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): T6 wrap eval-contradictions runner in withBudgetTracker

The runner now installs a BudgetTracker scope around its body so every
gateway-layer chat / embed / rerank call (the judge model + per-query
embedding) auto-records via the AsyncLocalStorage from T3. Currently
telemetry-only — the existing CostTracker remains the primary soft-
ceiling enforcement, so the public --budget-usd surface and
PreFlightBudgetError shape are byte-identical.

The wiring is the seam: future waves can promote the cap to BudgetTracker
semantics (TX1 + TX2 semantics on cumulative + no_pricing) by passing
maxCostUsd through to BudgetTracker without touching the CLI.

All 79 eval-contradictions tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): T7 --remediate budget tracker + checkpoint + --resume (A4)

A4 amended: doctor --remediate gains a resumable cost ceiling. The
runRemediate loop now runs inside `withBudgetTracker(tracker, ...)` so
every gateway-routed LLM call inside a Minion handler (synthesize,
patterns, consolidate, embed) honors the cap. When BudgetExhausted
fires mid-run, the onExhausted callback persists a checkpoint of
completed step ids + idempotency_keys to
~/.gbrain/remediation/<plan_hash>.json BEFORE the throw propagates,
and the catch surfaces a paste-ready --resume hint.

Wire-up:
  - New --resume <plan_hash> flag (with implicit "most recent matching"
    when no hash given) loads the checkpoint and skips already-
    completed steps. Mismatched plan_hash refuses with an explicit
    message.
  - --max-cost is now an alias for --max-usd. Both spellings honored
    and threaded through to BudgetTracker.maxCostUsd so the cap is
    a real ceiling, not just pre-flight advice.
  - On BudgetExhausted, exit 1 with the resume hint; on clean
    completion, clear the checkpoint.

New file: src/core/remediation-checkpoint.ts with
computePlanHash / save / load / list / clear helpers. Atomic write
via .tmp + rename. Pinned by 13 unit cases including determinism +
sort-order invariance + schema-mismatch return-null + atomic-rename.

All 48 doctor.test.ts cases still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(subagent): T8 A1 ordering ASCII diagram before acquireLease

Documents the load-bearing ordering invariant: the gateway's
BudgetTracker reserve() runs (implicitly, via AsyncLocalStorage)
BEFORE acquireLease() inside the subagent loop. A BudgetExhausted
throw must NOT consume a rate-lease slot, because the lease is the
rate-limit pacer for the entire fleet.

The handler body intentionally does NOT explicitly thread BudgetTracker;
TX5 (gateway-layer composition) handles that. The comment is the
reader's signpost.

No behavioral change. All 58 subagent tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(diarize): T9 payload-fitter (P6) with batch + summarize + gate

Generic utility for fitting arbitrarily-large item lists into a
downstream caller's per-call token budget. Two strategies:

  - 'batch': deterministic token-budgeted chunking. No LLM calls. The
    fitted list shape matches the input; the caller decides how to
    consume it (e.g. brainstorm judge concatenates per-chunk results).
    Surfaces a `dropped` count for items that exceed the per-call cap.

  - 'summarize': embed-cluster into ceil(items/4) groups via cheap
    deterministic nearest-neighbor on cosine; Haiku-summarize each
    cluster via Promise.allSettled at parallelism=4 (Perf1). Each
    Haiku call composes the active BudgetTracker via the gateway's
    AsyncLocalStorage scope (T3) — no per-call injection.

Quality gate (codex outside-voice finding #4): when summarize's
success_ratio < min_success_ratio (default 0.75), the result is
flagged `degraded: true` so the caller (brainstorm) can decide to
surface a partial result or abort. The fitter itself preserves the
successful subset either way.

Tested via 4 cases across two files (T3 contract):
  - happy path (all clusters succeed → degraded=false)
  - partial failure tolerated (1/5 fails, success_ratio=0.8 > 0.75 → degraded=false)
  - high-failure rate flips the gate (3/5 fails → degraded=true)
  - budget-respecting (BudgetExhausted thrown mid-cluster propagates
    via Promise.allSettled)

11 unit cases across batch + summarize. Brainstorm + cost-guardrails
tests still green; judges.ts internal chunking deferred to a follow-up
wave (TODOS) so the existing chunked-batch contract stays byte-stable
during this drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(brainstorm): T10 checkpoint + --resume with full idea bodies (P7)

The brainstorm cathedral capstone. Crashed runs can resume cleanly via
`gbrain brainstorm --resume <run_id>` (and `gbrain lsd --resume` etc).

TX3 load-bearing contract: completed_crosses on disk carries FULL idea
bodies (~50KB per run), not just counts. The resumed BrainstormResult
contains the pre-crash ideas (loaded from disk) merged with the post-
resume ideas — codex's outside-voice finding was that a resume that
produces only "what we generated this run" is silent partial output.

TX4 single rule: --resume continues any cross not in completed_crosses.
The proposed --retry-failed was dropped per codex review; failed AND
never-attempted crosses both go through --resume.

A5 amended: run_id = sha256(question + profile + sort(close_slugs) +
sort(far_slugs)).slice(0,16). NO embedding bits — stable across
embedding-model swaps. 7-day mtime-based GC.

Q2 fold: orchestrator.ts drops its inline BudgetExhausted class and
re-exports the canonical one from src/core/budget/budget-tracker.ts
(Phase 2). runBrainstorm now wraps the body in withBudgetTracker so
every gateway-layer chat call auto-records cost. The cap remains
opts.maxCostUsd (default $5).

New CLI flags:
  --resume <run_id>   Continue any cross not in completed_crosses.
                      Refuses to start when run_id doesn't match the
                      active inputs (paste-ready hint).
  --force-resume      Bypass the 7-day staleness gate.
  --list-runs         Print saved run_ids and exit.

Cycle purge phase (the 9th cycle phase) now also GCs stale brainstorm
checkpoints alongside op_checkpoints (~7d window).

Tests:
  - 20 unit cases in test/brainstorm/checkpoint.test.ts:
    computeRunId is deterministic + slug-array-order invariant + stable
    across embedding-model swaps; round-trip preserves ideas verbatim;
    saveCheckpoint atomic via .tmp+rename; loadCheckpoint returns null
    on missing/schema-mismatch/corrupt-JSON; gcStaleCheckpoints unlinks
    >N days; listRuns mtime-ordered.
  - 3 E2E cases in test/e2e/brainstorm-resume.test.ts:
    crash on cross 4 → first run aborts with checkpoint of crosses 1..N
    with full idea bodies; second run with resumeRunId merges pre-crash
    + post-resume ideas (TX3 contract); mismatched run_id refuses with
    paste-ready hint.

The PGLite schema-gap workaround in the E2E (CREATE VIEW page_links AS
SELECT * FROM links) is filed as a follow-up in TODOS T12 — the
real-engine brainstorm path needs that view to materialize as a
canonical schema fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: T11 + T12 wave release docs + deferred follow-ups

CHANGELOG entry for the brainstorm cost cathedral (Unreleased slot;
/ship will assign the next version):
  - ELI10 lead per CLAUDE.md voice rules
  - "How to turn it on" with paste-ready commands
  - "Things to watch" calls out the A4 semantic shift for
    `doctor --remediate --max-usd` (pre-flight → mid-run abort
    with resumable checkpoint)
  - Itemized changes by file/area
  - "For contributors" section noting the 73 new tests + the PGLite
    schema-gap workaround for the E2E

CLAUDE.md Key Files: 6 new entries for budget-tracker, audit-week-file,
gateway withBudgetTracker, payload-fitter, brainstorm/checkpoint,
remediation-checkpoint. Regenerated llms-full.txt + llms.txt (passes
test/build-llms.test.ts).

docs/incidents/2026-05-20-lsd-cost-explosion.md gains a closing
"Shipped in v0.37.x (the budget cathedral wave)" section listing P1-P7
completion status + the deferred follow-ups so the incident's audit
trail closes the loop.

TODOS.md gets a new top section for the wave's deferred items:
  - PGLite `page_links` schema gap fix
  - Explicit --max-cost on extract / enrich / integrity auto
  - P5 config-schema budgets: block in ~/.gbrain/config.json
  - Multi-day brainstorm resume (>7d)
  - Async-batched audit writes (profiling trigger criterion)
  - BudgetLedger unification with BudgetTracker
  - judges.ts internal chunking → payload-fitter delegation

Also: fixed a payload-fitter typecheck error (ChatFn import). Final
typecheck is clean on every file the wave touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(schema): F1 page_links view alias for both engines

Brainstorm's domain-bank queries reference `page_links` (pglite-engine.ts:896,
postgres-engine.ts:959) but the canonical table is `links`. Without the alias
view, `gbrain brainstorm` against PGLite fails with `relation "page_links"
does not exist`; the same was a latent bug on Postgres.

This commit lands the fix at three sites:

1. `src/core/pglite-schema.ts` — embedded schema bundle gets the view at
   table-bundle time, so fresh PGLite installs are correct from boot.
2. `src/core/migrate.ts` v81 (`page_links_view_alias`) — existing brains on
   either engine pick up the view via `gbrain apply-migrations`. CREATE OR
   REPLACE VIEW is idempotent; re-running is safe.
3. `test/e2e/brainstorm-resume.test.ts` — removed the ad-hoc workaround view
   from the test setup. The E2E now exercises the same schema path real
   users will see.

`TODOS.md` entry for the gap closed out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(brainstorm): F2 pre-flight --max-cost refusal smoke E2E

Pins the user-facing path that closed the original \$50 incident: when
the pre-run estimate exceeds the configured cap, runBrainstorm throws
BudgetExhausted with reason='cost' and a paste-ready hint pointing at
--limit / --max-cost / --max-far-set before any chat call happens.

The four assertions are the four things a real user can verify after
the throw lands:
  1. Typed BudgetExhausted (not a generic Error)
  2. reason === 'cost' (not runtime or no_pricing)
  3. Message names the remediation flags
  4. No provider HTTP would have happened (chat.crossCalls === 0)

Uses the same PGLite engine + tinyProfile + stub chatFn as the existing
--resume tests. Hermetic; ~5s wallclock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reindex-code): F3 --max-cost flag via withBudgetTracker

Wires gbrain reindex --code into the v0.38 budget cathedral. When the
caller passes --max-cost N (or --max-cost-usd N), runReindexCode wraps
its per-page import loop in withBudgetTracker so every gateway.embed()
call inside importCodeFile auto-composes the cap. On BudgetExhausted,
the partial-progress result reports what got reindexed before the cap
fired plus a synthetic failure row naming the cap throw.

reindex-code is idempotent (content_hash short-circuit in importCodeFile),
so a re-run after a budget abort picks up where the cap fired — no
manual checkpoint state needed.

Both --max-cost and --max-cost-usd are accepted (symmetry with brainstorm
which uses --max-cost, and a precedent for the spelling we want long-term).

When --max-cost is unset, the body runs outside any tracker scope — byte-
stable pre-F3 behavior for legacy callers.

Files:
  src/commands/reindex-code.ts:
    - ReindexCodeOpts.maxCostUsd?: number
    - runReindexCode wraps body in withBudgetTracker when set
    - runReindexCodeCli parses --max-cost / --max-cost-usd
    - BudgetExhausted caught + returned as partial-progress result
  test/reindex-code-max-cost.serial.test.ts (NEW):
    - dry-run + maxCostUsd happy path
    - empty-brain + maxCostUsd hits early-return cleanly
    - no tracker installed when cap is unset (regression guard for
      the conditional wrap)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(schema): narrow page_links view projection to bootstrap-safe columns

The v0.38 page_links view alias initially used SELECT * FROM links, which
broke the pre-v0.13 bootstrap test: applyForwardReferenceBootstrap drops
link_source + origin_page_id to simulate the pre-v0.13 schema shape, but
the SELECT * view created a dependency that blocked the column DROP.

Engine queries only reference pl.id (via COUNT(*)) and pl.to_page_id, so
the view's projection is now SELECT id, from_page_id, to_page_id FROM links
— what callers actually use, no more. This unblocks legacy-brain upgrade
paths AND keeps the bootstrap forward-reference probes safe.

Bootstrap suite: 15/15 pass after the change.

Also files a P0 TODO for a pre-existing test failure
(test/doctor-report-remote.test.ts "full report on healthy brain") that
fails on master too — out of scope for this wave but noticed during
/ship triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version to v0.39.0.0

Brainstorm cost cathedral wave (P1-P7). MINOR bump per user direction:
new architectural seam (gateway-layer BudgetTracker via AsyncLocalStorage),
5 new modules, new CLI flags (--max-cost / --resume / --list-runs /
--force-resume), new migration v81 (page_links view alias).

No breaking changes — BudgetExhausted re-exported from orchestrator for
back-compat; --max-usd preserved as alias for --max-cost; eval-contradictions
--budget-usd surface byte-identical.

CHANGELOG entry renamed from [Unreleased] to [0.39.0.0] and adds the
mandatory "To take advantage of v0.39.0.0" block per CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(isolation): rename 3 env-mutating tests to .serial.test.ts (CI fix)

CI's `check:test-isolation` flagged three tests added in the v0.39.0.0
cathedral that directly mutate `process.env` across test boundaries:

- test/brainstorm/checkpoint.test.ts (mutates GBRAIN_HOME)
- test/core/audit-week-file.test.ts (mutates GBRAIN_AUDIT_DIR)
- test/core/remediation-checkpoint.test.ts (mutates GBRAIN_HOME)

Per CLAUDE.md rule R1: env-mutating tests either use withEnv() OR rename
to *.serial.test.ts (the quarantine escape hatch). The mutation lives in
beforeEach/afterEach which spans the whole describe block, so .serial
rename is the cleaner fix — withEnv() would require restructuring every
test. The serial-test runner gives them their own bun process; no cross-
file env races.

Verified: check:test-isolation passes (527 non-serial unit files clean),
`bun run verify` passes, all 41 tests in the three renamed files pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:53:11 -07:00

13 KiB
Raw Blame History

Incident Report: LSD Brainstorm 53× Cost Overrun

Date: 2026-05-20 Severity: High (financial — $50.71 actual vs $0.96 estimated) Component: gbrain lsd / gbrain brainstorm Brain size: 13,690 pages, 16,314 links, ~2,000 unique directory prefixes Version: v0.37.1.0 (first release of brainstorm/lsd)

What Happened

A user ran gbrain lsd "what story should Garry's List write next" --yes on a 13,690-page brain. The command:

  1. Estimated cost: $0.96 (2×12 = 24 crosses × 4 ideas + judge)
  2. Actual cost: $50.71 — 53× over estimate
  3. Token usage: 4,906,011 input + 2,399,239 output = 7.3M total tokens
  4. Far set pulled 1,985 pages instead of the configured 12
  5. Generated 15,868 raw ideas across the crosses (vs expected ~96)
  6. Judge phase failed: 2,989,338 tokens exceeded Claude Sonnet's 1M context limit
  7. Zero ideas surfaced to the user — complete failure

A retry with --limit 12 explicit:

  • Far set correctly returned 12 pages, cost was $0.39
  • But judge still failed: parseJudgeJSON: no strategy produced valid JSON
  • Again, 0 ideas survived to output (96 generated, 0 scored)

Root Causes

RC1: Far Set Explosion (caused the $50 bill)

File: src/core/brainstorm/domain-bank.tsfetchFar()listPrefixSampledPages()

The domain bank samples pages by directory prefix to get diversity. listPrefixSampledPages returns one page per prefix passed in. On a 13K-page brain with ~2,000 unique prefixes (books/, civic/bundles/, civic/gl-article-*, people/, concepts/, etc.), passing all prefixes produces ~2,000 rows — not the configured m=12.

The cost estimator uses m (12) to predict crosses and cost. But the actual cross phase receives 1,985 far-set pages, producing 2 × 1985 = 3,970 crosses at 4 ideas each = 15,868 ideas.

The estimate formula is correct for the intended behavior; the far set selection is what diverged.

RC2: No Cost Circuit Breaker

There is no mechanism to:

  • Abort if estimated cost exceeds a threshold
  • Abort mid-run if actual spend diverges from estimate
  • Cap the far set size regardless of prefix count
  • Warn the user that a run will be expensive before proceeding

The --yes flag skips the 10-second cost preview wait, removing even the manual inspection opportunity.

RC3: Judge Context Overflow

The judge receives ALL ideas in a single prompt. With 15,868 ideas at ~350 tokens each, that's ~5.5M tokens — well beyond any model's context window.

Even on the retry with only 96 ideas, the judge failed with JSON parsing errors, suggesting the judge prompt/response format is fragile.

RC4: Unpaired UTF-16 Surrogates in Page Content

Two crosses failed with: The request body is not valid JSON: no low surrogate in string

Some pages (likely OCR imports or web scrapes) contain unpaired UTF-16 surrogates. When these get serialized into the JSON request body for the LLM API, the JSON encoder produces invalid JSON.

RC5: No Timeout on Individual Crosses

One cross timed out with no specific timeout configured. The default HTTP timeout allowed it to hang for an extended period before failing, consuming tokens on the API side.

Observed Token Flow

Configured:  2 close × 12 far = 24 crosses × 4 ideas = 96 ideas + 1 judge call
Actual:      2 close × 1985 far = 3970 crosses × 4 ideas = 15,868 ideas + 1 judge call (failed)

Per-cross tokens (estimated): ~1,200 in + 600 out
Actual total:                  4,906,011 in + 2,399,239 out

The judge call alone would have been:
  15,868 ideas × ~350 tokens = ~5.5M tokens (prompt)
  Model limit:                  1M tokens (Sonnet)
  Overflow:                     5.5× context limit

Proposed Fixes

P1: Far Set Cap (Critical — prevents cost explosion)

fetchFar() must cap the number of prefixes BEFORE calling listPrefixSampledPages. The cap should be max(m * 4, 50) to allow some diversity headroom while preventing runaway growth. Final selection trimmed to m by distance score.

Status: Implemented in dc080ac2.

P2: Cost Guardrails (Critical — defense in depth)

New flags for brainstorm and lsd commands:

  • --max-cost <usd> (default $5): hard-abort if pre-run estimate exceeds
  • --strict-budget: abort mid-run if running cost exceeds 5× estimate
  • --max-far-set <n> (default 50): explicit far set size cap

Status: Implemented in dc080ac2.

P3: Judge Chunking (Critical — prevents context overflow)

Split ideas into batches of ~100 before calling the judge LLM. Each batch is a separate API call; results concatenated. This bounds per-call token usage to ~35K regardless of total idea count.

Status: Implemented in dc080ac2.

P4: Unicode Sanitization (Medium — prevents cross failures)

Strip unpaired UTF-16 surrogates from page content before building cross prompts. This is a general problem for any gbrain function that serializes user-generated page content into JSON for API calls.

Status: Implemented in dc080ac2.

P5: Global Token & Time Budgets for All Analysis Functions (Proposed)

This is the bigger architectural ask. Every gbrain command that makes LLM calls should respect configurable budgets:

# Proposed config additions to ~/.gbrain/config.json
budgets:
  # Global defaults
  default:
    max_input_tokens: 500_000    # per-command input token cap
    max_output_tokens: 200_000   # per-command output token cap  
    max_cost_usd: 5.00           # per-command dollar cap
    max_runtime_seconds: 300     # 5-minute wall-clock cap
    
  # Per-command overrides
  brainstorm:
    max_cost_usd: 2.00
    max_runtime_seconds: 120
  lsd:
    max_cost_usd: 5.00
    max_runtime_seconds: 300
  dream:
    max_cost_usd: 10.00
    max_runtime_seconds: 600
  extract:
    max_input_tokens: 1_000_000
    max_runtime_seconds: 900
  enrich:
    max_cost_usd: 3.00
    max_runtime_seconds: 180

Commands affected:

  • brainstorm / lsd — bisociation crosses + judge (this incident)
  • dream — dream cycle phases (enrichment, emotional weight, etc.)
  • extract all — link + timeline extraction across all pages
  • enrich — per-page deep enrichment with web research
  • eval — evaluation runs (suspected-contradictions, retrieval drift)
  • integrity auto — automated content repair
  • doctor --remediate — autonomous self-healing via Minions

Implementation approach:

  1. Add a BudgetTracker class that wraps LLM calls with token/cost/time accounting
  2. Every analysis function receives a budget context
  3. On budget exhaustion: save partial results, emit a structured warning, exit cleanly
  4. CLI flags (--max-cost, --max-tokens, --timeout) override config defaults
  5. --no-budget escape hatch for power users who know what they're doing

P6: Diarization / Summarization for Oversized Payloads (Proposed)

When a judge or analysis phase receives more content than fits in context:

  1. Estimate tokens before calling the LLM
  2. If over budget, diarize: summarize/compress the content to fit
  3. For the judge specifically: rank ideas by a cheap heuristic first (keyword overlap, novelty score), then send only top-N to the LLM judge
  4. For other analysis: progressive summarization — chunk → summarize → merge summaries → final analysis

This is effectively a token budget allocator that decides how to spend a fixed token budget across variable-length inputs.

Example: 15,868 ideas need judging, context limit 900K tokens
  Step 1: Cheap pre-filter (keyword dedup, obvious duplicates) → 8,000 unique ideas
  Step 2: Batch into 80 chunks of 100 ideas each
  Step 3: Judge each chunk → 80 calls × ~35K tokens = 2.8M total (spread across calls)
  Step 4: Merge top ideas from each chunk → final ranking
  Total cost: ~$2-3 instead of $50

P7: Structured Error Recovery (Proposed)

When a cross or judge call fails:

  • Save the partial results immediately (don't wait for the full run)
  • Emit a machine-readable error event (not just a log warning)
  • Support --retry-failed to re-run only the failed crosses without repeating successful ones
  • Checkpoint progress to disk so interrupted runs can resume

Impact

  • Financial: $50.71 wasted on a single failed run
  • User trust: Zero ideas delivered despite ~7M tokens processed
  • Time: ~15 minutes of compute time, plus overnight delay in reporting results

Lessons

  1. First run of any new feature on a large brain should be dry-run or capped. The estimate was based on small-brain testing; 13K pages is a different universe.
  2. Cost estimators must account for actual data cardinality, not just configured parameters. The estimate used m=12 but the real far set was |prefixes|.
  3. Every LLM-calling function needs a budget. This isn't just a brainstorm problem — it's an architectural gap in any system that makes variable numbers of LLM calls based on data size.
  4. JSON serialization of user content is a landmine. Any page could contain invalid Unicode. Sanitize at the serialization boundary, not per-feature.

Shipped in v0.37.x (the budget cathedral wave)

P1-P4 already shipped via PR #1234 (the first fix wave). P5-P7 plus a few architectural rounds shipped in the budget-cathedral wave that followed:

  • P1 (far set cap): fetchFar() in src/core/brainstorm/domain-bank.ts caps prefix sampling to max(m*4, 50) and trims final pages to m by distance. The 2K-prefix explosion class is closed.
  • P2 (cost guardrails): --max-cost, --max-far-set, --strict-budget, --judge-model, --max-ideas-per-judge-call flags on brainstorm + lsd. Pre-flight estimate refusal, mid-run cost-ceiling abort.
  • P3 (judge chunking): runJudge in src/core/brainstorm/judges.ts auto-chunks at 100 ideas/call. Context-window overflow is structurally prevented.
  • P4 (unicode sanitization): sanitizeUnicode in src/core/brainstorm/orchestrator.ts strips unpaired surrogates before serialization.
  • P5 (BudgetTracker at the gateway layer): new src/core/budget/budget-tracker.ts is the canonical primitive. The gateway's withBudgetTracker(tracker, fn) composes via AsyncLocalStorage<BudgetTracker> so every gateway-routed LLM call inside the scope auto-records. BudgetExhausted is a typed error with reason: 'cost' | 'runtime' | 'no_pricing'. record() throws when cumulative spend exceeds the cap (TX1). reserve() hard-fails on no_pricing when the cap is set + model missing from pricing maps (TX2).
  • P6 (payload-fitter): src/core/diarize/payload-fitter.ts with 'batch' and 'summarize' strategies. Summarize embed-clusters (k=ceil(items/4)), Haiku-summarizes each cluster in parallel via Promise.allSettled at parallelism=4. Surfaces degraded: true flag when success ratio < 0.75 so callers decide whether to surface a partial result or abort.
  • P7 (brainstorm checkpoint + --resume): src/core/brainstorm/checkpoint.ts persists FULL idea bodies (not just counts — TX3 load-bearing). One --resume <run_id> flag covers both failed and never-attempted crosses (TX4). run_id formula uses NO embedding bits so the identity is stable across embedding-model swaps (A5 amended). 7-day mtime-based GC wired into the cycle purge phase. --list-runs lists saved checkpoints. --force-resume bypasses the 7d staleness gate.

Also shipped alongside the wave (folded inline):

  • doctor --remediate --resume: A4 amended. The mid-run cap is now a real ceiling; --max-cost is an alias for --max-usd. On BudgetExhausted, the orchestrator persists a checkpoint at ~/.gbrain/remediation/<plan_hash>.json and tells the user the exact gbrain doctor --remediate --resume command. The resumed run skips already-completed steps.
  • Audit-week-file consolidation (Q1): four call sites (shell-jobs / phantoms / slug-fallback / dream-budget) now share one ISO-week filename helper. Year-boundary correctness pinned by tests.
  • eval-contradictions tracker telemetry: the existing CostTracker stays for the report shape; the runner additionally installs a withBudgetTracker scope for the gateway-layer telemetry path.

What did NOT make this wave (filed in TODOS for a follow-up):

  • The schema fix for page_links on PGLite. The brainstorm domain-bank queries reference page_links but the embedded schema only defines links; the E2E works around this with a view in test setup, but real PGLite users currently can't run gbrain brainstorm. Schema fix needed.
  • --max-cost flag on extract, enrich, integrity auto. The gateway-layer enforcement covers them when wrapped at the entrypoint, but the CLI flag wiring is deferred.
  • Async-batched audit writes. Sync appendFileSync is fine at typical volumes; revisit if profiling shows it dominates.
  • Multi-day brainstorm resume (>7d). The --force-resume flag is the operator escape hatch for now.