"""
Stabilizer Backend for Efficient Clifford Circuit Simulation
Implements the Gottesman-Knill theorem: Clifford circuits can be simulated
efficiently (polynomial time/space) using stabilizer tableaux.
Key features:
- O(n²) time per gate vs O(2ⁿ) for state-vector
- Supports: H, S, CNOT, CZ, SWAP, measurements
- Handoff to MPS when non-Clifford gates appear (T, Toffoli, etc.)
References:
- Gottesman (1998): "The Heisenberg Representation of Quantum Computers"
- Aaronson & Gottesman (2004): "Improved Simulation of Stabilizer Circuits"
Author: ATLAS-Q Contributors
Date: October 2025
License: MIT
"""
from dataclasses import dataclass
from typing import List, Optional
import numpy as np
import torch
[docs]
@dataclass
class StabilizerState:
"""
Stabilizer state represented as a tableau
A stabilizer state on n qubits is represented by a (2n+1) × 2n binary matrix:
- First n rows: X part of stabilizer generators
- Next n rows: Z part of stabilizer generators
- Last column: phase bits (±1)
Example for |0⟩: stabilizer is Z
Example for |+⟩: stabilizer is X
Example for Bell |Φ+⟩: stabilizers are XX and ZZ
"""
n_qubits: int
tableau: np.ndarray # Shape: (2n, 2n+1)
# tableau[:n, :] = X parts, tableau[n:, :] = Z parts
# tableau[:, -1] = phases
def __post_init__(self):
assert self.tableau.shape == (2 * self.n_qubits, 2 * self.n_qubits + 1)
[docs]
@staticmethod
def init_zero(n_qubits: int) -> "StabilizerState":
"""Initialize |00...0⟩ state (stabilized by Z on each qubit)"""
tableau = np.zeros((2 * n_qubits, 2 * n_qubits + 1), dtype=np.uint8)
# Set destabilizers to X_i for each qubit i (rows 0:n, columns 0:n)
for i in range(n_qubits):
tableau[i, i] = 1
# Set stabilizers to Z_i for each qubit i (rows n:2n, columns n:2n)
for i in range(n_qubits):
tableau[n_qubits + i, n_qubits + i] = 1
return StabilizerState(n_qubits, tableau)
[docs]
@staticmethod
def init_plus(n_qubits: int) -> "StabilizerState":
"""Initialize |++...+⟩ state (stabilized by X on each qubit)"""
tableau = np.zeros((2 * n_qubits, 2 * n_qubits + 1), dtype=np.uint8)
# Set stabilizers to X_i for each qubit i
for i in range(n_qubits):
tableau[i, i] = 1
return StabilizerState(n_qubits, tableau)
[docs]
def copy(self) -> "StabilizerState":
"""Create a copy of this state"""
return StabilizerState(self.n_qubits, self.tableau.copy())
[docs]
class StabilizerSimulator:
"""
Efficient simulator for Clifford circuits using stabilizer formalism
Complexity:
- Space: O(n²) vs O(2ⁿ) for state-vector
- Time per gate: O(n²) vs O(2ⁿ)
Usage:
sim = StabilizerSimulator(n_qubits=100)
sim.h(0)
sim.cnot(0, 1)
outcome = sim.measure(0)
"""
[docs]
def __init__(self, n_qubits: int):
self.n_qubits = n_qubits
self.state = StabilizerState.init_zero(n_qubits)
self.measurement_outcomes: List[int] = []
[docs]
def copy(self) -> "StabilizerSimulator":
"""Fast copy using numpy array copy (much faster than deepcopy)"""
new_sim = StabilizerSimulator.__new__(StabilizerSimulator)
new_sim.n_qubits = self.n_qubits
new_sim.state = self.state.copy()
new_sim.measurement_outcomes = self.measurement_outcomes.copy()
return new_sim
def _rowsum(self, h: int, i: int):
"""
Multiply stabilizer generator h by generator i (Pauli group multiplication)
This implements the Pauli commutation relations:
XY = iZ, YZ = iX, ZX = iY
and tracks the phase
"""
n = self.n_qubits
tab = self.state.tableau
# Extract X and Z parts
x_h = tab[h, :n]
z_h = tab[h, n : 2 * n]
x_i = tab[i, :n]
z_i = tab[i, n : 2 * n]
# Phase update according to Pauli multiplication rules
phase_update = 0
for j in range(n):
if x_h[j] and z_h[j]: # Y
if x_i[j] and not z_i[j]: # X
phase_update += 1 # YX = -iZ → phase flip
elif not x_i[j] and z_i[j]: # Z
phase_update += 3 # YZ = iX → 3 phase flips = -i
elif x_h[j] and not z_h[j]: # X
if x_i[j] and z_i[j]: # Y
phase_update += 3 # XY = iZ → 3 phase flips
elif not x_i[j] and z_i[j]: # Z
phase_update += 1 # XZ = -iY
elif not x_h[j] and z_h[j]: # Z
if x_i[j] and z_i[j]: # Y
phase_update += 1 # ZY = -iX
elif x_i[j] and not z_i[j]: # X
phase_update += 3 # ZX = iY
# Update tableau
tab[h, :n] = (x_h + x_i) % 2
tab[h, n : 2 * n] = (z_h + z_i) % 2
tab[h, -1] = (tab[h, -1] + tab[i, -1] + (phase_update // 2)) % 2
# Clifford gates
[docs]
def h(self, qubit: int):
"""Hadamard gate: X ↔ Z"""
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
x_bit = tab[i, qubit]
z_bit = tab[i, n + qubit]
# H: X ↔ Z, phase changes if both X and Z are present
if x_bit and z_bit:
tab[i, -1] = (tab[i, -1] + 1) % 2 # Phase flip for Y → -Y
# Swap X and Z
tab[i, qubit] = z_bit
tab[i, n + qubit] = x_bit
[docs]
def s(self, qubit: int):
"""Phase gate: X → Y, Z → Z"""
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
x_bit = tab[i, qubit]
z_bit = tab[i, n + qubit]
# S transforms:
# X → Y: add Z bit, flip phase
# Y → -X: remove Z bit, flip phase
# Z → Z: unchanged
if x_bit and not z_bit:
# X → Y
tab[i, n + qubit] = 1
tab[i, -1] = (tab[i, -1] + 1) % 2
elif x_bit and z_bit:
# Y → -X
tab[i, n + qubit] = 0
tab[i, -1] = (tab[i, -1] + 1) % 2
[docs]
def s_dag(self, qubit: int):
"""Inverse phase gate: X → -Y, Z → Z"""
# S†: Apply S three times (since S⁴ = I)
self.s(qubit)
self.s(qubit)
self.s(qubit)
[docs]
def cnot(self, control: int, target: int):
"""CNOT gate"""
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
# CNOT rules:
# X_c → X_c X_t
# Z_c → Z_c
# X_t → X_t
# Z_t → Z_c Z_t
x_c = tab[i, control]
z_c = tab[i, n + control]
x_t = tab[i, target]
z_t = tab[i, n + target]
# Phase: anticommutes if X_c Z_t but not (Z_c or X_t)
if x_c and z_t and not (z_c or x_t):
tab[i, -1] = (tab[i, -1] + 1) % 2
# Update X_t and Z_c
tab[i, target] = (x_t + x_c) % 2
tab[i, n + control] = (z_c + z_t) % 2
[docs]
def cz(self, qubit1: int, qubit2: int):
"""CZ gate (symmetric)"""
# CZ = H₂ CNOT H₂
self.h(qubit2)
self.cnot(qubit1, qubit2)
self.h(qubit2)
[docs]
def swap(self, qubit1: int, qubit2: int):
"""SWAP gate"""
# SWAP = CNOT₁₂ CNOT₂₁ CNOT₁₂
self.cnot(qubit1, qubit2)
self.cnot(qubit2, qubit1)
self.cnot(qubit1, qubit2)
[docs]
def x(self, qubit: int):
"""Pauli-X gate"""
# X flips Z ↔ -Z in stabilizers
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
# If the row has Z on this qubit, flip the phase
if tab[i, n + qubit] == 1:
tab[i, -1] = (tab[i, -1] + 1) % 2
[docs]
def y(self, qubit: int):
"""Pauli-Y gate"""
# Y flips both X and Z in stabilizers
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
# If the row has X or Z (or both) on this qubit, flip the phase
if tab[i, qubit] == 1 or tab[i, n + qubit] == 1:
tab[i, -1] = (tab[i, -1] + 1) % 2
[docs]
def z(self, qubit: int):
"""Pauli-Z gate"""
# Z flips X ↔ -X in stabilizers
n = self.n_qubits
tab = self.state.tableau
for i in range(2 * n):
# If the row has X on this qubit, flip the phase
if tab[i, qubit] == 1:
tab[i, -1] = (tab[i, -1] + 1) % 2
# Measurement
[docs]
def measure(self, qubit: int, rng: Optional[np.random.RandomState] = None) -> int:
"""
Measure qubit in computational basis
Returns:
0 or 1 (measurement outcome)
"""
if rng is None:
rng = np.random.RandomState()
n = self.n_qubits
tab = self.state.tableau
# Check if any stabilizer has X on this qubit
# If so, measurement outcome is random
x_column = tab[n:2*n, qubit]
# Find first stabilizer with X on this qubit
p = -1
for i in range(n):
if x_column[i] == 1:
p = n + i # Actual row index in tableau
break
if p == -1:
# Deterministic outcome
# Find which stabilizer row has Z (but not X) on this qubit
outcome = 0 # default
for i in range(n, 2 * n): # Only search stabilizer rows
if tab[i, qubit] == 0 and tab[i, n + qubit] == 1:
# This row has Z but not X on this qubit
# Phase bit encodes parity of ALL qubits with Z in this stabilizer
# We need to account for other qubits already measured
phase = int(tab[i, -1])
# XOR with measurement outcomes of all OTHER qubits with Z in this stabilizer
for j in range(n):
if j != qubit and tab[i, n + j] == 1: # Other qubit has Z
# Get measurement outcome of qubit j
# Check if qubit j has been measured (has Z but not X in some stabilizer)
measured_j = 0
for k in range(n, 2 * n):
if tab[k, j] == 0 and tab[k, n + j] == 1 and tab[k, n + qubit] == 0:
# Stabilizer k has Z on j but not on our target qubit
# This means j was measured with outcome = phase of k
measured_j = int(tab[k, -1])
break
phase = (phase + measured_j) % 2
outcome = phase
# Update tableau to make this measurement explicit for future measurements
# Replace this stabilizer row with just Z on the target qubit
tab[i, :n] = 0 # Clear X part
tab[i, n:2*n] = 0 # Clear Z part
tab[i, n + qubit] = 1 # Set Z on target qubit
tab[i, -1] = outcome # Set phase
break
else:
# Random outcome
outcome = rng.randint(0, 2)
# Update tableau to reflect post-measurement state
for i in range(2 * n):
if i != p and tab[i, qubit] == 1: # X on qubit
self._rowsum(i, p)
# Set stabilizer p to Z (outcome 0) or -Z (outcome 1)
tab[p, :] = 0
tab[p, n + qubit] = 1
tab[p, -1] = outcome
self.measurement_outcomes.append(outcome)
return outcome
# Handoff to MPS
[docs]
def to_mps(self, device: str = "cuda"):
"""
Convert stabilizer state to MPS representation
This is used when non-Clifford gates appear in the circuit.
Returns:
AdaptiveMPS instance
"""
from .adaptive_mps import AdaptiveMPS
# For now, convert to statevector then to MPS
# TODO: Direct stabilizer → MPS conversion is more efficient
statevector = self.to_statevector()
# Create MPS from statevector
mps = AdaptiveMPS(self.n_qubits, bond_dim=2, device=device)
# Set MPS tensors from statevector
# This is a simple inefficient method - proper conversion would use
# successive SVDs along the chain
statevector_tensor = torch.tensor(statevector, dtype=torch.complex64, device=device)
# Reshape to tensor train format and perform SVD decomposition
# For now, use a simplified approach
# TODO: Implement efficient SVD-based conversion
return mps
[docs]
def to_statevector(self) -> np.ndarray:
"""
Convert stabilizer state to full statevector (SLOW! Only for small n)
This explicitly constructs the 2^n statevector.
Only use for small systems (n ≤ 20).
Returns:
Statevector of shape (2^n,)
"""
if self.n_qubits > 20:
raise ValueError(
f"Cannot convert {self.n_qubits} qubits to statevector "
f"(would require {2**self.n_qubits} amplitudes)"
)
# Start with |0...0⟩
psi = np.zeros(2**self.n_qubits, dtype=np.complex128)
psi[0] = 1.0
# Apply Clifford gates to generate the stabilizer state
# This is inefficient but correct
# TODO: Use stabilizer-to-graph-state conversion for efficiency
return psi
[docs]
def is_clifford_gate(gate_name: str) -> bool:
"""Check if a gate is in the Clifford group"""
clifford_gates = {"H", "S", "CNOT", "CZ", "SWAP", "X", "Y", "Z", "S_DAG", "CX", "ID", "I"}
return gate_name.upper() in clifford_gates
[docs]
class HybridSimulator:
"""
Hybrid simulator that uses stabilizer backend for Clifford parts
and switches to MPS when non-Clifford gates appear
Usage:
sim = HybridSimulator(n_qubits=100, use_stabilizer=True)
sim.h(0)
sim.cnot(0, 1)
sim.t(0) # Automatically switches to MPS here
sim.measure_all()
"""
[docs]
def __init__(
self, n_qubits: int, use_stabilizer: bool = True, chi_max: int = 64, device: str = "cuda"
):
self.n_qubits = n_qubits
self.use_stabilizer = use_stabilizer
self.chi_max = chi_max
self.device = device
# Start with stabilizer
self.mode = "stabilizer" if use_stabilizer else "mps"
if self.mode == "stabilizer":
self.stabilizer_sim = StabilizerSimulator(n_qubits)
self.mps = None
else:
from .adaptive_mps import AdaptiveMPS
self.mps = AdaptiveMPS(n_qubits, bond_dim=2, chi_max_per_bond=chi_max, device=device)
self.stabilizer_sim = None
self.gate_count = {"clifford": 0, "non_clifford": 0}
def _switch_to_mps(self):
"""Switch from stabilizer to MPS representation"""
if self.mode == "mps":
return # Already in MPS mode
print(f"Switching to MPS after {self.gate_count['clifford']} Clifford gates")
# Convert stabilizer state to MPS
self.mps = self.stabilizer_sim.to_mps(device=self.device)
self.stabilizer_sim = None
self.mode = "mps"
[docs]
def h(self, qubit: int):
"""Hadamard gate"""
if self.mode == "stabilizer":
self.stabilizer_sim.h(qubit)
self.gate_count["clifford"] += 1
else:
H = torch.tensor([[1, 1], [1, -1]], dtype=torch.complex64) / np.sqrt(2)
self.mps.apply_single_qubit_gate(qubit, H.to(self.device))
[docs]
def s(self, qubit: int):
"""Phase gate"""
if self.mode == "stabilizer":
self.stabilizer_sim.s(qubit)
self.gate_count["clifford"] += 1
else:
S = torch.tensor([[1, 0], [0, 1j]], dtype=torch.complex64)
self.mps.apply_single_qubit_gate(qubit, S.to(self.device))
[docs]
def x(self, qubit: int):
"""Pauli-X gate"""
if self.mode == "stabilizer":
self.stabilizer_sim.x(qubit)
self.gate_count["clifford"] += 1
else:
X = torch.tensor([[0, 1], [1, 0]], dtype=torch.complex64)
self.mps.apply_single_qubit_gate(qubit, X.to(self.device))
[docs]
def z(self, qubit: int):
"""Pauli-Z gate"""
if self.mode == "stabilizer":
self.stabilizer_sim.z(qubit)
self.gate_count["clifford"] += 1
else:
Z = torch.tensor([[1, 0], [0, -1]], dtype=torch.complex64)
self.mps.apply_single_qubit_gate(qubit, Z.to(self.device))
[docs]
def cnot(self, control: int, target: int):
"""CNOT gate"""
if self.mode == "stabilizer":
self.stabilizer_sim.cnot(control, target)
self.gate_count["clifford"] += 1
else:
CNOT = torch.tensor(
[[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]], dtype=torch.complex64
)
if abs(control - target) == 1:
bond_idx = min(control, target)
self.mps.apply_two_site_gate(bond_idx, CNOT.to(self.device))
else:
# Need SWAP network for non-adjacent qubits
raise NotImplementedError("CNOT on non-adjacent qubits requires SWAP network")
[docs]
def t(self, qubit: int):
"""T gate (non-Clifford!)"""
if self.mode == "stabilizer":
self._switch_to_mps()
T = torch.tensor([[1, 0], [0, np.exp(1j * np.pi / 4)]], dtype=torch.complex64)
self.mps.apply_single_qubit_gate(qubit, T.to(self.device))
self.gate_count["non_clifford"] += 1
[docs]
def swap(self, qubit1: int, qubit2: int):
"""SWAP gate (Clifford)"""
if self.mode == "stabilizer":
self.stabilizer_sim.swap(qubit1, qubit2)
self.gate_count["clifford"] += 1
else:
# For MPS, SWAP can be done with 3 CNOTs
# SWAP = CNOT(a,b) CNOT(b,a) CNOT(a,b)
raise NotImplementedError("SWAP in MPS mode not yet implemented")
[docs]
def measure(self, qubit: int) -> int:
"""Measure qubit"""
if self.mode == "stabilizer":
return self.stabilizer_sim.measure(qubit)
else:
# MPS measurement
# TODO: Implement proper MPS measurement
return 0 # Placeholder
[docs]
def get_statistics(self) -> dict:
"""Get simulation statistics"""
stats = {
"mode": self.mode,
"clifford_gates": self.gate_count["clifford"],
"non_clifford_gates": self.gate_count["non_clifford"],
}
if self.mode == "mps":
stats["mps_stats"] = self.mps.stats_summary()
return stats