Skip to main content

ruqu_algorithms/
vqe.rs

1//! Variational Quantum Eigensolver (VQE)
2//!
3//! Finds the ground-state energy of a Hamiltonian using a classical-quantum
4//! hybrid optimization loop:
5//!
6//! 1. A parameterized **ansatz** circuit prepares a trial state on the quantum
7//!    processor (or simulator).
8//! 2. The **expectation value** of the Hamiltonian is measured for that state.
9//! 3. A **classical optimizer** (gradient descent with parameter-shift rule)
10//!    updates the circuit parameters to minimize the energy.
11//! 4. Steps 1-3 repeat until convergence or the iteration budget is exhausted.
12//!
13//! The ansatz used here is "hardware-efficient": each layer applies Ry and Rz
14//! rotations on every qubit, followed by a linear CNOT entangling chain.
15
16use ruqu_core::circuit::QuantumCircuit;
17use ruqu_core::simulator::{SimConfig, Simulator};
18use ruqu_core::types::{Hamiltonian, PauliOp, PauliString};
19
20// ---------------------------------------------------------------------------
21// Configuration and result types
22// ---------------------------------------------------------------------------
23
24/// Configuration for a VQE run.
25pub struct VqeConfig {
26    /// The Hamiltonian whose ground-state energy we seek.
27    pub hamiltonian: Hamiltonian,
28    /// Number of qubits in the ansatz circuit.
29    pub num_qubits: u32,
30    /// Number of ansatz layers (depth). Each layer contributes
31    /// `2 * num_qubits` parameters (Ry + Rz per qubit).
32    pub ansatz_depth: u32,
33    /// Maximum number of classical optimizer iterations.
34    pub max_iterations: u32,
35    /// Stop early when the absolute energy change between successive
36    /// iterations falls below this threshold.
37    pub convergence_threshold: f64,
38    /// Step size for gradient descent.
39    pub learning_rate: f64,
40    /// Optional RNG seed for reproducible simulation.
41    pub seed: Option<u64>,
42}
43
44/// Result returned by [`run_vqe`].
45pub struct VqeResult {
46    /// Lowest energy found during the optimization.
47    pub optimal_energy: f64,
48    /// Parameter vector that produced `optimal_energy`.
49    pub optimal_parameters: Vec<f64>,
50    /// Energy at each iteration (length = `num_iterations`).
51    pub energy_history: Vec<f64>,
52    /// Total number of iterations executed.
53    pub num_iterations: u32,
54    /// Whether the optimizer converged before exhausting `max_iterations`.
55    pub converged: bool,
56}
57
58// ---------------------------------------------------------------------------
59// Ansatz construction
60// ---------------------------------------------------------------------------
61
62/// Return the total number of variational parameters for the given ansatz
63/// dimensions. Each layer uses `2 * num_qubits` parameters (one Ry and one
64/// Rz rotation per qubit).
65pub fn num_parameters(num_qubits: u32, depth: u32) -> usize {
66    (2 * num_qubits as usize) * (depth as usize)
67}
68
69/// Build a hardware-efficient ansatz circuit.
70///
71/// Each layer consists of:
72/// 1. **Rotation sub-layer**: Ry(theta) on every qubit.
73/// 2. **Rotation sub-layer**: Rz(theta) on every qubit.
74/// 3. **Entangling sub-layer**: Linear CNOT chain (0->1, 1->2, ..., n-2->n-1).
75///
76/// `params` must have exactly [`num_parameters`]`(num_qubits, depth)` entries.
77///
78/// # Panics
79///
80/// Panics if `params.len()` does not equal the expected parameter count.
81pub fn build_ansatz(num_qubits: u32, depth: u32, params: &[f64]) -> QuantumCircuit {
82    let expected = num_parameters(num_qubits, depth);
83    assert_eq!(
84        params.len(),
85        expected,
86        "build_ansatz: expected {} parameters, got {}",
87        expected,
88        params.len()
89    );
90
91    let mut circuit = QuantumCircuit::new(num_qubits);
92    let mut idx = 0;
93
94    for _layer in 0..depth {
95        // Ry rotations
96        for q in 0..num_qubits {
97            circuit.ry(q, params[idx]);
98            idx += 1;
99        }
100        // Rz rotations
101        for q in 0..num_qubits {
102            circuit.rz(q, params[idx]);
103            idx += 1;
104        }
105        // Linear CNOT entangling chain
106        for q in 0..num_qubits.saturating_sub(1) {
107            circuit.cnot(q, q + 1);
108        }
109    }
110
111    circuit
112}
113
114// ---------------------------------------------------------------------------
115// Energy evaluation
116// ---------------------------------------------------------------------------
117
118/// Evaluate the expectation value of the Hamiltonian for a given set of
119/// ansatz parameters.
120///
121/// Builds the ansatz, simulates it, and returns `<psi|H|psi>`.
122pub fn evaluate_energy(
123    config: &VqeConfig,
124    params: &[f64],
125) -> ruqu_core::error::Result<f64> {
126    let circuit = build_ansatz(config.num_qubits, config.ansatz_depth, params);
127    let sim_config = SimConfig {
128        seed: config.seed,
129        noise: None,
130        shots: None,
131    };
132    let result = Simulator::run_with_config(&circuit, &sim_config)?;
133    Ok(result.state.expectation_hamiltonian(&config.hamiltonian))
134}
135
136// ---------------------------------------------------------------------------
137// VQE optimizer
138// ---------------------------------------------------------------------------
139
140/// Run the VQE optimization loop.
141///
142/// Uses gradient descent with the **parameter-shift rule** to compute
143/// analytical gradients. For each parameter theta_i the gradient is:
144///
145/// ```text
146/// dE/d(theta_i) = [ E(theta_i + pi/2) - E(theta_i - pi/2) ] / 2
147/// ```
148///
149/// This requires 2 circuit evaluations per parameter per iteration, so the
150/// total cost is `O(max_iterations * 2 * num_parameters)` circuit runs.
151pub fn run_vqe(config: &VqeConfig) -> ruqu_core::error::Result<VqeResult> {
152    let n_params = num_parameters(config.num_qubits, config.ansatz_depth);
153
154    // Initialize parameters with small values to break symmetry.
155    let mut params = vec![0.1_f64; n_params];
156
157    let mut energy_history: Vec<f64> = Vec::with_capacity(config.max_iterations as usize);
158    let mut converged = false;
159
160    let mut best_energy = f64::MAX;
161    let mut best_params = params.clone();
162
163    for iteration in 0..config.max_iterations {
164        // ------------------------------------------------------------------
165        // Forward pass: compute current energy
166        // ------------------------------------------------------------------
167        let energy = evaluate_energy(config, &params)?;
168        energy_history.push(energy);
169
170        if energy < best_energy {
171            best_energy = energy;
172            best_params = params.clone();
173        }
174
175        // ------------------------------------------------------------------
176        // Convergence check (skip first iteration since we need a delta)
177        // ------------------------------------------------------------------
178        if iteration > 0 {
179            let prev = energy_history[iteration as usize - 1];
180            if (prev - energy).abs() < config.convergence_threshold {
181                converged = true;
182                break;
183            }
184        }
185
186        // ------------------------------------------------------------------
187        // Backward pass: compute gradient via parameter-shift rule
188        // ------------------------------------------------------------------
189        let shift = std::f64::consts::FRAC_PI_2;
190        let mut gradient = vec![0.0_f64; n_params];
191
192        for i in 0..n_params {
193            let mut params_plus = params.clone();
194            let mut params_minus = params.clone();
195            params_plus[i] += shift;
196            params_minus[i] -= shift;
197
198            let e_plus = evaluate_energy(config, &params_plus)?;
199            let e_minus = evaluate_energy(config, &params_minus)?;
200            gradient[i] = (e_plus - e_minus) / 2.0;
201        }
202
203        // ------------------------------------------------------------------
204        // Parameter update (gradient descent -- minimize energy)
205        // ------------------------------------------------------------------
206        for i in 0..n_params {
207            params[i] -= config.learning_rate * gradient[i];
208        }
209    }
210
211    let num_iterations = energy_history.len() as u32;
212    Ok(VqeResult {
213        optimal_energy: best_energy,
214        optimal_parameters: best_params,
215        energy_history,
216        num_iterations,
217        converged,
218    })
219}
220
221// ---------------------------------------------------------------------------
222// Hamiltonian helpers
223// ---------------------------------------------------------------------------
224
225/// Create an approximate H2 (molecular hydrogen) Hamiltonian in the STO-3G
226/// basis mapped to 2 qubits via the Bravyi-Kitaev transformation.
227///
228/// ```text
229/// H = -1.0523 II + 0.3979 IZ + -0.3979 ZI + -0.0112 ZZ + 0.1809 XX
230/// ```
231///
232/// The exact ground-state energy of this Hamiltonian is approximately -1.137
233/// Hartree (at equilibrium bond length ~0.735 angstrom).
234pub fn h2_hamiltonian() -> Hamiltonian {
235    Hamiltonian {
236        terms: vec![
237            // Identity term (constant offset)
238            (-1.0523, PauliString { ops: vec![] }),
239            // IZ: Pauli-Z on qubit 1
240            (
241                0.3979,
242                PauliString {
243                    ops: vec![(1, PauliOp::Z)],
244                },
245            ),
246            // ZI: Pauli-Z on qubit 0
247            (
248                -0.3979,
249                PauliString {
250                    ops: vec![(0, PauliOp::Z)],
251                },
252            ),
253            // ZZ: Pauli-Z on both qubits
254            (
255                -0.0112,
256                PauliString {
257                    ops: vec![(0, PauliOp::Z), (1, PauliOp::Z)],
258                },
259            ),
260            // XX: Pauli-X on both qubits
261            (
262                0.1809,
263                PauliString {
264                    ops: vec![(0, PauliOp::X), (1, PauliOp::X)],
265                },
266            ),
267        ],
268        num_qubits: 2,
269    }
270}
271
272/// Create a simple single-qubit Z Hamiltonian: `H = -1.0 Z`.
273///
274/// The ground state is |0> with energy -1.0. Useful for smoke-testing VQE.
275pub fn single_z_hamiltonian() -> Hamiltonian {
276    Hamiltonian {
277        terms: vec![(
278            -1.0,
279            PauliString {
280                ops: vec![(0, PauliOp::Z)],
281            },
282        )],
283        num_qubits: 1,
284    }
285}
286
287// ---------------------------------------------------------------------------
288// Tests
289// ---------------------------------------------------------------------------
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_num_parameters() {
297        assert_eq!(num_parameters(2, 1), 4);
298        assert_eq!(num_parameters(4, 3), 24);
299        assert_eq!(num_parameters(1, 5), 10);
300    }
301
302    #[test]
303    fn test_build_ansatz_gate_count() {
304        let n = 3;
305        let depth = 2;
306        let params = vec![0.0; num_parameters(n, depth)];
307        let circuit = build_ansatz(n, depth, &params);
308        assert_eq!(circuit.num_qubits(), n);
309        // Each layer: 3 Ry + 3 Rz + 2 CNOT = 8 gates, times 2 layers = 16
310        assert_eq!(circuit.gates().len(), 16);
311    }
312
313    #[test]
314    #[should_panic(expected = "expected 4 parameters")]
315    fn test_build_ansatz_wrong_param_count() {
316        build_ansatz(2, 1, &[0.0; 3]);
317    }
318
319    #[test]
320    fn test_h2_hamiltonian_structure() {
321        let h = h2_hamiltonian();
322        assert_eq!(h.num_qubits, 2);
323        assert_eq!(h.terms.len(), 5);
324    }
325
326    #[test]
327    fn test_single_z_hamiltonian() {
328        let h = single_z_hamiltonian();
329        assert_eq!(h.num_qubits, 1);
330        assert_eq!(h.terms.len(), 1);
331    }
332}