Incident Report: LSD Brainstorm 53× Cost Overrun
Date: 2026-05-20 Severity: High (financial — 0.96 estimated) Component:modusbrain lsd / modusbrain 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 ranmodusbrain lsd "what story should Shubham's List write next" --yes on a 13,690-page brain. The command:
- Estimated cost: $0.96 (2×12 = 24 crosses × 4 ideas + judge)
- Actual cost: $50.71 — 53× over estimate
- Token usage: 4,906,011 input + 2,399,239 output = 7.3M total tokens
- Far set pulled 1,985 pages instead of the configured 12
- Generated 15,868 raw ideas across the crosses (vs expected ~96)
- Judge phase failed: 2,989,338 tokens exceeded Claude Sonnet’s 1M context limit
- Zero ideas surfaced to the user — complete failure
--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.ts → fetchFar() → 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
--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
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 forbrainstorm 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
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 indc080ac2.
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 modusbrain function that serializes user-generated page content into JSON for API calls. Status: Implemented indc080ac2.
P5: Global Token & Time Budgets for All Analysis Functions (Proposed)
This is the bigger architectural ask. Every modusbrain command that makes LLM calls should respect configurable budgets:brainstorm/lsd— bisociation crosses + judge (this incident)dream— dream cycle phases (enrichment, emotional weight, etc.)extract all— link + timeline extraction across all pagesenrich— per-page deep enrichment with web researcheval— evaluation runs (suspected-contradictions, retrieval drift)integrity auto— automated content repairdoctor --remediate— autonomous self-healing via Minions
- Add a
BudgetTrackerclass that wraps LLM calls with token/cost/time accounting - Every analysis function receives a budget context
- On budget exhaustion: save partial results, emit a structured warning, exit cleanly
- CLI flags (
--max-cost,--max-tokens,--timeout) override config defaults --no-budgetescape 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:- Estimate tokens before calling the LLM
- If over budget, diarize: summarize/compress the content to fit
- For the judge specifically: rank ideas by a cheap heuristic first (keyword overlap, novelty score), then send only top-N to the LLM judge
- For other analysis: progressive summarization — chunk → summarize → merge summaries → final analysis
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-failedto 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
- 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.
- Cost estimators must account for actual data cardinality, not just configured parameters. The estimate used
m=12but the real far set was|prefixes|. - 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.
- 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()insrc/core/brainstorm/domain-bank.tscaps prefix sampling tomax(m*4, 50)and trims final pages tomby distance. The 2K-prefix explosion class is closed. - P2 (cost guardrails):
--max-cost,--max-far-set,--strict-budget,--judge-model,--max-ideas-per-judge-callflags on brainstorm + lsd. Pre-flight estimate refusal, mid-run cost-ceiling abort. - P3 (judge chunking):
runJudgeinsrc/core/brainstorm/judges.tsauto-chunks at 100 ideas/call. Context-window overflow is structurally prevented. - P4 (unicode sanitization):
ensureWellFormed(insrc/core/text-safe.ts, used bysrc/core/brainstorm/orchestrator.ts) replaces unpaired surrogates with U+FFFD before serialization. (Consolidated from the original hand-rolledsanitizeUnicodein v0.42.40.0 / #2011.) - P5 (BudgetTracker at the gateway layer): new
src/core/budget/budget-tracker.tsis the canonical primitive. The gateway’swithBudgetTracker(tracker, fn)composes viaAsyncLocalStorage<BudgetTracker>so every gateway-routed LLM call inside the scope auto-records.BudgetExhaustedis a typed error withreason: 'cost' | 'runtime' | 'no_pricing'.record()throws when cumulative spend exceeds the cap (TX1).reserve()hard-fails onno_pricingwhen the cap is set + model missing from pricing maps (TX2). - P6 (payload-fitter):
src/core/diarize/payload-fitter.tswith'batch'and'summarize'strategies. Summarize embed-clusters (k=ceil(items/4)), Haiku-summarizes each cluster in parallel viaPromise.allSettledat parallelism=4. Surfacesdegraded: trueflag when success ratio < 0.75 so callers decide whether to surface a partial result or abort. - P7 (brainstorm checkpoint + —resume):
src/core/brainstorm/checkpoint.tspersists FULL idea bodies (not just counts — TX3 load-bearing). One--resume <run_id>flag covers both failed and never-attempted crosses (TX4).run_idformula 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-runslists saved checkpoints.--force-resumebypasses the 7d staleness gate.
- doctor —remediate —resume: A4 amended. The mid-run cap is now a
real ceiling;
--max-costis an alias for--max-usd. On BudgetExhausted, the orchestrator persists a checkpoint at~/.modusbrain/remediation/<plan_hash>.jsonand tells the user the exactmodusbrain doctor --remediate --resumecommand. 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.
- The schema fix for
page_linkson PGLite. The brainstorm domain-bank queries referencepage_linksbut the embedded schema only defineslinks; the E2E works around this with a view in test setup, but real PGLite users currently can’t runmodusbrain brainstorm. Schema fix needed. --max-costflag onextract,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
appendFileSyncis fine at typical volumes; revisit if profiling shows it dominates. - Multi-day brainstorm resume (>7d). The
--force-resumeflag is the operator escape hatch for now.