group rewards

environment Mar 19, 2026 6 min read

compute_group_rewards is called once per prompt-group. it receives every completed rollout in the group (a sequence of BaseRollout), letting you compute rewards that depend on cross-rollout context. it runs alongside compute_reward, not instead of it; the trainer merges both reward dicts. return a mapping keyed by rollout id:

from collections.abc import Mapping, Sequence

from benchmax.envs import BaseRollout, RewardMap

async def compute_group_rewards(
    self, rollouts: Sequence[BaseRollout]
) -> Mapping[str, RewardMap] | None:
    ...
    return {r.rollout_id: {"component": score(r)} for r in rollouts}

each RewardMap value is just a dict of component name to float score. as with compute_reward, the transcript is rollout.messages and per-example data is rollout.example_args; all rollouts in a group share one example. per-rollout and group reward component names must not overlap.

keep every component non-negative, the same as compute_reward. see best practices.

pairwise win-rate

judges are usually better at “A beats B” than at producing calibrated absolute scores. fan out one judge call per pair, then aggregate into a win-rate per rollout.

import asyncio

from benchmax.rewards import extract_completion_text

async def compute_group_rewards(self, rollouts):
    texts = {r.rollout_id: extract_completion_text(r.messages) for r in rollouts}
    ids = [r.rollout_id for r in rollouts]
    wins = {rid: 0 for rid in ids}
    ground_truth = rollouts[0].example_args.get("ground_truth", "")

    # fan out all pairwise comparisons concurrently
    pairs = [(a, b) for i, a in enumerate(ids) for b in ids[i+1:]]
    results = await asyncio.gather(*[
        self._judge_pair(texts[a], texts[b], ground_truth) for a, b in pairs
    ])

    for (a, b), winner in zip(pairs, results):
        if winner == "a":
            wins[a] += 1
        elif winner == "b":
            wins[b] += 1

    max_wins = len(ids) - 1
    return {rid: {"win_rate": wins[rid] / max_wins} for rid in ids}

the async signature exists for exactly this fan-out pattern: all n*(n-1)/2 judge calls run in parallel.

rank transform

convert raw scores to in-group rank before they become advantages. this is robust to reward-model scale drift and outliers: a score of 9.8 when everyone else scored 9.7 matters; a score of 9.8 when everyone else scored 2.0 matters differently. ranking normalizes both cases.

async def compute_group_rewards(self, rollouts):
    raw = [
        self._score(
            extract_completion_text(r.messages),
            r.example_args.get("ground_truth", ""),
        )
        for r in rollouts
    ]

    # rank within group: 0.0 = worst, 1.0 = best
    order = sorted(range(len(raw)), key=lambda i: raw[i])
    ranks = [0.0] * len(raw)
    for position, idx in enumerate(order):
        ranks[idx] = position / (len(raw) - 1) if len(raw) > 1 else 1.0

    return {r.rollout_id: {"rank": rank} for r, rank in zip(rollouts, ranks)}

self-consistency (label-free)

when the example carries no ground truth, or it’s unreliable, majority consensus across the group acts as a pseudo-label. rollouts that agree with the majority get a positive signal; outliers get none.

from collections import Counter

async def compute_group_rewards(self, rollouts):
    answers = [
        self._extract_answer(extract_completion_text(r.messages)) for r in rollouts
    ]

    # find the most common answer
    counts = Counter(answers)
    majority, majority_count = counts.most_common(1)[0]

    # reward agreement with majority, scaled by how strong the consensus is.
    # outliers score 0, not a negative value (keep components non-negative).
    consensus_strength = majority_count / len(answers)
    return {
        r.rollout_id: {"consistency": consensus_strength if a == majority else 0.0}
        for r, a in zip(rollouts, answers)
    }

useful for tasks where you have prompts but no labels, or where ground truth is expensive to produce. works best when the group size is large enough (≥8) that the majority answer is meaningful.

test coverage / complementarity

reward passing the cases others missed. this pushes the group toward pass@k coverage (one rollout specializes in the edge cases another handles easily) rather than having all rollouts pile onto the same easy tests.

async def compute_group_rewards(self, rollouts):
    # passed[i] is a set of test-case ids that rollout i passed
    passed = [self._run_tests(r) for r in rollouts]
    pass_counts = Counter(tc for p in passed for tc in p)

    rewards = {}
    for r, p in zip(rollouts, passed):
        # tests passed by fewer rollouts are worth more
        complementarity = sum(1.0 / pass_counts[tc] for tc in p)
        # normalize by group size so scale is stable
        rewards[r.rollout_id] = {"complementarity": complementarity / len(rollouts)}

    return rewards

a test passed by only one rollout contributes 1/1 = 1.0; one passed by all contributes 1/n ≈ 0. the result incentivizes finding unique solutions rather than rediscovering the majority path.

diversity scaling

without diversity pressure, the model can collapse to a single strategy per prompt. scale_by_diversity groups the rollouts into clusters of similar responses, then divides each rollout’s reward by the size of its cluster. a strategy that five rollouts converge on has its reward split five ways; a unique strategy keeps full reward. this rewards exploring different approaches instead of piling onto one.

from benchmax.rewards import NgramDiversityConfig, extract_completion_text, scale_by_diversity

async def compute_group_rewards(self, rollouts):
    texts = [extract_completion_text(r.messages) for r in rollouts]
    raw = [
        {"diverse_quality": self._score(text, r.example_args)}
        for text, r in zip(texts, rollouts)
    ]
    scaled, cluster_info = await scale_by_diversity(
        rewards=raw,
        texts=texts,
        config=NgramDiversityConfig(),
    )
    return {r.rollout_id: reward for r, reward in zip(rollouts, scaled)}

note the scaled component gets its own name (diverse_quality): individual and group reward keys must stay disjoint, so don’t reuse a key that compute_reward already returns.

what counts as “similar” depends on the method:

methodcompareshowtradeoff
ngramsurface formcharacter n-gram overlap (Jaccard similarity), so responses that share wording cluster togetherfast, offline, no API calls. good when distinct strategies also read differently
llmmeaninga judge model groups responses by underlying tactic, so two differently-worded responses with the same approach still clustercatches paraphrases, but costs judge calls and needs a Judge

the config value selects the method and its parameters:

from benchmax.envs import InjectedAuth
from benchmax.rewards import Judge, LLMDiversityConfig, NgramDiversityConfig

# fast offline clustering
config = NgramDiversityConfig(similarity_threshold=0.5)

# semantic clustering via an LLM judge
config = LLMDiversityConfig(
    judge=Judge(
        model="gpt-5.4-mini",
        base_url="https://api.openai.com/v1",
        auth=InjectedAuth("judge"),
    ),
)

ngram clustering is offline and deterministic. a failed llm clustering call raises JudgeError, which the runtime records as an operational failure with zeroed reward keys for the group; it never silently mis-scores.

the telestich example trains with group rewards end to end, combining deterministic checks with an llm judge.