plotlet v0.6.2

For AI agents

The guide (users.md) ships inside the package; one short doc teaches an AI the whole library. Tell your assistant “run plotlet’s skill() and follow it” — or save it as a persistent skill to make it stick across sessions.

What makes one doc enough is the architecture under it, and this page demonstrates it with real output, executed on every build: every pipeline seam answers questions as structured data, correctness is verified by diffing bytes instead of trusting eyes, and the plot vocabulary extends when it runs out. (Contributing to plotlet itself? Give your tool the developers guidept.skill("developer").)

Inspect every seam

SymptomStage that owns itCall
Wrong data or mapping recordedjournal pt.to_journal(c).to_dict()["entries"]
Autoscale, palette or limits surprisingresolved IR pt.to_ir(c).resolve().to_dict()
What the finished plot meansSVG attributes data-plotlet-* in c.to_svg() (schema)
Something overlaps or clipslint from plotlet.lint import lint; lint(c)
Where exactly the chrome landedregions c.regions()
Grid or composition came out wronglayout diagram pt.layout_diagram(c)

Each fact lives at exactly one stage, so the answer also points at the fault: if the journal shows the wrong mapping, the script said the wrong thing; if the journal is right but the resolved IR surprises you, the surprise is a render decision — autoscale, palette, category order. The four sections below inspect this chart:

df = {"x": [1, 2, 3, 4], "y": [2, 4, 3, 5], "g": ["a", "a", "b", "b"]}

c = pt.chart(df, aes(x="x", y="y", color="g"), title="demo")
c.add_line()

The journal: what was recorded

Artist calls record; nothing draws until output is asked for. The journal is that record — which data, which mapping, which kwargs. Bulk data is hoisted into a separate table, so the entries stay readable. The journal also round-trips through JSON (pt.to_json / pt.from_json) and rebuilds to a byte-identical SVG.

pt.to_journal(c).to_dict()["entries"]
[
 {
  "op": "new_chart",
  "nid": 1,
  "args": [],
  "kwargs": {
   "data": {
    "$data": "d1"
   },
   "data_width": 524,
   "data_height": 322,
   "margin": {
    "top": 0,
    "right": 0,
    "bottom": 0,
    "left": 0
   },
   "mapping": {
    "x": "x",
    "y": "y",
    "color": "g"
   }
  }
 },
 {
  "op": "title",
  "nid": 1,
  "args": [
   "demo"
  ],
  "kwargs": {}
 },
 {
  "op": "line",
  "nid": 1,
  "args": [],
  "kwargs": {
   "mapping": {
    "x": "x",
    "y": "y",
    "color": "g"
   },
   "data": {
    "$data": "d1"
   }
  }
 }
]

The resolved IR: what render decided

Lowering the journal produces the FigureIR; resolving it fixes every decision render will make — autoscaled limits, palette assignment, category order — before any SVG exists. Here are two of those decisions: the y limits and the colors picked for the two lines.

panel = pt.to_ir(c).resolve().to_dict()["root"]["children"][0]

{k: panel["scales"]["y"][k] for k in ("kind", "lo", "hi")}
[(a["type"], a["opts"]["label"], a["_color"])
 for a in panel["state"]["artists"]]
{'kind': 'linear', 'lo': 1.85, 'hi': 5.15}
[('line', 'a', '#1f77b4'), ('line', 'b', '#ff7f0e')]

The SVG describes itself

Every artist group in the output carries data-plotlet-* attributes — plot type, series label, data ranges, scales. The finished figure declares what it means, with no source code in hand. The full schema is in AI attributes.

from c.to_svg()

<svg
   data-plotlet-version="0.6.2"
   data-plotlet-schema="2"
   data-plotlet-kind="layout"
   …>
<g
   data-plotlet-type="line"
   data-plotlet-index="0"
   data-plotlet-label="a"
   data-plotlet-color="#1f77b4"
   data-plotlet-n="2"
   data-plotlet-x-min="1"
   data-plotlet-x-max="2"
   data-plotlet-y-min="2"
   data-plotlet-y-max="4"
   …>

Where the chrome landed

c.regions() reports every piece of chrome — panel, spines, ticks, title, legend — as named bounding boxes. This is how a program answers “where did the title land?”

c.regions()
{'kind': 'rect', 'bbox': (34.0, 33.0, 524.0, 322), 'name': 'panel', 'meta': {}}
{'kind': 'segment', 'bbox': (34.0, 32.5, 524.0, 1.0), 'name': 'spine', 'meta': {'width': 1.0}}
{'kind': 'segment', 'bbox': (34.0, 354.5, 524.0, 1.0), 'name': 'spine', 'meta': {'width': 1.0}}
{'kind': 'segment', 'bbox': (33.5, 33.0, 1.0, 322), 'name': 'spine', 'meta': {'width': 1.0}}
{'kind': 'segment', 'bbox': (557.5, 33.0, 1.0, 322), 'name': 'spine', 'meta': {'width': 1.0}}
{'kind': 'text', 'bbox': (53.68268377130682, 364.5, 8.27099609375, 12.54296875), 'name': 'tick-x', 'meta': {'text': '1', 'size': 13, 'anchor': 'middle', 'rotate': 0}}
{'kind': 'text', 'bbox': (126.8749630089962, 364.5, 20.67431640625, 12.54296875), 'name': 'tick-x', 'meta': {'text': '1.5', 'size': 13, 'anchor': 'middle', 'rotate': 0}}
{'kind': 'text', 'bbox': (212.47056255918557, 364.5, 8.27099609375, 12.54296875), 'name': 'tick-x', 'meta': {'text': '2', 'size': 13, 'anchor': 'middle', 'rotate': 0}}
…

Overlap and clipping, as data

lint(c) turns “does anything overlap or clip?” into a list. Warnings, not errors — a flagged figure can still be intentional; the point is that a program can see the problem at all. Here five long labels crowd a narrow chart:

df = {"treatment": ["alpha-treatment", "beta-treatment",
                    "gamma-treatment", "delta-treatment",
                    "epsilon-treatment"],
      "response": [3, 5, 2, 6, 4]}

c = pt.chart(df, aes(x="treatment", y="response"),
             data_width=260, data_height=180)
c.add_bar()

from plotlet.lint import lint
lint(c)
["alpha-treatment","beta-treatment","gamma-treatment","delta-treatment","epsilon-treatment"]
overlap: tick-x ↔ tick-x at (57.25,193.50,99.51,12.54) — overlap 52.87x12.54px
overlap: tick-x ↔ tick-x at (97.15,193.50,119.70,12.54) — overlap 12.97x12.54px
overlap: tick-x ↔ tick-x at (97.15,193.50,119.70,12.54) — overlap 59.60x12.54px
overlap: tick-x ↔ tick-x at (155.44,193.50,103.12,12.54) — overlap 1.31x12.54px
overlap: tick-x ↔ tick-x at (155.44,193.50,103.12,12.54) — overlap 61.41x12.54px
overlap: tick-x ↔ tick-x at (198.68,193.50,116.63,12.54) — overlap 18.17x12.54px
overlap: tick-x ↔ tick-x at (198.68,193.50,116.63,12.54) — overlap 59.87x12.54px

The warnings name the colliding regions — tick-x against tick-x — so the fix is one call away:

c.xticks(rotation=90)
lint(c)
["alpha-treatment","beta-treatment","gamma-treatment","delta-treatment","epsilon-treatment"]
[]

The layout, drawn as a diagram

For composed figures, pt.layout_diagram(c) draws the layout decisions — panel rectangles, gaps, alignment — recovered entirely from the data-plotlet-* attributes of the finished SVG. It returns a normal Chart, so it renders and composes like one.

signal = {"x": [0, 1, 2, 3], "y": [0.0, 1.0, 0.5, 0.8]}
counts = {"cat": ["a", "b", "c"], "n": [4, 7, 3]}

a = pt.chart(signal, aes(x="x", y="y"), data_width=200, data_height=130)
a.add_line()
b = pt.chart(counts, aes(x="cat", y="n"), data_width=200, data_height=130)
b.add_bar()
g = pt.grid([[a, b]])

d = pt.layout_diagram(g)  # a Chart like any other — render or compose it
["a","b","c"]
200 × 130"(no title)"x: linear -0.15,3.15y: linear -0.05,1.05200 × 130"(no title)"x: category (category)y: linear 0,7.35

Verify, don't trust

Diff the bytes

A program can’t trust its eyes, but it can diff bytes. The same script produces the same SVG on every machine — text is drawn as paths from a bundled font, and there is no global state — so a committed baseline is an assertion, and a regression is a failing diff instead of a silent drift. plotlet’s own test suite is 270+ baseline SVGs compared byte for byte in CI. The reproducibility chapter shows the round trip.

Extend the vocabulary

A new plot type is three functions

The built-in vocabulary ends somewhere, and a machine author hits that edge often. A new plot type is three small functions — record (the call, as data), xdomain/ydomain (what autoscaling sees), draw (data to SVG, through the public draw.* API) — bundled into an ArtistSpec. Autoscale, gridlines, color cycling and the legend come for free. This is the complete artist from Extending, running here:

from plotlet.utils import to_list, pack_opts
from plotlet.draw import segment, circle

def record(data=None, x=None, y=None, color=None, label=None):
    return {"type": "lollipop", "xs": to_list(data[x]),
            "ys": to_list(data[y]),
            "opts": pack_opts(color=color, label=label)}

def xdomain(a): return a["xs"]
def ydomain(a): return list(a["ys"]) + [0]   # stems start at 0

def draw(a, ctx):
    color = a["opts"].get("color") or ctx.color
    y0 = ctx.y_scale(0)
    parts = []
    for x, y in zip(a["xs"], a["ys"]):
        px, py = ctx.x_scale(x), ctx.y_scale(y)
        parts.append(segment(px, y0, px, py, color=color, width=1.5))
        parts.append(circle(px, py, 5, fill=color))
    return "".join(parts)

pt.add_artist(pt.ArtistSpec(name="lollipop", record=record,
                            xdomain=xdomain, ydomain=ydomain, draw=draw))

df = {"x": [1, 2, 3, 4, 5], "y": [3, 7, 2, 9, 4]}

c = pt.chart(df, aes(x="x", y="y"), data_width=300, data_height=180)
c.add_lollipop()

The ~45 single-file artists in plotlet-extensions follow exactly this shape — one import registers the method on every chart.

Status

Where this is going

Honest status, in three tiers. Shipped: everything above — the pipeline seams, the SVG schema, byte-identical output, and the extension path (the artist registry, the draw.* API, Extending). Being proven now: an AI agent adding a new plot type end to end, unaided, and a full self-correction session on a realistic figure — each will be published here as a case study when it exists, not before. Open but unexercised: custom coordinate systems — the coordinate protocol is open, but Circular is the only coordinate system shipped so far.