Case Study 2: The Transpiler Bug That Wasn't

The alarm

A team is preparing their first hardware run. Following good practice — and Chapter 26 §26.9's protocol — they decide to verify that transpilation preserved their circuit before submitting it.

The check is reasonable. Build the operator for the logical circuit, build the operator for the transpiled circuit, compare:

padded = QuantumCircuit(transpiled.num_qubits)
padded.compose(logical, qubits=range(logical.num_qubits), inplace=True)
fidelity = process_fidelity(Operator(padded), Operator(transpiled))
   process_fidelity = 0.001406

Essentially zero. The transpiler has destroyed their circuit.

This is a serious finding, and they treat it seriously. They file an issue against Qiskit. They pin to an older version — the fidelity is still 0.001406. They drop the optimization level from 3 to 2 to 1 to 0, and the number moves around but never approaches 1. They rewrite the circuit to avoid the gates they suspect. They spend a week on it.

What was actually happening

The transpilation was perfect. Process fidelity 1.0000000000.

The comparison was wrong. Transpilation does two things to qubit identity, and the check accounted for neither:

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

The initial layout assigns your logical qubit 0 to physical qubit 3 — chosen by the transpiler to land on good hardware, exactly as Chapter 12 §12.4 described. Routing then inserts swaps to satisfy connectivity, so by the end of the circuit your logical qubit 0 has moved again, to physical qubit 4.

Padding the logical circuit to five qubits and comparing directly asks: "does my circuit acting on wires 0, 1, 2 equal the transpiled circuit acting on wires 3, 2, 4?" The answer is no, and 0.001406 is an entirely accurate measurement of how much no.

The fix is one function:

process_fidelity(Operator.from_circuit(transpiled), Operator(padded))     # 1.0000000000

Operator.from_circuit reads the layout off the transpiled circuit and applies both the initial permutation and the routing permutation before comparing.

Why it took a week

Because the wrong comparison ran without error and returned a plausible number.

Consider the alternatives. If the check had raised an exception, they would have read the traceback in five minutes. If it had returned 0.5 they might have suspected a phase or ordering issue. If it had returned 1.0 they would have moved on.

It returned 0.001406. That is a specific number, small but not zero, of exactly the kind a real fidelity computation produces — and it is stable across runs, so it survives every reproducibility check. Every property that would normally increase confidence in a measurement was present.

⚠️ A comparison that runs is not a comparison that is right.

Type-correct, exception-free, deterministic, reproducible — and comparing two circuits on mismatched wires. The failure was in the question, and no amount of scrutiny applied to the answer was going to surface it.

Note also which direction the error ran. Chapter 25 §Case Study 1's bug produced results that were too good — a logical error rate of zero. This one produced results that were too bad. Both are the same class of failure, and the second is arguably more expensive: nobody debugs a success, but a convincing false alarm consumes real engineering time and, in this case, generated an incorrect bug report against a widely-used library.

What they should have done

Sanity-check the check. Run the verification against a case where you already know the answer:

   verify_transpilation(circuit, transpile(circuit, backend=None))   -> should be 1.0
   verify_transpilation(circuit, transpile(other_circuit, backend))  -> should NOT be 1.0

A verification tool that reports failure on a known-good input is broken, and this takes two minutes to discover. Any tool whose output you would act on needs at least one positive and one negative control.

Read the layout. transpiled.layout is right there, it prints in one line, and it says in plain integers that your qubits moved. The information was never hidden.

And know that the technique has a ceiling. The same three-qubit circuit transpiled for a real 127-qubit backend:

   Operator.from_circuit(transpiled):  ValueError: Maximum allowed dimension exceeded
   2^127 = 1.701e+38 amplitudes

On a real-sized backend you cannot verify transpilation by building operators at all. This is not a limitation of the tooling; a $2^{127} \times 2^{127}$ matrix does not exist. The options are to transpile the same circuit to a small backend with similar coupling structure and check there, to compare simulated output distributions, or to move up a level and test the distribution — which is Chapter 27.

The project module raises rather than degrading gracefully:

raise ValueError(
    f"cannot verify a {transpiled.num_qubits}-qubit transpiled circuit by building "
    f"operators (2^{transpiled.num_qubits} amplitudes). Transpile to a small backend "
    f"with similar coupling for the correctness check, or test the output distribution "
    f"instead (Chapter 27)."
)

A tool that cannot answer should say so, with the alternative in the message. Silently falling back to a comparison that ignores the layout is precisely how this case study started.

The lessons

Verification tools need positive and negative controls. Especially the ones you consult when something looks broken, because that is exactly when you are least inclined to question the tool.

A plausible number is not evidence of a correct computation. 0.001406 had every superficial marker of a real result. The only way to catch it was to check the tool against a known answer.

The circuit you wrote is not the circuit that runs, and the relationship between them is a permutation you did not choose. Chapter 10 established this about gate sets and depth; it is equally true of qubit identity, and Operator.from_circuit exists for exactly this reason.

Raise instead of degrading. When a technique hits its limit, the useful behaviour is an error with a pointer to the alternative — not a fallback that produces a number nobody can interpret.

And check the direction of your alarm. A team that files a bug report against a mature, heavily tested library should first assume the mistake is theirs. It usually is, and here it was.


Reproduce it: code/example-04-debugging-what-actually-runs.py runs all three comparisons side by side and then hits the 127-qubit wall; verify_transpilation in code/vqelab/debugging.py applies the layout and refuses when it cannot; and test_ignoring_the_layout_gives_a_confident_wrong_answer in code/project-checkpoint.py asserts both numbers — the false alarm and the truth — in one test.