~/satyajit

Five judges were worth one opinion: RL on a reward you have to author yourself

mdjsonmcp

2026-08-24 · 29 min · rl · reward-design · grpo · creative-tools · explainer

When you make an image with a diffusion model, the only way to participate is the prompt. You cannot open the output and change one thing. To move a petal you go back to the text box and roll again, and hope the rest survives.

Surya Narreddi and Cameron Franz fine-tuned a reasoning model — Qwen3-30B-A3B-Thinking — to paint by writing p5.brush JavaScript instead. The artefact is source code, so the picture is editable at the line level. That premise is a good one and it is not what makes the write-up worth reading. What makes it worth reading is that it is one of the few honest accounts of trying to run RL against a reward nobody can verify — and of the specific way that reward fell over.

PostTraining AI to Paint with Code · Surya Narreddi · March 2026
TeamSurya Narreddi (design, development, RL research) · Cameron Franz (built the training infrastructure) · Alex Wang
MethodGRPO · policy emits a complete p5.brush sketch · sandboxed Puppeteer renders it · a separate judge model scores the PNG
PolicyQwen3-30B-A3B-Thinking, per the label on the project page's own step-625 sample
First rubric9 signals · plateaued at ~0.65 · every rollout a flat, five-petal clip-art flower
Diagnosisfive of the nine correlated 0.85–0.95 · code length, ~⅓ of the reward, saturated by step 30 · HPSv3, the only signal with variance, weighted 0.10
Second rubric4 signals · same base model, same data · old plateau reached 3× faster, then kept climbing
A point above ita labelled sample reads step 625, r = 0.79 — one datapoint past the 0.65 plateau, not a ceiling
Side effectgenerated code compressed 13,500 → under 2,000 tokens
Reference pool581 paintings — 117 love-tier + 266 okay from 1,664 hand-rated generations, plus 198 supplements
System promptGEPA, 200 iterations → allowlist of 8 brush methods, no API docs, no examples
Statusongoing · one more training run planned · full technical report promised for June

The loop

one iteration — run thousands of times during training2 of 5 stages are hand-authored
The training loop drawn as five boxes left to right — prompt, model, tooling, judge, update — joined by arrows, with a return arrow running back underneath from update to prompt. The judge box is highlighted as the hand-authored stage.prompt1 of 5model2 of 5tooling3 of 5judge4 of 5hand-authoredupdate5 of 5next rollout
judgevs two references sampled from the pool

A separate judge model is shown the rollout and two references drawn at random from the hand-rated pool, and asked which is the better watercolour. The reward is the fraction of comparisons won.

· pairwise, not 0–10· two references per rollout· 581 paintings in the pool, 117 of them love-tier

Three of these five stages are off-the-shelf. A prompt goes in, a headless browser renders a canvas, GRPO normalizes within the group. The project’s entire design surface is the other two: what the judge is asked and what it compares against. Both are hand-authored artefacts, and both are where the first run went wrong.

On the left, a painted hibiscus flower in peach and pink watercolour tones with loose, bleeding brush strokes on a cream ground. On the right, the JavaScript source file that produced it, showing p5.brush calls in a code editor.
The artefact is the file on the right. Everything on the left is downstream of it, which is the entire argument for doing it this way. (Narreddi, project page.)

Nothing in that loop is exotic. A prompt goes in, a headless browser renders a canvas, GRPO normalizes rewards within a group of rollouts on the same prompt. The whole design surface is the two stages in the middle: what the judge is asked, and what it is asked to compare against. Both are hand-authored, and both are where the first run went wrong.

Nine signals, one opinion

The first rubric had nine components: a compilation gate; a check that the sketch used p5.brush rather than native p5; a code-length ramp targeting around 3,000 tokens; HPSv3, a human-preference model; prompt adherence judged by a council of GPT-5.4 and Gemini; and four more quality judges — recognisability, aesthetics, technique, depth.

It plateaued around 0.65 and stayed there. The reward kept climbing. The pictures did not.

Five frames from one training run, labelled step 0, 29, 80, 140 and 200. Step 0 is a blank cream canvas; step 29 adds a faint warm smudge in one corner; step 80 is a solid dark maroon blob with a fan of thin strokes; step 140 is a pale, diffuse pink bloom; step 200 is a flat, symmetrical pink flower with five rounded petals and a small centre — clip-art rather than painting.
The first run's progression, five steps sampled across it. Step 200 is the failure the post describes: a flat, clip-art flower with five rounded petals, arrived at by a reward that was still going up. (Narreddi, project page.)

That final frame is the thing to sit with. It is not a broken render or a diverged policy. It is a competent answer to the question that was actually asked, and the question was badly posed.

The diagnosis came from reading the sub-rewards separately rather than as a sum. Three facts:

neff = k / (1 + (k−1)ρ) — the effective number of independent signals2.09 distinct opinions
compilation gate
0.05
uses p5.brush, not native p5
0.05
code length ramp → ~3,000 tokens
0.33
prompt adherence (GPT-5.4 + Gemini council)
0.09
recognisability
0.09
aesthetics
0.09
technique
0.09
depth
0.09
HPSv3
0.10
no gradient after step 30one opinion, stated five timesthe only independent signal left
judge correlation ρ0.90
each binary gate0.05
ρ is the reported 0.85–0.95 band · the two binary gates’ weights are not published, so they are a slider; everything downstream of them is arithmetic
dead weight
43%
of the reward, gradient-free after step 30
five judges are worth
1.09
independent signals, at ρ = 0.90
distinct opinions total
2.09
weighted 0.47 : 0.10

The post reports the correlation and leaves the arithmetic implicit, and the arithmetic is the part worth having. For k signals sharing a common pairwise correlation ρ, the effective number of independent ones is k / (1 + (k−1)ρ). Drag ρ across the reported 0.85–0.95 band and the five judges never rise above 1.14 independent signals or fall below 1.04. Five judges were worth one opinion.

Put that next to the other two facts and the whole rubric collapses. A third of the reward stopped producing gradient at step 30 and stayed in the sum anyway. The five-judge cluster — one opinion, five times — carried 47% of the weight. The one measurement that disagreed with it was weighted 0.10. A model optimizing that objective is being told one thing loudly, a second thing at a whisper, and nothing at all with the rest — which is a fair description of a flat, clip-art flower with five rounded petals.

The post reports the correlation and moves on. The arithmetic it implies is worth doing, because it turns a qualitative complaint into a number. For k equally-weighted signals sharing a common pairwise correlation ρ, the effective number of independent signals is

neff=k1+(k1)ρn_{\text{eff}} = \frac{k}{1 + (k-1)\rho}

At k = 5 and ρ = 0.90 that is 5 / 4.6 = 1.09. Across the whole reported 0.85–0.95 band it runs from 1.04 to 1.14. Five judges, five API bills, five sets of latency — and between them, one opinion.

Now stack it up. A third of the reward was dead weight after step 30. Something like half of it was one opinion stated five times. The single measurement that could have disagreed was worth a tenth. A policy optimizing that is being shouted at about one thing, whispered at about a second, and told nothing at all by the rest — and a flat, symmetrical, five-petal flower is a very reasonable thing to converge on when the loudest signal is "does this read as a flower".

Fix one: ask a relative question

The original rubric asked the judge to score each rollout from zero to ten. The scores came back compressed near zero.

The replacement asks something else. Show the judge the rollout and two references sampled from the pool, and ask: which of these is the better hibiscus watercolour? The reward is the fraction of comparisons won.

eight rollouts on one prompt — how many of the 28 pairs can each reward tell apart?pairwise orders 17 pairs · absolute orders 0
Two number lines with a bar at each reward value showing how many of the eight rollouts landed there. On the upper zero-to-ten absolute scale the rollouts pile onto one or two adjacent integers near the left end. On the lower pairwise scale, which has only three positions, they spread across all of them.absolute 0–10 — eleven levels available1 of them used · 0 of 28 pairs orderable0123456789108pairwise against two references — three levels3 of them used · 17 of 28 pairs orderable00.51152
scale compression0.20
rollout spread±0.08
reference draw#4
absolute 0–101 level used
100%
of pairs tied — no direction
nothing left to order
eleven levels, spent on the bottom of the range
pairwise3 levels used
39%
of pairs tied — no direction
76%
of the rest ordered correctly
three levels, and it uses them

The pairwise reward has fewer possible values than the scale it replaced — two references means it can only ever be 0, 0.5 or 1. That should be a downgrade, and it is not, because GRPO does not read the scale, it reads the ranking within the group. A group of eight rollouts contains 28 pairs; every pair the reward assigns the same value to is a pair the update cannot learn anything from.

Push the compression slider toward 1.0 and the absolute scale becomes the better instrument — which is exactly the point. Nothing was wrong with 0–10 as a scale. What was wrong was asking a judge model for an abstract magnitude and getting back the same two integers all day. The post’s phrasing is worth keeping: the judge “handles a relative question more reliably than an abstract scale.” Dynamic range is a property of the answers, not of the scale.

There is a genuine puzzle here that the widget is built around. Two references means the pairwise reward can only be 0, 0.5 or 1. Three levels, where the scale it replaced had eleven. On paper that is a downgrade.

It is not, and the reason generalises past this project. GRPO normalizes rewards within a group, so what propagates is the ranking of the rollouts in that group, not the magnitude of any one score. A group of eight rollouts contains 28 pairs, and every pair the reward assigns the same number to is a pair the update learns nothing from. Eleven levels spent on two adjacent integers leaves most of those pairs tied. Three levels that actually track quality do not.

Dynamic range is a property of the answers you get back, not of the scale you offered. The post's own phrasing is the right one: the judge "handles a relative question more reliably than an abstract scale."

Fix two: build something to compare against

The second half is 1,664 images rated one at a time into love, okay and nope.

Hundreds of small painted flower images sorted into three labelled bands. The top band, love, holds around a hundred richly coloured, loosely painted flowers at a large thumbnail size. The middle band, okay, holds a few hundred smaller thumbnails that are flatter and more repetitive. The bottom band, nope, is a dense field of over a thousand tiny thumbnails, many of them muddy, black, or barely legible.
The rating pass, one image at a time. The bands are drawn at different thumbnail scales, so read the counts rather than the areas: 117 love, 266 okay, 1,281 nope. (Narreddi, project page.)

117 landed in love-tier and seeded the comparison pool. 266 were okay. The remaining 1,281 — about 77% of everything generated — were nope, which is a number worth keeping in mind next time a pipeline reports its best-of-n.

But the pool that shipped is not the 117. It is 581: the love-tier, the okay-tier, and 198 supplements from a separate generation run, added to widen the comparison set in colours where hand-rated examples were thin.

117 + 266 + 198 = 581 reference paintings, from 1,664 rated one at a time77% rated nope
the rating pass — 1,664 generations, one at a time
nope · 1,281 · 77.0%okay · 266 · 16.0%love · 117 · 7.0%
the 117 love-tier seeded the comparison pool · 266 okay-tier and 198 supplements from a separate generation run widened it to 581, in the colours where hand-rated examples were thin
Expected pairwise reward plotted against rollout quality for three candidate reference pools. The love-tier-only curve stays low and flat across the mid-quality range; the shipped 581-painting pool rises through the middle of that range with a much steeper slope.0.00.51.0expected rewardrollout quality →0.46lovesuppokay
rollout quality0.58
love-tier only
0.50
reward per unit of quality · E[r] = 0.30
everything hand-rated
1.00
reward per unit of quality · E[r] = 0.47
the shipped pool
1.05
reward per unit of quality · E[r] = 0.46
steepest here: the shipped pool · the hand-rated and shipped curves almost coincide — the 198 supplements widen colour coverage, the 266 okay-tier do the work on the gradient · tier quality values are illustrative; the counts are the published ones

Rating 1,664 images one at a time to keep 117 is the least glamorous part of this project and probably the most important. But the pool that shipped is not the 117. It is 581, and the 464 additions are the ones doing work the love-tier cannot.

A comparison reward only carries information where the opponents are near the policy’s own level. Judge a mid-training rollout against nothing but the best paintings in the collection and it loses almost every comparison, the reward pins near zero, and the gradient flattens for exactly the reason the 0–10 scale flattened. Drag the quality slider through the middle of the range and watch the love-only curve stay low while the shipped pool climbs through its steepest section. Widening the pool with merely-okay paintings looks like lowering the bar; it is really moving the opponents to where the policy can learn from losing.

The post frames the additions as coverage, and they are. There is a second reason they matter that follows directly from what a comparison reward is. Beating an opponent tells you nothing when you always win or always lose; the information lives where the outcome is uncertain. Judge a mid-training rollout against nothing but the best 117 paintings in the collection and it loses nearly every comparison, the reward pins near zero, and the gradient goes flat — the same compression failure that the switch away from 0–10 scoring was meant to fix, reintroduced through the opponent distribution instead of the scale.

Padding the pool with merely-okay paintings looks like lowering the bar. It is really moving the opponents to where losing is still informative.

Every image in that pool is model output, incidentally — p5.brush is niche enough that there was no corpus of human examples to draw on. So the reward model for "good painting" is anchored to one person's ratings of a machine's attempts. That is not a criticism; it is the honest shape of the problem, and the post says so.

The system prompt finding

Early versions of the system prompt included a 400-line p5.brush API reference. The model produced confident, well-formatted code that invented APIs that did not exist.

The fix ran through GEPA — a prompt-optimisation library that evolves a prompt against a scoring function — for 200 iterations against a taste-anchored 7-shot judge. It converged on a strict allowlist of eight brush methods, no API documentation, and no examples. The first version to get three visible hibiscus blobs out of three generations was the one written after throwing the 400-line reference out entirely.

GEPA · 200 iterations · taste-anchored 7-shot judge · converged on 8 brush methodsa reported configuration
Two concentric squares. The inner solid square is the API surface the system prompt describes; the outer dashed square is the surface the model writes code against. With a four-hundred-line reference the dashed square is much the larger, and the gap between them is labelled invented APIs. With the eight-method allowlist the two squares coincide.described128 calls≈ 115 inventedlines of API reference in the prompt400what the post reports hereinvents APIs that do not existexamples in the promptnone◆ measuredsolid = what the prompt describes · dashed = what the model writes code against · area ∝ distinct calls, illustrative
reference length400 lines
400-line API reference
“Confident, well-formatted code that invented APIs that did not exist.” Fluent output, nonexistent calls — the failure mode that costs the most debugging time because nothing about the code looks wrong.
8-method allowlist, no docs, no examples
The first version to produce three visible hibiscus blobs out of three generations — written after throwing the reference out entirely. The post does not name the eight methods.

The finding is counterintuitive enough to be worth stating twice: giving the model more documentation about the library made it hallucinate more of the library. A long reference reads as a description of a large surface, and a model writing fluent code against a large surface will reach for the method that ought to exist. An allowlist is a different kind of statement — not “here is what exists” but “here is what you may call”, and a call outside it is a visible violation rather than a plausible guess.

The honest caveat is in the slider. Two prompts were run; the axis between them is empty. And GEPA rewrote the whole prompt across 200 iterations, so “we removed the API reference” is one edit inside a search over many, not a controlled ablation. The direction is convincing and the mechanism is plausible. The dose-response curve that the generalisation implies has two points on it.

More documentation about the library made the model hallucinate more of the library, which is counterintuitive until you notice that an API reference and an allowlist are different speech acts. A reference says here is what exists, and a model writing fluent code against a large described surface will reach for the method that ought to exist. An allowlist says here is what you may call, and a call outside it is a visible violation rather than a plausible guess.

I would hold the generalisation slightly more loosely than the post does. Two prompts were run; nothing on the axis between them was. And GEPA rewrote the entire prompt over 200 iterations, so "we removed the API reference" is one edit inside a search over many, not a controlled ablation. The direction is convincing and the mechanism is plausible; the dose-response curve the generalisation implies has two points on it.

What it actually produces

Everything above is about a reward function. This is what came out of the other end.

A watercolour hibiscus on textured paper, painted in deep pink with pale yellow petals behind it and fine dark stamens radiating from the centre. Handwritten across the top: Qwen3-30B-A3B-Thinking, step 625, r equals 0.79. In the lower right, two lines of code: brush.stroke open-paren quote watercolor quote close-paren, and createCanvas six hundred comma six hundred.
One sample, labelled with everything you need to place it: the policy, the step, and the reward. (Narreddi, project page.)

That label is worth reading twice, because it settles two things the prose leaves open. The policy being fine-tuned is Qwen3-30B-A3B-Thinking — a reasoning model, not an image model, which is the whole premise made concrete. And r = 0.79 at step 625 is a real datapoint above the 0.65 plateau the first rubric died on, rather than an assurance that the curve kept going.

It is one point, not a ceiling. But it is the difference between "kept climbing" as a claim and "kept climbing" as a number, and I had written the ceiling off as unmeasured before I looked properly at the page's own images.

what the medium can do — nineteen frames, three outputs each

An animated gallery cycling through generated paintings, three at a time: hibiscus flowers in pink, orange, magenta and yellow; fields of red poppies; a bee resting on a yellow bloom; meadows of small blue and red flowers; sprigs of lavender; a dark night scene scattered with pale blue dots; a black spiral of fine radiating lines; and loose ink-splatter fields.

This is the part a reward-function post-mortem tends to leave out, and it is the part that says whether the fix worked. The failure mode being diagnosed was mode collapse — every rollout the same flat five-petal flower — so the only convincing evidence of a repair is range. Poppy fields, a bee, lavender, a night sky, a black spiral of hatching: these are not variations on one composition, and none of them is the clip-art flower.

five more, held longer

A slower animated sequence of five generated watercolours: a magenta hibiscus with green leaves, a pale pink bloom on a thin stem, a small faint flower on cream paper, and a dense magenta blossom with dark radiating stamens.

Worth keeping in view, though: every one of these is a flower. The prompts, the 581-painting reference pool and the taste-anchored judge are all hibiscus-shaped, so the range on display is range within one subject. Whether the same rubric transfers to a domain where the author has no hand-rated pool is exactly the question the unbuilt reward model was meant to answer.

What I would want measured next

The reward model that did not get built. The post names it: the next step, not taken, was training a small reward model on the 1,664 ratings themselves, so the sense of good could be applied without comparing against the pool every time. That is the step that decides whether this is a technique or a one-off. Pairwise-against-a-pool is bounded by the pool; a learned reward is bounded by whether taste generalises out of 1,664 labels. Those are very different ceilings and only one of them has been probed.

The 3× is a speed claim, and the interesting number is still the ceiling. "Reached the previous plateau three times faster and kept climbing" is the right thing to report from a run in progress, and it is not the same as knowing where the second run stops. The step-625 sample above gives one point at r = 0.79, which is genuinely above the old plateau — but a single labelled frame is not a curve, and the concern survives it: a reward that climbs past 0.65 might be finding real quality or might be finding the second rubric's own exploitable corner. The first rubric's reward also climbed, right up to the clip-art flower. What would settle it is the reward curve with samples pinned to it at intervals, which is a chart the project must already have.

Code compressing 13,500 → under 2,000 tokens needs one more sentence. The post reads it as the model learning that verbose code did not help, and that is the natural reading. But a binary length check replaced a length ramp, so the pressure that was pushing toward 3,000 tokens simply left, and both stories predict the same drop. Do the short sketches score better on the pairwise judge than the long ones did, holding the rubric fixed? One scatter plot settles it.

Correlation was measured once, on the rubric that failed. The fix was to delete the redundant judges. The check that the new rubric is not quietly redundant too — HPSv3 at 0.30 and the pairwise judge at 0.60 are both, in the end, opinions about whether the picture looks good — is the same cheap computation that found the problem the first time. n_eff for k = 2 at ρ = 0.7 is 1.18.

Someone ran the ablation

A year is a long time for an open question to sit unanswered, and this one did not sit that long. On 3 September 2026, Sergio Paniego posted a walkthrough of an open, from-scratch reproduction of the same project: a different base model, a much smaller pool, and — the part that matters for everything above — every artefact published. The recipe, the environment, the reference pool, three trained LoRA adapters, their rollout datasets, and the per-step training curves as CSV.

Narreddi's write-up describes a recipe; it ships no code. Paniego's post closes with a section literally titled "What I changed from the original," which is the tell that this is not a fresh take on the idea — it is a rebuild against the same blueprint, with every divergence named. That is why it belongs here rather than as a separate piece: it answers part of what the previous section asked for, on a different model and a different pool, and it leaves part of it exactly as open as before.

This section is the experiment, not the infrastructure. OpenEnv's reset/step/state contract, its Rubric tree, and how that compares to Prime Intellect's taskset/harness/runtime split are covered from the infrastructure side in Scaling agentic RL: 365,000 environments behind one contract, which reproduces the same project for a different argument — that piece is the one to read for the environment-as-contract angle. What follows is the reward design, the ablation, what it produced, and what it cost.

PostTraining a coding model to paint watercolours with TRL and OpenEnv · Sergio Paniego · Hugging Face · September 2026
Base modelQwen/Qwen3.5-35B-A3B, LoRA rank 16 — a different model from Narreddi's Qwen3-30B-A3B-Thinking
Reference pool178 paintings (love + okay tiers), from 206 candidates over three refinement rounds — Narreddi's 581 was never published, so this is a fresh pool, not a reuse
Three runsone variable each: judge-led 0.60/0.30, hps-led 0.30/0.60, hps-only 0.00/0.90 (pairwise judge / HPSv3 slot)
Everything publishedrecipe, environment, pool, three adapters, three rollout datasets, per-step curves as CSV

What changed, and what didn't

The four-term reward is Narreddi's, kept almost exactly: a binary gate at 0.05, a length term at 0.05, the pairwise judge at 0.60, HPSv3's slot at 0.30. One term changed shape, and the reason is the same diagnosis this article opened with. The original length term was a flat band — one point for anything between 150 and 1,200 tokens — and a term that scores 1.0 for nearly every rollout contributes nothing to a GRPO group. It was rewritten as a ramp:

MIN_LENGTH_TOKENS = 150
TARGET_LENGTH_TOKENS = 3000
RUNAWAY_LENGTH_TOKENS = 6000
 
def length_score(source: str) -> float:
    tokens = len(source) / 4
    if tokens < MIN_LENGTH_TOKENS or tokens > RUNAWAY_LENGTH_TOKENS:
        return 0.0
    if tokens >= TARGET_LENGTH_TOKENS:
        return 1.0
    return (tokens - MIN_LENGTH_TOKENS) / (TARGET_LENGTH_TOKENS - MIN_LENGTH_TOKENS)

That is the environment's own source, not a paraphrase, and its docstring says the quiet part out loud: "a term that is one for every rollout contributes nothing to a GRPO group, so the only signal about elaboration in the whole rubric was doing no work." It is the identical complaint the section above makes about a code-length gate that saturated by step thirty — reached independently, in a different codebase, before this article existed to compare it against.

Two smaller, explicitly deliberate changes sit next to it. The pairwise judge now draws its four references half from love and half from okay, rather than the top tier alone:

DEFAULT_MIX = {"love": 0.50, "okay": 0.50, "rung": 0.0}

The reasoning is the same shape as the pool-widening argument two sections up: sampling only what a weak early policy cannot beat pins the reward near zero and the gradient goes flat. And the system prompt gained one sentence — paint each petal two or three times, a big pass first and a smaller, more opaque one inside it — which the author reports made the outputs noticeably more colourful. Everything else, all-linear LoRA targeting for a mixture-of-experts base, infrastructure failures returning None instead of a silent zero, stopping the judge runs at step 110 of a 200-step launch, are decisions the original post never specified, made once here and reported as such.

Two hibiscus watercolour paintings side by side in rounded cards on a cream background, labelled love and okay beneath each. The love painting is a symmetrical five-petal magenta-pink hibiscus with a yellow-orange stamen and two pale green leaves. The okay painting is a looser, more lopsided pink hibiscus with a dark maroon smudge on one petal, a shorter orange stamen, and a single thin leaf.
The two tiers the pool draws references from, half and half, so a weak policy still faces something it can beat. Disagreeing with the rating is reasonable — somebody's taste is now the reward function. (Hugging Face, “Training a coding model to paint watercolours with TRL and OpenEnv.”)

The trainer needed its own four fixes before any of the above mattered, and they are worth naming because they are not reward-design at all — they are the difference between a reward that cannot possibly work and one that might:

settingfromtomeasured reason
learning rate2e-55e-5at 1e-6, entropy, completion length and the paintings themselves sat unchanged over thirteen steps while the reward oscillated in the noise of the reference draw
schedulerlinearconstant_with_warmuplinear decay had spent 79% of one run's total parameter-space displacement by step 33 of 60
scale_rewardsgroupnonedividing advantages by the group's own standard deviation let one gate rejection — present in 55% of groups — shrink the other seven rollouts' advantages by a factor of 0.76 to 0.84
target_moduleshand-written listall-linearthe hand list is written for a dense transformer and reaches 0.9% of this mixture-of-experts model's weights

None of that is visible in a reward curve. It is the kind of failure this whole article is about, one level down: not "the reward is measuring the wrong thing" but "the reward cannot reach the weights it is supposed to move," which looks identical from outside the training loop until someone reads the optimiser settings.

The last change is a quieter callback to the system-prompt finding above. Building this environment independently reconfirmed it with a number: classifying twenty-one JavaScript errors from two training runs found ten were invented brush or field names — a call the model plausibly guessed at because the surrounding documentation described a larger surface than the training-time allowlist actually grants. The fix that stuck was the same shape of fix Narreddi's GEPA search converged on: a short, string-free allowlist (ten methods here, eight in Narreddi's) rather than a fuller reference — none of the ten take a string argument, so there is no name left to invent. Two independent teams, two different models, the same failure mode and the same class of fix — which is closer to a real replication than the original section could claim for itself, where "two prompts were run, nothing on the axis between them was."

Three adapters, one number moved

Three checkpoints are published: watercolour-grpo-judge-led, watercolour-grpo-hps-led, and watercolour-grpo-hps-only, all under HuggingEnvs on the Hub. The Hub API and each repo's adapter_config.json — checked directly here, not taken from the post — say all three are LoRA over the identical base, Qwen/Qwen3.5-35B-A3B, rank 16, alpha 32, dropout 0, the identical seventeen-module all-linear target set (each repo serialises the set in a different order — a Python set carries no guaranteed iteration order — but sorted, the three lists are identical), and an adapter_model.safetensors of the identical size in all three: 121,864,672 bytes. The sha256 of that file differs in every repo. Same architecture, same size, to the byte — and three genuinely different sets of trained weights. Which is exactly what a real ablation should look like: if the files were bit-for-bit identical, no training happened.

The only thing that was ever meant to move is the split between the pairwise judge and HPSv3's slot, and this is the direct test of the question the first half of this article could only ask: are those two terms measuring one thing or two?

Qwen/Qwen3.5-35B-A3B · r=16 α=32 · same 17-module target set · 121,864,672-byte adapter, three different filesthe only variable: judge : quality
pairwise judge
0.60
HPSv3 slot
0.30
Group-mean reward per step for all three runs, plotted against the real step number. hps-only stops at step 60; judge-led and hps-led were launched for 200 and stopped at 110, still climbing.0.00.51.0060110group-mean reward, by step
steps
110 / 200
stopped early, still climbing
first → final third
0.450.72
Δ 0.27 mean group reward
reward slope
t = +10.5
frac_reward_zero_std = 0.000 throughout
what it produced — the most diverse and artistically interesting of the three, by the author's own verdict — and the split Narreddi's write-up converged on
how redundant are the two judges now? judge_mean vs quality_mean, per-step group means
judge-ledρraw 0.76 → neff 1.14ρdetrended 0.46 → neff 1.37
hps-ledρraw 0.68 → neff 1.19ρdetrended 0.14 → neff 1.76
detrended = correlation of step-to-step deltas, which removes the trend both curves share simply from training proceeding

The naive number looks almost like the original rubric’s cluster: raw correlation between the two model-judge terms sits at 0.68 to 0.76 across the runs that carry both, which by the same n = 2 / (1 + ρ) arithmetic used above is worth barely more than one opinion. But most of that number is the two curves climbing together as the policy improves, not the two judges agreeing about any one picture — strip the shared trend out and the correlation drops to 0.14–0.46, and neff rises to 1.4–1.8 out of a possible 2.

The stronger evidence is not a correlation coefficient at all. It is that moving the split from 0.60/0.30 to 0.30/0.60 to 0.00/0.90 changed the shape of three otherwise-identical training runs and the paintings at the end of them — a converged, one-palette validation run; a reliable, wet-on-wet house style; and the most diverse set of the three. Two terms that were truly one opinion restated would have produced one outcome no matter how the weight moved between them. They did not.

The raw correlation between the two terms' per-step group means — computed here from the published CSVs, a number neither post reports — sits at 0.68 to 0.76 across the two runs that carry both. Run the same n = 2 / (1 + ρ) arithmetic this article opened with and that is barely more than one opinion between them, which would seem to confirm the original worry outright. It doesn't, quite: most of that correlation is the two curves climbing together as the policy improves over 110 steps, not the two judges agreeing about any single picture. Strip the shared trend — correlate step-to-step deltas instead of the raw series — and it drops to 0.14–0.46:

neff=21+ρn_{\text{eff}} = \frac{2}{1 + \rho}

at which point the two terms are worth 1.4 to 1.8 opinions out of a possible two, not 1.1. Neither number is the one the original correlation math actually needs — that calculation wants agreement between two judges scoring the same rollout at a fixed checkpoint, and both of these are aggregate statistics across a moving policy. The cleaner version of this test is sitting in the published rollout datasets, one row per submission with both scores attached, and nobody has run it yet.

What settles the question better than any correlation coefficient is behavioural: moving the split from 0.60/0.30 to 0.30/0.60 to 0.00/0.90 changed the shape of three otherwise-identical training runs, and it changed what came out the other end. hps-only converges hardest and settles on a handful of colours. hps-led paints convincing watercolours with a shared wet-on-wet look that reads as almost a house style. judge-led — the split Narreddi's own write-up converged on — ends up the most diverse and, by the author's own explicitly-subjective verdict, the most artistically interesting. Two terms that were truly one opinion stated twice would have produced one outcome regardless of how the weight moved between them. They did not.

What it produced, and what it cost

A dense grid of roughly 180 small square watercolour paintings of flowers on pale cream and green backgrounds, mostly orange, pink, magenta and red blooms on green stems, arranged edge to edge with no labels or gaps between tiles.
The reward's own 178 favourites, shuffled together from the two judge-carrying runs — the same count as the reference pool that trained it. (Hugging Face, “Training a coding model to paint watercolours with TRL and OpenEnv.”)

Range, not just quality, is the thing to look for here, for the same reason it mattered in the earlier gallery: a rubric that has genuinely stopped rewarding one flat composition should produce more than one flat composition. It does. Every one of these is still a flower — the pool, the criteria sentence, and the judge are all hibiscus-shaped, so range within the subject is what the reward can show, not range across subjects — but within that subject the tiles vary in palette, density, and how much of the canvas carries paint in a way the original's plateau never did.

The cost side has no dollar figures in the source, so none appear here. What it names instead: one H200 for eighteen hours to reach step 60, about thirty-four for step 110. HPSv3 kept on a dedicated a100-large Space for the entire run, whether or not a given step happens to call it. A step is eight rollouts and takes fifteen to eighteen minutes, of which seventy to eighty per cent is rendering — a single headless-Chromium render takes 69 to 96 seconds against a 90-second deadline, software-rendering a WEBGL canvas with no GPU in the container, and the author reports expecting it to be faster without finding the full cause. The line worth keeping from the source, verbatim: a scorer can cost more than the training that uses it. The infrastructure diagram and the full cost breakdown live in the sibling piece; the number this article adds to it is the one above — no run in this ablation ever lost its gradient (frac_reward_zero_std stayed at 0.000 throughout all three), which is the property the first rubric this article opened with did not have past step thirty.

What this answers, and what it still doesn't

The previous section closed with four things worth measuring next. This is not a continuation of Narreddi's own run — different model, different pool, a different team — so none of it settles a question about that checkpoint specifically. It does say something about the shape of the problem in general, one bullet at a time.

The reward model that did not get built still hasn't been. Paniego's own closing section names the identical gap: "178 paintings made by models define what this trained model considers beautiful. The pool is the bottleneck." What changed is the workaround — borrowing a general preference model trained on 1.17 million unrelated human comparisons (or, absent that, a zero-shot mark from the judge itself) rather than training a small model on the 178 or 1,664 in-domain ratings. That is a different answer to a nearby question, not the original one.

The ceiling question has real curves under it now, and they still don't resolve it. All three runs kept a positive slope to the point they were stopped, and no group in any of them ever lost its gradient — a materially better property than the original rubric had by step thirty. But judge-led and hps-led were launched for 200 steps and stopped at 110 to save compute, still climbing. The ceiling is still unmeasured; what's new is evidence that a two-term, judge-plus-preference-model reward can climb past where a nine-signal one stalled without visibly running out of room in 110 steps.

The length-compression question was not tested — it was pre-empted. Whether short sketches score better than long ones, holding the rubric fixed, needed the original flat band to be run and then compared against a ramp. Paniego's environment ships with the ramp already in place, for the same reasoning this article gives independently. The controlled comparison the original section asked for still doesn't exist; what exists now is a second team reaching the identical fix without reading the first team's diagnosis.

The redundancy question is the one this actually moves. The bullet above this one, written before any of the runs above existed, guessed n_eff for k = 2 at ρ = 0.7 and got 1.18 — almost exactly where the raw, trend-inflated correlation measured here lands (1.14 to 1.19). Detrended, it moves to 1.4–1.8. Neither number is the clean per-rollout measurement the guess was really asking for, and the right version of that test is sitting unrun in the published rollout data. But three checkpoints that share everything except one weight, producing three visibly different training curves and three visibly different painting styles, is stronger evidence than any correlation coefficient that a pairwise judge and a preference model are not simply one opinion counted twice — which is the most concrete answer any section of this article has gotten.

Why this one is worth your time

The write-up is not a paper. It is a blog post about a project that is still running, with a technical report promised later, and it reports one plateau, one diagnosis, one fix, and no baselines. Take the numbers as a field report.

Take the structure more seriously than that, because it is the clearest small example I have seen of a failure mode that is going to keep happening. Everyone doing RL on subjective work will build a rubric. Rubrics accrete signals, because adding one is easy and each addition feels like a defensible improvement — of course recognisability matters, of course technique matters. Nothing in the training curve ever tells you that you added the same signal four times. The reward goes up. The work does not get better. And the model, which is doing exactly what you asked, hands you back a flat flower with five rounded petals.

The correction is not more judges. It is k / (1 + (k−1)ρ), computed before you trust the sum.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Five judges were worth one opinion: RL on a reward you have to author yourself", ai.thesatyajit.com, August 2026.

bibtex
@misc{ghana2026paintwithcode,
  author = {Satyajit Ghana},
  title  = {Five judges were worth one opinion: RL on a reward you have to author yourself},
  url    = {https://ai.thesatyajit.com/articles/paint-with-code},
  year   = {2026}
}
share