Skip to content

fix(tasks): stop indexing a rollout key that is absent on disk - #70

Merged
ethan-scitix merged 4 commits into
mainfrom
fix/notrequired-record-access
Aug 6, 2026
Merged

fix(tasks): stop indexing a rollout key that is absent on disk#70
ethan-scitix merged 4 commits into
mainfrom
fix/notrequired-record-access

Conversation

@ethan-scitix

@ethan-scitix ethan-scitix commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Type

  • fix — bug fix or alignment correction

Summary

  • rollout["prediction"] raises KeyError on resume. build_prediction_record spells "could not extract" as prediction=None; obj_to_dict drops None-valued keys; so on disk the key is absent, not null. The loader hydrates postprocess_result from disk and hands it to feedback, which then KeyErrors for exactly the samples whose extraction failed. The identical line is fine on a fresh run — which is why every in-memory test passes.
  • 39 sites across 39 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. No behaviour change on a fresh run — the resumed path simply stops diverging from it.
  • The real fix is the enforcer. The contract was already correct in three places and still nothing stopped the drift (below), so this adds check_preflight.py --check check_record_key_access.
  • Scope is deliberately prediction only; _UNGATED_ROLLOUT_KEYS records why extra / score / metrics are 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-264 is already on main with 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:

  1. RolloutPrediction.prediction is declared NotRequired[JSONValue | None] — the type does not lie.
  2. Its docstring: "absent on disk in that case, so read extracted instead", and extracted is labelled "The durable signal."
  3. test_records.py::TestSerializationRoundTrip::test_none_prediction_is_absent_but_extracted_survives already pins the exact behaviour, and passes.

records.py is also defensive about the same hazard elsewhere: _checked_metrics hard-rejects a None metric because "a None metric 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 a NotRequired key:

class Rollout(TypedDict):
    index: int
    prediction: NotRequired[str | None]

def unsafe(r: Rollout) -> object:
    return r["prediction"]      # may KeyError at runtime
  • tyAll checks passed!
  • mypy --strictSuccess: no issues found in 1 source file

Both 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 dropping None, 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_access is structural, not name-based: a key is flagged only when its base provably comes from a record's rollouts — 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.py has a task-local datum["prediction"] whose sibling key is ground_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 prediction alone. _UNGATED_ROLLOUT_KEYS names the rest with reasons rather than ignoring them:

  • extra — absence is an authoring choice, not a runtime outcome: the task reading r["extra"]["grade"] is the same task that wrote that extra, unconditionally. And all 11 reads are nested, so a mechanical .get() would turn KeyError into NoneType is not subscriptable — strictly worse. Needs a per-site pass, not a sweep.
  • score / metrics — same authoring argument, plus RolloutJudgement states "absent is not zero", so a .get() returning None becomes a wrong metric instead of a loud failure.

A NotRequired rollout key in neither set makes the check FAIL rather than be silently skipped, so adding one to records.py forces the classification. That is what keeps the gate from quietly narrowing over time.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check, 370 files)
  • Type check clean (ty check)
  • Unit + integration + acceptance: 2999 passed (13 new)
  • Full preflight clean (scripts/check_preflight.py, exit 0), including the new check_record_key_access

New 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-record datum false-positive case, an unparsable module, a missing records.py, and the unclassified-key failure.

Manual

  • Reproduced the bug against production code, not a mock: feeding GSM8KZeroShotGenTask.feedback a prediction record that has been through the real serializer raises KeyError('prediction'), while the in-memory record scores normally.

    fresh (in-memory) record : {'rollouts': [{'index': 0, 'prediction': None, 'extracted': False}]}
    resumed (from disk)      : {'rollouts': [{'index': 0, 'extracted': False}]}
    fresh run   -> feedback ok, finalize=True
    resumed run -> KeyError('prediction') raised out of feedback
    
  • 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 postprocessed record 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 postprocess and feedback records 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)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring — touched files already carry theirs; no new modules added
  • No new upper-layer dependencies added to core/ (core/ is untouched)
  • Deleted code verified — nothing deleted

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 — 289756d3

Second commit, all on the guard itself; the 39 call-site fixes are unchanged.

  • 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 needing the rollout index — passed the check while reintroducing the identical KeyError. Tuple targets now bind every Name element, and _rollout_container unwraps 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.
  • Classification widened to all five record TypedDicts. JudgementRecord.reference shares prediction's exact shape (build_judgement_record writes it unconditionally, so None is 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 to PromptRecord either. Now classified ungated, with the reason recorded: the single [] 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.
  • 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.
  • Dropped a duplicate integration test whose body was identical to its sibling.

What reference actually 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) — prediction has extracted plus detect_extraction_failure for exactly this, reference has nothing. Out of scope here; tracked in #71.

Verification

ethan-scitix and others added 3 commits August 6, 2026 15:49
`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>
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Field evidence for this, in case it helps with priority: it fires in production, not
only in review.

Hit it today verifying #66 on a full 1,000-question run. Re-grading the LiveCodeBench lane
(sieval run --resume after clearing its terminal records — no model calls) failed on
4 of 90 rollouts with exception::KeyError 'prediction'. All four were truncations —
finish_reason: length, 0 chars of texts — so prediction was None, dropped by
obj_to_dict, absent on reload. The lane could not be re-graded at all until the line was
changed to .get.

Two details that support the framing in the description:

  • Resume is not a rare path. It is exactly what a harness bump makes people take: feat(livecodebench): replace the whole-suite timeout with upstream's per-case rule #66
    changed how LiveCodeBench grades, so the natural way to see the effect is to re-grade
    existing generations rather than re-run inference. The first thing anyone does after a
    grader change lands on this bug.
  • The blast radius is per-lane truncations. Any lane whose model sometimes runs out of
    tokens has some rollouts with prediction=None, so the 39 sites are not equally likely
    to fire but are all live. On this run the same 1,000 questions produced blank
    generations in mmlu_pro, aime, hmmt and livecodebench.

I had opened #72 for the single lane before finding this PR — closed as a duplicate. Its
regression test is redundant too: test_none_prediction_is_absent_but_extracted_survives
already pins the behaviour, which is your point exactly — the record-level test passes
while the consumers drift, so the preflight check is the part that actually holds.

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>
@ethan-scitix
ethan-scitix merged commit 4c4e90d into main Aug 6, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the fix/notrequired-record-access branch August 6, 2026 08:18
ethan-scitix added a commit that referenced this pull request Aug 6, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 6, 2026
… 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>
ethan-scitix added a commit that referenced this pull request Aug 6, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 6, 2026
…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>
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