feat(core): uniform stage-output protocol, piloted on 7 tasks - #60
Conversation
|
Two review items applied, plus a dependency to record. 1. Case counts now come back on every source, not just LiveCodeBench (
This widened the blast radius on the sieval side, which is worth stating: the four tasks that persist the evaluator payload verbatim as 2.
3. Depends on #51 — that one merges first #51 adds #51 merges as-is; this PR then rebases on top, and Do not merge this before #51. Details on #51: #51 (comment) |
890d866 to
79f04d2
Compare
|
Rebased onto Conflict resolution — all three files, as planned
#51's documented limit is now closed for #51 flagged that the stored reply is "extra": {
"grade": "NOT_ATTEMPTED",
"grader_reply": "",
"grader_model": "grader-reasoning",
"grader_calls": [{"model": {...}, "usage": {...}, "finish_reasons": ["length"]}]
}
Two scoping notes on that, stated rather than left implicit:
Re-verified after the rebase
|
79f04d2 to
1fb7e48
Compare
|
Follow-up on the three review questions. Only Q1 needed code; Q2 and Q3 I'm not changing, with reasons below. Folded into the protocol commit ( Q1 — grader output: store the full You were right that the grader is just another model and should be recorded as one. The old shape ( "extra": {
"grade": "CORRECT",
"grader_output": {
"model": {"model": "grader-o3", ...},
"texts": ["A"],
"reasoning_texts": ["The candidate names Shakespeare, which matches the gold."],
"usage": {"input_tokens": 55, "output_tokens": 4, "total_tokens": 59}
}
}
Two new tests pin the point: One consequence to flag: this makes Q2 — judge→grader naming: not doing it, and here's why it isn't the clean rename it looked like I mapped the surface before touching anything.
So a global rename would create same-name/different-contract collisions, fight vendored upstream vocabulary, and drag HLE + its community module + its test into a PR that otherwise doesn't touch them. Q3 — consolidation: nothing to move
Re-verified after the amend: 2792 tests pass; lint/type/preflight/index-sync clean; end-to-end through the real runner confirms the on-disk record above. Green CI to follow. |
1fb7e48 to
98a282b
Compare
|
Applied the builder-naming fix; the judge/grade question I'll answer rather than change. Builder naming → You're right, and I checked it's the actual convention, not a two-sample guess. The repo has two factory styles that split by purpose:
My builders construct records from parts, so they're the
Types stay PascalCase ( judge vs grade — they denote different layers, and within this PR they're already separated correctly They aren't two words for one thing:
A The genuine inconsistency is a third word — HLE's One deliberate call worth surfacing: I kept the protocol record as Force-pushed; CI to follow. |
98a282b to
12a0313
Compare
|
Swept for more of the same, and answered the judgement/feedback question. Q2 — judgement vs feedback: different axes, not synonyms
The records are named by content, not by stage, and that's systematic:
So Q1 — one real inconsistency, plus wording nits. Fixed, folded into the protocol commit. Real, with teeth:
Now mutually exclusive. Added Wording, same judge/grade axis as before: Checked and left as-is: the builder asymmetry — 2793 tests pass; lint/type clean. Force-pushed; CI to follow. |
12a0313 to
d6f5fb8
Compare
|
Added the vocabulary to Placement rationale: it's the task-author guide, which is who migrates the next task and who writes
No enforcer added — this is descriptive convention (like the existing Three commits still; force-pushed. |
Stage return types are free-form generics, so every task invented its own
shape for "the extracted answer" and "was it right". Nothing downstream could
read a sample's answer, ground truth, or correctness without a per-task special
case, and `raw_sample` is never serialized, so two of the seven tasks below had
no ground truth on disk at all.
Add a record per stage in `sieval/core/tasks/records.py` — PromptRecord,
PredictionRecord, JudgementRecord, plus builders — and migrate mmlu_pro,
gpqa_diamond, aime_2026, hmmt_feb_2026, livecodebench, ifeval and
simpleqa_verified onto it. `infer` is left alone: generative tasks already
return ModelOutput uniformly.
The envelope is sample-level with a `rollouts[]` list, so n=1 and n>1 share one
schema and a gold is stored once rather than once per rollout (AIME with n=4
used to repeat it four times). `n_rollouts`/`n_correct` are materialized as the
sample-level pass rate. `reference=None` means the ground truth is a procedure,
not a value — LiveCodeBench's is a test suite, described in `extra` instead.
Two rules that are easy to break, both documented on the module:
* Records are returned BARE. The runner preserves a TaskStageOutput box as the
stage value, so boxing one task would persist `{"value": ...}` with a type
marker while its peers persist a flat record — the exact divergence this
removes. Cost: grader ModelCallMeta rides inside the record rather than in
stage meta, so grader spend is on disk but not in profile.json; the fix
belongs in core, not in a box.
* `obj_to_dict` drops None-valued keys, so `prediction: None` is ABSENT on
disk, not null. Hence `extracted` and the n_* counts are explicit fields
rather than derived — the flags are what survive the wire.
Substantive gains beyond reshaping:
* ifeval grades in feedback() instead of report(), so per-sample strict/loose
verdicts and per-instruction results are finally persisted. Safe because both
graders are pure per-sample (their only use of the response map is
prompt_to_response[inp.prompt]) and all 541 IFEval prompts are unique.
* simpleqa_verified persists the raw grader reply. parse_grade silently
defaults to NOT_ATTEMPTED, so a defaulted grade was indistinguishable from a
real one — this blocked audits twice.
* New `extraction_failure` anomaly rule reports real per-rollout indices
instead of the `{0}` sentinel, so an occasional miss under n>1 is
distinguishable from total failure. detect_empty_postprocess defers on
protocol records (a PredictionRecord is a non-empty dict, so its emptiness
heuristics cannot see into it) and stays byte-identical for legacy tasks.
report.json metrics are unchanged for every task, verified against the
pre-migration implementations rather than only against new tests: real-run
replay for gpqa (792 records, exact to 15 decimals) and aime (30 samples x 64
rollouts, plus pass@8 vs an independent estimator), 120 real dataset rows for
ifeval, and 900 randomized differential trials for the rest.
Two deliberate behaviour changes in ifeval, outside the parity claim: report()
no longer raises ZeroDivisionError on an empty final set (returns zeros, like
every sibling task), and _get_report's tier0/tier1 breakdowns were computed and
never returned — dead code, removed.
BREAKING (on-disk schema): result dirs written by 0.7.x for these 7 tasks hold
the old feedback shape and cannot be re-reported by the new report(). Ships on
the 0.8.0 minor bump so the resume version gate rejects the resume with a
version message rather than a shape error. Other tasks are untouched; adoption
is per-task. One fleet-wide effect: adding an anomaly rule changes rules_hash,
so every task's next run rotates anomalies.json (auto-backed-up).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LiveCodeBench grading returned a bare pass/fail, so a failing submission gave no signal about how far it got. The evaluator only reported `(ok, msg)`. The case loop already runs cases in order and stops at the first failure, so the failing case's index IS the number that passed — a real count at zero extra execution cost. Thread it out of `_unsafe_execute` and expose it on the response `data` as `n_cases` / `n_passed`. Semantics, all verified against the real code path: all pass -> n_cases; fails at case i -> i; compile error / no function / arity mismatch -> 0; subprocess killed on timeout -> None, meaning unknown rather than zero. Kept as one flat ResourceMetrics model rather than a per-source subclass: FastAPI filters the response against the route's declared model, so a subclass returned from one branch would have its extra fields silently stripped. Purely additive — both fields default to None and the sieval task reads them with .get(), so an unpatched, separately-deployed evaluator keeps working. Version skew is the normal case for that service. Deliberately NOT a full pass rate: this is cases passed before the first failure, so a submission that fails case 0 and would pass the rest still records 0. A true rate means dropping the short-circuit, which makes every wrong submission run all N cases and can newly trip the shared timeout budget. Still needs to land upstream in scitix/code-evaluator and be re-vendored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the LiveCodeBench-only version of this change, on review feedback. Report `n_cases` / `n_passed` for *all* sources rather than only test-case-driven ones, so a caller can compute a pass rate without branching on `source`. A direct run (human-eval / mbpp / scicode, or livecodebench without `test`) is one all-or-nothing case, so the pair is 1/1 or 1/0. For those sources that is redundant with `status` by construction — reported anyway, because a field that is sometimes absent is a field every consumer has to special-case. Both fields stay None only when nothing ran at all (unsupported language or source), where `data` is itself None. Also translate README.md from Chinese to English, matching the rest of the repo. Content is unchanged apart from the case-count section. Sieval side: the four tasks that persist the evaluator payload verbatim as `metrics` (human_eval x2, mbpp, livecodebench kshot base) declare a ResourceMetrics TypedDict listing only the four resource fields, which would now misdescribe what is actually stored. Add the two counts as NotRequired so the declared type matches the record. No behaviour change: nothing reads them, and they were already being persisted by the passthrough. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the protocol pilot, on review feedback. No metric, report.json or on-disk value changes; the only observable delta is `rules_hash`, discussed below. The `failure`/`_FAILURE_PROBES` question is deliberately NOT touched here -- it needs a decision (drop it, or fix the probes and use the category in report()), not a nit fix. Contract honesty (`core/tasks/records.py`): * `RolloutPrediction.prediction` and `JudgementRecord.reference` are now `NotRequired`. Both are documented as *absent* on disk when None (obj_to_dict drops None-valued keys), so declaring them required told a reader that indexing a rehydrated record is safe when the module docstring says to use `.get()`. The builders always set them, so nothing changes at construction. * `PromptRecord.reference` said GT belongs here only when "only knowable at prompt-build time" and that a plain dataset field "may omit it" -- but five of the seven pilot tasks record it here anyway, and the pilot is the template for the ~80 still to migrate. Resolved in favour of the pilot: record it whenever it is known at build time, because a prompt row should be readable without joining to the feedback row, and say explicitly that coexisting with JudgementRecord.reference is intended rather than redundant. Sibling consistency: * `gpqa_diamond` read `feedback_result["n_correct"]` while `mmlu_pro` read `rollouts[0]["correct"]` -- two structurally identical n=1 tasks reading one shape two ways, which is the divergence the protocol exists to remove. Both now read the rollout verdict. gpqa's form was also an int read as a bool, and would have silently become pass@n rather than accuracy under n>1. Operator-facing wording (`core/tasks/anomaly.py`): * `extraction_failure` described itself as "no answer could be extracted", with a rationale pointing at "the prompt or the extraction rule". But ifeval and simpleqa_verified have no extraction step -- for them the rule fires on a blank *generation*, so an operator was handed a diagnosis for a rule that does not exist. Description and rationale now cover both cases, plus an `empty_output` tag so the blank-generation case is findable. Rule tags are descriptive only (`_rule_applies` keys on `applies_to`), so this is inert. * Timing, not cosmetics: `get_rules_schema()` feeds each rule's whole definition -- description and rationale included -- into `get_rules_hash()`, so rewording later would force a SECOND fleet-wide anomalies.json rotation. This PR already forces one by adding the rule, so the reword is free now and is not later. Verified: 9d36be341bf2eef5 -> 432371ad8044dcf5. * The empty-rollouts comment said "a record that judged nothing"; a PredictionRecord extracts, it does not judge. Nits: `__all__` in `core/tasks/__init__.py` had the `is_*` sniffs ahead of the `build_*` constructors, breaking an otherwise alphabetical list (RUF is not in ruff's select, so nothing catches it); `obj_to_dict(out, False)` now passes `add_type=False`, which is how the method docstring, tasks/CLAUDE.md and the changelog entry all spell it, and how every other call site reads. Verified: 2793 tests pass (unit + integration + acceptance, same count as before this commit); ruff check / ruff format --check / ty check clean; check_preflight.py all-PASS; sync_meta_index.py and sync_package_stubs.py --check both clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_classify_failure` bucketed the evaluator's free-text `msg` into a `failure` category stored on every rollout judgement. Removing it: `msg` is still stored raw, so no information is lost, and nothing read the derived field. Why remove rather than fix the probes: * Nothing consumed it, and its one plausible consumer declined. `report()` keeps its own `"timeout" in msg.lower()` check "so the counter stays byte-identical", so the PR added a taxonomy and simultaneously opted out of using it. It also had no test. * It was wrong for the majority path. Only the fn_call comparison wording (`!= expect`) was probed, so all three stdio comparison messages the current evaluator emits -- `output line count mismatch`, `output mismatch: got … expected …`, `numeric mismatch: got …` -- fell through to `"unknown"`. 611 of 1055 code_generation_lite problems (57.9%) have no `metadata.func_name` and so run stdio. * It decays silently, and the decay already happened once. The 17 stored LiveCodeBench runs under leaderboards/ contain 6025 real failure messages whose wording (`output line decimals mismatch`, `is not all decimals`) no longer exists in the vendored evaluator at all; 43.9% of them classify as `unknown` against 3.7% as `wrong_answer`. The evaluator is separately deployed and the PR itself notes version skew is normal for it. * `unknown` cannot report its own breakage -- it is what the classifier emits both for a parser miss and for a genuinely novel failure, so a rotted classifier is indistinguishable from healthy data. With no field, a reader of `msg` at least knows it is looking at raw text. * Wrong layer. The evaluator knows structurally why it failed (compile error vs no function vs which comparison branch) and flattens that into prose for the client to guess back. If a category is wanted it belongs on the response next to n_cases/n_passed -- the same additive shape this PR already used -- and it survives wording changes by construction. Ordering probes correctly would not have been enough either: `timeout` matched as a bare substring ahead of the comparison probes, so a wrong answer whose output text contains "timeout" classified as a timeout. `report()`'s counter has that same pre-existing false positive; left alone here, since changing it is a parity decision rather than a cleanup. Coupled prose updated: `RolloutJudgement.extra` and the `extra` bullet in tasks/CLAUDE.md said "a code runner's failure category". The CLAUDE.md entry now also states the general rule, since it is the task-author guide and this is the kind of field a future migrator would add back. Verified: 2793 tests pass (unchanged count -- the field had no test of its own, only a fixture key); ruff check / ruff format --check / ty check clean; check_preflight.py all-PASS; both sync scripts --check clean. `report()`'s timeout counter still reads extra["msg"] and is covered by test_pass_at_1_and_timeout_counting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verdict record had exactly one binary slot (`correct`) and one continuous
one (`score`). That fits a task whose two metrics are one of each -- DROP maps
`em` -> correct and `f1` -> score -- but a task with two metrics of the SAME
kind had to park one in `extra`. Four of the 39 tasks are in that shape: ifeval
(strict/loose), hellaswag_kshot_ppl (acc/acc_norm), gsm8k_kshot_base_gen
(correct/flexible_correct) and t_eval_before_calling (an evaluator's whole
metric dict).
A metric in `extra` is persisted but invisible to anything that does not already
know the task, which is the exact property this protocol exists to provide. It
already bit the pilot: migrated ifeval put loose entirely in `extra`, and its own
report() had to read `feedback_result["extra"][grade]` to get it back. A
cross-benchmark consumer reading n_correct/n_rollouts silently reported
strict-only -- no error, just half the answer.
Add `metrics: NotRequired[dict[str, bool | float]]` to RolloutJudgement and
JudgementRecord: every metric a verdict measured, named, INCLUDING the ones the
headline points at, so the mapping is self-describing and enumerable. `correct`
stays the single cross-task-comparable axis and `n_correct` still derives from
it -- forcing one headline is deliberate and unchanged; what changes is that the
alternatives are no longer hidden. A verdict now has three tiers, documented on
the module: headline (correct/score), measured values (metrics), mechanism
detail (extra).
Piloted on ifeval, which is the live case:
* strict and loose both land in `metrics` as `{strict,loose}_follow_all` +
`{strict,loose}_instruction_level`.
* `correct`/`score` are DERIVED from that mapping rather than computed a second
time, so the headline cannot disagree with the set it is drawn from. That is
the pattern for the next task, not an ifeval detail.
* `extra` keeps the per-instruction bool lists, deliberately. They are which
constraints passed (mechanism detail), they are lists so they are not metrics,
and report()'s instruction-level accuracy pools those raw counts -- averaging
the per-sample rates in `metrics` gives a different, wrong number whenever
samples carry different constraint counts. Pinned by the pre-existing
test_instruction_level_pools_counts_rather_than_averaging_samples.
Two traps the builders now fail loud on instead of absorbing, both from
`obj_to_dict` dropping None-valued keys:
* A `None` metric would be ABSENT on disk, so "we measured nothing" would read
as "this metric never existed". Rejected with the reason.
* A structured value (a list of per-constraint bools) is detail, not a measured
value; rejected and pointed at `extra`.
`False` and `0.0` are falsy but not None, so they do survive the wire -- pinned
by a round-trip test, since a failed metric being indistinguishable from an
unrecorded one is the failure mode this field exists to prevent.
Timing: ifeval is migrated but unreleased, so moving loose out of `extra` now is
free. After 0.8.0 ships it would be another on-disk break for that task -- and
the same applies to hellaswag/gsm8k/t_eval, which is why the field lands before
they migrate rather than after.
report.json is unchanged. 300 randomized differential trials of ifeval's
report() against the pre-change implementation: identical values AND key order,
plus the empty-finals case. New tests are mutation-checked -- dropping the None
guard, dropping `metrics` in the builder, and writing an empty `metrics` each
turn exactly one of them red.
Verified: 2800 tests pass (+7); ruff check / ruff format --check / ty check
clean; check_preflight.py all-PASS; both sync scripts --check clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's typecheck job runs a bare `ty check`, which the config roots at the repo (`[tool.ty.environment] root = ["./"]`), so it covers tests/ too. Three of the new metrics tests pass deliberately ill-typed values -- a None metric and a list metric -- to exercise the runtime guards, and ty rightly rejected them as arguments to `Mapping[str, bool | float]`. Hold those values in a bare `dict` instead of a literal at the call site. That keeps the guards under test without a type-suppression comment, and matches how the repo already spells a deliberately-loose dict. The guards exist for values that reach a task at runtime past the checker, which is now stated at the call site. Verified with the same command CI uses -- bare `ty check`, not `ty check sieval/`, which narrows the path and was what hid this locally. 2800 tests still pass, and the None-guard test still fails when the guard is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7c63645 to
c59ff5b
Compare
The record docstrings had grown to restate the same rules three and four times over: the `obj_to_dict` None-drop appeared in the module docstring, on `RolloutPrediction.prediction`, on `JudgementRecord.reference` and again in `_checked_metrics`; the metrics rule appeared in the module docstring and on both `metrics` fields. records.py was 311 lines for roughly 90 lines of code. Each rule is now stated once, at module level where it is the contract, and the field docstrings are short pointers. Net -38 lines across 8 files, with no rule removed -- the None-drop, bare-records, headline-derivation, pooled-vs-per-sample and mechanism-naming facts are all still there, just once each. Also tightened the comments this PR added to livecodebench (why `msg` is not bucketed), gpqa (why not `n_correct`), ifeval (the metrics/extra split) and the new tests, plus the two CLAUDE.md bullets. One knock-on: trimming `detect_extraction_failure`'s rationale moves `rules_hash` again (432371ad8044dcf5 -> 33e3c4cf9491114b), since `get_rules_schema` feeds each rule's whole definition into it. Free while unreleased -- the rule is new in this PR, so its next run rotates `anomalies.json` exactly once regardless of the final wording. Doing this after 0.8.0 would have cost a second rotation. Verified: 2827 tests pass; ruff check / ruff format --check clean; bare `ty check` clean (the repo-rooted command CI uses); check_preflight.py all-PASS; both sync scripts --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… remit The section opened by declaring itself "the vocabulary and conventions, not the schema" and then spent most of its length on schema: what each record holds, that a None prediction is absent on disk, the `metrics` type signature and builder validation, pooled-vs-per-sample aggregation, why a boxed record breaks the flat shape. All of it already authoritative in records.py. It had also grown out of proportion: 558 of this file's 976 words -- 57% of the task-authoring guide -- for one protocol. CLAUDE.md is loaded every session, so duplication costs there on every session, unlike a docstring read on demand. Now 342 words, keeping only what records.py cannot own: * the naming principle (records named by content, not by emitting stage) and the stage -> record map, minus the "holds" column; * the layered vocabulary, which is about which *word* to use -- judgement vs grade vs grader, and that `judge` is HLE's upstream term not to be introduced in new tasks; * the `build_*` / `is_*` naming conventions and the bare-records rule as a one-liner pointing at the reason, not restating it. Deliberately kept despite being cut once: "persist the grader's whole `ModelOutput` rather than hand-picked fields" is a convention, not schema, and the judge family migrates next -- but without the `obj_to_dict(out, add_type=False)` mechanics, which records.py and simpleqa_verified own. Deliberately dropped: the "don't derive a taxonomy from another service's free text" lesson. It is one task's finding, it lives in livecodebench's comment where it is actionable, and the always-loaded guide should not accumulate every lesson. Verified: every schema fact removed from here is present in records.py (None-drop, `n_correct` derivation, the score-mirroring rule, the metrics type and its guards, pooled-vs-per-sample, `__sieval_cls__`). check_preflight.py all-PASS including check_links; markdown-only change, no Python touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7770902 to
c9c8e2b
Compare
scicode_0shot_gen was the one registered task #61's inventory of 32 missed (#42 landed it just before #60). It is not a mechanical port: a sample is a chain of *dependent* sub-steps, each generating and executing code, which is not what rollouts[] means -- independent attempts at the same thing. One rollout per problem. The whole step sequence is the attempt; steps are neither independent nor attempts at the same thing, so they are not rollouts. Per-step detail lives in rollouts[0].extra.steps, and metrics carries both axes (main_problem_solved, sub_problem_pass_rate) with the headline derived from them. The raw counts correct_steps/total_steps sit in the record's extra because report() *pools* them -- averaging per-problem rates would give a different, wrong sub_problem_accuracy (the same reasoning ifeval's instruction-level pooling rests on). prompt is the first *generated* step's prompt, not the whole problem. It is knowable at preprocess because every preceding step is a special step whose gold code is static; infer then *reuses* the recorded prompt for that step, through a shared _gold_code(), so the record cannot disagree with what the model saw. Only steps 2..N are rebuilt, since their prompts embed the model's own prior output. extra.first_generated_step / n_generated_steps / previous_code say this explicitly, so prompt is not misread as the whole model input. Persisting all N per-step prompts would be quadratic text; stuffing the sample in would duplicate raw_sample. prediction is None iff *no* tested step produced extractable code. A partial miss is still a scoreable answer and keeps its prediction, which keeps the extraction_failure anomaly rule a real signal instead of firing on every partially-empty problem. reference is None: the ground truth is a procedure, this problem's per-step test suites. New StepCode.extracted_code holds the model's per-step answer so the prediction need not re-run the extractor. report.json is unchanged: 300 randomized differential trials against the pre-migration report(), 0 mismatches over 167 solved problems and 4529 graded steps; a deliberately sabotaged variant did produce mismatches, so the differential discriminates. Tests 34 (was 25), including one that drives the real feedback() twice and feeds both records to report(), so report reads exactly what feedback writes.
`iter_grader_outputs` and the two `is_*_record` predicates took `Any`, which
passes silently in both directions: a caller may hand them anything, and the
body may do anything with it. `object` is the accurate spelling -- no caller is
affected (everything is an `object`) while the bodies are forced to narrow,
which they already did.
That narrowing then has to reach the caller. `iter_grader_outputs` walks
`value.get("rollouts")` only after `is_judgement_record` says yes, and a plain
`bool` return throws that away -- only the old `Any` made it type-check. Both
predicates now return `TypeGuard[Mapping]`, so the narrowing they perform is the
narrowing callers get, with no redundant `isinstance` at the call site.
`TypeGuard` and deliberately not `TypeIs`: a mapping that is not a record still
returns False, so the negative branch says nothing about the type. `anomaly.py`
depends on exactly that, testing `isinstance(post_result, dict)` after a False.
Two of the three signatures predate this PR (#60); cleaned up here rather than
left inconsistent with their sibling.
Also fixes a non-discriminating assertion. `iter_grader_outputs({"rollouts":
"not a list"})` read as covering the per-rollout `isinstance` guard, but without
`n_rollouts` the value is not a judgement at all: it returned at the first guard
and never reached the one under test, so removing that guard failed no test.
The fixture now carries `n_rollouts`, and a mutant with the guard removed raises
AttributeError on the string's characters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aime_2026 / hmmt_feb_2026 moved in #60 while their six near-identical siblings did not, so tests/unit/tasks/test_math_pass_at_k_family.py carried a fork that built two different feedback shapes depending on set membership. All six now return PromptRecord / PredictionRecord / JudgementRecord, PROTOCOL_TASKS is gone, and _feedback() builds one shape for the whole family. The gold reaches disk from preprocess for all eight, which it previously did only for the two pilots -- raw_sample is never serialized, so a pass@k row that recorded no reference had no ground truth on disk at all. imo_answer_bench records the RAW dataset gold as `reference`, not the normalize_answer() output: normalization returns None when nothing survives stripping, and a None reference is reserved for "the ground truth is a procedure". The normalized form is in extra.normalized_reference, where None correctly reads as "the gold did not normalize". Correcting #61: it groups hmmt_nov_2025 and imo_answer_bench as pass@k tasks that "also use an LLM grader". They do not -- both grade deterministically (math_verify / the vendored verify_math_answer), and imo's own notes say an LLM grader would break the reproducibility contract. No grader detail is recorded because there is no grader. Parity: 300 randomized differential trials per task (1800 total) against the pre-migration report() loaded from d622448, over k in {1,2,4}, n >= k, 0-12 samples and 0-4 fails, including rollouts < k so the `short` warning path is exercised. 1800/1800 identical. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nine conditional-log-prob / perplexity tasks now return the three record types. #61 already corrected #60's "blocked on a design call" framing; this confirms it against the code: every one of the nine produces exactly ONE prediction per sample (an argmax index or a winning option label), so a one-entry rollouts[] is the honest shape. The candidate fan-out lives in `infer`, which the protocol deliberately leaves unwrapped. The migration unit is the family module, not the task: sieval/tasks/_arc.py holds the record builders (arc_prompt_record / arc_prediction_record / arc_judgement_record) and arc_report shared by all four ARC tasks (challenge/easy x ppl/clp), so the four move together and cannot fork. hellaswag_kshot_ppl is the `metrics` tier's second pilot after ifeval: acc and acc_norm are two argmax rules over one inference set, so both are named in `metrics` and `correct` is derived from acc_norm (the headline `score`) rather than recomputed. The raw-logprob argmax is the other rule's answer, not a second rollout, so it sits in the prediction record's extra next to the scores. mmmlu_kshot_clp keeps its TaskStageOutput box on `infer`: infer is not a record stage, so boxing it does not violate the bare-record rule. Its four flat prob_A..prob_D keys become one `probs` mapping in the rollout's extra -- mechanism behind the argmax, not metrics measuring the answer. Two deliberate non-changes, so no denominator moves: - cmmlu's report still groups by ctx.raw_sample, not the record's extra.subject. The two agree whenever both exist, but a final whose raw_sample is absent has always been dropped from the macro-average, and switching the source would silently change the denominator. The record carries `subject` regardless, so a row on disk is self-describing. - The ppl tasks' "no option could be picked" was the sentinel index -1 and is now None, per the protocol. Equivalent downstream: both mismatch the gold and both map to "" through choice_text (which now accepts None). Parity: 300 randomized differential trials per report against the pre-migration implementations loaded from d622448 -- 1800 total, 1800 identical. The cmmlu trials deliberately inject raw_sample=None samples to exercise the skip path. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…imit hle, browsecomp and aa_lcr adopt simpleqa_verified's shape from #60: the grader's whole ModelOutput is persisted as `extra.grader_output` (reply, reasoning, usage, finish_reasons, model id) instead of #51's flat `grader_reply`, which was response text only. That closes the limit #51 documented and #60 could only close for simpleqa_verified: a reasoning autorater that spends its entire budget thinking returns empty content, so an empty `grader_reply` was indistinguishable from an empty API response. `finish_reasons`, in the same output, is what separates them. HLE is the case #51 flagged as most reachable, since it normally runs a reasoning judge. Nothing is hand-picked, so no field is silently dropped and a future ModelOutput field is captured for free -- `grader_model` is subsumed (model id is in the output) and `predicted`/`gold` move to their protocol homes (the prediction record, and the judgement's `reference`). aa_lcr's empty-candidate short-circuit gets strictly better: it grades INCORRECT without calling the checker, and `grader_output` is now ABSENT on that path rather than an empty string -- absence is unambiguous where #51's "" meant both "never called" and "the checker returned nothing". The two are now asserted distinct. The short-circuit condition is also no longer spelled twice: blank answers normalize to None in postprocess, so `extracted: false` on the prediction and the feedback branch are one notion of "no answer". Grade / confidence / judge_parsed stay in `extra`, not `metrics`: none of them measures whether the answer was right. HLE's `confidence` in particular is the raw material report() pools into a calibration error, which a per-sample metric could not reconstruct. Module docstrings and reference_impl notes are updated in the same commit rather than left describing the deleted `grader_reply` / GradeFeedback / JudgeFeedback types; that rotates sieval/meta/index.json, regenerated here. Parity: 300 randomized differential trials per task (900 total) against the pre-migration report() from d622448, over 1-3 attempts, 0-15 samples and 0-4 fails. HLE's trials randomize judge_parsed so the unparsed-drop path (which must stay out of the calibration arrays) is exercised. 900/900 identical. Closes the #51 item in #61. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…otocol human_eval (chat + base), mbpp and livecodebench-base adopt the livecodebench_code_generation_0shot_gen shape from #60. This retires the ResourceMetrics TypedDict from all four. It was the coupling #61 named: the evaluator payload was persisted verbatim behind a client-side type, so when the evaluator gained n_cases/n_passed, #60 had to widen the TypedDict in lockstep. The payload now lands in the rollout's untyped `extra` (n_cases / n_passed hoisted, everything else under `resources`), so the next evaluator field needs no task change. `msg` stays raw and deliberately un-bucketed -- it is free text from a separately deployed service whose wording has already drifted once, which is why #60 removed its substring-derived failure category rather than keep it. `reference` is None across the family: the ground truth is a test suite, a procedure rather than a value. Each records what was actually run in the judgement's sample-level extra -- entry_point for HumanEval, the three lm-eval-shown asserts for MBPP, the case counts and io_mode for LiveCodeBench. Unextractable output is None in the record but still "" on the wire, so the evaluator runs it and reports a compile error. That is the pre-protocol behaviour: a real verdict, not a skipped rollout. The "Not evaluated" pre-filled feedback list is gone. It was unreachable -- the only path that left it in place re-raises -- so appending per rollout is equivalent and does not pretend a placeholder verdict can reach a report. Parity: 300 randomized differential trials per task (1200 total) against the pre-migration report() from d622448. The msg pool mixes casings and the near-miss "timed out" so the `timeouts` substring counter is exercised in both directions; rollout counts vary 0..n to cover short returns. 1200/1200 identical. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n complete The last ten: mmlu, openbookqa, gsm8k (chat + base), hendrycks_math, theoremqa, drop, ruler, t_eval_before_calling, ifbench. All 39 registered tasks now emit PromptRecord / PredictionRecord / JudgementRecord, so a consumer can read any sample's prompt, prediction, ground truth and verdict without knowing which task wrote the row -- the thing #60 built the protocol for and #61 tracked to done. The `metrics` tier does the work #61 predicted it would: - gsm8k_kshot_base_gen: strict and flexible extraction are co-equal published metrics; both are named, `correct` is DERIVED from the strict one. - drop: em -> correct, f1 -> score fits natively, but both are also in `metrics` so a generic reader can enumerate them, and report() pools from there. - t_eval: six co-equal continuous axes and no published headline. All six are named; `correct` is the strict reading (every scored axis at 1.0), derived from the mapping. Deliberately NOT parse_rate -- a task whose `correct` meant "the output parsed" would look near-perfect on the one axis that is supposed to be comparable across tasks. - ifbench: strict + loose, mirroring #60's ifeval work, with loose as the headline (ifeval's is strict). Grading moves from report() into feedback(), so per-sample verdicts are finally persisted, and report() stops depending on raw_sample being present. Its dead _get_report is removed. ruler gains a per-sample verdict it never had: upstream's string_match_* take whole lists and average internally, so before this no RULER sample carried a score of its own. Both metrics decompose exactly, so feedback() records the per-sample term. report() still calls the VENDORED functions on the whole cell -- re-deriving the mean here would fork upstream's scoring, and a vendored metric that no longer runs is a reproduction that drifts silently. theoremqa's `empty` counter now reads `extracted` off the prediction record instead of comparing the stored prediction against "": postprocess maps exactly that empty extraction to None, so it is the same population, spelled once. Parity: 300 randomized differential trials per task (2700) against the pre-migration report() from d622448 -- theoremqa's trials inject unextracted answers to exercise `empty`, ruler's vary reference counts and partial matches across both metric families, t_eval's vary all six axes. 2700/2700 identical. ifbench is covered instead by its unit test, whose expected report numbers are unchanged across the grading relocation. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#60 accepted a documented workaround: the profiler reads only task-supplied TaskStageMeta["model_calls"], which the runner derives from a stage RETURNING a ModelOutput. A judge task's `feedback` returns a bare record (the protocol forbids boxing it), so its grader calls had nowhere to go -- grader spend was on disk inside the record but absent from profile.json. #61 flagged fixing this before the judge family migrated in bulk; that family has now migrated, so all four judge tasks would otherwise inherit it. The record itself is the channel. A judged rollout already persists the grader's whole ModelOutput under extra.grader_output, so: - records.iter_grader_outputs() walks a judgement record for those outputs, and GRADER_OUTPUT_KEY names the key it reads. Non-judgements and grader-less judgements (a string compare, a test suite -- most tasks) return empty; this runs for every stage of every task, so it stays cheap and silent. - build_model_call_meta_from_mapping() rebuilds a ModelCallMeta from that already-flattened output. A mapping with no `model` returns None rather than contributing a usage-less phantom call. - build_stage_meta() gains `model_calls=` for calls not represented by an `outputs` entry, and the runner passes the recovered grader calls through it. No task changes: the four judge tasks already write the key. It is documented in sieval/tasks/CLAUDE.md as load-bearing rather than cosmetic -- spell it differently and the grader's tokens silently leave the profile. Verified end-to-end through the real TaskRunner + profiler, not just the helpers: a bare-record judge task's grader tokens now land in the FEEDBACK stage's usage (20 in / 1 out per sample from MockJudgeModel), the candidate's own spend stays attributed to `infer`, and the recorded call names the judge model. Also covers the aa_lcr case where a short-circuited rollout never called the checker and must not fabricate a call. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scicode_0shot_gen was the one registered task #61's inventory of 32 missed (#42 landed it just before #60). It is not a mechanical port: a sample is a chain of *dependent* sub-steps, each generating and executing code, which is not what rollouts[] means -- independent attempts at the same thing. One rollout per problem. The whole step sequence is the attempt; steps are neither independent nor attempts at the same thing, so they are not rollouts. Per-step detail lives in rollouts[0].extra.steps, and metrics carries both axes (main_problem_solved, sub_problem_pass_rate) with the headline derived from them. The raw counts correct_steps/total_steps sit in the record's extra because report() *pools* them -- averaging per-problem rates would give a different, wrong sub_problem_accuracy (the same reasoning ifeval's instruction-level pooling rests on). prompt is the first *generated* step's prompt, not the whole problem. It is knowable at preprocess because every preceding step is a special step whose gold code is static; infer then *reuses* the recorded prompt for that step, through a shared _gold_code(), so the record cannot disagree with what the model saw. Only steps 2..N are rebuilt, since their prompts embed the model's own prior output. extra.first_generated_step / n_generated_steps / previous_code say this explicitly, so prompt is not misread as the whole model input. Persisting all N per-step prompts would be quadratic text; stuffing the sample in would duplicate raw_sample. prediction is None iff *no* tested step produced extractable code. A partial miss is still a scoreable answer and keeps its prediction, which keeps the extraction_failure anomaly rule a real signal instead of firing on every partially-empty problem. reference is None: the ground truth is a procedure, this problem's per-step test suites. New StepCode.extracted_code holds the model's per-step answer so the prediction need not re-run the extractor. report.json is unchanged: 300 randomized differential trials against the pre-migration report(), 0 mismatches over 167 solved problems and 4529 graded steps; a deliberately sabotaged variant did produce mismatches, so the differential discriminates. Tests 34 (was 25), including one that drives the real feedback() twice and feeds both records to report(), so report reads exactly what feedback writes.
`iter_grader_outputs` and the two `is_*_record` predicates took `Any`, which
passes silently in both directions: a caller may hand them anything, and the
body may do anything with it. `object` is the accurate spelling -- no caller is
affected (everything is an `object`) while the bodies are forced to narrow,
which they already did.
That narrowing then has to reach the caller. `iter_grader_outputs` walks
`value.get("rollouts")` only after `is_judgement_record` says yes, and a plain
`bool` return throws that away -- only the old `Any` made it type-check. Both
predicates now return `TypeGuard[Mapping]`, so the narrowing they perform is the
narrowing callers get, with no redundant `isinstance` at the call site.
`TypeGuard` and deliberately not `TypeIs`: a mapping that is not a record still
returns False, so the negative branch says nothing about the type. `anomaly.py`
depends on exactly that, testing `isinstance(post_result, dict)` after a False.
Two of the three signatures predate this PR (#60); cleaned up here rather than
left inconsistent with their sibling.
Also fixes a non-discriminating assertion. `iter_grader_outputs({"rollouts":
"not a list"})` read as covering the per-rollout `isinstance` guard, but without
`n_rollouts` the value is not a judgement at all: it returned at the first guard
and never reached the one under test, so removing that guard failed no test.
The fixture now carries `n_rollouts`, and a mutant with the guard removed raises
AttributeError on the string's characters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/40) (#62) * feat(tasks): migrate the pass@k math family to the stage-output protocol aime_2026 / hmmt_feb_2026 moved in #60 while their six near-identical siblings did not, so tests/unit/tasks/test_math_pass_at_k_family.py carried a fork that built two different feedback shapes depending on set membership. All six now return PromptRecord / PredictionRecord / JudgementRecord, PROTOCOL_TASKS is gone, and _feedback() builds one shape for the whole family. The gold reaches disk from preprocess for all eight, which it previously did only for the two pilots -- raw_sample is never serialized, so a pass@k row that recorded no reference had no ground truth on disk at all. imo_answer_bench records the RAW dataset gold as `reference`, not the normalize_answer() output: normalization returns None when nothing survives stripping, and a None reference is reserved for "the ground truth is a procedure". The normalized form is in extra.normalized_reference, where None correctly reads as "the gold did not normalize". Correcting #61: it groups hmmt_nov_2025 and imo_answer_bench as pass@k tasks that "also use an LLM grader". They do not -- both grade deterministically (math_verify / the vendored verify_math_answer), and imo's own notes say an LLM grader would break the reproducibility contract. No grader detail is recorded because there is no grader. Parity: 300 randomized differential trials per task (1800 total) against the pre-migration report() loaded from d622448, over k in {1,2,4}, n >= k, 0-12 samples and 0-4 fails, including rollouts < k so the `short` warning path is exercised. 1800/1800 identical. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(tasks): migrate the clp/ppl family to the stage-output protocol The nine conditional-log-prob / perplexity tasks now return the three record types. #61 already corrected #60's "blocked on a design call" framing; this confirms it against the code: every one of the nine produces exactly ONE prediction per sample (an argmax index or a winning option label), so a one-entry rollouts[] is the honest shape. The candidate fan-out lives in `infer`, which the protocol deliberately leaves unwrapped. The migration unit is the family module, not the task: sieval/tasks/_arc.py holds the record builders (arc_prompt_record / arc_prediction_record / arc_judgement_record) and arc_report shared by all four ARC tasks (challenge/easy x ppl/clp), so the four move together and cannot fork. hellaswag_kshot_ppl is the `metrics` tier's second pilot after ifeval: acc and acc_norm are two argmax rules over one inference set, so both are named in `metrics` and `correct` is derived from acc_norm (the headline `score`) rather than recomputed. The raw-logprob argmax is the other rule's answer, not a second rollout, so it sits in the prediction record's extra next to the scores. mmmlu_kshot_clp keeps its TaskStageOutput box on `infer`: infer is not a record stage, so boxing it does not violate the bare-record rule. Its four flat prob_A..prob_D keys become one `probs` mapping in the rollout's extra -- mechanism behind the argmax, not metrics measuring the answer. Two deliberate non-changes, so no denominator moves: - cmmlu's report still groups by ctx.raw_sample, not the record's extra.subject. The two agree whenever both exist, but a final whose raw_sample is absent has always been dropped from the macro-average, and switching the source would silently change the denominator. The record carries `subject` regardless, so a row on disk is self-describing. - The ppl tasks' "no option could be picked" was the sentinel index -1 and is now None, per the protocol. Equivalent downstream: both mismatch the gold and both map to "" through choice_text (which now accepts None). Parity: 300 randomized differential trials per report against the pre-migration implementations loaded from d622448 -- 1800 total, 1800 identical. The cmmlu trials deliberately inject raw_sample=None samples to exercise the skip path. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(tasks): migrate the LLM-judge family, closing #51's documented limit hle, browsecomp and aa_lcr adopt simpleqa_verified's shape from #60: the grader's whole ModelOutput is persisted as `extra.grader_output` (reply, reasoning, usage, finish_reasons, model id) instead of #51's flat `grader_reply`, which was response text only. That closes the limit #51 documented and #60 could only close for simpleqa_verified: a reasoning autorater that spends its entire budget thinking returns empty content, so an empty `grader_reply` was indistinguishable from an empty API response. `finish_reasons`, in the same output, is what separates them. HLE is the case #51 flagged as most reachable, since it normally runs a reasoning judge. Nothing is hand-picked, so no field is silently dropped and a future ModelOutput field is captured for free -- `grader_model` is subsumed (model id is in the output) and `predicted`/`gold` move to their protocol homes (the prediction record, and the judgement's `reference`). aa_lcr's empty-candidate short-circuit gets strictly better: it grades INCORRECT without calling the checker, and `grader_output` is now ABSENT on that path rather than an empty string -- absence is unambiguous where #51's "" meant both "never called" and "the checker returned nothing". The two are now asserted distinct. The short-circuit condition is also no longer spelled twice: blank answers normalize to None in postprocess, so `extracted: false` on the prediction and the feedback branch are one notion of "no answer". Grade / confidence / judge_parsed stay in `extra`, not `metrics`: none of them measures whether the answer was right. HLE's `confidence` in particular is the raw material report() pools into a calibration error, which a per-sample metric could not reconstruct. Module docstrings and reference_impl notes are updated in the same commit rather than left describing the deleted `grader_reply` / GradeFeedback / JudgeFeedback types; that rotates sieval/meta/index.json, regenerated here. Parity: 300 randomized differential trials per task (900 total) against the pre-migration report() from d622448, over 1-3 attempts, 0-15 samples and 0-4 fails. HLE's trials randomize judge_parsed so the unparsed-drop path (which must stay out of the calibration arrays) is exercised. 900/900 identical. Closes the #51 item in #61. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(tasks): migrate the code-execution family to the stage-output protocol human_eval (chat + base), mbpp and livecodebench-base adopt the livecodebench_code_generation_0shot_gen shape from #60. This retires the ResourceMetrics TypedDict from all four. It was the coupling #61 named: the evaluator payload was persisted verbatim behind a client-side type, so when the evaluator gained n_cases/n_passed, #60 had to widen the TypedDict in lockstep. The payload now lands in the rollout's untyped `extra` (n_cases / n_passed hoisted, everything else under `resources`), so the next evaluator field needs no task change. `msg` stays raw and deliberately un-bucketed -- it is free text from a separately deployed service whose wording has already drifted once, which is why #60 removed its substring-derived failure category rather than keep it. `reference` is None across the family: the ground truth is a test suite, a procedure rather than a value. Each records what was actually run in the judgement's sample-level extra -- entry_point for HumanEval, the three lm-eval-shown asserts for MBPP, the case counts and io_mode for LiveCodeBench. Unextractable output is None in the record but still "" on the wire, so the evaluator runs it and reports a compile error. That is the pre-protocol behaviour: a real verdict, not a skipped rollout. The "Not evaluated" pre-filled feedback list is gone. It was unreachable -- the only path that left it in place re-raises -- so appending per rollout is equivalent and does not pretend a placeholder verdict can reach a report. Parity: 300 randomized differential trials per task (1200 total) against the pre-migration report() from d622448. The msg pool mixes casings and the near-miss "timed out" so the `timeouts` substring counter is exercised in both directions; rollout counts vary 0..n to cover short returns. 1200/1200 identical. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(tasks): migrate the remaining generative tasks; protocol adoption complete The last ten: mmlu, openbookqa, gsm8k (chat + base), hendrycks_math, theoremqa, drop, ruler, t_eval_before_calling, ifbench. All 39 registered tasks now emit PromptRecord / PredictionRecord / JudgementRecord, so a consumer can read any sample's prompt, prediction, ground truth and verdict without knowing which task wrote the row -- the thing #60 built the protocol for and #61 tracked to done. The `metrics` tier does the work #61 predicted it would: - gsm8k_kshot_base_gen: strict and flexible extraction are co-equal published metrics; both are named, `correct` is DERIVED from the strict one. - drop: em -> correct, f1 -> score fits natively, but both are also in `metrics` so a generic reader can enumerate them, and report() pools from there. - t_eval: six co-equal continuous axes and no published headline. All six are named; `correct` is the strict reading (every scored axis at 1.0), derived from the mapping. Deliberately NOT parse_rate -- a task whose `correct` meant "the output parsed" would look near-perfect on the one axis that is supposed to be comparable across tasks. - ifbench: strict + loose, mirroring #60's ifeval work, with loose as the headline (ifeval's is strict). Grading moves from report() into feedback(), so per-sample verdicts are finally persisted, and report() stops depending on raw_sample being present. Its dead _get_report is removed. ruler gains a per-sample verdict it never had: upstream's string_match_* take whole lists and average internally, so before this no RULER sample carried a score of its own. Both metrics decompose exactly, so feedback() records the per-sample term. report() still calls the VENDORED functions on the whole cell -- re-deriving the mean here would fork upstream's scoring, and a vendored metric that no longer runs is a reproduction that drifts silently. theoremqa's `empty` counter now reads `extracted` off the prediction record instead of comparing the stored prediction against "": postprocess maps exactly that empty extraction to None, so it is the same population, spelled once. Parity: 300 randomized differential trials per task (2700) against the pre-migration report() from d622448 -- theoremqa's trials inject unextracted answers to exercise `empty`, ruler's vary reference counts and partial matches across both metric families, t_eval's vary all six axes. 2700/2700 identical. ifbench is covered instead by its unit test, whose expected report numbers are unchanged across the grading relocation. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): route grader model_calls into the profiler #60 accepted a documented workaround: the profiler reads only task-supplied TaskStageMeta["model_calls"], which the runner derives from a stage RETURNING a ModelOutput. A judge task's `feedback` returns a bare record (the protocol forbids boxing it), so its grader calls had nowhere to go -- grader spend was on disk inside the record but absent from profile.json. #61 flagged fixing this before the judge family migrated in bulk; that family has now migrated, so all four judge tasks would otherwise inherit it. The record itself is the channel. A judged rollout already persists the grader's whole ModelOutput under extra.grader_output, so: - records.iter_grader_outputs() walks a judgement record for those outputs, and GRADER_OUTPUT_KEY names the key it reads. Non-judgements and grader-less judgements (a string compare, a test suite -- most tasks) return empty; this runs for every stage of every task, so it stays cheap and silent. - build_model_call_meta_from_mapping() rebuilds a ModelCallMeta from that already-flattened output. A mapping with no `model` returns None rather than contributing a usage-less phantom call. - build_stage_meta() gains `model_calls=` for calls not represented by an `outputs` entry, and the runner passes the recovered grader calls through it. No task changes: the four judge tasks already write the key. It is documented in sieval/tasks/CLAUDE.md as load-bearing rather than cosmetic -- spell it differently and the grader's tokens silently leave the profile. Verified end-to-end through the real TaskRunner + profiler, not just the helpers: a bare-record judge task's grader tokens now land in the FEEDBACK stage's usage (20 in / 1 out per sample from MockJudgeModel), the candidate's own spend stays attributed to `infer`, and the recorded call names the judge model. Also covers the aa_lcr case where a short-circuited rollout never called the checker and must not fabricate a call. Refs #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tasks): keep failed problems' steps in scicode's sub-problem denominator scicode reports two accuracies and they disagreed about what a pipeline failure means. `main_problem_accuracy` counted a failed problem as unsolved; `sub_problem_accuracy` dropped its steps from the denominator entirely. One metric was therefore diluted by a failure while the other was silently inflated by its removal, and a full test split fell below the fixed 288-step denominator the official sub-problem figures are computed over -- making the number incomparable to the leaderboard exactly when a run was unhealthy. The comment justifying it ("their step counts are unknown") was false: a failed context carries its `raw_sample`, so its tested steps are recoverable. Recover them -- excluding the three scientist-authored steps, as on the success path -- and leave them in the denominator scoring zero. A new `unevaluated_steps` report field says how many of `total_steps` never ran, so a reader can tell a model failure from a pipeline failure; read it alongside `fails`. A context that failed before its sample was loaded has nothing to recover and is skipped. It still dilutes main-problem accuracy. Audited against a prior in-house SciCode port, which cleared the rest of this task (identical sub-problem formula, byte-identical code extraction, identical prompt templates, equivalent special-step gold injection under the default `verbatim` mode). Deliberately not followed there: it filters failures out of *both* metrics, which hides pipeline failures and makes the denominator depend on run health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tasks): migrate scicode to the stage-output protocol, 40/40 scicode_0shot_gen was the one registered task #61's inventory of 32 missed (#42 landed it just before #60). It is not a mechanical port: a sample is a chain of *dependent* sub-steps, each generating and executing code, which is not what rollouts[] means -- independent attempts at the same thing. One rollout per problem. The whole step sequence is the attempt; steps are neither independent nor attempts at the same thing, so they are not rollouts. Per-step detail lives in rollouts[0].extra.steps, and metrics carries both axes (main_problem_solved, sub_problem_pass_rate) with the headline derived from them. The raw counts correct_steps/total_steps sit in the record's extra because report() *pools* them -- averaging per-problem rates would give a different, wrong sub_problem_accuracy (the same reasoning ifeval's instruction-level pooling rests on). prompt is the first *generated* step's prompt, not the whole problem. It is knowable at preprocess because every preceding step is a special step whose gold code is static; infer then *reuses* the recorded prompt for that step, through a shared _gold_code(), so the record cannot disagree with what the model saw. Only steps 2..N are rebuilt, since their prompts embed the model's own prior output. extra.first_generated_step / n_generated_steps / previous_code say this explicitly, so prompt is not misread as the whole model input. Persisting all N per-step prompts would be quadratic text; stuffing the sample in would duplicate raw_sample. prediction is None iff *no* tested step produced extractable code. A partial miss is still a scoreable answer and keeps its prediction, which keeps the extraction_failure anomaly rule a real signal instead of firing on every partially-empty problem. reference is None: the ground truth is a procedure, this problem's per-step test suites. New StepCode.extracted_code holds the model's per-step answer so the prediction need not re-run the extractor. report.json is unchanged: 300 randomized differential trials against the pre-migration report(), 0 mismatches over 167 solved problems and 4529 graded steps; a deliberately sabotaged variant did produce mismatches, so the differential discriminates. Tests 34 (was 25), including one that drives the real feedback() twice and feeds both records to report(), so report reads exactly what feedback writes. * fix(tasks): record only the axes t_eval actually scores feedback() recorded every axis _evaluate returns, and correct is derived as "every recorded axis came out at 1.0". But _evaluate pre-seeds all six axes to 0 to keep its mapping rectangular, and thought similarity costs an embedding call so it is opt-in -- so under the default eval_thought=False a retained thought: 0.0 pinned correct to False on every sample of every run. The one axis the stage-output protocol exists to make comparable across tasks was structurally unreachable for this task. A 0 on an axis nobody measured is a hole, not a measurement. metrics now carries only the scored axes, from a new _metric_keys() shared with the macro-average, so the recorded metrics, the derived correct and report() cannot disagree about which axes are real. report.json does not move. _post_process already macro-averaged exactly this key set (same json/str branch, same eval_thought gate), and it is now fed the narrowed mapping, so the *_parsed variants read defensively -- the str modes score no args at all, and 0.0 is what they reported before, when _evaluate's pre-seeded zeros were still on the record. Also fixes a latent NameError: metric_keys was left unbound for any prompt_type outside json/str, surfacing deep in the macro-average instead of rejecting the config. The unsupported branch now raises NotImplementedError where the decision is made. Tests 13 (was 1): the scored-axis set per mode, the NotImplementedError, correct reachable under the default config, a wrong tool name moving only `name`, an unparseable answer, and the str-mode report path where *_parsed must stay 0.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): read ruler's prediction with .get() on the resume-report path report() indexed rollouts[0]["prediction"], but postprocess normalizes a blank response to None so `extracted` stays a real signal -- and obj_to_dict drops None-valued keys, so the field is ABSENT on disk, not null. report() runs over disk-rehydrated records whenever every sample is complete but report.json is missing or invalid, which is exactly the resume case: an already-paid-for long-context run reached the last step and died with KeyError. Nothing about this is ruler-specific reasoning -- records.py states the rule ("optional fields are read with .get()"); this call site just didn't follow it. The test helper now round-trips both records through obj_to_dict, so every report test builds the shape report() actually has to read rather than the strictly more forgiving in-memory one, and the new case pins the blank sample as a scored miss (50.0 in a two-sample cell) rather than a silently skipped sample. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(tasks): use GRADER_OUTPUT_KEY instead of the literal in judge tasks The key name is load-bearing, not cosmetic: the runner reads extra["grader_output"] back to route grader spend into profile.json, because a feedback stage returning a bare record has no ModelOutput for the profiler to derive a call from. Spelled as a literal in four places, a divergence is silent -- the grader's tokens sit on disk and go missing from the profile, with nothing failing. core.tasks already exports the constant; the producers just weren't using it. Docstring occurrences stay as prose, since those describe the on-disk key name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): default a null evaluator msg in the code-exec timeout counters The five reports that count timeouts did r["extra"]["msg"].lower(). msg is passed straight through from the code-eval service response (res["msg"]), so a null makes a FRESH run raise AttributeError on None.lower() -- the timeout counter takes the whole report down at the last step, after every sample has already been executed and paid for. Serialization dropping None-valued keys makes the resumed run fail the same way for a second reason. Now (r["extra"].get("msg") or "") -- absent or null both read as "no message", which is what an evaluator that reported no message means. Nothing else about the verdict depends on msg; status already carries pass/fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(core): type the record predicates as TypeGuards over object `iter_grader_outputs` and the two `is_*_record` predicates took `Any`, which passes silently in both directions: a caller may hand them anything, and the body may do anything with it. `object` is the accurate spelling -- no caller is affected (everything is an `object`) while the bodies are forced to narrow, which they already did. That narrowing then has to reach the caller. `iter_grader_outputs` walks `value.get("rollouts")` only after `is_judgement_record` says yes, and a plain `bool` return throws that away -- only the old `Any` made it type-check. Both predicates now return `TypeGuard[Mapping]`, so the narrowing they perform is the narrowing callers get, with no redundant `isinstance` at the call site. `TypeGuard` and deliberately not `TypeIs`: a mapping that is not a record still returns False, so the negative branch says nothing about the type. `anomaly.py` depends on exactly that, testing `isinstance(post_result, dict)` after a False. Two of the three signatures predate this PR (#60); cleaned up here rather than left inconsistent with their sibling. Also fixes a non-discriminating assertion. `iter_grader_outputs({"rollouts": "not a list"})` read as covering the per-rollout `isinstance` guard, but without `n_rollouts` the value is not a judgement at all: it returned at the first guard and never reached the one under test, so removing that guard failed no test. The fixture now carries `n_rollouts`, and a mutant with the guard removed raises AttributeError on the string's characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(t_eval): give the thought-scoring test an embedding key `eval_thought=True` builds the embedding client in `__init__` with `api_key=os.getenv("SIEVAL_EMBED_API_KEY", "")`. An explicit empty string is not the same as `None`: it skips the OpenAI client's `OPENAI_API_KEY` fallback, and openai >= 2.x raises `OpenAIError: Missing credentials` at construction rather than failing later on the first call. So the test could not build the instance in a credential-free environment. Setting the env var keeps the instance real -- the point of the helper -- and still exercises the `eval_thought=True` branch of `__init__`. No embedding call is made; the assertion is on `_metric_keys()` alone. Verified against the locked openai (2.45.0): `api_key=""` raises exactly the CI error, `api_key="not-a-real-key"` constructs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): put four stragglers' declared types on the record protocol The migration moved these four tasks' bodies onto `build_prompt_record` / `build_prediction_record` / `build_judgement_record`, but left their `Task[...]` parameterization describing the shapes they no longer return — `list[ChatCompletionUserMessageParam]` / `str` for the prompt slot, `str` / `list[str]` / a per-task `Prediction` TypedDict for the prediction slot, and a per-task `Feedback` TypedDict for the judgement slot. Nothing caught it: the stage methods carry no return annotations, so the generic slots are never cross-checked against the bodies, and the five dead TypedDicts stayed reachable as those slots' arguments — which is also why the deletion sweep for per-task `Feedback`/`Prediction` names read clean. On-disk output was already correct, so no report or shard moves. What changes is that the declared type now matches it, the five unused TypedDicts are gone, and `gsm8k_0shot_gen` no longer imports `openai` at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(t_eval): name the env var that actually helps when a key is missing `eval_thought=True` builds the embedding client with `api_key=os.getenv("SIEVAL_EMBED_API_KEY", "")`. An explicit "" is not None, so it skips the OpenAI client's own OPENAI_API_KEY fallback and raises `Missing credentials` naming OPENAI_API_KEY -- a variable that would not help even if set, because the endpoint is our embedding service, not OpenAI's. The failure also arrives at construction, before any run output, so the message is all the operator gets. Check the key in the task instead and raise a ValueError that names SIEVAL_EMBED_API_KEY, mentions SIEVAL_EMBED_API for a non-default endpoint, and says the axis is optional (leave eval_thought unset). The empty-string case is the reachable one -- an exported-but-blank variable -- so both None and "" take the new path. No behaviour change when the key is present, and none at all under the default `eval_thought=False`, which builds no client. The new test discriminates: pre-fix the constructor raised OpenAIError, which is not a ValueError subclass, and its message named OPENAI_API_KEY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type
Summary
Stage return types are free-form generics, so every task invented its own shape for "the extracted answer" and "was it right". Nothing downstream could read a sample's answer / ground truth / correctness without a per-task special case — and since
raw_sampleis never serialized, ground truth reaches disk only if a task puts it in a stage result, which two of the seven tasks below never did.sieval/core/tasks/records.py—PromptRecord/PredictionRecord/JudgementRecord+ builders.inferis deliberately left alone (generative tasks already returnModelOutputuniformly; this does not hold for_ppl/_clp, which fan out over candidates — see Follow-ups).mmlu_pro,gpqa_diamond,aime_2026,hmmt_feb_2026,livecodebench_code_generation,ifeval,simpleqa_verified. Other ~80 tasks untouched; adoption is per-task and legacy shapes keep working.rollouts[]list, son=1andn>1share one schema and a gold is stored once instead of once per rollout (AIME withn=4used to repeat it 4×).n_rollouts/n_correctare materialized as the sample-level pass rate.reference=Nonemeans the ground truth is a procedure, not a value — LiveCodeBench's is a test suite, described inextra.ifevalnow grades infeedback()instead ofreport(), so per-sample strict/loose verdicts + per-instruction results are finally persisted.simpleqa_verifiednow persists the raw grader reply:parse_gradesilently defaults toNOT_ATTEMPTED, so a defaulted grade was previously indistinguishable from a real one.extraction_failureanomaly rule reports real per-rollout indices instead of the{0}sentinel.vendor/code-evaluatorgains{n_cases, n_passed}on the response, at zero extra execution cost.A migrated record on disk (
{iteration}/final/*.jsonl,aime_2026withn=2):Two rules that are easy to break
Both are documented on the
recordsmodule, because violating either silently undoes the point of the change.TaskStageOutput. The runner preserves that box as the stage value, so boxing one task would persist{"value": {...}, "meta": {...}, "__sieval_cls__": ...}while its peers persist a flat record. Accepted cost: the profiler only reads task-suppliedTaskStageMeta["model_calls"], sosimpleqa_verifiedcarries the grader'sModelCallMetainside the record (extra.grader_calls) — grader spend is on disk but not inprofile.json. The fix belongs in core; boxing would be working around it.obj_to_dictdropsNone-valued keys, soprediction: Noneandreference: Noneare absent on disk, not null (Falseand0survive — the check isis not None). That is whyextractedand then_*counts are explicit fields rather than derived from a missingprediction: the flags and counts are what survive the wire. Pinned by a round-trip test.Related Issues
Refs #49 (per-stage record schema; the run-level output-file envelope stays open and unprejudiced).
Test Plan
Automated
ruff check && ruff format --check) — only pre-existingsieval/_version.py(pdm-generated, gitignored)ty check→ all passed).mypy --strictadds 9type-argerrors on baredict, matching the project's existing style, on top of 63 pre-existing onmaintests/unit+tests/integration+tests/acceptancescripts/check_preflight.pyall-PASS;sync_meta_index.py --checkandsync_package_stubs.py --checkcleanNew tests:
tests/unit/core/tasks/test_records.py(27, incl. theNone-dropping round trip),tests/unit/tasks/test_stage_protocol_reports.py(21 report-parity), plusdetect_extraction_failurecoverage intest_anomaly.py.Manual — report parity
report.jsonmetrics are unchanged for every task. Verified against the pre-migration implementations, not only against new tests:report()first had to reproduce its storedreport.jsonto prove the harness valid49.621212121212125pass@8also checked against an independently computed estimatororigin/main'sreport()(no completed run for these exists on disk)IFEval's stage relocation is safe because both graders are pure per-sample — their only use of the response map is
prompt_to_response[inp.prompt]— and all 541 IFEval prompts are unique, so the old prompt-keyed dict had no collisions to preserve.TaskRunner+ saver (stubbed model, everything downstream production code): records land self-describing, andextraction_failurefires per rollout —aime_2026flagged[1](the rollout with no boxed answer),mmlu_proflagged[0]on a refusal.vendor/code-evaluatorexercised on all five paths: all pass →n_passed=4; fail at case 2 →2; compile error →0; arity mismatch →0; subprocess timeout →None.Checklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)in module docstringcore/—records.pyimports onlysieval.core.typesFeedback/GradeFeedback/PreprocessedTypedDicts andifeval._get_reporthad no call sites outside their own modules (_get_report'stier0_*/tier1_*breakdowns were computed and never returned)If: community/ Changes
vendor/code-evaluator/VENDORED.mdgains an entry for the test-case-progress patch; still to be landed upstream inscitix/code-evaluatorand re-vendoredIf: Breaking Change
On-disk schema, for the 7 migrated tasks only. Result dirs written by 0.7.x hold the old feedback shape and cannot be re-reported by the new
report(). Migration path: start fresh. This rides the 0.8.0 minor bump already staged for the infer-recipe split, so the resume version gate rejects a 0.7.x → 0.8.x resume with a version message rather than a confusing shape error.report.jsonshapes are unchanged for every task, so leaderboards, alignment cards andresolve_model_nameare unaffected.Two deliberate
ifevalbehaviour changes, outside the parity claim:report()no longer raisesZeroDivisionErroron an empty final set (returns zeros, matching every sibling task), and the dead tier0/tier1 code is gone.One fleet-wide effect: adding an anomaly rule changes
rules_hash, so every task's next run rotatesanomalies.json(_backup_if_rules_changedhandles it — harmless, but visible).Follow-ups (not in this PR)
tests/unit/tasks/test_math_pass_at_k_family.py::PROTOCOL_TASKSbridges the gap and should shrink to empty.simpleqa_verifieddid._ppl/_clpare blocked on a design call: they fan out over candidate answers rather than rollouts, so "one rollout + per-candidate scores inextra" needs confirming against a real ppl task.model_callsinto the profiler (core-side), ideally before the judge family migrates in bulk.🤖 Generated with Claude Code