draughtsman

Architecture figures for PyTorch models.

The tracer supplies the facts. An agent supplies the judgement — which operations to collapse into a stage and what to call it. It never supplies a number: every quantity in the figure is looked up from a trace of your model at render time.

A coverage check then proves no traced operation was silently dropped.

Three stages.

trace

Every node, shape and parameter count, from the model.

abstract

The agent groups and names. It writes no numbers.

render

Deterministic SVG. Quantities read back by node id.

What you can ask a figure to do.

Everything below is a field in spec.json — judgement, committed, and the same on any machine. None of it is a render-time flag, because a figure that comes out differently depending on who ran it cannot be checked against the model.

The tensor, drawn to scale — glyph

One axis tall, another wide, both from the same shape, so the area the eye reads is a real product. block is one rectangle; marks draws the axes as countable objects and turns to a labelled bar past about thirty, where counting stops; sheets takes a third axis and stacks it, and is the only style that may. scale chooses sqrt or the faithful linear, and the key names which.

No box at all — chrome

A box is right when a stage's content is words. It is wrong when the content is a drawing of the tensor: you get a rectangle around a rectangle competing for the same reading, and the eye settles on the larger. chrome set to none lets the tensor be the stage, and the colour family moves onto the glyph.

Parallelism and repetition — lanes, repeat

lanes names the channels of a bank the agent chose to draw as parallel, and check asserts there are exactly as many labels as the model has channels. repeat names the stages that draw one unit and draughtsman counts how many times they tile against the traced nodes — so a repetition the graph does not contain fails rather than draws.

Edges that say what they are — edges

A label, a dashed style for an identity or a skip, and untraced — an arrow the trace does not contain, drawn anyway with a written reason. A reader wants a sampled latent to depend on its mean and variance; the trace records only the shape read. That reason lands in a diff instead of in nobody's head.

Arrangement — orientation, wrap

Depth otherwise converts straight into width, which is the ribbon this page criticises other tools for. wrap breaks the spine into rows at a stated width, and a break is refused where a long edge is still in flight — so a net whose skips span its whole depth barely wraps, which is the honest answer rather than a row break drawn through a skip.

A number as a picture — meters

A quantity drawn as a bar rather than read as digits. The value is a reference the renderer resolves, never a number the agent typed. The label is also the series: every bar sharing one is drawn on a single scale across the figure, and bars in different series are never comparable, because parameters and frames have no common unit.

A key under the drawing — legend

One row per colour family actually drawn, each carrying its share of the traced operations and parameters — counted off the graph, not off the picture. Colour cannot carry proportion when a box is a collapsed stage rather than a layer, so the legend answers how much of this model is convolution as a fact instead of an impression. Off by default.

Where it is going — output

width states the size the figure will be printed at and min_type the type it must hold there. Layout solves against that budget, and check refuses a figure whose smallest type would land under the floor — naming the number, and how much narrower it would have to be. The type is the one thing that never gives.

At a size where nothing can be read — --icon

At a card or tile size no type survives at any point size, so the honest response is not a smaller floor but no text. render --icon WxH removes what cannot be read — labels, key, sub-pixel detail, any stage that was only text, and the arrows into it — and crops to what is left. It never crops to fill, because a cropped net is a net with a stage missing at the one size where nobody can tell. Every model as a mark, below.

Saying what was left out — elided, batch_axis, constants

A dropped node needs a written reason, so the loss is a decision in a diff. batch_axis declares which axis to stop drawing, and check refuses the declaration wherever the hidden number is not 1. constants is required before a spec may quote a traced constant the tracer warned it had baked out of a tensor.

Every model as a mark, at the size it will be used.

Each of these is render --icon 192x96 against the same committed spec that draws the figure above it — one by two inches, shown here at actual size. Nothing is hand-placed and nothing is redrawn: a mark is the figure with everything unreadable taken out.

Three of them work. --icon prints the scale it drew at, and that number predicts the verdict. The tool calls a mark readable at 0.25× and up and noise below 0.19×, with the pair between marked marginal — and both boundaries sit in the middle of a real gap in the measurements rather than against the nearest one. That is the whole reason they are those numbers: the readable marks stop at 0.2975× and the marginal pair starts at 0.2114×, which is 0.086 of empty range to put a line through.

It used to print that number and throw it away, so a mark nobody could see shipped as quietly as one anybody could. It now says which band it landed in and warns when that is not the top one. It is a warning and not a refusal, because seven of the ten here are under the line and ship anyway — a gate that fails most of its own corpus is one somebody turns off.

The first attempt at those bands was wrong, and the way it was wrong is the reason this page exists. They were set at 0.30× and 0.20× — read off a contact sheet that prints the scale to two places. lstm prints as 0.30× and is really 0.2975, so the boundary put the lowest mark anyone had confirmed readable on the wrong side of its own line, by 0.0025. Every model still rendered and nothing said a word. Rounding a number for a person to read and thresholding it for a machine to decide are different jobs, and one value doing both silently does the second one badly.

Nothing re-rendered a committed mark either, until now. tube's moved from 0.15× to 0.20× when its wrap changed for the banner at the top of this page — a mark crossing a band because of an edit aimed somewhere else — and a person caught it against that same contact sheet. CI now re-renders every committed mark and reads the slot back out of the file, so a mark states the size it was fitted to and the check needs no constant of its own.

The mlp figure reduced to a textless mark at 192 by 96 pixels.

mlp 0.40× reads

Reads, and says nothing. Boxes and arrows — nothing in it identifies the tool that drew it.

The lenet figure reduced to a textless mark at 192 by 96 pixels.

lenet 0.39× reads

The best mark here, and the only one that is not plain rectangles.

The lstm figure reduced to a textless mark at 192 by 96 pixels.

lstm 0.30× reads

Reads, slightly busy where the wrapped return crosses back.

The dual figure reduced to a textless mark at 192 by 96 pixels.

dual 0.21× marginal

Legible with nothing near it. The merge is already soft.

The tube figure reduced to a textless mark at 192 by 96 pixels.

tube 0.20× marginal

The model in the banner above, and it does not survive the shrink.

The resnet figure reduced to a textless mark at 192 by 96 pixels.

resnet 0.17× does not read

Flecks and hairlines.

The transformer figure reduced to a textless mark at 192 by 96 pixels.

transformer 0.15× does not read

Flecks and hairlines.

The vae figure reduced to a textless mark at 192 by 96 pixels.

vae 0.14× does not read

Flecks and hairlines.

The unet figure reduced to a textless mark at 192 by 96 pixels.

unet 0.12× does not read

Dirt on the glass.

The whisper figure reduced to a textless mark at 192 by 96 pixels.

whisper 0.11× does not read

Dirt on the glass.

LeNet wins because it loses the most. Its figure has nine stages and its mark keeps four: the image, the flatten, both dense layers and the class logits draw only text, so they go with the text. What is left reads as a bare conv stack and gives no hint the classifier head was ever there. That is a real loss of information, and it is exactly why the mark works — four large glyphs instead of nine cramped ones. As a figure it is wrong; as a mark it is the best of them. It is going on a card, so it is a mark.

One stated limit. --icon post-processes a rendered figure rather than re-solving the layout without labels, so boxes still carry the size of text nobody is drawing. The boxed nets pay for it and the glyph nets come out tight.

LeNet drawn as stacks of sheets with no boxes around them: six countable sheets at the first two stages, then a solid marked times sixteen.

sheets and chrome: none together. Six channels can be counted, so six sheets are drawn. Sixteen cannot, so it becomes one solid of the same depth carrying its number, and the stage is the tensor rather than a box with a small mark inside it. Solved to print at 6 inches — 8.74pt type.

A transformer drawn as a long horizontal ribbon of named stages, with a labelled dashed skip and a block marked as a repeated unit.

lanes and repeat, and the reason wrap exists. The agent named the unit; draughtsman tiled it against the traced nodes and counted. Its 1,277 units put detail type at about 10px here, and holding the page's body size would take 2,285px — the ribbon problem stated as a number rather than complained about. It declares no print width at all, because at this width there is none that clears the floor.

It refuses figures that are wrong.

Coverage asks whether an operation was dropped. Each of these asks something else, and each exists because a figure was wrong while coverage was green: an arrow the trace does not contain, a tied weight counted twice, a repeat count nobody verified, a glyph whose axes come from different tensors, a name painted over its own drawing, type too small to read at the size it will be printed, and an arrow whose path runs through a stage it has nothing to do with.

The last one was found by looking. A figure in this gallery drew one branch's output straight through the other branch on its way to a concatenate, and there is no such path in the trace — the confident-and-wrong figure this project convicts other tools of, in its own examples. Coverage was green, the type cleared its floor, and the committed SVG was exactly what the spec produced. Nothing had ever asked where an edge's own path goes. tools/edge_collisions.py is that look, mechanised.

The half a machine cannot check.

A green check says no operation was dropped. It says nothing about whether the names are good, the grouping natural or the figure legible — and check ends by saying so, so a green check is never read as a good figure. draughtsman ui is where a person does the other half: the figure, the coverage panel and every traced node in one place, the grouping editable, the picture redrawing as you change it.

Point it at a directory and All models renders every figure onto one sheet with its coverage state and aspect ratio — a layout defect in one model of many does not announce itself in a passing check, and opening a tab per model to find it is how it stays unfound. Every picture it shows comes from the same render() the CLI calls, so the figure you judge is the figure that ships.

A model for each way it could break.

How each model might break the tool was written down before it was traced. Two did — and what they cost is in DECISIONS.md, which is the most useful file here. Every model is written out in full in the repository — no torchvision, no downloads, no pinned third-party version — with the graph.json it was measured from and the spec.json that arranged it, so the whole pipeline reproduces from a clone.

A dual-branch 1-D network: two parallel branches over one input, rejoining at a concat, then a gate and a conv head.

25,989 parameters. Solved to print at 6 inches — 8.92pt type, so it stays readable in a double column. This is the figure the edge check was written against.