feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop - #68
Conversation
First live run — results, and why the task stays
|
| metric | value |
|---|---|
EAcc (score) |
34.46 |
| AAcc | 40.87 |
| CAcc | 48.07 |
| Delta | 6.42 |
Promotion criteria
| # | criterion | result |
|---|---|---|
| 1 | extracted=False rate low |
pass — 32/15,183 (0.21%); truncation 29 (0.19%) |
| 2 | fails is 0 |
pass — 0 |
| 3 | plausible band vs siblings on same model | fired — 34.46 on undergrad coursework vs this model's AIME 2026 72.5 / HMMT Feb 2026 51.5 in this harness. Undergrad coursework is not harder than AIME; the criterion caught a measurement problem |
| 4 | sampled false-negative rate | FAIL — 34.9% |
Criterion 4, and the fix it asks for
Of 1,634 wrong slots in the bucket where extraction and reference agree on slot count, 570 (34.9%) are the grader's error, confirmed by substituting the same random values into both sides' free symbols. Entirely in free-form types — EX 59.6%, NV 25.4%, OE 7/10 — and exactly 0% in every structured type (OL, UOL, MCS, MCM, TF, INT, EQ).
Root cause is in math_equal: math_verify.parse routes everything through a LaTeX reader, so the dataset's plain-sympy gold is mangled —
gold "7*sin(pi*x/5)+1" -> 7*s*i*n*(i*p*x)/5 + 1 # sin as s·i·n, pi as p·i
pred "7\sin(\frac{\pi}{5}x)+1" -> 7*sin(pi*x/5) + 1 # parsed fine
A gold containing any function name can then only match by exact string equality.
This is why the 99.81% replay figure could not see it. Replaying a reference as its own answer short-circuits on _squash(pred) == _squash(gold) and never reaches the symbolic path. A self-replay canary validates the fast path only — it is silent about precisely the comparison logic it appears to certify. Worth remembering beyond this task.
Suggested fix: parse the gold as sympy source, or add a numeric-substitution fallback in math_equal. Happy to push it here or as a follow-up — say which. It is worth ~+6.5 EAcc.
How to read 34.46
It is protocol-faithful and paper-comparable, but it is not this model's math ability. Two effects, both quantified off the same stored responses with no extra inference:
- Format tax ≈ +33.5 EAcc — the benchmark's, not ours. Only the last
\boxed{}is kept, and a slot-count mismatch scores every slot wrong. Qwen3 writes\boxed{a}, \boxed{b}, \boxed{c}instead of\boxed{a, b, c}. Single-answer rows mismatch 0.30%, multi-answer rows 86.31% — same model, same subjects, so this is formatting, not difficulty. Upstreamjudge_rule.pyhas the identical rule, so we reproduce it deliberately. - The criterion-4 defect ≈ +6.5 EAcc.
Repairing both, exactly (not extrapolated — EAcc is per-problem): 34.46 → 67.97 → 74.43, which does sit next to AIME 72.5 as criterion 3 expects.
Swapping in upstream's verifier is not the answer
Run as a local instrument over the same 15,183 responses (GPL-3.0 restricts distribution, not use; nothing vendored):
| grader | EAcc | AAcc | CAcc |
|---|---|---|---|
| this task | 34.46 | 40.87 | 48.07 |
| upstream official | 35.55 | 42.23 | 49.63 |
| diff | +1.09 | +1.36 | +1.56 |
94.95% per-sample agreement, disagreeing both ways (upstream wins 486, this grader wins 280 — the latter is the unwinnable-slot repair the module documents). It carries the same format tax, recovers little of the equivalence gap, and is unshippable here on licence.
Caveat on Delta
At temperature 0.6 the reasoning gap is mostly the sampler. Control on Geometry with n=3: Delta 8.70 across the three randomized versions vs 7.25 across three rollouts of one fixed version — only 1.45 pp is version sensitivity, 83% is sampling noise. Upstream generates greedily, where this does not arise. Read Delta only against the sampling settings that produced it.
Run artifacts, configs and analysis scripts: /volume/ai4s-data/ylsun/ugmathbench-run/. Tests: 72 passed (-p no:asyncio; the plugin collection error in this env is pre-existing on main).
Grader fix pushed —
|
| EAcc | AAcc | CAcc | |
|---|---|---|---|
| before | 34.46 | 40.87 | 48.07 |
| after | 38.43 | 45.52 | 53.45 |
| delta | +3.97 | +4.64 | +5.37 |
705 samples wrong → right, and 0 right → wrong. The zero matters more than the +3.97 — it's the structural safety property, confirmed empirically rather than argued. For reference, upstream's own verifier scores 35.55 on these same responses, so the repaired grader is now above both its previous self and the reference implementation.
13 regression tests added: the LaTeX-vs-sympy pairs, rounding and different-variable cases that must stay wrong, OL/UOL element dispatch, structured types left strict, and determinism across repeated calls. 97 passed, ruff clean.
Still experimental — and this is a maintainer call, not a measurement
- Criterion (3) is unmet on the raw number. 38.43 vs AIME 72.5, and the entire remaining gap is the format tax, which is the benchmark's last-box rule faithfully reproduced. Whether a protocol-faithful score dominated by a formatting convention counts as "a plausible band" is a decision about what this task is for — no further run settles it.
- Criterion (4) can no longer be measured the way it was. The audit that found this defect used numeric substitution; the grader now uses numeric substitution. Auditing it with the same method measures a method against itself. A residual audit needs an independent instrument (manual sample, or upstream's judge). A sampled eyeball of what remains wrong is dominated by genuine model errors, with a thin tail of prediction-side parse failures — a
y =prefix on anEXanswer, a trailingdx.
Happy to take either further if you want it in this PR.
Promoted to
|
| count | |
|---|---|
| agreement | 95.51% (was 94.95% pre-fix) |
| sieval right, upstream wrong | 591 ← where a lenient grader would show up |
| upstream right, sieval wrong | 91 ← residual misses, 0.60% of samples |
The 591 is the direction that matters. Sampled eyeball: 12 of 12 genuinely correct — 2√(2t+9) vs sqrt(2*4*t+36), 4^20 vs 1.09951E+12, 7/108 vs 0.0648148, ln(2)/2 vs 0.346573590279973, x^0*e^(-8x)cos(9x) vs e^{-8x}cos(9x). No false positives.
Residual misses have named, non-systematic causes: LaTeX interval notation (\cup, \infty), absolute-value bars, a y = prefix on an EX answer.
Also in this commit
Inverse-trig aliases for the sympy-source parser — the dataset spells it arcsin, sympy calls it asin, so without the alias it parsed as a symbol and silently changed the free-symbol set. Worth 11 more samples.
Final grader delta on the stored run (extraction held fixed):
| EAcc | AAcc | CAcc | |
|---|---|---|---|
| before | 34.46 | 40.87 | 48.07 |
| after | 38.49 | 45.59 | 53.53 |
716 wrong → right, 0 right → wrong.
meta/index.json regenerated via scripts/sync_meta_index.py (CI checks it). Full suite: 3677 passed; the 2 CLI JSON failures reproduce on pristine HEAD, so they predate this branch.
3c79d86 to
c2f299b
Compare
…81) * test(core): raise resume_gate's mutation score 41.9% -> 87.8% `resume_gate` scored lowest of all 26 `sieval/core` modules — 31 of its 74 mutants killed. It guards the contract this repo states most firmly: Precise reproducibility is a product contract, not a nicety. Safety guards (e.g. `--resume` strict match) ship strict-only. Nobody knew, because the mutation gate in `sieval/core/CLAUDE.md` has never been runnable (see #68). Tests only, no behaviour change. The existing suite covered the ladder's `action` thoroughly and its `reason` with `!= ""`. That is the gap: the four reject reasons lead an operator to four *different* fixes — repair the version string, reinstall a released build, pin a non-dev build, match the series — so swapping any of them for another passed every test, including a swap that sends the operator somewhere that cannot work. Each reason is now asserted exactly. `format_identity_reject_message` had no tests at all: 18 mutants with nothing to observe them, and the function was not even imported. It is the message for resuming into a directory another task produced, and its own text states the stake — a finished run is matched by path alone, so resuming would hand back the persisted task's report as this task's result without running a sample. Now asserted on both task names, the persisted/current labelling, the explanation, and both recovery paths — including that the two builders do *not* share a second option, since "reinstall sieval" and "give this task its own result_dir" are not interchangeable advice. Two contract holes the old tests could not see: * **Ladder precedence.** Rule order is the contract, not just membership. Exact match outranks every reject rule; unparseable outranks the 0.0.0 check (an unparseable string cannot be compared to 0.0.0 at all); 0.0.0 outranks the dev check; dev outranks the series check, because matching the series would still not make a dev build resumable. * **`local` and `dev` are independent markers.** The run-side test used `0.6.1.dev3+gxyz`, which carries *both*, so it could not distinguish the `or` in that guard from an `and`. A build tagged only local would have fallen through to the series check and resumed. Now covered on both sides, each marker alone. Also pins the break axis directly: minor under 1.0, major from 1.0 on, the crossing incompatible in both directions, and pre-releases falling through to the series check rather than being rejected as unpinnable like dev/local. Residual 9 survivors are mutmut's `XX`-sentinel string mutations on formatting this suite deliberately does not assert; killing them needs exact equality on multi-line operator text, which trades discrimination for brittleness. Measuring this requires the `[tool.mutmut].also_copy` fix in #68 — without it mutmut cannot assemble an importable `mutants/sieval` and dies before any mutant runs. Land that first, or apply it locally to reproduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(core): raise meta 61.4% -> 92.9% and progress 66.7% -> 71.5% Two of the three remaining `sieval/core` modules under the ≥70% bar. Tests only; no source file is touched. `tasks/meta.task_meta_from_dict` carried 84 survivors — the whole deserializer was unverified. Any field could be read from the wrong key, dropped, or swapped with its neighbour and nothing would notice, and `meta/index.json` is how every consumer outside this process learns what a task is. Now round-tripped against `task_meta_to_dict` with every field distinct, read back individually (a symmetric swap survives round-trip equality alone), and each documented default pinned — including that an omitted `status` means `stable` rather than downgrading a task that consumers gate on. `tasks/progress` had its three pbar gating conditions covered but not the arguments. `position` is how MultiTaskRunner keeps concurrent runners' bars from drawing over each other; collapsing it to a constant fails no behavioural test. Also pins `leave=True` (the final counts survive the moment they become worth reading), that `_enable_log` is exactly "show_progress and not a TTY" — silencing progress must silence both channels rather than swapping one for the other — and that the dump file needs both a directory and the flag. `tasks/anomaly` moved 64.6% -> 68.2% and is **still under the bar**. What landed here is real: the two counters in `generate_and_save_from_results` (`sample_details` counts occurrences, `rollout_details` counts rollouts — a swap misreports how widespread an anomaly is), `save`'s temp-file-then-rename atomicity (a truncated `anomalies.json` is worse than none, since it looks present and suppresses regeneration), the backup path's negative branches, and `rules_hash` pinned to its current value — editing any rule's *prose* rotates every stored report fleet-wide, so that is an event which belongs in a diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(core): raise anomaly 68.2% -> 70.8% by testing the decorator, not the registry The last module under the bar, and the one that took four tries. The first three rounds were spent guessing where the survivors were; the last round of six `detect` tests killed exactly zero mutants. Dumping the actual diff of all 134 survivors first changed the answer completely. They were concentrated in `@sieval_detection_rule`'s own registration logic: - rule_name = func_name.removeprefix("_detect_").removeprefix("detect_") + rule_name = func_name.removesuffix("_detect_").removeprefix("detect_") - rule_tags = tags if tags is not None else [rule_name.replace("_", " ")] + rule_tags = tags if tags is None else [...] The pinned `rules_hash` could not reach these. The built-in rules register at *import*, so a test that reads the resulting registry never exercises the decorator's derivation — it only sees what the derivation produced once. Killing them requires registering a rule through the decorator inside the test. The derived name is not cosmetic: it is what `applies_to` matches on and what every persisted report keys by, so a changed derivation renames rules across every stored `anomalies.json`. Now covered: `detect_` and `_detect_` prefixes stripped, a name without either kept whole, default tags derived from the rule name, explicit tags winning, and an explicitly empty tag list respected rather than replaced — `is not None`, not truthiness, since "no tags" and "derive some for me" are different instructions. Plus the default severity and each field of the definition, which are serialized into `rules_schema` and hashed. All 26 `sieval/core` modules are now at or above the ≥70% bar in `sieval/core/CLAUDE.md`. Tests only; no source file is touched in this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): match the DetectFunc protocol instead of renaming its parameter CI caught a typecheck failure I introduced after running `ty` and before pushing — the second time in this session I have made that exact sequencing mistake. `DetectFunc` is a Protocol declaring `__call__(self, ctx: TaskContext) -> set[int]`. Its parameter is positional *and* keyword, so renaming to `_ctx` to silence ruff's ARG001 stopped the test double from satisfying it: error[invalid-argument-type]: Expected `DetectFunc`, found `def rule(_ctx) -> Unknown` Fixed by matching the idiom this file already uses for its other test rules — a named, fully annotated `ctx` — and declaring the parameter intentionally unused with `del ctx`, which satisfies ARG001 without a suppression comment. Verified this time with `ty`, `ruff check`, `ruff format --check` and the suite in one invocation rather than in sequence, since running them apart is what let the last two edits through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from the second review pass on #68: - `_verify_answer` was copy-pasted byte-identically into 12 math-competition task modules (confirmed by AST + sha256: one hash, twelve sites). It moves to `sieval/tasks/_math_verify.py` as `verify_answer`. This is extraction on coupling, not on call count: the twelve sites must change together to preserve two contracts — `verify()` takes the gold answer first, and the call must run in a process, not a thread. A private module keeps it out of the task registry and out of `check_preflight`'s naming check, both of which skip `_*.py`. - `check_preflight`'s variant-may-not-spell-a-mode test never covered `foo_0shot_clp_gen.py`, the one example both `CLAUDE.md` and `.claude/rules/tasks.md` name as canonical. Added. - `UGMathBenchDataset.load` read an explicitly-empty `subjects=[]` as "unspecified" and loaded all 16 subjects, because `[]` is falsy. `None` now means unspecified; `[]` raises. And the trim (`-186` lines net). `offload.py`'s module docstring went 57 lines to 36 with no fact dropped, and the two DeepSeek-Math grading sites, the scicode comment, the `_limiter` and `run_cpu_bound` docstrings and the two new test docstrings now point at it instead of restating it. One correction rather than a trim: `sieval/core/CLAUDE.md` told the reader to scope a mutation run by editing `paths_to_mutate`, which is the thing that broke mutation testing in the first place — `also_copy` omits `sieval/core`, so narrowing `paths_to_mutate` to one file copies only that file and `conftest.py` dies on import. Scope by mutant name instead. The `_fixed` task's own docstring is left alone: it is the quantified-delta evidence `sieval/tasks/CLAUDE.md` requires of a `_fixed` variant, not prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the UGMathBench undergraduate-math benchmark (ICLR 2025, arXiv:2501.13766): dataset + `ugmathbench_0shot_gen`, `deps_group="math"` (no new dependencies), `status="experimental"`. Each of the 5,061 problems ships three randomized versions, so a sample is a (problem, version) pair and a full run is 15,183 inferences. That is what the benchmark's headline metric needs: EAcc (share of problems correct in ALL three versions) is `score`, reported alongside AAcc, CAcc, Delta (AAcc - EAcc, the reasoning gap), relative_delta (upstream's RE) and per-subject EAcc. Grading is per answer slot over the benchmark's 10 answer types; a sample is correct only when every slot is. The grader is an independent implementation, not a port — a licensing constraint, not a shortcut. UGMathBench is GPL-3.0 and sieval is Apache-2.0, so `judge_rule.py` / `utils.py` cannot be vendored the way matharena, DeepSeek-Math and IMO-Bench were. `community/ugmathbench.py` reimplements the 10 answer-type comparison rules on math-verify plus a relative-numeric fallback, and its docstring enumerates every known delta. Hence experimental: scores are not guaranteed to reproduce the paper's rule-based numbers. The prompt, by contrast, is exact: `build_prompt` reproduces upstream's `raw` template byte-for-byte on all 15,183 rows, verified by diffing against upstream's own `make_prompt`. Judge self-consistency, measured rather than asserted: replaying every reference answer back as a boxed response grades 99.7% of slots correct (11,256 slots over 4,000 sampled rows); every residual failure traces to upstream data defects — 26 of 15,183 rows (0.17%) store references the comma-separated-answers protocol cannot express, unanswerable upstream too. Two deliberate improvements over the reference judge, both documented in-module: a TF slot whose reference is not a boolean (9 of 1,665, e.g. `-22`, `not real`) is compared as a value rather than graded wrong outright, since upstream's assert-then-swallow leaves those slots unwinnable; and references are normalized like predictions, since a few "numerical value without units" answers are stored carrying a percent sign. Data quirks are kept, not cleaned: two upstream-corrupt rows (error-message problem text, empty answer sequence) are prompted and graded like any other sample, scoring 0 as upstream does — dropping them would inflate EAcc for the problems they belong to. Sample ordering is problem-major (upstream emits version-major) so `slice(n)` keeps whole problems and EAcc stays defined on a truncated run; `incomplete_problems` makes a partially-judged problem visible rather than silently a miss. `sieval dataset download ugmathbench` resolves (15,183 rows across 16 configs); ruff / ty / preflight green; 2975 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task file names gain an optional trailing variant segment:
<task>_<N>shot_<mode>[_<variant>].py
Upstream's buggy reading of a benchmark and our corrected one need to
coexist as separate registered tasks for a long time. The task name is
the registry key *and* the run-directory name, so a distinct name is the
only place that distinction can live — a `tasks/fixed/` subpackage would
only move the module while `@sieval_task(name=...)` still decides the key.
The unqualified name always means "what upstream measures", bugs
included, and is never repurposed: a run compared against a published
number must not have to check which variant produced it. A local
correction takes `_fixed`, which is licensed by a *defect* in upstream's
grader or data — not by preference — and owes two things: every
divergence enumerated in `reference_impl.notes`, and its score impact
*quantified*. An unmeasured fork does not get to call itself a fix.
Deliberately excluded: a different measurement regime is not a variant
(it gets a descriptive mode name, as `arc_challenge_kshot_clp` vs `_ppl`
already do), and fixing problem text or reference answers is a
`datasets/` concern — a `<name>_fixed` dataset applying an explicit patch
table (id, field, old -> new, why) over the same pinned revision, failing
loudly when a targeted row no longer matches `old`. Never a forked copy
of the data: a patch table shrinks to empty when upstream fixes the row,
and that is the only exit condition a local fix can have.
`_TASK_FILE_PATTERN` anchors the mode alternation right after the shot
segment (multi-token modes leading) so `model_type` stays readable off
the name, plus a negative lookahead rejecting a variant that spells a
mode — `foo_0shot_clp_gen.py` has two readings and is rejected outright
rather than settled by regex precedence.
UGMathBench is the first user. `ugmathbench_0shot_gen` ->
`ugmathbench_0shot_gen_fixed`, and `status` drops to the `stable`
default: the variant no longer claims to reproduce the paper, it claims
to be a correct grader whose divergence is measured. The unqualified name
stays *permanently* vacant — a faithful port would have to vendor a
GPL-3.0 grader, so what that name promises cannot exist here at all.
The two corrections that license `_fixed` are now quantified rather than
asserted. Replaying every reference answer as a boxed prediction under
both rule sets across all 15,183 samples: they move 27 of 42,064 slots
and 10 of 5,061 problems, an EAcc *ceiling* difference of 0.198 pp —
under a third of the 0.70 pp binomial standard error at n=5,061, and a
ceiling rather than an expectation, since it is only realized on a
problem a model would otherwise answer correctly in all three versions.
The comparison layer alone is 42,064/42,064 self-consistent, so the
previously recorded 99.7% is entirely the boxed round-trip protocol, not
the judge.
Two *further* divergences are shape, not repair, and are NOT covered by
that figure — math-verify replaces upstream's parse_latex + simplify +
random-substitution chain, and the declared answer type decides the rule
where upstream's `is_equal` retries every method until one accepts. Both
docstring and notes say so explicitly, so 0.2 pp is not read as "aligned
with upstream".
Convention written into `.claude/rules/tasks.md`, `sieval/tasks/CLAUDE.md`,
`sieval/datasets/CLAUDE.md` and `CONTRIBUTING.md`; regenerated
`__init__.pyi` + `meta/index.json`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one-row table read as "`_fixed` is the only legal variant", which would block the extension that motivated putting the variant *after* the mode in the first place: a future prompt-format variant of one benchmark. The segment is generic; a name earns a row when a second real case arrives, and coining one speculatively is still out (CLAUDE.md: do not abstract ahead of time). Also states the one thing every variant must be — a different reading of the *same* measurement, so the two are worth comparing side by side. That is what keeps the segment from becoming a dumping ground for unrelated tasks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… judge
The shipped figure was wrong in both directions. Its 27 slots decomposed as
9 real TF slots plus 18 percent-bearing NV slots that upstream wins: `judge()`
runs `norm_ans_str` over the gold as well as the prediction, and `norm_math_str`
strips `%` via SIMPLE_RM_STRS. Running upstream's judge over those 18 rows with
the reference replayed as the answer grades 18/18 correct, so two thirds of the
numerator was measuring against an upstream that does not exist.
Re-measured properly, with upstream's judge run as a local instrument (GPL-3.0
restricts distribution, not use; nothing vendored) over all 15,183 pinned rows:
upstream accepts its own reference on 14,616 rows (96.27%), this task on 15,154
(99.81%). The 546 disagreeing rows span 190 of 5,061 problems -- an EAcc ceiling
difference of 3.75 pp, ~5x the 0.70 pp binomial standard error rather than under
a third of it. Per slot, 788 of 42,064 (1.87%).
The direction also flips the classification: 542 of the 546 are rows upstream
rejects its OWN reference on, so the bulk is repair, not the "shape, not repair"
the notes claimed. Dominant cause is upstream's prediction-only normalization
(`Judger.normalize_answer` rewrites sqrt(x) into sqrt{(}x), which the gold never
sees) -- so normalizing both sides was right; only its stated example was wrong.
Also in this pass:
- status="experimental", declared rather than inherited from the default. Not
for diverging from upstream -- that is what `_fixed` and the figure carry --
but for the ordinary reason arc_* and ifbench are: no served model has run
this task. The docstring records what a live run must show to promote it,
since "matches upstream" is unreachable here by construction.
- report(): a problem whose three versions all failed left `by_problem`
entirely, so EAcc was computed over the survivors and silently biased upward,
opposite to the documented partial-failure case and with no warning. Failed
samples now hold their problem's slot in the denominator, recovered from
raw_sample or the prompt record; a fail that carries neither is surfaced as
`unattributed_fails` instead of vanishing.
- MCM gets the whole-answer comparison MCS already had. `_option_letters`
matches single characters, so word-labelled choices yielded no letters and the
slot was unwinnable. No instance in the pinned revision; latent only.
- Documented two further divergences that were missing: a zero-valued numeric
reference is compared absolutely here and falls through to the symbolic path
upstream, and `relative_delta` is upstream's RE scaled by 100.
- Corrected "26 rows unanswerable upstream too": 29 rows cannot be graded here
on replay, and upstream wins 4 of them (UOL references whose top-level commas
sit outside any bracket).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s experimental The task docstring gated promotion out of `experimental` on a live run and said "record the numbers here". The run has happened: Qwen3-30B-A3B (thinking on, temp 0.6/top_p 0.95/top_k 20), all 15,183 versions over all 5,061 problems, sglang tp2xdp4, 75.7M output tokens, fails 0, incomplete_problems 0. EAcc 34.46 AAcc 40.87 CAcc 48.07 Delta 6.42 Criteria 1 and 2 pass (extracted=False 0.21%, truncation 0.19%, 0 fails). Criterion 3 fired as a diagnostic should: 34.46 on undergraduate coursework is not a plausible neighbour of the same model's 72.5 on AIME 2026 and 51.5 on HMMT Feb 2026 in this harness. Criterion 4 FAILS, which is why status stays `experimental` — now for a located defect rather than the procedural "nobody has run it". Of 1,634 wrong slots in the slot-aligned bucket, 570 (34.9%) are the grader's error, not the model's, confirmed by substituting the same random values into both sides' free symbols. They are entirely in the free-form types (EX 59.6%, NV 25.4%, OE 7/10) and exactly 0% in every structured type. Root cause is in this module's math_equal: math_verify.parse routes every string through a LaTeX reader, so the dataset's plain-sympy GOLD is mangled -- `7*sin(pi*x/5)+1` parses as `7*s*i*n*(i*p*x)/5 + 1`, reading `sin` as s*i*n and `pi` as p*i. A gold containing any function name can then only match by exact string equality. That is also why the 99.81% replay figure could not see it: replaying a reference as its own answer short-circuits on _squash(pred) == _squash(gold) and never reaches the symbolic path. A self-replay canary validates the fast path only, and is silent about the comparison logic it appears to certify. Also recorded, since they decide how the headline should be read: * the format tax (~+33.5 EAcc) is the benchmark's, not ours -- single-answer rows mismatch 0.30%, multi-answer rows 86.31%, same model and subjects, and upstream's judge_rule.py carries the identical last-box rule; * repairing both effects exactly: 34.46 -> 67.97 -> 74.43, which does sit next to AIME 72.5 as criterion 3 expects; * swapping in upstream's verifier is not the fix -- run as a local instrument over the same responses it scores 35.55 vs 34.46 (+1.09), 94.95% agreement, disagreeing in both directions; * Delta at temperature 0.6 is 83% sampling noise (n=3 control: 8.70 across randomized versions vs 7.25 across rollouts of one fixed version). Docstring and status comment only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…titution
The live run's promotion audit found 34.9% of wrong slots in the slot-aligned
bucket were the grader's error, not the model's -- entirely in the free-form
answer types and exactly 0% in every structured one. Root cause:
`math_verify.parse` runs a LaTeX reader over BOTH sides, and the dataset does
not store LaTeX. In LaTeX an unescaped `sin` is the product s*i*n and `pi` is
p*i, so the stored gold
7*sin(pi*x/5)+1 -> 7*s*i*n*(i*p*x)/5 + 1
could never match a model's `7\sin(\frac{\pi}{5}x)+1` except by exact string
equality. Two additions:
* `_parse_sympy_source` reads the gold with sympy's own parser (square brackets
are grouping in this dialect, `^` is power, and e/pi/ln/infinity mean what
they look like), plus an implicit-multiplication reading as an extra
candidate for predictions like `5 - 5c`. Both parsers are tried on both
sides -- neither side is guaranteed to be in one syntax.
* `_same_function` closes the one gap where upstream was genuinely stronger:
equivalence by numeric substitution. It catches pairs no normalizer reaches,
e.g. `3\cos(2\sqrt{35}t)` vs `3*cos(sqrt(980/7)*t)`.
Conservative by construction. The pass runs only after every other strategy has
said "not equal", so the only verdict it can change is wrong-to-right; symbol
name sets must match, so an answer in x never matches one in t; every probe
that evaluates cleanly must agree and at least 3 must evaluate; and rounding
past the benchmark's own relative tolerance stays wrong.
The probe ladder is fixed rather than a seeded RNG -- a grader must return the
same verdict for the same pair regardless of how many comparisons preceded it.
It is deliberately small-valued: `e^{cosh(4x)}` overflows above x ~ 2, and
discarding overflowed probes on a larger ladder left too few usable points and
marked a correct exponential answer wrong.
Measured on the 15,183 stored responses of the live run, extraction held fixed
so the delta is the grader's alone:
EAcc 34.46 -> 38.43 AAcc 40.87 -> 45.52 CAcc 48.07 -> 53.45
705 samples wrong -> right, 0 right -> wrong
The zero matters more than the +3.97. For reference upstream's own verifier
scores 35.55 on the same responses.
Status stays `experimental`, now for two reasons no further run settles: (3) is
unmet on the raw number because the benchmark's own last-box rule dominates it,
and (4) can no longer be measured as before -- the audit that found this used
numeric substitution and the grader now uses numeric substitution, so a residual
audit needs an independent instrument.
13 regression tests: the LaTeX-vs-sympy pairs, the rounding and
different-variable cases that must stay wrong, list-element dispatch, structured
types left strict, and determinism across repeated calls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eam's judge
Criterion 4 could not be re-measured the way it was found: the audit that
exposed the gold-parsing defect used numeric substitution, and the fixed grader
now uses numeric substitution, so repeating it would measure a method against
itself. Upstream's judge_rule.py is an independent implementation of the same
spec, so it takes over that role. Over the same 15,183 stored responses:
agreement 95.51% (was 94.95% before the fix)
sieval right, upstream not 591 <- the direction a lenient grader shows up
upstream right, sieval not 91 <- residual misses, 0.60% of samples
A sampled eyeball of the 591 found 12 of 12 genuinely correct: 2*sqrt(2t+9) vs
sqrt(2*4*t+36), 4^20 vs 1.09951E+12, 7/108 vs 0.0648148, ln(2)/2 vs
0.346573590279973, x^0*e^(-8x)cos(9x) vs e^{-8x}cos(9x). No false positives.
Residual misses have named, non-systematic causes: LaTeX interval notation,
absolute-value bars, a `y = ` prefix on an EX answer.
Also adds inverse-trig aliases to the sympy-source parser -- the dataset spells
it `arcsin`, sympy calls it `asin`, and without the alias it parsed as a symbol
and silently changed the free-symbol set. Worth 11 more samples:
EAcc 34.46 -> 38.49 AAcc 40.87 -> 45.59 CAcc 48.07 -> 53.53
716 wrong -> right, 0 right -> wrong
Criterion 3 is explained rather than met, and that is why it no longer blocks.
It exists to catch an UNEXPLAINED anomaly -- a mis-wired prompt, a mis-joined
gold, a broken extractor. This gap is explained, quantified and attributed to
the benchmark's own last-box rule, which this task reproduces deliberately, and
the attribution is checked two ways: single-answer rows mismatch 0.30% against
multi-answer rows' 86.31% on the same model and subjects, and upstream's own
verifier scores 35.55 here, BELOW this task. A broken harness would not track
the reference implementation that closely.
meta/index.json regenerated via scripts/sync_meta_index.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r-counting
Review findings on this PR, fixed together because three of them share one
cause: the grader treats model output as trusted input, and the report treats a
missing grouping key as a missing sample.
Grader, `sieval/community/ugmathbench.py`:
* `parse_expr` evaluates what it parses, and its default global namespace is
built by `exec("from sympy import *", ...)`, which injects `__builtins__`. A
boxed `__import__('os').system(...)` therefore ran, on the ordinary path —
every wrong free-form answer falls through to the substitution pass — while
the slot was still reported wrong, so nothing in the run looked unusual.
`_sympy_globals()` removes the builtins and keeps the sympy names, which
`auto_symbol` needs to read `sin(...)` as a function at all.
* `^` is rewritten to `**` before parsing and sympy exponentiates eagerly, so a
boxed `9^9^9^9` asked for a 370-million-digit integer and never returned.
Grading is synchronous on the event loop, so one such sample froze every
concurrent sample in the run, and `max_retries` cannot recover a hang.
`_evaluable()` screens it out with an unevaluated pre-parse (microseconds) and
grades the answer wrong instead. The largest exponent in the pinned references
is three digits, so no reachable comparison changes.
* `math_equal`'s numeric retry *returned* its verdict instead of accepting on
success and falling through, which made the substitution pass unreachable for
every pair the LaTeX mangling turns into a number — `2**100` reads as `2`, so
a correct `2^100` was compared against 2 and graded wrong without the pass
this PR added ever running.
Report, `sieval/tasks/ugmathbench_0shot_gen_fixed.py`:
* `feedback()`'s raw-sample-less branch emitted a judgement with no `extra`, so
`report()` could not name the problem and dropped it from EAcc's denominator
while its wrong verdict stayed in AAcc's — the same upward bias this PR
already fixed for failed samples, in the path that had no guard. It now
recovers the grouping keys from the prompt record, as `_identify` does, and
`report()` does the same for any judgement that still lacks them.
* EAcc <= AAcc holds for any run, and the way to break it is exactly this class
of bug, so it is now checked and logged rather than left to be read as a
score. `unattributed_finals` records the samples that could not be recovered.
Docs and metadata, no behaviour change:
* the two modules disagreed on the same measurement (705/38.43 against
716/38.49) because the arcsin-alias commit updated only one of them;
* `reference_impl.notes` still said the task was experimental, and ships that
way in `meta/index.json` next to `status: stable`. Replaced with the live
head-to-head, which is the stronger measurement and was missing there;
* the task docstring stated current numbers in three mutually contradictory
places, having grown as a changelog. Rewritten to describe what ships;
* two extraction divergences from upstream were missing from a list that
enumerates them deliberately: the `herefore` pre-split, and an empty box
returning the whole response;
* `build_prompt` derived the joined type description once for both branches,
where upstream's single-answer branch reads `answer_type[0]` alone. Equal on
every pinned row, so this pins the shape rather than the coincidence.
Not done here: moving grading off the event loop. `math_verify` bounds its own
parse/verify with `signal.SIGALRM`, which only arms on the main thread, so a
worker-thread move would silently disable it. Worth its own change.
Tests: 91 passed (13 new). ruff, ty, sanitize.sh and check_preflight clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…session
Every runner in a session shares one event loop: `MultiTaskRunner.arun` starts
each `TaskRunner` with `tg.start_soon` inside a single `anyio.run`, and
`EvalSession` uses it. Synchronous grading in `feedback()` therefore stalls
every *other* task too, not just its own samples.
Measured, mirroring that structure — a co-running benchmark doing 50 ms awaited
round trips, alongside ugmathbench grading:
sibling alone 239 round trips / 12s
sibling + ugmathbench 1 round trip <- 0.4%, lost 99.6%
A heartbeat probe puts it plainly: 0 ticks during a grade, where a free loop
manages 100/s. Not only on pathological input — an ordinary wrong answer holds
the loop for ~23 ms, and a full ugmathbench run is ~8,200 of them.
`sieval/core/utils/offload.py` adds `run_cpu_bound`, which runs a picklable
module-level callable in a shared process pool. After wiring it:
sibling + ugmathbench 237 round trips <- 99.2%, lost 0.8%
and ugmathbench graded slightly *more* samples (524 vs 517), since grading now
parallelises across workers instead of serialising on the loop.
A process and not a thread, which was not the first choice:
* `math-verify` bounds its own parse/verify with `signal.SIGALRM`, which only
arms on the main thread. In a worker thread it does not degrade — it raises
`ValueError: ... doesn't support threaded environment`. Callers wrap that in a
broad `except`, so the whole math-verify strategy would vanish silently and
verdicts flip: `\frac{1}{2}` against `0.5` measured True -> False, as did
`\frac{\pi}{4}` and `\sqrt{2}`.
* Disabling its timeout does make it thread-safe, but math-verify then warns the
caller "must provide the logic for timeout interuption yourself" — which a
thread cannot do, being uninterruptible. That trades blocking for unbounded.
* A worker process is the main thread of its own process, so the timeouts keep
working and verdicts are unchanged. `spawn`, not `fork`: the parent is an
async process with live worker threads, and forking one risks inheriting a
held lock.
Degrades rather than fails: if the pool cannot start, work runs inline — the
previous behaviour, slow but correct. `SIEVAL_OFFLOAD_WORKERS=0` forces that
path for a sandbox that cannot spawn.
Wired where the cost is material, which is not everywhere. Measured per-run
blocked-loop time before deciding:
ugmathbench ~190s (~23 ms x 8,200 wrong answers) -> wired
hendrycks_math ~53s (deepseek_math math_equal, median 11 ms,
max 1.7s for a single comparison) -> wired
gsm8k ~14s (same grader) -> wired
the 12 math-verify tasks (AIME/HMMT/Apex/SMT/CMIMC/MATH-500)
0.2-0.7s each (median 1.4 ms/comparison) -> left alone
Changing twelve validated benchmarks to reclaim under a second each is churn
with no measurable benefit; `run_cpu_bound` is one line away if that changes.
Tests: 10 for the new module, including that math-verify verdicts survive the
worker — the property a thread would have broken. Coverage could not be measured
here: `--cov` fails in this environment with a pyarrow double-registration, and
reproduces identically on untouched modules such as `core/utils/hf.py`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lly works
Review catch: the previous commit routed all three graders through a new
process pool without checking how this repo already moves work off the loop.
It does have a pattern — `anyio.to_thread.run_sync`, called directly at the
site, in `core/tasks/loader.py` (4 sites), `infer/deployer.py` and
`cli/leaderboard/session.py`; `scicode_0shot_gen.py` does the same inline with
`asyncio.to_thread`. A process pool is a third mechanism, and two of the three
graders did not need one.
The constraint is narrower than I applied it. A thread breaks `math-verify`,
which bounds its parse/verify with `signal.SIGALRM` and raises outright off the
main thread — but `deepseek_math` does not use math-verify at all. It imports
sympy directly (`parse_latex`, `simplify`, `parse_expr`), so nothing there is
signal-based. Verified: `is_correct` returns identical verdicts on the main
thread and a worker thread across numeric, fraction, radical and
symbolic-equivalence cases.
So `gsm8k_0shot_gen` and `hendrycks_math_kshot_base_gen` now use
`anyio.to_thread.run_sync` like the rest of the repo — same benefit, no new
mechanism, and no timeout/fallback scaffolding they never needed.
`run_cpu_bound` stays for `ugmathbench_0shot_gen_fixed`, which does grade
through math-verify and where a thread was measured to flip verdicts
(`\frac{1}{2}` against `0.5`: True -> False). Its docstring now says the house
pattern first and this module second, so the next reader reaches for a thread
before a process.
That leaves it single-caller, which the repo's own rule usually reads as
"belongs in the caller's module". Keeping it in `core/utils/` on the stated
criterion instead — extract on coupling, not call count: the contract it
encodes ("a math-verify grader must not run in a thread") is shared by the
twelve other math-verify tasks, which are left on the loop deliberately because
their whole run costs 0.2-0.7 s of it against ugmathbench's ~190 s.
`GRADE_TIMEOUT` moves back to its only user rather than sitting in core.
No wrapper over `anyio.to_thread.run_sync`. The eleven thread-offload sites do
unrelated things — file I/O (loader x4), dataset download and model init
(session x4), a subprocess env probe (deployer), an HDF5 read (scicode),
symbolic grading (x2) — and share no contract that must change together, which
is the repo's stated bar for extracting. `run_sync` is already a named
one-liner; wrapping it would add an import and an indirection and buy nothing.
One real drift did turn up while inventorying them, and it is not cosmetic:
`scicode_0shot_gen.py` used `asyncio.to_thread`, which runs on the loop's own
executor and therefore escapes anyio's `CapacityLimiter` — those reads sat
outside the session's thread budget entirely. Switched to
`anyio.to_thread.run_sync`, so all eleven now share one mechanism and one
bound, with `run_cpu_bound` the single documented process exception.
Tests unchanged and passing; the offload suite still pins that math-verify
verdicts survive the worker, which is the property this whole split turns on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t just one Review catch, and the right one: ugmathbench was not special. It was just the task I happened to measure. The other twelve math-verify graders — AIME 2024 / 2025 / 2026, HMMT Feb 2025 / Feb 2026 / Nov 2025, Apex 2025, Apex Shortlist, BRUMO, CMIMC, SMT, MATH-500 — run the identical comparison, and were still grading inline on the shared event loop. The "process vs thread" question never arose for them only because they never offloaded at all, so the original bug was simply unfixed there. The previous justification for leaving them was that their whole run costs 0.2-0.7 s of blocked loop against ugmathbench's ~190 s. That is a weak line to draw: * it is a property of the *run*, not the task — 0.2 s assumes n=4, while Apex publishes at n=16, so someone changing `n` moves the number; * it came from short integer answers (median 1.4 ms), but the same sample reached 89 ms, and math-verify's own timeout is 5 s, so it can reach that. A single 1.7 s spike was exactly the argument for wiring the DeepSeek graders. One rule instead, uniformly applied: **grading does not run on the event loop, and the mechanism follows the grader's own constraint** — math-verify needs a process (its signal-based timeouts refuse threads), pure sympy takes a thread (the house `anyio.to_thread.run_sync` pattern). Thirteen tasks on the first, two on the second, none left inline. The edit is mechanical and the risk is bounded: the block was already duplicated verbatim in all twelve (verified — one digest across the twelve matches), so each file gets a module-level `_verify_answer` instead of the inline call. Module-level because a worker pickles the callable by reference. No shared helper was introduced and no import crosses benchmark families: the duplication already existed, this only makes each copy picklable. Verdicts are unchanged, checked directly rather than inferred from green tests: the extracted function against the old inline logic over integers, fractions, radicals, equivalent-but-differently-written pairs and both empty-string cases — 0 mismatches out of 12. The one honest cost is pool cold start, ~2.7 s. A session running only a 30-problem AIME pays more than the 0.2 s it saves. But the pool is lazy and shared, so any multi-task session amortises it, and against runs measured in minutes of inference both numbers are noise — not a reason to keep a rule that needs a paragraph to explain. `GRADE_TIMEOUT` moves back to `core/utils/offload.py`, now that thirteen callers share it; that is the coupling the last commit said would justify it living there. Tests: 546 task tests pass, full unit suite green, ruff / ty clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent loop The previous commit claimed every math-verify grader had been moved off the shared loop. It missed one: `imo_answer_bench_0shot_gen` grades through `verify_math_answer`, a community wrapper around `math_verify.verify`, so the completeness check — which looked for the names `parse` and `verify` inside `feedback` — walked straight past it and reported clean. The wrapper is already module-level and takes two strings, so the fix is the same one line as its thirteen siblings. The check is now wrapper-aware: it first resolves which `sieval/community` functions reach math-verify transitively (`math_equal`, `judge_answer`, `parse_answer`, `verify_math_answer`, `_element_equal`, `_parse_math`, `_sympy_candidates`) and only then asks whether any async stage calls one without an await. A name-matching check could only ever have found direct callers, which is why it was worth rewriting rather than re-running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both commands in `sieval/core/CLAUDE.md` fail on the pinned toolchain, found while trying to satisfy them for the new `core/utils/offload.py`. * `pytest --cov` dies with `pyarrow.lib.ArrowKeyError: ... Array2DExtensionType already defined`. It is the `pytest-cov` plugin, not the module under test — it reproduces on untouched files such as `core/utils/hf.py`. `python -m coverage run --source=sieval -m pytest` then `coverage report -m` collects fine, so the requirement is satisfiable; only the documented invocation is not. * `mutmut run --paths-to-mutate=<module>` — mutmut 3 dropped that flag, and the repo already moved the config into `[tool.mutmut]` in `pyproject.toml`, so the documented command has been dead since that bump. The argument is now a mutant-name glob. Also recorded the worktree trap that makes the second one look like a code failure: run from a worktree, mutmut resolves `sieval` through the editable install's own path and collects a *different* checkout, erroring on an unrelated import before a single mutant runs. It has to run from the primary checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ver ran
`also_copy` brings every `sieval` subpackage into the mutants copy but not
`sieval/__init__.py`, so `mutants/sieval` is a plain directory. Tests running
inside the copy therefore fail to import `sieval` from it and fall through to
the editable install, where the first unrelated import mismatch aborts
collection:
ImportError: cannot import name 'BOXED_INSTRUCTION' from
'sieval.community.matharena'
That happens during mutmut's stats collection, before a single mutant is run —
so the mutation-score requirement in `sieval/core/CLAUDE.md` has not been
satisfiable since this config landed, for anyone.
Adds `sieval/__init__.py` (the fix) and `sieval/meta` (the task/dataset index,
which several modules read at import time).
This gets mutmut past the copy and generation stages. It does **not** make the
requirement satisfiable from a git worktree: mutmut resolves the project through
the editable install rather than the cwd, so it copies and mutates whichever
checkout `sieval.pth` points at. Run from a worktree it silently operates on a
different tree — `mutants/` came out holding `core/utils/concurrency.py` and no
`offload.py` at all, which is the file this branch adds. It has to be run from
the primary checkout; that is now recorded in `sieval/core/CLAUDE.md`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it fails
Two self-inflicted corrections.
`8beafef0` shipped `tests_dir = ["tests/unit/core/utils"]` — a value I had set
temporarily to make a scoped run fast, and failed to strip before committing. It
would have silently narrowed mutation testing to one directory for everyone.
Restored to `tests/unit`. The `also_copy` fix in that commit stands.
`43c3d192` claimed mutmut must run from the primary checkout because a worktree
resolves `sieval` through the editable install and collects a different tree.
That was wrong, and the evidence I based it on was my own truncated `ls` — the
copy is alphabetical, and I cut the listing above `offload.py` and
`test_offload.py`. Both are present; mutmut mutates the cwd's tree, worktree or
not.
The real blocker, which only became visible once `also_copy` was fixed: the
trampoline mutmut injects imports `mutmut.__main__` on its first hit, and that
module calls `set_start_method('fork')` at import time. In a test process where
the start method is already fixed, that raises
RuntimeError: context has already been set
so mutation testing cannot run against a module whose tests spawn processes.
Verified this is not caused by the module under test: after importing
`core/utils/offload.py` and creating its pool, `multiprocessing
.get_start_method(allow_none=True)` is still `None`, because the pool is built
from `get_context("spawn")` rather than by setting the global.
Mutation score for `core/utils/offload.py` therefore remains unmeasured, on a
tool limitation rather than a gap in the tests. Recorded in
`sieval/core/CLAUDE.md` so the next person does not spend the same afternoon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it is run
The blocker was my invocation, not the code and not the worktree.
`python -m mutmut` loads `mutmut/__main__.py` under the name `__main__`; the
trampoline mutmut injects then imports the same file *again* as
`mutmut.__main__`, re-running its top-level `set_start_method('fork')`:
RuntimeError: context has already been set
It fires on the first mutant hit, so it looks like a broken test rather than a
wrong command. Confirmed by control: mutating `core/utils/concurrency.py`, which
predates this branch and shares no code with the new module, fails identically —
and passes (12/16 killed) the moment the console script is used instead. So this
was never introduced here; the requirement has simply never been runnable the
way `sieval/core/CLAUDE.md` implied.
`core/utils/offload.py` now measures **48/93 = 51.6%**, below the ≥70% bar, and
the shortfall is structural rather than a thin test suite: 27 of the 45
survivors are in `_get_pool`. mutmut cannot instrument a *spawned* worker — the
same import error kills it there — so the pool always degrades to its inline
fallback and any mutant that only changes worker behaviour is unobservable.
Excluding that region the score is 48/66 = 72.7%.
Test work that is worth having regardless of the tool, and which moved the raw
number 38.7% -> 45.2% -> 51.6%:
* `_worker_count` had two tests and ten surviving mutants. Now pins the actual
contract: a negative request floors at 0 (the documented "run inline" value)
rather than flipping sign, the default caps at 8 on a big machine, floors at 1
on a single core, survives `os.cpu_count()` returning None, and leaves one
core to the event loop that dispatches the work.
* `shutdown` now asserts it releases the pool with `wait=False,
cancel_futures=True` and clears `_pool` — waiting would hang a run on the very
sample that misbehaved, and a stale `_pool` would hand out a dead executor.
* `_mark_unusable` now asserts it warns once rather than per sample; it sits on
a path every later sample takes, so repeating would bury the run's output.
One test is skipped under mutmut and says why in-place: the timeout assertion
needs a live worker, and under instrumentation the pool degrades to inline,
which by design cannot time out. Every other test still exercises that fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g its contracts A low mutation score is a statement about the tests, not the code, and this one was right: the suite exercised the module without pinning what it promises. Nothing here adds behaviour — every new test asserts something the module already guaranteed and no test checked. The previous commit called the shortfall structural, on the grounds that mutmut cannot instrument a spawned worker. That was true but far too broad: it cannot observe mutations *inside* the worker, which is a much smaller set than the 27 `_get_pool` survivors I attributed to it. The pool object itself is perfectly observable without ever running a task on it. Pool contracts, previously untested (killed 15): * **spawned, never forked.** The load-bearing choice in the module — the parent is an async process with live worker threads, and forking one can inherit a held lock and deadlock the child. A mutation to `"fork"` fails no behavioural test; it just occasionally hangs a run, so it is asserted directly. * built once and reused — rebuilding per call pays spawn plus a sympy import on every grade, which is the cost this module exists to avoid; * sized by `_worker_count()`; * a disabled pool is not reconsidered mid-run, and a failed start is attempted once rather than per sample. Diagnostics (killed 8). `_mark_unusable` and `_worker_count` warn when the pool degrades or an override is unparseable, and those messages are the operator's only signal that grading quietly moved back onto the event loop. The tests now assert the cause appears in the text — a warning that drops it is precisely what makes a silent fallback silent — and that the second failure does not re-warn. Captured with a loguru sink, matching the convention in `tests/unit/core/test_datasets.py` and friends; `caplog` sees nothing here, since this module logs through loguru rather than stdlib. `_worker_count` edge cases (killed earlier, retained): negative floors to 0 (the documented "run inline" value) rather than flipping sign, the default caps at 8, floors at 1 on a single core, survives `os.cpu_count()` returning None, and leaves one core to the loop dispatching the work. Final: **71/93 = 76.3%**, over the >=70% bar in `sieval/core/CLAUDE.md`. The residual 22 are genuinely worker-internal — `pool.submit(func, …)` mutated to `submit(None, …)` is answered identically by the inline fallback mutmut forces. That distinction, and the 38.7% -> 76.3% path, are recorded in the same file so the next low score is read as a missing assertion first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ty` rejected the two pool-contract tests added in `9307b48f`, and CI caught what
I did not — I ran ruff after that change but not `ty`:
error[unresolved-attribute]: Attribute `get_start_method` is not defined on
`None` in union `BaseContext | None`
error[unresolved-attribute]: Object of type `ProcessPoolExecutor` has no
attribute `_max_workers`
Fixed by asserting what the module *does* rather than what the executor keeps:
a `pool_spy` fixture captures the kwargs `ProcessPoolExecutor` is constructed
with, so the spawn-not-fork and worker-count assertions read `mp_context` and
`max_workers` from the call. Better than silencing the checker with `getattr` —
`_mp_context` / `_max_workers` are CPython internals, and a test reading them is
one stdlib refactor from breaking. Mutation score is unchanged at 71/93 = 76.3%.
Also trims the prose this PR added, ~130 lines to ~95 across the docs and the
`offload` docstring. What went was rhetoric ("anyone comparing a run against a
published number can read the name and stop thinking") and PR process history
("an earlier revision wired only ugmathbench, on the grounds that…") — the
latter belongs in a commit message, not in a doc someone reads every week.
What stayed is anything that changes a decision: why the pool must be spawned
rather than forked, why a data fix is a patch table rather than a forked copy,
why a low mutation score should be read as a missing assertion first.
`.claude/rules/tasks.md` goes back to being a checklist, which is its job.
One stale figure fell out of the trim: the `offload` docstring still said "all
thirteen math-verify graders" from before `imo_answer_bench` was found to be the
fourteenth. The paragraph it sat in was PR history, so it went entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nrunnable I twice proposed filing an issue for this. An issue rots; a check runs. The repo's own `.claude/rules/engineering-infra.md` opens by asking whether a rule has a `scripts/check_*.py` behind it, and this one did not. `[tool.mutmut].also_copy` listed every `sieval` subpackage but not `sieval/__init__.py`, so the tree mutmut assembles under `mutants/` had a `sieval` directory that was not a package. Tests running inside the copy then resolved `sieval` through the editable install instead, and the run died during stats collection on whichever unrelated import mismatched first — before a single mutant ran. The failure reads like a broken test, which is why the mutation-score requirement in `sieval/core/CLAUDE.md` sat unsatisfiable rather than being noticed. `check_mutmut_config` asserts the copy would contain `sieval/__init__.py`, accepting either an explicit entry or a parent that carries it. Verified against the real regression rather than only in fixtures: dropping the entry from this repo's own `pyproject.toml` turns preflight red with the cause spelled out, and restoring it turns it green. Registered in `ALL_CHECKS` so CI runs it (preflight is now 23 checks), mirrored in `tests/unit/scripts/test_check_preflight.py`, and referenced from `sieval/core/CLAUDE.md` next to the rule it protects — the coupled sites that `engineering-infra.md` asks for. The other mutmut trap — `python -m mutmut` double-executing `mutmut/__main__.py` and dying on the first mutant of any module — is not mechanically checkable, since it is about how a human invokes the tool. It stays documented in `sieval/core/CLAUDE.md`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five defects surfaced in review, fixed together.
1. `parse_expr` re-entered the interpreter through a quoted string.
`_sympy_globals` strips the builtins from the parse namespace, but a
string *argument* is re-sympified by sympy with its own default
namespace, which has them back. `auto_symbol` makes every unknown name
callable, so the callee cannot be enumerated — `_quotes_free` refuses
the quote instead. Verified against `eval`, `sympify`, `S`, `N` and
`Function('f')`: with the guard removed all five execute; with it
armed all five return `[]`. Not one of the 42,064 gold slots on the
pinned revision contains a quote, so the guard costs nothing.
2. `run_cpu_bound` only caught `BrokenExecutor` on `pool.submit`. A
worker that cannot spawn raises `OSError`/`PermissionError`/
`MemoryError`, none of which subclass it, so the run died instead of
degrading to inline. Catch `Exception` and mark the pool unusable.
3. `_to_bool` read any string starting with t/y as true, so the free-text
parameter name `t` graded as True. Follow upstream's `judge_TF`: only
uppercase `T`/`Y`/`F`/`N` as single letters, lowercase words
otherwise. Upstream's case asymmetry is load-bearing, not an accident.
Measured: 0 change to the "9 of 1665 non-boolean TF references" figure
— the TF gold vocabulary has no lowercase single letters.
4. `_OPENERS` counted `<` and `>` as brackets, as upstream's
`split_by_comma` does. They are relational operators in this data, so
the comma after them never split and the row came out a slot short.
Dropping them, plus clamping the depth at zero instead of letting it
go negative, moves 6 and 3 rows respectively — all wrong->right, none
the other way. `{}` is kept (0 rows on the references, but it keeps a
comma inside a LaTeX group in a model's answer from splitting a slot).
All three divergences are now enumerated in the module docstring and
in `reference_impl.notes` with their measured cost.
5. The offload pool's admission was unbounded, so N concurrent samples
all called `run_sync` and drew on anyio's process-global 40-token
thread limiter — shared with the loader, the deployer and scicode's
reads. A dedicated `CapacityLimiter(workers + 2)` replaces it, so
grading queues against its own budget and cannot starve unrelated
I/O in the same session.
Self-replay over the pinned revision (16 configs, 5,061 problems, 15,183
versions, 42,064 slots) moves acceptance +6 rows and +15 slots. The
docstring figures are restated by applying that measured delta to the
author's originals: 15,154 -> 15,160 accepted (99.81% -> 99.85%), 546 ->
552 disagreeing rows over 190 -> 192 problems, 3.75 -> 3.79 pp.
Also: import `GRADE_TIMEOUT` from `offload` rather than redeclaring it
(the sole outlier among 15 math tasks), and correct three docstrings
that still described grading as synchronous on the event loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8e195a3 gave `run_cpu_bound` a dedicated `CapacityLimiter`, so "same mechanism as every other offload in the repo" stopped being true the moment it landed: this read shares anyio's default limiter with the loader, the deployer and the DeepSeek-Math graders, and `core/utils/offload.py` is now the one caller that substitutes its own. Name the two pools instead of asserting they are one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gsm8k_0shot_gen` and `hendrycks_math_kshot_base_gen` graded on
`anyio.to_thread.run_sync` with no timeout. Both reach sympy's `simplify`
on text a model wrote, and both call `math_equal(..., timeout=False)`, so
the vendored `call_with_timeout` never runs and nothing bounds them.
A thread cannot be cancelled, so a runaway comparison holds one of anyio's
40 shared thread tokens for the rest of the session — silently, until enough
accumulate to wedge the loader, the deployer and every other offload. Before
this PR the same input froze the event loop, which was at least loud.
Move both to `run_cpu_bound(..., timeout=GRADE_TIMEOUT)`, which gets its own
limiter and can give up on a worker. Verdicts are unchanged: both graders are
module-level functions over plain data, verified identical to inline under a
spawned pool (18==18 T, 17==18 F, \frac{1}{2}==0.5 T, 1==2 F). `TimeoutError`
propagates rather than scoring 0, matching the existing exception discipline
of these two tasks — a timeout is a visible sample failure, not a silent wrong
answer.
`offload.py`'s docstring said this module is for "the one case where a thread
provably changes the answer". That is a correctness test, and it says nothing
about boundedness — exactly what these two graders lack. Split the criterion in
two so the next caller does not have to rediscover it.
Also fixes a comment in `scicode_0shot_gen.py` that no longer holds: grading
does not share the default limiter now that it has its own.
`anyio.to_process.run_sync` is the obvious way to avoid hand-rolling a pool, and it does not work here. Its worker runs `del sys.modules["__main__"]` before re-importing the parent's main module; anything that imports `__main__` during that window raises. `dill` does exactly that at import time, and HuggingFace `datasets` imports `dill`, so a bare `import sieval` is enough to make every worker fail init with `BrokenWorkerProcess`. `ProcessPoolExecutor` with `spawn` is immune because `_fixup_main_from_path` *replaces* `sys.modules["__main__"]` rather than removing it. Docstring only — no behaviour change.
Three findings from the second review pass on #68: - `_verify_answer` was copy-pasted byte-identically into 12 math-competition task modules (confirmed by AST + sha256: one hash, twelve sites). It moves to `sieval/tasks/_math_verify.py` as `verify_answer`. This is extraction on coupling, not on call count: the twelve sites must change together to preserve two contracts — `verify()` takes the gold answer first, and the call must run in a process, not a thread. A private module keeps it out of the task registry and out of `check_preflight`'s naming check, both of which skip `_*.py`. - `check_preflight`'s variant-may-not-spell-a-mode test never covered `foo_0shot_clp_gen.py`, the one example both `CLAUDE.md` and `.claude/rules/tasks.md` name as canonical. Added. - `UGMathBenchDataset.load` read an explicitly-empty `subjects=[]` as "unspecified" and loaded all 16 subjects, because `[]` is falsy. `None` now means unspecified; `[]` raises. And the trim (`-186` lines net). `offload.py`'s module docstring went 57 lines to 36 with no fact dropped, and the two DeepSeek-Math grading sites, the scicode comment, the `_limiter` and `run_cpu_bound` docstrings and the two new test docstrings now point at it instead of restating it. One correction rather than a trim: `sieval/core/CLAUDE.md` told the reader to scope a mutation run by editing `paths_to_mutate`, which is the thing that broke mutation testing in the first place — `also_copy` omits `sieval/core`, so narrowing `paths_to_mutate` to one file copies only that file and `conftest.py` dies on import. Scope by mutant name instead. The `_fixed` task's own docstring is left alone: it is the quantified-delta evidence `sieval/tasks/CLAUDE.md` requires of a `_fixed` variant, not prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h tasks Offloading grading introduced a failure mode the synchronous call did not have. Before, a runaway `simplify` blocked; now it raises at GRADE_TIMEOUT. `gsm8k_0shot_gen` and `hendrycks_math_kshot_base_gen` were the only 2 of the 16 `run_cpu_bound` call sites with no `except` around the await, so that TimeoutError propagated out of `feedback` and `runner.py` turned it into a failed sample. The accuracy is identical either way -- both `report()` methods count fails in the denominator, so a timed-out sample scores 0 regardless. What changes is what the run *says*: `fails` goes non-zero and an ERROR is logged, which reads as infrastructure breakage, and `fails == 0` is one of the signals a task is promoted on. The 14 siblings, and `ugmathbench_0shot_gen_fixed`'s own explicit handler, all keep the opposite contract: an answer that cannot be graded is a wrong answer, not a failed run. Both new tests fail against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is dead `_mark_unusable` set `_pool_failed` but left `_pool` set, and `_get_pool`'s guard is `if _pool is not None or _pool_failed: return _pool` -- so the flag only ever stopped the pool being *rebuilt*, never stopped the dead one being handed back. Measured before the fix: 4 calls after the pool died made 4 `submit` attempts, each raising before falling back. After: 1. Results were always correct (the inline fallback runs either way), so what this fixes is the module agreeing with its own warning -- "falls back to the event loop for the rest of this run" -- and one exception per grade. Same guard, second case: if the executor is built but `anyio.CapacityLimiter` raises, `_pool` outlived `_limiter` and the pool would then run against anyio's shared 40-token default instead of its own. That silently undoes the admission control that makes `timeout` mean "one grade" rather than "grade plus whatever queue is in front of it" -- surfacing as a timeout on a healthy sample. Now both objects are built or neither. Both new tests fail against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_evaluable` promised "would evaluating this terminate in useful time?" while
its first line returns True for anything without `**`. `from sympy import *`
puts eagerly-evaluating callables in the parse namespace that need no exponent
to be expensive -- measured on the pinned parser, `primepi(10**12)` takes 48 s
and `factorial(1000000)` 3.8 s. Enumerating those callees is the same losing
game as allowlisting them in `_quotes_free`, so the docstring now says which
two shapes it screens and points at the bound that actually holds: the worker
process and GRADE_TIMEOUT. No behaviour change; the module docstring's narrower
wording ("a power tower is screened out") was already correct.
`_math_verify` records what extracting it cost: the twelve call sites it
replaced were free to drift apart, and now one edit rotates twelve benchmarks'
verdicts at once. Worth saying where the next person will read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…collected The `sieval/__init__.py` entry was one layer of this, not all of it. `scripts/` is not a package, so `tests/unit/scripts/` puts it on `sys.path` by walking up from its own `__file__`; inside the copy that resolves to `mutants/scripts`, which `also_copy` did not create. Stats collection therefore died on `ModuleNotFoundError: No module named 'check_layer_imports'` -- an unrelated broken test, not a config error, which is how it survived the commit that was specifically about making mutation testing runnable. Confirmed by control: `mutmut run "sieval.core.utils.offload.*"` fails identically on this branch's HEAD before these changes, so it is pre-existing. `check_mutmut_config` now asserts both required paths rather than one, since the failure mode and the symptom are identical and only the path differs. `test_live_repo_index_is_in_sync` is skipped inside a mutants copy: it compares a committed index against a live registry, and in a throwaway copy neither side is the thing it is about. This does NOT make mutation testing work end to end. It moves stats collection from 0 tests to 2847, and what remains is structural rather than configuration: `test_import_does_not_pull_math_verify` spawns a fresh interpreter, which re-imports the trampoline mutmut injects and dies on it. That is the limitation `sieval/core/CLAUDE.md` already describes for spawning tests -- here it blocks collection outright rather than just leaving mutants unobservable. No mutation score is claimed for `core/utils/offload.py` on this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdb68f4 to
a66e0af
Compare
`Test Requirements` was 239 of this file's 384 words -- 62% of a document titled "Core — Engine Constraints" spent on how to invoke a mutation-testing tool, against 120 words for the three sections actually about the engine. It also read in a different voice: the rest of the file is terse imperatives, this was a debugging narrative with error strings and a worked example. Length was the visible half. The other half is that it had stopped being true: * it presented `mutmut run` as the working invocation, and it is not -- the run dies during stats collection; * "cannot reach 100%" understated that: no score is produced at all; * the `also_copy` enumeration was incomplete (it missed `scripts`) and is now redundant with `check_mutmut_config`, which asserts the same thing; * it quoted 38.7% -> 76.3% as current fact, and that figure does not reproduce from a clean checkout; * the `pytest --cov` pyarrow note is a local-environment quirk -- CI runs `pytest --cov` without it. Now 138 words, keeping only what a reader cannot derive: both bars and which of them CI actually gates (coverage does, via `fail_under = 95`; mutation is not in CI at all), the local coverage workaround, the `python -m mutmut` trap, scoping by mutant name, and the one fact that changes behaviour today -- do not quote a mutation score until the tooling runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t yet `Corrected variants` was 145 of this file's 356 words -- 41% of it -- specifying a mechanism with zero users: the patch table's fields (`id`, field, old -> new, why), its fail-loudly semantics when a targeted row stops matching, and the `TypedDict` a fixed dataset would need. No `_fixed` dataset exists; PR #68, which introduced the section, explicitly declined to build one and left both upstream-corrupt UGMathBench rows in place. That is the same shape as the `core/CLAUDE.md` cut in the previous commit -- the longest section describing something that does not work yet -- and it sits two files from a `tasks/CLAUDE.md` rule reading "Do not coin one speculatively". Now 96 words: the rule survives (a data fix is a `datasets/` concern, a separate `<name>_fixed` over the same pinned revision, a patch table rather than a fork) along with the one justification that is not obvious -- a patch table has an exit condition and a forked copy has none. The format is left to the first dataset that needs it, which is where its actual shape will be known. `.claude/rules/tasks.md` already states only the rule and defers here, so it needs no change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type
Summary
Three things. The first two were the original scope; the third came out of review and grew to touch every math grader in the repo, so it is called out here rather than left to the diff.
ugmathbench_0shot_gen_fixed;core/utils/offload.py, and 16 task modules rewired onto it or onto the existinganyio.to_threadpattern.Scope: 33 files, +3,296 / −85. If review would rather see (3) split out, the seam is clean — it is the last four commits (
41c10e59,fa1232b0,0b218994,33f0905f) and touches no UGMathBench behaviour.1 · Task names gain an optional trailing variant segment
Upstream's buggy reading of a benchmark and our corrected one need to coexist as separate registered tasks, for a long time. The task name is the registry key and the run-directory name, so a distinct name is the only place that distinction can live — a
tasks/fixed/subpackage would only move the module while@sieval_task(name=...)still decides the key._fixedis licensed by a defect in upstream's grader or data — not by preference — and owes two things: every divergence enumerated inreference_impl.notes, and its score impact quantified. An unmeasured fork does not get to call itself a fix.arc_challenge_kshot_clpvs_pplalready differ). Fixing problem text or reference answers is adatasets/concern: a<name>_fixeddataset applying an explicit patch table (id, field, old → new, why) over the same pinned revision, which must fail loudly when a targeted row no longer matches the recordedold. Never a forked copy of the data — a patch table shrinks to empty when upstream fixes the row, and that is the only exit condition a local fix can have.Purely additive: every existing task name stays valid, and no existing task is renamed.
_TASK_FILE_PATTERNanchors the mode alternation right after the shot segment (multi-token modes leading) somodel_typestays readable off the name, plus a negative lookahead rejecting a variant that spells a mode —foo_0shot_clp_gen.pyhas two readings and is rejected outright rather than settled by regex precedence. Nothing else in the tree derives the mode from a filename, so the segment is contained to this lint.2 · UGMathBench, as
ugmathbench_0shot_gen_fixedugmathbench_0shot_gen_fixed,deps_group="math"(no new dependencies),status="stable".score, reported with AAcc, CAcc, Delta, relative_delta (upstream's RE, ×100) and per-subject EAcc.[ANS]placeholders it has and what type each takes (10 types: NV/EX/EQ/INT/TF/MCS/MCM/OE/OL/UOL). A sample is correct only when every slot is.judge_rule.py/utils.pycannot be vendored the way matharena / DeepSeek-Math / IMO-Bench were.community/ugmathbench.pyreimplements the 10 comparison rules onmath-verify+ sympy-source parsing + substitution equivalence._fixed, and the unqualifiedugmathbench_0shot_genstays permanently vacant — a faithful port would have to vendor that GPL-3.0 grader.rawtemplate on all 15,183 rows.Related Issues
_fixed-shaped change #77 —theoremqaevaluates model output with bareeval()and the guard is escapable. Same defect class as theparse_exprfix here, but closing it changes that benchmark's grading semantics, so it lands as a_fixedvariant and is deliberately gated on this PR's convention being exercised first.Live run — the headline, and how to read it
Qwen3-30B-A3B, thinking on, temperature 0.6 / top_p 0.95 / top_k 20, one rollout per version. All 15,183 versions / 5,061 problems, sglang tp2×dp4 on 8×H100, 75.7M output tokens.
score)fails/incomplete_problemsextracted=False38.49 is protocol-faithful, not this model's mathematical ability. The two are separated by a format tax of roughly 33 EAcc points, and the tax is the benchmark's rather than this port's: only the last
\boxed{}counts and a slot-count mismatch scores every slot wrong, while Qwen3 ends multi-part problems with\boxed{a}, \boxed{b}, \boxed{c}. Single-answer rows mismatch 0.30%, multi-answer rows 86.31% — same model, same subjects, so this is formatting, not difficulty. Upstream'sjudge_rule.pyhas the identical rule, so reproducing it is the point. Repairing extraction on the same stored responses lifts EAcc to 74.43, which is where a model scoring 72.5 on AIME 2026 in this harness belongs.The
_fixeddivergence, measured two waysLive head-to-head (the one that carries the weight). Upstream's judge run as a local instrument over the same 15,183 stored responses — GPL-3.0 restricts distribution, not use; nothing vendored:
The 591 is the direction that matters. Sampled eyeball: 12 of 12 genuinely correct —
2√(2t+9)vssqrt(2*4*t+36),4^20vs1.09951E+12,ln(2)/2vs0.346573590279973. No false positives. Residual misses have named, non-systematic causes: LaTeX interval notation (\cup,\infty), absolute-value bars, ay =prefix on anEXanswer.Reference replay (weaker, and it is worth saying why). Replaying each row's own reference back as a boxed answer: upstream accepts its own reference on 14,616 / 15,183 (96.27%), this module on 15,160 (99.85%); the 552 disagreeing rows span 192 of 5,061 problems — an EAcc ceiling difference of 3.79 pp, and 548 of the 552 are rows upstream rejects its own reference on, so the gap is repair rather than drift.
But this measurement could not see the largest defect the live run found: replaying a gold as its own answer short-circuits on
_squash(pred) == _squash(gold)and never reaches the symbolic path. A self-replay canary exercises the fast path and is silent about exactly the comparison logic it appears to certify. Worth remembering beyond this task.What promotion to
stablerested onThe gate was never "matches upstream" — unreachable by construction here, and it would pin this task to
experimentalforever. It was evidence that this grader is right:extracted=Falserate lowfailsis 0Criterion 4 failed at 34.9% on the first run, which is what caught the
math_verify.parseLaTeX-reader defect (the dataset's plain-sympy gold7*sin(pi*x/5)+1read as7*s*i*n*(i*p*x)/5 + 1). Fixing it was worth EAcc 34.46 → 38.49, 716 verdicts wrong-to-right and 0 right-to-wrong.Review fixes (
c2f299b5)Three of these share one cause: the grader treated model output as trusted input, and the report treated a missing grouping key as a missing sample.
Grader
parse_exprexecuted model output. Its default global namespace is built byexec("from sympy import *", ...), which injects__builtins__— so a boxed`__import__('os').system(...)`ran, on the ordinary path (every wrong free-form answer falls through to the substitution pass), while the slot was still reported wrong so nothing looked unusual._sympy_globals()removes the builtins and keeps the sympy names, whichauto_symbolneeds to readsin(...)as a function at all.9^9^9^9hung the run.^is rewritten to**and sympy exponentiates eagerly, so it asked for a 370-million-digit integer. Grading is synchronous on the event loop, so one such sample froze every concurrent sample, andmax_retriescannot recover a hang._evaluable()screens it with an unevaluated pre-parse (microseconds) and grades the answer wrong. The largest exponent in the pinned references is three digits, so no reachable comparison changes.math_equal's numeric retryreturned instead of falling through, making the substitution pass unreachable for every pair the LaTeX mangling turns into a number —2**100reads as2, so a correct2^100was compared against 2 and lost without the pass this PR added ever running.Report
feedback()'s raw-sample-less branch emitted a judgement with noextra, soreport()could not name the problem and dropped it from EAcc's denominator while its wrong verdict stayed in AAcc's — the same upward bias already fixed for failed samples, in the path that had no guard. It now recovers the grouping keys from the prompt record, as_identifydoes.EAcc <= AAccis checked and logged, andunattributed_finalsrecords what could not be recovered.Docs / metadata, no behaviour change
reference_impl.notesstill said the task was experimental, and shipped that way inmeta/index.jsonnext tostatus: stable. Replaced with the live head-to-head, which was missing there;hereforepre-split, and an empty box returning the whole response;build_promptderived the joined type description once for both branches, where upstream's single-answer branch readsanswer_type[0]alone. Equal on every pinned row, so this pins the shape rather than the coincidence.Note: moving grading off the event loop is now done — see the section below.
sieval/community/deepseek_math.pyhas the sameparse_exprexposure, but it is a vendored port of GPL upstream where fidelity is the constraint — separate issue.3 · Grading no longer blocks the session
(
41c10e59·fa1232b0·0b218994·33f0905f)Every runner in a session shares one event loop —
MultiTaskRunner.arunstarts eachTaskRunnerwithtg.start_sooninside a singleanyio.run, andEvalSessionuses it. Synchronous grading infeedback()therefore stalled every other task too, not just its own samples.Measured by mirroring that structure (a co-running benchmark doing 50 ms awaited round trips):
A heartbeat probe puts it plainly: 0 ticks during a grade, where a free loop manages 100/s. Not only on pathological input — an ordinary wrong answer holds the loop ~23 ms, and a full run is ~8,200 of them (~190 s). After the change ugmathbench graded slightly more samples (533 vs 517), since grading now parallelises across workers instead of serialising on the loop.
sieval/core/utils/offload.pyaddsrun_cpu_bound, running a picklable module-level callable in a shared process pool.A process and not a thread, which was not the first choice:
math-verifybounds its own parse/verify withsignal.SIGALRM, which only arms on the main thread. In a worker thread it does not degrade — it raisesValueError: ... doesn't support threaded environment. Callers wrap that in a broadexcept, so the whole math-verify strategy would vanish silently and verdicts flip:\frac{1}{2}against0.5measured True → False, as did\frac{\pi}{4}and\sqrt{2}.spawn, notfork: the parent is an async process with live worker threads.Degrades rather than fails — if the pool cannot start, work runs inline (the previous behaviour, slow but correct).
SIEVAL_OFFLOAD_WORKERS=0forces that path.One rule, applied to every grader. Grading does not run on the event loop, and the mechanism follows the grader's own constraint:
signal.SIGALRM-based and it raises off the main threadmath_equal(..., timeout=False), so nothing else bounds it — criterion 2An earlier revision wired only ugmathbench, on the grounds that the other twelve cost 0.2–0.7 s of blocked loop per run against its ~190 s. That was a weak line: 0.2 s assumes
n=4while Apex publishes at n=16, so the number moves with the run config — and it came from short integer answers (median 1.4 ms) when the same sample already reached 89 ms, with math-verify's own timeout at 5 s. A single 1.7 s spike was exactly the argument for wiring the DeepSeek graders. ugmathbench was never special; it was just the one measured first.The edit is mechanical and bounded: that comparison block was already duplicated verbatim in all twelve (verified — one digest across the twelve), so it is extracted once into
sieval/tasks/_math_verify.pyand imported by all twelve, module-level because a worker pickles the callable by reference. The twelve sites shared a contract that has to change together (gold first, both sides$-wrapped), which is this repo's bar for extracting — but the cost is real and is now stated in that module's docstring: one edit there rotates twelve benchmarks' verdicts at once, where the duplication it replaced let them drift apart deliberately.One of the fourteen was missed on the first pass:
imo_answer_benchgrades throughverify_math_answer, a community wrapper aroundmath_verify.verify, so a completeness check matching the namesparse/verifyinsidefeedbackwalked straight past it and reported clean. The check is now wrapper-aware — it resolves whichsieval/communityfunctions reach math-verify transitively, then asks whether any async stage calls one without an await.Verdicts checked directly rather than inferred from green tests: extracted function against the old inline logic over integers, fractions, radicals, equivalent-but-differently-written pairs and both empty-string cases — 0 mismatches / 12.
The one honest cost is pool cold start (~2.7 s): a session running only a 30-problem AIME pays more than the 0.2 s it saves. The pool is lazy and shared, so any multi-task session amortises it, and against runs measured in minutes of inference both numbers are noise — not a reason to keep a rule that needs a paragraph to explain.
No wrapper over
anyio.to_thread.run_sync. The ten thread-offload sites do unrelated things — shard-index collection, session setup, a subprocess env probe, scicode's target reads — and share no contract that must change together, which is this repo's stated bar for extracting. One real drift did surface while inventorying them:scicode_0shot_gen.pyusedasyncio.to_thread, which runs on the loop's own executor and so escapes anyio'sCapacityLimiter, putting those reads outside the session's thread budget. Switched toanyio.to_thread.run_sync, so all ten share one mechanism and one bound.Review fixes, round 2 (
22b77195·98900034·2a972dd9·a66e0af1)Rebased onto
main@bba57b79(#81). Two behavioural fixes, two that onlynarrow a claim to what the code does. Both behavioural fixes ship a test that
fails against the unfixed code, not just a passing one.
A grading timeout failed the sample instead of scoring it wrong —
gsm8k_0shot_genand
hendrycks_math_kshot_base_genwere the only 2 of the 16run_cpu_boundcall sites withno
exceptaround the await, soTimeoutErrorpropagated out offeedbackand the runnerturned it into a failed sample. The accuracy is identical either way — both
report()methodscount fails in the denominator — so what changed is what the run says:
failsgoes non-zeroand an ERROR is logged, which reads as infrastructure breakage, and
fails == 0is one of thesignals this very PR promotes a task on. The other 14 sites, and
ugmathbench_0shot_gen_fixed'sown explicit handler, all keep the opposite contract.
A dead offload pool was retried once per sample.
_mark_unusableset_pool_failedbut left_poolset, and_get_pool's guard isif _pool is not None or _pool_failed: return _pool— sothe flag only ever stopped the pool being rebuilt, never stopped the dead one being handed back.
Measured: 4 calls after the pool died made 4
submitattempts; now 1. Results were always correct(the inline fallback runs regardless), so this is the module agreeing with its own warning —
"falls back to the event loop for the rest of this run" — rather than a wrong answer. Same guard,
second case: an executor that outlived a failed
CapacityLimiterwould have run against anyio'sshared 40-token default instead of its own, silently undoing the admission control that makes
timeoutmean "one grade" rather than "grade plus queue". Now both are built, or neither._evaluablepromised more than it screens. Its docstring asked "would evaluating thisterminate in useful time?" while the first line returns
Truefor anything without**.from sympy import *puts eagerly-evaluating callables in the parse namespace that need noexponent to be expensive — measured on the pinned parser,
primepi(10**12)takes 48 s andfactorial(1000000)3.8 s. Enumerating those callees is the same losing game as allowlistingthem in
_quotes_free, so the docstring now names the two shapes it does screen and points atthe bound that actually holds (the worker process and
GRADE_TIMEOUT). No behaviour change —the module docstring's narrower wording was already correct.
[tool.mutmut]still could not collect. See the caveat in the Test Plan.also_copygainsscripts, andcheck_mutmut_confignow asserts both required paths rather than one, since thefailure mode and symptom are identical and only the path differs.
Also corrected in this description, no code involved: the reference-replay figures were the
pre-
arcsin-alias ones (the modules had the current numbers, this did not); the mechanism tablestill said the DeepSeek graders use
anyio.to_thread.run_sync, where109b0f1cmoved them to aprocess; the thread-site inventory still counted grading among them (ten sites now, none of them
grading); and the twelve math-verify graders share one
sieval/tasks/_math_verify.pyrather thaneach carrying a copy.
Test Plan
Automated
ruff check && ruff format --check— 409 files)ty check)main@bba57b79core/module — 97% (sieval/core/utils/offload.py: 59 stmts,1 miss, 14 branches). The single miss is the double-checked-lock re-check inside
with _lock:,reachable only when another thread wins the race between the two checks; not worth a contrived
test. Meets the ≥95% bar in
sieval/core/CLAUDE.md.(
pytest --covdies in this environment on a pyarrow double-registration — it reproduces onuntouched modules like
core/utils/hf.py, so it is thepytest-covplugin, not this change.python -m coverage run --source=sieval -m pytest ...works; both notes added tosieval/core/CLAUDE.md.)core/module — 71/93 = 76.3%, over the ≥70% bar.Caveat, found in review: this number cannot currently be reproduced from a clean
checkout —
mutmut runstill dies during stats collection, becausealso_copyomittedscriptsas well assieval/__init__.py(tests/unit/scripts/putsscripts/onsys.pathby walking up from__file__, which inside the copy ismutants/scripts).Fixed below, which moves collection from 0 tests to 2,847 — but one blocker remains and it
is structural, not configuration:
test_import_does_not_pull_math_verifyspawns a freshinterpreter, which re-imports mutmut's injected trampoline and dies on it. So no mutation
score is claimed for
offload.pyon this branch, and the ≥70% bar is currentlyunsatisfiable for any
core/change rather than failing. The paragraph below records whatthe assertions were written to pin, which stands on its own.
Started at 38.7%. The gain is entirely assertions, not new behaviour: the suite exercised the
module without pinning what it promises. Now asserted — the pool is spawned, never forked
(a mutation to
"fork"fails no behavioural test, it just occasionally deadlocks a run), builtonce and reused, sized by
_worker_count(), not reconsidered once disabled, and not retried persample after a failed start; the degradation warnings name their cause, since a diagnostic that
drops it is what makes a silent fallback silent; and
_worker_countpins its clamps (negativefloors to 0, caps at 8, floors at 1, survives a
Nonecpu_count, leaves a core for the loop).The residual 22 are genuinely worker-internal —
pool.submit(func, …)→submit(None, …)isanswered identically by the inline fallback mutmut forces, since it cannot instrument a spawned
worker.
Two pre-existing tooling bugs found on the way, both fixed here.
[tool.mutmut].also_copyomitted
sieval/__init__.py, somutants/sievalwas never an importable package (8beafef0);and
python -m mutmut— the formsieval/core/CLAUDE.mdprescribed — double-executesmutmut/__main__.pyand dies on the first mutant of every module, which is why thisrequirement had never been runnable. Verified by control on
core/utils/concurrency.py, whichpredates this branch: identical failure under
python -m, 12/16 killed under the console script.Neither was introduced by this PR.
Manual
sieval dataset download ugmathbenchsucceeds — 16 configs, unpacks to 15,183 rows;sieval task show/sieval dataset showresolve.make_promptvia AST and diffing againstbuild_promptwith therawtemplate: 15,183 / 15,183, 0 mismatches.fails0; numbers above.judge_answers()— the RCE payload creates no file and the power tower returns in 0.11 s, with 0 regressions on the equivalence cases the grader fix was worth.Checklist
Required (all PRs)
type(scope): description)sanitize.shclean)AI-Generated Code - <model> (<provider>)in module docstringcore/git mv(task module + its test)If: New or Modified Benchmark
_fixed. The grader that produced the published numbers is GPL-3.0 and cannot ship here, so comparing this task's EAcc to the paper's would compare two different rulers. The live head-to-head above is the substitute, and it is stronger: it measures this grader against upstream's directly, on real model prose rather than replayed references.hf:source revision-pinned (8ab16f0c…, verified to resolve; card licencegpl-3.0matches the declaredlicense)__init__.py(lazy export + regenerated__init__.pyiandmeta/index.json)If: community/ Changes
community/ugmathbench.py's module docstring, summarized inreference_impl.notesIf: Engineering Infrastructure (rules / enforcer / hooks)
_TASK_FILE_PATTERNinscripts/check_preflight.pymatches the documented convention, with its mirror test intests/unit/scripts/test_check_preflight.py.claude/rules/tasks.md,sieval/tasks/CLAUDE.md,sieval/datasets/CLAUDE.md,CONTRIBUTING.md; pre-commitfiles:scope unchangedfoo_5shot_clp_extra.pyis now a valid variant) — removed rather than left asserting the old behaviour, and replaced with positive coverage plus mode-shadowing rejectsDeliberate data decisions
Financial_mathematics_0132v2,Linear_algebra_0306v3 — problem text replaced byERROR caught by Translator, empty answer sequence). They are prompted and graded like any other sample and score 0, exactly as upstream does. Dropping them would let the two problems they belong to reach EAcc on 2 of 3 versions. These are data defects, and per the new convention a data fix would be a<name>_fixeddataset with a patch table — deliberately not done here.slice(n)keeps whole problems. Score is order-independent, so this costs nothing.report.jsoncarriesincomplete_problems, so a problem judged on fewer than three versions is visible rather than silently counted as a miss.Caveat on Delta
At temperature 0.6 the reasoning gap is mostly the sampler. Control on Geometry with
n=3: Delta 8.70 across the three randomized versions vs 7.25 across three rollouts of one fixed version — only 1.45 pp is version sensitivity, 83% is sampling noise. Upstream generates greedily, where this does not arise. Read Delta only against the sampling settings that produced it.Budget note
A full run is 15,183 inferences. Grading a wrong answer costs ~25 ms of synchronous sympy per sample; a correct one short-circuits on string equality and is effectively free.
datasets.<name>.args.subjectsselects a subject subset for smoke tests.🤖 Generated with Claude Code