Skip to content

fix(genrm): bound cohort deadlines and failure cleanup - #3351

Merged
ananthsub merged 10 commits into
mainfrom
yuhez/3181-genrm-cohort-lifecycle
Sep 23, 2026
Merged

ananthsub merged 10 commits into
mainfrom
yuhez/3181-genrm-cohort-lifecycle

Conversation

@yuhezhang-ai

@yuhezhang-ai yuhezhang-ai commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

This follows merged #2814, which established group, attempt, and member identities. It addresses #3181's immediate timeout and failure-cleanup requirements.

  • While collecting answers: enforce a finite deadline. If a member never arrives, fail the group and release waiting requests without publishing partial rewards.
  • Once all answers are available: retry transient judge HTTP failures, including interrupted response bodies, within a bounded budget using the same answers.
  • If judging ultimately fails: preserve each generated answer and return an explicitly masked failure. The zero reward is a placeholder, excluded from Gym evaluation metrics by default.
  • On failure, cancellation, or shutdown: release waiters and clean up pending work.

Recovery and compatibility

A judge failure returns HTTP 200 with mask_sample: true, failure_kind: judge_failed, a bounded failure_reason, and the existing failure-sidecar tags. The shared judge failsafe also preserves instance_config.mask_sample: true for older RL consumers. This shared response fix applies to every environment using judge_failsafe.

The existing evaluation opt-in count_failure_classes_as_zero: [judge_failed] still includes selected failures as zeros. Only the metric-input copy is unmasked; saved failure records and training masks remain unchanged. Online and offline aggregation apply the same policy, including when every rollout fails. With the opt-in, an all-judge_failed run reports mean/reward: 0.0; without eligible counted failures or successful results, collection still raises. The main rollout JSONL never receives synthetic successes.

A masked HTTP 200 result does not automatically make RL retry the group. It is a completed, unusable result for the current RL caller. If the caller chooses replacement:

  • Explicit-ID groups: advance the shared group-attempt number for every member and submit a complete replacement group.
  • Legacy groups without an ID: failed groups retain a failure record. Reusing their task/prompt key returns failure with migration guidance; recovery requires a fresh explicit group ID for the complete group. This prevents delayed old members from joining a replacement. Successful legacy groups still allow sequential new evaluations.

The judge-request default is now 1,800 seconds, matching the overall evaluation budget. This allows substantially more room for long generations and queueing than the previous 300-second default; deployments must still size both limits for their workload.

Required RL coordination: before NeMo RL adopts this Gym revision, coordinate with RL #4160 / #4061. Reading the mask alone is insufficient: TransferQueue must preserve it in the training loss mask, and unavailable judge rewards must be excluded from group statistics. #4160 includes these fixes with masked_reward_policy: exclude as the default. Top-level field ingestion remains separate migration work, so the nested compatibility field remains. Before updating RL's Gym revision, validate a real failed Gym response through RL's loss mask and group statistics. Gym can merge first if RL keeps its existing Gym revision until those fixes are available.

GenRM-specific usage and recovery documentation is now in the resources-server README, rather than a new Fern site page.

Closes #3181.

Validation

Current code: 6a7f30b707aee62c46478d757005f3fcc9fabe0d, incorporating main at d4c0f86c7. The latest fix is 059adb409.

  • 5,110 core, GenRM, and OSWorld dependency-policy tests passed on Python 3.13.14: python -m pytest tests/unit_tests resources_servers/genrm_compare/tests responses_api_agents/osworld_agent/tests/test_dependency_policy.py -m 'not sandbox' -q --tb=short. There were 103 skips, 673 sandbox tests deselected, and 184 passing subtests. Sandbox tests were not run locally.
  • The all-failure regression checks one and four masked judge failures, zero counting enabled/disabled, an unrelated counted class, and deferred aggregation. Four cases fail on the preceding code and pass with this fix; default and wrong-class error behavior remains intact. Existing mixed-success tests remain covered.
  • Actual TCP collector → SimpleAgent → GenRM tests check both collection timeout and judge outage, with zero counting on/off. Online and offline metrics match, the failure sidecar remains byte-for-byte unchanged, and the main/merged rollout JSONL stays empty.
  • Final all-files pre-commit, whitespace checks, and DCO sign-off passed. Exact-head CI passed, including core and all eight server shards. The preceding run passed core and seven server shards but failed OSWorld dependency resolution; merged fix(osworld): resolve Python 3.13 NumPy metadata conflict #3649 fixes that NumPy metadata conflict and is included in this head. Previous-head CI passed on be7e6353d, including core and all eight server shards.
  • No new model run for the latest collector aggregation fix: it changes metric eligibility, not policy generation or judging. The earlier real-model evidence below is from be7e6353d.
  • Real-model functional validation passed: Slurm job 19084624, exit 0 in 14m58s, on be7e6353d. Gym's native CLI/HTTP path used Qwen3-0.6B policy and Qwen3-Nemotron-235B-A22B-GenRM-2603 on eight H100s. Eight policy generations and four completed real judge calls were captured.
  • The run preserved four masked failure answers during an injected judge outage, then scored a caller-coordinated complete replacement attempt. Four HTTP 503s and four interrupted HTTP response bodies recovered within the retry budget without extra policy generation. A second collector resume made no further model calls. Failed legacy groups rejected delayed members, explicit zero-count aggregation preserved saved masks, and rewards exactly matched the captured judgments. Saved artifacts were independently inspected.
  • The model run enabled CUDA_LAUNCH_BLOCKING=1 for diagnostics after an earlier CUDA failure. That failure did not recur; this does not establish its root cause. The run's functional assertions passed before the separate launcher teardown issue described below.
  • No full RL training, checkpoint restart, or comparative throughput claim.
Limits and follow-ups

The caller coordinates complete replacement attempts. There is no partial scoring, automatic group scheduling, durable answer reuse, or change to generation/judge overlap. Legacy identities cannot isolate overlapping runs or provide completed replay. Explicit-ID replay and failure records are process-local and bounded by retention; eviction or restart is not a recovery protocol.

Ordinary individual reverification is unsupported. The existing judge_failed_only guard bypass does not create groups or advance attempts: retained failures remain failures until the caller supplies a complete replacement with the required identity.

Active-group memory limits, retention optimization, caller-minted group identities, durable outcomes, richer telemetry, and improvements to retry backoff remain follow-up work. The inherited judge-failed-only mode-guard bypass needs a separate compatibility change; documenting it does not make individual GenRM reverification supported.

A Ray teardown abort was previously reproduced on both main and an earlier PR head; its native cause remains undiagnosed. In the passing job 19084624, the Gym launcher again exited with SIGABRT after the functional assertions completed. The harness stopped the remaining owned children and recorded no survivors; the separate policy server exited normally and Slurm released the allocation. This validates the GenRM failure/recovery behavior, not a clean Ray launcher shutdown.

Related PRs: what is covered and what could follow

Built on #2814 (Anish Mahishi), with deadline and cleanup requirements from #2385 (Teodor-Dumitru Ene) and #2903 (Jiacheng Xu). Contributor credit remains in commit trailers.

#2385: the timeout requirement is covered. Its partial-scoring policy is not adopted: if A/B/C arrive but D is missing, this PR fails the incomplete group. Whole-group failure is allowed by #3181. Close #2385 as superseded if that policy is accepted; otherwise retain/rework partial scoring as separate follow-up work with explicit missing-member and training/metric semantics.

#2903: together, #2814 and this PR cover request deduplication, duplicate-disconnect safety, and failure cleanup. Three policy differences remain:

After this PR merges, maintainers can close #2903 as superseded if these choices are accepted, tracking desired early judging or broader replay/retry behavior separately. Alternatively, rework the existing PR against current main to focus on the remaining scope. Closing either older PR means its core problem is addressed and remaining choices are accepted or tracked, not that every proposed behavior was merged.

@copy-pr-bot

copy-pr-bot Bot commented Sep 13, 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.

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview your docs: https://nvidia-preview-yuhez-3181-genrm-cohort-lifecycle.docs.buildwithfern.com/nemo/gym

Here are the markdown pages you've updated:

@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test e4154b6

@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test cfc48b3

@yuhezhang-ai yuhezhang-ai changed the title fix(genrm): bound cohort scoring and isolate group attempts fix(genrm): bound cohort failures on the #2814 replay contract Sep 14, 2026
raveedturing pushed a commit to turing-rlgym/Gym that referenced this pull request Sep 15, 2026
## Summary

Fix GenRM comparison cohorts so physical retries cannot add duplicate
logical siblings or mix responses from different prompt-group attempts.

Previously, GenRM buffered requests in an append-only list keyed
primarily by task/prompt identity. A retry could therefore append
another physical response for an existing logical sibling, causing the
cohort to exceed `num_rollouts_per_prompt` or map rewards to the wrong
response.

This change introduces explicit logical cohort identity:
- `_ng_group_id`: stable identity for one logical prompt group.
- `_ng_group_attempt`: physical attempt of the entire prompt group.
- `_ng_rollout_index`: logical sibling slot within the group.

GenRM cohorts are now keyed by:
```text
(group_id, group_attempt)
```

Members within a cohort are keyed by:

```text
rollout_index
```

## Flow

                      First sibling arrives
                               │
                               ▼
                      ┌─────────────────┐
                      │   COLLECTING    │
                      │ Waiting for N   │
                      │ unique indices  │
                      └────────┬────────┘
                               │
                    All N siblings arrive
                               │
                               ▼
                      ┌─────────────────┐
                      │   EVALUATING    │
                      │ GenRM compares  │
                      │ the full cohort │
                      └────────┬────────┘
                               │
                    ┌──────────┴──────────┐
                    │                     │
                 Success               Unhandled cohort error
                    │                     │
                    ▼                     ▼
          ┌─────────────────┐   ┌─────────────────┐
          │    COMPLETED    │   │     FAILED      │
          │ Rewards cached  │   │ Waiters receive │
          │ by rollout_idx  │   │ controlled 503  │
          └─────────────────┘   └─────────────────┘

A conflicting response for an already occupied `rollout_index`, a stale
group attempt, or inconsistent prompt content is rejected rather than
silently creating another cohort member.

## Changes

- Replace the append-only cohort list with one authoritative member per
`rollout_index`.
- Isolate replacement cohorts using `(group_id, group_attempt)`.
- Track the newest attempt for each `group_id` and reject stale
requests.
- Supersede incomplete older attempts when a newer attempt arrives.
- Map returned rewards by `rollout_index`, independent of HTTP arrival
order.
- Allow identical duplicate requests to share the existing result.
- Return cached rewards for identical late duplicates.
- Reject conflicting duplicates with a controlled HTTP error.
- Release every waiter when cohort evaluation raises or is cancelled,
and when cohort collection times out.
- Do not retire a logical member merely because one HTTP client
disconnects.
- Retain compact completed/failed tombstones with configurable TTL and
size bounds.
- Release full response bodies after a cohort becomes terminal.
- Echo `_ng_group_id`, `_ng_group_attempt`, `_ng_task_index`, and
`_ng_rollout_index` through the shared
rollout-collection response boundary.

## Current failure behavior and follow-up

This PR preserves the existing GenRM judge fallback behavior. Judge
transport
errors and exhausted output-parsing retries caught by
`_run_single_comparison()` return the configured default scores. The
cohort is
therefore completed with fallback rewards rather than failed with HTTP
503.

A cohort enters the failed state and releases its waiters with HTTP 503
only
when an error escapes `_run_compare()`, evaluation is cancelled, or
cohort
collection times out.

`cohort_collection_timeout_s` optionally bounds the time spent waiting
for all
cohort members. This PR does not add an evaluation deadline or a
per-judge HTTP
deadline.

Follow-up NVIDIA-NeMo#3351 adds bounded collection, evaluation, and judge-request
deadlines; propagates judge transport failures as cohort failures; and
expands
failure cleanup and shutdown handling. It may also require an explicit,
run-unique `_ng_group_id` for multi-member verification. Those
behavioral
changes are intentionally outside the scope of this PR.

## Backward compatibility

Legacy requests without `_ng_group_id` continue using task/prompt-based
cohort identity.

When `_ng_group_id` is supplied but `_ng_group_attempt` is omitted, the
request is treated as attempt `0` and emits a migration warning.

The cohort registry remains process-local. GenRM comparison should
continue using one HTTP worker until this state is moved to shared
storage.

## Configuration

Adds:
- `cohort_collection_timeout_s`
- `cohort_result_ttl_s`
- `max_terminal_cohorts`

Collection timeout is optional so long-running tool rollouts are not
expired by default.

## Checklist

- [ ] I have read the [contributing
guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
- [ ] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [ ] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [ ] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format/copyright pass).
- [ ] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

---------

Signed-off-by: Anish Mahishi <amahishi@nvidia.com>
Add finite collection, evaluation and judge-request deadlines, drain owned
work on failure or shutdown, and reject judge transport failures without
publishing ordinary rewards. Preserve merged legacy sequential reruns and
explicit-ID replay, keeping group constants local to the resource server.

Cover collector failure artifacts and repeated legacy runs over real HTTP.
Accept null judge status and make malformed-answer fallback independent of
retry order. Document caller-owned group recovery and bounded retention.

Co-authored-by: Anish Mahishi <amahishi@nvidia.com>
Co-authored-by: Jiacheng Xu <jiachengx@nvidia.com>
Co-authored-by: Teodor-Dumitru Ene <teodord.ene@gmail.com>
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
@yuhezhang-ai
yuhezhang-ai force-pushed the yuhez/3181-genrm-cohort-lifecycle branch from 0851309 to 3491eed Compare September 16, 2026 03:15
@yuhezhang-ai yuhezhang-ai changed the title fix(genrm): bound cohort failures on the #2814 replay contract fix(genrm): bound cohort deadlines and failure cleanup Sep 16, 2026
@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test 3491eed

@yuhezhang-ai
yuhezhang-ai marked this pull request as ready for review September 22, 2026 13:36
@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

Thanks @ananthsub — the updates are in the current head, be7e6353d. Summary of the changes following your review:

  • The shared judge failsafe now returns the standard mask_sample, failure_kind, and bounded failure_reason, while retaining instance_config.mask_sample for older RL consumers. Tests cover the actual agent /run response.
  • Failed legacy groups retain a failure record, preventing delayed old answers from joining a replacement while that record is retained. Recovery requires a fresh explicit group ID; explicit-ID callers advance the shared attempt for the complete group.
  • The judge-request default is now 1,800 seconds. Transient HTTP errors and interrupted response bodies use bounded retries with the same answers.
  • The docs now live in the resources-server README and explicitly describe a masked HTTP 200 as a terminal result, with replacement coordinated by the caller. It does not automatically trigger RL regeneration.
  • A further regression fix preserves the evaluation opt-in to count judge failures as zero, without changing saved failure records or training masks.

Validation: CI passed, along with 4,845 local core tests and 192 GenRM tests. The real-model smoke passed on this head using Qwen3-0.6B and the supported 235B GenRM judge: eight policy generations and four completed judge calls. It verified preserved failed answers, complete replacement, recovery from HTTP/body interruptions, correct rewards, and no additional work on a second collector resume.

RL coordination remains required: the compatibility field alone does not fix the downstream loss-mask and group-statistics issues. RL #4160 contains those fixes and is still open. Before RL adopts this Gym revision, those fixes and a focused Gym-to-RL masking integration check are needed. Gym could merge first while RL retains its existing Gym revision; please confirm your preferred sequencing.

The known Ray launcher abort still occurred during teardown after the functional checks passed; cleanup stopped all owned processes. That limitation and the individual-reverification restrictions remain documented. Ready for your re-review.

@github-actions github-actions Bot added the sla:author-overdue Author response is over the one-business-day SLA label Sep 22, 2026

@ananthsub ananthsub 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.

looking really good! one comment inline

assert orjson.loads(metrics_fpath.read_bytes())[0]["key_metrics"] == {"mean/reward": expected_mean}

@pytest.mark.parametrize("count_judge_failure", [False, True])
async def test_masked_judge_failure_counts_as_zero_only_when_opted_in(

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.

Consider an evaluation with one rollout and this configuration:

route_failures_to_sidecar: true
count_failure_classes_as_zero:
  - judge_failed

The rollout returns:

{
  "reward": 0.0,
  "mask_sample": true,
  "_ng_failure_class": "judge_failed"
}

Online collection writes the row to the failure sidecar, then reaches the no-persisted-results guard and raises:

RuntimeError: None of the 1 dispatched rollouts produced a result

This happens before the opted-in failure is converted into a metrics-only zero at lines 1660–1673.

Running offline aggregation over the same artifacts succeeds and reports:

{"mean/reward": 0.0}

Therefore online and offline behavior still differs when every rollout belongs to an explicitly counted failure class. This parity test covers three successes plus one failure, so it does not reach the all-failure boundary.

Please determine the eligible metrics-only failure rows before applying the no-results guard. The guard should raise only when both are empty:

not persisted_results and not counted_failures

Keep the current failure behavior when count_failure_classes_as_zero is empty. Please add an all-judge_failed regression asserting that:

  1. online and offline aggregation both report mean/reward: 0.0;
  2. the saved sidecar remains masked and unchanged; and
  3. the main rollout JSONL remains free of synthetic results.

@github-actions github-actions Bot removed the sla:author-overdue Author response is over the one-business-day SLA label Sep 23, 2026
Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
…rt-lifecycle

Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test 7ffb0ef

…rt-lifecycle

Signed-off-by: Yuhe Zhang <yuhez@nvidia.com>
@yuhezhang-ai

Copy link
Copy Markdown
Contributor Author

/ok to test 6a7f30b

@ananthsub
ananthsub added this pull request to the merge queue Sep 23, 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/35895013332

Merged via the queue into main with commit 7c99590 Sep 23, 2026
41 checks passed
@ananthsub
ananthsub deleted the yuhez/3181-genrm-cohort-lifecycle branch September 23, 2026 17:34
katherineh123 pushed a commit to katherineh123/Gym that referenced this pull request Oct 2, 2026
… in the judge failure (NVIDIA-NeMo#3303)

## What does this PR do?

Names GenRM output-budget exhaustion in the judge failure instead of
reporting it as a generic "no completed answer", in
`resources_servers/genrm_compare`. No behaviour change: same retries,
same `JudgeError`, only the message and a per-attempt warning.

## Why

When the GenRM model spends its whole `max_output_tokens` on reasoning,
the model server returns HTTP 200 with a complete envelope and no
verdict: `status: "incomplete"`, `incomplete_details.reason:
"max_output_tokens"`. Since NVIDIA-NeMo#2814 and NVIDIA-NeMo#3351 the retry path already
handles this correctly (retry, then `JudgeError`), but the error reads
the same as an empty or garbled answer, so an operator cannot tell a
budget problem from a transport or parsing one without opening the raw
response.

Measured against a hosted GenRM endpoint with the candidates delivered
as `response_1`/`response_2` roles, temperature 0.6, same input each
time:

| `max_output_tokens` | runs | verdict (`status: completed`) | exhausted
(`status: incomplete`, `reason: max_output_tokens`, no verdict) |
|---|---|---|---|
| 16,384 | 5 | 2 | 3 |
| 24,576 | 4 | 2 | 2 |

The outcome is bimodal: the same input either converges at roughly 8 to
10k output tokens or runs away and consumes whatever budget it is given
(4 of 4 still exhausted at 49,152), so the fix belongs with the judge's
prompt or reasoning budget, and the message now points there.

## How

- `_output_budget_exhausted(raw_response)`: true when the Responses
object is `incomplete` with reason `max_output_tokens`.
- Each such attempt logs `GenRM output budget exhausted for pair (i, j)
(attempt a/n, max_output_tokens=N)`.
- When all attempts fail without a completed answer, the existing
`JudgeError` message gains `(k of them exhausted max_output_tokens=N
without emitting a verdict; raise the budget or constrain the judge's
reasoning)`. Completed-but-empty answers keep the unchanged generic
message.
- Tests: detection helper, all attempts exhausted -> message names the
budget, exhausted then verdict -> normal parse, empty completed answer
-> generic message. `resources_servers/genrm_compare/tests`: 35 passed.

Per review, the earlier opt-in budget escalation
(`genrm_budget_exhausted_retry_multiplier`,
`genrm_max_output_tokens_cap`) is dropped.

## Checklist

- [x] I have read the [contributing
guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally
(`resources_servers/genrm_compare/tests`, 35 passed).
- [x] Pre-commit checks pass locally (`pre-commit run --files ...`: all
Passed).
- [x] All commits have DCO sign-off (`git commit -s`).

Signed-off-by: Balaji Ganesan <bganesan@nvidia.com>

This branch was successfully deployed

1 active deployment
public — 6a7f30b7 Deployed Sep 23, 2026 by copy-pr-bot[bot] via release / finalize / notify #3567
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 bug Something isn't working complexity:high Cross-component or shared-contract change with a large review surface 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.

fix(genrm): bound incomplete cohorts and preserve cohort accounting

3 participants