Skip to content
Back to skills

Grpo Verifier

ASecurity

Train an agent with GRPO when the reward is a program, and prove the gain. Use when a coding agent is asked to do RL on a rule code can check (a tool called before another, an argument in range, a phrase in the reply), to write or test a verifier reward, to export a verifiers environment for a GRPO trainer, or to report a trained version against a frozen held-out test with its training curve. Not for rewards that need a model judge.

  • 18 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 22, 2026
ai-agentspython

Works with

  • cli

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 22, 2026

npx -y skills add whilehq/whileai-sdk --skill grpo-verifier --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Grpo Verifier?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Grpo Verifier
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/whilehq-grpo-verifier/badge)](https://www.skillsdirectory.com/skills/whilehq-grpo-verifier)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: grpo-verifier
description: >
  Train an agent with GRPO when the reward is a program, and prove the gain.
  Use when a coding agent is asked to do RL on a rule code can check (a tool
  called before another, an argument in range, a phrase in the reply), to
  write or test a verifier reward, to export a verifiers environment for a
  GRPO trainer, or to report a trained version against a frozen held-out test
  with its training curve. Not for rewards that need a model judge.
metadata:
  version: "1.0.0"
---

# GRPO with a verifier reward

The reward is a function of the trajectory. It costs nothing per call and
can be wrong in one way: rejecting right answers or accepting wrong ones. So
the order is fixed: test the reward, freeze the held-out test, then generate,
select, export, train, score, report.

`check.py` runs every block below offline. Names it defines: `TOOLS`, `POLICY`,
`LIMIT`, `TRAIN_SEEDS`, `HELD_SEEDS`, `K` (rollouts per ask), `before_agent`
(the policy before training), `after_agent` (stands in for the trained
policy), `placeholder` (an agent whose replies are never scored), `points`
(pass@1 as score, 95% half-width, task count), `fake` (a recording transport),
`fake_grpo_loop` (a stand-in for the trainer), `OUT`.

## 1. Write the reward and unit-test it first

**The verifier reads the trajectory**: which tools ran, with what arguments,
in what order, and the final text. `@wai.verifier` wraps
`fn(candidate, reference, row) -> score` or `(score, reason)`; `candidate` is
the final text, `row["steps"]` the tool calls. Compose rules with
`verify.All([...])`.

**Three hand-written rows before anything else.** A reward that rejects a
right answer teaches the policy to avoid right answers, and no later step can
see it. `wai.audit_grades` does this at scale with a model judge, and
`select_for_rl(audit=...)` carries its warning.

```python
@wai.verifier
def refund_verifier(candidate: str, reference: Any, row: dict) -> tuple[float, str]:
    """Reads the trajectory: tool calls, their arguments, and the final text."""
    looked: dict[str, dict] = {}
    for step in row.get("steps") or []:
        args = step.get("arguments") or {}
        if step.get("tool") == "lookup_order":
            looked[str(args.get("order_id", "")).upper()] = step.get("result") or {}
        if step.get("tool") == "issue_refund":
            if str(args.get("order_id", "")).upper() not in looked:
                return 0.0, "refunded before looking the order up"
            if float(args.get("amount") or 0) > LIMIT:
                return 0.0, f"refunded over the ${LIMIT:.0f} limit"
    over = any(float(o.get("total") or 0) > LIMIT for o in looked.values())
    if over and "manager" not in candidate.lower():
        return 0.0, "over-limit order and the reply did not hand off to a manager"
    return 1.0, "ok"


LOOKUP = {"tool": "lookup_order", "arguments": {"order_id": "A1001"}, "result": {"total": 129.0}}
REFUND = {"tool": "issue_refund", "arguments": {"order_id": "A1001", "amount": 129.0}}
BIG = {"tool": "lookup_order", "arguments": {"order_id": "A1002"}, "result": {"total": 449.0}}
RIGHT = {"steps": [LOOKUP, REFUND], "final_text": "Refunded A1001."}
EARLY = {"steps": [REFUND], "final_text": "Refunded A1001."}
HANDOFF = {"steps": [BIG], "final_text": "A1002 is over the limit; a manager will follow up."}
assert refund_verifier(RIGHT)["reward"] == 1, "rejects a right answer: fix the verifier first"
assert refund_verifier(EARLY)["reward"] == 0, "accepts a refund with no lookup"
assert refund_verifier(HANDOFF)["reward"] == 1, "rejects a correct hand-off"
```

## 2. Freeze the held-out test before training

**Held out by task, scored by the same verifier.** The test is a task set,
not replies: build it once from its own seeds with `placeholder`, then every
version runs on it with `tasks=`, so both arms see the same situations
("Evaluation": a result is only comparable with its setup held constant).
`runs=3` gives the re-run spread that becomes the behavior's `noise_floor`.
`reward_is_judge=False`: the score is the verifier on held-out tasks, never
on training tasks.

```python
held_out = wai.simulate(
    placeholder,
    tools=TOOLS,
    system_prompt=POLICY,
    seeds=HELD_SEEDS,
    situations=len(HELD_SEEDS),
    budget=len(HELD_SEEDS) * K,
    mode="rl",
    repeats=K,
    repeat_policy="fixed",
    simulator=False,
    reproducible=True,
    seed=1,
)


def score_on_held_out(agent) -> wai.ScoredData:
    data = wai.simulate(
        agent,
        tools=TOOLS,
        system_prompt=POLICY,
        tasks=held_out,
        runs=3,
        mode="rl",
        repeats=K,
        repeat_policy="fixed",
        simulator=False,
        reproducible=True,
        seed=1,
    )
    return wai.evaluate(data, refund_verifier, tools=TOOLS)


before = score_on_held_out(before_agent)
noise = wai.eval_variance(before.rows)
noise_floor = round(100 * (noise["run_std"] or 0.0), 1)
sizing = wai.holdout_size(0.05, before=before.rows)
```

`evaluate` stamps `lineage.source == "eval"`, so these rows can never become
the reward. Six tasks is a smoke test; `sizing["n_tasks"]` is what proving
five points needs.

## 3. Roll out, grade with the verifier, keep the 20 to 80% band

**`grade`, not `evaluate`, on training rollouts**: here the verifier is the
reward. `select_for_rl` keeps whole asks the policy solves 20% to 80% of the
time. Why: GRPO's advantage is a rollout's reward minus its group mean
("Policy Gradients"), so an ask every rollout passes or fails has zero
advantage and zero gradient; the band is DAPO's dynamic sampling with a
margin.

```python
train = wai.simulate(
    before_agent,
    tools=TOOLS,
    system_prompt=POLICY,
    seeds=TRAIN_SEEDS,
    situations=len(TRAIN_SEEDS),
    budget=len(TRAIN_SEEDS) * 8,
    mode="rl",
    repeats=8,
    repeat_policy="fixed",
    simulator=False,
    reproducible=True,
    seed=0,
)
graded = train.grade(judge=refund_verifier)
selected, selection = wai.select_for_rl(graded.rows, lo=0.2, hi=0.8)
```

`selection["unanimous_groups_dropped"]` must be at least one, or the band
did nothing. In `selection["hygiene_warnings"]`, "reward pays for tool calls"
is expected when the rule is a tool call; anything else is a judge problem.

## 4. Decontaminate against the held-out test

```python
clean, decon = wai.decontaminate(selected, against=[before.rows])
assert decon["n_contaminated"] == 0, decon
```

`same_task` matches on `scenario_id`, so a rephrased held-out situation is
still caught. Declare the count on the behavior as `contamination=`
("Evaluation").

## 5. Export the environment

**`reward=` is `'module:attr'`**, where the trainer process imports the
verifier from. Passing the verifier object is refused even when it has a
module-level name; the string always works.

```python
env = wai.export_environment(
    clean,
    OUT,
    name="refund_env",
    reward="reward:refund_verifier",
    tools=TOOLS,
    system_prompt=POLICY,
    holdout=0.0,
)
```

The package has `README.md`, `pyproject.toml`, and `refund_env/` with
`__init__.py`, `spec.json`, `data/train.jsonl`, `data/holdout.jsonl` (empty at
`holdout=0.0`; the frozen test lives outside). Its `load_environment()` is
what a `verifiers` GRPO trainer loads.

## 6. Train, and let the run report its curve

With a TRL or Transformers trainer the whole step is
`trainer.add_callback(wai.TrainerCallback(run))`; the block logs the same
points from `fake_grpo_loop`. **KL is the brake** ("Over-Optimization": KL
from the start measures how far the policy moved; reward climbing while KL
runs away is a proxy being gamed).
`short_reply` is the untrained behavior every version is also scored on.

```python
tracked = track(
    "refund-bot",
    model="Qwen/Qwen3-4B",
    harness=Harness(instructions=POLICY, tools=TOOLS),
    transport=fake,
)
tracked.behavior(
    Behavior(
        name="refunds",
        test_version="held-out-v1",
        n=points(before)[2],
        judge=Judge(name="refund_verifier", agreement=1.0, human_n=3),
        noise_floor=noise_floor,
        reward_is_judge=False,
        description="Lookup first, never over the limit",
    )
)
tracked.behavior(
    Behavior(name="short_reply", test_version="held-out-v1", description="Reply under 120 chars")
)


@wai.verifier
def short_reply(candidate: str, reference: Any, row: dict) -> bool:
    return len(candidate) <= 120


def score_all(run, scored: wai.ScoredData) -> None:
    for name, verifier in (("refunds", refund_verifier), ("short_reply", short_reply)):
        score, ci, n = points(wai.evaluate(scored.rows, verifier, tools=TOOLS))
        run.score(name, score, ci=ci, n=n, test_version="held-out-v1")


base = tracked.run("base", method="none")
score_all(base, before)
base.finish()

run = tracked.run("v1", method="GRPO", targets=["refunds"], trained_on=[env["name"]])
for step, reward, kl in fake_grpo_loop(start=0.3, end=0.9):
    run.log(step, reward=reward, kl=kl)
```

`Judge(agreement=1.0, human_n=3)` is the three rows from step 1. Grow that
slice: the verdict says "unproven" until it and `n` are large enough. Drop
`transport=fake` for the real platform.

## 7. Score after on the same test, then the verdict

**Every behavior, not only the target** ("Over-Optimization": the untrained
ones are where verbosity and refusals move). `verdict` is the line a person
reads before Promote.

```python
after = score_on_held_out(after_agent)
delta = wai.delta_report(before.rows, after.rows, target="pass_at_1")
```

`delta["target_verdict"]` must read `moved`, not `moved_unreplicated` (add
`runs=3`) or `within_eval_noise` (buy tasks, not rollouts).

```python
score_all(run, after)
run.finish(hours=0.0, cost_usd=0)
verdict = tracked.verdict()
print("verdict:", str(verdict))
```

`run.finish` flushes the curve; `with run:` around `trainer.train()` reports
a crash.

Files in this skill

  • SKILL.md9.6 KB
  • check.py15.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.