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 guide — pt.skill("developer").)
Inspect every seam
| Symptom | Stage that owns it | Call |
|---|---|---|
| Wrong data or mapping recorded | journal | pt.to_journal(c).to_dict()["entries"] |
| Autoscale, palette or limits surprising | resolved IR | pt.to_ir(c).resolve().to_dict() |
| What the finished plot means | SVG attributes | data-plotlet-* in c.to_svg()
(schema) |
| Something overlaps or clips | lint | from plotlet.lint import lint; lint(c) |
| Where exactly the chrome landed | regions | c.regions() |
| Grid or composition came out wrong | layout 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)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)[]
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 itVerify, 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.