Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/ci-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -586,15 +586,15 @@ jobs:
echo "$CHANGED_FILES"

# What the image is built from: its Dockerfile, the service package,
# the harness it copies, the dependency lock, README.md (copied next
# the harness and the R and Julia text probes it copies, the dependency lock, README.md (copied next
# to pyproject.toml), the catalogue's Julia project (the toolchain's
# Julia stage precompiles it), the catalogue's JavaScript harness and its
# lock (the toolchain's Node stage installs them) and the root .dockerignore
# (the build context); and the agents package's copy of the import allowlist
# and the JavaScript error grammar, which a step compares with the image, and
# the export page's wrapper and the API's preview relay, which steps probe in the
# image's Chromium.
RENDERER_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(agents/renderer/|agents/__init__\.py$|agents/anyplot/render/harness\.py$|agents/anyplot/render/convert\.py$|agents/anyplot/render/page\.py$|api/preview_relay\.py$|agents/anyplot/code/profiles/javascript/(import-allowlist\.json|errors\.py)$|tests/image/|tests/unit/agents/runtime/convert_fixtures\.py$|pyproject\.toml$|uv\.lock$|README\.md$|Project\.toml$|Manifest\.toml$|package\.json$|package-lock\.json$|automation/js-render/|\.dockerignore$|\.github/workflows/ci-image\.yml$)' || true)
RENDERER_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(agents/renderer/|agents/__init__\.py$|agents/anyplot/render/harness\.py$|agents/anyplot/render/convert\.py$|agents/anyplot/render/text_probe\.(R|jl)$|agents/anyplot/render/page\.py$|api/preview_relay\.py$|agents/anyplot/code/profiles/javascript/(import-allowlist\.json|errors\.py)$|tests/image/|tests/unit/agents/runtime/convert_fixtures\.py$|pyproject\.toml$|uv\.lock$|README\.md$|Project\.toml$|Manifest\.toml$|package\.json$|package-lock\.json$|automation/js-render/|\.dockerignore$|\.github/workflows/ci-image\.yml$)' || true)

if [[ "$EVENT_NAME" == "workflow_dispatch" && "$FORCE_RUN" == "true" ]]; then
echo "Manual trigger with force_run=true, will build the renderer image"
Expand Down Expand Up @@ -1067,6 +1067,12 @@ jobs:
width, height = struct.unpack(">II", png[16:24])
assert (width, height) == (3200, 1800), (width, height)
print(f"OK: R plot-light.png {width}x{height}, {len(png)} bytes")
# The row's text probe (R_TEXT_PROBE) left the drawn texts, which the harness made the probe.
import json
labels = {(label["kind"], label["text"]) for label in json.load(open("work-r/probe-light.json"))["labels"]}
assert ("title", "Größe – Überblick") in labels, labels
assert ("x tick label", "b") in labels and ("x axis label", "x") in labels, labels
print(f"OK: R probe with {len(labels)} drawn texts")
EOF
tail -n 1 work-r/harness.out

Expand All @@ -1084,12 +1090,12 @@ jobs:
set -euo pipefail
mkdir -p work-jl
python3 - > work-jl/row.flags <<'EOF'
from agents.renderer.commands import JULIA_WARMUP, command_for
from agents.renderer.commands import JULIA_TEXT_PROBE, JULIA_WARMUP, command_for

row = command_for("julia", "makie")
assert row.exec_deny and not row.rlimit_as, "the Julia row runs under the exec filter, without RLIMIT_AS"
assert "--compiled-modules=strict" in row.argv, row.argv
assert row.argv[-2:] == ("-L", JULIA_WARMUP), row.argv
assert row.argv[-4:] == ("-L", JULIA_WARMUP, "-L", JULIA_TEXT_PROBE), row.argv
for key, value in row.env.items():
print(f"-e\n{key}={value}")
EOF
Expand Down Expand Up @@ -1126,6 +1132,11 @@ jobs:
width, height = struct.unpack(">II", png[16:24])
assert (width, height) == (3200, 1800), (width, height)
print(f"OK: Makie plot-light.png {width}x{height}, {len(png)} bytes")
# The row's text probe (JULIA_TEXT_PROBE) left the figure's drawn texts, which the harness made the probe.
import json
kinds = {label["kind"] for label in json.load(open("work-jl/probe-light.json"))["labels"]}
assert {"x tick label", "y tick label"} <= kinds, kinds
print(f"OK: Makie probe with the kinds {sorted(kinds)}")
EOF
tail -n 1 work-jl/harness.out

Expand Down
84 changes: 66 additions & 18 deletions agents/anyplot/grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@

Two measurements, both code:

* `drawn_texts` reads the drawn texts the render's probe reports (`harness.drawn_labels`,
the `labels` field) by role, every one of them; `plot_texts` clips them into
`PlotShows.texts`, which the root reads fenced next to the result. The probe is written by
the code under test, so every value is checked for type and clipped; a probe without
`labels` (a renderer older than the field, or a failed text report) yields None, and a
run with requested texts then carries `UNVERIFIED_LINE` instead of a silent pass.
* `drawn_texts` reads the drawn texts the render's probe reports (the `labels` field) by
role, every one of them; `plot_texts` clips them into `PlotShows.texts`, which the root
reads fenced next to the result. Every library's render reports them since 2026-11-01:
matplotlib, seaborn and plotnine through `harness.drawn_labels`, pygal, altair and
lets-plot through `harness.install_text_probes`, the JavaScript libraries, plotly and bokeh
through the browser harness (`render.mjs`, `writeTextProbe`), ggplot2 and Makie through the
text probes their rows load (`text_probe.R`, `text_probe.jl`, `harness.collect_text_probe`).
The probe is written by the code under test, so every value is checked for type and
clipped; a probe without `labels` (a failed text report) yields None, which the root never
sees (`plugins/tool_safety.fence_pipeline_notes` drops it), and a run with requested texts
then carries `UNVERIFIED_LINE` instead of a silent pass.
* `expected_misses` checks each of the run's `expected_texts` (the texts and number formats
the user asked to see, verbatim) against all drawn texts, never the clipped summary. A
text (`Population 2025`, `Top 10`) must stand whole in one drawn text (case, spacing,
Expand All @@ -23,12 +28,13 @@
Each miss becomes a residual line, so the result says it honestly, and with
`AGENT_EXPECT_CHECK=repair` a measured finding the repair round gets.

A render without drawn texts (R, altair, pygal and lets-plot, which write no probe) is checked by the
reviewer instead where its runtime says so (`RuntimeAdapter.review_texts`, owner decision of
2026-10-29): the review request carries the requested texts, the reviewer reports each one it
cannot find in the image as a defect line under `EXPECT_LINE_ID` that quotes it, and
`reviewed_misses` reads those lines back. Only a render no readable review answered for keeps
`UNVERIFIED_LINE`.
`expected_misses` reads matplotlib's drawn texts only. Every other library is checked by the
reviewer instead (`RuntimeAdapter.review_texts`, owner decision of 2026-10-29): its probe reads
the texts from the library's own roles or chart model, which a plugin's own drawing is not in,
so it grounds the reply but never claims a requested text missing. The review request carries
the requested texts, the reviewer reports each one it cannot find in the image as a defect line
under `EXPECT_LINE_ID` that quotes it, and `reviewed_misses` reads those lines back. Only a
render no readable review answered for keeps `UNVERIFIED_LINE`.
"""

import re
Expand All @@ -48,6 +54,7 @@
"""One drawn text as the check reads it (`harness.LABEL_CHARS`): longer than any requested text."""
GROUP_LIMITS: dict[ShownGroup, int] = {
"title": 3,
"subtitle": 2,
"x axis label": 2,
"y axis label": 2,
"z axis label": 2,
Expand All @@ -60,13 +67,13 @@
"other texts": 6,
}
"""Distinct texts `PlotShows.texts` keeps per role: enough to quote a title or read a tick format, and
at most 62 texts of `MAX_SHOWN_TEXT_CHARS` (about 5 KB) next to the result's other fields."""
at most 64 texts of `MAX_SHOWN_TEXT_CHARS` (about 5 KB) next to the result's other fields."""
UNVERIFIED_LINE = (
"SC-04 ({theme}): the requested texts could not be checked, since the renderer reported no drawn texts "
"→ compare them with the image. Likely cause: a renderer without the probe's text report."
"SC-04 ({theme}): the requested texts could not be checked against the render "
"→ compare them with the image. Likely cause: no drawn texts to check them against and no readable review."
)
"""The residual line of a render with requested texts but no `labels` in its probe, unless a readable review
answered for them (`reviewed_misses`): never a silent pass."""
"""The residual line of a render with requested texts that neither matplotlib's drawn texts nor a readable review
answered for (`expected_misses`, `reviewed_misses`): never a silent pass."""
EXPECT_LINE_ID = {"text": "SC-04", "format": "SC-02"}
"""The rubric id a missing requested text (title, legend, label) or number format is reported under."""
_QUOTED = re.compile(r'["“”„«»]([^"“”„«»\n]+)["“”„«»]')
Expand Down Expand Up @@ -118,6 +125,7 @@
KIND_GROUPS: dict[str, ShownGroup] = {
"title": "title",
"suptitle": "title",
"subtitle": "subtitle",
"x axis label": "x axis label",
"y axis label": "y axis label",
"z axis label": "z axis label",
Expand All @@ -127,7 +135,9 @@
"z tick label": "z tick labels",
"annotation": "annotations",
}
"""The `PlotShows.texts` role of each probe `kind` (`harness._text_kinds` and `drawn_labels`'s tick labels)."""
"""The `PlotShows.texts` role of each probe `kind` (`harness._text_kinds` and `drawn_labels`'s tick labels); `subtitle`
is the text probes' (`harness.text_labels`, `render.mjs`, `text_probe.R`, `text_probe.jl`), a role of its own so a
subtitle is never trimmed with the other texts."""
TICK_GROUPS: tuple[ShownGroup, ...] = ("y tick labels", "x tick labels", "z tick labels")


Expand Down Expand Up @@ -194,6 +204,44 @@ def drawn_texts(probe: Any) -> DrawnTexts | None:
return DrawnTexts(labels, complete)


PROBE_ERRORS = frozenset(
{
"Error",
"TimeoutError",
"TypeError",
"ValueError",
"RangeError",
"ReferenceError",
"ParseError",
"KeyError",
"IndexError",
"AttributeError",
"RecursionError",
"MemoryError",
"OSError",
"RuntimeError",
"UnicodeDecodeError",
"JSONDecodeError",
"MethodError",
"FieldError",
"BoundsError",
"UndefVarError",
"ArgumentError",
}
)
"""The exception classes a failed probe names (`{"error": <class>}`: matplotlib's probe, a text probe, Julia's types
among them; R's probe names `Error`) that the attribution log keeps by name; any other value reads `other`, since the
code under test can write the probe."""


def probe_error(probe: Any) -> str | None:
"""The class a probe names for a report that failed, for the `pipeline_render` line: None when it names none."""
error = probe.get("error") if isinstance(probe, dict) else None
if error is None:
return None
return error if isinstance(error, str) and error in PROBE_ERRORS else "other"


def plot_texts(drawn: DrawnTexts | None) -> dict[ShownGroup, list[str]] | None:
"""`PlotShows.texts`: the drawn texts within `GROUP_LIMITS` and `MAX_SHOWN_TEXT_CHARS`, for the root's reply."""
if drawn is None:
Expand Down
Loading
Loading