Skip to content

feat(bbh): resilient sweep — stream results, abort-on-exhaustion, auto-resume#5

Open
nickmeinhold wants to merge 8 commits into
mainfrom
feat/bbh-resilient-resume
Open

feat(bbh): resilient sweep — stream results, abort-on-exhaustion, auto-resume#5
nickmeinhold wants to merge 8 commits into
mainfrom
feat/bbh-resilient-resume

Conversation

@nickmeinhold

Copy link
Copy Markdown
Collaborator

Why

The BBH sweep wrote results only once, at the end of the loop, from an in-memory list. Any process death mid-run (Max usage exhausted, kill, laptop sleep) lost the entire run. Worse, a usage-limit error was caught per-call and recorded as passed=False, so an exhausted run limped to completion writing hundreds of failure rows that summarize() averaged into pass rates as if they were wrong answers — silent corruption of the headline metric.

This matters now: we're scaling to n=100 (~720 Claude calls) on the Max 20× plan, where brushing the 5-hour usage window is plausible.

What

  1. Stream + flush each result to disk — partial progress survives any process death (OS page cache outlives the process). Output file format is byte-identical (meta line + one row per result); analyze_sweep.py / RUN_FOR_NICK.md unaffected.
  2. Abort loudly on genuine usage exhaustion (usage limit / credit / 429) with the exact resume command, instead of poisoning the dataset. Transient signals (timeout / 529) still record-and-continue — one blip doesn't kill a run.
  3. --resume <jsonl> — skip already-completed (task_id, arm) pairs and append. Resume is clean because the task list is deterministic (load_bbh(subtasks, n_per_subtask, start)), so "what's done" is recoverable from the output file alone.
  4. run_bbh_resumable.sh — unattended wrapper: catches the exit-2 abort, backs off BACKOFF_SECONDS, re-invokes with --resume, up to MAX_RETRIES. A long sweep survives the usage window with no human in the loop.

Verification (by running, not just reasoning)

  • Unit: usage-exhaustion classifier (separates exhaustion vs transient vs real wrong answer) + resume-parse round-trip.
  • Live n=1 end-to-end: fresh write lands on disk during the run; resume skips the done pair and writes nothing new.
  • Wrapper: syntax check, path-extraction for both success & abort lines, live n=1 run exits 0.

🤖 Generated with Claude Code

nickmeinhold and others added 8 commits June 25, 2026 11:13
The BBH sweep wrote results only once, at the end of the loop, from an
in-memory list — so any process death mid-run (Max usage exhausted,
kill, laptop sleep) lost the entire run. Worse, a usage-limit error was
caught per-call and recorded as passed=False, so an exhausted run limped
to completion writing hundreds of failure rows that summarize() averaged
into pass rates as if they were wrong answers (silent corruption).

Three fixes:
- Stream each TaskResult to disk with a flush; partial progress now
  survives process death (OS page cache outlives the process).
- Detect genuine Max-usage exhaustion (usage limit / credit / 429) and
  abort loudly with the exact resume command, instead of poisoning the
  dataset. Transient signals (timeout / 529) still record-and-continue.
- Add --resume <jsonl>: skip already-completed (task_id, arm) pairs and
  append, so an interrupted sweep continues where it stopped.

Output file format is byte-identical (meta line + one row per result);
existing analyze_sweep.py / RUN_FOR_NICK.md commands are unaffected.
Verified: unit tests for the classifier + resume parse, and a live
n=1 end-to-end (fresh write lands on disk, resume skips the done pair).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_bbh_resumable.sh wraps run_bbh_pilot.py: on a usage-exhaustion abort
(exit 2) it extracts the partial-results path, waits BACKOFF_SECONDS for
the Max window to recover, and re-invokes with --resume — looping up to
MAX_RETRIES until the sweep completes or a hard error occurs. Lets a
long n=100+ run survive hitting the 5-hour usage window with no human
in the loop. Verified: syntax check, path-extraction unit, live n=1 run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gen_progress.py renders the streaming sweep JSONL into a self-contained
auto-refreshing HTML status page (overall + per-arm progress, pass rates,
cost units, ETA). publish_progress.sh force-pushes it to the gh-pages
branch every 5min (under GitHub Pages' ~10 builds/hr soft limit) from an
isolated worktree until the run PID exits. Live at enspyrco.github.io/echo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The harness shells out to `claude --print` from inside the Echo project
dir, where `claude -p` loads the project CLAUDE.md, runs SessionStart
hooks, and ingests the task-restore injection. Every BBH model call was
therefore answering AS the Echo agent ("All 8 tasks restored. Now to
your question...") rather than the benchmark question.

This — not a parser bug or "Sonnet burying the format line" — is the
real cause of the n=30/n=99 Sonnet unparseables and the 98-125s wall
times (loading a huge CLAUDE.md + hooks per call). Accuracy was
confounded too.

Fix: pass `--setting-sources ""` so none of {user,project,local}
settings load (no hooks/CLAUDE.md/task-restore) while keeping the Max
OAuth auth (--bare drops auth, so it's unusable here).

Verified via scripts/repro_sonnet_unparse.py on the 3 known failing
tasks: all now parse cleanly, wall times drop ~6-10x (99s->6s), and
date_understanding/5 goes unparseable->correct. 25 parser/scoring unit
tests still pass.

IMPLICATION: all prior BBH sweeps (n=15/30/99) are contaminated and
must be re-run with the fixed harness before #336/#619.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First BBH sweep run with the --setting-sources "" fix (commit 23030a2),
so model calls no longer inherit the project CLAUDE.md/hooks/task-restore.

Clean n=30 (3 subtasks × 10):
  haiku-only   0.867  10.4s  0 unparse
  sonnet-only  0.900  10.3s  0 unparse
  echo-judge   0.867  17.4s  0 unparse
  echo-oracle  0.933  14.0s  0 unparse

vs contaminated 20260625T013951Z_bbh_n30.jsonl:
  sonnet-only was 0.833 @ 40.7s with 3 unparseables — the contamination
  cost Sonnet ~7 pts and 4x latency, and had pushed it BELOW Haiku.

Clean, Sonnet (0.900) edges Haiku (0.867) — direction restored — and
zero unparseables across all arms. Separation is +1 task at n=30, so
still needs a clean n=99 to be decisive; committing the datapoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full n=99 (3 subtasks × 33) on the --setting-sources "" fixed harness.

Clean n=99:
  haiku-only   0.848  11.3s  0 unparse
  sonnet-only  0.848  10.2s  0 unparse
  echo-judge   0.869  16.8s  0 unparse
  echo-oracle  0.899  14.3s  0 unparse

vs contaminated 20260625T014218Z_bbh_n99.jsonl (sonnet-only 0.657 @ 53s,
20 unparseables): the contamination cost Sonnet ~19 pts and 5x latency.

Findings:
- Sonnet-vs-Haiku separation: NONE on this slice. Both 0.848, and
  identical per-subtask (causal 22/33, date 29/33, logical 33/33). These
  subtasks are near-ceiling and don't discriminate the two models. A
  decisive separation needs harder subtasks (logical_deduction_seven, etc).
- Complementarity IS real despite the aggregate tie: echo-oracle 0.899 >
  both baselines because Haiku and Sonnet miss DIFFERENT items (oracle
  recovers causal_judgement 27/33 vs baselines' 22/33). That gap is the
  headroom Echo routing targets.
- echo-judge 0.869 edges above both baselines but leaves ~3 pts of the
  oracle headroom uncaptured — the real judge isn't yet as good as the
  perfect router. This is where cross-family judges (#619) come in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Experiment A: tested whether HARDER BBH subtasks reveal a Sonnet-vs-Haiku
separation that the near-ceiling easy slice couldn't (n=33 x 3, clean
--setting-sources "" harness).

Subtasks chosen as the reasoning-heavy "hard" siblings:
  logical_deduction_seven_objects, tracking_shuffled_objects_seven_objects,
  temporal_sequences

Result — every subtask is a CEILING for BOTH model tiers:
  haiku-only   99/99  1.000
  sonnet-only  99/99  1.000
  echo-judge   98/99  0.990  (one routing miss)
  echo-oracle  99/99  1.000
Per-subtask: haiku & sonnet BOTH 33/33 on all three.

KEY FINDING: current Haiku already aces the MCQ BBH subtasks that used to
discriminate model tiers (incl. 7-object state-tracking). So:
- No Sonnet-vs-Haiku accuracy separation exists to route on.
- No oracle->echo-judge headroom (1-task noise gap) for a better judge to
  close, so the OpenAI cross-family judge run (Experiment B) was correctly
  NOT launched — nothing to capture.

Implication: accuracy-based routing value cannot be demonstrated on MCQ BBH
with 2026 Claude models. Forward paths: (1) extend harness to score
free-form subtasks (word_sorting/dyck_languages/multistep_arithmetic — the
genuinely hard ones, currently unscoreable), (2) move to a non-saturated
benchmark (MMLU-Pro #620, GPQA), or (3) reframe Echo's value from accuracy
to cost/latency-neutral routing ("route to cheap, lose nothing").

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

Rigorous check that the HumanEval headline predates/escaped the project-context
contamination. Re-ran haiku-only over HumanEval 100-163 on the fixed harness
(--setting-sources ""): 63/64 = 0.984, EXACTLY the blog's published baseline.

An exact reproduction is only possible if the contamination never affected the
HumanEval runs (the task-restore SessionStart hook postdated them; coherent high
pass rates were incompatible with the contamination signature anyway). So the
blog's HumanEval headline foundation is sound. (sonnet-only / echo-judge /
echo-small-judge not yet re-run this pass — baseline match is the decisive
contamination test.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant