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