From 4213a9571852312254ab50b242251081cb41e5d9 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Wed, 10 Jun 2026 12:45:30 -0700 Subject: [PATCH 1/4] add loss policies to `NoiseConfig` and support for it in all QIR simulators --- .../qsc_eval/src/backend/noise_tests.rs | 6 +- source/qdk_package/qdk/_native.pyi | 19 + source/qdk_package/qdk/simulation/__init__.py | 7 +- .../qdk_package/qdk/simulation/_simulation.py | 1 + source/qdk_package/src/interpreter.rs | 4 +- source/qdk_package/src/qir_simulation.rs | 69 ++- .../src/qir_simulation/correlated_noise.rs | 4 +- .../tests/test_simulators_gates_noisy.py | 136 +++++- .../src/cpu_full_state_simulator.rs | 321 ++++++++++--- .../src/gpu_full_state_simulator/common.wgsl | 207 +++++++++ .../gpu_full_state_simulator/gpu_context.rs | 18 +- .../gpu_full_state_simulator/noise_mapping.rs | 26 +- .../simulator_adaptive.wgsl | 10 + .../simulator_base.wgsl | 15 +- source/simulators/src/noise_config.rs | 64 ++- source/simulators/src/stabilizer_simulator.rs | 439 ++++++++++++------ 16 files changed, 1115 insertions(+), 231 deletions(-) diff --git a/source/compiler/qsc_eval/src/backend/noise_tests.rs b/source/compiler/qsc_eval/src/backend/noise_tests.rs index 1c046f6411..e789dcb0f2 100644 --- a/source/compiler/qsc_eval/src/backend/noise_tests.rs +++ b/source/compiler/qsc_eval/src/backend/noise_tests.rs @@ -10,7 +10,7 @@ use crate::{ use expect_test::{Expect, expect}; use num_bigint::BigUint; use num_complex::Complex; -use qdk_simulators::noise_config::{NoiseConfig, NoiseTable, encode_pauli}; +use qdk_simulators::noise_config::{LossPolicy, NoiseConfig, NoiseTable, encode_pauli}; use std::fmt::Write; #[test] @@ -259,6 +259,7 @@ fn noise_config_with_single_qubit_fault( pauli_strings: vec![encode_pauli(pauli)], probabilities: vec![1.0], loss: 0.0, + on_loss: LossPolicy::Skip, }; set_gate(&mut config, table); config @@ -276,6 +277,7 @@ fn noise_config_with_two_qubit_fault( pauli_strings: vec![encode_pauli(pauli)], probabilities: vec![1.0], loss: 0.0, + on_loss: LossPolicy::Skip, }; set_gate(&mut config, table); config @@ -530,6 +532,7 @@ fn noise_config_mz_with_loss() { pauli_strings: vec![], probabilities: vec![], loss: 1.0, + on_loss: LossPolicy::Skip, }; let mut sim = SparseSim::new_with_noise_config(config.into()); let q = sim.qubit_allocate().expect("sparse simulator is infinite"); @@ -551,6 +554,7 @@ fn noise_config_gate_loss_causes_measurement_loss() { pauli_strings: vec![], probabilities: vec![], loss: 1.0, + on_loss: LossPolicy::Skip, }; let mut sim = SparseSim::new_with_noise_config(config.into()); let q = sim.qubit_allocate().expect("sparse simulator is infinite"); diff --git a/source/qdk_package/qdk/_native.pyi b/source/qdk_package/qdk/_native.pyi index d1aab18ad8..8848db006d 100644 --- a/source/qdk_package/qdk/_native.pyi +++ b/source/qdk_package/qdk/_native.pyi @@ -844,8 +844,27 @@ class QirInstruction: ... class IdleNoiseParams: s_probability: float +class LossPolicy(Enum): + """ + Specifies the behavior of a gate when at least one of its qubit operands is lost. + """ + + # If any operand of a gate is lost, skip the gate entirely. + SKIP: int + # If any operand of a gate is lost, propagate the loss to the other operands. + PROPAGATE: int + # For multi-qubit rotations, degrade the unitary to its single-qubit version + # on the surviving operand (e.g. rxx -> rx). Falls back to SKIP for gates with + # no single-qubit reduction (cx, cy, cz, swap, and single-qubit gates). + DEGRADE: int + # Skip the gate and instead apply an S adjoint to each surviving operand. + RESIDUAL_S_DAGGER: int + # Apply the unitary anyway, ignoring the loss. + APPLY_ANYWAY: int + class NoiseTable: loss: float + on_loss: LossPolicy def __init__(self, num_qubits: int): """ diff --git a/source/qdk_package/qdk/simulation/__init__.py b/source/qdk_package/qdk/simulation/__init__.py index 8e5701332c..8e93ba3f2e 100644 --- a/source/qdk_package/qdk/simulation/__init__.py +++ b/source/qdk_package/qdk/simulation/__init__.py @@ -15,6 +15,10 @@ to individual gate intrinsics to model depolarizing, bit-flip, phase-flip, or correlated noise channels. +- :class:`~qdk.simulation.LossPolicy` — selects how a gate behaves when one of + its qubit operands is lost. Assign it to a noise table's ``on_loss`` attribute + (e.g. ``noise.cx.on_loss = LossPolicy.SKIP``). + - :func:`~qdk.simulation.run_qir` — simulates QIR as given in one of three backend simulators: clifford, gpu or cpu. @@ -26,7 +30,7 @@ """ from .._device._atom import NeutralAtomDevice -from ._simulation import NoiseConfig, run_qir +from ._simulation import NoiseConfig, LossPolicy, run_qir from ._noisy_simulator import ( NoisySimulatorError, DensityMatrixSimulator, @@ -40,6 +44,7 @@ __all__ = [ "NeutralAtomDevice", "NoiseConfig", + "LossPolicy", "run_qir", "NoisySimulatorError", "Operation", diff --git a/source/qdk_package/qdk/simulation/_simulation.py b/source/qdk_package/qdk/simulation/_simulation.py index 4aa6bf367d..68847400ac 100644 --- a/source/qdk_package/qdk/simulation/_simulation.py +++ b/source/qdk_package/qdk/simulation/_simulation.py @@ -15,6 +15,7 @@ run_cpu_adaptive, run_cpu_full_state, NoiseConfig, + LossPolicy, GpuContext, try_create_gpu_adapter, Result, diff --git a/source/qdk_package/src/interpreter.rs b/source/qdk_package/src/interpreter.rs index 4cc65de9c8..c802b542b7 100644 --- a/source/qdk_package/src/interpreter.rs +++ b/source/qdk_package/src/interpreter.rs @@ -23,7 +23,7 @@ use crate::{ }, noisy_simulator::register_noisy_simulator_submodule, qir_simulation::{ - IdleNoiseParams, NoiseConfig, NoiseTable, QirInstruction, QirInstructionId, + IdleNoiseParams, LossPolicy, NoiseConfig, NoiseTable, QirInstruction, QirInstructionId, cpu_simulators::{ run_clifford, run_clifford_adaptive, run_cpu_adaptive, run_cpu_full_state, }, @@ -104,6 +104,7 @@ fn verify_classes_are_sendable() { is_send::(); is_send::(); is_send::(); + is_send::(); } #[pymodule] @@ -133,6 +134,7 @@ fn _native<'a>(py: Python<'a>, m: &Bound<'a, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(physical_estimates, m)?)?; m.add_function(wrap_pyfunction!(run_clifford, m)?)?; m.add_function(wrap_pyfunction!(try_create_gpu_adapter, m)?)?; diff --git a/source/qdk_package/src/qir_simulation.rs b/source/qdk_package/src/qir_simulation.rs index eccf18f248..4992ae8e0f 100644 --- a/source/qdk_package/src/qir_simulation.rs +++ b/source/qdk_package/src/qir_simulation.rs @@ -9,7 +9,7 @@ use crate::qir_simulation::correlated_noise::parse_noise_table; use num_traits::{Float, Unsigned}; use pyo3::{ - Bound, FromPyObject, Py, PyRef, PyResult, Python, + Bound, FromPyObject, Py, PyAny, PyRef, PyResult, Python, exceptions::{PyAttributeError, PyKeyError, PyTypeError, PyValueError}, pybacked::PyBackedStr, pyclass, pymethods, @@ -88,6 +88,47 @@ pub enum QirInstruction { ), } +/// Specifies the behavior of a gate when at least one of its qubit operands +/// is lost. Mirrors [`qdk_simulators::noise_config::LossPolicy`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[pyclass(eq, eq_int, from_py_object, module = "qdk._native")] +pub enum LossPolicy { + #[pyo3(name = "SKIP")] + Skip = 1, + #[pyo3(name = "PROPAGATE")] + Propagate = 2, + #[pyo3(name = "DEGRADE")] + Degrade = 3, + #[pyo3(name = "RESIDUAL_S_DAGGER")] + ResidualSDagger = 4, + #[pyo3(name = "APPLY_ANYWAY")] + ApplyAnyway = 5, +} + +impl From for qdk_simulators::noise_config::LossPolicy { + fn from(value: LossPolicy) -> Self { + match value { + LossPolicy::Skip => Self::Skip, + LossPolicy::Propagate => Self::Propagate, + LossPolicy::Degrade => Self::Degrade, + LossPolicy::ResidualSDagger => Self::ResidualSDagger, + LossPolicy::ApplyAnyway => Self::ApplyAnyway, + } + } +} + +impl From for LossPolicy { + fn from(value: qdk_simulators::noise_config::LossPolicy) -> Self { + match value { + qdk_simulators::noise_config::LossPolicy::Skip => Self::Skip, + qdk_simulators::noise_config::LossPolicy::Propagate => Self::Propagate, + qdk_simulators::noise_config::LossPolicy::Degrade => Self::Degrade, + qdk_simulators::noise_config::LossPolicy::ResidualSDagger => Self::ResidualSDagger, + qdk_simulators::noise_config::LossPolicy::ApplyAnyway => Self::ApplyAnyway, + } + } +} + #[derive(Debug)] #[pyclass(module = "qdk._native")] pub struct NoiseConfig { @@ -360,6 +401,9 @@ pub struct NoiseTable { pauli_noise: FxHashMap, #[pyo3(get, set)] pub loss: Probability, + /// The behavior of this gate when at least one of its operands is lost. + #[pyo3(get)] + pub on_loss: LossPolicy, } impl NoiseTable { @@ -504,6 +548,7 @@ impl NoiseTable { qubits: num_qubits, pauli_noise: FxHashMap::default(), loss: 0.0, + on_loss: LossPolicy::Skip, } } @@ -531,12 +576,19 @@ impl NoiseTable { /// /// for arbitrary pauli fields. Setting an element that was /// previously set overrides that entry with the new value. - fn __setattr__(&mut self, name: &str, value: Probability) -> PyResult<()> { - if name == "loss" { - self.loss = value; - Ok(()) - } else { - self.set_pauli_noise_elt(&name.to_uppercase(), value) + /// + /// The `on_loss` attribute is special-cased to accept a `LossPolicy`. + fn __setattr__(&mut self, name: &str, value: &Bound<'_, PyAny>) -> PyResult<()> { + match name { + "on_loss" => { + self.on_loss = value.extract::()?; + Ok(()) + } + "loss" => { + self.loss = value.extract::()?; + Ok(()) + } + _ => self.set_pauli_noise_elt(&name.to_uppercase(), value.extract::()?), } } @@ -629,6 +681,7 @@ impl From for qdk_simulators::noise_config::NoiseTable pauli_strings, probabilities, loss: generic_float_cast(value.loss), + on_loss: value.on_loss.into(), } } } @@ -648,6 +701,7 @@ fn from_noise_table_ref( pauli_strings, probabilities, loss: generic_float_cast(value.loss), + on_loss: value.on_loss.into(), } } @@ -668,6 +722,7 @@ impl From> for NoiseTable qubits: value.qubits, pauli_noise, loss: generic_float_cast(value.loss), + on_loss: value.on_loss.into(), } } } diff --git a/source/qdk_package/src/qir_simulation/correlated_noise.rs b/source/qdk_package/src/qir_simulation/correlated_noise.rs index 4859d837ef..ac33843628 100644 --- a/source/qdk_package/src/qir_simulation/correlated_noise.rs +++ b/source/qdk_package/src/qir_simulation/correlated_noise.rs @@ -8,7 +8,7 @@ use rustc_hash::FxHashMap; use std::fmt; use std::str::FromStr; -use crate::qir_simulation::NoiseTable; +use crate::qir_simulation::{LossPolicy, NoiseTable}; /// Errors that can occur while parsing a noise-table CSV. #[derive(Debug)] @@ -85,6 +85,7 @@ pub fn parse_noise_table(contents: &str) -> Result { qubits: qubits.unwrap_or(0), pauli_noise, loss: 0.0, + on_loss: LossPolicy::Skip, }); } @@ -158,6 +159,7 @@ pub fn parse_noise_table(contents: &str) -> Result { qubits, pauli_noise, loss: 0.0, + on_loss: LossPolicy::Skip, }) } diff --git a/source/qdk_package/tests/test_simulators_gates_noisy.py b/source/qdk_package/tests/test_simulators_gates_noisy.py index c6d6bb009a..7c781144e2 100644 --- a/source/qdk_package/tests/test_simulators_gates_noisy.py +++ b/source/qdk_package/tests/test_simulators_gates_noisy.py @@ -7,7 +7,7 @@ from qdk import qsharp from qdk._interpreter import compile from qdk import Result, TargetProfile -from qdk.simulation import run_qir as _run_qir, NoiseConfig +from qdk.simulation import run_qir as _run_qir, NoiseConfig, LossPolicy from qdk.simulation._simulation import try_create_gpu_adapter from typing import Literal, List, Optional, TypeAlias @@ -256,6 +256,140 @@ def test_two_qubit_loss(sim_type): ) +# =========================================================================== +# Loss-policy (on_loss) tests +# =========================================================================== +# +# These exercise the per-gate `NoiseConfig..on_loss` behavior. The +# `on_loss` policy is honored by the cpu (full-state) and clifford (stabilizer) +# simulators, so these tests are parametrized over just those two. +# +# A qubit is lost deterministically by giving a single-qubit gate a loss +# probability of 1.0 and then applying that gate. The gate under test then sees +# a lost operand and applies its configured policy. All outcomes are +# deterministic, so a single shot is sufficient. + + +LOSS_POLICY_SIM_TYPES = ["cpu", "clifford", gpu_param()] + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_default_controlled_gate_skips(sim_type): + # `cz.on_loss` defaults to SKIP: the lost control means CZ is skipped, so + # the surviving target qubit is left untouched in |0>. + noise = NoiseConfig() + noise.x.loss = 1.0 # deterministically lose qs[0] after X + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[0]); CZ(qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"-0": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_propagate_marks_other_operand_lost(sim_type): + # PROPAGATE: a lost operand propagates the loss to the other operand, so + # both qubits measure as Loss. + noise = NoiseConfig() + noise.x.loss = 1.0 + noise.cz.on_loss = LossPolicy.PROPAGATE + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[0]); CZ(qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"--": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_rxx_degrade_reduces_to_single_qubit(sim_type): + # `rxx.on_loss` defaults to DEGRADE: with one operand lost, Rxx reduces to + # Rx on the survivor. Rx(PI) flips qs[1] to |1>. + noise = NoiseConfig() + noise.x.loss = 1.0 + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[0]); Rxx(Std.Math.PI(), qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"-1": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_rxx_skip_leaves_survivor_untouched(sim_type): + # Overriding `rxx.on_loss` to SKIP leaves the surviving qubit in |0>. + noise = NoiseConfig() + noise.x.loss = 1.0 + noise.rxx.on_loss = LossPolicy.SKIP + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[0]); Rxx(Std.Math.PI(), qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"-0": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_residual_s_dagger_applies_s_adjoint(sim_type): + # RESIDUAL_S_DAGGER: the gate is skipped but an S-dagger is applied to each + # surviving operand. qs[1] is prepared in |+i> = S H |0>; the residual + # S-dagger maps it back to |+>, and a final H rotates it to |0>. + noise = NoiseConfig() + noise.x.loss = 1.0 + noise.cx.on_loss = LossPolicy.RESIDUAL_S_DAGGER + results = compile_and_run( + "{use qs = Qubit[2]; H(qs[1]); S(qs[1]); X(qs[0]); CNOT(qs[0], qs[1]); H(qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"-0": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_swap_apply_anyway_exchanges_state(sim_type): + # `swap.on_loss` defaults to APPLY_ANYWAY: the SWAP unitary still runs, so + # qs[1]'s |1> moves into qs[0]. The loss flag is always exchanged, so qs[1] + # becomes the lost qubit. qs[0] is lost via Y so X-prepared qs[1] is intact. + noise = NoiseConfig() + noise.y.loss = 1.0 + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[1]); Y(qs[0]); SWAP(qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"1-": 1.0}) + + +@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +def test_on_loss_swap_skip_keeps_state_but_swaps_loss_flag(sim_type): + # Overriding `swap.on_loss` to SKIP skips the SWAP unitary, but the loss + # flag is still exchanged. qs[0] keeps its reset |0> and qs[1] becomes lost. + noise = NoiseConfig() + noise.y.loss = 1.0 + noise.swap.on_loss = LossPolicy.SKIP + results = compile_and_run( + "{use qs = Qubit[2]; X(qs[1]); Y(qs[0]); SWAP(qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + shots=1, + seed=SEED, + noise=noise, + sim_type=sim_type, + ) + check_histogram(results, {"0-": 1.0}) + + # =========================================================================== # Two-qubit gate noise tests # =========================================================================== diff --git a/source/simulators/src/cpu_full_state_simulator.rs b/source/simulators/src/cpu_full_state_simulator.rs index 7b117ef19f..a786e1ea12 100644 --- a/source/simulators/src/cpu_full_state_simulator.rs +++ b/source/simulators/src/cpu_full_state_simulator.rs @@ -5,7 +5,7 @@ pub mod noise; use crate::{ MeasurementResult, QubitID, Simulator, - noise_config::{CumulativeNoiseConfig, IntrinsicID}, + noise_config::{CumulativeNoiseConfig, IntrinsicID, LossPolicy}, }; use core::f64; use nalgebra::Complex; @@ -512,6 +512,30 @@ impl NoisySimulator { } } + /// Marks each non-lost `target` as lost by measuring it, resetting it, and + /// flagging it. Used by the [`LossPolicy::Propagate`] behavior. + fn propagate_loss(&mut self, targets: &[QubitID]) { + for &target in targets { + if !self.loss[target] { + self.mresetz_impl(target); + self.loss[target] = true; + } + } + } + + /// Applies an `S` adjoint to each non-lost `target`. Used by the + /// [`LossPolicy::ResidualSDagger`] behavior. + fn residual_s_dagger(&mut self, targets: &[QubitID]) { + for &target in targets { + if !self.loss[target] { + self.apply_idle_noise(target); + self.state + .apply_operation(&S_ADJ, &[target]) + .expect("apply_operation should succeed"); + } + } + } + /// Records a z-measurement on the given `target`. fn record_mz(&mut self, target: QubitID, result_id: QubitID) { let measurement = self.mz_impl(target); @@ -682,7 +706,15 @@ impl Simulator for NoisySimulator { } fn x(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + // The only operand is lost. Only `ApplyAnyway` still applies a + // single-qubit gate; every other policy is equivalent to `Skip`. + if matches!(self.noise_config.x.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&X, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&X, &[target]) @@ -693,7 +725,13 @@ impl Simulator for NoisySimulator { } fn y(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.y.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&Y, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&Y, &[target]) @@ -704,7 +742,13 @@ impl Simulator for NoisySimulator { } fn z(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.z.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&Z, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&Z, &[target]) @@ -715,7 +759,13 @@ impl Simulator for NoisySimulator { } fn h(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.h.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&H, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&H, &[target]) @@ -726,7 +776,13 @@ impl Simulator for NoisySimulator { } fn s(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.s.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&S, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&S, &[target]) @@ -737,7 +793,13 @@ impl Simulator for NoisySimulator { } fn s_adj(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.s_adj.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&S_ADJ, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&S_ADJ, &[target]) @@ -748,7 +810,13 @@ impl Simulator for NoisySimulator { } fn sx(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.sx.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&SX, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&SX, &[target]) @@ -759,7 +827,13 @@ impl Simulator for NoisySimulator { } fn sx_adj(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.sx_adj.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&SX_ADJ, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&SX_ADJ, &[target]) @@ -770,7 +844,13 @@ impl Simulator for NoisySimulator { } fn t(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.t.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&T, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&T, &[target]) @@ -781,7 +861,13 @@ impl Simulator for NoisySimulator { } fn t_adj(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.t_adj.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&T_ADJ, &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&T_ADJ, &[target]) @@ -792,7 +878,13 @@ impl Simulator for NoisySimulator { } fn rx(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.rx.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&rx(angle), &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&rx(angle), &[target]) @@ -803,7 +895,13 @@ impl Simulator for NoisySimulator { } fn ry(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.ry.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&ry(angle), &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&ry(angle), &[target]) @@ -814,7 +912,13 @@ impl Simulator for NoisySimulator { } fn rz(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.rz.on_loss, LossPolicy::ApplyAnyway) { + self.state + .apply_operation(&rz(angle), &[target]) + .expect("apply_operation should succeed"); + } + } else { self.apply_idle_noise(target); self.state .apply_operation(&rz(angle), &[target]) @@ -825,7 +929,17 @@ impl Simulator for NoisySimulator { } fn cx(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cx.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CX, &[control, target]) + .expect("apply_operation should succeed"), + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state @@ -838,7 +952,17 @@ impl Simulator for NoisySimulator { } fn cy(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cy.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CY, &[control, target]) + .expect("apply_operation should succeed"), + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state @@ -851,7 +975,17 @@ impl Simulator for NoisySimulator { } fn cz(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cz.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CZ, &[control, target]) + .expect("apply_operation should succeed"), + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state @@ -864,78 +998,125 @@ impl Simulator for NoisySimulator { } fn rxx(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.rx(angle, q2), - (false, true) => self.rx(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - self.state + if self.loss[q1] || self.loss[q2] { + match self.noise_config.rxx.on_loss { + LossPolicy::Skip => {} + // Degrade the two-qubit rotation to its single-qubit version on + // the surviving operand. + LossPolicy::Degrade => { + if !self.loss[q1] { + self.rx(angle, q1); + } else if !self.loss[q2] { + self.rx(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => self + .state .apply_operation(&rxx(angle), &[q1, q2]) - .expect("apply_operation should succeed"); - apply_loss!(self, rxx, &[q1, q2]); - apply_noise!(self, rxx, &[q1, q2]); + .expect("apply_operation should succeed"), } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state + .apply_operation(&rxx(angle), &[q1, q2]) + .expect("apply_operation should succeed"); + apply_loss!(self, rxx, &[q1, q2]); + apply_noise!(self, rxx, &[q1, q2]); } } fn ryy(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.ry(angle, q2), - (false, true) => self.ry(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - self.state + if self.loss[q1] || self.loss[q2] { + match self.noise_config.ryy.on_loss { + LossPolicy::Skip => {} + LossPolicy::Degrade => { + if !self.loss[q1] { + self.ry(angle, q1); + } else if !self.loss[q2] { + self.ry(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => self + .state .apply_operation(&ryy(angle), &[q1, q2]) - .expect("apply_operation should succeed"); - apply_loss!(self, ryy, &[q1, q2]); - apply_noise!(self, ryy, &[q1, q2]); + .expect("apply_operation should succeed"), } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state + .apply_operation(&ryy(angle), &[q1, q2]) + .expect("apply_operation should succeed"); + apply_loss!(self, ryy, &[q1, q2]); + apply_noise!(self, ryy, &[q1, q2]); } } fn rzz(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.rz(angle, q2), - (false, true) => self.rz(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - self.state + if self.loss[q1] || self.loss[q2] { + match self.noise_config.rzz.on_loss { + LossPolicy::Skip => {} + LossPolicy::Degrade => { + if !self.loss[q1] { + self.rz(angle, q1); + } else if !self.loss[q2] { + self.rz(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => self + .state .apply_operation(&rzz(angle), &[q1, q2]) - .expect("apply_operation should succeed"); - apply_loss!(self, rzz, &[q1, q2]); - apply_noise!(self, rzz, &[q1, q2]); + .expect("apply_operation should succeed"), } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state + .apply_operation(&rzz(angle), &[q1, q2]) + .expect("apply_operation should succeed"); + apply_loss!(self, rzz, &[q1, q2]); + apply_noise!(self, rzz, &[q1, q2]); } } fn swap(&mut self, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => { - self.apply_idle_noise(q2); - self.state - .apply_operation(&SWAP, &[q1, q2]) - .expect("apply_operation should succeed"); - } - (false, true) => { - self.apply_idle_noise(q1); - self.state - .apply_operation(&SWAP, &[q1, q2]) - .expect("apply_operation should succeed"); - } - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - self.state - .apply_operation(&SWAP, &[q1, q2]) - .expect("apply_operation should succeed"); + if self.loss[q1] || self.loss[q2] { + // At least one operand is lost. The loss-flag swap below always + // happens; `on_loss` only governs the unitary and residual noise. + match self.noise_config.swap.on_loss { + LossPolicy::ApplyAnyway => { + let (l1, l2) = (self.loss[q1], self.loss[q2]); + if !l1 { + self.apply_idle_noise(q1); + } + if !l2 { + self.apply_idle_noise(q2); + } + // Both operands lost is a pure relabel, so only apply the + // unitary when at least one operand survives. + if !l1 || !l2 { + self.state + .apply_operation(&SWAP, &[q1, q2]) + .expect("apply_operation should succeed"); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::Skip | LossPolicy::Degrade => {} } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state + .apply_operation(&SWAP, &[q1, q2]) + .expect("apply_operation should succeed"); } // There are three kinds of swaps: // 1. A logical swap, also called a relabel. diff --git a/source/simulators/src/gpu_full_state_simulator/common.wgsl b/source/simulators/src/gpu_full_state_simulator/common.wgsl index 907dae91d8..653e9019bc 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -326,6 +326,213 @@ fn get_loss_idx(op_idx: u32) -> u32 { return 0u; } +// Loss policy values. These are stamped onto a gate op's `q3` field by the host +// (see `LossPolicy::as_u32` on the Rust side) and tell the shader how to handle +// the gate when one of its operands is lost. `0` means "no policy stamped", +// which the shader treats the same as SKIP. +const LOSS_POLICY_NONE = 0u; +const LOSS_POLICY_SKIP = 1u; +const LOSS_POLICY_PROPAGATE = 2u; +const LOSS_POLICY_DEGRADE = 3u; +const LOSS_POLICY_RESIDUAL_S_DAGGER = 4u; +const LOSS_POLICY_APPLY_ANYWAY = 5u; + +// Returns true if the gate at `op_idx` touches at least one lost qubit. +// `q1`/`q2` are the (resolved) operands of the gate. +fn gate_has_lost_operand(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> bool { + let shot = &shots[shot_idx]; + let op = &ops[op_idx]; + if (shot.qubit_state[q1].heat == -1.0) { + return true; + } + let is_2q = (op.id == OPID_CX || op.id == OPID_CY || op.id == OPID_CZ || + op.id == OPID_SWAP || op.id == OPID_RXX || op.id == OPID_RYY || + op.id == OPID_RZZ || op.id == OPID_MAT2Q); + return is_2q && (shot.qubit_state[q2].heat == -1.0); +} + +// Builds a 4x4 (in shot.unitary) that applies the 1-qubit matrix `m` (given as +// m00,m01,m10,m11) to `target_is_q2 ? q2 : q1` and identity to the other qubit +// of the pair. The lost qubit is in the |0> state, so the identity factor keeps +// it there. The 2-qubit basis is |q1 q2>, so the row/col index is +// (2 * q1_bit + q2_bit). +fn set_1q_on_pair_unitary(shot_idx: u32, target_is_q2: bool, + m00: vec2f, m01: vec2f, m10: vec2f, m11: vec2f) { + let shot = &shots[shot_idx]; + // Zero the whole 4x4 first. + for (var i = 0u; i < 16u; i++) { + shot.unitary[i] = vec2f(0.0, 0.0); + } + if target_is_q2 { + // Acts on q2 (low bit): block-diagonal diag(M, M). + // Top-left block (q1 = 0): + shot.unitary[0] = m00; shot.unitary[1] = m01; + shot.unitary[4] = m10; shot.unitary[5] = m11; + // Bottom-right block (q1 = 1): + shot.unitary[10] = m00; shot.unitary[11] = m01; + shot.unitary[14] = m10; shot.unitary[15] = m11; + } else { + // Acts on q1 (high bit): M (x) I. + shot.unitary[0] = m00; shot.unitary[2] = m01; + shot.unitary[8] = m10; shot.unitary[10] = m11; + shot.unitary[5] = m00; shot.unitary[7] = m01; + shot.unitary[13] = m10; shot.unitary[15] = m11; + } +} + +// Sets up the shot to execute a 2-qubit shot-buffer op on the gate's operands. +fn finish_2q_shot_buffer(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) { + let shot = &shots[shot_idx]; + shot.op_idx = op_idx; + shot.op_type = OPID_SHOT_BUFF_2Q; + shot.qubits_updated_last_op_mask = (1u << q1) | (1u << q2); +} + +// Loses a single surviving `qubit` for the PROPAGATE policy: samples a +// measurement outcome, collapses the qubit to that outcome and resets it to +// |0>, and marks it lost (heat = -1.0). The collapse is expressed as a 2-qubit +// tensor on the gate's operands (reset on `qubit`, identity on the lost +// partner, which is already in |0>), reusing the standard shot-buffer execute +// path. `qubit` must be one of the gate's two operands `q1`/`q2`. +fn propagate_loss_to_qubit(shot_idx: u32, op_idx: u32, q1: u32, q2: u32, qubit: u32) { + let shot = &shots[shot_idx]; + + let result = select(1u, 0u, shot.rand_measure < shot.qubit_state[qubit].zero_probability); + + // Reset instrument (project + move |1> into |0> slot), same as MResetZ: + // result==0: [[1,0],[0,0]] + // result==1: [[0,1],[0,0]] + let m00 = select(vec2f(1.0, 0.0), vec2f(0.0, 0.0), result == 1u); + let m01 = select(vec2f(0.0, 0.0), vec2f(1.0, 0.0), result == 1u); + let m10 = vec2f(0.0, 0.0); + let m11 = vec2f(0.0, 0.0); + + let target_is_q2 = (qubit == q2); + set_1q_on_pair_unitary(shot_idx, target_is_q2, m00, m01, m10, m11); + + // Renormalize by the measured branch probability. + shot.renormalize = select( + 1.0 / sqrt(shot.qubit_state[qubit].zero_probability), + 1.0 / sqrt(shot.qubit_state[qubit].one_probability), + result == 1u); + + // Mark the qubit lost and clear its definite-state bits so the probability + // pass recomputes it. + shot.qubit_state[qubit].heat = -1.0; + shot.qubit_is_0_mask = shot.qubit_is_0_mask & ~(1u << qubit); + shot.qubit_is_1_mask = shot.qubit_is_1_mask & ~(1u << qubit); + + finish_2q_shot_buffer(shot_idx, op_idx, q1, q2); +} + +// Handles a gate whose operand(s) include at least one lost qubit, according to +// the loss policy stamped on the op's `q3` field. `q1`/`q2` are the (resolved) +// operands. Returns true if the gate was fully handled here (the caller should +// return), or false if normal processing should continue (only for +// APPLY_ANYWAY, which runs the gate as usual). +fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> bool { + let shot = &shots[shot_idx]; + let op = &ops[op_idx]; + let policy = op.q3; + + // SWAP is special: it physically relocates the two qubits, so their loss + // state is always exchanged regardless of the policy (the policy only + // governs whether the unitary runs). Handle it explicitly here. + if (op.id == OPID_SWAP) { + // Exchange the per-qubit loss flag (heat) of the two operands. + let heat1 = shot.qubit_state[q1].heat; + shot.qubit_state[q1].heat = shot.qubit_state[q2].heat; + shot.qubit_state[q2].heat = heat1; + + if (policy == LOSS_POLICY_APPLY_ANYWAY) { + // A lost qubit is in a definite |0> state, so its bit is set in + // qubit_is_0_mask. The 2-qubit execute path skips amplitudes for + // qubits known to be in a definite state, which would skip exactly + // the amplitudes SWAP needs to move. Clear those bits for both + // operands so the swap is actually applied. + shot.qubit_is_0_mask = shot.qubit_is_0_mask & ~((1u << q1) | (1u << q2)); + shot.qubit_is_1_mask = shot.qubit_is_1_mask & ~((1u << q1) | (1u << q2)); + // shot.unitary already holds the SWAP matrix (set by the caller). + finish_2q_shot_buffer(shot_idx, op_idx, q1, q2); + return true; + } + + // SKIP / PROPAGATE / DEGRADE / RESIDUAL_S_DAGGER on a SWAP: the loss + // flags have been exchanged; skip the unitary. + shot.op_type = OPID_ID; + shot.op_idx = op_idx; + return true; + } + + // APPLY_ANYWAY: run the gate as if nothing was lost. + if (policy == LOSS_POLICY_APPLY_ANYWAY) { + return false; + } + + // For the remaining policies that act on a survivor, identify the surviving + // operand of a two-qubit gate (if any). For single-qubit gates the only + // operand is lost, so there is no survivor and these collapse to SKIP. + let q1_lost = shot.qubit_state[q1].heat == -1.0; + let is_2q = (op.id == OPID_CX || op.id == OPID_CY || op.id == OPID_CZ || + op.id == OPID_SWAP || op.id == OPID_RXX || op.id == OPID_RYY || + op.id == OPID_RZZ || op.id == OPID_MAT2Q); + let q2_lost = is_2q && (shot.qubit_state[q2].heat == -1.0); + let has_survivor = is_2q && !(q1_lost && q2_lost); + // The surviving operand (only meaningful when has_survivor is true). + let survivor = select(q1, q2, q1_lost); + let survivor_is_q2 = q1_lost; + + if (policy == LOSS_POLICY_PROPAGATE && has_survivor) { + propagate_loss_to_qubit(shot_idx, op_idx, q1, q2, survivor); + return true; + } + + if (policy == LOSS_POLICY_RESIDUAL_S_DAGGER && has_survivor) { + // Apply S-dagger = diag(1, -i) to the surviving operand. + set_1q_on_pair_unitary(shot_idx, survivor_is_q2, + vec2f(1.0, 0.0), vec2f(0.0, 0.0), + vec2f(0.0, 0.0), vec2f(0.0, -1.0)); + finish_2q_shot_buffer(shot_idx, op_idx, q1, q2); + return true; + } + + if (policy == LOSS_POLICY_DEGRADE && has_survivor && + (op.id == OPID_RXX || op.id == OPID_RYY || op.id == OPID_RZZ)) { + // Degrade the two-qubit rotation to its single-qubit version on the + // survivor. The op's unitary[0] holds cos(θ/2) for Rxx/Ryy; we recover + // the angle to build the 1-qubit rotation matrix. + let cos_half = op.unitary[0].x; + if (op.id == OPID_RXX) { + // Rx(θ) = [[c, -i s], [-i s, c]], where s = sin(θ/2). + let s = op.unitary[3].y * -1.0; // unitary[3] = (0, -sin(θ/2)) + set_1q_on_pair_unitary(shot_idx, survivor_is_q2, + vec2f(cos_half, 0.0), vec2f(0.0, -s), + vec2f(0.0, -s), vec2f(cos_half, 0.0)); + } else if (op.id == OPID_RYY) { + // Ry(θ) = [[c, -s], [s, c]], where s = sin(θ/2). + let s = op.unitary[3].y; // unitary[3] = (0, sin(θ/2)) for Ryy + set_1q_on_pair_unitary(shot_idx, survivor_is_q2, + vec2f(cos_half, 0.0), vec2f(-s, 0.0), + vec2f(s, 0.0), vec2f(cos_half, 0.0)); + } else { + // Rzz -> Rz(θ). The GPU Rz convention is [[1, 0], [0, e^{iθ}]], + // and unitary[5] = e^{iθ} holds the full-angle phase. + let phase = op.unitary[5]; + set_1q_on_pair_unitary(shot_idx, survivor_is_q2, + vec2f(1.0, 0.0), vec2f(0.0, 0.0), + vec2f(0.0, 0.0), phase); + } + finish_2q_shot_buffer(shot_idx, op_idx, q1, q2); + return true; + } + + // SKIP (and any policy with no applicable survivor, e.g. DEGRADE on a + // controlled gate, or a single-qubit gate): skip the gate entirely. + shot.op_type = OPID_ID; + shot.op_idx = op_idx; + return true; +} + fn apply_1q_pauli_noise(shot_idx: u32, op_idx: u32, noise_idx: u32) { // NOTE: Assumes that whatever prepared the program ensured that noise_op.q1 matches op.q1 and diff --git a/source/simulators/src/gpu_full_state_simulator/gpu_context.rs b/source/simulators/src/gpu_full_state_simulator/gpu_context.rs index b77672e680..b1d7e06bf4 100644 --- a/source/simulators/src/gpu_full_state_simulator/gpu_context.rs +++ b/source/simulators/src/gpu_full_state_simulator/gpu_context.rs @@ -10,7 +10,7 @@ use crate::bytecode::AdaptiveProgram; use crate::correlated_noise::NoiseTables; use crate::gpu_resources::GpuResources; use crate::noise_config::NoiseConfig; -use crate::noise_mapping::get_noise_ops; +use crate::noise_mapping::{get_noise_ops, loss_policy_u32}; use crate::shader_types::{ self, DiagnosticsData, InterpreterState, MAX_ALLOCA_SIZE, MAX_BUFFER_SIZE, MAX_QUBIT_COUNT, MAX_QUBITS_PER_WORKGROUP, MAX_REGISTERS, MAX_SHOTS_PER_BATCH, MIN_QUBIT_COUNT, MIN_REGISTERS, @@ -927,7 +927,13 @@ fn add_noise_config_to_ops(ops: &[Op], noise: &NoiseConfig) -> Vec let mut noisy_ops: Vec = Vec::with_capacity(ops.len() + 1); for op in ops { - let mut add_ops: Vec = vec![*op]; + // Stamp the configured loss policy onto the gate op so the shader can + // decide how to handle the gate when one of its operands is lost. + let mut gate_op = *op; + if let Some(policy) = loss_policy_u32(op, noise) { + gate_op.q3 = policy; + } + let mut add_ops: Vec = vec![gate_op]; // If there's a NoiseConfig, and we get noise for this op, append it if let Some(noise_ops) = get_noise_ops(op, noise) { add_ops.extend(noise_ops); @@ -978,7 +984,13 @@ fn add_noise_to_adaptive_ops( let new_idx = noisy_ops.len() as u32; index_map.push(new_idx); - noisy_ops.push(*op); + // Stamp the configured loss policy onto the gate op so the shader can + // decide how to handle the gate when one of its operands is lost. + let mut gate_op = *op; + if let Some(policy) = loss_policy_u32(op, noise) { + gate_op.q3 = policy; + } + noisy_ops.push(gate_op); // Append any noise ops (pauli + loss) from the config if let Some(noise_ops) = get_noise_ops(op, noise) { diff --git a/source/simulators/src/gpu_full_state_simulator/noise_mapping.rs b/source/simulators/src/gpu_full_state_simulator/noise_mapping.rs index 378f9716c3..09ba6d3875 100644 --- a/source/simulators/src/gpu_full_state_simulator/noise_mapping.rs +++ b/source/simulators/src/gpu_full_state_simulator/noise_mapping.rs @@ -69,9 +69,12 @@ fn get_noise_op(op: &Op, noise_table: &NoiseTable) -> Op { ), } } - -#[must_use] -pub fn get_noise_ops(op: &Op, noise_config: &NoiseConfig) -> Option> { +/// Returns the [`NoiseTable`] in `noise_config` that applies to the given op, +/// or `None` if the op has no associated noise table (e.g. a noise op itself). +fn noise_table_for<'a>( + op: &Op, + noise_config: &'a NoiseConfig, +) -> Option<&'a NoiseTable> { let noise_table = match op.id { ops::ID => &noise_config.i, ops::X => &noise_config.x, @@ -99,6 +102,23 @@ pub fn get_noise_ops(op: &Op, noise_config: &NoiseConfig) -> Option &noise_config.mresetz, _ => return None, }; + Some(noise_table) +} + +/// Returns the [`LossPolicy`] configured for the given op's gate, encoded as a +/// `u32` for the GPU shader (see [`LossPolicy::as_u32`]). Returns `None` for +/// ops that have no associated gate noise table. +/// +/// The shader reads this from the gate op's `q3` field to decide how to handle +/// the gate when one of its operands is lost. +#[must_use] +pub fn loss_policy_u32(op: &Op, noise_config: &NoiseConfig) -> Option { + noise_table_for(op, noise_config).map(|table| table.on_loss.as_u32()) +} + +#[must_use] +pub fn get_noise_ops(op: &Op, noise_config: &NoiseConfig) -> Option> { + let noise_table = noise_table_for(op, noise_config)?; if noise_table.is_noiseless() { return None; } diff --git a/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl b/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl index 4f317a598b..b7bf22b01a 100644 --- a/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl @@ -1569,6 +1569,16 @@ fn prepare_op(@builtin(global_invocation_id) globalId: vec3) { shot.op_idx = op_idx; shot.op_type = op.id; + // If any operand is lost, dispatch the gate's configured loss + // policy (stamped on op.q3). For most policies this fully handles + // the op; APPLY_ANYWAY returns false so the gate runs as usual. + if gate_has_lost_operand(shot_idx, op_idx, q1, q2) { + if handle_lost_operand_policy(shot_idx, op_idx, q1, q2) { + shots[shot_idx].interp.status = STATUS_RUNNING; + return; + } + } + // Check for noise ops after this gate in the ops pool let pauli_op_idx = get_pauli_noise_idx(op_idx); let loss_op_idx = get_loss_idx(select(op_idx, pauli_op_idx, pauli_op_idx != 0u)); diff --git a/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl b/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl index 54ac64d4ef..a49cdf9e0b 100644 --- a/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl @@ -285,13 +285,14 @@ fn prepare_op(@builtin(global_invocation_id) globalId: vec3) { return; } - // Before doing further work, if any qubit for the gate is lost, just skip by marking the op as ID - if (shot.qubit_state[op.q1].heat == -1.0) || - (op.id == OPID_CX || op.id == OPID_CY || op.id == OPID_CZ || op.id == OPID_SWAP || op.id == OPID_RXX || op.id == OPID_RYY || op.id == OPID_RZZ || op.id == OPID_MAT2Q) && - (shot.qubit_state[op.q2].heat == -1.0) { - shot.op_type = OPID_ID; - shot.op_idx = op_idx; - return; + // Before doing further work, if any qubit for the gate is lost, dispatch + // the gate's configured loss policy (stamped on op.q3). For most policies + // this fully handles the op; APPLY_ANYWAY returns false so the gate runs as + // usual below. + if (gate_has_lost_operand(shot_idx, op_idx, op.q1, op.q2)) { + if (handle_lost_operand_policy(shot_idx, op_idx, op.q1, op.q2)) { + return; + } } // If there is loss noise to apply, do that now diff --git a/source/simulators/src/noise_config.rs b/source/simulators/src/noise_config.rs index 6d8c580945..c702ec59d0 100644 --- a/source/simulators/src/noise_config.rs +++ b/source/simulators/src/noise_config.rs @@ -17,6 +17,51 @@ pub trait Fault { fn loss() -> Self; } +/// Specifies the behavior of a gate when at least one of its qubit +/// operands is lost. +/// +/// This lets users experiment with different lost-qubit gate behaviors +/// from Python (via the per-gate `on_loss` field of the noise config) +/// without modifying and recompiling the simulator. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LossPolicy { + /// If any of the qubit operands of a gate is lost, skip the gate entirely. + #[default] + Skip, + /// If any of the qubit operands of a gate is lost, propagate the loss to + /// the other operands (the surviving operands are measured, reset, and + /// flagged as lost) and skip the gate. + Propagate, + /// For multi-qubit rotations, degrade the unitary to its single-qubit + /// version applied to the surviving operand (e.g. `rxx` -> `rx`). + /// For gates with no single-qubit reduction (`cx`, `cy`, `cz`, `swap`, + /// and single-qubit gates) this falls back to [`LossPolicy::Skip`]. + Degrade, + /// Skip the gate and instead apply an `S` adjoint to each surviving operand. + ResidualSDagger, + /// Apply the unitary anyway, ignoring the loss. Lost operands (already + /// measured and reset to the zero state) remain flagged as lost. + ApplyAnyway, +} + +impl LossPolicy { + /// Encodes the policy as a `u32` for transport to the GPU shader. + /// + /// The values match the Python `LossPolicy` enum (`SKIP = 1` .. + /// `APPLY_ANYWAY = 5`). The value `0` is reserved by the shader to mean + /// "no policy stamped" and is never produced here. + #[must_use] + pub fn as_u32(self) -> u32 { + match self { + Self::Skip => 1, + Self::Propagate => 2, + Self::Degrade => 3, + Self::ResidualSDagger => 4, + Self::ApplyAnyway => 5, + } + } +} + /// Noise description for each operation. /// /// This is the format in which the user config files are @@ -80,10 +125,10 @@ impl NoiseConfig { cx: NoiseTable::::noiseless(2), cy: NoiseTable::::noiseless(2), cz: NoiseTable::::noiseless(2), - rxx: NoiseTable::::noiseless(2), - ryy: NoiseTable::::noiseless(2), - rzz: NoiseTable::::noiseless(2), - swap: NoiseTable::::noiseless(2), + rxx: NoiseTable::::noiseless_with_loss_policy(2, LossPolicy::Degrade), + ryy: NoiseTable::::noiseless_with_loss_policy(2, LossPolicy::Degrade), + rzz: NoiseTable::::noiseless_with_loss_policy(2, LossPolicy::Degrade), + swap: NoiseTable::::noiseless_with_loss_policy(2, LossPolicy::ApplyAnyway), ccx: NoiseTable::::noiseless(3), mov: NoiseTable::::noiseless(1), mz: NoiseTable::::noiseless(1), @@ -135,16 +180,24 @@ pub struct NoiseTable { pub pauli_strings: Vec, pub probabilities: Vec, pub loss: T, + /// The behavior of this gate when at least one of its operands is lost. + pub on_loss: LossPolicy, } impl NoiseTable { #[must_use] pub const fn noiseless(qubits: u32) -> Self { + Self::noiseless_with_loss_policy(qubits, LossPolicy::Skip) + } + + #[must_use] + pub const fn noiseless_with_loss_policy(qubits: u32, on_loss: LossPolicy) -> Self { Self { qubits, pauli_strings: Vec::new(), probabilities: Vec::new(), loss: num_traits::ConstZero::ZERO, + on_loss, } } } @@ -246,6 +299,8 @@ where pub struct CumulativeNoiseTable { pub sampler: CorrelatedNoiseSampler, pub loss: f64, + /// The behavior of this gate when at least one of its operands is lost. + pub on_loss: LossPolicy, } impl From> for CumulativeNoiseTable @@ -262,6 +317,7 @@ where Self { sampler: CorrelatedNoiseSampler::new(choices, probs), loss: value.loss, + on_loss: value.on_loss, } } } diff --git a/source/simulators/src/stabilizer_simulator.rs b/source/simulators/src/stabilizer_simulator.rs index 7e59317808..fe4b1ad260 100644 --- a/source/simulators/src/stabilizer_simulator.rs +++ b/source/simulators/src/stabilizer_simulator.rs @@ -8,7 +8,7 @@ pub mod operation; use crate::{ MeasurementResult, NearlyZero, QubitID, Simulator, - noise_config::{CumulativeNoiseConfig, IntrinsicID}, + noise_config::{CumulativeNoiseConfig, IntrinsicID, LossPolicy}, }; pub use noise::Fault; use operation::Operation; @@ -211,6 +211,28 @@ impl StabilizerSimulator { } } + /// Marks each non-lost `target` as lost by measuring it, resetting it, and + /// flagging it. Used by the [`LossPolicy::Propagate`] behavior. + fn propagate_loss(&mut self, targets: &[QubitID]) { + for &target in targets { + if !self.loss[target] { + self.mresetz_impl(target); + self.loss[target] = true; + } + } + } + + /// Applies an `S` adjoint to each non-lost `target`. Used by the + /// [`LossPolicy::ResidualSDagger`] behavior. + fn residual_s_dagger(&mut self, targets: &[QubitID]) { + for &target in targets { + if !self.loss[target] { + self.apply_idle_noise(target); + self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); + } + } + } + /// Records a z-measurement on the given `target`. fn record_mz(&mut self, target: QubitID, result_id: QubitID) { let measurement = self.mz_impl(target); @@ -285,7 +307,13 @@ impl Simulator for StabilizerSimulator { } fn x(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + // The only operand is lost. Only `ApplyAnyway` still applies a + // single-qubit gate; every other policy is equivalent to `Skip`. + if matches!(self.noise_config.x.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::X, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::X, &[target]); apply_loss!(self, x, &[target]); @@ -294,7 +322,11 @@ impl Simulator for StabilizerSimulator { } fn y(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.y.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::Y, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::Y, &[target]); apply_loss!(self, y, &[target]); @@ -303,7 +335,11 @@ impl Simulator for StabilizerSimulator { } fn z(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.z.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::Z, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::Z, &[target]); apply_loss!(self, z, &[target]); @@ -312,7 +348,11 @@ impl Simulator for StabilizerSimulator { } fn h(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.h.on_loss, LossPolicy::ApplyAnyway) { + apply_hadamard(&mut self.state, target); + } + } else { self.apply_idle_noise(target); apply_hadamard(&mut self.state, target); apply_loss!(self, h, &[target]); @@ -321,7 +361,11 @@ impl Simulator for StabilizerSimulator { } fn s(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.s.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); apply_loss!(self, s, &[target]); @@ -330,7 +374,11 @@ impl Simulator for StabilizerSimulator { } fn s_adj(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.s_adj.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); apply_loss!(self, s_adj, &[target]); @@ -339,7 +387,11 @@ impl Simulator for StabilizerSimulator { } fn sx(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.sx.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::SqrtX, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtX, &[target]); apply_loss!(self, sx, &[target]); @@ -348,7 +400,11 @@ impl Simulator for StabilizerSimulator { } fn sx_adj(&mut self, target: QubitID) { - if !self.loss[target] { + if self.loss[target] { + if matches!(self.noise_config.sx_adj.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(UnitaryOp::SqrtXInv, &[target]); + } + } else { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtXInv, &[target]); apply_loss!(self, sx_adj, &[target]); @@ -357,7 +413,16 @@ impl Simulator for StabilizerSimulator { } fn cx(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cx.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => self + .state + .apply_unitary(UnitaryOp::ControlledX, &[control, target]), + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state @@ -369,7 +434,19 @@ impl Simulator for StabilizerSimulator { } fn cy(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cy.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => { + self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); + self.state + .apply_unitary(UnitaryOp::ControlledX, &[control, target]); + self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); + } + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); @@ -383,7 +460,16 @@ impl Simulator for StabilizerSimulator { } fn cz(&mut self, control: QubitID, target: QubitID) { - if !self.loss[control] && !self.loss[target] { + if self.loss[control] || self.loss[target] { + match self.noise_config.cz.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => {} + LossPolicy::Propagate => self.propagate_loss(&[control, target]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[control, target]), + LossPolicy::ApplyAnyway => self + .state + .apply_unitary(UnitaryOp::ControlledZ, &[control, target]), + } + } else { self.apply_idle_noise(control); self.apply_idle_noise(target); self.state @@ -395,17 +481,20 @@ impl Simulator for StabilizerSimulator { } fn rx(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::X, + UnitaryOp::SqrtX, + UnitaryOp::SqrtXInv, + ); + if self.loss[target] { + if matches!(self.noise_config.rx.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(unitary, &[target]); + } + } else { self.apply_idle_noise(target); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::X, - UnitaryOp::SqrtX, - UnitaryOp::SqrtXInv, - ); self.state.apply_unitary(unitary, &[target]); apply_loss!(self, rx, &[target]); @@ -414,17 +503,20 @@ impl Simulator for StabilizerSimulator { } fn ry(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Y, + UnitaryOp::SqrtY, + UnitaryOp::SqrtYInv, + ); + if self.loss[target] { + if matches!(self.noise_config.ry.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(unitary, &[target]); + } + } else { self.apply_idle_noise(target); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::Y, - UnitaryOp::SqrtY, - UnitaryOp::SqrtYInv, - ); self.state.apply_unitary(unitary, &[target]); apply_loss!(self, ry, &[target]); @@ -433,17 +525,20 @@ impl Simulator for StabilizerSimulator { } fn rz(&mut self, angle: f64, target: QubitID) { - if !self.loss[target] { + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + if self.loss[target] { + if matches!(self.noise_config.rz.on_loss, LossPolicy::ApplyAnyway) { + self.state.apply_unitary(unitary, &[target]); + } + } else { self.apply_idle_noise(target); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::Z, - UnitaryOp::SqrtZ, - UnitaryOp::SqrtZInv, - ); self.state.apply_unitary(unitary, &[target]); apply_loss!(self, rz, &[target]); @@ -452,112 +547,192 @@ impl Simulator for StabilizerSimulator { } fn rxx(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.rx(angle, q2), - (false, true) => self.rx(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::Z, - UnitaryOp::SqrtZ, - UnitaryOp::SqrtZInv, - ); - // NOTE: We perform the Rxx gate by changing basis to Y and performing the decomposition of Rzz. - self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); - self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - self.state.apply_unitary(unitary, &[q1]); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); - self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); - - apply_loss!(self, rxx, &[q1, q2]); - apply_noise!(self, rxx, &[q1, q2]); + if self.loss[q1] || self.loss[q2] { + match self.noise_config.rxx.on_loss { + LossPolicy::Skip => {} + // Degrade the two-qubit rotation to its single-qubit version on + // the surviving operand. + LossPolicy::Degrade => { + if !self.loss[q1] { + self.rx(angle, q1); + } else if !self.loss[q2] { + self.rx(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => { + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + // NOTE: We perform the Rxx gate by changing basis to Y and performing the decomposition of Rzz. + self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); + } } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + // NOTE: We perform the Rxx gate by changing basis to Y and performing the decomposition of Rzz. + self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q1]); + self.state.apply_unitary(UnitaryOp::Hadamard, &[q2]); + + apply_loss!(self, rxx, &[q1, q2]); + apply_noise!(self, rxx, &[q1, q2]); } } fn ryy(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.ry(angle, q2), - (false, true) => self.ry(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::Z, - UnitaryOp::SqrtZ, - UnitaryOp::SqrtZInv, - ); - // NOTE: We perform the Ryy gate by changing basis to Z and performing the decomposition of Rzz. - self.state.apply_unitary(UnitaryOp::SqrtX, &[q1]); - self.state.apply_unitary(UnitaryOp::SqrtX, &[q2]); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - self.state.apply_unitary(unitary, &[q1]); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q1]); - self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q2]); - - apply_loss!(self, ryy, &[q1, q2]); - apply_noise!(self, ryy, &[q1, q2]); + if self.loss[q1] || self.loss[q2] { + match self.noise_config.ryy.on_loss { + LossPolicy::Skip => {} + LossPolicy::Degrade => { + if !self.loss[q1] { + self.ry(angle, q1); + } else if !self.loss[q2] { + self.ry(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => { + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + // NOTE: We perform the Ryy gate by changing basis to Z and performing the decomposition of Rzz. + self.state.apply_unitary(UnitaryOp::SqrtX, &[q1]); + self.state.apply_unitary(UnitaryOp::SqrtX, &[q2]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q1]); + self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q2]); + } } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + // NOTE: We perform the Ryy gate by changing basis to Z and performing the decomposition of Rzz. + self.state.apply_unitary(UnitaryOp::SqrtX, &[q1]); + self.state.apply_unitary(UnitaryOp::SqrtX, &[q2]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q1]); + self.state.apply_unitary(UnitaryOp::SqrtXInv, &[q2]); + + apply_loss!(self, ryy, &[q1, q2]); + apply_noise!(self, ryy, &[q1, q2]); } } fn rzz(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => self.rz(angle, q2), - (false, true) => self.rz(angle, q1), - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - - // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle - // and check to see if it is supported. - let unitary = unitary_from_normalized_angle( - angle, - UnitaryOp::Z, - UnitaryOp::SqrtZ, - UnitaryOp::SqrtZInv, - ); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - self.state.apply_unitary(unitary, &[q1]); - self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); - - apply_loss!(self, rzz, &[q1, q2]); - apply_noise!(self, rzz, &[q1, q2]); + if self.loss[q1] || self.loss[q2] { + match self.noise_config.rzz.on_loss { + LossPolicy::Skip => {} + LossPolicy::Degrade => { + if !self.loss[q1] { + self.rz(angle, q1); + } else if !self.loss[q2] { + self.rz(angle, q2); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::ApplyAnyway => { + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + } } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + + // We can only perform rotations by multiples of PI / 2 in the stabilizer, so normalize the angle + // and check to see if it is supported. + let unitary = unitary_from_normalized_angle( + angle, + UnitaryOp::Z, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + ); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + self.state.apply_unitary(unitary, &[q1]); + self.state.apply_unitary(UnitaryOp::ControlledX, &[q2, q1]); + + apply_loss!(self, rzz, &[q1, q2]); + apply_noise!(self, rzz, &[q1, q2]); } } fn swap(&mut self, q1: QubitID, q2: QubitID) { - match (self.loss[q1], self.loss[q2]) { - (true, true) => (), - (true, false) => { - self.apply_idle_noise(q2); - self.state.apply_permutation(&[1, 0], &[q1, q2]); - } - (false, true) => { - self.apply_idle_noise(q1); - self.state.apply_permutation(&[1, 0], &[q1, q2]); - } - (false, false) => { - self.apply_idle_noise(q1); - self.apply_idle_noise(q2); - self.state.apply_permutation(&[1, 0], &[q1, q2]); + if self.loss[q1] || self.loss[q2] { + // At least one operand is lost. The loss-flag swap below always + // happens; `on_loss` only governs the unitary and residual noise. + match self.noise_config.swap.on_loss { + LossPolicy::ApplyAnyway => { + let (l1, l2) = (self.loss[q1], self.loss[q2]); + if !l1 { + self.apply_idle_noise(q1); + } + if !l2 { + self.apply_idle_noise(q2); + } + // Both operands lost is a pure relabel, so only apply the + // unitary when at least one operand survives. + if !l1 || !l2 { + self.state.apply_permutation(&[1, 0], &[q1, q2]); + } + } + LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), + LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), + LossPolicy::Skip | LossPolicy::Degrade => {} } + } else { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state.apply_permutation(&[1, 0], &[q1, q2]); } // There are three kinds of swaps: // 1. A logical swap, also called a relabel. From 021b21d2980608fafd531f0d57d2a18c1d90c961 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Sat, 13 Jun 2026 10:10:33 -0700 Subject: [PATCH 2/4] start NoisePolicy enum from 0 --- source/qdk_package/src/qir_simulation.rs | 10 +++++----- .../src/gpu_full_state_simulator/common.wgsl | 11 +++++------ source/simulators/src/noise_config.rs | 14 +++++++------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/source/qdk_package/src/qir_simulation.rs b/source/qdk_package/src/qir_simulation.rs index 4992ae8e0f..627556b52f 100644 --- a/source/qdk_package/src/qir_simulation.rs +++ b/source/qdk_package/src/qir_simulation.rs @@ -94,15 +94,15 @@ pub enum QirInstruction { #[pyclass(eq, eq_int, from_py_object, module = "qdk._native")] pub enum LossPolicy { #[pyo3(name = "SKIP")] - Skip = 1, + Skip = 0, #[pyo3(name = "PROPAGATE")] - Propagate = 2, + Propagate = 1, #[pyo3(name = "DEGRADE")] - Degrade = 3, + Degrade = 2, #[pyo3(name = "RESIDUAL_S_DAGGER")] - ResidualSDagger = 4, + ResidualSDagger = 3, #[pyo3(name = "APPLY_ANYWAY")] - ApplyAnyway = 5, + ApplyAnyway = 4, } impl From for qdk_simulators::noise_config::LossPolicy { diff --git a/source/simulators/src/gpu_full_state_simulator/common.wgsl b/source/simulators/src/gpu_full_state_simulator/common.wgsl index 653e9019bc..628ca20cb1 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -330,12 +330,11 @@ fn get_loss_idx(op_idx: u32) -> u32 { // (see `LossPolicy::as_u32` on the Rust side) and tell the shader how to handle // the gate when one of its operands is lost. `0` means "no policy stamped", // which the shader treats the same as SKIP. -const LOSS_POLICY_NONE = 0u; -const LOSS_POLICY_SKIP = 1u; -const LOSS_POLICY_PROPAGATE = 2u; -const LOSS_POLICY_DEGRADE = 3u; -const LOSS_POLICY_RESIDUAL_S_DAGGER = 4u; -const LOSS_POLICY_APPLY_ANYWAY = 5u; +const LOSS_POLICY_SKIP = 0u; +const LOSS_POLICY_PROPAGATE = 1u; +const LOSS_POLICY_DEGRADE = 2u; +const LOSS_POLICY_RESIDUAL_S_DAGGER = 3u; +const LOSS_POLICY_APPLY_ANYWAY = 4u; // Returns true if the gate at `op_idx` touches at least one lost qubit. // `q1`/`q2` are the (resolved) operands of the gate. diff --git a/source/simulators/src/noise_config.rs b/source/simulators/src/noise_config.rs index c702ec59d0..a449e4f09d 100644 --- a/source/simulators/src/noise_config.rs +++ b/source/simulators/src/noise_config.rs @@ -47,17 +47,17 @@ pub enum LossPolicy { impl LossPolicy { /// Encodes the policy as a `u32` for transport to the GPU shader. /// - /// The values match the Python `LossPolicy` enum (`SKIP = 1` .. - /// `APPLY_ANYWAY = 5`). The value `0` is reserved by the shader to mean + /// The values match the Python `LossPolicy` enum (`SKIP = 0` .. + /// `APPLY_ANYWAY = 4`). The value `0` is reserved by the shader to mean /// "no policy stamped" and is never produced here. #[must_use] pub fn as_u32(self) -> u32 { match self { - Self::Skip => 1, - Self::Propagate => 2, - Self::Degrade => 3, - Self::ResidualSDagger => 4, - Self::ApplyAnyway => 5, + Self::Skip => 0, + Self::Propagate => 1, + Self::Degrade => 2, + Self::ResidualSDagger => 3, + Self::ApplyAnyway => 4, } } } From 1af491aba0398b2c233a122bc3127fa1375c8b2c Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Sat, 13 Jun 2026 10:10:44 -0700 Subject: [PATCH 3/4] fix test comment --- .../tests/test_simulators_gates_noisy.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/source/qdk_package/tests/test_simulators_gates_noisy.py b/source/qdk_package/tests/test_simulators_gates_noisy.py index 7c781144e2..f89ecc2164 100644 --- a/source/qdk_package/tests/test_simulators_gates_noisy.py +++ b/source/qdk_package/tests/test_simulators_gates_noisy.py @@ -260,9 +260,7 @@ def test_two_qubit_loss(sim_type): # Loss-policy (on_loss) tests # =========================================================================== # -# These exercise the per-gate `NoiseConfig..on_loss` behavior. The -# `on_loss` policy is honored by the cpu (full-state) and clifford (stabilizer) -# simulators, so these tests are parametrized over just those two. +# These exercise the per-gate `NoiseConfig..on_loss` behavior. # # A qubit is lost deterministically by giving a single-qubit gate a loss # probability of 1.0 and then applying that gate. The gate under test then sees @@ -270,17 +268,14 @@ def test_two_qubit_loss(sim_type): # deterministic, so a single shot is sufficient. -LOSS_POLICY_SIM_TYPES = ["cpu", "clifford", gpu_param()] - - -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_default_controlled_gate_skips(sim_type): # `cz.on_loss` defaults to SKIP: the lost control means CZ is skipped, so # the surviving target qubit is left untouched in |0>. noise = NoiseConfig() noise.x.loss = 1.0 # deterministically lose qs[0] after X results = compile_and_run( - "{use qs = Qubit[2]; X(qs[0]); CZ(qs[0], qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", + "{use qs = Qubit[2]; X(qs[0]); H(qs[1]); CZ(qs[0], qs[1]); H(qs[1]); [MResetZ(qs[0]), MResetZ(qs[1])]}", shots=1, seed=SEED, noise=noise, @@ -289,7 +284,7 @@ def test_on_loss_default_controlled_gate_skips(sim_type): check_histogram(results, {"-0": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_propagate_marks_other_operand_lost(sim_type): # PROPAGATE: a lost operand propagates the loss to the other operand, so # both qubits measure as Loss. @@ -306,7 +301,7 @@ def test_on_loss_propagate_marks_other_operand_lost(sim_type): check_histogram(results, {"--": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_rxx_degrade_reduces_to_single_qubit(sim_type): # `rxx.on_loss` defaults to DEGRADE: with one operand lost, Rxx reduces to # Rx on the survivor. Rx(PI) flips qs[1] to |1>. @@ -322,7 +317,7 @@ def test_on_loss_rxx_degrade_reduces_to_single_qubit(sim_type): check_histogram(results, {"-1": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_rxx_skip_leaves_survivor_untouched(sim_type): # Overriding `rxx.on_loss` to SKIP leaves the surviving qubit in |0>. noise = NoiseConfig() @@ -338,7 +333,7 @@ def test_on_loss_rxx_skip_leaves_survivor_untouched(sim_type): check_histogram(results, {"-0": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_residual_s_dagger_applies_s_adjoint(sim_type): # RESIDUAL_S_DAGGER: the gate is skipped but an S-dagger is applied to each # surviving operand. qs[1] is prepared in |+i> = S H |0>; the residual @@ -356,7 +351,7 @@ def test_on_loss_residual_s_dagger_applies_s_adjoint(sim_type): check_histogram(results, {"-0": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_swap_apply_anyway_exchanges_state(sim_type): # `swap.on_loss` defaults to APPLY_ANYWAY: the SWAP unitary still runs, so # qs[1]'s |1> moves into qs[0]. The loss flag is always exchanged, so qs[1] @@ -373,7 +368,7 @@ def test_on_loss_swap_apply_anyway_exchanges_state(sim_type): check_histogram(results, {"1-": 1.0}) -@pytest.mark.parametrize("sim_type", LOSS_POLICY_SIM_TYPES) +@pytest.mark.parametrize("sim_type", SIM_TYPES) def test_on_loss_swap_skip_keeps_state_but_swaps_loss_flag(sim_type): # Overriding `swap.on_loss` to SKIP skips the SWAP unitary, but the loss # flag is still exchanged. qs[0] keeps its reset |0> and qs[1] becomes lost. From 941d453561b6bae5c4c8b3b23deb175e3a72aaf6 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Sat, 13 Jun 2026 10:35:12 -0700 Subject: [PATCH 4/4] reuse is_1q_op --- .../simulators/src/gpu_full_state_simulator/common.wgsl | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/source/simulators/src/gpu_full_state_simulator/common.wgsl b/source/simulators/src/gpu_full_state_simulator/common.wgsl index 628ca20cb1..7a6f08194d 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -344,9 +344,7 @@ fn gate_has_lost_operand(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> bool { if (shot.qubit_state[q1].heat == -1.0) { return true; } - let is_2q = (op.id == OPID_CX || op.id == OPID_CY || op.id == OPID_CZ || - op.id == OPID_SWAP || op.id == OPID_RXX || op.id == OPID_RYY || - op.id == OPID_RZZ || op.id == OPID_MAT2Q); + let is_2q = !is_1q_op(op.id); return is_2q && (shot.qubit_state[q2].heat == -1.0); } @@ -472,9 +470,7 @@ fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> b // operand of a two-qubit gate (if any). For single-qubit gates the only // operand is lost, so there is no survivor and these collapse to SKIP. let q1_lost = shot.qubit_state[q1].heat == -1.0; - let is_2q = (op.id == OPID_CX || op.id == OPID_CY || op.id == OPID_CZ || - op.id == OPID_SWAP || op.id == OPID_RXX || op.id == OPID_RYY || - op.id == OPID_RZZ || op.id == OPID_MAT2Q); + let is_2q = !is_1q_op(op.id); let q2_lost = is_2q && (shot.qubit_state[q2].heat == -1.0); let has_survivor = is_2q && !(q1_lost && q2_lost); // The surviving operand (only meaningful when has_survivor is true).