Skip to content

feat(core): uniform stage-output protocol, piloted on 7 tasks - #60

Merged
ethan-scitix merged 9 commits into
mainfrom
feat/stage-output-protocol
Aug 4, 2026
Merged

feat(core): uniform stage-output protocol, piloted on 7 tasks#60
ethan-scitix merged 9 commits into
mainfrom
feat/stage-output-protocol

Conversation

@ethan-scitix

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

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_sample is 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.

  • New: a record per stage in sieval/core/tasks/records.pyPromptRecord / PredictionRecord / JudgementRecord + builders. infer is deliberately left alone (generative tasks already return ModelOutput uniformly; this does not hold for _ppl/_clp, which fan out over candidates — see Follow-ups).
  • Migrated: 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.
  • Sample-level envelope with a rollouts[] list, so n=1 and n>1 share one schema and a gold is stored once instead of once per rollout (AIME with n=4 used to repeat it 4×). 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.
  • ifeval now grades in feedback() instead of report(), so per-sample strict/loose verdicts + per-instruction results are finally persisted.
  • simpleqa_verified now persists the raw grader reply: parse_grade silently defaults to NOT_ATTEMPTED, so a defaulted grade was previously indistinguishable from a real one.
  • New extraction_failure anomaly rule reports real per-rollout indices instead of the {0} sentinel.
  • vendor/code-evaluator gains {n_cases, n_passed} on the response, at zero extra execution cost.

A migrated record on disk ({iteration}/final/*.jsonl, aime_2026 with n=2):

"preprocess_result": {"prompt": [...], "reference": "42"},
"postprocess_result": {"rollouts": [
    {"index": 0, "prediction": "42", "extracted": true},
    {"index": 1, "extracted": false}]},
"feedback_result": {"reference": "42",
    "rollouts": [{"index": 0, "correct": true}, {"index": 1, "correct": false}],
    "n_rollouts": 2, "n_correct": 1}

Two rules that are easy to break

Both are documented on the records module, because violating either silently undoes the point of the change.

  1. Records are returned bare — never wrapped in 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-supplied TaskStageMeta["model_calls"], so simpleqa_verified carries the grader's ModelCallMeta inside the record (extra.grader_calls) — grader spend is on disk but not in profile.json. The fix belongs in core; boxing would be working around it.
  2. obj_to_dict drops None-valued keys, so prediction: None and reference: None are absent on disk, not null (False and 0 survive — the check is is not None). That is why extracted and the n_* counts are explicit fields rather than derived from a missing prediction: 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

  • Lint/format clean (ruff check && ruff format --check) — only pre-existing sieval/_version.py (pdm-generated, gitignored)
  • Type check clean (ty check → all passed). mypy --strict adds 9 type-arg errors on bare dict, matching the project's existing style, on top of 63 pre-existing on main
  • Unit tests pass — 2787 across tests/unit + tests/integration + tests/acceptance
  • scripts/check_preflight.py all-PASS; sync_meta_index.py --check and sync_package_stubs.py --check clean

New tests: tests/unit/core/tasks/test_records.py (27, incl. the None-dropping round trip), tests/unit/tasks/test_stage_protocol_reports.py (21 report-parity), plus detect_extraction_failure coverage in test_anomaly.py.

Manual — report parity

report.json metrics are unchanged for every task. Verified against the pre-migration implementations, not only against new tests:

task method result
gpqa_diamond replayed a real 792-record run; the old report() first had to reproduce its stored report.json to prove the harness valid identical — 49.621212121212125
aime_2026, hmmt_feb_2026 replayed real AIME runs incl. 30 samples × 64 rollouts (285/1920 correct); pass@8 also checked against an independently computed estimator identical
ifeval 120 real dataset rows through both the old whole-set grading and the new per-sample grading — all 8 metrics and key order identical (fixture discriminating: strict 14/120, loose 23/120, differing on 9)
mmlu_pro, livecodebench, simpleqa_verified 300 randomized differential trials each against origin/main's report() (no completed run for these exists on disk) 900/900 identical

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.

  • End-to-end through the real TaskRunner + saver (stubbed model, everything downstream production code): records land self-describing, and extraction_failure fires per rollout — aime_2026 flagged [1] (the rollout with no boxed answer), mmlu_pro flagged [0] on a refusal.
  • vendor/code-evaluator exercised 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)

  • 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/records.py imports only sieval.core.types
  • Deleted code verified — Feedback/GradeFeedback/Preprocessed TypedDicts and ifeval._get_report had no call sites outside their own modules (_get_report's tier0_*/tier1_* breakdowns were computed and never returned)

If: community/ Changes

  • Upstream diff documented — vendor/code-evaluator/VENDORED.md gains an entry for the test-case-progress patch; still to be landed upstream in scitix/code-evaluator and re-vendored
  • License attribution preserved

If: Breaking Change

  • Described what breaks and migration path in Summary — see below
  • Existing tests updated to reflect new behavior

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.json shapes are unchanged for every task, so leaderboards, alignment cards and resolve_model_name are unaffected.

Two deliberate ifeval behaviour changes, outside the parity claim: report() no longer raises ZeroDivisionError on 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 rotates anomalies.json (_backup_if_rules_changed handles it — harmless, but visible).

Follow-ups (not in this PR)

  • Finish the migration. The math family is currently split — aime_2026/hmmt_feb_2026 moved while their near-identical siblings did not; tests/unit/tasks/test_math_pass_at_k_family.py::PROTOCOL_TASKS bridges the gap and should shrink to empty.
  • Judge family (hle, browsecomp, aa_lcr) maps exactly as simpleqa_verified did.
  • _ppl/_clp are blocked on a design call: they fan out over candidate answers rather than rollouts, so "one rollout + per-candidate scores in extra" needs confirming against a real ppl task.
  • Route grader model_calls into the profiler (core-side), ideally before the judge family migrates in bulk.
  • Full per-test-case pass rate needs dropping the evaluator's short-circuit — slower, and it can newly trip the shared timeout budget. Ship as an opt-in flag if wanted.

🤖 Generated with Claude Code

@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Two review items applied, plus a dependency to record.

1. Case counts now come back on every source, not just LiveCodeBench (890d866d)

n_cases / n_passed are reported for all sources, 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 it 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 stay null only when nothing ran at all (unsupported language or source), where data is itself null. Verified on the direct-run path: clean run → 1/1, raises → 0/1, syntax error → 0/1.

This widened the blast radius on the sieval side, which is worth stating: the four tasks that persist the evaluator payload verbatim as metrics (human_eval ×2, mbpp, livecodebench kshot base) each declare a ResourceMetrics TypedDict listing only the four resource fields, which would now misdescribe what is actually written to disk. Added the two counts as NotRequired so the declared type matches the record. No behaviour change — nothing reads them, and the passthrough was already persisting them.

2. vendor/code-evaluator docs are English now (890d866d)

README.md translated in full; content otherwise unchanged apart from the case-count section. No CJK left anywhere under vendor/code-evaluator/. VENDORED.md records both edits as local patches still to be landed upstream in scitix/code-evaluator.

3. Depends on #51 — that one merges first

#51 adds grader_reply to all four LLM-judged tasks and Closes #48. This PR independently added the same field to simpleqa_verified before I spotted it, so the two overlap. A trial merge conflicts in sieval/tasks/simpleqa_verified_0shot_gen.py, tests/unit/tasks/test_simpleqa_verified_0shot_gen.py, and sieval/meta/index.json (both rewrote that task's reference_impl.notes).

#51 merges as-is; this PR then rebases on top, and simpleqa_verified's grader_reply moves from a top-level feedback field to extra.grader_reply — the protocol keeps grader-specific detail under extra while correct / reference / the counts stay at the top. Same field, same name, same rationale; only its position changes. #51's four simpleqa_verified assertions get rewritten to the new shape rather than dropped.

Do not merge this before #51. Details on #51: #51 (comment)

@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 890d866 to 79f04d2 Compare August 4, 2026 12:13
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Rebased onto main post-#51 (88754304). Three commits, linear on top of it, force-pushed as 79f04d29.

Conflict resolution — all three files, as planned

file resolution
sieval/tasks/simpleqa_verified_0shot_gen.py grader_reply moved from a top-level feedback field to extra.grader_reply. #51's rationale wording is kept, not replaced — its docstring and reference_impl.notes explanations were better than mine, and I adapted them to the new location rather than reverting to my terser version.
tests/unit/tasks/test_simpleqa_verified_0shot_gen.py All three of #51's additions ported to the new shape, including its deliberately multi-line grader reply (the fixture that fails a "store only the matched letter" implementation) and its test_feedback_persists_reply_behind_not_attempted_default. Nothing dropped.
sieval/meta/index.json Regenerated from the live registry rather than hand-merged; sync_meta_index.py --check clean.

hle / browsecomp / aa_lcr are untouched by this PR, so #51's field is intact on all four tasks.

#51's documented limit is now closed for simpleqa_verified

#51 flagged that the stored reply is ModelOutput.texts — response content only — so a reasoning autorater that spends its whole budget thinking records an empty reply, indistinguishable from an empty API response, and assigned the follow-up to this PR since grader-call metadata lives under extra here. It is closed: extra.grader_calls carries the grader's ModelCallMeta, whose finish_reasons separates the two. An actual on-disk record from a run with a budget-exhausted grader:

"extra": {
  "grade": "NOT_ATTEMPTED",
  "grader_reply": "",
  "grader_model": "grader-reasoning",
  "grader_calls": [{"model": {...}, "usage": {...}, "finish_reasons": ["length"]}]
}

["length"] = burned the budget thinking; ["stop"] = genuinely empty response. Asserted by test_finish_reason_separates_a_thinking_grader_from_an_empty_response, which pins that the two cases are identical on grader_reply and grade and separable only via the call metadata — so it fails if grader_calls is ever dropped.

Two scoping notes on that, stated rather than left implicit:

  • reasoning_texts is still not stored. It is unbounded, and the finish reason already answers the question the record needs to answer. If reading a judge's actual reasoning turns out to matter, that is a separate decision about payload weight.
  • This closes it for simpleqa_verified only. hle / browsecomp / aa_lcr still carry the limit, and HLE is the case feat(tasks): persist the raw grader reply in LLM-judged tasks #51 named as most reachable since it normally runs a reasoning judge. It closes for them when they migrate to the protocol — tracked with the judge-family follow-up.

Re-verified after the rebase

@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 79f04d2 to 1fb7e48 Compare August 4, 2026 12:53
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

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 (14a90187, force-pushed).

Q1 — grader output: store the full ModelOutput, nothing hand-picked

You were right that the grader is just another model and should be recorded as one. The old shape (grader_reply + grader_calls = a ModelCallMeta projection) dropped exactly one field — reasoning_texts — which is the field that matters for a reasoning autorater. New 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}
  }
}

grader_output is the grader's ModelOutput flattened with obj_to_dict(out, add_type=False). That captures every field (reply, reasoning, usage, finish_reasons, request_params, model id), loses nothing, and picks up any future ModelOutput field for free. add_type=False keeps it a plain dict, so the record stays uniformly plain-dict rather than nesting a typed @sieval_record — verified it round-trips through save/load with no type markers. grade stays a separate key because it's the parsed verdict (task logic), not model output. report() reads only grade, so F1 is unchanged.

Two new tests pin the point: test_grader_reasoning_is_persisted_not_dropped (the info-loss fix — reasoning reaches disk) and the finish-reason test now reads it from grader_output.

One consequence to flag: this makes simpleqa_verified the template for the judge family. The three non-migrated judge tasks (hle/browsecomp/aa_lcr) keep #51's flat grader_reply until they migrate, then adopt grader_output. Staged in the changelog note.

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. grader (the role — task arg, model, #51's shared field) is already consistent across all four tasks. judge is concentrated in HLE and is not sloppiness:

  • It's upstream's own term — JUDGE_PROMPT and the metric kernel are vendored byte-for-byte from hle_eval/run_judge_results.py.
  • parse_judge returns (correct, confidence, parsed) — a different contract from the other three modules' parse_grade, which returns a grade string. Renaming parse_judge → parse_grade would put two different signatures under one name: worse than the inconsistency it "fixes".

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. JudgementRecord/rollout_judgement are the verdict layer (mechanism-agnostic) and are correctly named. If you still want the cosmetic JudgeFeedback → HLEFeedback cleanup, I'd do it as its own small PR rather than smuggle it in here.

Q3 — consolidation: nothing to move

records.py is single-purpose. The only mild incohesion nearby is pre-existing and unrelated: TaskRunMeta (meta.json) and TaskManifest (manifest.json) live in context.py despite being persisted-file schemas, not context state. That's the same group as the deferred run-level "Unified output file envelope" (TODO §1.8) — the right place to move them is alongside that work, gated on sieval analyze, not mixed into this protocol PR. TaskStageMeta (→ context) and ModelCallMeta (→ models) are cohesive where they are, as you noted.

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.

@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 1fb7e48 to 98a282b Compare August 4, 2026 13:09
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Applied the builder-naming fix; the judge/grade question I'll answer rather than change.

Builder naming → build_*, matching build_model_call_meta / build_stage_meta

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:

  • build_* (7) — construct from parts: build_stage_meta, build_model_call_meta, build_prompt
  • *_from_* (11) — convert from a source: task_meta_from_dict, dataset_meta_from_dict

My builders construct records from parts, so they're the build_* family — same category as build_model_call_meta (which builds the ModelCallMeta TypedDict from a ModelOutput). Renamed across all 14 files:

  • prompt_recordbuild_prompt_record
  • prediction_recordbuild_prediction_record
  • rollout_judgementbuild_rollout_judgement
  • judgement_recordbuild_judgement_record

Types stay PascalCase (JudgementRecord, RolloutJudgement), exactly the ModelCallMeta / build_model_call_meta split. is_prediction_record / is_judgement_record keep the is_ predicate convention. Whole-word rename, is_*_record verified untouched; 2792 tests pass, folded into the protocol commit.

judge vs grade — they denote different layers, and within this PR they're already separated correctly

They aren't two words for one thing:

concept word scope
the verdict record (feedback output) judgement protocol, mechanism-agnostic — string-compare (mmlu), math-verify (aime), test-suite (lcb), or LLM
the LLM autorater's categorical output grade (CORRECT/INCORRECT/NOT_ATTEMPTED) LLM-judged tasks only
the LLM actor grader LLM-judged tasks only

A JudgementRecord contains a grade for LLM tasks — one nests in the other. A math task produces a judgement with no grade and no grader anywhere, which is exactly why the protocol layer uses the mechanism-neutral word. Within this PR the usage is already consistent: judgement/build_rollout_judgement for the record, grade for the LLM output, grader for the actor.

The genuine inconsistency is a third word — HLE's judge (parse_judge, judge_parsed, JudgeFeedback) used where the other three say grader/grade. That's HLE-internal, out of this PR, and not a blind rename: parse_judge returns (correct, confidence, parsed), a different contract from parse_grade's grade string, so it can't just take that name. It's a scoped follow-up on the judge family, tracked with their protocol migration.

One deliberate call worth surfacing: I kept the protocol record as Judgement rather than renaming it to Verdict. "Verdict" would remove even the shared root with HLE's "judge", but "judgement" is standard for a mechanism-agnostic decision (and it's what the eval field means by "LLM-as-a-judge"), so I didn't think the churn earned it. Say the word if you'd rather have the zero-overlap Verdict.

Force-pushed; CI to follow.

@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 98a282b to 12a0313 Compare August 4, 2026 13:21
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Swept for more of the same, and answered the judgement/feedback question.

Q2 — judgement vs feedback: different axes, not synonyms

feedback is the stage — the pipeline method that returns (finalize, payload): it decides finalize-vs-iterate and emits the verdict. judgement is the content of that payload — the verdict itself.

The records are named by content, not by stage, and that's systematic:

stage (method / *_result field) record type (content)
preprocess / preprocess_result PromptRecord
infer / infer_result ModelOutput
postprocess / postprocess_result PredictionRecord
feedback / feedback_result JudgementRecord

So feedback → JudgementRecord is the same choice as postprocess → PredictionRecord and preprocess → PromptRecord: the stage name describes the pipeline step, the record name describes the data. "PromptRecord" reads better than "PreprocessRecord" on a shard line, and "JudgementRecord" better than "FeedbackRecord" — a reader sees what it holds, not which method emitted it. A JudgementRecord also nests a grade for LLM tasks, so judgement (the verdict record) genuinely sits a layer above grade (the LLM output) and feedback (the stage).

Q1 — one real inconsistency, plus wording nits. Fixed, folded into the protocol commit.

Real, with teeth: is_prediction_record and is_judgement_record had byte-identical bodies ("rollouts" in value), so is_judgement_record(a_prediction) returned True — the names promised a discrimination the code didn't do. And is_judgement_record had no production caller (only is_prediction_record is used, in anomaly.py), so the bug was latent. Fixed to key on n_rollouts, which build_judgement_record always materializes (even at zero rollouts) and a prediction never has:

  • is_judgement_record(v)"n_rollouts" in v
  • is_prediction_record(v)"rollouts" in v and "n_rollouts" not in v

Now mutually exclusive. Added test_sniffs_discriminate_prediction_from_judgement (fails on the old identical bodies) and extended the empty-record test to assert an empty judgement isn't mistaken for a prediction. No behavior change at the one real call site — anomaly.py only ever passes postprocess_result, always a prediction.

Wording, same judge/grade axis as before: RolloutJudgement.extra / JudgementRecord.extra docstrings said "grader-specific detail" / "sample-level grader detail" — but these types are mechanism-agnostic, and a string-compare (mmlu) or math-verify (aime) verdict has no grader. Reworded to "verdict-mechanism-specific" and named the mechanisms (LLM grader / code runner / constraint checker).

Checked and left as-is: the builder asymmetry — build_rollout_judgement exists but there's no build_rollout_prediction — is intentional, not a slip. Prediction rollout items are trivial (wrap a value, derive extracted), so build_prediction_record([...]) builds them internally; judgement items carry caller-decided correct/score/extra, so they get an item builder. Different data, different ergonomics.

2793 tests pass; lint/type clean. Force-pushed; CI to follow.

@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 12a0313 to d6f5fb8 Compare August 4, 2026 13:29
@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

Added the vocabulary to sieval/tasks/CLAUDE.md — a new Stage-Output Protocol (opt-in) section, folded into the protocol commit.

Placement rationale: it's the task-author guide, which is who migrates the next task and who writes grade/grader/extra. Kept to the doc-layering already in use — records.py docstrings stay the authoritative type/schema source, the CLAUDE.md section carries only the vocabulary + conventions and points to records.py. It captures the decisions this thread settled that no single code file makes obvious:

  • Stage → record map, and the "records named by content, not stage" choice (feedback → JudgementRecord, not FeedbackRecord).
  • The layered vocabulary: judgement (verdict record, mechanism-agnostic) vs grade (LLM categorical output, contained by a judgement) vs grader (LLM actor) — plus a one-liner that judge is HLE-only upstream terminology not to be introduced in new tasks.
  • Conventions: build_* constructors (matching build_model_call_meta), is_* sniffs, records returned bare, and grader ModelOutput persisted whole via obj_to_dict(out, add_type=False).

No enforcer added — this is descriptive convention (like the existing ppl/clp explanation in the same file), not a checkable rule, so per the engineering-infra coherence rule there's no preflight/pre-commit/CONTRIBUTING coupling to wire. markdownlint passes.

Three commits still; force-pushed.

ethan-scitix and others added 7 commits August 5, 2026 00:07
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>
@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 7c63645 to c59ff5b Compare August 4, 2026 16:11
ethan-scitix and others added 2 commits August 5, 2026 00:23
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>
@ethan-scitix
ethan-scitix force-pushed the feat/stage-output-protocol branch from 7770902 to c9c8e2b Compare August 4, 2026 17:17
@ethan-scitix
ethan-scitix merged commit d622448 into main Aug 4, 2026
18 checks passed
@ethan-scitix
ethan-scitix deleted the feat/stage-output-protocol branch August 4, 2026 17:26
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
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.
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
`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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
…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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
#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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
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.
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
`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>
ethan-scitix added a commit that referenced this pull request Aug 5, 2026
…/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>
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