From 4213a9571852312254ab50b242251081cb41e5d9 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Wed, 10 Jun 2026 12:45:30 -0700 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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). From 8ee62597658cc8f1034e99632c0bc6b6a296d645 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Tue, 16 Jun 2026 01:27:28 -0700 Subject: [PATCH 05/10] noise policies only on multi-qubit gates + always apply idle noise on remaining qubits --- source/qdk_package/qdk/_native.pyi | 3 +- source/qdk_package/src/qir_simulation.rs | 4 +- .../tests/test_clifford_simulator.py | 2 +- .../src/cpu_full_state_simulator.rs | 426 ++++++------- .../src/gpu_full_state_simulator/common.wgsl | 11 +- source/simulators/src/noise_config.rs | 2 +- source/simulators/src/stabilizer_simulator.rs | 573 ++++++++---------- 7 files changed, 455 insertions(+), 566 deletions(-) diff --git a/source/qdk_package/qdk/_native.pyi b/source/qdk_package/qdk/_native.pyi index 8848db006d..5fdbd147ca 100644 --- a/source/qdk_package/qdk/_native.pyi +++ b/source/qdk_package/qdk/_native.pyi @@ -846,7 +846,8 @@ class IdleNoiseParams: class LossPolicy(Enum): """ - Specifies the behavior of a gate when at least one of its qubit operands is lost. + Specifies the behavior of a multi-qubit gate when at least one of its + qubit operands is lost. """ # If any operand of a gate is lost, skip the gate entirely. diff --git a/source/qdk_package/src/qir_simulation.rs b/source/qdk_package/src/qir_simulation.rs index 627556b52f..7a9cef7342 100644 --- a/source/qdk_package/src/qir_simulation.rs +++ b/source/qdk_package/src/qir_simulation.rs @@ -88,8 +88,8 @@ 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`]. +/// Specifies the behavior of a multi-qubit 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 { diff --git a/source/qdk_package/tests/test_clifford_simulator.py b/source/qdk_package/tests/test_clifford_simulator.py index 9d9c87800e..d54e5aeee6 100644 --- a/source/qdk_package/tests/test_clifford_simulator.py +++ b/source/qdk_package/tests/test_clifford_simulator.py @@ -319,7 +319,7 @@ def test_clifford_run_mixed_noise(): result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] print(result) # Reasonable results obtained from manual run - assert result == ["00000-0000000001"] + assert result == ["00000-0000000000"] def test_clifford_run_isolated_loss(): diff --git a/source/simulators/src/cpu_full_state_simulator.rs b/source/simulators/src/cpu_full_state_simulator.rs index a786e1ea12..492be1d1eb 100644 --- a/source/simulators/src/cpu_full_state_simulator.rs +++ b/source/simulators/src/cpu_full_state_simulator.rs @@ -512,28 +512,13 @@ 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"); - } - } + /// Applies an `S` adjoint to the given target + /// Used by the [`LossPolicy::ResidualSDagger`] behavior. + fn residual_s_dagger(&mut self, target: QubitID) { + 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`. @@ -591,6 +576,13 @@ impl NoisySimulator { MeasurementResult::Zero } } + + fn loss_impl(&mut self, target: QubitID) { + if !self.loss[target] { + self.mresetz_impl(target); + self.loss[target] = true; + } + } } /// Design decision: Why is this a macro? @@ -706,15 +698,7 @@ impl Simulator for NoisySimulator { } fn x(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&X, &[target]) @@ -725,13 +709,7 @@ impl Simulator for NoisySimulator { } fn y(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&Y, &[target]) @@ -742,13 +720,7 @@ impl Simulator for NoisySimulator { } fn z(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&Z, &[target]) @@ -759,13 +731,7 @@ impl Simulator for NoisySimulator { } fn h(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&H, &[target]) @@ -776,13 +742,7 @@ impl Simulator for NoisySimulator { } fn s(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&S, &[target]) @@ -793,13 +753,7 @@ impl Simulator for NoisySimulator { } fn s_adj(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&S_ADJ, &[target]) @@ -810,13 +764,7 @@ impl Simulator for NoisySimulator { } fn sx(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&SX, &[target]) @@ -827,13 +775,7 @@ impl Simulator for NoisySimulator { } fn sx_adj(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&SX_ADJ, &[target]) @@ -844,13 +786,7 @@ impl Simulator for NoisySimulator { } fn t(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&T, &[target]) @@ -861,13 +797,7 @@ impl Simulator for NoisySimulator { } fn t_adj(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&T_ADJ, &[target]) @@ -878,13 +808,7 @@ impl Simulator for NoisySimulator { } fn rx(&mut self, angle: f64, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&rx(angle), &[target]) @@ -895,13 +819,7 @@ impl Simulator for NoisySimulator { } fn ry(&mut self, angle: f64, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&ry(angle), &[target]) @@ -912,13 +830,7 @@ impl Simulator for NoisySimulator { } fn rz(&mut self, angle: f64, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state .apply_operation(&rz(angle), &[target]) @@ -929,22 +841,28 @@ impl Simulator for NoisySimulator { } fn cx(&mut self, control: QubitID, target: QubitID) { - 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 + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cx.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CX, &[control, target]) + .expect("apply_operation should succeed"), + } + } + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state .apply_operation(&CX, &[control, target]) - .expect("apply_operation should succeed"), + .expect("apply_operation should succeed"); } - } else { - self.apply_idle_noise(control); - self.apply_idle_noise(target); - self.state - .apply_operation(&CX, &[control, target]) - .expect("apply_operation should succeed"); } // We still apply operation faults to non-lost qubits. apply_loss!(self, cx, &[control, target]); @@ -952,22 +870,28 @@ impl Simulator for NoisySimulator { } fn cy(&mut self, control: QubitID, target: QubitID) { - 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 + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cy.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CY, &[control, target]) + .expect("apply_operation should succeed"), + } + } + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state .apply_operation(&CY, &[control, target]) - .expect("apply_operation should succeed"), + .expect("apply_operation should succeed"); } - } else { - self.apply_idle_noise(control); - self.apply_idle_noise(target); - self.state - .apply_operation(&CY, &[control, target]) - .expect("apply_operation should succeed"); } // We still apply operation faults to non-lost qubits. apply_loss!(self, cy, &[control, target]); @@ -975,22 +899,28 @@ impl Simulator for NoisySimulator { } fn cz(&mut self, control: QubitID, target: QubitID) { - 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 + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cz.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&CZ, &[control, target]) + .expect("apply_operation should succeed"), + } + } + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state .apply_operation(&CZ, &[control, target]) - .expect("apply_operation should succeed"), + .expect("apply_operation should succeed"); } - } else { - self.apply_idle_noise(control); - self.apply_idle_noise(target); - self.state - .apply_operation(&CZ, &[control, target]) - .expect("apply_operation should succeed"); } // We still apply operation faults to non-lost qubits. apply_loss!(self, cz, &[control, target]); @@ -998,125 +928,115 @@ impl Simulator for NoisySimulator { } fn rxx(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); - } + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.rxx.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.rx(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&rxx(angle), &[q1, q2]) + .expect("apply_operation should succeed"), } - LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), - LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), - LossPolicy::ApplyAnyway => self - .state + } + (false, false) => { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state .apply_operation(&rxx(angle), &[q1, q2]) - .expect("apply_operation should succeed"), + .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]); } + apply_loss!(self, rxx, &[q1, q2]); + apply_noise!(self, rxx, &[q1, q2]); } fn ryy(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); - } + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.ryy.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.ry(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&ryy(angle), &[q1, q2]) + .expect("apply_operation should succeed"), } - LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), - LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), - LossPolicy::ApplyAnyway => self - .state + } + (false, false) => { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state .apply_operation(&ryy(angle), &[q1, q2]) - .expect("apply_operation should succeed"), + .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]); } + apply_loss!(self, ryy, &[q1, q2]); + apply_noise!(self, ryy, &[q1, q2]); } fn rzz(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); - } + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.rzz.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.rz(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_operation(&rzz(angle), &[q1, q2]) + .expect("apply_operation should succeed"), } - LossPolicy::Propagate => self.propagate_loss(&[q1, q2]), - LossPolicy::ResidualSDagger => self.residual_s_dagger(&[q1, q2]), - LossPolicy::ApplyAnyway => self - .state + } + (false, false) => { + self.apply_idle_noise(q1); + self.apply_idle_noise(q2); + self.state .apply_operation(&rzz(angle), &[q1, q2]) - .expect("apply_operation should succeed"), + .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]); } + apply_loss!(self, rzz, &[q1, q2]); + apply_noise!(self, rzz, &[q1, q2]); } fn swap(&mut self, q1: QubitID, q2: QubitID) { - 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"); - } + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.swap.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => 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"); + (false, false) => { + 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 7a6f08194d..c2104c83c2 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -430,8 +430,17 @@ fn propagate_loss_to_qubit(shot_idx: u32, op_idx: u32, q1: u32, q2: u32, qubit: 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 is_1q = is_1q_op(op.id); let policy = op.q3; + // Loss policies only make sense for multi-qubit gates. + // If this is a single-qubit gate, skip it entirely. + if (is_1q) { + shot.op_type = OPID_ID; + shot.op_idx = op_idx; + return true; + } + // 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. @@ -470,7 +479,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 = !is_1q_op(op.id); + let is_2q = !is_1q; 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). diff --git a/source/simulators/src/noise_config.rs b/source/simulators/src/noise_config.rs index a449e4f09d..f7757175a1 100644 --- a/source/simulators/src/noise_config.rs +++ b/source/simulators/src/noise_config.rs @@ -17,7 +17,7 @@ pub trait Fault { fn loss() -> Self; } -/// Specifies the behavior of a gate when at least one of its qubit +/// Specifies the behavior of a multi-qubit gate when at least one of its qubit /// operands is lost. /// /// This lets users experiment with different lost-qubit gate behaviors diff --git a/source/simulators/src/stabilizer_simulator.rs b/source/simulators/src/stabilizer_simulator.rs index fe4b1ad260..dca61735da 100644 --- a/source/simulators/src/stabilizer_simulator.rs +++ b/source/simulators/src/stabilizer_simulator.rs @@ -211,26 +211,11 @@ 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]); - } - } + /// Applies an `S` adjoint to the given target + /// Used by the [`LossPolicy::ResidualSDagger`] behavior. + fn residual_s_dagger(&mut self, target: QubitID) { + self.apply_idle_noise(target); + self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); } /// Records a z-measurement on the given `target`. @@ -288,6 +273,13 @@ impl StabilizerSimulator { MeasurementResult::Zero } } + + fn loss_impl(&mut self, target: QubitID) { + if !self.loss[target] { + self.mresetz_impl(target); + self.loss[target] = true; + } + } } impl Simulator for StabilizerSimulator { @@ -307,13 +299,7 @@ impl Simulator for StabilizerSimulator { } fn x(&mut self, target: QubitID) { - 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 { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::X, &[target]); apply_loss!(self, x, &[target]); @@ -322,11 +308,7 @@ impl Simulator for StabilizerSimulator { } fn y(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.y.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::Y, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::Y, &[target]); apply_loss!(self, y, &[target]); @@ -335,11 +317,7 @@ impl Simulator for StabilizerSimulator { } fn z(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.z.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::Z, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::Z, &[target]); apply_loss!(self, z, &[target]); @@ -348,11 +326,7 @@ impl Simulator for StabilizerSimulator { } fn h(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.h.on_loss, LossPolicy::ApplyAnyway) { - apply_hadamard(&mut self.state, target); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); apply_hadamard(&mut self.state, target); apply_loss!(self, h, &[target]); @@ -361,11 +335,7 @@ impl Simulator for StabilizerSimulator { } fn s(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.s.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); apply_loss!(self, s, &[target]); @@ -374,11 +344,7 @@ impl Simulator for StabilizerSimulator { } fn s_adj(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.s_adj.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); apply_loss!(self, s_adj, &[target]); @@ -387,11 +353,7 @@ impl Simulator for StabilizerSimulator { } fn sx(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.sx.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::SqrtX, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtX, &[target]); apply_loss!(self, sx, &[target]); @@ -400,11 +362,7 @@ impl Simulator for StabilizerSimulator { } fn sx_adj(&mut self, target: QubitID) { - if self.loss[target] { - if matches!(self.noise_config.sx_adj.on_loss, LossPolicy::ApplyAnyway) { - self.state.apply_unitary(UnitaryOp::SqrtXInv, &[target]); - } - } else { + if !self.loss[target] { self.apply_idle_noise(target); self.state.apply_unitary(UnitaryOp::SqrtXInv, &[target]); apply_loss!(self, sx_adj, &[target]); @@ -413,20 +371,26 @@ impl Simulator for StabilizerSimulator { } fn cx(&mut self, control: QubitID, target: QubitID) { - 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]), + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cx.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_unitary(UnitaryOp::ControlledX, &[control, target]), + } + } + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state + .apply_unitary(UnitaryOp::ControlledX, &[control, target]); } - } else { - self.apply_idle_noise(control); - self.apply_idle_noise(target); - self.state - .apply_unitary(UnitaryOp::ControlledX, &[control, target]); } // We still apply operation faults to non-lost qubits. apply_loss!(self, cx, &[control, target]); @@ -434,25 +398,31 @@ impl Simulator for StabilizerSimulator { } fn cy(&mut self, control: QubitID, target: QubitID) { - 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]); + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cy.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + 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]); - self.state - .apply_unitary(UnitaryOp::ControlledX, &[control, target]); - self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state.apply_unitary(UnitaryOp::SqrtZInv, &[target]); + self.state + .apply_unitary(UnitaryOp::ControlledX, &[control, target]); + self.state.apply_unitary(UnitaryOp::SqrtZ, &[target]); + } } // We still apply operation faults to non-lost qubits. apply_loss!(self, cy, &[control, target]); @@ -460,20 +430,26 @@ impl Simulator for StabilizerSimulator { } fn cz(&mut self, control: QubitID, target: QubitID) { - 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]), + match (self.loss[control], self.loss[target]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[control] { target } else { control }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.cz.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => self + .state + .apply_unitary(UnitaryOp::ControlledZ, &[control, target]), + } + } + (false, false) => { + self.apply_idle_noise(control); + self.apply_idle_noise(target); + self.state + .apply_unitary(UnitaryOp::ControlledZ, &[control, target]); } - } else { - self.apply_idle_noise(control); - self.apply_idle_noise(target); - self.state - .apply_unitary(UnitaryOp::ControlledZ, &[control, target]); } // We still apply operation faults to non-lost qubits. apply_loss!(self, cz, &[control, target]); @@ -481,20 +457,17 @@ impl Simulator for StabilizerSimulator { } fn rx(&mut self, angle: f64, target: QubitID) { - // 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 { + if !self.loss[target] { 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]); @@ -503,20 +476,17 @@ impl Simulator for StabilizerSimulator { } fn ry(&mut self, angle: f64, target: QubitID) { - // 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 { + if !self.loss[target] { 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]); @@ -525,20 +495,17 @@ impl Simulator for StabilizerSimulator { } fn rz(&mut self, angle: f64, target: QubitID) { - // 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 { + if !self.loss[target] { 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]); @@ -547,192 +514,184 @@ impl Simulator for StabilizerSimulator { } fn rxx(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.rxx.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.rx(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => { + // 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]); } } - 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]); + (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]); } fn ryy(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.ryy.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.ry(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => { + // 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]); } } - 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]); + (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]); } fn rzz(&mut self, angle: f64, q1: QubitID, q2: QubitID) { - 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); + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.rzz.on_loss { + LossPolicy::Skip => (), + LossPolicy::Degrade => return self.rz(angle, remaining_qubit), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => { + // 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]); } } - 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]); + (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]); } fn swap(&mut self, q1: QubitID, q2: QubitID) { - 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]); - } + match (self.loss[q1], self.loss[q2]) { + (true, true) => (), + (true, false) | (false, true) => { + let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; + self.apply_idle_noise(remaining_qubit); + match self.noise_config.swap.on_loss { + LossPolicy::Skip | LossPolicy::Degrade => (), + LossPolicy::Propagate => self.loss_impl(remaining_qubit), + LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), + LossPolicy::ApplyAnyway => 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]); + (false, false) => { + 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 91350dbe45a5e9c04c61f8e1254eeb87e5ff87f2 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Tue, 16 Jun 2026 01:49:08 -0700 Subject: [PATCH 06/10] add warning when setting `.on_loss` on single-qubit gate --- source/qdk_package/src/qir_simulation.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/source/qdk_package/src/qir_simulation.rs b/source/qdk_package/src/qir_simulation.rs index 7a9cef7342..263968f085 100644 --- a/source/qdk_package/src/qir_simulation.rs +++ b/source/qdk_package/src/qir_simulation.rs @@ -581,8 +581,14 @@ impl NoiseTable { fn __setattr__(&mut self, name: &str, value: &Bound<'_, PyAny>) -> PyResult<()> { match name { "on_loss" => { - self.on_loss = value.extract::()?; - Ok(()) + if self.qubits < 2 { + Err(PyAttributeError::new_err( + "Loss policies only apply to multi-qubit gates.".to_string(), + )) + } else { + self.on_loss = value.extract::()?; + Ok(()) + } } "loss" => { self.loss = value.extract::()?; From 640f9737d22bee589a27782d8615d88f41da87f3 Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Wed, 17 Jun 2026 18:24:48 -0700 Subject: [PATCH 07/10] more reasonable swap behavior --- .../tests/test_simulators_gates_noisy.py | 286 ++++++++++++------ .../src/cpu_full_state_simulator.rs | 38 ++- .../src/gpu_full_state_simulator/common.wgsl | 104 +++++-- source/simulators/src/stabilizer_simulator.rs | 31 +- 4 files changed, 310 insertions(+), 149 deletions(-) diff --git a/source/qdk_package/tests/test_simulators_gates_noisy.py b/source/qdk_package/tests/test_simulators_gates_noisy.py index f89ecc2164..f3b05b3807 100644 --- a/source/qdk_package/tests/test_simulators_gates_noisy.py +++ b/source/qdk_package/tests/test_simulators_gates_noisy.py @@ -266,123 +266,227 @@ def test_two_qubit_loss(sim_type): # 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. +# +# Need to test: CX, CY, CZ, RXX, RYY, RZZ, SWAP +# with: SKIP, PROPAGATE, DEGRADE, RESIDUAL_S_DAGGER +# +# TEST 0: C*, R**, and SWAP default loss policies +# TEST 1: C*, R**, and SWAP with SKIP do not apply unitary +# TEST 2: C*, R**, and SWAP with PROPAGATE lose first qubit +# TEST 3: C*, R**, and SWAP with PROPAGATE lose second qubit +# TEST 4: C* and SWAP with DEGRADE behave like skip +# TEST 5: R** with DEGRADE behave like R* +# TEST 6: C*, R**, and SWAP with RESIDUAL_S_DAGGER do not apply unitary +# TEST 7: SWAP always exchanges loss flags and qubit states + +# Two-qubit gate-call fragments, grouped by how they reduce when exactly one +# operand is lost. Each entry is (NoiseConfig attribute, Q# gate call). +CONTROLLED_GATES = [ + ("cx", "CNOT(qs[0], qs[1])"), + ("cy", "CY(qs[0], qs[1])"), + ("cz", "CZ(qs[0], qs[1])"), +] +ROTATION_GATES = [ + ("rxx", "Rxx(Std.Math.PI(), qs[0], qs[1])"), + ("ryy", "Ryy(Std.Math.PI(), qs[0], qs[1])"), + ("rzz", "Rzz(Std.Math.PI(), qs[0], qs[1])"), +] +SWAP_GATE = ("swap", "SWAP(qs[0], qs[1])") +ALL_GATES = CONTROLLED_GATES + ROTATION_GATES + [SWAP_GATE] + +CONTROLLED_IDS = [attr for attr, _ in CONTROLLED_GATES] +ROTATION_IDS = [attr for attr, _ in ROTATION_GATES] +SWAP_ID = SWAP_GATE[0] +ALL_IDS = [attr for attr, _ in ALL_GATES] + +# Rotation gates under DEGRADE reduce to their single-qubit version on the +# survivor. With theta = PI the degraded rotation flips the survivor's measured +# bit to 1, but Rz only adds phase, so Rzz is prepared/measured in the X basis. +ROTATION_DEGRADE_RECIPES = [ + ("rxx", "Rxx(Std.Math.PI(), qs[0], qs[1])", "", ""), + ("ryy", "Ryy(Std.Math.PI(), qs[0], qs[1])", "", ""), + ("rzz", "Rzz(Std.Math.PI(), qs[0], qs[1])", "H(qs[1]);", "H(qs[1]);"), +] + + +def run_loss_policy_scenario( + gate: str, + sim_type: SimType, + *, + attr: str = "", + on_loss=None, + prep: str = "", + post: str = "", + lose: int = 0, +) -> str: + """ + Lose one operand of a two-qubit gate deterministically, apply the gate, and + measure both qubits. + + The qubit at index *lose* is taken out via a Y gate configured with + ``loss = 1.0``; the survivor can therefore be prepared with any non-Y gate + through *prep* (and post-processed through *post*). Returns the single + deterministic shot as a two-character string for + ``[MResetZ(qs[0]), MResetZ(qs[1])]``. + """ + noise = NoiseConfig() + noise.y.loss = 1.0 + if on_loss is not None: + setattr(getattr(noise, attr), "on_loss", on_loss) + source = ( + f"{{use qs = Qubit[2]; {prep} Y(qs[{lose}]); {gate}; {post} " + f"[MResetZ(qs[0]), MResetZ(qs[1])]}}" + ) + return compile_and_run(source, shots=1, seed=SEED, noise=noise, sim_type=sim_type)[ + 0 + ] -@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>. +# TEST 0: C*, R**, and SWAP default loss policies +def test_on_loss_defaults(): noise = NoiseConfig() - noise.x.loss = 1.0 # deterministically lose qs[0] after X - results = compile_and_run( - "{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, - sim_type=sim_type, - ) - check_histogram(results, {"-0": 1.0}) + assert noise.cx.on_loss == LossPolicy.SKIP + assert noise.cy.on_loss == LossPolicy.SKIP + assert noise.cz.on_loss == LossPolicy.SKIP + assert noise.rxx.on_loss == LossPolicy.DEGRADE + assert noise.ryy.on_loss == LossPolicy.DEGRADE + assert noise.rzz.on_loss == LossPolicy.DEGRADE + assert noise.swap.on_loss == LossPolicy.APPLY_ANYWAY +# TEST 1: SKIP never applies the unitary. C*/R** leave the survivor (|1>) +# untouched ("-1"); SWAP also skips the unitary but still exchanges the loss +# flag, so the survivor's |1> ends up on the now-lost qubit ("0-"). @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. - 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, +@pytest.mark.parametrize( + "attr,gate,expected", + [(*elt, "-1") for elt in ALL_GATES], + ids=ALL_IDS, +) +def test_on_loss_skip_does_not_apply_unitary(attr, gate, expected, sim_type): + res = run_loss_policy_scenario( + gate, sim_type, attr=attr, on_loss=LossPolicy.SKIP, prep="X(qs[1]);" ) - check_histogram(results, {"--": 1.0}) + assert res == expected +# TEST 2: PROPAGATE loses the surviving operand too. Here the first operand +# (qs[0]) is the one originally lost, and the loss propagates to qs[1], so both +# operands measure as loss. @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>. - 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, +@pytest.mark.parametrize("attr,gate", ALL_GATES, ids=ALL_IDS) +def test_on_loss_propagate_lose_first(attr, gate, sim_type): + res = run_loss_policy_scenario( + gate, sim_type, attr=attr, on_loss=LossPolicy.PROPAGATE, lose=0 ) - check_histogram(results, {"-1": 1.0}) + assert res == "--" +# TEST 3: same as TEST 2 but the second operand (qs[1]) is the one originally +# lost; the loss still propagates to the survivor, so both measure as loss. @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() - 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, +@pytest.mark.parametrize("attr,gate", ALL_GATES, ids=ALL_IDS) +def test_on_loss_propagate_lose_second(attr, gate, sim_type): + res = run_loss_policy_scenario( + gate, sim_type, attr=attr, on_loss=LossPolicy.PROPAGATE, lose=1 ) - check_histogram(results, {"-0": 1.0}) + assert res == "--" +# TEST 4: DEGRADE has no single-qubit reduction for controlled gates or SWAP, +# so it falls back to SKIP. C* leave the survivor (|1>) untouched ("-1"); SWAP +# skips the unitary but exchanges the loss flag ("0-"). @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 - # 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, +@pytest.mark.parametrize( + "attr,gate,expected", + [(*elt, "-1") for elt in CONTROLLED_GATES + [SWAP_GATE]], + ids=CONTROLLED_IDS + [SWAP_ID], +) +def test_on_loss_degrade_behaves_like_skip(attr, gate, expected, sim_type): + res = run_loss_policy_scenario( + gate, sim_type, attr=attr, on_loss=LossPolicy.DEGRADE, prep="X(qs[1]);" + ) + assert res == expected + + +# TEST 5: DEGRADE reduces a two-qubit rotation to its single-qubit version on +# the survivor (Rxx->Rx, Ryy->Ry, Rzz->Rz). With theta = PI the reduced +# rotation flips the survivor's measured bit; Rz only adds phase, so Rzz is +# measured in the X basis (H before and after). +@pytest.mark.parametrize("sim_type", SIM_TYPES) +@pytest.mark.parametrize( + "attr,gate,prep,post", ROTATION_DEGRADE_RECIPES, ids=ROTATION_IDS +) +def test_on_loss_degrade_reduces_rotation(attr, gate, prep, post, sim_type): + res = run_loss_policy_scenario( + gate, sim_type, attr=attr, on_loss=LossPolicy.DEGRADE, prep=prep, post=post ) - check_histogram(results, {"-0": 1.0}) + assert res == "-1" +# TEST 6a: RESIDUAL_S_DAGGER skips the unitary but applies an S-dagger to the +# survivor in place. The survivor is prepared in |+i> = S H |0>; S-dagger maps it +# back to |+>, and the final H rotates it to |0>, so the survivor (still at +# qs[1] for C*/R**) measures as 0 ("-0"). @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] - # 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, +@pytest.mark.parametrize( + "attr,gate", + CONTROLLED_GATES + ROTATION_GATES, + ids=CONTROLLED_IDS + ROTATION_IDS, +) +def test_on_loss_residual_s_dagger_applies_s_adjoint(attr, gate, sim_type): + res = run_loss_policy_scenario( + gate, + sim_type, + attr=attr, + on_loss=LossPolicy.RESIDUAL_S_DAGGER, + prep="H(qs[1]); S(qs[1]);", + post="H(qs[1]);", + ) + assert res == "-0" + + +# TEST 6b: SWAP under RESIDUAL_S_DAGGER applies the full swap, then the S-dagger +# to the survivor, then exchanges the loss flag. Unlike C*/R**, the swap +# relocates the survivor to qs[0] (the originally-lost operand) and moves the +# loss flag to qs[1]. The survivor |+i> = S H |0> maps under S-dagger to |+>, so +# the post-processing H is applied to qs[0] (the survivor's new location) to +# rotate it to |0>: qs[0] measures 0 and the now-lost qs[1] measures as loss +# ("0-"). Measuring the survivor in place (its X-basis phase) is what verifies +# the S-dagger was actually applied. +@pytest.mark.parametrize("sim_type", SIM_TYPES) +def test_on_loss_swap_residual_s_dagger_applies_s_adjoint(sim_type): + res = run_loss_policy_scenario( + SWAP_GATE[1], + sim_type, + attr=SWAP_GATE[0], + on_loss=LossPolicy.RESIDUAL_S_DAGGER, + prep="H(qs[1]); S(qs[1]);", + post="H(qs[0]);", ) - check_histogram(results, {"1-": 1.0}) + assert res == "0-" +# TEST 7: SWAP always exchanges the loss flag between its operands, whatever the +# policy. The survivor (|1>) becomes the lost qubit and the originally-lost +# qubit returns as a reset |0> ("0-"); under PROPAGATE both end up lost ("--"). @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. - 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}) +@pytest.mark.parametrize( + "on_loss,expected", + [ + (LossPolicy.SKIP, "-1"), + (LossPolicy.PROPAGATE, "--"), + (LossPolicy.DEGRADE, "-1"), + (LossPolicy.RESIDUAL_S_DAGGER, "1-"), + (LossPolicy.APPLY_ANYWAY, "1-"), + ], + ids=["skip", "propagate", "residual_s_dagger", "degrade", "apply_anyway"], +) +def test_on_loss_swap_swaps_loss_flag(on_loss, expected, sim_type): + res = run_loss_policy_scenario( + SWAP_GATE[1], sim_type, attr="swap", on_loss=on_loss, prep="X(qs[1]);" + ) + assert res == expected # =========================================================================== diff --git a/source/simulators/src/cpu_full_state_simulator.rs b/source/simulators/src/cpu_full_state_simulator.rs index 492be1d1eb..a48496a933 100644 --- a/source/simulators/src/cpu_full_state_simulator.rs +++ b/source/simulators/src/cpu_full_state_simulator.rs @@ -1015,19 +1015,37 @@ impl Simulator for NoisySimulator { } fn swap(&mut self, q1: QubitID, q2: QubitID) { + // There are three kinds of swaps: + // 1. A logical swap, also called a relabel. + // 2. A swap by physically exchanging the location of the qubits. + // 3. An exchange of information by doing three CX. + // + // This method is concerned with the kinds (1) and (2), since (3) + // gets decomposed into other instructions before making it to the simulator. + // In both (1) and (2), the loss state of the qubits gets exchanged. + match (self.loss[q1], self.loss[q2]) { (true, true) => (), (true, false) | (false, true) => { + let lost_qubit = if self.loss[q1] { q1 } else { q2 }; let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; self.apply_idle_noise(remaining_qubit); match self.noise_config.swap.on_loss { LossPolicy::Skip | LossPolicy::Degrade => (), LossPolicy::Propagate => self.loss_impl(remaining_qubit), - LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), - LossPolicy::ApplyAnyway => self - .state - .apply_operation(&SWAP, &[q1, q2]) - .expect("apply_operation should succeed"), + LossPolicy::ResidualSDagger => { + self.state + .apply_operation(&SWAP, &[q1, q2]) + .expect("apply_operation should succeed"); + self.residual_s_dagger(lost_qubit); + self.loss.swap(q1, q2); + } + LossPolicy::ApplyAnyway => { + self.state + .apply_operation(&SWAP, &[q1, q2]) + .expect("apply_operation should succeed"); + self.loss.swap(q1, q2); + } } } (false, false) => { @@ -1036,17 +1054,9 @@ impl Simulator for NoisySimulator { self.state .apply_operation(&SWAP, &[q1, q2]) .expect("apply_operation should succeed"); + self.loss.swap(q1, q2); } } - // There are three kinds of swaps: - // 1. A logical swap, also called a relabel. - // 2. A swap by physically exchanging the location of the qubits. - // 3. An exchange of information by doing three CX. - // - // This method is concerned with the kinds (1) and (2), since (3) - // gets decomposed into other instructions before making it to the simulator. - // In both (1) and (2), the loss state of the qubits gets exchanged. - self.loss.swap(q1, q2); // Is up to the user if swap is a virtual operation or not. // If they don't specify noise/loss probability for swap, then it is virtual. diff --git a/source/simulators/src/gpu_full_state_simulator/common.wgsl b/source/simulators/src/gpu_full_state_simulator/common.wgsl index c2104c83c2..eb75c1627e 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -377,6 +377,18 @@ fn set_1q_on_pair_unitary(shot_idx: u32, target_is_q2: bool, } } +// Multiplies one row of the 4x4 pair unitary (in shot.unitary) by -i, in place. +// Folding a diag(1, -i) = S-dagger factor on one qubit into a 2-qubit matrix +// scales the rows whose target-qubit bit is 1 by -i. For a complex entry +// (x + y i), (x + y i) * -i = y - x i. +fn scale_pair_unitary_row_by_neg_i(shot_idx: u32, row: u32) { + let shot = &shots[shot_idx]; + for (var c = 0u; c < 4u; c++) { + let e = shot.unitary[row * 4u + c]; + shot.unitary[row * 4u + c] = vec2f(e.y, -e.x); + } +} + // 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]; @@ -431,6 +443,7 @@ fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> b let shot = &shots[shot_idx]; let op = &ops[op_idx]; let is_1q = is_1q_op(op.id); + let is_2q = !is_1q; let policy = op.q3; // Loss policies only make sense for multi-qubit gates. @@ -441,33 +454,69 @@ fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> b return true; } + let q1_lost = shot.qubit_state[q1].heat == -1.0; + 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; + // 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; + switch policy { + case LOSS_POLICY_PROPAGATE { + propagate_loss_to_qubit(shot_idx, op_idx, q1, q2, survivor); + return true; + } + case LOSS_POLICY_RESIDUAL_S_DAGGER { + // Match the CPU/stabilizer SWAP + residual S-dagger semantics: + // 1. Apply the full SWAP (shot.unitary already holds it). + // 2. Apply S-dagger = diag(1, -i) to the (originally) lost + // operand's position, which after the SWAP holds the + // survivor's amplitudes. + // 3. Exchange the per-qubit loss flag (heat) of the operands. + + // Fold the S-dagger into the SWAP matrix by scaling, by -i, the + // two rows of the |q1 q2> pair matrix whose lost-qubit bit is 1. + // q1 is the high bit (rows 2, 3); q2 is the low bit (rows 1, 3). + let lost_row = select(1u, 2u, q1_lost); + scale_pair_unitary_row_by_neg_i(shot_idx, lost_row); + scale_pair_unitary_row_by_neg_i(shot_idx, 3u); + // 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; + // The 2-qubit execute path skips amplitudes for qubits known to be + // in a definite state, which would skip 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 now holds (S-dagger on lost) * SWAP. + finish_2q_shot_buffer(shot_idx, op_idx, q1, q2); + return true; + } + case LOSS_POLICY_APPLY_ANYWAY { + // 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; + // The 2-qubit execute path skips amplitudes for qubits known to be + // in a definite state, which would skip 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; + } + default { + shot.op_type = OPID_ID; + shot.op_idx = op_idx; + 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. @@ -475,17 +524,6 @@ fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> b 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 = !is_1q; - 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; diff --git a/source/simulators/src/stabilizer_simulator.rs b/source/simulators/src/stabilizer_simulator.rs index dca61735da..65b1f9304b 100644 --- a/source/simulators/src/stabilizer_simulator.rs +++ b/source/simulators/src/stabilizer_simulator.rs @@ -675,33 +675,42 @@ impl Simulator for StabilizerSimulator { } fn swap(&mut self, q1: QubitID, q2: QubitID) { + // There are three kinds of swaps: + // 1. A logical swap, also called a relabel. + // 2. A swap by physically exchanging the location of the qubits. + // 3. An exchange of information by doing three CX. + // + // This method is concerned with the kinds (1) and (2), since (3) + // gets decomposed into other instructions before making it to the simulator. + // In both (1) and (2), the loss state of the qubits gets exchanged. + match (self.loss[q1], self.loss[q2]) { (true, true) => (), (true, false) | (false, true) => { + let lost_qubit = if self.loss[q1] { q1 } else { q2 }; let remaining_qubit = if self.loss[q1] { q2 } else { q1 }; self.apply_idle_noise(remaining_qubit); match self.noise_config.swap.on_loss { LossPolicy::Skip | LossPolicy::Degrade => (), LossPolicy::Propagate => self.loss_impl(remaining_qubit), - LossPolicy::ResidualSDagger => self.residual_s_dagger(remaining_qubit), - LossPolicy::ApplyAnyway => self.state.apply_permutation(&[1, 0], &[q1, q2]), + LossPolicy::ResidualSDagger => { + self.state.apply_permutation(&[1, 0], &[q1, q2]); + self.residual_s_dagger(lost_qubit); + self.loss.swap(q1, q2); + } + LossPolicy::ApplyAnyway => { + self.state.apply_permutation(&[1, 0], &[q1, q2]); + self.loss.swap(q1, q2); + } } } (false, false) => { self.apply_idle_noise(q1); self.apply_idle_noise(q2); self.state.apply_permutation(&[1, 0], &[q1, q2]); + self.loss.swap(q1, q2); } } - // There are three kinds of swaps: - // 1. A logical swap, also called a relabel. - // 2. A swap by physically exchanging the location of the qubits. - // 3. An exchange of information by doing three CX. - // - // This method is concerned with the kinds (1) and (2), since (3) - // gets decomposed into other instructions before making it to the simulator. - // In both (1) and (2), the loss state of the qubits gets exchanged. - self.loss.swap(q1, q2); // Is up to the user if swap is a virtual operation or not. // If they don't specify noise/loss probability for swap, then it is virtual. From 885da6abecd1dbfa5f205a3290c2b662de1cfc7b Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Wed, 17 Jun 2026 19:30:15 -0700 Subject: [PATCH 08/10] store policy in `policy` field instead of `q3` --- .../src/gpu_full_state_simulator/common.wgsl | 2 +- .../src/gpu_full_state_simulator/gpu_context.rs | 4 ++-- .../src/gpu_full_state_simulator/shader_types.rs | 10 +++++++++- .../gpu_full_state_simulator/simulator_adaptive.wgsl | 8 ++++++-- .../src/gpu_full_state_simulator/simulator_base.wgsl | 8 ++++++-- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/source/simulators/src/gpu_full_state_simulator/common.wgsl b/source/simulators/src/gpu_full_state_simulator/common.wgsl index eb75c1627e..3eb239a95a 100644 --- a/source/simulators/src/gpu_full_state_simulator/common.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/common.wgsl @@ -444,7 +444,7 @@ fn handle_lost_operand_policy(shot_idx: u32, op_idx: u32, q1: u32, q2: u32) -> b let op = &ops[op_idx]; let is_1q = is_1q_op(op.id); let is_2q = !is_1q; - let policy = op.q3; + let policy = op.policy; // Loss policies only make sense for multi-qubit gates. // If this is a single-qubit gate, skip it entirely. 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 b1d7e06bf4..27c915b583 100644 --- a/source/simulators/src/gpu_full_state_simulator/gpu_context.rs +++ b/source/simulators/src/gpu_full_state_simulator/gpu_context.rs @@ -931,7 +931,7 @@ fn add_noise_config_to_ops(ops: &[Op], noise: &NoiseConfig) -> Vec // 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; + gate_op.policy = policy; } let mut add_ops: Vec = vec![gate_op]; // If there's a NoiseConfig, and we get noise for this op, append it @@ -988,7 +988,7 @@ fn add_noise_to_adaptive_ops( // 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; + gate_op.policy = policy; } noisy_ops.push(gate_op); diff --git a/source/simulators/src/gpu_full_state_simulator/shader_types.rs b/source/simulators/src/gpu_full_state_simulator/shader_types.rs index edf26ec147..a770c14b97 100644 --- a/source/simulators/src/gpu_full_state_simulator/shader_types.rs +++ b/source/simulators/src/gpu_full_state_simulator/shader_types.rs @@ -296,6 +296,10 @@ pub struct Op { pub q1: u32, pub q2: u32, pub q3: u32, // For ccx + pub policy: u32, + pub pad0: u32, // for 16-byte alignment + pub pad1: u32, // for 16-byte alignment + pub pad2: u32, // for 16-byte alignment pub r00: f32, pub i00: f32, pub r01: f32, @@ -331,7 +335,7 @@ pub struct Op { } // safety check to make sure Op is the correct size with padding at compile time -const _: () = assert!(std::mem::size_of::() == 144); +const _: () = assert!(std::mem::size_of::() == 160); impl Default for Op { fn default() -> Self { @@ -340,6 +344,10 @@ impl Default for Op { q1: 0, q2: 0, q3: 0, + policy: 0, + pad0: 0, + pad1: 0, + pad2: 0, r00: 0.0, i00: 0.0, r01: 0.0, 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 b7bf22b01a..6503bbf055 100644 --- a/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/simulator_adaptive.wgsl @@ -79,10 +79,14 @@ struct Op { q1: u32, q2: u32, q3: u32, + policy: u32, + pad0: u32, + pad1: u32, + pad2: u32, // Entries in the unitary are: 00, 01, 02, 03, 10, 11, 12, 13, 20, ..., 32, 33 // 1q matrix elements are stored in: 00, 01, 10, 11 (i.e., indices 0, 1, 4, and 5) unitary: array, -} // Struct size: 4 * 4 + 16 * 8 = 144 bytes (which is aligned to 16 bytes) +} // Struct size: 4 * 8 + 16 * 8 = 160 bytes (which is aligned to 16 bytes) @group(0) @binding(2) var ops: array; @@ -1570,7 +1574,7 @@ fn prepare_op(@builtin(global_invocation_id) globalId: vec3) { 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 + // policy (stamped on op.policy). 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) { 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 a49cdf9e0b..9d04804bd8 100644 --- a/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl +++ b/source/simulators/src/gpu_full_state_simulator/simulator_base.wgsl @@ -70,10 +70,14 @@ struct Op { q1: u32, q2: u32, q3: u32, + policy: u32, + pad0: u32, + pad1: u32, + pad2: u32, // Entries in the unitary are: 00, 01, 02, 03, 10, 11, 12, 13, 20, ..., 32, 33 // 1q matrix elements are stored in: 00, 01, 10, 11 (i.e., indices 0, 1, 4, and 5) unitary: array, -} // Struct size: 4 * 4 + 16 * 8 = 144 bytes (which is aligned to 16 bytes) +} // Struct size: 4 * 4 + 16 * 8 = 160 bytes (which is aligned to 16 bytes) @group(0) @binding(2) var ops: array; @@ -286,7 +290,7 @@ fn prepare_op(@builtin(global_invocation_id) globalId: vec3) { } // 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 + // the gate's configured loss policy (stamped on op.policy). 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)) { From 6ef40fcaf4328f4e02ad2e7e055f444dfffaeaae Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Fri, 19 Jun 2026 19:33:50 -0700 Subject: [PATCH 09/10] fix struct size calculation after merge --- .../gpu_full_state_simulator/shader_types.rs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/source/simulators/src/gpu_full_state_simulator/shader_types.rs b/source/simulators/src/gpu_full_state_simulator/shader_types.rs index ed3999639a..74aaf9f528 100644 --- a/source/simulators/src/gpu_full_state_simulator/shader_types.rs +++ b/source/simulators/src/gpu_full_state_simulator/shader_types.rs @@ -701,10 +701,14 @@ impl Op { /// in the op's matrix-storage floats. The host and shader agree that flat /// slot `k` maps to WGSL `unitary[k / 2][k % 2]`. pub fn set_noise_prob_slot(&mut self, slot: usize, prob: f32) { - // Op is 4 leading u32 fields followed by 32 matrix floats - // (r00, i00, r01, i01, ...), so matrix flat slot `k` lives at index `4 + k`. - let floats: &mut [f32; 36] = bytemuck::cast_mut(self); - floats[4 + slot] = prob; + // The matrix floats (r00, i00, r01, i01, ...) follow the leading u32 + // header fields. Derive the lengths from the struct layout so this stays + // correct if header fields are added or removed, and so the `cast_mut` + // below never hits a size mismatch. + const OP_FLOATS: usize = std::mem::size_of::() / std::mem::size_of::(); + const MATRIX_OFFSET: usize = std::mem::offset_of!(Op, r00) / std::mem::size_of::(); + let floats: &mut [f32; OP_FLOATS] = bytemuck::cast_mut(self); + floats[MATRIX_OFFSET + slot] = prob; } #[must_use] @@ -936,16 +940,22 @@ impl Op { /// in matrix flat slot `i`). #[must_use] pub fn correlated_noise_qubit(&self, index: u32) -> u32 { - let floats: &[f32; 36] = bytemuck::cast_ref(self); - // The 4 leading u32 fields precede the 32 matrix floats. Qubit ids are - // stored as exact f32 values (range limited to 32), mirroring how the - // shader reads them back as u32. + // The matrix floats (r00, i00, r01, i01, ...) follow the leading u32 + // header fields. Derive the lengths from the struct layout so this stays + // correct if header fields are added or removed, and so the `cast_mut` + // below never hits a size mismatch. + const OP_FLOATS: usize = std::mem::size_of::() / std::mem::size_of::(); + const MATRIX_OFFSET: usize = std::mem::offset_of!(Op, r00) / std::mem::size_of::(); + let floats: &[f32; OP_FLOATS] = bytemuck::cast_ref(self); + + // Qubit ids are stored as exact f32 values (range limited to 32), + // mirroring how the shader reads them back as u32. #[allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "qubit ids are small non-negative integers stored exactly as f32" )] - let qubit = floats[4 + index as usize] as u32; + let qubit = floats[MATRIX_OFFSET + index as usize] as u32; qubit } From 52a758be101ac6d4468af6fb0c1709b8fcf986bb Mon Sep 17 00:00:00 2001 From: Oscar Puente Date: Fri, 19 Jun 2026 19:34:04 -0700 Subject: [PATCH 10/10] fix flaky tests after merge --- .../tests/test_clifford_simulator.py | 144 ++++-------------- .../tests/test_sparse_simulator.py | 121 +++++++++++---- 2 files changed, 122 insertions(+), 143 deletions(-) diff --git a/source/qdk_package/tests/test_clifford_simulator.py b/source/qdk_package/tests/test_clifford_simulator.py index 04417c9c31..80d8801ccd 100644 --- a/source/qdk_package/tests/test_clifford_simulator.py +++ b/source/qdk_package/tests/test_clifford_simulator.py @@ -303,147 +303,69 @@ def test_clifford_run_no_noise(): assert output == [[Result.Zero] * 16], "Expected result of 0s with pi/2 angles." +QSHARP_OP_25_QUBITS = """ +operation Test() : Result[] { + use qs = Qubit[25]; X(qs[0]); CZ(qs[23], qs[24]); MResetEachZ(qs) +}""" + + def test_clifford_run_bitflip_noise(): """Bitflip noise for Clifford simulator.""" qsharp.init(target_profile=TargetProfile.Base) - qsharp.eval(read_file_relative("CliffordIsing.qs")) + qsharp.eval(QSHARP_OP_25_QUBITS) - p_noise = 0.005 + p_noise = 0.2 noise = NoiseConfig() - noise.rx.set_bitflip(p_noise) - noise.rzz.set_pauli_noise("XX", p_noise) - noise.mresetz.set_bitflip(p_noise) - - output = qsharp.run( - "IsingModel2DEvolution(4, 4, PI() / 2.0, PI() / 2.0, 10.0, 10)", - shots=10_000, - noise=noise, - seed=17, - type="clifford", - ) - result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] - # Reasonable results obtained from manual run + noise.x.set_bitflip(p_noise) + noise.cz.set_pauli_noise("XX", p_noise) expect = { - "0000000001000000": 0.0084, - "0000010000000000": 0.0079, - "0001000000000000": 0.0087, - "0000100000000000": 0.0096, - "0000000000000000": 0.1412, - "0011000000000000": 0.0066, - "0000000001100000": 0.0082, - "1100000000000000": 0.0072, - "0000000000000011": 0.0083, - "0000000000100010": 0.0091, - "0000000000000010": 0.0074, - "0100010000000000": 0.0058, - "0000000001000100": 0.0078, - "0000011000000000": 0.0067, - "0010000000000000": 0.0089, - "0000000000000110": 0.0085, - "0000000000100000": 0.0091, - "0000000010001000": 0.007, - "0000001000000000": 0.008, - "0000100010000000": 0.0078, - "1000100000000000": 0.0086, - "1000000000000000": 0.0067, - "0000000000010001": 0.0073, - "0001000100000000": 0.0085, - "0000110000000000": 0.0075, - "0000000000000001": 0.0076, - "0110000000000000": 0.0073, - "0010001000000000": 0.0068, - "0100000000000000": 0.0087, - "0000000100000000": 0.0066, - "0000010001000000": 0.007, - "0000000000000100": 0.0068, - "0000001000100000": 0.0067, - "0000000011000000": 0.0102, - "0000000000010000": 0.0087, - "0000000000110000": 0.0081, - "0000000010000000": 0.008, - "0000000100010000": 0.008, - "0000001100000000": 0.0075, - "0000000000001000": 0.0088, - "0000000000001100": 0.0066, + "1000000000000000000000000": (1 - p_noise) ** 2, # No noise + "0000000000000000000000000": p_noise * (1 - p_noise), # X bitflip + "1000000000000000000000011": (1 - p_noise) * p_noise, # CZ bitflip + "0000000000000000000000011": p_noise**2, # X & CZ bitflip } + + output = qsharp.run("Test()", shots=500, noise=noise, seed=17, type="clifford") + result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] expect_distribution( result, expect, - tolerance=0.005, + tolerance=0.02, ) # Same execution should work with the operation itself. - output = qsharp.run( - qdk.code.IsingModel2DEvolution, - 10_000, - 4, - 4, - math.pi / 2, - math.pi / 2, - 10.0, - 10, - noise=noise, - seed=17, - type="clifford", - ) + output = qsharp.run(qdk.code.Test, 500, noise=noise, seed=17, type="clifford") result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] expect_distribution( result, expect, - tolerance=0.005, + tolerance=0.02, ) def test_clifford_run_mixed_noise(): qsharp.init(target_profile=TargetProfile.Base) - qsharp.eval(read_file_relative("CliffordIsing.qs")) + qsharp.eval(QSHARP_OP_25_QUBITS) + p_noise = 0.2 noise = NoiseConfig() - noise.rx.set_bitflip(0.008) - noise.rx.loss = 0.005 - noise.rzz.set_depolarizing(0.008) - noise.rzz.loss = 0.005 + noise.x.set_bitflip(p_noise) + noise.cz.XI = p_noise + noise.cz.IL = p_noise - output = qsharp.run( - "IsingModel2DEvolution(4, 4, PI() / 2.0, PI() / 2.0, 4.0, 4)", - shots=10_000, - noise=noise, - seed=228, - type="clifford", - ) + output = qsharp.run("Test()", shots=500, noise=noise, seed=17, type="clifford") result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] expect_distribution( result, - # Reasonable results obtained from manual run { - "0000000000-00000": 0.01, - "0000000000001000": 0.0055, - "000000000-000000": 0.0104, - "0000000000000000": 0.0854, - "0100000000000000": 0.0062, - "0000-00000000000": 0.0098, - "-000000000000000": 0.0066, - "0-00000000000000": 0.0084, - "00000-0000000000": 0.0116, - "00000000000-0000": 0.0069, - "00-0000000000000": 0.0104, - "0000000001000000": 0.0057, - "00000000-0000000": 0.0108, - "0010000000000000": 0.0054, - "000000-000000000": 0.0113, - "0000000000010000": 0.0067, - "00000000000000-0": 0.0092, - "000000000000-000": 0.0072, - "0000000000100000": 0.0074, - "0000000010000000": 0.0056, - "0000010000000000": 0.0065, - "0001000000000000": 0.0052, - "0000000000000-00": 0.0087, - "0000000-00000000": 0.0081, - "000000000000000-": 0.0052, - "0000000100000000": 0.0052, + "1000000000000000000000000": (1 - p_noise) * (1 - 2 * p_noise), # No noise + "0000000000000000000000000": p_noise * (1 - 2 * p_noise), # X bitflip + "1000000000000000000000010": (1 - p_noise) * p_noise, # CZ bitflip + "100000000000000000000000-": (1 - p_noise) * p_noise, # CZ loss + "0000000000000000000000010": p_noise**2, # X bitflip + CZ bitflip + "000000000000000000000000-": p_noise**2, # X bitflip + CZ loss }, - tolerance=0.005, + tolerance=0.02, ) diff --git a/source/qdk_package/tests/test_sparse_simulator.py b/source/qdk_package/tests/test_sparse_simulator.py index b2e36c4a29..0105b392bf 100644 --- a/source/qdk_package/tests/test_sparse_simulator.py +++ b/source/qdk_package/tests/test_sparse_simulator.py @@ -3,7 +3,7 @@ from collections import Counter from pathlib import Path -from typing import Sequence, cast +from typing import Dict, Sequence, cast import math import random @@ -43,6 +43,52 @@ def result_array_to_string(results: Sequence[Result]) -> str: return "".join(chars) +def format_expectation(actual: Dict[str, float], expect: Dict[str, float]): + return f"Expected distribution:\n {expect}\n\nActual distribution:\n {actual}" + + +def assert_err(msg: str, actual: Dict[str, float], expect: Dict[str, float]): + return msg + "\n\n" + format_expectation(actual, expect) + + +def assert_distributions_eq( + actual: Dict[str, float], expect: Dict[str, float], tolerance: float +): + # Prune values that are smaller than the tolerance. + actual = {key: val for key, val in actual.items() if val > tolerance} + expect = {key: val for key, val in expect.items() if val > tolerance} + + for key in actual: + assert key in expect, assert_err( + f"Unexpected measurement string: '{key}'.", actual, expect + ) + + for key in expect: + assert key in actual, assert_err( + f"Missing measurement string: '{key}'", actual, expect + ) + + tolerance_percent = int(tolerance * 100) + for key in actual: + assert abs(actual[key] - expect[key]) < tolerance, assert_err( + f"Probability for {key} outside {tolerance_percent}% tolerance.", + actual, + expect, + ) + + +def expect_distribution( + results, + expected: Dict[str, float], + *, + tolerance: float = 0.01, +): + histogram = Counter(results) + total = sum(histogram.values()) + actual = {key: val / total for key, val in histogram.items()} + assert_distributions_eq(actual, expected, tolerance) + + def test_sparse_no_noise(): """Simple test that sparse simulator works without noise.""" qsharp.init(target_profile=TargetProfile.Base) @@ -54,49 +100,60 @@ def test_sparse_no_noise(): assert output == [[Result.Zero] * 16], "Expected result of 0s with pi/2 angles." +QSHARP_OP_25_QUBITS = """ +operation Test() : Result[] { + use qs = Qubit[25]; X(qs[0]); CZ(qs[23], qs[24]); MResetEachZ(qs) +}""" + + def test_sparse_bitflip_noise(): - """Bitflip noise for sparse simulator.""" + """Bitflip noise for Clifford simulator.""" qsharp.init(target_profile=TargetProfile.Base) - qsharp.eval(read_file_relative("CliffordIsing.qs")) + qsharp.eval(QSHARP_OP_25_QUBITS) - p_noise = 0.005 + p_noise = 0.2 noise = NoiseConfig() - noise.rx.set_bitflip(p_noise) - noise.rzz.set_pauli_noise("XX", p_noise) - noise.mresetz.set_bitflip(p_noise) - - output = run( - "IsingModel2DEvolution(4, 4, PI() / 2.0, PI() / 2.0, 10.0, 10)", - shots=1, - noise=noise, - seed=17, - ) + noise.x.set_bitflip(p_noise) + noise.cz.set_pauli_noise("XX", p_noise) + + output = qsharp.run("Test()", shots=1000, noise=noise, seed=17) result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] - print(result) - # Reasonable results obtained from manual run - assert result == ["1000110000000000"] + expect_distribution( + result, + { + "1000000000000000000000000": (1 - p_noise) ** 2, # No noise + "0000000000000000000000000": p_noise * (1 - p_noise), # X bitflip + "1000000000000000000000011": (1 - p_noise) * p_noise, # CZ bitflip + "0000000000000000000000011": p_noise**2, # X & CZ bitflip + }, + tolerance=0.02, + ) def test_sparse_mixed_noise(): qsharp.init(target_profile=TargetProfile.Base) - qsharp.eval(read_file_relative("CliffordIsing.qs")) + qsharp.eval(QSHARP_OP_25_QUBITS) + p_noise = 0.2 noise = NoiseConfig() - noise.rz.set_bitflip(0.008) - noise.rz.loss = 0.005 - noise.rzz.set_depolarizing(0.008) - noise.rzz.loss = 0.005 - - output = run( - "IsingModel2DEvolution(4, 4, PI() / 2.0, PI() / 2.0, 4.0, 4)", - shots=1, - noise=noise, - seed=23, - ) + noise.x.set_bitflip(p_noise) + noise.cz.XI = p_noise + noise.cz.IL = p_noise + + output = qsharp.run("Test()", shots=1000, noise=noise, seed=17) result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] - print(result) - # Reasonable results obtained from manual run - assert result == ["0000000000-00011"] + expect_distribution( + result, + { + "1000000000000000000000000": (1 - p_noise) * (1 - 2 * p_noise), # No noise + "0000000000000000000000000": p_noise * (1 - 2 * p_noise), # X bitflip + "1000000000000000000000010": (1 - p_noise) * p_noise, # CZ bitflip + "100000000000000000000000-": (1 - p_noise) * p_noise, # CZ loss + "0000000000000000000000010": p_noise**2, # X bitflip + CZ bitflip + "000000000000000000000000-": p_noise**2, # X bitflip + CZ loss + }, + tolerance=0.02, + ) def test_sparse_isolated_loss():