feat: log toolcall and thinktag violation rate into single controller - #3765
Conversation
arnavk-nvidia
left a comment
There was a problem hiding this comment.
Automated review found one bug that will fail CI.
Generated by Claude Code
Signed-off-by: Arnav Kundu <arnavk@nvidia.com>
…p_metrics test Positional [0] was landing in the seq_logprob_error_metrics parameter instead of num_invalid_tool_calls, causing a TypeError instead of the asserted empty dict. Signed-off-by: Arnav Kundu <arnavk@nvidia.com>
76b70ce to
c51e9ff
Compare
|
/ok to test c51e9ff |
yfw
left a comment
There was a problem hiding this comment.
Thanks for closing this gap — the SC path had no violation telemetry while the sync GRPO path did, and riding the documented tags sidecar with metric names matching grpo.py is the right shape for it. Nice quick turnaround on the keyword-arg fix in c51e9ff too.
2 action items, 1 follow-up. Findings re-verified against c51e9ff after the force-push.
The branch is still red on tests it doesn't touch. addopts carries -x, so a plain run stops at the first failure and hides the rest:
$ uv run --group test pytest tests/unit/single_controller/ tests/unit/experience/ -q --maxfail=0
7 failed, 702 passed
AI-1 — _train_pump_controller needs the new _step_log_dict keys
All 7 remaining failures are test_train_pump_* / test_advantage_stage_* in tests/unit/single_controller/test_single_controller_actor.py — a file this PR doesn't touch — and they share one cause:
for tag in meta.tags or []:
for key in VIOLATION_TAG_KEYS:
> self._step_log_dict[key].append(int(tag.get(key, 0)))
E KeyError: 'num_invalid_tool_calls'
nemo_rl/algorithms/single_controller.py:1967: KeyError
The new drain loop hard-indexes _step_log_dict. Production seeds every key at single_controller.py:313-319, but _train_pump_controller hand-builds it with four at line 1112, and its metas carry tags, so the loop actually runs. That file is unchanged from main, so this is a regression from the production change rather than a stale test.
Action: add the keys to the fixture, plus the import. Verified locally — 45 passed on that file, from 7 failed.
# tests/unit/single_controller/test_single_controller_actor.py
from nemo_rl.experience.payload import VIOLATION_TAG_KEYS
...
ctrl._step_log_dict = {
"rewards": [],
"masked_advantages": [],
"sequence_lengths": [],
"seq_logprob_error_metrics": [],
**{key: [] for key in VIOLATION_TAG_KEYS},
}Please fix in this PR — CI stays red otherwise. Worth fixing in the fixture rather than by moving the drain loop below the _advantage_estimator is None early-out: the other _advantage_stage tests pass only because their metas have no tags, which is a fragile reason to be green.
AI-2 — one comment on VIOLATION_TAG_KEYS
VIOLATION_TAG_KEYS is now load-bearing in four independent places: the tuple itself, the __init__ seed, the reduce_advantage_pump_metrics signature, and that test fixture. Nothing links them statically — the **self._step_log_dict splat at single_controller.py:1081 is opaque to both ruff and pyrefly. The KeyError above is one failure mode; adding a 4th key to the tuple without editing the utils.py signature is the other, and it surfaces as TypeError: ... unexpected keyword argument at the first train step.
Action: a one-line comment on the tuple ("keep in sync with reduce_advantage_pump_metrics kwargs"). Explicitly not asking for a refactor — collapsing the three kwargs into a single dict parameter moves the complexity rather than removing it, and it would break the deliberate metric-name parity with grpo.py that motivates this PR.
Follow-up — an assertion on the drained counts
Nothing currently asserts what the drain loop does: the other _advantage_stage tests build metas without tags, so it runs zero iterations, and the 7 above only enter it incidentally. Since you're editing that fixture anyway, an assertion on the drained counts would also pin the tag.get(key, 0) default for rows enqueued before this feature — the rolling-upgrade case. Fine as a follow-up if you'd rather keep this PR tight.
Context — no action
ruff check and pyrefly check are clean on all changed files. We looked at these and are explicitly not asking for them: popping the non-tensor violation_counts key out of the BatchedDataDict (it's inert — pack_payload filters on isinstance(v, torch.Tensor) and the batch is dropped right after); using stamp_tags instead of pack_payload (wrong lifecycle — stamp_tags is for scalars derived later in the train loop); and unifying _violation_counts with grpo's counting loop (that loop also writes penalty advantages and threads token_offset, so merging them would be over-abstraction).
Generated by Claude Code
…cstring Signed-off-by: Arnav Kundu <arnavk@nvidia.com>
|
/ok to test 8c2ba3d |
Test doubles that hand-construct _step_log_dict (bypassing __init__) predate the violation-count keys, so _advantage_stage raised KeyError on them. Use setdefault instead of a direct index. Also updates test_rollout_pump_writes_expected_tq_data's tag-schema assertion, which asserted the old weight_version-only tag shape and no longer matches pack_payload's output now that it stamps violation counts too. Signed-off-by: Arnav Kundu <arnavk@nvidia.com>
|
/ok to test af2c889 |
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
|
/ok to test 59fb697 |
PR3 (#3591) was SQUASH-merged into main as b3b6713, so none of its commits are ancestors of main while PR4 still carries all of them. Git therefore sees PR3's whole diff as independently added on both sides, which is why all 16 conflicts name b3b6713 and why the PR showed CONFLICTING despite the content being identical. That made the classification, not the content, the work. For each conflicted file: is main's version byte-identical to PR3's head (3d9ce21), and does PR4 add anything beyond it? Three groups fell out. GROUP A -- pure squash artefacts, resolved by taking OURS (10 files) fleet_health.py, collective_weight_synchronizer.py, membership.py, nccl_reshard_weight_synchronizer.py, grpo_sc_generation_shard_recovery.sh, test_watchdog_pump.py, test_membership.py, test_reconcile_communicator.py, test_reshard_rebuild.py, test_weight_synchronizer.py main == PR3 exactly and no other PR touched them, so PR4's side is main's content plus PR4's delta. Taking ours loses nothing. GROUP B -- PR4 contributes nothing, resolved by taking THEIRS (2 files) single_controller_utils/setup.py (#3480, #3727, #3821 on top of PR3) tests/unit/single_controller/test_refit_recovery.py (#3480 on top of PR3) GROUP C -- genuine merges (4 files), one per upstream PR below. The six upstream PRs that contributed real content, and what each needed: #3480 recover replay buffer from native TQ checkpoints single_controller.py: rollout_recovery imports. Kept alongside ours. setup.py, test_refit_recovery.py, L1 harness: group B / additive. #3765 log toolcall and thinktag violation rate single_controller.py: VIOLATION_TAG_KEYS. Auto-merged, verified present. #3727 support non-colocated MInf single_controller.py: MegatronGeneration import, kept alongside ours. L1 harness: grpo_megatron_generation_gym_single_controller.sh entry. #3821 warm-start the value model from a critic-pretrain checkpoint config.py: the max_num_epochs validator. Ours only adds restart_dead_shards to FleetHealthConfig, so both survive; verified the field landed in the right class and the validator is intact. #3655 nemo-lens telemetry vllm_generation.py: the @trace_fn decorator on generate. Ours adds restart_shard in a different region; both kept. #3839 pause generation during in-flight refit vllm_generation.py: pause_generation_for_refit / resume_generation_after_refit. Auto-merged, verified present -- worth knowing it exists, since it pauses engines around a refit and this PR restarts them. Verified after resolving: no conflict markers; all four lint hooks clean (the single pyrefly error is the pre-existing unrelated transfer_queue import); 1122 unit tests pass; both submodule pointers and uv.lock/pyproject byte-identical to main. Both sides' work was checked individually rather than assumed: EngineSupervisor wiring, restart_dead_shards, restart_shard, recreate_worker, desired_membership and the report_refit call on our side; the six items above on main's. Note for anyone reproducing locally: #3655 adds a nemo-lens dependency that the pre-merge container image does not carry, so tests fail at import with ModuleNotFoundError: nemo until the venv is refreshed. Plain upstream/main fails the same way in that image; it is not a merge defect. Signed-off-by: asolergibert <asolergibert@nvidia.com>
What does this PR do ?
Logs metrics of toolcall and think tag violation rate in single controller similar to the one existing in grpo.py.
In single controller the message log gets dropped hence the metrics are calculated in payload itself.
Issues
List issues that this PR closes (syntax):
Usage
# Add a code snippet demonstrating how to use thisBefore your PR is "Ready for review"
Pre checks:
Additional Information