The LLM StackFrom Silicon to Agents
Part VI — RL Infrastructure (Deep Dive)
38 min read·Updated ·▶ Run the code (Colab)

6.12 RL Data, Curriculum & Replay Management

Every chapter in this part so far has treated the prompts as a given — a static .jsonl of math problems or coding tasks that flows into the rollout engine. That is a convenient fiction, and it is the single most expensive fiction in RL-for-LLMs. The dirty secret of reinforcement learning with verifiable rewards (RLVR) is that most of your rollout budget is wasted on prompts that teach the policy nothing. A prompt the model already solves 8/8 times produces a group with zero reward variance and therefore zero gradient under GRPO. A prompt the model solves 0/8 times produces the same zero-variance, zero-gradient group from the other end. You paid for \(G\) full autoregressive rollouts — the most expensive operation in the entire stack (The Anatomy of an RL-for-LLM System) — and got an all-zeros advantage vector for your trouble.

This chapter is about the data side of RL: the levers that determine which prompts the policy sees, when it sees them, and whether a rollout you already paid for gets reused. These levers — dataset construction and quality control, difficulty estimation, difficulty-targeted online selection, prompt curriculum, dynamic sampling, and rollout replay buffers — are collectively the largest free lunch in RL sample efficiency. They are also where theory (the variance of the policy-gradient estimator) and systems (keeping a heavy-tailed generation fleet saturated) collide most violently.

We assume you know GRPO and the RLVR recipe (GRPO, RLOO & Critic-Free RL; RL with Verifiable Rewards (RLVR) & The Reasoning Recipe), the throughput model from the previous chapter (Scaling RL: Throughput, Load Balancing & The Latest Tricks), and the reward/verifier machinery that turns a completion into a scalar (Reward Engineering, Verifiers & Sandboxes). This is the curriculum-and-data complement to those chapters.

Why difficulty is the master variable

Start from the gradient. For a prompt \(x\) with a binary verifiable reward \(r\in\{0,1\}\), sampled \(G\) times under the current policy, the empirical pass rate is \(\hat p = \frac{1}{G}\sum_{i} r_i\). GRPO centers each completion’s reward by the group mean and (in vanilla form) divides by the group standard deviation:

\[ A_i \;=\; \frac{r_i - \bar r}{\operatorname{std}(r) + \varepsilon}, \qquad \bar r = \hat p, \qquad \operatorname{std}(r) = \sqrt{\hat p (1-\hat p)}. \]

Two facts fall out immediately. First, if every \(r_i\) is identical (all \(0\) or all \(1\)), then \(\bar r = r_i\) for all \(i\), every \(A_i = 0\), and the prompt contributes exactly zero to the policy gradient. The group is dead. Second, the amount of usable signal in a group is governed by the variance of the reward, which for a Bernoulli is

\[ \operatorname{Var}(r) \;=\; p(1-p). \]

This is a downward parabola, maximized at \(p = 0.5\) where \(\operatorname{Var}(r) = 0.25\), and falling to zero at both ends. The expected number of informative pairs — completions with different rewards inside a group, which is what GRPO’s centering actually exploits — is proportional to \(p(1-p)\) as well. So the gradient signal-to-noise of a prompt is a single-peaked function of one scalar: its pass rate under the current policy.

signal per prompt ~ p(1-p) 0.25 0.00 pass rate p 0.0 0.25 0.5 0.75 1.0 p* = 0.5 Var = 0.25 Var = 0.09 ~0.01 (dead) all-wrong: A_i=0 all-correct: A_i=0 2.8x signal
Gradient signal per prompt peaks at pass rate 0.5. The Bernoulli variance Var(r) = p(1-p) — the per-prompt contribution to the policy-gradient estimator — is a downward parabola maximized at p=0.5 (Var=0.25) and zero at both ends. A prompt at p=0.9 yields only Var=0.09, roughly 2.8x less signal per rollout than one at p=0.5, making pass rate the master variable controlling sample efficiency.

The practical consequence: you want to spend rollout compute on prompts whose current pass rate is near 0.5. A prompt at \(p=0.9\) gives variance \(0.09\); a prompt at \(p=0.5\) gives \(0.25\) — roughly \(2.8\times\) the signal per rollout. A prompt at \(p=0.99\) gives \(0.0099\), essentially nothing. This is the quantitative heart of curriculum learning in RL, and it is why “difficulty” is not a soft pedagogical nicety but the master variable controlling your estimator’s efficiency.

Optional: what dividing by the group std does to this argument

The clean \(p(1-p)\) law is exact for the unnormalized advantage \(A_i = r_i - \bar r\) used by Dr. GRPO and DAPO (GRPO, RLOO & Critic-Free RL): with \(k\) successes out of \(G\) and \(\hat p = k/G\), the total advantage mass in the group is

\[ \sum_i |A_i| = k(1-\hat p) + (G-k)\hat p = 2G\,\hat p(1-\hat p). \]

With vanilla GRPO’s std division, each success gets \(A_i = \sqrt{(1-\hat p)/\hat p}\) and each failure \(-\sqrt{\hat p/(1-\hat p)}\), so the mass becomes \(\sum_i |A_i| = 2G\sqrt{\hat p(1-\hat p)}\) — a square root, which is much flatter. At \(\hat p = 0.1\) versus \(\hat p = 0.5\) the unnormalized group carries \(0.09/0.25 = 36\%\) of the mass, the std-normalized one \(0.3/0.5 = 60\%\). That amplification of extreme-difficulty prompts is the “difficulty bias” Dr. GRPO objects to (it is not the gradient of any objective) and that others defend as a free curriculum.

Either way the targeting conclusion survives, for a reason independent of magnitude: at finite \(G\) a prompt only contributes at all if its group is non-degenerate, which happens with probability \(1 - p^G - (1-p)^G\). That function — the survival probability you will meet again under dynamic sampling — collapses at both ends no matter how the advantage is scaled. Extreme prompts are not merely quiet; most of the time they are silent.

There is a subtlety the variance argument hides. Pass rate is non-stationary: it is a property of the prompt and the current policy together, and the policy moves every step. A prompt that sits at \(p=0.5\) at step 0 may be at \(p=0.95\) by step 200 because the model learned it. A static difficulty label computed once, offline, decays. The whole architecture of online difficulty-targeted selection exists to track this moving target.

Aside: why not just always train at p=0.5?

Because a policy that only ever sees 50%-pass prompts never gets pulled into new regimes. The frontier of “things I can do half the time” advances only if some signal leaks from harder prompts (occasional lucky successes that the gradient amplifies) and is consolidated on easier ones. In practice you target a band — say \(p\in[0.2, 0.8]\) — not a razor at \(0.5\), and you let the band’s contents drift as the policy improves. The band is the curriculum; the policy walks up it.

Building the dataset: construction and quality control

Before any online selection can help, you need a corpus of prompts that is (a) verifiable, (b) de-duplicated against your eval sets, and © spread across a usable difficulty range. Garbage in the prompt pool is more corrosive in RL than in SFT, because RL will ruthlessly exploit any defect in the reward signal (Reward Hacking, Over-Optimization & Alignment Failures).

The non-negotiables of an RLVR prompt set

For each task you need a prompt and a checker. The checker is the verifier — a deterministic (or near-deterministic) function that maps a completion to a reward. For math it is symbolic/numeric answer-equivalence; for code it is a unit-test harness in a sandbox; for agentic tasks it is an environment that returns a terminal success bit (Reward Engineering, Verifiers & Sandboxes). The data record therefore carries more than text:

from dataclasses import dataclass, field
from typing import Callable, Optional
import hashlib

@dataclass
class RLTask:
    task_id: str                      # stable hash, used for dedup + replay keys
    prompt: str                       # the rendered chat prompt fed to the policy
    answer: Optional[str] = None      # ground-truth answer for math-style checking
    tests: Optional[list] = None      # unit tests for code tasks
    domain: str = "unknown"           # math / code / logic / agentic / ...
    # difficulty state, maintained ONLINE (see later sections):
    pass_rate_ema: float = 0.5        # exponential moving avg of empirical pass rate
    n_attempts: int = 0               # how many groups we've spent on this task
    n_solved_groups: int = 0          # groups where at least one sample passed
    static_difficulty: Optional[float] = None  # offline estimate, optional prior

    def fingerprint(self) -> str:
        # Normalize before hashing so trivial whitespace/casing diffs collapse.
        norm = " ".join(self.prompt.lower().split())
        return hashlib.sha256(norm.encode()).hexdigest()[:16]


# Do NOT hand-roll math answer matching. `math-verify` (HuggingFace,
# `pip install math-verify`) is the de-facto open-source checker: `parse` pulls the
# answer out of \boxed{...}/LaTeX/plain text and `verify` compares them with a
# SymPy-backed equivalence test, so 0.5 == 1/2 and (x+1)^2 == x^2+2x+1 instead of
# being scored wrong by string equality. (API tracks the current release.)
from math_verify import parse, verify


def make_checker(task: RLTask) -> Callable[[str], float]:
    """Return a verifier closure. In production the code path runs in a sandbox;
    here we sketch the math path. The checker MUST be robust to extraction noise."""
    if task.domain == "math":
        gold = parse(f"${task.answer}$")              # parse gold once, reuse per rollout
        def check(completion: str) -> float:
            pred = parse(completion)                  # extraction is part of the reward
            return 1.0 if pred and verify(gold, pred) else 0.0
        return check
    # For domain == "code", the checker executes `task.tests` against the extracted
    # program inside a resource-limited sandbox — see the verifiers chapter.
    raise NotImplementedError(f"no checker for domain {task.domain}")

A few rules that separate a usable RL corpus from a frustrating one:

  1. Verifiability over volume. Ten thousand prompts with airtight checkers beat a million with flaky ones. A checker that returns a false positive 2% of the time teaches the policy to find that 2% — RL is an adversary against your verifier. Audit checkers on known-good and known-bad completions before trusting them.

  2. Answer extraction is part of the reward. If your checker only accepts \boxed{} and the model writes “The answer is 42,” you will mislabel correct completions as failures, depressing pass rates and corrupting your difficulty estimates. Make extraction permissive for grading correctness but be careful it cannot be gamed. This is precisely why you use math-verify rather than a regex.

  3. Decontaminate against evals — twice. Run n-gram (e.g. 13-gram) and embedding-level overlap checks between your prompt pool and every benchmark you will report on. Then do it again after any synthetic augmentation. Contamination inflates eval and is the most common cause of “great training curve, flat real-world gains.” This mirrors pretraining decontamination (Data Cleaning, Deduplication & Quality Filtering) — and the tooling is the same: HuggingFace datatrove’s MinHash and n-gram-decontamination pipelines at corpus scale, or datasketch’s MinHashLSH for a pool small enough to fit in memory.

  4. Dedup near-duplicates inside the pool. Two prompts that differ only in variable names are one prompt’s worth of signal but two prompts’ worth of compute. MinHash/LSH or embedding clustering collapses them. RLVR pools are small enough (104–106 prompts) that a single-machine datasketch pass over shingled prompt text takes minutes; there is no excuse for skipping it.

  5. Track provenance. Keep domain, source, and a difficulty prior per task. You will want to re-weight domains later, exactly as in pretraining data mixing (Data Mixing, Domain Weighting & Curriculum).

Where the prompts come from

Most strong RLVR runs blend several sources: curated competition/benchmark-style problems with known answers; synthetically generated variants (a stronger model or a generator produces new problems plus checkable answers, then a solver filters for solvability — see Synthetic Data for Pre- and Post-Training); and mined failures from a previous policy (prompts the last checkpoint got wrong become the next run’s curriculum, a data flywheel — Data Flywheels & Continuous Improvement). For agentic tasks the “prompt” is a whole environment seed (a repo state, a browser task, a tool sandbox), and construction means building reproducible, resettable environments (Agentic & Multi-Turn RL).

The 2026 packaging convention for all of this is the environment: prompt set plus parser plus a rubric of reward functions, behind one interface that several trainers can consume. willccbb/verifiers is the reference implementation and the emerging unit of exchange — you pip install (or write) an environment and hand it to a TRL- or veRL-style trainer instead of shipping a bespoke .jsonl plus a bespoke grader. Prefer it over hand-rolling: an environment carries its checker with it, which is exactly the coupling that keeps difficulty estimates meaningful. On disk, the pool itself is best kept as a HuggingFace datasets parquet table keyed by task_id so that the online difficulty state below can be checkpointed as a small sidecar against the same keys.

Difficulty estimation: offline priors and online truth

You ultimately want, for every task, an estimate of its pass rate under the current policy. There are two regimes: an offline prior computed once before the run, and the online estimate maintained during it.

Offline difficulty estimation

Before the run, sample the base policy (or a reference model) \(k\) times on each prompt and record \(\hat p_0 = (\text{successes})/k\). This is the maximum-likelihood estimate of the pass rate, with the obvious caveat that for small \(k\) it is coarse — with \(k=8\) you can only distinguish pass rates in steps of \(1/8 = 0.125\), and the standard error is

\[ \operatorname{SE}(\hat p) = \sqrt{\frac{\hat p(1-\hat p)}{k}} \;\le\; \frac{1}{2\sqrt{k}}. \]

For \(k=8\) that worst-case SE is \(\approx 0.18\) — so a prompt you measured at \(\hat p_0 = 0.5\) might truly be anywhere in roughly \([0.32, 0.68]\). Offline difficulty is a prior, not a label. Its value is in bucketing and in discarding the unusable tails (prompts the base model solves \(0/k\) — possibly impossible or mis-checkered — and \(k/k\) — already mastered). A common offline pipeline:

import numpy as np

def estimate_offline_difficulty(tasks, policy, checker_fn, k=8, batch_gen=None):
    """One pass over the pool: k rollouts each, record empirical pass rate.
    Returns buckets and prunes the dead tails. batch_gen() should call your
    rollout engine (vLLM/SGLang) — batch ALL prompts*k together for throughput."""
    for t in tasks:
        completions = batch_gen(t.prompt, n=k)            # k samples
        rewards = [checker_fn(t)(c) for c in completions]
        t.static_difficulty = float(np.mean(rewards))     # p_hat_0 in [0,1]
        t.pass_rate_ema = t.static_difficulty             # seed the online EMA

    keep, pruned = [], []
    for t in tasks:
        # Prune the dead tails: never-solved (maybe broken) and always-solved.
        if 0.0 < t.static_difficulty < 1.0:
            keep.append(t)
        else:
            pruned.append(t)
    return keep, pruned


def difficulty_bucket(p, edges=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0)):
    """Map a pass rate to a coarse bucket index. Buckets, not raw p, because
    raw p from small k is too noisy to act on at fine granularity."""
    for b in range(len(edges) - 1):
        if edges[b] <= p < edges[b + 1]:
            return b
    return len(edges) - 2  # p == 1.0 edge case

The \(0/k\)-and-\(k/k\) pruning typically removes 20–50% of a raw competition-math pool: a chunk is trivial for a strong base model, and another chunk is currently impossible (genuinely too hard, or the checker is wrong). Both contribute zero gradient and pure rollout waste, so removing them is a direct sample-efficiency win before training begins.

Online difficulty: the EMA and Beta views

During training the truth is the online pass rate. Each time task \(t\) is rolled out as a group of \(G\), you observe \(\hat p_t = (\text{successes})/G\) and update an exponential moving average:

\[ \bar p \;\leftarrow\; (1-\alpha)\,\bar p \;+\; \alpha\,\hat p_t, \]

with \(\alpha\) around \(0.1\)\(0.3\). The EMA tracks the moving policy: a prompt that was hard becomes easy as the policy learns, and the EMA follows. A cleaner Bayesian alternative is a Beta–Bernoulli posterior per task: maintain counts \((s, f)\) of successes and failures (optionally discounted over time so old observations decay), and the posterior pass rate is \(\text{Beta}(s+1, f+1)\) with mean \(\frac{s+1}{s+f+2}\). The Beta view gives you not just a point estimate but a credible interval, which is exactly what you need to decide whether a prompt is “near 0.5” with confidence or just under-sampled — and it plugs naturally into a Thompson-sampling selection policy (below).

This state is small and you must checkpoint it with the model. Three floats per task (\(s\), \(f\), n_groups) keyed by task_id is a few megabytes for a million-prompt pool — write it as a parquet/JSON sidecar next to every model checkpoint. If you do not, a preempted run resumes with a warm policy and a cold curriculum: the selector spends its first hundred steps re-discovering difficulties it already paid to measure, and (worse) re-samples the mastered prompts you had correctly stopped selecting.

Common pitfall: stale difficulty labels

The most common difficulty-curriculum bug is computing pass rates once and treating them as fixed. Within a few hundred steps a well-chosen 50%-band collapses toward 100% as the policy masters it, and if you keep sampling the same “medium” bucket you are now training on solved prompts — zero gradient, wasted compute, and a training curve that mysteriously plateaus. Difficulty MUST be re-estimated online (EMA or decayed Beta). The whole point is that difficulty is a function of the current policy, which is a moving target.

Difficulty-targeted online selection: keeping prompts near p≈0.5

Now the central mechanism. Given a pool with online difficulty estimates, how do you choose the next batch of prompts to roll out so that the realized groups cluster near \(p\approx0.5\) and almost none come back zero-variance?

Selection as a bandit over difficulty

Frame it as a multi-armed bandit where each task (or each difficulty bucket) is an arm and the “reward” is the informativeness of the group it produces — for example \(\hat p_t(1-\hat p_t)\), the realized Bernoulli variance, or simply \(\mathbb{1}[\text{group had non-zero variance}]\). You want to pull arms that are currently informative while exploring arms whose difficulty you are unsure about (because the policy moved). Two clean policies:

  • Greedy-to-target: score each task by closeness of its estimated pass rate to the target \(p^\star\) (usually \(0.5\)), e.g. \(\text{score}(t) = -\,|\bar p_t - p^\star|\), and sample the top-scoring tasks (with noise / temperature so you do not over-commit to a handful). Simple, effective, but blind to estimate uncertainty.
  • Thompson sampling on the Beta posterior: draw \(\tilde p_t \sim \text{Beta}(s_t+1, f_t+1)\) for each candidate and select tasks whose sampled pass rate is nearest the target. This automatically explores under-sampled tasks (wide posteriors get drawn far from their mean), so freshly-promoted-difficulty prompts get re-evaluated. This is the principled version and is what I reach for in practice.

A worked implementation appears in the code section. The key design choice is the band, not the point: select for \(\bar p \in [p_{\text{lo}}, p_{\text{hi}}]\) (say \([0.2, 0.8]\)) rather than exactly \(0.5\), so the batch retains some easier prompts (stability, anti-forgetting) and some harder prompts (frontier signal, exploration). Within the band you can still weight toward the center.

The relationship to dynamic sampling

Difficulty-targeted selection and dynamic sampling (DAPO-style) attack the same enemy — zero-variance groups — from opposite ends of the loop. Selection works before generation: pick prompts likely to land in-band. Dynamic sampling works after generation: discard the groups that came back all-same-reward anyway and oversample to refill the batch. You want both, because difficulty estimates are noisy (small-\(k\) SE is large) and the policy moves between selection and rollout, so even a well-targeted batch will produce some dead groups. Selection reduces how many you have to throw away; dynamic sampling guarantees the batch you train on is fully informative.

Dynamic sampling: never train on a zero-variance batch

Dynamic sampling, popularized by DAPO, is mechanically simple and statistically important. After generating \(G\) completions per prompt and scoring them, drop every prompt whose group has zero reward variance (all correct or all wrong), then keep generating more prompts until you have collected a full target batch of \(B_{\text{keep}}\) prompts that all have non-zero variance.

\[ \text{keep prompt } x \iff 0 < \hat p(x) < 1 \quad\Longleftrightarrow\quad \operatorname{std}\big(r(x)\big) > 0. \]

The benefit is that every gradient step now operates on a batch where every prompt contributes signal — no dead weight diluting the update, no wasted optimizer step. The cost is throughput: you must oversample. If a fraction \(\rho\) of generated groups survive the filter, you must generate \(B_{\text{keep}}/\rho\) groups to fill the batch — and \(\rho\) shrinks as the policy improves and more prompts saturate to \(p=1\). This is the throughput-vs-statistics tension flagged in the scaling chapter (Scaling RL: Throughput, Load Balancing & The Latest Tricks): dynamic sampling is cheap on an async, oversubscribed generation layer and brutal on a synchronous one, where the extra rollouts serialize against training.

It is worth being precise about what dynamic sampling does and does not do to the estimator, because “we filter the batch” sounds like it should bias something. Dropping a zero-variance group is exactly gradient-neutral: that group’s contribution was already the zero vector, so removing it changes only the denominator you average over — the direction of the update is untouched, and the loss stops being diluted. The bias enters through the refill: the prompts you generate to replace the discards are, by construction, drawn conditional on being informative, so you are optimizing a difficulty-reweighted objective — expected reward under a prompt distribution tilted toward mid-difficulty — rather than uniform expected reward over the pool. That is almost always what you want, but it means your training reward curve is not comparable across runs with different filters, and it is why you must evaluate on a fixed, unfiltered held-out set (Building Eval Harnesses) rather than reading progress off the training reward.

Difficulty-targeted selection is the throughput rescue for dynamic sampling: by feeding the generator prompts that are already likely to be in-band, you raise the survival fraction \(\rho\), so you oversample less to fill the batch. The two are complementary — selection raises \(\rho\), dynamic sampling guarantees correctness when \(\rho<1\).

Where these knobs live in the real libraries. You will rarely write the filter loop yourself. In veRL (veRL: HybridFlow & The Single-Controller Architecture) the DAPO recipe turns dynamic sampling on with an algorithm.filter_groups block (enable it, name the metric it filters on — the per-group accuracy — and cap how many generation batches it may consume before giving up), together with a data.gen_batch_size set larger than data.train_batch_size: that ratio is the oversampling factor \(1/\rho\) made into a config field, and the cap is what stops a saturated pool from spinning forever. OpenRLHF exposes an equivalent group-filtering flag on its GRPO/REINFORCE++ path. TRL’s GRPOConfig (TRL: HuggingFace’s RL Library) gives you the group and clipping knobs (num_generations = \(G\), epsilon/epsilon_high, scale_rewards) but leaves prompt selection to you — the natural hook is a custom Sampler over the training Dataset that reads the difficulty sidecar and yields the in-band task_ids, which is precisely the select_candidates function below. Field names drift between releases; read the config dataclass in the version you install. At the other end of the scale ladder, the capstone implements the whole filter inline in a single-GPU loop, because at 100M parameters generation is cheap enough that oversampling costs seconds (Post-Training: SFT, DPO, and Narrow RLVR (GRPO) That Works at 100M).

Worked example: the oversampling tax, with and without targeting

Suppose you want a training batch of \(B_{\text{keep}} = 256\) informative prompts, \(G = 8\) samples each.

Naive uniform sampling (mid-training). Your pool’s pass-rate distribution, under the current policy, is roughly: 40% of prompts at \(p\approx0.95\) (nearly mastered), 25% at \(p\approx0.05\) (nearly impossible), 35% spread in \([0.2,0.8]\). What survives the zero-variance filter? A group at \(p=0.95\) comes back all-correct with probability \(0.95^8 \approx 0.66\), i.e. it is dead 66% of the time, surviving only 34%. A group at \(p=0.05\) is dead \(0.95^8\approx0.66\) of the time too (all-wrong), surviving 34%. The in-band prompts survive far more often: \(1 - 0.5^8 - 0.5^8 \approx 0.99\) at the center, falling to \(1 - 0.8^8 - 0.2^8 \approx 0.83\) at the band edge \(p=0.8\) — take \(\approx 0.83\) as a conservative average across the band. Survival fraction:

\[ \rho \approx 0.40(0.34) + 0.25(0.34) + 0.35(0.83) \approx 0.136 + 0.085 + 0.29 \approx 0.51. \]

To fill 256 you must generate \(256 / 0.51 \approx 502\) groups — roughly 2× the rollout compute thrown away as zero-variance, \(502 \times 8 \approx 4016\) completions for 2048 kept.

Difficulty-targeted selection. Now you pre-select prompts whose online EMA sits in \([0.2,0.8]\). Even accounting for estimate noise and policy drift (so realized in-band fraction is, say, 80% rather than 100%), survival climbs to roughly

\[ \rho \approx 0.80(0.83) + 0.20(0.34) \approx 0.66 + 0.07 \approx 0.73. \]

Now you generate \(256/0.73 \approx 351\) groups — you have cut the oversampling tax from \(\sim\)2× down to \(\sim\)1.37×, saving about 30% of generation compute for the same informative batch. On a run where generation is 75% of wall-clock, that is a \(\sim\)20% end-to-end speedup, for free, from data-side bookkeeping.

1. Select (before) 2. Generate G per prompt 3. Filter (after) = dynamic sampling prompt pool, difficulty-labeled low-p p*=0.5 high-p (shaded = target band) 9 selected (in-band) -> each: G=8 samples; filled o = pass, hollow o = fail 5/8 3/8 8/8 6/8 0/8 4/8 2/8 8/8 5/8 sieve: dashed drop-line above = zero-variance (8/8 or 0/8) 6 kept -> 3 dropped -> Informative training batch every kept group has 0 < p̂ < 1 kept: 6 Discard tray zero-variance: dropped dropped: 3 oversample to refill rho = kept / generated = 6 / 9 ≈ 0.67 selection raises rho (fewer dead groups made); dynamic sampling guarantees rho<1 batches are still fully informative.
Selection (before generation) and dynamic sampling (after generation) attack zero-variance groups from opposite ends of the same loop. The selector pulls prompts from a scattered pool toward the p*=0.5 band so more of the nine rolled-out groups come back mixed; the sieve then drops whatever still lands all-correct or all-wrong into a discard tray and triggers an oversample-to-refill wave, so every group that reaches the training batch -- survival fraction rho = kept/generated -- carries a non-zero gradient.

Prompt curriculum: walking the policy up the difficulty ladder

Difficulty-targeted selection keeps you near \(p=0.5\) at each step. A curriculum is the trajectory of that band over the whole run. Three flavors, increasingly automatic:

Static / staged curriculum

Sort prompts into difficulty tiers offline and present them in stages: easy first, then medium, then hard, switching tiers on a schedule or when an aggregate pass-rate threshold is hit. This is the RL analogue of pretraining curriculum (Data Mixing, Domain Weighting & Curriculum). It is simple and sometimes helps warm-start, but it is brittle: the offline tiers decay (the master-variable problem), and a hard stage switch can destabilize the policy.

Automatic / online curriculum

Let the curriculum emerge from online difficulty targeting. Because difficulty is re-estimated every step and you always select for the band, the contents of the band naturally drift from easy to hard as the policy masters the easier material — the curriculum is an emergent property, not a hand-authored schedule. This is the modern default: you do not write a curriculum, you write a selection rule and the curriculum is its trajectory.

Self-paced / regret-based curriculum

A more aggressive variant (rooted in the unsupervised-environment-design and automatic-curriculum-learning literature) selects prompts by learning progress or regret: prioritize tasks where the policy is improving fastest, or where there is the largest gap between achievable and current performance. Practically, score a task by the recent change in its pass-rate EMA (a prompt whose \(\bar p\) is climbing is where learning is happening) and up-weight it. This pushes compute exactly to the frontier of competence. It is powerful but adds estimator complexity and can be unstable if the progress signal is noisy.

pass rate 1.0 0.5 0.0 training steps -> band target (p*) easy medium hard band rides right, tracking each tier's crossing of p*
The training band is not the fixed strip on the left — it is the region that rides along p*=0.5, linking each tier's crossing in turn. Every tier rises and saturates toward p=1 at a different rate, and the shaded guide threads through the moment each one crosses the target p*=0.5: easy first (hollow green circle), then medium, then hard, with chevrons marking the direction of travel. A good online curriculum keeps the training batch riding that diagonal — nobody authors a schedule, the band's contents simply drift from easy to hard as each tier saturates.

The figure is the whole idea: each tier rises and saturates; the band (the prompts currently near \(p^\star\)) slides from easy through medium to hard. A good online curriculum keeps the training batch riding the diagonal where the tiers cross \(p^\star\).

Replay buffers: reusing rollouts you already paid for

Generation is the dominant cost (Scaling RL: Throughput, Load Balancing & The Latest Tricks). A rollout you used once and discarded is money burned. Replay reuses past rollouts — but RL-for-LLMs is on-policy by nature (the policy gradient is an expectation under the current policy), so naive replay is off-policy and biased. Replay in this setting comes in two distinct flavors that solve different problems.

stores policy status correctness depth job Prompt buffer (which tasks to attempt) task IDs / prompts only (never completions) on-policy (completions always freshly generated) trivially unbiased FREE the whole live pool throughput + signal = the curriculum Staleness buffer (recent completions) completions + stored old log-probs pi_old off-policy rollouts 1-4 policy versions stale MUST importance- correct: PPO ratio + clip SHALLOW half-life ~ a few steps evict beyond tau_max async latency overlap Success / hard-case buffer (banked winners) successful trajectories on rarely-solved tasks on-policy-ish positive / SFT-style anchors expert-iteration style: retention, not gradient-unbiasedness banked, long-lived (kept until superseded) anti-forgetting on sparse agentic tasks stored trajectories have a half-life: stale = clipped = 0 gradient
Three distinct objects with three distinct correctness constraints -- do not conflate them. The prompt buffer is free and on-policy because only task IDs are replayed and completions are always fresh; the staleness buffer stores actual old completions and must be importance-corrected and evicted young, because a stale trajectory's gradient contribution decays to zero under PPO clipping; the success buffer trades gradient-unbiasedness for retention, banking rare wins so the policy does not forget them.

Prompt-level replay (revisiting tasks)

The cheap, safe kind: replay which prompts to attempt, not the old completions. A prioritized prompt buffer stores tasks with a priority equal to their current informativeness (e.g. \(|\bar p_t - p^\star|^{-1}\), or recent learning progress) and re-samples high-priority prompts more often — this is exactly the prioritized-experience-replay idea (Schaul et al.) applied at the task level rather than the transition level. The completions are always freshly generated under the current policy, so it stays on-policy. This is just difficulty-targeted selection with persistence and is essentially free.

Trajectory-level replay (reusing completions)

The expensive, dangerous kind: store the actual completions and their per-token log-probs \(\pi_{\text{old}}(a_t\mid s_t)\), and reuse them for a few extra gradient steps. Because they were generated under an older policy, you must importance-correct with the PPO ratio and rely on clipping to bound the bias (Policy Gradients & PPO for Language Models; Advantage Estimation, KL Control & Stability Tricks):

\[ \rho_t = \frac{\pi_\theta(a_t\mid s_t)}{\pi_{\text{old}}(a_t\mid s_t)}, \qquad L = \min\!\big(\rho_t A_t,\; \operatorname{clip}(\rho_t, 1-\epsilon, 1+\epsilon)A_t\big). \]

This is the mechanism behind asynchronous / off-policy RL (the generator runs ahead of the trainer; rollouts are 1–4 steps stale by the time they are consumed — Prime-RL, Async RL & Decentralized Training). The replay “buffer” here is shallow — a few steps of staleness, not a DQN-style million-transition reservoir — because the importance weights blow up and the clipped gradient goes to zero once \(\pi_\theta\) has drifted too far from \(\pi_{\text{old}}\). Staleness is the half-life of a stored trajectory. Beyond a few steps the IS weights are so far from 1 that clipping zeroes the contribution, so the trajectory is dead weight. A practical buffer evicts trajectories older than a staleness bound \(\tau_{\max}\) (e.g. 2–4 policy versions) and tracks the fraction of tokens being clipped as a health metric — if most tokens are clipped, your buffer is too stale and you are training on noise.

importance ratio rho_t = pi_theta(a|s) / pi_old(a|s), sampled per stored token 1.2 1.0 0.8 PPO clip corridor [1-eps, 1+eps] rho_t fresh: inside corridor, carries gradient stale: outside corridor, gradient -> 0 grad. gradient contribution collapses to ~0 by tau_max clipped health metric: clipped-token fraction, rising with staleness tau=0 1 2 3 (tau_max) 4 5 staleness (policy versions since generation) -> evict beyond tau_max evicted shallow buffer, not a million-transition reservoir -- staleness is the trajectory's half-life.
A stored trajectory's gradient contribution decays with staleness because the importance ratio drifts out of the fixed PPO clip corridor. At tau=0 the ratio sits at 1.0 and the sample carries full gradient; as the policy moves on, rho_t spreads and drifts away from 1, clipping increasingly flattens the objective, and by tau_max the gradient contribution is already near zero -- which is why a practical buffer evicts on a short staleness bound rather than accumulating a large reservoir.

A third, increasingly important pattern is the experience / success buffer for agentic RL: store successful trajectories (especially for hard, rarely-solved tasks) and replay them as on-policy-ish positive examples or as SFT-style anchors, to keep the policy from forgetting a hard-won capability. This blurs into expert iteration / rejection-sampling fine-tuning (the policy generates, you keep the winners, you train on them) and is a robust way to bank progress on sparse-reward agentic tasks (Agentic & Multi-Turn RL).

Practitioner tip: keep three buffers, not one

In a mature RL stack you typically maintain (1) a prioritized prompt buffer (which tasks to attempt next — on-policy, free, the curriculum), (2) a shallow staleness buffer of recent completions with stored log-probs for async overlap (off-policy, importance-corrected, half-life of a few steps), and (3) a success/hard-case buffer of banked winning trajectories for anti-forgetting on sparse tasks. They are different objects with different correctness constraints — do not conflate them. The first is about throughput and signal, the second about latency overlap, the third about retention.

Code: a dynamic-sampling + difficulty-bucketing rollout loop

Here is a self-contained, heavily-commented loop that ties the chapter together: difficulty-targeted selection via Thompson sampling, generation, zero-variance filtering with oversampling (dynamic sampling), online difficulty (Beta posterior) updates, and a prioritized prompt buffer. The rollout engine and trainer are stubbed so the data logic is in focus; in a real system engine.generate calls vLLM/SGLang and trainer.step calls your GRPO update.

import numpy as np
from dataclasses import dataclass, field

rng = np.random.default_rng(0)

# ---------------------------------------------------------------------------
# Task state: a Beta(s+1, f+1) posterior over the *current-policy* pass rate.
# We decay old counts so the posterior tracks the moving policy (not lifetime).
# ---------------------------------------------------------------------------
@dataclass
class TaskState:
    task_id: int
    true_p: float                 # SIMULATION ONLY: the latent pass rate
    s: float = 1.0                # decayed success pseudo-count (+1 prior)
    f: float = 1.0                # decayed failure pseudo-count (+1 prior)
    n_groups: int = 0             # how many groups we've spent here (priority/age)

    def posterior_mean(self):
        return self.s / (self.s + self.f)

    def sample_p(self):
        # Thompson draw: sample a plausible pass rate from the posterior.
        return rng.beta(self.s, self.f)

    def update(self, successes, G, decay=0.9):
        # Decay then add this group's evidence. Decay makes the posterior
        # forget stale (old-policy) observations so it tracks current p.
        self.s = decay * self.s + successes
        self.f = decay * self.f + (G - successes)
        self.n_groups += 1


# ---------------------------------------------------------------------------
# Stubs standing in for the real rollout engine and trainer.
# ---------------------------------------------------------------------------
class FakeEngine:
    """Simulates generating G samples for a task; returns #successes ~ Binomial.
    A real engine returns text completions; the checker turns them into r in {0,1}.
    We also let true_p drift UP a touch each time a task is trained on, to mimic
    the policy mastering material (the non-stationarity the EMA/Beta must track)."""
    def rollout(self, task: TaskState, G: int) -> int:
        succ = int(rng.binomial(G, task.true_p))
        return succ

    def learn_drift(self, task: TaskState, lr=0.02):
        # Mastering nudges pass rate toward 1; harder material drifts slower.
        task.true_p = min(0.999, task.true_p + lr * (1.0 - task.true_p))


# ---------------------------------------------------------------------------
# Difficulty-targeted selection by Thompson sampling toward a target pass rate.
# ---------------------------------------------------------------------------
def select_candidates(tasks, n_select, target_p=0.5):
    scored = []
    for t in tasks:
        p_tilde = t.sample_p()              # explore via posterior uncertainty
        score = -abs(p_tilde - target_p)    # prefer tasks near the target band
        scored.append((score, t))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [t for _, t in scored[:n_select]]


# ---------------------------------------------------------------------------
# One RL step: select -> generate -> DYNAMIC SAMPLING filter -> update -> train.
# Dynamic sampling: keep only non-zero-variance groups; oversample to refill.
# ---------------------------------------------------------------------------
def rl_step(tasks, engine, B_keep=64, G=8, target_p=0.5,
            oversample_cap=6, buckets=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0)):
    kept = []                               # (task, successes) that have signal
    generated_groups = 0
    rounds = 0
    while len(kept) < B_keep and rounds < oversample_cap:
        rounds += 1
        # Oversample a generous candidate set so we can refill after filtering.
        need = B_keep - len(kept)
        cands = select_candidates(tasks, n_select=2 * need, target_p=target_p)
        for t in cands:
            successes = engine.rollout(t, G)
            generated_groups += 1
            t.update(successes, G)          # online difficulty (Beta) update
            # DYNAMIC SAMPLING: drop zero-variance groups (all pass / all fail).
            if 0 < successes < G:
                kept.append((t, successes))
                if len(kept) >= B_keep:
                    break

    # --- "Train" on the kept (informative) groups: here we just record stats
    #     and apply the simulated learning drift so difficulty is non-stationary.
    bucket_counts = np.zeros(len(buckets) - 1, dtype=int)
    for t, successes in kept:
        engine.learn_drift(t)               # policy improves -> p drifts up
        p_hat = successes / G
        b = min(np.searchsorted(buckets, p_hat, side="right") - 1, len(buckets) - 2)
        bucket_counts[max(b, 0)] += 1

    survival = len(kept) / max(generated_groups, 1)
    return {
        "kept": len(kept),
        "generated_groups": generated_groups,
        "survival_rho": survival,           # what fraction survived the filter
        "oversample_factor": generated_groups / max(len(kept), 1),
        "bucket_counts": bucket_counts,     # distribution of kept difficulties
    }


# ---------------------------------------------------------------------------
# Run it. Watch survival rho recover toward 1 (selection feeds in-band prompts)
# and the kept-difficulty histogram concentrate near the target band.
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    # A pool spanning the full difficulty range, incl. the dead tails we must avoid.
    pool = [TaskState(i, true_p=rng.uniform(0.02, 0.98)) for i in range(4000)]

    # OFFLINE PASS: k rollouts per task, used to (a) seed each Beta posterior with
    # real pseudo-counts and (b) prune the dead tails. Do NOT skip this. Without
    # it every posterior starts at the uninformative Beta(1,1), Thompson draws are
    # pure noise, and selection is no better than uniform sampling -- for this pool
    # the uniform baseline is E[1 - p^8 - (1-p)^8] ~= 0.81, which is essentially
    # what an unseeded run scores. The offline pass is what buys the edge.
    K_OFFLINE = 8
    for t in pool:
        succ = int(rng.binomial(K_OFFLINE, t.true_p))     # a real run calls the engine
        t.s += succ
        t.f += K_OFFLINE - succ
    n_raw = len(pool)
    pool = [t for t in pool if 0 < (t.s - 1.0) < K_OFFLINE]   # drop 0/k and k/k
    print(f"offline pass: kept {len(pool)}/{n_raw} tasks "
          f"({100 * (1 - len(pool) / n_raw):.0f}% pruned as dead tails)")

    for step in range(8):
        stats = rl_step(pool, FakeEngine(), B_keep=64, G=8, target_p=0.5)
        print(f"step {step:2d} | rho={stats['survival_rho']:.2f} "
              f"| oversample={stats['oversample_factor']:.2f}x "
              f"| buckets[0.0-1.0]={stats['bucket_counts']}")

Three behaviors to watch when you run this. First, the survival fraction \(\rho\) sits around \(0.9\) (oversampling factor \(\approx1.1\times\)) against a uniform-sampling baseline of \(\mathbb{E}[1 - p^8 - (1-p)^8] \approx 0.81\) for this pool — that is the oversampling-tax saving from the worked example, made mechanical. Second, the kept-difficulty histogram concentrates in the middle buckets (typically \(\approx 80\%\) of kept groups in buckets 1–3): even though the pool spans \([0.02, 0.98]\), the batch you actually train on is the informative middle, by construction. (Read buckets 1–3 together: at \(G=8\) the middle bucket \([0.4, 0.6)\) contains only the single realizable value \(\hat p = 0.5\), so its count is structurally low — a discretization artifact, not a dip in the band.) Third, as learn_drift pushes mastered tasks toward \(p=1\), the Beta posteriors follow, those tasks stop being selected, and harder tasks rotate into the band — the emergent curriculum, in code.

There is an honest caveat worth measuring yourself. Swap the Thompson draw for the deterministic posterior mean (Exercise 5’s select_band_greedy) and \(\rho\) climbs to \(\approx 0.97\). Thompson sampling pays an exploration tax, and the tax is worse the larger the candidate pool: picking the top 4% of 3200 tasks by a sampled pass rate selects partly on posterior noise, so some genuinely-easy tasks ride a lucky draw into the batch. That exploration is not wasted — it is what re-checks stale estimates as the policy drifts, which greedy selection never does — but the trade is real. The practical compromise is to restrict Thompson to a shortlist (greedy-filter to a few hundred plausible tasks, then sample within it), or to widen the offline \(k\) so the posteriors are sharp enough that the draws are not noise.

# Bolt-on: a prioritized PROMPT buffer (PER at the task level). Priority = how
# close a task is to the target band, with a small age bonus so we revisit
# under-sampled tasks. This is the on-policy, FREE kind of replay.
def prompt_priority(t: TaskState, target_p=0.5, age_w=0.05):
    closeness = 1.0 / (abs(t.posterior_mean() - target_p) + 0.05)  # near band -> high
    uncertainty = (t.s * t.f) / ((t.s + t.f) ** 2 * (t.s + t.f + 1))  # Beta variance
    return closeness + age_w * uncertainty  # exploit band + explore uncertain tasks

def sample_from_buffer(tasks, n, target_p=0.5, temperature=1.0):
    pr = np.array([prompt_priority(t, target_p) for t in tasks])
    probs = (pr ** (1.0 / temperature))
    probs /= probs.sum()
    idx = rng.choice(len(tasks), size=n, replace=False, p=probs)
    return [tasks[i] for i in idx]

The prioritized prompt buffer is the persistent, sampling-without-replacement-per-step version of the Thompson selector: priority rewards proximity to the band (exploit) plus posterior variance (explore under-sampled tasks). Note this buffer stores tasks, never old completions — it is strictly on-policy and therefore free of importance-weighting concerns, unlike the staleness buffer discussed earlier.

Putting it together: the data-side knobs that move sample efficiency

Step back. The levers in this chapter form a pipeline, and each one removes a different category of wasted rollout:

raw prompt pool (1) construction + QC + decontam + dedup -> remove garbage verifiable corpus (2) offline difficulty estimate, prune 0/k and k/k -> remove dead tails usable corpus with priors (3) difficulty-targeted online selection (band) -> raise survival rho selected batch (mostly in-band) (4) generate G per prompt -> the expensive step raw groups (5) dynamic sampling: drop zero-variance, oversample -> guarantee signal informative training batch GRPO update (6) update online difficulty (EMA/Beta) -> track moving p prioritized prompt buffer feeds back into (3) = emergent curriculum (+ optional shallow staleness buffer for async overlap, success buffer for retention)
The data-side pipeline: six stages that each remove a category of wasted rollout. Starting from a raw prompt pool, each numbered operation strips away a different class of inefficiency — garbage prompts, dead-tail prompts, out-of-band prompts, zero-variance groups — until only fully-informative groups reach the GRPO update. Stage 4 (generate G per prompt, in amber) is the dominant compute cost; every upstream stage exists to reduce how much of it gets wasted. The feedback loop from stage 6 back into stage 3 is the emergent curriculum: online difficulty estimates continuously re-steer selection as the policy improves.

Each numbered stage is a multiplier on sample efficiency, and they compound: decontamination protects the validity of everything downstream; tail-pruning removes the prompts that can never contribute; targeting raises survival \(\rho\) so dynamic sampling oversamples less; dynamic sampling guarantees the gradient is never diluted by dead groups; and online difficulty closes the loop so the whole thing tracks the policy as it improves. A run with all six tuned can be several times more sample-efficient — in rollouts-per-unit-improvement — than the same algorithm reading a static shuffled .jsonl, with no change to the loss function at all.

Interview Corner

Q: You’re running GRPO on a math corpus and notice your training reward climbs for 200 steps then plateaus, while GPU utilization stays high and the loss is non-zero but tiny. What’s happening, and how do you diagnose and fix it on the data side — without touching the RL algorithm?

A: This is the classic difficulty-saturation failure. Early on, many prompts sit near \(p=0.5\) where reward variance \(p(1-p)\) — and thus the policy-gradient signal — is maximal. As the policy improves, those prompts drift toward \(p=1\); their groups increasingly come back all-correct, contribute zero advantage variance, and the effective batch (the prompts that actually carry gradient) shrinks even though every GPU is busy generating. Utilization stays high because you’re still rolling out; the loss is tiny because most groups are near-zero-variance.

To diagnose, I’d log the per-group pass-rate histogram and the fraction of zero-variance groups per step; the smoking gun is that fraction climbing over training. The fix is entirely data-side: (1) turn on dynamic sampling so you only train on \(0<\hat p<1\) groups and oversample to refill — this immediately restores a fully-informative batch; (2) maintain an online difficulty estimate (EMA or decayed Beta posterior) and target selection toward the \(p\approx0.5\) band, so you stop spending rollouts on mastered prompts and the emergent curriculum slides toward harder material; (3) if survival fraction \(\rho\) is dropping, that confirms saturation and also tells you the oversampling tax you’re paying. If the pool itself is exhausted of in-band prompts (everything is now easy), that’s a corpus problem — mine the current policy’s failures or add harder synthetic prompts to extend the curriculum. Crucially none of this changes the GRPO objective; it changes which prompts the objective sees.

Key Takeaways

Key Takeaways

  • Difficulty is the master variable. For a binary verifiable reward, gradient signal per prompt scales as \(p(1-p)\) — exactly so for the unnormalized Dr. GRPO/DAPO advantage, as \(\sqrt{p(1-p)}\) if you keep GRPO’s std division — peaking at pass rate \(p=0.5\) and vanishing at both ends, so a prompt’s current pass rate decides its worth.
  • Pass rate is non-stationary. It’s a property of the prompt and the current policy; a static difficulty label decays as the policy learns. Estimate difficulty online (EMA or a decayed Beta–Bernoulli posterior), never once-and-forever.
  • Construction and QC dominate validity. Verifiable checkers, eval decontamination, and near-duplicate removal matter more in RL than SFT because RL adversarially exploits any reward defect; prune the \(0/k\) and \(k/k\) tails before training to delete prompts that can never contribute.
  • Difficulty-targeted online selection (greedy-to-target or Thompson sampling on the Beta posterior) keeps realized groups near a \(p\approx0.5\) band, raising the dynamic-sampling survival fraction \(\rho\) and cutting the oversampling tax — often a ~20–30% end-to-end win, free.
  • Dynamic sampling drops zero-variance groups and oversamples to refill, guaranteeing every gradient step trains on signal — cheap on an async/oversubscribed generator, brutal on a synchronous one.
  • Curriculum is emergent, not authored: with online targeting, the band’s contents drift from easy to hard automatically as the policy masters material; regret/learning-progress weighting pushes compute to the competence frontier.
  • Three buffers, three jobs: a prioritized prompt buffer (on-policy, free, the curriculum); a shallow staleness buffer of completions with stored log-probs for async overlap (off-policy, importance-corrected, half-life of a few steps); and a success/hard-case buffer for anti-forgetting on sparse agentic tasks.
  • Trajectory replay has a half-life: stored completions go stale as the policy drifts; importance weights blow up and clipping zeroes their gradient, so evict beyond a staleness bound and monitor the clipped-token fraction.
  • Use the real tools: math-verify for math checking (never a regex), datatrove/datasketch for pool dedup and decontamination, verifiers-style environments as the packaging unit, and veRL’s algorithm.filter_groups + oversized data.gen_batch_size (or OpenRLHF’s equivalent) for dynamic sampling — TRL gives you \(G\) and the clipping knobs but leaves prompt selection to a custom sampler.

State of the Art & Resources (2026)

Data-side RL has converged on a recognizable stack: verifiable corpora with aggressive decontamination, offline difficulty bucketing to prune dead tails, online difficulty tracking, difficulty-targeted selection toward a ~50%-pass band, and DAPO-style dynamic sampling as default practice. The frontier is automatic curriculum (learning-progress/regret weighting) and principled off-policy replay for fully-async, agentic RL.

Foundational work

Recent advances (2024–2026)

Open-source & tools

  • verl-project/verl and OpenRLHF/OpenRLHF — production RL frameworks with dynamic sampling, dataset/curriculum hooks, and async replay.
  • PrimeIntellect-ai/prime-rl — fully-async agentic RL where shallow staleness replay and importance correction are first-class.
  • willccbb/verifiers — RLVR environments (prompt set + parser + reward rubric, single- or multi-turn) behind one interface; the 2026 packaging unit for the prompt pools this chapter manages.
  • huggingface/Math-Verifyparse + verify, SymPy-backed answer equivalence. The checker quality that your difficulty estimates are only as good as.
  • huggingface/datatrove and ekzhu/datasketch — MinHash/LSH dedup and n-gram decontamination for the prompt pool, before a single rollout is spent.

Further reading

  • Schaul, Quan, Antonoglou & Silver, Prioritized Experience Replay — priority and importance weighting; the buffer mechanics this chapter ports to prompts and trajectories.
  • Graves et al., Automated Curriculum Learning for Neural Networks — learning-progress-driven task ordering; the basis for regret/progress curricula.
  • Yu et al., DAPO: An Open-Source LLM Reinforcement Learning System at Scale — dynamic sampling and the zero-variance-group filter in an RLVR context.
  • DeepSeek-AI, DeepSeekMath (GRPO) and DeepSeek-R1 — the group-relative advantage that makes pass-rate variance \(p(1-p)\) the signal-to-noise of a prompt.
  • Kimi Team, Kimi k1.5: Scaling Reinforcement Learning with LLMs — curriculum, prioritized sampling, and long-context RL data infrastructure.
  • Noukhovitch et al., Asynchronous RLHF: Faster and More Efficient Off-Policy RL for Language Models — the staleness/importance-weight regime that bounds trajectory-replay reuse.
  • Schulman et al., Proximal Policy Optimization Algorithms — the clipped importance-sampling objective that makes shallow off-policy replay safe.
  • Dennis et al., Emergent Complexity and Zero-shot Transfer via Unsupervised Environment Design (PAIRED) — regret-based automatic curriculum, the theoretical cousin of difficulty-targeted selection.

Exercises

1. (Conceptual.) Under GRPO with a binary verifiable reward, a group of \(G\) completions for a single prompt comes back either all-correct (\(\hat p = 1\)) or all-wrong (\(\hat p = 0\)). Explain, from the advantage formula in this chapter, why both of these extremes contribute exactly zero to the policy gradient — even though one looks like “success” and the other like “failure.” Why does this make pass rate, rather than absolute correctness, the quantity you must control?

Solution

GRPO centers each completion’s reward by the group mean before forming the advantage: $$ A_i = \frac{r_i - \bar r}{\operatorname{std}® + \varepsilon}, \qquad \bar r = \hat p. $$ If every \(r_i\) is identical (all \(1\) when \(\hat p = 1\), all \(0\) when \(\hat p = 0\)), then \(\bar r = r_i\) for every \(i\), so the numerator \(r_i - \bar r = 0\) for all \(i\). Every advantage is zero, and the per-prompt gradient contribution \(\sum_i A_i \nabla_\theta \log \pi_\theta\) is therefore exactly zero. The group is “dead” regardless of which extreme it sits at: the all-correct prompt is dead because the model already mastered it, the all-wrong prompt is dead because the model cannot get any purchase on it — but the mechanism is identical, namely zero reward variance inside the group.

The signal GRPO exploits is relative — the contrast between completions of the same prompt — not the absolute reward level. An all-correct group and an all-wrong group both have zero within-group contrast, hence zero gradient. This is why the master variable is the pass rate \(p\): the usable signal scales as the Bernoulli variance \(p(1-p)\), which vanishes at both \(p=0\) and \(p=1\) and peaks at \(p=0.5\). You cannot make a prompt informative by making the model “more correct”; you make it informative by keeping its pass rate away from the two dead ends.

2. (Quantitative.) The chapter states a prompt at \(p = 0.5\) yields roughly \(2.8\times\) the signal of a prompt at \(p = 0.9\). Compute the exact ratio of gradient signal (proportional to \(p(1-p)\)) between a prompt at \(p = 0.5\) and one at \(p = 0.75\). If a fixed rollout budget must be split between the two difficulty levels to maximize total signal per rollout, which should get more of it, and by roughly what factor per prompt?

Solution

The per-prompt signal is proportional to the Bernoulli variance \(p(1-p)\).

  • At \(p = 0.5\): \(\;0.5 \times 0.5 = 0.25\).
  • At \(p = 0.75\): \(\;0.75 \times 0.25 = 0.1875\).

Ratio: $$ \frac{0.25}{0.1875} = \frac{4}{3} \approx 1.33. $$ So a \(p=0.5\) prompt delivers about \(1.33\times\) the gradient signal per rollout of a \(p=0.75\) prompt. Per prompt, the \(p=0.5\) prompt is worth about a third more, so it should receive proportionally more of the budget. (Contrast this with the \(p=0.9\) case: \(0.9 \times 0.1 = 0.09\), and \(0.25 / 0.09 \approx 2.78\) — matching the chapter’s \(\sim 2.8\times\). The signal cliff steepens fast as you leave the center of the band, which is exactly why targeting a band around \(0.5\) pays off.)

3. (Quantitative.) You want a training batch of \(B_{\text{keep}} = 256\) informative (non-zero-variance) prompts with \(G = 8\) samples each. Under the current policy your candidate pool is, in effect, two populations: half the prompts at \(p = 0.5\) and half at \(p = 0.9\). A group at pass rate \(p\) survives the dynamic-sampling filter (has non-zero variance) with probability \(1 - p^G - (1-p)^G\). Compute the survival fraction \(\rho\) for uniform sampling from this pool, the number of groups you must generate to fill the batch, and the fraction of generated completions thrown away.

Solution

Per-population survival probability, \(G = 8\):

  • At \(p = 0.5\): \(\;1 - 0.5^8 - 0.5^8 = 1 - 2\cdot\frac{1}{256} = 1 - 0.0078 = 0.9922.\)
  • At \(p = 0.9\): \(\;0.9^8 = 0.4305\), and \(0.1^8 \approx 10^{-8}\) (negligible), so survival \(= 1 - 0.4305 - 0 \approx 0.5695.\)

Uniform mix (half and half): $$ \rho = 0.5\,(0.9922) + 0.5\,(0.5695) = 0.4961 + 0.2848 = 0.7809. $$

Groups to generate to fill \(B_{\text{keep}} = 256\): $$ \frac{256}{0.7809} \approx 327.8 \;\Rightarrow\; 328 \text{ groups}. $$

Completions generated: \(328 \times 8 = 2624\); completions kept: \(256 \times 8 = 2048\). Fraction thrown away: $$ 1 - \frac{2048}{2624} = 1 - 0.7809 = 0.219 \approx 22\%. $$ So even with a fairly benign pool, uniform sampling wastes about a fifth of your generation on zero-variance groups — the “oversampling tax.” Difficulty-targeted selection attacks this by raising \(\rho\): if selection could feed only the \(p=0.5\) population, \(\rho \to 0.9922\) and the tax nearly vanishes.

4. (Quantitative.) A task is tracked with the decayed Beta-Bernoulli posterior from the chapter’s code: start at the prior \(s = 1,\ f = 1\), use \(G = 8\) and \(\text{decay} = 0.9\), with update \(s \leftarrow 0.9\,s + \text{successes}\), \(f \leftarrow 0.9\,f + (G - \text{successes})\). The policy is improving, so the task returns \(3\) successes in its first group and then \(6\) successes in its second. Compute the posterior mean pass rate after each group, and explain in one line why the decay factor is what lets this estimate chase a non-stationary pass rate.

Solution

Posterior mean is \(\dfrac{s}{s+f}\).

After group 1 (3 successes, 5 failures): $$ s = 0.9(1) + 3 = 3.9, \qquad f = 0.9(1) + 5 = 5.9. $$ $$ \bar p_1 = \frac{3.9}{3.9 + 5.9} = \frac{3.9}{9.8} = 0.398. $$

After group 2 (6 successes, 2 failures): $$ s = 0.9(3.9) + 6 = 3.51 + 6 = 9.51, \qquad f = 0.9(5.9) + 2 = 5.31 + 2 = 7.31. $$ $$ \bar p_2 = \frac{9.51}{9.51 + 7.31} = \frac{9.51}{16.82} = 0.565. $$

The estimate moved from \(0.40\) up toward \(0.57\) in a single step, following the policy as it mastered the task. The decay factor is what makes this possible: multiplying the old counts by \(0.9\) before adding new evidence down-weights stale (old-policy) observations, so the posterior forgets the past at a controlled rate instead of averaging over the task’s entire lifetime. Without decay, \(s\) and \(f\) accumulate forever and the mean becomes rigidly anchored to early, now-obsolete observations — the “stale difficulty label” pitfall the chapter warns about.

5. (Implementation.) The chapter’s select_candidates uses Thompson sampling and scores every task by closeness to a single point \(p^\star\). Implement an alternative selector, select_band_greedy, that (a) uses the deterministic posterior mean (posterior_mean()) rather than a Thompson draw, (b) selects for a band \([p_{\text{lo}}, p_{\text{hi}}]\) — preferring the center of the band but ranking any in-band task above any out-of-band task — and returns the top n_select. Then write expected_survival(tasks, G) that estimates the dynamic-sampling survival fraction \(\rho\) a candidate set would yield, using each task’s posterior mean and the formula \(1 - p^G - (1-p)^G\). Keep the chapter’s TaskState interface.

Solution
import numpy as np

def select_band_greedy(tasks, n_select, p_lo=0.2, p_hi=0.8, target_p=0.5):
    """Deterministic band selector. In-band tasks are scored by closeness to
    the band center (target_p) and always outrank out-of-band tasks; out-of-band
    tasks are ranked by distance to the nearest band edge so the batch degrades
    gracefully when the band is under-populated."""
    scored = []
    for t in tasks:
        p = t.posterior_mean()
        if p_lo <= p <= p_hi:
            # In band: 0 (best) at the edge-worst, more negative penalty as we
            # move off-center. Offset by +1 so every in-band task beats any
            # out-of-band task (whose score is <= 0 below).
            score = 1.0 - abs(p - target_p)
        else:
            # Out of band: strictly negative, penalized by distance past the edge.
            edge = p_lo if p < p_lo else p_hi
            score = -abs(p - edge)
        scored.append((score, t))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [t for _, t in scored[:n_select]]


def expected_survival(tasks, G=8):
    """Estimated dynamic-sampling survival fraction rho for a candidate set:
    the mean over tasks of P(group has non-zero variance) = 1 - p^G - (1-p)^G,
    using each task's posterior-mean pass rate as the estimate of p."""
    p = np.array([t.posterior_mean() for t in tasks])
    surv = 1.0 - p**G - (1.0 - p)**G
    return float(surv.mean())

Usage mirrors the chapter’s loop: swap select_band_greedy in for select_candidates, and call expected_survival(selected, G) as a health metric before generating. In-band tasks always sort above out-of-band ones because their scores lie in \([1 - 0.3,\, 1] = [0.7, 1]\) (the \(|p - target_p|\) term never exceeds the half-band width \(0.3\), so in-band scores stay above \(0.7\)), while every out-of-band score is \(\le 0\). expected_survival gives you a cheap pre-generation estimate of \(\rho\): if it is near \(1\) your selector is feeding a clean in-band batch and the oversampling tax is small; if it is dropping over training, that is the saturation signal from the Interview Corner — the band is emptying and you need harder prompts.

6. (Conceptual + quantitative.) The chapter says prompt-level replay is “free” while trajectory-level replay has a “half-life.” Consider one stored token whose old policy assigned probability \(\pi_{\text{old}} = 0.1\); after a few trainer steps the current policy has drifted to \(\pi_\theta = 0.6\) on that token, and its advantage is positive (\(A_t > 0\)). Using the clipped objective with \(\epsilon = 0.2\), compute the importance ratio and show that this token’s contribution to the gradient is zeroed. Then explain why the prompt buffer never hits this problem.

Solution

The importance ratio is $$ \rho_t = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\text{old}}(a_t \mid s_t)} = \frac{0.6}{0.1} = 6.0. $$ The clipped objective is $$ L = \min!\big(\rho_t A_t,\; \operatorname{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\,A_t\big), \qquad \operatorname{clip}(6.0,\, 0.8,\, 1.2) = 1.2. $$ With \(A_t > 0\): the unclipped branch is \(6.0\,A_t\) and the clipped branch is \(1.2\,A_t\); since \(A_t > 0\), \(\min(6.0\,A_t,\, 1.2\,A_t) = 1.2\,A_t\). The clipped branch wins, and \(1.2\,A_t\) is a constant in \(\theta\) (the ratio has been clipped to a flat \(1.2\)), so \(\nabla_\theta L = 0\) for this token. The trajectory has drifted far enough that clipping flattens its gradient to zero — it is now dead weight in the buffer. This is the half-life: once \(\pi_\theta\) has moved a few steps away from \(\pi_{\text{old}}\), the ratios leave the \([1-\epsilon, 1+\epsilon]\) window, clipping zeroes the contribution, and the stored completion is worthless. Hence you evict beyond a staleness bound \(\tau_{\max}\) and monitor the clipped-token fraction as a buffer-health metric.

The prompt buffer never suffers this because it stores only which task to attempt, never the old completions. Every completion is freshly generated under the current policy \(\pi_\theta\), so there is no \(\pi_{\text{old}}\), no importance ratio, and no clipping — the samples are exactly on-policy by construction. That is why prompt-level replay is “free” (a persistence layer over difficulty-targeted selection) while trajectory replay is off-policy and bounded by a short half-life.