Skip to content

feat(eval): optionally record killed and unstarted rollouts in the failures sidecar - #5081

Merged
marcromeyn merged 4 commits into
mainfrom
romeyn/record-omitted-rollouts
Oct 26, 2026
Merged

marcromeyn merged 4 commits into
mainfrom
romeyn/record-omitted-rollouts

Conversation

@marcromeyn

@marcromeyn marcromeyn commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Scope: diagnostic capture only. Retry and recovery policy stay in #5072/#5073/#4996, and #3302's run manifest keeps its own interpretation of never-started, interrupted and omitted work. Reviewed with @AdamRajfer; his two findings are fixed in 7fa409f: readers keep the latest execution visible before setting these rows aside, and key on their own marker (_ng_omitted_rollout), so existing ATIF rows are read as before.

What and why

Today a rollout that produced no result leaves no row in any artifact:

  • an attempt the agent reports as kill_shaped (_ng_no_persist: true; Slurm SIGTERM, a dead Ray actor, an OOM
    outside the rollout), and
  • a row dispatch_budget_s never started (_ng_dispatch_drained, class cancelled).

Resume relies on that: no row means "run it again". But absence is then the only evidence. The materialized inputs
show the rollout was owed. Nothing says it was killed or never started, or why.

This is the "no-result attempt" half of GYM-327 ("define typed rollout outcomes and recovery"), which notes that
kill-shaped omissions leave no evidence. It is offered as an input to that work, not a separate design. It adds no
outcome taxonomy and no new failure class, and one new key, _ng_omitted_rollout, that marks the rows it writes. It
writes down what the collector already knows, using the markers the result already carries. GYM-327 can later map these rows onto a typed outcome. For example
nemo_gym/rollout_outcomes.py's RolloutFailure (not yet used by the collector) has source="collector" and
delivery="not_sent" for a drained row and "possibly_delivered" for a killed attempt. This PR does not decide
that mapping.

Writing (nemo_gym/omitted_rollouts.py, called from rollout_collection.py): with
+record_omitted_rollouts=true, each _ng_no_persist result is appended to <stem>_failures.jsonl as one row:

  • a copy of the result, so _ng_task_index, _ng_rollout_index, agent_ref, _ng_attempt_index (on a retry) and
    any diagnostics the agent returned stay with it;
  • _ng_no_persist: true is kept, and _ng_omitted_rollout: true is added: readers key on the latter, because ATIF
    reverification already writes _ng_no_persist on rows it counts as failures;
  • _ng_failure_class: the result's own class. If the result has none, cancelled for a drained row, otherwise
    kill_shaped;
  • _ng_failure_message: the reason the result gave (_ng_failure_message, error_message or error). Without one it
    is "<class>: the rollout produced no result". A drained row says, for example,
    not dispatched: dispatch budget exhausted.

The rows are not attempts: no reward is added, no _ng_failure_max_attempts is stamped, and they never enter
failure_counts.

Reading (unconditional, so a sidecar written with the option is read correctly by a run without it). A row is
never a failure or a failed attempt. Where a reader takes a rollout's latest attempt, an interrupted row
(kill_shaped) is that latest execution, so an older failure is not counted again and the rollout is missing; a row
the dispatch budget never started (_ng_dispatch_drained) never advances history. Rows with only _ng_no_persist
are read exactly as on main.

Reader File Effect
scoring, count_failure_classes_as_zero, batch status, gym eval aggregate (_latest_failure_rows) rollout_collection.py latest execution first, then not a failure; count_missing_rollouts_as_zero counts the rollout as missing
exporter metrics (_failure_export_metrics) rollout_collection.py no retries/attempts; an interrupted latest execution hides an older coverage/failed/<class>
resume (_load_from_cache) rollout_collection.py no attempt counted, _ng_failure_terminal ignored, the rollout runs again unstamped (retry policy unchanged)
run health (_rollout_accounting) rollout_health.py latest execution first; the rollout is missing, not failed
gym dev compare (load_rollouts) rollout_compare/rows.py latest execution first; no masked failure row, the rollout is missing
gym eval reverify --judge-failed-only rollout_reverification.py not a judge failure to recover, and does not trigger the multi-stage refusal

gym eval reverify --judge-failed-only still selects judge failures as on main; how a later execution affects
rejudging a saved answer belongs to the recovery work (#5073, #3879).

Why the option is off by default

EvalRDL's nemo-gym reader counts every failures-sidecar row of an unfinished rollout as a failed rollout. If this
were on by default, every run with a kill-shaped attempt or a drained budget would show "not run" rollouts as failures
in EvalRDL. That is the same missing-reported-as-failed mistake this change exists to avoid. A matching EvalRDL change
(read these rows as not run, run.n_not_run) is in review separately. Even after it merges, an
older EvalRDL deployment reading newer Gym output would still miscount, so the default should flip only once that
reader has shipped. Until then the cost is one boolean: Gym's own readers already handle the rows, and the option can
be removed when the default flips. The option does not depend on that change. It can be turned on today for runs
whose sidecar is read only by Gym.

Validation

All commands from the repository root.

python -m pytest -q -p no:cacheprovider -o addopts="" -n 8 \
  tests/unit_tests/test_rollout_collection.py tests/unit_tests/test_rollout_reverification.py \
  tests/unit_tests/test_rollout_health.py tests/unit_tests/test_compare.py tests/unit_tests/test_dev_compare.py \
  tests/unit_tests/test_batch_status.py resources_servers/gdpval/tests/test_multistage_orchestrator.py \
  tests/unit_tests/test_fern_docs_links.py

1350 passed, 24 subtests passed at 985b9ef (7fa409f merged with current main), with the telemetry extra installed (without it
test_rollout_collection.py is skipped as a whole).

New and extended tests (20 new test items):

  • TestOmittedRolloutRecords (5 cases, table-driven): killed; killed with no class or reason; no result under another
    class; budget exhausted; below the drain margin. Each case runs run_from_config and checks for exactly one marked
    row, with its class, reason, identity and no added reward. It then resumes, and checks that the rollout is sent
    again with no attempt index and lands in the rollouts JSONL.
  • TestSidecarReadersSkipOmittedRows (5): scoring and missing-as-zero, exporter metrics, run health,
    gym dev compare, and resume (five terminal copies of a marked row do not retire the rollout).
  • Existing end-to-end tests gain an "omitted recorded" parameter, and the existing assertions still hold:
    • test_run_from_config_counts_a_rollout_that_produced_no_row_as_zero: the score is the same, with
      count_failure_classes_as_zero=[kill_shaped] set;
    • test_rows_the_budget_never_started_count_as_zero_until_resume_runs_them: the score and the resume are the same;
    • test_run_from_config_non_retaining_preserves_upload_semantics: the upload is the same, including on resume over
      a sidecar that already holds a marked row.
  • test_rows_for_rollouts_with_no_result_are_not_recovered (4) in test_rollout_reverification.py.
  • TestOmittedRowsAndTheLatestAttempt (4, after review): in scoring, exporter metrics, run health and
    gym dev compare, an older judge_failed followed by an interrupted row is not counted again (the rollout is
    missing), an older judge_failed followed by a never-started row still stands, and an ATIF-style row with only
    _ng_no_persist is still counted. All four fail on the previous head (958e1e6).

Red/green: with only the production files stashed, the selected tests fail 17 times. Three new items pass without
the production change and are kept as regression guards: the upload case (the option adds nothing to uploads) and the
reverification killed and never-started cases (their classes were never recovered).

The full tests/unit_tests/ run and the red/green check above were done before rebasing onto current main and have not been repeated.

pre-commit run --files <changed files> passes on the rebased commit. The guardwords hook skipped
itself because the private catalog is not available.

Rollout evidence

On the head before rebasing onto current main and before review; the rows now also carry _ng_omitted_rollout.
Real run: example_single_tool_call + simple_agent against openai_model (gpt-6-luna), 3 tasks,
--concurrency 1 --max-output-tokens 512 +dispatch_budget_s=2. Task 0 started. By the time it finished, the 2 s
budget was spent, so tasks 1 and 2 were never started.

Run Options rollouts.jsonl rollouts_failures.jsonl Health (quality_summary.json)
default budget only task 0, reward 1.0 empty (0 bytes) n/a
recorded +record_omitted_rollouts=true task 0, reward 1.0 2 rows: tasks 1, 2 expected 3, failed 0, missing 2
recorded, --resume same, no budget tasks 0, 1, 2, all reward 1.0 the same 2 rows n/a

Each recorded row: _ng_no_persist: true, _ng_dispatch_drained: true, _ng_failure_class: "cancelled",
_ng_failure_message: "not dispatched: dispatch budget exhausted", agent_ref, task and rollout index, no reward.

  • Metrics. mean/reward was 1.0 over 1 rollout. The completion report said "Rollouts missing from the score: 2 of
    3" with no failures routed.
  • Resume. The resume printed 0 prior failure attempts, 2 rows that still need to be run, and ran both.

A kill-shaped attempt was not run against a real model: the example agent cannot be made to report kill_shaped from
configuration. The killed cases above cover it with a stubbed agent reply.

Compatibility

  • Default artifacts are unchanged. With the option off, nothing new is written.
  • Existing _ng_no_persist rows are read as before. ATIF reverification's kill-shaped rows carry no
    _ng_omitted_rollout, so their failure counts and requested kill_shaped zeros are unchanged.
  • Rows can carry the agent's own fields. A recorded row is a copy of the result. An agent that puts a placeholder
    reward on a kill-shaped result (pinchbench writes reward: 0.0) keeps it in the row. External readers must check
    _ng_no_persist before reading reward.
  • Uploads are unchanged. Kill-shaped results were already uploaded and still are. Drained rows were not uploaded
    and still are not. The sidecar row is never uploaded.
  • Not covered.
    • A custom rollout_collection_driver ignores the option. GDPVal's multi-stage driver keeps its own routing and
      writes nothing for _ng_no_persist rows.
    • A driver that is itself killed before it sees an outcome still leaves no row.
  • Scores change only with the option on, and only where a count_*_as_zero option is set. A rollout whose latest
    execution was interrupted is then missing rather than counted under an older failure class. With the option off
    (the default), no artifact, metric or score changes.

Docs: reference/cli-commands.mdx (a "Rollouts with no result" bullet, plus "Counting lost rollouts" and "Dispatch
budget" wording) and evaluation/aggregate-metrics.mdx.

Proposed labels: area:evaluation.

Linear: FEP-1842 (part of FEP-1829)

…ilures sidecar

A rollout that produced no result leaves no row anywhere: an attempt the agent
reports as kill_shaped (Slurm SIGTERM, a dead Ray actor, an OOM) and a row
dispatch_budget_s never started. The materialized inputs are then the only
evidence the rollout was owed, and nothing says why it is missing.

record_omitted_rollouts=true writes one failures-sidecar row for each. The row
is a copy of the result, so it keeps the rollout's identity and agent, and it
keeps the marker the result already carries (_ng_no_persist), its failure class
(kill_shaped, or cancelled with _ng_dispatch_drained) and a reason in
_ng_failure_message. No new keys or classes are introduced.

Every reader of the sidecar now skips a row marked _ng_no_persist: scoring and
count_failure_classes_as_zero, count_missing_rollouts_as_zero (which counts
such a rollout as missing), exporter failure and retry metrics, the run health
summary, gym dev compare, the resume attempt count and terminal gate, and
gym eval reverify --judge-failed-only. A resumed run therefore runs the
rollout again as its first attempt. Uploads are unchanged: the sidecar row is
never uploaded.

The option is off by default, so default artifacts are unchanged, until
sidecar readers outside Gym read these rows as not run rather than failed.

Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 26, 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.

@svcnvidia-nemo-ci svcnvidia-nemo-ci added area:evaluation Evaluation, rollout collection, reward profiling, comparison, and exporters feature New capabilities, enhancements, or enablement work labels Oct 26, 2026

@AdamRajfer AdamRajfer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The diagnostic capture scope makes sense. Two reader behaviors need attention before merge.

Comment thread nemo_gym/rollout_collection.py Resolved
Comment thread nemo_gym/rollout_health.py Resolved
…hey are

Review of the omitted-rollout rows found two problems.

Readers dropped `_ng_no_persist` rows before picking a rollout's latest
attempt, so an older judge failure followed by an interrupted attempt was
counted again. Readers that take the latest attempt (scoring and batch status,
exporter metrics, run health, gym dev compare) now let an interrupted row be
the latest execution and then set it aside, so the rollout is missing. A row
the dispatch budget never started never advances history.

The readers keyed on `_ng_no_persist`, which ATIF reverification already writes
on rows it counts, so ATIF failure counts and requested kill_shaped zeros
changed even with recording off. The rows record_omitted_rollouts writes now
carry `_ng_omitted_rollout`, and readers key on that; rows with only
`_ng_no_persist` are read as before.

Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>
…les-main

Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>

# Conflicts:
#	tests/unit_tests/test_rollout_collection.py
@marcromeyn
marcromeyn marked this pull request as ready for review October 26, 2026 18:48
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the needs-review PR is ready for code review and waiting on a reviewer label Oct 26, 2026
@AdamRajfer
AdamRajfer self-requested a review October 26, 2026 18:51
AdamRajfer
AdamRajfer previously approved these changes Oct 26, 2026
@marcromeyn

Copy link
Copy Markdown
Contributor Author

/ok to test 985b9ef

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Signed-off-by: Marc Romeyn <mromeijn@nvidia.com>
@marcromeyn

Copy link
Copy Markdown
Contributor Author

/ok to test 41ab898

@marcromeyn
marcromeyn added this pull request to the merge queue Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40394147664

@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40394677434

Merged via the queue into main with commit 948a6a5 Oct 26, 2026
67 of 70 checks passed
@marcromeyn
marcromeyn deleted the romeyn/record-omitted-rollouts branch October 26, 2026 20:51
yuhezhang-ai added a commit that referenced this pull request Oct 26, 2026
#5081's record_omitted_rollouts writes a sidecar row for a kill_shaped
attempt and for a row the dispatch budget never started. #3302's run
history already saves a kill_shaped attempt as an interruption that
spends no retry, so the option adds no second row for it. A row never
started still gets #5081's marked row, written through RolloutStore,
and the history skips it on load: it is not an attempt, and resume
runs the rollout.

Sidecars written without a manifest keep #5081's reader rules, and the
history reads their rows the same way: an interrupted row is the
rollout's latest execution and a row never started is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
yuhezhang-ai added a commit that referenced this pull request Oct 27, 2026
#4841 adds run.stats.timeouts to the run health summary:
- Run accounting keeps #3302's split: a run with a manifest is counted from
  its history, a legacy output from its sidecar. The legacy branch now reads
  the sidecar through #4841's _read_failures.
- The timeouts section reads each never-landed rollout's failed attempt from
  the same source: the attempt the run history selects, or the legacy
  sidecar's last row.
- The built-in collector still writes the summary under its run lock, now
  with the run's model-call capture dirs, and exports the section;
  gym eval run and collect only check custom drivers, as before.
- Run timing classifies the record collection saves, after #3302's
  native episode validation.

#4638's per-run token-capture check replaces the per-row lookup, including
for recoverable judge failures.

Tests: #5081's accounting tests call #3302's _rollout_accounting signature;
#4841's collection fakes return a response, its aggregation recomputes in
place (a run is never merged onto itself), and #4638's test stubs the
server client #3302's collector fingerprints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>

This branch was previously deployed

1 inactive deployment
public — 41ab898c Deployed Oct 26, 2026 by marcromeyn via release / finalize / notify #7403
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:evaluation Evaluation, rollout collection, reward profiling, comparison, and exporters feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants