Skip to content

feat(core): report infrastructure failure from verify, not just a reward - #2611

Merged
yuhezhang-ai merged 7 commits into
NVIDIA-NeMo:mainfrom
waple0820:feat/verify-response-mask-sample
Sep 18, 2026
Merged

yuhezhang-ai merged 7 commits into
NVIDIA-NeMo:mainfrom
waple0820:feat/verify-response-mask-sample

Conversation

@waple0820

@waple0820 waple0820 commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2608

BaseVerifyResponse carries a single scalar for the outcome, so an environment that knows it failed — lost session, unavailable judge, OOM-killed container, reset timeout — can only return 0.0, which is indistinguishable from a policy that genuinely scored zero.

The concept is not new to this repo. It exists in four private locations:

  • mask_sample: bool = False on SWEBenchWrapperInstanceConfig (responses_api_agents/swe_agents/app.py:272), also anyterminal_agent/app.py:108 and anyswe_agent/app.py:180, documented at swe_agents/README.md:670.
  • conversational_tool_use/simulation/app.py:112 keeps its own {"mask_sample": ...} dict.
  • nemo_gym/token_id_capture/delivery.py:49 already defines MASK_SAMPLE_KEY = "mask_sample" as a top-level rollout field, described there as what "a consumer reads to exclude a rollout from the loss".
  • NeMo-RL consumes only the first one, through full_result["instance_config"]["mask_sample"] (nemo_rl/experience/rollouts.py:229-241), which zeroes loss_multiplier in nemo_rl/algorithms/grpo.py:2104.

So core already agrees what the key is called and what it means; it is reachable only if your environment happens to be SWE-shaped or if token capture produced it. verl gets nothing.

This is the same objection raised on #2384 — that a fix "should be applied uniformly, to other benchmarks as well" rather than to one harness. Putting the field on the contract is what makes it uniform.

What this changes

1. The contract (nemo_gym/base_resources_server.py)

class BaseVerifyResponse(BaseVerifyRequest):
    reward: float
    mask_sample: bool = False              # is this reward a valid measurement
    failure_kind: Optional[str] = None     # which kind of failure, from #3179's vocabulary
    failure_reason: Optional[str] = None   # what happened, for a person

The three are orthogonal. mask_sample decides usability, failure_kind groups in logs and metrics, failure_reason explains one occurrence. A sample that degraded but was still measured validly names a kind and stays unmasked.

failure_kind validates against nemo_gym.failure_kinds and warns rather than rejects — an unregistered name is a migration signal, and failing the response over it would replace a visible wrong label with an invisible lost failure. An environment that needs something the shared set should not grow uses <server>:<kind>.

Gym states the fact and stops there. Masking, resampling, excluding from group statistics or dropping the group stays the training framework's decision — the three frameworks we checked genuinely differ.

swe_agents and anyswe_agent mirror their instance-config flag onto the contract field and keep the old location for one release. anyterminal_agent already surfaces it through TerminalBenchMetrics.

2. Something that reads it (nemo_gym/reward_profile.py, nemo_gym/rollout_collection.py)

A declaration nothing consumes is not worth much, and the consumer that matters is the one that publishes the score.

compute_aggregate_metrics() computes quality from the scored subset: the profiler input, a custom compute_metrics(), and key-metric selection all skip masked samples. Masked rows stay in the input and are reported as coverage instead — coverage/measured_rollouts|masked_rollouts and coverage/measured_tasks|fully_masked_tasks. A run where every sample is masked publishes coverage rather than inventing a zero, and a fully masked task is counted without contributing a group. mask_sample is stripped before profiling, so it is no longer averaged as though the flag were a measurement.

perf_summary deliberately keeps using every response, masked included: a masked sample still executed and still reported its latency, so observability coverage should not shrink because a rollout was excluded from scoring.

Progress telemetry gets the same split while a run is still going, over the results that reach the main rollout output:

  • progress/<agent>/reward is unchanged — it still averages over every rollout, so an existing dashboard reads the number it read before.
  • progress/<agent>/reward_unmasked is that same average with the masked rollouts removed.
  • progress/<agent>/masked_pct is how many were removed.
  • progress/<agent>/failed and /omitted count the attempts that never reach the main output at all, so a sidecar failure is never folded into a quality average as a reward of zero.

The gap between the two reward series is the score a run lost to its own infrastructure rather than to the policy. On the run that motivated this, 1172 of 1280 rollouts across steps 61–80 failed in session reset and entered GRPO as legitimate zeros; nothing in the metrics said so.

Every new key appears only once something is actually masked, failed or omitted, so a healthy run publishes exactly what it published before.

Scope

Run-level reconciliation is no longer in this PR. #3302 implements it for #2135, using the materialized inventory and attempt journal to account for omitted and unknown work offline — neither of which this contract can see. What remains here is the per-sample half.

Relationship to the other failure-handling work

Compatibility

Defaults are inert; an unmodified environment behaves identically, and a run that masks nothing publishes exactly the keys it published before.

One inheritance detail worth naming: AnySweVerifyResponse inherits mask_sample from both SWEBenchMetrics and BaseVerifyResponse, and the metrics model wins the MRO, so the contract field resolved to Optional[bool] = None with no description on that response. It is restated on the subclass so the schema keeps the contract's type, default and description.

Tests

  • tests/unit_tests/test_base_resources_server.py — defaults, round trip, that a plain reward=0.0 is not implicitly masked, and that a degraded-but-scored rollout stays unmasked.
  • tests/unit_tests/test_aggregate_metrics.py — a masked zero does not halve the score, the flag is not published as a quality metric, a run that masks nothing is byte-identical, a fully masked run reports coverage instead of a zero, a fully masked task is counted but not scored, custom compute_metrics() sees only scored samples, and a fully masked run still reports observability coverage.
  • tests/unit_tests/test_rollout_collection.py — the disposition account closes for every combination and the residual never goes negative; a healthy run adds no keys; a partially masked agent reports its share and the score without it; a fully masked agent publishes no average; failed and omitted attempts are counted, never averaged.
  • responses_api_agents/anyswe_agent/tests/test_app.py — an end-to-end /run that constructs and serializes the response. Reverting the fix makes it fail with TypeError: got multiple values for keyword argument 'mask_sample'.

uv run --extra dev pytest tests/unit_tests -q → 3383 passed. Two failures on this branch, test_e2b_provider and test_enroot_provider, reproduce on a clean upstream/main and are unrelated. pre-commit clean.

@copy-pr-bot

copy-pr-bot Bot commented Aug 19, 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.

@waple0820

waple0820 commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

@ananthsub rebased onto main and reshaped around the three fields as you described them here: #2552 (comment)

  • This PR is mask_sample only. failure_reason landed in feat: declare failure_reason on BaseVerifyResponse #2552, failure_kind belongs to feat: add shared failure-kind registry #2728 — it touches neither, and it does not re-declare what main already has.
  • The contract comment now states the orthogonality, including the case you named: a rollout that degraded but was still scored legitimately keeps mask_sample=False while naming a failure_kind/failure_reason.
  • Added the half that reads it. The flag was a declaration nothing consumed, so rollout_collection now reports progress/<agent>/masked_pct and progress/<agent>/reward_unmasked next to the existing progress/<agent>/reward, which is unchanged. The gap between the two reward series is the score a run lost to its own infrastructure rather than to the policy. Both keys appear only once something is actually masked, so a run that reports no masking exports exactly what it exported before.

Two things worth checking against your design:

nemo_gym/token_id_capture/delivery.py:49 already defines MASK_SAMPLE_KEY = "mask_sample" as a top-level rollout field meaning "exclude this rollout from the loss", and token capture already keeps a masked count with a max_mask_fraction abort. So core agrees on the name and the meaning; an environment that reports the same fact through verify is simply invisible to that accounting today. This PR routes it into the metrics but deliberately leaves the abort guardrail alone — coupling env-reported masking to max_mask_fraction looks like your call, not mine.

In #2728 session_lost is reserved with source="#2611". This PR does not produce that kind — it produces mask_sample. The producer for session_lost is the browser environment in #1865, and it will set both once the registry exists.

Ordering is yours: happy to land after #2728 and drop anything that overlaps, or before it if that is easier.

@ananthsub
ananthsub self-requested a review August 27, 2026 19:58
Comment thread nemo_gym/base_resources_server.py Outdated
class BaseVerifyResponse(BaseVerifyRequest):
reward: float

# True when ``reward`` does not reflect policy quality because the environment or its

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.

This description is too specific to RL training. Gym evaluation and other library callers can consume the same field, while loss masking, resampling, and group handling are only some possible policies. It is also an ordinary comment, so it will not appear in Pydantic's generated schema.

instead, lets add this with Field(description=...) that defines the result semantics: whether this completed sample should be excluded from evaluation scores and other downstream quality calculations because its reward is not a valid measurement of the evaluated system

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

One prerequisite this needed: AnySweVerifyResponse(SWEBenchMetrics, BaseVerifyResponse) inherits mask_sample from both, and SWEBenchMetrics wins the MRO, so on that response the field resolved to Optional[bool] = None with no description — the contract field was invisible in its schema whatever the base declared. Restated on the subclass so the type, default and description survive.

Comment thread nemo_gym/rollout_collection.py Outdated


def _masking_step_metrics(agent_name: str, total: int, unmasked: Counter) -> Dict[str, float]:
"""Progress metrics for the rollouts an environment reported as masked.

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.

This helper changes progress telemetry only; it does not make the final evaluation result mask-aware. Later, every persisted result is sent to /aggregate_metrics, where the generic profiler still averages masked rewards and treats mask_sample itself as a numeric metric. For example, a valid reward of 1 plus a masked reward of 0 still publishes mean/reward=0.5.

Please apply masking centrally in compute_aggregate_metrics(), including the input to custom compute_metrics() and key-metric selection. Masked rows should remain persisted for coverage, and fully masked tasks should publish coverage without inventing a zero quality score.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread nemo_gym/rollout_collection.py Outdated
metrics = agent_name_to_metrics[agent_name]
metrics.update({k: v for k, v in result.items() if isinstance(v, (int, float)) and not k.startswith("_")})
agent_name_to_counts[agent_name] += 1
if not result.get(MASK_SAMPLE_KEY):

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.

reward_unmasked currently includes failure-sidecar and non-persisted results because these counters are updated for every returned dictionary. Please update quality metrics only for results eligible for the main rollout output. Track failed and omitted results separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

reward=1.0 if metrics.resolved else 0.0,
# Report it on the contract as well; `instance_config.mask_sample` stays
# for one release so existing consumers keep working.
mask_sample=bool(instance_config.mask_sample),

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.

SWEBenchMetrics.model_dump() already contains mask_sample, so this explicit keyword followed by **metrics.model_dump() raises TypeError: got multiple values for keyword argument 'mask_sample' on every result. The value here also comes from instance_config, which remains at its default, while the computed decision is written to metrics.mask_sample.

Please resolve the value once from metrics.mask_sample, copy it into the existing compatibility instance_config field, exclude the key from the expanded metrics mapping, and pass it once at the top level.

An end-to-end /run test should construct and serialize the response so this path is covered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.
We added the end-to-end /run test. Reverting the fix makes it fail with exactly the TypeError you predicted.

@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-customer Waiting on the original author to respond label Aug 27, 2026
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 28, 2026
… telemetry

Addresses review on NVIDIA-NeMo#2611.

`mask_sample` moves from a comment to `Field(description=...)` so it reaches the
generated schema, and the description states the result semantics rather than one
consumer's policy: whether a completed sample should be excluded from evaluation
scores and other downstream quality calculations because its reward is not a
valid measurement of the evaluated system.

`compute_aggregate_metrics()` now applies that centrally. Quality metrics, the
input to a custom `compute_metrics()`, and key-metric selection all come from the
scored subset; masked rows stay in the input and are reported as coverage. A run
where every sample is masked publishes coverage instead of inventing a zero, and
`mask_sample` is no longer coerced into a numeric metric of its own. The keys
appear only once something is masked, so a run that masks nothing is unchanged.

Progress accounting is restricted to results that reach the main rollout output.
A sidecar failure and a kill-shaped omission are counted as such rather than
averaged in as rewards of zero.

`anyswe_agent` raised `TypeError: got multiple values for keyword argument
'mask_sample'` on every result, because `SWEBenchMetrics` already carries the key
that was also passed explicitly. It also read `instance_config.mask_sample`,
which keeps its default, while `_should_mask_sample()` writes the decision to
`metrics.mask_sample`. The value is now resolved once from the metrics, mirrored
onto the compatibility field, and passed once. `SWEBenchMetrics.mask_sample` is
declared `Optional[bool] = None` and wins the MRO on `AnySweVerifyResponse`, so
the contract field is restated there to keep its type, default and description.

An end-to-end `/run` test constructs and serializes the response, and fails with
the original `TypeError` without the fix.

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
@waple0820
waple0820 force-pushed the feat/verify-response-mask-sample branch from f95c306 to ceb910e Compare August 28, 2026 03:49
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 28, 2026
… telemetry

Addresses review on NVIDIA-NeMo#2611.

`mask_sample` moves from a comment to `Field(description=...)` so it reaches the
generated schema, and the description states the result semantics rather than one
consumer's policy: whether a completed sample should be excluded from evaluation
scores and other downstream quality calculations because its reward is not a
valid measurement of the evaluated system.

`compute_aggregate_metrics()` now applies that centrally. Quality metrics, the
input to a custom `compute_metrics()`, and key-metric selection all come from the
scored subset; masked rows stay in the input and are reported as coverage. A run
where every sample is masked publishes coverage instead of inventing a zero, and
`mask_sample` is no longer coerced into a numeric metric of its own. The keys
appear only once something is masked, so a run that masks nothing is unchanged.

Progress accounting is restricted to results that reach the main rollout output.
A sidecar failure and a kill-shaped omission are counted as such rather than
averaged in as rewards of zero.

`anyswe_agent` raised `TypeError: got multiple values for keyword argument
'mask_sample'` on every result, because `SWEBenchMetrics` already carries the key
that was also passed explicitly. It also read `instance_config.mask_sample`,
which keeps its default, while `_should_mask_sample()` writes the decision to
`metrics.mask_sample`. The value is now resolved once from the metrics, mirrored
onto the compatibility field, and passed once. `SWEBenchMetrics.mask_sample` is
declared `Optional[bool] = None` and wins the MRO on `AnySweVerifyResponse`, so
the contract field is restated there to keep its type, default and description.

An end-to-end `/run` test constructs and serializes the response, and fails with
the original `TypeError` without the fix.

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-customer Waiting on the original author to respond label Aug 28, 2026
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 28, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Aug 30, 2026
@waple0820
waple0820 force-pushed the feat/verify-response-mask-sample branch from ceb910e to df1d43f Compare August 31, 2026 06:15
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 31, 2026
… telemetry

Addresses review on NVIDIA-NeMo#2611.

`mask_sample` moves from a comment to `Field(description=...)` so it reaches the
generated schema, and the description states the result semantics rather than one
consumer's policy: whether a completed sample should be excluded from evaluation
scores and other downstream quality calculations because its reward is not a
valid measurement of the evaluated system.

`compute_aggregate_metrics()` now applies that centrally. Quality metrics, the
input to a custom `compute_metrics()`, and key-metric selection all come from the
scored subset; masked rows stay in the input and are reported as coverage. A run
where every sample is masked publishes coverage instead of inventing a zero, and
`mask_sample` is no longer coerced into a numeric metric of its own. The keys
appear only once something is masked, so a run that masks nothing is unchanged.

Progress accounting is restricted to results that reach the main rollout output.
A sidecar failure and a kill-shaped omission are counted as such rather than
averaged in as rewards of zero.

`anyswe_agent` raised `TypeError: got multiple values for keyword argument
'mask_sample'` on every result, because `SWEBenchMetrics` already carries the key
that was also passed explicitly. It also read `instance_config.mask_sample`,
which keeps its default, while `_should_mask_sample()` writes the decision to
`metrics.mask_sample`. The value is now resolved once from the metrics, mirrored
onto the compatibility field, and passed once. `SWEBenchMetrics.mask_sample` is
declared `Optional[bool] = None` and wins the MRO on `AnySweVerifyResponse`, so
the contract field is restated there to keep its type, default and description.

An end-to-end `/run` test constructs and serializes the response, and fails with
the original `TypeError` without the fix.

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 31, 2026
… telemetry

Addresses review on NVIDIA-NeMo#2611.

`mask_sample` moves from a comment to `Field(description=...)` so it reaches the
generated schema, and the description states the result semantics rather than one
consumer's policy: whether a completed sample should be excluded from evaluation
scores and other downstream quality calculations because its reward is not a
valid measurement of the evaluated system.

`compute_aggregate_metrics()` now applies that centrally. Quality metrics, the
input to a custom `compute_metrics()`, and key-metric selection all come from the
scored subset; masked rows stay in the input and are reported as coverage. A run
where every sample is masked publishes coverage instead of inventing a zero, and
`mask_sample` is no longer coerced into a numeric metric of its own. The keys
appear only once something is masked, so a run that masks nothing is unchanged.

Progress accounting is restricted to results that reach the main rollout output.
A sidecar failure and a kill-shaped omission are counted as such rather than
averaged in as rewards of zero.

`anyswe_agent` raised `TypeError: got multiple values for keyword argument
'mask_sample'` on every result, because `SWEBenchMetrics` already carries the key
that was also passed explicitly. It also read `instance_config.mask_sample`,
which keeps its default, while `_should_mask_sample()` writes the decision to
`metrics.mask_sample`. The value is now resolved once from the metrics, mirrored
onto the compatibility field, and passed once. `SWEBenchMetrics.mask_sample` is
declared `Optional[bool] = None` and wins the MRO on `AnySweVerifyResponse`, so
the contract field is restated there to keep its type, default and description.

An end-to-end `/run` test constructs and serializes the response, and fails with
the original `TypeError` without the fix.

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
@waple0820
waple0820 force-pushed the feat/verify-response-mask-sample branch from df1d43f to b05103d Compare August 31, 2026 06:15
waple0820 added a commit to waple0820/Gym that referenced this pull request Aug 31, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@waple0820
waple0820 force-pushed the feat/verify-response-mask-sample branch from b05103d to 2bbc5a3 Compare September 1, 2026 06:59
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 1, 2026
… telemetry

Addresses review on NVIDIA-NeMo#2611.

`mask_sample` moves from a comment to `Field(description=...)` so it reaches the
generated schema, and the description states the result semantics rather than one
consumer's policy: whether a completed sample should be excluded from evaluation
scores and other downstream quality calculations because its reward is not a
valid measurement of the evaluated system.

`compute_aggregate_metrics()` now applies that centrally. Quality metrics, the
input to a custom `compute_metrics()`, and key-metric selection all come from the
scored subset; masked rows stay in the input and are reported as coverage. A run
where every sample is masked publishes coverage instead of inventing a zero, and
`mask_sample` is no longer coerced into a numeric metric of its own. The keys
appear only once something is masked, so a run that masks nothing is unchanged.

Progress accounting is restricted to results that reach the main rollout output.
A sidecar failure and a kill-shaped omission are counted as such rather than
averaged in as rewards of zero.

`anyswe_agent` raised `TypeError: got multiple values for keyword argument
'mask_sample'` on every result, because `SWEBenchMetrics` already carries the key
that was also passed explicitly. It also read `instance_config.mask_sample`,
which keeps its default, while `_should_mask_sample()` writes the decision to
`metrics.mask_sample`. The value is now resolved once from the metrics, mirrored
onto the compatibility field, and passed once. `SWEBenchMetrics.mask_sample` is
declared `Optional[bool] = None` and wins the MRO on `AnySweVerifyResponse`, so
the contract field is restated there to keep its type, default and description.

An end-to-end `/run` test constructs and serializes the response, and fails with
the original `TypeError` without the fix.

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
@cafzal

cafzal commented Sep 5, 2026

Copy link
Copy Markdown

Independent evaluation evidence supports the core distinction in this PR: one rollout with an agent/infrastructure failure and its fresh recovery with a valid-but-wrong result both produced scalar reward 0. Treating both as ordinary zeros would hide invalid-execution denominator drift; treating both as failures would hide a real policy miss. A top-level, producer-owned masking fact is general across SQL, coding, browser, sandbox, tool-use, and judge-backed environments and avoids coupling consumers to a SWE-specific private config path.

The strongest validation properties are already reflected in this PR's plan: valid zero versus masked zero, inert defaults for unmodified environments, fully masked groups producing coverage rather than an invented score, custom metrics receiving only scored samples, and observability retaining all attempts. I would additionally preserve an exact reconciliation invariant across scored, masked, failed, omitted, and unknown dispositions as #2750 evolves.

Given that #2611 already owns the implementation, I will not open a duplicate PR for #2608.

@waple0820
waple0820 force-pushed the feat/verify-response-mask-sample branch from 4add419 to ca37a95 Compare September 15, 2026 05:45
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 15, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@yuhezhang-ai

Copy link
Copy Markdown
Contributor

/ok to test ca37a95

@yuhezhang-ai yuhezhang-ai 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.

Looks good to me. Thanks for addressing the review feedback and updating the server-test expectations! I reviewed the latest head (ca37a95a), and CI is now passing.

@ananthsub, could you please take a final look and make the merge decision?

@yuhezhang-ai
yuhezhang-ai added this pull request to the merge queue Sep 18, 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/35399030034

Merged via the queue into NVIDIA-NeMo:main with commit cba9a98 Sep 18, 2026
39 checks passed
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 18, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-maintainers Waiting on maintainers to respond label Sep 19, 2026
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 22, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
waple0820 added a commit to waple0820/Gym that referenced this pull request Sep 22, 2026
…coring it zero

`verify` returned `reward=0.0` whenever the browser session was missing, which is
the conflation this environment exists to demonstrate: a rollout whose session was
lost is not a policy that solved nothing, and downstream the two are identical.
It now reports `failure_reason` on that path, using the field NVIDIA-NeMo#2552 added to
`BaseVerifyResponse`.

A browser that died mid-episode was worse than mis-scored. `_score` reads the live
page, so a dead browser raised out of `verify`; only `JudgeError` is converted to a
routed row, so that exception ended the whole collection run rather than the one
rollout it belonged to. Browser reads are now wrapped and reported the same way.
An unsupported scoring key still raises, because a dataset typo is a configuration
error rather than an infrastructure failure, and failing on the first rollout is
the intended behaviour.

Building the response by spreading the request and also passing `reward` as a
keyword was a latent `TypeError: got multiple values`: `BrowserVerifyRequest`
allows extra fields, so a caller putting `reward` or `failure_reason` in the body
crashed verify. Response-owned fields are now dropped from the spread. The same
shape was found and fixed in `anyswe_agent` on NVIDIA-NeMo#2611.

Closing the previous browser on a re-seeded session swallowed every exception. A
browser we could not close is a resource the run still holds, so it is logged.

Tests build and serialize the real response, since the collision only appears at
construction time. Reverting any one of the three fixes fails a test.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
tanishku added a commit to tanishku/Gym that referenced this pull request Sep 22, 2026
… scoring them zero

Adopt the mask_sample/failure_kind contract from NVIDIA-NeMo#2611: an unparseable judge
response is an infrastructure/judge failure, not a policy one, so it's now
reported via mask_sample=True + failure_kind=JUDGE_UNPARSEABLE and excluded
from quality metrics as lost coverage. An empty model generation stays an
unmasked, genuine zero, per review from @yaoyu-33 on NVIDIA-NeMo#3348.

Signed-off-by: Tanish Kushalkar <38599503+tanishku@users.noreply.github.com>
pjin-nvidia pushed a commit that referenced this pull request Sep 29, 2026
Closes #3179

Gym describes failures with free text and component-local labels, so the
same failure reaches a collector, a log line and a metric under three
different names and cannot be grouped.

## What a name is, and what it is not

A name says what kind of thing went wrong and nothing else.
Retryability, replay safety, whether a completed result should be
masked, and what to tell a person are properties of the occurrence
rather than of the name — they belong to the failure record, the verify
response, and `failure_reason`.

Keeping them out is the reason the registry carries no per-entry
metadata: anything attached to a name goes stale the moment one caller
wants to retry what another does not. That is also what the acceptance
criterion "no retry-policy metadata that can become stale" asks for.

Names are `<domain>_<condition>`, so a reader can tell an unreachable
model server from an unreachable sandbox without a second field, and
they are low cardinality, so they are safe as a metric label or a span
dimension. `failure_reason` never is.

## Unknown names warn rather than raise

`validate_failure_kind` returns the name unchanged and logs once.
Rejecting an unregistered value would trade a visible wrong label for an
invisible dropped failure, which is worse than the problem the
vocabulary exists to fix, and it would break components mid-migration.
An environment that needs something the shared set should not grow can
use `<server>:<kind>`.

## The vocabulary describes the repo, not an intention

`agent_run_error`, `agent_request_failed` and `judge_failed` are labels
`main` emits today. Tests import those constants from
`rollout_collection` and `rollout_reverification` and assert they are
registered, so the set cannot drift from the code that produces it.

## Scope

This adds the vocabulary only. `failure_kind` reaching
`BaseVerifyResponse` is #2608's follow-on and lands with #2611, which
rebases onto whatever merges here. Typed rollout outcomes referencing
the same names are #2135.

## Relationship to #2728

This replaces rather than rebases #2728. That PR is on a fork branch we
cannot push to, and #3179's last acceptance criterion is "PR #2728 is
rebased or replaced under this issue". The vocabulary here is drawn from
what `main` produces now, which has moved since #2728 was drafted —
#2017 and #2883 introduced the `agent_request_failed` /
`agent_run_error` split. The `status`/`source` fields are dropped for
the reason above.

## Tests

16 cases: every name matches one convention and no two differ only by
case or separator; every name states the layer that observed the
failure; registered, unregistered and namespaced lookup; validation
staying silent on known names and warning without raising on unknown
ones; the set staying small enough to be a metric label and carrying no
occurrence detail; the labels `main` already produces staying
registered; and the module importing nothing from the server stack so
data tooling can read it without installing a server's requirements.

`uv run --extra dev pytest tests/unit_tests -q` → 4348 passed.
`test_e2b_provider` and `test_enroot_provider` fail on a clean
`upstream/main` too.

---------

Signed-off-by: waple0820 <232305951+waple0820@users.noreply.github.com>
Co-authored-by: waple0820 <232305951+waple0820@users.noreply.github.com>
(cherry picked from commit 4402098)
Signed-off-by: Peter Jin <pjin@nvidia.com>
@wdykas wdykas mentioned this pull request Oct 4, 2026
7 tasks done

This branch was previously deployed

1 inactive deployment
public — ca37a95a Deployed Sep 15, 2026 by copy-pr-bot[bot] via release / finalize / notify #2980
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request Issue reported or requested by someone from the community

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(core): define completed verifier-result failure semantics

5 participants