draughtsman
Readable architecture diagrams for PyTorch models. The tracer supplies the facts, an agent supplies the abstraction, and a coverage check proves nothing was silently dropped.
Status: the three stages work end to end on the model that prompted them.
SPEC.mdcarries the design, the measurements behind it, and the failure it exists to prevent.DECISIONS.mdanswers what the spec left open and records three places building found it mistaken — read that second, and before changing the trace layer.
Whisper tiny, drawn from the model card
Whisper tiny — speech recognition, encoder–decoder, two inputs. 37,184,640 parameters over 271 traced operations, log-mel spectrogram to vocabulary logits. The weights are random: this draws the architecture, not the trained model.
OpenAI's speech recogniser at its published dimensions, written out in PyTorch in
examples/gallery/whisper_tiny.py and traced
from that source — 80 mel bins, four encoder blocks against four decoder blocks, six
heads, sinusoidal audio positions against learned text positions, cross-attention in
every decoder block, and the output projection tied to the token embedding. Attention
is written out with explicit q/k/v projections, which is what Whisper itself does, so
the heads are a shape in the figure: nn.MultiheadAttention fuses into a single traced
node and takes its heads with it.
Most production ASR architectures are unpublished, so this is the one that can be drawn from a public description. It is also the model that broke two parts of the tool, and both fixes are what the tool now is.
trace took one input. It built a single dummy tensor, so every encoder–decoder,
two-tower and masked model was excluded — not by difficulty, by signature. Whisper's
forward wants the mel spectrogram and the token ids together:
draughtsman trace whisper_tiny:build_whisper_tiny \
--input-shape 1,80,3000 --dtype float32 \
--input-shape 1,12 --dtype int64 \
-o whisper/graph.json
--input-shape repeats, with an optional --dtype per input, and one shape still
produces byte-identical output, so nothing traced before this means anything different.
A model with several inputs has no singular {model.input_shape} fact: asking for one
raises rather than quietly describing half the input.
A tied weight was charged twice. Whisper's output projection is its token
embedding — one tensor reached through two prim::GetAttr nodes, both billed — and the
trace reported the model as having 54% more parameters than it has. Coverage was green
throughout: every node sat in exactly one stage, and the figure would have printed a
total this model does not have. A parameter is now charged to the earliest substantive
consumer in trace order, which also decides where it is drawn — the 19.9M-entry table
appears at the embedding, where a reader meets it, rather than at a matmul four hundred
nodes later. Fixed in tracing.py, pinned by
test_a_tied_weight_is_counted_once in tests/test_trace.py.
What a tracer alone gives you, on this model. torchview 0.2.7, on the same
build_whisper_tiny and called the way its own documentation calls it, draws Whisper
tiny as 74 boxes and 96 edges in a column 547 × 4,896 points tall — committed beside
the figure above, at twice that pixel density so it survives a zoom, as whisper/torchview.png.
Nothing in it is wrong. Every box is a real operation carrying its real shapes, and
asked for the depth this figure covers it gives 234 boxes at 2,088 × 14,688 pixels.
It is the correct answer to a different question, and there is no page it goes on.
The page puts the two in windows of the same
size; it is the clearest statement of what this repository is for.
Every other model, and what each was chosen to break, is in
examples/gallery/.
Why this exists
A 1,149-parameter model was drawn by five existing tools. Every one of them failed, and they failed in two families:
| tool | what it did |
|---|---|
| torchview | Traced the true op graph. Correct — and forty nodes of exp/clamp/div in a strip one pixel tall at page width. |
pytorch-graph (research_paper style) |
Clean, styled, and wrong — see below. |
| visualtorch | 993 × 13 pixels. Its renderers scale block height by channel count; this model has ≤ 8. |
| nn-SVG | Browser-only, no API. FCNN / LeNet / AlexNet styles, all linear stacks, aimed at 2D image CNNs. No branching. |
| Model Explorer (Google AI Edge) | Ingests PyTorch ExportedProgram. torch.export cannot export this model — data-dependent control flow. Doubly unavailable, and it is an interactive debugging viewer rather than a figure generator. |
The pytorch-graph result is the one that matters. It produced a clean
publication-styled figure of head.0 … head.12 and silently omitted the
max-pool, the mean over cells, the four difference-of-Gaussian kernels, the
bypass and the concat — which is to say, the architecture. Every shape field read
Input: (). The summary box asserted "Output Classes: Variable · End-to-end
trainable · GPU compatible."
torchview, 2419 × 123 px. Correct and unreadable — every operation is there, including forty nodes of kernel construction, and none of it can be seen.
pytorch-graph, research_paper style. Readable and wrong — a tidy thirteen-layer
stack that omits the pooling, the mean, all four kernels, the bypass and the concat.
Every shape field reads Input: (). This is the failure the repository is named for.
It enumerates registered nn.Module children. That model's architecture lives in
forward(). So the tool drew a plain thirteen-layer conv stack and called it the
model, and a reader would come away confident and wrong.
The gap, stated once
Tools either trace the computation graph — complete, unreadable — or
enumerate registered modules — readable, and blind to everything a forward()
does. Neither can decide which operations matter, because that is a judgement
about what a reader needs, not a fact about the graph.
That judgement is the only missing piece, and it is now cheap. Tony, 2026-09-01:
"except now we have very powerful coding agents. i'm fine with putting an agent in there for internal use."
So: trace for the facts, agent for the abstraction, and a mechanical check that the abstraction did not lose anything — because losing the architecture quietly is exactly what the existing tools do.
What it produces
Nine stages over 52 traced operations. Every quantity — 464 params, kernel 3,
16×32×32, 10 classes — is looked up from graph.json by node id at render
time; none of it is typed into the spec. The residual identity is a dashed arc
because it is a real fork in the traced graph. The other five blocks are
collapsed into two boxes, and the caption says so rather than letting the figure
imply the model is nine layers deep.
That figure and one for every other model are in examples/, each with the
graph.json it was measured from and the spec.json that arranged it.
Running it
export PYTHONPATH=examples/gallery # the models are written out here
draughtsman trace models:build_resnet --input-shape 1,3,32,32 -o graph.json
draughtsman abstract graph.json -o spec.json # prints the prompt; an agent answers it
draughtsman check spec.json graph.json # every traced node in exactly one stage
draughtsman render spec.json -o figure.svg
draughtsman render spec.json --icon 420x104 -o icon.svg # a mark, with no text
draughtsman ui examples/ # review every model in a browser
Icons — the figure at a size where nothing can be read
check refuses a figure whose type would print under its stated floor. That is
the right answer when the figure is going somewhere type can survive, and no
answer at all for a card, a tile or a favicon, where no type survives at any
size: in a 420×104 slot every figure here lands between 1.6px and 3.6px of body
type. The honest response is not a smaller floor but no text.
--icon WxH removes everything that cannot be read at that size and crops to what
is left — the labels, the legend, sub-pixel detail, and any stage that was only
text, together with the arrows that pointed at it. What survives is shape: the
sequence of stages, their relative bulk, and the merges and skips between them.
It also re-solves the layout, because layout.wrap is a page-fitting decision and
an icon is not on that page; both layouts are rendered and the one that comes out
larger in the slot wins.
It never crops to fill. An icon may letterbox, but a cropped net is a net with a stage missing — a false figure, at the one size where nobody can tell.
That runs against a clone with nothing else installed, and it reproduces the
committed graph.json — byte for byte on the torch it was traced with, and fact
for fact otherwise. The facts are the durable claim, and
tests/test_reproduces.py asserts them
by re-deriving the trace rather than by comparing bytes, because torch.jit.trace's
value names are not stable across releases
(DECISIONS.md correction 3).
What is measured is one torch at a time. CI installs the current CPU wheel across four Python versions, so that assertion is exercised against whichever torch that is on the day, not across a range — a matrix of one, four times over. "On any torch" is the reason the comparison is semantic instead of byte-exact and it is the design intent; it is not something this repository has yet measured, and the sentence used to claim it was. Earning it means pinning two or three torch minors in the workflow matrix, which is not a free change: the minors do not span 3.10 to 3.13 uniformly, so the matrix has to exclude as well as include.
Every model in
examples/gallery/ is written out in full in this repo — no
torchvision, no downloads, no pinned third-party version — so the whole pipeline
reproduces from here.
Installing
pip install -e . # check, render, ui — no dependencies at all
pip install -e ".[trace]" # ... and read a PyTorch model
pip install -e ".[dev]" # ... and run the tests
On PyPI this is draughtsman-nn, and everything else — the import, the
draughtsman command, this repository — keeps the unabbreviated spelling. PyPI's
draughtsman is an unrelated API Blueprint parser last released in 2020, so the
name had to move; nothing a reader types does. It is not published yet, so the
lines above are the only way in today. tests/test_dist_name.py holds the three
files that state the name to each other.
Runs on Python 3.10 through 3.13, and CI runs the whole suite on every one of
them — a range stated in three files and checked in tests/test_versions.py, so
the floor in pyproject.toml, the matrix in the workflow and this sentence
cannot drift apart.
The first line installs nothing but draughtsman. No torch, no graphviz, no system binary, no CDN at runtime — the layout engine and the SVG emitter are in this repo. A machine that only draws figures needs none of it, which is why the staleness test in CI can be an unconditional assertion rather than one that skips when a tool is missing.
That is checked rather than claimed: pip install -e . into an empty virtualenv,
then draughtsman render examples/gallery/resnet/spec.json, produces a file
byte-identical to the committed figure.svg. trace is the only verb that wants
torch, and without it says so in a sentence rather than a stack trace.
See examples/tube/ for the result on the model below, and for
where every number in it comes from.
The three stages, and why the split is the design
model ──▶ [1 TRACE] ──▶ graph.json ──▶ [2 ABSTRACT] ──▶ spec.json ──▶ [3 RENDER] ──▶ figure.svg
facts facts judgement judgement deterministic
(torch) (agent) (committed)
The agent never supplies a fact. Not a parameter count, not a shape, not a
kernel width. It supplies groupings, human names and topology. Where the figure
wants a number, spec.json carries a reference — {stage.params},
{node:n0149.constants.dilation} — and the renderer looks it up in graph.json
by node id. An agent that hallucinates a parameter count produces exactly the
confident-and-wrong figure pytorch-graph produced, and coverage would not catch it
because every node would still be covered.
Every traced node must be accounted for in exactly one stage. Not zero, not
two. A node may be marked elided explicitly, with a reason — a decision in a
diff, not a silent loss. It is precisely what pytorch-graph lacked — and it is the
FIRST of the assertions that make an agent safe here, not the only one. Four more
things have since been caught being confidently wrong while coverage was green:
see DECISIONS.md correction 5.
Coverage passing says nothing about whether the figure is any good. check says
so in its own output, so a green check is never read as a good figure.
draughtsman ui — the part coverage cannot do
The check ends by naming what it does not verify: whether the names are good, the
grouping is natural, the figure legible. That is a person's job, and ui is where
they do it — the figure, the coverage panel, and every traced node in one place,
with the grouping editable and the picture redrawing as you change it.
draughtsman ui examples/tube/spec.json # one model
draughtsman ui examples/ # every model under it
Point it at a directory and it finds one model per folder — a graph.json with
its spec.json beside it, which is the convention examples/tube/ already
follows. All models renders every figure onto one sheet, each with its
coverage state and aspect ratio, and clicking one opens it for editing. Unsaved
edits survive switching, and a model carrying them is marked in both the picker
and the sheet.
That sheet is a visual regression test. A layout defect in one model of many does
not announce itself in a passing check — coverage is about what was dropped, not
about what the picture looks like — and opening a tab per model to find it is how it stays
unfound. The half a machine can check is parametrised in tests/test_render.py,
so every committed model has its coverage and its figure's freshness asserted;
adding a folder adds a test.
Standard library only; it binds to localhost and writes the two paths you named.
Every picture it shows comes from the same render() the CLI calls. A
browser-side re-implementation would have been quicker and would have meant the
figure you judged was never the figure that shipped — so Save writes spec.json
and figure.svg together, byte-identical to the CLI's, and a test asserts it.
Save writes into the repo; Export takes a copy out. Export ▾ (⌘E) gives you
Copy SVG, Download SVG, and PNG at 1×, 2× or 4×. The SVG it hands over is the
exact string render() produced — the same bytes Save writes — rather than a
re-serialisation of what the browser is displaying. The PNG is rasterised from
that, on a white ground, because the figure ships no background of its own and a
PNG has no page to inherit one from. For print or LaTeX, prefer the SVG.
If coverage is failing the menu says so before you export. Exporting anyway is allowed — you may want it mid-edit — but it is never silent, because shipping a figure that omits operations the model performs is the exact failure in §2.
Arrangement is part of the judgement, so it lives in the spec. A layout
field takes orientation (lr across, tb down) and wrap (break the spine
into rows at this width); the header carries a control for each, with the figure's
size and aspect ratio beside them. Depth otherwise converts directly into width —
LeNet, ResNet and the transformer all rendered as 8:1 ribbons, which is the defect
this README criticises torchview for, arrived at more slowly. Wrapped, they are
2.7:1, 1.6:1 and 3.4:1.
A row break is refused where a long edge is still in flight, so U-Net — three skips spanning its whole depth — barely wraps. That is the honest answer rather than a break drawn through a skip.
Click a stage in the figure to select it; click nodes in the table to move them
into it. Coverage updates as you go, so a regrouping that drops an operation says
so before you save rather than after. The spec is a small readable document and
the UI never becomes the only way to edit it: there is a raw-JSON escape hatch,
and hand edits survive, because abstract refuses to overwrite a spec without
--force.
Two things this got wrong, and how they were caught
The value of this repository is not that its checks pass. It is that twice they passed while the thing they checked was wrong, and both times something else caught it. Both are worth a minute before you trust any figure it draws.
A check that ran where it could not see. tests/test_claims.py resolves each
claim against the branches it can find. GitHub Actions checks out shallow and
single-branch by default, so on CI every branch a claim named "did not exist" and
the board went red — for a reason that had nothing to do with the board. The
green runs before it were worse than the red one: the check could not see what it
was checking, and reported that as a failure of the subject rather than of
itself. A gate has to distinguish checked and wrong from could not check,
and this one could not. The fix is fetch-depth: 0 and it is three characters;
the finding is that nothing would have told us. See DECISIONS.md
correction 8.
A figure that disagreed with its own spec, silently. check validated a
glyph's scale against the two legal values and raised on anything else. It
validated the style field against nothing at all — so a spec asking for a style
that did not exist, or carrying a typo, rendered as the default block and passed
every assertion. The figure was not the spec, and the spec was not wrong enough
for anything to notice. Found by reading the schema, not by a test. The check now
names both valid styles and says what goes wrong: an unknown style would be
drawn as a block, and the figure would not be the spec.
Nine such corrections are written up in DECISIONS.md, and they are one
shape: a quantity with a single correct value, computed in two places and
allowed to disagree, or computed in one place and never checked. Coverage
cannot see any of them, because coverage answers a different question — was an
operation dropped.
The page
index.html is a
one-page site, served by GitHub Pages from this branch. It leads with whisper at
bleed width, sets torchview's view of the same model beside it, and then names every
option a spec can carry — glyph styles, chrome,
lanes, repeats, edge kinds, wrap, the legend, the print floor, icon mode and the
honesty declarations. It points at the committed figures in examples/ rather than
embedding copies, so it cannot drift from what the tool produces — change a figure and
the page changes with it. Its own type arithmetic is written into the stylesheet, and
the figures the column cannot hold — whisper, tube and transformer — say in
their captions what width they would need rather than letting a reader assume it was
solved. Below laptop width those three stop shrinking and scroll at their own size,
because a figure reduced to 2px type is not a smaller reading of it but none.
Checking a figure will be legible
check refuses a spec whose figure would print under its stated type floor.
tools/measure_type.py
answers the same question about a rendered file, at any width — a journal column, a
slide, a web page:
tools/measure_type.py --print 6in --floor 6pt examples/gallery/*/figure.svg
It reports unit_size x display_width / viewBox_width and exits 1 below the floor, so it
works as a gate. Three inputs decide that number and they are easy to get wrong together;
this repository got it wrong three different ways in one evening, and every one was
invisible to the eye and obvious to arithmetic. It refuses what it cannot measure — a
PNG, a missing viewBox, a relative font size — rather than reporting those as clean.
Working on this
Several Claude Code sessions have worked this repository at once.
CLAIMS.md records who holds which files and what is queued, and
tests/test_claims.py fails when it goes stale — a claim board nothing checks is
decoration, which is DECISIONS.md correction 5 applied to the
sessions themselves.
Licence
BSD-3-Clause.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file draughtsman_nn-0.1.2.tar.gz.
File metadata
- Download URL: draughtsman_nn-0.1.2.tar.gz
- Upload date:
- Size: 2.0 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a5b7b0d8364ac2a446365c215933e3d23e2eab338116929d38fa583b31052c08
|
|
| MD5 |
f19d0ed925c3fd60950c12b1e86cfb79
|
|
| BLAKE2b-256 |
ebbbb9cdd2e5c3cd653acb7429d48b36db591aca3f1f75f3c6b98776b5d912fb
|
Provenance
The following attestation bundles were made for draughtsman_nn-0.1.2.tar.gz:
Publisher:
publish.yml on syncytium2/draughtsman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
draughtsman_nn-0.1.2.tar.gz -
Subject digest:
a5b7b0d8364ac2a446365c215933e3d23e2eab338116929d38fa583b31052c08 - Sigstore transparency entry: 2731378362
- Sigstore integration time:
-
Permalink:
syncytium2/draughtsman@10f1da6de2b595ce75ebcbc67e3d949a32e7054a -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/syncytium2
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@10f1da6de2b595ce75ebcbc67e3d949a32e7054a -
Trigger Event:
push
-
Statement type:
File details
Details for the file draughtsman_nn-0.1.2-py3-none-any.whl.
File metadata
- Download URL: draughtsman_nn-0.1.2-py3-none-any.whl
- Upload date:
- Size: 112.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0498bf3589468cc3c5e3430b5dba79b4ab756ffd1b0d09961f2d34409c4e4d93
|
|
| MD5 |
528ba741721661192578540387376c56
|
|
| BLAKE2b-256 |
41f7e626e7c7c6abe5c73af7b3bcff3679790ec60e3de87b803fdedd0ea15b62
|
Provenance
The following attestation bundles were made for draughtsman_nn-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on syncytium2/draughtsman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
draughtsman_nn-0.1.2-py3-none-any.whl -
Subject digest:
0498bf3589468cc3c5e3430b5dba79b4ab756ffd1b0d09961f2d34409c4e4d93 - Sigstore transparency entry: 2731378685
- Sigstore integration time:
-
Permalink:
syncytium2/draughtsman@10f1da6de2b595ce75ebcbc67e3d949a32e7054a -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/syncytium2
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@10f1da6de2b595ce75ebcbc67e3d949a32e7054a -
Trigger Event:
push
-
Statement type: