A Qiskit Aer Noise Model That Installs Zero Errors Still Returns Successfully
A simulator that installs no noise returns the same clean tuple as one that installs the right noise. SuperconducTED's noise engine met that failure on the commit that made its noise physically correct. Here are the measured error counts and the guard that shipped with them.
Why the Kraus projector cannot decide which gates carry noise
The engine wraps Qiskit Aer with a fuzzy inference layer that turns IBM calibration snapshots into noise model ensembles. Aer registers a quantum error against an instruction name and a qubit list through add_quantum_error (Aer's own NoiseModel source), so something upstream has to decide which instruction names qualify as physical.
Its ChannelProjector takes a (gate_name, qubits) pair and deliberately ignores gate_name while building the channel, and channels/kraus.py is locked against edits. So FuzzyNoiseModel.prepare() handed the projector every single-qubit instruction in the circuit. The same damping channel landed on zero-duration virtual rz gates, and could reach delay and Aer save instructions (PR 96).
The calibration record beats an allowlist because the backend rewrites it
Issue 73 posed two candidate sources of truth: the calibration snapshot's own gate records, or an explicit allowlist. ADR-028 chose the snapshot. A single-qubit gate is eligible exactly when its properties.gates[*].parameters[gate_length] record is strictly positive, read through the same training/targets.py::gate_lengths parser the project's reference model uses for its thermal relaxation targets.
That excludes rz for a physical reason rather than for missing metadata. In the archived ibm_fez snapshot the repository tests against, all 156 rz records carry a gate_length of 0 ns, while id, rx, sx and x each carry 24 ns across 156 qubits. Those figures carry no per-qubit spread: the fixture records one nominal duration per gate, not a measurement of each qubit. The delay and Aer save instructions have no properties.gates record at all, so they fail closed.
The ledger records why the allowlist lost. It is a second copy of the device's physics, it drifts silently when the backend recalibrates, and it cannot express a per-qubit fact. Eligibility therefore matches on gate name and physical qubit tuple together, so a gate calibrated on one qubit does not authorize the same gate on its neighbour.
A corrupt record behaves differently from a missing one. A non-numeric value, or a unit other than ns, raises CalibrationParseError inside the parser before eligibility is ever evaluated, which is the louder of the two failures.
A zero-duration rz was carrying half the installed noise
Compile a three-qubit GHZ circuit to the device basis with transpile(circuit, basis_gates=['id','rz','sx','x','cz'], optimization_level=1, seed_transpiler=0), then call prepare(). On the pre-fix tree it installs errors on ['rz','sx'], six in total. After the change it installs errors on ['sx'] alone, three in total (implementation record).
Half the noise on that circuit was attached to an instruction the calibration archives at 0 ns. The regression test is blunter about the old behaviour: run it against the pre-fix tree and the assertion reads ['delay', 'rz', 'save_density_matrix', 'sx', 'x'] == ['sx', 'x'].
An uncompiled circuit gets a noise model with nothing in it
Keying eligibility on physical basis names has a cost the change exposed rather than removed. benchmarks/harness.py:75 calls prepare() on logical circuits and never transpiles them. Against the same fixture, ghz(3) drops from one installed error to zero and vqe(3) from six to zero, while qft(3) was already at zero on both trees.
Every one of those runs still succeeds. prepare() returns its tuple, Aer simulates a noiseless circuit, and a benchmark row reports a number measured from an engine that was switched off.
So the fix ships a second change that makes the silence audible. prepare() inspects the NoiseModel it is about to return and, when nothing was installed, prints the circuit's instruction names beside the calibration's eligible names:
FuzzyNoiseModel.prepare installed no error: none of the circuit's
candidate instructions ['barrier', 'cx', 'h', 'measure'] is eligible
under this calibration ['id', 'rx', 'sx', 'x']. Compile the circuit to
the calibrated physical basis before calling prepare().
It warns and lets the call succeed, because an rz-only or measure-only circuit is legitimately noise-free and ADR-021 fixes prepare()'s return contract. Raising would break honest callers to catch a mistaken one. A stacklevel of 3 attributes the warning to harness.py:75 rather than to a line inside the library.
cz has a positive gate length and is still ineligible
gate_lengths skips any record whose qubits list is not of length one. In the archived snapshot, cz has 352 records with a gate_length between 68 and 88 ns, and none of them reaches the eligible set. That matches the projector as it stands, which is single-qubit only, so nothing is lost today.
ADR-028 writes the trap down for whoever implements a multi-qubit channel. Changing the projector alone leaves the new channel unreachable, because an ineligible pair yields no error, and that failure is silent in the same way the harness one was.
What to check before reading a number off a calibration-derived run
Count what the model installed before trusting any figure that comes out of it. Aer exposes noise_instructions on the returned NoiseModel, and an empty list beside a successful exit is the entire failure. The ordering that produces it, calling prepare() before transpile() or skipping the transpile, is easy to write and invisible afterwards.
Anyone mitigating errors on real hardware meets the same accounting problem from the other side, which the sampling overhead arithmetic in probabilistic error cancellation sets out. Read ADR-028 and CalibrationGateEligibilityPolicy in the SuperconducTED repository if you build noise models from vendor calibration data, or browse the SuperconducTED case study for what the fuzzy layer around them is for.