Skip to content
5 changes: 5 additions & 0 deletions nemo_rl/algorithms/single_controller_utils/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
CheckpointManager,
validate_warm_start_checkpoint,
)
from nemo_rl.utils.logger import should_log_nemo_gym_full_result_tables
from nemo_rl.weight_sync import WeightSynchronizer, create_weight_synchronizer


Expand Down Expand Up @@ -1428,6 +1429,10 @@ def _build_generation_then_trainer(
generation_config=generation_config,
use_nemo_gym=use_nemo_gym,
mask_env_flagged_samples=should_mask_flagged_samples(master_config.env),
log_full_result_tables=should_log_nemo_gym_full_result_tables(
wandb_enabled=master_config.logger["wandb_enabled"],
wandb_config=master_config.logger["wandb"],
),
reward_penalty_config=resolved_reward_penalty_config,
tq_buffer=tq_buffer,
timeouts=RolloutTimeouts(
Expand Down
15 changes: 10 additions & 5 deletions nemo_rl/experience/rollout_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ def __init__(
stats: Optional[RolloutStats] = None,
# Length-based reward shaping for low-effort prompts; None disables it.
effort_config: Optional[EffortLevelsConfig] = None,
log_full_result_tables: bool = False,
**kwargs: Any,
) -> None:
self._tokenizer = tokenizer
Expand All @@ -786,6 +787,7 @@ def __init__(
self._max_rollout_turns = max_rollout_turns
self._generation_config = generation_config
self._mask_env_flagged_samples = mask_env_flagged_samples
self._log_full_result_tables = log_full_result_tables
self._reward_penalty_config = reward_penalty_config
self._timeouts = timeouts if timeouts is not None else RolloutTimeouts()
self._max_gym_row_attempts = (
Expand Down Expand Up @@ -1156,10 +1158,11 @@ def _compute_rollout_metrics(
rollout_metrics.update(
calculate_single_metric(values, n, f"{agent_name}/{key}")
)
rollout_metrics[f"{agent_name}/full_result"] = Table(
data=[[json.dumps(r, separators=(",", ":"))] for r in agent_extras],
columns=["Full result"],
)
if self._log_full_result_tables:
Comment thread
yfw marked this conversation as resolved.
Resolved
rollout_metrics[f"{agent_name}/full_result"] = Table(
data=[[json.dumps(r, separators=(",", ":"))] for r in agent_extras],
columns=["Full result"],
)

# Necessary for downstream nemo rl logging/printing.
rollout_metrics["mean_gen_tokens_per_sample"] = rollout_metrics[
Expand Down Expand Up @@ -1187,6 +1190,7 @@ def __init__(
timeouts: Optional[RolloutTimeouts] = None,
retry_policy: Optional[RolloutRetryPolicy] = None,
effort_config: Optional[EffortLevelsConfig] = None,
log_full_result_tables: bool = False,
) -> None:
assert num_generations_per_prompt >= 1, (
"num_generations_per_prompt must be >= 1"
Expand Down Expand Up @@ -1220,8 +1224,9 @@ def __init__(
max_rollout_turns=max_rollout_turns,
policy_generation=policy_generation, # type: ignore
generation_config=generation_config,
# Only used by AsyncNemoGymRolloutImpl; AsyncRolloutImpl ignores it.
# Only used by AsyncNemoGymRolloutImpl; AsyncRolloutImpl ignores these.
mask_env_flagged_samples=mask_env_flagged_samples,
log_full_result_tables=log_full_result_tables,
reward_penalty_config=reward_penalty_config,
# None means "no deadlines", which is what async_rl's own defaults resolve
# to; callers that have a config pass the resolved values in.
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/experience/test_rollout_generation_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ def _make_gym_impl(
impl._stats = stats if stats is not None else RolloutStats()
# Upstream default; this fixture is about re-dispatch, not sample masking.
impl._mask_env_flagged_samples = True
# Full-result tables are likewise opt-in in the real constructor.
impl._log_full_result_tables = False
# Reward penalties are off; direct construction must still satisfy the impl contract.
impl._reward_penalty_config = None
# Effort-level reward shaping is off unless env.nemo_gym.effort_levels is set.
Expand Down
45 changes: 44 additions & 1 deletion tests/unit/experience/test_rollout_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,31 @@ def test_rollout_manager_forwards_mask_env_flagged_samples():
assert manager._impl._reward_penalty_config is reward_penalty_config


def _nemo_gym_impl(mask_env_flagged_samples, reward_penalty_config=None):
def test_rollout_manager_forwards_log_full_result_tables():
common = {
"tokenizer": None,
"task_to_env": {},
"num_generations_per_prompt": 1,
"max_seq_len": 1,
"generation_config": {
"stop_strings": None,
"stop_token_ids": None,
"top_k": None,
},
"use_nemo_gym": True,
}

assert RolloutManager(**common)._impl._log_full_result_tables is False
manager = RolloutManager(**common, log_full_result_tables=True)
assert manager._impl._log_full_result_tables is True


def _nemo_gym_impl(
mask_env_flagged_samples,
reward_penalty_config=None,
*,
log_full_result_tables=False,
):
return AsyncNemoGymRolloutImpl(
tokenizer=None,
task_to_env={},
Expand All @@ -591,6 +615,7 @@ def _nemo_gym_impl(mask_env_flagged_samples, reward_penalty_config=None):
"top_k": None,
},
mask_env_flagged_samples=mask_env_flagged_samples,
log_full_result_tables=log_full_result_tables,
reward_penalty_config=reward_penalty_config,
)

Expand Down Expand Up @@ -626,6 +651,24 @@ def test_result_to_completion_drops_mask_flag_when_gate_off():
assert completion.env_extras["instance_config"]["other_key"] == "kept"


@pytest.mark.parametrize("log_full_result_tables", [False, True])
def test_nemo_gym_full_result_tables_are_opt_in(log_full_result_tables):
impl = _nemo_gym_impl(True, log_full_result_tables=log_full_result_tables)
completion = Completion(
message_log=[
{"role": "user", "token_ids": [1]},
{"role": "assistant", "token_ids": [2, 3]},
],
env_extras={"reward": 1.0, "payload": "large"},
truncated=False,
reward=1.0,
)

metrics = impl._compute_rollout_metrics([completion], "agent")

assert ("agent/full_result" in metrics) is log_full_result_tables


def _reward_penalty_result(output, assistant_overrides=None, assistant_tokens=None):
assistant_message = {
"role": "assistant",
Expand Down
1 change: 1 addition & 0 deletions tests/unit/single_controller/test_checkpointing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,7 @@ def _setup_master_config(checkpoint_dir: str) -> MasterConfig:
val_at_start=False,
val_at_end=False,
),
logger={"wandb_enabled": False, "wandb": {}},
policy={
"train_global_batch_size": 8,
"max_total_sequence_length": 32,
Expand Down
1 change: 1 addition & 0 deletions tests/unit/single_controller/test_ppo_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def _make_master_config(
"save_period": 10,
"save_optimizer": False,
},
logger={"wandb_enabled": False, "wandb": {}},
loss_fn=ClippedPGLossConfig(reference_policy_kl_penalty=0.0),
env={},
cluster={"num_nodes": 2, "gpus_per_node": 8, "segment_size": None},
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/single_controller/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def _make_master_config(
"save_period": 10,
"save_optimizer": False,
},
logger={"wandb_enabled": False, "wandb": {}},
cluster={"num_nodes": 2, "gpus_per_node": 8, "segment_size": None},
loss_fn=loss_cfg if loss_cfg is not None else ClippedPGLossConfig(),
env=env if env is not None else {},
Expand Down Expand Up @@ -944,6 +945,29 @@ def test_effort_levels_reach_the_rollout_manager(self, patched_factories):
low_weight=1.0, low_penalty=2.0, low_ub=500, low_string="<budget>"
)

@pytest.mark.parametrize(
("wandb_enabled", "table_flag", "expected"),
[(False, True, False), (True, False, False), (True, True, True)],
)
def test_full_result_table_gate_reaches_the_rollout_manager(
self,
wandb_enabled: bool,
table_flag: bool,
expected: bool,
patched_factories,
):
mc = _make_master_config()
mc.logger = {
"wandb_enabled": wandb_enabled,
"wandb": {"log_nemo_gym_full_result_tables": table_flag},
}

with patch.object(sc_setup_mod, "RolloutManager") as mock_rollout_manager:
setup_single_controller(mc, MagicMock(pad_token_id=0))

_, call_kwargs = mock_rollout_manager.call_args
assert call_kwargs["log_full_result_tables"] is expected

@pytest.mark.parametrize(
"env",
[
Expand Down
53 changes: 52 additions & 1 deletion tests/unit/single_controller/test_tq_replay_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
from __future__ import annotations

import asyncio
import io
import threading
from typing import Any

import pytest
import torch
from wandb import Table

import nemo_rl.algorithms.async_utils.replay_buffer as _replay_buffer_module
from nemo_rl.algorithms.async_utils.replay_buffer import (
Expand All @@ -35,7 +37,8 @@
from nemo_rl.data_plane import KVBatchMeta
from nemo_rl.data_plane.schema import ROLLOUT_METRICS
from nemo_rl.distributed.batched_data_dict import BatchedDataDict
from nemo_rl.experience.interfaces import PromptGroupRecord
from nemo_rl.experience.interfaces import Completion, PromptGroupRecord
from nemo_rl.experience.rollout_manager import AsyncNemoGymRolloutImpl

# Each record yields _N_GENS training rows.
_N_GENS = 2
Expand Down Expand Up @@ -983,6 +986,54 @@ def test_native_tq_round_trip_restores_index_without_reputting_rows(self):
]
assert restored_dp.put_calls == []

def test_checkpoint_serialization_preserves_full_result_table(self):
"""Opt-in NeMo Gym tables remain usable after the torch checkpoint round trip."""
gym_impl = AsyncNemoGymRolloutImpl(
tokenizer=None,
task_to_env={},
num_generations_per_prompt=1,
max_seq_len=16,
max_rollout_turns=1,
generation_config={
"stop_strings": None,
"stop_token_ids": None,
"top_k": None,
},
log_full_result_tables=True,
)
rollout_metrics = gym_impl._compute_rollout_metrics(
[
Completion(
message_log=[
{"role": "user", "token_ids": [1]},
{"role": "assistant", "token_ids": [2]},
],
env_extras={"reward": 1.0, "status": "completed"},
truncated=False,
reward=1.0,
)
],
"agent",
)
assert isinstance(rollout_metrics["agent/full_result"], Table)

buf = _make_buffer(FakeDataPlaneClient())
_add_group(buf, weight=1, rollout_metrics=rollout_metrics)

payload = io.BytesIO()
torch.save(buf.metadata_state_dict(saved_capacity=8), payload)
payload.seek(0)
restored_state = torch.load(payload, weights_only=False)

restored_buf = _make_buffer(FakeDataPlaneClient())
assert _load(restored_buf, restored_state) == 1
restored_table = restored_buf.meta_list[0].extra_info[ROLLOUT_METRICS][0][
"agent/full_result"
]
assert isinstance(restored_table, Table)
assert restored_table.columns == ["Full result"]
assert restored_table.data == [['{"reward":1.0,"status":"completed"}']]

def test_round_trip_preserves_end_weight_and_target_step(self):
# start != end and a non-None target_step must survive the round-trip:
# a load that swapped start/end or dropped target_step (the
Expand Down
Loading