Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Circuits

Building a circuit

A Circuit is an ordered list of channels on a fixed register, and every method call on it pushes exactly one channel. Circuit(n) is the empty circuit; the named gates are h, s, sdg, x, y, z, cnot, cz, swap, the rotations rx, ry, rz and pauli_rotation, and the checked matrices unitary_1q and unitary_2q.

Every rotation implements exp(-i·θ·P/2) for its generator P, the same convention as qiskit’s rz. A Trotter step written as exp(-i·s·P), with no /2, therefore needs theta = 2*s; getting this wrong halves every angle and the run still completes, just for a different Hamiltonian. pauli_rotation(pauli, qubits, theta) rotates about a Pauli string of any weight: pauli[k] acts on qubits[k], and identity positions are left out rather than written as I, so pauli_rotation("ZZ", [0, 1], theta) is the two-qubit ZZ rotation.

import math
from paulistrings import Circuit

n, J, h, dt = 4, 1.0, 0.5, 0.05
step = Circuit(n)
for i in range(n - 1):
    step.pauli_rotation("ZZ", [i, i + 1], theta=2 * -J * dt)   # exp(-i * (-J dt) * ZZ)
for i in range(n):
    step.rx(2 * -h * dt, i)                                     # exp(-i * (-h dt) * X)
print(len(step), step.gates[0])
7 {'name': 'pauli_rotation', 'qubits': [0, 1], 'pauli': 'ZZ', 'theta': -0.1}

circuit.gates lists the channels as plain dicts, the task-JSON gate vocabulary, and is the quickest way to check what was actually pushed. unitary_1q(qubit, matrix) and unitary_2q(q0, q1, matrix) take a 2x2 or 4x4 complex matrix, reject a non-unitary one with ValueError, and order the two-qubit tensor factors as |q0 q1⟩, q0 more significant — so a CNOT matrix in the textbook basis is unitary_2q(control, target, CNOT). The same gates exist as factories in the gates module, returning a channel for circuit.append, for building a channel once and pushing it onto several circuits; circuit.append(gates.h(0)) is circuit.h(0).

The full table, with per-gate caveats, is Circuit; C — Deep Trotter circuits is the same shape of step — ZZ rotations then X kicks — on a 127-qubit heavy-hex lattice, 5 to 20 steps deep.

Composition

Circuits compose by channel list. a + b is a new circuit with a’s channels then b’s, a.extend(b) appends in place, both require equal num_qubits, and len, indexing and slicing work on the channel list. circuit.adjoint() is a new circuit with the channel order reversed and every gate replaced by its dagger: rotations negate theta, s becomes sdg and back, matrices are conjugate-transposed, and the self-adjoint Cliffords are unchanged.

front = Circuit(2)
front.h(0)
front.s(0)
back = Circuit(2)
back.rz(0.3, 1)

whole = front + back
print(len(front), len(back), len(whole))
print([g["name"] for g in whole.adjoint().gates], whole.adjoint().gates[0]["theta"])
2 1 3
['rz', 'sdg', 'h'] -0.3

The adjoint is what turns one direction into the other: propagating through c in the Heisenberg picture agrees, to floating-point tolerance, with propagating through c.adjoint() in the forward picture. It exists only for unitary circuits — a noise channel has no adjoint in the time-reversal sense, so adjoint() on a circuit containing one raises ValueError naming the channel; take the adjoint of the unitary part and add the noise to the reversed circuit explicitly. How push order and direction interact is Push order; the composition table is under Circuit.

One gate, one truncation point

Throughout this Manual, a layer is one applied channel — one gate, or one noise channel on one qubit or pair — never a brickwork layer of parallel gates. Truncation runs after every layer in that sense, so len(circuit) is the number of truncation points a propagation will pass, and the per-layer records in PropagationStats have one entry per channel.

Two consequences follow. Fusing two gates into one unitary_2q changes the answer, because it removes a truncation point between them; every circuit in the example suite is built one gate per push for that reason, and it is what makes a per-layer comparison against another engine meaningful at all. And a broadcast call is still one channel per target: depolarize(p, [0, 1]) pushes two channels, not one bundled one.

noisy = Circuit(3)
noisy.h(0)
noisy.depolarize(0.01, [0, 1, 2])
print(len(noisy))
4

Why a truncation point between gates matters, and why noise makes a fixed threshold bite harder rather than softer, is A gate is a truncation point.

Noise channels

Noise is pushed like a gate, one channel per qubit in the list, with the noise module offering the same channels as factories for append.

from paulistrings import noise

circuit = Circuit(4)
circuit.h(0)
circuit.depolarize(0.01, [0])                    # p spread evenly over X, Y, Z
circuit.cnot(0, 1)
circuit.depolarize2(0.01, [(0, 1)])              # p over the 15 non-identity two-qubit Paulis
circuit.dephase(0.01, [1])                       # Z error with probability p
circuit.amplitude_damping(0.01, [2])             # relaxation toward |0> with probability gamma
circuit.pauli_channel(0.002, 0.002, 0.008, [3])  # X, Y, Z with px, py, pz; px + py + pz <= 1
circuit.append(noise.depolarize(0.01, qubit=0))
print(len(circuit))
8

In the Pauli basis a Pauli channel is a coefficient rescale, not a fan-out: depolarize(p) multiplies every non-identity Pauli on its qubit by 1 - 4p/3, and dephase(p) leaves Z alone and scales X and Y by 1 - 2p. pauli_channel(p/3, p/3, p/3, q) is depolarize(p, q) and pauli_channel(0, 0, p, q) is dephase(p, q). amplitude_damping is the one channel here that is not a Pauli channel: it moves Z partly onto the identity, so it changes the key set, and it is not self-adjoint, which is why its Heisenberg and forward results differ in more than a sign.

Every noise channel is non-unitary, so a circuit that carries one has no adjoint(). The rescale is why noise makes a simulation cheaper rather than dearer under a coefficient threshold, the effect B2 measures; the semantics table is Circuit.

Importing circuits

Three importers in the interop module build a Circuit from an outside description. Each maps what it can onto the gate vocabulary above and raises ValueError naming the instruction on anything it cannot; nothing is ever skipped silently. circuit_from_stim and circuit_from_qiskit import stim/qiskit lazily on first call — install the examples or bench extra from Installation to use them.

circuit_from_stim takes a stim circuit object, stim program text, or a path to a .stim file, expands REPEAT blocks, and returns (circuit, observable), where the observable comes from OBSERVABLE_INCLUDE instructions with Pauli targets or is None. Measurements, resets, detectors and sweep or record targets are errors.

import stim
from paulistrings import interop

circuit, observable = interop.circuit_from_stim(
    stim.Circuit("H 0\nCNOT 0 1\nDEPOLARIZE1(0.01) 0 1\nOBSERVABLE_INCLUDE(0) Z0 Z1")
)
print(len(circuit), [g["name"] for g in circuit.gates], observable.coefficients())
4 ['h', 'cnot', 'depolarize', 'depolarize'] [(1+0j)]

circuit_from_qiskit maps the named gates h s sdg x y z cx cz swap rz rx ry directly, rzz/rxx/ryy to pauli_rotation, and anything else with a one- or two-qubit unitary through the checked unitary_1q/unitary_2q path, reordering the two-qubit basis from qiskit’s little-endian convention for you. Measurements, resets, conditioned instructions and gates on more than two qubits are errors; qubit indices come from find_bit, so multi-register circuits import correctly.

from qiskit import QuantumCircuit

qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.rzz(0.3, 0, 1)
qc.t(1)
print([g["name"] for g in interop.circuit_from_qiskit(qc).gates])
['h', 'cnot', 'pauli_rotation', 'unitary_1q']

circuit_from_json builds from the frozen task-JSON schema’s "circuit" object, the interchange format shared with the cross-engine comparison, and load_task reads a whole task file — circuit, observable, truncation policy and direction — into one object.

task = interop.load_task({
    "version": 1,
    "n_qubits": 2,
    "circuit": {"gates": [{"name": "h", "qubits": [0]}, {"name": "rz", "qubits": [1], "theta": 0.3}]},
    "observable": {"ZI": 1.0},
    "truncation": {"min_abs_coeff": 1e-8},
    "run": {"direction": "heisenberg"},
})
print(len(task.circuit), len(task.observable), task.direction)
2 1 heisenberg

Unknown keys, unknown gate names and a missing run.direction are errors. Importer signatures are under Circuit; the stim importer’s sibling, stabilizers_from_stim, reads a Clifford circuit’s output state rather than its gates and belongs to Stabilizer states.

Hard edge: support on more than two qubits

The engine applies a channel through a lookup on its support, and that support is capped at two qubits: a channel acting on three or more makes propagate panic, with no fallback path. pauli_rotation is the one exemption and accepts a generator of any weight, so a ZZZ or weight-ten rotation is fine.

From Python the constructors keep you inside the edge: every named gate and noise method is one- or two-qubit, unitary_2q is the widest matrix accepted, and the importers reject a three-qubit instruction with ValueError before it reaches a circuit. The edge is therefore what a three-qubit gate becomes when you need one: a decomposition into one- and two-qubit gates, each its own truncation point, or a Pauli rotation if it is one.

See it in use

  • B2 — Noisy circuit verification — a depolarizing channel on every qubit a gate just touched, at 127 qubits, then the same run with each of the other channels swapped in, and the collapse in term count that follows.
  • B5 — Hybrid depth reduction — a circuit split into a front and a tail, the tail’s propagated observable written to a task JSON and read back with load_task.
  • A — Clifford point — the same recorded gate list handed to the engine and, as schema-v1 task JSON, to PauliPropagation.jl, for a term-for-term parity check.
  • C — Deep Trotter circuits — a kicked-Ising Trotter step on 127 qubits, one rotation per push, 5 420 channels at 20 steps.