Skip to content

Commit b68d05b

Browse files
halleriteclaude
andcommitted
fix(orchestrator): gate group-relative algorithms to one trainable seat
GRPO/MaxRL mean-center over the whole trainable cohort of a group. A multi-agent episode with several trainable seats would mix different roles' reward scales into one baseline — silently wrong credit. Refuse loudly instead: score_group now asserts the group's trainable traces share one agent name (assert_single_trainable_agent in algo/base.py). Single-trainable-seat multi-agent envs keep working unchanged: an agentic judge's solver cohort and best-of-n's n-attempts-per-episode are exchangeable samples of one seat, which is exactly what the baseline assumes. Echo inherits the gate via GRPO's score_group; per-rollout algorithms (opd/opsd/sft) are untouched. Verified: tests/unit/orchestrator/test_advantage.py + test_algorithms.py green (37 passed), ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 208ea64 commit b68d05b

5 files changed

Lines changed: 38 additions & 2 deletions

File tree

docs/algorithms.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ The per-token training signal is set by `algo.type` and the [algorithm](#the-alg
285285

286286
The default advantage is per-group reward minus per-group baseline (DR-GRPO without std normalization). For each prompt's group of `group_size` rollouts, every token in rollout $i$ receives advantage $s_i - \bar{s}$ where $\bar{s}$ is the group mean.
287287

288+
The group-relative algorithms (`grpo`, `max_rl`, `echo`) require the group's trainable traces to come from **one** agent — the baseline assumes exchangeable attempts by a single agent. Multi-agent envs work as long as exactly one agent is trainable (e.g. a trainable solver rewarded by a frozen agentic judge); a group spanning several trainable agent names is refused at scoring time. Credit assignment across multiple trainable agents is not something prime-rl prescribes — implement your own algorithm for it (see [Authoring an Algorithm](#authoring-an-algorithm)).
289+
288290
This is intentionally simple — it does the right thing for most envs. Write a named algorithm class when you need group-aware shaping that depends on trajectory metadata (sub-agent rollouts, relative-rank shaping, …) — see [Authoring an Algorithm](#authoring-an-algorithm).
289291

290292
A **length penalty** (`length_penalty` on the `grpo`-family algorithms) can be layered on top to discourage rambling. The `linear` penalty subtracts a single `pass_rate`-scaled penalty from each reward before the GRPO baseline, combining output tokens (`num_output_tokens_weight`), input / context tokens (`num_input_tokens_weight`), and turns (`num_turns_weight`) — each normalized by the group's own max for that quantity, with `num_input_tokens_weight` and `num_turns_weight` defaulting to `0.1`.

src/prime_rl/orchestrator/algo/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ async def connect_frozen_pool(
7878
return pool
7979

8080

81+
def assert_single_trainable_agent(group: list[Rollout]) -> None:
82+
"""Group-relative credit assumes the group is exchangeable attempts by one
83+
agent; a cohort spanning several trainable agents would mix different
84+
agents' reward scales into one baseline. What the right cross-agent credit
85+
assignment is is an open question — refuse instead of guessing."""
86+
names = {rollout.agent_name for rollout in group}
87+
if len(names) > 1:
88+
raise ValueError(
89+
f"group for env '{group[0].env_name}' spans trainable agents "
90+
f"{sorted(str(name) for name in names)} — group-relative advantages assume a "
91+
"single trainable agent. Mark the other agents untrainable in the env's "
92+
"setup() (agents.<name>.trainable = False), or implement an algorithm that "
93+
"assigns credit across agents."
94+
)
95+
96+
8197
class Algorithm:
8298
"""Base class for one env's training algorithm — the runtime of the
8399
algorithm config's per-token training signal (its sibling :class:`Sampler`

src/prime_rl/orchestrator/algo/grpo.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import torch
66

77
from prime_rl.configs.algorithm import GRPOAlgoConfig
8-
from prime_rl.orchestrator.algo.base import Algorithm
8+
from prime_rl.orchestrator.algo.base import Algorithm, assert_single_trainable_agent
99

1010
if TYPE_CHECKING:
1111
from prime_rl.orchestrator.types import Rollout
@@ -22,6 +22,7 @@ def __init__(self, config: GRPOAlgoConfig, policy_pool: InferencePool):
2222
self.length_penalty = config.length_penalty
2323

2424
async def score_group(self, group: list[Rollout]) -> None:
25+
assert_single_trainable_agent(group)
2526
rewards = torch.tensor([rollout.reward for rollout in group], dtype=torch.float32)
2627
length_penalty = self.length_penalty
2728
if length_penalty is None:

src/prime_rl/orchestrator/algo/max_rl.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import torch
66

7-
from prime_rl.orchestrator.algo.base import Algorithm
7+
from prime_rl.orchestrator.algo.base import Algorithm, assert_single_trainable_agent
88

99
if TYPE_CHECKING:
1010
from prime_rl.orchestrator.types import Rollout
@@ -24,6 +24,7 @@ class MaxRLAlgorithm(Algorithm):
2424
drops it, matching the paper's no-success convention)."""
2525

2626
async def score_group(self, group: list[Rollout]) -> None:
27+
assert_single_trainable_agent(group)
2728
rewards = torch.tensor([rollout.reward for rollout in group], dtype=torch.float32)
2829
mean = rewards.mean()
2930
advantages = torch.zeros_like(rewards) if mean <= 0 else (rewards - mean) / mean

tests/unit/orchestrator/test_advantage.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,22 @@ def test_max_rl_mean_normalized():
183183
assert _max_rl(_make_group(rewards=[1.0, 1.0])) == pytest.approx([0.0, 0.0])
184184

185185

186+
def test_group_algos_refuse_multiple_trainable_agents():
187+
"""One baseline can't span agents: a group mixing trainable agent names is
188+
refused; a same-agent multi-trace cohort stays exchangeable and passes."""
189+
190+
def _agent_rollout(name: str, reward: float) -> Rollout:
191+
rollout = _build_rollout(reward, sampled_lengths=[2])
192+
rollout.agent = vf.AgentInfo(model="policy", name=name)
193+
return rollout
194+
195+
for drive in (_grpo, _max_rl):
196+
with pytest.raises(ValueError, match="single trainable agent"):
197+
drive([_agent_rollout("solver", 1.0), _agent_rollout("opponent", 0.0)])
198+
advs = _grpo([_agent_rollout("solver", 1.0), _agent_rollout("solver", 0.0)])
199+
assert sum(advs) == pytest.approx(0.0, abs=1e-6)
200+
201+
186202
# --------------------------------------------------------------------------
187203
# GRPO linear length penalty: pass_rate-scaled penalty before the baseline.
188204
# --------------------------------------------------------------------------

0 commit comments

Comments
 (0)