Chapter 26 — Key Takeaways (Debugging Quantum Programs)

Part V opens here. Twenty-five chapters produced bugs; this is the method that finds them.

Why the classical loop is unavailable

  • You cannot print an intermediate state — reading collapses it. A print is a measurement.
  • You cannot step through execution — no breakpoint pauses a superposition.
  • You cannot read the output — it is a sampled distribution, and a subtly wrong circuit differs from a correct one by about shot noise ($0.5/\sqrt N$, Ch. 24).
  • You cannot tell a bug from noise — Ch. 19's dirty ancilla was indistinguishable from decoherence.

Debug on a simulator, where you can see everything. Validate on hardware, where you can see nothing. Two activities, two toolsets. Conflating them is the field's most common mistake.

What the simulator gives back

sv = Statevector.from_instruction(qc)          # every amplitude, exactly, no shots

def prefix(qc, k):                             # a stepping debugger in four lines
    out = QuantumCircuit(qc.num_qubits)
    for inst in qc.data[:k]: out.append(inst)
    return out

save_statevector(label=...) does the same from inside Aer, for circuits that must run through it anyway. And the wall both hit:

    28 qubits:      268,435,456 amplitudes =     4 GB
    30 qubits:    1,073,741,824 amplitudes =    16 GB
    40 qubits:  1.1 x 10^12     amplitudes = 16 TB

Dies around 30–35 qubits. Past that: stabilizer method if Clifford (Ch. 11), MPS if entanglement is low, or debug subcircuits.

★ Four equality tests, four questions

For $XZXZ = -I$ versus $I$:

   Operator(p) == Operator(q)        False
   process_fidelity == 1             True
   Statevector.equiv                 True
   state_fidelity == 1               True

All four are correct. Global phase cancels in $|\langle\psi|\psi\rangle|^2$ — until you control the gate:

   Operator(ctrl-p) == Operator(ctrl-q)        False    <-- phase became RELATIVE
   Operator(ctrl-p).equiv(Operator(ctrl-q))    False    <-- and not a phase convention

⚛️ Choose by USE, not by strictness.

text standalone program -> equiv / process_fidelity will be CONTROLLED -> Operator equality, exactly after a specific input -> state_fidelity input-independent whole map -> process_fidelity or Operator

Ch. 18 §18.4 hit this from the other side: translation loses global phase, harmless until the result becomes a controlled subroutine.

★★ Circuit bisection

Binary search the first divergent instruction. Valid because divergence is monotone — once two circuits differ, more gates cannot make them agree.

     circuit ops      linear    bisection
             500         500           10
           3,368       3,368           13    <-- Ch. 23's Shor(15)
      20,000,000  20,000,000           26    <-- Shor on a 2048-bit key

★★ ...and the bug inside the debugger

Bisecting on states from $|000\rangle$ found nothing for a circuit with process fidelity 0.9498 against its reference. Every control reaching the buggy gate was in $|0\rangle$, so it never fired.

$|{+}{+}{+}\rangle$ was blind too — the Hadamard maps $|+\rangle \to |0\rangle$ first. Counting properly:

   computational basis states blind:   4/8    ['000','010','100','110']
   states over {0, 1, +} blind:       11/27   (41%)
   random states blind:               0/100

⚠️ You cannot reason your way to a safe test input — choosing a good one requires already knowing where the bug is. $|1{+}0\rangle$ was written into the checkpoint as one that "obviously" catches it. It does not.

Use Operator while the circuit is small enough (~12–14 qubits). Past that, random_statevector, and more than one.

And the tool must refuse: DIVERGED / AGREE / BLIND. A state-based bisection that finds nothing cross-checks against the operator first. "I found nothing" and "there is nothing" are different claims, and a debugger that conflates them certifies broken circuits.

Bug Ch. Diagnostic Result
Bit order 14 Operator(a) == Operator(b.reverse_bits()) True → convention, not logic
Dirty ancilla 19 purity(partial_trace(sv, anc)) 1.0000 → 0.6250
Wrong convention 22 process_fidelity vs library gate 1.0000 vs 0.2500 / 0.1547 / 0.2500
Deleted gates 25 diff count_ops() across transpile id: 1 → 0
Global phase 18 the equality test matching the use see above

A pure state that has gone mixed on a NOISELESS simulator is not noise. There is no noise. It is entanglement with something you forgot to uncompute — and the number is exact, not statistical.

Assertions inside circuits

p_dirty = 1 - Statevector.from_instruction(qc).probabilities(ancillas)[0]
   uncompute=True    P(ancilla != |0>) = 0.0000   CLEAN
   uncompute=False   P(ancilla != |0>) = 0.2500   DIRTY

Also worth asserting: unentangled registers (purity 1), uniform distributions, real amplitudes where the algorithm says they should be, and norm preservation.

★ Debugging what actually runs

   initial layout (virtual -> physical): [3, 2, 4, 1, 0]
   final layout   (after routing):       [4, 2, 3]

Three ways to verify a transpiled circuit:

   1. Operator(logical) vs Operator(transpiled)      QiskitError: 3 qubits vs 5
   2. padded, IGNORING the layout                    0.001406      <-- FALSE ALARM
   3. Operator.from_circuit(transpiled)              1.0000000000  <-- CORRECT

⚠️ A comparison that runs is not a comparison that is right. 0.001406 is type-correct, exception-free, deterministic, reproducible — and comparing two circuits on mismatched wires.

And the ceiling: the same circuit on a 127-qubit backend gives ValueError: Maximum allowed dimension exceeded. On real-sized hardware you cannot verify transpilation by building operators at all.

What you lose on hardware

    qubits   state tomography   process tomography
         5                243              248,832
        10             59,049       61,917,364,224
        20      3,486,784,401          3.8 x 10^21

$3^n$ settings for states, $\sim12^n$ for processes. A diagnostic for one or two qubits.

So hardware "debugging" is really four coarse checks: does the distribution match the simulator's; does it degrade the way noise degrades; does a scaled-down version work; do the classical checks pass?

Ch. 23's Shor verifies its factors classically and Ch. 24's QAOA verifies its cut. That Las Vegas structure is not only an efficiency trick — it is the only form of hardware debugging that scales.

The protocol

   1. Diff count_ops() before/after transpilation.
   2. Compare the UNITARY against a reference -- not a single nice input.
   3. If it differs, BISECT (operator, or random input).
   4. Check convention: reverse_bits, and the phase-insensitive test.
   5. Check ancilla cleanliness: purity == 1.
   6. Only then scale up.
   7. On hardware: compare distributions, verify classically. You are validating now.

Steps 1–5 are exact, cheap, and simulator-only. Exhaust them before touching hardware, because after that the tools are gone and every symptom looks like noise.

Common pitfalls

  • Debugging on hardware, where you cannot see anything.
  • Testing with $|0\dots0\rangle$ — or $|{+}\dots{+}\rangle$, which is no safer.
  • Reading "I found nothing" as "there is nothing."
  • Picking an equality test by strictness rather than by how the circuit will be used.
  • Comparing a logical circuit to a transpiled one without applying the layout.
  • Trusting a verification tool that has no positive and negative control.
  • Assuming your bug is in the library. It usually is not.

Project piece added this chapter

vqelab/debugging.pyprefix; bisect returning DIVERGED/AGREE/BLIND with an operator cross-check before ever reporting agreement; equality_verdict(a, b, will_be_controlled) with no default for the flag, because both defaults are wrong half the time; ancilla_report; count_ops_diff; is_bit_order_only; verify_transpilation that applies the layout and raises rather than returning a wrong number. 32 tests pass, including test_bisecting_from_zero_reports_BLIND_not_AGREE, test_HALF_the_computational_basis_states_are_blind, and test_verify_transpilation_REFUSES_rather_than_returning_a_wrong_number.