Skip to content

Reward providers

Come here when a rule reward cannot express what "better" means for your task. A reward provider scores one group of candidate completions and is the main extension point for online RL.

Primarily used by ../guides/grpo.md and ../guides/opd.md; ../guides/nft.md uses the image and video reward contracts instead. Provider implementations are supplied as operator-owned extension modules.

Posttrain owns reward execution, not customer reward semantics. A dataset supplies a canonical prompt and an optional opaque structured reference; the provider selected for the run interprets that reference after rollout has produced a completion.

Raw dataset conversion stays outside the reward framework. Configure rl.data.prompt_column and rl.data.response_column to map source columns onto the canonical prompt and reference fields, or use the existing schema-preserving rl.data.items_fn row map for an HF source. DataPacker remains responsible for model chat templating, tokenization, media shaping, packing, and collation; it is not a benchmark-schema adapter.

Data contract

Each prepared row needs one prompt column and, when the selected provider declares needs=("reference",), one non-null JSON-compatible reference column. Column names are customer choices; config maps them into the framework vocabulary. For example:

{
  "messages": [{"role": "user", "content": "Follow the supplied constraints."}],
  "reward_input": {"constraint_ids": ["customer_rule"], "arguments": {"count": 3}},
  "source_id": "stable-customer-row-id"
}
rl.data.path=/path/to/prepared.jsonl
rl.data.prompt_column=messages
rl.data.response_column=reward_input

The Env renames messages to canonical prompt and reward_input to opaque reference. The local JSONL loader retains only those mapped fields, so provenance or other provider inputs must live inside the structured reference. The HF _ColumnMapped path additionally preserves unmapped row columns in obs.representations. Core validates presence/cardinality but never interprets the reference schema. The customer provider reads the completion plus whichever canonical fields it declared in RewardProviderSpec.needs.

For a local JSONL, prepare this shape offline. For an HF source, rl.data.items_fn may deterministically rewrite row values before column mapping, but it must preserve the row's keys. Dataset-specific downloads, filtering, joins, deduplication, and schema interpretation remain customer-owned.

Group-native online provider contract

The online GRPO/OPD reward stage has one synchronous runtime contract. Every provider receives one immutable request containing every ordered candidate for exactly one complete group_id; there is no trajectory/group scope switch in RewardWorker:

class MyReward:
    def evaluate(self, request: RewardGroupRequest) -> RewardGroupResult:
        return RewardGroupResult(
            group_id=request.group_id,
            items=tuple(
                RewardCandidateResult(
                    trajectory_id=candidate.trajectory_id,
                    result=RewardProviderResult(
                        terms={"task": float(bool(candidate.completion.strip()))}
                    ),
                )
                for candidate in request.candidates
            ),
        )

Each RewardCandidate carries trajectory_id, completion text, an optional rollout-authored completion_token_count, and an optional rollout-authored finish_reason (the engine's verbatim verdict on how the completion ended, policy_info["finish_reason"]; None when the producer recorded none). The candidate.is_length_truncated property answers whether that verdict is exactly "length", i.e. the engine cut the completion at its token budget; None reads as "not known to be truncated", never as "stopped". It delegates to schema.is_length_verdict, the same comparison schema.is_length_truncated(policy_info) uses, so the reward boundary and the trainer's truncation mask can never disagree. RewardGroupRequest.context carries the group's common declared prompt and/or reference. Before calling custom code, the framework-owned bound adapter rejects an incomplete group, duplicate or missing identity, missing completion, obs.id/group_id disagreement, or inconsistent common inputs. Every result item must repeat the exact ordered trajectory ID and contain one valid RewardProviderResult. Candidate work may run concurrently inside a provider, but evaluate remains one synchronous group barrier.

Common-input identity is type-strict and structural after freezing: for example, JSON integer 1, boolean true, and floating-point 1.0 are distinct. A malformed candidate therefore cannot borrow another candidate's superficially equal prompt or reference.

Common inputs are detached from trajectory storage and deeply frozen: mappings are read-only and JSON sequences become tuples. A scorer that needs mutable containers must make its own private copy; mutating provider inputs can never mutate a trajectory or another provider's prepared request.

Training validates group cardinality against rl.group_size; held-out validation uses its own n_generation. Provider-name and optimized-term collisions fail at Worker construction. Scoring, terminal reward writes, group-relative annotation, and report construction form one rollback-safe transaction; a failure restores the batch's prior reward-stage state.

Pointwise scorer authoring

A rule that scores candidates independently does not implement a second provider kind. Write a typed candidate scorer and lift it through the framework's map_candidates adapter:

def score_candidate(
    candidate: RewardCandidate,
    context: RewardContext,
) -> RewardProviderResult:
    reference = context.reference
    if not isinstance(reference, str):
        raise RewardContractError("score_candidate needs a string reference")
    passed = float(reference.casefold() in candidate.completion.casefold())
    return RewardProviderResult(terms={"task": passed})


provider = map_candidates(score_candidate)

The adapter maps the function over the request in candidate order and produces explicitly correlated RewardCandidateResult items. Built-in arithmetic, GSM8K, countdown, exact-match, nonempty, and constant rewards all use this path. Comparative judges and the NVIDIA Inference Hub rubric provider implement the same group-native evaluate method directly. This gives custom code two concise authoring styles while preserving one catalog contract and one Worker execution path.

Only finite declared terms enter RewardCombiner and terminal reward["final"]. Reporting-only diagnostics are provider-local MetricContribution values and never enter reward or advantage computation. This contract is specific to the online GRPO/OPD reward stage; env-native step rewards and future process-reward placement are separate framework contracts.

Migrating an older pointwise provider

The former scope="trajectory", mode="sync", evaluate(Trajectory) authoring contract is not retained as a second runtime path. Move its candidate-local logic into a (RewardCandidate, RewardContext) -> RewardProviderResult function, declare the same needs, term, and diagnostic schemas in RewardProviderSpec, and use partial(map_candidates, scorer) as the factory. Code that genuinely compares candidates should instead implement group-native evaluate and return correlated result items. Catalog names and rl.reward selection do not otherwise change.

Run-bound catalog

Custom providers are supplied by a zero-argument, importable catalog factory. Each RewardProviderSpec declares the provider factory, required canonical inputs, revision, term names, fixed diagnostic names, and an optional JSON-compatible semantic identity. The catalog is resolved from the same dotted path during driver preflight and reward-actor setup; custom entries may add names but cannot override built-ins. The small rule scorers remain built in and are lifted with map_candidates; they are not a precedent for adding benchmark evaluator libraries to core.

The module containing the catalog must be importable on the driver and every reward actor. An unknown provider, import failure, factory contract mismatch, missing required input, undeclared term or diagnostic, or non-finite output fails before publishing a partially scored batch. Driver preflight constructs the selected training provider and, when enabled, the selected validation provider before model or rollout setup. Both the zero-argument catalog factory and provider factories must therefore be deterministic, lightweight, CPU-only, and side-effect free; model/GPU setup, network calls, and scoring belong outside construction. Reward actors independently resolve the same persisted catalog path and construct their own provider instances. Change the declared revision whenever provider code changes. The catalog also derives a canonical identity SHA-256 from semantic_identity; remote providers use it for judge revision, complete judge-content hash, timeout/retry/concurrency settings, credential reference, and failure policy. Held-out validation persists the catalog path, selected provider name, revision, and identity hash so a pending result cannot silently resume under different reward semantics. A credential reference is identity; the resolved secret value never is.

Provider diagnostic metrics use the unambiguous hierarchy train/reward_provider/<provider>/<diagnostic> (or the corresponding val/ namespace). The framework-owned sample count is <namespace>/reward_provider/<provider>/sample_count; provider and diagnostic names are separately validated path segments.

Training and held-out validation resolve the same catalog. Validation may select another provider name from that catalog through rl.validation.reward, but it cannot inject a second implementation under the same name.

Rank-local decoded-video adapter

Online WFM has one specialized provider adapter for the deployed DanceGRPO video service. It keeps the canonical group request/result boundary, fixed RewardProviderResult term/diagnostic schema, and provider-revision provenance. Its bound adapter maps each candidate through the singular scorer; it does not batch candidates into one service request or claim comparative group scoring. In the initial distributed WFM topology, every model rank owns exactly one trajectory, so production makes one service request per rank while the complete reward group exists only after the distributed advantage estimator gathers one scalar per rank.

The adapter receives a model-owned decoder and decodes the final UniPC latent locally under torch.no_grad(). The injected decoder must explicitly attest output_range="neg_one_to_one"; the adapter validates one floating, finite, materially in-range [1,3,T,H,W] result before mapping it to CPU uint8. It sends media_type="video", explicit FPS, and receives one positional score; the service does not echo or authenticate a client sample identity. The client instead retains the non-empty service UUID returned by enqueue and sends that exact UUID with every pull. Correlated 202 pending responses must carry that exact UUID and status="pending"; the only accepted legacy 400 pending shape is the exact Not found scores for <uuid> message. Every other 400 is terminal and is not polled again. A successful 200 contains only the singleton positional score. End-to-end client identity or idempotency therefore requires a future service-protocol extension and is not claimed by this adapter. The reward actor must provide curl, endpoint URLs, and its bearer token. Enqueue is never retried because the service exposes no idempotency key, so an ambiguous transport failure can leave orphaned remote work. Pull is the only retryable operation, and the service exposes no server-side cancellation. Transient pull failures use capped backoff and retry until the absolute evaluation deadline. Each transport process and the whole decode/enqueue/poll/result sequence have hard wall-clock bounds. A result is accepted only before the absolute deadline and only when its singleton score is finite. Video payloads are streamed from a private per-rank spool file. Set VideoRewardConfig.spool_dir to capacity-managed actor-local storage in production; None uses the platform temporary directory. Capacity must cover one decoded uint8 [1,3,T,H,W] sample per active rank plus the NumPy header. The production 121x704x1280 contract is about 312 MiB per rank (about 2.44 GiB across eight concurrent ranks), before unrelated runtime temporary storage.

VideoRewardConfig is the dependency-light value contract for this adapter, including the required provider revision, transport bounds, FPS, and the exact fp32 clamp-then-scale transform. The online-WFM entrypoint constructs the client and decoder in each owning rank, injects distributed failure convergence, and publishes the mergeable provider report together with globally complete advantage telemetry. Its recipe intentionally carries a rejected sentinel until the operator overrides it with the immutable deployed scorer revision.

The qualified T2I recipe adds ImageRewardConfig and a separate image adapter while reusing the same group-native bound-provider, correlation, deadline, diagnostic, and provenance contracts. Image conditioning must decode to finite floating [1,3,1,H,W] in [-1,1]; the adapter converts it to CPU uint8 [1,H,W,3], serializes with numpy.save(..., allow_pickle=False), sends media_type=image, and omits video_infos. The video request schema and default remain unchanged. See public NFT run guide for the image reward service contract.

What belongs outside core

Benchmark-specific checkers, copied third-party evaluator source, external model judges, their dependencies and credentials, source converters, suite configs, and full parity matrices belong to the customer module or an optional integration package. Core CI uses small fake providers to verify the catalog, binding, reporting, GRPO, and DPCA contracts without depending on a benchmark package.