Rshahbazyan/add scicodepile benchmark - #3153
Conversation
Adds the executable stratum of SciCodePile (arXiv 2607.19104): 200 scientific code-generation tasks harvested from real computational-science repositories, each shipping its own test. Each task's `test` defines `check(candidate)`. The server executes `setup_code + model_code + test` in one namespace, looks up `entry_point`, and calls `check` with it, mirroring the upstream harness. Notes on two places this deliberately differs from bigcodebench: * No calibration prefix. BigCodeBench prepends `code_prompt + "pass"` so the entry point exists even when a model returns only a function body. That is not possible here: SciCodePile's `prompt` is display text whose docstring is not indented under the `def` line, so it is not valid Python. The prompt therefore demands a complete definition, matching all 200 canonical solutions. * Each task runs in a throwaway working directory. All 200 tasks carry the upstream `env_sensitive` flag and 117 carry `globals_patch`; several write files relative to the CWD (observed: .fasta/.a3m/.pdb from bioinformatics tasks). Without an isolated CWD, concurrent tasks collide on filenames and one run's leftovers can make a later run pass. `code_extraction.py` is a byte-identical copy of the bigcodebench module so a score difference between the two servers can never be an extractor artifact. Validation: all 200 upstream canonical solutions pass through the runner. Negative controls over 30 tasks behave correctly -- a stub returning None and a raising implementation score `fail`, a renamed function scores `entry_point_missing`, unparseable code scores `error`. 17 unit tests cover the runner statuses, setup_code ordering, stdout isolation, CWD isolation, and the extractor, including an inherited quirk where an untagged fence with trailing prose extracts nothing. Not to be confused with the existing `scicode` benchmark: different source (SciCode1/SciCode vs SciCodePile), size (65 problems / 288 sub-steps vs 200 tasks), and structure (multi-step vs single-shot). Upstream reports 12.30% Pass@1 for the strongest evaluated model; that figure has not been reproduced here. Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Replaces the instruction-wrapped prompt with the upstream `prompt` field verbatim, HumanEval-style. Upstream publishes no prompt of its own, so a wrapper is invention that changes what is measured. Chosen from measurement, not taste. Both forms over all 200 tasks against gpt-5.4-mini (upstream reports 12.30% for this model): raw upstream prompt : 9.50%, 34 non-attempts, 11.45% given usable code instruction-wrapped : 8.50%, 0 non-attempts, 8.50% given usable code The raw prompt scores higher overall and substantially higher on tasks where the model produced usable code. It carries a real cost, documented in the benchmark README: with no instruction a chat model sometimes answers conversationally rather than writing code, which the extractor cannot parse. That was 34 of 200 tasks. Wrapping eliminates those but depresses the quality of the code produced, apparently by encouraging literal transcription of the docstring. The `no_code_block`, `entry_point_missing`, and `syntax_error` statuses isolate non-attempts for anyone who needs them reported separately. The verifier is unchanged and still requires a complete function definition, since SciCodePile's prompt is display text (its docstring is not indented) and so cannot serve as a BigCodeBench-style calibration prefix. Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
The runner wrote its JSON result to fd 1, which untrusted model code also
owns. contextlib.redirect_stdout only rebinds sys.stdout and does not protect
the descriptor, so a completion could write {"status": "pass"} to fd 1 and
call os._exit(0) to claim reward 1.0 without check() ever running. The benign
direction broke too: stray fd-1 output from a genuinely passing task prepended
itself to the JSON and scored the task as unparseable_runner_output.
main() now takes a private os.dup(1) as the result channel and points fd 1 at
/dev/null before any task code runs. A task that exits early produces an empty
read rather than a forged verdict, so the runner fails closed.
Adds TestResultChannel, whose three cases were each confirmed failing against
the previous fd-1 channel, and TestScientificImports covering numpy import and
ordinary scratch-file use. Also documents the runner's actual threat model:
process isolation, an address-space cap, a throwaway CWD and a timeout, but
not a security sandbox.
Regenerates the root README environment table, which the update-readme-table
hook requires for this server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
The per-task scratch CWD was created by `tempfile.TemporaryDirectory()` inside
the runner, so cleanup only ran on a normal context-manager exit. The parent
SIGKILLs the runner on timeout and task code can call `os._exit`, and neither
path runs that cleanup — so every hung rollout stranded its directory, along
with any .fasta/.a3m/.pdb artifacts the task had written, for good.
Move ownership to the parent: `_run_task` creates the directory, passes it to
the runner on stdin, and removes it in a `finally` that survives the kill.
Standalone invocations (the 200-solution harness validation) still fall back to
a self-managed directory, now documented as best-effort for the same reason.
Also set `failure_reason` for failures the harness or dataset owns — timeout,
unparseable runner output, runner crash, and a test that defines no `check` —
so those rollouts can be separated from genuine wrong answers instead of
silently depressing accuracy. Model-owned outcomes deliberately leave it unset.
Alongside: decode the runner's stdout once instead of twice on the parse-failure
path; drop the no-op `env={**os.environ}` copy (the default already inherits);
and report a warning when `benchmark_meta` fails to parse in prepare.py rather
than silently emitting null provenance fields on every row.
Verified: 35 unit tests pass, and all 200 canonical solutions still pass through
the changed path (the check README.md requires whenever the runner changes). The
two new leak tests were confirmed to fail when the cleanup is reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Three statements had drifted from the code: - README.md and the extractor docstring said `code_extraction.py` is a "byte-identical copy" of the BigCodeBench module. It is not: the docstrings differ, which is what makes the claim self-refuting. The extraction *logic* is identical, which is what the parity argument actually rests on. - README.md claimed "the prompt asks for a ```python tag to avoid this", but 61684d6 reduced the prompt to an unmodified upstream passthrough with no instructions at all — contradicting the same README's own note that the benchmark deliberately does not instruct the model. Untagged fences are part of the non-attempt rate that choice costs, not something the prompt mitigates. - The extractor docstring described `</think>` handling that the code does not implement: there is no check for an opening `<think>`, and the `return ""` branch is unreachable because the tag is known to be present. Both quirks are inherited verbatim from NeMo-Skills and are left in place to hold score parity with BigCodeBench, so this documents them rather than changing behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
armarkos
left a comment
There was a problem hiding this comment.
Thanks for a carefully built PR. The runner design, the upstream-data notes, and the negative-control mindset are all above the bar for code benchmarks in this repo. I reviewed at head efd0e4d, read the sibling code benchmarks (bigcodebench, evalplus/human_eval, code_fim, code_gen, scicode) and the core paths this server touches, and re-ran the model-independent checks over all 200 upstream tasks in a clean nemo-gym[dev] env. Two of the findings below are verifier-correctness issues that I think should block merge; the rest are ordered by impact.
Note on CI: because of the copy-pr-bot gate, only lint, copyright, secrets, and DCO ran on this PR. Unit tests and gym env test have not run in CI yet.
Blocking
1. One compile unit lets model output replace check. scp_runner.py:91-106 concatenates setup_code + code + test and compiles it once. A trailing decorator line in the model's code therefore decorates the test's def check(candidate):. This wrong solution passes 200/200 tasks:
def <entry_point>(*a, **k):
return None
@(lambda f: (lambda candidate: None))The @(lambda f: None) variant yields test_defines_no_check, which app.py:55 then files as a harness failure. Fix: exec setup_code and code, then compile and exec test as a separate unit in the same namespace, and take check from that unit only.
2. The same single unit mis-scores legitimate solutions and mis-attributes dataset faults. A correct solution that begins with from __future__ import annotations is scored error/syntax_error on all 105 tasks with non-empty setup_code ("from future imports must occur at the beginning of the file"). Failures raised by dataset-owned setup_code or the test's module body surface as exec_failed/syntax_error with failure_reason unset, i.e. charged to the model, and SyntaxError line numbers are offset by the setup length. Separate compile units with distinct filenames fix all three, and let setup/test-phase failures carry a harness FailureCode.
3. The verdict can still be forged. The fd-1 redirect (scp_runner.py:142-158) blocks only writes to fd 1. Task code that loops os.write(fd, forged_json) over fds 3..63 and then calls os._exit(0) produced {"status": "pass"} in the parent and reward 1.0; monkeypatching json.dumps at module level does the same. The docstring, commit 53e5dee and test_task_cannot_forge_a_pass_verdict claim forge resistance that does not hold. Either drop the claim and keep the redirect for its real value (fd-1 noise no longer corrupts honest verdicts), or add a parent-generated nonce passed on stdin and required in the result, and state the residual risk (frame introspection) explicitly.
4. gym env test data validation fails for this server. nemo_gym/cli/env.py:829-894 asserts exactly 5 rows in data/example.jsonl, plus data/example_metrics.json and data/example_rollouts.jsonl with 5 entries each; CI's changed-server job runs it with +should_validate_data=true (.github/workflows/unit-tests.yml:123). This PR ships 3 rows and neither companion file, and data/.gitignore (* / !example.jsonl) would exclude them if generated. 119 of the other 120 servers commit all three. Regenerate 5 examples, run gym dataset collate ... +mode=example_validation, collect the 5 example rollouts, and narrow the gitignore to the sibling pattern.
Should fix
5. failure_reason flags model-caused outcomes as harness faults. Through the real /verify endpoint: an infinite-loop solution gets failure_reason=timeout, os._exit(0) in the candidate gets unparseable_runner_output, and a compile() MemoryError gets runner_crashed. BaseVerifyResponse defines the field as "why reward may not reflect policy quality" (nemo_gym/base_resources_server.py:100-102), and the FailureCode docstring (app.py:36-43) tells readers to filter these rows out of accuracy. Doing so inflates pass@1 and makes hanging or exiting reward-neutral under RL. No code in nemo_gym consumes the field, and none of the six servers with a FailureCode enum flags a model-code timeout; code_gen scores TLE as a failure. Reserve failure_reason for setup/test-phase and runner-internal faults, and consider a harness_failure score in _score_fn so it gets its own metric line instead of a manual filter.
6. Runner exit path turns passes into timeouts. proc.communicate() (app.py:190) waits for EOF on stderr, which any process the task spawns inherits; a non-daemon thread or atexit hook left by task code also keeps the runner alive after the verdict is written. In both cases a written pass becomes timeout, a semaphore slot is held for the full 120 s, and children are orphaned because there is no process group. Six upstream rows already use subprocess/multiprocessing. Fix: os._exit(0) right after os.close(result_fd), redirect fd 2 in the runner, spawn with start_new_session=True and kill the group, and kill in a finally so a CancelledError on uvicorn shutdown (server_utils.py:949, 0.5 s grace) does not leave the child running while rmtree deletes its cwd.
7. The namespace is a bare dict, not a module. Dataclasses under PEP 563 (sys.modules[cls.__module__] is None), pickling or multiprocessing of task-defined functions ("import of module __scicodepile__ failed"), and __file__ all fail and are charged to the model. Exec in a types.ModuleType("__scicodepile__") registered in sys.modules with __file__ set.
8. system: "" sends an empty system message on every request. nemo_gym/prompt.py:66-67 emits a system turn whenever the key is present, and all three example rows carry {"role": "system", "content": ""}. Nothing downstream strips it; on the vLLM chat path it is template-dependent (Qwen templates drop their default system prompt, Llama templates render an empty block), and the completions path prepends a blank line pair. This is not the "unmodified upstream prompt" the README claims, and it perturbs the reported 9.50%. Of 41 prompt YAMLs only bigcodebench's uses system: "", for NeMo-Skills byte parity that does not apply here. Delete the key (or reuse benchmarks/prompts/generic/default.yaml) and regenerate example.jsonl.
9. Model-controlled exception text can 500 the endpoint. A lone surrogate in details.message (raise ValueError('\udcff'), or a surrogateescape-decoded filename) survives the runner's JSON round-trip and makes the response serialization raise, so /verify returns HTTP 500. With the default route_failures_to_sidecar=False that aborts the whole rollout run (rollout_collection.py:1890-1920). Sanitize details strings with .encode("utf-8", "replace").decode().
10. The 8 GiB RLIMIT_AS is untested where it applies. Every task imports numpy; with uncapped OpenBLAS threads, import numpy can fail under an 8 GiB address-space cap on many-core Linux nodes (OpenBLAS issue #4762 reproduces at exactly this limit), and it would be scored as the model's exec_failed. All tests pass max_as_limit=0 and the 200/200 validation ran on macOS, where the cap is skipped. Set OPENBLAS_NUM_THREADS=1/OMP_NUM_THREADS=1 in the child env, consider bigcodebench's 30 GiB, and add a Linux test with the cap on.
Minor
python benchmarks/scicodepile/prepare.py --output xfails with Hydra's "unrecognized arguments: --output" becauseget_global_config_dict()re-parses argv; usemaybe_get_global_config_dict()or drop the flag. Theast.literal_evalbranch is dead (load_datasetyields dicts) and its comment is wrong.gym eval prepareworks (200 rows).- No test exercises
verify(),_score_fn, orget_key_metrics(app.py 75% covered), andtest_solution_stdout_does_not_corrupt_the_resultdrives the in-processrun_task, so it stays green with the fd-1 protections deleted. Mirror bigcodebench's TestClient tests for pass/fail/empty_output/no_code_block. - Three upstream tasks (
alignment/python/144,178,273) pass with a stub returningNone; their tests only assert the return isNoneor any object. The README's negative-control claim was measured on 30 tasks. Document or flag them, and run the controls over all 200 (none pass with an empty answer or a raising stub, which is good). - The raw-vs-wrapped A/B is a single repeat where the gap is two tasks out of 200; either add repeats or soften "made from measurement".
- Prose answers land as
error/syntax_error, notno_code_block(no fence means the whole text is returned), so the statuses do not cleanly isolate non-attempts. - Task stdout/stderr are captured into never-read
StringIObuffers even though fd 1 is already/dev/null; redirect toos.devnullinstead. REVERIFY_MODEis leftUNKNOWNthough verify is a pure function of the persisted row; declaringSTATELESSunlocksgym eval reverifywithout++force.meta["test"]/meta["entry_point"]raiseKeyErroron a malformed row, which 500s and aborts the run by default; validate in the request model or inprepare.py.
What checks out
All 35 tests pass in a clean env; all 200 canonical solutions pass with only the shipped requirements; empty answers and raising stubs pass 0 tasks; the core benchmark-discovery tests pass with this tree; the dataset is public, Apache-2.0, 200 rows, all runnable and primary_score_eligible; the README row is byte-identical to the generator's output; ruff is clean; task_data.py follows the convention; the metric helpers are used correctly; and the execution model is the bigcodebench pattern with strictly more containment.
…ox boundary
Two discrepancies between the READMEs and the shipped code.
The benchmark README's "Data notes" said the model "is therefore asked for a
complete function definition". Nothing asks it: `_build_question()` returns the
upstream prompt verbatim and the prompt template is a bare `{question}`. This
contradicted the Prompting section of the same file and was left over from the
instruction-wrapped prompt removed in 61684d6. The requirement is the verifier's,
not the prompt's — it looks `entry_point` up after exec, so a bare function body
scores `entry_point_missing`. That distinction is why 34 of 200 tasks become
non-attempts, so it is worth stating precisely.
The resources server README described containment as "a wall-clock timeout and an
RLIMIT_AS cap", which is half of what scp_runner.py actually documents, and it
omitted the warning that the runner is not a security sandbox. Task code runs with
the server's privileges and can shell out, open sockets, or write outside its CWD.
Adds a section naming all four containment measures, pointing at nemo_gym/sandbox/
for untrusted rollouts, and explaining why the result channel is kept off fd 1 —
without that, a completion could forge a reward-1.0 verdict with a direct write to
fd 1, since redirect_stdout rebinds only sys.stdout.
Also expands the `error` status, previously described as "syntax or import
failure", to list the reasons it actually carries: syntax_error, exec_failed,
test_defines_no_check, runner_crashed, and unparseable_runner_output.
Docs only; no code or behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Removes the prompt A/B table and the pass rates measured in-house. Peer servers in this repo cite upstream-published figures and explain metric semantics; they do not publish their own model measurements, and those numbers belong in internal tracking rather than a public README. The design rationale is kept in full: the upstream prompt is passed through unmodified because upstream publishes no prompt to match, and a wrapper would change what is measured and make results incomparable with published figures. The cost of that choice is kept too — a chat model given a bare signature sometimes answers conversationally instead of writing code, scoring zero without having attempted the task — along with the statuses that separate those non-attempts from genuine failures. The upstream 12.30% Pass@1 reference is retained, as is the note that this repository has not reproduced it. Docs only; no code or behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Addresses two blocking findings from the PR #3153 review, which share one cause: the runner concatenated `setup_code + code + test` into a single compile unit, so untrusted model output was in the same source file as the test that scores it. **A trailing decorator in the model's code bound to the test's own `def check`.** Because a decorator applies to whatever function is defined next, and the test was glued directly beneath the solution, `@(lambda f: (lambda candidate: None))` replaced the assertions with a no-op. Reproduced across the benchmark: a solution returning `None` for every input scored **200/200**. The `@(lambda f: None)` variant erased `check` entirely and was then filed as a harness failure, hiding a model-caused fault. Nothing in the existing validation could catch this — canonical solutions do not decorate, and the negative controls used a stub with no trailing decorator. **The same concatenation mis-scored correct solutions.** A solution beginning `from __future__ import annotations` was no longer at the top of the compiled file once `setup_code` preceded it, so it failed with "from __future__ imports must occur at the beginning of the file" on all **105** tasks with non-empty setup. Reproduced: 40/40 sampled such tasks scored `error`, against 39/40 passing where setup was empty. Both are fixed by compiling and executing the three sources as separate units with distinct filenames, which also keeps `SyntaxError` line numbers pointing at the offending source instead of an offset into the concatenation. The units still share one namespace. That is deliberate and load-bearing: many upstream tests inject stubs through `candidate.__globals__`, and giving the test a copied namespace fails 13 of the 200 tasks. Separation alone is sufficient, since a dangling decorator cannot bind across a compile boundary, and the test's own `def check` executes after the model's code so it rebinds any `check` the model defined. Faults are now attributed to the phase that raised them. `setup_code` and the test's module body are dataset-owned, so failures there carry `harness_fault` and map to new `SETUP_CODE_FAILED` / `TEST_CODE_FAILED` codes rather than being scored as a wrong answer; only the model's own unit is charged to the model. Validation: 200/200 canonical solutions still pass; the decorator payload scores 0/200; `from __future__` passes on all 200 with and without setup_code. Eight new tests, mutation-checked — six fail against the pre-fix runner, and the two that do not are the guards asserting previously-working behaviour is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Addresses blocking finding #3 from the PR #3153 review. The claim was false. Commit 53e5dee moved the result channel off fd 1 and described the result as forge-resistant, in the module docstring, the server README, and the test name `test_task_cannot_forge_a_pass_verdict`. Moving the channel only renumbered it: `os.dup(1)` returns another descriptor in the same process, so task code that sprays `os.write(fd, forged)` across fds 3..63 and then calls `os._exit(0)` still lands a `{"status": "pass"}` in the parent. Replacing `json.dumps` at module level does the same without touching descriptors. Both were reproduced against the runner. Removes `test_task_cannot_forge_a_pass_verdict` and `test_forged_verdict_does_not_override_a_real_failure`. The first asserted a guarantee that does not exist. The second passed only because its payload did not exit before `check()` ran, so it gave the same false comfort in a narrower case. What survives is the property the change actually delivers: incidental fd-1 output from an honest task no longer corrupts that task's own verdict. That is kept, and `test_fd1_noise_does_not_corrupt_an_honest_verdict` still covers it. No nonce is added. The review offered that as an alternative to dropping the claim. It would raise the cost of forgery but not close it — frame introspection recovers the nonce — and the runner is explicitly not a sandbox, so task code that wants to subvert a score has cheaper options than the result channel. The docs now say this plainly instead of implying protection. Worth revisiting if this server is ever used for RL training rather than evaluation, where reward pressure can surface an exploit nobody would write deliberately. Validation: 200/200 canonical solutions pass; 41 tests pass; ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…ttribution Follows the same principle as 2d85117: do not assert a property nothing provides. `test_solution_stdout_does_not_corrupt_the_result` claimed "stdout is the runner's result channel", but drove the in-process `run_task`, which never touches fd 1 — the result channel belongs to `main()`. Verified by mutation: with the fd-1 protection deleted the test stayed green while `test_fd1_noise_does_not_corrupt_an_honest_verdict`, which goes through the subprocess, correctly failed. The review flagged this one directly. Removed rather than repaired, since the surviving test already covers the real behaviour. `SETUP_CODE_FAILED` and `TEST_CODE_FAILED`, added in d67f25c, had no coverage at all. `TestFailureReason` now exercises both, and the model-phase cases are pinned with an explicit `phase` because the same `reason` strings occur on both sides and only the phase distinguishes a dataset fault from a model fault. Mutation-checked: disabling the `harness_fault` branch fails four cases. Also corrects an unenforced claim. `code_extraction.py` and its BigCodeBench counterpart said a score difference between the servers "can never be an extractor artifact", but nothing keeps them in step — no shared import, no symlink, no comparison test. Their executable logic is identical today (verified by comparing both ASTs with docstrings stripped) and the wording now says exactly that, plus what would break it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…eouts Three passing solutions were scored `timeout` at the full subprocess_timeout: leaves a child running (holds the parent's stderr pipe open past the verdict) leaves a non-daemon thread (interpreter shutdown joins it) registers an atexit hook (interpreter shutdown runs it) Six upstream tasks already use subprocess/multiprocessing, so this is not hypothetical. Each also held a concurrency slot for the whole timeout and orphaned its children against a working directory the parent then deleted. Runner: fd 2 now goes to /dev/null alongside fd 1, so nothing the task spawns can hold the parent's pipe open, and the real stderr is kept on a private duplicate so a runner-internal crash is still reportable. After the verdict is written the runner calls os._exit(0) instead of returning, skipping atexit hooks and non-daemon thread joins. Server: the runner is spawned with start_new_session=True and its whole process group is SIGKILLed in a `finally`, so grandchildren die before the rmtree of their own CWD and a CancelledError on uvicorn shutdown takes the same path. The kill helper must never block -- it runs in that `finally`, so a hang there would hold the semaphore for the rest of the run -- hence a proc.kill() fallback if the group signal cannot be delivered and a bounded reap after it. All three cases now return `pass` in 0.1s instead of `timeout` in 8.0s, and a spawned grandchild is confirmed dead after the run. Tests: 56 pass. Mutation-checked: reverting os._exit, leaving fd 2 on the pipe, dropping the group kill, and dropping start_new_session each fail a test, and a genuine infinite loop still times out. All 200 canonical solutions still pass and `gym env test` validates the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
`sys.modules["__scicodepile__"]` was None, so anything that resolves a function's module by name failed and was charged to the model: @DataClass under `from __future__ import annotations` AttributeError: 'NoneType' object has no attribute '__dict__' pickle / multiprocessing of any task-defined function PicklingError: import of module '__scicodepile__' failed __file__ NameError: name '__file__' is not defined All four reproduce, and six upstream tasks already use multiprocessing. The three compile units now share the __dict__ of a real types.ModuleType registered in sys.modules, with __file__ pointing into the task's throwaway working directory so paths derived from it stay inside the scratch area. __name__ stays "__scicodepile__" rather than "__main__": harvested sources guard side effects behind a __main__ check and must not run them here. Tests: 61 pass. Mutation-checked -- reverting to a bare dict, skipping the sys.modules registration, and leaving __file__ unset each fail the matching tests. All 200 canonical solutions still pass and `gym env test` validates the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
`raise ValueError('\udcff')` in a candidate returned HTTP 500, as did the same
raised from the module body. A lone surrogate is representable in a Python str
and survives the runner's JSON round trip, then raises UnicodeEncodeError when
the response is encoded for the wire. With the default
route_failures_to_sidecar=False a 500 aborts the entire rollout run, so one
task's exception message could end the job.
Sanitizing only `details` is not enough. `\udcff` is a legal JSON escape, so a
client relaying model output produces a lone surrogate in the request body that
json.loads accepts happily and that we then echo back. Confirmed: with only the
added fields sanitized, that route still returns 500. All response strings --
echoed request included -- are now forced back through
`.encode("utf-8", "replace").decode()`.
This is lossy only for text that could not have been sent at all.
Tests: 67 pass. Mutation-checked: disabling the sanitizer and narrowing it to
the added fields each fail a test. `gym env test` validates the data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Every test passed max_as_limit=0 and the 200/200 validation ran on macOS, where _apply_limits is skipped entirely -- so the cap shipped untested on the only platform that enforces it. Every task in this benchmark imports numpy, and OpenBLAS reserves a per-core buffer at library load sized from the machine's core count, which counts against RLIMIT_AS. Measured on this 28-core host: `import numpy` plus one SVD reserves 1.18 GiB of address space unpinned versus 0.12 GiB pinned, ~39 MiB per core. The reservation grows with core count, so on a many-core node numpy alone approaches an 8 GiB cap -- and the resulting failure is scored as the model's `exec_failed` (OpenBLAS issue #4762). I could not reproduce the import failure here: OpenBLAS sizes the reservation from the detected core count, and raising OPENBLAS_NUM_THREADS above nproc does not grow it, so a 28-core host cannot stand in for a 192-core one. The change rests on the measured per-core scaling, not on a reproduction. - Runner is spawned with OPENBLAS_NUM_THREADS/OMP_NUM_THREADS/MKL/NUMEXPR = 1. These must be in the child's environment, not set inside the runner: OpenBLAS reads them when the shared library loads, before any code we control runs. - max_as_limit raised from 8 GiB to 30 GiB, matching bigcodebench. The cap is there to stop a runaway allocation taking down the node, not to measure the model, so it should sit well clear of legitimate scientific work. - Two Linux-only tests exercise the cap at the shipped default and assert the pinning measurably shrinks the reservation, plus one asserting the server actually spawns with that environment. Tests: 70 pass. Mutation-checked: dropping the pinning and dropping the env argument each fail a test. All 200 canonical solutions still pass under the new cap and pinned environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
prepare.py - `--output` crashed with Hydra's "unrecognized arguments" because get_global_config_dict() falls through to a full CLI parse that re-reads argv. Use maybe_get_global_config_dict(), which only consults an existing config. Both `prepare.py --output ...` and `gym eval prepare` now write 200 rows. - The ast.literal_eval branch was dead and its comment wrong: load_dataset decodes benchmark_meta to a dict on all 200 rows, verified. Replaced with an explicit type check that fails loudly if upstream changes serialization. app.py - A row missing `test`/`entry_point` raised KeyError -> HTTP 500 -> aborted the whole run by default. It is now reported as `malformed_task` with a MALFORMED_TASK failure_reason, so the run continues and the harness_failure metric shows how many rows are unusable. - REVERIFY_MODE declared STATELESS, which unlocks `gym eval reverify` without ++force. Verified: 40 tasks re-verified forward, reversed and again give identical verdicts. scp_runner.py - Task stdout/stderr went to StringIO buffers nothing ever reads, so a chatty task's output was buffered in memory and charged against RLIMIT_AS -- then scored as the model's exec_failed. Redirect to os.devnull instead. Tests - Added TestClient coverage of the real /verify path (pass, fail, entry_point_missing, empty_output, no_code_block, prose, malformed row), mirroring bigcodebench. Previously nothing exercised verify(), _score_fn or get_key_metrics. README - Negative controls re-run over all 200 tasks rather than a 30-task sample, with results tabulated. Two caveats this surfaced are now documented: alignment/python/144, 178 and 273 pass with a stub returning None (their tests only assert the result is None or any object -- 1.5pp of any reported score), and alignment/python/76 defines its own entry point `gsea` in setup_code, so entry_point_missing is not a reliable non-attempt signal there. - Documented that the statuses do not isolate non-attempts: with no fence the whole text is compiled, so prose and refusals land as error/syntax_error. The raw-vs-wrapped A/B claim was already removed from both READMEs earlier. Tests: 79 pass. 200/200 canonical solutions pass and `gym env test` validates the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
Follow-up review found the previous exit-path fix incomplete. A correct solution that calls os.fork() still scored `timeout` at the full budget: os.fork() child sleeps (correct solution) -> timeout in 6.0s subprocess.Popen (control) -> pass in 0.1s Popen hid the bug: exec closes non-inheritable descriptors (PEP 446), so its child never held our pipes. A forked child inherits the whole descriptor table, including the runner's duplicates of both pipe write ends. Waiting on process exit instead of EOF does not fix it either -- asyncio's Process.wait() does not resolve until the pipe transports close, so it carries the same dependency. Confirmed by trying it. The verdict now has its own completion signal: the runner writes it newline-terminated and the server reads a single line, which arrives regardless of who else holds the pipe open. stderr is drained concurrently so the runner can never block on a full pipe, and collected best-effort after the process group is killed. Both cases now pass in 0.1s, and the finding #6 cases (subprocess, non-daemon thread, atexit) still do. Also from the same review: - configs/scicodepile.yaml pinned max_as_limit: 8192, so raising the class default to 30 GiB changed nothing in deployment while the README claimed it had. The YAML is corrected, and a new test asserts the shipped config agrees with the class defaults for every execution knob -- mutation-checked by restoring the 8192 override. - benchmarks/scicodepile/README.md claimed the statuses isolate non-attempts, contradicting the server README. Corrected: prose and refusals compile and land as error/syntax_error, and entry_point_missing is unreliable on alignment/python/76. - The REVERIFY_MODE=STATELESS justification overclaimed. It said verification is a pure function of the persisted row; executing arbitrary model code cannot guarantee that. Rewritten to claim only what holds -- no state carried between verifications -- and to name the residual. The mode itself is unchanged and matches bird_sql and terminal_bench_2_1, which also execute model code. What is checked: three independent sweeps of all 200 canonical solutions gave identical verdicts. - ruff check (import sorting) and ruff format now pass. Tests: 81 pass. 200/200 canonical solutions pass and `gym env test` validates the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…ation claims Read the paper (arXiv:2607.19104) rather than inferring from the dataset card. The 12.30% Pass@1 the README cited as "the strongest evaluated model" is GPT-5.4-mini specifically, and the paper's Table 3 reports Pass@1 and Pass@5 for all 15 models. The README now names the model and gives the surrounding figures. Prompting is now grounded in the source instead of asserted. The paper specifies only that models are evaluated zero-shot on "HumanEval-style task prompts, each consisting of a function signature and its natural-language specification". It contains no system prompt, no decoding parameters and no per-task sample count -- "system prompt", "temperature" and "top_p" appear zero times in the full text. Appendix K.5, which the benchmark section cites for the prompt, is titled "Prompt Design for Executable Benchmark *Construction*": it is the prompt used to generate the test artifacts, not the prompt given to evaluated models. So the paper cites an appendix for its evaluation prompt that does not contain one. That confirms the choice already made here -- pass `prompt` through unmodified with no system message -- and the benchmark README now records why `system: ""` is not the same thing (an empty system turn is template-dependent; Qwen templates drop their built-in default system prompt when any system message is supplied). The README keeps only paper-reported information. Replication detail belongs in the PR description and is drafted there instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…tion Which appendix the paper cites for its evaluation prompt is commentary on the paper, not something a reader of this benchmark needs. The substantive claim -- the paper publishes no system prompt and no instruction wrapper -- stands without it. The detail stays in the PR description where it explains the investigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…ng sabotaged Three issues from the second review round. 1. Model failure mislabelled as a harness fault. `_phase_error` rendered the exception with `type()` and `str()` resolved through builtins at call time, so model code could rebind `builtins.type` and then raise: the error-reporting path itself threw, escaped `run_task` before the post-model guard, and the last-resort handler in `main` reported `phase=runner`/`harness_fault=true`. A model-controlled outcome excused as ours, corrupting the `harness_failure` metric. Reproduced verbatim from the review. `_phase_error` now uses builtins captured at import, degrades instead of raising, and `main`'s handler consults whether model execution had begun rather than assuming every escape predates it. The model's real error is reported now (RuntimeError), not the sabotaged builtin's MemoryError. Two tests, both mutation-checked: the reported case, plus one that genuinely reaches `main`'s handler after model code (rebinding `os.chdir`, which `_working_directory` restores in a `finally` that catches only OSError). The second was added because the first stops reaching that handler once the builtin capture is in place -- without it the flag was untested. 2. The subprocess timeout did not cover request delivery. Only `stdout.readline()` was timed; `stdin.drain()` sat outside it. The request carries the model's code plus the task's test, so it routinely exceeds the 64 KiB pipe buffer, and a child that never reaches `sys.stdin.read()` blocked the write indefinitely while holding a semaphore slot. Reproduced: 2.0s elapsed against a configured 0.25s. Delivery and read now share one deadline in `_deliver_and_read`. Returns in 0.278s against 0.25s. Mutation-checked by reverting to timing only the read. 3. Two stale docstrings. `task_data.py` still said `verify()` indexes `meta[...]` and raises KeyError; it uses `.get()` and returns `malformed_task`. The fields stay required -- a row without them cannot be scored -- but the rationale was wrong. `prepare.py` still pointed at prompt measurements that were removed. Tests: 84 pass. 200/200 canonical solutions pass, ruff clean, data validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
… docstring Third review round. `prepare.py` called `load_dataset` with no `revision`, so the benchmark was whatever the Hub served that day. The 200-row assertion catches only a change in size: an edit to a prompt, test, canonical solution, entry point or audit flag that kept 200 rows would move every score with no failure at all -- while the README claimed an upstream change would surface loudly. Pinned to 9afb3a95c7fa8e470119cf6f74b44ec735c5a95b, the snapshot every figure and control in the READMEs was produced against; the row count stays as a shape check. Verified from a cold HF cache: 200 rows, byte-identical to the checked-in prepared file (sha256 b0139b829b115975d7e5cd79583e91146709af8e9d23d634e32bbcec7a78865d). `_phase_error`'s docstring still said harness faults are kept out of the accuracy figure. They are not, and have not been since the failure_reason rework: those rollouts score accuracy 0 and the rate is published separately as `harness_failure`. Corrected to say identified, not excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
…rets
The secrets-detector check failed on the head just pushed. It is a false
positive, not a leaked credential: `HF_REVISION` is the 40-character hex commit
SHA of the pinned public Hugging Face dataset snapshot, and detect-secrets'
HexHighEntropyString plugin flags any such string.
Secret Type: Hex High Entropy String
Location: benchmarks/scicodepile/prepare.py:42
Marked with the inline `pragma: allowlist secret` the tool itself recommends,
rather than regenerating the shared baseline -- the pragma is scoped to this one
line and documents why it is there, where a baseline refresh would touch a file
used by every other PR and could mask unrelated findings.
Reproduced the CI check locally with detect-secrets 1.5.0 against the repo
baseline: failing before, exit 0 after. Independently grepped the full branch
diff for credential literals (nvapi-, sk-, hf_, ghp_, AKIA, PEM headers,
assigned api_key/password/secret): no matches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
The Metrics section was missed when the READMEs were reduced to paper-reported information. It asserted "that figure has not been reproduced here" -- replication commentary of exactly the kind that was removed elsewhere, and no longer accurate. It also said only "the strongest evaluated model" where the paper names the model: GPT-5.4-mini, 12.30% Pass@1 / 15.50% Pass@5 (Table 3). Replaced with what the section should actually cover: the headline metric, pass@5, and the `harness_failure` score that rides alongside accuracy -- which was documented in the server README but nowhere in the benchmark README, despite being a metric a reader of this file needs to interpret a result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rima Shahbazyan <rshahbazyan@nvidia.com>
What does this PR do?
Adds the executable stratum of SciCodePile as a NeMo Gym benchmark: 200
scientific code-generation tasks harvested from 37,737 public
computational-science repositories, each shipping its own test.
Each task's
testdefinescheck(candidate). The server looks upentry_pointandcalls
checkwith it. Reward is binary.Not the same artifact as the existing
scicodebenchmark — different source(
SciCode1/SciCodevsSciCodePile), size (65 problems / 288 sub-steps vs 200single-shot tasks), and structure (multi-step agent vs single-turn generation).
Checking coverage by name alone will read one as the other.
Verification design
setup_code, the model's code, andtestare compiled and executed as threeseparate units sharing one namespace — the
__dict__of a real module registered insys.modules. The separation is load-bearing, not stylistic: concatenated, a trailingdecorator in the model's output binds to the test's own
def checkand replaces theassertions (a solution returning
Nonethen passed all 200 tasks), andfrom __future__imports are pushed off the top of the file.Execution is out-of-process with a throwaway CWD per task (all 200 tasks carry the
upstream
env_sensitiveflag and 117 carryglobals_patch, so tests mutate globalstate), an
RLIMIT_AScap, pinned BLAS thread pools, and a process-group kill ontimeout. It is not a security sandbox — see the server README.
The dataset revision is pinned, so the benchmark is a fixed protocol rather than
whatever the Hub serves today.
Verification
The strongest correctness signal is model-independent:
canonical_solutionvalues pass through the runner. Thisvalidates the harness with no model involved and cannot be confounded by model
quality. Re-run it whenever the runner changes.
unparseable code errors 200/200; a stub returning
Nonefails 197/200; a renamedfunction and an empty answer each score
entry_point_missing199/200.alignment/python/144,178and273pass with a stub returningNone(theirtests only assert the result is
Noneor any object — worth ~1.5pp of any score),and
alignment/python/76defines its own entry point insetup_code.review.
Prompting
The upstream
promptis passed through unmodified with no system message. That isthe only prompting the paper specifies: zero-shot on "HumanEval-style task prompts,
each consisting of a function signature and its natural-language specification". The
paper publishes no system prompt, no decoding parameters, and no per-task sample count.
This costs some tasks where a chat model answers conversationally instead of writing
code. Note that the statuses do not cleanly isolate those non-attempts: with no
fence the extractor returns the whole response, so a refusal compiles and lands as
error/syntax_error, the same status as a real attempt with a syntax error.Counting non-attempts means reading the text.
Comparison with the published figures
Upstream's Table 3 reports Pass@1 / Pass@5 for 15 models. GPT-5.4-mini — the strongest
model in that table — was run through this server on all 200 tasks with the shipped
config (upstream prompt verbatim, no system message,
max_output_tokens131072),5 repeats:
pass@1[avg-of-5])The interval describes the precision of our estimate — a bootstrap resampling tasks
rather than rollouts, because repeats are clustered within a task. It is not a
comparison with upstream: it is wide enough to contain their figure, but containment
is not a test, and no test is available here.
harness_failurewas 0.0% across all 1000 rollouts — no dataset-owned orrunner-internal fault fired, so the whole gap is model behaviour rather than harness
noise.
No test of "ours versus theirs" is offered, because the data to do one does not exist
publicly. The paper reports a rounded aggregate with no per-task outcomes and no
per-task sample count — 12.30% of 200 is 24.6, so the figure is not a count of tasks
and cannot be treated as one. Since both runs cover the same 200 tasks, a paired or
cluster-aware analysis would be appropriate if upstream's per-task outcomes were ever
published; an independent two-sample test on reconstructed counts would not be.
Exact reproduction is not possible from the paper as written: it publishes neither the
decoding parameters nor the number of samples per task, both of which the HumanEval
Pass@k estimator depends on. The server README therefore records only the
paper-reported figures and makes no reproduction claim.
Treat model ranking on this benchmark with caution at these score levels. The
paper's own table puts GPT-5 (5.70%) below GPT-4o (7.50%).
Notes
example.jsonlships 5 rows withexample_metrics.jsonandexample_rollouts.jsonl;gym env test --resources-server scicodepile +should_validate_data=truepasses.code_extraction.pyis duplicated from the bigcodebench server to hold score parity;nothing enforces the two stay identical, which the module docstring states.
Checklist
git commit -s).