Interop (Qiskit / PennyLane / STIM)¶
Every bridge on this page goes through OpenQASM 2.0 and this library's own
QASMParser, not a bespoke gate-by-gate translator — gate coverage always
matches whatever the parser and simulator already support.
Step 1. Bring a Qiskit circuit in¶
import qiskit
from dense_evolution.interop import from_qiskit
qc = qiskit.QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
circuit = from_qiskit(qc)
circuit.to_tuples()
from_qiskit exports qc to an OpenQASM 2.0 string (qiskit.qasm2.dumps) and hands it
straight to QASMParser — the same QASMCircuit the parser guide already
covers, from a Qiskit circuit instead of a QASM string you wrote by hand.
Step 2. Run it in one call¶
from dense_evolution.interop import run_qiskit_circuit
from qiskit.quantum_info import Statevector
sim, probs = run_qiskit_circuit(qc)
probs.round(4), Statevector(qc).probabilities().round(4)
run_qiskit_circuit does Step 1, runs the result on a fresh DenseSVSimulator, and
returns (sim, probabilities) — already reordered to match Qiskit's own convention, so
it compares directly against Statevector(qc).probabilities() with no extra work.
Step 3. Why that reordering matters¶
qc2 = qiskit.QuantumCircuit(2)
qc2.x(0)
sim2, probs2 = run_qiskit_circuit(qc2)
probs2.round(4), sim2.get_probabilities().round(4)
Same simulator, two different-looking answers for the exact same state: probs2 (what
Step 2 returns) matches Qiskit — index 1 (01, qubit 0 is the least significant bit,
Qiskit's convention). sim2.get_probabilities() — the simulator's own native output,
with no reordering — puts it at index 2 (10, qubit 0 is the most significant bit,
this library's convention everywhere else). run_qiskit_circuit
exists specifically to paper over that difference automatically.
Step 4. PennyLane needs no such reordering¶
import pennylane as qml
from dense_evolution.interop import run_pennylane_circuit
dev = qml.device('default.qubit', wires=2)
@qml.qnode(dev)
def pl_circuit():
qml.PauliX(wires=0)
return qml.probs(wires=[0, 1])
sim3, probs3 = run_pennylane_circuit(pl_circuit)
probs3.round(4)
The exact same physical state as Step 3 — X on qubit 0 of 2 — lands at index 2 here,
with no bit-reversal applied, and matches pl_circuit()'s own PennyLane-native output.
PennyLane's own wire convention already numbers qubit 0 as the most significant bit,
the same as this library — run_pennylane_circuit deliberately does not reorder,
unlike run_qiskit_circuit; the two frameworks genuinely need different treatment.
Step 5. Run with a real device's own calibrated noise¶
from qiskit_ibm_runtime.fake_provider import FakeSherbrooke
from dense_evolution.interop import noise_model_from_qiskit_backend
backend = FakeSherbrooke()
specs = noise_model_from_qiskit_backend(backend)
ecr_specs = [s for s in specs if s['gate'] == 'ecr' and s['qubits'] == [1, 0]]
len(specs), ecr_specs[0]
FakeSherbrooke carries a real, measured calibration snapshot — specs is one entry
per unique (gate, qubit-target) pair with a calibrated error rate, each already shaped
as the model/p/qubits arguments NoiseModel.apply_to_sv expects, so a
simulation can use this device's actual measured error rates instead of a guessed
number.
Step 6. Cross-check a Clifford circuit against STIM¶
import dense_evolution as de
from dense_evolution.interop import to_stim
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; h q[0]; cx q[0],q[1];'
circuit = de.QASMParser().parse(qasm)
stim_circuit = to_stim(circuit.to_tuples(), circuit.n_qubits)
stim_circuit
to_stim maps every Clifford gate (h, x, y, z, s, sdg, sx, id, cx,
cy, cz) 1:1 onto a native STIM instruction, for cross-validating a circuit against
STIM's own independent stabilizer simulator — useful specifically because STIM is a
different implementation of the same physics, not a wrapper around this library's own
simulator. The leading I 1 guarantees the STIM circuit has n_qubits qubits even when
the highest-indexed one is never otherwise touched.
Details¶
These bridges are not differentiable¶
from_qiskit/from_pennylane bake every gate parameter into a plain Python float
inside the QASM text before parsing — the value leaves any JAX trace it came from.
jax.grad through run_qiskit_circuit/run_pennylane_circuit does not raise: it
silently returns 0.0, which reads as "already converged" rather than "not wired up".
For a real gradient, call from_qiskit/from_pennylane for the QASMCircuit alone,
before that float-baking happens, and pass it to
circuit_to_energy_fn instead.
What this bridge can't run¶
Inherited from the OpenQASM 2.0 export itself, not something this layer works around:
no classical control flow (if/while and mid-circuit-measurement-conditioned gates
are parsed out, never executed); no expansion of composite/custom gates (a Qiskit call
like mcx with 3+ controls exports as a named gate mcx { ... } definition that parses
cleanly but isn't a primitive this simulator can execute — a call to it is a silent
no-op, same as any unrecognized gate name); and no backend/device registration — this is
a bridge for circuit execution you call explicitly, not a Qiskit BackendV2 or
PennyLane Device you point existing framework code at.
PennyLane's wire-numbering pitfall¶
By default, PennyLane numbers exported QASM qubits in the order wires are first
touched in the circuit, not by their actual wire index — qml.PauliX(wires=2) followed
by qml.CNOT(wires=[2, 1]) exports as x q[0]; cx q[0],q[1];, silently renumbering wire
2 to q[0] and wire 1 to q[1]. from_pennylane passes an explicit wires= argument
(the device's declared order for a QNode, the tape's own wires sorted ascending for a
bare tape) specifically to prevent this — nothing further to do on the caller's side,
but worth knowing if a circuit's exported QASM ever looks unexpectedly renumbered
outside this bridge.
Real calibration data, deduplicated per target¶
noise_model_from_qiskit_backend walks backend.target, one entry per unique (gate,
qubit-target) pair — never once per occurrence in a circuit, regardless of how many
times that circuit repeats the same gate on the same qubits. This matters: an earlier
version of this bridge (Dense-Evolution-Discovery's Steane-code hardware script) called
qiskit_aer's NoiseModel.add_quantum_error once per gate occurrence instead, and
AerSimulator composes the same Kraus channel with itself on repeated registration for
the same target — on a circuit repeating gates on the same qubits many times, this
produced a combinatorial explosion of Kraus terms that OOM-killed the process. measure
is excluded by default (readout error is a post-measurement classical effect, not a
pre-measurement channel); pass a different skip_gates to change that.
On macOS, Qiskit's own object construction can crash the process¶
Qiskit's QuantumCircuit.__init__ is a known upstream segfault risk on macOS/arm64
(reproduced on GitHub Actions arm64 runners, Python 3.10-3.12) — from_qiskit/
run_qiskit_circuit/noise_model_from_qiskit_backend all emit a RuntimeWarning the
first time they're called on that platform, pointing at the PennyLane bridge as a
workaround (it never constructs a Qiskit object, so it has no exposure to this bug).
qiskit_pennylane ¶
Qiskit / PennyLane interop bridges (from_qiskit, from_pennylane,
run_qiskit_circuit, run_pennylane_circuit), a real-calibration noise
bridge (noise_model_from_qiskit_backend), and a Clifford-only STIM
bridge (to_stim). All circuit bridges go through OpenQASM 2.0
(qiskit.qasm2.dumps / qml.to_openqasm) and this library's own
QASMParser -- not bespoke gate-by-gate translators, so gate coverage
matches whatever the parser/simulator already support.
Bit-order. Qiskit indexes probability/statevector arrays
little-endian (qubit 0 = least significant bit); Dense-Evolution indexes
MSB-first everywhere (phys = n_qubits - 1 - qubit, the same convention
apply_gate_1q/apply_gate_2q/measure/run_circuit_jit use).
run_qiskit_circuit reorders its output into Qiskit's own convention
(_to_qiskit_bit_order, a plain bit-reversal permutation, verified
against Statevector.from_instruction(...).probabilities() on an
asymmetric circuit) so it's directly comparable to
Statevector(circuit).probabilities(). PennyLane's own wire convention
already matches Dense-Evolution's MSB-first indexing natively --
run_pennylane_circuit does not reorder, on purpose; verified
directly that the two frameworks genuinely need different treatment
here, not just "symmetric for simplicity."
Known limits, inherited from the QASM2 bridge (not something this layer works around):
- No classical control flow --
if/whileand mid-circuit-measurement- conditioned gates are parsed out, not executed (same limitation as native QASM3 circuits). - No expansion of composite/custom gates. A Qiskit call like
mcxwith 3+ controls gets exported as a namedgate mcx { ... }definition; the definition parses cleanly but the gate itself isn't a primitive this simulator knows how to execute, so a call to it is a silent no-op -- same as referencing any unrecognized gate name elsewhere. Stick to the gates this simulator actually implements for results you can trust. - Only a plugin/backend-free bridge for circuit execution -- no
qiskit.providers.BackendV2or PennyLaneDeviceregistration, so callers still invokerun_qiskit_circuit/run_pennylane_circuitexplicitly rather than pointing existing framework code at a new backend/device string. (noise_model_from_qiskit_backendreads aBackendV2's calibration data -- that's data extraction only, not backend/provider registration.) run_qiskit_circuit/run_pennylane_circuitare not differentiable.from_pennylane/from_qiskitmaterialize every gate parameter into a plain Pythonfloatinside the QASM text before parsing -- the value leaves the JAX trace entirely.jax.gradthroughrun_pennylane_circuitdoes not raise: it silently returns0.0, which looks like "already converged" rather than "not wired up" (verified directly). For a real gradient, usecircuit_to_energy_fninstead -- pass it theQASMCircuitthatfrom_qiskit/from_pennylanereturns, before that float-baking happens, andjax.gradworks correctly.
from_qiskit ¶
Convert a Qiskit QuantumCircuit into a QASMCircuit via OpenQASM 2.0 (qiskit.qasm2.dumps), reusing the existing QASMParser rather than a bespoke gate-by-gate translator.
Source code in dense_evolution/interop/qiskit_pennylane.py
from_pennylane ¶
Convert a PennyLane QNode or QuantumTape/QuantumScript into a QASMCircuit via OpenQASM 2.0, reusing the existing QASMParser.
PennyLane's own serialization API for a bare tape has changed across versions in an incompatible way (verified directly against both): - >=~0.43 (Python 3.11+ only): qml.to_openqasm(tape) returns the QASM string directly; QuantumTape/QuantumScript no longer has a to_openqasm() method at all. - <=0.42.x (still installed on Python 3.10, where newer PennyLane isn't available): qml.to_openqasm(tape) does NOT special-case a bare tape — it returns a QNode-oriented wrapper that crashes with AttributeError ('QuantumTape' object has no attribute 'func') if called on one. The tape's own tape.to_openqasm() method is what works there instead. So: a bare tape/QuantumScript uses its own to_openqasm() method when present (old API), otherwise falls through to the top-level qml.to_openqasm() (new API). A QNode (not a QuantumScript instance) always uses the top-level function, which returns a wrapper that must be called with the QNode's own arguments — consistent across both versions, this path was never the one that broke.
WIRE ORDER: by default, both PennyLane APIs number the exported QASM
qubits in the order wires are FIRST TOUCHED in the circuit, not by
their actual wire index — e.g. qml.PauliX(wires=2) followed by
qml.CNOT(wires=[2, 1]) becomes x q[0]; cx q[0],q[1]; in the
default export, silently renumbering wire 2 -> q[0] and wire 1 -> q[1].
Verified directly: this produced a topologically different circuit
from the one PennyLane itself executes whenever wires aren't touched
in ascending order (a QASMParser-based bridge has no way to recover
the true mapping after the fact — the touch-order renumbering has
already happened by the time QASM text exists). Both APIs accept an
explicit wires= argument that forces the true wire order into the
export instead — used here for both the QNode path (the device's own
declared wire order) and the tape path (the tape's own wires, sorted
ascending, since a bare tape has no device to ask).
Source code in dense_evolution/interop/qiskit_pennylane.py
run_qiskit_circuit ¶
run_qiskit_circuit(
circuit,
use_float32: bool = True,
sim: Optional[DenseSVSimulator] = None,
) -> Tuple[DenseSVSimulator, np.ndarray]
Run a Qiskit QuantumCircuit on DenseSVSimulator. Returns (sim, probabilities) with probabilities reordered into Qiskit's own little-endian bit convention, so they compare directly against Statevector(circuit).probabilities() — see _to_qiskit_bit_order.
Source code in dense_evolution/interop/qiskit_pennylane.py
noise_model_from_qiskit_backend ¶
noise_model_from_qiskit_backend(
backend,
circuit=None,
skip_gates=_DEFAULT_CALIBRATION_SKIP_GATES,
) -> list
Build a Dense-Evolution-native noise specification from a Qiskit BackendV2's own calibration data (backend.target) -- works for both real backends and fake/mock backends carrying a real historical calibration snapshot (e.g. qiskit_ibm_runtime.fake_provider.FakeSherbrooke), so a simulation can use the device's actual measured per-qubit/per-gate error rates instead of an idealized channel.
Returns a list of dicts, each directly usable as the model/p/qubits arguments to NoiseModel.apply_to_sv: [{'gate': 'sx', 'qubits': [3], 'model': 'depolarizing', 'p': 0.00029}, {'gate': 'ecr', 'qubits': [1, 0], 'model': 'depolarizing', 'p': 0.0075}, ...] One entry per unique (gate, qubit-target) pair found in the calibration data -- never duplicated by how many times a gate occurs in any one circuit.
If circuit is given, restricts the result to the (gate, qargs)
targets that circuit actually uses. Still exactly one entry per
unique target regardless of how many times the circuit repeats it --
promoted from Dense-Evolution-Discovery's Steane-code hardware bridge
script (scripts/steane_code_block5_qiskit_bridge.py), where an
earlier version called qiskit_aer's NoiseModel.add_quantum_error once
per gate OCCURRENCE instead of once per unique target: AerSimulator
composes the same Kraus channel with itself on repeated registration
for the same target (documented semantics, not "apply once per
instance"), and on a circuit with many repeats on the same qubits
this blew up to multi-GB memory from the resulting Kraus-term
combinatorial explosion before OOM-killing the process. This function
can't reproduce that bug by construction: it walks target[gate_name]
(already deduplicated by qargs, one InstructionProperties per target)
and, when circuit is given, additionally dedupes via a seen set
keyed on (gate, qargs) before ever appending a spec entry -- so
repeated occurrences in circuit collapse to the same single entry.
measure is excluded by default (readout error is a classical
bit-flip-on-outcome effect measured after collapse, not a
pre-measurement unitary channel on the state) -- pass a smaller/
larger skip_gates to change that. Gates with no calibrated error
(virtual gates like rz, or entries with error=None such as delay
or control-flow ops like for_loop/if_else) are skipped too, since
there is no error rate to convert into a channel.
Every entry uses Dense-Evolution's 'depolarizing' model with the
calibration's average gate error as p -- the same standard
average-gate-infidelity-to-depolarizing-parameter approximation
qiskit_aer.noise.NoiseModel.from_backend itself falls back to when no
finer-grained error data is available, not a from-scratch physical
model of this library's own invention.
circuit matching is order-independent on the qubit tuple (e.g. an
ecr(0, 1) instruction matches a calibration target stored as
(1, 0)) -- NoiseModel.apply_to_sv itself only ever applies
independent single-qubit channels per entry in qubits, never a
genuine joint multi-qubit channel, so which qubit was "control" vs
"target" in the original gate has no effect on the result here.
Source code in dense_evolution/interop/qiskit_pennylane.py
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | |
run_pennylane_circuit ¶
run_pennylane_circuit(
circuit,
*args,
use_float32: bool = True,
sim: Optional[DenseSVSimulator] = None,
**kwargs,
) -> Tuple[DenseSVSimulator, np.ndarray]
Run a PennyLane QNode/tape on DenseSVSimulator. Returns (sim, probabilities) in Dense-Evolution's native ordering, WITHOUT any bit-reversal — unlike run_qiskit_circuit, because PennyLane's own wire convention (wire 0 = most significant) already matches Dense-Evolution's MSB-first convention. Do not "symmetrize" this with the Qiskit version; that would silently misorder circuits that are asymmetric under qubit reversal (verified directly: no permutation needed here, one is required for Qiskit — the two frameworks are genuinely different).
NOT DIFFERENTIABLE: from_pennylane() bakes every gate parameter into a plain Python float inside the QASM text, so it leaves the JAX trace. jax.grad through this function does not raise — it silently returns 0.0 (verified), which reads as "converged" rather than "not wired up". For a real gradient through a Dense-Evolution circuit, use the dashboard_core._vqe_energy_fn pattern instead (jax.value_and_grad over a jax.lax.scan template with sentinel-injected parameters).
Source code in dense_evolution/interop/qiskit_pennylane.py
to_stim ¶
Convert a Dense-Evolution op-list circuit (e.g. [['h', 0], ['cx', 0, 1]]) into a stim.Circuit, for cross-validation against STIM's own stabilizer simulator/decoder tooling. Promoted from Dense-Evolution-Discovery's Steane-code STIM bridge script (scripts/steane_code_block4_stim_translation.py), generalized from that script's Steane-specific gate set to every STIM-representable gate this library has.
STIM is a STABILIZER simulator: it can only represent Clifford operations. Every gate below maps 1:1 onto a native STIM instruction (h/x/y/z/s/sdg/sx/id/cx/cy/cz); anything else in the op list -- continuous-angle rotations (rx/ry/rz/p/phase/u1/cp/cphase/crz) or the non-Clifford t/tdg gates -- raises ValueError rather than being dropped or approximated, since a silently-wrong stabilizer circuit defeats the purpose of using STIM as an independent cross-check in the first place.
A leading no-op 'I' on qubit n_qubits - 1 is emitted first so the
returned circuit always has exactly n_qubits qubits, even if the
highest-indexed qubit is never otherwise touched by ops.
Qubit indexing matches ops directly (STIM has no reordering of its
own, unlike Qiskit's little-endian convention handled by
run_qiskit_circuit) -- but STIM's state_vector()/TableauSimulator
output is still little-endian (qubit 0 = least significant bit),
the same convention _to_qiskit_bit_order reorders into, so comparing
against DenseSVSimulator's own MSB-first probabilities/statevector
still needs that same reordering.
Source code in dense_evolution/interop/qiskit_pennylane.py
See Also¶
QASMParser— what every bridge on this page actually parses the exported QASM text with.- Noise —
NoiseModel.apply_to_sv, what Step 5'sspecsentries are shaped to plug into directly. - Autodiff —
circuit_to_energy_fn, the real-gradient alternative when differentiating through a circuit these bridges brought in.