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::error::{PrismError, Result};
12use crate::sim::RunMetadata;
13use crate::sim::unified_pauli::{PauliAxis, PauliTerm};
14
15/// Weighted sum of joint Pauli observables, `H = sum_k c_k P_k`.
16///
17/// Terms are kept canonical: factors sorted by qubit, identical Pauli strings
18/// merged by summing coefficients, terms ordered by string. An empty factor
19/// list is the identity and contributes its coefficient as a constant offset.
20/// The qubit-wise-commuting grouping the grouped evaluation route uses is
21/// computed lazily and cached; mutation invalidates the cache.
22#[derive(Debug, Clone, Default)]
23pub struct PauliObservable {
24    terms: Vec<(f64, Vec<PauliTerm>)>,
25    grouping: OnceLock<Grouping>,
26}
27
28impl PauliObservable {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Build from `(coefficient, factors)` pairs, the Hamiltonian shape
34    /// [`Simulate::expectation_gradient`] takes.
35    ///
36    /// [`Simulate::expectation_gradient`]: crate::sim::Simulate::expectation_gradient
37    pub fn from_terms(terms: impl IntoIterator<Item = (f64, Vec<PauliTerm>)>) -> Result<Self> {
38        let mut observable = Self::new();
39        for (coefficient, factors) in terms {
40            observable.add_term(coefficient, factors)?;
41        }
42        Ok(observable)
43    }
44
45    /// Add `coefficient` times the Pauli string `factors`, merging into an
46    /// existing term with the same string.
47    ///
48    /// # Errors
49    /// Rejects a non-finite coefficient and duplicate factors on one qubit.
50    pub fn add_term(&mut self, coefficient: f64, mut factors: Vec<PauliTerm>) -> Result<()> {
51        if !coefficient.is_finite() {
52            return Err(PrismError::InvalidParameter {
53                message: format!("observable coefficient {coefficient} is not finite"),
54            });
55        }
56        factors.sort_unstable_by_key(|term| term.qubit);
57        if let Some(pair) = factors
58            .windows(2)
59            .find(|pair| pair[0].qubit == pair[1].qubit)
60        {
61            return Err(PrismError::InvalidParameter {
62                message: format!(
63                    "joint Pauli observable has duplicate factor on qubit {}",
64                    pair[0].qubit
65                ),
66            });
67        }
68        self.merge_term(coefficient, factors);
69        Ok(())
70    }
71
72    fn merge_term(&mut self, coefficient: f64, factors: Vec<PauliTerm>) {
73        match self
74            .terms
75            .binary_search_by(|(_, existing)| existing.as_slice().cmp(&factors))
76        {
77            Ok(i) => self.terms[i].0 += coefficient,
78            Err(i) => self.terms.insert(i, (coefficient, factors)),
79        }
80        self.grouping = OnceLock::new();
81    }
82
83    /// Canonical `(coefficient, factors)` pairs, ordered by Pauli string.
84    pub fn terms(&self) -> &[(f64, Vec<PauliTerm>)] {
85        &self.terms
86    }
87
88    pub fn num_terms(&self) -> usize {
89        self.terms.len()
90    }
91
92    /// Number of qubit-wise-commuting groups, computing the grouping if
93    /// needed. Identity terms belong to no group.
94    pub fn num_groups(&self) -> usize {
95        self.grouping().groups.len()
96    }
97
98    pub(crate) fn grouping(&self) -> &Grouping {
99        self.grouping.get_or_init(|| compute_grouping(&self.terms))
100    }
101
102    /// The constant term's coefficient and the rest of the sum.
103    ///
104    /// `Var(H + cI) = Var(H)`, so a variance squares the traceless part rather
105    /// than the whole sum: at a large `c` the constant dominates both `<H^2>`
106    /// and `<H>^2` and the difference loses the spread it was meant to report.
107    pub fn split_identity(&self) -> (f64, PauliObservable) {
108        let mut offset = 0.0;
109        let mut rest = PauliObservable::new();
110        for (coefficient, string) in &self.terms {
111            if string.is_empty() {
112                offset += coefficient;
113            } else {
114                rest.merge_term(*coefficient, string.clone());
115            }
116        }
117        (offset, rest)
118    }
119
120    /// `H^2` as a Pauli sum, the second moment [`Simulate::observable_variance`]
121    /// reads `Var(H) = <H^2> - <H>^2` from.
122    ///
123    /// Every coefficient of the square is real. Two Pauli strings either
124    /// commute, and their product carries no phase, or anticommute, and the
125    /// `(j, k)` and `(k, j)` products carry opposite imaginary phases that
126    /// cancel. Phases are tracked as powers of `i` so that cancellation is
127    /// exact rather than a subtraction of two nearly equal floats.
128    ///
129    /// Costs `T^2` string products over `T` terms, so it suits the tensor
130    /// products and small Hermitian matrices an observable request names
131    /// rather than a molecular Hamiltonian.
132    ///
133    /// [`Simulate::observable_variance`]: crate::sim::Simulate::observable_variance
134    pub fn square(&self) -> PauliObservable {
135        let mut accumulated: BTreeMap<Vec<PauliTerm>, f64> = BTreeMap::new();
136        for (left, left_string) in &self.terms {
137            for (right, right_string) in &self.terms {
138                let (phase, product) = multiply_pauli_strings(left_string, right_string);
139                if phase % 2 == 1 {
140                    continue;
141                }
142                let sign = if phase == 0 { 1.0 } else { -1.0 };
143                *accumulated.entry(product).or_insert(0.0) += sign * left * right;
144            }
145        }
146        let norm = self.terms.iter().map(|(c, _)| c.abs()).sum::<f64>();
147        let tolerance = f64::EPSILON * norm * norm * self.terms.len().max(1) as f64;
148        let mut squared = PauliObservable::new();
149        for (string, coefficient) in accumulated {
150            if coefficient.abs() > tolerance {
151                squared.merge_term(coefficient, string);
152            }
153        }
154        squared
155    }
156}
157
158/// Product of two sorted Pauli strings as `(power of i, string)`.
159fn multiply_pauli_strings(left: &[PauliTerm], right: &[PauliTerm]) -> (u32, Vec<PauliTerm>) {
160    let mut phase = 0u32;
161    let mut product = Vec::with_capacity(left.len() + right.len());
162    let (mut i, mut j) = (0, 0);
163    while i < left.len() && j < right.len() {
164        let (a, b) = (left[i], right[j]);
165        match a.qubit.cmp(&b.qubit) {
166            std::cmp::Ordering::Less => {
167                product.push(a);
168                i += 1;
169            }
170            std::cmp::Ordering::Greater => {
171                product.push(b);
172                j += 1;
173            }
174            std::cmp::Ordering::Equal => {
175                if let Some((step, axis)) = multiply_pauli_axes(a.axis, b.axis) {
176                    phase = (phase + step) % 4;
177                    product.push(PauliTerm::new(a.qubit, axis));
178                }
179                i += 1;
180                j += 1;
181            }
182        }
183    }
184    product.extend_from_slice(&left[i..]);
185    product.extend_from_slice(&right[j..]);
186    (phase, product)
187}
188
189/// `a * b` on one qubit as `(power of i, axis)`, `None` when the two axes
190/// agree and the product is the identity.
191fn multiply_pauli_axes(a: PauliAxis, b: PauliAxis) -> Option<(u32, PauliAxis)> {
192    use PauliAxis::{X, Y, Z};
193    match (a, b) {
194        (X, Y) => Some((1, Z)),
195        (Y, Z) => Some((1, X)),
196        (Z, X) => Some((1, Y)),
197        (Y, X) => Some((3, Z)),
198        (Z, Y) => Some((3, X)),
199        (X, Z) => Some((3, Y)),
200        _ => None,
201    }
202}
203
204impl std::ops::Add for PauliObservable {
205    type Output = PauliObservable;
206
207    fn add(mut self, rhs: PauliObservable) -> PauliObservable {
208        for (coefficient, factors) in rhs.terms {
209            self.merge_term(coefficient, factors);
210        }
211        self
212    }
213}
214
215impl std::ops::Sub for PauliObservable {
216    type Output = PauliObservable;
217
218    fn sub(self, rhs: PauliObservable) -> PauliObservable {
219        self + (-rhs)
220    }
221}
222
223impl std::ops::Neg for PauliObservable {
224    type Output = PauliObservable;
225
226    fn neg(mut self) -> PauliObservable {
227        for (coefficient, _) in &mut self.terms {
228            *coefficient = -*coefficient;
229        }
230        self
231    }
232}
233
234impl std::ops::Mul<f64> for PauliObservable {
235    type Output = PauliObservable;
236
237    fn mul(mut self, rhs: f64) -> PauliObservable {
238        for (coefficient, _) in &mut self.terms {
239            *coefficient *= rhs;
240        }
241        self
242    }
243}
244
245/// Weighted-observable expectation with the grouped-measurement variance.
246#[derive(Debug, Clone)]
247pub struct ObservableExpectation {
248    /// `<H> = sum_k c_k <P_k>`, including identity-term constants.
249    pub mean: f64,
250    /// Sum of per-group variances `Var(H_g) = <H_g^2> - <H_g>^2`, each exact
251    /// in the output state. This is the variance of a grouped measurement
252    /// estimate drawing one shot per commuting group; with `S_g` shots on
253    /// group `g` the estimator variance is `sum_g Var(H_g) / S_g`. It equals
254    /// `Var(H)` of the full operator only when one group covers every term,
255    /// since cross-group covariances are excluded. `None` on a route that
256    /// evaluates term by term without the grouped traversal.
257    pub variance: Option<f64>,
258    /// Per-group `Var(H_g)` in grouping order, the input to shot allocation.
259    pub group_variances: Option<Vec<f64>>,
260    /// Standard error of `mean` when a sampling route estimated the per-term
261    /// values, `None` for analytic routes.
262    pub std_error: Option<f64>,
263    pub metadata: RunMetadata,
264}
265
266/// Qubit-wise-commuting grouping over an observable's non-identity terms.
267#[derive(Debug, Clone)]
268pub(crate) struct Grouping {
269    pub(crate) groups: Vec<QwcGroup>,
270}
271
272/// One commuting set: member term indices plus per-qubit axis-assignment
273/// words. A qubit's assigned axis is X when only its `axis_x` bit is set, Z
274/// when only `axis_z`, Y when both; unset bits are unconstrained.
275#[derive(Debug, Clone)]
276pub(crate) struct QwcGroup {
277    pub(crate) term_indices: Vec<usize>,
278    axis_x: Vec<u64>,
279    axis_z: Vec<u64>,
280}
281
282impl QwcGroup {
283    fn accepts(&self, tx: &[u64], tz: &[u64]) -> bool {
284        for w in 0..tx.len() {
285            let shared = (tx[w] | tz[w]) & (self.axis_x[w] | self.axis_z[w]);
286            if ((tx[w] ^ self.axis_x[w]) | (tz[w] ^ self.axis_z[w])) & shared != 0 {
287                return false;
288            }
289        }
290        true
291    }
292
293    fn absorb(&mut self, index: usize, tx: &[u64], tz: &[u64]) {
294        for w in 0..tx.len() {
295            self.axis_x[w] |= tx[w];
296            self.axis_z[w] |= tz[w];
297        }
298        self.term_indices.push(index);
299    }
300
301    /// Whether every assigned axis is Z, so members evaluate on the
302    /// unrotated state.
303    pub(crate) fn is_z_only(&self) -> bool {
304        self.axis_x.iter().all(|&word| word == 0)
305    }
306
307    /// Qubit masks `(x, y)` of the group's X-assigned and Y-assigned qubits.
308    /// [`rotate_to_z_basis`] on them sends each member string to a plus-sign Z
309    /// string on the same support. Statevector widths fit one word.
310    pub(crate) fn rotation_masks(&self) -> (usize, usize) {
311        let (x, z) = (self.axis_x[0], self.axis_z[0]);
312        ((x & !z) as usize, (x & z) as usize)
313    }
314}
315
316fn compute_grouping(terms: &[(f64, Vec<PauliTerm>)]) -> Grouping {
317    let max_qubit = terms
318        .iter()
319        .flat_map(|(_, factors)| factors.iter())
320        .map(|term| term.qubit)
321        .max();
322    let num_words = max_qubit.map_or(0, |q| q / 64 + 1);
323
324    // First-fit-decreasing on factor count, index-stable for determinism.
325    let mut order: Vec<usize> = (0..terms.len())
326        .filter(|&i| !terms[i].1.is_empty())
327        .collect();
328    order.sort_by(|&a, &b| terms[b].1.len().cmp(&terms[a].1.len()).then(a.cmp(&b)));
329
330    let mut groups: Vec<QwcGroup> = Vec::new();
331    let mut tx = vec![0u64; num_words];
332    let mut tz = vec![0u64; num_words];
333    for &index in &order {
334        tx.fill(0);
335        tz.fill(0);
336        for term in &terms[index].1 {
337            let bit = 1u64 << (term.qubit % 64);
338            match term.axis {
339                PauliAxis::X => tx[term.qubit / 64] |= bit,
340                PauliAxis::Z => tz[term.qubit / 64] |= bit,
341                PauliAxis::Y => {
342                    tx[term.qubit / 64] |= bit;
343                    tz[term.qubit / 64] |= bit;
344                }
345            }
346        }
347        match groups.iter_mut().find(|group| group.accepts(&tx, &tz)) {
348            Some(group) => group.absorb(index, &tx, &tz),
349            None => groups.push(QwcGroup {
350                term_indices: vec![index],
351                axis_x: tx.clone(),
352                axis_z: tz.clone(),
353            }),
354        }
355    }
356    Grouping { groups }
357}
358
359/// First two moments `(sum_j p_j h(j), sum_j p_j h(j)^2)` of one group
360/// operator `h(j) = sum_i c_i (-1)^popcount(j & z_i)`, normalized by `norm`.
361///
362/// `h` is built a block of [`MOMENT_BLOCK`] indices at a time. Within a block
363/// the high index bits are fixed, so each term reduces to a signed
364/// coefficient on its low-bit pattern, and a Walsh-Hadamard transform over
365/// those patterns yields `h` at every index of the block. That costs one
366/// scalar step per term per block plus `log2(MOMENT_BLOCK)` butterflies per
367/// index, where a per-index sum over terms costs a parity and an add per term
368/// per index.
369pub(crate) fn weighted_group_moments(
370    state: &[Complex64],
371    zmasks: &[usize],
372    coefficients: &[f64],
373    norm: f64,
374) -> (f64, f64) {
375    if norm == 0.0 {
376        return (0.0, 0.0);
377    }
378
379    let accumulate = |acc: &mut (f64, f64), base: usize, chunk: &[Complex64]| {
380        for (b, block) in chunk.chunks(MOMENT_BLOCK).enumerate() {
381            let block_base = base + b * MOMENT_BLOCK;
382            let mut h = [0.0f64; MOMENT_BLOCK];
383            for (&zmask, &c) in zmasks.iter().zip(coefficients) {
384                let flip = u64::from((block_base & zmask).count_ones() & 1) << 63;
385                h[zmask & (MOMENT_BLOCK - 1)] += f64::from_bits(c.to_bits() ^ flip);
386            }
387            let mut half = 1;
388            while half < MOMENT_BLOCK {
389                for pair in h.chunks_exact_mut(2 * half) {
390                    let (lo, hi) = pair.split_at_mut(half);
391                    for (a, b) in lo.iter_mut().zip(hi) {
392                        (*a, *b) = (*a + *b, *a - *b);
393                    }
394                }
395                half *= 2;
396            }
397            for (amp, &hj) in block.iter().zip(&h) {
398                let weighted = amp.norm_sqr() * hj;
399                acc.0 += weighted;
400                acc.1 += weighted * hj;
401            }
402        }
403    };
404
405    #[cfg(feature = "parallel")]
406    if state.len() >= crate::backend::MIN_PAR_REDUCE_ELEMS {
407        use rayon::prelude::*;
408        let chunk = crate::backend::MIN_PAR_ELEMS.max(MOMENT_BLOCK);
409        let (m1, m2) = state
410            .par_chunks(chunk)
411            .enumerate()
412            .fold(
413                || (0.0, 0.0),
414                |mut acc, (c, block)| {
415                    accumulate(&mut acc, c * chunk, block);
416                    acc
417                },
418            )
419            .reduce(|| (0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
420        return (m1 / norm, m2 / norm);
421    }
422
423    let mut acc = (0.0, 0.0);
424    accumulate(&mut acc, 0, state);
425    (acc.0 / norm, acc.1 / norm)
426}
427
428/// Indices per Walsh-Hadamard block in [`weighted_group_moments`]. A state
429/// shorter than a block reads only the leading entries, which are exact
430/// because every mask then fits below the state length.
431const MOMENT_BLOCK: usize = 64;
432
433/// Copy `state` into `out` rotated so each qubit in `x_bits` reads in the X
434/// basis and each in `y_bits` in the Y basis, as `H` and `H S†` do. The
435/// butterflies drop the `1/sqrt(2)`, so the squared norm of `out` is that of
436/// `state` times `2^popcount(x_bits | y_bits)`, an exact power of two for the
437/// caller to fold into the moments' `norm`.
438///
439/// Qubits below `log2(`[`ROTATION_BLOCK`]`)` rotate a block at a time while
440/// the block is copied in and still in L1. The rest take one sweep per pair of
441/// qubits, each pair a radix-4 butterfly that loads and stores an amplitude
442/// once for two levels.
443pub(crate) fn rotate_to_z_basis(
444    state: &[Complex64],
445    out: &mut Vec<Complex64>,
446    x_bits: usize,
447    y_bits: usize,
448) {
449    out.resize(state.len(), Complex64::ZERO);
450    let block = ROTATION_BLOCK.min(state.len());
451    let levels = |range: std::ops::Range<usize>| {
452        range
453            .filter(|&q| (x_bits | y_bits) >> q & 1 == 1)
454            .map(|q| (q, y_bits >> q & 1 == 1))
455            .collect::<Vec<_>>()
456    };
457    let low = levels(0..block.trailing_zeros() as usize);
458    let high = levels(block.trailing_zeros() as usize..state.len().trailing_zeros() as usize);
459
460    let fill = |(dst, src): (&mut [Complex64], &[Complex64])| {
461        dst.copy_from_slice(src);
462        rotate_levels(dst, &low);
463    };
464
465    #[cfg(feature = "parallel")]
466    if state.len() >= 1 << crate::backend::PARALLEL_THRESHOLD_QUBITS {
467        use rayon::prelude::*;
468        out.par_chunks_mut(block)
469            .zip(state.par_chunks(block))
470            .for_each(fill);
471        for pair in high.chunks(2) {
472            rotate_pair_par(out, pair);
473        }
474        return;
475    }
476
477    out.chunks_mut(block)
478        .zip(state.chunks(block))
479        .for_each(fill);
480    rotate_levels(out, &high);
481}
482
483/// Amplitudes per block in [`rotate_to_z_basis`], 16 KiB.
484const ROTATION_BLOCK: usize = 1 << 10;
485
486/// `-i b` for a Y level, `b` for an X level: the second column of the
487/// unnormalized `H` or `H S†`.
488#[inline(always)]
489fn twiddle<const Y: bool>(b: Complex64) -> Complex64 {
490    if Y { Complex64::new(b.im, -b.re) } else { b }
491}
492
493#[inline(always)]
494fn butterfly2<const Y: bool>(lo: &mut [Complex64], hi: &mut [Complex64]) {
495    for (a, b) in lo.iter_mut().zip(hi) {
496        let t = twiddle::<Y>(*b);
497        (*a, *b) = (*a + t, *a - t);
498    }
499}
500
501/// Levels `q1 < q2` on one amplitude from each quarter, the quarters indexed
502/// by the two target bits as `00 01 10 11`.
503#[inline(always)]
504fn quad<const Y1: bool, const Y2: bool>(
505    a: &mut Complex64,
506    b: &mut Complex64,
507    c: &mut Complex64,
508    d: &mut Complex64,
509) {
510    let (tb, td) = (twiddle::<Y1>(*b), twiddle::<Y1>(*d));
511    let (a1, b1, c1, d1) = (*a + tb, *a - tb, *c + td, *c - td);
512    let (tc, td) = (twiddle::<Y2>(c1), twiddle::<Y2>(d1));
513    (*a, *b, *c, *d) = (a1 + tc, b1 + td, a1 - tc, b1 - td);
514}
515
516#[inline(always)]
517fn butterfly4<const Y1: bool, const Y2: bool>(
518    a: &mut [Complex64],
519    b: &mut [Complex64],
520    c: &mut [Complex64],
521    d: &mut [Complex64],
522) {
523    let n = a.len();
524    let (b, c, d) = (&mut b[..n], &mut c[..n], &mut d[..n]);
525    for i in 0..n {
526        quad::<Y1, Y2>(&mut a[i], &mut b[i], &mut c[i], &mut d[i]);
527    }
528}
529
530/// Apply ascending `(qubit, is_y)` levels to `amps`, pairing levels into
531/// radix-4 sweeps from [`MIN_RADIX4_QUBIT`] up.
532fn rotate_levels(amps: &mut [Complex64], levels: &[(usize, bool)]) {
533    let mut rest = levels;
534    while let Some((&(q1, y1), tail)) = rest.split_first() {
535        match tail.first() {
536            Some(&(q2, y2)) if q1 >= MIN_RADIX4_QUBIT || (q1, q2) == (0, 1) => {
537                match (y1, y2) {
538                    (false, false) => rotate4::<false, false>(amps, q1, q2),
539                    (false, true) => rotate4::<false, true>(amps, q1, q2),
540                    (true, false) => rotate4::<true, false>(amps, q1, q2),
541                    (true, true) => rotate4::<true, true>(amps, q1, q2),
542                }
543                rest = &tail[1..];
544            }
545            _ => {
546                if y1 {
547                    rotate2::<true>(amps, q1);
548                } else {
549                    rotate2::<false>(amps, q1);
550                }
551                rest = tail;
552            }
553        }
554    }
555}
556
557/// Lowest qubit a radix-4 sweep starts from, apart from qubits 0 and 1, which
558/// pair as fixed groups of four. Starting at qubit 1 or 2 leaves quarters of 2
559/// or 4 amplitudes, and those sweeps measured slower than two radix-2 sweeps.
560const MIN_RADIX4_QUBIT: usize = 3;
561
562fn rotate2<const Y: bool>(amps: &mut [Complex64], q: usize) {
563    for pair in amps.chunks_exact_mut(2 << q) {
564        let (lo, hi) = pair.split_at_mut(1 << q);
565        butterfly2::<Y>(lo, hi);
566    }
567}
568
569fn rotate4<const Y1: bool, const Y2: bool>(amps: &mut [Complex64], q1: usize, q2: usize) {
570    if (q1, q2) == (0, 1) {
571        for [a, b, c, d] in amps.as_chunks_mut::<4>().0 {
572            quad::<Y1, Y2>(a, b, c, d);
573        }
574        return;
575    }
576    for quad in amps.chunks_exact_mut(2 << q2) {
577        let (lo, hi) = quad.split_at_mut(1 << q2);
578        for (l, h) in lo
579            .chunks_exact_mut(2 << q1)
580            .zip(hi.chunks_exact_mut(2 << q1))
581        {
582            let (a, b) = l.split_at_mut(1 << q1);
583            let (c, d) = h.split_at_mut(1 << q1);
584            butterfly4::<Y1, Y2>(a, b, c, d);
585        }
586    }
587}
588
589/// [`rotate_levels`] on one or two levels at or above `log2(`[`ROTATION_BLOCK`]`)`,
590/// split into runs of [`MIN_PAR_ELEMS`](crate::backend::MIN_PAR_ELEMS) across
591/// the pool.
592#[cfg(feature = "parallel")]
593fn rotate_pair_par(amps: &mut [Complex64], levels: &[(usize, bool)]) {
594    use rayon::prelude::*;
595    let run = crate::backend::MIN_PAR_ELEMS;
596    match *levels {
597        [(q, y)] => amps.par_chunks_mut(2 << q).for_each(|pair| {
598            let (lo, hi) = pair.split_at_mut(1 << q);
599            lo.par_chunks_mut(run)
600                .zip(hi.par_chunks_mut(run))
601                .for_each(|(lo, hi)| {
602                    if y {
603                        butterfly2::<true>(lo, hi)
604                    } else {
605                        butterfly2::<false>(lo, hi)
606                    }
607                });
608        }),
609        [(q1, y1), (q2, y2)] => amps.par_chunks_mut(2 << q2).for_each(|quad| {
610            let (lo, hi) = quad.split_at_mut(1 << q2);
611            lo.par_chunks_mut(2 << q1)
612                .zip(hi.par_chunks_mut(2 << q1))
613                .for_each(|(l, h)| {
614                    let (a, b) = l.split_at_mut(1 << q1);
615                    let (c, d) = h.split_at_mut(1 << q1);
616                    a.par_chunks_mut(run)
617                        .zip(b.par_chunks_mut(run))
618                        .zip(c.par_chunks_mut(run))
619                        .zip(d.par_chunks_mut(run))
620                        .for_each(|(((a, b), c), d)| match (y1, y2) {
621                            (false, false) => butterfly4::<false, false>(a, b, c, d),
622                            (false, true) => butterfly4::<false, true>(a, b, c, d),
623                            (true, false) => butterfly4::<true, false>(a, b, c, d),
624                            (true, true) => butterfly4::<true, true>(a, b, c, d),
625                        });
626                });
627        }),
628        _ => unreachable!(),
629    }
630}
631
632#[cfg(test)]
633#[path = "observable_tests.rs"]
634mod tests;
635
636/// Reject out-of-range qubits and duplicate factors in a joint Pauli
637/// observable.
638///
639/// Same checks [`pauli_masks`] makes, without its `1 << qubit` mask width, so
640/// it also covers the backends that run past 64 qubits.
641pub(crate) fn validate_observable(observable: &[PauliTerm], num_qubits: usize) -> Result<()> {
642    let mut seen = vec![false; num_qubits];
643    for term in observable {
644        if term.qubit >= num_qubits {
645            return Err(PrismError::InvalidQubit {
646                index: term.qubit,
647                register_size: num_qubits,
648            });
649        }
650        if seen[term.qubit] {
651            return Err(PrismError::InvalidParameter {
652                message: format!(
653                    "joint Pauli observable has duplicate factor on qubit {}",
654                    term.qubit
655                ),
656            });
657        }
658        seen[term.qubit] = true;
659    }
660    Ok(())
661}
662
663/// Validate a joint Pauli observable and reduce it to `(Xmask, Zmask, #Y)`,
664/// where `Xmask` covers X and Y factors and `Zmask` covers Z and Y factors.
665pub(crate) fn pauli_masks(
666    observable: &[PauliTerm],
667    num_qubits: usize,
668) -> Result<(usize, usize, u32)> {
669    let mut xmask = 0usize;
670    let mut zmask = 0usize;
671    let mut num_y = 0u32;
672    let mut seen = vec![false; num_qubits];
673    for term in observable {
674        if term.qubit >= num_qubits {
675            return Err(PrismError::InvalidQubit {
676                index: term.qubit,
677                register_size: num_qubits,
678            });
679        }
680        if seen[term.qubit] {
681            return Err(PrismError::InvalidParameter {
682                message: format!(
683                    "joint Pauli observable has duplicate factor on qubit {}",
684                    term.qubit
685                ),
686            });
687        }
688        seen[term.qubit] = true;
689        let bit = 1usize << term.qubit;
690        match term.axis {
691            PauliAxis::X => xmask |= bit,
692            PauliAxis::Z => zmask |= bit,
693            PauliAxis::Y => {
694                xmask |= bit;
695                zmask |= bit;
696                num_y += 1;
697            }
698        }
699    }
700    Ok((xmask, zmask, num_y))
701}
702
703/// Rayon fan-out threshold for the sandwich reductions. Higher than the gate
704/// kernels': a sandwich is a single lightweight O(N) reduction, so fan-out only
705/// pays off past 2^16 elements. Below that (and for a multi-term Hamiltonian's
706/// many small reductions) the sequential path is faster.
707#[cfg(feature = "parallel")]
708const SANDWICH_MIN_PAR_QUBITS: usize = 16;
709
710/// Complex Pauli sandwich `⟨λ|P|φ⟩`, where `P` acts as
711/// `P|j⟩ = i^{#Y}·(-1)^{popcount(j & Zmask)}·|j ⊕ Xmask⟩`. Returns the raw
712/// (unnormalized) complex value. The adjoint gradient engine uses this with
713/// distinct `λ` and `φ`; `pauli_expectation_from_masks` is the `λ = φ` case.
714///
715/// Inlined explicitly: the single-mask fallback of
716/// [`pauli_sandwiches_from_masks`], [`pauli_expectation_from_masks`] and the
717/// distributed backend all reduce through this, and leaving the decision to LTO
718/// ties it to how many callers the function happens to have.
719#[inline]
720pub(crate) fn pauli_sandwich(
721    lambda: &[Complex64],
722    phi: &[Complex64],
723    xmask: usize,
724    zmask: usize,
725    num_y: u32,
726) -> Complex64 {
727    let term = |j: usize, amp: Complex64| {
728        let partner = lambda[j ^ xmask];
729        let sign = if (j & zmask).count_ones() & 1 == 1 {
730            -1.0
731        } else {
732            1.0
733        };
734        partner.conj() * amp * sign
735    };
736
737    #[cfg(feature = "parallel")]
738    let acc: Complex64 = if phi.len() >= (1 << SANDWICH_MIN_PAR_QUBITS) {
739        use rayon::prelude::*;
740        phi.par_iter()
741            .enumerate()
742            .map(|(j, &amp)| term(j, amp))
743            .sum()
744    } else {
745        phi.iter().enumerate().map(|(j, &amp)| term(j, amp)).sum()
746    };
747    #[cfg(not(feature = "parallel"))]
748    let acc: Complex64 = phi.iter().enumerate().map(|(j, &amp)| term(j, amp)).sum();
749
750    acc * i_pow(num_y)
751}
752
753/// Complex Pauli sandwiches `⟨λ|P_i|φ⟩` for every mask triple in one traversal
754/// of the pair.
755///
756/// Same value as [`pauli_sandwich`] per entry, to within the association of the
757/// sum. A mask with `xmask == 0` reads `λ` at the loop index rather than at a
758/// partner index, so the two families are accumulated separately as in
759/// [`pauli_expectations_from_masks`].
760pub(crate) fn pauli_sandwiches_from_masks(
761    lambda: &[Complex64],
762    phi: &[Complex64],
763    masks: &[(usize, usize, u32)],
764) -> Vec<Complex64> {
765    if masks.len() < 2 {
766        return masks
767            .iter()
768            .map(|&(xmask, zmask, num_y)| pauli_sandwich(lambda, phi, xmask, zmask, num_y))
769            .collect();
770    }
771
772    let z_only: Vec<usize> = masks
773        .iter()
774        .filter(|&&(xmask, _, _)| xmask == 0)
775        .map(|&(_, zmask, _)| zmask)
776        .collect();
777    let general: Vec<(usize, usize)> = masks
778        .iter()
779        .filter(|&&(xmask, _, _)| xmask != 0)
780        .map(|&(xmask, zmask, _)| (xmask, zmask))
781        .collect();
782
783    let accumulate = |z_acc: &mut [Complex64], g_acc: &mut [Complex64], base: usize, len: usize| {
784        for j in base..base + len {
785            let amp = phi[j];
786            let aligned = lambda[j].conj() * amp;
787            for (slot, &zmask) in z_acc.iter_mut().zip(z_only.iter()) {
788                *slot += if (j & zmask).count_ones() & 1 == 1 {
789                    -aligned
790                } else {
791                    aligned
792                };
793            }
794            for (slot, &(xmask, zmask)) in g_acc.iter_mut().zip(general.iter()) {
795                let partner = lambda[j ^ xmask];
796                let sign = if (j & zmask).count_ones() & 1 == 1 {
797                    -1.0
798                } else {
799                    1.0
800                };
801                *slot += partner.conj() * amp * sign;
802            }
803        }
804    };
805
806    let zeros = || {
807        (
808            vec![Complex64::new(0.0, 0.0); z_only.len()],
809            vec![Complex64::new(0.0, 0.0); general.len()],
810        )
811    };
812    let (mut z_sum, mut g_sum) = zeros();
813
814    #[cfg(feature = "parallel")]
815    if phi.len() >= (1 << SANDWICH_MIN_PAR_QUBITS) {
816        use rayon::prelude::*;
817        let chunk = crate::backend::MIN_PAR_ELEMS;
818        let (z, g) = phi
819            .par_chunks(chunk)
820            .enumerate()
821            .fold(zeros, |mut acc, (c, block)| {
822                accumulate(&mut acc.0, &mut acc.1, c * chunk, block.len());
823                acc
824            })
825            .reduce(zeros, |mut a, b| {
826                for (slot, v) in a.0.iter_mut().zip(b.0) {
827                    *slot += v;
828                }
829                for (slot, v) in a.1.iter_mut().zip(b.1) {
830                    *slot += v;
831                }
832                a
833            });
834        return finish_sandwiches(masks, &z, &g);
835    }
836
837    accumulate(&mut z_sum, &mut g_sum, 0, phi.len());
838    finish_sandwiches(masks, &z_sum, &g_sum)
839}
840
841/// Interleave the two sandwich accumulator families back into mask order, the
842/// [`finish_expectations`] split applied to the unnormalized complex values.
843fn finish_sandwiches(
844    masks: &[(usize, usize, u32)],
845    z_sum: &[Complex64],
846    g_sum: &[Complex64],
847) -> Vec<Complex64> {
848    let (mut zi, mut gi) = (0, 0);
849    masks
850        .iter()
851        .map(|&(xmask, _, num_y)| {
852            let raw = if xmask == 0 {
853                zi += 1;
854                z_sum[zi - 1]
855            } else {
856                gi += 1;
857                g_sum[gi - 1]
858            };
859            raw * i_pow(num_y)
860        })
861        .collect()
862}
863
864/// `i^{num_y}`, the phase a joint Pauli picks up from its Y factors.
865#[inline]
866pub(crate) fn i_pow(num_y: u32) -> Complex64 {
867    match num_y % 4 {
868        0 => Complex64::new(1.0, 0.0),
869        1 => Complex64::new(0.0, 1.0),
870        2 => Complex64::new(-1.0, 0.0),
871        _ => Complex64::new(0.0, -1.0),
872    }
873}
874
875/// Exact `⟨ψ|P|ψ⟩` from the reduced observable masks. Normalization
876/// independent, so raw backend amplitudes are fine.
877pub(crate) fn pauli_expectation_from_masks(
878    state: &[Complex64],
879    xmask: usize,
880    zmask: usize,
881    num_y: u32,
882    norm: f64,
883) -> f64 {
884    if norm == 0.0 {
885        return 0.0;
886    }
887    pauli_sandwich(state, state, xmask, zmask, num_y).re / norm
888}
889
890/// Exact `⟨ψ|P_i|ψ⟩` for every mask triple in one traversal of `state`, and the
891/// norm `Σ|amp|^2` the values were divided by, read in that same traversal.
892///
893/// Same value as [`pauli_expectation_from_masks`] per entry, to within the
894/// association of the sum. A Z-only observable has `xmask == 0` and therefore
895/// no Y factor, so its contribution is `±|amp|^2` and needs neither the partner
896/// load nor complex arithmetic; the two families are accumulated separately for
897/// that reason.
898pub(crate) fn pauli_expectations_from_masks(
899    state: &[Complex64],
900    masks: &[(usize, usize, u32)],
901) -> (Vec<f64>, f64) {
902    if masks.is_empty() {
903        return (Vec::new(), crate::backend::state_norm_sqr(state));
904    }
905    let (z_sum, g_sum, norm) = expectation_sums(state, masks);
906    if norm == 0.0 {
907        return (vec![0.0; masks.len()], norm);
908    }
909    (finish_expectations(masks, &z_sum, &g_sum, norm), norm)
910}
911
912/// Unnormalized sums of the two families [`finish_expectations`] interleaves,
913/// and `Σ|amp|^2`.
914fn expectation_sums(
915    state: &[Complex64],
916    masks: &[(usize, usize, u32)],
917) -> (Vec<f64>, Vec<Complex64>, f64) {
918    let z_only: Vec<usize> = masks
919        .iter()
920        .filter(|&&(xmask, _, _)| xmask == 0)
921        .map(|&(_, zmask, _)| zmask)
922        .collect();
923    let general: Vec<(usize, usize)> = masks
924        .iter()
925        .filter(|&&(xmask, _, _)| xmask != 0)
926        .map(|&(xmask, zmask, _)| (xmask, zmask))
927        .collect();
928
929    let accumulate =
930        |z_acc: &mut [f64], g_acc: &mut [Complex64], norm: &mut f64, base: usize, len: usize| {
931            for j in base..base + len {
932                let amp = state[j];
933                let n2 = amp.norm_sqr();
934                *norm += n2;
935                for (slot, &zmask) in z_acc.iter_mut().zip(z_only.iter()) {
936                    *slot += if (j & zmask).count_ones() & 1 == 1 {
937                        -n2
938                    } else {
939                        n2
940                    };
941                }
942                for (slot, &(xmask, zmask)) in g_acc.iter_mut().zip(general.iter()) {
943                    let partner = state[j ^ xmask];
944                    let sign = if (j & zmask).count_ones() & 1 == 1 {
945                        -1.0
946                    } else {
947                        1.0
948                    };
949                    *slot += partner.conj() * amp * sign;
950                }
951            }
952        };
953
954    let zeros = || {
955        (
956            vec![0.0f64; z_only.len()],
957            vec![Complex64::new(0.0, 0.0); general.len()],
958            0.0f64,
959        )
960    };
961
962    #[cfg(feature = "parallel")]
963    if state.len() >= crate::backend::MIN_PAR_REDUCE_ELEMS {
964        use rayon::prelude::*;
965        let chunk = crate::backend::MIN_PAR_ELEMS;
966        return state
967            .par_chunks(chunk)
968            .enumerate()
969            .fold(zeros, |mut acc, (c, block)| {
970                accumulate(&mut acc.0, &mut acc.1, &mut acc.2, c * chunk, block.len());
971                acc
972            })
973            .reduce(zeros, |mut a, b| {
974                for (slot, v) in a.0.iter_mut().zip(b.0) {
975                    *slot += v;
976                }
977                for (slot, v) in a.1.iter_mut().zip(b.1) {
978                    *slot += v;
979                }
980                a.2 += b.2;
981                a
982            });
983    }
984
985    let mut sums = zeros();
986    accumulate(&mut sums.0, &mut sums.1, &mut sums.2, 0, state.len());
987    sums
988}
989
990/// Interleave the two accumulator families back into observable order.
991///
992/// Entries of `z_sum` and `g_sum` are in `masks` order within their family:
993/// the `i`-th `xmask == 0` entry of `masks` reads `z_sum[i]`, and the `i`-th
994/// `xmask != 0` entry reads `g_sum[i]`.
995pub(crate) fn finish_expectations(
996    masks: &[(usize, usize, u32)],
997    z_sum: &[f64],
998    g_sum: &[Complex64],
999    norm: f64,
1000) -> Vec<f64> {
1001    let (mut zi, mut gi) = (0, 0);
1002    masks
1003        .iter()
1004        .map(|&(xmask, _, num_y)| {
1005            if xmask == 0 {
1006                zi += 1;
1007                z_sum[zi - 1] / norm
1008            } else {
1009                gi += 1;
1010                (g_sum[gi - 1] * i_pow(num_y)).re / norm
1011            }
1012        })
1013        .collect()
1014}