Last verified: 2026-07-13
# GRPO Reward Function Library
Complete, runnable reward functions for TRL's
`GRPOTrainer`. Every function here follows the
current TRL reward-function signature: it accepts
`completions` plus any extra dataset columns as
keyword arguments, and returns a `list[float]` the
same length as `completions`. Base models are never
named here — `SFT_CHECKPOINT`/`JUDGE_MODEL` are
placeholders; see `finetuning-method-selection`'s
`references/model-catalog.md` for actual
checkpoints.
**These examples assume the standard (string)
completion format** — `completions: list[str]` — and
call `.strip()`, `json.loads()`, `.split()`, etc.
directly on each `completion`. TRL's conversational
dataset format instead passes each completion as
`[{"role": "assistant", "content": "..."}]`; on that
format, extract `completion[0]["content"]` before
applying any of the string operations below, in
every function in this file.
**Before wiring any of these into a training run,
inspect them against 50–100 sampled outputs by
hand** — this is `SKILL.md`'s Inspection Rule, not
optional. A reward function that looks correct in
isolation can still disagree with human judgment
on real model outputs.
## Format Reward
Checks structural compliance — did the completion
follow the required response shape at all — as a
prerequisite to grading correctness:
```python
import re
def format_reward(completions, **kwargs) -> list[float]:
"""1.0 if the completion has a ...
block followed by an ... block, else 0.0.
This is a gate, not the correctness signal — a
well-formed wrong answer still scores 0 on
correctness_reward below.
"""
pattern = re.compile(
r"^.*?\s*.*?$",
re.DOTALL,
)
return [1.0 if pattern.match(c.strip()) else 0.0 for c in completions]
```
## Correctness Reward — Exact Match
The baseline verifiable-answer reward, for tasks
with a single ground-truth string (math final
answers, closed-form lookups):
```python
def correctness_reward(completions, answer, **kwargs) -> list[float]:
"""`answer` is the ground-truth column from the
training dataset, aligned index-for-index with
`completions`. Extracts the block from
format_reward's expected shape and compares.
"""
rewards = []
for completion, gold in zip(completions, answer):
match = re.search(r"(.*?)", completion, re.DOTALL)
predicted = match.group(1).strip() if match else None
rewards.append(2.0 if predicted == gold.strip() else 0.0)
return rewards
```
## Correctness Reward — Schema Validation
For structured-output and tool-call tasks, where
"correct" means "conforms to the required JSON
schema," not string equality:
```python
import json
from jsonschema import validate, ValidationError
def schema_reward(completions, output_schema, **kwargs) -> list[float]:
"""`output_schema` is a JSON Schema dict, either a
single constant schema for the whole batch or a
per-example list the same length as `completions`.
Rewards valid, schema-conformant JSON; 0.0 for
anything that doesn't parse or doesn't validate.
"""
if isinstance(output_schema, dict):
# Constant case: one schema dict for every completion —
# zip()-ing a bare dict would iterate its keys instead,
# not the schema itself, so normalize first.
schemas = [output_schema] * len(completions)
else:
schemas = list(output_schema)
if len(schemas) != len(completions):
raise ValueError(
f"schema_reward: {len(schemas)} schemas for "
f"{len(completions)} completions"
)
rewards = []
for completion, schema in zip(completions, schemas):
try:
parsed = json.loads(completion)
validate(instance=parsed, schema=schema)
rewards.append(1.0)
except (json.JSONDecodeError, ValidationError):
rewards.append(0.0)
return rewards
```
## Correctness Reward — Unit Test Execution
For code-generation tasks, where "correct" means
the generated function passes a held-out test
suite. Execute in a subprocess with a hard
timeout — never `exec()` untrusted completions
in-process.
**WARNING — this function executes model-generated
code and REQUIRES an isolated environment:** a
network-disabled container, gVisor/firejail, or a
dedicated CI sandbox, with **no secrets or
credentials in the environment** — no HF tokens,
experiment-tracker keys, cloud credentials, or SSH
keys. GRPO will, by design, push adversarial
completions through this path as the policy
explores. Never run it directly on a training host
holding credentials. The timeout below protects
training-loop liveness only — **it is NOT a
security boundary**. Likewise, `TemporaryDirectory`
confines where the harness writes its files, not
what the executed code can read or reach. The
function below enforces this: it takes the sandbox
boundary as a required argument and refuses to run
at all — returning reward 0.0 — when the caller
doesn't supply one. It never falls back to host
execution.
```python
import logging
import subprocess
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def test_execution_reward(
completions, test_code, sandbox_cmd, timeout_s=10, **kwargs
) -> list[float]:
"""`test_code` is a per-example pytest snippet that
imports the candidate under a fixed module name
and asserts expected behavior. Runs each candidate
in its own subprocess with a wall-clock timeout;
an infinite loop or crash scores 0.0 instead of
hanging the training loop.
SECURITY: executes model-generated code. This
function REQUIRES an isolation boundary — it does
not run anything on the host by itself.
`sandbox_cmd` (list[str], required) is a command
prefix that wraps pytest in that boundary, e.g. a
network-disabled, resource-capped Docker container:
# sandbox_cmd = [
# "docker", "run", "--rm", "--network=none",
# "--memory=1g", "--cpus=1",
# "-v", f"{workdir}:/work:ro", "-w", "/work",
# "python:3.12-slim",
# ]
If `sandbox_cmd` is falsy, this function refuses to
execute anything and returns 0.0 for every
completion — it never falls back to running
pytest on the host. The subprocess environment is
scrubbed to a minimal PATH (no HF tokens,
experiment-tracker keys, cloud credentials, or SSH
keys). The timeout is a liveness guard for the
training loop, NOT a security boundary — isolation
comes entirely from `sandbox_cmd`; the temporary
directory only confines harness writes, not what
executed code can read or reach.
"""
if not sandbox_cmd:
logger.warning(
"test_execution_reward: no sandbox boundary provided "
"— refusing to execute model-generated code"
)
return [0.0 for _ in completions]
scrubbed_env = {"PATH": "/usr/bin:/bin"}
rewards = []
for completion, tests in zip(completions, test_code):
with tempfile.TemporaryDirectory() as tmp:
candidate_path = Path(tmp) / "candidate.py"
test_path = Path(tmp) / "test_candidate.py"
candidate_path.write_text(completion)
test_path.write_text(tests)
try:
result = subprocess.run(
[*sandbox_cmd, "python", "-m", "pytest",
str(test_path), "-q"],
cwd=tmp,
capture_output=True,
timeout=timeout_s,
env=scrubbed_env,
)
rewards.append(1.0 if result.returncode == 0 else 0.0)
except subprocess.TimeoutExpired:
rewards.append(0.0)
return rewards
```
## Length-Penalty Wrapper
Wraps any reward function above to discourage
runaway completion length without replacing the
underlying correctness signal — use when a
correctness-only reward starts trending toward
longer, padded outputs:
```python
def with_length_penalty(reward_fn, target_len=512, penalty_per_token=0.001):
"""Returns a new reward function that subtracts a
small per-token penalty for every token past
`target_len`, applied on top of `reward_fn`'s
output. Penalty is capped so it can't drive an
otherwise-correct reward negative — it discourages
padding without overriding correctness.
"""
def wrapped(completions, **kwargs) -> list[float]:
base_rewards = reward_fn(completions, **kwargs)
adjusted = []
for reward, completion in zip(base_rewards, completions):
overflow = max(0, len(completion.split()) - target_len)
penalty = min(reward, overflow * penalty_per_token)
adjusted.append(reward - penalty)
return adjusted
return wrapped
```
Note that `len(completion.split())` counts words
as a cheap proxy for tokens — use the model's own
tokenizer for true token counts when tuning
`target_len`.
This is a targeted fix for observed length
creep, not a substitute for Dr.GRPO — if length
bias is systemic rather than an occasional
overflow, route to the Dr.GRPO variant in
`SKILL.md`'s Variant Selection instead of stacking
penalty wrappers.
## Rubric-as-Reward Judge Pattern
For tasks where correctness isn't code-checkable
but the pass/fail line is still crisp enough for a
judge to apply consistently — e.g., "did the
response follow the requested format and stay
on-topic" rather than "is this a good essay."
Binary pass/fail, not a Likert score:
TRL calls reward functions with `completions` plus
whatever dataset columns the trainer was given, via
`**kwargs` — it does not inject arbitrary objects
like a judge client. Bind `judge_client` and the
fixed `rubric` in a closure before handing the
result to `GRPOTrainer(reward_funcs=[...])`, rather
than declaring them as parameters TRL is expected to
supply:
```python
def make_rubric_judge_reward(judge_client, rubric):
"""`judge_client` calls JUDGE_MODEL — a model from a
*different* model family than the model under
training, never the model being trained or a
same-family relative of it. `rubric` is a fixed
pass/fail criterion string, not a free-form
quality prompt, so both are bound here rather than
read from TRL-supplied per-example kwargs. Returns a
reward function matching TRL's actual signature.
"""
def rubric_judge_reward(completions, prompts, **kwargs) -> list[float]:
"""Returns 1.0/0.0 per completion, never an
intermediate score."""
rewards = []
for prompt, completion in zip(prompts, completions):
verdict = judge_client.judge(
rubric=rubric,
prompt=prompt,
response=completion,
output_format="pass_fail", # binary only — no Likert scale
)
rewards.append(1.0 if verdict == "pass" else 0.0)
return rewards
return rubric_judge_reward
```
**Calibration is a hard prerequisite, not a
nice-to-have.** An uncalibrated judge is a
noisier, more expensive version of the exact-match
reward above — before wiring `rubric_judge_reward`
into a GRPO run, the judge must be calibrated
against human labels (train/dev/sealed-test
splits, TPR/TNR reported, judge pinned to a fixed
snapshot). That calibration workflow lives in
`eval-harness-first`; do not skip it because the
rubric "looks obviously right" — the same
plugin-wide judge-calibration prerequisite applies
here as everywhere else a judge grades a reward.