diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index 499138c66..e5d4f5807 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -129,6 +129,8 @@ title: Agent World Model - local: environments/opencode title: OpenCode + - local: environments/pelican_svg + title: Pelican SVG - local: environments/sophistry_bench_sprint title: Sophistry Bench Sprint title: Environments diff --git a/docs/source/environments.md b/docs/source/environments.md index beb19be62..fb8e83fc1 100644 --- a/docs/source/environments.md +++ b/docs/source/environments.md @@ -258,6 +258,14 @@ The OpenEnv community has built a catalog of ready-to-run environments that cove 📄 Docs +
+
Pelican SVG
+

pelican_svg_env scores blind SVG drawings of an animal riding a vehicle in three layers: a source gate against cheats, deterministic geometry checks and a vision judge. 30 subject-vehicle tasks, with eval and GRPO training examples included.

+
+ 📄 Docs + 🤗 HF +
+
Sophistry Bench Sprint

sophistry_bench_sprint_env is a single-turn advocacy reward-hacking environment on QuALITY passages: the policy defends an assigned answer and the reward proxy peaks at 8 <claim> tags, with four weight-0 canaries that detect format hacking.

diff --git a/docs/source/environments/pelican_svg.md b/docs/source/environments/pelican_svg.md new file mode 100644 index 000000000..806222cc0 --- /dev/null +++ b/docs/source/environments/pelican_svg.md @@ -0,0 +1,322 @@ + +# Pelican SVG Environment + +Blind vector drawing, scored. The model is asked for an SVG of an animal riding +a vehicle and never sees the result, so it has to hold the spatial arrangement +in its head and emit coordinates for it. This is the check Simon Willison +popularised as "generate an SVG of a pelican riding a bicycle", turned into +something you can run repeatedly, at scale, and train against. + +## What this is, and what it is not + +**Not a model ranking.** Simon Willison, whose prompt this is, is explicit that +"the correlation between pelican performance and actual model quality has been +mostly severed now", and ends with "don't go using pelicans to compare models" +([Kimi K3, and what we can still learn from the pelican +benchmark](https://simonwillison.net/2026/Jul/16/kimi-k3/)). Our own numbers +agree with him: on the canonical task, across 139 scored samples from 7 frontier models, +136 scored a perfect 1.000 on the structural layer and 47 scored a perfect 1.000 overall. +It is saturated. + +**Not a contamination study either.** [Dylan +Castillo](https://dylancastillo.co/posts/pelicanmaxxing.html) already ran that, +across an 8 by 6 grid and 1008 SVGs from 7 frontier models, and found little +evidence that labs optimise for the pelican. The extra animals and vehicles here +are ours and serve a smaller purpose: checking that the scorer measures drawing +ability rather than one memorised picture. Only `pelican_bicycle` is Simon's, and +it is the default. + +**What it is** is a reproducible, executable target. The same code that scores a +frontier model over an API scores a 0.6B model mid-training, the reward is dense +enough to run GRPO against, and the whole thing deploys as a Space. That is the +part a prompt cannot do: + +- [`examples/pelican_svg_eval.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/pelican_svg_eval.py) scores any set of models through HF Inference Providers. +- [`examples/pelican_svg_grpo.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/pelican_svg_grpo.py) trains against it with TRL, runnable on HF Jobs. + +The interesting question stops being "which model draws the best pelican" and +becomes "can a small model be taught to, and does it learn to draw or to game +the scorer". + +## The task catalogue + +6 animals by 5 vehicles, so 30 tasks. All three +scoring layers adapt to whichever one is served, so nothing about the pipeline is special +to the pelican: + +- **The judge's checklist is generated per task** from the features below. A pelican is + asked about a throat pouch, a capybara about a blunt snout, an octopus about curling + tentacles. +- **The geometry layer reads `wheels`** from the vehicle, and drops the wheel-pair checks + entirely for a single-wheeled vehicle rather than failing them. +- **The anti-cheat terms are derived from the task**, so `axolotl_scooter` forbids + `axolotl, salamander, amphibian, scooter, kick scooter` and not the pelican's words. + +### Animals + +| subject | features the judge asks about | counts as partial recognition | +| --- | --- | --- | +| `pelican` | a long beak, a throat pouch under the beak | bird, seabird, waterbird | +| `flamingo` | a long thin neck, long thin legs | bird, waterbird | +| `capybara` | a blunt rectangular snout, small rounded ears | rodent, animal | +| `axolotl` | feathery external gills on the head, a wide flat smiling mouth | salamander, amphibian, animal | +| `octopus` | a large bulbous head, multiple curling tentacles | cephalopod, animal | +| `hedgehog` | a coat of spines, a small pointed snout | animal | + +### Vehicles + +| vehicle | wheels in side view | features the judge asks about | +| --- | --- | --- | +| `bicycle` | 2 | two wheels of similar size, a frame joining the wheels, handlebars | +| `unicycle` | 1 | a single wheel, a seat post rising from the wheel | +| `tandem bicycle` | 2 | two wheels, an extended frame with two saddles | +| `scooter` | 2 | two small wheels, a deck with an upright steering column | +| `skateboard` | 2 | a flat deck, wheels under the deck | + +### How much of the grid has actually been run + +**All 30 combinations have been scored end to end by a live model. None crashed and none +was rejected by the gate.** The counts below are how much traffic each cell has actually +seen, which is a different question from whether it works: + +| animal | `bicycle` | `unicycle` | `tandem bicycle` | `scooter` | `skateboard` | +| --- | --- | --- | --- | --- | --- | +| `pelican` | **224** | 1 | 1 | 1 | 1 | +| `flamingo` | 1 | 1 | 1 | **16** | 1 | +| `capybara` | 1 | **16** | 1 | 1 | 1 | +| `axolotl` | 1 | 1 | **16** | 1 | 1 | +| `octopus` | 1 | 1 | 1 | 1 | 1 | +| `hedgehog` | 1 | 1 | 1 | 1 | 1 | + +Read a `1` as "the plumbing works", not as a result. It says the task builds, the prompt +renders, the gate admits the reply, the geometry resolves against that vehicle's wheel +count and the judge returns a checklist. It says nothing about how well the scoring +behaves on that combination, and it cannot: one sample has no variance to report. + +Across the 30-task sweep, reward ranged 0.362 to 1.000 with a median of 0.838, structure +averaged 0.905 and the judged semantic score 0.785. The weakest cell was +`hedgehog_tandem-bicycle` at 0.362, where the judge would not accept the animal. Three of +the 30 were corrected upward by the `` fix described below, `capybara_unicycle` most +of all, from 0.333 to 1.000 on structure. + +`pelican_bicycle` is Simon Willison's original prompt and the default. It is also the only +task the GRPO example trains on: `examples/pelican_svg_grpo.py` pins it deliberately, +since that script is a small demonstration that the environment is trainable rather than +an attempt to teach a model the whole grid. + +## Scoring + +Three layers, ordered by cost. Each earns the right to run the next, so a run +against a model producing garbage stays cheap. + +### 1. Gate, free, pass or fail + +Rejects anything that is not an honest attempt to draw. A rejected submission +scores zero and never reaches the judge. + +| Code | Meaning | +| --- | --- | +| `no_svg_in_response` / `truncated_svg` | No SVG, or one cut off mid-generation. Kept separate because "the model refused" and "the harness cut it off" are different facts. | +| `unparseable` / `render_failed` | Not well-formed, declares a DTD, or will not rasterise. | +| `embedded_raster` | An `` element or a `data:image` URI. | +| `external_reference` | An `href` pointing off-document. | +| `forbidden_element` | `' + assert "forbidden_element" in inspect_source(svg(body)).codes + + def test_rejects_text_naming_the_subject(self): + body = 'a pelican' + assert "text_label" in inspect_source(svg(body), PELICAN_TERMS).codes + + def test_rejects_subject_named_inside_tspan(self): + body = 'a pelican' + assert "text_label" in inspect_source(svg(body), PELICAN_TERMS).codes + + def test_text_after_closing_tag_is_not_rendered_and_not_counted(self): + """An element's tail sits outside it, so it never reaches the canvas.""" + body = 'hipelican' + assert "text_label" not in inspect_source(svg(body), PELICAN_TERMS).codes + + def test_rejects_entity_declarations(self): + """ElementTree expands internal entities, so a DTD is a billion-laughs vector.""" + bomb = ( + ']>' + '&lol;' + ) + with pytest.raises(SvgParseError, match="DTD"): + parse_svg(bomb) + + def test_rejects_oversized_source(self): + with pytest.raises(SvgParseError, match="exceeds"): + parse_svg(svg("" * 40000)) + + def test_rejects_non_svg_root(self): + with pytest.raises(SvgParseError, match="root element"): + parse_svg( + '' + ) + + +class TestRender: + """Rasterisation and the measurements taken from it.""" + + def test_render_is_deterministic(self): + source = fixture("good_pelican_bike") + digests = {render_png(source, 256) for _ in range(3)} + assert len(digests) == 1 + + def test_malformed_source_raises(self): + with pytest.raises(RenderError): + render_png(fixture("malformed")) + + def test_blank_canvas_has_no_ink(self): + assert image_stats(render_png(fixture("blank"))).ink_fraction == 0.0 + + def test_ink_is_measured_against_the_background_not_transparency(self): + """A painted backdrop must not read as a full canvas of ink.""" + source = svg( + '' + ) + assert image_stats(render_png(source)).ink_fraction < 0.2 + + def test_black_on_white_counts_as_ink(self): + """Squared channel differences overflow int16 and silently vanish.""" + source = svg('') + assert image_stats(render_png(source)).ink_fraction > 0.4 + + +class TestGeometry: + """Shape extraction must not depend on how the shape was spelled.""" + + CIRCLES = { + "circle": '', + "bezier": ( + '' + ), + "arc": '', + # The same two arcs with the flags run together and into the next + # coordinate, which the spec allows and minifiers produce. + "arc_compact_flags": '', + } + + @pytest.mark.parametrize("spelling", sorted(CIRCLES)) + def test_every_circle_spelling_agrees(self, spelling): + shape = extract_shapes(parse_svg(svg(self.CIRCLES[spelling])))[0] + assert shape.circularity == pytest.approx(1.0, abs=0.01) + assert shape.radius == pytest.approx(0.25, abs=0.005) + assert shape.centroid == pytest.approx((0.5, 0.5), abs=0.005) + + def test_square_is_not_round(self): + shape = extract_shapes( + parse_svg(svg('')) + )[0] + assert shape.circularity == pytest.approx(math.pi / 4, abs=0.01) + + def test_centroid_is_not_biased_by_vertex_count(self): + """A duplicated closing vertex used to drag the centroid off centre.""" + shape = extract_shapes( + parse_svg(svg('')) + )[0] + assert shape.centroid == pytest.approx((0.5, 0.5), abs=0.001) + + def test_radius_variation_survives_coarse_sampling(self): + """With four raw segments a square's radius variation reads as zero.""" + shape = extract_shapes( + parse_svg(svg('')) + )[0] + assert shape.radius_cv == pytest.approx(0.107, abs=0.02) + + def test_smooth_cubic_after_quadratic_does_not_reflect_its_control(self): + """S may only reflect after C/S; after Q its first control is the cursor.""" + points = [ + p + for sub, _ in flatten_path("M 0 0 Q 50 100 100 0 S 200 100 200 0") + for p in sub + ] + assert min(y for _, y in points) >= 0 + + def test_smooth_quadratic_after_cubic_does_not_reflect_its_control(self): + """T may only reflect after Q/T; after C its control is the cursor.""" + points = [ + p + for sub, _ in flatten_path("M 0 0 C 20 80 80 80 100 0 T 200 0") + for p in sub + ] + assert min(y for _, y in points) >= 0 + + @pytest.mark.parametrize( + "hiding", + [ + 'display="none"', + 'style="display: none"', + 'visibility="hidden"', + 'opacity="0"', + ], + ) + def test_hidden_geometry_is_not_scored(self, hiding): + source = svg(f'') + assert extract_shapes(parse_svg(source)) == [] + + def test_a_hidden_group_hides_its_children(self): + source = svg('') + assert extract_shapes(parse_svg(source)) == [] + + def test_unpainted_geometry_is_not_scored(self): + source = svg('') + assert extract_shapes(parse_svg(source)) == [] + + def test_a_stroked_outline_with_no_fill_still_counts(self): + source = svg('') + assert len(extract_shapes(parse_svg(source))) == 1 + + def test_a_child_can_repaint_inside_an_unpainted_group(self): + source = svg( + '' + ) + assert len(extract_shapes(parse_svg(source))) == 1 + + def test_off_canvas_geometry_is_not_scored(self): + source = svg('') + assert extract_shapes(parse_svg(source)) == [] + + def test_partially_visible_geometry_still_counts(self): + source = svg('') + assert len(extract_shapes(parse_svg(source))) == 1 + + def test_long_ellipse_is_rejected_by_aspect(self): + shape = extract_shapes( + parse_svg(svg('')) + )[0] + assert shape.aspect == pytest.approx(2.0, abs=0.05) + + def test_group_transforms_are_applied(self): + source = svg( + '' + ) + shape = extract_shapes(parse_svg(source))[0] + assert shape.centroid == pytest.approx((0.5, 0.5), abs=0.005) + + def test_transform_composition_is_left_to_right(self): + assert apply(parse_transform("translate(10 0) scale(2)"), (5.0, 5.0)) == ( + 20.0, + 10.0, + ) + assert apply(parse_transform("scale(2) translate(10 0)"), (5.0, 5.0)) == ( + 30.0, + 10.0, + ) + + def test_rotate_about_a_point(self): + x, y = apply(parse_transform("rotate(90)"), (1.0, 0.0)) + assert (x, y) == pytest.approx((0.0, 1.0), abs=1e-9) + + +class TestLengthUnits: + """Percentages and CSS units are legal in every geometry attribute. + + Models use them freely, `width="100%"` most of all, so the raw attribute + cannot be passed to float(). + """ + + @pytest.mark.parametrize( + "value,expected", + [ + ("12", 12.0), + ("12px", 12.0), + ("100%", 400.0), + ("50%", 200.0), + ("72pt", 96.0), + ("1in", 96.0), + ("1pc", 16.0), + ("25.4mm", 96.0), + ("2.54cm", 96.0), + ("1em", 16.0), + ("5e1", 50.0), + (" 12 ", 12.0), + ], + ) + def test_units_resolve(self, value, expected): + assert length(value, reference=400.0) == pytest.approx(expected) + + @pytest.mark.parametrize("value", ["garbage", "", None, "12 34", "calc(1px)"]) + def test_unparseable_falls_back_to_the_default(self, value): + assert length(value, reference=400.0, default=7.0) == 7.0 + + @pytest.mark.parametrize( + "body", + [ + '', + '', + '', + '', + '', + '', + '', + ], + ) + def test_extraction_survives_units(self, body): + assert extract_shapes(parse_svg(svg(body, "0 0 400 300"))) is not None + + def test_percentages_resolve_against_the_matching_axis(self): + """Width percentages use viewport width, height percentages the height.""" + shape = extract_shapes( + parse_svg( + svg('', "0 0 400 200") + ) + )[0] + # Normalisation divides by the longer side, 400. + assert shape.bbox == pytest.approx((0.0, 0.0, 0.5, 0.25), abs=0.001) + + +class TestStructure: + """Structural checks over the extracted shapes.""" + + def structure(self, name, expected_wheels=2): + return analyse_structure( + extract_shapes(parse_svg(fixture(name))), expected_wheels=expected_wheels + ) + + def test_complete_scene_passes_everything(self): + assert self.structure("good_pelican_bike").score == 1.0 + + def test_wheels_drawn_as_paths_score_the_same(self): + """Representation must not change the structural verdict.""" + assert self.structure("wheels_as_paths").score == 1.0 + + def test_wheels_placed_with_use_score_the_same(self): + """A model that factors its SVG must not be punished for it. + + Nothing inside `` is painted and every `` is, so the + wheels are at the positions the `` elements give them. + """ + report = self.structure("wheels_via_use") + assert report.score == 1.0 + assert [round(w.centroid[0], 3) for w in report.wheels] == [0.325, 0.675] + + def test_definitions_alone_draw_nothing(self): + """`` content is a template, not paint. Without a `` to + instantiate it there is no geometry on the canvas at all.""" + source = svg( + '' + '', + ) + assert extract_shapes(parse_svg(source)) == [] + + def test_background_rect_is_not_mistaken_for_a_rider(self): + report = self.structure("good_pelican_bike") + assert report.rider is not None + assert report.rider.area < 0.35 + + def test_animal_head_is_not_counted_as_a_wheel(self): + """Any circle passes a roundness test, so wheels are found by row.""" + assert len(self.structure("good_pelican_bike").wheels) == 2 + + def test_vehicle_without_rider_loses_only_the_rider_checks(self): + report = self.structure("bike_no_bird") + assert not report.checks[-1].passed + assert 0.0 < report.score < 1.0 + assert len(report.wheels) == 2 + + def test_rider_without_vehicle_scores_zero(self): + assert self.structure("bird_no_bike").score == 0.0 + + def test_scribble_scores_zero(self): + assert self.structure("bad_scribble").score == 0.0 + + def test_a_pedal_below_the_axle_does_not_displace_a_single_wheel(self): + """With one expected wheel, rows are ranked by bottom edge. + + A pedal drawn below the axle has a lower centre than the wheel, but + the wheel's rim is what reaches the ground. + """ + source = svg( + '' + '' + '', + view_box="0 0 400 300", + ) + wheels = find_wheels( + significant_shapes(extract_shapes(parse_svg(source))), expected=1 + ) + assert len(wheels) == 1 + assert wheels[0].radius == pytest.approx(0.15, abs=0.01) + + def test_frame_check_spans_the_outermost_wheels(self): + """A third wheel-like shape in the row must not shrink the span. + + The frame connects the outermost hubs, and anything sitting between + them, another wheel candidate included, counts as bridging geometry. + """ + source = svg( + '' + '' + '' + '' + '' + '' + '', + view_box="0 0 500 300", + ) + shapes = significant_shapes(extract_shapes(parse_svg(source))) + assert len(find_wheels(shapes, expected=2)) == 3 + report = analyse_structure(shapes, expected_wheels=2).to_dict() + assert report["checks"]["frame_spans_wheels"] is True + + def test_two_real_wheels_beat_a_pedal_when_one_wheel_is_expected(self): + """A bicycle scored as a unicycle must fail wheel_count. + + The expected count is a preference and the ground is a fact, so the + two wheels win the row ranking and the check then fails honestly, + rather than the pedal being adopted as the single expected wheel. + """ + source = svg( + '' + '' + '' + '', + view_box="0 0 400 300", + ) + shapes = significant_shapes(extract_shapes(parse_svg(source))) + wheels = find_wheels(shapes, expected=1) + assert len(wheels) == 2 + report = analyse_structure(shapes, expected_wheels=1) + assert report.to_dict()["checks"]["wheel_count"] is False + + def test_a_rider_bigger_than_the_wheels_does_not_hide_them(self): + """A body drawn larger than the wheels must not become the anchor. + + Wheels are found as a row, so the largest round shape in the drawing + cannot displace them however big it is. + """ + body_radius, wheel_radius = 58, 52 + source = svg( + f'' + f'' + '' + '' + '' + f'', + "0 0 400 300", + ) + report = analyse_structure(extract_shapes(parse_svg(source)), expected_wheels=2) + assert len(report.wheels) == 2, "the body displaced the wheels" + assert report.score == 1.0 + + def test_wheels_are_taken_from_the_lower_row(self): + """Two round shapes level at the top must not be read as the wheels.""" + source = svg( + '' + '' + '' + '' + '', + "0 0 400 300", + ) + wheels = analyse_structure( + extract_shapes(parse_svg(source)), expected_wheels=2 + ).wheels + assert len(wheels) == 2 + assert all(w.centroid[1] > 0.5 for w in wheels), "picked the upper row" + + def test_a_hub_does_not_outrank_the_rim_it_sits_in(self): + """The bug that made the same drawing score differently on two machines. + + A hub is concentric with its rim but too small to share a row with it, so + it forms a valid row of one at the same height. The row comparison used + raw floats, so the two rows differed in the last bits of `cy` and the tie + was settled before the radius was consulted: a hub of radius 0.02 beat a + rim of 0.1248, the axle line jumped up the canvas and the rider fell + outside it. Locally the drawing scored 1.000 and on the deployed Space + 0.333. + """ + source = svg( + '' + '' + '' + '' + '', + "0 0 400 400", + ) + report = analyse_structure(extract_shapes(parse_svg(source)), expected_wheels=1) + assert len(report.wheels) == 1 + assert report.wheels[0].radius > 0.1, "the hub was taken for the wheel" + assert report.rider is not None + assert report.score == 1.0 + + def test_row_choice_survives_last_bit_noise_in_height(self): + """Whatever wins must not be decided by floating-point rounding.""" + import math as _math + from dataclasses import replace as _replace + + source = svg( + '' + '' + '', + "0 0 400 400", + ) + shapes = significant_shapes(extract_shapes(parse_svg(source))) + radii = set() + for nudge in (0, 1, -1, 2, -2): + moved = [] + for shape in shapes: + cy = shape.centroid[1] + for _ in range(abs(nudge)): + cy = _math.nextafter(cy, _math.inf if nudge > 0 else -_math.inf) + moved.append(_replace(shape, centroid=(shape.centroid[0], cy))) + wheels = find_wheels(moved, expected=1) + radii.add(round(wheels[0].radius, 4) if wheels else None) + assert len(radii) == 1, f"the choice moved with float noise: {radii}" + + def test_single_wheel_vehicle_drops_the_pair_checks(self): + """Scoring a unicycle against bicycle geometry punished a good drawing.""" + names = { + c.name for c in self.structure("bike_no_bird", expected_wheels=1).checks + } + assert "wheels_apart" not in names + assert "wheel_count" in names + + +class TestGate: + """The full deterministic admission check.""" + + @pytest.mark.parametrize("name", sorted(p.stem for p in FIXTURES.glob("*.svg"))) + def test_corpus_admission_matches_ground_truth(self, name): + result = run_gate(fixture(name), PELICAN_TERMS) + assert result.passed is (name in ADMISSIBLE), result.codes + + def test_off_canvas_is_distinguished_from_blank(self): + assert "content_off_canvas" in run_gate(fixture("offcanvas")).codes + assert "blank_canvas" in run_gate(fixture("blank")).codes + + def test_passing_gate_returns_the_raster_for_reuse(self): + """The submission is rasterised once and the judge reuses the bytes.""" + assert run_gate(fixture("good_pelican_bike"), PELICAN_TERMS).png is not None + + +class TestTasks: + """The subject-by-vehicle grid.""" + + def test_only_the_canonical_pair_is_not_held_out(self): + not_held_out = [t.task_id for t in all_tasks() if not t.held_out] + assert not_held_out == ["pelican_bicycle"] + + def test_held_out_only_excludes_the_canonical_pair(self): + assert all(t.held_out for t in all_tasks(held_out_only=True)) + + def test_sampling_is_reproducible_under_a_seed(self): + assert sample_task(seed=7).task_id == sample_task(seed=7).task_id + + def test_prompt_forbids_the_two_known_cheats(self): + prompt = make_task("pelican", "bicycle").prompt + assert "raster" in prompt and "text" in prompt + + def test_forbidden_terms_cover_subject_and_vehicle(self): + terms = make_task("pelican", "bicycle").forbidden_terms + assert "pelican" in terms and "bicycle" in terms and "bird" in terms + + def test_unknown_task_id_names_the_valid_ones(self): + with pytest.raises(ValueError, match="pelican_bicycle"): + task_from_ids(["pelican_bicycel"]) + + +class StubVisionClient: + """A judge endpoint that answers from a script instead of a network.""" + + def __init__(self, caption="a pelican riding a bicycle", answer=True, fail=False): + self.model = "stub" + self.caption = caption + self.answer = answer + self.fail = fail + self.calls = 0 + + async def complete_with_image( + self, prompt, png_bytes, *, schema=None, max_tokens=400 + ): + self.calls += 1 + if self.fail: + raise RuntimeError("judge is down") + if schema is None: + return self.caption + keys = list(schema["properties"]) + return "{" + ", ".join(f'"{k}": {str(self.answer).lower()}' for k in keys) + "}" + + +class TestScoring: + """Composition of the layers into one reward.""" + + task = make_task("pelican", "bicycle") + + def evaluate(self, name, judge=None): + return asyncio.run( + evaluate_submission(f"```svg\n{fixture(name)}\n```", self.task, judge) + ) + + def test_rejected_submission_scores_zero(self): + assert self.evaluate("cheat_raster_datauri").reward == 0.0 + + def test_offline_mode_scores_on_structure_alone(self): + evaluation = self.evaluate("good_pelican_bike") + assert not evaluation.judge_enabled + assert evaluation.reward == pytest.approx(evaluation.structure_score) + + def test_judge_failure_does_not_raise_the_reward(self): + """Otherwise breaking the judge becomes a winning strategy.""" + broken = self.evaluate( + "good_pelican_bike", VisionJudge(StubVisionClient(fail=True)) + ) + assert not broken.judged + assert broken.reward < self.evaluate("good_pelican_bike").reward + + def test_perfect_judge_gives_full_marks(self): + evaluation = self.evaluate( + "good_pelican_bike", VisionJudge(StubVisionClient(answer=True)) + ) + assert evaluation.reward == pytest.approx(1.0) + + def test_blind_caption_that_misses_the_subject_costs_marks(self): + named = self.evaluate( + "good_pelican_bike", + VisionJudge(StubVisionClient(caption="a pelican on a bicycle")), + ) + unnamed = self.evaluate( + "good_pelican_bike", VisionJudge(StubVisionClient(caption="some shapes")) + ) + assert unnamed.reward < named.reward + + def test_not_riding_is_penalised_but_not_erased(self): + judge = VisionJudge(StubVisionClient(answer=True)) + riding = self.evaluate("good_pelican_bike", judge) + # Same drawing, judge says nothing is riding. + not_riding = asyncio.run( + evaluate_submission( + f"```svg\n{fixture('good_pelican_bike')}\n```", + self.task, + VisionJudge(_NotRidingClient()), + ) + ) + assert 0.0 < not_riding.semantic_score < riding.semantic_score + + def test_truncated_reply_is_reported_as_such(self): + evaluation = evaluate_deterministic( + "", self.task + ) + assert evaluation.gate.codes == ["truncated_svg"] + + +class _NotRidingClient(StubVisionClient): + """Answers yes to everything except the riding posture.""" + + async def complete_with_image( + self, prompt, png_bytes, *, schema=None, max_tokens=400 + ): + if schema is None: + return self.caption + keys = list(schema["properties"]) + parts = [f'"{k}": {"false" if k == "riding_posture" else "true"}' for k in keys] + return "{" + ", ".join(parts) + "}" + + +class TestRewardConsistency: + """The rubric tree and the evaluation must never disagree.""" + + @pytest.mark.parametrize("judge_enabled", [True, False]) + def test_weights_come_from_one_place(self, judge_enabled): + structure_weight, semantic_weight = component_weights(judge_enabled) + assert structure_weight + semantic_weight == pytest.approx(1.0) + + @pytest.mark.parametrize("name", sorted(ADMISSIBLE)) + def test_rubric_matches_evaluation_reward(self, name): + environment = PelicanSvgEnvironment( + subject="pelican", vehicle="bicycle", enable_judge=False + ) + environment.reset() + observation = environment.step(PelicanSvgAction(response=fixture(name))) + expected = build_rubric(judge_enabled=False)(None, observation) + assert observation.reward == pytest.approx(expected) + + +class TestEnvironment: + """The Gym-shaped contract.""" + + def environment(self, **kwargs): + kwargs.setdefault("enable_judge", False) + return PelicanSvgEnvironment(**kwargs) + + def test_reset_returns_a_prompt_and_no_reward(self): + observation = self.environment().reset(seed=3) + assert observation.prompt + assert observation.reward is None + assert not observation.done + + def test_step_before_reset_raises(self): + with pytest.raises(RuntimeError, match="reset"): + self.environment().step(PelicanSvgAction(response=fixture("blank"))) + + def test_step_ends_the_episode(self): + environment = self.environment(subject="pelican", vehicle="bicycle") + environment.reset() + observation = environment.step( + PelicanSvgAction(response=fixture("good_pelican_bike")) + ) + assert observation.done + assert observation.reward == pytest.approx(1.0) + assert environment.state.submitted + + def test_task_can_be_pinned_by_id(self): + """A benchmark run must ask every model the same question.""" + observation = self.environment().reset(task_id="capybara_unicycle") + assert observation.task_id == "capybara_unicycle" + assert observation.expected_wheels == 1 + + def test_truthy_strings_do_not_pass_the_checklist(self): + """Only a JSON true counts. A judge that answers "false" as a string + ignored the schema, and that must not read as approval.""" + from envs.pelican_svg_env.server.vision_judge import _parse_checklist + + questions = {"a": "?", "b": "?", "c": "?", "d": "?"} + raw = '{"a": "false", "b": "no", "c": "0", "d": true}' + assert _parse_checklist(raw, questions) == { + "a": False, + "b": False, + "c": False, + "d": True, + } + + def test_partial_pin_is_honoured(self): + """Pinning one dimension must not be silently ignored.""" + assert self.environment(subject="octopus").reset().task_id == "octopus_bicycle" + observation = self.environment(vehicle="skateboard").reset() + assert observation.task_id == "pelican_skateboard" + assert observation.expected_wheels == 2 + + def test_partial_pin_works_at_reset_time_too(self): + environment = self.environment() + assert environment.reset(subject="octopus").task_id == "octopus_bicycle" + assert environment.reset(vehicle="skateboard").task_id == "pelican_skateboard" + + def test_pin_takes_precedence_over_sampling(self): + environment = self.environment(sample_tasks=True, subject="octopus") + assert environment.reset(seed=5).task_id == "octopus_bicycle" + + def test_held_out_only_rejects_a_pin_that_resolves_canonical(self): + """held_out_only is a hard promise, not a default.""" + with pytest.raises(ValueError, match="held-out"): + self.environment(held_out_only=True, subject="pelican").reset() + with pytest.raises(ValueError, match="held-out"): + self.environment(held_out_only=True).reset(task_id="pelican_bicycle") + + def test_held_out_only_accepts_a_held_out_pin(self): + environment = self.environment(held_out_only=True, subject="capybara") + assert environment.reset().task_id == "capybara_bicycle" + + def test_default_task_is_the_original_prompt(self): + """A default that changes per reset makes two runs incomparable.""" + assert self.environment().reset().task_id == "pelican_bicycle" + assert self.environment().reset(seed=99).task_id == "pelican_bicycle" + + def test_seeded_sampling_is_reproducible(self): + first = self.environment(sample_tasks=True).reset(seed=11).task_id + second = self.environment(sample_tasks=True).reset(seed=11).task_id + assert first == second + + def test_held_out_only_never_serves_the_original_prompt(self): + ids = { + self.environment(held_out_only=True).reset(seed=s).task_id + for s in range(25) + } + assert "pelican_bicycle" not in ids + assert len(ids) > 1 + + def test_violations_are_surfaced_to_the_caller(self): + environment = self.environment(subject="pelican", vehicle="bicycle") + environment.reset() + observation = environment.step( + PelicanSvgAction(response=fixture("cheat_raster_datauri")) + ) + assert "embedded_raster" in observation.violations + assert observation.reward == 0.0 diff --git a/tests/envs/test_pelican_svg_training_contract.py b/tests/envs/test_pelican_svg_training_contract.py new file mode 100644 index 000000000..226c2ecf6 --- /dev/null +++ b/tests/envs/test_pelican_svg_training_contract.py @@ -0,0 +1,306 @@ +# SPDX-License-Identifier: BSD-3-Clause + +"""The contract `examples/pelican_svg_grpo.py` relies on, checked without TRL. + +The training script does exactly three things against the environment: pin the +task, submit a completion, read `result.reward`. If any of those drift, a GRPO +run either crashes hours in or, worse, trains happily against a reward that is +zero everywhere and learns nothing. + +This runs on CPU with no network and no TRL installed, so it can guard the +training path in CI. It deliberately does not import trl: that would make the +whole module skip on a machine without it, which is most CI machines, and the +contract being guarded is the environment's, not the trainer's. +""" + +from __future__ import annotations + +import asyncio +import math +import pathlib +import sys +from types import SimpleNamespace + +import pytest + +pytest.importorskip("resvg_py", reason="resvg-py is not installed") + +from envs.pelican_svg_env.models import PelicanSvgAction +from envs.pelican_svg_env.server.pelican_svg_environment import PelicanSvgEnvironment +from envs.pelican_svg_env.server.scoring import component_weights + +# A minimal drawing that clears the gate: two level wheels, a frame between +# them, and a body above. Enough to exercise the reward the trainer sees. +DRAWABLE = ( + '' + '' + '' + '' + '' + '' + '' + "" +) + + +@pytest.fixture +def env(): + """The environment as the training script configures it: no judge.""" + return PelicanSvgEnvironment(enable_judge=False) + + +def roll(env, completion: str) -> float: + """One rollout the way the reward function does it.""" + env.reset(task_id="pelican_bicycle") + return env.step(PelicanSvgAction(response=completion)).reward + + +class TestRewardContract: + def test_reward_is_a_finite_float_in_range(self, env): + reward = roll(env, DRAWABLE) + assert isinstance(reward, float) + assert math.isfinite(reward) + assert 0.0 <= reward <= 1.0 + + def test_a_drawing_earns_more_than_garbage(self, env): + """Without this ordering GRPO has no signal to follow.""" + assert roll(env, DRAWABLE) > roll(env, "I cannot draw that.") + + def test_garbage_scores_exactly_zero(self, env): + assert roll(env, "no svg here") == 0.0 + + def test_reward_is_deterministic_without_a_judge(self, env): + """A reward that wobbles run to run makes the curve unreadable.""" + assert len({roll(env, DRAWABLE) for _ in range(5)}) == 1 + + def test_offline_reward_is_the_structural_score(self, env): + """With no judge, structure carries the whole weight.""" + env.reset(task_id="pelican_bicycle") + observation = env.step(PelicanSvgAction(response=DRAWABLE)) + assert observation.reward == pytest.approx(observation.structure_score) + assert component_weights(judge_enabled=False) == (1.0, 0.0) + + def test_judge_never_runs_when_disabled(self, env): + """Training must not make a paid API call per rollout.""" + env.reset(task_id="pelican_bicycle") + observation = env.step(PelicanSvgAction(response=DRAWABLE)) + assert observation.judged is False + assert observation.semantic_score == 0.0 + + +class TestTruncationTrap: + """The failure mode that quietly wastes a GPU run. + + `max_completion_length` too small cuts every SVG off mid-document. Every + rollout then scores zero, GRPO sees no variance, and the run burns hours + learning nothing. The environment has to name that case so it is visible in + the logs rather than looking like a model that cannot draw. + """ + + def test_truncated_completion_is_named(self, env): + env.reset(task_id="pelican_bicycle") + observation = env.step( + PelicanSvgAction(response=DRAWABLE[: len(DRAWABLE) // 2]) + ) + assert observation.reward == 0.0 + assert "truncated_svg" in observation.violations + + def test_absent_svg_is_a_different_code(self, env): + env.reset(task_id="pelican_bicycle") + observation = env.step(PelicanSvgAction(response="Sorry, I will not.")) + assert "no_svg_in_response" in observation.violations + + +class TestTrainingTaskIsPinned: + def test_every_reset_serves_the_same_prompt(self, env): + """The trainer builds one dataset row per episode from one prompt.""" + prompts = {env.reset(task_id="pelican_bicycle").prompt for _ in range(5)} + assert len(prompts) == 1 + + def test_the_pinned_task_is_the_original_prompt(self, env): + observation = env.reset(task_id="pelican_bicycle") + assert observation.subject == "pelican" + assert observation.vehicle == "bicycle" + assert observation.held_out is False + + +class TestStructuralProxyIsGameable: + """Documents, in a test, the thing the training run is looking for. + + The structural layer is satisfiable without drawing an animal, which is why + the training script probes with the judge before and after. Pinning it here + means nobody later mistakes the proxy for the task. + """ + + def test_a_bare_vehicle_already_scores_most_of_the_structural_reward(self, env): + bare = ( + '' + '' + '' + '' + '' + '' + "" + ) + reward = roll(env, bare) + assert reward > 0.5, "a vehicle with no rider should still score well" + assert reward < 1.0, "but it must not score full marks" + + +_EXAMPLES = pathlib.Path(__file__).resolve().parents[2] / "examples" +if str(_EXAMPLES) not in sys.path: + sys.path.insert(0, str(_EXAMPLES)) + + +class _FakeStream: + """An async iterable of chat-completion chunks.""" + + def __init__(self, deltas): + self._deltas = deltas + + def __aiter__(self): + async def gen(): + for delta in self._deltas: + yield SimpleNamespace(choices=[SimpleNamespace(delta=delta)]) + + return gen() + + +class _FakeClient: + def __init__(self, deltas): + self._deltas = deltas + + async def chat_completion(self, **kwargs): + assert kwargs.get("stream") is True + return _FakeStream(self._deltas) + + +def _delta(content=None, reasoning_content=None): + return SimpleNamespace(content=content, reasoning_content=reasoning_content) + + +def _args(): + return SimpleNamespace(no_stream=False, max_tokens=100, temperature=0.7) + + +class TestReasoningModelStreams: + """A reasoning model streams its thinking apart from its answer. + + `delta.reasoning_content` carries the chain of thought while `delta.content` + stays empty. Reading only `content` makes a model that is still thinking look + like a model that refused, and a model that spent its whole budget reasoning + look like a model that cannot draw. + """ + + def run(self, deltas): + from pelican_svg_eval import generate + + return asyncio.run( + generate( + _FakeClient(deltas), + asyncio.Semaphore(1), + "fake/model", + "draw", + 1, + _args(), + ) + ) + + def test_answer_and_thinking_are_kept_apart(self): + out = self.run( + [ + _delta(reasoning_content="let me think"), + _delta(content=""), + ] + ) + assert out["reply"] == "" + assert out["reasoning_chars"] == len("let me think") + assert out["error"] is None + + def test_thinking_only_is_reported_as_an_exhausted_budget(self): + """Not as a refusal, which is what reading `content` alone suggests.""" + out = self.run([_delta(reasoning_content="thinking " * 20)]) + assert out["reply"] == "" + assert out["error"] is not None + assert "reasoning" in out["error"] and "max-tokens" in out["error"] + + def test_a_plain_model_with_no_thinking_still_works(self): + out = self.run([_delta(content="" + assert out["reasoning_chars"] == 0 + assert out["error"] is None + + +class TestEvalHoldsNoIdleConnection: + """The environment connection must not span the generation phase. + + Holding an idle WebSocket across a long generation gets it closed by a proxy + ("ConnectionClosedOK") and every generated sample is lost with it. Cheap to + reintroduce by tidying generation back inside the `with` block, and expensive + when it happens, so it is pinned here. + """ + + def test_scoring_opens_the_connection_after_generation(self): + source = (_EXAMPLES / "pelican_svg_eval.py").read_text() + assert source.index("outputs = await asyncio.gather") < source.index( + "with PelicanSvgEnv(base_url=args.env_url) as env:" + ) + + +def _proxy_gap(): + """Load the detector without importing trl, which CI does not have.""" + source = (_EXAMPLES / "pelican_svg_grpo.py").read_text() + fn = source[ + source.index("def report_proxy_gap") : source.index("def main() -> None:") + ] + namespace: dict = {} + exec(fn, namespace) + return namespace["report_proxy_gap"] + + +class TestProxyGapDetector: + """Did the policy learn the task, or just the measurable part of it? + + Pinned with the real numbers from a Qwen3-1.7B run: 80 GRPO steps lifted the + held-out structural score from 0.381 to 0.643 while the judged semantic score + stayed at 0.014, meaning the judge still recognised nothing. An earlier + version of the check used absolute thresholds and missed that case by under + 0.002, so it now compares the size of the two gains. + """ + + def test_fires_on_the_run_that_slipped_past_the_old_thresholds(self, capsys): + _proxy_gap()( + {"structure": 0.381, "semantic": 0.0023, "gate_failures": 6}, + {"structure": 0.6429, "semantic": 0.0139, "gate_failures": 2}, + ) + out = capsys.readouterr().out + assert "WARNING" in out + assert "reward hacking" in out + + def test_stays_quiet_when_both_layers_rise_together(self, capsys): + _proxy_gap()( + {"structure": 0.38, "semantic": 0.10, "gate_failures": 6}, + {"structure": 0.64, "semantic": 0.45, "gate_failures": 2}, + ) + out = capsys.readouterr().out + assert "WARNING" not in out + assert "moved together" in out + + def test_says_so_when_nothing_moved(self, capsys): + _proxy_gap()( + {"structure": 0.38, "semantic": 0.01, "gate_failures": 6}, + {"structure": 0.39, "semantic": 0.01, "gate_failures": 6}, + ) + out = capsys.readouterr().out + assert "did not move" in out + assert "WARNING" not in out + + def test_a_high_judged_score_is_not_flagged_even_on_a_big_structural_gain( + self, capsys + ): + """Structure can legitimately outpace a judge already scoring well.""" + _proxy_gap()( + {"structure": 0.30, "semantic": 0.60, "gate_failures": 4}, + {"structure": 0.90, "semantic": 0.62, "gate_failures": 1}, + ) + assert "WARNING" not in capsys.readouterr().out