Skip to main content

optirs_core/quantum_inspired/
vqe.rs

1// Variational Quantum Eigensolver (VQE) inspired optimizer based on SPSA.
2//
3// This module implements a SPSA (Simultaneous Perturbation Stochastic
4// Approximation) optimizer with a quantum-inspired ansatz. SPSA is the
5// optimizer of choice for hardware VQE because it estimates gradients with
6// only two loss evaluations regardless of dimensionality.
7
8use scirs2_core::ndarray::{Array, Array1, Dimension, ScalarOperand};
9use scirs2_core::numeric::Float;
10use scirs2_core::random::Random;
11use std::fmt::Debug;
12
13use crate::error::{OptimError, Result};
14use crate::optimizers::Optimizer;
15
16use super::DEFAULT_SEED;
17
18/// Default SPSA gain `a` (numerator of the learning-rate schedule).
19pub(crate) const DEFAULT_SPSA_A: f64 = 0.1;
20/// Default SPSA perturbation `c` (numerator of the perturbation schedule).
21pub(crate) const DEFAULT_SPSA_C: f64 = 0.1;
22/// Default SPSA exponent `α` (gain decay).
23pub(crate) const DEFAULT_SPSA_ALPHA: f64 = 0.602;
24/// Default SPSA exponent `γ` (perturbation decay).
25pub(crate) const DEFAULT_SPSA_GAMMA: f64 = 0.101;
26/// Default SPSA stability `A` (offset that softens early-iteration steps).
27pub(crate) const DEFAULT_SPSA_BIG_A: f64 = 10.0;
28
29/// Variational Quantum Optimizer.
30///
31/// `VariationalQuantumOptimizer` implements a SPSA optimizer with a
32/// quantum-inspired ansatz update rule. SPSA approximates the gradient with
33///
34/// ```text
35///     g_i(k) ≈ (L(θ + c_k * Δ) - L(θ - c_k * Δ)) / (2 * c_k * Δ_i)
36/// ```
37///
38/// where `Δ ∈ {-1, +1}^d` is sampled uniformly at every iteration. The gain
39/// sequences follow the canonical Spall (1998) recipe:
40///
41/// ```text
42///     a_k = a / (k + 1 + A)^α
43///     c_k = c / (k + 1)^γ
44/// ```
45///
46/// The "quantum ansatz" applies a rotation-gate-inspired factor `cos²(θ_i / 2)`
47/// to the SPSA update, smoothing updates near `θ_i = 0` (mimicking how a
48/// rotation gate has unit effect near identity) and vanishing near `θ_i = π`.
49///
50/// # Examples
51///
52/// ```
53/// use optirs_core::quantum_inspired::VariationalQuantumOptimizer;
54/// use scirs2_core::ndarray::Array1;
55///
56/// let mut optimizer: VariationalQuantumOptimizer<f64> =
57///     VariationalQuantumOptimizer::new(0.1)
58///         .with_perturbation(0.05)
59///         .with_seed(7);
60///
61/// let params = Array1::from_vec(vec![0.5, -0.3, 1.2]);
62/// let loss_fn = |theta: &Array1<f64>| theta.iter().map(|x| x * x).sum::<f64>();
63/// let next = optimizer.step_from_loss(&params, loss_fn).expect("step failed");
64/// assert_eq!(next.len(), 3);
65/// ```
66#[derive(Debug)]
67pub struct VariationalQuantumOptimizer<A: Float + ScalarOperand + Debug> {
68    /// SPSA gain numerator `a` (also used as the canonical learning rate).
69    learning_rate: A,
70    /// SPSA perturbation numerator `c`.
71    c: A,
72    /// SPSA gain decay exponent `α`.
73    alpha: A,
74    /// SPSA perturbation decay exponent `γ`.
75    gamma: A,
76    /// SPSA stability `A` that softens the first few learning-rate steps.
77    big_a: A,
78    /// Current step counter `k`, starting at `0`.
79    step: usize,
80    /// Last observed loss value (for diagnostics).
81    last_loss: Option<A>,
82    /// Seed used to initialise the RNG.
83    seed: u64,
84    /// Seeded RNG.
85    rng: Random<scirs2_core::random::rngs::StdRng>,
86    /// Optional cached ansatz parameters (used in the trait-driven step).
87    ansatz_params: Option<Array1<A>>,
88}
89
90impl<A> VariationalQuantumOptimizer<A>
91where
92    A: Float + ScalarOperand + Debug + Send + Sync,
93{
94    /// Create a VQE-inspired SPSA optimizer with the canonical SPSA gain
95    /// `a = 0.1`, matching the defaults already used for `c`, `α`, `γ` and
96    /// `A`.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use optirs_core::quantum_inspired::VariationalQuantumOptimizer;
102    ///
103    /// let optimizer = VariationalQuantumOptimizer::<f64>::with_default_gain();
104    /// assert!((optimizer.learning_rate() - 0.1).abs() < 1e-12);
105    /// ```
106    pub fn with_default_gain() -> Self {
107        Self::new(A::from(DEFAULT_SPSA_A).unwrap_or_else(A::one))
108    }
109
110    /// Create a new VQE-inspired SPSA optimizer with the given learning rate.
111    pub fn new(learning_rate: A) -> Self {
112        let c = A::from(DEFAULT_SPSA_C).unwrap_or_else(|| A::epsilon());
113        let alpha = A::from(DEFAULT_SPSA_ALPHA).unwrap_or_else(A::one);
114        let gamma = A::from(DEFAULT_SPSA_GAMMA).unwrap_or_else(A::one);
115        let big_a = A::from(DEFAULT_SPSA_BIG_A).unwrap_or_else(A::zero);
116        Self {
117            learning_rate,
118            c,
119            alpha,
120            gamma,
121            big_a,
122            step: 0,
123            last_loss: None,
124            seed: DEFAULT_SEED,
125            rng: Random::seed(DEFAULT_SEED),
126            ansatz_params: None,
127        }
128    }
129
130    /// Configure the SPSA perturbation magnitude `c`.
131    pub fn with_perturbation(mut self, c: A) -> Self {
132        self.c = c;
133        self
134    }
135
136    /// Configure the SPSA decay exponents `α` (gain) and `γ` (perturbation).
137    pub fn with_gain_decay(mut self, alpha: A, gamma: A) -> Self {
138        self.alpha = alpha;
139        self.gamma = gamma;
140        self
141    }
142
143    /// Configure the SPSA stability offset `A`.
144    pub fn with_stability(mut self, big_a: A) -> Self {
145        self.big_a = big_a;
146        self
147    }
148
149    /// Seed the optimizer's RNG.
150    pub fn with_seed(mut self, seed: u64) -> Self {
151        self.seed = seed;
152        self.rng = Random::seed(seed);
153        self
154    }
155
156    /// Returns the SPSA `α` exponent.
157    pub fn alpha(&self) -> A {
158        self.alpha
159    }
160
161    /// Returns the SPSA `γ` exponent.
162    pub fn gamma(&self) -> A {
163        self.gamma
164    }
165
166    /// Returns the SPSA stability offset `A`.
167    pub fn big_a(&self) -> A {
168        self.big_a
169    }
170
171    /// Returns the SPSA perturbation numerator `c`.
172    pub fn c(&self) -> A {
173        self.c
174    }
175
176    /// Returns the current step counter `k`.
177    pub fn step_count(&self) -> usize {
178        self.step
179    }
180
181    /// Returns the most recently observed loss value, if any.
182    pub fn last_loss(&self) -> Option<A> {
183        self.last_loss
184    }
185
186    /// Returns the seed.
187    pub fn seed(&self) -> u64 {
188        self.seed
189    }
190
191    /// Returns the learning rate. Inherent helper that mirrors the trait
192    /// method [`Optimizer::get_learning_rate`] so callers do not need to
193    /// disambiguate the dimension type.
194    pub fn learning_rate(&self) -> A {
195        self.learning_rate
196    }
197
198    /// Set the learning rate. Inherent helper that mirrors the trait method.
199    pub fn set_lr(&mut self, learning_rate: A) {
200        self.learning_rate = learning_rate;
201    }
202
203    /// SPSA gain `a_k`.
204    pub fn a_k(&self, k: usize) -> A {
205        let k_f = A::from(k).unwrap_or_else(A::zero);
206        let one = A::one();
207        let denom = (k_f + one + self.big_a).powf(self.alpha);
208        if denom <= A::zero() {
209            self.learning_rate
210        } else {
211            self.learning_rate / denom
212        }
213    }
214
215    /// SPSA perturbation `c_k`.
216    pub fn c_k(&self, k: usize) -> A {
217        let k_f = A::from(k).unwrap_or_else(A::zero);
218        let one = A::one();
219        let denom = (k_f + one).powf(self.gamma);
220        if denom <= A::zero() {
221            self.c
222        } else {
223            self.c / denom
224        }
225    }
226
227    /// Reset the step counter and re-seed the RNG.
228    pub fn reset(&mut self) {
229        self.step = 0;
230        self.last_loss = None;
231        self.rng = Random::seed(self.seed);
232        self.ansatz_params = None;
233    }
234
235    /// Quantum-inspired ansatz factor `cos²(θ_i / 2)`. Public for testing.
236    pub fn ansatz_factor(theta: A) -> A {
237        let half = A::from(0.5).unwrap_or_else(A::one);
238        let c = (theta * half).cos();
239        c * c
240    }
241
242    /// Sample a fresh SPSA perturbation `Δ ∈ {-1, +1}^d`.
243    pub(crate) fn sample_perturbation_vector(&mut self, dim: usize) -> Array1<A> {
244        let mut buf: Vec<A> = Vec::with_capacity(dim);
245        let one = A::one();
246        let neg_one = -A::one();
247        for _ in 0..dim {
248            let u: f64 = self.rng.gen_range(0.0..1.0);
249            buf.push(if u < 0.5 { neg_one } else { one });
250        }
251        Array1::from_vec(buf)
252    }
253
254    /// Compute an SPSA gradient estimate using the supplied loss function.
255    ///
256    /// Returns `(gradient, c_k, delta)`.
257    pub fn spsa_gradient<F>(
258        &mut self,
259        params: &Array1<A>,
260        loss_fn: F,
261        k: usize,
262    ) -> Result<(Array1<A>, A, Array1<A>)>
263    where
264        F: Fn(&Array1<A>) -> A,
265    {
266        let dim = params.len();
267        if dim == 0 {
268            return Err(OptimError::InvalidParameter(
269                "VariationalQuantumOptimizer: parameters must be non-empty".to_string(),
270            ));
271        }
272        let c_k = self.c_k(k);
273        if c_k <= A::zero() {
274            return Err(OptimError::InvalidConfig(
275                "VariationalQuantumOptimizer: c_k must be positive".to_string(),
276            ));
277        }
278        let delta = self.sample_perturbation_vector(dim);
279        let plus = params + &(&delta * c_k);
280        let minus = params - &(&delta * c_k);
281        let loss_plus = loss_fn(&plus);
282        let loss_minus = loss_fn(&minus);
283        let two = A::from(2.0).unwrap_or_else(A::one);
284        let numerator = loss_plus - loss_minus;
285        let denom = two * c_k;
286        let mut grad = Array1::<A>::zeros(dim);
287        for i in 0..dim {
288            // Δ_i ∈ {-1, +1} so dividing is safe and equivalent to multiplying.
289            let d = delta[i];
290            grad[i] = numerator / (denom * d);
291        }
292        Ok((grad, c_k, delta))
293    }
294
295    /// Apply the quantum-inspired ansatz update.
296    ///
297    /// `new_θ_i = θ_i - a_k * g_i * cos²(θ_i / 2)`
298    fn apply_ansatz(&self, params: &Array1<A>, grad: &Array1<A>, k: usize) -> Array1<A> {
299        let a_k = self.a_k(k);
300        let mut updated = params.clone();
301        for i in 0..updated.len() {
302            let factor = Self::ansatz_factor(updated[i]);
303            updated[i] = updated[i] - a_k * grad[i] * factor;
304        }
305        updated
306    }
307
308    /// Perform a loss-driven SPSA step.
309    ///
310    /// This is the canonical VQE-style update that uses a closed-form loss
311    /// rather than relying on user-provided gradients.
312    pub fn step_from_loss<F>(&mut self, params: &Array1<A>, loss_fn: F) -> Result<Array1<A>>
313    where
314        F: Fn(&Array1<A>) -> A,
315    {
316        let k = self.step;
317        let (grad, _c_k, _delta) = self.spsa_gradient(params, &loss_fn, k)?;
318        let updated = self.apply_ansatz(params, &grad, k);
319        self.last_loss = Some(loss_fn(&updated));
320        self.step = self.step.saturating_add(1);
321        self.ansatz_params = Some(updated.clone());
322        Ok(updated)
323    }
324}
325
326impl<A, D> Optimizer<A, D> for VariationalQuantumOptimizer<A>
327where
328    A: Float + ScalarOperand + Debug + Send + Sync,
329    D: Dimension,
330{
331    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
332        if params.shape() != gradients.shape() {
333            return Err(OptimError::DimensionMismatch(format!(
334                "VQE optimizer: parameters have shape {:?}, gradients have shape {:?}",
335                params.shape(),
336                gradients.shape()
337            )));
338        }
339
340        let params_dyn = params.to_owned().into_dyn();
341        let grads_dyn = gradients.to_owned().into_dyn();
342        let k = self.step;
343        let a_k = self.a_k(k);
344
345        // Apply the quantum-inspired ansatz on each parameter using the
346        // user-supplied gradient directly (so the optimizer remains a true
347        // Optimizer<A, D> on top of pre-computed gradients).
348        let mut updated = params_dyn.clone();
349        for (out, (p, g)) in updated
350            .iter_mut()
351            .zip(params_dyn.iter().zip(grads_dyn.iter()))
352        {
353            let factor = Self::ansatz_factor(*p);
354            *out = *p - a_k * (*g) * factor;
355        }
356
357        self.step = self.step.saturating_add(1);
358
359        updated.into_dimensionality::<D>().map_err(|err| {
360            OptimError::ComputationError(format!(
361                "VQE optimizer: failed to restore dimension: {err}"
362            ))
363        })
364    }
365
366    fn get_learning_rate(&self) -> A {
367        self.learning_rate
368    }
369
370    fn set_learning_rate(&mut self, learning_rate: A) {
371        self.learning_rate = learning_rate;
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use approx::{assert_abs_diff_eq, assert_relative_eq};
379    use scirs2_core::ndarray::Array1;
380    use std::f64::consts::PI;
381
382    #[test]
383    fn test_default_config_values() {
384        let optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.1);
385        assert_abs_diff_eq!(optimizer.learning_rate(), 0.1);
386        assert_abs_diff_eq!(optimizer.c(), DEFAULT_SPSA_C);
387        assert_abs_diff_eq!(optimizer.alpha(), DEFAULT_SPSA_ALPHA);
388        assert_abs_diff_eq!(optimizer.gamma(), DEFAULT_SPSA_GAMMA);
389        assert_abs_diff_eq!(optimizer.big_a(), DEFAULT_SPSA_BIG_A);
390        assert_eq!(optimizer.step_count(), 0);
391    }
392
393    #[test]
394    fn test_builder_pattern() {
395        let optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.05)
396            .with_perturbation(0.2)
397            .with_gain_decay(0.5, 0.2)
398            .with_stability(20.0)
399            .with_seed(99);
400        assert_abs_diff_eq!(optimizer.c(), 0.2);
401        assert_abs_diff_eq!(optimizer.alpha(), 0.5);
402        assert_abs_diff_eq!(optimizer.gamma(), 0.2);
403        assert_abs_diff_eq!(optimizer.big_a(), 20.0);
404        assert_eq!(optimizer.seed(), 99);
405    }
406
407    #[test]
408    fn test_gain_sequences_endpoints() {
409        let optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.1)
410            .with_perturbation(0.1)
411            .with_gain_decay(0.602, 0.101)
412            .with_stability(10.0);
413        // a_0 = a / (0 + 1 + A)^α = 0.1 / 11^0.602
414        let expected_a0 = 0.1_f64 / 11.0_f64.powf(0.602);
415        assert_relative_eq!(optimizer.a_k(0), expected_a0, epsilon = 1e-12);
416        // c_0 = c / (0 + 1)^γ = 0.1 / 1 = 0.1
417        assert_abs_diff_eq!(optimizer.c_k(0), 0.1);
418        // c_k decays at k=10: 0.1 / 11^0.101
419        let expected_c10 = 0.1_f64 / 11.0_f64.powf(0.101);
420        assert_relative_eq!(optimizer.c_k(10), expected_c10, epsilon = 1e-12);
421    }
422
423    #[test]
424    fn test_gain_sequences_decay() {
425        let optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.1);
426        // a_k and c_k must be strictly decreasing because all exponents and
427        // denominators are positive.
428        for k in 0..50 {
429            assert!(
430                optimizer.a_k(k + 1) < optimizer.a_k(k),
431                "a_k not decreasing at k={k}"
432            );
433            assert!(
434                optimizer.c_k(k + 1) < optimizer.c_k(k),
435                "c_k not decreasing at k={k}"
436            );
437        }
438    }
439
440    #[test]
441    fn test_perturbation_is_pm_one() {
442        let mut optimizer: VariationalQuantumOptimizer<f64> =
443            VariationalQuantumOptimizer::new(0.1).with_seed(13);
444        for _ in 0..20 {
445            let delta = optimizer.sample_perturbation_vector(32);
446            for v in delta.iter() {
447                assert!(
448                    (*v == 1.0) || (*v == -1.0),
449                    "perturbation entry {} was not in {{-1, 1}}",
450                    *v
451                );
452            }
453        }
454    }
455
456    #[test]
457    fn test_spsa_gradient_unbiasedness() {
458        // For a quadratic loss L(θ) = ||θ||² the analytical gradient at θ is
459        // 2θ. The SPSA gradient is exactly unbiased for quadratic objectives
460        // (the second-order Taylor term cancels in the central difference) so
461        // averaging over many trials should converge in probability to 2θ.
462        //
463        // We average across many seeds to drive variance down even when each
464        // individual mean has substantial residual variance.
465        let theta = Array1::from_vec(vec![1.5, -0.5, 0.25]);
466        let mut accum = Array1::<f64>::zeros(theta.len());
467        let trials_per_seed = 2000;
468        let num_seeds = 5;
469        let mut total_trials = 0usize;
470        for seed in 0..num_seeds {
471            let mut optimizer: VariationalQuantumOptimizer<f64> =
472                VariationalQuantumOptimizer::new(0.01)
473                    .with_perturbation(0.01)
474                    .with_seed(2024 + seed as u64);
475            for _ in 0..trials_per_seed {
476                let (g, _c, _d) = optimizer
477                    .spsa_gradient(&theta, |x| x.iter().map(|v| v * v).sum::<f64>(), 0)
478                    .expect("spsa gradient failed");
479                accum = &accum + &g;
480                total_trials += 1;
481            }
482        }
483        let n = total_trials as f64;
484        for i in 0..theta.len() {
485            let mean = accum[i] / n;
486            let expected = 2.0 * theta[i];
487            // SPSA is exactly unbiased for quadratic losses, so the mean
488            // should converge to 2θ. Variance per trial for θ=[1.5,-0.5,0.25]
489            // is O(1) so std of mean with n=10_000 is ~0.04 → 0.2 tolerance
490            // is a safe ~5σ bound.
491            assert!(
492                (mean - expected).abs() < 0.2,
493                "SPSA gradient bias too large at index {i}: mean={mean}, expected={expected}, n={n}"
494            );
495        }
496    }
497
498    #[test]
499    fn test_step_from_loss_decreases_loss() {
500        let mut optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.5)
501            .with_perturbation(0.05)
502            .with_gain_decay(0.602, 0.101)
503            .with_stability(2.0)
504            .with_seed(77);
505        let mut params = Array1::from_vec(vec![1.2, -0.8, 0.5]);
506        let loss_fn = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
507        let initial = loss_fn(&params);
508        for _ in 0..150 {
509            params = optimizer
510                .step_from_loss(&params, loss_fn)
511                .expect("step failed");
512        }
513        let final_loss = loss_fn(&params);
514        assert!(
515            final_loss < initial,
516            "Loss did not decrease: initial={initial}, final={final_loss}"
517        );
518    }
519
520    #[test]
521    fn test_ansatz_smoothness() {
522        // cos²(θ/2) → 1 as θ → 0
523        let near_zero = VariationalQuantumOptimizer::<f64>::ansatz_factor(0.0);
524        assert_abs_diff_eq!(near_zero, 1.0, epsilon = 1e-12);
525        // cos²(θ/2) → 0 as θ → π
526        let at_pi = VariationalQuantumOptimizer::<f64>::ansatz_factor(PI);
527        assert_abs_diff_eq!(at_pi, 0.0, epsilon = 1e-12);
528        // intermediate value smoothly between 0 and 1
529        let mid = VariationalQuantumOptimizer::<f64>::ansatz_factor(PI / 2.0);
530        assert!(mid > 0.4 && mid < 0.6, "midpoint ansatz factor = {mid}");
531    }
532
533    #[test]
534    fn test_seed_reproducibility() {
535        let mut a: VariationalQuantumOptimizer<f64> =
536            VariationalQuantumOptimizer::new(0.1).with_seed(321);
537        let mut b: VariationalQuantumOptimizer<f64> =
538            VariationalQuantumOptimizer::new(0.1).with_seed(321);
539        let params = Array1::from_vec(vec![0.2, -0.4, 0.6, -0.8]);
540        let loss_fn = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
541        for _ in 0..25 {
542            let pa = a.step_from_loss(&params, loss_fn).expect("step failed");
543            let pb = b.step_from_loss(&params, loss_fn).expect("step failed");
544            for (x, y) in pa.iter().zip(pb.iter()) {
545                assert_abs_diff_eq!(*x, *y, epsilon = 1e-12);
546            }
547        }
548    }
549
550    #[test]
551    fn test_get_set_learning_rate() {
552        let mut optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.3);
553        assert_abs_diff_eq!(optimizer.learning_rate(), 0.3);
554        optimizer.set_lr(0.05);
555        assert_abs_diff_eq!(optimizer.learning_rate(), 0.05);
556    }
557
558    #[test]
559    fn test_step_returns_same_shape() {
560        let mut optimizer: VariationalQuantumOptimizer<f64> =
561            VariationalQuantumOptimizer::new(0.1).with_seed(5);
562        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
563        let grads = Array1::from_vec(vec![0.5, -0.3, 0.2, 0.1]);
564        let updated = <VariationalQuantumOptimizer<f64> as Optimizer<f64, _>>::step(
565            &mut optimizer,
566            &params,
567            &grads,
568        )
569        .expect("step failed");
570        assert_eq!(updated.shape(), params.shape());
571    }
572
573    #[test]
574    fn test_convergence_on_quadratic() {
575        let mut optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(1.0)
576            .with_perturbation(0.05)
577            .with_gain_decay(0.602, 0.101)
578            .with_stability(2.0)
579            .with_seed(31);
580        let mut params = Array1::from_vec(vec![1.0, -1.0]);
581        let loss_fn = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
582        for _ in 0..400 {
583            params = optimizer
584                .step_from_loss(&params, loss_fn)
585                .expect("step failed");
586        }
587        // Parameters near zero (the global minimum).
588        for v in params.iter() {
589            assert!(v.abs() < 0.5, "Parameter did not converge: |x|={}", v.abs());
590        }
591    }
592
593    #[test]
594    fn test_step_count_increments() {
595        let mut optimizer: VariationalQuantumOptimizer<f64> =
596            VariationalQuantumOptimizer::new(0.1).with_seed(1);
597        let params = Array1::from_vec(vec![0.5, -0.5]);
598        let loss_fn = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
599        assert_eq!(optimizer.step_count(), 0);
600        for i in 1..=5 {
601            let _ = optimizer
602                .step_from_loss(&params, loss_fn)
603                .expect("step failed");
604            assert_eq!(optimizer.step_count(), i);
605        }
606    }
607
608    #[test]
609    fn test_dimension_mismatch_errors() {
610        let mut optimizer: VariationalQuantumOptimizer<f64> = VariationalQuantumOptimizer::new(0.1);
611        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
612        let grads = Array1::from_vec(vec![1.0, 2.0]);
613        let result = <VariationalQuantumOptimizer<f64> as Optimizer<f64, _>>::step(
614            &mut optimizer,
615            &params,
616            &grads,
617        );
618        assert!(result.is_err(), "expected dimension mismatch error");
619    }
620}