Skip to content

feat(platinum): add PlatinumBench dataset + 5 math 0-shot generation tasks - #65

Merged
ethan-scitix merged 8 commits into
mainfrom
feat/platinum-bench-math
Aug 6, 2026
Merged

feat(platinum): add PlatinumBench dataset + 5 math 0-shot generation tasks#65
ethan-scitix merged 8 commits into
mainfrom
feat/platinum-bench-math

Conversation

@ethan-scitix

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

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

Summary

  • Integrates PlatinumBench (MadryLab/platinum-benchmarks, data madrylab/platinum-bench) — benchmarks whose labels were re-verified so that a remaining failure is a real model failure, not label noise. Scope is the 5 math subsets (gsm8k, svamp, multiarith, singleop, singleq); the other 9 configs load but ship no task.
  • One dataset, one task subpackage. PlatinumBenchDataset merges all 14 non-vqa configs into one test split and stamps subset on every row; the caller narrows to one subset with a filter operation (see Selecting a subset). sieval/tasks/platinum_bench/ holds 5 leaves that are 2-line subclasses of one _base.py — same layout as arc/ (feat(core): persist task identity in run meta.json #57). The dataset FK resolves off the base's sample generic through the MRO, so the leaves declare no generic of their own.
  • Rejected rows are dropped, which is the point of the benchmark: cleaning_status == "rejected" marks a question the authors found unanswerable/mislabeled. 953 of 1042 rows kept (gsm8k 268/300, svamp 265/300, multiarith 170/174, singleop 150/159, singleq 100/109).
  • Prompt, extraction and scoring all follow upstream, not sieval's native math path. The prompts are carried in the data (platinum_prompt / platinum_prompt_no_cot); upstream's get_parse_fn + check_prediction (exact float equality on platinum_target[0]) are vendored byte-faithfully into sieval/community/platinum_bench.py.
  • status="stable" on all five, earned by an exact reproduction: replaying upstream's own published inferences (madrylab/platinum-bench-paper-cache) through this pipeline reproduces all 120 math error counts of the paper's Table 3 — 24 models × 5 subsets, every cell. Independently, three live runs on two published checkpoints (Qwen2.5-72B-Instruct and Llama-3.3-70B-Instruct, 968 rows each, on upstream's own serving provider) land within 0.7 σ_D of their Table 3 rows — 8 vs 11, 16 vs 14, 15 vs 14 — confirming the numbers survive real sampling over the wire. Tables below.
  • Three prompt variants, not two. prompt_variant now takes cot / no_cot / no_cot_o1. Upstream's platinum_prompt_no_cot column carries a dangling "Then, provide" (a leftover conjunction from the CoT wording) and upstream rewrites it to "Provide" for its o1 snapshots only — not for o1-mini / o3-mini, which ran against the unedited string. Both branches are needed, and having them takes the port from 21 to 24 of 24 published rows.
  • No new dependencies, no examples/ YAML, no changes to any existing task. One additive core/ change: Dataset.filter, plus its registration as a YAML operation (see below). The get_task_class() subpackage-lookup fix these tasks need already landed in feat(core): persist task identity in run meta.json #57.

Selecting a subset

A HuggingFace config is a load-time choice, so the loader reads all 14 non-vqa configs and the caller narrows:

datasets:
  platinum_gsm8k:
    class: PlatinumBenchDataset
    path: madrylab/platinum-bench
    operations:
      - filter: {by: subset, value: gsm8k}

This replaced an earlier required subset= constructor argument — see Review Fixes for why and what was re-verified. Omit the operation, or name the wrong subset, and setup() fails before any tokens are spent.

Related Issues

Refs #57 (the arc/ subpackage layout and the get_task_class() subpackage lookup this builds on).

Test Plan

Automated

  • Lint/format clean (ruff check + ruff format --check: all checks passed, 386 files formatted)
  • Type check clean (ty check: all checks passed). mypy --strict reports 29 diagnostics of 4 classes (no-untyped-def on stage methods, no-untyped-call into the vendor, union-attr on upstream's unguarded re.search(...).group()); the existing gsm8k dataset+task pair reports 37 of the same classes, and [tool.mypy] excludes sieval/community — so this is baseline, not new.
  • Unit tests pass: 3067 passed (24 community, 14 dataset, 60 task, 12 Dataset.filter, plus the operation's two CLI whitelists)
  • Integration + acceptance: 64 passed
  • Full preflight (scripts/check_preflight.py): all PASS, including check_meta_index_sync, check_tasks (45 tasks), check_datasets (70 exports, all sources pinned), check_imports, check_task_shot_knobs

New tests deliberately pin the failure modes that would otherwise be silent:

  • Every one of the 5 subset names is on upstream's math_datasets list — a misspelled subset silently degrades scoring from float equality to string membership, and "42" in ["42"] still passes, so nothing else would notice.
  • Each leaf's vars() is exactly {subset, tags, model_type, n_shot} (the last three seeded by @sieval_task), so a leaf can never grow behaviour that drifts from its four siblings.
  • Row counts are asserted three ways (kept, total, total - kept) so a stale number cannot agree with itself.
  • prediction=None and the absent prediction key are covered separately, because obj_to_dict drops None keys on the disk round-trip.
  • The vendor's two upstream quirks are pinned as behaviour the task layer absorbs: AttributeError on digit-free output, and the dead prediction != 'Parsing error' guard that lets float('parsing error') raise ValueError.

Manual

  • sieval dataset download platinum_bench succeeds (17 files at the pinned revision).
  • Real-data load of all 5 subsets: kept-row counts exactly as claimed (953 total); every kept row has platinum_parsing_strategy == "math"; surviving statuses are only consensus/verified/revised; the selected columns are exactly the 6 shared ones plus the stamped subset; and all 953 gold answers round-trip through upstream's parse + check_prediction (953/953), so the vendored extractor and the pinned data agree.
  • sieval task show platinum_gsm8k_0shot_gen resolves by name (the subpackage-hosted lookup path) and renders the full reference-impl notes.
  • Score comparison: all 120 cells of Table 3's math block reproduce exactly. See the table below.
  • Live end-to-end run against a real endpoint — all 5 subsets, 953 questions, Qwen/Qwen3-32B over an OpenAI-protocol endpoint, temperature=0.5, one sample, driven through sieval eval + YAML. 0 pipeline failures, 6 errors: singleq 0/100, multiarith 1/170, singleop 1/150, gsm8k 2/268, svamp 2/265. Persisted request_params confirm max_tokens=6000 and temperature=0.5 actually reach the wire. (That run predates the review fix below, which moved the budget out of the task; the request it sent is byte-identical either way, since the value is the same 6000 — only its source changed, from infer() to the model config.) Five of the six errors are ordinary wrong answers with a clean Answer: line.
    • The sixth is a finding, and it is now documented. Upstream sized max_tokens=6000 for non-thinking models. Qwen3-32B spent the entire budget inside the reasoning channel on one singleop question (finish_reason="length", 10k characters of "Wait, no, 4 + 7 is 11?") and returned an empty answer — which scores as an error indistinguishable from a wrong one unless you know to look. anomalies.json does distinguish it: that row is the only anomaly across all 953, flagged truncated_output + extraction_failure. The docstring and reference notes carry this as an infer prerequisite: set max_tokens=6000 to reproduce, raise it on a thinking model, and read errors alongside anomalies.json. Tests pin the wording.
    • This run alone cannot confirm a published number — Qwen3-32B is not a model upstream evaluated, so it pins the transport but has no number to be checked against. The run below closes that gap.
  • Live runs on models upstream did publish, aligned against Table 3. Qwen2.5-72B-Instruct and meta-llama/Llama-3.3-70B-Instruct — byte-identical to the paper cache's model keys — all 5 subsets over the paper version's 968 questions, temperature=0.5, one sample. Upstream's model factory routes both of its open-weight rows through DeepInfra, so the runs pin that provider with fallbacks disabled and the pin lands in the persisted request_params; a third run on a bf16 endpoint brackets the serving-precision effect. 0 pipeline failures, full 968-row coverage, all three within 0.7 σ_D of their published rows, and the live misses overlap upstream's own while moving in both directions — numbers in Live alignment below.
    • Exact equality is not reachable and is not the claim: upstream samples at temperature=0.5 with no seed. The two checks answer different questions on purpose — the replay pins scoring fidelity against upstream's own inferences, a live run pins that the transport and stage plumbing work on real completions.

Score Comparison

Three checks, because none alone is sufficient: a replay of upstream's published inferences pins scoring fidelity against Table 3; live runs on two published models pin that the same numbers survive real sampling over the wire; and a live run on a thinking model exercises a decoding regime upstream never covered. All three are described below.

Why replay and not a fresh run

Upstream decodes at temperature=0.5, one sample, no seed — so a fresh run against any single model is unreproducible by construction, and the metric makes that fatal rather than merely annoying. Upstream reports raw error counts, 0–19 out of 100–274 rows; for GSM8K (n=274, p≈0.011) the binomial σ is ≈1.7 errors, so "our count is within ±2 of theirs" would be unfalsifiable noise dressed up as agreement.

Upstream also published the actual completions behind the paper's numbers (madrylab/platinum-bench-paper-cache, 15 pickles). Replaying those through this pipeline is deterministic, costs nothing, and is a strictly stronger check than any live run:

  • The stub model looks up (prompt, temperature, 0, model) and raises on a miss. All 968 prompts × 25 cached models hit — which is what proves our prompt string is byte-identical to upstream's, not merely equivalent.
  • Everything else is production code: the dataset's rejected-row filter, the leaf task, preprocess/infer/postprocess/feedback/report, and the on-disk record round-trip TaskRunner drives. fails == 0 on every run.
  • Run against madrylab/platinum-bench-paper-version (the revision the paper's numbers are computed on), which keeps 968 math rows.

Table 3, math block — errors per subset (lower is better)

# Platinum Questions: SingleOp 150 · SingleEq 100 · MultiArith 171 · SVAMP 273 · GSM8K 274.

Model variant SingleOp SingleEq MultiArith SVAMP GSM8K
o1-2024-12-17 (high) no_cot_o1 @ 1 0 0 0 0 2
Claude 3.5 Sonnet (Oct) 0 0 0 1 3
o1-2024-12-17 (med) no_cot_o1 @ 1 0 0 0 1 2
DeepSeek-R1 no_cot 0 0 1 1 1
Claude 3.5 Sonnet (June) 0 0 0 2 5
Llama 3.1 405B Inst 0 0 0 3 2
GPT-4o (Aug) 0 0 0 7 4
GPT-4o (Nov) 0 0 0 6 7
o1-preview no_cot_o1 @ 1 0 0 0 1 2
DeepSeek-V3 1 0 0 3 3
o1-mini no_cot @ 1 1 0 1 1 2
Gemini Thinking (12/19) no_cot 0 0 1 0 4
Qwen 2.5 72B Inst 0 0 0 4 7
o3-mini-2025-01-31 (high) no_cot @ 1 0 0 2 1 1
Grok 2 1 0 0 4 3
Mistral Large 0 0 0 7 3
Gemini 2.0 Flash 0 0 1 4 8
Llama 3.3 70B Inst 0 0 0 7 7
Llama 3.1 70B Inst 2 0 0 7 7
Gemini 1.5 Pro 0 0 1 6 6
GPT-4o mini 0 1 1 6 6
Claude 3.5 Haiku 0 0 1 8 10
Gemini 1.5 Flash 0 1 0 13 11
Mistral Small 1 0 0 11 19

Every cell above is both ours and the paper's — rows compared: 24, exact: 24, mismatched: 0; cells compared: 120, exact: 120. Blank variant = the default cot @ 0.5. The cache holds a 25th model (gemini-2.0-flash-thinking-01-21) that Table 3 does not print; it replays cleanly too but has nothing to be compared against.

Live alignment on published models

The replay stubs the model layer, so it cannot catch a defect that only appears with real sampling over the wire. Two of the checkpoints Table 3 scores are still served, so both were run live on the paper version's 968 rows at temperature=0.5, one sample.

Which provider is not a detail. An aggregator routes across providers that quantize differently, and quantization changes completions, so every run pins its provider with allow_fallbacks: false — a wrong-silicon serve fails instead of silently routing, and the pin lands in the persisted request_params where it stays auditable. The first run picked a bf16 endpoint on the reasoning that released precision is the right standard. Upstream's own model factory says otherwise: both open-weight rows in Table 3 were served through DeepInfra. So both models were run there, and the bf16 run is kept alongside rather than discarded — together they bracket the serving-precision effect instead of hiding it.

model provider singleop singleq multiarith svamp gsm8k total Table 3 Δ Δ/σ_D
Qwen2.5-72B-Instruct DeepInfra (fp8) 0 0 0 4 4 8 11 −3 0.69
Llama-3.3-70B-Instruct DeepInfra (fp8) 1 0 0 6 9 16 14 +2 0.37
Llama-3.3-70B-Instruct Crusoe (bf16) 0 0 0 8 7 15 14 +1 0.19

The yardstick is σ_D = √(paper + live) ≈ 4.4–5.5, not √paper. The paper's own count is a single unseeded draw at temperature 0.5, so the comparison is a difference of two noisy draws and its spread is the sum of both variances. This also settles whether more sampling would help: it would not. Repeats shrink only our side of the variance, and the published number's own σ ≈ 3.7 is an irreducible floor — 5 repeats would move σ_D from 5.5 only to ≈ 4.2. Buying a different published model adds information; re-drawing the same one does not.

Three of the five cells publish 0 errors (singleop, singleq, multiarith — 421 rows at a near-zero true error rate), so agreeing there is close to automatic and should not be counted as evidence. Only svamp and gsm8k carry signal.

Counts alone cannot tell sampling noise from a scoring difference — both look like "a different number". The identity of the missed questions can. Upstream's cache is keyed by prompt, so its own completion for every live row is directly retrievable:

run live misses also missed by upstream live-only paper-only
Qwen2.5-72B @ DeepInfra 8 7 1 4
Llama-3.3-70B @ DeepInfra 16 9 7 5
Llama-3.3-70B @ Crusoe 15 9 6 5

Misses overlap heavily and move in both directions in every run. That is the signature of resampling the same model; a scorer that had drifted looser or stricter would push one-sidedly. The strongest form of the argument: the same scorer applied to upstream's own completions reproduces Table 3 exactly (the 120/120 replay), and applied to fresh completions from the same model, provider and decoding it differs — so the delta lives entirely in the sampled text, not in the scoring. All 968 live prompts were verified byte-identical to the cache's keys.

Plumbing is clean in all three runs: 0 pipeline failures, full 968-row coverage, and max_tokens=6000 / temperature=0.5 / the provider pin on every persisted record. Every miss finished on a stop token with a parseable integer prediction, so none is a truncation or a parse artifact — precisely where a defect in the vendored extractor or the max_tokens budget would surface. The one non-saturated surprise, a singleop miss in the fp8 Llama run where Table 3 has 0, is a genuine arithmetic slip (4754 vs 4764), not a pipeline failure.

Scored on the shipped 953-row pin from the same records, no extra inference: Qwen 0/0/0/4/4 = 8, Llama@DeepInfra 1/0/0/5/9 = 15, Llama@Crusoe 0/0/0/7/6 = 13.

Exact equality is not achievable here and is not the bar: upstream samples at temperature 0.5 with no seed. The replay is what pins scoring fidelity; these runs pin the live path.

The one caveat, and why the pin does not change

The counts above belong to platinum-bench-paper-version (968 math rows). The shipped pin stays on the current madrylab/platinum-bench, matching upstream's own default, which rejects 15 more math rows — 953 total (multiarith 171→170, svamp 273→265, gsm8k 274→268). Raw row counts are identical in both repos, so the delta is purely additional rejected labels from later cleaning. Upstream publishes no table for the current revision, so its scores will land near Table 3 but not on it. That is recorded in ReferenceImpl.notes (with the reproduction recipe) and pinned by a test, so nobody reads Table 3 as an exact target for the default pin.

Review Fixes

Round 1 — the token budget

Landed in refactor(platinum): hand the token budget back to the model layer. The reproduction evidence above is unchanged — re-running the paper-cache replay against the modified code returns the same Table 3 cells (30 spot-checked cells across all three prompt variants, 968 cache hits per model, 0 fails), because the budget never reached the replay's stub model in the first place.

max_tokens is no longer forwarded by the task. infer() used to pass max_tokens=6000 as a call-time kwarg. agenerate merges {**self._kwargs, **kwargs}, so that did not supply a default — it overrode the caller: a max_tokens set in models: / infer_args was silently discarded and 6000 went on the wire regardless. That is the one knob this benchmark most needs a caller to turn, since the score is budget-sensitive, and the docstring was telling readers to raise the budget for a thinking model without saying that the obvious place to raise it does nothing.

The max_tokens task arg and the UPSTREAM_MAX_TOKENS constant are gone; 6000 is documented as an infer prerequisite instead (the imo_answer_bench pattern). The task now injects no decoding params, matching gsm8k_0shot_gen / human_eval_0shot_base_gen / livecodebench_*. The tasks that legitimately do pass max_tokens are the ones where it is task-coupled — *_clp scoring a single next token, ruler reading a per-sample budget from the row.

⚠️ Behaviour change for callers: these five tasks now inherit whatever budget the model config supplies. Reproducing upstream requires max_tokens: 6000 on the model; omit it and you get the backend default, which can truncate CoT and under-score.

The test stub is why this was invisible. CapturingChatModel overrode _agenerate_impl, which sits below the kwargs merge, so last_kwargs recorded what the task passed rather than what the model would send — making every "the task injects no decoding params" assertion vacuous. It now records the merged request and accepts model-side kwargs, and a new test pins that a configured budget survives. Both infer tests fail against the previous code (verified by reverting).

Also in that commit:

  • get_parse_fn("math") is resolved once at import instead of rebuilding the strategy table per sample.
  • A comment records that PLATINUM_BENCH_REVISION is not forwarded to load_dataset — the pin is honoured by sieval dataset download staging, so passing the bare hub id gets whatever is current (same wording as _arc.py).
  • A comment records that the dataset's categories / tags cover only the subsets that currently ship a task; one sample TypedDict is the FK for all 14 configs, so a future drop / squad leaf must widen them or render as ElementaryMath.

Re-verified after the change: ruff + ruff format, ty, package stubs, 3010 unit and 64 integration/acceptance tests, and full preflight all clean; sieval/meta/index.json regenerated for the notes edit.

Round 2 — the subset is selected, not constructed

Landed in refactor(platinum): select the subset with a Dataset.filter operation (plus a one-line comment fix). The reproduction is unchanged, and that was verified rather than argued — see the re-verification list below.

PlatinumBenchDataset no longer takes a subset= argument. It was the only custom __init__ among the repo's 37 dataset modules and the only required dataset constructor argument anywhere. The cost was a redundant knob: each leaf already pins subset: ClassVar[str] = "gsm8k", so args.subset was a second copy of a fact the class fixes, and setup() spent a runtime check confirming the two copies agreed. Mis-wiring was guarded, not unrepresentable.

Dataset.filter(by, value, *, split="test") -> Self is the transform that was missing. It joins repeat/slice/shuffle/stratified_sample and follows their conventions: a bare verb, by= spelled as in stratified_sample, an immutable clone via _clone_with_new_dict, self returned for an absent or empty split. It raises on an unknown column and — deliberately, unlike the size transforms — on zero matches, because an empty split is a misspelled value far more often than an intent and would otherwise surface as a run that silently scores nothing. value takes a scalar or a list; a string stays atomic rather than becoming a set of characters.

It does not subsume stratified_sample: filter drops non-matching groups, stratified_sample never drops a group. Opposite purposes, no overlap.

Filtering in the outer layer is what keeps this simple. Task.dataset is read-only, so a task cannot adopt a clone; narrowing before the dataset reaches the task means no Task.dataset setter and no task-side mutation.

load() merges the 14 non-vqa configs. They measurably share one HuggingFace feature signature across the six platinum columns, so they concatenate without a cast. Each config is loaded, cleaned and stamped in full isolation and only then concatenated, which makes "narrow back to one subset == the rows a single-config load produced, in order" structural rather than merely observed. vqa stays out: it misspells the column as platinum_parsing_stratagy and fails select_columns outright. PLATINUM_SUBSETS is a frozenset, so the merge sorts it — without that the row order would follow the string hash seed and sample ids would shift between processes.

setup() now checks the wired split via unique("subset"). A missing split, a different Dataset class and an empty split all collapse to [], so one comparison covers every way the wiring can be wrong, and the message names the filter operation that fixes it. A stale args.subset gets a migration error rather than load_dataset's opaque "unexpected keyword argument" — the same courtesy session.py extends to the selectslice rename.

filter is registered in both operation whitelists: the dispatch in session.py and _VALID_OPERATIONS in sieval/cli/validation.py. Only the first would have let --dry-run reject a config the run executes fine. Its value is required by presence, not truthiness, so value: 0 and value: false stay usable.

A corrected number. The loader docstring claimed 2752 merged rows; that was the paper version's total. Re-measured against the pinned snapshot it is 2725 kept of 3062 across the 14 configs — later cleaning rejects 27 more non-math rows. The five math subsets (953) were always right. Caught by re-verifying against the real pinned data rather than the local paper-version copy.

Re-verified after the change:

Check Result
Row lists, old per-config path vs merge-then-filter, pinned revision byte-identical, content and order — 268/265/170/150/100 = 953
Same, paper version byte-identical — 274/273/171/150/100 = 968
Paper-cache replay re-run through the new code 120/120 cells, 24/24 models exact against Table 3
Shipped counts vs the pinned snapshot 2725 of 3062 merged; every math subset platinum_parsing_strategy == "math", no rejected survivors
Wiring smoke through the real session layer filter → 268 rows, setup() passes; operation omitted → 2725 rows, setup() rejects; wrong subset → 265 rows, setup() rejects
Suites 3067 unit, 64 integration/acceptance
ruff / ty / full preflight / both sync scripts all clean, all PASS

No CHANGELOG entry: the dataset and all five tasks are new in this same unmerged PR, so no shipped config breaks.

Follow-up, not this PR: mmmlu_kshot_clp mutates dataset_dict["test"] in place (:466, :550) for want of exactly this transform. Migrating it needs a fraction= budget on stratified_sample and a decision about task-state-dependent narrowing, so it ships separately.

Checklist

Required (all PRs)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring
  • No new upper-layer dependencies added to core/ — the one core/ change (Dataset.filter) adds no imports at all, only a method beside the existing transforms
  • Deleted code verified — nothing deleted

If: New or Modified Benchmark

  • Reference paper/repo linked in Summary
  • Score comparison table — 24 models × 5 subsets, 120/120 cells exact against the paper's Table 3 by replay, plus a live run on a published checkpoint (Llama-3.3-70B-Instruct) landing 4/5 cells exact and +1 pooled (see §Score Comparison)
  • Dataset loading tested (sieval dataset download platinum_bench succeeds)
  • Task registered in package-level __init__.py (lazy AST scan picks up the subpackage; .pyi stubs regenerated, sieval/meta/index.json synced)

If: community/ Changes

  • Upstream diff documented — sieval/community/platinum_bench.py is a byte-faithful copy of get_parse_fn + check_prediction from src/utils.py@8fd2f82; its docstring names the two behaviours callers must absorb rather than fixing them in place. Two deliberate deviations, both recorded in each task's ReferenceImpl.notes (so sieval task show surfaces them):
    1. prompt_variant (task arg, default cot) replaces upstream's hardcoded reasoning-model name list. All three of upstream's prompt strings are reachable explicitly — cot, no_cot, and no_cot_o1 (upstream's o1-only "Then, provide""Provide" edit) — so a name list that ages badly is traded for an argument the caller sets. The reference notes say which value reproduces which published row.
    2. A parse failure is recorded as prediction=None / correct=False instead of upstream's swallowed AttributeError.
  • License attribution preserved — upstream harness code is CC-BY-4.0, attributed with a commit-pinned permalink in the vendor's module docstring. Note the data is cc-by-sa-4.0 (that's what DatasetMeta.license records); the two differ.

If: New Dependency

Not applicable — no new dependencies.

🤖 Generated with Claude Code

@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #70 fixes the fleet-wide rollout["prediction"] resume KeyError this PR's task sidesteps with .get(), and adds check_preflight.py --check check_record_key_access to enforce it.

Suggested order: #70 first, then this one. This PR is already compliant either way — it is the only module in the repo that was — so nothing here needs to change. Merging the guard first just means this lands in a tree where the rule is enforced rather than one where it is a comment.

ethan-scitix and others added 6 commits August 6, 2026 17:31
Integrate MadryLab's PlatinumBench math subsets: one dataset serving all
14 non-vqa HF configs, and a task subpackage whose five leaves
(gsm8k, svamp, multiarith, singleop, singleq) share one base.

Prompts, extraction and scoring all follow upstream rather than sieval's
native math path: the prompts are carried in the data (platinum_prompt /
platinum_prompt_no_cot), and upstream's parse + float comparison are
vendored byte-faithfully into sieval/community/platinum_bench.py under
its CC-BY-4.0 terms. Rows whose cleaning_status is "rejected" are
dropped, which is the point of the benchmark: 953 of 1042 rows kept.

All five tasks ship status="experimental" — not yet validated against
upstream's published per-dataset error counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaying upstream's own published inferences
(madrylab/platinum-bench-paper-cache) through this pipeline reproduces
all 120 math error counts of the paper's Table 3 -- 24 models x 5
subsets, exact, every cell. A fresh run could not have shown this:
upstream samples at temperature 0.5 with no seed, and the metric is a
0-19 error count out of 100-274 rows, so binomial noise alone is ~2
errors wide. The replay is deterministic and costs nothing, and every
one of the 968 prompts per model hits a cache entry, which is what
proves our prompt is byte-identical to upstream's.

Closing the last fidelity gap needed a third prompt variant.
`platinum_prompt_no_cot` carries a dangling "Then, provide" -- a
leftover conjunction from the CoT wording -- and upstream rewrites it
to "Provide" for its o1 snapshots only, not for o1-mini / o3-mini. Both
branches are now reachable as `prompt_variant="no_cot"` /
`"no_cot_o1"`, which takes the port from 21 to 24 of 24 published rows.

The paper's counts belong to madrylab/platinum-bench-paper-version (968
math rows kept). The shipped pin stays on the current
madrylab/platinum-bench, matching upstream's own default, which rejects
15 more rows (953) and has no published table; the delta is recorded in
the reference notes so nobody reads Table 3 as an exact target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t trap

The Table-3 replay stubs the model layer, so it proves scoring fidelity
and nothing about the wire. Ran all five subsets end-to-end instead --
953 questions, Qwen3-32B over an OpenAI-protocol endpoint,
temperature=0.5, one sample. Zero pipeline failures. 6 errors: singleq
0/100, multiarith 1/170, singleop 1/150, gsm8k 2/268, svamp 2/265. Five
are ordinary wrong answers with a clean "Answer:" line; the records
confirm max_tokens=6000 and temperature=0.5 reach `request_params`, so
the task's budget is not merely set on the object.

The sixth error is worth documenting rather than dismissing. Upstream
sized max_tokens=6000 for non-thinking models; Qwen3-32B spent the whole
budget inside the reasoning channel on one singleop question --
finish_reason "length", 10k characters of "Wait, no, 4 + 7 is 11?" --
and returned an empty answer, which scores as an error indistinguishable
from a wrong one unless the reader knows to look. anomalies.json does
separate them: that row is the only anomaly across all 953, flagged
truncated_output + extraction_failure. Both the docstring and the
reference notes now say to raise the budget on a thinking model and to
read `errors` alongside anomalies.json, with tests pinning the wording
so it cannot quietly drop out.

No behaviour change -- notes only, plus the regenerated meta/index.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The replay that earned `status="stable"` stubs the model layer, and the live
run that exercised it used Qwen3-32B — a model upstream never published a row
for, so it could confirm the transport but not the number. This closes that
gap on a model upstream did publish.

Llama-3.3-70B-Instruct, all 5 math subsets over the paper version's 968
questions at temperature 0.5, one sample, served bf16 — the released precision.
That last part is not incidental: aggregators route across providers that
quantize differently, so the run pins its provider with fallbacks disabled and
the pin lands in the persisted `request_params`, making the silicon that
answered auditable after the fact.

    0/0/0/8/7 = 15 errors   live
    0/0/0/7/7 = 14 errors   Table 3

Four of five cells exact, +1 pooled against a ~3.7 Poisson sigma, 98.45% vs
98.55% accuracy, 0 pipeline failures, full 968-row coverage. All 15 misses are
genuine arithmetic errors: every one finished on a stop token with a parseable
integer, so none is a truncation or parse artifact — which is the distinction
that matters, since a scoring bug would surface exactly there.

Exact equality is not reachable: upstream samples at temperature 0.5 with no
seed and the metric is a raw 0–19 error count, so the replay is what pins
scoring fidelity and this run is what pins the live path.

`meta/index.json` is regenerated because the notes text is embedded in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wn provider

The first live alignment picked a bf16 endpoint on the reasoning that released
precision is the right standard. Upstream's model factory shows that is not what
they did: both open-weight rows in Table 3 were served through DeepInfra. Two more
968-row runs on that provider, and the notes now report all three.

  Qwen2.5-72B-Instruct  DeepInfra  0/0/0/4/4 =  8  vs Table 3   0/0/0/4/7 = 11
  Llama-3.3-70B-Instruct DeepInfra 1/0/0/6/9 = 16  vs Table 3   0/0/0/7/7 = 14
  Llama-3.3-70B-Instruct Crusoe    0/0/0/8/7 = 15  vs Table 3   0/0/0/7/7 = 14

All three land within 0.7 sigma_D, 0 pipeline failures, every miss a stop-token
finish with a parseable integer. Keeping the bf16 run alongside the fp8 one
brackets the serving-precision effect rather than hiding it.

Two corrections to how the earlier number was stated:

- The yardstick was the single-count Poisson sigma (~3.7). The statistic is a
  difference of two independent unseeded draws, so it is sigma_D = sqrt(paper +
  live) ~ 5.5. Understating the spread overstates the agreement.
- "Four of five cells exact" oversold it: three of the five cells publish 0
  errors, so agreement there is close to automatic. Only svamp and gsm8k carry
  signal, and the notes now say so.

Counts alone cannot separate sampling noise from a scoring difference, so the
notes add the discriminator: 9 of 15, 9 of 16 and 7 of 8 live misses are misses
in upstream's own completions, with the remainder moving in both directions.
Same scorer on upstream's text reproduces Table 3 exactly (the 120/120 replay);
same scorer on fresh text differs only where the model sampled differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`infer()` passed `max_tokens=6000` (upstream's engine default) as a
call-time kwarg. `agenerate` merges `{**self._kwargs, **kwargs}`, so that
did not supply a default — it overrode the caller. A user configuring
`max_tokens` in `models:` / `infer_args` got 6000 on the wire regardless,
silently, which is exactly the knob this benchmark most needs turned: the
score is budget-sensitive, and the docstring told readers to raise the
budget for a thinking model without saying that the obvious place to raise
it does nothing.

Drop the task-side budget and the `max_tokens` ctor arg entirely, and
document 6000 as an infer prerequisite instead (the `imo_answer_bench`
pattern). The task now injects no decoding params at all, matching
`gsm8k_0shot_gen` / `human_eval_0shot_base_gen` / `livecodebench_*`; the
tasks that do pass `max_tokens` are the ones where it is task-coupled
(`*_clp` scoring one next token, `ruler` reading a per-sample budget).

The test stub is why this was invisible. `CapturingChatModel` overrode
`_agenerate_impl`, which sits *below* the merge, so `last_kwargs` recorded
what the task passed rather than what the model would send — making every
"injects no decoding params" assertion vacuous. It now records the merged
request and takes model-side kwargs, and a new test pins that a configured
budget survives. Both new `infer` tests fail against the previous code.

Also: resolve `get_parse_fn("math")` once at import instead of rebuilding
the strategy table per sample; note that `PLATINUM_BENCH_REVISION` is not
forwarded to `load_dataset` (arc's wording); note that the dataset's
`categories`/`tags` cover only the subsets that ship a task, since one
sample TypedDict is the FK for all 14 configs.

Verified: replaying upstream's paper cache through the changed pipeline
reproduces the same Table 3 cells as before (30 spot-checked cells across
all three prompt variants, 968 cache hits per model, 0 fails) — the budget
never reached the stub, so scoring fidelity is untouched. 3010 unit + 64
integration/acceptance pass; ruff, ty, stubs and full preflight clean;
`meta/index.json` regenerated for the notes edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ethan-scitix
ethan-scitix force-pushed the feat/platinum-bench-math branch from 0027ad9 to ddb0837 Compare August 6, 2026 09:37
ethan-scitix and others added 2 commits August 6, 2026 18:26
The comment on `setup()`'s mis-wiring guard said the Dataset class serves
all 15 configs. It serves 14: `vqa` is the 15th and `__init__` rejects it,
since at the pinned revision it spells the strategy column
`platinum_parsing_stratagy` and its prompts reference images stored outside
the dataset repo. `sieval/datasets/platinum_bench.py` already says 14, so
the two comments disagreed.

Comment-only, and the string is not part of `PLATINUM_REFERENCE_NOTES`, so
`sieval/meta/index.json` is unaffected (`check_meta_index_sync` passes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PlatinumBenchDataset` required a `subset=` keyword argument — the only custom
`__init__` among 37 dataset modules, and the only required dataset constructor
argument in the repo. It existed because a HuggingFace config is a load-time
choice that no existing row transform could express.

The cost was a redundant knob. Each leaf already pins its subset
(`subset: ClassVar[str] = "gsm8k"`), so `args.subset` was a second copy of a
fact the class fixes, and `setup()` spent a runtime check confirming the two
copies agreed. Mis-wiring was guarded, not unrepresentable.

So give `Dataset` the transform that was missing, and let the caller narrow:

    datasets:
      platinum_gsm8k:
        class: PlatinumBenchDataset
        operations:
          - filter: {by: subset, value: gsm8k}

`Dataset.filter(by, value, *, split="test") -> Self` joins
repeat/slice/shuffle/stratified_sample and follows their conventions: a bare
verb, `by=` spelled as in `stratified_sample`, an immutable clone via
`_clone_with_new_dict`, and `self` returned for an absent or empty split. It
raises on an unknown column and — deliberately, unlike the size transforms — on
zero matches, because an empty split is a misspelled value far more often than
an intent, and would otherwise surface as a run that silently scores nothing.
`value` takes a scalar or a list, so two subsets need no sibling method. A
string stays atomic rather than becoming a set of characters.

Filtering in the outer layer is what keeps this simple: `Task.dataset` is
read-only, so a task cannot adopt a clone, and narrowing outside means no
setter and no task-side mutation. (`mmmlu_kshot_clp` mutates
`dataset_dict["test"]` in place for want of exactly this; migrating it needs a
`fraction=` budget on `stratified_sample` and is left alone here.)

`load()` now merges the 14 non-vqa configs, which measurably share one feature
signature across the six platinum columns and so concatenate without a cast.
Each config is loaded, cleaned and stamped in full isolation and only then
concatenated, so narrowing back to one subset is structurally — not just
empirically — the rows a single-config load produced, in order. `vqa` stays out:
it misspells the column as `platinum_parsing_stratagy` and fails
`select_columns` outright. `PLATINUM_SUBSETS` is a frozenset, so the merge sorts
it; without that the row order would follow the string hash seed and sample ids
would shift between processes.

`setup()` now checks the wired split carries only this task's subset via
`unique("subset")`. A missing split, another Dataset class and an empty split
all collapse to `[]`, so one comparison covers every way the wiring can be
wrong, and the message names the `filter` operation that fixes it. A stale
`args.subset` gets the migration rather than `load_dataset`'s opaque
"unexpected keyword argument", the same courtesy `session.py` extends to the
`select` -> `slice` rename.

The reproduction is unchanged, and that was verified rather than assumed:

* Row lists are byte-identical — content and order — between the old
  per-config path and merge-then-filter, on the pinned revision (953 math rows:
  268/265/170/150/100) and on the paper version (968).
* Re-running the paper-cache replay through this code reproduces **all 120 math
  cells of Table 3, 24/24 models exact**.
* Shipped counts re-measured against the pinned snapshot: 2725 kept of 3062
  across the 14 configs. The docstring said 2752 — the paper version's number —
  and is corrected.
* Wiring smoke through the real session layer: the filter yields 268 rows and
  setup passes; omitting it yields 2725 and setup rejects; the wrong subset
  yields 265 and setup rejects.

`filter` is registered in both operation whitelists — the dispatch in
`session.py` and `_VALID_OPERATIONS` in `validation.py`. Only the first would
have let `--dry-run` reject a config the run executes fine. Its `value` is
required by presence, not truthiness, so `value: 0` and `value: false` stay
usable.

No CHANGELOG entry: the dataset and all five tasks are new in this same
unmerged PR, so no shipped config breaks.

ruff, ty, 3067 unit tests, 64 integration/acceptance, full preflight and both
sync scripts all clean; `meta/index.json` regenerated for the description edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ethan-scitix
ethan-scitix merged commit 5ee0564 into main Aug 6, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the feat/platinum-bench-math branch August 6, 2026 13:13
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