Skip to main content

quantrs2_ml/
optimization.rs

1use crate::error::{MLError, Result};
2use scirs2_core::ndarray::{Array1, ArrayView1};
3use std::collections::HashMap;
4use std::fmt;
5
6/// Spall's recommended step-size decay exponent for SPSA's gain sequence
7/// `a_k = a / (k+1)^alpha`.
8const SPSA_ALPHA: f64 = 0.602;
9/// Spall's recommended perturbation decay exponent for SPSA's gain sequence
10/// `c_k = c / (k+1)^gamma`.
11const SPSA_GAMMA: f64 = 0.101;
12
13/// Optimization method to use for training quantum machine learning models
14#[derive(Debug, Clone, Copy)]
15pub enum OptimizationMethod {
16    /// Gradient descent
17    GradientDescent,
18
19    /// Adam optimizer
20    Adam,
21
22    /// SPSA (Simultaneous Perturbation Stochastic Approximation)
23    SPSA,
24
25    /// L-BFGS (Limited-memory Broyden–Fletcher–Goldfarb–Shanno)
26    LBFGS,
27
28    /// Quantum Natural Gradient
29    QuantumNaturalGradient,
30
31    /// SciRS2 Adam optimizer
32    SciRS2Adam,
33
34    /// SciRS2 L-BFGS optimizer
35    SciRS2LBFGS,
36
37    /// SciRS2 Conjugate Gradient
38    SciRS2CG,
39}
40
41/// Optimizer for quantum machine learning models
42#[derive(Debug, Clone)]
43pub enum Optimizer {
44    /// Gradient descent
45    GradientDescent {
46        /// Learning rate
47        learning_rate: f64,
48    },
49
50    /// Adam optimizer
51    Adam {
52        /// Learning rate
53        learning_rate: f64,
54
55        /// Beta1 parameter
56        beta1: f64,
57
58        /// Beta2 parameter
59        beta2: f64,
60
61        /// Epsilon parameter
62        epsilon: f64,
63
64        /// First-moment (momentum) accumulator. Lazily (re)sized to match
65        /// the parameter vector on the first `update_parameters` call.
66        m: Array1<f64>,
67
68        /// Second-moment (uncentered variance) accumulator. Lazily
69        /// (re)sized to match the parameter vector on the first
70        /// `update_parameters` call.
71        v: Array1<f64>,
72    },
73
74    /// SPSA optimizer
75    SPSA {
76        /// Learning rate
77        learning_rate: f64,
78
79        /// Perturbation size
80        perturbation: f64,
81    },
82
83    /// Quantum Natural Gradient optimizer.
84    ///
85    /// This variant stores the scalar hyper-parameters only.  Callers are expected
86    /// to pre-condition gradients through `QuantumAutoDiff::natural_gradients()`
87    /// (which requires a circuit executor closure) and then pass the resulting
88    /// natural-gradient vector to `update_parameters`.  The `regularization` field
89    /// is used as additive damping: `Δθ_i = −lr · g_i / (1 + reg)`.
90    QuantumNaturalGradient {
91        /// Learning rate
92        learning_rate: f64,
93        /// Tikhonov regularisation added to the QFIM diagonal before inversion
94        regularization: f64,
95    },
96
97    /// SciRS2-based optimizers: Adam, L-BFGS (two-loop recursion), and
98    /// nonlinear Conjugate Gradient (Fletcher-Reeves/Polak-Ribiere), each
99    /// with real per-parameter state carried between `update_parameters`
100    /// calls.
101    SciRS2 {
102        /// Optimizer method: "adam", "lbfgs", or "cg"
103        method: String,
104        /// Configuration parameters
105        config: HashMap<String, f64>,
106        /// Adam first-moment accumulator (method == "adam")
107        adam_m: Array1<f64>,
108        /// Adam second-moment accumulator (method == "adam")
109        adam_v: Array1<f64>,
110        /// Bounded curvature-pair history `(s_k, y_k)` for L-BFGS's
111        /// two-loop recursion (method == "lbfgs"), newest last, truncated to
112        /// `config["m"]` entries.
113        lbfgs_history: Vec<(Array1<f64>, Array1<f64>)>,
114        /// Previous call's parameter vector, used by both L-BFGS and CG to
115        /// form the next curvature pair / conjugate direction.
116        prev_params: Option<Array1<f64>>,
117        /// Previous call's gradient vector.
118        prev_gradient: Option<Array1<f64>>,
119        /// Previous CG search direction (method == "cg").
120        cg_direction: Option<Array1<f64>>,
121    },
122}
123
124impl Optimizer {
125    /// Creates a new optimizer with default parameters
126    pub fn new(method: OptimizationMethod) -> Self {
127        match method {
128            OptimizationMethod::GradientDescent => Optimizer::GradientDescent {
129                learning_rate: 0.01,
130            },
131            OptimizationMethod::Adam => Optimizer::Adam {
132                learning_rate: 0.01,
133                beta1: 0.9,
134                beta2: 0.999,
135                epsilon: 1e-8,
136                m: Array1::zeros(0),
137                v: Array1::zeros(0),
138            },
139            OptimizationMethod::SPSA => Optimizer::SPSA {
140                learning_rate: 0.01,
141                perturbation: 0.01,
142            },
143            OptimizationMethod::LBFGS => {
144                // Default to Adam as LBFGS is not implemented yet
145                Optimizer::Adam {
146                    learning_rate: 0.01,
147                    beta1: 0.9,
148                    beta2: 0.999,
149                    epsilon: 1e-8,
150                    m: Array1::zeros(0),
151                    v: Array1::zeros(0),
152                }
153            }
154            OptimizationMethod::QuantumNaturalGradient => Optimizer::QuantumNaturalGradient {
155                learning_rate: 0.01,
156                regularization: 1e-3,
157            },
158            OptimizationMethod::SciRS2Adam => {
159                let mut config = HashMap::new();
160                config.insert("learning_rate".to_string(), 0.001);
161                config.insert("beta1".to_string(), 0.9);
162                config.insert("beta2".to_string(), 0.999);
163                config.insert("epsilon".to_string(), 1e-8);
164                Optimizer::SciRS2 {
165                    method: "adam".to_string(),
166                    config,
167                    adam_m: Array1::zeros(0),
168                    adam_v: Array1::zeros(0),
169                    lbfgs_history: Vec::new(),
170                    prev_params: None,
171                    prev_gradient: None,
172                    cg_direction: None,
173                }
174            }
175            OptimizationMethod::SciRS2LBFGS => {
176                let mut config = HashMap::new();
177                config.insert("m".to_string(), 10.0); // Memory size
178                config.insert("c1".to_string(), 1e-4);
179                config.insert("c2".to_string(), 0.9);
180                config.insert("learning_rate".to_string(), 0.1);
181                Optimizer::SciRS2 {
182                    method: "lbfgs".to_string(),
183                    config,
184                    adam_m: Array1::zeros(0),
185                    adam_v: Array1::zeros(0),
186                    lbfgs_history: Vec::new(),
187                    prev_params: None,
188                    prev_gradient: None,
189                    cg_direction: None,
190                }
191            }
192            OptimizationMethod::SciRS2CG => {
193                let mut config = HashMap::new();
194                config.insert("beta_method".to_string(), 0.0); // Fletcher-Reeves
195                config.insert("restart_threshold".to_string(), 100.0);
196                config.insert("learning_rate".to_string(), 0.01);
197                Optimizer::SciRS2 {
198                    method: "cg".to_string(),
199                    config,
200                    adam_m: Array1::zeros(0),
201                    adam_v: Array1::zeros(0),
202                    lbfgs_history: Vec::new(),
203                    prev_params: None,
204                    prev_gradient: None,
205                    cg_direction: None,
206                }
207            }
208        }
209    }
210
211    /// Updates parameters based on gradients.
212    ///
213    /// Each variant now carries and mutates its own real optimizer state
214    /// (Adam's first/second moments, L-BFGS's curvature-pair history, CG's
215    /// previous conjugate direction, ...), so this takes `&mut self`.
216    pub fn update_parameters(
217        &mut self,
218        parameters: &mut Array1<f64>,
219        gradients: &ArrayView1<f64>,
220        iteration: usize,
221    ) -> Result<()> {
222        match self {
223            Optimizer::GradientDescent { learning_rate } => {
224                // Simple gradient descent update
225                for i in 0..parameters.len() {
226                    parameters[i] -= *learning_rate * gradients[i];
227                }
228                Ok(())
229            }
230            Optimizer::Adam {
231                learning_rate,
232                beta1,
233                beta2,
234                epsilon,
235                m,
236                v,
237            } => {
238                Self::adam_update(
239                    parameters,
240                    gradients,
241                    iteration,
242                    *learning_rate,
243                    *beta1,
244                    *beta2,
245                    *epsilon,
246                    m,
247                    v,
248                );
249                Ok(())
250            }
251            Optimizer::SPSA {
252                learning_rate,
253                perturbation,
254            } => {
255                // Spall's canonical two-gain-sequence SPSA schedule:
256                // a_k = a / (k+1)^alpha decays the step size, while
257                // c_k = c / (k+1)^gamma reflects the (decaying) magnitude of
258                // the simultaneous perturbation used upstream to estimate
259                // `gradients`; a larger effective c_k means a noisier
260                // one-shot gradient estimate, so we damp the step by
261                // (1 + c_k) to avoid overreacting to it.
262                let k = iteration as f64 + 1.0;
263                let a_k = *learning_rate / k.powf(SPSA_ALPHA);
264                let c_k = *perturbation / k.powf(SPSA_GAMMA);
265                let damping = 1.0 + c_k;
266                let step = a_k / damping;
267                for i in 0..parameters.len() {
268                    parameters[i] -= step * gradients[i];
269                }
270                Ok(())
271            }
272            Optimizer::QuantumNaturalGradient {
273                learning_rate,
274                regularization,
275            } => {
276                // Gradients are expected to be pre-conditioned natural gradients
277                // (computed via `QuantumAutoDiff::natural_gradients()`).
278                // Apply Tikhonov-damped update: Δθ = -lr * g / (1 + reg).
279                let damp = 1.0 + *regularization;
280                for i in 0..parameters.len() {
281                    parameters[i] -= *learning_rate * gradients[i] / damp;
282                }
283                Ok(())
284            }
285            Optimizer::SciRS2 {
286                method,
287                config,
288                adam_m,
289                adam_v,
290                lbfgs_history,
291                prev_params,
292                prev_gradient,
293                cg_direction,
294            } => match method.as_str() {
295                "adam" => {
296                    let learning_rate = config.get("learning_rate").copied().unwrap_or(0.001);
297                    let beta1 = config.get("beta1").copied().unwrap_or(0.9);
298                    let beta2 = config.get("beta2").copied().unwrap_or(0.999);
299                    let epsilon = config.get("epsilon").copied().unwrap_or(1e-8);
300                    Self::adam_update(
301                        parameters,
302                        gradients,
303                        iteration,
304                        learning_rate,
305                        beta1,
306                        beta2,
307                        epsilon,
308                        adam_m,
309                        adam_v,
310                    );
311                    Ok(())
312                }
313                "lbfgs" => {
314                    Self::lbfgs_update(
315                        parameters,
316                        gradients,
317                        config,
318                        lbfgs_history,
319                        prev_params,
320                        prev_gradient,
321                    );
322                    Ok(())
323                }
324                "cg" => {
325                    Self::cg_update(
326                        parameters,
327                        gradients,
328                        iteration,
329                        config,
330                        prev_params,
331                        prev_gradient,
332                        cg_direction,
333                    );
334                    Ok(())
335                }
336                _ => Err(MLError::InvalidConfiguration(format!(
337                    "Unknown SciRS2 optimizer method: {}",
338                    method
339                ))),
340            },
341        }
342    }
343
344    /// Real Adam update: tracks biased first/second moment estimates and
345    /// applies bias-corrected parameter updates.
346    #[allow(clippy::too_many_arguments)]
347    fn adam_update(
348        parameters: &mut Array1<f64>,
349        gradients: &ArrayView1<f64>,
350        iteration: usize,
351        learning_rate: f64,
352        beta1: f64,
353        beta2: f64,
354        epsilon: f64,
355        m: &mut Array1<f64>,
356        v: &mut Array1<f64>,
357    ) {
358        let n = parameters.len();
359        if m.len() != n {
360            *m = Array1::zeros(n);
361            *v = Array1::zeros(n);
362        }
363        let t = iteration as f64 + 1.0;
364        let bias_correction1 = 1.0 - beta1.powf(t);
365        let bias_correction2 = 1.0 - beta2.powf(t);
366        for i in 0..n {
367            m[i] = beta1 * m[i] + (1.0 - beta1) * gradients[i];
368            v[i] = beta2 * v[i] + (1.0 - beta2) * gradients[i] * gradients[i];
369            let m_hat = m[i] / bias_correction1;
370            let v_hat = v[i] / bias_correction2;
371            parameters[i] -= learning_rate * m_hat / (v_hat.sqrt() + epsilon);
372        }
373    }
374
375    /// Real nonlinear Conjugate Gradient update (Fletcher-Reeves or
376    /// Polak-Ribiere, selected by `config["beta_method"]`), with periodic
377    /// restart to steepest descent every `config["restart_threshold"]`
378    /// iterations (or whenever no prior direction is available).
379    #[allow(clippy::too_many_arguments)]
380    fn cg_update(
381        parameters: &mut Array1<f64>,
382        gradients: &ArrayView1<f64>,
383        iteration: usize,
384        config: &HashMap<String, f64>,
385        prev_params: &mut Option<Array1<f64>>,
386        prev_gradient: &mut Option<Array1<f64>>,
387        cg_direction: &mut Option<Array1<f64>>,
388    ) {
389        let n = parameters.len();
390        let learning_rate = config.get("learning_rate").copied().unwrap_or(0.01);
391        let beta_method = config.get("beta_method").copied().unwrap_or(0.0);
392        let restart_threshold = config
393            .get("restart_threshold")
394            .copied()
395            .unwrap_or(100.0)
396            .max(1.0) as usize;
397
398        // Snapshot the pre-update iterate: this is theta_t, stored as
399        // `prev_params` for the *next* call (which will need it to relate
400        // to theta_{t+1}). Restart to steepest descent whenever we lack a
401        // prior gradient/direction, or every `restart_threshold` iterations.
402        let theta_t = parameters.clone();
403        let restart =
404            prev_gradient.is_none() || cg_direction.is_none() || iteration % restart_threshold == 0;
405
406        let direction = if !restart {
407            let prev_g = prev_gradient.as_ref().expect("checked Some above");
408            let prev_d = cg_direction.as_ref().expect("checked Some above");
409            let denom: f64 = prev_g.iter().map(|g| g * g).sum();
410            let beta = if denom.abs() > 1e-15 {
411                if beta_method < 0.5 {
412                    // Fletcher-Reeves
413                    let numer: f64 = gradients.iter().map(|g| g * g).sum();
414                    (numer / denom).max(0.0)
415                } else {
416                    // Polak-Ribiere (clamped to be non-negative, i.e.
417                    // automatic restart to steepest descent when negative)
418                    let numer: f64 = gradients
419                        .iter()
420                        .zip(prev_g.iter())
421                        .map(|(g, gp)| g * (g - gp))
422                        .sum();
423                    (numer / denom).max(0.0)
424                }
425            } else {
426                0.0
427            };
428            let mut d = Array1::zeros(n);
429            for i in 0..n {
430                d[i] = -gradients[i] + beta * prev_d[i];
431            }
432            d
433        } else {
434            let mut d = Array1::zeros(n);
435            for i in 0..n {
436                d[i] = -gradients[i];
437            }
438            d
439        };
440
441        for i in 0..n {
442            parameters[i] += learning_rate * direction[i];
443        }
444
445        *prev_params = Some(theta_t);
446        *prev_gradient = Some(gradients.to_owned());
447        *cg_direction = Some(direction);
448    }
449
450    /// Real L-BFGS update using the standard two-loop recursion over a
451    /// bounded history of curvature pairs `(s_k = Δθ_k, y_k = Δg_k)`
452    /// (`config["m"]` most recent pairs), with a fixed damped step in place
453    /// of a Wolfe line search.
454    fn lbfgs_update(
455        parameters: &mut Array1<f64>,
456        gradients: &ArrayView1<f64>,
457        config: &HashMap<String, f64>,
458        history: &mut Vec<(Array1<f64>, Array1<f64>)>,
459        prev_params: &mut Option<Array1<f64>>,
460        prev_gradient: &mut Option<Array1<f64>>,
461    ) {
462        let n = parameters.len();
463        let memory = config.get("m").copied().unwrap_or(10.0).max(1.0) as usize;
464        let learning_rate = config.get("learning_rate").copied().unwrap_or(0.1);
465
466        // Snapshot the pre-update iterate theta_t; this (not the post-update
467        // theta_{t+1}) is what the *next* call needs as its "previous"
468        // iterate to form the following curvature pair.
469        let theta_t = parameters.clone();
470
471        // Form the newest curvature pair from the previous call's iterate.
472        if let (Some(prev_p), Some(prev_g)) = (prev_params.as_ref(), prev_gradient.as_ref()) {
473            let s = &theta_t - prev_p;
474            let y = gradients.to_owned() - prev_g;
475            let sy: f64 = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
476            // Only accept curvature-condition-satisfying pairs to keep the
477            // implied Hessian approximation positive definite.
478            if sy > 1e-10 {
479                history.push((s, y));
480                while history.len() > memory {
481                    history.remove(0);
482                }
483            }
484        }
485
486        // Two-loop recursion computing r ≈ H_k * g_k.
487        let mut q = gradients.to_owned();
488        let mut alphas = Vec::with_capacity(history.len());
489        for (s, y) in history.iter().rev() {
490            let sy: f64 = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
491            let rho = 1.0 / sy;
492            let alpha = rho * s.iter().zip(q.iter()).map(|(a, b)| a * b).sum::<f64>();
493            for i in 0..n {
494                q[i] -= alpha * y[i];
495            }
496            alphas.push((rho, alpha));
497        }
498
499        let gamma = match history.last() {
500            Some((s, y)) => {
501                let sy: f64 = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
502                let yy: f64 = y.iter().map(|v| v * v).sum();
503                if yy.abs() > 1e-15 {
504                    sy / yy
505                } else {
506                    1.0
507                }
508            }
509            None => 1.0,
510        };
511        let mut r = q.mapv(|x| x * gamma);
512
513        for (idx, (s, y)) in history.iter().enumerate() {
514            let (rho, alpha) = alphas[history.len() - 1 - idx];
515            let beta = rho * y.iter().zip(r.iter()).map(|(a, b)| a * b).sum::<f64>();
516            for i in 0..n {
517                r[i] += s[i] * (alpha - beta);
518            }
519        }
520
521        // r approximates H_k * g_k; the descent direction is -r.
522        for i in 0..n {
523            parameters[i] -= learning_rate * r[i];
524        }
525
526        *prev_params = Some(theta_t);
527        *prev_gradient = Some(gradients.to_owned());
528    }
529}
530
531/// Objective function for optimization
532pub trait ObjectiveFunction {
533    /// Evaluates the objective function at the given parameters
534    fn evaluate(&self, parameters: &ArrayView1<f64>) -> Result<f64>;
535
536    /// Computes the gradient of the objective function
537    fn gradient(&self, parameters: &ArrayView1<f64>) -> Result<Array1<f64>> {
538        // Default implementation uses finite differences
539        let epsilon = 1e-6;
540        let n = parameters.len();
541        let mut gradient = Array1::zeros(n);
542
543        let f0 = self.evaluate(parameters)?;
544
545        for i in 0..n {
546            let mut params_plus = parameters.to_owned();
547            params_plus[i] += epsilon;
548
549            let f_plus = self.evaluate(&params_plus.view())?;
550
551            gradient[i] = (f_plus - f0) / epsilon;
552        }
553
554        Ok(gradient)
555    }
556}
557
558impl fmt::Display for OptimizationMethod {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        match self {
561            OptimizationMethod::GradientDescent => write!(f, "Gradient Descent"),
562            OptimizationMethod::Adam => write!(f, "Adam"),
563            OptimizationMethod::SPSA => write!(f, "SPSA"),
564            OptimizationMethod::LBFGS => write!(f, "L-BFGS"),
565            OptimizationMethod::QuantumNaturalGradient => write!(f, "Quantum Natural Gradient"),
566            OptimizationMethod::SciRS2Adam => write!(f, "SciRS2 Adam"),
567            OptimizationMethod::SciRS2LBFGS => write!(f, "SciRS2 L-BFGS"),
568            OptimizationMethod::SciRS2CG => write!(f, "SciRS2 Conjugate Gradient"),
569        }
570    }
571}
572
573impl fmt::Display for Optimizer {
574    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575        match self {
576            Optimizer::GradientDescent { learning_rate } => {
577                write!(f, "Gradient Descent (learning_rate: {})", learning_rate)
578            }
579            Optimizer::Adam {
580                learning_rate,
581                beta1,
582                beta2,
583                epsilon,
584                ..
585            } => {
586                write!(
587                    f,
588                    "Adam (learning_rate: {}, beta1: {}, beta2: {}, epsilon: {})",
589                    learning_rate, beta1, beta2, epsilon
590                )
591            }
592            Optimizer::SPSA {
593                learning_rate,
594                perturbation,
595            } => {
596                write!(
597                    f,
598                    "SPSA (learning_rate: {}, perturbation: {})",
599                    learning_rate, perturbation
600                )
601            }
602            Optimizer::QuantumNaturalGradient {
603                learning_rate,
604                regularization,
605            } => {
606                write!(
607                    f,
608                    "Quantum Natural Gradient (learning_rate: {}, regularization: {})",
609                    learning_rate, regularization
610                )
611            }
612            Optimizer::SciRS2 { method, config, .. } => {
613                write!(f, "SciRS2 {} with config: {:?}", method, config)
614            }
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    /// Regression test: `Optimizer::Adam` must behave like real Adam (with
624    /// persistent first/second moment accumulators), not degrade into plain
625    /// gradient descent. On a constant gradient, Adam's first update step
626    /// size is `learning_rate * sign(gradient)` (up to the bias-correction
627    /// ratio, which is exactly 1 on the very first step), which is *not*
628    /// equal to `learning_rate * gradient` unless `|gradient| == 1`.
629    #[test]
630    fn test_adam_is_not_plain_gradient_descent() {
631        let mut optimizer = Optimizer::new(OptimizationMethod::Adam);
632        let mut params = Array1::from_vec(vec![1.0, 1.0]);
633        let gradients = Array1::from_vec(vec![10.0, 10.0]);
634
635        optimizer
636            .update_parameters(&mut params, &gradients.view(), 0)
637            .expect("Adam update should succeed");
638
639        // Plain gradient descent with lr=0.01 would move by exactly 0.1;
640        // Adam's normalized step is close to +/- learning_rate instead.
641        let plain_gd_step = 0.01 * 10.0;
642        let actual_step = 1.0 - params[0];
643        assert!(
644            (actual_step - plain_gd_step).abs() > 1e-3,
645            "Adam step ({actual_step}) should differ from plain GD step ({plain_gd_step})"
646        );
647        // The normalized Adam step on the very first iteration is
648        // approximately learning_rate (moment ratio ~ sign(gradient)).
649        assert!((actual_step - 0.01).abs() < 1e-3);
650    }
651
652    #[test]
653    fn test_adam_moments_persist_across_calls() {
654        // A gradient sign-flip (+1.0 then -1.0) exercises Adam's momentum
655        // smoothing: the *same* second gradient produces a much smaller
656        // step for an optimizer warmed up by the first call than for a
657        // fresh optimizer seeing that gradient for the first time at the
658        // same iteration index (so bias correction alone cannot explain the
659        // difference) -- proof that `m`/`v` genuinely persist in the
660        // optimizer's own state instead of being recomputed from scratch
661        // (or ignored) on every call.
662        let mut warmed = Optimizer::new(OptimizationMethod::Adam);
663        let mut params_warm = Array1::from_vec(vec![0.0]);
664        let g1 = Array1::from_vec(vec![1.0]);
665        warmed
666            .update_parameters(&mut params_warm, &g1.view(), 0)
667            .expect("update 1");
668        let g2 = Array1::from_vec(vec![-1.0]);
669        let before_second = params_warm[0];
670        warmed
671            .update_parameters(&mut params_warm, &g2.view(), 1)
672            .expect("update 2");
673        let warmed_step = params_warm[0] - before_second;
674
675        let mut fresh = Optimizer::new(OptimizationMethod::Adam);
676        let mut params_fresh = Array1::from_vec(vec![0.0]);
677        fresh
678            .update_parameters(&mut params_fresh, &g2.view(), 1)
679            .expect("fresh update");
680        let fresh_step = params_fresh[0];
681
682        assert!(
683            (warmed_step - fresh_step).abs() > 1e-3,
684            "step with accumulated momentum ({warmed_step}) should differ substantially from a \
685             fresh optimizer's step ({fresh_step}) given the same gradient/iteration"
686        );
687    }
688
689    #[test]
690    fn test_spsa_uses_decaying_gain_sequence() {
691        // SPSA's defining feature vs plain GD is a step size that decays
692        // with the iteration count; verify the applied step actually
693        // shrinks as `iteration` grows for an identical gradient.
694        let mut optimizer = Optimizer::new(OptimizationMethod::SPSA);
695        let gradients = Array1::from_vec(vec![1.0]);
696
697        let mut params_early = Array1::from_vec(vec![0.0]);
698        optimizer
699            .update_parameters(&mut params_early, &gradients.view(), 0)
700            .expect("update at iteration 0");
701
702        let mut optimizer2 = Optimizer::new(OptimizationMethod::SPSA);
703        let mut params_late = Array1::from_vec(vec![0.0]);
704        optimizer2
705            .update_parameters(&mut params_late, &gradients.view(), 1000)
706            .expect("update at iteration 1000");
707
708        assert!(
709            params_early[0].abs() > params_late[0].abs(),
710            "SPSA step at iteration 0 ({}) should be larger than at iteration 1000 ({})",
711            params_early[0].abs(),
712            params_late[0].abs()
713        );
714    }
715
716    #[test]
717    fn test_scirs2_cg_direction_differs_from_gradient_descent() {
718        // Nonlinear CG's direction after the first restart-free step should
719        // incorporate the Fletcher-Reeves beta term, differing from plain
720        // steepest descent once a second gradient is supplied.
721        let mut optimizer = Optimizer::new(OptimizationMethod::SciRS2CG);
722        let mut params = Array1::from_vec(vec![1.0, 1.0]);
723
724        // First call restarts to steepest descent (no history yet).
725        let g1 = Array1::from_vec(vec![1.0, 0.0]);
726        optimizer
727            .update_parameters(&mut params, &g1.view(), 0)
728            .expect("cg update 1");
729        let after_first = params.clone();
730
731        // Second call (iteration=1, below restart_threshold) should apply a
732        // Fletcher-Reeves-conjugated direction, not steepest descent again.
733        let g2 = Array1::from_vec(vec![0.5, 0.5]);
734        optimizer
735            .update_parameters(&mut params, &g2.view(), 1)
736            .expect("cg update 2");
737
738        let learning_rate = 0.01;
739        let plain_steepest_descent_step = -learning_rate * g2[0];
740        let actual_step = params[0] - after_first[0];
741        assert!(
742            (actual_step - plain_steepest_descent_step).abs() > 1e-8,
743            "CG step ({actual_step}) should differ from plain steepest descent ({plain_steepest_descent_step})"
744        );
745    }
746
747    #[test]
748    fn test_scirs2_lbfgs_uses_curvature_history() {
749        // After two calls, L-BFGS should have recorded a curvature pair and
750        // used it (via the two-loop recursion) rather than falling back to
751        // a fixed-scale gradient step every time.
752        let mut optimizer = Optimizer::new(OptimizationMethod::SciRS2LBFGS);
753        let mut params = Array1::from_vec(vec![2.0, -1.0]);
754
755        let g1 = Array1::from_vec(vec![2.0, -1.0]);
756        optimizer
757            .update_parameters(&mut params, &g1.view(), 0)
758            .expect("lbfgs update 1");
759
760        let g2 = Array1::from_vec(vec![1.0, -0.5]);
761        let before_second = params.clone();
762        optimizer
763            .update_parameters(&mut params, &g2.view(), 1)
764            .expect("lbfgs update 2");
765
766        // A fixed-scale (learning_rate-only) gradient step would move
767        // exactly `-learning_rate * g2`; the two-loop-recursion direction
768        // (scaled by accumulated curvature) should differ from that.
769        let learning_rate = 0.1;
770        let naive_step = -learning_rate * g2[0];
771        let actual_step = params[0] - before_second[0];
772        assert!(
773            (actual_step - naive_step).abs() > 1e-8,
774            "L-BFGS step ({actual_step}) should differ from a naive scaled-gradient step ({naive_step})"
775        );
776    }
777
778    #[test]
779    fn test_quantum_natural_gradient_damping_unchanged() {
780        // Regression guard: this variant's Tikhonov damping behavior must
781        // remain unaffected by the Adam/CG/L-BFGS rewrite.
782        let mut optimizer = Optimizer::QuantumNaturalGradient {
783            learning_rate: 0.1,
784            regularization: 1.0,
785        };
786        let mut params = Array1::from_vec(vec![1.0]);
787        let gradients = Array1::from_vec(vec![2.0]);
788        optimizer
789            .update_parameters(&mut params, &gradients.view(), 0)
790            .expect("QNG update");
791        // Δθ = -lr * g / (1 + reg) = -0.1 * 2 / 2 = -0.1
792        assert!((params[0] - 0.9).abs() < 1e-9);
793    }
794}