Skip to main content

draughtsman

DOI

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.md carries the design, the measurements behind it, and the failure it exists to prevent. DECISIONS.md answers 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

A draughtsman figure of Whisper tiny: eleven named stages on two spines that meet — a log-mel spectrogram through a conv frontend into the audio encoder, token ids through the token embedding into masked self-attention, the two joining at a cross-attention block whose six heads are drawn as lanes, then a feed-forward, a box standing for three more decoder blocks, and vocabulary logits

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.0head.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's output: a horizontal strip of about forty nodes, one pixel tall at page width, individual operations unreadable

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's output: a clean vertically-stacked publication-styled figure of thirteen numbered layers, with empty shape fields

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

A draughtsman figure of a ResNet: nine named stages wrapped across three rows, with the residual identity drawn as a dashed arc around one opened-up block

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

draughtsman_nn-0.1.2.tar.gz (2.0 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

draughtsman_nn-0.1.2-py3-none-any.whl (112.1 kB view details)

Uploaded Python 3

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

Hashes for draughtsman_nn-0.1.2.tar.gz
Algorithm Hash digest
SHA256 a5b7b0d8364ac2a446365c215933e3d23e2eab338116929d38fa583b31052c08
MD5 f19d0ed925c3fd60950c12b1e86cfb79
BLAKE2b-256 ebbbb9cdd2e5c3cd653acb7429d48b36db591aca3f1f75f3c6b98776b5d912fb

See more details on using hashes here.

Provenance

The following attestation bundles were made for draughtsman_nn-0.1.2.tar.gz:

Publisher: publish.yml on syncytium2/draughtsman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for draughtsman_nn-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 0498bf3589468cc3c5e3430b5dba79b4ab756ffd1b0d09961f2d34409c4e4d93
MD5 528ba741721661192578540387376c56
BLAKE2b-256 41f7e626e7c7c6abe5c73af7b3bcff3679790ec60e3de87b803fdedd0ea15b62

See more details on using hashes here.

Provenance

The following attestation bundles were made for draughtsman_nn-0.1.2-py3-none-any.whl:

Publisher: publish.yml on syncytium2/draughtsman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.1.3

2 files

This release

0.1.2 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page