Skip to main content

wai_quantum/
quantum_mitigate.rs

1//! Deterministic quantum ERROR MITIGATION — `wai.quantum.mitigate`
2//! (extensions/quantum-ops § Mitigation).
3//!
4//! Every quantum error-mitigation technique splits into two halves: **(A)** a
5//! hardware data-acquisition front-end you do not own (running a circuit at
6//! amplified noise, calibrating a readout matrix, taking randomized snapshots),
7//! and **(B)** a deterministic numerical back-end — the extrapolation, the matrix
8//! inversion, the shadow estimator — that turns the raw counts into the reported
9//! number. Run in float, half (B) is reproducible only on the same build, and
10//! nothing records the step between the QPU and the reported number.
11//!
12//! This module is half (B), done the WAI way: pure fixed-point integer numerics,
13//! byte-identical on every machine, each producing a signed, content-addressed,
14//! joule-metered [`crate::quantum_ops::MitigationReceipt`] binding
15//! `(raw data, noise-model hash, method, seed) → (mitigated estimate ± error)`.
16//!
17//! Three methods, chosen because they need **no learned noise model** and so are
18//! byte-exact reproducible end-to-end today:
19//! - [`zne_extrapolate`] — Zero-Noise Extrapolation (linear least-squares and
20//!   exact Richardson), the cleanest: pure curve-fitting over measured data.
21//! - [`readout_mitigate`] — tensored readout-error inversion (the M3 spirit):
22//!   deterministic per-qubit 2×2 assignment-matrix inversion.
23//! - [`estimate_pauli`] — classical shadows (random single-qubit Pauli basis):
24//!   the inverse measurement channel, median-of-means.
25//!
26//! Honest boundary, identical to the calibration engines: the **front-end is a
27//! deterministic simulator** (we have no QPU), exactly the surface these methods
28//! are developed against. What is not approximate is the reproducibility of the
29//! post-processing and the receipt over it — the property the float stacks lack.
30//!
31//! For the heavier estimators (PEC/PEA, tensor-network mitigation) the receipt's
32//! `noise_model_hash` is the hook: those estimators consume an externally-learned
33//! noise model, and the receipt pins *which* model produced the number without
34//! WAI claiming to have learned it.
35
36use crate::quantum_ops::{content_hash, GrantRef, MitigationReceipt};
37use ed25519_dalek::SigningKey;
38
39/// Fixed-point fractional bits — the WAI quantum unit, scale `2^20`. Expectation
40/// values are signed i64 in `[-MIT_ONE, MIT_ONE]`.
41pub const MIT_FRAC: u32 = 20;
42/// `1.0` in fixed-point.
43pub const MIT_ONE: i64 = 1 << MIT_FRAC;
44
45#[inline]
46fn fmul(a: i64, b: i64) -> i64 {
47    ((a as i128 * b as i128) >> MIT_FRAC) as i64
48}
49
50#[inline]
51fn fdiv(p: i64, q: i64) -> i64 {
52    if q == 0 {
53        return 0;
54    }
55    (((p as i128) << MIT_FRAC) / q as i128) as i64
56}
57
58/// Integer square root of a non-negative i128 (floor). Deterministic.
59fn isqrt_i128(v: i128) -> i128 {
60    if v <= 0 {
61        return 0;
62    }
63    let mut x = v;
64    let mut y = (x + 1) / 2;
65    while y < x {
66        x = y;
67        y = (x + v / x) / 2;
68    }
69    x
70}
71
72/// `sqrt` of a fixed-point *square*: given `s = real² · 2^(2·MIT_FRAC)`, returns
73/// `real · 2^MIT_FRAC`. (isqrt of a 2·FRAC-scaled square yields a FRAC-scaled
74/// value directly.)
75fn sqrt_fx_of_square(sq: i128) -> i64 {
76    isqrt_i128(sq) as i64
77}
78
79fn splitmix64(state: &mut u64) -> u64 {
80    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
81    let mut z = *state;
82    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
83    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
84    z ^ (z >> 31)
85}
86
87/// Draw one ±1 outcome with `P(+1) = (1 + e_fx)/2` for a signed expectation
88/// `e_fx ∈ [-MIT_ONE, MIT_ONE]`. Deterministic given the stream.
89fn draw_pm1(st: &mut u64, e_fx: i64) -> i64 {
90    let p_plus = (MIT_ONE + e_fx) / 2; // [0, MIT_ONE]
91    let r = (splitmix64(st) >> (64 - MIT_FRAC)) as i64; // [0, MIT_ONE)
92    if r < p_plus {
93        1
94    } else {
95        -1
96    }
97}
98
99// ===========================================================================
100// 1. Zero-Noise Extrapolation (wai.quantum.mitigate / zne.*)
101// ===========================================================================
102
103/// Which extrapolator to fit to the noise-scaled expectation table.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum ZneMethod {
106    /// Least-squares straight line `E(λ)=a+bλ`, extrapolated to `λ=0`. Robust to
107    /// shot noise, biased when the true decay is curved.
108    Linear,
109    /// Exact polynomial through all points (Lagrange at `x=0`) — classic
110    /// Richardson extrapolation. Unbiased for a polynomial decay, noisier.
111    Richardson,
112}
113
114impl ZneMethod {
115    fn tag(self) -> &'static str {
116        match self {
117            ZneMethod::Linear => "zne.linear",
118            ZneMethod::Richardson => "zne.richardson",
119        }
120    }
121}
122
123/// Output of a ZNE post-processing step. Deterministic given the input table.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ZneResult {
126    pub method: &'static str,
127    /// The measured `(noise_scale_fx, expectation_fx)` table that went in.
128    pub points: Vec<(i64, i64)>,
129    /// Expectation at the smallest scale (base hardware noise).
130    pub raw_fx: i64,
131    /// Extrapolation to zero noise — the mitigated estimate.
132    pub mitigated_fx: i64,
133    /// Uncertainty: fit RMS residual (Linear) or |Richardson − linear| model
134    /// disagreement (Richardson).
135    pub error_fx: i64,
136    pub shots: u64,
137}
138
139/// Least-squares line through `points`, extrapolated to `λ=0`. Returns
140/// `(intercept_fx, slope_fx)`.
141fn fit_linear(points: &[(i64, i64)]) -> (i64, i64) {
142    let n = points.len() as i128;
143    if n == 0 {
144        return (0, 0);
145    }
146    let (mut sx, mut sy, mut sxy, mut sxx) = (0i128, 0i128, 0i128, 0i128);
147    for &(x, y) in points {
148        sx += x as i128;
149        sy += y as i128;
150        sxy += x as i128 * y as i128; // scale 2^40
151        sxx += x as i128 * x as i128; // scale 2^40
152    }
153    let denom = n * sxx - sx * sx; // scale 2^40
154    if denom == 0 {
155        return ((sy / n) as i64, 0); // degenerate: flat mean
156    }
157    let numer = n * sxy - sx * sy; // scale 2^40
158    // slope_fx = (numer/denom) · 2^20
159    let slope = ((numer << MIT_FRAC) / denom) as i64;
160    let mean_x = (sx / n) as i64;
161    let mean_y = (sy / n) as i64;
162    let intercept = mean_y - fmul(slope, mean_x);
163    (intercept, slope)
164}
165
166/// Exact Richardson extrapolation (Lagrange interpolation evaluated at `x=0`).
167/// Requires distinct scales; capped at 8 points to keep the products bounded.
168fn richardson_zero(points: &[(i64, i64)]) -> i64 {
169    let m = points.len();
170    let mut acc = 0i64;
171    for i in 0..m {
172        let xi = points[i].0;
173        // weight_i = Π_{j≠i} x_j / (x_j − x_i)
174        let mut w = MIT_ONE;
175        for (j, &(xj, _)) in points.iter().enumerate() {
176            if j == i {
177                continue;
178            }
179            let d = xj - xi;
180            if d == 0 {
181                continue; // duplicate scale: skip (shouldn't happen)
182            }
183            w = fdiv(fmul(w, xj), d);
184        }
185        acc += fmul(w, points[i].1);
186    }
187    acc
188}
189
190/// Extrapolate a noise-scaled expectation table to the zero-noise limit. `points`
191/// is `(noise_scale_fx, measured_expectation_fx)`, scale `≥ MIT_ONE` (1.0 = base
192/// noise). Pure post-processing — no noise model required.
193pub fn zne_extrapolate(points: &[(i64, i64)], method: ZneMethod, shots: u64) -> ZneResult {
194    let mut pts = points.to_vec();
195    pts.sort_by_key(|p| p.0);
196    let raw_fx = pts.first().map(|p| p.1).unwrap_or(0);
197
198    let (intercept, slope) = fit_linear(&pts);
199    let lin0 = intercept;
200
201    let (mitigated_fx, error_fx) = match method {
202        ZneMethod::Linear => {
203            // RMS residual against the fitted line.
204            let n = pts.len().max(1) as i128;
205            let mut ss = 0i128;
206            for &(x, y) in &pts {
207                let pred = intercept + fmul(slope, x);
208                let r = (y - pred) as i128;
209                ss += r * r; // scale 2^40
210            }
211            let rms = sqrt_fx_of_square(ss / n);
212            (lin0, rms)
213        }
214        ZneMethod::Richardson => {
215            let capped: Vec<(i64, i64)> = pts.iter().take(8).copied().collect();
216            let rich = richardson_zero(&capped);
217            (rich, (rich - lin0).abs())
218        }
219    };
220
221    ZneResult {
222        method: method.tag(),
223        points: pts,
224        raw_fx,
225        mitigated_fx,
226        error_fx,
227        shots,
228    }
229}
230
231impl ZneResult {
232    /// Canonical bytes of the raw input table (what `input_hash` binds).
233    pub fn input_bytes(&self) -> Vec<u8> {
234        let mut b = Vec::with_capacity(self.points.len() * 16 + 8);
235        b.extend_from_slice(b"wai:zne-input\x01");
236        for &(x, y) in &self.points {
237            b.extend_from_slice(&x.to_le_bytes());
238            b.extend_from_slice(&y.to_le_bytes());
239        }
240        b
241    }
242
243    /// Seal this ZNE step into a signed, joule-metered mitigation receipt. No
244    /// noise model — the receipt records that none was needed.
245    #[allow(clippy::too_many_arguments)]
246    pub fn seal(
247        &self,
248        signer: &SigningKey,
249        signer_id: impl Into<String>,
250        backend_id: impl Into<String>,
251        observable: impl Into<String>,
252        joules_micro: u64,
253        grant: GrantRef,
254    ) -> MitigationReceipt {
255        MitigationReceipt::seal(
256            signer,
257            signer_id,
258            backend_id,
259            self.method,
260            observable,
261            content_hash(&self.input_bytes()),
262            None,
263            self.raw_fx,
264            self.mitigated_fx,
265            self.error_fx,
266            self.shots,
267            joules_micro,
268            grant,
269            None,
270        )
271    }
272}
273
274/// Deterministic front-end for ZNE: a noisy observable whose expectation decays
275/// as `E(λ) = ideal · exp(-γλ)` (a depolarizing-like model), sampled with shot
276/// noise at each scale. Returns the `(scale_fx, measured_fx)` table a real ZNE
277/// run would hand to [`zne_extrapolate`].
278pub fn simulate_zne(
279    ideal_fx: i64,
280    gamma_fx: i64,
281    scales_fx: &[i64],
282    shots: u32,
283    seed: u64,
284) -> Vec<(i64, i64)> {
285    scales_fx
286        .iter()
287        .map(|&lam| {
288            let e_true = fmul(ideal_fx, exp_neg_fx(fmul(gamma_fx, lam)));
289            let mut st = seed
290                .wrapping_mul(0x1000_0001)
291                .wrapping_add(lam as u64)
292                .wrapping_add(0xA5A5);
293            let mut sum = 0i64;
294            for _ in 0..shots {
295                sum += draw_pm1(&mut st, e_true);
296            }
297            let meas = ((sum as i128 * MIT_ONE as i128) / shots.max(1) as i128) as i64;
298            (lam, meas)
299        })
300        .collect()
301}
302
303/// Fixed-point `exp(-x)` for `x ≥ 0` via a 6-term Taylor series with range
304/// reduction by halving (`exp(-x) = (exp(-x/2))²`). Deterministic, integer-only.
305fn exp_neg_fx(x_fx: i64) -> i64 {
306    if x_fx <= 0 {
307        return MIT_ONE;
308    }
309    // Range-reduce so the argument is small, then square back.
310    let mut halvings = 0;
311    let mut x = x_fx;
312    while x > (MIT_ONE >> 2) {
313        x >>= 1;
314        halvings += 1;
315    }
316    // Taylor: 1 - x + x²/2 - x³/6 + x⁴/24 - x⁵/120
317    let mut term = MIT_ONE;
318    let mut acc = 0i64;
319    let mut sign = 1i64;
320    for k in 0..6 {
321        acc += sign * term;
322        term = fmul(term, x) / (k + 1) as i64;
323        sign = -sign;
324    }
325    let mut r = acc;
326    for _ in 0..halvings {
327        r = fmul(r, r);
328    }
329    r.clamp(0, MIT_ONE)
330}
331
332// ===========================================================================
333// 2. Readout-error mitigation — tensored inversion (wai.quantum.mitigate / readout.tensored)
334// ===========================================================================
335
336/// Per-qubit readout assignment matrices. Each is `[p00, p01, p10, p11]`
337/// (row = measured, col = true): `p00 = P(measure 0 | true 0)`,
338/// `p01 = P(measure 0 | true 1)`, etc. Fixed-point.
339#[derive(Clone, Debug, PartialEq, Eq)]
340pub struct ReadoutModel {
341    pub qubits: Vec<[i64; 4]>,
342}
343
344impl ReadoutModel {
345    /// A symmetric model: each qubit flips `0→1` with `p01_hi` and `1→0` with
346    /// `p10_lo` (as fixed-point rates); the rest is correct assignment.
347    pub fn symmetric(n: usize, p_meas0_given1: i64, p_meas1_given0: i64) -> ReadoutModel {
348        let mat = [
349            MIT_ONE - p_meas1_given0, // p00
350            p_meas0_given1,           // p01
351            p_meas1_given0,           // p10
352            MIT_ONE - p_meas0_given1, // p11
353        ];
354        ReadoutModel {
355            qubits: vec![mat; n],
356        }
357    }
358
359    /// Canonical bytes for content-hashing (the pinned noise model).
360    pub fn bytes(&self) -> Vec<u8> {
361        let mut b = Vec::with_capacity(self.qubits.len() * 32 + 8);
362        b.extend_from_slice(b"wai:readout-model\x01");
363        for q in &self.qubits {
364            for v in q {
365                b.extend_from_slice(&v.to_le_bytes());
366            }
367        }
368        b
369    }
370
371    fn inv(&self, q: usize) -> [i64; 4] {
372        let [a, b, c, d] = self.qubits[q];
373        let det = fmul(a, d) - fmul(b, c);
374        let id = fdiv(MIT_ONE, det);
375        [fmul(id, d), -fmul(id, b), -fmul(id, c), fmul(id, a)]
376    }
377}
378
379/// Apply a per-qubit 2×2 operator across bit `q` of a dense length-`2^n` vector
380/// (the butterfly). `op = [m00, m01, m10, m11]`.
381fn apply_qubit(v: &mut [i64], n: usize, q: usize, op: [i64; 4]) {
382    let bit = 1usize << q;
383    for x in 0..(1usize << n) {
384        if x & bit == 0 {
385            let v0 = v[x];
386            let v1 = v[x | bit];
387            v[x] = fmul(op[0], v0) + fmul(op[1], v1);
388            v[x | bit] = fmul(op[2], v0) + fmul(op[3], v1);
389        }
390    }
391}
392
393/// Output of a readout-mitigation step.
394#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct ReadoutResult {
396    pub n: usize,
397    /// Raw expectation of the observable over the measured distribution.
398    pub raw_fx: i64,
399    /// Mitigated expectation after applying `A^{-1}`.
400    pub mitigated_fx: i64,
401    /// Negativity of the corrected quasi-distribution (∑ of negative mass) — a
402    /// standard diagnostic; 0 means the correction stayed physical.
403    pub negativity_fx: i64,
404    pub shots: u64,
405    /// Canonical measured-counts bytes (what `input_hash` binds).
406    pub counts_bytes: Vec<u8>,
407    /// Content hash of the readout model (the pinned noise model).
408    pub model_hash: [u8; 32],
409}
410
411/// Invert tensored readout error. `counts` is measured shots over the `2^n`
412/// bitstrings (little-endian: bit `q` is qubit `q`). `obs` is the diagonal
413/// observable value per bitstring (fixed-point, e.g. `±MIT_ONE` for a parity).
414pub fn readout_mitigate(
415    counts: &[u64],
416    obs: &[i64],
417    model: &ReadoutModel,
418) -> ReadoutResult {
419    let n = model.qubits.len();
420    let dim = 1usize << n;
421    assert_eq!(counts.len(), dim, "counts must have 2^n entries");
422    assert_eq!(obs.len(), dim, "obs must have 2^n entries");
423
424    let total: u64 = counts.iter().sum::<u64>().max(1);
425    let mut p: Vec<i64> = counts
426        .iter()
427        .map(|&c| ((c as i128 * MIT_ONE as i128) / total as i128) as i64)
428        .collect();
429
430    let raw_fx = p
431        .iter()
432        .zip(obs)
433        .map(|(&pi, &oi)| fmul(pi, oi))
434        .sum::<i64>();
435
436    for q in 0..n {
437        apply_qubit(&mut p, n, q, model.inv(q));
438    }
439
440    let mitigated_fx = p.iter().zip(obs).map(|(&pi, &oi)| fmul(pi, oi)).sum::<i64>();
441    let negativity_fx = p.iter().filter(|&&x| x < 0).map(|&x| -x).sum::<i64>();
442
443    let mut cb = Vec::with_capacity(dim * 8 + 8);
444    cb.extend_from_slice(b"wai:readout-counts\x01");
445    for &c in counts {
446        cb.extend_from_slice(&c.to_le_bytes());
447    }
448
449    ReadoutResult {
450        n,
451        raw_fx,
452        mitigated_fx,
453        negativity_fx,
454        shots: total,
455        counts_bytes: cb,
456        model_hash: content_hash(&model.bytes()),
457    }
458}
459
460impl ReadoutResult {
461    /// Seal this readout-mitigation step, binding the pinned readout model.
462    pub fn seal(
463        &self,
464        signer: &SigningKey,
465        signer_id: impl Into<String>,
466        backend_id: impl Into<String>,
467        observable: impl Into<String>,
468        joules_micro: u64,
469        grant: GrantRef,
470    ) -> MitigationReceipt {
471        MitigationReceipt::seal(
472            signer,
473            signer_id,
474            backend_id,
475            "readout.tensored",
476            observable,
477            content_hash(&self.counts_bytes),
478            Some(self.model_hash),
479            self.raw_fx,
480            self.mitigated_fx,
481            self.negativity_fx,
482            self.shots,
483            joules_micro,
484            grant,
485            None,
486        )
487    }
488}
489
490/// Deterministic front-end for readout mitigation: sample measured counts from
491/// `A · p_true` where `p_true` is an ideal distribution over `2^n` bitstrings
492/// (fixed-point, summing to `MIT_ONE`). Returns the measured counts a real run
493/// would produce.
494pub fn simulate_readout(
495    p_true_fx: &[i64],
496    model: &ReadoutModel,
497    shots: u32,
498    seed: u64,
499) -> Vec<u64> {
500    let n = model.qubits.len();
501    let dim = 1usize << n;
502    // Forward-apply A to get the measured distribution.
503    let mut p = p_true_fx.to_vec();
504    for q in 0..n {
505        apply_qubit(&mut p, n, q, model.qubits[q]);
506    }
507    // Build a CDF (clamp tiny negatives from rounding) and sample.
508    let mut cdf = vec![0i64; dim];
509    let mut run = 0i64;
510    for i in 0..dim {
511        run += p[i].max(0);
512        cdf[i] = run;
513    }
514    let norm = run.max(1);
515    let mut counts = vec![0u64; dim];
516    let mut st = seed.wrapping_mul(0x2545_F491).wrapping_add(1);
517    for _ in 0..shots {
518        let r = (((splitmix64(&mut st) >> (64 - MIT_FRAC)) as i128 * norm as i128) >> MIT_FRAC) as i64;
519        let mut idx = dim - 1;
520        for (i, &c) in cdf.iter().enumerate() {
521            if r < c {
522                idx = i;
523                break;
524            }
525        }
526        counts[idx] += 1;
527    }
528    counts
529}
530
531// ===========================================================================
532// 3. Classical shadows — random single-qubit Pauli (wai.quantum.mitigate / shadow.pauli)
533// ===========================================================================
534
535/// Pauli basis / operator labels used by the shadow estimator.
536pub const PX: u8 = 0;
537pub const PY: u8 = 1;
538pub const PZ: u8 = 2;
539/// Identity — a qubit not in the observable's support.
540pub const PI: u8 = 3;
541
542/// One classical-shadow snapshot: the randomly chosen measurement basis per
543/// qubit (`PX`/`PY`/`PZ`) and the ±1 outcome observed.
544#[derive(Clone, Debug, PartialEq, Eq)]
545pub struct Snapshot {
546    pub bases: Vec<u8>,
547    pub outcomes: Vec<i8>,
548}
549
550/// Output of a classical-shadows Pauli-observable estimate.
551#[derive(Clone, Debug, PartialEq, Eq)]
552pub struct ShadowResult {
553    pub estimate_fx: i64,
554    /// Median-of-means spread across batches (half the batch min–max range).
555    pub error_fx: i64,
556    pub n_snapshots: usize,
557    /// Canonical snapshot bytes (what `input_hash` binds).
558    pub input: Vec<u8>,
559}
560
561fn canonical_snapshots(snaps: &[Snapshot]) -> Vec<u8> {
562    let mut b = Vec::with_capacity(snaps.len() * 8 + 8);
563    b.extend_from_slice(b"wai:shadow-snapshots\x01");
564    for s in snaps {
565        for &x in &s.bases {
566            b.push(x);
567        }
568        for &o in &s.outcomes {
569            b.push(o as u8);
570        }
571    }
572    b
573}
574
575/// Single-snapshot contribution to `⟨P⟩`: for each qubit in `pauli`'s support the
576/// measured basis must match `pauli`'s basis (factor `3·outcome`), else the whole
577/// snapshot contributes 0. Identity qubits contribute factor 1. Returns the
578/// contribution as an exact integer (product of `±3`s).
579fn snapshot_contribution(snap: &Snapshot, pauli: &[u8]) -> Option<i64> {
580    let mut prod = 1i64;
581    for (q, &pq) in pauli.iter().enumerate() {
582        if pq == PI {
583            continue;
584        }
585        if snap.bases[q] != pq {
586            return None; // wrong basis measured on a support qubit → 0
587        }
588        prod *= 3 * snap.outcomes[q] as i64;
589    }
590    Some(prod)
591}
592
593/// Estimate `⟨P⟩` for a Pauli string `pauli` (`PX`/`PY`/`PZ`/`PI` per qubit) from
594/// classical-shadow snapshots, using a median-of-means over `n_batches` groups
595/// for robustness. Pure post-processing; no noise model.
596pub fn estimate_pauli(snaps: &[Snapshot], pauli: &[u8], n_batches: usize) -> ShadowResult {
597    let m = snaps.len();
598    let batches = n_batches.clamp(1, m.max(1));
599    let mut means: Vec<i64> = Vec::with_capacity(batches);
600    for b in 0..batches {
601        let lo = b * m / batches;
602        let hi = (b + 1) * m / batches;
603        if hi <= lo {
604            continue;
605        }
606        let mut acc = 0i128;
607        for snap in &snaps[lo..hi] {
608            // contribution·MIT_ONE, 0 when the snapshot misses the basis.
609            let c = snapshot_contribution(snap, pauli).unwrap_or(0);
610            acc += c as i128 * MIT_ONE as i128;
611        }
612        means.push((acc / (hi - lo) as i128) as i64);
613    }
614    means.sort_unstable();
615    let estimate_fx = if means.is_empty() {
616        0
617    } else {
618        means[means.len() / 2]
619    };
620    let error_fx = if means.len() < 2 {
621        0
622    } else {
623        (means[means.len() - 1] - means[0]) / 2
624    };
625    ShadowResult {
626        estimate_fx,
627        error_fx,
628        n_snapshots: m,
629        input: canonical_snapshots(snaps),
630    }
631}
632
633impl ShadowResult {
634    /// Seal this classical-shadows estimate. No noise model — the snapshots and
635    /// the estimator are the whole story.
636    #[allow(clippy::too_many_arguments)]
637    pub fn seal(
638        &self,
639        signer: &SigningKey,
640        signer_id: impl Into<String>,
641        backend_id: impl Into<String>,
642        observable: impl Into<String>,
643        raw_fx: i64,
644        joules_micro: u64,
645        grant: GrantRef,
646    ) -> MitigationReceipt {
647        MitigationReceipt::seal(
648            signer,
649            signer_id,
650            backend_id,
651            "shadow.pauli",
652            observable,
653            content_hash(&self.input),
654            None,
655            raw_fx,
656            self.estimate_fx,
657            self.error_fx,
658            self.n_snapshots as u64,
659            joules_micro,
660            grant,
661            None,
662        )
663    }
664}
665
666/// Deterministic front-end for classical shadows: a product state with per-qubit
667/// Bloch vector `[rx, ry, rz]` (fixed-point, `|r| ≤ 1`). For each snapshot each
668/// qubit is measured in a uniformly-random Pauli basis, with the correct ±1
669/// outcome probabilities. The true `⟨Pauli string⟩` is the product of the Bloch
670/// components over the support (see [`bloch_expectation`]) — a known ground truth.
671pub fn simulate_shadows(bloch: &[[i64; 3]], n_snapshots: usize, seed: u64) -> Vec<Snapshot> {
672    let n = bloch.len();
673    let mut out = Vec::with_capacity(n_snapshots);
674    let mut st = seed.wrapping_mul(0x9E37_79B9).wrapping_add(7);
675    for _ in 0..n_snapshots {
676        let mut bases = Vec::with_capacity(n);
677        let mut outcomes = Vec::with_capacity(n);
678        for qb in bloch.iter() {
679            let basis = (splitmix64(&mut st) % 3) as u8; // 0=X,1=Y,2=Z uniform
680            let r = qb[basis as usize];
681            let o = draw_pm1(&mut st, r) as i8;
682            bases.push(basis);
683            outcomes.push(o);
684        }
685        out.push(Snapshot { bases, outcomes });
686    }
687    out
688}
689
690/// The analytic `⟨Pauli string⟩` for a Bloch product state — the ground truth the
691/// shadow estimate should recover. Product of the Bloch component along each
692/// support qubit's Pauli axis.
693pub fn bloch_expectation(bloch: &[[i64; 3]], pauli: &[u8]) -> i64 {
694    let mut prod = MIT_ONE;
695    for (q, &pq) in pauli.iter().enumerate() {
696        if pq == PI {
697            continue;
698        }
699        prod = fmul(prod, bloch[q][pq as usize]);
700    }
701    prod
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    fn key(s: u8) -> SigningKey {
709        SigningKey::from_bytes(&[s; 32])
710    }
711
712    // --- ZNE ----------------------------------------------------------------
713
714    #[test]
715    fn zne_recovers_ideal_from_noisy_decay() {
716        // ideal ⟨O⟩ = 0.8, exponential decay in the noise scale.
717        let ideal = (0.8 * MIT_ONE as f64) as i64;
718        let gamma = (0.25 * MIT_ONE as f64) as i64;
719        // Canonical, well-conditioned ZNE: 3 scales → quadratic Richardson. (More
720        // points raise the polynomial degree and reintroduce Runge instability
721        // when extrapolating back to λ=0 — the classic ZNE bias/variance trade.)
722        let scales: Vec<i64> = [1.0, 2.0, 3.0]
723            .iter()
724            .map(|s| (s * MIT_ONE as f64) as i64)
725            .collect();
726        let table = simulate_zne(ideal, gamma, &scales, 20_000, 0xBEEF);
727        let raw = table[0].1;
728
729        let rich = zne_extrapolate(&table, ZneMethod::Richardson, 100_000);
730        // Richardson should pull the estimate much closer to the ideal than the
731        // raw base-noise value.
732        let err_raw = (raw - ideal).abs();
733        let err_mit = (rich.mitigated_fx - ideal).abs();
734        assert!(
735            err_mit < err_raw,
736            "mitigated must beat raw: raw_err={err_raw}, mit_err={err_mit}"
737        );
738        // within ~8% of ideal
739        assert!(err_mit < (0.08 * MIT_ONE as f64) as i64, "mit within 8%: {err_mit}");
740    }
741
742    #[test]
743    fn zne_is_deterministic() {
744        let ideal = MIT_ONE / 2;
745        let g = MIT_ONE / 5;
746        let s: Vec<i64> = [1.0, 2.0, 3.0].iter().map(|x| (x * MIT_ONE as f64) as i64).collect();
747        let a = simulate_zne(ideal, g, &s, 5000, 42);
748        let b = simulate_zne(ideal, g, &s, 5000, 42);
749        assert_eq!(a, b, "same seed → byte-identical table");
750        assert_eq!(
751            zne_extrapolate(&a, ZneMethod::Linear, 15_000),
752            zne_extrapolate(&b, ZneMethod::Linear, 15_000)
753        );
754    }
755
756    #[test]
757    fn zne_linear_exact_on_a_line() {
758        // On a perfectly linear table the linear fit is exact and residual 0.
759        let pts = vec![
760            (MIT_ONE, MIT_ONE / 2),         // (1.0, 0.5)
761            (2 * MIT_ONE, MIT_ONE / 4),     // (2.0, 0.25) slope -0.25
762            (3 * MIT_ONE, 0),               // (3.0, 0.0)
763        ];
764        let r = zne_extrapolate(&pts, ZneMethod::Linear, 0);
765        // extrapolate to 0 → 0.75
766        assert!((r.mitigated_fx - (3 * MIT_ONE / 4)).abs() < 16, "intercept ~0.75");
767        assert!(r.error_fx < 16, "residual ~0 on an exact line");
768    }
769
770    #[test]
771    fn zne_seals_and_verifies_no_model() {
772        let pts = vec![(MIT_ONE, MIT_ONE / 2), (2 * MIT_ONE, MIT_ONE / 4)];
773        let r = zne_extrapolate(&pts, ZneMethod::Richardson, 10_000);
774        let rec = r.seal(&key(1), "did:key:lab", "wai.quantum.circuit", "ZZ", 500_000, GrantRef::unbounded("quantum.mitigate"));
775        assert!(rec.verify());
776        assert!(rec.noise_model_hash.is_none());
777        assert!(rec.input_matches(&r.input_bytes()));
778    }
779
780    // --- Readout ------------------------------------------------------------
781
782    #[test]
783    fn readout_inversion_recovers_parity() {
784        let n = 2;
785        // GHZ-like ideal: |00> and |11> each 0.5. Parity ZZ = +1 on both → ⟨ZZ⟩=1.
786        let dim = 1usize << n;
787        let mut p_true = vec![0i64; dim];
788        p_true[0b00] = MIT_ONE / 2;
789        p_true[0b11] = MIT_ONE / 2;
790        let obs: Vec<i64> = (0..dim)
791            .map(|x| if (x.count_ones() & 1) == 0 { MIT_ONE } else { -MIT_ONE })
792            .collect();
793
794        let model = ReadoutModel::symmetric(n, MIT_ONE / 12, MIT_ONE / 12); // ~8% each way
795        let counts = simulate_readout(&p_true, &model, 200_000, 0xC0FFEE);
796        let res = readout_mitigate(&counts, &obs, &model);
797
798        // Raw parity is pulled below 1 by readout error; mitigation restores it.
799        assert!(res.raw_fx < (0.9 * MIT_ONE as f64) as i64, "raw pulled down: {}", res.raw_fx);
800        let err = (res.mitigated_fx - MIT_ONE).abs();
801        assert!(err < (0.03 * MIT_ONE as f64) as i64, "mitigated ~1.0: {}", res.mitigated_fx);
802    }
803
804    #[test]
805    fn readout_seals_with_pinned_model() {
806        let n = 2;
807        let dim = 1usize << n;
808        let mut p_true = vec![0i64; dim];
809        p_true[0] = MIT_ONE;
810        let obs = vec![MIT_ONE, -MIT_ONE, -MIT_ONE, MIT_ONE];
811        let model = ReadoutModel::symmetric(n, MIT_ONE / 20, MIT_ONE / 20);
812        let counts = simulate_readout(&p_true, &model, 50_000, 1);
813        let res = readout_mitigate(&counts, &obs, &model);
814        let rec = res.seal(&key(2), "lab", "sim", "ZZ", 400_000, GrantRef::unbounded("quantum.mitigate"));
815        assert!(rec.verify());
816        assert!(rec.noise_model_matches(&model.bytes()));
817        assert!(rec.input_matches(&res.counts_bytes));
818    }
819
820    // --- Classical shadows --------------------------------------------------
821
822    #[test]
823    fn shadows_recover_pauli_expectation() {
824        // 3-qubit product state, known Bloch vectors.
825        let bloch = [
826            [(0.6 * MIT_ONE as f64) as i64, 0, (0.5 * MIT_ONE as f64) as i64],
827            [0, (0.7 * MIT_ONE as f64) as i64, 0],
828            [0, 0, (0.9 * MIT_ONE as f64) as i64],
829        ];
830        // Observable X0 Y1 Z2 → truth = 0.6 · 0.7 · 0.9.
831        let pauli = [PX, PY, PZ];
832        let truth = bloch_expectation(&bloch, &pauli);
833        let snaps = simulate_shadows(&bloch, 60_000, 0x5EED);
834        let est = estimate_pauli(&snaps, &pauli, 12);
835        let err = (est.estimate_fx - truth).abs();
836        assert!(
837            err < (0.05 * MIT_ONE as f64) as i64,
838            "shadow estimate {} vs truth {} (err {})",
839            est.estimate_fx, truth, err
840        );
841    }
842
843    #[test]
844    fn shadows_deterministic_and_sealed() {
845        let bloch = [[0, 0, MIT_ONE], [0, 0, MIT_ONE / 2]];
846        let a = simulate_shadows(&bloch, 4000, 9);
847        let b = simulate_shadows(&bloch, 4000, 9);
848        assert_eq!(a, b);
849        let pauli = [PZ, PI];
850        let ea = estimate_pauli(&a, &pauli, 8);
851        assert_eq!(ea, estimate_pauli(&b, &pauli, 8));
852        let rec = ea.seal(&key(3), "lab", "sim", "Z0", 0, 300_000, GrantRef::unbounded("quantum.mitigate"));
853        assert!(rec.verify());
854        assert!(rec.input_matches(&ea.input));
855    }
856
857    #[test]
858    fn shadow_wrong_basis_contributes_zero() {
859        // A snapshot that measured Z on qubit 0 cannot inform ⟨X0⟩.
860        let snap = Snapshot { bases: vec![PZ], outcomes: vec![1] };
861        assert_eq!(snapshot_contribution(&snap, &[PX]), None);
862        let snap2 = Snapshot { bases: vec![PX], outcomes: vec![-1] };
863        assert_eq!(snapshot_contribution(&snap2, &[PX]), Some(-3));
864    }
865}