ket
TypeScript quantum circuit simulator. Immutable API, four backends (statevector, MPS, density matrix, Clifford), 14 import/export formats, zero dependencies. Runs in Node.js ≥ 22 and any modern browser.
Install
npm install @kirkelliott/ket
Or load directly in a browser via CDN (no bundler needed):
<script type="module">
import { Circuit } from 'https://unpkg.com/@kirkelliott/ket/dist/ket.min.js'
const bell = new Circuit(2).h(0).cnot(0, 1)
console.log(bell.stateAsString()) // 0.7071|00⟩ + 0.7071|11⟩
</script>
Two bundle flavors ship in the package: ket.js (341 kB unminified, for bundlers that tree-shake) and ket.min.js (154 kB, for direct CDN use). The unpkg field points to the minified build. No dependencies either way.
Quick start
import { Circuit } from '@kirkelliott/ket'
// Build a Bell state
const bell = new Circuit(2).h(0).cnot(0, 1)
bell.draw() // ASCII diagram
bell.stateAsString() // '0.7071|00⟩ + 0.7071|11⟩'
bell.exactProbs() // { '00': 0.5, '11': 0.5 }
// Shot-based sampling
const result = bell
.creg('out', 2)
.measure(0, 'out', 0)
.measure(1, 'out', 1)
.run({ shots: 1000, seed: 42 })
// result.probs → { '00': ~0.5, '11': ~0.5 }
Conventions
All bitstrings use q0 leftmost throughout the entire API — matching Qiskit, Cirq, and textbooks.
'10'→ q0=1, q1=0amplitude('10'),exactProbs()['10'],initialState: '10'all follow q0-left
Circuit and leaves the original unchanged. Safe to compose, branch, and reuse without copying.Circuit constructor
| Parameter | Type | Description |
|---|---|---|
| n | number | Number of qubits. All qubits start in |0⟩. |
const c = new Circuit(3)
const aria = Circuit.device('aria-1') // new Circuit(25)
State inspection
| Method | Returns | Description |
|---|---|---|
| statevector(opts?) | Map<bigint, Complex> | Full sparse amplitude map. All basis states with |a|² ≥ 1e-10. |
| amplitude(bitstring) | Complex | Single amplitude ⟨bitstring|ψ⟩. |
| probability(bitstring) | number | |amplitude|². |
| exactProbs() | Record<string, number> | All non-negligible probabilities. No sampling variance. |
| stateAsString() | string | Human-readable superposition: '0.7071|00⟩ + 0.7071|11⟩'. |
| stateAsArray() | StateEntry[] | Sorted by prob desc. Each entry: { bitstring, re, im, prob, phase }. |
| marginals() | number[] | P(qᵢ=1) for each qubit. |
| blochAngles(q) | { theta, phi } | Single-qubit Bloch sphere angles via partial trace. |
| expectation(pauli) | number | ⟨ψ|P|ψ⟩ for a Pauli string (e.g. 'ZZ', 'XX'). |
| depth() | number | Circuit depth (longest gate-layer path). |
| circuitMatrix() | Complex[][] | The circuit's full 2ⁿ×2ⁿ unitary. Pure circuits only — throws on measure/reset/if. |
| bondEntropies(opts?) | number[] | Von Neumann entropy at each MPS bond — the entanglement profile. |
| expectMps(H, opts?) | { energy, truncated } | Pauli expectation value evaluated on the MPS backend. |
statevector(), exactProbs(), and friends throw on circuits with measurements or unbound parameters. Add a .creg and .run() for measurement-based sampling.Circuit composition
Concatenates two circuits of the same width. Classical registers are merged; custom gate definitions merged (this wins on name conflicts). Mismatched qubit counts throw TypeError.
const prep = new Circuit(2).h(0).cnot(0, 1)
const rot = new Circuit(2).rz(Math.PI / 4, 0).rz(Math.PI / 4, 1)
const full = prep.compose(rot)
Parametric circuits
Gate angles can be symbolic strings. Use .bind() to substitute values. Calling statevector() or any export on a circuit with unbound parameters throws a TypeError listing the missing names.
const ansatz = new Circuit(2)
.ry('theta', 0)
.rz('phi', 0)
.cnot(0, 1)
ansatz.params // ['phi', 'theta']
const bound = ansatz.bind({ theta: Math.PI / 4, phi: 0.1 })
bound.statevector() // runs normally
Gates that accept symbolic angles: rx, ry, rz, vz, u1, p, u2, u3, gpi, gpi2, xx, yy, zz, xy, ms, crx, cry, crz, cu1, cu2, cu3.
Single-qubit gates
| Gate | Method | Description |
|---|---|---|
| H | h(q) | Hadamard |
| X | x(q) | Pauli-X (NOT) |
| Y | y(q) | Pauli-Y |
| Z | z(q) | Pauli-Z |
| S | s(q) | Phase — Rz(π/2) |
| S† | si(q) / sdg(q) | S-inverse |
| T | t(q) | T gate — Rz(π/4) |
| T† | ti(q) / tdg(q) | T-inverse |
| √X | v(q) / srn(q) | Square-root NOT |
| √X† | vi(q) / srndg(q) | √X-inverse |
| Rx | rx(θ, q) | X-axis rotation |
| Ry | ry(θ, q) | Y-axis rotation |
| Rz | rz(θ, q) | Z-axis rotation |
| U1 / P | u1(λ, q) / p(λ, q) | Phase gate. p = Qiskit 1.0+ name |
| U2 | u2(φ, λ, q) | Two-parameter single-qubit unitary |
| U3 | u3(θ, φ, λ, q) | General single-qubit unitary |
| VZ | vz(θ, q) | VirtualZ — Rz alias |
| I | id(q) | Identity |
Two-qubit gates
| Gate | Method | Description |
|---|---|---|
| CNOT / CX | cnot(c, t) / cx(c, t) | Controlled-X |
| SWAP | swap(q0, q1) | SWAP |
| CY | cy(c, t) | Controlled-Y |
| CZ | cz(c, t) | Controlled-Z |
| CH | ch(c, t) | Controlled-H |
| CRx / CRy / CRz | crx(θ, c, t) | Controlled rotations |
| CU1 | cu1(λ, c, t) | Controlled-U1 |
| CU2 / CU3 | cu2(φ, λ, c, t) | Controlled-U2 / U3 |
| CS / CT | cs(c, t) / ct(c, t) | Controlled-S / T |
| XX | xx(θ, q0, q1) | Ising XX interaction |
| YY | yy(θ, q0, q1) | Ising YY interaction |
| ZZ | zz(θ, q0, q1) | Ising ZZ interaction |
| XY | xy(θ, q0, q1) | XY interaction |
| iSWAP | iswap(q0, q1) | iSWAP |
| √iSWAP | srswap(q0, q1) | Square-root iSWAP |
| C√X | csrn(c, t) | Controlled-√NOT (C-V) |
Three-qubit gates
| Gate | Method | Description |
|---|---|---|
| CCX | ccx(c0, c1, t) | Toffoli |
| CSWAP | cswap(c, q0, q1) | Fredkin |
| C√SWAP | csrswap(c, q0, q1) | Controlled-√SWAP |
Custom unitary gate
Matrix must be 2ᴺ × 2ᴺ where N is the number of qubits. Entries can be plain number (real) or { re, im } complex objects. Supported in statevector, density matrix, and MPS (1 and 2-qubit only). Throws in runClifford.
const SWAP = [[1,0,0,0],[0,0,1,0],[0,1,0,0],[0,0,0,1]]
circuit.unitary(SWAP, 0, 1)
const S = [
[{ re: 1, im: 0 }, { re: 0, im: 0 }],
[{ re: 0, im: 0 }, { re: 0, im: 1 }],
]
circuit.unitary(S, 0)
Complex numbers and gate matrices
Rather than writing { re, im } by hand, use the exported helpers. c(re, im?) builds a Complex; ZERO, ONE, and I are the constants:
import { c, ZERO, ONE, I, add, mul, scale, conj, norm2 } from '@kirkelliott/ket'
const S = [[ONE, ZERO], [ZERO, I]]
circuit.unitary(S, 0)
mul(I, I) // { re: -1, im: 0 }
norm2(c(3, 4)) // 25
Every built-in gate's matrix is exported too, so you can compose or inspect them directly — useful for building Kraus operators and custom channels:
import { H, X, Y, Z, S, Si, T, Ti, V, Vi, Rx, Ry, Rz, U1, U2, U3,
Xx, Yy, Zz, Xy, ISwap, SrSwap, Gpi, Gpi2, Ms, Id } from '@kirkelliott/ket'
H // fixed gates are matrix values: 2×2
Rx(Math.PI) // parameterised gates are functions returning a matrix
Ms(0, 0) // IonQ native Mølmer–Sørensen, 4×4
Gpi(0), Gpi2(0) // IonQ native single-qubit, 2×2
IonQ native gates
| Gate | Method | Description |
|---|---|---|
| GPI | gpi(φ, q) | Single-qubit rotation on Bloch equator |
| GPI2 | gpi2(φ, q) | Half-angle GPI |
| MS | ms(φ₀, φ₁, q0, q1) | Mølmer-Sørensen entangling gate |
Use circuit.checkDevice('aria-1') to validate a circuit against a device's native gate set before calling circuit.toIonQ().
Simulation backends
If you don't want to choose, don't. simulate() inspects the circuit and routes it to the cheapest exact backend — Clifford if every gate is Clifford, statevector below statevectorLimit qubits (default 20), MPS above it:
circuit.simulate({ shots: 1024 })
// Which one ran?
const d = circuit.simulate({ shots: 1024 })
d.backend // 'clifford' | 'statevector' | 'mps'
d.peakChi // peak bond dimension, when the MPS path ran
| Option | Type | Description |
|---|---|---|
| shots | number | Shot count (default 1024). |
| seed | number | Seed the RNG for reproducible sampling. |
| noise | NoiseParams | string | Explicit parameters or a named device profile. |
| initialState | string | Starting basis state, q0 leftmost. Forces the statevector path. |
| statevectorLimit | number | Qubit count below which statevector is always used (default 20). Raise to force MPS sooner. |
| maxBond | number | Initial χ allocation if this routes to MPS (default 64). Not a cap. |
| truncErr | number | Relative singular-value cutoff (default 0 — exact). Above zero the run becomes approximate. |
| maxChi | number | Hard ceiling on χ. Unlike maxBond this binds, and sets Distribution.truncated. |
| dense | DenseOptions | Tune the sparse → dense statevector promotion threshold. |
Or pick a backend yourself:
Statevector
Exact amplitudes. Starts as a sparse BigInt-keyed map and promotes itself to a contiguous Float64Array past ⅛ fill, so sparse circuits (Bell, GHZ) stay cheap at any width and dense ones stop paying for a hash map. Practical to ~20 qubits dense.
MPS / Tensor Network
Low-entanglement circuits, 50+ qubits. Exact by default — maxBond is an initial allocation, not a cap, and χ grows as the circuit demands. GHZ-50 runs in milliseconds at χ=2; GHZ-127 samples 1024 shots in 186 ms. Supports mid-circuit measure/reset/if, and spreads work across cores with workers.
Density Matrix
Mixed-state and noisy simulation. Exact per-gate depolarizing channels, no Monte Carlo. Practical up to n=12.
Clifford Stabilizer
Clifford-only circuits. H, S, X, Y, Z, CNOT, CZ, CY, SWAP. Non-Clifford gates throw at runtime. 1,024 qubits in milliseconds.
All backends accept an initialState option to start from an arbitrary computational basis state:
circuit.run({ initialState: '110' })
circuit.runMps({ shots: 1000, initialState: '110' })
circuit.statevector({ initialState: '110' })
Distribution
Every shot-based method — simulate(), run(), runMps(), runClifford() — returns a Distribution.
| Member | Description |
|---|---|
| probs | Bitstring → probability, q0 leftmost. |
| shots | Shot count the distribution was sampled from. |
| qubits | Number of qubits measured. |
| backend | Which backend actually ran: 'clifford', 'statevector', or 'mps'. |
| representation | Statevector runs only — whether the run finished 'sparse' or 'dense'. Undefined for MPS and Clifford. |
| peakChi | MPS only — peak bond dimension reached, not the allocation size. Useful for profiling entanglement. |
| truncated | True when truncErr or maxChi discarded amplitude, i.e. the result is approximate. |
| cregs | cregs[name][bit] — fraction of shots in which that classical bit read 1. |
| histogram | Decimal-integer key → probability, e.g. { '0': 0.5, '3': 0.5 } (IonQ API convention). Not a method. |
| render() | ASCII bar chart of the outcomes. |
| toSVG() | Styled SVG histogram — self-contained, no external fonts. |
| mitigateReadout(p) | Correct symmetric readout error, returning a new Distribution. See below. |
Readout error mitigation
Given a known symmetric bit-flip probability, mitigateReadout() inverts the readout confusion matrix and returns corrected probabilities:
const noisy = circuit.run({ shots: 8192, noise: { pMeas: 0.02 } })
const fixed = noisy.mitigateReadout(0.02)
noisy.probs['00'] // biased by SPAM error
fixed.probs['00'] // corrected
Measurements & classical control
| Method | Description |
|---|---|
| creg(name, size) | Add a classical register. |
| measure(q, reg, bit) | Measure qubit q into classical register bit. |
| reset(q) | Reset qubit to |0⟩. |
| if(reg, val, fn) | Apply gates conditionally on classical register value. |
| barrier(...qubits) | Scheduling hint — no-op in simulation, emits barrier in QASM. |
| defineGate(name, circ) | Register a named sub-circuit gate. |
| gate(name, ...qubits) | Apply a registered named gate. |
| decompose() | Inline all named gates back to primitives. |
const c = new Circuit(2)
.creg('out', 2)
.h(0).cnot(0, 1)
.measure(0, 'out', 0)
.measure(1, 'out', 1)
const result = c.run({ shots: 1000, seed: 42 })
// result.probs → { '00': ~0.5, '11': ~0.5 }
// Conditional gate
const teleport = new Circuit(3)
.if('out', 1, q => q.x(2))
.if('out', 2, q => q.z(2))
Noise models
All three stochastic backends accept a noise option — either a named device or explicit parameters:
// Named device profile
circuit.run({ noise: 'forte-1' })
circuit.runClifford({ shots: 10000, noise: 'aria-1' })
circuit.dm({ noise: 'h2-1' })
// Custom depolarizing parameters
circuit.run({ noise: { p1: 0.001, p2: 0.005, pMeas: 0.004 } })
| Parameter | Type | Description |
|---|---|---|
| p1 | number | Single-qubit depolarizing error probability per gate. |
| p2 | number | Two-qubit depolarizing error probability. |
| pMeas | number | Bit-flip probability per measured bit (SPAM error). |
| gamma | number | Amplitude damping (T1 relaxation) per gate: γ = 1 − exp(−tgate/T1). Applied to every qubit after every gate. |
| lambda | number | Pure dephasing (T2 beyond T1) per gate: λ = 1 − exp(−2 tgate(1/T2 − 1/2T1)). |
| kraus1 | Gate2x2[] | Custom Kraus operators applied after each single-qubit gate. |
| kraus2 | Gate4x4[] | Custom Kraus operators applied after each two-qubit gate. |
T1 / T2 relaxation
Convert hardware coherence times into per-gate probabilities:
const tGate = 200e-9, T1 = 50e-6, T2 = 30e-6
circuit.run({ shots: 4096, noise: {
gamma: 1 - Math.exp(-tGate / T1),
lambda: 1 - Math.exp(-2 * tGate * (1 / T2 - 1 / (2 * T1))),
}})
Custom Kraus channels
Supply your own channel as a set of Kraus operators. They must be trace-preserving — Σk Kk†Kk = I — and one is sampled per shot per gate:
import { c } from '@kirkelliott/ket'
// Amplitude damping written out by hand, γ = 0.01
const g = 0.01
const K0 = [[c(1), c(0)], [c(0), c(Math.sqrt(1 - g))]]
const K1 = [[c(0), c(Math.sqrt(g))], [c(0), c(0)]]
circuit.run({ shots: 4096, noise: { kraus1: [K0, K1] } })
kraus1 applies after single-qubit gates only and kraus2 after two-qubit gates only — neither leaks into the other. Both are supported by run() and dm(), but not by runMps().
Noiseless circuits take the fast path — zero overhead. The density matrix backend applies exact per-gate depolarizing channels (no sampling).
Built-in algorithms
| Export | Signature | Description |
|---|---|---|
| qft | qft(n) | Quantum Fourier Transform circuit on n qubits. |
| iqft | iqft(n) | Inverse QFT. |
| grover | grover(n, oracle) | Grover's search. Oracle receives a Circuit and returns it with the phase-kick applied. |
| groverAncilla | groverAncilla(n, oracle) | Grover's with an explicit ancilla qubit for the oracle, rather than a phase kick. |
| phaseEstimation | phaseEstimation(precision, U, nTargets) | QPE. Returns a circuit with precision+nTargets qubits. |
| trotter | trotter(n, H, t, steps?, order?) | Trotterized Hamiltonian simulation. order=1 (Lie-Trotter) or order=2 (Suzuki). |
| qaoa | qaoa(n, edges, gamma, beta) | QAOA Max-Cut circuit. |
| maxCutHamiltonian | maxCutHamiltonian(n, edges) | Max-Cut Hamiltonian as PauliTerms[] for vqe. |
| realAmplitudes | realAmplitudes(n, reps) | Ry + CNOT ansatz. Real amplitudes. Has .paramCount. |
| efficientSU2 | efficientSU2(n, reps) | Ry·Rz + CNOT ansatz. Full SU(2). Has .paramCount. |
// Grover's search — find |101⟩ in 3 qubits
const circ = grover(3, (c) => {
c = c.x(1)
c = c.h(2)
c = c.ccx(0, 1, 2)
c = c.h(2)
c = c.x(1)
return c
})
circ.exactProbs() // { '101': ~0.945, ... }
// QPE — estimate phase of T gate (φ = 1/8)
const qpe = phaseEstimation(3,
(c, ctrl, pow, tgts) => c.cu1(Math.PI * pow / 4, ctrl, tgts[0]), 1)
qpe.run({ shots: 1000, seed: 42, initialState: '0001' })
Shor's algorithm
Not an oracle mock-up. The full Beauregard (2003) circuit, with modular exponentiation decomposed into primitive gates, simulated exactly — O(n³) gates, 2n+1 counting qubits, period recovered by continued fractions.
import { shorBeauregard } from '@kirkelliott/ket'
const r = shorBeauregard(15n, { a: 7n })
r.factors // [5n, 3n]
r.period // 4n — 7⁴ ≡ 1 (mod 15), from phase estimation
r.method // 'quantum' — the circuit really ran
r.qubits // 19
Shor's tries classical shortcuts first, and at these sizes they hit often — an even N, or a base sharing a factor with N, is resolved by gcd without building a circuit. method tells you which path ran, so a demo can prove the quantum one did:
shorBeauregard(15n, { a: 3n }).method // 'classical-gcd' — no circuit
shorBeauregard(15n, { a: 7n }).method // 'quantum'
| N | qubits | time |
|---|---|---|
| 15 | 19 | 0.4s |
| 21 | 23 | 3.0s |
| 33 | 27 | 22.4s |
| 35 | 27 | 29.0s |
This does not reach cryptographic sizes — no classical simulator does. What you get is the real circuit, exactly simulated, at sizes you can inspect.
The building blocks are exported too, so you can assemble the circuit yourself or reuse the number theory:
| Export | Signature | Description |
|---|---|---|
| factor | factor(N, opts?) | Factor N, choosing a base automatically. |
| shorBeauregard | shorBeauregard(N, opts?) | Full run. Returns ShorResult: { factors, period, method, qubits }. |
| phiAdd | phiAdd(circuit, a, qubits) | Draper QFT adder — the innermost Beauregard layer. |
| applyQft / applyIqft | applyQft(circuit, qubits) | In-place (I)QFT over a qubit subset. |
| modPow | modPow(base, exp, mod) | Modular exponentiation on BigInt. |
| modInverse | modInverse(a, mod) | Modular multiplicative inverse. |
| gcd | gcd(a, b) | Greatest common divisor. |
| continuedFractions | continuedFractions(x, qmax) | Convergents of x — recovers the period from a QPE measurement. |
VQE & optimization
| Export | Signature | Description |
|---|---|---|
| vqe | vqe(circuit, hamiltonian) | ⟨ψ(θ)|H|ψ(θ)⟩ — exact statevector, no sampling noise. |
| gradient | gradient(ansatz, H, params) | Analytic gradient via parameter-shift rule. 2N vqe() calls for N params. |
| minimize | minimize(ansatz, H, initParams, opts?) | Gradient descent. Returns { params, energy, steps, converged }. |
| gradientMps | gradientMps(ansatz, H, params, opts?) | Parameter-shift gradient evaluated on the MPS backend instead of a statevector. |
| minimizeMps | minimizeMps(ansatz, H, initParams, opts?) | Gradient descent on MPS. Reaches ansatz widths a statevector can't hold. |
| minimize option | Type | Description |
|---|---|---|
| lr | number | Learning rate. Default 0.1. |
| steps | number | Max gradient steps. Default 500. |
| tol | number | Convergence tolerance (gradient L2 norm). Default 1e-6. |
const H = [{ coeff: 0.5, ops: 'ZI' }, { coeff: 0.5, ops: 'IZ' }]
const ansatz = realAmplitudes(2, 2) // paramCount = 6
const { energy, params, converged } = minimize(
ansatz, H, Array(ansatz.paramCount).fill(0.1), { lr: 0.2, steps: 500 }
)
VQE beyond statevector width
Ground states obeying an area law stay at low bond dimension, so the MPS variants run ansätze far wider than 2ⁿ amplitudes would allow — a 12-qubit Heisenberg chain costs about what a 12-qubit statevector does, but the same code keeps going into the hundreds:
import { efficientSU2, minimizeMps } from '@kirkelliott/ket'
const ansatz = efficientSU2(12, 2)
const { energy, params } = minimizeMps(
ansatz, heisenbergH, Array(ansatz.paramCount).fill(0.1),
{ lr: 0.12, steps: 80 }
)
Two circuit methods support this workflow directly:
| Method | Description |
|---|---|
| expectMps(H, opts?) | Expectation value of a Pauli Hamiltonian on the MPS backend. Returns { energy, truncated }. |
| bondEntropies(opts?) | Von Neumann entropy at every bond — the entanglement profile across the chain. A product state is flat at zero; GHZ is flat at exactly 1 bit, since any cut splits a single shared bit. |
const s = ansatz.bind(params).bondEntropies()
// s[i] = entropy across the cut between qubit i and i+1
Pauli algebra
| Method | Returns | Description |
|---|---|---|
| .add(other) | PauliOp | Operator addition. |
| .scale(c) | PauliOp | Scalar multiplication. |
| .mul(other) | PauliOp | Operator product with phase tracking. |
| .commutator(other) | PauliOp | [A, B] = AB − BA. |
| .toTerms() | PauliTerm[] | Convert back to terms array. Throws if not Hermitian. |
const X = PauliOp.from([{ coeff: 1, ops: 'X' }])
const Y = PauliOp.from([{ coeff: 1, ops: 'Y' }])
X.mul(Y) // iZ
X.commutator(Y) // 2iZ
const H1 = PauliOp.from([{ coeff: 1, ops: 'ZI' }, { coeff: 1, ops: 'IZ' }])
const H2 = PauliOp.from([{ coeff: 0.5, ops: 'XX' }])
vqe(circuit, H1.add(H2).toTerms())
Import / Export
// Import
const c = Circuit.fromQASM(`
OPENQASM 2.0;
qreg q[2];
h q[0];
cx q[0],q[1];
`)
// Export
c.toQASM() // OpenQASM 2.0 string
c.toIonQ() // IonQ JSON for hardware submission
c.toCirq() // Cirq Python code
c.toLatex() // quantikz LaTeX environment
Devices
import { DEVICES, IONQ_DEVICES, Circuit } from '@kirkelliott/ket'
const forte = DEVICES['forte-1'] // { qubits: 36, noise: { p1, p2, pMeas } }
// Validate before submitting
circuit.checkDevice('aria-1') // throws if incompatible
circuit.toIonQ() // safe to call after check
| Device | Vendor | Qubits | p1 (1Q) | p2 (2Q) | pMeas |
|---|---|---|---|---|---|
| forte-1 | IonQ | 36 | 0.010% | 0.20% | 0.20% |
| aria-1 | IonQ | 25 | 0.030% | 0.50% | 0.40% |
| harmony | IonQ | 11 | 0.100% | 1.50% | 1.00% |
| h2-1 | Quantinuum | 56 | 0.0019% | 0.11% | 0.10% |
| h1-1 | Quantinuum | 20 | 0.0018% | 0.097% | 0.23% |
| ibm_torino | IBM | 133 | 0.020% | 0.30% | 1.00% |
| ibm_sherbrooke | IBM | 127 | 0.024% | 0.74% | 1.35% |
| ibm_brisbane | IBM | 127 | 0.024% | 0.76% | 1.35% |
IONQ_DEVICES additionally exposes nativeGates for compilation and validation — aria-1, forte-1, and harmony.
Transpiling to native gates
compile(device) rewrites a circuit into the target's hardware-native gate set, returning a new Circuit. For IonQ that means GPI, GPI2, and MS, plus virtual-Z frame rotations that cost nothing on hardware:
const native = new Circuit(2).h(0).cnot(0, 1).compile('forte-1')
native.draw()
// q0: ─Rz(π/2)──GPI2(0)──Rz(π/2)──GPI2(π/2)──MS(0,0)──GPI2(-π/2)──Rz(-π/2)─
// │
// q1: ─GPI2(π)───────────────────────────────MS(0,0)───────────────────────
native.toIonQ() // submit-ready
Barriers pass through. Gates with no native translation throw TypeError, as do unknown device names — so a failed compile is loud rather than silently approximate.
Visualization
| Method | Returns | Description |
|---|---|---|
| draw() | string | ASCII diagram. Gates on non-conflicting qubits share a column. |
| toSVG(opts?) | string | Self-contained SVG. No external fonts or stylesheets. Accepts { theme }. |
| blochSphere(q, opts?) | string | SVG Bloch sphere for qubit q via partial trace. Accepts { theme }. |
| blochAngles(q) | { theta, phi } | Bloch sphere angles for qubit q. |
| toLatex() | string | quantikz LaTeX environment with proper \ctrl{}, \targ{}, etc. |
const bell = new Circuit(2).h(0).cnot(0, 1)
console.log(bell.draw())
// q0: ─H──●─
// │
// q1: ─────⊕─
// Write SVG files
import fs from 'fs'
fs.writeFileSync('bell.svg', bell.toSVG())
fs.writeFileSync('state.svg', bell.blochSphere(0))
Light and dark themes 0.6.1
All three SVG exporters take a theme of 'light' (the default) or 'dark'. The colours are baked into the SVG, so the output stays self-contained — it does not depend on the host page's stylesheet or on prefers-color-scheme:
bell.toSVG({ theme: 'dark' })
bell.blochSphere(0, { theme: 'dark' })
const d = bell.run({ shots: 1024 })
d.toSVG({ theme: 'dark', title: 'bell state' })
Dark swaps the surface for #0c0e18 with light ink and a teal accent; the geometry is identical between themes. An unrecognised theme name throws TypeError rather than silently falling back. Every chart and diagram on this site is generated with theme: 'dark'.
Serialization
Lossless round-trip through JSON. All operation types preserved: gates, measure, reset, if, named sub-circuits.
const json = circuit.toJSON()
const restored = Circuit.fromJSON(json)
// Or pass a parsed object
const restored2 = Circuit.fromJSON(JSON.parse(json))
DensityMatrix
circuit.dm() returns a DensityMatrix — the exact mixed state, with per-gate channels applied analytically rather than sampled. Use it when you want the state itself rather than shot statistics.
| Member | Description |
|---|---|
| probabilities() | Bitstring → probability, q0 leftmost. |
| purity() | Tr(ρ²). Exactly 1 for a pure state, 1/2ⁿ for the maximally mixed one. |
| entropy() | Von Neumann entropy −Tr(ρ log ρ), in bits. Zero for a pure state. |
| blochAngles(q) | Reduced single-qubit state of q as { theta, phi }. |
| get(i, j) | Matrix element ρij as a Complex. |
const rho = new Circuit(2).h(0).cnot(0, 1).dm({ noise: { p1: 0.01 } })
rho.purity() // 0.986756 — decoherence has pulled it off 1
rho.entropy() // 0.057778 bits of mixedness
rho.probabilities() // { '00': 0.5, '11': 0.5 }
rho.get(0, 0) // { re: 0.5, im: 0 }
Accepts DmNoiseParams or a named device, and a dense option that tunes the sparse → dense promotion (default 1/32 fill). Practical to about 12 qubits, since ρ is 4ⁿ.
CliffordSim
CliffordSim is exported directly for researchers who need more control than runClifford — custom decoders, syndrome extraction, mid-circuit readout.
| Method | Returns | Description |
|---|---|---|
| h(q), s(q), x(q), cnot(c, t) … | void | Apply Clifford gates. Mutates in place (unlike Circuit). |
| measure(q, rand) | 0 | 1 | Measure qubit. rand is a uniform random in [0, 1). |
| stabilizerGenerators() | string[] | Current generators as signed Pauli strings, e.g. '+XX', '-ZZ'. |
import { CliffordSim } from '@kirkelliott/ket'
const sim = new CliffordSim(2)
sim.h(0); sim.cnot(0, 1)
sim.stabilizerGenerators() // ['+XX', '+ZZ']
// Inject error
sim.x(0)
sim.stabilizerGenerators() // ['+XX', '-ZZ'] ← syndrome bit flipped
const outcome = sim.measure(0, Math.random())
Quantum error correction
Sweep physical error rate to find the logical threshold:
// Surface code threshold curve
for (const p2 of [0.001, 0.005, 0.01, 0.02, 0.05]) {
const result = surfaceCode.runClifford({ shots: 10000, noise: { p2 } })
console.log(p2, result.probs['0']) // logical error rate vs physical
}
For detailed threshold analysis, use CliffordSim directly to extract stabilizer syndromes after each noise injection step without going through the full circuit runner.