dense-Evolution / test_interop.py
Tatopenn's picture
Sync package with GitHub Dense-Evolution v8.1.21
4173e5f
Raw
History Blame Contribute Delete
10.7 kB
"""
Tests for dense_evolution.interop (Qiskit / PennyLane bridge).
Both frameworks are optional dependencies β€” pytest.importorskip guards each
class so this file stays green in environments without qiskit/pennylane
installed, on top of CI installing both explicitly.
"""
import numpy as np
import pytest
import dense_evolution as de
from dense_evolution import interop
from dense_evolution.interop import (
from_qiskit, from_pennylane, run_qiskit_circuit, run_pennylane_circuit,
_to_qiskit_bit_order,
)
# ─────────────────────────────────────────────────────────────
# Import safety β€” must hold regardless of qiskit/pennylane presence
# ─────────────────────────────────────────────────────────────
class TestImportSafety:
def test_root_import_never_fails(self):
# this is the regression itself: interop.py's try/except pattern
# must make `import dense_evolution` safe even if qiskit/pennylane
# were both absent β€” can't literally uninstall them mid-suite, so
# this asserts the exported symbols exist and mirrors registry.py's
# HAS_JAX/HAS_CUPY pattern instead.
assert hasattr(de, 'from_qiskit')
assert hasattr(de, 'from_pennylane')
assert hasattr(de, 'run_qiskit_circuit')
assert hasattr(de, 'run_pennylane_circuit')
def test_missing_qiskit_raises_clear_importerror(self, monkeypatch):
monkeypatch.setattr(interop, 'HAS_QISKIT', False)
with pytest.raises(ImportError, match='qiskit'):
from_qiskit(None)
def test_missing_pennylane_raises_clear_importerror(self, monkeypatch):
monkeypatch.setattr(interop, 'HAS_PENNYLANE', False)
with pytest.raises(ImportError, match='pennylane'):
from_pennylane(None)
# ─────────────────────────────────────────────────────────────
# Qiskit
# ─────────────────────────────────────────────────────────────
class TestQiskitInterop:
qiskit = pytest.importorskip('qiskit')
@staticmethod
def _asymmetric_circuit():
from qiskit import QuantumCircuit
qc = QuantumCircuit(3)
qc.h(0)
qc.cx(0, 1)
qc.rx(0.5, 2)
qc.crz(0.3, 1, 2)
return qc
def test_from_qiskit_structure(self):
qc = self._asymmetric_circuit()
circ = from_qiskit(qc)
assert circ.n_qubits == 3
names = [op['name'] for op in circ.ops]
assert names == ['h', 'cx', 'rx', 'crz']
def test_run_qiskit_circuit_matches_statevector_probabilities(self):
from qiskit.quantum_info import Statevector
qc = self._asymmetric_circuit()
qk_probs = Statevector.from_instruction(qc).probabilities()
_, de_probs = run_qiskit_circuit(qc, use_float32=False)
np.testing.assert_allclose(de_probs, qk_probs, atol=1e-6)
def test_bit_order_regression_asymmetric_circuit(self):
# X only on qubit 0 of 3 -> must land on qiskit index 1 (q0 = LSB),
# not index 4 (which would be the DE-native MSB-first index) β€”
# pins the exact convention, not just "some permutation happened to
# work" on a symmetric circuit.
from qiskit import QuantumCircuit
qc = QuantumCircuit(3)
qc.x(0)
_, probs = run_qiskit_circuit(qc, use_float32=False)
nonzero = np.where(probs > 1e-9)[0]
assert list(nonzero) == [1]
def test_custom_gate_definition_does_not_corrupt_following_statement(self):
# qiskit.qasm2.dumps emits composite gates (e.g. mcx) as a `gate
# NAME params { ... }` block on a single line β€” same brace-block
# corruption class as QASM3 for/if/while/def, fixed by widening
# _RE_BLOCK_HEAD to also strip `gate` blocks. mcx itself has no
# physical implementation in this simulator (unknown gate name,
# silent no-op elsewhere in run_circuit too) β€” that part is a real,
# separate, documented limitation, not something this test hides.
from qiskit import QuantumCircuit
qc = QuantumCircuit(4)
qc.h(0)
qc.mcx([0, 1, 2], 3)
circ = from_qiskit(qc)
assert circ.n_qubits == 4
assert [op['name'] for op in circ.ops] == ['h', 'mcx']
def test_to_qiskit_bit_order_is_involution(self):
# bit-reversal applied twice must return the original array
rng = np.random.default_rng(0)
probs = rng.random(2 ** 3)
once = _to_qiskit_bit_order(probs, 3)
twice = _to_qiskit_bit_order(once, 3)
np.testing.assert_allclose(twice, probs)
# ─────────────────────────────────────────────────────────────
# PennyLane
# ─────────────────────────────────────────────────────────────
class TestPennyLaneInterop:
pennylane = pytest.importorskip('pennylane')
def test_from_pennylane_qnode_structure(self):
import pennylane as qml
dev = qml.device('default.qubit', wires=3)
@qml.qnode(dev)
def circuit():
qml.Hadamard(wires=0)
qml.CNOT(wires=[0, 1])
qml.RX(0.5, wires=2)
qml.CRZ(0.3, wires=[1, 2])
return qml.probs(wires=[0, 1, 2])
circ = from_pennylane(circuit)
assert circ.n_qubits == 3
assert [op['name'] for op in circ.ops] == ['h', 'cx', 'rx', 'crz']
def test_from_pennylane_tape_input(self):
import pennylane as qml
with qml.tape.QuantumTape() as tape:
qml.Hadamard(0)
qml.CNOT(wires=[0, 1])
circ = from_pennylane(tape)
assert circ.n_qubits == 2
assert [op['name'] for op in circ.ops] == ['h', 'cx']
def test_run_pennylane_circuit_matches_qml_probs_no_reordering(self):
import pennylane as qml
dev = qml.device('default.qubit', wires=3)
@qml.qnode(dev)
def circuit():
qml.Hadamard(wires=0)
qml.CNOT(wires=[0, 1])
qml.RX(0.5, wires=2)
qml.CRZ(0.3, wires=[1, 2])
return qml.probs(wires=[0, 1, 2])
pl_probs = np.asarray(circuit())
_, de_probs = run_pennylane_circuit(circuit, use_float32=False)
np.testing.assert_allclose(de_probs, pl_probs, atol=1e-6)
def test_bit_order_regression_asymmetric_circuit(self):
# Same asymmetric single-qubit-X probe as the Qiskit test, but here
# NO reordering should be needed at all β€” PennyLane's own wire
# convention already matches Dense-Evolution's MSB-first indexing.
import pennylane as qml
dev = qml.device('default.qubit', wires=3)
@qml.qnode(dev)
def circuit():
qml.PauliX(wires=0)
return qml.probs(wires=[0, 1, 2])
_, probs = run_pennylane_circuit(circuit, use_float32=False)
nonzero = np.where(probs > 1e-9)[0]
assert list(nonzero) == [4] # MSB-first: X on qubit 0 -> index 100b = 4
def test_non_monotonic_wire_order_does_not_get_renumbered(self):
# Found via independent fuzz testing: qml.to_openqasm (and the old
# tape.to_openqasm()) number exported QASM qubits by the order
# wires are FIRST TOUCHED in the circuit, not by wire index β€”
# PauliX(wires=2) then CNOT(wires=[2,1]) used to export as
# `x q[0]; cx q[0],q[1];`, silently renumbering wire 2->q[0] and
# wire 1->q[1]. Verified directly this produced a topologically
# different circuit whenever wires weren't touched in ascending
# order. Fixed by passing explicit wires= to force true wire order.
import pennylane as qml
dev = qml.device('default.qubit', wires=4)
@qml.qnode(dev)
def circuit():
qml.PauliX(wires=2)
qml.CNOT(wires=[2, 1])
return qml.probs(wires=range(4))
ref = np.asarray(circuit())
_, ours = run_pennylane_circuit(circuit, use_float32=False)
np.testing.assert_allclose(ours, ref, atol=1e-6)
def test_non_monotonic_wire_order_fuzz(self):
# Same style fuzz test that originally caught the bug (9/20 passed
# before the fix) β€” regression guard against it coming back.
import pennylane as qml
dev = qml.device('default.qubit', wires=4)
rng = np.random.default_rng(1)
for trial in range(20):
n_ops = rng.integers(5, 12)
piano = []
for _ in range(n_ops):
tipo = rng.integers(0, 4)
if tipo < 3:
piano.append((int(tipo), int(rng.integers(4))))
else:
a, b = rng.choice(4, 2, replace=False)
piano.append((3, int(a), int(b)))
def circuit(piano=piano):
for op in piano:
if op[0] == 0: qml.Hadamard(wires=op[1])
elif op[0] == 1: qml.PauliX(wires=op[1])
elif op[0] == 2: qml.RZ(0.7, wires=op[1])
else: qml.CNOT(wires=[op[1], op[2]])
return qml.probs(wires=range(4))
qnode = qml.QNode(circuit, dev)
ref = np.asarray(qnode())
_, ours = run_pennylane_circuit(qnode, use_float32=False)
assert np.allclose(ref, ours, atol=1e-6), f"trial {trial} mismatch, piano={piano}"
def test_non_monotonic_wire_order_bare_tape(self):
# Same fix, tape (not QNode) input path.
import pennylane as qml
with qml.tape.QuantumTape() as tape:
qml.PauliX(wires=2)
qml.CNOT(wires=[2, 1])
circ = from_pennylane(tape)
# wire 2 touched first, wire 1 second, but sorted-order export
# means q[0]=wire1, q[1]=wire2 -> cx control is q[1], target is q[0]
assert circ.n_qubits == 2
assert [op['name'] for op in circ.ops] == ['x', 'cx']
assert circ.ops[0]['qubits'] == [1] # x on wire 2 -> q[1]
assert circ.ops[1]['qubits'] == [1, 0] # cx(wire2, wire1) -> q[1], q[0]