qsimlab.analysis — magic, stabilizer rank, simulability, monitored circuits¶
Status: provisional (phase 2; see ../API.md §1, §8 and the
qsimlab.analysis section). These functions explain why a circuit is easy or hard to simulate
exactly, with the invariants the engines themselves use. Most of them never build a state vector.
Everything is checked against independent references in python/tests/test_analysis.py: brute
force over all Pauli strings, the active dimension recomputed in pure Python, dense numpy states,
and Born probabilities of a numpy simulation forced to the same outcomes.
| what | function | engine underneath |
|---|---|---|
active dimension d_k, factored dimension f_k, skeleton entanglement, support bound |
magic_profile(c) → MagicProfile |
qsim_lab::magic_atlas::profile (magic-atlas.md) |
stabilizer nullity, stabilizer 2-Rényi entropy (n ≤ 13) |
state_magic(c_or_state) → StateMagic |
magic_atlas::state_magic (all 4^n Pauli expectations, one Walsh–Hadamard transform per x) |
| number of stabilizer terms after every gate | branching_rank(c) → BranchingRank |
qsim_lab::engines::stab_rank (theory-rank.md) |
| per-engine work estimates + the planner's explanation | simulability(c, request) → Simulability |
simulability::features, Planner v2 |
Clifford+T with mid-circuit measurements: d(t), Born probabilities, cut entropies |
monitored(c) → MonitoredResult |
qsim_lab::engines::monitored (magic-transition.md) |
The rotation frame in one paragraph¶
Lower a unitary circuit to Clifford gates and Z rotations and push every rotation to the front
through the Clifford part: U = C · R_m ⋯ R_1 with R_j = exp(−iθ_j Q_j/2) and Pauli axes Q_j.
Acting on |0^n⟩, only the x-parts of the axes create superposition, so
U|0^n⟩ = C (|φ⟩ ⊗ |0⟩) where |φ⟩ lives on d = dim span{x(Q_j)} qubits: the active
dimension, the exact register size of the compressed-state engine (2^d amplitudes). The
rotations only couple the coordinates they touch, so |φ⟩ factorises; f is the largest
factor. The stabilizer nullity of the final state is at most d.
Tutorial: the magic profile of QFT vs Grover¶
A 12-qubit QFT applied to a basis state, and Grover search for |1…1⟩ on 8 qubits with a
Toffoli-ladder oracle (6 ancillas), 2 iterations:
>>> import math
>>> import qsimlab as qs
>>> import qsimlab.analysis as an
>>> def qft(n, x):
... c = qs.Circuit(n)
... for q in range(n):
... if x >> q & 1:
... c.x(q)
... for j in reversed(range(n)):
... c.h(j)
... for k in reversed(range(j)):
... c.cp(k, j, math.pi / 2 ** (j - k))
... return c
>>> def grover(n, iterations):
... s, anc = list(range(n)), list(range(n, 2 * n - 2))
... c = qs.Circuit(2 * n - 2)
... def mcz(): # Z on |1...1> of s, via a Toffoli ladder into anc
... c.ccx(s[0], s[1], anc[0])
... for i in range(2, n - 1):
... c.ccx(s[i], anc[i - 2], anc[i - 1])
... c.cz(anc[n - 3], s[-1])
... for i in reversed(range(2, n - 1)):
... c.ccx(s[i], anc[i - 2], anc[i - 1])
... c.ccx(s[0], s[1], anc[0])
... for q in s:
... c.h(q)
... for _ in range(iterations):
... mcz() # oracle
... for q in s:
... c.h(q).x(q)
... mcz() # diffusion
... for q in s:
... c.x(q).h(q)
... return c
>>> for name, c in [("qft", qft(12, 0b101100111)), ("grover", grover(8, 2))]:
... p = an.magic_profile(c)
... print(f"{name:<7} n={p.num_qubits} T={p.t_count:3} rot={p.rotations:3} d={p.d:2} f={p.f:2} "
... f"log2_work={p.log2_work:5.2f} factored={p.log2_work_factored:5.2f}")
qft n=12 T= 33 rot=198 d=11 f= 1 log2_work=14.45 factored= 8.04
grover n=14 T=336 rot=336 d=14 f=14 log2_work=22.22 factored=22.22
Both circuits reach almost full active dimension, but the QFT of a basis state factorises
completely (f = 1: its output is a product state, a 2-amplitude register per qubit), while
Grover's state is one 14-qubit block from the first oracle on. The same profile is what the
compressed-state engine pays: 2^14.45 amplitude updates for the QFT in the unfactored frame and
2^8.04 factored, against 2^22.22 for Grover.
The profile is a trace, so you can see when the magic arrives: Grover's register is full
(d = 14) by the first checkpoint, after 31 gates, in the first oracle's Toffoli ladder:
>>> p = an.magic_profile(grover(8, 2), checkpoints=4)
>>> [(gate, d, f) for gate, rot, t, d, f, e in p.checkpoints]
[(30, 14, 14), (61, 14, 14), (92, 14, 14), (123, 14, 14)]
>>> int(p.d_profile[0]), int(p.d_profile[15]), int(p.rotation_gate[15]), int(p.d_profile[-1])
(1, 7, 10, 14)
State magic needs the dense state (n ≤ 13). For the QFT of a basis state the nullity is
n − 2 (magic-atlas.md, finding 4: nullity is not
cost) even though the state is a product of single-qubit states:
>>> m = an.state_magic(qft(8, 0b1011001))
>>> m.nullity, round(m.m2, 4)
(6.0, 1.5728)
>>> an.branching_rank(qft(8, 0b1011001)).rank
64
Branching rank¶
branching_rank runs the exact low-rank simulator and records how many stabilizer terms the
state needs after every gate. A non-Clifford gate splits a term only when it cannot be applied as
a Clifford on that term, and equal rays merge again:
>>> c = qs.Circuit(3).h(0).t(0).cx(0, 1)
>>> an.branching_rank(c).trace.tolist()
[1, 2, 2]
>>> an.branching_rank(c.copy().t(1)).trace.tolist()[-1] # (|00> + i|11>)/√2 is a stabilizer state
1
>>> b = an.branching_rank(grover(5, 2))
>>> b.max_rank, b.rank, b.overflow
(5, 2, False)
Simulability and the planner¶
simulability returns the planner's features (one log2 work estimate per engine) and its
explanation (the engines ranked by predicted seconds, from cost models fitted on an M1 Pro):
>>> s = an.simulability(qft(30, 12345))
>>> s.features["d"], s.features["chi_bits"], s.explanation.engine
(29, 15, 'mps')
>>> [e for e, _ in s.log2_costs][:3]
['pauli-frame', 'hsf', 'compressed']
Monitored Clifford+T circuits¶
monitored simulates circuits with mid-circuit Z measurements exactly in the rotation frame.
A measurement is either random in the frame (probability 1/2, no amplitude work), determined, or
Born-sampled on the active register, which then shrinks by one qubit; d(t) does not depend on
the outcomes:
>>> import numpy as np
>>> rng = np.random.default_rng(1)
>>> c = qs.Circuit(8)
>>> for layer in range(12):
... for q in range(layer % 2, 7, 2):
... _ = c.h(q).cx(q, q + 1).t(q + 1)
... for q in range(8):
... if rng.random() < 0.3:
... _ = c.measure(q)
>>> r = an.monitored(c, seed=7, cuts=[[0, 1, 2, 3]], entropy_every=len(c) // 4)
>>> r.final_d, r.max_d, len(r.outcomes), r.stats["t_activating"], r.stats["meas_register"]
(3, 7, 29, 30, 27)
>>> [(k, round(lo, 3), round(hi, 3), round(s2, 3)) for k, [(lo, hi, s2)] in r.entropies]
[(37, 0.0, 0.0, 0.0), (75, 0.0, 3.0, 0.0), (113, 0.0, 3.0, 0.0), (151, 0.0, 3.0, 0.415), (154, 0.0, 2.0, 0.0)]
>>> bool((an.monitored(c, seed=8).d == r.d).all()) # same d(t) for other outcomes
True
exact=False keeps only the tableau: d(t) and entropy bounds for any n, without amplitudes.
Limitations¶
magic_profile,branching_rankandsimulabilitytake unitary circuits (terminal measurements are dropped; mid-circuit measurements, resets, conditionals and noise raiseUnsupportedOperationError);monitoredtakes them all, but notreadout_error.state_magicenumerates all4^nPauli expectations:n ≤ 13.branching_rankstops atmax_terms(overflow=True); its rank is an upper bound on the stabilizer rank, not the rank itself.state=Truebuilds a dense vector (n ≤ 20).monitoredreturns the state up to a global phase (it does not track it); the exact Rényi-2 entropys2is computed only when the cost2^(d + ...)stays under2^max_cost_log2(elseNone, with the bounds still exact). One call is one trajectory; withexact=False, register outcomes are drawn 50/50, soc_ifon them is not physical.- Planner predictions are relative (fitted on one machine, single thread), see
API.md§9.