use num_complex::Complex64;
use rand::SeedableRng;
use rand::{Rng, RngExt};
use rand_chacha::ChaCha8Rng;
use std::borrow::Cow;
use crate::circuit::clifford_t::{is_pauli_native, lower_to_pauli_forms};
use crate::circuit::{Circuit, Instruction, SmallVec};
use crate::error::{PrismError, Result};
use crate::gates::Gate;
use crate::sim::compiled::{PauliVec, flip_bit, get_bit, propagate_backward};
use crate::sim::noise::{NoiseChannel, NoiseModel, thermal_relaxation_rates};
const SPD_MAX_TERMS_CEILING: usize = 1 << 20;
#[inline]
fn check_spd_term_ceiling(len: usize, backend: &str) -> Result<()> {
if len > SPD_MAX_TERMS_CEILING {
return Err(PrismError::BackendUnsupported {
backend: backend.into(),
operation: format!(
"weighted-Pauli sum exceeded {SPD_MAX_TERMS_CEILING} terms; pass a nonzero \
max_terms for bounded approximate truncation"
),
});
}
Ok(())
}
#[inline]
fn z_rotation_angle(gate: &Gate) -> Option<f64> {
match gate {
Gate::T => Some(std::f64::consts::FRAC_PI_4),
Gate::Tdg => Some(-std::f64::consts::FRAC_PI_4),
Gate::Rz(theta) | Gate::P(theta) => Some(*theta),
_ => None,
}
}
#[inline]
fn zz_rotation_angle(gate: &Gate) -> Option<f64> {
match gate {
Gate::Rzz(theta) => Some(*theta),
_ => None,
}
}
fn validate_and_lower<'c>(circuit: &'c Circuit, backend: &'static str) -> Result<Cow<'c, Circuit>> {
let reject = |operation: String| PrismError::BackendUnsupported {
backend: backend.to_string(),
operation,
};
let mut needs_lowering = false;
for inst in &circuit.instructions {
match inst {
Instruction::Gate { gate, targets } => {
if is_pauli_native(gate) {
continue;
}
lower_to_pauli_forms(gate, targets, &mut |_, _| {}).map_err(reject)?;
needs_lowering = true;
}
Instruction::Barrier { .. } => {}
Instruction::Save { .. }
| Instruction::Measure { .. }
| Instruction::Reset { .. }
| Instruction::Conditional { .. }
| Instruction::Region(_) => {
return Err(PrismError::IncompatibleBackend {
backend: backend.to_string(),
reason: "Pauli propagation requires a unitary circuit without measurements, \
resets, or conditionals"
.to_string(),
});
}
}
}
if !needs_lowering {
return Ok(Cow::Borrowed(circuit));
}
let mut out: Vec<Instruction> = Vec::with_capacity(circuit.instructions.len() * 2);
for inst in &circuit.instructions {
let Instruction::Gate { gate, targets } = inst else {
out.push(inst.clone());
continue;
};
lower_to_pauli_forms(gate, targets, &mut |gate, tgts| {
out.push(Instruction::Gate {
gate,
targets: SmallVec::from_slice(tgts),
});
})
.map_err(reject)?;
}
Ok(Cow::Owned(circuit.with_instructions(out)))
}
const ZROT_SINGLE: u32 = u32::MAX;
enum CoalescedOp {
SmallCliff(Vec<(Gate, SmallVec<[usize; 4]>)>),
ZRot {
qubit: u32,
pair: u32,
branch: RotBranch,
},
}
fn coalesce_cliffords(circuit: &Circuit) -> Vec<CoalescedOp> {
let mut ops = Vec::new();
let mut cliff_buf: Vec<(Gate, SmallVec<[usize; 4]>)> = Vec::new();
for inst in &circuit.instructions {
if let Instruction::Gate { gate, targets } = inst {
if let Some(theta) = z_rotation_angle(gate) {
flush_cliff_buf(&mut cliff_buf, &mut ops);
ops.push(CoalescedOp::ZRot {
qubit: targets[0] as u32,
pair: ZROT_SINGLE,
branch: RotBranch::new(theta),
});
} else if let Some(theta) = zz_rotation_angle(gate) {
flush_cliff_buf(&mut cliff_buf, &mut ops);
ops.push(CoalescedOp::ZRot {
qubit: targets[0] as u32,
pair: targets[1] as u32,
branch: RotBranch::new(theta),
});
} else {
cliff_buf.push((gate.clone(), SmallVec::from_slice(targets)));
}
} else {
flush_cliff_buf(&mut cliff_buf, &mut ops);
}
}
flush_cliff_buf(&mut cliff_buf, &mut ops);
ops
}
fn flush_cliff_buf(buf: &mut Vec<(Gate, SmallVec<[usize; 4]>)>, ops: &mut Vec<CoalescedOp>) {
if buf.is_empty() {
return;
}
ops.push(CoalescedOp::SmallCliff(std::mem::take(buf)));
}
#[derive(Clone, Copy)]
struct RotBranch {
p_keep: f64,
keep_weight: f64,
flip_weight_im: f64,
}
impl RotBranch {
fn new(theta: f64) -> Self {
let (sin, cos) = theta.sin_cos();
let norm1 = cos.abs() + sin.abs();
Self {
p_keep: cos.abs() / norm1,
keep_weight: norm1 * cos.signum(),
flip_weight_im: -norm1 * sin.signum(),
}
}
}
#[inline(always)]
fn branch_z_rotation(
pauli: &mut PauliVec,
qubit: u32,
pair: u32,
branch: &RotBranch,
rng: &mut impl Rng,
) -> Complex64 {
let q = qubit as usize;
let anticommutes = if pair == ZROT_SINGLE {
pauli.has_x_or_y(q)
} else {
pauli.has_x_or_y(q) != pauli.has_x_or_y(pair as usize)
};
if !anticommutes {
return Complex64::new(1.0, 0.0);
}
if rng.random_bool(branch.p_keep) {
return Complex64::new(branch.keep_weight, 0.0);
}
flip_bit(&mut pauli.z, q);
if pair != ZROT_SINGLE {
flip_bit(&mut pauli.z, pair as usize);
}
Complex64::new(0.0, branch.flip_weight_im)
}
fn backward_propagate_coalesced(
ops: &[CoalescedOp],
observable: &PauliVec,
rng: &mut impl Rng,
) -> (PauliVec, Complex64) {
let mut pauli = PauliVec {
x: observable.x.clone(),
z: observable.z.clone(),
};
let mut weight = Complex64::new(1.0, 0.0);
for op in ops.iter().rev() {
match op {
CoalescedOp::SmallCliff(gates) => {
for (gate, targets) in gates.iter().rev() {
weight *= clifford_conjugation_phase(gate, targets, &pauli);
propagate_backward(&mut pauli, gate, targets);
}
}
CoalescedOp::ZRot {
qubit,
pair,
branch,
} => {
weight *= branch_z_rotation(&mut pauli, *qubit, *pair, branch, rng);
}
}
}
(pauli, weight)
}
fn count_branching_gates(circuit: &Circuit) -> usize {
circuit
.instructions
.iter()
.filter(|inst| match inst {
Instruction::Gate { gate, .. } => {
z_rotation_angle(gate).is_some() || zz_rotation_angle(gate).is_some()
}
_ => false,
})
.count()
}
pub struct SppResult {
pub expectations: Vec<f64>,
pub std_errors: Vec<f64>,
pub num_samples: usize,
pub t_count: usize,
pub nonzero_fraction: f64,
}
#[derive(Clone, Copy, Default)]
pub(crate) struct Welford {
mean: f64,
m2: f64,
count: usize,
}
impl Welford {
#[inline]
pub(crate) fn push(&mut self, val: f64) {
self.count += 1;
let delta = val - self.mean;
self.mean += delta / self.count as f64;
self.m2 += delta * (val - self.mean);
}
pub(crate) fn mean(&self) -> f64 {
self.mean
}
pub(crate) fn variance(&self) -> f64 {
if self.count > 1 {
self.m2 / (self.count - 1) as f64
} else {
0.0
}
}
pub(crate) fn std_error(&self) -> f64 {
(self.variance() / self.count.max(1) as f64).sqrt()
}
}
fn estimate_qubit_expectation(
ops: &[CoalescedOp],
qubit: usize,
num_words: usize,
num_samples: usize,
seed: u64,
) -> (f64, f64, usize) {
let obs = PauliVec::z_on_qubit(num_words, qubit);
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut stats = Welford::default();
let mut nonzero = 0usize;
for _ in 0..num_samples {
let (pauli, weight) = backward_propagate_coalesced(ops, &obs, &mut rng);
let val = if pauli.is_diagonal() {
nonzero += 1;
weight.re
} else {
0.0
};
stats.push(val);
}
(stats.mean(), stats.std_error(), nonzero)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PauliAxis {
X,
Y,
Z,
}
impl PauliAxis {
pub fn letter(self) -> char {
match self {
PauliAxis::X => 'X',
PauliAxis::Y => 'Y',
PauliAxis::Z => 'Z',
}
}
pub fn from_letter(letter: char) -> Option<Self> {
match letter.to_ascii_uppercase() {
'X' => Some(PauliAxis::X),
'Y' => Some(PauliAxis::Y),
'Z' => Some(PauliAxis::Z),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PauliTerm {
pub qubit: usize,
pub axis: PauliAxis,
}
impl PauliTerm {
pub fn new(qubit: usize, axis: PauliAxis) -> Self {
Self { qubit, axis }
}
pub fn x(qubit: usize) -> Self {
Self::new(qubit, PauliAxis::X)
}
pub fn y(qubit: usize) -> Self {
Self::new(qubit, PauliAxis::Y)
}
pub fn z(qubit: usize) -> Self {
Self::new(qubit, PauliAxis::Z)
}
}
#[derive(Debug, Clone)]
pub struct SppObservableResult {
pub mean: f64,
pub std_error: f64,
pub variance: f64,
pub num_samples: usize,
pub nonzero_fraction: f64,
pub t_count: usize,
}
#[derive(Debug, Clone)]
pub struct SpdObservableResult {
pub mean: f64,
pub t_count: usize,
pub peak_terms: usize,
pub total_discarded: f64,
}
fn pauli_vec_from_terms(
num_qubits: usize,
terms: &[PauliTerm],
) -> std::result::Result<(PauliVec, Complex64), crate::error::PrismError> {
let num_words = num_qubits.div_ceil(64);
let mut pv = PauliVec::new(num_words);
let mut coeff = Complex64::new(1.0, 0.0);
let mut seen = vec![false; num_qubits];
for term in terms {
if term.qubit >= num_qubits {
return Err(crate::error::PrismError::InvalidQubit {
index: term.qubit,
register_size: num_qubits,
});
}
if seen[term.qubit] {
return Err(crate::error::PrismError::InvalidParameter {
message: format!(
"joint Pauli observable has duplicate factor on qubit {}",
term.qubit
),
});
}
seen[term.qubit] = true;
match term.axis {
PauliAxis::X => {
pv.x[term.qubit / 64] |= 1u64 << (term.qubit % 64);
}
PauliAxis::Z => {
pv.z[term.qubit / 64] |= 1u64 << (term.qubit % 64);
}
PauliAxis::Y => {
pv.x[term.qubit / 64] |= 1u64 << (term.qubit % 64);
pv.z[term.qubit / 64] |= 1u64 << (term.qubit % 64);
coeff *= Complex64::new(0.0, 1.0);
}
}
}
Ok((pv, coeff))
}
pub fn run_spp_observable(
circuit: &Circuit,
observable: &[PauliTerm],
num_samples: usize,
seed: u64,
) -> Result<SppObservableResult> {
let lowered = validate_and_lower(circuit, "SPP observable")?;
let circuit = lowered.as_ref();
let n = circuit.num_qubits;
let t_count = count_branching_gates(circuit);
let ops = coalesce_cliffords(circuit);
let (obs, obs_coeff) = pauli_vec_from_terms(n, observable)?;
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut stats = Welford::default();
let mut nonzero = 0usize;
for _ in 0..num_samples {
let (pauli, weight) = backward_propagate_coalesced(&ops, &obs, &mut rng);
let val = if pauli.is_diagonal() {
nonzero += 1;
(obs_coeff * weight).re
} else {
0.0
};
stats.push(val);
}
let nonzero_fraction = nonzero as f64 / num_samples.max(1) as f64;
Ok(SppObservableResult {
mean: stats.mean(),
std_error: stats.std_error(),
variance: stats.variance(),
num_samples,
nonzero_fraction,
t_count,
})
}
pub fn run_spp(circuit: &Circuit, num_samples: usize, seed: u64) -> Result<SppResult> {
let lowered = validate_and_lower(circuit, "SPP")?;
let circuit = lowered.as_ref();
let n = circuit.num_qubits;
let num_words = n.div_ceil(64);
let t_count = count_branching_gates(circuit);
let ops = coalesce_cliffords(circuit);
#[cfg(feature = "parallel")]
let results: Vec<(f64, f64, usize)> = {
use rayon::prelude::*;
(0..n)
.into_par_iter()
.map(|q| {
estimate_qubit_expectation(
&ops,
q,
num_words,
num_samples,
super::mix_seed(seed, q),
)
})
.collect()
};
#[cfg(not(feature = "parallel"))]
let results: Vec<(f64, f64, usize)> = (0..n)
.map(|q| {
estimate_qubit_expectation(&ops, q, num_words, num_samples, super::mix_seed(seed, q))
})
.collect();
let mut expectations = Vec::with_capacity(n);
let mut std_errors = Vec::with_capacity(n);
let mut total_nonzero = 0usize;
for (mean, std_error, nonzero) in &results {
expectations.push(*mean);
std_errors.push(*std_error);
total_nonzero += nonzero;
}
let nonzero_fraction = total_nonzero as f64 / (n * num_samples) as f64;
Ok(SppResult {
expectations,
std_errors,
num_samples,
t_count,
nonzero_fraction,
})
}
#[cfg(test)]
fn spp_to_probabilities(result: &SppResult) -> Vec<f64> {
super::expectations_to_marginals(&result.expectations)
.into_iter()
.flat_map(|(p0, p1)| [p0, p1])
.collect()
}
use std::collections::HashMap;
#[inline(always)]
fn pv_get_bit(words: &[u64], qubit: usize) -> bool {
(words[qubit / 64] >> (qubit % 64)) & 1 != 0
}
#[inline(always)]
fn clifford_conjugation_phase(gate: &Gate, targets: &[usize], pauli: &PauliVec) -> Complex64 {
match gate {
Gate::H => {
let q = targets[0];
if pv_get_bit(&pauli.x, q) && pv_get_bit(&pauli.z, q) {
Complex64::new(-1.0, 0.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::S => {
let q = targets[0];
if pv_get_bit(&pauli.x, q) {
Complex64::new(0.0, -1.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::Sdg => {
let q = targets[0];
if pv_get_bit(&pauli.x, q) {
Complex64::new(0.0, 1.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::X => {
let q = targets[0];
if pv_get_bit(&pauli.z, q) {
Complex64::new(-1.0, 0.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::Y => {
let q = targets[0];
let xq = pv_get_bit(&pauli.x, q);
let zq = pv_get_bit(&pauli.z, q);
if xq ^ zq {
Complex64::new(-1.0, 0.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::Z => {
let q = targets[0];
if pv_get_bit(&pauli.x, q) {
Complex64::new(-1.0, 0.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::SX => {
let q = targets[0];
if pv_get_bit(&pauli.z, q) {
Complex64::new(0.0, 1.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::SXdg => {
let q = targets[0];
if pv_get_bit(&pauli.z, q) {
Complex64::new(0.0, -1.0)
} else {
Complex64::new(1.0, 0.0)
}
}
Gate::Cz => {
let q0 = targets[0];
let q1 = targets[1];
if pv_get_bit(&pauli.x, q0) && pv_get_bit(&pauli.x, q1) {
Complex64::new(-1.0, 0.0)
} else {
Complex64::new(1.0, 0.0)
}
}
_ => Complex64::new(1.0, 0.0),
}
}
struct WeightedPauliSum {
terms: HashMap<PauliVec, Complex64>,
scratch: Vec<(PauliVec, Complex64)>,
}
impl WeightedPauliSum {
fn new() -> Self {
Self {
terms: HashMap::new(),
scratch: Vec::new(),
}
}
fn insert(&mut self, pauli: PauliVec, coeff: Complex64) {
let entry = self.terms.entry(pauli).or_insert(Complex64::new(0.0, 0.0));
*entry += coeff;
}
fn conjugate_all_backward_phased(&mut self, gate: &Gate, targets: &[usize]) {
self.scratch.clear();
self.scratch.extend(self.terms.drain());
for (mut pauli, coeff) in self.scratch.drain(..) {
let phase = clifford_conjugation_phase(gate, targets, &pauli);
propagate_backward(&mut pauli, gate, targets);
self.terms.insert(pauli, coeff * phase);
}
}
fn branch_z_rotation(&mut self, qubit: usize, sin: f64, cos: f64) {
let old_terms: Vec<(PauliVec, Complex64)> = self.terms.drain().collect();
for (pauli, coeff) in old_terms {
if !pauli.has_x_or_y(qubit) {
self.insert(pauli, coeff);
continue;
}
if cos != 0.0 {
self.insert(pauli.clone(), coeff * cos);
}
if sin != 0.0 {
let mut pauli_flip = pauli;
flip_bit(&mut pauli_flip.z, qubit);
self.insert(pauli_flip, Complex64::new(coeff.im * sin, -coeff.re * sin));
}
}
}
fn branch_zz_rotation(&mut self, a: usize, b: usize, sin: f64, cos: f64) {
let old_terms: Vec<(PauliVec, Complex64)> = self.terms.drain().collect();
for (pauli, coeff) in old_terms {
if pauli.has_x_or_y(a) == pauli.has_x_or_y(b) {
self.insert(pauli, coeff);
continue;
}
if cos != 0.0 {
self.insert(pauli.clone(), coeff * cos);
}
if sin != 0.0 {
let mut pauli_flip = pauli;
flip_bit(&mut pauli_flip.z, a);
flip_bit(&mut pauli_flip.z, b);
self.insert(pauli_flip, Complex64::new(coeff.im * sin, -coeff.re * sin));
}
}
}
#[inline(always)]
fn apply_backward(&mut self, gate: &Gate, targets: &[usize]) {
match gate {
Gate::T => self.branch_z_rotation_angle(targets[0], std::f64::consts::FRAC_PI_4),
Gate::Tdg => self.branch_z_rotation_angle(targets[0], -std::f64::consts::FRAC_PI_4),
Gate::Rz(theta) | Gate::P(theta) => self.branch_z_rotation_angle(targets[0], *theta),
Gate::Rzz(theta) => {
let (sin, cos) = theta.sin_cos();
self.branch_zz_rotation(targets[0], targets[1], sin, cos);
}
_ => self.conjugate_all_backward_phased(gate, targets),
}
}
#[inline(always)]
fn branch_z_rotation_angle(&mut self, qubit: usize, theta: f64) {
let (sin, cos) = theta.sin_cos();
self.branch_z_rotation(qubit, sin, cos);
}
fn truncate(&mut self, epsilon: f64) -> f64 {
let mut discarded = 0.0;
self.terms.retain(|_, coeff| {
if coeff.norm() < epsilon {
discarded += coeff.norm();
false
} else {
true
}
});
discarded
}
fn truncate_to_budget(&mut self, max_terms: usize) -> f64 {
let surplus = self.terms.len().saturating_sub(max_terms);
if surplus == 0 {
return 0.0;
}
self.scratch.clear();
self.scratch.extend(self.terms.drain());
self.scratch.select_nth_unstable_by(surplus - 1, |a, b| {
a.1.norm_sqr().total_cmp(&b.1.norm_sqr())
});
let mut discarded = 0.0;
for (_, coeff) in self.scratch.drain(..surplus) {
discarded += coeff.norm();
}
for (pauli, coeff) in self.scratch.drain(..) {
self.terms.insert(pauli, coeff);
}
discarded
}
fn diagonal_expectation(&self) -> f64 {
let mut sum = Complex64::new(0.0, 0.0);
for (pauli, coeff) in &self.terms {
if pauli.is_diagonal() {
sum += coeff;
}
}
sum.re
}
}
pub struct SpdResult {
pub expectations: Vec<f64>,
pub t_count: usize,
pub max_terms: usize,
pub total_discarded: f64,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SpdTruncation {
Threshold { epsilon: f64, max_terms: usize },
Budget { max_terms: usize },
}
impl SpdTruncation {
fn validate(&self) -> Result<()> {
if let SpdTruncation::Budget { max_terms } = *self {
if max_terms == 0 || max_terms > SPD_MAX_TERMS_CEILING {
return Err(PrismError::InvalidParameter {
message: format!(
"budgeted SPD needs a term budget between 1 and {SPD_MAX_TERMS_CEILING}"
),
});
}
}
Ok(())
}
fn enforce(&self, sum: &mut WeightedPauliSum, total_discarded: &mut f64) {
match *self {
SpdTruncation::Threshold { epsilon, max_terms } => {
if max_terms > 0 && sum.terms.len() > max_terms {
*total_discarded += sum.truncate(epsilon);
}
}
SpdTruncation::Budget { max_terms } => {
if sum.terms.len() > max_terms {
*total_discarded += sum.truncate_to_budget(max_terms);
}
}
}
}
fn final_sweep(&self, sum: &mut WeightedPauliSum, total_discarded: &mut f64) {
if let SpdTruncation::Threshold { epsilon, .. } = *self {
if epsilon > 0.0 {
*total_discarded += sum.truncate(epsilon);
}
}
}
}
pub fn run_spd_observable(
circuit: &Circuit,
observable: &[PauliTerm],
epsilon: f64,
max_terms: usize,
) -> Result<SpdObservableResult> {
run_spd_observable_with(
circuit,
observable,
&SpdTruncation::Threshold { epsilon, max_terms },
)
}
pub fn run_spd_observable_budgeted(
circuit: &Circuit,
observable: &[PauliTerm],
max_terms: usize,
) -> Result<SpdObservableResult> {
run_spd_observable_with(circuit, observable, &SpdTruncation::Budget { max_terms })
}
pub(crate) fn run_spd_observable_with(
circuit: &Circuit,
observable: &[PauliTerm],
truncation: &SpdTruncation,
) -> Result<SpdObservableResult> {
truncation.validate()?;
let lowered = validate_and_lower(circuit, "SPD observable")?;
let circuit = lowered.as_ref();
let n = circuit.num_qubits;
let t_count = count_branching_gates(circuit);
let (obs, obs_coeff) = pauli_vec_from_terms(n, observable)?;
let mut sum = WeightedPauliSum::new();
sum.insert(obs, obs_coeff);
let mut peak_terms = sum.terms.len();
let mut total_discarded = 0.0;
for inst in circuit.instructions.iter().rev() {
if let Instruction::Gate { gate, targets } = inst {
sum.apply_backward(gate, targets);
}
truncation.enforce(&mut sum, &mut total_discarded);
check_spd_term_ceiling(sum.terms.len(), "SPD")?;
if sum.terms.len() > peak_terms {
peak_terms = sum.terms.len();
}
}
truncation.final_sweep(&mut sum, &mut total_discarded);
Ok(SpdObservableResult {
mean: sum.diagonal_expectation(),
t_count,
peak_terms,
total_discarded,
})
}
pub fn run_spd(circuit: &Circuit, epsilon: f64, max_terms: usize) -> Result<SpdResult> {
run_spd_with(circuit, &SpdTruncation::Threshold { epsilon, max_terms })
}
pub fn run_spd_with(circuit: &Circuit, truncation: &SpdTruncation) -> Result<SpdResult> {
truncation.validate()?;
Ok(run_spd_bounded(circuit, truncation, None)?.expect("an unbounded run always completes"))
}
pub(crate) fn run_spd_exact_within(
circuit: &Circuit,
work_budget: usize,
) -> Result<Option<SpdResult>> {
run_spd_bounded(
circuit,
&SpdTruncation::Threshold {
epsilon: 0.0,
max_terms: 0,
},
Some(work_budget),
)
}
fn run_spd_bounded(
circuit: &Circuit,
truncation: &SpdTruncation,
work_budget: Option<usize>,
) -> Result<Option<SpdResult>> {
let lowered = validate_and_lower(circuit, "SPD")?;
let circuit = lowered.as_ref();
let n = circuit.num_qubits;
let num_words = n.div_ceil(64);
let t_count = count_branching_gates(circuit);
let mut expectations = Vec::with_capacity(n);
let mut peak_terms = 0usize;
let mut total_discarded = 0.0;
let mut work = 0usize;
enum Rot {
Single { sin: f64, cos: f64 },
Pair { sin: f64, cos: f64 },
}
let rotations: Vec<Option<Rot>> = circuit
.instructions
.iter()
.map(|inst| match inst {
Instruction::Gate { gate, .. } => {
if let Some((sin, cos)) = z_rotation_angle(gate).map(f64::sin_cos) {
Some(Rot::Single { sin, cos })
} else {
zz_rotation_angle(gate)
.map(f64::sin_cos)
.map(|(sin, cos)| Rot::Pair { sin, cos })
}
}
_ => None,
})
.collect();
for q in 0..n {
let mut sum = WeightedPauliSum::new();
sum.insert(PauliVec::z_on_qubit(num_words, q), Complex64::new(1.0, 0.0));
for (idx, inst) in circuit.instructions.iter().enumerate().rev() {
if let Instruction::Gate { gate, targets } = inst {
match rotations[idx] {
Some(Rot::Single { sin, cos }) => sum.branch_z_rotation(targets[0], sin, cos),
Some(Rot::Pair { sin, cos }) => {
sum.branch_zz_rotation(targets[0], targets[1], sin, cos)
}
None => sum.conjugate_all_backward_phased(gate, targets),
}
}
truncation.enforce(&mut sum, &mut total_discarded);
if let Some(budget) = work_budget {
work += sum.terms.len();
if work > budget || sum.terms.len() > SPD_MAX_TERMS_CEILING {
return Ok(None);
}
} else {
check_spd_term_ceiling(sum.terms.len(), "SPD")?;
}
if sum.terms.len() > peak_terms {
peak_terms = sum.terms.len();
}
}
truncation.final_sweep(&mut sum, &mut total_discarded);
expectations.push(sum.diagonal_expectation());
}
Ok(Some(SpdResult {
expectations,
t_count,
max_terms: peak_terms,
total_discarded,
}))
}
pub fn inverse_light_cone(circuit: &Circuit, observable: &[PauliTerm]) -> Vec<bool> {
let mut cone: std::collections::HashSet<usize> = observable.iter().map(|t| t.qubit).collect();
let n_inst = circuit.instructions.len();
let mut keep = vec![false; n_inst];
for (idx, inst) in circuit.instructions.iter().enumerate().rev() {
let targets: &[usize] = match inst {
Instruction::Gate { targets, .. } => targets,
_ => continue,
};
let touches = targets.iter().any(|q| cone.contains(q));
if touches {
keep[idx] = true;
for &q in targets {
cone.insert(q);
}
}
}
keep
}
pub fn run_spd_observable_light_cone(
circuit: &Circuit,
observable: &[PauliTerm],
epsilon: f64,
max_terms: usize,
) -> Result<SpdObservableResult> {
let lowered = validate_and_lower(circuit, "light-cone SPD observable")?;
let circuit = lowered.as_ref();
let n = circuit.num_qubits;
let t_count = count_branching_gates(circuit);
let (obs, obs_coeff) = pauli_vec_from_terms(n, observable)?;
let keep = inverse_light_cone(circuit, observable);
let mut sum = WeightedPauliSum::new();
sum.insert(obs, obs_coeff);
let mut peak_terms = sum.terms.len();
let mut total_discarded = 0.0;
for (idx, inst) in circuit.instructions.iter().enumerate().rev() {
if !keep[idx] {
continue;
}
if let Instruction::Gate { gate, targets } = inst {
sum.apply_backward(gate, targets);
}
if max_terms > 0 && sum.terms.len() > max_terms {
total_discarded += sum.truncate(epsilon);
}
check_spd_term_ceiling(sum.terms.len(), "SPD")?;
if sum.terms.len() > peak_terms {
peak_terms = sum.terms.len();
}
}
if epsilon > 0.0 {
total_discarded += sum.truncate(epsilon);
}
Ok(SpdObservableResult {
mean: sum.diagonal_expectation(),
t_count,
peak_terms,
total_discarded,
})
}
#[derive(Clone, Copy)]
enum ChannelAction {
Local {
lambda: [f64; 3],
z_to_identity: f64,
},
Depolarizing2q { lambda: f64 },
}
fn channel_action(channel: &NoiseChannel) -> Option<ChannelAction> {
let damping = |gamma: f64| {
let keep = (1.0 - gamma).max(0.0);
ChannelAction::Local {
lambda: [keep.sqrt(), keep.sqrt(), keep],
z_to_identity: gamma,
}
};
match channel {
NoiseChannel::Pauli { px, py, pz } => {
let pi = 1.0 - px - py - pz;
Some(ChannelAction::Local {
lambda: [pi + px - py - pz, pi + py - px - pz, pi + pz - px - py],
z_to_identity: 0.0,
})
}
NoiseChannel::Depolarizing { p } => {
let lambda = 1.0 - 4.0 * p / 3.0;
Some(ChannelAction::Local {
lambda: [lambda; 3],
z_to_identity: 0.0,
})
}
NoiseChannel::PhaseDamping { gamma } => {
let keep = (1.0 - gamma).max(0.0).sqrt();
Some(ChannelAction::Local {
lambda: [keep, keep, 1.0],
z_to_identity: 0.0,
})
}
NoiseChannel::AmplitudeDamping { gamma } => Some(damping(*gamma)),
NoiseChannel::ThermalRelaxation {
t1,
t2,
gate_time,
excited_population,
} => {
let (gad, gpd) = thermal_relaxation_rates(*t1, *t2, *gate_time);
let dephase = (1.0 - gpd).max(0.0).sqrt();
let ChannelAction::Local { lambda, .. } = damping(gad) else {
unreachable!("amplitude damping is a local action")
};
Some(ChannelAction::Local {
lambda: [lambda[0] * dephase, lambda[1] * dephase, lambda[2]],
z_to_identity: gad * (1.0 - 2.0 * *excited_population),
})
}
NoiseChannel::TwoQubitDepolarizing { p } => Some(ChannelAction::Depolarizing2q {
lambda: 1.0 - 16.0 * p / 15.0,
}),
NoiseChannel::Custom { .. } | NoiseChannel::Kraus2q { .. } => None,
}
}
#[inline]
fn pauli_letter(pauli: &PauliVec, qubit: usize) -> Option<usize> {
match (get_bit(&pauli.x, qubit), get_bit(&pauli.z, qubit)) {
(false, false) => None,
(true, false) => Some(0),
(true, true) => Some(1),
(false, true) => Some(2),
}
}
impl WeightedPauliSum {
fn apply_backward_channel(&mut self, action: &ChannelAction, qubits: &[usize]) {
match *action {
ChannelAction::Local {
lambda,
z_to_identity,
} => {
let q = qubits[0];
if z_to_identity == 0.0 {
for (pauli, coeff) in self.terms.iter_mut() {
if let Some(letter) = pauli_letter(pauli, q) {
*coeff *= lambda[letter];
}
}
return;
}
let mut scratch = std::mem::take(&mut self.scratch);
scratch.clear();
scratch.extend(self.terms.drain());
for (pauli, coeff) in scratch.drain(..) {
match pauli_letter(&pauli, q) {
None => self.insert(pauli, coeff),
Some(2) => {
let mut identity = pauli.clone();
flip_bit(&mut identity.z, q);
self.insert(pauli, coeff * lambda[2]);
self.insert(identity, coeff * z_to_identity);
}
Some(letter) => self.insert(pauli, coeff * lambda[letter]),
}
}
self.scratch = scratch;
}
ChannelAction::Depolarizing2q { lambda } => {
let (a, b) = (qubits[0], qubits[1]);
for (pauli, coeff) in self.terms.iter_mut() {
if pauli_letter(pauli, a).is_some() || pauli_letter(pauli, b).is_some() {
*coeff *= lambda;
}
}
}
}
}
}
enum PathOp {
Gate(Gate, SmallVec<[usize; 4]>),
Channel(ChannelAction, SmallVec<[usize; 2]>),
}
fn pauli_path_ops(circuit: &Circuit, noise: &NoiseModel, backend: &str) -> Result<Vec<PathOp>> {
if noise.readout.iter().any(|r| r.is_some()) {
return Err(PrismError::IncompatibleBackend {
backend: backend.to_string(),
reason: "readout error has no Pauli-basis form on an expectation value; use \
BackendKind::DensityMatrix"
.to_string(),
});
}
let mut ops = Vec::with_capacity(circuit.instructions.len() * 2);
for (index, inst) in circuit.instructions.iter().enumerate() {
match inst {
Instruction::Gate { gate, targets } => {
lower_to_pauli_forms(gate, targets, &mut |g, tgts| {
ops.push(PathOp::Gate(g, SmallVec::from_slice(tgts)));
})
.map_err(|operation| PrismError::BackendUnsupported {
backend: backend.to_string(),
operation,
})?;
}
Instruction::Barrier { .. } => {}
Instruction::Save { .. }
| Instruction::Measure { .. }
| Instruction::Reset { .. }
| Instruction::Conditional { .. }
| Instruction::Region(_) => {
return Err(PrismError::IncompatibleBackend {
backend: backend.to_string(),
reason: "Pauli propagation requires a unitary circuit without measurements, \
resets, or conditionals"
.to_string(),
});
}
}
for event in noise.after_gate.get(index).into_iter().flatten() {
let Some(action) = channel_action(&event.channel) else {
return Err(PrismError::BackendUnsupported {
backend: backend.to_string(),
operation: format!(
"channel `{:?}` has no Pauli-basis form; use BackendKind::DensityMatrix",
event.channel
),
});
};
ops.push(PathOp::Channel(action, event.qubits.clone()));
}
}
Ok(ops)
}
#[derive(Debug, Clone)]
pub struct PauliPathResult {
pub mean: f64,
pub peak_terms: usize,
pub total_discarded: f64,
}
pub fn run_pauli_path_observable(
circuit: &Circuit,
noise: &NoiseModel,
observable: &[PauliTerm],
epsilon: f64,
max_terms: usize,
) -> Result<PauliPathResult> {
let ops = pauli_path_ops(circuit, noise, "PauliPath")?;
let (obs, obs_coeff) = pauli_vec_from_terms(circuit.num_qubits, observable)?;
let truncation = SpdTruncation::Threshold { epsilon, max_terms };
let mut sum = WeightedPauliSum::new();
sum.insert(obs, obs_coeff);
let mut peak_terms = sum.terms.len();
let mut total_discarded = 0.0;
for op in ops.iter().rev() {
match op {
PathOp::Gate(gate, targets) => sum.apply_backward(gate, targets),
PathOp::Channel(action, qubits) => sum.apply_backward_channel(action, qubits),
}
truncation.enforce(&mut sum, &mut total_discarded);
check_spd_term_ceiling(sum.terms.len(), "PauliPath")?;
peak_terms = peak_terms.max(sum.terms.len());
}
truncation.final_sweep(&mut sum, &mut total_discarded);
Ok(PauliPathResult {
mean: sum.diagonal_expectation(),
peak_terms,
total_discarded,
})
}
#[cfg(test)]
fn spd_to_probabilities(result: &SpdResult) -> Vec<f64> {
super::expectations_to_marginals(&result.expectations)
.into_iter()
.flat_map(|(p0, p1)| [p0, p1])
.collect()
}
#[cfg(test)]
#[path = "unified_pauli_tests.rs"]
mod tests;