Skip to main content

scirs2_optimize/quantum_classical/
vqe.rs

1//! VQE (Variational Quantum Eigensolver) for ground-state energy estimation
2//!
3//! The VQE algorithm prepares a parametrized quantum state (ansatz) and minimizes
4//! the expectation value ⟨ψ(θ)|H|ψ(θ)⟩ over the parameters θ.
5
6use crate::error::OptimizeError;
7use crate::quantum_classical::statevector::{cabs2, Statevector};
8use crate::quantum_classical::QcResult;
9
10// ─── Pauli operators ───────────────────────────────────────────────────────
11
12/// Single-qubit Pauli operator.
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PauliOp {
16    /// Identity
17    I,
18    /// Pauli X
19    X,
20    /// Pauli Y
21    Y,
22    /// Pauli Z
23    Z,
24}
25
26// ─── Pauli Hamiltonian ─────────────────────────────────────────────────────
27
28/// A Hamiltonian expressed as a weighted sum of Pauli tensor products.
29///
30/// H = Σ_k c_k ⊗_{i} P_{k,i}
31///
32/// Each term is `(coefficient, [(qubit_index, PauliOp)])`.
33#[derive(Debug, Clone)]
34pub struct PauliHamiltonian {
35    /// Hamiltonian terms: `(weight, list_of_(qubit, pauli)_pairs)`
36    pub terms: Vec<(f64, Vec<(usize, PauliOp)>)>,
37    /// Number of qubits in the system
38    pub n_qubits: usize,
39}
40
41impl PauliHamiltonian {
42    /// Create a new empty Hamiltonian for `n_qubits` qubits.
43    pub fn new(n_qubits: usize) -> Self {
44        Self {
45            terms: Vec::new(),
46            n_qubits,
47        }
48    }
49
50    /// Add a term to the Hamiltonian.
51    pub fn add_term(&mut self, coefficient: f64, ops: Vec<(usize, PauliOp)>) {
52        self.terms.push((coefficient, ops));
53    }
54
55    /// Compute ⟨ψ|H|ψ⟩ using the statevector.
56    ///
57    /// Each term is evaluated via: `⟨ψ|P_k|ψ⟩ = ⟨ψ|P_k^† P_k|ψ⟩`
58    /// For Hermitian Paulis we use diagonal or off-diagonal matrix elements.
59    pub fn expectation(&self, sv: &Statevector) -> QcResult<f64> {
60        let mut total = 0.0;
61        for (coeff, ops) in &self.terms {
62            // Build a copy of the statevector and apply the Pauli string
63            let mut temp = sv.clone();
64            for &(qubit, ref pauli) in ops {
65                match pauli {
66                    PauliOp::I => {} // identity: do nothing
67                    PauliOp::X => temp.apply_x(qubit)?,
68                    PauliOp::Y => temp.apply_y(qubit)?,
69                    PauliOp::Z => temp.apply_z(qubit)?,
70                    _ => {} // exhaustive match on non_exhaustive enum
71                }
72            }
73            // Compute ⟨ψ|P_k|ψ⟩ = Re(ψ†·P_k ψ)
74            let dot: f64 = sv
75                .amplitudes
76                .iter()
77                .zip(temp.amplitudes.iter())
78                .map(|(&(ar, ai), &(br, bi))| ar * br + ai * bi)
79                .sum();
80            total += coeff * dot;
81        }
82        Ok(total)
83    }
84
85    /// Create the transverse-field Ising model: H = -J Σ Z_i Z_{i+1} - h Σ X_i
86    pub fn transverse_ising_1d(n_qubits: usize, j: f64, h: f64) -> Self {
87        let mut ham = Self::new(n_qubits);
88        // ZZ couplings
89        for i in 0..(n_qubits - 1) {
90            ham.add_term(-j, vec![(i, PauliOp::Z), (i + 1, PauliOp::Z)]);
91        }
92        // Transverse field (X)
93        for i in 0..n_qubits {
94            ham.add_term(-h, vec![(i, PauliOp::X)]);
95        }
96        ham
97    }
98}
99
100// ─── Statevector extensions for Pauli gates ────────────────────────────────
101
102impl Statevector {
103    /// Apply Pauli X (bit flip) to `qubit`.
104    pub fn apply_x(&mut self, qubit: usize) -> QcResult<()> {
105        // Hadamard → Rz(π) → Hadamard is equivalent to X, but direct swap is simpler:
106        let bit = 1usize << qubit;
107        let dim = self.amplitudes.len();
108        if qubit >= self.n_qubits {
109            return Err(OptimizeError::ValueError(format!(
110                "Qubit {qubit} out of range"
111            )));
112        }
113        for i in 0..dim {
114            if i & bit == 0 {
115                self.amplitudes.swap(i, i | bit);
116            }
117        }
118        Ok(())
119    }
120
121    /// Apply Pauli Y to `qubit`.
122    ///
123    /// Y = [[0, -i], [i, 0]]
124    /// Y|0⟩ = i|1⟩,  Y|1⟩ = -i|0⟩
125    pub fn apply_y(&mut self, qubit: usize) -> QcResult<()> {
126        if qubit >= self.n_qubits {
127            return Err(OptimizeError::ValueError(format!(
128                "Qubit {qubit} out of range"
129            )));
130        }
131        let bit = 1usize << qubit;
132        let dim = self.amplitudes.len();
133        for i in 0..dim {
134            if i & bit == 0 {
135                let j = i | bit;
136                let (ar, ai) = self.amplitudes[i]; // |0⟩ component
137                let (br, bi) = self.amplitudes[j]; // |1⟩ component
138                                                   // Y|0⟩ = i|1⟩ → new amplitude at j: (i * a) = (-ai, ar)
139                                                   // Y|1⟩ = -i|0⟩ → new amplitude at i: (-i * b) = (bi, -br)
140                self.amplitudes[i] = (bi, -br);
141                self.amplitudes[j] = (-ai, ar);
142            }
143        }
144        Ok(())
145    }
146
147    /// Apply Pauli Z (phase flip) to `qubit`.
148    ///
149    /// Z|0⟩ = |0⟩,  Z|1⟩ = -|1⟩
150    pub fn apply_z(&mut self, qubit: usize) -> QcResult<()> {
151        if qubit >= self.n_qubits {
152            return Err(OptimizeError::ValueError(format!(
153                "Qubit {qubit} out of range"
154            )));
155        }
156        let bit = 1usize << qubit;
157        for (i, amp) in self.amplitudes.iter_mut().enumerate() {
158            if i & bit != 0 {
159                amp.0 = -amp.0;
160                amp.1 = -amp.1;
161            }
162        }
163        Ok(())
164    }
165}
166
167// ─── Hardware-efficient ansatz ─────────────────────────────────────────────
168
169/// Hardware-efficient variational ansatz.
170///
171/// Structure per layer:
172/// - Ry(θ) on each qubit
173/// - Rz(φ) on each qubit
174/// - CNOT entangling gates: qubit 0 → 1, qubit 1 → 2, ...
175///
176/// Total parameters per layer: 2 * n_qubits (Ry angles + Rz angles)
177/// Final Ry layer: n_qubits extra parameters
178/// Total: (2 * n_layers + 1) * n_qubits
179#[derive(Debug, Clone)]
180pub struct HardwareEfficientAnsatz {
181    /// Number of qubits
182    pub n_qubits: usize,
183    /// Number of entangling layers
184    pub n_layers: usize,
185}
186
187impl HardwareEfficientAnsatz {
188    /// Create a new hardware-efficient ansatz.
189    pub fn new(n_qubits: usize, n_layers: usize) -> Self {
190        Self { n_qubits, n_layers }
191    }
192
193    /// Total number of parameters: (2 * n_layers + 1) * n_qubits
194    pub fn n_params(&self) -> usize {
195        (2 * self.n_layers + 1) * self.n_qubits
196    }
197
198    /// Build the ansatz statevector from the parameter vector.
199    pub fn run(&self, params: &[f64]) -> QcResult<Statevector> {
200        let expected = self.n_params();
201        if params.len() != expected {
202            return Err(OptimizeError::ValueError(format!(
203                "Expected {expected} parameters, got {}",
204                params.len()
205            )));
206        }
207
208        let mut state = Statevector::zero_state(self.n_qubits)?;
209        let n = self.n_qubits;
210        let mut offset = 0;
211
212        for _layer in 0..self.n_layers {
213            // Ry rotations
214            for q in 0..n {
215                state.apply_ry(q, params[offset + q])?;
216            }
217            offset += n;
218
219            // Rz rotations
220            for q in 0..n {
221                state.apply_rz(q, params[offset + q])?;
222            }
223            offset += n;
224
225            // CNOT entangling: chain pattern
226            for q in 0..(n - 1) {
227                state.apply_cnot(q, q + 1)?;
228            }
229        }
230
231        // Final Ry layer (no entangling after)
232        for q in 0..n {
233            state.apply_ry(q, params[offset + q])?;
234        }
235
236        Ok(state)
237    }
238}
239
240// ─── VQE optimizer ─────────────────────────────────────────────────────────
241
242/// VQE optimizer: minimizes ⟨ψ(θ)|H|ψ(θ)⟩ over ansatz parameters θ.
243#[derive(Debug, Clone)]
244pub struct VqeOptimizer {
245    /// The Hamiltonian to minimize
246    pub hamiltonian: PauliHamiltonian,
247    /// The variational ansatz
248    pub ansatz: HardwareEfficientAnsatz,
249    /// Initial parameters
250    pub init_params: Vec<f64>,
251    /// Maximum gradient-descent iterations
252    pub max_iter: usize,
253    /// Convergence tolerance
254    pub tol: f64,
255    /// Learning rate for gradient descent
256    pub learning_rate: f64,
257}
258
259impl VqeOptimizer {
260    /// Create a new VQE optimizer.
261    pub fn new(
262        hamiltonian: PauliHamiltonian,
263        ansatz: HardwareEfficientAnsatz,
264        init_params: Vec<f64>,
265    ) -> Self {
266        Self {
267            hamiltonian,
268            ansatz,
269            init_params,
270            max_iter: 500,
271            tol: 1e-7,
272            learning_rate: 0.1,
273        }
274    }
275
276    /// Evaluate the energy ⟨H⟩ at the given parameters.
277    pub fn energy(&self, params: &[f64]) -> QcResult<f64> {
278        let state = self.ansatz.run(params)?;
279        self.hamiltonian.expectation(&state)
280    }
281
282    /// Compute gradient using the parameter-shift rule.
283    ///
284    /// ∂E/∂θ_k = 0.5 * [E(θ_k + π/2) - E(θ_k - π/2)]
285    pub fn gradient(&self, params: &[f64]) -> QcResult<Vec<f64>> {
286        let n = params.len();
287        let shift = std::f64::consts::FRAC_PI_2;
288        let mut grad = vec![0.0; n];
289
290        for k in 0..n {
291            let mut p_plus = params.to_vec();
292            let mut p_minus = params.to_vec();
293            p_plus[k] += shift;
294            p_minus[k] -= shift;
295            let e_plus = self.energy(&p_plus)?;
296            let e_minus = self.energy(&p_minus)?;
297            grad[k] = 0.5 * (e_plus - e_minus);
298        }
299        Ok(grad)
300    }
301
302    /// Run VQE optimization using gradient descent with momentum.
303    pub fn run(&mut self) -> QcResult<VqeResult> {
304        let n_params = self.ansatz.n_params();
305        if self.init_params.len() != n_params {
306            return Err(OptimizeError::ValueError(format!(
307                "Expected {n_params} initial parameters, got {}",
308                self.init_params.len()
309            )));
310        }
311
312        let mut params = self.init_params.clone();
313        let mut n_evals = 0usize;
314
315        // Momentum gradient descent (Adam-like adaptive learning rate)
316        let beta1: f64 = 0.9;
317        let beta2: f64 = 0.999;
318        let eps_adam: f64 = 1e-8;
319        let mut m = vec![0.0; n_params]; // first moment
320        let mut v = vec![0.0; n_params]; // second moment
321        let mut t = 0u32;
322
323        let mut prev_energy = self.energy(&params)?;
324        n_evals += 1;
325
326        for _iter in 0..self.max_iter {
327            let grad = self.gradient(&params)?;
328            n_evals += 2 * n_params; // 2 evals per parameter
329            t += 1;
330
331            let t_f = t as f64;
332            let lr_t =
333                self.learning_rate * (1.0 - beta2.powf(t_f)).sqrt() / (1.0 - beta1.powf(t_f));
334
335            for k in 0..n_params {
336                m[k] = beta1 * m[k] + (1.0 - beta1) * grad[k];
337                v[k] = beta2 * v[k] + (1.0 - beta2) * grad[k] * grad[k];
338                params[k] -= lr_t * m[k] / (v[k].sqrt() + eps_adam);
339            }
340
341            let energy = self.energy(&params)?;
342            n_evals += 1;
343
344            if (prev_energy - energy).abs() < self.tol {
345                return Ok(VqeResult {
346                    ground_state_energy: energy,
347                    optimal_params: params,
348                    n_evaluations: n_evals,
349                    converged: true,
350                });
351            }
352            prev_energy = energy;
353        }
354
355        Ok(VqeResult {
356            ground_state_energy: prev_energy,
357            optimal_params: params,
358            n_evaluations: n_evals,
359            converged: false,
360        })
361    }
362}
363
364// ─── Result type ───────────────────────────────────────────────────────────
365
366/// Result of a VQE optimization run.
367#[derive(Debug, Clone)]
368pub struct VqeResult {
369    /// Estimated ground-state energy
370    pub ground_state_energy: f64,
371    /// Optimal ansatz parameters
372    pub optimal_params: Vec<f64>,
373    /// Total circuit evaluations
374    pub n_evaluations: usize,
375    /// Whether the optimizer converged
376    pub converged: bool,
377}
378
379// ─── Tests ─────────────────────────────────────────────────────────────────
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    const EPS: f64 = 1e-10;
386
387    #[test]
388    fn test_pauli_z_expectation_zero_state() {
389        // |0⟩: ⟨Z⟩ should be +1
390        let sv = Statevector::zero_state(1).unwrap();
391        let mut ham = PauliHamiltonian::new(1);
392        ham.add_term(1.0, vec![(0, PauliOp::Z)]);
393        let e = ham.expectation(&sv).unwrap();
394        assert!((e - 1.0).abs() < EPS, "Expected +1, got {e}");
395    }
396
397    #[test]
398    fn test_pauli_z_expectation_one_state() {
399        // |1⟩: ⟨Z⟩ should be -1
400        let mut sv = Statevector::zero_state(1).unwrap();
401        sv.amplitudes[0] = (0.0, 0.0);
402        sv.amplitudes[1] = (1.0, 0.0);
403        let mut ham = PauliHamiltonian::new(1);
404        ham.add_term(1.0, vec![(0, PauliOp::Z)]);
405        let e = ham.expectation(&sv).unwrap();
406        assert!((e + 1.0).abs() < EPS, "Expected -1, got {e}");
407    }
408
409    #[test]
410    fn test_vqe_ising_2qubit_energy() {
411        // H = -J * Z0Z1 - h * X0 - h * X1 with J=1, h=0.5
412        // Ground state energy for 2-qubit Ising at h=0.5, J=1.0:
413        // Eigenvalues of 2x2 Ising can be computed analytically.
414        // E_min = -sqrt(J² + h²) ~ -sqrt(1.25) ≈ -1.118 per coupling
415        // Full 4x4 ground state ~ -1.5 to -2
416        let j = 1.0_f64;
417        let h = 0.5_f64;
418        let ham = PauliHamiltonian::transverse_ising_1d(2, j, h);
419
420        let ansatz = HardwareEfficientAnsatz::new(2, 2);
421        let n_p = ansatz.n_params();
422        // Initialize with some non-trivial parameters
423        let init_params: Vec<f64> = (0..n_p).map(|i| 0.1 * (i as f64 + 1.0)).collect();
424
425        let mut vqe = VqeOptimizer::new(ham, ansatz, init_params);
426        vqe.max_iter = 300;
427        vqe.learning_rate = 0.05;
428
429        let result = vqe.run().unwrap();
430        // The exact ground state for 2-qubit transverse Ising H = -ZZ - 0.5*X0 - 0.5*X1
431        // is approximately -sqrt(2) ≈ -1.414.
432        // The pure classical ground state (ZZ only, no transverse field) is -J = -1.0.
433        // VQE with HEA should find something better than the classical minimum (-1.0).
434        // We require energy ≤ -1.2 to confirm quantum variational improvement.
435        assert!(
436            result.ground_state_energy <= -1.2 * j,
437            "VQE energy {:.4} should be ≤ -1.2 (below classical minimum of -J=-1.0)",
438            result.ground_state_energy
439        );
440    }
441
442    #[test]
443    fn test_vqe_gradient_vs_finite_differences() {
444        let ham = PauliHamiltonian::transverse_ising_1d(2, 1.0, 1.0);
445        let ansatz = HardwareEfficientAnsatz::new(2, 1);
446        let n_p = ansatz.n_params();
447        let params: Vec<f64> = (0..n_p).map(|i| 0.3 * (i as f64 + 1.0)).collect();
448        let init = params.clone();
449
450        let vqe = VqeOptimizer::new(ham, ansatz, init);
451        let grad = vqe.gradient(&params).unwrap();
452
453        let eps = 1e-5;
454        for k in 0..n_p {
455            let mut pp = params.clone();
456            let mut pm = params.clone();
457            pp[k] += eps;
458            pm[k] -= eps;
459            let fd = (vqe.energy(&pp).unwrap() - vqe.energy(&pm).unwrap()) / (2.0 * eps);
460            assert!(
461                (grad[k] - fd).abs() < 0.01,
462                "Parameter-shift gradient {:.4} vs FD {:.4} at param {k}",
463                grad[k],
464                fd
465            );
466        }
467    }
468
469    #[test]
470    fn test_hea_norm_preserved() {
471        let ansatz = HardwareEfficientAnsatz::new(3, 2);
472        let n_p = ansatz.n_params();
473        let params: Vec<f64> = (0..n_p).map(|i| 0.5 + 0.1 * i as f64).collect();
474        let state = ansatz.run(&params).unwrap();
475        let norm = state.norm_squared();
476        assert!((norm - 1.0).abs() < 1e-12, "HEA norm must be 1, got {norm}");
477    }
478
479    #[test]
480    fn test_pauli_x_expectation() {
481        // |+⟩ = H|0⟩ → ⟨X⟩ = +1
482        let mut sv = Statevector::zero_state(1).unwrap();
483        sv.apply_hadamard(0).unwrap();
484        let mut ham = PauliHamiltonian::new(1);
485        ham.add_term(1.0, vec![(0, PauliOp::X)]);
486        let e = ham.expectation(&sv).unwrap();
487        assert!((e - 1.0).abs() < 1e-10, "⟨X⟩ of |+⟩ should be 1, got {e}");
488    }
489
490    #[test]
491    fn test_hea_n_params() {
492        // (2*n_layers + 1) * n_qubits
493        let ansatz = HardwareEfficientAnsatz::new(4, 3);
494        assert_eq!(ansatz.n_params(), 7 * 4);
495    }
496}