fix(tasks): stop indexing a rollout key that is absent on disk - #70
Conversation
`build_prediction_record` spells "could not extract" as `prediction=None`,
and `obj_to_dict` drops None-valued keys, so the key is *absent* on disk —
not null, gone. On resume the loader hydrates `postprocess_result` from
disk and hands it to `feedback`, where `rollout["prediction"]` raises
KeyError for exactly the samples whose extraction failed. The same line is
fine on a fresh run, which is why every in-memory test passes.
Reproduced against production code: feeding `GSM8KZeroShotGenTask.feedback`
a record that has been through the real serializer raises
`KeyError('prediction')`, while the in-memory record scores normally.
39 sites across 38 task modules were indexing it. All become
`.get("prediction")` — exactly equivalent when the key is present, and
otherwise yielding the same None the fresh path produced, so the resumed
path now behaves identically to the fresh one. No behaviour change on a
fresh run.
The interesting part is why nothing caught this. The contract was already
stated three times over: `RolloutPrediction.prediction` is declared
`NotRequired`, its docstring says "absent on disk in that case, so read
`extracted` instead", and `test_records.py::TestSerializationRoundTrip`
pins the behaviour. But neither `ty` nor `mypy --strict` reports a `[]`
subscript of a `NotRequired` key — both accept it by design, the typing
spec leaves that diagnostic optional — so a correctly declared,
correctly documented, unit-tested contract had no enforcement at the call
site, and 38 modules drifted off it with CI green throughout.
So the fix is the enforcer, not just the 39 edits:
`check_preflight.py --check check_record_key_access`. It is structural
rather than name-based — a key is flagged only when its base provably comes
from a record's `rollouts`, indexed or iterated — so t_eval's task-local
`datum["prediction"]` (sibling: `ground_truth`) is correctly left alone.
Scope is `_GATED_ROLLOUT_KEYS`; `_UNGATED_ROLLOUT_KEYS` records why `extra`
/ `score` / `metrics` are different in kind (absence there is an authoring
choice, and every read is nested, so a mechanical `.get()` would swap
KeyError for TypeError — strictly worse). A NotRequired rollout key in
neither set fails the check, so adding one to `records.py` forces the call.
Adjacent staleness fixed in passing: `.claude/commands/sieval-preflight.md`
was already missing `check_task_shot_knobs` from its check list.
Verified: the guard fails with exactly 39 violations on the parent commit
and passes after. ruff, ruff format, ty, and full preflight clean; 2999
unit + integration + acceptance tests pass (13 new, covering both rollout
shapes, the non-record false positive, the ungated keys, and the
unclassified-key failure).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…classification Review follow-ups on the guard itself, not on the 39 call-site fixes. `enumerate` / `zip` were blind spots. `_rollout_bound_names` only bound a name when the `for` target was a bare `ast.Name` and the iterable was *directly* a rollout container, so `for i, r in enumerate(post["rollouts"])` -- the natural way to write feedback for a task that needs the rollout index -- passed the check while reintroducing the exact same KeyError. Now tuple targets bind every `Name` element, and `_rollout_container` unwraps the pass-through builtins (`enumerate`/`zip`/`reversed`/`list`/`sorted`) plus a walrus. Probe of twelve shapes: 5 flagged before, 8 now. The four that remain all need the rollout list to travel through a *name* first (`rs = post["rollouts"]`, a helper parameter), which needs dataflow rather than a syntactic walk; they are documented as deliberate limits and one is pinned by a test. Classification now spans all five record TypedDicts, not just the two rollout ones. `JudgementRecord.reference` shares `prediction`'s exact shape -- `build_judgement_record` writes it unconditionally, so `None` is present in memory and dropped on disk -- yet it sat in neither set, so nothing forced a decision about it and nothing would force one for a key added to `PromptRecord` either. It is classified as ungated, with the reason: the one `[]` read (ruler's report) consumes it as an iterable, so a mechanical `.get()` would swap KeyError for a `list(None)` TypeError, the same trap as `extra`'s nested reads. Gating a key whose reads are not rollout subscripts would be inert and would read as coverage the check does not have. What `reference` actually lacks is a durable signal for *which* of its two absence causes applies; that is tracked separately. An unreadable `records.py` now FAILs instead of SKIPping. It is the check's subject, not an optional input -- renaming it would otherwise narrow the gate to nothing with preflight still green, the same silent narrowing the unclassified-key branch exists to prevent. Also drops a duplicate integration test whose body was identical to its sibling. Verified: guard still reports exactly 39 violations on the parent commit and PASS here; the new tests fail when the tuple-target handling is reverted; ruff, `ty`, 3005 tests and full preflight all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing issue The `_UNGATED_RECORD_KEYS` comment said "see the issue linked from `check_record_key_access`", but no issue was linked there — a dangling cross-reference. Link #71 directly instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7219e25 to
f638ccb
Compare
|
Field evidence for this, in case it helps with priority: it fires in production, not Hit it today verifying #66 on a full 1,000-question run. Re-grading the LiveCodeBench lane Two details that support the framing in the description:
I had opened #72 for the single lane before finding this PR — closed as a duplicate. Its |
Comment-only. The prose had drifted into arguing the case rather than recording the reasons that are not derivable from the code — the `reference` entry alone ran fourteen lines re-deriving what `records.py` already states. Each comment keeps its load-bearing "why" and drops the restatement: net -23 lines. Behaviour is unchanged and verified so: the twelve-shape probe still flags the same 8, the guard still reports exactly 39 violations on the parent commit, 3016 tests pass and full preflight exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s prompt cohort Review follow-ups on the five new competitions, plus the two already-merged siblings that turned out to share the same conditions. 1. `list_answer` is now derived from the gold, as upstream's grader.py does. The branch was already vendored in `find_last_boxed_content`, but unreachable: `extract_boxed_answer` never passed the flag and `extract_answer` did not expose it. So sieval carried upstream's list logic and could never run it, while the BRUMO/SMT notes described the resulting gap as an inherent limitation. Both wrappers now take the flag and all 9 matharena-sourced tasks derive it the way `grader.py:178` does — a comma in the gold. Measured by replaying upstream's own stored rollouts: brumo_2025 98.30% -> 98.83% 28/5,280 moved (all problem 23) hmmt_feb_2025 99.39% -> 99.60% 16/7,680 moved (all problem 10) smt_2025 99.13% -> 99.13% 0/10,875 (every model boxed the whole list) cmimc/apex/shortlist unchanged — no comma golds, so the flag is always False 44 rollouts changed verdict, all 44 toward upstream and none away. `hmmt_feb_2025` had the same comma gold as the two new sets and no note; it has one now. 2. PROMPT COHORT recorded on all seven affected ports. Upstream changed its `instruction` string and did not re-run the earlier rows, so the published tables mix two prompts and the port only sends one of them: brumo 5/44 models on the ported instruction (600/5,280 rollouts) smt 5/43 (1,060/10,875) cmimc 4/35 (640/5,600) — 1,088 predate the `### Final answer` section apex 25/46 (3,830/7,717) apex_shortlist 30/42 (4,813/9,659) hmmt_feb_2025 5/64 (600/7,680) hmmt_nov_2025 5/22 (600/2,640) `hmmt_feb_2026` (30/30) and `aime_2026` (30/30) are clean and get no clause. The positioning is unchanged — sieval tracks the pinned config — so this is stated as a property of the comparison, not a defect. It does bound what a live delta means: every row in this branch's alignment table, including BRUMO's +3.3 pp, was produced under the older prefix. 3. `apex_shortlist_2025` loader docstring still said the overlap was three. 377299e corrected it to five in the task notes and index.json but not here. Tests: 4 extractor cases for the list branch, plus a family-level check that each matharena task derives the flag from the gold and degrades to upstream's default when `raw_sample` is absent (the resume path #70 lived on). Verified discriminating by reverting one task. 3,156 pass; ruff, ty and all 22 preflight checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rollout key These five were authored in parallel with #70, so they landed the same defect that PR had just removed from 38 other modules: `rollout["prediction"]` raises KeyError on resume, because `build_prediction_record` spells "could not extract" as `prediction=None` and `obj_to_dict` drops None-valued keys — the key is gone from disk, not null. A fresh run never sees it, which is why every unit test and both type checkers stayed green. Caught by rebasing onto main: #70 shipped `check_preflight.py --check check_record_key_access` as the enforcer, and it flagged all five on the first run. That is the enforcer working exactly as intended on code written after the contract but before the check. Fix is `.get("prediction")`, identical to the 39 sites #70 converted — equivalent when the key is present, and otherwise yielding the same None the fresh path produced. Not hypothetical for this branch: the live reproduction run resumed 11 stream-dropped samples through `feedback`. It survived only because a rolled-back sample re-runs postprocess in memory rather than hydrating it from disk; a sample resumed from a persisted postprocessed record with a failed extraction would have crashed. Two such rollouts exist in that run's output (apex #2, shortlist #25). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s prompt cohort Review follow-ups on the five new competitions, plus the two already-merged siblings that turned out to share the same conditions. 1. `list_answer` is now derived from the gold, as upstream's grader.py does. The branch was already vendored in `find_last_boxed_content`, but unreachable: `extract_boxed_answer` never passed the flag and `extract_answer` did not expose it. So sieval carried upstream's list logic and could never run it, while the BRUMO/SMT notes described the resulting gap as an inherent limitation. Both wrappers now take the flag and all 9 matharena-sourced tasks derive it the way `grader.py:178` does — a comma in the gold. Measured by replaying upstream's own stored rollouts: brumo_2025 98.30% -> 98.83% 28/5,280 moved (all problem 23) hmmt_feb_2025 99.39% -> 99.60% 16/7,680 moved (all problem 10) smt_2025 99.13% -> 99.13% 0/10,875 (every model boxed the whole list) cmimc/apex/shortlist unchanged — no comma golds, so the flag is always False 44 rollouts changed verdict, all 44 toward upstream and none away. `hmmt_feb_2025` had the same comma gold as the two new sets and no note; it has one now. 2. PROMPT COHORT recorded on all seven affected ports. Upstream changed its `instruction` string and did not re-run the earlier rows, so the published tables mix two prompts and the port only sends one of them: brumo 5/44 models on the ported instruction (600/5,280 rollouts) smt 5/43 (1,060/10,875) cmimc 4/35 (640/5,600) — 1,088 predate the `### Final answer` section apex 25/46 (3,830/7,717) apex_shortlist 30/42 (4,813/9,659) hmmt_feb_2025 5/64 (600/7,680) hmmt_nov_2025 5/22 (600/2,640) `hmmt_feb_2026` (30/30) and `aime_2026` (30/30) are clean and get no clause. The positioning is unchanged — sieval tracks the pinned config — so this is stated as a property of the comparison, not a defect. It does bound what a live delta means: every row in this branch's alignment table, including BRUMO's +3.3 pp, was produced under the older prefix. 3. `apex_shortlist_2025` loader docstring still said the overlap was three. 377299e corrected it to five in the task notes and index.json but not here. Tests: 4 extractor cases for the list branch, plus a family-level check that each matharena task derives the flag from the gold and degrades to upstream's default when `raw_sample` is absent (the resume path #70 lived on). Verified discriminating by reverting one task. 3,156 pass; ruff, ty and all 22 preflight checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pex Shortlist) (#73) * feat(tasks): add 5 MathArena competitions (BRUMO, SMT, CMIMC, Apex, Apex Shortlist) Adds datasets + 0-shot generative tasks for five MathArena final-answer competitions, cloning the existing AIME/HMMT pass@k shape. Takes the pass@k math family from 8 to 13 members; no new dependencies. brumo_2025 30 problems Brown University Math Olympiad smt_2025 53 Stanford Math Tournament cmimc_2025 40 Carnegie Mellon Informatics and Math Competition apex_2025 12 MathArena-curated, very hard for models apex_shortlist_2025 47 MathArena-curated, ~50% for frontier models All five pin their HF snapshot and cite their upstream competition config at the same matharena commit the HMMT ports already reference. Validated against MathArena's published outputs, the bar HMMT Nov 2025 set: replaying the five `MathArena/*_outputs` datasets (39,131 rollouts) through each task's real extraction + grading reproduces upstream's recorded `correct` on 98.3 / 99.1 / 99.1 / 99.9 / 99.4% — inside the 96.2-99.7% band the four already-shipped ports occupy (re-measured on 17,367 rollouts), with four of five above the family's worst. Per-task figures live in reference_impl.notes. `HMMT_INSTRUCTION` is renamed `BOXED_INSTRUCTION`: seven upstream configs carry that string byte-identical, so four new tasks would otherwise import an HMMT-named constant. New `CMIMC_INSTRUCTION` covers the one ported competition whose instruction differs (it mandates a `### Final answer` section). The string each existing task sends is byte-identical, so no score moves. Also documented, verified by exact problem-text match: upstream curates Apex from other 2025 contests, so apex_2025 shares 3 of 12 problems with smt_2025 and apex_shortlist_2025 shares 3 of 47 with brumo_2025 / hmmt_feb_2025 — evaluating those together scores the shared problems twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tasks): Apex 2025 publishes at n=16, not the family's n=4 The task's reference_impl.notes told users to set n=4 to compare against matharena.ai. Measured over MathArena/apex_2025_outputs, that is wrong for Apex: 35 of 46 scored models are run at 16 samples/problem, 10 at 8, and only one at 4 — a 12-problem set needs the extra samples to produce a usable score. The other four competitions added in the previous commit are n=4 as documented (BRUMO and CMIMC uniformly; SMT and Apex Shortlist for the large majority). Adds examples/leaderboard-matharena.yaml, which runs the five new benchmarks at their published repeat counts and records the two per-model things a leaderboard comparison also has to match (temperature/top_p/max_tokens come from the model's own matharena config, and the model must be one MathArena actually scored). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(examples): record the long-stream failure mode in the MathArena config Measured while reproducing MathArena's GPT OSS 120B numbers through an OpenAI-compatible gateway. Three things cost real time and none of them were written down anywhere: sieval asks for all `n` rollouts in ONE request where matharena issues `n` separate ones, so with a reasoning model the single response is n x longer and a gateway that drops a long stream mid-body kills the whole sample (`RemoteProtocolError: peer closed connection without sending complete message body`). The OpenAI client's `max_retries` cannot retry that — it only covers failures before the response body starts — so only `max_iterations` recovers. Failure rate tracked response length: 0/30 and 0/53 failed at ~17-21k output tokens/sample, 3/40 at ~29k, 6/12 at Apex's n=16 (~679k). And the sharp edge: a failed sample scores 0 rather than abstaining, because report() keeps it in the denominator. Apex reported 0.5 against an official 1.0 purely because 6 of 12 samples died; on the 6 that completed it matched exactly. So `fails` has to be read before any score is compared to a published number, and a non-zero count means recomputing over the completed problems and comparing against the official number on that same subset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(examples): name the right recovery path for dropped streams The failure-mode note I added in c0fcc79 got two things wrong. `max_iterations` does not recover a dropped stream. A stage exception is terminal within a run (runner.py: `except Exception -> ctx.to_failed`); `max_iterations` bounds the feedback/iterate loop, and these tasks finalize on the first pass, so it was dead config. The actual recovery is re-invoking with `auto_resume: true`, which sends every retriable failure through the loader's `_prepare_failed_retries` -> rolled back to its pre-infer stage with `retry_count+1`, bounded by `runner_config.max_retries` (a different knob from the per-model one). Verified against this run's on-disk records: all 9 checked came back `preprocessed rc=1`, since `exception::RemoteProtocolError` is not in `ERROR_REASONS_NON_RETRIABLE`. The token figures also mixed units — per-rollout for three tasks, per-request for Apex — and omitted apex_shortlist, which also had failures. Replaced with one table in consistent tok/request, recomputed from the finished run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(datasets): apex_shortlist is a sibling tier, not the pool Apex came from The shortlist loader's docstring claimed it was "the shortlist Apex was drawn from". It is not. The two sets are disjoint: no shared problem at exact, normalized, or fuzzy (>=0.80) statement match, no shared answer string, and not one shared entry between their `source` columns (12 vs 47, zero intersection). Neither HF dataset card states the relationship, so the claim was an inference from the name. MathArena's own Apex writeup gives the real one: both sets come off the same 2025-contest sweep, split by difficulty — Apex kept only problems that Grok 4, GPT-5 (High), Gemini 2.5 Pro and GLM 4.5 all failed across 4 attempts (~100 competitions reviewed, 12 survived), while the shortlist is the companion band where SOTA models score ~50%. Also records the adversarial filter and its stated model bias in the Apex docstring, and cross-links the two so the naming does not re-invite the subset assumption. The 3+3 byte-identical sibling overlaps already documented (apex -> smt_2025 8/42/43; shortlist -> brumo_2025 30 and hmmt_feb_2025 19/20) are unaffected and independently confirmed by the `source` columns. meta/index.json unchanged: module docstrings are not embedded there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): apex_shortlist duplicates 5 sibling problems, not 3 Byte-equality undercounted the overlap. Reconciling every `source` string in both Apex sets against the sibling datasets sieval actually ships turns up two more duplicates in the shortlist: problems 25/26 are AIME 2025 P14/P15, matching aime_2025 problems 14/15 at 0.992/0.970 after normalization — identical statements with identical golds (60, 735), differing only because that loader mirrors opencompass/AIME2025, which re-typesets. A run covering both scores them twice, exactly as the three byte-identical ones do. The same reconciliation clears two false candidates: the shortlist attributes problems 4 and 5 to the HMMT 2025 team round, which is not in hmmt_feb_2025 (individual rounds only, 30 problems) — statement match 0.43/0.34, not duplicates. And it confirms apex_2025's three SMT overlaps are the complete set there. Both notes now also state that the two Apex sets are disjoint, since the naming invites the opposite assumption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): the 5 new MathArena tasks reintroduced the absent-on-disk rollout key These five were authored in parallel with #70, so they landed the same defect that PR had just removed from 38 other modules: `rollout["prediction"]` raises KeyError on resume, because `build_prediction_record` spells "could not extract" as `prediction=None` and `obj_to_dict` drops None-valued keys — the key is gone from disk, not null. A fresh run never sees it, which is why every unit test and both type checkers stayed green. Caught by rebasing onto main: #70 shipped `check_preflight.py --check check_record_key_access` as the enforcer, and it flagged all five on the first run. That is the enforcer working exactly as intended on code written after the contract but before the check. Fix is `.get("prediction")`, identical to the 39 sites #70 converted — equivalent when the key is present, and otherwise yielding the same None the fresh path produced. Not hypothetical for this branch: the live reproduction run resumed 11 stream-dropped samples through `feedback`. It survived only because a rolled-back sample re-runs postprocess in memory rather than hydrating it from disk; a sample resumed from a persisted postprocessed record with a failed extraction would have crashed. Two such rollouts exist in that run's output (apex #2, shortlist #25). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(matharena): apply upstream's list_answer rule, record each table's prompt cohort Review follow-ups on the five new competitions, plus the two already-merged siblings that turned out to share the same conditions. 1. `list_answer` is now derived from the gold, as upstream's grader.py does. The branch was already vendored in `find_last_boxed_content`, but unreachable: `extract_boxed_answer` never passed the flag and `extract_answer` did not expose it. So sieval carried upstream's list logic and could never run it, while the BRUMO/SMT notes described the resulting gap as an inherent limitation. Both wrappers now take the flag and all 9 matharena-sourced tasks derive it the way `grader.py:178` does — a comma in the gold. Measured by replaying upstream's own stored rollouts: brumo_2025 98.30% -> 98.83% 28/5,280 moved (all problem 23) hmmt_feb_2025 99.39% -> 99.60% 16/7,680 moved (all problem 10) smt_2025 99.13% -> 99.13% 0/10,875 (every model boxed the whole list) cmimc/apex/shortlist unchanged — no comma golds, so the flag is always False 44 rollouts changed verdict, all 44 toward upstream and none away. `hmmt_feb_2025` had the same comma gold as the two new sets and no note; it has one now. 2. PROMPT COHORT recorded on all seven affected ports. Upstream changed its `instruction` string and did not re-run the earlier rows, so the published tables mix two prompts and the port only sends one of them: brumo 5/44 models on the ported instruction (600/5,280 rollouts) smt 5/43 (1,060/10,875) cmimc 4/35 (640/5,600) — 1,088 predate the `### Final answer` section apex 25/46 (3,830/7,717) apex_shortlist 30/42 (4,813/9,659) hmmt_feb_2025 5/64 (600/7,680) hmmt_nov_2025 5/22 (600/2,640) `hmmt_feb_2026` (30/30) and `aime_2026` (30/30) are clean and get no clause. The positioning is unchanged — sieval tracks the pinned config — so this is stated as a property of the comparison, not a defect. It does bound what a live delta means: every row in this branch's alignment table, including BRUMO's +3.3 pp, was produced under the older prefix. 3. `apex_shortlist_2025` loader docstring still said the overlap was three. 377299e corrected it to five in the task notes and index.json but not here. Tests: 4 extractor cases for the list branch, plus a family-level check that each matharena task derives the flag from the gold and degrades to upstream's default when `raw_sample` is absent (the resume path #70 lived on). Verified discriminating by reverting one task. 3,156 pass; ruff, ty and all 22 preflight checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(matharena): the prompt cohort does not explain BRUMO's delta — measured Ran the A/B the review asked for: BRUMO 2025, gpt-oss-120b at reasoning_effort=high, n=4, one gateway and one session, both arms config-identical except the instruction. old prefix ("Please reason step by step, and ...") 90.83 ported instruction (what this task sends) 90.00 official 92.50 The prompt is worth +0.83 pp. Four of thirty problems moved and two of those went each way, giving t=0.37 over the per-problem differences (SE 2.24 pp, df=29). That is indistinguishable from resampling, so the cohort split — real, and still worth recording — is not an explanation for a delta of any size. brumo's note now carries the measurement; the other six say the magnitude is known only from this one set rather than asserting an unquantified confound. Two things the run also settles, neither of which changes code: * The saturation argument holds and is if anything understated. Only 4-5 of the 30 problems are non-degenerate at n=4 (24/30 and 25/30 come back 4/4), so the score is carried by a handful of problems and one problem is worth 3.33 pp. Computing sigma_D from the observed per-problem binomial spread gives 2.20 pp against the 2.57 pp the PR states — same ballpark, PR's is the conservative one. * Neither arm reproduces this branch's own earlier 95.8. Both land near 90, i.e. 2.5 pp *below* official where the earlier run was 3.3 pp above, a 5.8 pp gap at 2.6 sigma_D between two sieval runs of the same cell. That run used max_tokens=32768 and a set temperature; this one used 131072 and left temperature unset to match upstream's oss-120b.yaml. So the gap is decoding config, not sampling — which is the sharper form of "this set cannot rank models". Raw arms, configs and log: /volume/ai-infra/ylsun/brumo-prompt-ab (out of tree). 3,156 tests pass; ruff, project-wide ty and all 22 preflight checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(matharena): condense the nine notes blocks, no measured fact dropped The notes had grown to 2,232 words across the nine matharena tasks, and most of the growth was restatement rather than content: the PROMPT COHORT tail was 54 identical words in seven files and the REPEATS clause another 44, each paraphrased slightly differently in every copy. Rewrote the shared clauses once and applied them identically, then compressed the per-set prose. 2,232 -> 1,761 words (-21%). Every measured figure survives; verified mechanically by diffing the numeric tokens of each notes block against HEAD (thousands separators normalized) rather than by eye -- no number is lost or invented in any of the nine. The four already-merged siblings (hmmt_feb_2025, hmmt_nov_2025, aime_2026, hmmt_feb_2026) are included deliberately. They share the REPEATS and DEVIATION clauses with the five new ports; shortening only the new files would leave two wordings of the same fact in the same directory. meta/index.json embeds reference_impl.notes, so it is regenerated here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(readme): drop the example config and stop enumerating benchmarks Two things that both amount to "do not keep a roster in two places". `examples/leaderboard-matharena.yaml` is removed: it was added by this branch and should not have been. Nothing referenced it but itself, and the net diff against main now shows no change under `examples/`. What it carried that is not recorded elsewhere is the long-stream failure mode — sieval issues all `n` rollouts as one streamed request where matharena issues `n`, so tokens/request scales with `n` and a gateway that hangs up mid-body fails the whole sample, which `max_retries` cannot retry; plus the measured tok/request table and the warning that a failed sample scores 0 rather than abstaining. That is runner behaviour rather than per-task metadata, so it wants its own home rather than a silent reinstatement here. README no longer lists benchmark names. The Features bullet named seven math sets and would have needed an edit for every future one; it now names the six top-level categories of the shipped taxonomy (`Level1Category`), which changes only when the taxonomy does, and points at `sieval dataset list` / `sieval task list` / `sieval task show <name>` for what a given build actually ships. The `[math]` extra comment described its ten current consumers; it now describes what the extra provides. The remaining extras are one-to-one with their benchmark and are unchanged. The `DEPS_GROUP` pointer already below the code block is what makes the enumeration redundant in the first place. ruff clean, all 22 preflight checks pass (`check_examples` now covers 6 files), 3,026 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type
Summary
rollout["prediction"]raisesKeyErroron resume.build_prediction_recordspells "could not extract" asprediction=None;obj_to_dictdropsNone-valued keys; so on disk the key is absent, not null. The loader hydratespostprocess_resultfrom disk and hands it tofeedback, which thenKeyErrors for exactly the samples whose extraction failed. The identical line is fine on a fresh run — which is why every in-memory test passes..get("prediction"): exactly equivalent when the key is present, and otherwise yielding the sameNonethe fresh path produced. No behaviour change on a fresh run — the resumed path simply stops diverging from it.check_preflight.py --check check_record_key_access.predictiononly;_UNGATED_ROLLOUT_KEYSrecords whyextra/score/metricsare different in kind.Related Issues
Surfaced while reviewing #65 (PlatinumBench), whose task uses
.get("prediction")with a three-line comment explaining why. It is not the first:sieval/tasks/ruler_0shot_gen.py:261-264is already onmainwith the same.get()and the same three-line explanation, on its resume-report path. That sharpens the argument rather than softening it — the hazard was documented in prose inside a merged module, and 39 other modules drifted off it anyway. Documentation of the mechanism is not enforcement of it. Refs #65.Suggested merge order: this PR before #65. #65 is unaffected either way (it is already compliant), but landing the guard first means it merges into a tree where the rule is enforced rather than one where it is a comment.
Why nothing caught this
This is the part worth reviewing, more than the 39 one-line edits.
The contract was already stated three times over:
RolloutPrediction.predictionis declaredNotRequired[JSONValue | None]— the type does not lie.extractedinstead", andextractedis labelled "The durable signal."test_records.py::TestSerializationRoundTrip::test_none_prediction_is_absent_but_extracted_survivesalready pins the exact behaviour, and passes.records.pyis also defensive about the same hazard elsewhere:_checked_metricshard-rejects aNonemetric because "aNonemetric would be absent on disk, turning 'not measured' into 'never existed'".And yet 38 modules drifted off it with CI green the whole time, because neither type checker reports a
[]subscript of aNotRequiredkey:ty→All checks passed!mypy --strict→Success: no issues found in 1 source fileBoth are behaving per spec — the typing spec leaves that diagnostic optional (pyright has
reportTypedDictNotRequiredAccess, not used here). So the root cause is not the serializer droppingNone, which is a deliberate, documented, tested design. It is that a correctly declared, correctly documented, unit-tested contract had no enforcement at the call site. Documentation and a test of the mechanism did not stop 38 violations of it.The check
check_record_key_accessis structural, not name-based: a key is flagged only when its base provably comes from a record'srollouts— either indexed (post["rollouts"][0][...]) or iterated (for r in post["rollouts"], comprehensions, and the.get("rollouts", [])report-stage idiom). That precision matters:t_eval_before_calling_0shot_gen.pyhas a task-localdatum["prediction"]whose sibling key isground_truth. It is not a record, absence there is a genuine bug, and a name-based rule would have forced a.get()that silently swallows it. It is correctly left untouched.Gated set is
predictionalone._UNGATED_ROLLOUT_KEYSnames the rest with reasons rather than ignoring them:extra— absence is an authoring choice, not a runtime outcome: the task readingr["extra"]["grade"]is the same task that wrote that extra, unconditionally. And all 11 reads are nested, so a mechanical.get()would turnKeyErrorintoNoneType is not subscriptable— strictly worse. Needs a per-site pass, not a sweep.score/metrics— same authoring argument, plusRolloutJudgementstates "absent is not zero", so a.get()returningNonebecomes a wrong metric instead of a loud failure.A
NotRequiredrollout key in neither set makes the check FAIL rather than be silently skipped, so adding one torecords.pyforces the classification. That is what keeps the gate from quietly narrowing over time.Test Plan
Automated
ruff check && ruff format --check, 370 files)ty check)scripts/check_preflight.py, exit 0), including the newcheck_record_key_accessNew tests cover both rollout shapes (indexed / iterated / comprehension /
.get("rollouts")), the.get()form passing, Required keys not being flagged, the ungated keys not being flagged, the non-recorddatumfalse-positive case, an unparsable module, a missingrecords.py, and the unclassified-key failure.Manual
Reproduced the bug against production code, not a mock: feeding
GSM8KZeroShotGenTask.feedbacka prediction record that has been through the real serializer raisesKeyError('prediction'), while the in-memory record scores normally.The guard discriminates: it reports exactly 39 violations on the parent commit and PASS after the edits. Not a check that trivially passes.
Confirmed on a real run's shard data that the key is genuinely absent on disk — a stored
postprocessedrecord for a truncated sample reads{"index": 0, "extracted": false}.Trigger window characterised (narrower than "any resume"): needs a sample that both failed extraction and was interrupted between its
postprocessandfeedbackrecords landing. Reachable in normal operation — at interruption, samples are spread across stages — and likeliest on tasks that truncate often, which are also the long-running ones most likely to be interrupted.Checklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)in module docstring — touched files already carry theirs; no new modules addedcore/(core/is untouched)If: Breaking Change
Not a breaking change.
.get()returns the same value whenever[]would have succeeded; it only replaces a crash with the value the fresh path already produced.If: New Dependency
Not applicable — no new dependencies.
🤖 Generated with Claude Code
Review follow-ups —
289756d3Second commit, all on the guard itself; the 39 call-site fixes are unchanged.
enumerate/zipwere blind spots._rollout_bound_namesonly bound a name when thefortarget was a bareast.Nameand the iterable was directly a rollout container, sofor i, r in enumerate(post["rollouts"])— the natural way to write feedback for a task needing the rollout index — passed the check while reintroducing the identicalKeyError. Tuple targets now bind everyNameelement, and_rollout_containerunwraps the pass-through builtins (enumerate/zip/reversed/list/sorted) and a walrus. Probe of twelve shapes: 5 flagged before, 8 now. The remaining four all need the rollout list to pass through a name first (rs = post["rollouts"], a helper parameter) — dataflow, not a syntactic walk — and are documented as deliberate limits, with one pinned by a test.JudgementRecord.referencesharesprediction's exact shape (build_judgement_recordwrites it unconditionally, soNoneis present in memory and dropped on disk) yet sat in neither set — nothing forced a decision on it, and nothing would have forced one for a key added toPromptRecordeither. Now classified ungated, with the reason recorded: the single[]read (ruler's report) consumes it as an iterable, so a mechanical.get()would swapKeyErrorfor alist(None)TypeError— the same trap asextra's nested reads. Gating a key whose reads are not rollout subscripts would be inert and would read as coverage the check does not have.records.pynow FAILs instead of SKIPping. It is the check's subject, not an optional input: renaming it would otherwise narrow the gate to nothing with preflight still green — the same silent narrowing the unclassified-key branch exists to prevent.What
referenceactually lacks is a durable signal for which of its two absence causes applies (task truth is a procedure vs. this sample's gold is missing) —predictionhasextractedplusdetect_extraction_failurefor exactly this,referencehas nothing. Out of scope here; tracked in #71.Verification
assert 'PASS' == 'FAIL'on bothenumerate/zipcases).ruff check+ruff formatclean;ty checkclean.ee30abe6(feat(livecodebench): replace the whole-suite timeout with upstream's per-case rule #66), fullscripts/check_preflight.pyexit 0,tests/performance66 passed.ee30abe6(feat(livecodebench): replace the whole-suite timeout with upstream's per-case rule #66). feat(livecodebench): replace the whole-suite timeout with upstream's per-case rule #66 rewrote both livecodebench task modules; verified after the rebase that their.get("prediction")survived at the new line numbers, that feat(livecodebench): replace the whole-suite timeout with upstream's per-case rule #66's per-case timeout logic is intact, and that commit 1's stat is byte-identical to its pre-rebase form (43 files, 416/42). 39 task modules, 39.get("prediction")sites, and the only remaining["prediction"]in the tree is t_eval's intentional task-localdatum.