Build1 publisher3 min readPublished
Three gates in, empty circuit out: a Qiskit pass that deletes a rotation quietly
A one-qubit circuit optimised to nothing in Qiskit 2.5.0, fixed in 2.5.1. The durable lesson is that a simulator handing back a clean number is not a passing test.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- A one-qubit Qiskit circuit built as qc.sxdg(0); qc.sxdg(0); qc.sx(0) and run through PassManager([CommutativeCancellation()]) returns a circuit whose count_ops() prints OrderedDict().
- The output of that pass is an empty circuit.
- On paper the three gates add up to a quarter turn in the negative direction, so one gate should come out of the pass.
- There is no error, no warning and nothing in the logs; the rotation is gone, and measuring the qubit gives a different answer than the circuit says it should.
- The author wrote a fuzzer that throws random circuits at simulators and checks whether they disagree with each other; the first time it stopped, it reported that two backends did not match.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A one-qubit circuit holding three gates (sxdg, sxdg, sx) run through Qiskit's CommutativeCancellation transpiler pass on version 2.5.0 comes back empty, with count_ops() printing OrderedDict() [1][2]. Those three compose to a quarter turn in the negative direction, so one gate should have survived; instead the rotation is gone, with no error, no warning and nothing in the logs, and measuring that qubit then gives a different answer than the circuit specifies [3][4].
The finder was not a test suite. According to the developer's account, published on dev.to while building nqf-lint, a linter for quantum chemistry setups [7], he had stopped trusting the simulators because they looked too tidy: every run handed back a clean number and never complained [6]. So he wrote a fuzzer that throws random circuits at multiple backends and flags disagreement, and the first stop was two backends failing to match [5]. That is the whole argument for differential testing in one line. A single backend cannot tell you it is wrong. Two can tell you at least one of them is.
The failure reproduces on qiskit==2.5.0 and is fixed in 2.5.1 [8]. What happened between those versions is instructive about diagnosis. The report named a cause: that _x_rotations at line 69 of commutative_cancellation.py includes both sx and sxdg, so two sxdg get treated as an inverse pair [9]. Jake Lishman, a core maintainer, replied the same day that the root cause was quite different, that all of the code in question is in Rust, and that the pass computes the required rotation angle correctly but fails to synthesise it back into gates when the combined X rotation is a negative odd multiple of pi/2 and sx appears to be a supported gate [10]. The reporter conceded he had been reading the Python and guessed wrong [11].
He then did the part that was actually worth something: he measured. Sweeping the combined rotation across every multiple of pi/2 from -8 to 8, forcing exact angles with pairs of rx gates and checking each result against the exact operator, he found even multiples failing as well [12]. That is a wider failure set than the stated root cause, which was scoped to negative odd multiples [16]. He also retracted a precondition from his own report, that a gate on another qubit had to sit between the two sxdg; it happens on one qubit with nothing else in the circuit, and he posted that correction before anyone asked [13].
The code he landed on is short: num_sx is computed as (total_angle / FRAC_PI_2).round(), then a loop runs 0..(num_sx as i64) % 4 inserting gates [14]. For a combined angle of minus a quarter turn that rounds to -1, and Rust's remainder keeps the sign of the dividend, so -1 % 4 is -1 and the range 0..-1 is empty, which emits zero gates [15]. That reading is mine, from the excerpt; it is consistent with an angle computed correctly and then synthesised into nothing.
The operational takeaway is not about one pass. A transpiler is a compiler, and this class of bug is silent by construction: output that type-checks, runs, and returns a number. If your pipeline's only oracle is "the simulator did not throw", you have no oracle. Pin the transpiler version alongside the SDK version, keep an exact-operator check on small circuits after optimisation, and treat backend disagreement as a build failure rather than noise.