Gates in a Stabilizer Simulator Tutorial 01 of 10
Cliffords conjugate operators, not amplitudes
A general
When a gate
so if
1-qubit stabilizer under a gate
Operators, never 2^n numbers
A Pauli is two bits plus a sign
To compute with Paulis we encode each single-qubit factor as two bits (an x bit and a z bit) plus one global sign bit for the whole operator (
Pauli ↔ bits
The full tableau
A stabilizer state of
Single-qubit gates are tiny bit ops
Each Clifford is a fixed recipe on the bits. The order matters: read the original
Bit-level gate playground
Worked example: H on Z, then S on X (every number from the rules)
- Start at
: bits , sign . - Apply
. Sign term , so sign stays . Swap bits: . with sign reads as . So . ✓ - Now start fresh at
: , sign . Apply . Sign term ; then makes .
Result:
The H step of that example is four lines of arithmetic:
x, z, sign = 0, 1, 0 # start at +Z
sign = sign ^ (x & z) # H sign term, from the original bits
x, z = z, x # H swaps x and z
print(("+" if sign == 0 else "-") + "IXZY"[x + 2 * z]) # -> +XThe CX gate: two propagation directions
The two-qubit case follows the same recipe, with one asymmetry.
Two bit copies fall out, and they go in opposite directions:
CX propagator
Control and target are NOT symmetric
A natural guess is that CX copies the control onto the target for everything. That holds for X, but Z propagates the other way, target -> control.
Worked example: The two propagations, in bits
- X on the control.
is . Apply -> . Now both qubits carry an X. - Sign term
. Result: , the + forward copy. - Z on the target.
is . Apply -> . Now both qubits carry a Z. - Sign term
. Result: , the backward copy.
Result:
For contrast,
| CZ generator | → becomes |
|---|---|
X₀ | +X₀Z₁ |
X₁ | +Z₀X₁ |
Z₀ | +Z₀ |
Faults spread through entangling gates
This propagation is what QEC cares about. Because
Error propagation through a GHZ encoder
Worked example: One X on the control lights two detectors
- Inject
on just before (set inject = X, qubit q0, before gate #1). sends : the fault is now on two data qubits. - If
also feeds the X keeps copying forward: a chain of X's across the support. - On a 1-D code each flipped data qubit sits under a parity check, but only the two ends of the flipped chain break parity: every interior check sees two flips and stays quiet.
Result: One physical
Why decoders see correlated checks
Propagation is what makes one fault touch several checks. The pairs MWPM matches, the correlated checks belief propagation reasons over, and the detector-error-model edges all trace back to this: a Pauli fault, conjugated forward through the circuit's CX gates, ends up supported on several detectors.
Signs, phases, and the only randomness
The sign bit is more than bookkeeping.
Sign carries the outcome
Gates are deterministic; only measurement rolls dice
Every gate on this page is a deterministic rewrite of operator bits; nothing is random. In a Clifford simulator the only source of randomness is measuring a Pauli that anticommutes with a stabilizer, where the outcome is a fair coin and the tableau is updated to match. That split (deterministic operator updates, randomness only at measurement) is what makes the whole scheme efficient and exactly samplable.
Why this scales, and where it goes next
Tally the cost. A single-qubit gate edits one column's bits; a
Every gate as a bit rule
| gate | sign ^= | bit update | effect |
|---|---|---|---|
H(a) | x_a · z_a | swap x_a ↔ z_a | X↔Z, Y→−Y |
S(a) | x_a · z_a | z_a ^= x_a | X→Y, Z→Z |
S†(a) | x_a · ¬z_a | z_a ^= x_a | X→−Y |
X(a) | z_a | (none) | phase on Z,Y |
Z(a) | x_a | (none) | phase on X,Y |
Y(a) | x_a ^ z_a | (none) | phase on X,Z |
CX(a,b) | x_a·z_b·(x_b^z_a^1) | x_b ^= x_a; z_a ^= z_b | X fwd, Z back |
CZ(a,b) | x_a·x_b·(z_a^z_b) | z_a ^= x_b; z_b ^= x_a | symmetric |
The complete rule set used on this page, copied from qliff's tableau core. (The sign column is computed from the original bits.)
Implementation
The table above is the whole engine. The tabs below write it down three ways: pseudocode for the row update (the invariant is the order: the sign term reads the original bits, and only then do the bits move), a minimal NumPy version whose prints reproduce every worked example on this page, and the same checks through qliff's API, where Simulator(n) starts in self, so they chain), and stabilizers() reads back the signed rows.
apply_gate(row, gate):
# row = (x[0..n), z[0..n), sign): one conjugated Pauli
# step 1: fold the ORIGINAL bits into the sign
# step 2: permute or XOR the bits
H(a):
sign ^= x[a] AND z[a]
swap x[a], z[a]
S(a):
sign ^= x[a] AND z[a]
z[a] ^= x[a]
CX(a, b): # a = control, b = target
sign ^= x[a] AND z[b] AND (x[b] XOR z[a] XOR 1)
x[b] ^= x[a] # X copies forward (control to target)
z[a] ^= z[b] # Z copies backward (target to control)import numpy as np
X_BITS = {"I": 0, "X": 1, "Y": 1, "Z": 0}
Z_BITS = {"I": 0, "X": 0, "Y": 1, "Z": 1}
def row(letters):
"""One tableau row: x bits, z bits, sign bit (0 = +, 1 = -)."""
x = np.array([X_BITS[c] for c in letters], dtype=np.uint8)
z = np.array([Z_BITS[c] for c in letters], dtype=np.uint8)
return x, z, 0
def show(x, z, sign):
body = "".join("IXZY"[int(x[j]) + 2 * int(z[j])] for j in range(len(x)))
return ("-" if sign == 1 else "+") + body
def H(x, z, sign, a):
sign = sign ^ int(x[a] & z[a]) # sign from the ORIGINAL bits
x[a], z[a] = z[a], x[a]
return sign
def S(x, z, sign, a):
sign = sign ^ int(x[a] & z[a])
z[a] = z[a] ^ x[a]
return sign
def CX(x, z, sign, a, b):
sign = sign ^ int(x[a] & z[b] & (x[b] ^ z[a] ^ 1))
x[b] = x[b] ^ x[a] # X copies forward: control -> target
z[a] = z[a] ^ z[b] # Z copies backward: target -> control
return sign
x, z, sign = row("Z")
sign = H(x, z, sign, 0)
print("H Z H =", show(x, z, sign)) # -> H Z H = +X
x, z, sign = row("X")
sign = S(x, z, sign, 0)
print("S X S_dag =", show(x, z, sign)) # -> S X S_dag = +Y
x, z, sign = row("Y")
sign = H(x, z, sign, 0)
print("H Y H =", show(x, z, sign)) # -> H Y H = -Y
x, z, sign = row("XI")
sign = CX(x, z, sign, 0, 1)
print("CX on X_c =", show(x, z, sign)) # -> CX on X_c = +XX
x, z, sign = row("IZ")
sign = CX(x, z, sign, 0, 1)
print("CX on Z_t =", show(x, z, sign)) # -> CX on Z_t = +ZZfrom qliff import Simulator
# |0> is stabilized by +Z; H conjugates it to +X (H Z H = +X)
sim = Simulator(1)
print(sim.stabilizers()) # -> ['+Z']
sim.H(0)
print(sim.stabilizers()) # -> ['+X']
# S sends +X to +Y (S X S_dag = +Y)
sim.S(0)
print(sim.stabilizers()) # -> ['+Y']
# CX: X on the control copies forward, Z on the target copies backward
sim = Simulator(2)
sim.H(0)
print(sim.stabilizers()) # -> ['+XI', '+IZ']
sim.CX(0, 1)
print(sim.stabilizers()) # -> ['+XX', '+ZZ']
# the sign is the measurement outcome: X flips +Z to -Z, so M reads 1
sim = Simulator(1)
sim.X(0)
print(sim.stabilizers()) # -> ['-Z']
print(sim.M(0)) # -> 1Where these gates reappear
The Cliffords