Case Study 1: The Archive That Changed the Answer

"Every serialization format is lossy. The only question is whether you know what it loses."

Executive Summary

A research group archives its circuit library as OpenQASM — sound practice, and exactly what Chapter 6 §6.1 recommends. Eighteen months later, a phase estimation experiment built from those archived subcircuits gives a systematically wrong answer, and nobody can find the bug because every test passes.

The cause is one line of missing information. OpenQASM 3 has no syntax for a circuit's global phase, so the archive silently dropped it — and in phase estimation, where the subcircuit is used as a controlled operation, an unobservable global phase becomes a very observable relative one.

This case study reproduces the failure, shows why every plausible test misses it, and derives the three-line fix. It is short because the bug is simple. It is worth an hour because the class of bug — information lost at a boundary you trusted — is one you will meet repeatedly, and because the reason every test passed is genuinely instructive.

Skills applied: global versus relative phase (Chapter 3 §3.7); QASM round-trip fidelity (§6.6); Operator.equiv versus exact comparison (Chapter 3 §3.7, §6.6); phase kickback (previewing Chapter 19 §19.3).

Reproducibility. Runs locally on a simulator with seed_simulator=1234.

The Setup

The group maintains a library of reusable subcircuits. One of them — call it block — is a small state-preparation routine that happens to carry a nonzero global phase, because it was built by composing operations whose decompositions introduced one.

import numpy as np
from qiskit import QuantumCircuit, qasm3

block = QuantumCircuit(1, name="block")
block.h(0)
block.global_phase = np.pi          # e^{i pi} = -1

The phase is real and it is invisible. Nothing you can measure about block on its own depends on it, and Chapter 3 §3.7 told you — correctly — that you may ignore it.

The archive step:

with open("block.qasm", "w", encoding="utf-8") as f:
    f.write(qasm3.dumps(block))
OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
h q[0];

Four lines, and the phase is not among them. There is no global_phase in the OpenQASM 3 grammar, so the exporter had nothing to write it into. It did not warn, because from the specification's point of view nothing was lost — a global phase is not part of the physical state.

The Failure

Eighteen months later, someone builds a phase estimation circuit and uses the archived block as a controlled operation:

archived = qasm3.loads(open("block.qasm").read())

def experiment(sub):
    qc = QuantumCircuit(2, 1)
    qc.h(0)
    qc.append(sub.to_gate().control(1), [0, 1])
    qc.h(0)
    qc.measure(0, 0)
    return qc
  controlled-original  -> {'0': 291,  '1': 1757}
  controlled-archived  -> {'0': 1758, '1': 290}

The answer inverted. Same gate counts, same structure, same everything a diff would show — and the dominant outcome flipped from 1 to 0.

Why Every Test Passed

This is the part worth studying, because the group's testing was not careless. Here is what they had.

Test 1: the unitary is preserved

from qiskit.quantum_info import Operator
assert Operator(block).equiv(Operator(archived))     # passes

Passes. equiv compares up to global phase — by design, and that design is correct almost everywhere else. Chapter 3 §3.7 recommended equiv over == precisely because comparing exactly makes correct code look broken when a transpiler introduces a phase.

The tool that protects you in nine cases out of ten is the tool that blinds you in the tenth.

Test 2: the gate counts match

assert dict(block.count_ops()) == dict(archived.count_ops())     # passes

Passes. Both are {'h': 1}. A global phase is not a gate.

Test 3: the measurement statistics match

# prepare, measure, compare distributions
assert chisquare_test(run(block), run(archived)).pvalue > 0.05   # passes

Passes, and it must — the two circuits produce identical distributions. That is what "unobservable" means. No amount of sampling, at any shot count, on any hardware, can distinguish them. Chapter 5 §5.8's warning about statistical power does not even apply here: the test is not underpowered, it is asking a question with no answer.

Test 4: the QASM round-trips

assert qasm3.dumps(qasm3.loads(qasm3.dumps(block))) == qasm3.dumps(block)   # passes

Passes. The QASM is stable under round trips. It has reached a fixed point — one that omits the phase. Testing that serialization is idempotent tells you nothing about whether it is lossless.

🐛 Debug This — The general shape.

Four tests, all reasonable, all passing, one real bug. What do they have in common?

Every one of them tests the circuit in isolation. The lost information is invisible in isolation by definition — it is a global phase, and "global phase is unobservable" is a theorem. It becomes observable only in a context: as the controlled part of a larger circuit.

The general lesson, which applies far beyond quantum computing:

A property that is unobservable in isolation can be load-bearing in composition. Test the composition.

The test that would have caught this is the one nobody writes, because it requires imagining the downstream use:

python def test_survives_control(sub): """The real contract: this subcircuit is safe to use as a controlled gate.""" a = experiment(sub) b = experiment(qasm3.loads(qasm3.dumps(sub))) assert Operator(a).equiv(Operator(b)), "round trip changed the controlled behavior"

Note that this test can safely use equiv — because the phase difference has already been promoted to a relative phase by the control, and equiv sees relative phases fine.

The Fix

Three lines. Write what the format cannot carry into a comment, which every conforming parser ignores:

PHASE_COMMENT = "// vqelab: global_phase = "

def to_qasm(circuit):
    text = qasm3.dumps(circuit)
    if circuit.global_phase:
        text = f"{PHASE_COMMENT}{circuit.global_phase!r}\n" + text
    return text

def from_qasm(text):
    phase = 0.0
    for line in text.splitlines():
        if line.startswith(PHASE_COMMENT):
            phase = float(line[len(PHASE_COMMENT):].strip())
            break
    circuit = qasm3.loads(text)
    if phase:
        circuit.global_phase = phase
    return circuit
// vqelab: global_phase = 3.141592653589793
OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
h q[0];

The file remains valid OpenQASM 3 and remains readable by every other tool. Tools that do not know about the comment behave exactly as before — they lose the phase, which is what they would have done anyway. Tools that do know recover it.

And add the test that would have caught it, using exact comparison:

assert np.allclose(Operator(block).data, Operator(from_qasm(to_qasm(block))).data)

Analysis: The Class of Bug

Strip away the quantum mechanics and this is a very ordinary failure: information was lost at a boundary, and the boundary did not report it.

Three properties made it hard.

The loss was semantically invisible. The archive was not corrupted, truncated, or malformed. By the specification, nothing was lost — a global phase is genuinely not part of the physical state, and the exporter behaved correctly. The loss only becomes a loss relative to a future use the specification does not know about.

The verification tool was aligned with the loss. equiv ignores exactly the thing that was dropped. This is not coincidence: both equiv and the QASM specification encode the same true fact — global phase does not matter — and both inherit the same exception.

The delay was eighteen months. Nothing connects the archive step to the phase estimation experiment. By the time the symptom appeared, the cause was in a different file, a different project, and a different year.

The defense that generalizes is not "remember global phase." It is:

When you cross a serialization boundary, enumerate what the format cannot express, and either record it out-of-band or assert that it is absent.

The second option is often better and almost never taken:

def archive_strict(circuit, path):
    """Refuse to archive information the format cannot carry."""
    if circuit.global_phase:
        raise ValueError(
            f"circuit '{circuit.name}' has global_phase={circuit.global_phase!r}, "
            "which OpenQASM 3 cannot represent. Use to_qasm() to record it in a "
            "comment, or set global_phase = 0 if it is genuinely irrelevant.")
    ...

Failing loudly at the boundary is worth more than recovering gracefully eighteen months later.

Lessons

  1. Every serialization format is lossy. Know what yours loses before you rely on it.
  2. OpenQASM 3 has no global phase, and QASM 3 also mangles parameter names (theta[0]_theta_0_).
  3. equiv is blind to exactly what QASM drops. Use exact comparison in round-trip tests when the phase matters.
  4. Unobservable in isolation ≠ irrelevant in composition. Global phase becomes relative under control — phase kickback.
  5. Test the composition, not just the component. The test that would have caught this exercises the downstream contract: "is this safe as a controlled gate?"
  6. Idempotent serialization is not lossless serialization. Round-tripping to a fixed point proves nothing.
  7. Record out-of-band, in comments. Parsers ignore them; the file stays portable.
  8. Better: fail loudly at the boundary. A ValueError at archive time beats a wrong answer eighteen months later.

Questions

  1. Reproduce the failure end to end. Then apply the fix and confirm the controlled experiment gives the original answer.

  2. The four passing tests each fail to catch the bug for a slightly different reason. Write one sentence for each explaining precisely why it is blind.

  3. Write test_survives_control and confirm it fails on the archived block and passes on the fixed one. Why can this test safely use equiv?

  4. Implement archive_strict as sketched above. Then argue for it against the comment-based approach: under what circumstances is refusing better than recovering, and vice versa?

  5. The parameter-name mangling is the other QASM loss. Is it in the same category as the phase loss, or a different one? Consider how each fails — silently versus loudly — and what that implies about which is more dangerous.

  6. Enumerate what QPY loses. (Hint: it is not lossy about circuit content — so what is the cost? Consider a file written by Qiskit 1.2 and read by Qiskit 3.0, or by Cirq.)

  7. Hardest. The group could have avoided this by archiving with QPY instead. Construct the argument against that fix. What would they have lost, and how would that failure have surfaced in eighteen months? Then propose the policy you would actually adopt, and say what it costs in discipline and storage.