Skip to main content

optirs_core/quantum_inspired/
annealing.rs

1// Quantum annealing optimizer
2//
3// Implements a quantum-inspired simulated annealing optimizer that mixes the
4// classical Metropolis acceptance criterion with a tunneling kernel, allowing
5// the optimizer to escape local minima more easily than pure SGD or pure
6// simulated annealing on its own.
7
8use scirs2_core::ndarray::{Array, 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::{
17    DEFAULT_FINAL_TEMP, DEFAULT_INITIAL_TEMP, DEFAULT_NUM_ITERATIONS, DEFAULT_SEED,
18    DEFAULT_TUNNELING_STRENGTH,
19};
20
21/// Quantum annealing optimizer.
22///
23/// `QuantumAnnealing` performs a Metropolis-based stochastic search where each
24/// candidate update is a perturbation of the current parameters whose magnitude
25/// is scaled by the current temperature. The acceptance probability blends the
26/// classical Boltzmann factor with a quantum-inspired tunneling kernel:
27///
28/// ```text
29///     P(accept) = min(1, exp(-ΔE / (k * T) + Γ * exp(-‖δ‖²)))
30/// ```
31///
32/// where `ΔE` is approximated by the dot product `gradients · δ` (treating the
33/// supplied gradient as an unbiased local descent direction), `Γ` is the
34/// tunneling strength and `δ` is the candidate perturbation.
35///
36/// Temperature follows a geometric (exponential) decay
37///
38/// ```text
39///     T(t) = T_initial * (T_final / T_initial)^(t / N)
40/// ```
41///
42/// which is well-behaved for arbitrary positive endpoints and reproduces
43/// `T(0) = T_initial`, `T(N) = T_final` exactly.
44///
45/// # Examples
46///
47/// ```
48/// use optirs_core::quantum_inspired::QuantumAnnealing;
49/// use optirs_core::optimizers::Optimizer;
50/// use scirs2_core::ndarray::Array1;
51///
52/// let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.05)
53///     .with_temperature_schedule(2.0, 0.01)
54///     .with_tunneling(0.5)
55///     .with_seed(123);
56///
57/// let params = Array1::from_vec(vec![1.0, -1.0, 0.5]);
58/// let gradients = params.mapv(|x| 2.0 * x);
59/// let next = optimizer.step(&params, &gradients).expect("step failed");
60/// assert_eq!(next.len(), 3);
61/// ```
62#[derive(Debug)]
63pub struct QuantumAnnealing<A: Float + ScalarOperand + Debug> {
64    /// Learning rate that scales the perturbation magnitude.
65    learning_rate: A,
66    /// Current temperature in the annealing schedule.
67    current_temperature: A,
68    /// Temperature at iteration zero.
69    initial_temperature: A,
70    /// Temperature at iteration `num_iterations`.
71    final_temperature: A,
72    /// Total number of iterations the cooling schedule spans.
73    num_iterations: usize,
74    /// Step count, used to compute the current temperature.
75    current_step: usize,
76    /// Strength `Γ` of the quantum-inspired tunneling kernel.
77    tunneling_strength: A,
78    /// Best parameters discovered so far, flattened to a contiguous vector.
79    best_params: Option<Vec<A>>,
80    /// Shape associated with `best_params` to allow validated reconstruction.
81    best_shape: Option<Vec<usize>>,
82    /// Best (lowest) proxy energy observed so far.
83    best_energy: A,
84    /// Seed used to initialise the RNG.
85    seed: u64,
86    /// Seeded random number generator.
87    rng: Random<scirs2_core::random::rngs::StdRng>,
88}
89
90impl<A> QuantumAnnealing<A>
91where
92    A: Float + ScalarOperand + Debug + Send + Sync,
93{
94    /// Creates a new quantum annealing optimizer with the given learning rate
95    /// and all other parameters set to sensible defaults.
96    pub fn new(learning_rate: A) -> Self {
97        let initial = A::from(DEFAULT_INITIAL_TEMP).unwrap_or_else(A::one);
98        let final_t = A::from(DEFAULT_FINAL_TEMP).unwrap_or_else(|| A::epsilon());
99        let tunneling = A::from(DEFAULT_TUNNELING_STRENGTH).unwrap_or_else(A::zero);
100        Self {
101            learning_rate,
102            current_temperature: initial,
103            initial_temperature: initial,
104            final_temperature: final_t,
105            num_iterations: DEFAULT_NUM_ITERATIONS,
106            current_step: 0,
107            tunneling_strength: tunneling,
108            best_params: None,
109            best_shape: None,
110            best_energy: A::infinity(),
111            seed: DEFAULT_SEED,
112            rng: Random::seed(DEFAULT_SEED),
113        }
114    }
115
116    /// Configure the temperature schedule endpoints.
117    ///
118    /// `initial` must be strictly greater than `final_t` and both must be
119    /// positive. The current temperature is reset to `initial` on each call so
120    /// chained builders behave intuitively.
121    pub fn with_temperature_schedule(mut self, initial: A, final_t: A) -> Self {
122        self.initial_temperature = initial;
123        self.final_temperature = final_t;
124        self.current_temperature = initial;
125        self
126    }
127
128    /// Configure the tunneling strength `Γ`.
129    ///
130    /// Larger values increase the average acceptance rate by enlarging the
131    /// quantum-inspired kernel contribution to the Metropolis exponent.
132    pub fn with_tunneling(mut self, strength: A) -> Self {
133        self.tunneling_strength = strength;
134        self
135    }
136
137    /// Seed the optimizer's RNG.
138    pub fn with_seed(mut self, seed: u64) -> Self {
139        self.seed = seed;
140        self.rng = Random::seed(seed);
141        self
142    }
143
144    /// Configure the number of iterations the cooling schedule spans.
145    pub fn with_iterations(mut self, num_iterations: usize) -> Self {
146        self.num_iterations = num_iterations.max(1);
147        self
148    }
149
150    /// Returns the temperature for the current step.
151    pub fn current_temperature(&self) -> A {
152        self.current_temperature
153    }
154
155    /// Returns the best proxy energy discovered so far. Initialised to `+∞`.
156    pub fn best_energy(&self) -> A {
157        self.best_energy
158    }
159
160    /// Returns the current step count.
161    pub fn current_step(&self) -> usize {
162        self.current_step
163    }
164
165    /// Returns the configured initial temperature.
166    pub fn initial_temperature(&self) -> A {
167        self.initial_temperature
168    }
169
170    /// Returns the configured final temperature.
171    pub fn final_temperature(&self) -> A {
172        self.final_temperature
173    }
174
175    /// Returns the configured tunneling strength.
176    pub fn tunneling_strength(&self) -> A {
177        self.tunneling_strength
178    }
179
180    /// Returns the configured number of iterations.
181    pub fn num_iterations(&self) -> usize {
182        self.num_iterations
183    }
184
185    /// Returns the seed used to initialise the RNG.
186    pub fn seed(&self) -> u64 {
187        self.seed
188    }
189
190    /// Returns the current learning rate. This is an inherent helper so the
191    /// caller does not need to qualify a dimension `D` to invoke the trait
192    /// implementation of [`Optimizer::get_learning_rate`].
193    pub fn learning_rate(&self) -> A {
194        self.learning_rate
195    }
196
197    /// Set the learning rate. Inherent helper that mirrors the trait method.
198    pub fn set_lr(&mut self, learning_rate: A) {
199        self.learning_rate = learning_rate;
200    }
201
202    /// Returns a clone of the best parameters seen so far, reshaped to the
203    /// requested dimensionality.
204    pub fn best_params<D: Dimension>(&self) -> Option<Array<A, D>> {
205        let buf = self.best_params.as_ref()?;
206        let shape = self.best_shape.as_ref()?;
207        let arr = Array::from_shape_vec(scirs2_core::ndarray::IxDyn(shape), buf.clone()).ok()?;
208        arr.into_dimensionality::<D>().ok()
209    }
210
211    /// Reset internal step counting and best-state tracking. The RNG is
212    /// re-seeded with the original seed so the new trajectory is reproducible.
213    pub fn reset(&mut self) {
214        self.current_step = 0;
215        self.current_temperature = self.initial_temperature;
216        self.best_params = None;
217        self.best_shape = None;
218        self.best_energy = A::infinity();
219        self.rng = Random::seed(self.seed);
220    }
221
222    /// Internal: compute the geometric-decay temperature for a given step.
223    fn temperature_at(&self, step: usize) -> A {
224        if self.num_iterations == 0 {
225            return self.final_temperature;
226        }
227        let n = self.num_iterations;
228        let step_clamped = step.min(n);
229        let frac =
230            A::from(step_clamped).unwrap_or_else(A::zero) / A::from(n).unwrap_or_else(A::one);
231        // Guard against non-positive temperatures – if either endpoint is
232        // unusable, fall back to a numerically stable linear interpolation so
233        // we never produce NaN or Inf during the schedule.
234        if self.initial_temperature <= A::zero() || self.final_temperature <= A::zero() {
235            let span = self.initial_temperature - self.final_temperature;
236            return self.initial_temperature - span * frac;
237        }
238        let ratio = self.final_temperature / self.initial_temperature;
239        self.initial_temperature * ratio.powf(frac)
240    }
241
242    /// Internal: sample a perturbation `δ` whose entries follow a centered
243    /// uniform distribution on `[-lr*T, lr*T]`.
244    fn sample_perturbation(
245        &mut self,
246        shape: &[usize],
247    ) -> Result<Array<A, scirs2_core::ndarray::IxDyn>> {
248        let scale = self.learning_rate * self.current_temperature;
249        // Guard against degenerate scale: produce zero perturbation rather
250        // than panicking. This still lets the algorithm proceed sensibly when
251        // the schedule has fully cooled.
252        let total: usize = shape.iter().product();
253        let mut buf: Vec<A> = Vec::with_capacity(total);
254        for _ in 0..total {
255            let u: f64 = self.rng.gen_range(-1.0..1.0);
256            let val = A::from(u).unwrap_or_else(A::zero) * scale;
257            buf.push(val);
258        }
259        Array::from_shape_vec(scirs2_core::ndarray::IxDyn(shape), buf).map_err(|err| {
260            OptimError::ComputationError(format!("Failed to build perturbation array: {err}"))
261        })
262    }
263
264    /// Internal: Metropolis acceptance with quantum-inspired tunneling.
265    fn accept(&mut self, delta_e: A, perturbation_sq_norm: A) -> bool {
266        // Always accept improvements unconditionally so the algorithm is
267        // monotone on strictly downhill moves.
268        if delta_e <= A::zero() {
269            return true;
270        }
271        // Guard against degenerate temperature – treat T<=0 as fully frozen.
272        if self.current_temperature <= A::zero() {
273            return false;
274        }
275        let tunneling_kernel = (-perturbation_sq_norm).exp();
276        let exponent =
277            -delta_e / self.current_temperature + self.tunneling_strength * tunneling_kernel;
278        // Clamp exponent into a safe range so `exp` never overflows. Anything
279        // above zero we accept; below the threshold we treat as zero.
280        if exponent >= A::zero() {
281            return true;
282        }
283        let safety = A::from(-50.0).unwrap_or_else(|| -A::one());
284        let exponent_safe = if exponent < safety { safety } else { exponent };
285        let p = exponent_safe.exp();
286        let p_f64 = p.to_f64().unwrap_or(0.0);
287        let u: f64 = self.rng.gen_range(0.0..1.0);
288        u < p_f64
289    }
290
291    /// Internal: advance the cooling schedule by one step.
292    fn advance_temperature(&mut self) {
293        // We cap the step counter so the schedule plateaus at `final_t`
294        // instead of crashing through it once we exceed `num_iterations`.
295        self.current_step = self.current_step.saturating_add(1);
296        self.current_temperature = self.temperature_at(self.current_step);
297    }
298
299    /// Internal: compute the gradient-dot-perturbation proxy energy.
300    fn proxy_energy(
301        gradients: &Array<A, scirs2_core::ndarray::IxDyn>,
302        candidate_offset: &Array<A, scirs2_core::ndarray::IxDyn>,
303    ) -> A {
304        gradients
305            .iter()
306            .zip(candidate_offset.iter())
307            .fold(A::zero(), |acc, (g, p)| acc + (*g) * (*p))
308    }
309
310    /// Internal: compute squared L2 norm.
311    fn sq_norm(arr: &Array<A, scirs2_core::ndarray::IxDyn>) -> A {
312        arr.iter().fold(A::zero(), |acc, x| acc + (*x) * (*x))
313    }
314
315    /// Internal: update best-known-state tracking.
316    fn track_best(&mut self, candidate: &Array<A, scirs2_core::ndarray::IxDyn>, energy: A) {
317        if energy < self.best_energy {
318            self.best_energy = energy;
319            self.best_params = Some(candidate.iter().copied().collect());
320            self.best_shape = Some(candidate.shape().to_vec());
321        }
322    }
323}
324
325impl<A, D> Optimizer<A, D> for QuantumAnnealing<A>
326where
327    A: Float + ScalarOperand + Debug + Send + Sync,
328    D: Dimension,
329{
330    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
331        if params.shape() != gradients.shape() {
332            return Err(OptimError::DimensionMismatch(format!(
333                "Quantum annealing: parameters have shape {:?}, gradients have shape {:?}",
334                params.shape(),
335                gradients.shape()
336            )));
337        }
338
339        let params_dyn = params.to_owned().into_dyn();
340        let gradients_dyn = gradients.to_owned().into_dyn();
341        let shape: Vec<usize> = params_dyn.shape().to_vec();
342
343        // Initialise best-known state on first call so we can always return a
344        // sensible best estimate.
345        if self.best_params.is_none() {
346            self.best_energy = A::zero();
347            self.best_params = Some(params_dyn.iter().copied().collect());
348            self.best_shape = Some(shape.clone());
349        }
350
351        let perturbation = self.sample_perturbation(&shape)?;
352        // The candidate move is `params - perturbation`. A first-order Taylor
353        // expansion of the unknown loss `f` gives
354        //
355        //     ΔE ≈ f(params - perturbation) - f(params) ≈ -∇f · perturbation
356        //
357        // so the proxy energy used in the Metropolis criterion is the
358        // negative gradient–perturbation dot product.
359        let delta_e = -Self::proxy_energy(&gradients_dyn, &perturbation);
360        let sq_norm = Self::sq_norm(&perturbation);
361
362        let updated_dyn = if self.accept(delta_e, sq_norm) {
363            &params_dyn - &perturbation
364        } else {
365            params_dyn.clone()
366        };
367
368        // Energy proxy for tracking purposes: linearised change in the
369        // unknown loss between `params` and the accepted parameters.
370        let accepted_offset = &updated_dyn - &params_dyn;
371        let accepted_energy = -Self::proxy_energy(&gradients_dyn, &accepted_offset);
372        self.track_best(&updated_dyn, accepted_energy);
373
374        self.advance_temperature();
375
376        updated_dyn.into_dimensionality::<D>().map_err(|err| {
377            OptimError::ComputationError(format!(
378                "Quantum annealing: failed to restore dimension: {err}"
379            ))
380        })
381    }
382
383    fn get_learning_rate(&self) -> A {
384        self.learning_rate
385    }
386
387    fn set_learning_rate(&mut self, learning_rate: A) {
388        self.learning_rate = learning_rate;
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use approx::assert_abs_diff_eq;
396    use scirs2_core::ndarray::Array1;
397
398    fn quadratic_grad(params: &Array1<f64>) -> Array1<f64> {
399        params.mapv(|x| 2.0 * x)
400    }
401
402    #[test]
403    fn test_default_config() {
404        let optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1);
405        assert_abs_diff_eq!(optimizer.learning_rate(), 0.1);
406        assert_abs_diff_eq!(optimizer.initial_temperature(), DEFAULT_INITIAL_TEMP);
407        assert_abs_diff_eq!(optimizer.final_temperature(), DEFAULT_FINAL_TEMP);
408        assert_abs_diff_eq!(optimizer.tunneling_strength(), DEFAULT_TUNNELING_STRENGTH);
409        assert_eq!(optimizer.num_iterations(), DEFAULT_NUM_ITERATIONS);
410        assert_eq!(optimizer.current_step(), 0);
411    }
412
413    #[test]
414    fn test_builder_pattern() {
415        let optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.05)
416            .with_temperature_schedule(2.0, 0.01)
417            .with_tunneling(0.7)
418            .with_iterations(250)
419            .with_seed(42);
420        assert_abs_diff_eq!(optimizer.initial_temperature(), 2.0);
421        assert_abs_diff_eq!(optimizer.final_temperature(), 0.01);
422        assert_abs_diff_eq!(optimizer.tunneling_strength(), 0.7);
423        assert_eq!(optimizer.num_iterations(), 250);
424        assert_eq!(optimizer.seed(), 42);
425        // current_temperature is reset to initial after configuring schedule
426        assert_abs_diff_eq!(optimizer.current_temperature(), 2.0);
427    }
428
429    #[test]
430    fn test_temperature_decay_monotonic() {
431        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.01)
432            .with_temperature_schedule(1.0, 1e-3)
433            .with_iterations(50)
434            .with_seed(7);
435        let params = Array1::from_vec(vec![1.0, -1.0, 0.5]);
436        let mut prev = optimizer.current_temperature();
437        for _ in 0..30 {
438            let grads = quadratic_grad(&params);
439            let _ = optimizer.step(&params, &grads).expect("step failed");
440            let curr = optimizer.current_temperature();
441            assert!(
442                curr <= prev + 1e-12,
443                "Temperature did not decrease monotonically: prev={prev}, curr={curr}"
444            );
445            prev = curr;
446        }
447    }
448
449    #[test]
450    fn test_geometric_cooling_endpoints() {
451        let optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.01)
452            .with_temperature_schedule(2.0, 1e-2)
453            .with_iterations(100);
454        // Endpoints: T(0) = initial, T(N) = final
455        let t0 = optimizer.temperature_at(0);
456        let tn = optimizer.temperature_at(100);
457        assert_abs_diff_eq!(t0, 2.0, epsilon = 1e-9);
458        assert_abs_diff_eq!(tn, 1e-2, epsilon = 1e-9);
459        // Halfway should be geometric mean: sqrt(2.0 * 1e-2)
460        let t_half = optimizer.temperature_at(50);
461        let expected_half = (2.0_f64 * 1e-2).sqrt();
462        assert_abs_diff_eq!(t_half, expected_half, epsilon = 1e-9);
463    }
464
465    #[test]
466    fn test_metropolis_accepts_lower_energy() {
467        // With purely downhill gradients (proxy energy <= 0), we always accept.
468        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
469            .with_temperature_schedule(1.0, 1e-3)
470            .with_iterations(50)
471            .with_seed(11);
472        // Negative ΔE should always be accepted irrespective of temperature.
473        for _ in 0..30 {
474            let accept = optimizer.accept(-0.5, 0.25);
475            assert!(accept, "Metropolis rejected a strictly downhill move");
476        }
477    }
478
479    #[test]
480    fn test_metropolis_rejects_higher_energy_at_low_temp_probabilistically() {
481        // At very low temperature with zero tunneling, almost all uphill
482        // proposals should be rejected.
483        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
484            .with_temperature_schedule(1e-6, 1e-6)
485            .with_tunneling(0.0)
486            .with_iterations(100)
487            .with_seed(101);
488        // Force T to be effectively zero by directly setting via construction.
489        optimizer.current_temperature = 1e-6;
490        let mut accepted = 0;
491        let trials = 500;
492        for _ in 0..trials {
493            if optimizer.accept(1.0, 0.5) {
494                accepted += 1;
495            }
496        }
497        assert!(
498            accepted < (trials / 50).max(2),
499            "Too many uphill moves accepted at near-zero temperature: {accepted} / {trials}"
500        );
501    }
502
503    #[test]
504    fn test_tunneling_increases_acceptance() {
505        // Hold everything constant except tunneling strength and measure mean
506        // acceptance for uphill moves. Tunneling=1.0 should accept more than
507        // tunneling=0.0.
508        let mut low: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
509            .with_temperature_schedule(0.1, 0.1)
510            .with_tunneling(0.0)
511            .with_iterations(1000)
512            .with_seed(2024);
513        let mut high: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
514            .with_temperature_schedule(0.1, 0.1)
515            .with_tunneling(2.0)
516            .with_iterations(1000)
517            .with_seed(2024);
518
519        let mut accept_low = 0usize;
520        let mut accept_high = 0usize;
521        let trials = 4000;
522        for _ in 0..trials {
523            if low.accept(0.2, 0.1) {
524                accept_low += 1;
525            }
526            if high.accept(0.2, 0.1) {
527                accept_high += 1;
528            }
529        }
530        assert!(
531            accept_high > accept_low,
532            "Higher tunneling did not increase acceptance: low={accept_low}, high={accept_high}"
533        );
534    }
535
536    #[test]
537    fn test_convergence_on_quadratic_bowl() {
538        // f(x) = x^2 from x = 5. Quantum annealing performs a stochastic
539        // gradient-driven random walk where the perturbation magnitude is
540        // scaled by `learning_rate * current_temperature`. With a high
541        // learning rate, broad temperature schedule and modest tunneling, we
542        // expect the parameters to descend substantially towards zero within
543        // a few hundred iterations.
544        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(1.0)
545            .with_temperature_schedule(2.0, 1e-3)
546            .with_tunneling(0.05)
547            .with_iterations(200)
548            .with_seed(91);
549        let initial = 5.0_f64;
550        let mut params = Array1::from_vec(vec![initial]);
551        for _ in 0..200 {
552            let grads = quadratic_grad(&params);
553            params = optimizer.step(&params, &grads).expect("step failed");
554        }
555        // The optimizer should reduce |x| substantially relative to its
556        // starting value. We use a generous tolerance because the process is
557        // a stochastic gradient-biased random walk.
558        assert!(
559            params[0].abs() < initial * 0.5,
560            "Optimizer did not converge: |x|={}, started at {initial}",
561            params[0].abs()
562        );
563    }
564
565    #[test]
566    fn test_seed_reproducibility() {
567        let mut a: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
568            .with_temperature_schedule(1.0, 1e-2)
569            .with_iterations(100)
570            .with_seed(123);
571        let mut b: QuantumAnnealing<f64> = QuantumAnnealing::new(0.1)
572            .with_temperature_schedule(1.0, 1e-2)
573            .with_iterations(100)
574            .with_seed(123);
575        let params = Array1::from_vec(vec![1.0, 2.0, -1.0]);
576        let grads = Array1::from_vec(vec![0.1, -0.2, 0.05]);
577        for _ in 0..20 {
578            let p_a = a.step(&params, &grads).expect("step failed");
579            let p_b = b.step(&params, &grads).expect("step failed");
580            for (x, y) in p_a.iter().zip(p_b.iter()) {
581                assert_abs_diff_eq!(*x, *y, epsilon = 1e-12);
582            }
583        }
584    }
585
586    #[test]
587    fn test_set_learning_rate_changes_step_size() {
588        let params = Array1::from_vec(vec![1.0, 1.0, 1.0]);
589        let grads = Array1::from_vec(vec![0.0, 0.0, 0.0]);
590
591        let mut small: QuantumAnnealing<f64> = QuantumAnnealing::new(0.01)
592            .with_temperature_schedule(1.0, 1.0)
593            .with_tunneling(0.0)
594            .with_iterations(10)
595            .with_seed(5);
596        let mut large: QuantumAnnealing<f64> = QuantumAnnealing::new(1.0)
597            .with_temperature_schedule(1.0, 1.0)
598            .with_tunneling(0.0)
599            .with_iterations(10)
600            .with_seed(5);
601
602        let mut max_small = 0.0_f64;
603        let mut max_large = 0.0_f64;
604        for _ in 0..30 {
605            let ps = small.step(&params, &grads).expect("step failed");
606            let pl = large.step(&params, &grads).expect("step failed");
607            for (s, l) in ps.iter().zip(pl.iter()) {
608                max_small = max_small.max((s - 1.0).abs());
609                max_large = max_large.max((l - 1.0).abs());
610            }
611        }
612        // Sanity: large LR should have produced larger displacements.
613        assert!(
614            max_large > max_small,
615            "large LR ({max_large}) did not move further than small LR ({max_small})"
616        );
617
618        // After setting learning rate, the optimizer reports the update.
619        let mut opt: QuantumAnnealing<f64> = QuantumAnnealing::new(0.01);
620        opt.set_lr(0.5);
621        assert_abs_diff_eq!(opt.learning_rate(), 0.5);
622    }
623
624    #[test]
625    fn test_step_returns_same_shape_as_params() {
626        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.05)
627            .with_temperature_schedule(0.5, 1e-3)
628            .with_iterations(20)
629            .with_seed(31);
630        let params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
631        let grads = Array1::from_vec(vec![0.1, -0.2, 0.3, -0.4, 0.5]);
632        let updated = optimizer.step(&params, &grads).expect("step failed");
633        assert_eq!(updated.shape(), params.shape());
634    }
635
636    #[test]
637    fn test_best_params_tracked() {
638        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.05)
639            .with_temperature_schedule(0.5, 1e-3)
640            .with_iterations(100)
641            .with_seed(17);
642        let params = Array1::from_vec(vec![5.0]);
643        let mut current = params.clone();
644        for _ in 0..100 {
645            let grads = quadratic_grad(&current);
646            current = optimizer.step(&current, &grads).expect("step failed");
647        }
648        assert!(
649            optimizer.best_energy() <= 0.0,
650            "best_energy {} should be <= 0",
651            optimizer.best_energy()
652        );
653        let best: Option<Array1<f64>> = optimizer.best_params();
654        assert!(best.is_some(), "best_params should be tracked");
655    }
656
657    #[test]
658    fn test_dimension_mismatch_errors() {
659        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.05).with_seed(42);
660        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
661        let grads = Array1::from_vec(vec![1.0, 2.0]);
662        let result = optimizer.step(&params, &grads);
663        assert!(result.is_err(), "expected dimension mismatch error");
664    }
665
666    #[test]
667    fn test_temperature_plateau_after_n_iterations() {
668        let mut optimizer: QuantumAnnealing<f64> = QuantumAnnealing::new(0.01)
669            .with_temperature_schedule(1.0, 1e-3)
670            .with_iterations(10)
671            .with_seed(3);
672        let params = Array1::from_vec(vec![0.0]);
673        let grads = Array1::from_vec(vec![0.0]);
674        for _ in 0..50 {
675            let _ = optimizer.step(&params, &grads).expect("step failed");
676        }
677        // Once we've exceeded num_iterations, temperature should plateau at
678        // the final temperature endpoint.
679        assert_abs_diff_eq!(optimizer.current_temperature(), 1e-3, epsilon = 1e-9);
680    }
681}