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
+
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 = 'hi pelican '
+ 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["reply"] == " "
+ 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