Skip to main content

oxicuda_quantum/algorithms/
vqls.rs

1//! Variational Quantum Linear Solver (VQLS).
2//!
3//! Reference: Bravo-Prieto, LaRose, Cerezo, Subasi, Cincio, Coles, *"Variational
4//! Quantum Linear Solver"*, Quantum 7, 1188 (2023) (arXiv:1909.05820, 2019).
5//!
6//! # Problem
7//!
8//! Solve `A|x⟩ = |b⟩` for `|x⟩`, where the matrix is a *linear combination of
9//! unitaries* (LCU)
10//!
11//! ```text
12//! A = Σ_l c_l P_l,
13//! ```
14//!
15//! with real coefficients `c_l` and Pauli-string unitaries `P_l`. VQLS prepares a
16//! trial solution `|x(θ)⟩ = V(θ)|0⟩` from a parameterised *ansatz* `V(θ)` and
17//! minimises a cost that vanishes precisely when `A|x(θ)⟩ ∝ |b⟩`.
18//!
19//! # Cost function
20//!
21//! This is a state-vector simulator, so rather than estimating the cost from
22//! Hadamard tests we evaluate the **global cost** exactly:
23//!
24//! ```text
25//! C(θ) = 1 − |⟨b| A |x(θ)⟩|² / ⟨x(θ)| A†A |x(θ)⟩.
26//! ```
27//!
28//! `C(θ) ≥ 0` always, and `C(θ) = 0` **iff** the normalised `A|x(θ)⟩` equals
29//! `|b⟩` up to a global phase, i.e. `|x(θ)⟩ ∝ A⁻¹|b⟩`. Minimising `C` therefore
30//! drives the ansatz state to the (normalised) solution.
31//!
32//! # Ansatz
33//!
34//! `V(θ)` is a hardware-efficient layered circuit: each layer applies an `R_y(θ)`
35//! to every qubit followed by a ladder of nearest-neighbour `CNOT`s. With enough
36//! layers this reaches any real-amplitude state, which suffices for the
37//! real-coefficient test systems here.
38//!
39//! # Optimiser
40//!
41//! The parameters are optimised by **finite-difference gradient descent** with a
42//! simple backtracking step (halving the learning rate when a step fails to
43//! decrease the cost). All gradients are central differences of the exactly
44//! evaluated cost.
45//!
46//! # Numerics
47//!
48//! Pauli strings are expanded to dense `f32` matrices and combined into the dense
49//! `A`; the ansatz drives an `f32` [`StateVector`] through the shared gate
50//! machinery. Cost, gradients, and the LCU assembly are exact up to `f32`.
51
52use crate::error::{QuantumError, QuantumResult};
53use crate::gates::controlled::apply_cnot;
54use crate::gates::parametric::gate_ry;
55use crate::statevec::apply_1q::apply_1q_inplace;
56use crate::statevec::state::StateVector;
57use num_complex::Complex;
58
59type Complex32 = Complex<f32>;
60
61/// A single-qubit Pauli factor used to build Pauli-string unitaries.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Pauli {
64    /// Identity.
65    I,
66    /// Pauli-X.
67    X,
68    /// Pauli-Y.
69    Y,
70    /// Pauli-Z.
71    Z,
72}
73
74impl Pauli {
75    /// The `2×2` matrix of this Pauli, row-major.
76    #[must_use]
77    fn matrix(self) -> [[Complex32; 2]; 2] {
78        let zero = Complex32::new(0.0, 0.0);
79        let one = Complex32::new(1.0, 0.0);
80        let neg = Complex32::new(-1.0, 0.0);
81        let i = Complex32::new(0.0, 1.0);
82        let ni = Complex32::new(0.0, -1.0);
83        match self {
84            Pauli::I => [[one, zero], [zero, one]],
85            Pauli::X => [[zero, one], [one, zero]],
86            Pauli::Y => [[zero, ni], [i, zero]],
87            Pauli::Z => [[one, zero], [zero, neg]],
88        }
89    }
90}
91
92/// One term `c_l · P_l` of the LCU `A = Σ_l c_l P_l`, where `P_l` is a Pauli
93/// string over `n` qubits.
94#[derive(Debug, Clone)]
95pub struct PauliTerm {
96    /// Real coefficient `c_l`.
97    pub coeff: f32,
98    /// Per-qubit Pauli factors; `paulis[q]` acts on qubit `q` (little-endian).
99    pub paulis: Vec<Pauli>,
100}
101
102/// The LCU operator `A = Σ_l c_l P_l` and its dense `f32` matrix.
103#[derive(Debug, Clone)]
104pub struct LcuOperator {
105    /// Number of qubits `n` the operator acts on.
106    n_qubits: usize,
107    /// Row-major dense `dim×dim` matrix of `A` (`dim = 2^n`).
108    matrix: Vec<Complex32>,
109}
110
111impl LcuOperator {
112    /// Build the dense matrix of `A = Σ_l c_l P_l` from a list of Pauli-string
113    /// terms over `n_qubits` qubits.
114    ///
115    /// # Errors
116    /// * [`QuantumError::EmptyInput`] if `terms` is empty.
117    /// * [`QuantumError::InvalidQubitCount`] if `n_qubits == 0` or `n_qubits > 8`
118    ///   (the dense matrix would be impractical beyond that).
119    /// * [`QuantumError::DimensionMismatch`] if any term's Pauli-string length is
120    ///   not `n_qubits`.
121    pub fn new(terms: &[PauliTerm], n_qubits: usize) -> QuantumResult<Self> {
122        if terms.is_empty() {
123            return Err(QuantumError::EmptyInput);
124        }
125        if n_qubits == 0 || n_qubits > 8 {
126            return Err(QuantumError::InvalidQubitCount { n: n_qubits });
127        }
128        let dim = 1usize << n_qubits;
129        let mut matrix = vec![Complex32::new(0.0, 0.0); dim * dim];
130        for term in terms {
131            if term.paulis.len() != n_qubits {
132                return Err(QuantumError::DimensionMismatch {
133                    expected: n_qubits,
134                    got: term.paulis.len(),
135                });
136            }
137            let pmat = pauli_string_matrix(&term.paulis, n_qubits);
138            let c = Complex32::new(term.coeff, 0.0);
139            for (dst, src) in matrix.iter_mut().zip(pmat.iter()) {
140                *dst += c * *src;
141            }
142        }
143        Ok(Self { n_qubits, matrix })
144    }
145
146    /// Number of qubits `n`.
147    #[must_use]
148    pub fn n_qubits(&self) -> usize {
149        self.n_qubits
150    }
151
152    /// The dense row-major matrix entries of `A`.
153    #[must_use]
154    pub fn matrix(&self) -> &[Complex32] {
155        &self.matrix
156    }
157
158    /// Apply `A` to a vector `v` (length `dim`), returning `A·v`.
159    #[must_use]
160    fn apply_to(&self, v: &[Complex32]) -> Vec<Complex32> {
161        let dim = 1usize << self.n_qubits;
162        let mut out = vec![Complex32::new(0.0, 0.0); dim];
163        for (r, slot) in out.iter_mut().enumerate() {
164            let mut acc = Complex32::new(0.0, 0.0);
165            for (col, &xv) in v.iter().enumerate() {
166                acc += self.matrix[r * dim + col] * xv;
167            }
168            *slot = acc;
169        }
170        out
171    }
172
173    /// Classical normalised solution `A⁻¹|b⟩` (the reference target), or `None`
174    /// if `A` is singular within tolerance. `b` is normalised internally.
175    #[must_use]
176    pub fn classical_solution(&self, b: &[Complex32]) -> Option<Vec<Complex32>> {
177        let dim = 1usize << self.n_qubits;
178        if b.len() != dim {
179            return None;
180        }
181        // Solve A x = b by dense Gauss–Jordan elimination in f64 for stability.
182        let mut aug = vec![Complex::<f64>::new(0.0, 0.0); dim * (dim + 1)];
183        for r in 0..dim {
184            for c in 0..dim {
185                let z = self.matrix[r * dim + c];
186                aug[r * (dim + 1) + c] = Complex::<f64>::new(z.re as f64, z.im as f64);
187            }
188            let bz = b[r];
189            aug[r * (dim + 1) + dim] = Complex::<f64>::new(bz.re as f64, bz.im as f64);
190        }
191        // Forward elimination with partial pivoting on |pivot|.
192        for col in 0..dim {
193            // Find pivot row.
194            let mut pivot_row = col;
195            let mut best = aug[col * (dim + 1) + col].norm();
196            for r in (col + 1)..dim {
197                let mag = aug[r * (dim + 1) + col].norm();
198                if mag > best {
199                    best = mag;
200                    pivot_row = r;
201                }
202            }
203            if best < 1e-12 {
204                return None; // singular
205            }
206            if pivot_row != col {
207                for c in 0..=dim {
208                    aug.swap(col * (dim + 1) + c, pivot_row * (dim + 1) + c);
209                }
210            }
211            let pivot = aug[col * (dim + 1) + col];
212            for c in col..=dim {
213                aug[col * (dim + 1) + c] /= pivot;
214            }
215            for r in 0..dim {
216                if r == col {
217                    continue;
218                }
219                let factor = aug[r * (dim + 1) + col];
220                if factor.norm() < 1e-300 {
221                    continue;
222                }
223                for c in col..=dim {
224                    let sub = factor * aug[col * (dim + 1) + c];
225                    aug[r * (dim + 1) + c] -= sub;
226                }
227            }
228        }
229        // Extract and normalise the solution.
230        let mut x: Vec<Complex32> = (0..dim)
231            .map(|r| {
232                let z = aug[r * (dim + 1) + dim];
233                Complex32::new(z.re as f32, z.im as f32)
234            })
235            .collect();
236        let norm: f32 = x.iter().map(|z| z.norm_sqr()).sum::<f32>().sqrt();
237        if norm < 1e-9 {
238            return None;
239        }
240        for z in &mut x {
241            *z /= norm;
242        }
243        Some(x)
244    }
245}
246
247/// Build the dense `2^n × 2^n` matrix of a Pauli string (tensor product, with
248/// qubit `0` the least-significant bit), row-major.
249fn pauli_string_matrix(paulis: &[Pauli], n_qubits: usize) -> Vec<Complex32> {
250    let dim = 1usize << n_qubits;
251    let single: Vec<[[Complex32; 2]; 2]> = paulis.iter().map(|p| p.matrix()).collect();
252    let mut out = vec![Complex32::new(0.0, 0.0); dim * dim];
253    for row in 0..dim {
254        for col in 0..dim {
255            // Matrix element = Π_q P_q[row_bit_q][col_bit_q].
256            let mut elem = Complex32::new(1.0, 0.0);
257            for (q, pmat) in single.iter().enumerate() {
258                let rb = (row >> q) & 1;
259                let cb = (col >> q) & 1;
260                elem *= pmat[rb][cb];
261                if elem.re == 0.0 && elem.im == 0.0 {
262                    break;
263                }
264            }
265            out[row * dim + col] = elem;
266        }
267    }
268    out
269}
270
271/// A hardware-efficient ansatz: `n_layers` layers, each an `R_y` on every qubit
272/// followed by a nearest-neighbour `CNOT` ladder.
273#[derive(Debug, Clone)]
274pub struct HardwareEfficientAnsatz {
275    /// Number of qubits.
276    n_qubits: usize,
277    /// Number of layers.
278    n_layers: usize,
279}
280
281impl HardwareEfficientAnsatz {
282    /// Create an ansatz over `n_qubits` qubits with `n_layers` layers.
283    ///
284    /// # Errors
285    /// [`QuantumError::IncompatibleAnsatz`] if `n_qubits == 0` or `n_layers == 0`.
286    pub fn new(n_qubits: usize, n_layers: usize) -> QuantumResult<Self> {
287        if n_qubits == 0 || n_layers == 0 {
288            return Err(QuantumError::IncompatibleAnsatz);
289        }
290        Ok(Self { n_qubits, n_layers })
291    }
292
293    /// Number of variational parameters: one `R_y` angle per qubit per layer.
294    #[must_use]
295    pub fn n_params(&self) -> usize {
296        self.n_qubits * self.n_layers
297    }
298
299    /// Prepare `|x(θ)⟩ = V(θ)|0…0⟩`.
300    ///
301    /// # Errors
302    /// [`QuantumError::DimensionMismatch`] if `params.len() != n_params()`;
303    /// propagates gate-application errors.
304    pub fn prepare(&self, params: &[f32]) -> QuantumResult<StateVector> {
305        if params.len() != self.n_params() {
306            return Err(QuantumError::DimensionMismatch {
307                expected: self.n_params(),
308                got: params.len(),
309            });
310        }
311        let mut sv = StateVector::new_zero_state(self.n_qubits)?;
312        let mut idx = 0usize;
313        for _layer in 0..self.n_layers {
314            for q in 0..self.n_qubits {
315                apply_1q_inplace(&mut sv, q, &gate_ry(params[idx]))?;
316                idx += 1;
317            }
318            // Nearest-neighbour CNOT ladder (skipped for a single qubit).
319            for q in 0..self.n_qubits.saturating_sub(1) {
320                apply_cnot(&mut sv, q, q + 1)?;
321            }
322        }
323        Ok(sv)
324    }
325}
326
327/// The VQLS optimiser: holds the operator `A`, the normalised target `|b⟩`, and
328/// the ansatz, and minimises the global cost over the ansatz parameters.
329#[derive(Debug, Clone)]
330pub struct VqlsSolver {
331    operator: LcuOperator,
332    /// Normalised right-hand side `|b⟩` (length `dim`).
333    b: Vec<Complex32>,
334    ansatz: HardwareEfficientAnsatz,
335}
336
337/// Result of a VQLS optimisation run.
338#[derive(Debug, Clone)]
339pub struct VqlsResult {
340    /// Optimised parameters.
341    pub params: Vec<f32>,
342    /// Final cost `C(θ*)`.
343    pub final_cost: f64,
344    /// Cost history, one entry per iteration (including the initial cost).
345    pub cost_history: Vec<f64>,
346    /// The optimised normalised ansatz state `|x(θ*)⟩`.
347    pub solution: Vec<Complex32>,
348}
349
350impl VqlsSolver {
351    /// Build a solver for `A|x⟩ = |b⟩`. `b` is normalised internally and must be
352    /// non-zero with `b.len() == 2^{n_qubits}`.
353    ///
354    /// # Errors
355    /// * [`QuantumError::DimensionMismatch`] if `b`'s length or the ansatz qubit
356    ///   count disagree with the operator's qubit count.
357    /// * [`QuantumError::InvalidParameter`] if `b` is the zero vector.
358    pub fn new(
359        operator: LcuOperator,
360        b: &[Complex32],
361        ansatz: HardwareEfficientAnsatz,
362    ) -> QuantumResult<Self> {
363        let dim = 1usize << operator.n_qubits();
364        if b.len() != dim {
365            return Err(QuantumError::DimensionMismatch {
366                expected: dim,
367                got: b.len(),
368            });
369        }
370        if ansatz.n_qubits != operator.n_qubits() {
371            return Err(QuantumError::DimensionMismatch {
372                expected: operator.n_qubits(),
373                got: ansatz.n_qubits,
374            });
375        }
376        let norm: f32 = b.iter().map(|z| z.norm_sqr()).sum::<f32>().sqrt();
377        if norm < 1e-12 {
378            return Err(QuantumError::InvalidParameter {
379                name: "right-hand side b must be non-zero".into(),
380            });
381        }
382        let inv = 1.0 / norm;
383        let b_norm: Vec<Complex32> = b.iter().map(|z| *z * inv).collect();
384        Ok(Self {
385            operator,
386            b: b_norm,
387            ansatz,
388        })
389    }
390
391    /// Evaluate the global cost
392    /// `C(θ) = 1 − |⟨b|A|x⟩|² / ⟨x|A†A|x⟩` for a given parameter vector.
393    ///
394    /// # Errors
395    /// Propagates ansatz preparation errors.
396    pub fn cost(&self, params: &[f32]) -> QuantumResult<f64> {
397        let x = self.ansatz.prepare(params)?;
398        Ok(self.cost_of_state(&x.amps))
399    }
400
401    /// Global cost of an explicit (not necessarily normalised) state amplitude
402    /// vector `|x⟩`.
403    fn cost_of_state(&self, x: &[Complex32]) -> f64 {
404        let ax = self.operator.apply_to(x);
405        // Denominator ⟨x|A†A|x⟩ = ‖A x‖².
406        let denom: f64 = ax.iter().map(|z| z.norm_sqr() as f64).sum();
407        if denom < 1e-18 {
408            // A|x⟩ = 0 ⇒ cannot align with |b⟩; maximal cost.
409            return 1.0;
410        }
411        // Numerator |⟨b|A x⟩|².
412        let mut inner = Complex::<f64>::new(0.0, 0.0);
413        for (bz, az) in self.b.iter().zip(ax.iter()) {
414            let b64 = Complex::<f64>::new(bz.re as f64, bz.im as f64);
415            let a64 = Complex::<f64>::new(az.re as f64, az.im as f64);
416            inner += b64.conj() * a64;
417        }
418        let overlap = inner.norm_sqr();
419        let cost = 1.0 - overlap / denom;
420        // Numerical hygiene: clamp tiny negative round-off to 0.
421        cost.max(0.0)
422    }
423
424    /// The normalised ansatz state `|x(θ)⟩` for inspection.
425    ///
426    /// # Errors
427    /// Propagates ansatz preparation errors.
428    pub fn solution_state(&self, params: &[f32]) -> QuantumResult<Vec<Complex32>> {
429        let mut x = self.ansatz.prepare(params)?;
430        x.normalize_inplace();
431        Ok(x.amps)
432    }
433
434    /// Minimise the cost by finite-difference gradient descent.
435    ///
436    /// Starts from `init_params`, runs up to `max_iters` iterations with central
437    /// difference step `eps` and an initial learning rate `lr` that is halved
438    /// (backtracking) whenever a step fails to decrease the cost. Stops early when
439    /// the cost drops below `tol` or the learning rate underflows.
440    ///
441    /// # Errors
442    /// * [`QuantumError::DimensionMismatch`] if `init_params.len() != n_params()`.
443    /// * Propagates cost-evaluation errors.
444    pub fn optimize(
445        &self,
446        init_params: &[f32],
447        max_iters: usize,
448        lr: f32,
449        eps: f32,
450        tol: f64,
451    ) -> QuantumResult<VqlsResult> {
452        let n = self.ansatz.n_params();
453        if init_params.len() != n {
454            return Err(QuantumError::DimensionMismatch {
455                expected: n,
456                got: init_params.len(),
457            });
458        }
459        let mut params = init_params.to_vec();
460        let mut current = self.cost(&params)?;
461        let mut history = vec![current];
462        let mut step = lr;
463
464        for _ in 0..max_iters {
465            if current < tol {
466                break;
467            }
468            // Central-difference gradient.
469            let mut grad = vec![0.0_f32; n];
470            for (i, g) in grad.iter_mut().enumerate() {
471                let saved = params[i];
472                params[i] = saved + eps;
473                let c_plus = self.cost(&params)?;
474                params[i] = saved - eps;
475                let c_minus = self.cost(&params)?;
476                params[i] = saved;
477                *g = ((c_plus - c_minus) / (2.0 * eps as f64)) as f32;
478            }
479            // Backtracking line search along −grad.
480            let mut accepted = false;
481            let mut local_step = step;
482            for _try in 0..20 {
483                let trial: Vec<f32> = params
484                    .iter()
485                    .zip(grad.iter())
486                    .map(|(&p, &g)| p - local_step * g)
487                    .collect();
488                let c_trial = self.cost(&trial)?;
489                if c_trial < current {
490                    params = trial;
491                    current = c_trial;
492                    accepted = true;
493                    // Mildly grow the step for the next iteration.
494                    step = local_step * 1.2;
495                    break;
496                }
497                local_step *= 0.5;
498            }
499            history.push(current);
500            if !accepted {
501                // Converged to a (local) stationary point at this resolution.
502                break;
503            }
504        }
505
506        let solution = self.solution_state(&params)?;
507        Ok(VqlsResult {
508            params,
509            final_cost: current,
510            cost_history: history,
511            solution,
512        })
513    }
514}
515
516/// Fidelity `|⟨u|v⟩|²` between two normalised `f32` amplitude vectors.
517#[must_use]
518pub fn fidelity(u: &[Complex32], v: &[Complex32]) -> f64 {
519    let mut inner = Complex::<f64>::new(0.0, 0.0);
520    for (a, b) in u.iter().zip(v.iter()) {
521        let a64 = Complex::<f64>::new(a.re as f64, a.im as f64);
522        let b64 = Complex::<f64>::new(b.re as f64, b.im as f64);
523        inner += a64.conj() * b64;
524    }
525    inner.norm_sqr()
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::handle::LcgRng;
532
533    fn c(re: f32) -> Complex32 {
534        Complex32::new(re, 0.0)
535    }
536
537    /// Random initial parameters in [−π, π].
538    fn random_params(n: usize, seed: u64) -> Vec<f32> {
539        let mut rng = LcgRng::new(seed);
540        (0..n)
541            .map(|_| (rng.next_f32() * 2.0 - 1.0) * std::f32::consts::PI)
542            .collect()
543    }
544
545    // (f) The LCU Σ c_l P_l reconstructs the intended A (unit test).
546    #[test]
547    fn lcu_reconstructs_matrix() {
548        // A = 0.6·I + 0.8·X on 1 qubit = [[0.6, 0.8], [0.8, 0.6]].
549        let terms = vec![
550            PauliTerm {
551                coeff: 0.6,
552                paulis: vec![Pauli::I],
553            },
554            PauliTerm {
555                coeff: 0.8,
556                paulis: vec![Pauli::X],
557            },
558        ];
559        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit LCU operator from I+X terms");
560        let m = op.matrix();
561        assert!((m[0] - c(0.6)).norm() < 1e-6, "[0][0]={:?}", m[0]);
562        assert!((m[1] - c(0.8)).norm() < 1e-6, "[0][1]={:?}", m[1]);
563        assert!((m[2] - c(0.8)).norm() < 1e-6, "[1][0]={:?}", m[2]);
564        assert!((m[3] - c(0.6)).norm() < 1e-6, "[1][1]={:?}", m[3]);
565
566        // 2-qubit: A = 1.0·(Z⊗I) + 0.5·(X⊗X). Check a couple of entries.
567        // Z⊗I is diag(1,1,-1,-1) in little-endian (Z on qubit 1). X⊗X is the
568        // anti-diagonal all-ones. paulis[0] acts on qubit 0.
569        let terms2 = vec![
570            PauliTerm {
571                coeff: 1.0,
572                paulis: vec![Pauli::I, Pauli::Z], // I on q0, Z on q1
573            },
574            PauliTerm {
575                coeff: 0.5,
576                paulis: vec![Pauli::X, Pauli::X],
577            },
578        ];
579        let op2 = LcuOperator::new(&terms2, 2)
580            .expect("valid 2-qubit LCU operator from Z\u{2297}I and X\u{2297}X terms");
581        let m2 = op2.matrix();
582        let at = |row: usize, col: usize| m2[row * 4 + col];
583        // Diagonal from Z on q1: index bit-1 = 0 → +1 (indices 0,1); bit-1 = 1 →
584        // −1 (indices 2,3).
585        assert!((at(0, 0) - c(1.0)).norm() < 1e-6);
586        assert!((at(1, 1) - c(1.0)).norm() < 1e-6);
587        assert!((at(2, 2) - c(-1.0)).norm() < 1e-6);
588        assert!((at(3, 3) - c(-1.0)).norm() < 1e-6);
589        // X⊗X couples |00⟩↔|11⟩ and |01⟩↔|10⟩ with 0.5.
590        assert!((at(0, 3) - c(0.5)).norm() < 1e-6, "00-11");
591        assert!((at(1, 2) - c(0.5)).norm() < 1e-6, "01-10");
592    }
593
594    // (e) Cost is non-negative for arbitrary parameters.
595    #[test]
596    fn cost_is_non_negative() {
597        let terms = vec![
598            PauliTerm {
599                coeff: 0.7,
600                paulis: vec![Pauli::I],
601            },
602            PauliTerm {
603                coeff: 0.4,
604                paulis: vec![Pauli::X],
605            },
606        ];
607        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit LCU operator from I+X terms");
608        let b = vec![c(1.0), c(0.0)];
609        let ansatz = HardwareEfficientAnsatz::new(1, 2).expect("valid 1-qubit 2-layer ansatz");
610        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
611        for seed in 0..20 {
612            let p = random_params(solver.ansatz.n_params(), seed);
613            let cost = solver.cost(&p).expect("cost evaluation should succeed");
614            assert!(cost >= -1e-9, "seed {seed}: cost {cost} negative");
615            assert!(cost <= 1.0 + 1e-9, "seed {seed}: cost {cost} > 1");
616        }
617    }
618
619    // (c) cost = 0 ⟺ |x⟩ ∝ A⁻¹|b⟩: the exact classical solution gives ~0 cost.
620    #[test]
621    fn exact_solution_gives_zero_cost() {
622        // A = 0.6·I + 0.8·X; b = |0⟩.
623        let terms = vec![
624            PauliTerm {
625                coeff: 0.6,
626                paulis: vec![Pauli::I],
627            },
628            PauliTerm {
629                coeff: 0.8,
630                paulis: vec![Pauli::X],
631            },
632        ];
633        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit LCU operator from I+X terms");
634        let b = vec![c(1.0), c(0.0)];
635        let ansatz = HardwareEfficientAnsatz::new(1, 1).expect("valid 1-qubit 1-layer ansatz");
636        let target = op
637            .classical_solution(&b)
638            .expect("classical solution should exist for invertible A");
639        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
640        // Feed the exact (normalised) solution amplitudes directly into the cost.
641        let cost = solver.cost_of_state(&target);
642        assert!(cost < 1e-6, "exact-solution cost {cost} should be ~0");
643    }
644
645    // (a) After optimisation the normalised |x⟩ has fidelity > 0.95 with A⁻¹|b⟩.
646    #[test]
647    fn optimized_state_has_high_fidelity() {
648        // A = 0.8·I + 0.6·Z (diagonal, invertible): A = diag(1.4, 0.2). b = (1,1)/√2.
649        let terms = vec![
650            PauliTerm {
651                coeff: 0.8,
652                paulis: vec![Pauli::I],
653            },
654            PauliTerm {
655                coeff: 0.6,
656                paulis: vec![Pauli::Z],
657            },
658        ];
659        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit LCU operator from I+Z terms");
660        let inv_sqrt2 = std::f32::consts::FRAC_1_SQRT_2;
661        let b = vec![c(inv_sqrt2), c(inv_sqrt2)];
662        let target = op
663            .classical_solution(&b)
664            .expect("classical solution should exist for invertible A");
665        let ansatz = HardwareEfficientAnsatz::new(1, 2).expect("valid 1-qubit 2-layer ansatz");
666        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
667
668        // Try a few seeds; gradient descent should reach high fidelity.
669        let mut best_fid = 0.0_f64;
670        for seed in 0..6 {
671            let init = random_params(solver.ansatz.n_params(), 100 + seed);
672            let res = solver
673                .optimize(&init, 400, 0.5, 1e-3, 1e-8)
674                .expect("optimization should succeed");
675            let fid = fidelity(&res.solution, &target);
676            best_fid = best_fid.max(fid);
677            if best_fid > 0.95 {
678                break;
679            }
680        }
681        assert!(best_fid > 0.95, "best fidelity {best_fid} ≤ 0.95");
682    }
683
684    // (b) The cost decreases over iterations and reaches near 0.
685    #[test]
686    fn cost_decreases_and_reaches_zero() {
687        let terms = vec![
688            PauliTerm {
689                coeff: 0.8,
690                paulis: vec![Pauli::I],
691            },
692            PauliTerm {
693                coeff: 0.6,
694                paulis: vec![Pauli::Z],
695            },
696        ];
697        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit LCU operator from I+Z terms");
698        let inv_sqrt2 = std::f32::consts::FRAC_1_SQRT_2;
699        let b = vec![c(inv_sqrt2), c(inv_sqrt2)];
700        let ansatz = HardwareEfficientAnsatz::new(1, 2).expect("valid 1-qubit 2-layer ansatz");
701        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
702
703        // Find a run that converges low and verify monotone-ish non-increase.
704        let mut converged = None;
705        for seed in 0..8 {
706            let init = random_params(solver.ansatz.n_params(), 7 + seed);
707            let res = solver
708                .optimize(&init, 400, 0.5, 1e-3, 1e-8)
709                .expect("optimization should succeed");
710            if res.final_cost < 1e-3 {
711                converged = Some(res);
712                break;
713            }
714        }
715        let res = converged.expect("at least one seed should converge near 0");
716        // Cost history is non-increasing (backtracking only accepts improvements).
717        for w in res.cost_history.windows(2) {
718            assert!(w[1] <= w[0] + 1e-9, "cost increased: {} → {}", w[0], w[1]);
719        }
720        assert!(
721            res.final_cost < 1e-3,
722            "final cost {} not near 0",
723            res.final_cost
724        );
725        // First entry should be substantially larger than the last (real progress).
726        assert!(
727            res.cost_history[0] > res.final_cost,
728            "no progress: {} → {}",
729            res.cost_history[0],
730            res.final_cost
731        );
732    }
733
734    // (d) A = I → optimal |x⟩ = |b⟩.
735    #[test]
736    fn identity_solution_is_b() {
737        // A = I on 1 qubit.
738        let terms = vec![PauliTerm {
739            coeff: 1.0,
740            paulis: vec![Pauli::I],
741        }];
742        let op = LcuOperator::new(&terms, 1).expect("valid 1-qubit identity LCU operator");
743        // b = R_y target: choose b = (cos(0.3), sin(0.3)) (real, reachable by 1
744        // R_y layer). With A = I, A⁻¹|b⟩ = |b⟩.
745        let b = vec![c(0.3_f32.cos()), c(0.3_f32.sin())];
746        let target = op
747            .classical_solution(&b)
748            .expect("classical solution should exist for identity A");
749        let ansatz = HardwareEfficientAnsatz::new(1, 1).expect("valid 1-qubit 1-layer ansatz");
750        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
751
752        let mut best_fid = 0.0_f64;
753        for seed in 0..6 {
754            let init = random_params(solver.ansatz.n_params(), 200 + seed);
755            let res = solver
756                .optimize(&init, 400, 0.5, 1e-3, 1e-9)
757                .expect("optimization should succeed");
758            best_fid = best_fid.max(fidelity(&res.solution, &target));
759            if best_fid > 0.98 {
760                break;
761            }
762        }
763        // Target equals normalised |b⟩.
764        let bnorm: f32 = b.iter().map(|z| z.norm_sqr()).sum::<f32>().sqrt();
765        let b_normed: Vec<Complex32> = b.iter().map(|z| *z / bnorm).collect();
766        assert!(
767            fidelity(&target, &b_normed) > 0.999,
768            "target should equal |b⟩ for A=I"
769        );
770        assert!(best_fid > 0.98, "fidelity to |b⟩={best_fid}");
771    }
772
773    // 2-qubit end-to-end smoke: assemble A, optimise, and reach a low cost.
774    #[test]
775    fn two_qubit_solver_converges() {
776        // A = 1.0·I⊗I + 0.3·Z⊗I + 0.2·I⊗Z (diagonal, strictly diagonally
777        // dominant ⇒ invertible). b = uniform.
778        let terms = vec![
779            PauliTerm {
780                coeff: 1.0,
781                paulis: vec![Pauli::I, Pauli::I],
782            },
783            PauliTerm {
784                coeff: 0.3,
785                paulis: vec![Pauli::Z, Pauli::I],
786            },
787            PauliTerm {
788                coeff: 0.2,
789                paulis: vec![Pauli::I, Pauli::Z],
790            },
791        ];
792        let op = LcuOperator::new(&terms, 2).expect("valid 2-qubit diagonal LCU operator");
793        let half = 0.5_f32;
794        let b = vec![c(half), c(half), c(half), c(half)];
795        let target = op
796            .classical_solution(&b)
797            .expect("classical solution should exist for diagonally dominant A");
798        let ansatz = HardwareEfficientAnsatz::new(2, 3).expect("valid 2-qubit 3-layer ansatz");
799        let solver = VqlsSolver::new(op, &b, ansatz).expect("valid VQLS solver");
800
801        let mut best_fid = 0.0_f64;
802        let mut best_cost = 1.0_f64;
803        for seed in 0..10 {
804            let init = random_params(solver.ansatz.n_params(), 300 + seed);
805            let res = solver
806                .optimize(&init, 500, 0.4, 1e-3, 1e-8)
807                .expect("optimization should succeed");
808            best_cost = best_cost.min(res.final_cost);
809            best_fid = best_fid.max(fidelity(&res.solution, &target));
810            if best_fid > 0.95 {
811                break;
812            }
813        }
814        assert!(best_cost < 1e-2, "2-qubit best cost {best_cost}");
815        assert!(best_fid > 0.95, "2-qubit best fidelity {best_fid}");
816    }
817
818    // Validation: dimension and emptiness checks.
819    #[test]
820    fn validation_errors() {
821        // Empty term list.
822        assert!(LcuOperator::new(&[], 1).is_err());
823        // Wrong Pauli-string length.
824        let bad = vec![PauliTerm {
825            coeff: 1.0,
826            paulis: vec![Pauli::X, Pauli::X],
827        }];
828        assert!(LcuOperator::new(&bad, 1).is_err());
829        // Ansatz with zero layers.
830        assert!(HardwareEfficientAnsatz::new(2, 0).is_err());
831        // Mismatched b length.
832        let op = LcuOperator::new(
833            &[PauliTerm {
834                coeff: 1.0,
835                paulis: vec![Pauli::I],
836            }],
837            1,
838        )
839        .expect("valid single-qubit identity LCU operator");
840        let ansatz = HardwareEfficientAnsatz::new(1, 1).expect("valid 1-qubit 1-layer ansatz");
841        assert!(VqlsSolver::new(op, &[c(1.0), c(0.0), c(0.0)], ansatz).is_err());
842        // Zero b.
843        let op2 = LcuOperator::new(
844            &[PauliTerm {
845                coeff: 1.0,
846                paulis: vec![Pauli::I],
847            }],
848            1,
849        )
850        .expect("valid single-qubit identity LCU operator for zero-b test");
851        let ansatz2 = HardwareEfficientAnsatz::new(1, 1).expect("valid 1-qubit 1-layer ansatz");
852        assert!(VqlsSolver::new(op2, &[c(0.0), c(0.0)], ansatz2).is_err());
853    }
854}