From 2f5d9b9cdc22b3eeab7a42e191656e22c1391a9b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 19 Sep 2026 23:43:23 -0600 Subject: [PATCH] Classify gates for with_noise and Stim export through shared predicates and fail loudly on unknown gate types --- .../src/fault_tolerance/propagator.rs | 83 +++++++++++ python/pecos-rslib/pecos_rslib.pyi | 4 + .../pecos-rslib/src/dag_circuit_bindings.rs | 114 +++++++-------- .../src/pecos/qec/surface/circuit_builder.py | 29 +++- .../test_tick_circuit_gate_classification.py | 132 ++++++++++++++++++ 5 files changed, 294 insertions(+), 68 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_tick_circuit_gate_classification.py diff --git a/crates/pecos-qec/src/fault_tolerance/propagator.rs b/crates/pecos-qec/src/fault_tolerance/propagator.rs index 77deb8ae9..ad41adcad 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator.rs @@ -152,6 +152,52 @@ pub fn is_supported_noop_or_metadata_gate(gate_type: GateType) -> bool { ) } +/// Gate classification for compiling gate-triggered quantum noise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateNoiseKind { + /// Preparation or allocation receiving preparation noise. + Prep, + /// Single-qubit operation receiving single-qubit noise. + Single, + /// Two-qubit operation receiving one noise channel per pair. + Two, + /// Measurement receiving no post-gate quantum noise. + Measurement, + /// Operation left unchanged by the noise callback. + Transparent, + /// Unsupported operation that must be rejected when noise is active. + Error, +} + +/// Classify a gate for preparation, single-qubit, or two-qubit quantum noise. +/// +/// Identity and idle gates retain single-qubit noise even though they are +/// transparent to Pauli propagation. Custom gates have no known arity. +#[inline] +#[must_use] +pub fn gate_noise_kind(gate_type: GateType) -> GateNoiseKind { + match gate_type { + // Custom has placeholder arity 1, not a known single-qubit operation. + GateType::Custom => GateNoiseKind::Error, + gate if is_supported_prep_gate(gate) => GateNoiseKind::Prep, + // consumes_measurement_record excludes MeasureLeaked, which still collapses + // the qubit. A complete measurement predicate belongs in pecos_core::GateType, + // alongside consumes_measurement_record (see also Gate::validate). + gate if gate.consumes_measurement_record() || gate == GateType::MeasureLeaked => { + GateNoiseKind::Measurement + } + // Identity and idle operations retain their physical single-qubit noise. + GateType::I | GateType::Idle => GateNoiseKind::Single, + // The core rejects Channel upstream of the noise callback. + gate if is_supported_noop_or_metadata_gate(gate) || gate == GateType::Channel => { + GateNoiseKind::Transparent + } + gate if gate.is_single_qubit() => GateNoiseKind::Single, + gate if gate.is_two_qubit() => GateNoiseKind::Two, + _ => GateNoiseKind::Error, + } +} + /// Circuit position of a gate that cannot be faithfully Pauli-propagated. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnsupportedGateLocation { @@ -759,6 +805,43 @@ mod tests { use super::*; use pecos_quantum::TickCircuit; + #[test] + fn every_gate_has_an_explicit_noise_classification() { + for value in u8::MIN..=u8::MAX { + let Ok(gate) = GateType::try_from(value) else { + continue; + }; + let prep = is_supported_prep_gate(gate); + let measurement = gate.consumes_measurement_record() || gate == GateType::MeasureLeaked; + let transparent = (is_supported_noop_or_metadata_gate(gate) + && !matches!(gate, GateType::I | GateType::Idle)) + || gate == GateType::Channel; + let single = gate.is_single_qubit() + && gate != GateType::Custom + && !prep + && !measurement + && !transparent; + let two = gate.is_two_qubit(); + // Intentionally enumerate errors: a future unclassified variant must fail. + let error = matches!(gate, GateType::CCX | GateType::Custom); + let buckets = [ + (prep, GateNoiseKind::Prep), + (single, GateNoiseKind::Single), + (two, GateNoiseKind::Two), + (measurement, GateNoiseKind::Measurement), + (transparent, GateNoiseKind::Transparent), + (error, GateNoiseKind::Error), + ]; + assert_eq!( + buckets.iter().filter(|(member, _)| *member).count(), + 1, + "{gate:?}" + ); + let expected = buckets.iter().find(|(member, _)| *member).unwrap().1; + assert_eq!(gate_noise_kind(gate), expected, "{gate:?}"); + } + } + fn simple_syndrome_circuit() -> TickCircuit { // Simple Z-stabilizer measurement: Z0 Z1 // Ancilla qubit 2 measures the parity diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 968d6fe37..af31d7d50 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1741,6 +1741,10 @@ class llvm: PHYSICAL_DURATION_META_KEY: str +def is_supported_noop_or_metadata_gate(gate_type: GateType) -> bool: + """Return whether the gate is transparent to Pauli propagation.""" + ... + class GateType: """Gate type marker.""" diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index baa44e184..2fc52e971 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -25,6 +25,9 @@ use crate::dtypes::AngleParam; use crate::gate_registry_bindings::PyGateRegistry; use pecos_core::{Angle64, ChannelExpr, GateQubits, GateSignature, Pauli, TimeUnits}; +use pecos_qec::fault_tolerance::propagator::{ + GateNoiseKind, gate_noise_kind, is_supported_noop_or_metadata_gate, +}; use pecos_quantum::{ Attribute, DagCircuit, Gate, GateType, PHYSICAL_DURATION_META_KEY, QubitId, Tick, TickCircuit, TickGateError, @@ -88,25 +91,6 @@ fn validate_probability(name: &str, p: f64) -> PyResult<()> { } } -fn receives_two_qubit_noise(gate_type: GateType) -> bool { - matches!( - gate_type, - GateType::CX - | GateType::CY - | GateType::CZ - | GateType::SZZ - | GateType::SZZdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::SWAP - | GateType::RXX - | GateType::RYY - | GateType::RZZ - ) -} - /// Convert a Rust Attribute to a Python object. fn attribute_to_py(py: Python<'_>, attr: &Attribute) -> Py { match attr { @@ -647,6 +631,12 @@ impl PyGateType { } } +/// Whether a gate is transparent to Pauli propagation. +#[pyfunction(name = "is_supported_noop_or_metadata_gate")] +fn py_is_supported_noop_or_metadata_gate(gate_type: PyGateType) -> bool { + is_supported_noop_or_metadata_gate(gate_type.inner) +} + impl From for PyGateType { fn from(inner: GateType) -> Self { Self { inner } @@ -2895,9 +2885,22 @@ impl PyTickCircuit { )); } - if p2 > 0.0 { + if p1 > 0.0 || p2 > 0.0 || p_prep > 0.0 { for (tick_idx, gate) in self.inner.iter_gate_batches_with_tick() { - if receives_two_qubit_noise(gate.gate_type) && !gate.qubits.len().is_multiple_of(2) + match gate_noise_kind(gate.gate_type) { + GateNoiseKind::Prep + | GateNoiseKind::Single + | GateNoiseKind::Two + | GateNoiseKind::Measurement + | GateNoiseKind::Transparent => {} + GateNoiseKind::Error => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unsupported gate type {:?} at tick {tick_idx} for with_noise", + gate.gate_type + ))); + } + } + if p2 > 0.0 && gate.gate_type.is_two_qubit() && !gate.qubits.len().is_multiple_of(2) { return Err(pyo3::exceptions::PyValueError::new_err(format!( "{:?} at tick {tick_idx} has {} qubits; expected pairs", @@ -2912,63 +2915,36 @@ impl PyTickCircuit { .inner .try_with_noise(&|gate: &Gate| -> Vec { let mut channels = Vec::new(); - match gate.gate_type { - GateType::PZ | GateType::QAlloc if p_prep > 0.0 => { - channels.extend( - gate.qubits - .iter() - .map(|q| pecos_core::channel::BitFlip(p_prep, q.index())), - ); + match gate_noise_kind(gate.gate_type) { + GateNoiseKind::Prep if p_prep > 0.0 => { + // Match pecos-qec's dem_builder/mem_builder.rs: PX errors are Z flips. + channels.extend(gate.qubits.iter().map(|q| { + if gate.gate_type == GateType::PX { + pecos_core::channel::Dephasing(p_prep, q.index()) + } else { + pecos_core::channel::BitFlip(p_prep, q.index()) + } + })); } - GateType::I - | GateType::X - | GateType::Y - | GateType::Z - | GateType::H - | GateType::F - | GateType::Fdg - | GateType::SX - | GateType::SXdg - | GateType::SY - | GateType::SYdg - | GateType::SZ - | GateType::SZdg - | GateType::T - | GateType::Tdg - | GateType::RX - | GateType::RY - | GateType::RZ - | GateType::U - | GateType::RXY1Q - | GateType::Idle - if p1 > 0.0 => - { + GateNoiseKind::Single if p1 > 0.0 => { channels.extend( gate.qubits .iter() .map(|q| pecos_core::channel::Depolarizing(p1, q.index())), ); } - GateType::CX - | GateType::CY - | GateType::CZ - | GateType::SZZ - | GateType::SZZdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::SWAP - | GateType::RXX - | GateType::RYY - | GateType::RZZ - if p2 > 0.0 => - { + GateNoiseKind::Two if p2 > 0.0 => { channels.extend(gate.qubits.as_chunks::<2>().0.iter().map(|pair| { pecos_core::channel::Depolarizing2(p2, pair[0].index(), pair[1].index()) })); } - _ => {} + // Unsupported gates were rejected above whenever noise is active. + GateNoiseKind::Prep + | GateNoiseKind::Single + | GateNoiseKind::Two + | GateNoiseKind::Measurement + | GateNoiseKind::Transparent + | GateNoiseKind::Error => {} } channels }) @@ -4128,6 +4104,10 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py // Add classes to parent module parent_module.add_class::()?; parent_module.add_class::()?; + parent_module.add_function(wrap_pyfunction!( + py_is_supported_noop_or_metadata_gate, + parent_module + )?)?; parent_module.add_class::()?; parent_module.add_class::()?; parent_module.add_class::()?; diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 8a9c95734..a13f647ab 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -3191,6 +3191,8 @@ def tick_circuit_to_stim( import json import math + from pecos_rslib import is_supported_noop_or_metadata_gate + lines = [] simple_gate_map = { @@ -3302,7 +3304,32 @@ def _gate_to_stim( msg = f"Unsupported traced Clifford RXY1Q angles: theta={theta!r}, phi={phi!r}" raise ValueError(msg) - return [], None + if gate_name == "RXYXY2Q": + if len(gate.angles) < 2: + return [], None + theta = float(gate.angles[0]) + phi = float(gate.angles[1]) + if _is_close_turn(theta, 0.0): + return [], None + axis = None + if _is_close_turn(phi, 0.0) or _is_close_turn(phi, math.pi): + axis = "X" + elif _is_close_turn(phi, math.pi / 2) or _is_close_turn(phi, 3 * math.pi / 2): + axis = "Y" + if axis is not None: + if _is_close_turn(theta, math.pi / 2): + return [(f"SQRT_{axis}{axis}", qubits)], "two" + if _is_close_turn(theta, 3 * math.pi / 2): + return [(f"SQRT_{axis}{axis}_DAG", qubits)], "two" + if _is_close_turn(theta, math.pi): + return [(axis, qubits)], "two" + msg = f"Unsupported traced Clifford RXYXY2Q angles: theta={theta!r}, phi={phi!r}" + raise ValueError(msg) + + if is_supported_noop_or_metadata_gate(gate.gate_type): + return [], None + msg = f"Unsupported gate for Stim export: {gate_name}" + raise ValueError(msg) for tick_idx in range(tc.num_ticks()): tick = tc.get_tick(tick_idx) diff --git a/python/quantum-pecos/tests/qec/test_tick_circuit_gate_classification.py b/python/quantum-pecos/tests/qec/test_tick_circuit_gate_classification.py new file mode 100644 index 000000000..5143d410d --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_tick_circuit_gate_classification.py @@ -0,0 +1,132 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Gate classification must preserve noise and reject unsupported exports.""" + +import math + +import pytest +from pecos.qec.surface.circuit_builder import tick_circuit_to_stim +from pecos_rslib.quantum import TickCircuit + + +def circuit_with_gate(name: str, qubits: list[int], angles: list[float] | None = None) -> TickCircuit: + circuit = TickCircuit() + circuit.tick().add_gate(name, qubits, angles) + return circuit + + +@pytest.mark.parametrize( + ("name", "angles"), + [ + ("RXYXY2Q", [0.3, 0.1]), + ("CH", None), + ("RXXRYYRZZ", [0.1, 0.2, 0.3]), + ("U2q", [0.0] * 15), + ], +) +def test_with_noise_structural_two_qubit_gates(name: str, angles: list[float] | None) -> None: + noisy = circuit_with_gate(name, [0, 1], angles).with_noise(p2=0.5) + channels = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + assert len(channels) == 1 + assert list(channels[0].qubits) == [0, 1] + terms = channels[0].channel_mixed_pauli_terms() + assert len(terms) == 16 + assert terms[0] == (0.5, []) + assert [prob for prob, _ in terms[1:]] == pytest.approx([0.5 / 15] * 15) + assert {tuple(paulis) for _, paulis in terms} == { + tuple((pauli, qubit) for qubit, pauli in enumerate((p0, p1)) if pauli != "I") for p0 in "IXYZ" for p1 in "IXYZ" + } + + +def test_with_noise_px_preparation() -> None: + noisy = circuit_with_gate("PX", [0]).with_noise(p_prep=0.5) + channels = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + assert len(channels) == 1 + assert channels[0].channel_mixed_pauli_terms() == [(0.5, []), (0.5, [("Z", 0)])] + + +def test_with_noise_px_preparation_full_error_channel() -> None: + circuit = circuit_with_gate("PX", [0]) + circuit.tick().add_gate("MX", [0]) + noisy = circuit.with_noise(p_prep=1.0) + channels = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + assert len(channels) == 1 + assert channels[0].channel_mixed_pauli_terms() == [(0.0, []), (1.0, [("Z", 0)])] + assert not any(gate.is_channel() for _, gate in circuit.with_noise(p_prep=0.0).gate_batches()) + + +@pytest.mark.parametrize("name", ["PZ", "QAlloc"]) +def test_with_noise_z_preparation_retains_bit_flip(name: str) -> None: + noisy = circuit_with_gate(name, [0]).with_noise(p_prep=0.5) + channels = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + assert len(channels) == 1 + assert channels[0].channel_mixed_pauli_terms() == [(0.5, []), (0.5, [("X", 0)])] + + +@pytest.mark.parametrize("rate", ["p1", "p2", "p_prep"]) +def test_with_noise_ccx_raises(rate: str) -> None: + circuit = circuit_with_gate("CCX", [0, 1, 2]) + with pytest.raises(ValueError, match=r"CCX.*tick 0"): + circuit.with_noise(**{rate: 0.1}) + assert circuit.with_noise().gate_count() == 1 + + +@pytest.mark.parametrize("name", ["I", "Idle"]) +def test_with_noise_identity_retains_single_qubit_noise(name: str) -> None: + circuit = TickCircuit() + if name == "Idle": + circuit.tick().idle(1, [0]) + else: + circuit.tick().add_gate(name, [0]) + noisy = circuit.with_noise(p1=0.1) + assert sum(gate.is_channel() for _, gate in noisy.gate_batches()) == 1 + + +def test_with_noise_rejects_existing_channel_operations() -> None: + noisy = circuit_with_gate("PX", [0]).with_noise(p_prep=0.5) + with pytest.raises(ValueError, match="already contains channel operations"): + noisy.with_noise(p1=0.1) + + +@pytest.mark.parametrize("phi", [0.0, math.pi, math.pi / 2, 3 * math.pi / 2]) +@pytest.mark.parametrize(("theta", "suffix"), [(math.pi / 2, ""), (3 * math.pi / 2, "_DAG"), (math.pi, None)]) +def test_tick_circuit_stim_rxyxy2q_clifford(phi: float, theta: float, suffix: str | None) -> None: + circuit = circuit_with_gate("RXYXY2Q", [0, 1], [theta, phi]) + axis = "X" if phi in (0.0, math.pi) else "Y" + operation = axis if suffix is None else f"SQRT_{axis}{axis}{suffix}" + assert tick_circuit_to_stim(circuit, p2=1) == f"{operation} 0 1\nDEPOLARIZE2(1) 0 1" + + +def test_tick_circuit_stim_rxyxy2q_zero() -> None: + circuit = circuit_with_gate("RXYXY2Q", [0, 1], [0.0, 0.1]) + assert tick_circuit_to_stim(circuit) == "" + + +@pytest.mark.parametrize(("theta", "phi"), [(0.3, 0.1), (math.pi / 2, 0.1)]) +def test_tick_circuit_stim_rxyxy2q_unsupported_angles(theta: float, phi: float) -> None: + circuit = circuit_with_gate("RXYXY2Q", [0, 1], [theta, phi]) + with pytest.raises(ValueError, match="RXYXY2Q angles"): + tick_circuit_to_stim(circuit) + + +def test_tick_circuit_stim_ccx_raises() -> None: + with pytest.raises(ValueError, match="CCX"): + tick_circuit_to_stim(circuit_with_gate("CCX", [0, 1, 2])) + + +@pytest.mark.parametrize("name", ["I", "QFree", "TrackedPauliMeta"]) +def test_tick_circuit_stim_shared_transparent_gates(name: str) -> None: + assert tick_circuit_to_stim(circuit_with_gate(name, [0])) == "" + + +@pytest.mark.parametrize("name", ["MX", "MZ", "MPZ", "MeasureFree", "MeasureLeaked"]) +def test_with_noise_measurements_have_no_quantum_channel(name: str) -> None: + noisy = circuit_with_gate(name, [0]).with_noise(p1=0.1, p_prep=0.1) + assert not any(gate.is_channel() for _, gate in noisy.gate_batches()) + + +def test_with_noise_batched_two_qubit_pairs() -> None: + noisy = circuit_with_gate("RXYXY2Q", [0, 1, 2, 3], [0.3, 0.1]).with_noise(p2=0.5) + channels = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + assert [list(gate.qubits) for gate in channels] == [[0, 1], [2, 3]]