Skip to content

feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop - #68

Merged
ethan-scitix merged 30 commits into
mainfrom
worktree-ugmathbench
Aug 8, 2026
Merged

feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop#68
ethan-scitix merged 30 commits into
mainfrom
worktree-ugmathbench

Conversation

@ethan-scitix

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

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

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.

  1. a naming convention for task variants;
  2. UGMathBench as its first user, ugmathbench_0shot_gen_fixed;
  3. grading moved off the shared event loop — a new core/utils/offload.py, and 16 task modules rewired onto it or onto the existing anyio.to_thread pattern.

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

<task>_<N>shot_<mode>[_<variant>].py        class <Benchmark><ShotType><Mode>[<Variant>]Task

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. Anyone comparing a run against a published number reads the name and stops thinking.
  • _fixed 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.
  • Two exclusions. A different measurement regime is not a variant (it gets a descriptive mode name, the way arc_challenge_kshot_clp vs _ppl already differ). 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, which must fail loudly when a targeted row no longer matches the recorded 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.

Purely additive: every existing task name stays valid, and no existing task is renamed. _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. 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_fixed

  • Adds UGMathBench (paper, ICLR 2025 · repo · dataset): dataset + ugmathbench_0shot_gen_fixed, deps_group="math" (no new dependencies), status="stable".
  • 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. EAcc (share of problems correct in all three versions) is score, reported with AAcc, CAcc, Delta, relative_delta (upstream's RE, ×100) and per-subject EAcc.
  • Grading is per answer slot: a problem declares how many [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.
  • 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 / IMO-Bench were. community/ugmathbench.py reimplements the 10 comparison rules on math-verify + sympy-source parsing + substitution equivalence.
  • It therefore ships as _fixed, and the unqualified ugmathbench_0shot_gen stays permanently vacant — a faithful port would have to vendor that GPL-3.0 grader.
  • The prompt, by contrast, is exact — byte-identical to upstream's raw template on all 15,183 rows.

Related Issues

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.

metric value
EAcc (score) 38.49
AAcc 45.59
CAcc 53.53
Delta 7.10
fails / incomplete_problems 0 / 0
extracted=False 32 / 15,183 (0.21%)

38.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's judge_rule.py has 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 _fixed divergence, measured two ways

Live 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:

value
per-sample agreement 95.51%
sieval right, upstream wrong 591 ← where a lenient grader would show up
upstream right, sieval wrong 91 (0.60% of samples)
upstream's own EAcc on these responses 35.55 (below this task's 38.49)

The 591 is the direction that matters. Sampled eyeball: 12 of 12 genuinely correct2√(2t+9) vs sqrt(2*4*t+36), 4^20 vs 1.09951E+12, ln(2)/2 vs 0.346573590279973. 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.

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 stable rested on

The gate was never "matches upstream" — unreachable by construction here, and it would pin this task to experimental forever. It was evidence that this grader is right:

# criterion result
1 extracted=False rate low met — 0.21%, truncation 0.19%
2 fails is 0 met — 0
3 plausible band vs siblings on same model explained, not met — the whole remaining gap is the format tax above, attributed two independent ways (the 0.30% vs 86.31% split, and tracking upstream's verifier to within 3 points). This criterion exists to catch an unexplained anomaly
4 sampled false-negative rate met — via the independent-instrument audit above

Criterion 4 failed at 34.9% on the first run, which is what caught the math_verify.parse LaTeX-reader defect (the dataset's plain-sympy gold 7*sin(pi*x/5)+1 read as 7*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_expr executed model output. Its default global namespace is built by exec("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, which auto_symbol needs to read sin(...) as a function at all.
  • A boxed 9^9^9^9 hung 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, and max_retries cannot 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 retry returned instead of falling through, making the substitution pass unreachable for every pair the LaTeX mangling turns into a number2**100 reads as 2, so a correct 2^100 was compared against 2 and lost without the pass this PR added ever running.

Report

  • EAcc could exceed AAcc, silently. 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 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. EAcc <= AAcc is checked and logged, and unattributed_finals records what could not be recovered.

Docs / metadata, no behaviour change

  • the two modules disagreed on the same measurement (705/38.43 vs 716/38.49) — the arcsin-alias commit updated only one;
  • reference_impl.notes still said the task was experimental, and shipped that way in meta/index.json next to status: stable. Replaced with the live head-to-head, which was missing there;
  • the task docstring had grown into a changelog and stated current numbers three contradictory ways. Rewritten to describe what ships;
  • two extraction divergences were missing from a list that enumerates them deliberately: upstream's 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.

Note: moving grading off the event loop is now done — see the section below. sieval/community/deepseek_math.py has the same parse_expr exposure, 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.arun starts each TaskRunner with tg.start_soon inside a single anyio.run, and EvalSession uses it. Synchronous grading in feedback() 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):

round trips in 12 s
sibling benchmark alone 239
sibling + ugmathbench grading, before 1 (0.4%)
sibling + ugmathbench grading, after 237 (99.2%)

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.py adds run_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-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 timeouts keep working and verdicts are unchanged. spawn, not fork: 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=0 forces 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:

grader tasks mechanism why
math-verify 14 tasks — ugmathbench, IMO-AnswerBench, and the 12 competition/MATH tasks (AIME ×3, HMMT ×3, Apex ×2, BRUMO, CMIMC, SMT, MATH-500) process its timeouts are signal.SIGALRM-based and it raises off the main thread
deepseek_math (pure sympy) gsm8k, hendrycks_math process thread-safe, but reached with math_equal(..., timeout=False), so nothing else bounds it — criterion 2

An 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=4 while 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.py and 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_bench grades through verify_math_answer, a community wrapper around math_verify.verify, so a completeness check matching the names parse / verify inside feedback walked straight past it and reported clean. The check is now wrapper-aware — it resolves which sieval/community functions 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.py used asyncio.to_thread, which runs on the loop's own executor and so escapes anyio's CapacityLimiter, putting those reads outside the session's thread budget. Switched to anyio.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 only
narrow 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 wronggsm8k_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 TimeoutError propagated out of feedback and the runner
turned it into a failed sample. The accuracy is identical either way — both report() methods
count fails in the denominator — so what changed 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 this very PR promotes a task on. The other 14 sites, and ugmathbench_0shot_gen_fixed's
own explicit handler, all keep the opposite contract.

A dead offload pool was retried once per sample. _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: 4 calls after the pool died made 4 submit attempts; 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 CapacityLimiter would have run against anyio's
shared 40-token default instead of its own, silently undoing the admission control that makes
timeout mean "one grade" rather than "grade plus queue". Now both are built, or neither.

_evaluable promised more than it screens. Its docstring asked "would evaluating this
terminate in useful time?" while the 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 names the two shapes it does screen and points at
the 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_copy gains
scripts, and check_mutmut_config now asserts both required paths rather than one, since the
failure 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 table
still said the DeepSeek graders use anyio.to_thread.run_sync, where 109b0f1c moved them to a
process; 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.py rather than
each carrying a copy.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check — 409 files)
  • Type check clean (ty check)
  • Unit tests pass — 3425 passed, 0 failed (28 new tests added by this PR), rebased onto main @ bba57b79
  • Coverage for the new core/ 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 --cov dies in this environment on a pyarrow double-registration — it reproduces on
    untouched modules like core/utils/hf.py, so it is the pytest-cov plugin, not this change.
    python -m coverage run --source=sieval -m pytest ... works; both notes added to
    sieval/core/CLAUDE.md.)
  • Mutation score for the new 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 run still dies during stats collection, because also_copy omitted
    scripts as well as sieval/__init__.py (tests/unit/scripts/ puts scripts/ on
    sys.path by walking up from __file__, which inside the copy is mutants/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_verify spawns a fresh
    interpreter, which re-imports mutmut's injected trampoline and dies on it. So no mutation
    score is claimed for offload.py on this branch
    , and the ≥70% bar is currently
    unsatisfiable for any core/ change rather than failing. The paragraph below records what
    the 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), built
    once and reused, sized by _worker_count(), not reconsidered once disabled, and not retried per
    sample 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_count pins its clamps (negative
    floors to 0, caps at 8, floors at 1, survives a None cpu_count, leaves a core for the loop).
    The residual 22 are genuinely worker-internal — pool.submit(func, …)submit(None, …) is
    answered 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_copy
    omitted sieval/__init__.py, so mutants/sieval was never an importable package (8beafef0);
    and python -m mutmut — the form sieval/core/CLAUDE.md prescribed — double-executes
    mutmut/__main__.py and dies on the first mutant of every module, which is why this
    requirement had never been runnable. Verified by control on core/utils/concurrency.py, which
    predates this branch: identical failure under python -m, 12/16 killed under the console script.
    Neither was introduced by this PR.

Manual

  • sieval dataset download ugmathbench succeeds — 16 configs, unpacks to 15,183 rows; sieval task show / sieval dataset show resolve.
  • Prompt is byte-identical to upstream on all 15,183 rows, verified by extracting upstream's make_prompt via AST and diffing against build_prompt with the raw template: 15,183 / 15,183, 0 mismatches.
  • Full live run — 15,183 versions, fails 0; numbers above.
  • Head-to-head against upstream's judge on the same stored responses — 95.51% agreement, 591-to-91.
  • Guards verified end-to-end through 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)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files (sanitize.sh clean)
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring
  • No new upper-layer dependencies added to core/
  • Deleted code verified — nothing deleted; the two renames are git mv (task module + its test)

If: New or Modified Benchmark

  • Reference paper/repo linked in Summary
  • Score comparison table — N/A by construction, and that is the point of _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.
  • Dataset loading tested; hf: source revision-pinned (8ab16f0c…, verified to resolve; card licence gpl-3.0 matches the declared license)
  • Task registered in package-level __init__.py (lazy export + regenerated __init__.pyi and meta/index.json)

If: community/ Changes

  • Upstream diff documented — every known delta enumerated in community/ugmathbench.py's module docstring, summarized in reference_impl.notes
  • License attribution preserved — the module header states UGMathBench's GPL-3.0 licence, why no harness code is vendored, and what is reproduced (the prompt protocol), against a commit-pinned URL

If: Engineering Infrastructure (rules / enforcer / hooks)

  • Enforcer and rule agree — _TASK_FILE_PATTERN in scripts/check_preflight.py matches the documented convention, with its mirror test in tests/unit/scripts/test_check_preflight.py
  • Coupled sites updated in the same commit — .claude/rules/tasks.md, sieval/tasks/CLAUDE.md, sieval/datasets/CLAUDE.md, CONTRIBUTING.md; pre-commit files: scope unchanged
  • One existing reject case became legal (foo_5shot_clp_extra.py is now a valid variant) — removed rather than left asserting the old behaviour, and replaced with positive coverage plus mode-shadowing rejects

Deliberate data decisions

  • Two upstream-corrupt rows are kept, not dropped (Financial_mathematics_0132 v2, Linear_algebra_0306 v3 — problem text replaced by ERROR 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>_fixed dataset with a patch table — deliberately not done here.
  • Sample ordering is problem-major (upstream emits version-major), so a problem's three versions are adjacent and slice(n) keeps whole problems. Score is order-independent, so this costs nothing.
  • report.json carries incomplete_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.subjects selects a subject subset for smoke tests.

🤖 Generated with Claude Code

@ethan-scitix ethan-scitix changed the title feat(tasks): add UGMathBench (16 subjects, 3 randomized versions, EAcc) feat(tasks): task-name variants, and UGMathBench as ugmathbench_0shot_gen_fixed Aug 6, 2026
@ethan-scitix
ethan-scitix marked this pull request as ready for review August 6, 2026 07:17
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

First live run — results, and why the task stays experimental

The docstring gated promotion on a live run and said "Record the numbers here." Done (e0ce467). Docstring + status comment only; no behaviour change.

Setup. 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. fails 0, incomplete_problems 0.

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. Upstream judge_rule.py has 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).

@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Grader fix pushed — bb80669

The criterion-4 defect from the previous comment is fixed and measured.

What was wrong

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:

gold "7*sin(pi*x/5)+1"          ->  7*s*i*n*(i*p*x)/5 + 1     # mangled
pred "7\sin(\frac{\pi}{5}x)+1"  ->  7*sin(pi*x/5) + 1         # fine

So any gold naming a function could only match by exact string equality.

The fix

  • _parse_sympy_source — read the gold with sympy's own parser ([...] is grouping in this dialect, ^ is power, 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 — equivalence by numeric substitution, closing the one gap where upstream was genuinely stronger. Catches what no normalizer reaches, e.g. 3\cos(2\sqrt{35}t) vs 3*cos(sqrt(980/7)*t).

Conservative by construction: it runs only after every other strategy said "not equal", so the only verdict it can change is wrong→right. Symbol-name sets must match (an answer in x never matches one in t); every cleanly-evaluating probe must agree and ≥3 must evaluate; rounding past the benchmark's own tolerance stays wrong.

Two details worth review:

  • the probe ladder is fixed, not 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 on a larger ladder every overflowed probe got discarded, leaving too few points and marking a correct exponential answer wrong. That was a real bug in my first cut, caught by re-auditing the residuals.

Measured, on the same 15,183 stored responses (extraction held fixed, so this is the grader's delta alone)

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

  1. 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.
  2. 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 an EX answer, a trailing dx.

Happy to take either further if you want it in this PR.

@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Promoted to stable3c79d86

You were right on the format tax, and it changed the call. Recording why.

Criterion 3 does not block

The tax is upstream's own rule, and reproducing it is this task's design goal. Criterion 3 exists to catch an unexplained anomaly — a mis-wired prompt, a mis-joined gold, a broken extractor. This gap is explained, quantified, and attributed, and the attribution is checked two independent ways:

  • single-answer rows mismatch 0.30% vs multi-answer rows 86.31%, same model and subjects — formatting, not difficulty;
  • upstream's own verifier scores 35.55 on these responses, i.e. below this task. A broken harness would not track the reference implementation that closely.

So 38.49 is protocol-faithful and paper-comparable; it is just not the model's math ability. That is a caveat in the docstring, not a status blocker.

Criterion 4, re-measured with an independent instrument

My "it can't be measured" was too quick — the circularity is real (the audit that found the defect used substitution; the grader now uses substitution), but upstream's judge_rule.py is an independent implementation of the same spec and can play that role. Over the same 15,183 responses:

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 correct2√(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.

@ethan-scitix
ethan-scitix force-pushed the worktree-ugmathbench branch from 3c79d86 to c2f299b Compare August 6, 2026 17:55
@ethan-scitix ethan-scitix changed the title feat(tasks): task-name variants, and UGMathBench as ugmathbench_0shot_gen_fixed feat(tasks): task-name variants, UGMathBench as _fixed, and grading off the shared event loop Aug 7, 2026
ethan-scitix added a commit that referenced this pull request Aug 7, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 7, 2026
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>
ethan-scitix and others added 19 commits August 8, 2026 15:47
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>
ethan-scitix and others added 9 commits August 8, 2026 15:47
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>
@ethan-scitix
ethan-scitix force-pushed the worktree-ugmathbench branch from fdb68f4 to a66e0af Compare August 8, 2026 07:56
ethan-scitix and others added 2 commits August 8, 2026 16:07
`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>
@ethan-scitix
ethan-scitix merged commit 1c15c00 into main Aug 8, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the worktree-ugmathbench branch August 8, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant