Skip to content

Rshahbazyan/add scicodepile benchmark - #3153

Merged
bxyu-nvidia merged 24 commits into
mainfrom
rshahbazyan/add-scicodepile-benchmark
Sep 21, 2026
Merged

bxyu-nvidia merged 24 commits into
mainfrom
rshahbazyan/add-scicodepile-benchmark

Conversation

@rimashahbazyan

@rimashahbazyan rimashahbazyan commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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 test defines check(candidate). The server looks up entry_point and
calls check with it. Reward is binary.

Not the same artifact as the existing scicode benchmark — different source
(SciCode1/SciCode vs SciCodePile), size (65 problems / 288 sub-steps vs 200
single-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, and test are compiled and executed as three
separate units sharing one namespace
— the __dict__ of a real module registered in
sys.modules. The separation is load-bearing, not stylistic: concatenated, a trailing
decorator in the model's output binds to the test's own def check and replaces the
assertions (a solution returning None then passed all 200 tasks), and
from __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_sensitive flag and 117 carry globals_patch, so tests mutate global
state), an RLIMIT_AS cap, pinned BLAS thread pools, and a process-group kill on
timeout. 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:

  • All 200 upstream canonical_solution values pass through the runner. This
    validates the harness with no model involved and cannot be confounded by model
    quality. Re-run it whenever the runner changes.
  • Negative controls over all 200 tasks: a raising implementation fails 200/200;
    unparseable code errors 200/200; a stub returning None fails 197/200; a renamed
    function and an empty answer each score entry_point_missing 199/200.
  • Two caveats the full-set controls surfaced, both documented in the server README:
    alignment/python/144, 178 and 273 pass with a stub returning None (their
    tests only assert the result is None or any object — worth ~1.5pp of any score),
    and alignment/python/76 defines its own entry point in setup_code.
  • 84 tests, including mutation-checked regression tests for every issue raised in
    review.

Prompting

The upstream prompt is passed through unmodified with no system message. That is
the 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_tokens 131072),
5 repeats:

Model Metric Upstream This server 95% CI (ours)
GPT-5.4-mini Pass@1 12.30% 9.30% (pass@1[avg-of-5]) [5.80%, 13.20%]
GPT-5.4-mini Pass@5 15.50% 13.00%

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_failure was 0.0% across all 1000 rollouts — no dataset-owned or
runner-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.jsonl ships 5 rows with example_metrics.json and example_rollouts.jsonl;
    gym env test --resources-server scicodepile +should_validate_data=true passes.
  • code_extraction.py is duplicated from the bigcodebench server to hold score parity;
    nothing enforces the two stay identical, which the module docstring states.

Checklist

  • I have read the contributing guidelines.
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally (84 passed), plus a 200/200 canonical-solution validation.
  • Pre-commit checks pass locally.
  • All commits have DCO sign-off (git commit -s).

rimashahbazyan and others added 5 commits September 2, 2026 18:50
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>
@copy-pr-bot

copy-pr-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@yaoyu-33 yaoyu-33 added area:environment Individual environments, benchmarks, verifiers, and environment-specific resources servers feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer labels Sep 8, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 9, 2026

@armarkos armarkos left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 x fails with Hydra's "unrecognized arguments: --output" because get_global_config_dict() re-parses argv; use maybe_get_global_config_dict() or drop the flag. The ast.literal_eval branch is dead (load_dataset yields dicts) and its comment is wrong. gym eval prepare works (200 rows).
  • No test exercises verify(), _score_fn, or get_key_metrics (app.py 75% covered), and test_solution_stdout_does_not_corrupt_the_result drives the in-process run_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 returning None; their tests only assert the return is None or 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, not no_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 StringIO buffers even though fd 1 is already /dev/null; redirect to os.devnull instead.
  • REVERIFY_MODE is left UNKNOWN though verify is a pure function of the persisted row; declaring STATELESS unlocks gym eval reverify without ++force.
  • meta["test"]/meta["entry_point"] raise KeyError on a malformed row, which 500s and aborts the run by default; validate in the request model or in prepare.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.

@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 10, 2026
…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>
@yaoyu-33 yaoyu-33 added waiting-on-customer Waiting on the original author to respond and removed needs-review PR is ready for code review and waiting on a reviewer labels Sep 10, 2026
rimashahbazyan and others added 2 commits September 10, 2026 16:21
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>
@github-actions github-actions Bot added the sla:author-overdue Author response is over the one-business-day SLA label Sep 11, 2026
…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>
rimashahbazyan and others added 10 commits September 14, 2026 15:59
…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>
@yaoyu-33 yaoyu-33 added the complexity:medium Single-domain change with interacting parts or a moderate review surface label Sep 15, 2026
@github-actions github-actions Bot removed the sla:author-overdue Author response is over the one-business-day SLA label Sep 17, 2026
@bxyu-nvidia
bxyu-nvidia merged commit a0c16a8 into main Sep 21, 2026
19 checks passed
@bxyu-nvidia
bxyu-nvidia deleted the rshahbazyan/add-scicodepile-benchmark branch September 21, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:environment Individual environments, benchmarks, verifiers, and environment-specific resources servers complexity:medium Single-domain change with interacting parts or a moderate review surface feature New capabilities, enhancements, or enablement work waiting-on-customer Waiting on the original author to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants