Skip to content

feat(tasks): add MultiPL-E — HumanEval + MBPP in 24 languages - #138

Merged
ethan-scitix merged 6 commits into
mainfrom
feat/multipl-e
Sep 10, 2026
Merged

feat(tasks): add MultiPL-E — HumanEval + MBPP in 24 languages#138
ethan-scitix merged 6 commits into
mainfrom
feat/multipl-e

Conversation

@ethan-scitix

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

Summary

  • MultiPL-E (paper · repo · data) — HumanEval and MBPP machine-translated into other programming languages. Four tasks from two independent choices: suite (humaneval 24 languages / mbpp 23 — upstream translated HumanEval to Dart and MBPP not) × protocol (completion / chat). Fills the code gap docs/benchmark-coverage/coverage.yaml:268 records as reported by Kimi K2.6 and MiMo-V2.5-Pro.
  • Language selection is a dataset arg (languages), MMMLU-style, using upstream's registry tags, not English names (jl/ml/rkt/adb) — those tags are the HF config names, so a wrong one is an unresolvable config rather than a language that quietly scores badly.
  • The chat protocol grades the model's own copy of the prefix and discards the dataset prompt. Upstream blanks it deliberately, because models paraphrase docstrings and failing a program over that would be a spurious error. Prepending the dataset prompt instead defines the function twice and fails in all four validated languages — a naive port scores ~0 across the benchmark, silently. That is why the two protocols are separate tasks and not a flag.
  • vendor/code-evaluator gains a declarative language table (cpp/bash/perl as {ext, build argv, run argv, budgets} rows behind one executor) plus GET /languages, probed at task setup before any inference. Also fixes a latent 500 on that exact path.
  • Both tasks ship status="experimental": no published-score alignment run has been made (see Manual below).

Related Issues

None. Refs the code coverage gap in docs/benchmark-coverage/coverage.yaml.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check)
  • Type check clean (ty check)
  • Unit tests pass — 6780 passed, of which 61 are new (tests/unit/tasks/multipl_e/, tests/unit/datasets/test__multipl_e.py)
  • scripts/check_preflight.py — all checks pass, including check_meta_index_sync, check_report_declarations, check_reference_kind, check_datasets (source pinned to nuprl/MultiPL-E@28441b60)

Manual

Executed end-to-end against a live code-eval service on real MultiPL-E rows, for the four languages whose toolchains were available (cpp, js, sh, pl). MultiPL-E ships no reference solutions, so correct and wrong completions were hand-written per language.

  • Completion protocol — correct completions pass and wrong ones fail in all four languages; pass@1 50.0 per language at one-correct-of-two rollouts, pooled 50.0, macro 50.0.
  • Chat protocol — whole-program fenced replies pass in all four (100.0), exercising the fence strip, the len(prompt) split and the blank-prompt assembly.
  • The trap, measured — prepending the dataset prompt to a whole-program reply fails in all four (build exit 1 for cpp, exit 1 js, exit 2 sh, exit 255 pl).
  • Evaluator layer — pass / assertion-fail / build-fail / timeout distinguished for cpp; pass/fail for bash; pass / exit 1 / ERROR-on-stdout for perl; js unchanged; unsupported lang returns its own message instead of a 500.
  • Bounds proven, not assumed — memory cap binds (1 GiB allocation refused at memory_limit=256, allowed uncapped, trivial program unaffected); cpp infinite loop and bash sleep 30 both stopped by the wall.
  • Report contractinterval_declaration_problems() (what the runner applies at report-write time) returns clean at n=1/2/4, on uniform verdicts, with a pipeline fail present, and on the empty path.

No score comparison table, deliberately. Producing one needs a model run at upstream's protocol (20 completions at temperature 0.2), which this change does not include — hence experimental rather than stable, and the UNMEASURED: clause in both reference_impl.notes. The per-language pass@1_<tag> keys are the paper-comparable ones; the headline is a size-weighted pool over whatever languages ran, with pass@1_macro beside it, because upstream publishes a table and no single number.

sieval dataset download multipl_e_humaneval was not exercised; the loader was driven directly against the Hub (641 rows over 4 languages, per-language counts 161/161/161/158 — they differ by language, which is why per-language denominators are reported).

Checklist

Required (all PRs)

  • PR title follows conventional format
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - Claude Opus 5 (1M context) (Anthropic) in module docstring
  • No new upper-layer dependencies added to core/core/ untouched
  • Deleted code verified — nothing deleted; the three hand-rolled exec_* modules are untouched, so no existing task's grading moves

If: New or Modified Benchmark

  • Reference paper/repo linked in Summary
  • Score comparison table included — not applicable: no alignment run, task ships experimental. See Manual.
  • Dataset loading tested via sieval dataset download — loader driven directly against the Hub instead; the hf: source is pinned and check_datasets passes.
  • Task registered — sieval/tasks/multipl_e/ with an empty __init__.py; all four resolve by name through get_task_class() (asserted in test_multipl_e_family.py, since a nested task that imports fine but is unresolvable shows up only as a bare KeyError from sieval task show)

If: community/ Changes

sieval/community/ is untouched. The equivalent applies to the vendor/code-evaluator patch:

  • Upstream diff documented — two new VENDORED.md entries covering the language table, the endpoint, the perl output rule, the reduced failure taxonomy, the ulimit-over-preexec_fn choice, and the 500 fix
  • License attribution preserved — no upstream file copied. MultiPL-E's harness is BSD-3-Clause with a Machine Learning Restriction (clause 4: contents may not be used as ML training data; gh api .../license reports NOASSERTION, not a missing license), which is more restrictive than this repo's Apache-2.0. Its per-language graders are ~30 lines each of build command, run command and status classification, so the commands were reimplemented in the table with upstream cited per row. The dataset is plain MIT.

If: Breaking Change

Not a breaking change. Purely additive: 4 tasks, 2 datasets, 3 new evaluator languages, 1 new endpoint. CODE_EXECUTOR_MAP moved from per-request construction to module scope with its original three rows intact.

If: New Dependency

None. No pyproject.toml change.


Reviewer note — three PRs now add multi-language execution to the same file

Worth reconciling rather than merging blind. vendor/code-evaluator/app/server.py is touched by three open branches, each with a different mechanism:

PR Mechanism Shape
#121 LiveOIBench bespoke module exec_cpp.py, own source="liveoibench" branch, compile-once-run-many over official case files
#123 Ag-LiveCodeBench-X per-language published containers exec_agnostics.py, podman run ghcr.io/nuprl/agnostics@<digest>
this PR toolchains in the image + declarative table exec_lang.py, extends CODE_EXECUTOR_MAP for the direct-run sources

No semantic clash: #121's cpp lives under its own source and never enters CODE_EXECUTOR_MAP, and #123 adds its own source too. The conflicts are textual — the imports, the CODE_EXECUTOR_MAP region, and VENDORED.md — so whoever merges second rebases.

The design question is worth asking now rather than at 20 languages: for MultiPL-E's remaining languages, delegating to a container may beat apt-installing toolchains. Upstream MultiPL-E publishes its own evaluation/Dockerfile, and #123 has already built the container-delegation plumbing (digest pinning, command-as-deployment-config, lang pattern validation). This PR's table is the right shape for the four languages it proves, and each further language is currently a row plus an apt package — but if #123 lands first, the cheaper path to the other 20 is probably its mechanism with a MultiPL-E image, not 20 more apt lines. Happy to rework in that direction.

One thing that generalises out of this and is worth applying to both siblings: a language's test-harness dependencies matter as much as its compiler. Every MultiPL-E perl test opens with use Test::Deep, so without libtest-deep-perl the language scores a clean 0 with no error anywhere — upstream's own image installs it, and its Dockerfile also pins lua-unit and a javatuples jar for lua and java. Adding a language means reading its test template, not just installing its toolchain.

🤖 Generated with Claude Code

ethan-scitix added a commit that referenced this pull request Sep 10, 2026
… half held

Six things from review of #138, none in the grading path.

* The chat prompt sent TWO CONSECUTIVE `user` messages. Every chat template
  that enforces strict alternation rejects that outright -- Mistral's raises
  "After the optional system message, conversation roles must alternate
  user/assistant/..." -- and nothing between the task and the server merges
  same-role messages (`normalize_chat_input` passes them through as written), so
  the split reached the API. Upstream does not have this shape either: DSPy
  appends a signature's docstring as a `system` message, so the instruction
  belongs there. One role, and it is simultaneously the portable choice and the
  faithful one. Roles are now asserted on, not just the joined text -- the text
  is identical either way, which is why the old test could not see it.

* Upstream's OUTPUT field description ("The complete program including the full
  prefix") was dropped with the DSPy scaffolding, while the notes claimed "field
  descriptions" were carried. It restates the prefix-repetition requirement the
  blank-prompt grading path depends on, so it is carried and the claim narrowed
  to say which descriptions and why.

* `GET /languages` answered for the SOURCE TABLE, so the setup probe only moved
  its own failure one step: from "language not in the table" to "row present,
  toolchain absent". Measured, the second reads as `failed: [FileNotFoundError]`
  or `exit 127` per sample -- bucketed as `n_execution_errors`, i.e. blamed on
  the model's program -- which is the run of zeros the probe exists to prevent,
  reachable by deploying any of the other Dockerfiles with this server. Rows are
  now gated on `toolchain_present`, and `POST /evaluations` applies the same
  test so the two endpoints agree and the message names the missing command. It
  is a PATH lookup, not an invocation: 0.072 ms for the whole table, so nothing
  is compiled to answer a probe. It proves the entry point exists, not that the
  toolchain works -- `perl` is present here while `Test::Deep` is not.

* The empty report omitted six keys the full path always writes. A schema that
  depends on whether any sample survived reads, to anyone diffing two runs, as a
  measurement gone missing rather than an empty one. Zeroed, intervals still
  absent. Merged with `|` and NOT spread with `**` inside the literal: a `**`
  makes a key unnameable to `check_report_declarations`, which then stops
  verifying this report's `score_key` while staying PASS -- caught only because
  the count in its summary line moved 55 -> 53.

* `_advertised_languages` left `raise_for_status` bare, so a 5xx on the
  capability probe surfaced as `HTTPStatusError` with a URL and no reading --
  the one branch of that probe with no guidance beside 404 and a dead socket.

* `_spawn` slept 0.1s for a monitor it had not started, costing every compiled
  language that on each grade; and `ulimit -v` is documented as not
  generalising to a managed runtime, since a JVM or Go row reserves virtual
  space far past what it commits and would fail to start under any useful cap.
  Memory is a third thing a new language needs decided, beside its toolchain and
  its test harness's dependencies.

Vendor changes carry no tests under `tests/` on purpose (VENDORED.md: they
belong in `scitix/code-evaluator`); the toolchain gate was verified by probe --
an injected row with an absent command is withheld from `/languages`, refused by
`/evaluations` naming that command, and a present row still executes.

6786 tests pass; ruff, ty and the full preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix and others added 6 commits September 10, 2026 23:56
Four tasks over two new datasets, from two independent choices: suite
(`humaneval` / `mbpp`) and protocol (completion / chat). A problem is a
partial program; the model continues it; the graded program is compiled
and run against the suite's assertions, with the exit code as the
verdict.

Language selection is a dataset arg, MMMLU-style, using upstream's
registry tags rather than English names (`jl`/`ml`/`rkt`/`adb`) — those
tags are the HuggingFace config names, so a wrong one is an unresolvable
config instead of a language that scores badly. MBPP has 23 languages,
not 24: upstream translated HumanEval to Dart and MBPP not.

Two upstream details a port cannot guess:

- Program assembly is `prompt + completion + "\n" + tests`, separator
  included (`evaluation/src/main.py`).
- The chat protocol grades the MODEL's copy of the prefix and discards
  the dataset prompt. Upstream blanks it deliberately, because models
  paraphrase docstrings and failing a program over that would be a
  spurious error. Verified the trap: prepending the dataset prompt to a
  whole-program reply defines the function twice and fails in all four
  validated languages, so a naive port scores ~0 across the benchmark.
  That is why the protocols are separate tasks rather than a flag.

`report.json` publishes per-language `pass@1_<tag>` + `n_problems_<tag>`
(upstream publishes a table and no single number, so these are the
paper-comparable columns), a size-weighted pooled headline,
`pass@1_macro`, `n_languages`, and the build-vs-run failure split. Both
ship `experimental`: no published-score alignment run has been made.

Evaluator side, in `vendor/code-evaluator` (a local patch — land in
`scitix/code-evaluator` and re-vendor):

- `app/exec_lang.py` adds `cpp` / `bash` / `perl` as rows in one
  declarative table behind a single executor, rather than a fourth,
  fifth and sixth copy of the write-file/spawn/decode sequence the three
  hand-rolled modules each carry. Those three are untouched, so no
  existing task's grading moves. Commands follow upstream's
  `eval_<lang>.py`, including perl's rule that an otherwise-passing run
  fails on `ERROR` in its output.
- `GET /languages`, probed at task setup before any inference. Without
  it an unsupported language returns a clean failed verdict per sample,
  so the run completes, burns a full generation budget, and reports
  `pass@1 = 0` with no errors — indistinguishable from a model that
  cannot write Racket.
- `docker/Dockerfile.multipl-e` installs `libtest-deep-perl`: every
  MultiPL-E perl test opens with `use Test::Deep`, so without it the
  language scores a clean 0. A language's test-harness dependencies
  matter as much as its compiler.
- Fixes a latent 500: for an unsupported `lang` the direct-run branch
  never bound `timeout`, which the log line below it reads
  unconditionally, so the request died with `UnboundLocalError` instead
  of returning its own message. That is the path every language whose
  toolchain is not deployed takes — 20 of 24 today.

Validated end-to-end on real MultiPL-E rows for cpp / js / sh / pl (the
toolchains available locally): hand-written correct and wrong
completions grade as pass and fail in each, under both protocols, with
per-language rates and interval declarations checked against
`interval_declaration_problems`. The memory cap is verified binding
rather than assumed — a 1 GiB allocation is refused at
`memory_limit=256` and succeeds uncapped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`_spawn` killed the direct child only. Anything that child forked inherited
this process's stdout and stderr, so the `proc.wait()` after the kill blocked
on the orphan holding those pipes rather than on the budget. The verdict was
still `failed: timeout`, but the call returned whenever the orphan chose to.

Measured before the fix: `bash` running a program that forks a 30s sleeper
returned after 30.1s against a 3s budget, and the sleeper survived the kill.
After it: 3.1s, and no survivor. The two already-bounded shapes are unchanged
(no-fork busy loop 3.10s; forks-with-exiting-children 0.10s, still ok).

The fix is upstream MultiPL-E's own -- `safe_subprocess` spawns with
`start_new_session=True` and SIGKILLs the group, commented "Without this line,
test_fork_once fails". The reap is bounded by `KILL_GRACE_SECONDS` so a process
wedged in uninterruptible sleep cannot re-introduce the unbounded wait the kill
exists to remove.

`exec_js` / `exec_ts` have the same shape and are deliberately untouched: they
are pre-existing and out of this change's scope.

Also documents, in `VENDORED.md`, `README.md` and the `LANGUAGES` comment, that
the per-step wall clocks diverge from upstream in BOTH directions -- upstream
gives every `safe_subprocess.run` call a flat 15s, the build included, while an
interpreted row here gets 3s and a build gets 60s. The old comment claimed the
commands make a program that passes here one that passes there, without the
budget caveat.

Co-Authored-By: Claude Opus 5 <[email protected]>
…e_kind

The base tier walked exactly one level. `multipl_e` sits two: leaf ->
per-protocol base -> shared `feedback`, so the check found no judgement record
for any of its four leaves and reported them `unverified`. That is a WARN
meaning "could not read this one" -- which is exactly what it says when the
declaration underneath is also wrong, so the guard went quiet on the first
family to nest twice while the run still ended green.

Before: `[WARN] check_reference_kind -- 4 task(s) build no judgement record
this check can reach` alongside `[PASS] ... all 70 task(s)`.
After: `[PASS] check_reference_kind -- all 74 task(s) ...`, no WARN.

Termination is a visited set keyed on (module, class name), not a depth cap: a
cap is a second number to get wrong the next time a family nests deeper, which
is this bug. The helper tier stays one hop -- a helper calling a helper is a
refactor away from being inlined, but a class hierarchy is a shape a benchmark
family picks deliberately.

`_resolve_base_class` looks in the class's OWN module before an import: a
shared base and the subclasses parameterizing it commonly live in one
`_base.py` (`multipl_e`, `arc`), where there is no `ImportFrom` to resolve.

The mirror test is reverse-mutation checked: pinning the walk back to depth 1
fails `test_a_base_chain_is_followed_past_its_first_level` and nothing else.

Co-Authored-By: Claude Opus 5 <[email protected]>
…surface

Four separate things, all in the same family:

* `_failure_buckets` matched the evaluator's message by SUBSTRING. The message
  tail carries the compiler's own output verbatim, so a build failing on an
  identifier named `timeout` (`error: no member named 'timeout'`) landed under
  `timeouts` -- the one bucket that says the model's program ran and was slow.
  Now matched on the prefix, against the evaluator's documented vocabulary. A
  build that exceeds its own wall is a BUILD failure: the run never started,
  and build-versus-run is the split these three keys exist to carry.

* `_advertised_languages` derived the probe URL with a bare `rsplit("/", 1)`.
  A `code_eval_api` with a trailing slash dropped the empty last segment
  instead of `evaluations`, probing `.../evaluations/languages` -- a 404,
  reported as "this deployment predates table-driven languages" when the
  deployment is fine. `rstrip("/")` first.

* An EMPTY `languages` list read as "all", which is 24 languages of inference
  apart from what the caller meant. An empty list reaches the loader from a
  config that computed a selection and came up with nothing far more often than
  from an author spelling "everything" as `[]` -- which omitting the argument
  already says. Refused, naming both readings.

* `EVALUATOR_LANG_BY_TAG`'s comment claimed upstream's `containerized_eval`
  accepts both the tag and the English spelling, so "neither side is being bent
  to fit the other". Nothing here is passed to `containerized_eval` at all, and
  its `EVALUATORS` keys are the tags. The right column is this service's
  vocabulary and only ever this service's.

`_SHARED_NOTES` gains divergence (4): the per-step wall clocks are the
service's, not upstream's -- upstream gives every `safe_subprocess.run` call a
flat 15s including the build, while an interpreted row here gets 3s and a c++
build 60s. Both directions are reachable. `meta/index.json` regenerated.

Co-Authored-By: Claude Opus 5 <[email protected]>
… half held

Six things from review of #138, none in the grading path.

* The chat prompt sent TWO CONSECUTIVE `user` messages. Every chat template
  that enforces strict alternation rejects that outright -- Mistral's raises
  "After the optional system message, conversation roles must alternate
  user/assistant/..." -- and nothing between the task and the server merges
  same-role messages (`normalize_chat_input` passes them through as written), so
  the split reached the API. Upstream does not have this shape either: DSPy
  appends a signature's docstring as a `system` message, so the instruction
  belongs there. One role, and it is simultaneously the portable choice and the
  faithful one. Roles are now asserted on, not just the joined text -- the text
  is identical either way, which is why the old test could not see it.

* Upstream's OUTPUT field description ("The complete program including the full
  prefix") was dropped with the DSPy scaffolding, while the notes claimed "field
  descriptions" were carried. It restates the prefix-repetition requirement the
  blank-prompt grading path depends on, so it is carried and the claim narrowed
  to say which descriptions and why.

* `GET /languages` answered for the SOURCE TABLE, so the setup probe only moved
  its own failure one step: from "language not in the table" to "row present,
  toolchain absent". Measured, the second reads as `failed: [FileNotFoundError]`
  or `exit 127` per sample -- bucketed as `n_execution_errors`, i.e. blamed on
  the model's program -- which is the run of zeros the probe exists to prevent,
  reachable by deploying any of the other Dockerfiles with this server. Rows are
  now gated on `toolchain_present`, and `POST /evaluations` applies the same
  test so the two endpoints agree and the message names the missing command. It
  is a PATH lookup, not an invocation: 0.072 ms for the whole table, so nothing
  is compiled to answer a probe. It proves the entry point exists, not that the
  toolchain works -- `perl` is present here while `Test::Deep` is not.

* The empty report omitted six keys the full path always writes. A schema that
  depends on whether any sample survived reads, to anyone diffing two runs, as a
  measurement gone missing rather than an empty one. Zeroed, intervals still
  absent. Merged with `|` and NOT spread with `**` inside the literal: a `**`
  makes a key unnameable to `check_report_declarations`, which then stops
  verifying this report's `score_key` while staying PASS -- caught only because
  the count in its summary line moved 55 -> 53.

* `_advertised_languages` left `raise_for_status` bare, so a 5xx on the
  capability probe surfaced as `HTTPStatusError` with a URL and no reading --
  the one branch of that probe with no guidance beside 404 and a dead socket.

* `_spawn` slept 0.1s for a monitor it had not started, costing every compiled
  language that on each grade; and `ulimit -v` is documented as not
  generalising to a managed runtime, since a JVM or Go row reserves virtual
  space far past what it commits and would fail to start under any useful cap.
  Memory is a third thing a new language needs decided, beside its toolchain and
  its test harness's dependencies.

Vendor changes carry no tests under `tests/` on purpose (VENDORED.md: they
belong in `scitix/code-evaluator`); the toolchain gate was verified by probe --
an injected row with an absent command is withheld from `/languages`, refused by
`/evaluations` naming that command, and a present row still executes.

6786 tests pass; ruff, ty and the full preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Comments and docs only -- no behaviour change, and every fact kept. The previous
commit explained several points twice and hedged where the measurement already
spoke; -35 lines net across the seven files it touched.

Cut, specifically: the second telling of why the chat instruction is a `system`
message (the docstring and `reference_impl.notes` each carried the full Mistral
reasoning -- the notes keep it, the docstring points at it), the restatement of
"offered vs present" in three places (`/languages`, README, VENDORED), and the
prose around `**` vs `|` in the empty report, which now names the consequence
and stops.

Kept verbatim where the number IS the argument: 0.1 ms for the PATH sweep, the
build-vs-run entry-command rule, and why an address-space cap does not suit a
managed runtime.

`reference_impl.notes` changed, so `meta/index.json` is regenerated with it.
6786 tests pass; ruff, ty and preflight clean, with `check_report_declarations`
still resolving 55 score_keys (the count the `**`-spread regression moved).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@ethan-scitix
ethan-scitix merged commit cc4dc92 into main Sep 10, 2026
9 checks passed
ethan-scitix added a commit that referenced this pull request Sep 10, 2026
Review follow-up. The prefix set was missing one spelling that the substring
test it replaced had caught by accident, so the fix came out stricter than
intended in exactly one place.

`failed: [CaseTimeout]` is the per-case wall arriving late. `_unsafe_execute`
catches `CaseTimeout` per case, but a signal the kernel has already delivered
cannot be un-delivered: an alarm firing as a case ends is raised at the next
bytecode check, which can land past that `except`. The worker's outer handler
then formats it through the generic class-name branch, so a real wall reads
`failed: [CaseTimeout] ` instead of `failed: case timeout: 6.0s`. The vendored
service names `CaseTimeout` in that handler for precisely this reason.

Reachable only where a per-case budget is armed, which is the two LiveCodeBench
tasks (both default `timeout_per_case=6.0`); HumanEval, MBPP and SciCode append
the test to the program and take the `exec_py_code` path. Still diagnostic only.
The name cannot come from submitted code -- it is a service-internal
`BaseException` -- so the added prefix carries no false-positive risk.

Also from review:

- `exec_lang` and `failed: build timeout` are marked forward-looking. Neither
  is in the vendored service at this commit; both arrive with #138, and the
  js/ts entry two lines up already carried that caveat.
- scicode's comment claimed its `TimeoutError` test was "matched the way
  `memoryerror` below is". It is not -- the new one anchors on the brackets
  that `failed: [{type}] ...` always supplies, while the neighbours match bare.
  The comment now says so. The neighbours are deliberately left alone:
  bracketing them would narrow `memory_errors` and `import_errors` without a
  measurement, which is the same scope line this change already draws.
- The convention survey's `zip(..., strict=False)` now says why it is not
  strict: `n` ops against `n + 1` operands, so `strict=True` would raise on
  every chained compare in the tree.

Prose trimmed throughout. The three-line comment repeated verbatim at five
identical call sites is gone -- the helper's name and its module carry it --
and the module and test docstrings say the same things in fewer words.

Reverse-mutation: dropping the new prefix fails two tests, one of them the
pairwise case asserting that `[CaseTimeout]` and `[TimeoutError]` split
opposite ways. 6798 tests pass; ruff, ty, both sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix added a commit that referenced this pull request Sep 10, 2026
Six tasks decided `timeouts` with `"timeout" in msg.lower()`. The code-eval
service documents a small closed set of message shapes, but every one of them
interpolates the failing program's own output after the colon -- so the
substring test also charged these to the clock:

    failed: [TimeoutError] deadline exceeded     the program raised
    failed: [ValueError] timeout must be positive
    failed: output ['timeout'] != expect ['ok']  LiveCodeBench, wrong answer
    failed [build exit 1]: error: no member named 'timeout' in 'Config'

`timeouts` is the one bucket that says the program RAN and was merely slow, so a
misfiled failure sends a reader after efficiency instead of the exception or the
wrong answer in front of them.

Diagnostic only, and that is why it survived six copies: `correct` comes from
the service's boolean, never from this string, so no headline moves and no
metric test could have failed.

The vocabulary now has one home (`_code_eval_msg.py`) because it is one contract
with six readers and it belongs to the service, not to any benchmark -- the same
reason `_sqlite_exec.py` sits there. It is the union across executors, since the
spellings differ by which one answered: `failed: subprocess timeout:`
(`exec_py_code`, `exec_py_test`), `failed: case timeout:` / `compile timeout:`
(LiveCodeBench's per-case budgets), `failed: timeout` (js/ts), `failed: build
timeout` (compiled rows). Each is emitted only by a timeout path, so the union
has no false positives.

`scicode` keeps BOTH readings, deliberately: its `timeouts` sits beside
`memory_errors` and `import_errors`, which read exception CLASS NAMES out of the
message tail on purpose, so a raised `TimeoutError` is in scope there. Only the
word-appearing-anywhere-else case is removed. Narrowing that counter to the wall
alone would redefine someone else's metric, which a bug fix does not get to do.

Two existing fixtures asserted `timeouts == 1` from `"timeout"` and `"Timeout
exceeded"` -- strings `exec_py_code` cannot emit. They would have passed against
any implementation; both now carry the service's real wording, plus a case
proving a raised `TimeoutError` is not counted.

Re-drift is what the survey is for (`test_timeout_bucketing_convention.py`),
built like `test_grading_call_site_convention.py`: it reads the AST rather than a
hand-kept list, forbids the BARE literal only, and counts CALL nodes rather than
occurrences of the name -- an unused import satisfied the first version of that
assertion, which reverse-mutation caught. All three mutants (predicate reverted,
one call site reverted, every reader removed) now fail the suite.

Follow-up to #138, which fixed this in its own `_failure_buckets` and where the
prefixes were first enumerated. When that lands, its bucketing should adopt this
module; the branches touch no common file, so they merge in either order.

6796 tests pass; ruff, ty, both sync checks and all 24 preflight checks clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix added a commit that referenced this pull request Sep 10, 2026
Review follow-up. The prefix set was missing one spelling that the substring
test it replaced had caught by accident, so the fix came out stricter than
intended in exactly one place.

`failed: [CaseTimeout]` is the per-case wall arriving late. `_unsafe_execute`
catches `CaseTimeout` per case, but a signal the kernel has already delivered
cannot be un-delivered: an alarm firing as a case ends is raised at the next
bytecode check, which can land past that `except`. The worker's outer handler
then formats it through the generic class-name branch, so a real wall reads
`failed: [CaseTimeout] ` instead of `failed: case timeout: 6.0s`. The vendored
service names `CaseTimeout` in that handler for precisely this reason.

Reachable only where a per-case budget is armed, which is the two LiveCodeBench
tasks (both default `timeout_per_case=6.0`); HumanEval, MBPP and SciCode append
the test to the program and take the `exec_py_code` path. Still diagnostic only.
The name cannot come from submitted code -- it is a service-internal
`BaseException` -- so the added prefix carries no false-positive risk.

Also from review:

- `exec_lang` and `failed: build timeout` are marked forward-looking. Neither
  is in the vendored service at this commit; both arrive with #138, and the
  js/ts entry two lines up already carried that caveat.
- scicode's comment claimed its `TimeoutError` test was "matched the way
  `memoryerror` below is". It is not -- the new one anchors on the brackets
  that `failed: [{type}] ...` always supplies, while the neighbours match bare.
  The comment now says so. The neighbours are deliberately left alone:
  bracketing them would narrow `memory_errors` and `import_errors` without a
  measurement, which is the same scope line this change already draws.
- The convention survey's `zip(..., strict=False)` now says why it is not
  strict: `n` ops against `n + 1` operands, so `strict=True` would raise on
  every chained compare in the tree.

Prose trimmed throughout. The three-line comment repeated verbatim at five
identical call sites is gone -- the helper's name and its module carry it --
and the module and test docstrings say the same things in fewer words.

Reverse-mutation: dropping the new prefix fails two tests, one of them the
pairwise case asserting that `[CaseTimeout]` and `[TimeoutError]` split
opposite ways. 6798 tests pass; ruff, ty, both sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix added a commit that referenced this pull request Sep 10, 2026
#138 landed while this branch was open, which makes two of the module's
statements false and one of them dangerous.

Stale: `exec_lang` and `failed: build timeout` are in the tree now rather than
forward-looking, and "no in-tree task sends a non-default `lang`" stopped being
true the moment MultiPL-E arrived with 24 of them.

Dangerous: MultiPL-E routes `failed: build timeout` to `n_build_errors`, NOT to
`timeouts`, and argues for it -- the run never started, and build-versus-run is
the split its three keys exist to carry. `is_timeout_message` answers "did the
service stop the clock", which for a build wall is yes. Those are different
questions, and this branch's own note that MultiPL-E "should adopt this module"
reads as an instruction to collapse them.

So the module now says which of the two questions it answers, and points at the
counter that deliberately disagrees. No behaviour change, and no new test:
MultiPL-E already asserts `n_build_errors == 2, timeouts == 0` on that exact
message in tests/unit/tasks/multipl_e/test__base.py, so a naive adoption fails
there rather than silently moving a bucket.

Rebased onto main (#141, #138) in the same push. 7123 tests pass -- the jump
from 6812 is MultiPL-E's own suite arriving, not new tests here; ruff, ty, both
sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix added a commit that referenced this pull request Sep 11, 2026
…142)

* fix(tasks): read a timeout off the message prefix, not by substring

Six tasks decided `timeouts` with `"timeout" in msg.lower()`. The code-eval
service documents a small closed set of message shapes, but every one of them
interpolates the failing program's own output after the colon -- so the
substring test also charged these to the clock:

    failed: [TimeoutError] deadline exceeded     the program raised
    failed: [ValueError] timeout must be positive
    failed: output ['timeout'] != expect ['ok']  LiveCodeBench, wrong answer
    failed [build exit 1]: error: no member named 'timeout' in 'Config'

`timeouts` is the one bucket that says the program RAN and was merely slow, so a
misfiled failure sends a reader after efficiency instead of the exception or the
wrong answer in front of them.

Diagnostic only, and that is why it survived six copies: `correct` comes from
the service's boolean, never from this string, so no headline moves and no
metric test could have failed.

The vocabulary now has one home (`_code_eval_msg.py`) because it is one contract
with six readers and it belongs to the service, not to any benchmark -- the same
reason `_sqlite_exec.py` sits there. It is the union across executors, since the
spellings differ by which one answered: `failed: subprocess timeout:`
(`exec_py_code`, `exec_py_test`), `failed: case timeout:` / `compile timeout:`
(LiveCodeBench's per-case budgets), `failed: timeout` (js/ts), `failed: build
timeout` (compiled rows). Each is emitted only by a timeout path, so the union
has no false positives.

`scicode` keeps BOTH readings, deliberately: its `timeouts` sits beside
`memory_errors` and `import_errors`, which read exception CLASS NAMES out of the
message tail on purpose, so a raised `TimeoutError` is in scope there. Only the
word-appearing-anywhere-else case is removed. Narrowing that counter to the wall
alone would redefine someone else's metric, which a bug fix does not get to do.

Two existing fixtures asserted `timeouts == 1` from `"timeout"` and `"Timeout
exceeded"` -- strings `exec_py_code` cannot emit. They would have passed against
any implementation; both now carry the service's real wording, plus a case
proving a raised `TimeoutError` is not counted.

Re-drift is what the survey is for (`test_timeout_bucketing_convention.py`),
built like `test_grading_call_site_convention.py`: it reads the AST rather than a
hand-kept list, forbids the BARE literal only, and counts CALL nodes rather than
occurrences of the name -- an unused import satisfied the first version of that
assertion, which reverse-mutation caught. All three mutants (predicate reverted,
one call site reverted, every reader removed) now fail the suite.

Follow-up to #138, which fixed this in its own `_failure_buckets` and where the
prefixes were first enumerated. When that lands, its bucketing should adopt this
module; the branches touch no common file, so they merge in either order.

6796 tests pass; ruff, ty, both sync checks and all 24 preflight checks clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tasks): count a late CaseTimeout, and trim the bucketing prose

Review follow-up. The prefix set was missing one spelling that the substring
test it replaced had caught by accident, so the fix came out stricter than
intended in exactly one place.

`failed: [CaseTimeout]` is the per-case wall arriving late. `_unsafe_execute`
catches `CaseTimeout` per case, but a signal the kernel has already delivered
cannot be un-delivered: an alarm firing as a case ends is raised at the next
bytecode check, which can land past that `except`. The worker's outer handler
then formats it through the generic class-name branch, so a real wall reads
`failed: [CaseTimeout] ` instead of `failed: case timeout: 6.0s`. The vendored
service names `CaseTimeout` in that handler for precisely this reason.

Reachable only where a per-case budget is armed, which is the two LiveCodeBench
tasks (both default `timeout_per_case=6.0`); HumanEval, MBPP and SciCode append
the test to the program and take the `exec_py_code` path. Still diagnostic only.
The name cannot come from submitted code -- it is a service-internal
`BaseException` -- so the added prefix carries no false-positive risk.

Also from review:

- `exec_lang` and `failed: build timeout` are marked forward-looking. Neither
  is in the vendored service at this commit; both arrive with #138, and the
  js/ts entry two lines up already carried that caveat.
- scicode's comment claimed its `TimeoutError` test was "matched the way
  `memoryerror` below is". It is not -- the new one anchors on the brackets
  that `failed: [{type}] ...` always supplies, while the neighbours match bare.
  The comment now says so. The neighbours are deliberately left alone:
  bracketing them would narrow `memory_errors` and `import_errors` without a
  measurement, which is the same scope line this change already draws.
- The convention survey's `zip(..., strict=False)` now says why it is not
  strict: `n` ops against `n + 1` operands, so `strict=True` would raise on
  every chained compare in the tree.

Prose trimmed throughout. The three-line comment repeated verbatim at five
identical call sites is gone -- the helper's name and its module carry it --
and the module and test docstrings say the same things in fewer words.

Reverse-mutation: dropping the new prefix fails two tests, one of them the
pairwise case asserting that `[CaseTimeout]` and `[TimeoutError]` split
opposite ways. 6798 tests pass; ruff, ty, both sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tasks): read scicode's exception counters off the class slot

Follow-up to the review thread on `memory_errors` / `import_errors`, left
matching a bare `"memoryerror" in msg` beside the timeout fix. Same defect as
the timeout one: the tail of `failed: [{type}] {e}` is the program's own
output, so `[ValueError] simulated memoryerror path` counted as an OOM.

Bracketing them was the obvious fix and is wrong. The service reports the
CONCRETE class, so a subclass is what actually arrives, and a test for
`[memoryerror]` drops it. Surveyed every exception class reachable from the
SciCode execution image's stack (docker/Dockerfile.scicode -- numpy 1.26.4,
scipy, sympy, matplotlib; no torch, no pyarrow):

    memoryerror          builtins.MemoryError, numpy's (below)    no loss
    modulenotfounderror  builtins.ModuleNotFoundError             no loss
    importerror          builtins.ImportError, zipimport.ZipImportError

`ZipImportError` is an `ImportError` that a bracketed test would silently stop
counting. numpy is safe for a non-obvious reason worth recording: its
`_ArrayMemoryError` carries `@_display_as_base`, which assigns
`cls.__name__ = cls.__base__.__name__`, so the allocation failure most likely
in a numpy-heavy benchmark reports as a plain `MemoryError` -- checked at the
pinned 1.26.4, not only at the version installed locally.

So the slot is parsed rather than pattern-matched. `exception_class_name`
returns the class the service named, and each counter matches its family with
`endswith`. That drops false positives only: it never stops counting a class
the service actually named, which is what separates this from the metric
change I said would need its own measurement first. `timeouts` now reads the
same way, which makes the parity its comment used to claim untruthfully true.

14 new tests. Reverse-mutation, each direction caught by the test written for
it: reverting to the bare substring fails the tail case, narrowing the slot to
the builtin name fails the subclass case.

6812 tests pass; ruff, ty, both sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* docs(tasks): a clock stop is not the `timeouts` bucket

#138 landed while this branch was open, which makes two of the module's
statements false and one of them dangerous.

Stale: `exec_lang` and `failed: build timeout` are in the tree now rather than
forward-looking, and "no in-tree task sends a non-default `lang`" stopped being
true the moment MultiPL-E arrived with 24 of them.

Dangerous: MultiPL-E routes `failed: build timeout` to `n_build_errors`, NOT to
`timeouts`, and argues for it -- the run never started, and build-versus-run is
the split its three keys exist to carry. `is_timeout_message` answers "did the
service stop the clock", which for a build wall is yes. Those are different
questions, and this branch's own note that MultiPL-E "should adopt this module"
reads as an instruction to collapse them.

So the module now says which of the two questions it answers, and points at the
counter that deliberately disagrees. No behaviour change, and no new test:
MultiPL-E already asserts `n_build_errors == 2, timeouts == 0` on that exact
message in tests/unit/tasks/multipl_e/test__base.py, so a naive adoption fails
there rather than silently moving a bucket.

Rebased onto main (#141, #138) in the same push. 7123 tests pass -- the jump
from 6812 is MultiPL-E's own suite arriving, not new tests here; ruff, ty, both
sync checks and preflight clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tasks): correct the CaseTimeout claim, and share the vocabulary

Review follow-up, five findings.

`CaseTimeout` is forgeable. The module claimed submitted code could not
produce the name because the service's class is a `BaseException`. That is
why the service's own handler names it explicitly, but it says nothing about
what a program may call its own class: `class CaseTimeout(Exception)` raised
from a solution is caught by `_unsafe_execute_fn_call`'s `except Exception`
and wrapped into `failed: [CaseTimeout] ...`, which `is_timeout_message`
accepts. Checked against the service's own helper rather than argued. The
prefix stays -- dropping it loses a real wall, the substring test it replaced
counted the forgery too, and what the forgery costs is one diagnostic count --
but the note now says what is true.

The vocabulary is split into `CODE_EVAL_BUILD_TIMEOUT_PREFIXES` and
`CODE_EVAL_RUN_TIMEOUT_PREFIXES`, union unchanged, so MultiPL-E reads the two
groups instead of keeping its own copy of two of them. The fused tuple was
what blocked it: its three keys split build from run, which
`is_timeout_message` deliberately does not, so it could take the predicate
only by moving its build walls. No behaviour moves -- none of its 24
languages is Python, so every prefix it gains is unreachable there. Routing
them anyway is what keeps one home for the contract, and means a Python row
added later lands in `timeouts` rather than silently in `n_execution_errors`.

Also:

- The prefix tuples are lowercased, and only `[casetimeout]` depends on it,
  so a caller comparing a raw message passes against the other five and
  silently drops that one. Documented, and asserted.
- scicode's `import_errors` matched `ModuleNotFoundError` by equality where
  its two siblings match a family. The stated rule is the family, since the
  service reports the concrete class, so a subclass is what arrives.
- The convention test justified its narrowness with scicode's
  `"[timeouterror]" in msg`, which 6d72735 had already replaced with a
  class-slot read. The carve-out is kept for the next such counter; the
  example is now the live one.

Reverse-mutation, each caught by a test: the two groups made to overlap, a
prefix left uppercased, MultiPL-E routing the run group to `n_build_errors`,
and scicode's import family narrowed back to the builtin.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
@ethan-scitix
ethan-scitix deleted the feat/multipl-e branch September 11, 2026 04:09
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