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=0
  • amplitude('10'), exactProbs()['10'], initialState: '10' all follow q0-left
Every gate method is immutable — it returns a new Circuit and leaves the original unchanged. Safe to compose, branch, and reuse without copying.

Circuit constructor

new Circuit(n: number) Circuit.device(name: DeviceName) // new Circuit(device.qubits)
ParameterTypeDescription
nnumberNumber of qubits. All qubits start in |0⟩.
const c = new Circuit(3)
const aria = Circuit.device('aria-1')  // new Circuit(25)

State inspection

MethodReturnsDescription
statevector(opts?)Map<bigint, Complex>Full sparse amplitude map. All basis states with |a|² ≥ 1e-10.
amplitude(bitstring)ComplexSingle amplitude ⟨bitstring|ψ⟩.
probability(bitstring)number|amplitude|².
exactProbs()Record<string, number>All non-negligible probabilities. No sampling variance.
stateAsString()stringHuman-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()numberCircuit 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

circuit.compose(other: Circuit): Circuit

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.

circuit.bind(values: Record<string, number>): Circuit circuit.params // string[] — sorted unbound parameter 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

GateMethodDescription
Hh(q)Hadamard
Xx(q)Pauli-X (NOT)
Yy(q)Pauli-Y
Zz(q)Pauli-Z
Ss(q)Phase — Rz(π/2)
S†si(q) / sdg(q)S-inverse
Tt(q)T gate — Rz(π/4)
T†ti(q) / tdg(q)T-inverse
√Xv(q) / srn(q)Square-root NOT
√X†vi(q) / srndg(q)√X-inverse
Rxrx(θ, q)X-axis rotation
Ryry(θ, q)Y-axis rotation
Rzrz(θ, q)Z-axis rotation
U1 / Pu1(λ, q) / p(λ, q)Phase gate. p = Qiskit 1.0+ name
U2u2(φ, λ, q)Two-parameter single-qubit unitary
U3u3(θ, φ, λ, q)General single-qubit unitary
VZvz(θ, q)VirtualZ — Rz alias
Iid(q)Identity

Two-qubit gates

GateMethodDescription
CNOT / CXcnot(c, t) / cx(c, t)Controlled-X
SWAPswap(q0, q1)SWAP
CYcy(c, t)Controlled-Y
CZcz(c, t)Controlled-Z
CHch(c, t)Controlled-H
CRx / CRy / CRzcrx(θ, c, t)Controlled rotations
CU1cu1(λ, c, t)Controlled-U1
CU2 / CU3cu2(φ, λ, c, t)Controlled-U2 / U3
CS / CTcs(c, t) / ct(c, t)Controlled-S / T
XXxx(θ, q0, q1)Ising XX interaction
YYyy(θ, q0, q1)Ising YY interaction
ZZzz(θ, q0, q1)Ising ZZ interaction
XYxy(θ, q0, q1)XY interaction
iSWAPiswap(q0, q1)iSWAP
√iSWAPsrswap(q0, q1)Square-root iSWAP
C√Xcsrn(c, t)Controlled-√NOT (C-V)

Three-qubit gates

GateMethodDescription
CCXccx(c0, c1, t)Toffoli
CSWAPcswap(c, q0, q1)Fredkin
C√SWAPcsrswap(c, q0, q1)Controlled-√SWAP

Custom unitary gate

circuit.unitary(matrix: number[][] | Complex[][], ...qubits): Circuit

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

GateMethodDescription
GPIgpi(φ, q)Single-qubit rotation on Bloch equator
GPI2gpi2(φ, q)Half-angle GPI
MSms(φ₀, φ₁, 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
OptionTypeDescription
shotsnumberShot count (default 1024).
seednumberSeed the RNG for reproducible sampling.
noiseNoiseParams | stringExplicit parameters or a named device profile.
initialStatestringStarting basis state, q0 leftmost. Forces the statevector path.
statevectorLimitnumberQubit count below which statevector is always used (default 20). Raise to force MPS sooner.
maxBondnumberInitial χ allocation if this routes to MPS (default 64). Not a cap.
truncErrnumberRelative singular-value cutoff (default 0 — exact). Above zero the run becomes approximate.
maxChinumberHard ceiling on χ. Unlike maxBond this binds, and sets Distribution.truncated.
denseDenseOptionsTune the sparse → dense statevector promotion threshold.

Or pick a backend yourself:

Statevector

circuit.run() / circuit.statevector()
sparse map → dense Float64Array

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

circuit.runMps({ shots, maxBond? })
O(n·χ²)

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

circuit.dm({ noise? })
O(4ⁿ) sparse

Mixed-state and noisy simulation. Exact per-gate depolarizing channels, no Monte Carlo. Practical up to n=12.

Clifford Stabilizer

circuit.runClifford({ shots, noise? })
O(n²) tableau

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.

MemberDescription
probsBitstring → probability, q0 leftmost.
shotsShot count the distribution was sampled from.
qubitsNumber of qubits measured.
backendWhich backend actually ran: 'clifford', 'statevector', or 'mps'.
representationStatevector runs only — whether the run finished 'sparse' or 'dense'. Undefined for MPS and Clifford.
peakChiMPS only — peak bond dimension reached, not the allocation size. Useful for profiling entanglement.
truncatedTrue when truncErr or maxChi discarded amplitude, i.e. the result is approximate.
cregscregs[name][bit] — fraction of shots in which that classical bit read 1.
histogramDecimal-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

MethodDescription
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 } })
ParameterTypeDescription
p1numberSingle-qubit depolarizing error probability per gate.
p2numberTwo-qubit depolarizing error probability.
pMeasnumberBit-flip probability per measured bit (SPAM error).
gammanumberAmplitude damping (T1 relaxation) per gate: γ = 1 − exp(−tgate/T1). Applied to every qubit after every gate.
lambdanumberPure dephasing (T2 beyond T1) per gate: λ = 1 − exp(−2 tgate(1/T2 − 1/2T1)).
kraus1Gate2x2[]Custom Kraus operators applied after each single-qubit gate.
kraus2Gate4x4[]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 KkKk = 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

ExportSignatureDescription
qftqft(n)Quantum Fourier Transform circuit on n qubits.
iqftiqft(n)Inverse QFT.
grovergrover(n, oracle)Grover's search. Oracle receives a Circuit and returns it with the phase-kick applied.
groverAncillagroverAncilla(n, oracle)Grover's with an explicit ancilla qubit for the oracle, rather than a phase kick.
phaseEstimationphaseEstimation(precision, U, nTargets)QPE. Returns a circuit with precision+nTargets qubits.
trottertrotter(n, H, t, steps?, order?)Trotterized Hamiltonian simulation. order=1 (Lie-Trotter) or order=2 (Suzuki).
qaoaqaoa(n, edges, gamma, beta)QAOA Max-Cut circuit.
maxCutHamiltonianmaxCutHamiltonian(n, edges)Max-Cut Hamiltonian as PauliTerms[] for vqe.
realAmplitudesrealAmplitudes(n, reps)Ry + CNOT ansatz. Real amplitudes. Has .paramCount.
efficientSU2efficientSU2(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'
Nqubitstime
15190.4s
21233.0s
332722.4s
352729.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:

ExportSignatureDescription
factorfactor(N, opts?)Factor N, choosing a base automatically.
shorBeauregardshorBeauregard(N, opts?)Full run. Returns ShorResult: { factors, period, method, qubits }.
phiAddphiAdd(circuit, a, qubits)Draper QFT adder — the innermost Beauregard layer.
applyQft / applyIqftapplyQft(circuit, qubits)In-place (I)QFT over a qubit subset.
modPowmodPow(base, exp, mod)Modular exponentiation on BigInt.
modInversemodInverse(a, mod)Modular multiplicative inverse.
gcdgcd(a, b)Greatest common divisor.
continuedFractionscontinuedFractions(x, qmax)Convergents of x — recovers the period from a QPE measurement.

VQE & optimization

ExportSignatureDescription
vqevqe(circuit, hamiltonian)⟨ψ(θ)|H|ψ(θ)⟩ — exact statevector, no sampling noise.
gradientgradient(ansatz, H, params)Analytic gradient via parameter-shift rule. 2N vqe() calls for N params.
minimizeminimize(ansatz, H, initParams, opts?)Gradient descent. Returns { params, energy, steps, converged }.
gradientMpsgradientMps(ansatz, H, params, opts?)Parameter-shift gradient evaluated on the MPS backend instead of a statevector.
minimizeMpsminimizeMps(ansatz, H, initParams, opts?)Gradient descent on MPS. Reaches ansatz widths a statevector can't hold.
minimize optionTypeDescription
lrnumberLearning rate. Default 0.1.
stepsnumberMax gradient steps. Default 500.
tolnumberConvergence 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:

MethodDescription
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

PauliOp.from(terms: PauliTerm[]): PauliOp
MethodReturnsDescription
.add(other)PauliOpOperator addition.
.scale(c)PauliOpScalar multiplication.
.mul(other)PauliOpOperator 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

OpenQASM 2 / 3
fromQASM · toQASM
IonQ JSON
fromIonQ · toIonQ
Quil 2.0
fromQuil · toQuil
Qiskit (Python)
fromQiskit · toQiskit
Cirq (Python)
fromCirq · toCirq
ket JSON
fromJSON · toJSON
Qiskit Qobj JSON
fromQobj
Q#
toQSharp
pyQuil
toPyQuil
Amazon Braket
toBraket
CUDA-Q
toCudaQ
TensorFlow Quantum
toTFQ
Quirk JSON
toQuirk
LaTeX (quantikz)
toLatex
// 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
DeviceVendorQubitsp1 (1Q)p2 (2Q)pMeas
forte-1IonQ360.010%0.20%0.20%
aria-1IonQ250.030%0.50%0.40%
harmonyIonQ110.100%1.50%1.00%
h2-1Quantinuum560.0019%0.11%0.10%
h1-1Quantinuum200.0018%0.097%0.23%
ibm_torinoIBM1330.020%0.30%1.00%
ibm_sherbrookeIBM1270.024%0.74%1.35%
ibm_brisbaneIBM1270.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

MethodReturnsDescription
draw()stringASCII diagram. Gates on non-conflicting qubits share a column.
toSVG(opts?)stringSelf-contained SVG. No external fonts or stylesheets. Accepts { theme }.
blochSphere(q, opts?)stringSVG Bloch sphere for qubit q via partial trace. Accepts { theme }.
blochAngles(q){ theta, phi }Bloch sphere angles for qubit q.
toLatex()stringquantikz 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.

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

new CliffordSim(n: number)
MethodReturnsDescription
h(q), s(q), x(q), cnot(c, t) …voidApply Clifford gates. Mutates in place (unlike Circuit).
measure(q, rand)0 | 1Measure 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.

ket — quantum circuits in JavaScript
Built by @dmvjs · MIT License · 1,301 tests
Open playground → GitHub