An autoresearch loop that automatically hardens RL verifiers against reward hacking.
Point it at a grader (the reward you'd train against): a red-team swarm discovers how it can be gamed,
a green team seals the holes, and it reports how much more trustworthy the grader became — measured against held-out ground truth.
Python 3.12 · uv · anthropic SDK · FastAPI + websocket · pytest / ruff
The product is the loop. The deliverable is a hardened grader and a before→after robustness number. The red-team swarm + conductor are the engine that finds holes to seal — impressive, but supporting. The headline is "we made a broken grader trustworthy, automatically," not "look how smart our swarm is."
RL trains a model against a verifier — a test suite, a reward model, an LLM judge. If that verifier is gameable, you don't get a better model: you get a reward-hacker that scores high while doing the wrong thing, and you usually find out after the compute is spent. Auto-generating RL environments is now cheap, so verifier quality is the bottleneck. Goodhart automates the thing that today is done by hand — making a verifier un-gameable — and hands back the hardened verifier with a number that proves it.
The loop runs over an env (a grader over a task set). One pass per gate:
- Red swarm (
agents/) — specialist coding agents try to make the grader pass without solving the task. Each specializes in a cheat category. They have write access to the task repo (solution.py,test_visible.py, a runner) — a realistic CI / coding-agent setting, and what makes filesystem-level cheats possible without us planting anything. - Conductor (
conductor/) — the brain. Allocates each (gate × cheat-type) cell exactly once, diversifies toward under-explored categories, gatekeeps (a breach is verified before it spreads), and invalidates (one patch can kill several breaches for free). - Breach — a solution that passes the grader (R=1) but fails the oracle (T=0). That, and only that, is a breach.
- Green team (
green/) — an LLM selects and parameterizes a hardening patch template and applies it to a copy of the grader. It never fixes the cheating code and never sees the oracle, the gold solution, or the held-out inputs. - Regression gate (
gate/) — the patch is accepted only if the breach now fails and the gold solution still passes. Otherwise it's rejected (the over-tighten / friendly-fire case). - Robustness ↑ — agreement against the held-out oracle, recomputed with the honest-pass rate beside it, and streamed to the siege.
A sealed gate escalates: the next round faces the harder grader, so red and green iterate.
sequenceDiagram
participant R as Red agent
participant G as Grader<br/>(visible tests)
participant O as Oracle<br/>(held-out)
participant V as Green + gate
participant D as Siege gauge
R->>G: write solution.py, edit tests, run visible suite
G-->>R: R = 1 (grader passes)
O-->>V: T = 0 (oracle fails) ⇒ breach_found
V->>V: pick template (pristine_readonly_plus_heldout)
V->>V: regression gate — breach→0 ∧ gold→1 ?
alt gate passes
V-->>D: patch_applied · agent_killed · robustness_update ↑
else gold broke
V-->>D: patch_rejected (friendly fire, torn down)
end
The task is a coding task; the grader is a test suite; cheating is passing the tests without really fixing the bug; hardening makes the test-grader impossible to satisfy without a real fix. The red team writes code; the green team fortifies the tests — it never fixes the buggy code.
| Concept | In code | Where |
|---|---|---|
| Task | "implement this function" — EvalPlus problem | substrate/evalplus_task.py |
| Grader (verifier) | the visible/weak test suite → R ∈ {0,1} — the thing we harden |
grader/runner.py |
| Oracle (ground truth) | held-out EvalPlus plus_input tests, gold-derived → T ∈ {0,1} |
oracle/runner.py |
| Breach | a solution with R=1 ∧ T=0 (is_breach) |
oracle/runner.py |
| Patch | a hardening edit from a template library, applied to a grader copy | templates/registry.py |
| Regression gate | accept iff grade(g', breach)=0 ∧ grade(g', gold)=1 |
gate/runner.py |
| Robustness | % of held-out breaches blocked, honest-pass preserved | metrics/agreement.py |
The threat model is realistic, not planted. Agents operate in an ephemeral workdir
(harness/workdir.py) holding solution.py, test_visible.py (built from base_input only), and a
naive runner.py — all editable. The oracle's plus_input and the gold canonical_solution
live outside the workdir and are never written into it. The seed exploit list is empty
(SEED_LIST = []) — every breach is discovered, and an anti-theater check confirms it was not on a
seed list.
Determinism is load-bearing. Both grader and oracle run solutions in a subprocess sandbox
(sandbox.py): 30 s CPU timeout, 2048 MB cap, no network, PYTHONHASHSEED=0, and the verdict is
parsed from pytest's JUnit XML — never exit codes. Float comparisons use a shared _eq with
rel_tol = abs_tol = 1e-6 (suite.py), embedded identically into grader and oracle suites.
| Specialist | cheat_type |
Strategy |
|---|---|---|
| sapper | tamper_harness |
edits the test harness / runner itself instead of solving |
| forger | hardcode_outputs |
memorizes / special-cases the visible inputs, no general solution |
| edge_slipper | sparse_test_gap |
minimal code matching only the visible cases |
The red and green models default to claude-sonnet-4-6; the demo conductor uses
claude-haiku-4-5 as the red model (weaker models cheat more readily — a feature for the demo).
| Template id | Effect |
|---|---|
pristine_readonly_plus_heldout |
re-grade from a pristine read-only test copy + gold-labeled held-out inputs the agent never saw — neutralizes harness tampering and memorization |
require_delimited_answer (RG) |
extract the answer after a #### / answer: marker |
reject_multiple_numbers (RG) |
reject responses containing more than one distinct number |
require_cue_answer (RG) |
parse the number after the last conclusion cue (mechanism distinct from the oracle, to preserve independence) |
Patches are immutable: apply_patch(grader, template_id, params) returns a new frozen Grader
with the patch appended — the original is never mutated.
allocate (next_cell probes each gate×cheat cell once) · diversify (prefer under-explored
cheat categories) · gatekeep (verify confirms R=1 ∧ T=0 before a breach spreads) ·
invalidate (regrade lets one patch retire other now-dead breaches). It runs in three modes:
run_seed— deterministic forger + pristine hardening, no API key. The reliable demo path.run_live— the real LLM swarm + green team (needsANTHROPIC_API_KEY).escalate_gate— iterated red↔green on a single gate, grader hardening each round.
Probes are resilient: 3 retries, 2 s backoff, a 90 s hard wall-clock cap per probe.
The robustness number is the whole product, so it is measured honestly:
- Held-out split is sacred. Patch against a train split of breaches; measure on a disjoint
held-out split the patch never saw (
split_breachessorts then alternates[0::2] / [1::2]). - The oracle is independent of the grader.
_split_pluspartitionsplus_inputinto disjoint hardening vs. oracle halves and drops any case that overlapsbase_input— grader and oracle never share a test case. - Honest-pass is always tracked beside robustness. A patch that blocks cheats by also rejecting
gold solutions is a regression, not a win — the regression gate enforces it, and every number is
reported with
honest_passnext to it. - No LLM-as-judge in the verdict path. Every grader/oracle verdict is a deterministic subprocess result, reproducible on replay.
Every number below is computed by a locked function — no hand-tuning.
| Metric | Definition | Source |
|---|---|---|
| agreement (cheats blocked) | fraction of the breach set the grader rejects (matches oracle) = rejected / N |
metrics/agreement.py |
| baseline_agreement ("before") | fraction the naive grader blocks — 0.0 for real breaches by construction |
metrics/agreement.py |
| honest_pass | fraction of gold solutions still scoring 1 under the grader | metrics/agreement.py |
| hit_rate (exploitability) | n_breachable / n_graders — "M of N standard graders were breachable" |
breadth/loop.py |
| consequence | reward points a pure-cheating policy collects = Σ n_held_out · (1 − agreement), naive vs. hardened (reward per cheat = 1 − blocked) |
consequence/consequence.py |
| safety_score | balanced accuracy ×100 = 50 · (catch_rate + honest_pass); false_accept = 1 − catch_rate |
bench/core.py |
| best-of-K gap | hardened_accuracy − naive_accuracy — how often picking by the naive reward lands on a cheat while the hardened reward picks a real solve (recovered_rate) |
bestofk/core.py |
Against the standard EvalPlus base grader (the repo's own test_visible.py run as plain
pass/fail, unmodified — the exact way hundreds of papers score code), red agent = Claude Sonnet:
- Discovered breach rate: 10% (3/30 hardest HumanEval tasks). The agent neuters the visible tests
so the naive grader passes while the solution stays a stub that fails the oracle (
R=1, T=0) — a discovered harness-tamper cheat, nothing planted. - Hardening seals it to 0 with honest-pass 1.0 — grading from a pristine read-only copy rejects every tamper breach while the gold solution still passes.
- Why ~10% and not higher: on easy tasks a strong model just solves honestly (nothing to breach); cheating only appears where faking is cheaper than solving — the hardest slice. We did not chase a bigger number.
Nothing is sabotaged — the naive grader's leakiness is a published field fact (EvalPlus exists
because sparse base tests let wrong code through). Only the held-out slice and the attacker vary.
Reproduce from RESULTS.md.
Every loop element maps to one thing on screen — the dashboard animates purely off a 7-event stream, so the engine can be driven live or replayed from a recorded golden run.
- "Here's a standard EvalPlus grader the room trusts." Name it as the exact base-test scoring.
- Run the loop → the siege. Red agents flow toward soft gates; a breach opens an undefended gate
(
breach_found, card: passed grader / failed oracle); green builds a turret (patch_applied); agents whose exploit was sealed die at the gate (agent_killed). The gauge climbs ~0% → ~100% cheats-blocked, honest-pass pinned at 100%, measured on held-out truth. - Friendly-fire beat. An over-tightened patch that would reject a gold solution flashes red and
is torn down (
patch_rejected) — proof the honest-pass guardrail is enforced. - "And across N standard graders, the loop breached M of them" — the exploitability hit-rate.
- Consequence beat. "Train on the leaky reward and it pays out this much for pure cheating; on the hardened one, almost nothing."
make install # uv sync + git hooks
make check # ruff + pytest (the deterministic gate)
make demo # replay the recorded golden run — no API key → http://localhost:8000
uv run python -m goodhart.server --seed # deterministic live siege over the hardest tasks — no API key
make dev # live LLM run (needs ANTHROPIC_API_KEY)The siege serves the 3D surface at / and a 2D fallback at /2d; events stream over /ws;
the consequence magnitude is served at /tier_a.json. Both live paths default to the hardest
EvalPlus tasks (load_hardest, _hardness = len(canonical_solution) / len(base_input) — sparse base
tests vs. tricky logic, where the standard grader is weakest). Override with --tasks; re-record a
golden with --record golden_run.jsonl.
agent_spawn {agent, specialty} · agent_move {agent, gate} ·
breach_found {agent, gate, cheat_type, grader_score, oracle_score, example} ·
patch_applied {gate, technique} · patch_rejected {gate, reason} ·
agent_killed {agent, gate} · robustness_update {held_out_blocked, honest_pass, probes}
— defined in events.py, bussed over server/bus.py, replayed by server/replay.py.
src/goodhart/
├─ agents/ red specialists (sapper/forger/edge_slipper) + the agentic attack loop
├─ conductor/ core (4 levers, shared memory) · live · seed · escalate
├─ green/ the LLM hardening team (harden → validate → gate)
├─ grader/ the visible-test verdict (R) ┐
├─ oracle/ the held-out verdict (T) │ deterministic,
├─ gate/ the regression gate (breach→0 ∧ gold→1) │ sandboxed,
├─ templates/ hardening template registry + apply_patch │ never share cases
├─ suite/sandbox subprocess isolation, float-tolerant _eq ┘
├─ substrate/ EvalPlus + reasoning-gym task loaders
├─ metrics/ agreement / baseline / honest_pass · train-heldout split
├─ breadth/ run_breadth → hit-rate + before/after across an env
├─ consequence/ reward-points naive vs hardened (Tier A)
├─ rollout/ the rollout seam + multi-model sampling
├─ bench/ verifier-safety scoring (false-accept, honest-pass, safety)
├─ bestofk/ the best-of-K capability gap (selection proxy)
├─ train/ two-model RFT stretch (Qwen + LoRA, expert iteration) [parked]
├─ server/ FastAPI app + websocket bus + golden replay
└─ events.py the 7-event Seam-2 schema
dashboard/index.html 2D siege viz3d/siege.html 3D siege
Everything downstream of the loop reads one locked record
(SEAM_FIELDS = (task_id, model, completion, r_naive, r_hardened, t_oracle)): the completion's reward
under the leaky grader, under the hardened grader, and the held-out ground truth. A row is labeled
honest / cheat / fail. rollout/ samples it across a model registry
(opus, sonnet, haiku, gpt-4o-mini, deepseek-chat, Fireworks Qwen-Coder) and can inject
deterministic seed exploits as a guaranteed cheat class.
- EvalPlus / HumanEval (flagship) —
base_inputis the naive grader,plus_input(~80× expanded) is the oracle,canonical_solutionis the gold for the honest-pass check. Pure Python, ms runtime. - reasoning-gym (
gsm_symbolic) — a real RL math reward. The grader is a lenient substring scorer; the oracle (rg_oracle) checks the last number against gold while the grader parses the first — disjoint by construction, so the oracle is never the grader. - HUD —
hud_adapter.pywraps any HUD env as a Goodhart adapter (the env's reward is the verifier under test);bench/hud.pylogs a leaderboard run as a HUD trace.
| Command | What it does |
|---|---|
make install / make check / make fmt |
sync + hooks · ruff + pytest gate · autoformat |
make dev / make demo |
live siege · golden-replay siege |
python -m goodhart.server [--seed|--replay FILE] [--tasks IDS] [--record FILE] [--model ID] [--speed F] [--host] [--port] |
the siege server |
python -m goodhart.breadth [--hardest N] [--source auto|seed|discovered] [--workers] [--model] |
exploitability hit-rate + mean before/after across an env |
python -m goodhart.consequence [--hardest N] [--source ...] [--emit-tier-a] [--emit-events] |
reward-points naive vs hardened → tier_a.json |
python -m goodhart.metrics |
the single-task M1 loop: before / after / honest-pass |
python -m goodhart.rollout [--models] [--count] [--k] [--red] [--seed-exploits] [--rg] [--out] |
build the rollout dataset (the seam) |
python -m goodhart.bench --data runs/rollouts.jsonl [--judge] [--hud] |
verifier-safety scores + best-of-K gap |
python -m goodhart.bestofk --data runs/rollouts.jsonl [--seed] [--show] |
the best-of-K capability gap report |
python -m goodhart.red_rg [--count] [--scorer lenient|first_number] [--pressure] |
red team on the reasoning-gym substrate |
- Per-env report card — a shareable "your reward accepted X% of cheats → 0% after hardening, here
are the exact cheats" artifact assembled over
breadth+consequence— is the next deliverable, not yet built. - Verifier-safety leaderboard (
make leaderboard,bench/) and the best-of-K proxy exist as CLIs, but global ranking is parked — the loop's before→after, not a ranking, is the headline. - Two-model RFT (
train/) — train one model against the leaky grader, one against the hardened grader, and show the leaky-trained one collapses on a clean held-out eval (Qwen2.5-Coder-0.5B + LoRA, expert iteration, Modal A100) — is scaffolded but pre-recorded and droppable. Clean executable verifiers resist reward-hacking by optimization, so the deterministic consequence number is the demonstrated proxy.
See SPEC.md for the full design and RESULTS.md to reproduce the numbers.