A step-through view of the engine. Pick a program, feed it inputs, and watch the register from whichever angle answers the question.
npm run dev # from the repo root: builds the WASM package, then serves
npm test # engine tests, smoke tests, typecheck, program verificationThe Share card creates a room and a link on the current origin. Opening that link creates a read-only viewer which reproduces the host's deterministic run locally and follows its view and playhead. During measurement, every connected viewer takes a share of the requested shots; the host re-takes work from peers that disconnect, merges all returned histograms, and broadcasts the merged result back to the room.
The host's complete stage layout is mirrored too: split rows, pane order, relative pane sizes, and per-pane zoom. Viewer controls remain locked. The Sharing card exposes machine and per-machine shot capacity. Expand combines each machine's chosen memory contribution into one register and shows total room memory, qubit capacity, shard size, and shards per machine.
Signaling is served at /ws by Vite in development and preview, so there is no
second process or relay address in ordinary links. npm run signal starts the
standalone relay for deployments that need one; pass its address with the
advanced ?s= query parameter. Peer traffic uses WebRTC directly. Rooms are
unprotected capabilities and the default STUN-only setup does not traverse
every restrictive NAT.
Expand does not treat a machine as one shard. Every active participant runs a local group of at least two worker-owned WASM shards, and higher memory contributions create more slots on that machine. Pairs on one machine exchange through its local workers; only pairs split across machines send amplitude blocks over WebRTC. Losing a machine still loses its unique shards and aborts that run.
Three columns: what to run on the left, what it looks like in the middle, what came out on the right. The answer has a column of its own because it was ending up below the fold of a scrolling sidebar, which is the one place an answer must never be.
Explanation lives behind an ⓘ, not in the layout. The dividing line: if it explains, it hides; if it is a fact about what you are looking at — a count, a percentage, a caveat about what has been truncated — it stays visible, because hiding those would be hiding the data. Data rows reveal their note on hover rather than carrying an ⓘ each, since the row is already a hit target and six more small buttons is not simpler.
Controls are counted too. Speed was six buttons and is one select; the shot count was four and is one. What is left is what was asked for: start, back, play/pause, forward, end, a scrubber, and the measurement buttons that appear only once the circuit has finished.
One source of truth: runProgram executes a program one step at a time and
records what the register looked like after each, and everything on screen is a
function of that timeline plus a playhead. Changing an input re-runs from
scratch — microseconds at small sizes — so the display cannot drift from the
engine.
| File | Contents |
|---|---|
lib/types.ts |
Program, Step, Frame, Timeline — the vocabulary |
lib/steps.ts |
step constructors, and the gate arity table |
lib/backend.ts |
the Backend interface, and the shard layout it builds |
lib/shardedRegister.ts |
the sharded implementation, over workers |
lib/runner.ts |
executes a program, records frames, budgets the detail |
lib/analysis.ts |
presents a recorded frame to the views |
programs/*.ts |
one program per file |
views/*.tsx |
one projection per file |
verify.mjs |
every program against the engine, under Node |
Each program used to invent its own output rows — ten programs, ten vocabularies, anywhere from two to six rows in a different order — so reading a new one meant working out its language first. They all answer the same three questions, so the shape is fixed and only the words inside it change:
| Answer | what the run computed, in the program's terms |
| Expected | what it should be, when that is knowable another way, with ✓ / ✗ |
| Confidence | how strongly the shots support it |
expected is the interesting part of the contract. Nine of these ten problems
have an answer that is knowable independently — by exhaustive search, by
arithmetic, from an analytic formula — so a program says what the answer should
be and the panel marks whether the run got it. A coin flip cannot, and leaves it
out; that absence is itself the point.
Measurement is not a program's business either. Seven of them used to carry their own "measure at the end" toggle, which duplicated the transport's Measure button in seven slightly different ways. They are all unitary now except teleportation, whose measurements are part of the algorithm rather than a display option.
Views share a footer for the same reason: one component renders the legend from a table of marks and one line of facts, so the bottom of the screen does not change shape when you switch tabs.
A program is a generator of steps rather than a list, so a step can depend on a mid-circuit measurement — teleportation's corrections need exactly that. The runner pulls one step at a time, executes it, and records the result, which means the resolved step list is only known after the run. That is why the circuit diagram shows the gates that actually ran, with conditional ones marked, rather than a pair of maybe-gates.
Adding one is adding a file and a line in programs/index.ts. It declares its
own inputs, its own qubit count, its own wire names and its own readouts;
nothing else in the app needs to know it exists.
One at a time by default — they are different ways of looking at the same amplitudes, and two of them competing for the same glance is worse than one.
| View | Shows | Good for |
|---|---|---|
| Circuit | the gates in execution order, playhead on the last one | following what ran |
| State vector | probability as height, phase as a dial | interference, amplification |
| Qubit map | a dial per qubit, chorded by correlation | entanglement, collapse |
| Polarisation | a Bloch sphere per qubit | phase on a single qubit; entanglement as a short arrow |
| Complex plane | amplitudes as points | phase, exactly — the QFT's winding |
There is deliberately no shots view — see below.
The stage tiles, though, because explaining a view is not the same act as reading one: "the cost layer writes each allocation's shortfall into its phase and the bars do not move" is one claim about two pictures, and made against a single pane it is a claim about a picture that is no longer on screen. So a tab can be dragged into the stage — down the middle of a pane to sit beside it, near a top or bottom edge to sit above or below it — and dividers reweight what is open. Shift-clicking a tab does the same without the drag, and clicking one plainly still means "just this one", which is where every session starts.
Each pane also has its own scale — a separate question from its size, and
the more useful of the two for some views. A divider gives a pane more room; the
scale gives the view more room in the room it has, which is what a Bloch sphere
per qubit and a 278-gate circuit both actually want. It is CSS zoom rather than
a transform, so the view underneath still measures a real box and lays itself out
for it: at 60% the polarisation view fits more spheres per row rather than
shrinking the picture it had.
The model is rows of panes rather than a general split tree. Rows of panes covers every arrangement anyone asks for — two side by side, two stacked, a wide pair over a third — in a shape you can read straight off the state, where a tree needs a recursion to answer "what is next to what". The floor on a pane's size is deliberately small: a floor set to the width a view would like leaves so little travel between two panes that the divider reads as snapping between two positions, and a squeezed view scrolls rather than breaking.
Probability and phase get two different encodings — height and angle — rather than one colour-coded bar. A phase is a direction, so a dial reads at a glance and survives greyscale. Colour is left to carry one thing: blue is |1⟩ and amplitude, grey is |0⟩ and chrome, orange is measurement. The palette is the data-viz reference set, validated all-pairs on the light surface.
Playing stops at the end of the circuit, where most of these programs leave the interesting thing in a superposition. The transport's primary button then offers to measure, and pressing it reads every qubit out in turn so the collapse is something you watch rather than a jump — a Bell pair's second qubit snaps the moment the first is looked at, without a gate touching it. It is a separate button because looking is a separate act from computing, and a destructive one. A program that already measured everything itself is not offered it: there is nothing left to decide.
Pressing it reads out the best of the shots whenever the program ranks its outcomes, and one draw when there is nothing to rank. That asymmetry is the whole point. One sample from a distribution where the answer holds 0.1% of the probability is almost always a poor one, and a collapsed register is the most prominent thing on screen — so a poor draw sitting in the register reads as the program's answer when it is nothing of the kind. Keeping the best of the shots is how a sampling algorithm is actually used: you take the shots, you score them, you keep the winner. It is a selection among draws rather than a measurement, so the badge over the view says which it was.
This used to be a second button beside Measure, and having to know to press it was the bug. A coin flip has nothing to rank and still gives you a draw; six presses on a Bell pair gave |11⟩ |00⟩ |11⟩ |11⟩ |00⟩ |00⟩, always agreeing and never predictable. Pressing again takes a fresh set of shots either way.
What "best" means is the program's business, not the app's: a program can
implement score(state, values) — lower is better, null disqualifies — and
the app ranks the shots with it. A program whose output is a single definite
state has nothing to rank and leaves it out.
A state vector is not a result. The amplitudes are not something any experiment can read, and the answer to "what does this program compute" is what comes back when you measure it, repeatedly. So measurement is not one of the views: it is part of every run, and it lives beside the outputs because that is where an answer belongs.
How the shots are taken depends on the circuit, and the difference is not cosmetic:
| when | cost | |
|---|---|---|
| sampled | no mid-circuit measurement | one pass — every shot is drawn from the same final state, exactly |
| repeated | the circuit measures | the whole circuit re-run per shot; there is no shortcut, so it is budgeted |
For a sampled run the exact probability sits beside the sampled share, because that gap is the shot noise and watching it close as the count rises is the reason anyone takes more than one shot. For a repeated run there is no single final state to compare against, so the column is absent rather than invented.
There is no seed control. A seed asks the reader to manage the one thing shots exist to average away, so the run picks its own and rolls it every time you ask to measure. An earlier version derived it from a fixed index instead, in the name of reproducibility, and the result was a coin flip that came up heads on every fresh page load — the one thing a coin flip must not do. Reproducibility is not worth that.
The floor is 8,192 shots and the only other option is 65,536. Nothing smaller is offered, because a smaller number is not a faster answer — it is a worse one that looks exactly like a better one, and the tail of the distribution is where these algorithms keep their answers.
How many shots is a property of the algorithm, not a taste setting, so a program states its own default and most want the floor. A sampling optimiser whose best outcome carries one part in a thousand of the distribution will miss it half the time at a thousand shots and report a worse one with a straight face; that is a budget, not a bug, and the program is what knows the difference. QAOA is that program, and asks for 65,536 — see below.
Readouts describe the end of the circuit — not the playhead, and not the end of the timeline. An answer that changes as you scrub is not an answer; and a readout collapses the state to one draw, which would turn "P(marked) = 96%" into "100%" and make the exact column contradict the shot column beside it. The collapse is reported separately, as the one draw it is. And a program's answer is what it measured, not what was most probable — Grover reports how many shots found the marked state, the adder whether all of them read the same sum, Deutsch–Jozsa's verdict is what the shots said. The difference is not pedantic: for QAOA the likeliest outcome is a bad allocation, and reporting it was a wrong answer stated confidently.
The program that justifies the rest of it, and the one place the visualiser deliberately owns no physics at all.
engine/src/qaoa.rs builds the QAOA circuit for a QaoaConfig and hands back a
sampled histogram — the right shape for "what does this problem sample to", the
wrong shape for watching it happen. So engine/src/qaoa_plan.rs returns the
same gates as data, tagged with which part of the circuit they belong to, and
tests/qaoa_plan.rs requires the two to produce the same state to 1e-13 across
48 combinations of γ, β and λ and four problem shapes. web/src/programs/qaoa.ts
then does nothing but turn the panel's inputs into a config, ask for the plan,
and label the stages. verify.mjs checks that the program's step list is
gate-for-gate and angle-for-angle the plan the engine returned.
That indirection is the point. A visualiser with its own copy of the construction shows you a circuit that resembles the one the engine runs, and the resemblance is exactly what you cannot check by looking.
The problem and the objective do live here, because qaoa.rs deliberately keeps
both out: the weights, the groupings, and what makes one allocation better than
another are not generic. 20 W of supply, four consumers wanting 27 W between
them, twelve switchable supplies, so one bit string is one allocation and the
register holds all 4096 at once. Exhaustive search says the best reachable
allocation leaves 2.692308 of weighted demand unmet, which
tests/optimization_problem.rs pins down independently.
Watch it with the state-vector view open. The cost layer writes each
allocation's shortfall into its phase and the bars do not move at all — the
step that makes people think nothing happened. The mixer turns those phases into
interference. At the angles qaoa.rs ships (γ = 0.1, β = 0.5) the optimum comes
out 2.5× more likely than an even draw; drag γ down to about 0.04 and it is
33×. One round of QAOA is powerful and brittle at the same time, and a
slider says that better than a paragraph.
Pressing Measure here is instructive precisely because it disappoints: a draw scores around 9.4 against an optimum of 2.69, because the optimum holds 0.1% of the probability and one sample is one sample. Best shot collapses onto the one that won — ranked around 400th by frequency, drawn once in a thousand. That gap is the whole character of the algorithm.
The panel says it in the shape every program uses: the answer is the best
allocation the shots found, expected is the optimum from exhaustive search, and
the ✓ says whether this run got there.
It asks for 65,536 shots. The optimum carries about one part in a thousand of the distribution at the shipped angles, which is why an earlier version of this app — sampling a thousand — landed on it only about half the time and spent the other half reporting a worse allocation as though it were the answer. The 8,192 floor already fixes that on its own: six runs at the floor found the optimum every time, with 5 to 12 hits. 65,536 is asked for so the confidence figure is seventy-odd hits rather than five, because "5 of 8,192" reads as luck even when it isn't.
The answer is the cheapest allocation among the shots, which is how
tests/qaoa_module.rs defines it and not the same thing as the likeliest
outcome. At the shipped angles the likeliest outcome leaves 10.92 of demand
unmet against an optimum of 2.69 — reporting it, as an earlier version of this
program did, is a wrong answer stated confidently next to the right one. So the
panel says which allocation the shots found, how many of them found it (one in a
thousand at the shipped angles, seventeen at γ = 0.04), and what fraction stayed
within budget at all: about 90%, because the budget is a phase penalty rather
than a constraint and can simply be broken.
Two modelling choices are exposed rather than buried. qaoa.rs's example gives
demand terms to the hospital and the factory only, so the two homes are scored
by the objective but never appear in the phases — "Demand terms" switches
between that, every consumer, and none. And every pair term is a
CNOT · RZ · CNOT, which is why one twelve-qubit round is 278 gates.
There is no hand-picked qubit cap. There are four real ceilings, and the visualiser's own is the cost of summarising a step, not the memory to store one:
| Ceiling | Value | Set by |
|---|---|---|
| Whole amplitude array per frame | 14 qubits | 16 B × 2ⁿ × steps — 256 KiB a frame at 14 |
| Comfortable stepping | 22 qubits | measured: ~40 ms a step at 20, 180 ms at 24, 4.7 s at 28 |
| One WASM module | 26 qubits | isize::MAX caps a single Rust allocation at 2 GiB |
| Register | 30 qubits | 16 shards × 1 GiB, and the machine has to have 16 GiB |
Execution is always sharded. There used to be a setting choosing between a whole-state backend and a sharded one, and it was doing nothing useful: the sharded path is a superset, so the choice was between "shard" and "shard". With it went the qubit cap it was gating, because the limit is what the machine can allocate, not which mode you picked. 28 qubits is 4 shards of 1 GiB and takes about 131 s to step through a 28-gate circuit; 29 is 8 shards. The panel states the layout and the cost before you commit to it.
Above 14 qubits a frame keeps a summary — the Bloch vectors, the largest
amplitudes and the correlation matrix — which is everything a view draws, at
kilobytes rather than megabytes. Those come from the engine's own
bloch_vector, top_amplitudes and reduced_two_of, each one pass with no buffer
proportional to the state, so they stay available at any size. Summarising is
what actually costs: a frame needs one pass per qubit, so it grows as n · 2ⁿ
and dominates the gates themselves by a factor of n.
Grover is the one program whose limit is the circuit, not the register. A
round is about 6n + 2 gates and the optimal round count grows as sqrt(2^n),
so the whole search grows as n · 2^(n/2) — around 1500 steps at ten qubits,
which is where the timeline's own step limit lands. Its oracle is a single
mcz at any width, because the engine takes a gate's control count from the
call.
Correlation links are the one thing that gets switched off rather than
approximated: a link needs one pass over the state per pair, so all pairs is
O(n² · 2ⁿ). The budget allows it to about 19 qubits, and the map says so
rather than drawing an empty ring that implies independence.
Selectable at any size, not only past 26 qubits — the mechanism is identical at
four qubits and at thirty, and forcing it on is the only way to watch it work.
planGate in Rust decides which shards take part in a gate and which pair with
which; this side only routes work and moves blocks.
Two things had to be added to make a sharded register visualisable rather than merely computable:
- Summaries. A local qubit's are sums within each slice, so the shards never communicate. A global qubit is a shard-id bit, so its diagonal comes straight from the shard masses with no amplitude arithmetic at all, and only its off-diagonal needs a pass — which reuses the gate exchange's own staging buffer.
- Measurement. The outcome has to be drawn once, against the global
marginal, which no shard can see. So the orchestrator draws it and hands both
the outcome and the scale factor to
collapse_local. A global qubit collapses by shard selection instead: the slices on the unobserved side are emptied.
The orchestrator draws from the engine's Prng rather than one of its own,
which makes a sharded run bit-identical to a whole-state run of the same circuit
and seed. Without that, switching execution mode changes the measured outcomes
and the two paths cannot be compared at all — and comparing them is the point:
tests/sharding.rs proves the algorithm in process, and identical readouts in
the browser prove this reimplementation of the routing.
node web/verify.mjs runs every program through the engine under Node. A
program is a generator of steps and the engine has no browser dependency, so
there is nothing to click:
- every gate name, arity and qubit index is one the engine accepts
- the norm survives every run
- teleportation's payload arrives whatever the measurements said
- Deutsch–Jozsa returns the right verdict for each oracle, in one query
- the adder is correct over all sixteen input pairs, with certainty
- Grover's marked state is the peak, past 94%
- the QAOA program's step list is gate-for-gate the plan the engine returned, and its shipped angles amplify the optimum while a better setting is on the sliders
Node strips the types from the imported .ts sources; tsresolve.mjs fills in
the extensions its resolver wants and Vite's does not, so the app source stays
written for the bundler.