Skip to content
This repository was archived by the owner on Sep 20, 2026. It is now read-only.

Latest commit

 

History

History

README.md

rock visualiser

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 verification

Sharing and pooled measurement

The 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.

Layout

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.

The shape of it

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

One shape for every program

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.

Programs

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.

The views

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.

Measurement

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.

QAOA

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.

Register size

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.

Sharded execution

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.

Verification

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.