Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions crates/ppvm-pauli-sum/src/sum/noise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,23 @@ impl<T: Config> LossChannel<T> for PauliSum<T> {
impl<T: Config> CorrelatedLossChannel<T> for PauliSum<T> {
/// Apply a correlated loss channel to qubits at `addr0` and `addr1`.
///
/// The three probabilities are:
/// The parameters are exactly those of
/// [`CorrelatedLossChannel`](ppvm_traits::traits::CorrelatedLossChannel),
/// which is the normative statement; this impl only restates them:
/// * `p[0]`: The probability of losing both qubits simultaneously when
/// both of them are in the qubit subspace.
/// * `p[1]`: The probability of losing either one qubit when both of them are
/// in the qubit subspace.
/// * `p[1]`: The probability of losing a **named** one of the two when both
/// of them are in the qubit subspace — so the probability of losing
/// *exactly one* is `2·p[1]`, split evenly between the two qubits, and the
/// survivor is scaled by `1 − 2·p[1] − p[0]`.
/// * `p[2]`: The probability of losing one qubit when the other one has already
/// been lost prior to the channel.
///
/// Admissible region: `p[0], p[1] >= 0`, `p[0] + 2·p[1] <= 1`,
/// `p[2] ∈ [0, 1]`. Outside it the map is not completely positive and this
/// impl will produce negative coefficients rather than raising — unlike the
/// tableau backends, which `debug_assert`. Guarding a generic
/// `Coefficient` (which carries no ordering) is tracked separately.
fn correlated_loss_channel(&mut self, addr0: usize, addr1: usize, p: [T::Coeff; 3]) {
self.map_insert_multiple(|k, v| {
match (k.get(addr0), k.get(addr1)) {
Expand Down
171 changes: 166 additions & 5 deletions crates/ppvm-tableau-sum/src/noise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use num::{
};
use ppvm_pauli_word::pattern::NotIdentity;
use ppvm_tableau::{
data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex,
data::GeneralizedTableau, noise::is_admissible_correlated_loss, sparsevec::SparseVector,
tableau_index::TableauIndex,
};
use ppvm_traits::config::Config;
use ppvm_traits::traits::{
Expand Down Expand Up @@ -450,6 +451,10 @@ where
addr1: usize,
p: [<T as Config>::Coeff; 3],
) {
debug_assert!(
is_admissible_correlated_loss(&p),
"correlated loss needs p0, p1 >= 0, p0 + 2*p1 <= 1, p2 in [0, 1]; got {p:?}"
);
let mut branches = Vec::<(GeneralizedTableau<T, I, C>, T::Coeff, u64, u64)>::with_capacity(
3 * self.entries.len(),
);
Expand Down Expand Up @@ -481,7 +486,11 @@ where
}

// if both are present, then we create 3 new branches:
// losing both (p[0]), one, or the other qubit (p[1])
// losing both (p[0]), one, or the other qubit. `p[1]` is the
// probability that a *named* one of the pair is lost, so each
// single-loss branch carries `p[1]` and the survivor keeps
// `1 − p[0] − 2·p[1]`. See
// `ppvm_traits::traits::CorrelatedLossChannel`.

let tab_seed_both = self.rng.random::<u64>();
let mut tab_lose_both = tab.fork(Some(tab_seed_both));
Expand All @@ -503,7 +512,7 @@ where
tab_lose_0.is_lost[addr0] = true;
branches.push((
tab_lose_0,
p_sum.clone() * (p[1].clone() / 2.0.into()),
p_sum.clone() * p[1].clone(),
word_fp,
phase_loss ^ loss_mask(addr0),
));
Expand All @@ -513,12 +522,12 @@ where
tab_lose_1.is_lost[addr1] = true;
branches.push((
tab_lose_1,
p_sum.clone() * (p[1].clone() / 2.0.into()),
p_sum.clone() * p[1].clone(),
word_fp,
phase_loss ^ loss_mask(addr1),
));

let p_total = p[0].clone() + p[1].clone();
let p_total = p[0].clone() + p[1].clone() + p[1].clone();
*p_sum *= T::Coeff::one() - p_total;
});

Expand Down Expand Up @@ -573,3 +582,155 @@ where
.insert_or_merge_batch(branches, &self.sum_cutoff);
}
}

#[cfg(test)]
mod tests {
// === G-040 — the correlated-loss `p[1]` convention ===
//
// The paper (`ppvm-paper/main.tex:462`, `:523`, `:845`) is the definition of
// record: `p[1]` is `p_LQ`, the probability that a **named** one of the pair
// is lost, so each single-loss branch carries `p[1]`, the total weight on
// "exactly one lost" is `2·p[1]`, and the survivor keeps `1 − p[0] − 2·p[1]`.
// The same number is observable three ways — as a Heisenberg coefficient
// (`ppvm-pauli-sum`), as a branch weight (this mixture) and as a sampling
// frequency (`ppvm-tableau`'s trajectory) — and the three must agree, since
// the cross-backend disagreement is what a Python user actually hits.

use ppvm_pauli_sum::config::fxhash::ByteF64;
use ppvm_pauli_sum::prelude::*;
use ppvm_tableau::prelude::*;
use ppvm_traits::traits::{CorrelatedLossChannel, NoStrategy};

use crate::data::GeneralizedTableauSum;
use crate::storage::EntryStore;

type Cfg = ByteF64<1>;
type TabSum = GeneralizedTableauSum<Cfg, u128>;
type Tab = GeneralizedTableau<Cfg, u128>;
type LossyTestSum = PauliSum<
ppvm_pauli_sum::config::fxhash::Byte<
1,
f64,
NoStrategy,
LossyPauliWord<[u8; 1], fxhash::FxBuildHasher>,
>,
>;

/// Total mixture weight on branches with exactly one lost qubit.
/// `sum_cutoff = 0.0`, so no branch is truncated away and no
/// renormalization can hide a mis-weighted survivor.
fn mixture_single_loss_weight(p: [f64; 3]) -> f64 {
let mut sum: TabSum = GeneralizedTableauSum::new_with_seed(2, 1e-12, 0.0, 7);
sum.correlated_loss_channel(0, 1, p);
sum.entries
.iter()
.filter(|(tab, _)| tab.is_lost[0] ^ tab.is_lost[1])
.map(|(_, probability)| *probability)
.sum()
}

/// Total mixture weight on branches where both qubits are still present.
fn mixture_survivor_weight(p: [f64; 3]) -> f64 {
let mut sum: TabSum = GeneralizedTableauSum::new_with_seed(2, 1e-12, 0.0, 7);
sum.correlated_loss_channel(0, 1, p);
sum.entries
.iter()
.filter(|(tab, _)| !tab.is_lost[0] && !tab.is_lost[1])
.map(|(_, probability)| *probability)
.sum()
}

/// The Heisenberg scale factor `ppvm-pauli-sum` applies to a fully
/// in-subspace observable, i.e. `1 − p[0] − P(exactly one lost)`.
fn pauli_sum_survivor(p: [f64; 3]) -> f64 {
let mut sum = LossyTestSum::builder().n_qubits(2).build();
sum += ("ZZ", 1.0);
sum.correlated_loss_channel(0, 1, p);
let zz: LossyPauliWord<[u8; 1], fxhash::FxBuildHasher> = "ZZ".into();
*sum.data()
.get(&zz)
.expect("the all-present term survives a pure rescale")
}

/// The trajectory's sampled fraction of runs that lose exactly one qubit.
fn trajectory_single_loss_fraction(p: [f64; 3], trials: u64) -> f64 {
let mut hits = 0u64;
for seed in 0..trials {
let mut tab: Tab = GeneralizedTableau::new_with_seed(2, 1e-12, seed);
tab.correlated_loss_channel(0, 1, p);
if tab.is_lost[0] ^ tab.is_lost[1] {
hits += 1;
}
}
hits as f64 / trials as f64
}

#[test]
fn correlated_loss_exactly_one_lost_is_two_p1_on_every_backend() {
let p1 = 0.3_f64;
let p = [0.0, p1, 0.0];
let expected = 2.0 * p1;

let mixture = mixture_single_loss_weight(p);
let pauli_sum = 1.0 - pauli_sum_survivor(p);
let trajectory = trajectory_single_loss_fraction(p, 20_000);

// Report every dissenting backend, so a failure names the split rather
// than only its first symptom.
let mut wrong = Vec::new();
for (backend, value, tolerance) in [
("mixture", mixture, 1e-12),
("ppvm-pauli-sum", pauli_sum, 1e-12),
("trajectory (20k seeds)", trajectory, 0.02),
] {
if (value - expected).abs() >= tolerance {
wrong.push(format!(" {backend}: {value}"));
}
}
assert!(
wrong.is_empty(),
"p = {p:?}: P(exactly one lost) must be 2*p[1] = {expected} on every \
backend, got\n{}",
wrong.join("\n")
);
}

#[test]
fn correlated_loss_survivor_weight_is_one_minus_p0_minus_two_p1() {
let p = [0.2_f64, 0.3, 0.4];
let expected = 1.0 - p[0] - 2.0 * p[1];
let mixture = mixture_survivor_weight(p);
assert!(
(mixture - expected).abs() < 1e-12,
"mixture survivor weight {mixture}, want 1 - p0 - 2*p1 = {expected}"
);
let pauli_sum = pauli_sum_survivor(p);
assert!(
(pauli_sum - expected).abs() < 1e-12,
"ppvm-pauli-sum survivor {pauli_sum}, want {expected}"
);
}

// G-043 — the admissible region `p0, p1 >= 0`, `p0 + 2·p1 <= 1`,
// `p2 ∈ [0, 1]` is what makes the channel completely positive. Outside it
// the mixture truncates a negative survivor weight and renormalizes,
// silently.

#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "p0 + 2*p1 <= 1")]
fn correlated_loss_rejects_inadmissible_probabilities() {
let mut sum: TabSum = GeneralizedTableauSum::new_with_seed(2, 1e-12, 0.0, 7);
sum.correlated_loss_channel(0, 1, [0.6, 0.6, 0.0]);
}

/// The saturated boundary `p0 + 2·p1 == 1` is admissible and must not trip
/// the guard.
#[test]
fn correlated_loss_saturated_boundary_is_admissible() {
let mut sum: TabSum = GeneralizedTableauSum::new_with_seed(2, 1e-12, 0.0, 7);
sum.correlated_loss_channel(0, 1, [0.2, 0.4, 1.0]);
let survivor = mixture_survivor_weight([0.2, 0.4, 1.0]);
assert!(survivor.abs() < 1e-15, "survivor {survivor} should vanish");
}
}
32 changes: 18 additions & 14 deletions crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1190,10 +1190,14 @@ fn bell_pair_with_two_qubit_pauli_error_nonuniform() {
// Correlated two-qubit loss channel
// ---------------------------------------------------------------------------
//
// Probability layout (matches the trait spec and GeneralizedTableau):
// Probability layout (normative statement: `ppvm_traits::traits::noise::CorrelatedLossChannel`):
// p[0] = P(lose both | both present)
// p[1] = P(lose either one | both present) → split 50/50 across q0/q1
// p[1] = P(lose a *named* one | both present) → P(exactly one) = 2·p[1],
// split 50/50 across q0/q1
// p[2] = P(lose remaining | the other was lost prior)
// Admissible region: p[0], p[1] >= 0, p[0] + 2·p[1] <= 1, p[2] in [0, 1]. Every
// triple below stays inside it — a triple that violates it names a channel that
// is not completely positive, and the backends now `debug_assert` against it.

#[test]
fn correlated_loss_channel_zero_prob_is_noop() {
Expand Down Expand Up @@ -1229,10 +1233,10 @@ fn correlated_loss_channel_single_loss_certain_is_5050_between_qubits() {
// Never both, never neither.
let shots = 8000;
let sum = run_sum(2, shots, 1e-12, |t| {
t.correlated_loss_channel(0, 1, [0.0, 1.0, 0.0]);
t.correlated_loss_channel(0, 1, [0.0, 0.5, 0.0]);
});
let pure = run_pure(2, shots, |t| {
t.correlated_loss_channel(0, 1, [0.0, 1.0, 0.0]);
t.correlated_loss_channel(0, 1, [0.0, 0.5, 0.0]);
});
// Sanity: no shot has both lost or neither lost.
assert!(
Expand Down Expand Up @@ -1268,16 +1272,16 @@ fn correlated_loss_channel_marginals_on_ground_state() {
// probabilities and TVD against pure.
// p[0] = 0.20, p[1] = 0.40, p[2] is unused (no qubit pre-lost).
// Expected outcome probabilities:
// P(both lost) = 0.20
// P(only q0 lost) = 0.20 (p[1]/2)
// P(only q1 lost) = 0.20
// P(none lost) = 0.40
// P(both lost) = 0.20 (p[0])
// P(only q0 lost) = 0.20 (p[1])
// P(only q1 lost) = 0.20 (p[1])
// P(none lost) = 0.40 (1 − p[0] − 2·p[1])
let shots = 8000;
let sum = run_sum(2, shots, 1e-12, |t| {
t.correlated_loss_channel(0, 1, [0.20, 0.40, 0.0]);
t.correlated_loss_channel(0, 1, [0.20, 0.20, 0.0]);
});
let pure = run_pure(2, shots, |t| {
t.correlated_loss_channel(0, 1, [0.20, 0.40, 0.0]);
t.correlated_loss_channel(0, 1, [0.20, 0.20, 0.0]);
});
for (label, data) in [("sum", &sum), ("pure", &pure)] {
let both = data
Expand Down Expand Up @@ -1326,11 +1330,11 @@ fn correlated_loss_channel_preexisting_loss_falls_back_to_p2() {
let shots = 8000;
let sum = run_sum(2, shots, 1e-12, |t| {
t.loss_channel(0, 1.0);
t.correlated_loss_channel(0, 1, [0.5, 0.5, 0.3]);
t.correlated_loss_channel(0, 1, [0.5, 0.25, 0.3]);
});
let pure = run_pure(2, shots, |t| {
t.loss_channel(0, 1.0);
t.correlated_loss_channel(0, 1, [0.5, 0.5, 0.3]);
t.correlated_loss_channel(0, 1, [0.5, 0.25, 0.3]);
});
// q0 must be lost every shot. q1 must be lost with frequency p[2].
assert!(sum.iter().all(|s| s[0].is_none()));
Expand Down Expand Up @@ -1360,12 +1364,12 @@ fn correlated_loss_channel_both_preexisting_loss_is_noop() {
let sum = run_sum(2, shots, 1e-12, |t| {
t.loss_channel(0, 1.0);
t.loss_channel(1, 1.0);
t.correlated_loss_channel(0, 1, [0.5, 0.5, 0.5]);
t.correlated_loss_channel(0, 1, [0.5, 0.25, 0.5]);
});
let pure = run_pure(2, shots, |t| {
t.loss_channel(0, 1.0);
t.loss_channel(1, 1.0);
t.correlated_loss_channel(0, 1, [0.5, 0.5, 0.5]);
t.correlated_loss_channel(0, 1, [0.5, 0.25, 0.5]);
});
assert!(sum.iter().all(|s| s == &vec![None, None]));
assert!(pure.iter().all(|s| s == &vec![None, None]));
Expand Down
2 changes: 1 addition & 1 deletion crates/ppvm-tableau/benches/micro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ fn bench_noise(c: &mut Criterion) {
group.bench_function("correlated_loss_channel", |b| {
b.iter_batched_ref(
|| tab.fork(None),
|t| t.correlated_loss_channel(0, 1, [0.5, 0.3, 0.2]),
|t| t.correlated_loss_channel(0, 1, [0.4, 0.3, 0.2]),
criterion::BatchSize::SmallInput,
);
});
Expand Down
Loading
Loading