Research note · RL post-training
What the reward curve hides
Label-free behavioral change detection on 106,336 public GRPO rollouts from four reward-hacking runs: what it surfaced and what it means for RL post-training tooling.
I took four public GRPO training runs in which the policy learned to exploit its grader and set the labels aside. With only the rollout dumps a trainer already writes, could you see what the policy started doing, and when?
Summary
- In three of the four runs, the earliest label-free event was the exploit mechanism itself:
os._exit(0), a plantedconftest.pywith process exits, and a patched pytestTestReport. In Aria, the self-definedrun_testsalerted after a benign shift in explanation style and ranked fourth by reward association. I picked these features from the alert lists afterwards, with no ranking fixed in advance. - In the Aria run, the mean reward levels off near 3.3, which reads as a plateau. Underneath it, the share of maximum-reward rollouts that solve the problem falls from 95% to 41%.
- Timing is mixed. Under the same rule, when the exploit is the reward, behavior and reward alert at the same step (104 in AISI β=0, 142 in AISI β=0.02). Where they differ, the behavioral alert came 8 steps earlier in Aria and 3 in the TRL run, and the TRL lead disappears without a standard-error floor I chose (section 3). Under reward’s usual, looser treatment, reward came first in three of four runs: by 32 steps in AISI β=0, 4 in AISI β=0.02, and 7 in TRL.
- A transcript feature captures what the policy tried, while code execution determines whether it worked: in Aria, 0.98 precision against attempted hacks and 0.54 against successful ones.
- One scalar diagnostic package described the hacked runs as having overly easy tasks or a stalled reward curve and called one healthy, while the other flagged degenerate groups in one run and nothing elsewhere. Neither received KL, entropy, or held-out evaluation inputs: six of rldoctor’s nine checks, including its reward-hacking check, were skipped for that reason, and rlprobe’s KL, entropy, clipping, and gradient detectors had nothing to read. That is what scalar metrics capture, and it is the gap behavioral analysis fills.
Why look at behavior
Post-training dashboards are built on scalars: mean reward, KL, entropy, response length, and the fraction of prompt groups with zero reward variance. They show when a trajectory shifts while leaving the specific behavior opaque. Reward hacking is the sharpest case, because an exploit raises the exact number you are watching. The same blindness covers benign shifts, such as a new explanation style or a self-testing habit that appears and fades.
Most GRPO trainers can already write their rollouts to disk (TRL’s log_completions, which keeps the latest generation batch at each logging step, and verl’s rollout_data_dir). The question is whether a cheap, deterministic analysis of those files surfaces the changes that matter, before anyone pays a model to read transcripts.
Data
The four runs
| Run | Source | Model and environment | Steps × rollouts | Prompts / step | Labels | License |
|---|---|---|---|---|---|---|
| Aria | gutenbergpbc/aria-reward-hacking | Qwen3-4B, LeetCode-style tasks where the response can overwrite the evaluator’s run_tests | 200 × 256 | 16 | attempted, successful, correct | other (card asserts no open-source license) |
| AISI β=0 | ai-safety-institute/…kl0.0-seed2-rollouts | OLMo-3.1-32B, competitive programming with documented process-exit and test-harness hacks | 401 × 64 | 3–4 | successful | Apache-2.0 |
| AISI β=0.02 | ai-safety-institute/…kl0.02-seed2-rollouts | Same, with a KL penalty of 0.02 (it also hacks) | 403 × 64 (402 complete) | 3–4 | successful | Apache-2.0 |
| TRL | sunshineNew/rh_qwen3_8b_prompted_v2_completions | Qwen3-8B, native TRL completion logs | 115 × 32 | 1 | successful | none stated |
All four runs are single-turn Python. The last AISI β=0.02 batch has 29 unscored rollouts, which I scored as 0. The AISI step numbers follow the order of the evaluation files, as verified optimizer step indices are absent from the logs. No data is redistributed here. The one code excerpt below comes from an Apache-2.0 AISI run. I describe the Aria and TRL rollouts in words because those datasets’ licensing terms are unresolved.
Every number in the findings comes from the research prototype I ran on October 17, 2026, from a rerun of its archived code, or from the datasets themselves. Rollout Diff, the package I built afterwards, gives different values in a few places, which the Reproduction section near the end lists.
Method
Everything up to evaluation is label-free.
- 01Features per rolloutAST and prose n-grams
- 02Per-step seriesprompt-averaged prevalence ± SE
- 03Detectionz > 4 for 3 steps, rise ≥ 0.10
- 04Filtering and eventssplit-half check, reward association, clustering
- evalLabels enteronset at 1, 5 or 10%
- Features per rollout. From the Python AST: names the response defines, modules it imports, functions it calls, attributes it assigns, files it declares, and a few categories such as process exit,
exec, file writes, and dunder methods. From the prose: unigrams and bigrams. - Per-step series. GRPO samples several completions per prompt, so rollouts within a step are correlated. Each feature’s prevalence at a step is the mean over prompts of its within-prompt prevalence, with a standard error across prompts.
- Detection. A sustained-z rule fixed before I ran any detection, with one caveat I give under Reproduction (the eight-group floor was set knowing the prompt counts): the first 15 steps are the baseline, and a series alerts when z > 4 for three consecutive steps and, for prevalence features, the absolute rise is at least 0.10. The chronological mode scores each step from the steps before it, though vocabulary selection uses the whole run (a token must reach 5% of the rollouts at some step, which
run_testsfirst does at step 104), so the early alert in the exploratory section depends on that look-ahead. A retrospective mode picks the single best mean-shift split over the whole series. - Filtering and events. I kept an alert if the same feature also alerted in each half of the problems, with the two half alerts within ten steps of each other, and if it carried a positive within-group reward association. TRL has one prompt per step, so there the halves split the completions of the same prompt. Alerts with nearby onsets are clustered into events. I pulled exemplar rollouts, cited by ID, for the top two filtered alerts and for the mechanism feature I picked in each run.
Labels entered only at evaluation. A label’s onset is the first step at which the labeled rate stays at or above 1%, 5%, or 10% for three consecutive steps.
For baselines, I applied the same rule to standard logged scalars (reward mean, share of rollouts at maximum reward, response length, and share of zero-variance groups). I also ran the detector sets of rlprobe 0.1.0 and rldoctor 0.1.0, along with the deterministic surface-statistics layer of RHDA from the CHERRL repository. I evaluated only RHDA’s deterministic layer without running its LLM agent loop or any model reading of transcripts. The one-line descriptions below are mine, written from the evidence the pipeline produced.
1 The alerts named the mechanism
The event carrying the exploit in each run, chosen from the alert list after the fact, with a cited rollout for three of them:
Aria, step 112
def:run_tests rises from 0 to 16% of rollouts (97% by step 130), with a within-group reward association of +0.73, in both halves of the problems. Rollout step114_row142, rewarded 3.5 and 2.53 above its group, adds a run_tests method to the solution class that prints its own test results. The policy defines its own run_tests, replacing the evaluator’s.
AISI β=0, step 104
import os with os._exit(0) goes from 1% to 55% of rollouts, with a reward association of +0.85 for os._exit (+0.89 for import os). From rollout 2026-05-07T1:2:5 at step 101:
# Exit hack (not used in normal solution)
import os
os._exit(0)
A second event at steps 107–110 is a class AlwaysEqual overriding __eq__, __ne__, and related methods: a second exploit the policy tried and then dropped.
AISI β=0.02, steps 140–148
Process-exit calls, together with responses that declare a conftest.py file next to the solution. The exit call and the count of declared files alerted together at step 142. The conftest.py token itself held above the threshold for three straight steps only at 147, although at steps 140 to 142, 78 of the 89 rollouts that declare a file already declare conftest.py.
TRL, step 98
A conftest.py that imports pytest’s TestReport, wraps its from_item_and_call factory, and marks every test-call report as passed (rollout 98:14). The dataset’s label calls this “conftest”, while the feature names the exact hook being patched. os._exit and AlwaysEqual follow at steps 107 and 111.
The pipeline also found benign changes in behavior. In Aria at step 33, explanatory prose expands (the word “explanation” goes from 0 to 74% of responses) and response length doubles. Around the same step, a habit of printing self-tests rises to about 0.6 print calls per response before fading by step 75. These replicate across halves of the problems as behavioral shifts before the labeled exploit appears, and they alerted first. A useful tool has to show them while ranking them below the exploit, making ranking the central problem once detection works.
2 What the reward curve hides
Mean reward per step
Overall correct rate, 10-step averages
Share of maximum-reward rollouts, 5-step bins
Read alone, the reward curve says the run improved and then leveled off near 3.3 from about step 140 (the 130s average 3.1). The composition underneath says the top of the distribution was taken over. Between steps 100–110 and steps 180–200, the share of correct maximum-reward rollouts fell from 95% to 41%, and successful hacks rose from 5% to 59%. The overall correct rate was 39% in steps 100–110 and 38% in steps 180–200, and its 10-step averages stayed between 36% and 41% in between, so the model kept the solve rate it had reached by step 100 while finding a cheaper route to the maximum score.
def:run_tests, label-freeattempted hacks, labeledsuccessful hacks, labeled
def:run_tests against the attempted-hack and successful-hack rates. Dashed verticals mark the 5% attempted-hack onset (step 109), the behavioral alert (step 112), and the reward alert (step 120). The labels are drawn for comparison and were unseen by the detector. Per-step values from the experiment archive.The feature follows the attempted-hack rate almost exactly (correlation 0.998 over steps 80–160), while diverging from the lower successful-hack rate examined in finding 4.
3 Timing is mixed
onset 1% to 10%behavioralreward, same rulereward, usual
conftest.py alert. Aria onsets are for attempted hacks. TRL’s behavioral and same-rule reward marks rest on my eight-group standard-error floor. Using TRL’s single prompt group moves both to step 111.Training step of the first alert, per run (archived prototype)
| Run | Label onset 1 / 5 / 10% | Reward alert, usual treatment | Reward alert, same rule | Behavioral alert | Retrospective changepoint |
|---|---|---|---|---|---|
| Aria | 94 / 109 / 110 | 120 | 120 | 112 def:run_tests | 117 |
| AISI β=0 | 54 / 94 / 100 | 72 | 104 | 104 os._exit | 104 |
| AISI β=0.02 | 29 / 139 / 140 | 138 | 142 | 142 exit, 147 conftest.py | 141 / 148 |
| TRL | 71 / 89 / 99 | 91 | 101 | 98 TestReport patch | 101 |
“Same rule” holds reward to behavior’s minimum effect size (0.10) and standard-error floor; on Aria’s 0 to 3.5 reward scale that floor is undefined, so none applied. “Usual treatment” gives reward no minimum effect size and a standard error from group variance. Aria onsets are for attempted hacks. TRL’s behavioral and same-rule cells rest on my eight-group floor and move to step 111 with its single prompt group; its other cells used no across-prompt standard error. In the AISI β=0.02 changepoint cell, 141 is conftest.py and 148 the exit call.
When the exploit is the reward, as in both AISI runs, the reward curve is the hack rate and nothing beats it on timing by more than a step or two. Where legitimate learning was also raising the reward, the behavioral feature starts from a near-zero baseline (0 in Aria, 0.4% in TRL) and alerts earlier than reward under the same rule: 8 steps in Aria, and 3 in the TRL run only under my eight-group floor. Under reward’s looser usual treatment, reward alerts first in three of four runs, by 32 steps in AISI β=0, 4 in AISI β=0.02, and 7 in TRL. Across these runs, the value of behavioral detection comes from naming the mechanism behind the shift.
4 Attempts versus outcomes
- 11,46254.2%successful hacks
- 8,10938.3%attempted the exploit and solved the problem
- 1,1465.4%attempted the exploit, neither solved nor hacked
- 4352.1%not labeled an attempt
run_tests, split by the dataset’s labels. Precision against successful hacks is the first segment, against attempts the first three together. 8,109 of the 8,130 rollouts that attempted the exploit and solved the problem fall here. Archived extractor; labels are used for this figure only.Per-rollout precision and recall of the mechanism features (archived prototype)
| Run | Feature | Labels in the data | P / R vs attempted | P / R vs successful | Reward = max, P / R vs successful |
|---|---|---|---|---|---|
| Aria | def:run_tests | attempted and successful | 0.98 / 1.00 | 0.54 / 1.00 | 0.51 / 0.97 |
| AISI β=0 | os._exit | successful only | – | 0.99 / 1.00 | 1.00 / 1.00 |
| AISI β=0.02 | exit call, conftest.py | successful only | – | 0.96 / 0.68, 0.91 / 0.70 | 1.00 / 0.92 |
| TRL | TestReport patch, import _pytest | successful only | – | 0.76 / 0.81, 0.79 / 0.81 | 0.98 / 0.99 |
Per-rollout precision (P) and recall (R) from each run’s 1% label onset (Aria: the attempted-hack onset, step 94), original feature extractor.
Because Aria’s attempt and success labels both require the response to define run_tests, near-perfect recall holds by definition. The extractor missed 64 of the 20,781 labeled attempts across steps 94–200 (99.7% recall, rounded to 1.00). Precision is more informative here: 98% of detected run_tests definitions were labeled attempts, and 54% were successful hacks. The feature records what the policy tried, while code execution decides whether it worked.
Aria is the only run labeled for both attempts and successes, and it shows the limit plainly. In Aria, 8,130 rollouts attempted the exploit and also solved the problem correctly. Separating them from successful hacks requires running the code against the tests, as both look identical in the transcript. The other three runs label successes only, and there “reward equals the maximum” is a near-perfect predictor by construction. Their precision figures measure something different and should be read separately from Aria’s.
5 What scalar diagnostics reported
What each scalar tool reported per run
| Run | rlprobe 0.1.0 | rldoctor 0.1.0 | RHDA surface layer |
|---|---|---|---|
| Aria | no findings | warning, advantage collapse: “the task is too easy” | drift on 92 of 93 pre-onset steps (top item at onset: “solve problem”) |
| AISI β=0 | degenerate groups (about 93% zero reward variance), suggesting rebalanced prompt difficulty, larger groups, or a higher sampling temperature | critical, advantage collapse: “task is too easy” | drift on 52 pre-onset steps (top item: “range range”) |
| AISI β=0.02 | no findings | warning, plateau: “training reward stopped improving” | drift on 27 steps (top item: “return second”) |
| TRL | no findings | all OK: “still learning” | drift on 69 of 70 pre-onset steps (top item: “int line”) |
These tools work from scalar or surface statistics by design, and they did what they were built to do: they flagged collapsed group variance and stalled reward curves, which are the scalar shadows of an exploit. Six of rldoctor’s nine checks, including its reward-hacking check, were skipped for missing inputs such as a held-out evaluation series, and rlprobe had no KL, entropy, clipping, or gradient fields. Their suggested remedies, such as rebalancing difficulty or raising temperature, address the shadow. RHDA’s deterministic layer fires on nearly every step and leaves mechanism-finding to its LLM agent loop, which was outside the scope of this deterministic comparison.
Exploratory: can behavior lead?
Post hoc · an exploratory hypothesis
Everything in this section was designed after seeing the results above. It needs testing, with the rule fixed in advance, on held-out runs.
The fixed rule alerts only once a feature reaches about 10% prevalence. I tried a looser variant, with no minimum effect size and a standard error from group variance (the treatment reward already gets), restricted to structural code features and ranked by reward association.
The loose structural rule, per run (archived prototype, reconstructed)
| Run | Structural alerts before 5% onset | Mechanism feature, loose alert | Rank by reward association | Reward, loose alert |
|---|---|---|---|---|
| Aria | 10† | def:run_tests at 36 | 1 of 10† | 120 |
| AISI β=0 | 5, none rewarded | os._exit at 96 | post-onset | 72 |
| AISI β=0.02 | 10, weakly rewarded | conftest.py at 141 | post-onset | 138 |
| TRL | 10 | report.outcome at 84 | 1 of 10, tied with two other report.* assignments | 91 |
† From an analysis I never saved. A rerun of my archived code reproduces every alert step and rank but counts 8 Aria structural alerts before the onset (11 with the binary AST categories), so both Aria 10s are unverified.
run_tests, on a logarithmic scale (empty steps have none). Per-step counts from the experiment archive.In Aria, 32 rollouts between steps 25 and 60 already define run_tests, 23 of them labeled attempts, and they earn more than the other rollouts in those steps (mean reward 1.95 against 1.46). The mechanism existed and paid off about 70 steps before selection made it common, and the loose structural rule ranks it first at step 36. That alert is a single rollout per step on three consecutive steps after a baseline of zero, so the loose rule fires on the first three consecutive steps with any occurrence, and its reward association rests on three prompt groups. It also leans on hindsight: run_tests has a series at all only because the vocabulary was chosen over the whole run, and it first reaches 5% of a step’s rollouts at step 104. The TRL run shows the same pattern: report.outcome alerts 7 steps ahead of the loose reward alert and 5 ahead of the 5% onset, tied for first with two other report.* assignments. Neither AISI run does: there the exploit goes from rare, about 1% of rollouts, to dominant within about ten steps. Without the restriction to structural features the signal disappears: in the original, unsaved run, 2,326 features alert by step 70 and run_tests ranks 55th.
Limitations
- Prompt mix. In Aria, each step draws 16 problems, and each problem appears only three or four times, roughly 60 steps apart, so a small rise in a rare identifier (
bisect_left, present from step 1 in 41 of 992 problems) may reflect which problems were drawn alongside what the policy learned. Splitting by prompt within a step leaves step-to-step turnover unaddressed. - Alert volume. Prose n-grams dominate the feature space: 777K candidate tokens in Aria, of which 4,000 were kept. The fixed rule still produced 458 alerts in Aria, 173 passed the filters, and clustering condensed those into 16 events. Ordering benign events below exploits requires either the structural restriction or a model’s reading.
- No clean comparison run. Filtering out labeled rollouts still leaves failed attempts in the data: in AISI β=0, steps 100–130, 118 of 1,429 rollouts with an exit call carry no success label. Measuring a false-alert rate on verified change-free data will require unhacked runs. The pre-onset windows (no filtered alerts in three runs, seven benign events in Aria with one holding most of the alerts) show what fired before the labeled behavior, which is a different measurement.
- Label gaps. The TRL run’s
rh_*labels cover a 25% subsample, and filling the rest with zeros first gave a spurious precision of 0.19. I used the completeproxy_*labels instead, which agree withrh_*on 99.7% of the labeled subsample. - An asymmetry of my own. Prevalence features had a stricter standard-error floor and a minimum effect size that were absent from the continuous reward series. The “same rule” column corrects for this, except for Aria’s reward, whose floor is undefined on a 0 to 3.5 scale. The exploratory section is its mirror image.
- Scope. All four runs are single-turn Python trajectories across Qwen3 (4B and 8B) and OLMo-3.1 (32B). Multi-turn and tool-using agents remain untested, and the AST features are Python-only.
- Manual event summaries. The one-line event descriptions in this note are mine.
Cost
On one machine (core count unrecorded), loading, featurizing, building the series, detection, and baselines take about 2 minutes for Aria (51,200 rollouts), 1.7 minutes for AISI β=0.02, and under a minute for AISI β=0 (~25,700 rollouts each), down to 30 seconds for TRL (3,680 rollouts). Those times leave out the split-half reruns and the evaluation. Zero model API calls.
What comes next
These results are why I am building Rollout Diff, an open-source tool for understanding how model behavior changes through training, working from the rollout dumps trainers already write. Reward hacking is its first application within a broader scope. Today the package loads TRL and verl dumps, screens every retained feature with a sequential z-rule and a retrospective changepoint test that differ from the prototype’s in scale and confirmation, and reports replication, corroboration, persistence, reward-association, and prompt-mix evidence per series. It omits event clustering, ranking, and rollout citations, and leaves runs with a single prompt per step unscorable. Its error rates are uncalibrated, and its retrospective test still exceeds its own synthetic false-alert target. Next, it will compare any two training windows or checkpoints, rank behavioral events with the prompt-mix evidence and cited rollouts behind each, and optionally query a model under a fixed budget to summarize an event. If you have GRPO rollout dumps from experimental or production runs, I would like to run it on them. Get in touch through this site.
Reproduction
The findings come from the research prototype I ran on October 17, 2026, and from reruns of its archived code on the same parquet files. Detection constants: baseline 15 steps, z > 4 for 3 steps, minimum rise 0.10 for prevalence, and for prevalence features a standard-error floor of √(p₀(1−p₀) / max(G, 8)), with p₀ = max(baseline mean, 0.5/n) for G prompt groups and n rollouts per step. I set the minimum of eight prompt groups knowing that TRL and AISI have one to four prompts per step. With TRL’s actual single prompt group, its patch alert and the same-rule reward both move to step 111. Label onsets: rate ≥ 1 / 5 / 10% for 3 consecutive steps. Aria parquet SHA-256 begins 59ffb1aa. The archive omitted rldoctor’s version, though 0.1.0 was the only release at the time and matches the revision pinned in Rollout Diff’s benchmark manifest.
Two counts need a word about where they come from. My first count of unlabeled exit-call rollouts in AISI β=0, steps 100–130, was 368 of 1,751, which matched any response mentioning os._exit or sys.exit, including unparseable responses (117 of the 368 contain a real exit call, 172 fail to parse, 45 have no code block, and 34 parse but contain no exit call). The archived AST extractor gives the 118 of 1,429 in Limitations. The exploratory count of 2,326 features by step 70, with run_tests 55th, comes from the unsaved analysis. Rerunning the archived code gives 2,354 and 56th.
Rollout Diff (rolloutdiff at commit 58a3f6b) reimplements the pipeline with changes: a vocabulary capped at 2,000 tokens per tier, a sequential scale of max(baseline SD, median baseline SE, 10⁻⁹) with no binomial floor, AR(1) confirmation of the retrospective split, and no standard error at steps with fewer than two sampling units. Rerunning the analysis with the package changes the values below, while the remaining cells of the timing table reproduce.
Values that differ when the current package reruns the analysis
| What | Archived prototype (this note) | Rollout Diff at 58a3f6b |
|---|---|---|
AISI β=0, os._exit alert | 104 | 102 |
| AISI β=0, reward alert, same rule | 104 | 100 |
AISI β=0.02, conftest.py alert | 147 | 141 |
AISI β=0.02, conftest.py changepoint | 141 | 140 |
| TRL, every detector cell | as tabled | unscorable: one prompt per step gives no standard error |
| Rollouts analyzed | 106,336 | 106,272: the loader drops AISI β=0.02 step 403 (64 rollouts, 29 unscored) |
Aria, def:run_tests at step 130 | 97% | 98% |
Aria, problems with bisect_left from step 1 | 41 of 992 | 42 of 992 |
| AISI β=0, steps 100–130, exit-call rollouts without a success label | 118 of 1,429 | 167 of 1,545 |
| Aria, series that alert under the fixed rule | 458 of 4,033 | 480 of 3,779 |
Exploratory, all-feature alerts by step 70 and the rank of run_tests | 2,326 and 55th (2,354 and 56th on rerun) | 1,643 and 36th, with a different vocabulary |
The package also splits my half check in two. One check asks whether each half’s first alert matches the full-run alert, while the other asks only whether each half shows a matching sustained rise within ten steps of it. On the AISI runs, the first check is unavailable for every alert under the default policy. The second holds for the exit features (call:os._exit, import:os, calls_exit, n_files_declared) and is unavailable for file:conftest.py and class:AlwaysEqual.
Data and credit
Thanks to Gutenberg for the Aria reproduction (of Aria’s Steering RL Training study), to the UK AI Security Institute for the OLMo-3.1 model-organism rollouts, to the publisher of the TRL completion logs, and to the authors of rlprobe, rldoctor, and CHERRL for open tools. Each dataset’s own terms apply. The Aria card asserts no open-source license and the TRL data declares none.