diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index 8de31b90d0e..e593366e43e 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -493,18 +493,19 @@ jobs: # runs in development. Without this job its first build ever would be the # bootstrap deploy, after the merge. `sandbox do` exists only on Cloud Run, so # the job checks the image the way the local backend uses it: the harness - # renders a seaborn plot under the deployed rlimits, with no network and a - # read-only root filesystem, then a multi-script fixture whose probe must - # report no missing glyph (and a DejaVu-only control whose probe must), and - # the service boots and reports itself. The event-shape handling is the API - # job's in short form. + # renders a seaborn, a plotnine and an altair plot under the deployed + # rlimits, with no network and a read-only root filesystem, then a + # multi-script fixture whose probe must report no missing glyph (and a + # DejaVu-only control whose probe must), and the service boots and reports + # itself. The event-shape handling is the API job's in short form. renderer-image: name: Build renderer image and render through the harness runs-on: ubuntu-latest permissions: contents: read - # A cold build installs pandas, numpy, matplotlib and seaborn; 20 fails - # loudly instead of hanging on a slow package index. + # A cold build installs pandas, numpy, matplotlib, seaborn, plotnine and + # altair with vl-convert; 20 fails loudly instead of hanging on a slow + # package index. timeout-minutes: 20 steps: @@ -632,6 +633,106 @@ jobs: EOF tail -n 1 harness.out + # plotnine (step 1b of agentic/specs/261024-agent-all-libraries.md) runs in process like + # seaborn, with the same flags. It reads the plot's frame to look `aes` names up + # (FRAME_READERS in harness.py) and saves a figure that has no canvas, which the probe + # gives an Agg one: the probe's canvas is the assert for both. + - name: Render a plotnine plot through the harness + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + mkdir -p work-plotnine + printf 'x,y\na,1\nb,3\nc,2\n' > work-plotnine/data.csv + cat > work-plotnine/plot.py <<'EOF' + import os + + import pandas as pd + from plotnine import aes, geom_col, ggplot + + THEME = os.environ["ANYPLOT_THEME"] + df = pd.read_csv("data.csv") + plot = ggplot(df) + aes(x="x", y="y") + geom_col() + plot.save(f"plot-{THEME}.png", dpi=400, width=8, height=4.5, units="in", verbose=False) + EOF + docker run --rm --network none --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" \ + -v "$PWD/work-plotnine:/work" -w /work \ + -e ANYPLOT_THEME=light -e MPLBACKEND=Agg -e MPLCONFIGDIR=/tmp/mpl -e ANYPLOT_MPL_SEED=/opt/mplconfig \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e OPENBLAS_NUM_THREADS=1 -e OMP_NUM_THREADS=1 \ + -e ANYPLOT_RLIMIT_CPU_S=60 -e ANYPLOT_RLIMIT_FSIZE_MB=50 -e ANYPLOT_RLIMIT_AS_MB=1024 \ + anyplot-renderer:ci /app/.venv/bin/python -I -X disable-remote-debug /opt/anyplot/harness.py plot.py > work-plotnine/harness.out + grep -qxF 'HARNESS {"event": "start"}' work-plotnine/harness.out || { echo "::error::no HARNESS start line"; cat work-plotnine/harness.out; exit 1; } + grep -q '^HARNESS {"event": "end"' work-plotnine/harness.out || { echo "::error::no HARNESS end line"; cat work-plotnine/harness.out; exit 1; } + python3 - <<'EOF' + import json, struct + png = open("work-plotnine/plot-light.png", "rb").read() + assert png[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (3200, 1800), (width, height) + probe = json.load(open("work-plotnine/probe-light.json")) + assert probe["canvas"] == [3200, 1800], probe.get("canvas") + print(f"OK: plotnine plot-light.png {width}x{height}, {len(png)} bytes; probe canvas {probe['canvas']}") + EOF + tail -n 1 work-plotnine/harness.out + + # altair renders through vl-convert, a binary with an embedded V8 that reserves far + # more address space at start than it uses: it aborts under an RLIMIT_AS of 64 GiB + # and less (measured 2026-10-25, 1024 MB included). This run leaves + # ANYPLOT_RLIMIT_AS_MB out, as the altair row of agents/renderer/commands.py does; + # every other flag is the seaborn step's. The PIL block is the catalogue's padding + # to 3200 x 1800, which reopens the PNG. vl-convert draws no matplotlib figure, so + # the run writes no probe: the PNG's size and the HTML are the asserts. + - name: Render an altair plot through the harness + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + mkdir -p work-altair + printf 'x,y\na,1\nb,3\nc,2\n' > work-altair/data.csv + cat > work-altair/plot.py <<'EOF' + import os + + import altair as alt + import pandas as pd + from PIL import Image + + THEME = os.environ["ANYPLOT_THEME"] + df = pd.read_csv("data.csv") + chart = alt.Chart(df).mark_bar().encode(x="x:N", y="y:Q").properties(width=740, height=400) + chart.save(f"plot-{THEME}.png", scale_factor=4.0) + + TW, TH = 3200, 1800 + _img = Image.open(f"plot-{THEME}.png").convert("RGB") + _w, _h = _img.size + if _w > TW or _h > TH: + raise SystemExit(f"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}") + if _w < TW or _h < TH: + _canvas = Image.new("RGB", (TW, TH), "#FAF8F1") + _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2)) + _canvas.save(f"plot-{THEME}.png") + + chart.save(f"plot-{THEME}.html") + EOF + docker run --rm --network none --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" \ + -v "$PWD/work-altair:/work" -w /work \ + -e ANYPLOT_THEME=light -e MPLBACKEND=Agg -e MPLCONFIGDIR=/tmp/mpl -e ANYPLOT_MPL_SEED=/opt/mplconfig \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e OPENBLAS_NUM_THREADS=1 -e OMP_NUM_THREADS=1 \ + -e ANYPLOT_RLIMIT_CPU_S=60 -e ANYPLOT_RLIMIT_FSIZE_MB=50 \ + anyplot-renderer:ci /app/.venv/bin/python -I -X disable-remote-debug /opt/anyplot/harness.py plot.py > work-altair/harness.out + grep -qxF 'HARNESS {"event": "start"}' work-altair/harness.out || { echo "::error::no HARNESS start line"; cat work-altair/harness.out; exit 1; } + grep -q '^HARNESS {"event": "end"' work-altair/harness.out || { echo "::error::no HARNESS end line"; cat work-altair/harness.out; exit 1; } + python3 - <<'EOF' + import struct + png = open("work-altair/plot-light.png", "rb").read() + assert png[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (3200, 1800), (width, height) + html = open("work-altair/plot-light.html", encoding="utf-8").read() + assert "vega" in html, "plot-light.html holds no Vega chart" + print(f"OK: altair plot-light.png {width}x{height}, {len(png)} bytes; plot-light.html {len(html)} characters") + EOF + tail -n 1 work-altair/harness.out + # The file conversion inside the image (harness.py --convert): every format the # page sends, with the image's slimmed pyarrow (owner decision of 2026-10-15), # its calamine and NumPy, the guard's preload, and the sandbox's environment. diff --git a/agentic/specs/261024-agent-all-libraries.md b/agentic/specs/261024-agent-all-libraries.md new file mode 100644 index 00000000000..69eaaebae6c --- /dev/null +++ b/agentic/specs/261024-agent-all-libraries.md @@ -0,0 +1,669 @@ +# Feature: "Use with my data" for all 15 catalogue libraries + +> **Status (2026-10-25):** plan, in progress. Step 1a (the runtime seam) is open as +> PR #12404. Step 1b (plotnine and altair) is implemented on its branch and waits for +> 1a's merge; see "What 1b built" under step 1b. The deploy serves matplotlib and seaborn: +> plotnine and altair have runtimes after 1b, but `AGENT_LIBRARIES` keeps its default +> `matplotlib,seaborn` until each one's matrix run passes. Every other library in this +> document is planned, not shipped. + +## Metadata + +run_id: `session_01WbjVqBweiXjUZKp626ZFWm` +prompt: `Rebuild the AI chat mode so that generating works not only for matplotlib and seaborn but for all 15 libraries; the biggest part is that the Cloud Run renderer can also render JavaScript, R and Julia. Plan it, start implementing, get second opinions, test in local Docker where the cloud session has no gcloud access, and ask when something is not canonical.` + +## Feature Description + +The agent network ("Use with my data", `agents/`) adapts a catalogue implementation +to a user's table, renders it in a Cloud Run sandbox, reviews it and ships the plot. +Phase 1 serves matplotlib and seaborn. This plan takes the network to the other 13 +libraries of the catalogue: six more Python libraries (plotnine, altair, pygal, +lets-plot without a browser; plotly and bokeh with Chromium), five JavaScript +libraries through the browser harness (Chart.js, D3, ECharts, Highcharts, MUI X), R +(ggplot2) and Julia (Makie). + +The design already fixes the shape ([Agent network design](../../docs/concepts/agent-network.md), +"Render", "Phase 2"): later languages add `RuntimeAdapter`s, not backends; each runtime +reuses the catalogue's CI render command; the renderer image carries the toolchains; a +library is enabled through `AGENT_LIBRARIES` once its eval matrix reaches 80 %. Where +the design leaves room, this plan names the choice and marks it as an owner question +(see "Open questions"). + +What the code does today, and what has to change: + +| Layer | Today | Change | +|---|---|---| +| `agents/anyplot/render/runtimes/` | one class, `PythonRuntime`, built in three places (`pipeline.py`, `theme_render.py`, `render/__init__.py`) | a registry `runtime_for(library)`; `RUNTIME_LIBRARIES` replaces `normalise.SUPPORTED_LIBRARIES` in the settings validator | +| `agents/renderer/wire.py`, `executor.py` | `language: Literal["python"]`; `build_argv` hard-codes `python -I harness.py plot.py` | a fixed command per language in one shared table (`agents/renderer/commands.py`), chosen by the renderer from `(language, library)`, never taken from the request; the harness gets an exec mode that sets the limits and supervises the language's own command | +| `agents/renderer/Dockerfile` | Python 3.14 venv with matplotlib and seaborn, Noto fonts | all eight Python extras, Chromium (one copy, shared by Playwright, kaleido and bokeh), Node 22 with the pinned `node_modules`, R 4.6 with ggplot2 4 and ragg, Julia 1.13 with the precompiled Makie depot | +| `agents/anyplot/code/` | Python AST everywhere: normaliser, regions, readiness, loader, validator, edits' drift check | the Python modules stay for the eight Python libraries (import surfaces per library, a save-target rule per library); JavaScript, R and Julia get a text-and-tree-sitter code profile each: markers, protected regions, loader, deny-list validator | +| `agents/anyplot/policy.py`, `prompts/adapter.md` | pandas wording (`df`, `pd.read_csv`) | a runtime section per language (how the data arrives, the placeholder spelling); the library rules already come from `prompts/library/.md` | +| `agents/evals/make_fixtures.py` | 10 specs x 2 libraries x 6 perturbations | the same 10 specs for every library; the 80 % gate per library is the owner's Vertex run | +| `.github/workflows/ci-image.yml`, `agents/renderer/cloudbuild.yaml` | one 20-minute image build | a toolchain base image built rarely (owner question 1) and a fast renderer image on top | + +## Requirements + +- Every catalogue pair (spec, library) whose code follows the catalogue conventions is + eligible, and the `.adapt()` button shows for it, once its library is in + `AGENT_LIBRARIES`. +- The renderer runs the catalogue's CI render command for each language verbatim, in the + same sandbox (no egress, read-only root, the harness's resource limits, the host + watchdog), with the same `job_id` idempotency and cancel path. +- The host gates R1 (outputs), R2 (PNG hardening) and R3 (canvas) apply to every + language. The probe gates (G3 to G12) stay matplotlib's: plotnine inherits them + through matplotlib; every other runtime renders without a probe, and `evaluate` + reports no probe line for it. +- The validator stays one guardrail layer among three (sandbox, loader substitution, + validator). For Python the AST validator gains the import surface of each library. For + JavaScript, R and Julia a deny-list over the parse tree refuses network, process, + filesystem and reflection routes before the code runs. +- The exported code runs unchanged next to `data.csv` in the language's own tool + (`python plot.py`, `Rscript plot.R`, `julia plot.jl`, `node render.mjs plot.js` with + the harness, which the export names in its header). +- `AGENT_LIBRARIES` keeps its default `matplotlib,seaborn`; each further library is + switched on in the deploy after its eval matrix reaches 80 % (design rule, owner run). +- No change to the catalogue pipeline, the muix regeneration or `plots/`. + +## Relevant Files + +### Existing Files to Modify + +- `agents/anyplot/render/runtimes/__init__.py`: the registry (`runtime_for`, + `RUNTIME_LIBRARIES`, `LANGUAGE_OF`). +- `agents/anyplot/render/runtimes/python.py`: library-aware (`library` on the instance; + a per-library environment where a library needs one, such as `BOKEH_RESOURCES=inline`; + the validator and normaliser called with the library). +- `agents/anyplot/render/contract.py`: `RuntimeAdapter` gains `regions`, `readiness`, + `validate_adaptation`, `protected_drift`, `placeholder` and `export_run_line`, so the + pipeline stops importing the Python code modules directly. +- `agents/anyplot/render/__init__.py`, `agents/anyplot/pipeline.py`, + `agents/anyplot/theme_render.py`, `agents/anyplot/opening.py`, + `agents/anyplot/render/backends/local.py`, `agents/anyplot/render/backends/sandbox.py`, + `agents/evals/matrix.py` (the preflight), `agents/evals/stress.py`, + `agents/evals/ladder_lab.py`, `tests/unit/agents/runtime/test_service_flow.py` (the + `pipeline.PythonRuntime` monkeypatch): use the registry instead of `PythonRuntime()`; + backends resolve the runtime per job. +- `agents/anyplot/schemas.py` (`ArtifactName`, `artifact_names`), `api/routers/agent.py` + (`ARTIFACT_MEDIA_TYPES`), `api/agent_cases.py`, `app/src/sections/agent-chat/ResultCard.tsx`: + the code artifact's name per language instead of the literal `plot.py`. +- `agents/anyplot/settings.py`: `_known_libraries` checks the registry; the docstring + table. +- `agents/anyplot/code/validate.py`: `LIBRARY_IMPORTS` for plotnine (`plotnine`, + `mizani`), altair (`altair`, `vl_convert`), pygal (`pygal`, `cairosvg`), lets-plot + (`lets_plot`), plotly (`plotly`; never `kaleido`, which the run form does not call), + bokeh (`bokeh`); the walk of their public + submodules for `UNSAFE_SUBMODULES` and `UNSAFE_HELPERS`, pinned by the existing test. +- `agents/anyplot/code/regions.py`, `normalise.py`, `readiness.py`: the save statement + per library (`write_image`, `chart.save`, `render_to_png`, `ggsave`, `plot.save`) next + to `savefig`; the header and title rewrite unchanged; the canvas rewrite stays + matplotlib-only (plotnine through `ggsave(..., dpi=, width=, height=)` later). +- `agents/anyplot/render/harness.py`: exec mode (`--exec`): start line, limits, run the + language's command as a child, end line with the child's `rusage`; plot mode: the write + allowlist gains `plot-.html`. No spawn allowance: a browser never starts from + plot code (step 4 screenshots through the JavaScript harness instead). +- `agents/anyplot/render/gates.py`: `CODE_FILE` per language for the error line; + probe gates skipped without a probe. +- `agents/anyplot/code/export.py`: the run line per language. +- `agents/anyplot/policy.py`, `agents/anyplot/prompts/adapter.md`: the runtime section. +- `agents/renderer/wire.py`: `language: Literal["python", "javascript", "r", "julia"]`. +- `agents/renderer/executor.py`, `settings.py`: argv and env from the commands table; + per-language `RLIMIT_AS` (off for Chromium, Node and Julia, which reserve address + space far beyond their resident memory); `PATH` with the toolchain directories. +- `agents/renderer/Dockerfile`, `.dockerignore`, `agents/renderer/cloudbuild.yaml`, + `.github/workflows/ci-image.yml`: the toolchains, the context allowlist + (`package.json`, `package-lock.json`, `Project.toml`, `Manifest.toml`, + `automation/js-render/render.mjs`), the smoke renders per language. +- `automation/js-render/render.mjs`: sandbox mode (`ANYPLOT_DATA_CSV` or a `data.csv` + next to the snippet becomes `window.ANYPLOT_DATA`; `page.route` deny-all; no + `plot-.html` outside the catalogue pipeline unless asked; `NODE_MODULES` from + `ANYPLOT_NODE_MODULES`; the Chromium path from `PLAYWRIGHT_BROWSERS_PATH`). The CI + pipeline's behaviour stays byte-identical without those variables. +- `agents/evals/make_fixtures.py`, `agents/evals/eligibility.py`, `agents/evals/cases.py`: + every library. +- `app/src/pages/AgentChatPage.tsx`, `app/src/sections/agent-chat/*`: syntax + highlighting and the download name per language (`plot.R`, `plot.jl`, `plot.js`, + `plot.tsx`); the library pills already carry every library. +- `docs/concepts/agent-network.md` ("Render", "Adapt and validate", Phase 2 status), + `agents/README.md` (settings, image, "Run it locally"), `agentic/docs/project-guide.md` + (ci-image jobs), `changelog.d/.md` per PR. + +### New Files to Create + +- `agents/renderer/commands.py`: `LANGUAGE_COMMANDS`, the one table of code file name, + argv and env per language, read by the executor, the local Docker backend and the + runtimes. No ADK import. +- `agents/anyplot/render/runtimes/javascript.py`, `r.py`, `julia.py`: the three + `RuntimeAdapter`s. +- `agents/anyplot/code/profiles/javascript.py`, `r.py`, `julia.py`: markers, protected + regions (theme switch, theme tokens, the save statement, the placeholder), the loader + statement, the normaliser (header strip, save target), the readiness scan + (convention markers, security) and the deny-list validator per language. +- `agents/anyplot/code/treesitter.py`: the parser wrapper (`tree-sitter`, + `tree-sitter-javascript`, `tree-sitter-typescript`, `tree-sitter-r`, + `tree-sitter-julia`, all pinned in the `agents` extra), with the query helpers the + three deny lists share. +- `agents/renderer/toolchain.Dockerfile` (owner question 1): Ubuntu 24.04 with R from + rocker's layout and Posit binaries, Python 3.14 managed by uv, Node 22, Playwright + Chromium, Julia 1.13 and the precompiled depot; published to GHCR by a workflow that + runs on changes to the lock files. +- `agents/evals/fixtures/cases/--/` for the 13 libraries + (generated). +- `tests/unit/agents/code/test_profiles_*.py`, `tests/unit/agents/renderer/test_commands.py`, + `tests/unit/agents/runtime/test_runtimes.py`; renderer-image smoke scripts under + `tests/image/` per language. + +## Implementation Plan + +IMPORTANT: Execute every step in order, top to bottom. Steps 1a to 5 are one PR each, +seven PRs in all (the Fable review of 2026-10-24 split the former step 1 and put R +before JavaScript; see "Second opinion"); step 6 lists the tests those PRs carry, not a +PR of its own. Steps 1b to 5 each end with the library's fixtures generated, +the eligibility sweep's numbers in the PR, and the matrix run documented as an owner +task; a library is enabled in the deploy only after that run. For R, Julia and +JavaScript the ten fixture specs must first carry the Data block (owner task per step: +regenerate them through the pipeline's regen), because the readiness scan blocks a file +without the marker and the matrix would otherwise have a handful of cases. + +### 1a. Foundation: the runtime seam, with no behaviour change + +- Add `agents/renderer/commands.py` with the command table keyed by `(language, + library)`: the code file name, the argv (`python`: the current command; `javascript`, + `r`, `julia`: the CI commands through the harness exec mode), the environment, the + resource limits that differ from the defaults (`RLIMIT_AS` off for the JavaScript and + Julia rows, `RLIMIT_NPROC` off for the JavaScript row alone; a screenshot post-step for + bokeh and plotly, step 4) and the + render p95 the repair reserve uses (`RENDER_P95_S` becomes a runtime attribute). The + executor builds its argv from it; the library in the request is checked against the + closed set of `core.constants` and only selects a row; the assertion that no `--write` + or `--allow-egress` reaches a sandbox stays. +- Harness exec mode: `harness.py --exec -- ` prints the start line, sets the + limits as today, runs the command with `subprocess.run` (the limits are inherited), + prints the end line from `resource.getrusage(RUSAGE_CHILDREN)` and exits with the + child's code. The start line comes before the child starts, so the launcher retry, + the host watchdog and the kill path keep working. Python plots keep the in-process + plot mode and the probe. +- Registry: `runtimes/__init__.py` maps every library of `core.constants` that has a + runtime to an instance; `runtime_for(library, cpu_seconds=)`; `RUNTIME_LIBRARIES` is + its key set (matplotlib and seaborn only in this step). The settings validator refuses + a library without a runtime, as today. +- `RuntimeAdapter` grows: `placeholder`, `regions`, `readiness`, `security`, + `adaptation`, `apply_plan`, `restore_protected`, `new_literals`, `error_summary` (the + per-language error grammar: class and line, never the message), `export` (the run + line), `code_artifact` (the artifact name) and `render_p95_s`. + The pipeline's direct uses of the Python modules go through it; so do + `agents/evals/matrix.py` (the preflight), `stress.py`, `ladder_lab.py`, and the + `pipeline.PythonRuntime` monkeypatch in `tests/unit/agents/runtime/test_service_flow.py`. +- Backends resolve the runtime per job (`runtime_for(job.library)`) in `render`, while + `run_data` and `convert` stay on the Python runtime; the local Docker backend's argv + and the sandbox stub follow. +- The artifact name becomes a wire field: `schemas.ArtifactName` gains `plot.R`, + `plot.jl`, `plot.js` and `plot.tsx`, `artifact_names` takes the runtime's code artifact, + `ARTIFACT_MEDIA_TYPES` in `api/routers/agent.py` and `api/agent_cases.py` carry the + media types, and `ResultCard.tsx` reads the name from the version instead of the + literal `plot.py`; the download, the feedback bundle and `get_current_code` follow. +- `wire.RenderRequest.language` widened; `gates.evaluate` without a probe; the renderer + refuses a language whose toolchain the image does not carry (`503 runtime_unavailable`, + from a startup check of the commands table, reported in `/status`). +- Tests, docs and the changelog fragment; the deploy's `_LIBRARIES` unchanged; the + behaviour of matplotlib and seaborn is byte-identical (the catalogue corpus tests pin it). + +### 1b. plotnine and altair + +- `PythonRuntime(library=...)`: `LIBRARY_IMPORTS` gains `plotnine` and `mizani`, + `altair` and `vl_convert`, each after a review of its public submodules + (`REVIEWED_SUBMODULES`, `UNSAFE_SUBMODULES`, `UNSAFE_HELPERS`); `save` leaves + `BANNED_ATTRIBUTES` for a per-library rule (altair's `chart.save` and plotnine's + `plot.save` are the save statement, like `savefig`), the regions and the readiness + scan know each library's save statement, and the normaliser's header and title + rewrites run for both while its canvas rewrite stays matplotlib's (plotnine's + `ggsave(dpi=, width=, height=)` later). +- altair's PIL padding block (`Image.open(f"plot-{THEME}.png")` ... `_canvas.save(...)`, + 179 catalogue files): `PIL.Image` joins altair's import surface with the reads and + writes bound to the plot file by the harness's write allowlist; the normaliser keeps + the block. `plot-.html` writes pass the harness (allowlist) and the file stays + in the sandbox; the normaliser drops the HTML save from the run form (the export + keeps it). +- Harness: the write allowlist gains `plot-.html`; a `FRAME_READERS` entry for + `plotnine/mapping/_env.py: capture` (the spike's `call-stack is not that deep!`). +- Image: `uv sync` with `lib-plotnine` and `lib-altair`; the CI job smoke-renders one + catalogue file per library through the harness. +- Fixtures for both libraries; the eligibility sweep's numbers in the PR; the owner's + matrix run as a Todoist task. + +#### What 1b built (2026-10-25) + +- **A save rule per library.** `regions.SAVE_RULES` holds one `SaveRule` per library: + matplotlib and seaborn `savefig`; plotnine `save`, `ggsave` (the file is its second + positional or `filename=`) and `savefig` after `fig = plot.draw()`; altair `save` + with `fp`, the keywords `scale_factor`, `ppi`, `inline`, `embed_options` and + `format`, a second allowed target `f"plot-{THEME}.html"` and `.open` of the PNG for + the PIL block. `find_regions`, the normaliser's save-target rewrite, the readiness + scan and `edits.apply_plan`, `restore_protected` and `protected_drift` take the + library. The protected final save is the last module-level statement that writes + the PNG, because altair's files end on the HTML save and pad the PNG inside an `if`. + `normalise.SUPPORTED_LIBRARIES` is the rules' key set, and `PythonRuntime` serves + `PYTHON_LIBRARIES`: matplotlib, seaborn, plotnine and altair. +- **Validator.** `LIBRARY_IMPORTS` gains plotnine (the matplotlib stack, `plotnine`, + `mizani`) and altair (`altair`, `PIL.Image`). The walk of both public surfaces + (2026-10-24) and an adversarial pass over it (2026-10-25) added denied submodules + (`plotnine.data`, `watermark` and `animation`; `altair.datasets`, `jupyter`, + `utils.execeval`, `utils.server` and `utils.html`), re-exported modules (`quarto`, + jsonschema, narwhals, jinja2, vl_convert, pyarrow, polars and others), unsafe + helpers (plotnine's `save_as_pdf_pages`, `Environment` and every plot's + `.environment`; altair's image mark, `limit_rows`, `transformed_data`, + `topo_feature`, `UrlData` and the plugin and renderer registries), altair-only bans + (`from_dict`, `from_json`), the `pathlib.Path` and polars writers in `BANNED_IO`, + and `theme_matplotlib` as a settings consumer that reads no settings file. + `_check_save(rule)` replaces the savefig rule. +- **Harness.** The write allowlist gains `plot-.html`. `FRAME_READERS` gains + plotnine's `Environment.capture`, and `inspect.currentframe` answers its caller's + frame to a reader and None to everyone else (`frame_for_readers`), so both options + of the spike's table are taken. The probe gives plotnine's canvas-less figure an Agg + canvas. altair draws no matplotlib figure and writes no probe, so the host gates + alone judge it. +- **Renderer.** The image installs `lib-plotnine` and `lib-altair` and links + matplotlib's DejaVu Serif and DejaVu Sans Mono into `/usr/share/fonts`, which + vl-convert needs for `serif` and `monospace`; the altair row of the command table + runs without `RLIMIT_AS`; `ci-image.yml` renders a minimal plotnine and a minimal + altair plot through the harness in the built image. +- **Evals.** `make_fixtures.py` writes the ten specs for plotnine and altair (240 + generated cases in all); the smoke set gains `scatter-basic-plotnine-renamed` and + `scatter-basic-altair-renamed` (14 cases); `eligibility.py` sweeps all four + libraries. The readiness scan blocks none of the ten fixture specs for either + library. +- **Verified by hand.** The normalised catalogue `scatter-basic` files of plotnine (a + 3200x1800 PNG and the probe) and altair (the PNG and the HTML) render under the + guard with the test venv; `test_harness_guard.py` renders both where the libraries + are installed. + +#### Decisions that change or settle the text above + +| Text above | Built | Why | +|---|---|---| +| The normaliser drops altair's HTML save from the run form; the export keeps it | The HTML save stays in the run form: the validator admits exactly `f"plot-{THEME}.html"` as altair's second target, the harness allowlist takes the write, and the file stays in the sandbox, where nothing collects it | The export stays byte for byte the run form ("Fencing and exported code" in the design), which question 10 leaves to bokeh and plotly alone | +| The PIL block's reads and writes are bound to the plot file by the harness's write allowlist; the normaliser keeps the block | The block is kept, not stripped, and admitted by rule as well: `PIL.Image` (the module, not `ImageDraw` or `ImageFont`) in altair's import surface, `.open` only in altair's profile and only on the PNG, and the second `.save` (`_canvas.save(f"plot-{THEME}.png")`) checked by the save rule | The exported code runs unguarded on the user's machine, so the validator, not only the sandbox, holds the block to the plot file | +| Not covered | plotnine's expression strings are parsed and walked as code (every rule of the profile, the save and buffer-writer rules included): every string literal passed to `aes`, `after_stat`, `after_scale`, `stage`, `facet_wrap` or `facet_grid`, which take only literals the walk can read (a string, a number, a list of them, a nested expression call, a name bound once at module level to a string, `**` from a dict literal); `qplot` and `from_formula` are banned by name, `formula=` is a banned keyword on every call. The plot guard lets plotnine's `Environment.eval` run those strings (`CODE_BUILDERS`): what they reach stays refused there | plotnine evaluates a string that is not a column name with real builtins and the plot's namespace (survey of 2026-10-24), and patsy `exec`s Python while it parses a formula, which the plot guard would refuse anyway | +| `save` leaves `BANNED_ATTRIBUTES` for a per-library rule | `save` left `BANNED_IO`, where it was; the save rule refuses a save name it does not list as a `banned-call`, so `np.save` is refused by the rule, not by the set | Every save name (`SAVE_NAMES`) is checked in every profile, allowed or not; the DATA profile refuses all of them | +| Not covered | The probe attaches an Agg canvas to a figure that has none | plotnine builds its figure with `Figure()`, whose base canvas has no renderer; a PNG `savefig` switches it to Agg anyway, so the probe does the same first | +| The canvas rewrite stays matplotlib's | As planned: `SaveRule.canvas` is off for plotnine and altair, their readiness reasons carry a `canvas:` note, and their renders rely on the R3 gate | plotnine sizes its canvas on the save and altair through the chart's size times `scale_factor`; plotnine's own rewrite is next | +| `vl_convert` joins altair's import surface | `vl_convert` stays out | altair reaches it internally, and its own functions read files and open sockets for `` hrefs | +| Not covered | The adversarial pass over the import surveys (2026-10-25) closed four routes by name: an altair image mark whose `file://` href vl-convert's SVG renderer reads from disk in native code (`mark_image`, `Url*`, the `url=` keyword, `Chart.from_dict` and `from_json` in altair's profile); the narwhals frame `alt.limit_rows` hands out, whose `.implementation.to_native_namespace()` is the pyarrow module; VegaFusion's `transformed_data`; and plotnine's `.environment`, the captured namespace every plot carries, whose `.namespace["os"]` is the `os` module as a value. altair's `data_transformers.enable("json")` and the registries' `.register` are banned in its profile too; `np.save` is refused under every rule by its receiver; a save's `format` and `path` by position are findings | The surveys' walk missed them; each was shown live in the scratchpad before the ban | +| Not covered (Python keeps the default limits) | altair's command row runs without `RLIMIT_AS` | vl-convert's embedded V8 aborts at start under any `RLIMIT_AS` up to 64 GiB (measured on 2026-10-25 through the harness); its resident set is about 270 MiB, and the `MemAvailable` watchdog, `RLIMIT_CPU`, `RLIMIT_FSIZE` and the byte budget remain | + +#### Eligibility after 1b + +`uv run --extra agents python -m agents.evals.eligibility` on 2026-10-25; matplotlib +and seaborn are unchanged against main. + +| Library | Files | Eligible | Clean | Coupled | Blocked | +|---|---|---|---|---|---| +| matplotlib | 325 | 285 (87.7 %) | 15 | 270 | 40 | +| seaborn | 325 | 287 (88.3 %) | 18 | 269 | 38 | +| plotnine | 321 | 264 (82.2 %) | 58 | 206 | 57 | +| altair | 325 | 269 (82.8 %) | 57 | 212 | 56 | + +Why the plotnine and altair files are blocked, counted in files from the readiness +reasons (a file can have several causes): + +| Cause | plotnine | altair | +|---|---|---| +| One of the 13 map specs (altair's also carry URL literals and `topo_feature`) | 13 | 13 | +| Old-form file: no `THEME` block and a single-theme `plot.png` (altair's also `plot.html`) | 8 | 11 | +| A guard, path or `os` shape the normaliser does not strip yet: `sys.modules`, `importlib.util`, `import_module`, `SourceFileLoader`, a `pathlib` output directory or a target built from `__file__`, a bare `import sys`, `os` beyond `getenv` | 10 | 11 | +| `sklearn.datasets` for the example data | 8 | 8 | +| `.format` on a string | 4 | 5 | +| A module outside the surface: `contourpy` (2 each, one a map spec), `qrcode`; for altair also `circlify`, `zlib`, `PIL.ImageColor`, `statsmodels.regression`, `stats` and `tools`, and `vega_datasets` (a map spec) | 3 | 8 | +| A URL literal or `topo_feature` outside the map specs (`qrcode-basic`; altair's `chessboard-pieces` and `hexbin-map-geographic` too) | 1 | 3 | +| A string literal over 200 characters | 2 | 1 | +| `.query` | 1 | 1 | +| `formula=` | 1 | 0 | +| An aesthetic or facet argument the validator cannot read as a literal (a variable, a concatenation, `**mapping`; the adversarial pass of 2026-10-25) | 10 | 0 | +| An image mark (`mark_image`, `url=`), in a map spec | 0 | 1 | + +#### Next + +- Normaliser rules for the guard shapes it does not strip yet (the third row above: + about 10 files per library). +- plotnine's own canvas rewrite through `save(dpi=, width=, height=)` and `ggsave`, so + its canvas is exact before the R3 gate. +- The readiness coupling scan knows only matplotlib's limit and tick calls: plotnine's + `coord_cartesian(xlim=, ylim=)`, `scale_*_continuous(limits=, breaks=)` and altair's + `alt.Scale(domain=)` and axis `values=` go unflagged, so the adapter gets no `limits` + hint for them (the `x10` fixture cases measure exactly this). +- altair writes no probe, so the advisory gates (G3 to G12, the row-fidelity check G8 + among them) never run for it; whether a row count read from the Vega spec's data + should replace G8 is open. +- The normaliser blanks the catalogue title to `""`; code that divides by `len(title)` + (11 plotnine, 5 altair, 54 matplotlib files) fails a raw render with ZeroDivisionError + unless the adapter writes a new title. A non-empty placeholder would avoid it. +- A `plot-.html` of an altair chart with `disable_max_rows()` can hit + `RLIMIT_FSIZE` (heatmap-mandelbrot: 50 MB); nothing collects the file, so the + normaliser could drop the HTML save after all. +- `matrix.py` warns when `AGENT_LIBRARIES` does not enable a selected case's library + (the two new smoke cases need `AGENT_LIBRARIES=matplotlib,seaborn,plotnine,altair`); + whether the harness should enable the cases' libraries itself is open. +- The owner's matrix run per library on Vertex (question 7), then `_LIBRARIES` in + `agents/cloudbuild.yaml`. + +### 1c. pygal and lets-plot + +- The same shape: `pygal`, `cairosvg` and `cairocffi` (the harness imports `cairosvg` + before the guard is armed when the plot names pygal: the spike's `ctypes.dlopen`), + `lets_plot`; `render_to_png` and `ggsave` as save statements; lets-plot's + `shutil.move`/`rmtree` of its `lets-plot-images` directory (24 catalogue files) is + rewritten by the normaliser to `ggsave(..., path=".")`, the form the other 301 use. + The slim image needs `libcairo2` for cairosvg. + +### 2. R: ggplot2 (the exec-mode pilot) + +- Toolchain: R 4.6.1 as in `.github/actions/setup-r` (Posit snapshot 2026-10-20, the same + package list plus `readr`), `Rscript plot.R` through the exec mode; + `R_LIBS_SITE` read-only from the image; `HOME=/tmp` for R's temp files; a baked + `fc-cache` and `XDG_CACHE_HOME=/tmp` for ragg on the read-only root. +- Profile: placeholder `df <- load_user_data()`, loader `readr::read_csv("data.csv", + col_types = cols(...), na = c(""), show_col_types = FALSE)` with the dtype map; + protected regions: `THEME <- Sys.getenv("ANYPLOT_THEME", "light")`, the token + assignments, the `ggsave(filename = sprintf("plot-%s.png", THEME), ...)` call; error + grammar: R's `Error in : ` line (the call's function name, no line number, so the + repair gets the function and the stderr tail's `Calls:` chain); deny list (hygiene + over tree-sitter, see "Second opinion"): `system`, `system2`, `pipe`, `file`, `url`, + `socketConnection`, `download.file`, `source`, `sys.source`, `setwd`, `Sys.setenv`, + `get`, `do.call`, `match.fun`, `eval(parse(...))`, `.Internal`, `dyn.load`, + `requireNamespace`, `loadNamespace`, `::`/`:::` on `base` and `utils`, `library()` + outside the image's set, `file.*` writes, `readLines` on a URL. +- Fixtures (after the owner regenerates the ten specs' R files under the Data block), + the matrix, docs. + +### 3. JavaScript: Chart.js, D3, ECharts, Highcharts and MUI X + +- Toolchain: Node 22 under `/opt/node`, the repository's `package-lock.json` installed + to `/opt/anyplot/node_modules`, Playwright's Chromium under `/opt/pw-browsers` + (`PLAYWRIGHT_BROWSERS_PATH`), the harness at `/opt/anyplot/js-render/render.mjs`. +- `render.mjs` sandbox mode, every change behind an environment variable so the CI + pipeline sees no difference: `ANYPLOT_DATA_CSV` (path) parsed to typed records as + `window.ANYPLOT_DATA` (numbers, ISO dates, `null` for a missing cell, the dtype map + from the profile passed as `ANYPLOT_DATA_TYPES`); `page.route("**/*", deny)` so the + page can load nothing but the inlined bundle; `ANYPLOT_NO_HTML=1` leaves the + interactive page out; `ANYPLOT_NODE_MODULES`; a hard cap on the snippet's console + output (`consoleErrors` bounded). Chromium runs with `--no-sandbox` already (gVisor + as uid 0). The sandbox file is `plot.js` or `plot.tsx`, so the command carries + `--library=` from the request, checked against the closed set. +- `RLIMIT_AS` and `RLIMIT_NPROC` off for `javascript` in the commands table: V8 and + Chromium reserve virtual memory far beyond use, and one render runs 7 processes with + 77 tasks (processes plus threads), over the 64 of `RLIMIT_NPROC`, which counts + threads on Linux. `RLIMIT_CPU` is per process after exec, so the host's wall clock is + the real bound; the `MemAvailable` watchdog stays the memory control, and + `RLIMIT_DATA` is a candidate for a bound that lets reservations pass (unverified + under gVisor). +- Profile: placeholder `const data = loadUserData();`, loader + `const data = window.ANYPLOT_DATA;`; protected regions: the token lines that read + `window.ANYPLOT_TOKENS`/`window.ANYPLOT_THEME`, the `//# anyplot-orientation` + directive; the deny list over tree-sitter (hygiene, not the boundary, see "Second + opinion"): `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `Worker`, + `navigator.*`, `window.open`, `new Image().src`, `import()`/`require`, `eval`, + `Function`, `.constructor`/`__proto__`, computed access on `window`, `globalThis`, + `self`, `document` and `navigator`, `document.cookie`, `localStorage`, `postMessage`, + `