Recursive Language Models.
A model that improves itself is running two different loops at two different speeds, and mixing them up is how these pipelines quietly break. The loop, the objective that closes it, what goes wrong — and a demonstrator you can run right here.
1 · The pipeline
One closed loop. The policy acts in an environment; the environment produces evidence; a filter turns evidence into a preference signal; an optimiser turns that signal into the next set of weights, which is the policy that acts next round.
Every arrow is a control point. The policy acting is where data can leave. The arena runs untrusted code written by something trying to score well. The filter is the objective — whoever writes it decides what the model becomes. And the update ships a new model. Which is the argument for an external boundary: the thing being controlled keeps changing, so the control cannot live inside it.
2 · Two recursions
Runtime recursion — weights frozen
The model re-enters its own output. A fixed-point iteration on sequence space:
Training recursion — weights move
Round index k. The training distribution
is a function of the current parameters:
3 · The objective
The diagram names DPO, so the filter's job is to emit preference
pairs (y_w, y_l) over a shared prompt. Direct Preference
Optimization drops the separate reward model of RLHF and optimises the pairs directly:
The choice that bites
π_ref is the frozen anchor, and which round you freeze is a policy decision with real consequences. Anchoring to θ₀ bounds total drift across every round. Re-anchoring each round to θ_k bounds only per-round drift — and lets the model walk arbitrarily far given enough rounds. β is the leash: as β → 0 the constraint vanishes and the policy is free to collapse onto whatever maximises the proxy.
LoRA is what makes a round cheap enough to close the loop at all. Instead of updating the full weight matrix, learn a low-rank correction:
4 · Where the recursion is legitimate
The loop is sound exactly where verification is cheaper than generation. That asymmetry is what makes Φ trustworthy, and its absence is what makes a self-improvement pipeline a rumour mill.
| Domain | Verifier | Asymmetry |
|---|---|---|
| Code | Test suite, type checker, compiler | Strong — execution is ground truth |
| Formal maths | A mechanical proof checker | Strong — mechanically decidable |
| Retrieval | Citation resolves and entails | Moderate |
| Open-ended prose | Another model | None — this is where collapse lives |
Coding agents are the canonical RLM domain for exactly this reason, and it is why the demonstrator below decides with a test suite rather than a judge model.
5 · Run the loop
The pipeline above, running in your browser. The policy proposes a program, the arena runs it against real tests, the score is the fraction that passed, and the failures become the feedback for the next attempt. Solve a task and it emits the preference pair the training half would train on.
Two things worth watching. On fizzbuzz, attempt 1
makes the fix it says it is making and gets worse — 0.67 down to 0.00.
Self-correction does not only ever improve, and without the arena nothing catches that. On
collatz, attempt 0 never returns and the watchdog kills the
thread. Code that will not stop cannot be asked to stop from inside itself, which is why
the arena has to be a separate thread.
6 · Failure modes
Model collapse
Each round samples from the last round's output, so rare cases disappear first and the distribution narrows toward whatever the model already does most. It compounds, and fresh real data is the only way back. Only accepting runs that actually executed and passed helps — it keeps the data tied to something outside the model — but it does not stop the narrowing.
Goodhart and reward hacking
The filter is a proxy for what you want, and training finds the places where the proxy and the real goal come apart, because that is where the cheap wins are. A test-suite filter selects for passing the tests — which includes hard-coding the expected output and catching the assertion. Every proxy works this way. The only question is how fast the policy finds the gap.
Distribution shift
D_k ~ π_θk means each round trains on data drawn from a policy the previous round moved. The KL anchor is the only thing bounding the walk, and β sets how tight that bound is.
Unbounded runtime recursion
A depth cap is a correctness property, not a cost control. Without one — and without checking that each attempt actually improved — a loop will re-derive the same wrong answer until it runs out of context.
7 · How the system actually works
Everything below is the code running on this page — three modules, one message protocol, four invariants. Read it if you want to plug a real model in, add a task, or lift the arena into your own project.
Module map
| File | Role in the diagram | Depends on |
|---|---|---|
assets/js/rlm-policy.js | Base Policy π_θk, plus the task corpus and the Trajectory Filter's pair construction | nothing |
assets/js/rlm-worker.js | Target Environment — the Execution Arena | nothing (runs in a Worker; no imports) |
assets/js/rlm-terminal.js | The driver: closes the loop, bounds it, renders it | the other two |
test/rlm-policy.test.js | Executes every candidate and pins the reward it earns | rlm-policy.js |
The policy module has no dependencies and no DOM access on purpose, so it runs the same under Node — which is how the tests execute every candidate outside a browser.
The arena protocol
One request, one response, matched on runId. The driver owns the
timeout; the worker never sees it.
detail is the real call and the real return value —
twoSum([3,2,4], 6) → [2,4], want [1,2]. That string is the
feedback the next attempt gets, so it has to carry something usable.Loop state
One array. Each entry is what the arena reported for one attempt, and it is everything the policy learns about its own past:
reward: 0 with every
test listed as failing. An attempt that could not be judged is a failure, not a gap —
dropping it would let a hanging candidate look like it was never tried.Invariants
| Guarantee | Enforced by |
|---|---|
| The loop always terminates | MAX_DEPTH = 4 in the driver, and propose() returning null when the ladder is exhausted |
| A single candidate cannot hang the page | RUN_TIMEOUT_MS = 1200 watchdog, then worker.terminate() and respawn |
| A stale result cannot be attributed to a later attempt | runId check, plus a settled latch so watchdog and message race exactly once |
| Two loops cannot share one worker | the input is disabled for the whole run and re-enabled in a finally |
Plugging in a real model
One function. The driver awaits propose() even though the stub is
synchronous, so a network-backed policy drops straight in:
rung, source,
and optionally note and claims.
Return null to stop early. Nothing else changes — the arena,
the scoring and the depth bound do not care where the program came from.What changes when the policy is real
Three things stop being free. The arena becomes a genuine sandbox problem
— a Web Worker is isolation from the page, not from a determined adversary, and model-written
code needs a container with no network and a filesystem you are willing to lose.
Sampling replaces the ladder, so MAX_DEPTH
becomes a cost control as well as a termination guarantee. And the filter needs a
floor: a pair is only worth training on if y_w beats
y_l by a margin that is not noise.
Adding a task
Append to TASKS. The tests check that the last rung passes
everything, that no earlier one accidentally does, and that every
claims entry names a real test — so a task that is wrong about
itself breaks the build instead of misleading a visitor.
NON_TERMINATING
in the test file — the suite runs every other rung directly, and nothing there can stop
an infinite loop.Running it locally
node --test test/
resolves the bare directory as a module path and fails. The worker is loaded by relative
path, so the page has to be served over HTTP; opening the file directly gives you the
prose and a dead terminal.