File size: 13,566 Bytes
2ac71c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# SPDX-License-Identifier: BSD-3-Clause

"""Run the layers over one submission and assemble the verdict.

The weights follow the rubric Narreddi's write-up converged on after the first
one, which had nine signals, plateaued: a binary compile-and-uses-brush gate at
0.05, a binary length check at 0.05, HPSv3 at 0.30, and the pairwise judge at
0.60.

Two deliberate differences from that, both of which change what the numbers mean.

**HPSv3's slot is filled by an absolute mark from the judge model.** HPSv3 is a
7B preference model trained on 1.17M human comparisons, and it carries 0.30 of
their reward. Its *role* is what matters: it scores a painting on its own rather
than against a reference, so a policy has something to climb before it can beat
anything in the pool. Leaving that slot empty cost the first four runs, and
filling it with a coverage term cost the next few: coverage knows how much paint
landed and nothing about whether the painting is any good, so once the policy
painted a plausible amount the term went flat while the pairwise judge sat at
exactly zero for twenty-four rollouts in a row. See
[`~envs.watercolour_env.server.quality`] for the replacement and for the measured
proof that it grades where the comparison had given up.

**The gate stays absolute for the dishonest cases.** Awarding 0.05 for compiling
is their structure and it is worth keeping, because it is the only term a policy
can earn on day one and so the only source of reward variance before the judge
engages. But a sketch that loaded somebody else's painting or wrote the answer as
text scores zero outright rather than collecting the compile credit.

The gate runs first and is free. Only what clears it reaches the judge, and the
render is carried across so the painting is produced exactly once per episode.
"""

from __future__ import annotations

import asyncio
import os
from dataclasses import dataclass
from typing import Any

try:
    from .gate import GateResult, run_gate
    from .pairwise_judge import JudgeReport, PairwiseJudge
    from .quality import QualityReport, QualityScorer
    from .render import SketchRenderer
    from .tasks import Task
except ImportError:
    from server.gate import GateResult, run_gate
    from server.pairwise_judge import JudgeReport, PairwiseJudge
    from server.quality import QualityReport, QualityScorer
    from server.render import SketchRenderer
    from server.tasks import Task


# Defaults are the write-up's final rubric, kept exactly. They are read from the
# environment because the split between the pairwise judge and the preference model
# is the one thing worth varying: three runs of this environment differ only in
# these four numbers, and hard-coding them meant a forked copy of the whole server
# per combination.
GATE_WEIGHT = float(os.environ.get("WATERCOLOUR_GATE_WEIGHT", 0.05))
LENGTH_WEIGHT = float(os.environ.get("WATERCOLOUR_LENGTH_WEIGHT", 0.05))
JUDGE_WEIGHT = float(os.environ.get("WATERCOLOUR_JUDGE_WEIGHT", 0.60))
# The slot HPSv3 holds in their rubric, at their weight. See
# [`~envs.watercolour_env.server.quality`] for why an absolute mark from the judge
# model stands in for the preference model itself, and for the numbers that say the
# stand-in discriminates where the pairwise term had stopped.
QUALITY_WEIGHT = float(os.environ.get("WATERCOLOUR_QUALITY_WEIGHT", 0.30))

# A ramp, not a band, and the change is deliberate. Their write-up describes "a
# code length ramp targeting around 3,000 tokens" and sketches that "compressed
# from 13,500 tokens to under 2,000", so elaboration is something their reward
# pulls towards. The band this replaces returned one for anything between 150 and
# 1200 tokens, and measured output sits inside it: a 4B writes 570 to 1256 tokens
# and a VLM 700 to 1300. 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.
#
# Below the floor a sketch is degenerate and scores zero. From there it climbs to
# full credit at the target, so writing more elaborate code always pays a little.
# Past the runaway cap it scores zero again: their 13,500-token sketches were a
# problem to be compressed, not a goal.
MIN_LENGTH_TOKENS = 150
TARGET_LENGTH_TOKENS = 3000
RUNAWAY_LENGTH_TOKENS = 6000


def length_score(source: str) -> float:
    """Return how close a sketch's length is to the target elaboration.

    Args:
        source (`str`):
            The extracted sketch source.

    Returns:
        `float`: Zero below the floor and above the runaway cap, climbing
            linearly to one at the target in between.

    Examples:

    ```python
    >>> length_score("x" * 4 * 600)  # what a 4B writes today
    0.16
    >>> length_score("x" * 4 * 3000)  # the target
    1.0
    ```
    """
    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)


@dataclass(frozen=True)
class Evaluation:
    """Everything known about one submission.

    Attributes:
        task ([`Task`]):
            The request the submission was answering.
        gate ([`GateResult`]):
            Verdict of the admission check.
        judge ([`JudgeReport`] or `None`):
            The comparative verdict, `None` when the gate rejected the
            submission or no judge was configured.
        judge_enabled (`bool`):
            Whether a judge was configured for this run, regardless of whether
            it managed to answer.
        quality ([`QualityReport`], *optional*):
            The absolute mark, `None` when the gate rejected the submission or
            no scorer was configured.
    """

    task: Task
    gate: GateResult
    judge: JudgeReport | None = None
    judge_enabled: bool = False
    quality: QualityReport | None = None

    @property
    def gate_passed(self) -> bool:
        """`bool`: Whether the submission cleared the admission check."""
        return self.gate.passed

    @property
    def judge_score(self) -> float:
        """`float`: The comparative score, zero when nothing was judged."""
        return self.judge.score if self.judge is not None else 0.0

    @property
    def render_unavailable(self) -> bool:
        """`bool`: Whether the browser failed rather than the sketch."""
        return self.gate.render_unavailable

    @property
    def judged(self) -> bool:
        """`bool`: Whether a judge verdict was actually obtained."""
        return self.judge is not None and self.judge.available

    @property
    def quality_score(self) -> float:
        """`float`: The absolute mark in [0, 1], zero when nothing was marked."""
        return self.quality.score if self.quality is not None else 0.0

    @property
    def quality_scored(self) -> bool:
        """`bool`: Whether an absolute mark was actually obtained."""
        return self.quality is not None and self.quality.available

    @property
    def length_score(self) -> float:
        """`float`: One if the sketch's length is in the accepted band."""
        if self.gate.source is None:
            return 0.0
        return length_score(self.gate.source.source)

    @property
    def reward(self) -> float:
        """`float`: The episode reward.

        A weighted sum of the components that survive, and the components are
        what matter more than the total. Clearing the gate is worth 0.05 on its
        own, which is the only thing a policy can earn before it paints well
        enough to beat anything, and therefore the only reward variance a GRPO
        group has on day one.

        Failing the gate scores zero outright. A submission that loaded someone
        else's painting or wrote the answer in words does not collect the
        compile credit, and one that did not render has no painting to judge.

        Without a judge configured only the free components count, which is a
        thin signal useful for shaking out a run without spending on vision
        calls and not something to train against.
        """
        if not self.gate.passed:
            return 0.0
        total = GATE_WEIGHT + LENGTH_WEIGHT * self.length_score
        total += QUALITY_WEIGHT * self.quality_score
        if self.judge_enabled:
            total += JUDGE_WEIGHT * self.judge_score
        return total

    @property
    def critique(self) -> str:
        """`str`: What may be shown to the policy between revisions.

        Deliberately narrower than [`feedback`], which exists for a person reading
        a log and says how many references the painting beat and what it scored.
        Handing that to the policy would teach it to optimise against the specific
        references it was drawn, not to paint: with a shared seed the whole group
        faces the same eight, so naming the outcome names the opponents. The
        reference paintings and the score are the answer key.

        What is left is the submission's own properties, which it could have
        measured itself if it had eyes: whether it was admitted, why not, and how
        much of the canvas it covered.
        """
        if not self.gate.passed:
            return "rejected: " + ", ".join(self.gate.violations)
        render = self.gate.render
        painted = render.paint_fraction if render else 0.0
        return (
            f"admitted. {painted:.1%} of the canvas carries pigment; the reference "
            f"paintings run from 12% to 25%."
        )

    @property
    def feedback(self) -> str:
        """`str`: A short explanation of the score, safe to show the model."""
        if not self.gate.passed:
            return "rejected: " + ", ".join(self.gate.violations)
        if not self.judge_enabled:
            return "painted, no judge configured"
        if not self.judged:
            return "painted, judge unavailable"
        # Counts only comparisons the judge actually resolved. Reporting a
        # loss for a reference nobody managed to look at reads as a verdict
        # when it is a failed call.
        resolved = [
            c
            for c in self.judge.comparisons
            if c.score is not None
            and (c.submission_first is not None or c.reference_first is not None)
        ]
        won = sum(1 for c in resolved if c.score == 1.0)
        return (
            f"painted, beat {won} of {len(resolved)} references"
            f" (judge {self.judge_score:.2f}, quality {self.quality_score:.2f},"
            f" reward {self.reward:.2f})"
        )

    def to_dict(self) -> dict[str, Any]:
        """Return the full per-layer analysis."""
        return {
            "task_id": self.task.task_id,
            "reward": self.reward,
            "components": {
                "gate": GATE_WEIGHT if self.gate.passed else 0.0,
                "length": LENGTH_WEIGHT * self.length_score,
                "quality": QUALITY_WEIGHT * self.quality_score,
                "judge": JUDGE_WEIGHT * self.judge_score if self.judge_enabled else 0.0,
            },
            "gate": self.gate.to_dict(),
            "judge": self.judge.to_dict() if self.judge else None,
            "judge_enabled": self.judge_enabled,
            "judge_weight": JUDGE_WEIGHT if self.judge_enabled else 0.0,
        }


async def evaluate_submission(
    response: str,
    task: Task,
    renderer: SketchRenderer,
    judge: PairwiseJudge | None,
    seed: int | None = None,
    references: int | None = None,
    scorer: QualityScorer | None = None,
) -> Evaluation:
    """Score one submission.

    Args:
        response (`str`):
            The model's raw reply.
        task ([`Task`]):
            The request it was answering.
        renderer ([`SketchRenderer`]):
            The browser to render with.
        judge ([`PairwiseJudge`], *optional*):
            The comparative scorer. `None` scores on gate admission alone.
        seed (`int`, *optional*):
            Makes the sampled references reproducible.
        references (`int`, *optional*):
            Override how many references to judge against. Defaults to the
            judge's own setting.
        scorer ([`QualityScorer`], *optional*):
            The absolute scorer. `None` leaves that term of the reward at zero.

    Returns:
        [`Evaluation`]: The assembled verdict.

    Examples:

    ```python
    evaluation = await evaluate_submission(reply, task, renderer, judge)
    print(evaluation.reward, evaluation.feedback)
    ```
    """
    gate = await run_gate(response, renderer)
    if not gate.passed:
        return Evaluation(task=task, gate=gate, judge_enabled=judge is not None)
    # Both vision terms look at the same painting, so they go out together rather
    # than one after the other. The absolute mark is one call against the pairwise
    # judge's two per reference, so it adds nothing to the wall clock.
    coroutines = []
    if judge is not None:
        coroutines.append(judge.score(gate.png, seed=seed, references=references))
    if scorer is not None:
        coroutines.append(scorer.score(gate.png))
    results = list(await asyncio.gather(*coroutines)) if coroutines else []
    report = results.pop(0) if judge is not None else None
    mark = results.pop(0) if scorer is not None else None
    return Evaluation(
        task=task,
        gate=gate,
        judge=report,
        judge_enabled=judge is not None,
        quality=mark,
    )