Skip to main content

prism_q/sim/
observable.rs

1//! Weighted Pauli-sum observables: construction and arithmetic, qubit-wise-
2//! commuting grouping, and the grouped moment accumulation the statevector
3//! route evaluates mean and variance with. Also holds the joint-Pauli mask
4//! reduction and the expectation kernels the backends share.
5
6use std::collections::BTreeMap;
7use std::sync::OnceLock;
8
9use num_complex::Complex64;
10
11use crate::circuit::Circuit;
12use crate::error::{PrismError, Result};
13use crate::gates::Gate;
14use crate::sim::RunMetadata;
15use crate::sim::unified_pauli::{PauliAxis, PauliTerm};
16
17/// Weighted sum of joint Pauli observables, `H = sum_k c_k P_k`.
18///
19/// Terms are kept canonical: factors sorted by qubit, identical Pauli strings
20/// merged by summing coefficients, terms ordered by string. An empty factor
21/// list is the identity and contributes its coefficient as a constant offset.
22/// The qubit-wise-commuting grouping the grouped evaluation route uses is
23/// computed lazily and cached; mutation invalidates the cache.
24#[derive(Debug, Clone, Default)]
25pub struct PauliObservable {
26    terms: Vec<(f64, Vec<PauliTerm>)>,
27    grouping: OnceLock<Grouping>,
28}
29
30impl PauliObservable {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Build from `(coefficient, factors)` pairs, the Hamiltonian shape
36    /// [`Simulate::expectation_gradient`] takes.
37    ///
38    /// [`Simulate::expectation_gradient`]: crate::sim::Simulate::expectation_gradient
39    pub fn from_terms(terms: impl IntoIterator<Item = (f64, Vec<PauliTerm>)>) -> Result<Self> {
40        let mut observable = Self::new();
41        for (coefficient, factors) in terms {
42            observable.add_term(coefficient, factors)?;
43        }
44        Ok(observable)
45    }
46
47    /// Add `coefficient` times the Pauli string `factors`, merging into an
48    /// existing term with the same string.
49    ///
50    /// # Errors
51    /// Rejects a non-finite coefficient and duplicate factors on one qubit.
52    pub fn add_term(&mut self, coefficient: f64, mut factors: Vec<PauliTerm>) -> Result<()> {
53        if !coefficient.is_finite() {
54            return Err(PrismError::InvalidParameter {
55                message: format!("observable coefficient {coefficient} is not finite"),
56            });
57        }
58        factors.sort_unstable_by_key(|term| term.qubit);
59        if let Some(pair) = factors
60            .windows(2)
61            .find(|pair| pair[0].qubit == pair[1].qubit)
62        {
63            return Err(PrismError::InvalidParameter {
64                message: format!(
65                    "joint Pauli observable has duplicate factor on qubit {}",
66                    pair[0].qubit
67                ),
68            });
69        }
70        self.merge_term(coefficient, factors);
71        Ok(())
72    }
73
74    fn merge_term(&mut self, coefficient: f64, factors: Vec<PauliTerm>) {
75        match self
76            .terms
77            .binary_search_by(|(_, existing)| existing.as_slice().cmp(&factors))
78        {
79            Ok(i) => self.terms[i].0 += coefficient,
80            Err(i) => self.terms.insert(i, (coefficient, factors)),
81        }
82        self.grouping = OnceLock::new();
83    }
84
85    /// Canonical `(coefficient, factors)` pairs, ordered by Pauli string.
86    pub fn terms(&self) -> &[(f64, Vec<PauliTerm>)] {
87        &self.terms
88    }
89
90    pub fn num_terms(&self) -> usize {
91        self.terms.len()
92    }
93
94    /// Number of qubit-wise-commuting groups, computing the grouping if
95    /// needed. Identity terms belong to no group.
96    pub fn num_groups(&self) -> usize {
97        self.grouping().groups.len()
98    }
99
100    pub(crate) fn grouping(&self) -> &Grouping {
101        self.grouping.get_or_init(|| compute_grouping(&self.terms))
102    }
103
104    /// The constant term's coefficient and the rest of the sum.
105    ///
106    /// `Var(H + cI) = Var(H)`, so a variance squares the traceless part rather
107    /// than the whole sum: at a large `c` the constant dominates both `<H^2>`
108    /// and `<H>^2` and the difference loses the spread it was meant to report.
109    pub fn split_identity(&self) -> (f64, PauliObservable) {
110        let mut offset = 0.0;
111        let mut rest = PauliObservable::new();
112        for (coefficient, string) in &self.terms {
113            if string.is_empty() {
114                offset += coefficient;
115            } else {
116                rest.merge_term(*coefficient, string.clone());
117            }
118        }
119        (offset, rest)
120    }
121
122    /// `H^2` as a Pauli sum, the second moment [`Simulate::observable_variance`]
123    /// reads `Var(H) = <H^2> - <H>^2` from.
124    ///
125    /// Every coefficient of the square is real. Two Pauli strings either
126    /// commute, and their product carries no phase, or anticommute, and the
127    /// `(j, k)` and `(k, j)` products carry opposite imaginary phases that
128    /// cancel. Phases are tracked as powers of `i` so that cancellation is
129    /// exact rather than a subtraction of two nearly equal floats.
130    ///
131    /// Costs `T^2` string products over `T` terms, so it suits the tensor
132    /// products and small Hermitian matrices an observable request names
133    /// rather than a molecular Hamiltonian.
134    ///
135    /// [`Simulate::observable_variance`]: crate::sim::Simulate::observable_variance
136    pub fn square(&self) -> PauliObservable {
137        let mut accumulated: BTreeMap<Vec<PauliTerm>, f64> = BTreeMap::new();
138        for (left, left_string) in &self.terms {
139            for (right, right_string) in &self.terms {
140                let (phase, product) = multiply_pauli_strings(left_string, right_string);
141                if phase % 2 == 1 {
142                    continue;
143                }
144                let sign = if phase == 0 { 1.0 } else { -1.0 };
145                *accumulated.entry(product).or_insert(0.0) += sign * left * right;
146            }
147        }
148        let norm = self.terms.iter().map(|(c, _)| c.abs()).sum::<f64>();
149        let tolerance = f64::EPSILON * norm * norm * self.terms.len().max(1) as f64;
150        let mut squared = PauliObservable::new();
151        for (string, coefficient) in accumulated {
152            if coefficient.abs() > tolerance {
153                squared.merge_term(coefficient, string);
154            }
155        }
156        squared
157    }
158}
159
160/// Product of two sorted Pauli strings as `(power of i, string)`.
161fn multiply_pauli_strings(left: &[PauliTerm], right: &[PauliTerm]) -> (u32, Vec<PauliTerm>) {
162    let mut phase = 0u32;
163    let mut product = Vec::with_capacity(left.len() + right.len());
164    let (mut i, mut j) = (0, 0);
165    while i < left.len() && j < right.len() {
166        let (a, b) = (left[i], right[j]);
167        match a.qubit.cmp(&b.qubit) {
168            std::cmp::Ordering::Less => {
169                product.push(a);
170                i += 1;
171            }
172            std::cmp::Ordering::Greater => {
173                product.push(b);
174                j += 1;
175            }
176            std::cmp::Ordering::Equal => {
177                if let Some((step, axis)) = multiply_pauli_axes(a.axis, b.axis) {
178                    phase = (phase + step) % 4;
179                    product.push(PauliTerm::new(a.qubit, axis));
180                }
181                i += 1;
182                j += 1;
183            }
184        }
185    }
186    product.extend_from_slice(&left[i..]);
187    product.extend_from_slice(&right[j..]);
188    (phase, product)
189}
190
191/// `a * b` on one qubit as `(power of i, axis)`, `None` when the two axes
192/// agree and the product is the identity.
193fn multiply_pauli_axes(a: PauliAxis, b: PauliAxis) -> Option<(u32, PauliAxis)> {
194    use PauliAxis::{X, Y, Z};
195    match (a, b) {
196        (X, Y) => Some((1, Z)),
197        (Y, Z) => Some((1, X)),
198        (Z, X) => Some((1, Y)),
199        (Y, X) => Some((3, Z)),
200        (Z, Y) => Some((3, X)),
201        (X, Z) => Some((3, Y)),
202        _ => None,
203    }
204}
205
206impl std::ops::Add for PauliObservable {
207    type Output = PauliObservable;
208
209    fn add(mut self, rhs: PauliObservable) -> PauliObservable {
210        for (coefficient, factors) in rhs.terms {
211            self.merge_term(coefficient, factors);
212        }
213        self
214    }
215}
216
217impl std::ops::Sub for PauliObservable {
218    type Output = PauliObservable;
219
220    fn sub(self, rhs: PauliObservable) -> PauliObservable {
221        self + (-rhs)
222    }
223}
224
225impl std::ops::Neg for PauliObservable {
226    type Output = PauliObservable;
227
228    fn neg(mut self) -> PauliObservable {
229        for (coefficient, _) in &mut self.terms {
230            *coefficient = -*coefficient;
231        }
232        self
233    }
234}
235
236impl std::ops::Mul<f64> for PauliObservable {
237    type Output = PauliObservable;
238
239    fn mul(mut self, rhs: f64) -> PauliObservable {
240        for (coefficient, _) in &mut self.terms {
241            *coefficient *= rhs;
242        }
243        self
244    }
245}
246
247/// Weighted-observable expectation with the grouped-measurement variance.
248#[derive(Debug, Clone)]
249pub struct ObservableExpectation {
250    /// `<H> = sum_k c_k <P_k>`, including identity-term constants.
251    pub mean: f64,
252    /// Sum of per-group variances `Var(H_g) = <H_g^2> - <H_g>^2`, each exact
253    /// in the output state. This is the variance of a grouped measurement
254    /// estimate drawing one shot per commuting group; with `S_g` shots on
255    /// group `g` the estimator variance is `sum_g Var(H_g) / S_g`. It equals
256    /// `Var(H)` of the full operator only when one group covers every term,
257    /// since cross-group covariances are excluded. `None` on a route that
258    /// evaluates term by term without the grouped traversal.
259    pub variance: Option<f64>,
260    /// Per-group `Var(H_g)` in grouping order, the input to shot allocation.
261    pub group_variances: Option<Vec<f64>>,
262    /// Standard error of `mean` when a sampling route estimated the per-term
263    /// values, `None` for analytic routes.
264    pub std_error: Option<f64>,
265    pub metadata: RunMetadata,
266}
267
268/// Qubit-wise-commuting grouping over an observable's non-identity terms.
269#[derive(Debug, Clone)]
270pub(crate) struct Grouping {
271    pub(crate) groups: Vec<QwcGroup>,
272}
273
274/// One commuting set: member term indices plus per-qubit axis-assignment
275/// words. A qubit's assigned axis is X when only its `axis_x` bit is set, Z
276/// when only `axis_z`, Y when both; unset bits are unconstrained.
277#[derive(Debug, Clone)]
278pub(crate) struct QwcGroup {
279    pub(crate) term_indices: Vec<usize>,
280    axis_x: Vec<u64>,
281    axis_z: Vec<u64>,
282}
283
284impl QwcGroup {
285    fn accepts(&self, tx: &[u64], tz: &[u64]) -> bool {
286        for w in 0..tx.len() {
287            let shared = (tx[w] | tz[w]) & (self.axis_x[w] | self.axis_z[w]);
288            if ((tx[w] ^ self.axis_x[w]) | (tz[w] ^ self.axis_z[w])) & shared != 0 {
289                return false;
290            }
291        }
292        true
293    }
294
295    fn absorb(&mut self, index: usize, tx: &[u64], tz: &[u64]) {
296        for w in 0..tx.len() {
297            self.axis_x[w] |= tx[w];
298            self.axis_z[w] |= tz[w];
299        }
300        self.term_indices.push(index);
301    }
302
303    /// Whether every assigned axis is Z, so members evaluate on the
304    /// unrotated state.
305    pub(crate) fn is_z_only(&self) -> bool {
306        self.axis_x.iter().all(|&word| word == 0)
307    }
308
309    /// Rotation taking every assigned axis to Z: H on X qubits, Sdg then H on
310    /// Y qubits. Conjugation by it sends each member string to a plus-sign Z
311    /// string on the same support.
312    pub(crate) fn basis_rotation_circuit(&self, num_qubits: usize) -> Circuit {
313        let mut circuit = Circuit::new(num_qubits, 0);
314        for qubit in 0..num_qubits.min(self.axis_x.len() * 64) {
315            let bit = 1u64 << (qubit % 64);
316            if self.axis_x[qubit / 64] & bit != 0 {
317                if self.axis_z[qubit / 64] & bit != 0 {
318                    circuit.add_gate(Gate::Sdg, &[qubit]);
319                }
320                circuit.add_gate(Gate::H, &[qubit]);
321            }
322        }
323        circuit
324    }
325}
326
327fn compute_grouping(terms: &[(f64, Vec<PauliTerm>)]) -> Grouping {
328    let max_qubit = terms
329        .iter()
330        .flat_map(|(_, factors)| factors.iter())
331        .map(|term| term.qubit)
332        .max();
333    let num_words = max_qubit.map_or(0, |q| q / 64 + 1);
334
335    // First-fit-decreasing on factor count, index-stable for determinism.
336    let mut order: Vec<usize> = (0..terms.len())
337        .filter(|&i| !terms[i].1.is_empty())
338        .collect();
339    order.sort_by(|&a, &b| terms[b].1.len().cmp(&terms[a].1.len()).then(a.cmp(&b)));
340
341    let mut groups: Vec<QwcGroup> = Vec::new();
342    let mut tx = vec![0u64; num_words];
343    let mut tz = vec![0u64; num_words];
344    for &index in &order {
345        tx.fill(0);
346        tz.fill(0);
347        for term in &terms[index].1 {
348            let bit = 1u64 << (term.qubit % 64);
349            match term.axis {
350                PauliAxis::X => tx[term.qubit / 64] |= bit,
351                PauliAxis::Z => tz[term.qubit / 64] |= bit,
352                PauliAxis::Y => {
353                    tx[term.qubit / 64] |= bit;
354                    tz[term.qubit / 64] |= bit;
355                }
356            }
357        }
358        match groups.iter_mut().find(|group| group.accepts(&tx, &tz)) {
359            Some(group) => group.absorb(index, &tx, &tz),
360            None => groups.push(QwcGroup {
361                term_indices: vec![index],
362                axis_x: tx.clone(),
363                axis_z: tz.clone(),
364            }),
365        }
366    }
367    Grouping { groups }
368}
369
370/// First two moments `(sum_j p_j h(j), sum_j p_j h(j)^2)` of one group
371/// operator `h(j) = sum_i c_i (-1)^popcount(j & z_i)`, normalized by `norm`.
372///
373/// The z-only accumulator family of `pauli_expectations_from_masks`, combined
374/// per element before squaring so the group variance comes from the same
375/// traversal as its mean.
376pub(crate) fn weighted_group_moments(
377    state: &[Complex64],
378    zmasks: &[usize],
379    coefficients: &[f64],
380    norm: f64,
381) -> (f64, f64) {
382    if norm == 0.0 {
383        return (0.0, 0.0);
384    }
385
386    let accumulate = |acc: &mut (f64, f64), base: usize, block: &[Complex64]| {
387        for (offset, amp) in block.iter().enumerate() {
388            let j = base + offset;
389            let mut h = 0.0;
390            for (&zmask, &c) in zmasks.iter().zip(coefficients) {
391                h += if (j & zmask).count_ones() & 1 == 1 {
392                    -c
393                } else {
394                    c
395                };
396            }
397            let weighted = amp.norm_sqr() * h;
398            acc.0 += weighted;
399            acc.1 += weighted * h;
400        }
401    };
402
403    #[cfg(feature = "parallel")]
404    if state.len() >= crate::backend::MIN_PAR_REDUCE_ELEMS {
405        use rayon::prelude::*;
406        let chunk = crate::backend::MIN_PAR_ELEMS;
407        let (m1, m2) = state
408            .par_chunks(chunk)
409            .enumerate()
410            .fold(
411                || (0.0, 0.0),
412                |mut acc, (c, block)| {
413                    accumulate(&mut acc, c * chunk, block);
414                    acc
415                },
416            )
417            .reduce(|| (0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
418        return (m1 / norm, m2 / norm);
419    }
420
421    let mut acc = (0.0, 0.0);
422    accumulate(&mut acc, 0, state);
423    (acc.0 / norm, acc.1 / norm)
424}
425
426#[cfg(test)]
427#[path = "observable_tests.rs"]
428mod tests;
429
430/// Reject out-of-range qubits and duplicate factors in a joint Pauli
431/// observable.
432///
433/// Same checks [`pauli_masks`] makes, without its `1 << qubit` mask width, so
434/// it also covers the backends that run past 64 qubits.
435pub(crate) fn validate_observable(observable: &[PauliTerm], num_qubits: usize) -> Result<()> {
436    let mut seen = vec![false; num_qubits];
437    for term in observable {
438        if term.qubit >= num_qubits {
439            return Err(PrismError::InvalidQubit {
440                index: term.qubit,
441                register_size: num_qubits,
442            });
443        }
444        if seen[term.qubit] {
445            return Err(PrismError::InvalidParameter {
446                message: format!(
447                    "joint Pauli observable has duplicate factor on qubit {}",
448                    term.qubit
449                ),
450            });
451        }
452        seen[term.qubit] = true;
453    }
454    Ok(())
455}
456
457/// Validate a joint Pauli observable and reduce it to `(Xmask, Zmask, #Y)`,
458/// where `Xmask` covers X and Y factors and `Zmask` covers Z and Y factors.
459pub(crate) fn pauli_masks(
460    observable: &[PauliTerm],
461    num_qubits: usize,
462) -> Result<(usize, usize, u32)> {
463    let mut xmask = 0usize;
464    let mut zmask = 0usize;
465    let mut num_y = 0u32;
466    let mut seen = vec![false; num_qubits];
467    for term in observable {
468        if term.qubit >= num_qubits {
469            return Err(PrismError::InvalidQubit {
470                index: term.qubit,
471                register_size: num_qubits,
472            });
473        }
474        if seen[term.qubit] {
475            return Err(PrismError::InvalidParameter {
476                message: format!(
477                    "joint Pauli observable has duplicate factor on qubit {}",
478                    term.qubit
479                ),
480            });
481        }
482        seen[term.qubit] = true;
483        let bit = 1usize << term.qubit;
484        match term.axis {
485            PauliAxis::X => xmask |= bit,
486            PauliAxis::Z => zmask |= bit,
487            PauliAxis::Y => {
488                xmask |= bit;
489                zmask |= bit;
490                num_y += 1;
491            }
492        }
493    }
494    Ok((xmask, zmask, num_y))
495}
496
497/// Rayon fan-out threshold for the sandwich reductions. Higher than the gate
498/// kernels': a sandwich is a single lightweight O(N) reduction, so fan-out only
499/// pays off past 2^16 elements. Below that (and for a multi-term Hamiltonian's
500/// many small reductions) the sequential path is faster.
501#[cfg(feature = "parallel")]
502const SANDWICH_MIN_PAR_QUBITS: usize = 16;
503
504/// Complex Pauli sandwich `⟨λ|P|φ⟩`, where `P` acts as
505/// `P|j⟩ = i^{#Y}·(-1)^{popcount(j & Zmask)}·|j ⊕ Xmask⟩`. Returns the raw
506/// (unnormalized) complex value. The adjoint gradient engine uses this with
507/// distinct `λ` and `φ`; `pauli_expectation_from_masks` is the `λ = φ` case.
508///
509/// Inlined explicitly: the single-mask fallback of
510/// [`pauli_sandwiches_from_masks`], [`pauli_expectation_from_masks`] and the
511/// distributed backend all reduce through this, and leaving the decision to LTO
512/// ties it to how many callers the function happens to have.
513#[inline]
514pub(crate) fn pauli_sandwich(
515    lambda: &[Complex64],
516    phi: &[Complex64],
517    xmask: usize,
518    zmask: usize,
519    num_y: u32,
520) -> Complex64 {
521    let term = |j: usize, amp: Complex64| {
522        let partner = lambda[j ^ xmask];
523        let sign = if (j & zmask).count_ones() & 1 == 1 {
524            -1.0
525        } else {
526            1.0
527        };
528        partner.conj() * amp * sign
529    };
530
531    #[cfg(feature = "parallel")]
532    let acc: Complex64 = if phi.len() >= (1 << SANDWICH_MIN_PAR_QUBITS) {
533        use rayon::prelude::*;
534        phi.par_iter()
535            .enumerate()
536            .map(|(j, &amp)| term(j, amp))
537            .sum()
538    } else {
539        phi.iter().enumerate().map(|(j, &amp)| term(j, amp)).sum()
540    };
541    #[cfg(not(feature = "parallel"))]
542    let acc: Complex64 = phi.iter().enumerate().map(|(j, &amp)| term(j, amp)).sum();
543
544    acc * i_pow(num_y)
545}
546
547/// Complex Pauli sandwiches `⟨λ|P_i|φ⟩` for every mask triple in one traversal
548/// of the pair.
549///
550/// Same value as [`pauli_sandwich`] per entry, to within the association of the
551/// sum. A mask with `xmask == 0` reads `λ` at the loop index rather than at a
552/// partner index, so the two families are accumulated separately as in
553/// [`pauli_expectations_from_masks`].
554pub(crate) fn pauli_sandwiches_from_masks(
555    lambda: &[Complex64],
556    phi: &[Complex64],
557    masks: &[(usize, usize, u32)],
558) -> Vec<Complex64> {
559    if masks.len() < 2 {
560        return masks
561            .iter()
562            .map(|&(xmask, zmask, num_y)| pauli_sandwich(lambda, phi, xmask, zmask, num_y))
563            .collect();
564    }
565
566    let z_only: Vec<usize> = masks
567        .iter()
568        .filter(|&&(xmask, _, _)| xmask == 0)
569        .map(|&(_, zmask, _)| zmask)
570        .collect();
571    let general: Vec<(usize, usize)> = masks
572        .iter()
573        .filter(|&&(xmask, _, _)| xmask != 0)
574        .map(|&(xmask, zmask, _)| (xmask, zmask))
575        .collect();
576
577    let accumulate = |z_acc: &mut [Complex64], g_acc: &mut [Complex64], base: usize, len: usize| {
578        for j in base..base + len {
579            let amp = phi[j];
580            let aligned = lambda[j].conj() * amp;
581            for (slot, &zmask) in z_acc.iter_mut().zip(z_only.iter()) {
582                *slot += if (j & zmask).count_ones() & 1 == 1 {
583                    -aligned
584                } else {
585                    aligned
586                };
587            }
588            for (slot, &(xmask, zmask)) in g_acc.iter_mut().zip(general.iter()) {
589                let partner = lambda[j ^ xmask];
590                let sign = if (j & zmask).count_ones() & 1 == 1 {
591                    -1.0
592                } else {
593                    1.0
594                };
595                *slot += partner.conj() * amp * sign;
596            }
597        }
598    };
599
600    let zeros = || {
601        (
602            vec![Complex64::new(0.0, 0.0); z_only.len()],
603            vec![Complex64::new(0.0, 0.0); general.len()],
604        )
605    };
606    let (mut z_sum, mut g_sum) = zeros();
607
608    #[cfg(feature = "parallel")]
609    if phi.len() >= (1 << SANDWICH_MIN_PAR_QUBITS) {
610        use rayon::prelude::*;
611        let chunk = crate::backend::MIN_PAR_ELEMS;
612        let (z, g) = phi
613            .par_chunks(chunk)
614            .enumerate()
615            .fold(zeros, |mut acc, (c, block)| {
616                accumulate(&mut acc.0, &mut acc.1, c * chunk, block.len());
617                acc
618            })
619            .reduce(zeros, |mut a, b| {
620                for (slot, v) in a.0.iter_mut().zip(b.0) {
621                    *slot += v;
622                }
623                for (slot, v) in a.1.iter_mut().zip(b.1) {
624                    *slot += v;
625                }
626                a
627            });
628        return finish_sandwiches(masks, &z, &g);
629    }
630
631    accumulate(&mut z_sum, &mut g_sum, 0, phi.len());
632    finish_sandwiches(masks, &z_sum, &g_sum)
633}
634
635/// Interleave the two sandwich accumulator families back into mask order, the
636/// [`finish_expectations`] split applied to the unnormalized complex values.
637fn finish_sandwiches(
638    masks: &[(usize, usize, u32)],
639    z_sum: &[Complex64],
640    g_sum: &[Complex64],
641) -> Vec<Complex64> {
642    let (mut zi, mut gi) = (0, 0);
643    masks
644        .iter()
645        .map(|&(xmask, _, num_y)| {
646            let raw = if xmask == 0 {
647                zi += 1;
648                z_sum[zi - 1]
649            } else {
650                gi += 1;
651                g_sum[gi - 1]
652            };
653            raw * i_pow(num_y)
654        })
655        .collect()
656}
657
658/// `i^{num_y}`, the phase a joint Pauli picks up from its Y factors.
659#[inline]
660pub(crate) fn i_pow(num_y: u32) -> Complex64 {
661    match num_y % 4 {
662        0 => Complex64::new(1.0, 0.0),
663        1 => Complex64::new(0.0, 1.0),
664        2 => Complex64::new(-1.0, 0.0),
665        _ => Complex64::new(0.0, -1.0),
666    }
667}
668
669/// Exact `⟨ψ|P|ψ⟩` from the reduced observable masks. Normalization
670/// independent, so raw backend amplitudes are fine.
671pub(crate) fn pauli_expectation_from_masks(
672    state: &[Complex64],
673    xmask: usize,
674    zmask: usize,
675    num_y: u32,
676    norm: f64,
677) -> f64 {
678    if norm == 0.0 {
679        return 0.0;
680    }
681    pauli_sandwich(state, state, xmask, zmask, num_y).re / norm
682}
683
684/// Exact `⟨ψ|P_i|ψ⟩` for every mask triple in one traversal of `state`.
685///
686/// Same value as [`pauli_expectation_from_masks`] per entry, to within the
687/// association of the sum. A Z-only observable has `xmask == 0` and therefore
688/// no Y factor, so its contribution is `±|amp|^2` and needs neither the partner
689/// load nor complex arithmetic; the two families are accumulated separately for
690/// that reason.
691pub(crate) fn pauli_expectations_from_masks(
692    state: &[Complex64],
693    masks: &[(usize, usize, u32)],
694    norm: f64,
695) -> Vec<f64> {
696    if norm == 0.0 {
697        return vec![0.0; masks.len()];
698    }
699    if masks.len() < 2 {
700        return masks
701            .iter()
702            .map(|&(xmask, zmask, num_y)| {
703                pauli_expectation_from_masks(state, xmask, zmask, num_y, norm)
704            })
705            .collect();
706    }
707
708    let z_only: Vec<usize> = masks
709        .iter()
710        .filter(|&&(xmask, _, _)| xmask == 0)
711        .map(|&(_, zmask, _)| zmask)
712        .collect();
713    let general: Vec<(usize, usize)> = masks
714        .iter()
715        .filter(|&&(xmask, _, _)| xmask != 0)
716        .map(|&(xmask, zmask, _)| (xmask, zmask))
717        .collect();
718
719    let accumulate = |z_acc: &mut [f64], g_acc: &mut [Complex64], base: usize, len: usize| {
720        for j in base..base + len {
721            let amp = state[j];
722            let n2 = amp.norm_sqr();
723            for (slot, &zmask) in z_acc.iter_mut().zip(z_only.iter()) {
724                *slot += if (j & zmask).count_ones() & 1 == 1 {
725                    -n2
726                } else {
727                    n2
728                };
729            }
730            for (slot, &(xmask, zmask)) in g_acc.iter_mut().zip(general.iter()) {
731                let partner = state[j ^ xmask];
732                let sign = if (j & zmask).count_ones() & 1 == 1 {
733                    -1.0
734                } else {
735                    1.0
736                };
737                *slot += partner.conj() * amp * sign;
738            }
739        }
740    };
741
742    let zeros = || {
743        (
744            vec![0.0f64; z_only.len()],
745            vec![Complex64::new(0.0, 0.0); general.len()],
746        )
747    };
748    let (mut z_sum, mut g_sum) = zeros();
749
750    #[cfg(feature = "parallel")]
751    if state.len() >= crate::backend::MIN_PAR_REDUCE_ELEMS {
752        use rayon::prelude::*;
753        let chunk = crate::backend::MIN_PAR_ELEMS;
754        let (z, g) = state
755            .par_chunks(chunk)
756            .enumerate()
757            .fold(zeros, |mut acc, (c, block)| {
758                accumulate(&mut acc.0, &mut acc.1, c * chunk, block.len());
759                acc
760            })
761            .reduce(zeros, |mut a, b| {
762                for (slot, v) in a.0.iter_mut().zip(b.0) {
763                    *slot += v;
764                }
765                for (slot, v) in a.1.iter_mut().zip(b.1) {
766                    *slot += v;
767                }
768                a
769            });
770        return finish_expectations(masks, &z, &g, norm);
771    }
772
773    accumulate(&mut z_sum, &mut g_sum, 0, state.len());
774    finish_expectations(masks, &z_sum, &g_sum, norm)
775}
776
777/// Interleave the two accumulator families back into observable order.
778///
779/// Entries of `z_sum` and `g_sum` are in `masks` order within their family:
780/// the `i`-th `xmask == 0` entry of `masks` reads `z_sum[i]`, and the `i`-th
781/// `xmask != 0` entry reads `g_sum[i]`.
782pub(crate) fn finish_expectations(
783    masks: &[(usize, usize, u32)],
784    z_sum: &[f64],
785    g_sum: &[Complex64],
786    norm: f64,
787) -> Vec<f64> {
788    let (mut zi, mut gi) = (0, 0);
789    masks
790        .iter()
791        .map(|&(xmask, _, num_y)| {
792            if xmask == 0 {
793                zi += 1;
794                z_sum[zi - 1] / norm
795            } else {
796                gi += 1;
797                (g_sum[gi - 1] * i_pow(num_y)).re / norm
798            }
799        })
800        .collect()
801}