ket

TypeScript quantum circuit simulator. Immutable API, seven backends (statevector, MPS, density matrix, Clifford, stabilizer rank, tensor-network contraction, Pauli path), 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>

Or as a plain script tag — no module setup, works in any page:

<script src="https://unpkg.com/@kirkelliott/ket"></script>
<script>
  const bell = new ket.Circuit(2).h(0).cnot(0, 1)
</script>
FileSizeFormatFor
dist/ket.js429 kBESMbundlers that tree-shake and minify
dist/ket.min.js195 kBESMimport from a CDN
dist/ket.global.js196 kBIIFE, ket global<script src> — what unpkg and jsdelivr serve
dist/compat.js69 BESM@kirkelliott/ket/compat, a re-export of ket.js

The global build is a second format, not a second copy — the compat entry re-exports the main bundle, so a Circuit from either entry point is the same class. No dependencies in any of them. Node.js ≥ 22; worker parallelism (workers: N) needs ≥ 22.3 and otherwise falls back to single-threaded, as it does in a browser.

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()     // { '11': 0.4999999999999999, '00': 0.4999999999999999 }
                      // analytic — the last digit is float rounding, not shot noise

// 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 1/64 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 10 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.

Stabilizer Rank

circuit.runStabilizerRank({ shots, targetError? })
O(20.228t · n²/8)

Clifford+T at 100+ qubits. Cost is exponential in the non-Clifford count t, not the width, so it goes where a statevector cannot. Exact runs stop near t=18; setting targetError derives a term budget from the circuit’s stabilizer extent and reaches t=50 in seconds. maxTGates({ qubits, targetError, memoryBytes }) gives the memory bound; runtime binds sooner. Approximate runs set Distribution.truncated, and estimateNorm() measures the damage — an exact decomposition of a unitary circuit has ‖ψ‖² = 1, so drift from 1 is the cost.

Tensor Network Contraction

amplitudeByContraction(circuit, bitstring)
O(2width), width = treewidth not n

Wide, shallow circuits. Contracts the circuit to a scalar, so cost follows the largest intermediate tensor rather than the qubit count — a statevector is the special case where the width is n. 400 qubits at depth 4 in 362 ms; 40 qubits at depth 24 in 543 ms. Diagonal gates (cz, rz, t…) sit on their wires as hyper-indices instead of cutting them, which is what makes depth reachable at all. amplitudeBatchByContraction leaves qubits open with ? and returns all 2k amplitudes from one contraction — 65,536 in 76 ms — and amplitudeBySlicedContraction trades independent repeats for memory. Planning is exposed separately: planContraction, planContractionPartitioned, planBest.

Pauli Path

pauliPathExpectation(circuit, observable)
O(terms), set by the light cone

Expectation values ⟨O⟩ for a Pauli observable without ever building a state, by carrying the observable backward through the circuit. Clifford gates never branch, so a Clifford circuit costs one term per gate at any width. A 120-qubit QAOA layer takes 28 ms. threshold, maxTerms and maxWeight truncate, and droppedWeight bounds how far the answer can have moved; noise damps coefficients so truncation costs less.

The last two are not shot samplers — contraction returns amplitudes and Pauli path returns an expectation value, so neither is reachable from simulate(). Call them directly.

All shot-based 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 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

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

OpenQASM coverage

fromQASM parses OpenQASM 2.0 and the 3.0 subset that maps onto it, auto-detecting the version.

Supported: any number of qreg/creg (or qubit/bit) declarations, laid out in declaration order — qreg a[2]; qreg b[2]; puts b[0] at qubit 2; gate definitions expanded inline, including parameterised and nested ones; opaque; measure in both the -> and assignment forms; reset; barrier; if (c == N); register-wide broadcast (h q;, measure q -> c;); and all of qelib1.inc plus the U/CX builtins.

Angle expressions accept pi, + - * / ^, parentheses, scientific notation, sin cos tan exp ln sqrt, and — inside a gate body — that gate's own parameters.

Rejected with a TypeError rather than silently mis-parsed: undeclared registers, out-of-range indices, gate arity mismatches, registers that cannot broadcast together, gate modifiers (ctrl @, inv @, pow @), gphase, for/while/def, else, let, and QASM 3 classical types beyond bit.

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)pMeasStatusSource
forte-1IonQ360.0267%0.49%0.20%availablevendor-published
forte-enterprise-1IonQ360.0267%0.49%0.20%availableestimated
h2-1Quantinuum560.0019%0.11%0.10%availableestimated
heliosQuantinuum980.0025%0.079%0.10%availablevendor-published
ibm_brisbaneIBM1270.024%0.76%1.35%availableestimated
aria-1IonQ250.05%1.33%0.40%retiredvendor-published
aria-2IonQ250.0657%1.86%0.40%retiredvendor-published
h1-1Quantinuum200.0018%0.097%0.23%retiredestimated
harmonyIonQ110.10%1.50%1.00%retiredestimated
ibm_sherbrookeIBM1270.024%0.74%1.35%retiredestimated
ibm_torinoIBM1330.02%0.30%1.00%retiredestimated

Retired devices are kept in the table so a circuit written against one still reports honestly, rather than silently matching nothing. Rows marked estimated are not vendor-published figures — treat them as order-of-magnitude. Regenerate from IonQ’s API with scripts/refresh-ionq-devices.ts.

IONQ_DEVICES is the subset of DEVICES that declares nativeGates, so it is keyed only by IonQ machines.

Expanding unsupported gates

toIonQ() is a serializer, not a compiler: it rejects any gate outside IonQ’s set rather than silently expanding it, so the gate count you inspect is the gate count you send. toIonQBasis() is the explicit expansion step. QFT and modular-exponentiation circuits are built almost entirely from cu1 and could not previously be exported at all:

const shor = shorCircuit(15n, 7n, 3)
shor.toIonQ()                       // TypeError: 'cu1' is not serializable

const native = shor.toIonQBasis()   // cu1 -> rz + controlled rz; cswap -> controlled x
native.toIonQ()                     // 5,798 gates, submit-ready

shor.toIonQBasis().compile('forte-1')   // and on to gpi/gpi2/ms

Controlled gates are emitted natively, as IonQ’s format expects: a base gate plus a controls array, so ccx is {gate:'x', controls:[a,b], target:t} rather than fifteen Clifford+T gates. What remains is still not free — cu1 becomes two gates and cswap three — so check gateCounts() before submitting. Each rule is exact up to global phase.

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.

Running on IonQ hardware

toIonQ() produces the payload; runIonQ submits it, waits and fetches the results. No dependency is added — the client uses the global fetch, so it runs anywhere ket does.

import { Circuit, runIonQ, countsToProbs } from '@kirkelliott/ket'

const bell = new Circuit(2).h(0).cnot(0, 1)
bell.checkDevice('forte-1')            // fail fast, before spending queue time

const { job, counts } = await runIonQ(bell.toIonQ(), {
  apiKey: process.env.IONQ_API_KEY,
  target: 'qpu.forte-1',               // or 'simulator' — free
  shots: 1024,
  noise: { model: 'ideal' },
  onPoll: j => console.log(j.status),
})

countsToProbs(counts, 2, 1024)         // { '00': 0.49, '11': 0.51 }
FunctionPurpose
runIonQ(circuit, opts)Submit, poll, fetch, convert. The path that cannot be got wrong.
submitIonQ(circuit, opts)Queue a job. Returns once accepted, not when it finishes.
getIonQJob(id, opts)Fetch current state. Does not include results.
awaitIonQJob(id, opts)Poll to a terminal state. Throws on failed/canceled.
getIonQResults(job, opts)Fetch the histogram, following the job’s results_url.
cancelIonQJob(id, opts)Cancel a queued or running job and release its slot.
ionqSubmitRequest(circuit, opts)Build the request without sending it — for testing, or to route it yourself.
ionqHistogramToCounts(hist, shots)Probabilities → shot counts keyed by basis index.

Results are not on the job object. GET /jobs/{id} returns metadata and a results_url; the histogram lives behind it. runIonQ and getIonQResults follow that automatically — reading job.data will find nothing.

State the noise model rather than inheriting it. Omitting noise leaves the choice to IonQ’s server-side default, which is not guaranteed to be noiseless. At any real depth a device model flattens the distribution completely, and a flat distribution is indistinguishable from a wrong answer. Pass { model: 'ideal' }, or { model: 'forte-1', seed: 100 } to sample a device profile.

Angle units differ between the two gate sets. QIS gates (rx/ry/rz/xx/yy/zz) take rotation in radians — IonQ’s own example gives Rx(π/2) as rotation: 1.5708. The native gates (gpi/gpi2/ms) take phase in turns, where 1.0 = 2π. ket handles both. The distinction is not cosmetic: getting it wrong submits a valid circuit that computes something else, and Clifford-only circuits such as a Bell pair are blind to the error.

Bit order matches, and that is verified rather than assumed. IonQ histogram keys are little-endian integers with qubit i at 2i, which is exactly ket’s convention, so no conversion is applied. Submitting x(0) on two qubits returns {"1": 1.0}.

Calling from a browser: use a proxy

api.ionq.co sends no access-control-allow-origin, so a browser cannot call it directly. That is a feature: an IonQ API key buys paid hardware time, and a key in a JS bundle is a key anyone can spend. Keep it server-side and forward.

// server — the key never leaves this process
app.post('/api/ionq/jobs', async (req, res) => {
  res.json(await submitIonQ(req.body.input, { apiKey: process.env.IONQ_API_KEY }))
})

// browser — same library, same types, no key
const { id } = await submitIonQ(circuit.toIonQ(), {
  endpoint: '/api/ionq', target: 'qpu.forte-1', shots: 1024,
})

Both halves import the same Circuit, so the browser can simulate for instant feedback and hand the identical object to the server to run on hardware — one representation, no serialisation boundary between two languages.

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))

Mutable adapter /compat

@kirkelliott/ket/compat exports a QuantumCircuit class with a mutable, column-indexed API: gates are placed at an explicit column, the circuit grows to fit any wire named, and run() stores state on the instance. It matches the API shape used by the quantum-circuit package, so code written against that shape runs unmodified.

import { QuantumCircuit } from '@kirkelliott/ket/compat'

const circuit = new QuantumCircuit(2)
circuit.addGate('h', 0, 0)
circuit.addGate('cx', 1, [0, 1])
circuit.addMeasure(0, 'c', 0)
circuit.run()

circuit.getCregValue('c')          // 0 or 1
circuit.measureAllMultishot(1024)  // { '00': 517, '11': 507 }
circuit.toKet()                    // → Circuit, for the rest of the library

It is an adapter, not a second API to build on. toKet() returns the equivalent immutable Circuit, which is where everything else on this page applies.

Bit order is converted at the boundary: strings from stateAsString and measureAllMultishot put the highest wire leftmost, the opposite of ket's native order. probabilities() and measureAll() are indexed by wire. Measurement is non-destructive — a measure gate writes to its register but leaves the state alone, unless the circuit also has a classically-controlled gate or a reset. Seeding is the one addition: run(initialValues, { seed }) is reproducible, and unseeded runs draw fresh entropy each time.

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()  // { '11': 0.4999999999999999, '00': 0.4999999999999999 }
rho.get(0, 0)        // { re: 0.4999999999999999, 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 · 2,456 tests
Open playground → GitHub