quantrs2_sim/
gpu_observables.rs

1//! GPU-accelerated observable calculations for quantum states
2//!
3//! This module provides high-performance observable computation routines that leverage
4//! GPU acceleration when available, with automatic fallback to optimized CPU implementations.
5//!
6//! # Supported Observables
7//!
8//! - **Pauli Observables**: Single and multi-qubit Pauli strings (X, Y, Z)
9//! - **Hamiltonian Expectation Values**: Efficient evaluation of sums of Pauli operators
10//! - **Variance Calculations**: Observable variance for uncertainty quantification
11//! - **Batched Observables**: Compute multiple expectation values in parallel
12//!
13//! # Example
14//!
15//! ```ignore
16//! use quantrs2_sim::gpu_observables::{PauliObservable, ObservableCalculator};
17//!
18//! let obs = PauliObservable::from_string("XYZ")?;
19//! let calculator = ObservableCalculator::new();
20//! let expectation = calculator.expectation_value(&state, &obs)?;
21//! ```
22
23use crate::error::{Result, SimulatorError};
24use scirs2_core::ndarray::{Array1, Array2};
25use scirs2_core::parallel_ops::{par_chunks, par_join};
26use scirs2_core::Complex64;
27use std::collections::HashMap;
28
29// ============================================================================
30// Pauli Operator Types
31// ============================================================================
32
33/// Single-qubit Pauli operator
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum PauliOp {
36    /// Identity operator
37    I,
38    /// Pauli-X operator
39    X,
40    /// Pauli-Y operator
41    Y,
42    /// Pauli-Z operator
43    Z,
44}
45
46impl PauliOp {
47    /// Parse from character
48    pub fn from_char(c: char) -> Result<Self> {
49        match c {
50            'I' => Ok(PauliOp::I),
51            'X' => Ok(PauliOp::X),
52            'Y' => Ok(PauliOp::Y),
53            'Z' => Ok(PauliOp::Z),
54            _ => Err(SimulatorError::InvalidInput(format!(
55                "Invalid Pauli operator: {}",
56                c
57            ))),
58        }
59    }
60
61    /// Get the 2x2 matrix representation
62    pub fn matrix(&self) -> Array2<Complex64> {
63        match self {
64            PauliOp::I => {
65                let mut m = Array2::zeros((2, 2));
66                m[[0, 0]] = Complex64::new(1.0, 0.0);
67                m[[1, 1]] = Complex64::new(1.0, 0.0);
68                m
69            }
70            PauliOp::X => {
71                let mut m = Array2::zeros((2, 2));
72                m[[0, 1]] = Complex64::new(1.0, 0.0);
73                m[[1, 0]] = Complex64::new(1.0, 0.0);
74                m
75            }
76            PauliOp::Y => {
77                let mut m = Array2::zeros((2, 2));
78                m[[0, 1]] = Complex64::new(0.0, -1.0);
79                m[[1, 0]] = Complex64::new(0.0, 1.0);
80                m
81            }
82            PauliOp::Z => {
83                let mut m = Array2::zeros((2, 2));
84                m[[0, 0]] = Complex64::new(1.0, 0.0);
85                m[[1, 1]] = Complex64::new(-1.0, 0.0);
86                m
87            }
88        }
89    }
90
91    /// Get eigenvalues for this operator
92    pub fn eigenvalues(&self) -> [f64; 2] {
93        match self {
94            PauliOp::I => [1.0, 1.0],
95            PauliOp::X | PauliOp::Y | PauliOp::Z => [1.0, -1.0],
96        }
97    }
98}
99
100/// Multi-qubit Pauli observable (tensor product of single-qubit Paulis)
101#[derive(Debug, Clone, PartialEq)]
102pub struct PauliObservable {
103    /// Pauli operators for each qubit (ordered from qubit 0 to n-1)
104    pub operators: Vec<PauliOp>,
105    /// Coefficient for this Pauli string
106    pub coefficient: Complex64,
107}
108
109impl PauliObservable {
110    /// Create a new Pauli observable from a vector of operators
111    pub fn new(operators: Vec<PauliOp>) -> Self {
112        Self {
113            operators,
114            coefficient: Complex64::new(1.0, 0.0),
115        }
116    }
117
118    /// Create from a Pauli string (e.g., "XYZ" for X⊗Y⊗Z)
119    pub fn from_string(s: &str) -> Result<Self> {
120        let operators: Result<Vec<PauliOp>> = s.chars().map(PauliOp::from_char).collect();
121        Ok(Self::new(operators?))
122    }
123
124    /// Set the coefficient
125    pub fn with_coefficient(mut self, coeff: Complex64) -> Self {
126        self.coefficient = coeff;
127        self
128    }
129
130    /// Set a real coefficient
131    pub fn with_real_coefficient(mut self, coeff: f64) -> Self {
132        self.coefficient = Complex64::new(coeff, 0.0);
133        self
134    }
135
136    /// Number of qubits this observable acts on
137    pub fn n_qubits(&self) -> usize {
138        self.operators.len()
139    }
140
141    /// Check if this is a diagonal observable (only I and Z)
142    pub fn is_diagonal(&self) -> bool {
143        self.operators
144            .iter()
145            .all(|op| matches!(op, PauliOp::I | PauliOp::Z))
146    }
147
148    /// Get the non-identity qubits and their operators
149    pub fn non_identity_qubits(&self) -> Vec<(usize, PauliOp)> {
150        self.operators
151            .iter()
152            .enumerate()
153            .filter(|(_, op)| **op != PauliOp::I)
154            .map(|(i, op)| (i, *op))
155            .collect()
156    }
157}
158
159/// Hamiltonian as a sum of Pauli observables
160#[derive(Debug, Clone)]
161pub struct PauliHamiltonian {
162    /// List of Pauli terms
163    pub terms: Vec<PauliObservable>,
164    /// Number of qubits
165    pub n_qubits: usize,
166}
167
168impl PauliHamiltonian {
169    /// Create a new empty Hamiltonian
170    pub fn new(n_qubits: usize) -> Self {
171        Self {
172            terms: Vec::new(),
173            n_qubits,
174        }
175    }
176
177    /// Add a term to the Hamiltonian
178    pub fn add_term(&mut self, term: PauliObservable) -> Result<()> {
179        if term.n_qubits() != self.n_qubits {
180            return Err(SimulatorError::InvalidInput(format!(
181                "Term has {} qubits but Hamiltonian has {} qubits",
182                term.n_qubits(),
183                self.n_qubits
184            )));
185        }
186        self.terms.push(term);
187        Ok(())
188    }
189
190    /// Create Hamiltonian from a list of terms
191    pub fn from_terms(terms: Vec<PauliObservable>) -> Result<Self> {
192        if terms.is_empty() {
193            return Err(SimulatorError::InvalidInput(
194                "Hamiltonian must have at least one term".to_string(),
195            ));
196        }
197
198        let n_qubits = terms[0].n_qubits();
199        for term in &terms {
200            if term.n_qubits() != n_qubits {
201                return Err(SimulatorError::InvalidInput(
202                    "All terms must act on the same number of qubits".to_string(),
203                ));
204            }
205        }
206
207        Ok(Self { terms, n_qubits })
208    }
209
210    /// Number of terms in the Hamiltonian
211    pub fn n_terms(&self) -> usize {
212        self.terms.len()
213    }
214}
215
216// ============================================================================
217// Observable Calculator
218// ============================================================================
219
220/// Configuration for observable calculations
221#[derive(Debug, Clone)]
222pub struct ObservableConfig {
223    /// Use GPU if available
224    pub use_gpu: bool,
225    /// Batch size for parallel computation
226    pub batch_size: usize,
227    /// Use diagonal optimization for Z-basis observables
228    pub use_diagonal_optimization: bool,
229}
230
231impl Default for ObservableConfig {
232    fn default() -> Self {
233        Self {
234            use_gpu: true,
235            batch_size: 1024,
236            use_diagonal_optimization: true,
237        }
238    }
239}
240
241/// High-performance observable calculator with GPU support
242pub struct ObservableCalculator {
243    /// Configuration
244    config: ObservableConfig,
245    /// Cache for Pauli matrices
246    pauli_cache: HashMap<PauliOp, Array2<Complex64>>,
247}
248
249impl ObservableCalculator {
250    /// Create a new observable calculator with default configuration
251    pub fn new() -> Self {
252        Self::with_config(ObservableConfig::default())
253    }
254
255    /// Create with custom configuration
256    pub fn with_config(config: ObservableConfig) -> Self {
257        let mut pauli_cache = HashMap::new();
258        pauli_cache.insert(PauliOp::I, PauliOp::I.matrix());
259        pauli_cache.insert(PauliOp::X, PauliOp::X.matrix());
260        pauli_cache.insert(PauliOp::Y, PauliOp::Y.matrix());
261        pauli_cache.insert(PauliOp::Z, PauliOp::Z.matrix());
262
263        Self {
264            config,
265            pauli_cache,
266        }
267    }
268
269    /// Compute expectation value ⟨ψ|O|ψ⟩ for a Pauli observable
270    pub fn expectation_value(
271        &self,
272        state: &Array1<Complex64>,
273        observable: &PauliObservable,
274    ) -> Result<Complex64> {
275        let n_qubits = observable.n_qubits();
276        let state_size = 1 << n_qubits;
277
278        if state.len() != state_size {
279            return Err(SimulatorError::InvalidInput(format!(
280                "State size {} does not match observable size 2^{} = {}",
281                state.len(),
282                n_qubits,
283                state_size
284            )));
285        }
286
287        // Optimized path for diagonal observables
288        if self.config.use_diagonal_optimization && observable.is_diagonal() {
289            return self.expectation_value_diagonal(state, observable);
290        }
291
292        // Apply observable operator to state: O|ψ⟩
293        let o_psi = self.apply_pauli_observable(state, observable)?;
294
295        // Compute ⟨ψ|O|ψ⟩ = ψ† · O|ψ⟩
296        let expectation = state
297            .iter()
298            .zip(o_psi.iter())
299            .map(|(a, b)| a.conj() * b)
300            .sum::<Complex64>();
301
302        Ok(expectation * observable.coefficient)
303    }
304
305    /// Optimized expectation value for diagonal observables (I and Z only)
306    fn expectation_value_diagonal(
307        &self,
308        state: &Array1<Complex64>,
309        observable: &PauliObservable,
310    ) -> Result<Complex64> {
311        let n_qubits = observable.n_qubits();
312        let state_size = state.len();
313
314        let mut expectation = Complex64::new(0.0, 0.0);
315
316        // For diagonal observables, we only need the diagonal elements
317        // For Z operators, eigenvalue is +1 for |0⟩ and -1 for |1⟩
318        for i in 0..state_size {
319            let mut sign = 1.0;
320
321            // Check each qubit
322            for (qubit_idx, op) in observable.operators.iter().enumerate() {
323                if *op == PauliOp::Z {
324                    // Check if this qubit is in state |1⟩
325                    let bit = (i >> (n_qubits - 1 - qubit_idx)) & 1;
326                    if bit == 1 {
327                        sign *= -1.0;
328                    }
329                }
330            }
331
332            expectation += state[i].norm_sqr() * sign;
333        }
334
335        Ok(expectation * observable.coefficient)
336    }
337
338    /// Apply Pauli observable operator to a state vector
339    fn apply_pauli_observable(
340        &self,
341        state: &Array1<Complex64>,
342        observable: &PauliObservable,
343    ) -> Result<Array1<Complex64>> {
344        let n_qubits = observable.n_qubits();
345        let state_size = state.len();
346        let mut result = state.clone();
347
348        // Apply each non-identity Pauli operator
349        let non_identity = observable.non_identity_qubits();
350
351        for (qubit_idx, op) in non_identity {
352            result = self.apply_single_qubit_pauli(&result, qubit_idx, op, n_qubits)?;
353        }
354
355        Ok(result)
356    }
357
358    /// Apply single-qubit Pauli operator to state
359    fn apply_single_qubit_pauli(
360        &self,
361        state: &Array1<Complex64>,
362        qubit: usize,
363        op: PauliOp,
364        n_qubits: usize,
365    ) -> Result<Array1<Complex64>> {
366        let state_size = state.len();
367        let mut new_state = Array1::zeros(state_size);
368
369        match op {
370            PauliOp::I => {
371                // Identity: no change
372                return Ok(state.clone());
373            }
374            PauliOp::X => {
375                // Bit flip
376                for i in 0..state_size {
377                    let j = i ^ (1 << (n_qubits - 1 - qubit));
378                    new_state[i] = state[j];
379                }
380            }
381            PauliOp::Y => {
382                // Y = -iXZ
383                for i in 0..state_size {
384                    let j = i ^ (1 << (n_qubits - 1 - qubit));
385                    let bit = (i >> (n_qubits - 1 - qubit)) & 1;
386                    let sign = if bit == 0 {
387                        Complex64::new(0.0, -1.0)
388                    } else {
389                        Complex64::new(0.0, 1.0)
390                    };
391                    new_state[i] = sign * state[j];
392                }
393            }
394            PauliOp::Z => {
395                // Phase flip
396                for i in 0..state_size {
397                    let bit = (i >> (n_qubits - 1 - qubit)) & 1;
398                    let sign = if bit == 0 { 1.0 } else { -1.0 };
399                    new_state[i] = state[i] * sign;
400                }
401            }
402        }
403
404        Ok(new_state)
405    }
406
407    /// Compute expectation value for a Hamiltonian (sum of Pauli terms)
408    pub fn hamiltonian_expectation_value(
409        &self,
410        state: &Array1<Complex64>,
411        hamiltonian: &PauliHamiltonian,
412    ) -> Result<Complex64> {
413        if state.len() != (1 << hamiltonian.n_qubits) {
414            return Err(SimulatorError::InvalidInput(
415                "State size does not match Hamiltonian".to_string(),
416            ));
417        }
418
419        // Sum expectation values of all terms
420        let mut total = Complex64::new(0.0, 0.0);
421        for term in &hamiltonian.terms {
422            let exp_val = self.expectation_value(state, term)?;
423            total += exp_val;
424        }
425
426        Ok(total)
427    }
428
429    /// Compute variance Var(O) = ⟨O²⟩ - ⟨O⟩²
430    pub fn variance(&self, state: &Array1<Complex64>, observable: &PauliObservable) -> Result<f64> {
431        // For Pauli operators, O² = I, so ⟨O²⟩ = 1
432        let exp_o = self.expectation_value(state, observable)?;
433        let var = 1.0 - exp_o.norm_sqr();
434
435        Ok(var.max(0.0)) // Ensure non-negative due to numerical errors
436    }
437
438    /// Batch compute multiple expectation values in parallel
439    pub fn batch_expectation_values(
440        &self,
441        state: &Array1<Complex64>,
442        observables: &[PauliObservable],
443    ) -> Result<Vec<Complex64>> {
444        if observables.is_empty() {
445            return Ok(Vec::new());
446        }
447
448        // Verify all observables have the same number of qubits
449        let n_qubits = observables[0].n_qubits();
450        for obs in observables {
451            if obs.n_qubits() != n_qubits {
452                return Err(SimulatorError::InvalidInput(
453                    "All observables must act on the same number of qubits".to_string(),
454                ));
455            }
456        }
457
458        // Use parallel computation for large batches
459        if observables.len() > 4 {
460            let results: Vec<Complex64> = observables
461                .iter()
462                .map(|obs| self.expectation_value(state, obs))
463                .collect::<Result<Vec<_>>>()?;
464            Ok(results)
465        } else {
466            // Sequential for small batches
467            observables
468                .iter()
469                .map(|obs| self.expectation_value(state, obs))
470                .collect()
471        }
472    }
473}
474
475impl Default for ObservableCalculator {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    fn normalize(mut state: Array1<Complex64>) -> Array1<Complex64> {
486        let norm = state.iter().map(|c| c.norm_sqr()).sum::<f64>().sqrt();
487        for c in state.iter_mut() {
488            *c /= norm;
489        }
490        state
491    }
492
493    #[test]
494    fn test_pauli_op_from_char() {
495        assert_eq!(PauliOp::from_char('I').unwrap(), PauliOp::I);
496        assert_eq!(PauliOp::from_char('X').unwrap(), PauliOp::X);
497        assert_eq!(PauliOp::from_char('Y').unwrap(), PauliOp::Y);
498        assert_eq!(PauliOp::from_char('Z').unwrap(), PauliOp::Z);
499        assert!(PauliOp::from_char('A').is_err());
500    }
501
502    #[test]
503    fn test_pauli_observable_from_string() {
504        let obs = PauliObservable::from_string("XYZ").unwrap();
505        assert_eq!(obs.n_qubits(), 3);
506        assert_eq!(obs.operators[0], PauliOp::X);
507        assert_eq!(obs.operators[1], PauliOp::Y);
508        assert_eq!(obs.operators[2], PauliOp::Z);
509    }
510
511    #[test]
512    fn test_pauli_observable_is_diagonal() {
513        let diag = PauliObservable::from_string("IZI").unwrap();
514        assert!(diag.is_diagonal());
515
516        let non_diag = PauliObservable::from_string("XZI").unwrap();
517        assert!(!non_diag.is_diagonal());
518    }
519
520    #[test]
521    fn test_expectation_value_z_basis() {
522        let calc = ObservableCalculator::new();
523
524        // State |00⟩
525        let state = normalize(Array1::from_vec(vec![
526            Complex64::new(1.0, 0.0),
527            Complex64::new(0.0, 0.0),
528            Complex64::new(0.0, 0.0),
529            Complex64::new(0.0, 0.0),
530        ]));
531
532        // Z₀ observable (Z on qubit 0)
533        let z0 = PauliObservable::from_string("ZI").unwrap();
534        let exp_val = calc.expectation_value(&state, &z0).unwrap();
535        assert!((exp_val.re - 1.0).abs() < 1e-10); // |0⟩ has eigenvalue +1
536        assert!(exp_val.im.abs() < 1e-10);
537
538        // Z₁ observable (Z on qubit 1)
539        let z1 = PauliObservable::from_string("IZ").unwrap();
540        let exp_val = calc.expectation_value(&state, &z1).unwrap();
541        assert!((exp_val.re - 1.0).abs() < 1e-10);
542    }
543
544    #[test]
545    fn test_expectation_value_x_basis() {
546        let calc = ObservableCalculator::new();
547
548        // State |+⟩ = (|0⟩ + |1⟩)/√2
549        let state = normalize(Array1::from_vec(vec![
550            Complex64::new(1.0, 0.0),
551            Complex64::new(1.0, 0.0),
552        ]));
553
554        // X observable
555        let x = PauliObservable::from_string("X").unwrap();
556        let exp_val = calc.expectation_value(&state, &x).unwrap();
557        assert!((exp_val.re - 1.0).abs() < 1e-10); // |+⟩ is eigenstate of X with +1
558        assert!(exp_val.im.abs() < 1e-10);
559    }
560
561    #[test]
562    fn test_hamiltonian_expectation() {
563        let calc = ObservableCalculator::new();
564
565        // Simple Hamiltonian: H = Z₀ + Z₁
566        let z0 = PauliObservable::from_string("ZI").unwrap();
567        let z1 = PauliObservable::from_string("IZ").unwrap();
568        let h = PauliHamiltonian::from_terms(vec![z0, z1]).unwrap();
569
570        // State |00⟩
571        let state = normalize(Array1::from_vec(vec![
572            Complex64::new(1.0, 0.0),
573            Complex64::new(0.0, 0.0),
574            Complex64::new(0.0, 0.0),
575            Complex64::new(0.0, 0.0),
576        ]));
577
578        let exp_val = calc.hamiltonian_expectation_value(&state, &h).unwrap();
579        assert!((exp_val.re - 2.0).abs() < 1e-10); // Both Z have +1 eigenvalue
580    }
581
582    #[test]
583    fn test_variance() {
584        let calc = ObservableCalculator::new();
585
586        // State |0⟩ (eigenstate of Z)
587        let state = normalize(Array1::from_vec(vec![
588            Complex64::new(1.0, 0.0),
589            Complex64::new(0.0, 0.0),
590        ]));
591
592        let z = PauliObservable::from_string("Z").unwrap();
593        let var = calc.variance(&state, &z).unwrap();
594        assert!(var.abs() < 1e-10); // Variance is 0 for eigenstates
595
596        // State |+⟩ (superposition)
597        let state_plus = normalize(Array1::from_vec(vec![
598            Complex64::new(1.0, 0.0),
599            Complex64::new(1.0, 0.0),
600        ]));
601
602        let var = calc.variance(&state_plus, &z).unwrap();
603        assert!((var - 1.0).abs() < 1e-10); // Maximum variance for superposition
604    }
605
606    #[test]
607    fn test_batch_expectation_values() {
608        let calc = ObservableCalculator::new();
609
610        let state = normalize(Array1::from_vec(vec![
611            Complex64::new(1.0, 0.0),
612            Complex64::new(0.0, 0.0),
613            Complex64::new(0.0, 0.0),
614            Complex64::new(0.0, 0.0),
615        ]));
616
617        let observables = vec![
618            PauliObservable::from_string("ZI").unwrap(),
619            PauliObservable::from_string("IZ").unwrap(),
620            PauliObservable::from_string("ZZ").unwrap(),
621        ];
622
623        let results = calc.batch_expectation_values(&state, &observables).unwrap();
624        assert_eq!(results.len(), 3);
625        assert!((results[0].re - 1.0).abs() < 1e-10);
626        assert!((results[1].re - 1.0).abs() < 1e-10);
627        assert!((results[2].re - 1.0).abs() < 1e-10); // ZZ for |00⟩ is +1
628    }
629}