# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared reward calculations and logging for verified capture rows.
Capture observations retain each valid row's final reward until its group is
consumed, allowing exact pooled medians, histograms and agent reward logging.
Optional full-result JSON is retained only when full-result logging is enabled.
"""
from __future__ import annotations
import json
import statistics
from collections.abc import Callable, Container, Iterable, Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any
from nemo_rl.experience.metric_utils import calculate_single_metric
if TYPE_CHECKING:
from nemo_rl.algorithms.grpo import RewardPenaltyConfig
from nemo_rl.experience.rollouts import EffortLevelsConfig
[docs]
def has_duplicated_reasoning(output_items: list[dict[str, Any]]) -> bool:
"""Detect nonempty reasoning copied into immediately adjacent content."""
for item1, item2 in zip(output_items, output_items[1:]):
if item1.get("type") != "reasoning":
continue
summary = item1.get("summary", [])
if not summary or "text" not in summary[0]:
continue
reasoning_text = summary[0]["text"].strip()
content = item2.get("content", "")
if isinstance(content, list) and content and "text" in content[0]:
chat_text = content[0]["text"].strip()
elif isinstance(content, str):
chat_text = content.strip()
else:
continue
if reasoning_text and chat_text and reasoning_text == chat_text:
return True
return False
[docs]
def has_empty_final_answer(output_items: list[dict[str, Any]]) -> bool:
"""Detect missing supported final content, except a final function call."""
# Skip if the last output item is a function_call — it is legit for model to
# produce reasoning and then a function_call as the last output item in PivotRL
if output_items and output_items[-1].get("type") == "function_call":
return False
final_answer_text = None
for item in reversed(output_items):
# Skip items without content (function_call, function_call_output, etc.)
if "content" not in item:
continue
content = item["content"]
if isinstance(content, list) and content and "text" in content[0]:
final_answer_text = content[0]["text"].strip()
break
elif isinstance(content, str):
final_answer_text = content.strip()
break
return final_answer_text is None or final_answer_text == ""
[docs]
def effort_shaping_enabled(config: EffortLevelsConfig | None) -> bool:
"""Return whether the configured low-effort adjustment is active."""
return config is not None and config.low_weight > 0 and bool(config.low_string)
[docs]
def is_low_effort(row: dict[str, Any], config: EffortLevelsConfig) -> bool:
"""Classify the original rollout input using the standard last-user rule."""
prompt = next(
(
msg["content"]
for msg in reversed(row["responses_create_params"]["input"])
if msg.get("role") == "user" and "content" in msg
),
"",
)
return config.low_string in prompt
[docs]
def shape_effort_reward(
reward: float, *, length: int, config: EffortLevelsConfig
) -> tuple[float, float]:
"""Return shaped reward and length term, preserving negative reward semantics."""
length_reward = min(1.0, config.low_weight * (1.0 - length / config.low_ub))
return (
reward
+ reward * max(length_reward, 0.0)
+ config.low_penalty * min(length_reward, 0.0),
length_reward,
)
[docs]
def has_unwanted_tokens(
generations: Iterable[Container[int]], unwanted_ids: Sequence[int]
) -> bool:
"""Check full generated sequences, including their terminal tokens."""
return any(
token in generation for generation in generations for token in unwanted_ids
)
[docs]
@dataclass(frozen=True)
class RewardChecks:
"""Scored-text and original-prompt checks saved with a rollout attempt."""
duplicated_reasoning: bool
empty_final_answer: bool
low_effort: bool
[docs]
def __post_init__(self) -> None:
if any(
type(value) is not bool
for value in (
self.duplicated_reasoning,
self.empty_final_answer,
self.low_effort,
)
):
raise ValueError("reward checks must be booleans")
[docs]
@dataclass(frozen=True)
class CapturePenalty:
"""One supported penalty's configuration, counter and logging contract."""
name: str
flag: str
metric: str
enabled: Callable[[RewardPenaltyConfig], bool]
text_match: Callable[[RewardChecks], bool] | None
CAPTURE_PENALTIES = (
CapturePenalty(
"duplicated_reasoning",
"penalize_duplicated_reasoning",
"reasoning_equal_to_final_answer_rate",
lambda config: config.penalize_duplicated_reasoning,
lambda checks: checks.duplicated_reasoning,
),
CapturePenalty(
"empty_final_answer",
"penalize_empty_final_answer",
"empty_final_answer_rate",
lambda config: config.penalize_empty_final_answer,
lambda checks: checks.empty_final_answer,
),
CapturePenalty(
"unwanted_token",
"penalize_unwanted_tokens",
"unwanted_token_rate",
lambda config: config.penalize_unwanted_tokens,
None,
),
)
[docs]
@dataclass(frozen=True)
class EffortRewardSettings:
"""Active effort parameters that affect saved reward interpretation."""
low_weight: float
low_penalty: float
low_ub: int
low_string: str
[docs]
@dataclass(frozen=True)
class CaptureRewardSettings:
"""Semantic checkpoint identity; user-config extras are never persisted."""
enabled_penalties: tuple[str, ...]
unwanted_tokens: tuple[int, ...]
effort: EffortRewardSettings | None
[docs]
@classmethod
def from_configs(
cls, penalties: RewardPenaltyConfig, effort: EffortLevelsConfig | None
) -> CaptureRewardSettings:
if penalties.penalize_malformed_think_tag:
raise ValueError("capture does not support penalize_malformed_think_tag")
unwanted: tuple[int, ...] = ()
if penalties.penalize_unwanted_tokens:
assert penalties.token_ids is not None
assert penalties.token_ids.unwanted is not None
unwanted = tuple(sorted(set(penalties.token_ids.unwanted)))
return cls(
tuple(spec.flag for spec in CAPTURE_PENALTIES if spec.enabled(penalties)),
unwanted,
EffortRewardSettings(
effort.low_weight, effort.low_penalty, effort.low_ub, effort.low_string
)
if effort is not None and effort_shaping_enabled(effort)
else None,
)
[docs]
def to_state(self) -> dict[str, Any]:
"""Serialize version 1 using the existing config field names."""
penalties: dict[str, Any] = {flag: True for flag in self.enabled_penalties}
if self.unwanted_tokens:
penalties["token_ids"] = {"unwanted": list(self.unwanted_tokens)}
return {
"version": 1,
"penalties": penalties,
"effort": asdict(self.effort) if self.effort is not None else None,
}
[docs]
@classmethod
def from_state(cls, value: object) -> CaptureRewardSettings:
"""Validate a version-1 record written by ``to_state``.
Sidecars always write version 1; the live config never passes through
this path (see ``from_configs``). Existing model defaults fill absent
config fields. A future change to reward semantics must explicitly
version/migrate this projection.
"""
# Config modules import this module for the shared calculations.
from nemo_rl.algorithms.grpo import RewardPenaltyConfig
from nemo_rl.experience.rollouts import EffortLevelsConfig
if not isinstance(value, dict) or set(value) != {
"version",
"penalties",
"effort",
}:
raise ValueError("invalid capture reward_settings record")
if type(value["version"]) is not int or value["version"] != 1:
raise ValueError(
f"unsupported capture reward_settings version={value['version']!r}"
)
penalties = RewardPenaltyConfig.model_validate(value["penalties"])
effort = (
EffortLevelsConfig.model_validate(value["effort"])
if value["effort"] is not None
else None
)
return cls.from_configs(penalties, effort)
[docs]
def require_compatible(self, current: CaptureRewardSettings) -> None:
"""Reject changed reward rules with actionable field names."""
differences = []
for spec in CAPTURE_PENALTIES:
if (spec.flag in self.enabled_penalties) != (
spec.flag in current.enabled_penalties
):
differences.append(f"penalties.{spec.flag}")
if self.unwanted_tokens != current.unwanted_tokens:
differences.append("penalties.token_ids.unwanted")
if self.effort != current.effort:
if self.effort is None or current.effort is None:
differences.append("effort.enabled")
else:
previous = asdict(self.effort)
for name, value in asdict(current.effort).items():
if value != previous[name]:
differences.append(f"effort.{name}")
if differences:
raise ValueError(
"capture reward settings differ from the checkpoint: "
+ ", ".join(differences)
)
[docs]
@dataclass(frozen=True)
class RewardLogContext:
"""Agent identity and optional full-result table data, without raw reward."""
agent_name: str
full_result_json: str | None
[docs]
def __post_init__(self) -> None:
if not isinstance(self.agent_name, str) or not self.agent_name:
raise ValueError("reward log agent_name must be a nonempty string")
if self.full_result_json is not None:
if not isinstance(self.full_result_json, str) or not isinstance(
json.loads(self.full_result_json), dict
):
raise ValueError("reward log full_result_json must encode an object")
[docs]
@dataclass(frozen=True)
class FinalizedReward:
"""A valid finalized sample's reward, retained until group consumption."""
sample_id: str
rollout_id: str
reward: float
log_context: RewardLogContext | None
[docs]
def __post_init__(self) -> None:
if not all(
isinstance(value, str) and value
for value in (self.sample_id, self.rollout_id)
):
raise ValueError("finalized reward identifiers must be nonempty strings")
if type(self.reward) not in (int, float):
raise ValueError("finalized reward must be numeric")
[docs]
@classmethod
def from_state(cls, value: object) -> FinalizedReward:
"""Validate checkpoint observations before using them for metrics."""
if not isinstance(value, dict) or set(value) != {
"sample_id",
"rollout_id",
"reward",
"log_context",
}:
raise ValueError("invalid finalized reward observation")
raw_context = value["log_context"]
context = parse_reward_log_context(raw_context)
return cls(value["sample_id"], value["rollout_id"], value["reward"], context)
[docs]
def parse_reward_log_context(value: object) -> RewardLogContext | None:
"""Validate the optional log context stored with an unfinished attempt."""
if value is None:
return None
if not isinstance(value, dict) or set(value) != {"agent_name", "full_result_json"}:
raise ValueError("invalid reward log context")
return RewardLogContext(value["agent_name"], value["full_result_json"])
[docs]
def compute_reward_checks(
result: dict[str, Any],
row: dict[str, Any],
effort_config: EffortLevelsConfig | None,
) -> RewardChecks:
"""Evaluate available checks before sealing, without changing the raw reward."""
response = result.get("response")
output = response.get("output", []) if isinstance(response, dict) else []
return RewardChecks(
has_duplicated_reasoning(output),
has_empty_final_answer(output),
is_low_effort(row, effort_config)
if effort_shaping_enabled(effort_config) and effort_config is not None
else False,
)
[docs]
def finalize_capture_reward(
reward: float,
*,
checks: RewardChecks | None,
penalty_config: RewardPenaltyConfig | None,
effort_config: EffortLevelsConfig | None,
token_ids: list[int],
link_spans: list[tuple[str, int, int]],
) -> tuple[float, dict[str, int], dict[str, float]]:
"""Shape, then penalize. Token IDs and spans must already be verified by Gym."""
counts = {spec.name: 0 for spec in CAPTURE_PENALTIES}
metrics: dict[str, float] = {}
shaping = effort_shaping_enabled(effort_config)
text_checks = penalty_config is not None and any(
spec.text_match is not None and spec.enabled(penalty_config)
for spec in CAPTURE_PENALTIES
)
if (shaping or text_checks) and checks is None:
raise ValueError("missing_reward_checks")
if shaping:
assert effort_config is not None and checks is not None
length = link_spans[-1][2]
bucket = "low" if checks.low_effort else "high"
metrics[f"finalize/effort/{bucket}/{length}"] = 1.0
if checks.low_effort:
reward, length_reward = shape_effort_reward(
reward, length=length, config=effort_config
)
metrics["finalize/effort/length_reward_sum"] = length_reward
metrics["finalize/effort/reward_sum"] = reward
if penalty_config is not None:
if checks is not None:
for spec in CAPTURE_PENALTIES:
if spec.text_match is not None:
counts[spec.name] = int(
spec.enabled(penalty_config) and spec.text_match(checks)
)
if penalty_config.penalize_unwanted_tokens:
assert penalty_config.token_ids is not None
unwanted_ids = penalty_config.token_ids.unwanted
assert unwanted_ids is not None
offset = 0
for _, carry, generated in link_spans:
end = offset + carry + generated
if has_unwanted_tokens(
(token_ids[offset + carry : end],),
unwanted_ids,
):
counts["unwanted_token"] = 1
break
offset = end
return (0.0 if any(counts.values()) else reward), counts, metrics
CAPTURE_PENALTY_METRICS = {spec.name: spec.metric for spec in CAPTURE_PENALTIES}
[docs]
def aggregate_capture_reward_metrics(
metrics: dict[str, list[float]],
observations: Sequence[FinalizedReward],
) -> dict[str, Any]:
"""Pool per-group finalizer counts and per-row rewards over valid rows.
``metrics`` holds the per-group finalizer metrics of the consumed groups;
``observations`` holds one retained reward per valid row of those groups.
Rates are computed over valid rows, never averaged across groups.
"""
count = len(observations)
if not count:
return {}
result: dict[str, Any] = {"finalize/reward_count": float(count)}
for category, metric in CAPTURE_PENALTY_METRICS.items():
key = f"finalize/penalty_count/{category}"
if key in metrics:
result[key] = sum(metrics[key])
result[metric] = result[key] / count
result.update(
calculate_single_metric(
[row.reward for row in observations], count, "total_reward"
)
)
agent_rewards: dict[str, list[float]] = {}
for row in observations:
if row.log_context is not None:
agent_rewards.setdefault(row.log_context.agent_name, []).append(row.reward)
for agent_name, values in agent_rewards.items():
result.update(
calculate_single_metric(values, len(values), f"{agent_name}/reward")
)
for bucket in ("low", "high"):
lengths = [
int(name.rsplit("/", 1)[1])
for name, values in metrics.items()
if name.startswith(f"finalize/effort/{bucket}/")
for _ in range(int(sum(values)))
]
if lengths:
result[f"mean_length_{bucket}"] = statistics.fmean(lengths)
result[f"median_length_{bucket}"] = float(statistics.median(lengths))
if bucket == "low":
result["mean_length_reward_low"] = sum(
metrics["finalize/effort/length_reward_sum"]
) / len(lengths)
result["mean_reward_low"] = sum(
metrics["finalize/effort/reward_sum"]
) / len(lengths)
return result
[docs]
def capture_reward_result_tables(
observations: Sequence[FinalizedReward],
) -> dict[str, list[list[str]]]:
"""Build the existing agent full-result table rows using final rewards."""
tables: dict[str, list[list[str]]] = {}
for row in observations:
context = row.log_context
if context is None or context.full_result_json is None:
continue
result = json.loads(context.full_result_json)
result.update(
reward=row.reward, ng_rollout_id=row.rollout_id, sample_id=row.sample_id
)
tables.setdefault(f"{context.agent_name}/full_result", []).append(
[json.dumps(result, separators=(",", ":"))]
)
return tables