Skip to main content

optirs_learned/
quantum_learned.rs

1//! Quantum-inspired learned optimizers.
2//!
3//! This module bridges the three quantum-inspired optimizers from
4//! [`optirs_core::quantum_inspired`] into the learned-optimizer interface
5//! [`crate::domain_optimizers::AdvancedOptimizer`]. The core optimizers operate
6//! over arbitrary [`scirs2_core::ndarray::Dimension`]s through the
7//! [`optirs_core::optimizers::Optimizer`] trait; here we specialise them to the
8//! one-dimensional parameter vectors (`Array1<T>`) used throughout the learned
9//! optimizer stack and layer on the bookkeeping the learned interface expects
10//! (step counting, gradient-norm EMA, and an [`OptimizerStateInfo`] snapshot).
11//!
12//! Three backends are wrapped:
13//!
14//! - **Quantum annealing** ([`QuantumAnnealing`]) — a Metropolis-driven
15//!   stochastic search with a quantum-inspired tunneling kernel.
16//! - **Variational quantum** ([`VariationalQuantumOptimizer`]) — a SPSA
17//!   optimizer with a rotation-gate-inspired ansatz factor.
18//! - **Hybrid quantum-classical** ([`HybridQuantumClassical`]) — a two-phase
19//!   optimizer that explores with annealing and refines with Adam.
20//!
21//! All three are exposed behind a single concrete type
22//! [`QuantumLearnedOptimizer`] selected at construction time.
23//!
24//! # Examples
25//!
26//! ```
27//! use optirs_learned::domain_optimizers::AdvancedOptimizer;
28//! use optirs_learned::quantum_learned::QuantumLearnedOptimizer;
29//! use scirs2_core::ndarray::Array1;
30//!
31//! let mut optimizer: QuantumLearnedOptimizer<f64> =
32//!     QuantumLearnedOptimizer::annealing(0.05);
33//! let params = Array1::from_vec(vec![1.0, -1.0, 0.5]);
34//! let gradients = params.mapv(|x| 2.0 * x);
35//! let next = optimizer.step(&params, &gradients).expect("step succeeds");
36//! assert_eq!(next.len(), params.len());
37//! ```
38
39use std::fmt::Debug;
40
41use scirs2_core::ndarray::{Array1, Ix1, ScalarOperand};
42use scirs2_core::numeric::Float;
43
44use optirs_core::optimizers::Optimizer;
45use optirs_core::quantum_inspired::{
46    HybridQuantumClassical, QuantumAnnealing, VariationalQuantumOptimizer,
47};
48
49use crate::domain_optimizers::{l2_norm, AdvancedOptimizer, OptimizerStateInfo};
50use crate::error::{OptimError, Result};
51
52/// Default decay used for the gradient-norm exponential moving average.
53const DEFAULT_NORM_EMA_DECAY: f64 = 0.9;
54
55/// The concrete quantum-inspired optimizer driving a [`QuantumLearnedOptimizer`].
56///
57/// Each variant holds a fully-configured core optimizer from
58/// [`optirs_core::quantum_inspired`]. The wrapper dispatches the
59/// [`AdvancedOptimizer`] interface to the held optimizer through the
60/// [`Optimizer`] trait specialised to [`tyalias@Ix1`].
61#[derive(Debug)]
62pub enum QuantumBackend<T>
63where
64    T: Float + ScalarOperand + Debug + Send + Sync + 'static,
65{
66    /// Metropolis-based quantum annealing exploration.
67    Annealing(QuantumAnnealing<T>),
68    /// SPSA-based variational quantum optimizer.
69    Variational(VariationalQuantumOptimizer<T>),
70    /// Two-phase hybrid quantum-classical optimizer.
71    Hybrid(HybridQuantumClassical<T>),
72}
73
74impl<T> QuantumBackend<T>
75where
76    T: Float + ScalarOperand + Debug + Send + Sync + 'static,
77{
78    /// Perform one optimization step on a one-dimensional parameter vector.
79    ///
80    /// Dispatches to the held core optimizer's [`Optimizer`] implementation at
81    /// `D = Ix1` and maps any core error into a learned
82    /// [`OptimError::ComputationError`].
83    fn step_array1(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
84        let outcome = match self {
85            QuantumBackend::Annealing(inner) => {
86                <QuantumAnnealing<T> as Optimizer<T, Ix1>>::step(inner, params, gradients)
87            }
88            QuantumBackend::Variational(inner) => <VariationalQuantumOptimizer<T> as Optimizer<
89                T,
90                Ix1,
91            >>::step(inner, params, gradients),
92            QuantumBackend::Hybrid(inner) => {
93                <HybridQuantumClassical<T> as Optimizer<T, Ix1>>::step(inner, params, gradients)
94            }
95        };
96        outcome.map_err(|err| OptimError::ComputationError(err.to_string()))
97    }
98
99    /// Returns the current learning rate of the held core optimizer.
100    fn learning_rate(&self) -> T {
101        match self {
102            QuantumBackend::Annealing(inner) => {
103                <QuantumAnnealing<T> as Optimizer<T, Ix1>>::get_learning_rate(inner)
104            }
105            QuantumBackend::Variational(inner) => {
106                <VariationalQuantumOptimizer<T> as Optimizer<T, Ix1>>::get_learning_rate(inner)
107            }
108            QuantumBackend::Hybrid(inner) => {
109                <HybridQuantumClassical<T> as Optimizer<T, Ix1>>::get_learning_rate(inner)
110            }
111        }
112    }
113
114    /// Sets the learning rate of the held core optimizer.
115    fn set_learning_rate(&mut self, learning_rate: T) {
116        match self {
117            QuantumBackend::Annealing(inner) => {
118                <QuantumAnnealing<T> as Optimizer<T, Ix1>>::set_learning_rate(inner, learning_rate)
119            }
120            QuantumBackend::Variational(inner) => {
121                <VariationalQuantumOptimizer<T> as Optimizer<T, Ix1>>::set_learning_rate(
122                    inner,
123                    learning_rate,
124                )
125            }
126            QuantumBackend::Hybrid(inner) => {
127                <HybridQuantumClassical<T> as Optimizer<T, Ix1>>::set_learning_rate(
128                    inner,
129                    learning_rate,
130                )
131            }
132        }
133    }
134
135    /// Returns the stable display name for this backend.
136    fn name(&self) -> &'static str {
137        match self {
138            QuantumBackend::Annealing(_) => "QuantumAnnealingLearned",
139            QuantumBackend::Variational(_) => "VariationalQuantumLearned",
140            QuantumBackend::Hybrid(_) => "HybridQuantumLearned",
141        }
142    }
143}
144
145/// A learned-interface adapter over the quantum-inspired core optimizers.
146///
147/// `QuantumLearnedOptimizer` wraps one of the [`QuantumBackend`] variants and
148/// implements [`AdvancedOptimizer`], tracking the step count and an
149/// exponential moving average of the gradient L2 norm so the learned stack can
150/// query optimizer health via [`AdvancedOptimizer::get_state`].
151#[derive(Debug)]
152pub struct QuantumLearnedOptimizer<T>
153where
154    T: Float + ScalarOperand + Debug + Send + Sync + 'static,
155{
156    /// The quantum-inspired optimizer doing the actual work.
157    backend: QuantumBackend<T>,
158    /// Total number of successful optimization steps taken.
159    step_count: usize,
160    /// Exponential moving average of the gradient L2 norm.
161    grad_norm_ema: T,
162    /// Decay used when updating [`Self::grad_norm_ema`]; in `(0, 1)`.
163    norm_ema_decay: T,
164}
165
166impl<T> QuantumLearnedOptimizer<T>
167where
168    T: Float + ScalarOperand + Debug + Send + Sync + 'static,
169{
170    /// Default gradient-norm EMA decay coefficient, materialised in `T`.
171    fn default_norm_ema_decay() -> T {
172        T::from(DEFAULT_NORM_EMA_DECAY).unwrap_or_else(|| {
173            // Fall back to a value that is unambiguously in (0, 1) if the
174            // literal cannot be represented exactly in `T`.
175            let nine = T::from(9.0).unwrap_or_else(T::one);
176            let ten = T::from(10.0).unwrap_or_else(|| T::one() + T::one());
177            nine / ten
178        })
179    }
180
181    /// Construct a wrapper around an arbitrary [`QuantumBackend`].
182    fn from_backend(backend: QuantumBackend<T>) -> Self {
183        Self {
184            backend,
185            step_count: 0,
186            grad_norm_ema: T::zero(),
187            norm_ema_decay: Self::default_norm_ema_decay(),
188        }
189    }
190
191    /// Create a quantum-annealing-backed learned optimizer.
192    ///
193    /// The `learning_rate` scales the perturbation magnitude of the underlying
194    /// [`QuantumAnnealing`] optimizer.
195    pub fn annealing(learning_rate: T) -> Self {
196        Self::from_backend(QuantumBackend::Annealing(QuantumAnnealing::new(
197            learning_rate,
198        )))
199    }
200
201    /// Create a variational-quantum (SPSA) backed learned optimizer.
202    ///
203    /// The `learning_rate` is the SPSA gain numerator `a` of the underlying
204    /// [`VariationalQuantumOptimizer`].
205    pub fn variational(learning_rate: T) -> Self {
206        Self::from_backend(QuantumBackend::Variational(
207            VariationalQuantumOptimizer::new(learning_rate),
208        ))
209    }
210
211    /// Create a hybrid quantum-classical backed learned optimizer.
212    ///
213    /// The first `switch_step` updates are produced by quantum annealing; later
214    /// updates are produced by the classical Adam refinement phase of the
215    /// underlying [`HybridQuantumClassical`] optimizer.
216    pub fn hybrid(learning_rate: T, switch_step: usize) -> Self {
217        Self::from_backend(QuantumBackend::Hybrid(HybridQuantumClassical::new(
218            learning_rate,
219            switch_step,
220        )))
221    }
222
223    /// Returns the decay coefficient used for the gradient-norm EMA.
224    pub fn norm_ema_decay(&self) -> T {
225        self.norm_ema_decay
226    }
227
228    /// Override the gradient-norm EMA decay coefficient.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`OptimError::InvalidConfig`] unless `decay` lies in the open
233    /// interval `(0, 1)`; a decay outside that range would make the EMA either
234    /// ignore new observations entirely or diverge.
235    pub fn set_norm_ema_decay(&mut self, decay: T) -> Result<()> {
236        if !(decay > T::zero() && decay < T::one()) {
237            return Err(OptimError::InvalidConfig(format!(
238                "norm_ema_decay must lie in (0, 1), got {decay:?}"
239            )));
240        }
241        self.norm_ema_decay = decay;
242        Ok(())
243    }
244
245    /// Immutable access to the underlying backend (useful for diagnostics).
246    pub fn backend(&self) -> &QuantumBackend<T> {
247        &self.backend
248    }
249
250    /// Current step count.
251    pub fn step_count(&self) -> usize {
252        self.step_count
253    }
254}
255
256impl<T> AdvancedOptimizer<T> for QuantumLearnedOptimizer<T>
257where
258    T: Float + ScalarOperand + Debug + Send + Sync + 'static,
259{
260    fn step(&mut self, params: &Array1<T>, gradients: &Array1<T>) -> Result<Array1<T>> {
261        // Delegate to the backend; it performs the dimension check and any
262        // RNG-driven update. Only on success do we advance our bookkeeping so a
263        // failed step leaves the wrapper in a consistent state.
264        let updated = self.backend.step_array1(params, gradients)?;
265
266        let grad_norm = l2_norm(gradients);
267        self.grad_norm_ema =
268            self.norm_ema_decay * self.grad_norm_ema + (T::one() - self.norm_ema_decay) * grad_norm;
269        self.step_count = self.step_count.saturating_add(1);
270
271        Ok(updated)
272    }
273
274    fn get_learning_rate(&self) -> T {
275        self.backend.learning_rate()
276    }
277
278    fn set_learning_rate(&mut self, lr: T) {
279        self.backend.set_learning_rate(lr);
280    }
281
282    fn name(&self) -> &str {
283        self.backend.name()
284    }
285
286    fn get_state(&self) -> OptimizerStateInfo<T> {
287        OptimizerStateInfo {
288            step_count: self.step_count,
289            current_lr: self.get_learning_rate(),
290            grad_norm_ema: self.grad_norm_ema,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use scirs2_core::ndarray::Array1;
299
300    /// Gradient of the convex quadratic `f(x) = ||x||^2`, i.e. `2 * x`.
301    fn quadratic_grad(params: &Array1<f64>) -> Array1<f64> {
302        params.mapv(|x| 2.0 * x)
303    }
304
305    fn all_finite(arr: &Array1<f64>) -> bool {
306        arr.iter().all(|x| x.is_finite())
307    }
308
309    #[test]
310    fn test_annealing_steps_preserve_shape_and_track_state() {
311        let mut opt = QuantumLearnedOptimizer::<f64>::annealing(0.05);
312        let mut params = Array1::from_vec(vec![1.0, -2.0, 0.5, 3.0]);
313        assert_eq!(opt.step_count(), 0);
314        for i in 1..=10 {
315            let grads = quadratic_grad(&params);
316            params = opt.step(&params, &grads).expect("annealing step succeeds");
317            assert_eq!(params.len(), 4, "shape must be preserved");
318            assert!(all_finite(&params), "outputs must remain finite");
319            assert_eq!(opt.step_count(), i, "step count must increment");
320        }
321        assert!(
322            opt.get_state().grad_norm_ema > 0.0,
323            "grad_norm_ema must be positive after nonzero-grad steps"
324        );
325    }
326
327    #[test]
328    fn test_variational_steps_preserve_shape_and_track_state() {
329        let mut opt = QuantumLearnedOptimizer::<f64>::variational(0.1);
330        let mut params = Array1::from_vec(vec![2.0, -1.0, 0.25]);
331        for i in 1..=10 {
332            let grads = quadratic_grad(&params);
333            params = opt
334                .step(&params, &grads)
335                .expect("variational step succeeds");
336            assert_eq!(params.len(), 3);
337            assert!(all_finite(&params));
338            assert_eq!(opt.step_count(), i);
339        }
340        assert!(opt.get_state().grad_norm_ema > 0.0);
341    }
342
343    #[test]
344    fn test_hybrid_steps_preserve_shape_and_track_state() {
345        let mut opt = QuantumLearnedOptimizer::<f64>::hybrid(0.05, 4);
346        let mut params = Array1::from_vec(vec![1.5, -0.75]);
347        for i in 1..=8 {
348            let grads = quadratic_grad(&params);
349            params = opt.step(&params, &grads).expect("hybrid step succeeds");
350            assert_eq!(params.len(), 2);
351            assert!(all_finite(&params));
352            assert_eq!(opt.step_count(), i);
353        }
354        assert!(opt.get_state().grad_norm_ema > 0.0);
355    }
356
357    #[test]
358    fn test_annealing_learning_rate_round_trip() {
359        let mut opt = QuantumLearnedOptimizer::<f64>::annealing(0.05);
360        assert!((opt.get_learning_rate() - 0.05).abs() < 1e-12);
361        opt.set_learning_rate(0.123);
362        assert!((opt.get_learning_rate() - 0.123).abs() < 1e-12);
363    }
364
365    #[test]
366    fn test_variational_learning_rate_round_trip() {
367        let mut opt = QuantumLearnedOptimizer::<f64>::variational(0.2);
368        assert!((opt.get_learning_rate() - 0.2).abs() < 1e-12);
369        opt.set_learning_rate(0.01);
370        assert!((opt.get_learning_rate() - 0.01).abs() < 1e-12);
371    }
372
373    #[test]
374    fn test_hybrid_learning_rate_round_trip() {
375        // In the exploration phase the hybrid reports the quantum learning
376        // rate; setting the rate updates both sub-optimizers.
377        let mut opt = QuantumLearnedOptimizer::<f64>::hybrid(0.05, 10);
378        assert!((opt.get_learning_rate() - 0.05).abs() < 1e-12);
379        opt.set_learning_rate(0.33);
380        assert!((opt.get_learning_rate() - 0.33).abs() < 1e-12);
381    }
382
383    #[test]
384    fn test_names_per_backend() {
385        let annealing = QuantumLearnedOptimizer::<f64>::annealing(0.05);
386        let variational = QuantumLearnedOptimizer::<f64>::variational(0.05);
387        let hybrid = QuantumLearnedOptimizer::<f64>::hybrid(0.05, 5);
388        assert_eq!(annealing.name(), "QuantumAnnealingLearned");
389        assert_eq!(variational.name(), "VariationalQuantumLearned");
390        assert_eq!(hybrid.name(), "HybridQuantumLearned");
391    }
392
393    #[test]
394    fn test_annealing_length_mismatch_errors() {
395        let mut opt = QuantumLearnedOptimizer::<f64>::annealing(0.05);
396        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
397        let grads = Array1::from_vec(vec![1.0, 2.0]);
398        let result = opt.step(&params, &grads);
399        assert!(result.is_err(), "mismatched lengths must error");
400        // A failed step must not advance bookkeeping.
401        assert_eq!(opt.step_count(), 0);
402    }
403
404    #[test]
405    fn test_variational_length_mismatch_errors() {
406        let mut opt = QuantumLearnedOptimizer::<f64>::variational(0.1);
407        let params = Array1::from_vec(vec![1.0, 2.0]);
408        let grads = Array1::from_vec(vec![1.0, 2.0, 3.0]);
409        let result = opt.step(&params, &grads);
410        assert!(result.is_err());
411        assert_eq!(opt.step_count(), 0);
412    }
413
414    #[test]
415    fn test_hybrid_length_mismatch_errors() {
416        let mut opt = QuantumLearnedOptimizer::<f64>::hybrid(0.05, 5);
417        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
418        let grads = Array1::from_vec(vec![1.0]);
419        let result = opt.step(&params, &grads);
420        assert!(result.is_err());
421        assert_eq!(opt.step_count(), 0);
422    }
423
424    #[test]
425    fn test_hybrid_crosses_switch_step_without_error() {
426        // Run switch_step + 2 steps to cover the exploration -> refinement
427        // transition (and the one-time warm-start handoff).
428        let switch_step = 6usize;
429        let mut opt = QuantumLearnedOptimizer::<f64>::hybrid(0.05, switch_step);
430        let mut params = Array1::from_vec(vec![2.0, -1.0, 0.5]);
431        for _ in 0..(switch_step + 2) {
432            let grads = quadratic_grad(&params);
433            params = opt
434                .step(&params, &grads)
435                .expect("hybrid step across switch succeeds");
436            assert!(all_finite(&params));
437        }
438        assert_eq!(opt.step_count(), switch_step + 2);
439    }
440
441    #[test]
442    fn test_get_state_reflects_learning_rate_and_steps() {
443        let mut opt = QuantumLearnedOptimizer::<f64>::variational(0.07);
444        let params = Array1::from_vec(vec![0.5, -0.5, 0.5]);
445        let grads = quadratic_grad(&params);
446        let _ = opt.step(&params, &grads).expect("step succeeds");
447        let state = opt.get_state();
448        assert_eq!(state.step_count, 1);
449        assert!((state.current_lr - 0.07).abs() < 1e-12);
450        assert!(state.grad_norm_ema >= 0.0);
451    }
452
453    #[test]
454    fn test_norm_ema_decay_validation() {
455        let mut opt = QuantumLearnedOptimizer::<f64>::annealing(0.05);
456        // Default decay is in (0, 1).
457        assert!(opt.norm_ema_decay() > 0.0 && opt.norm_ema_decay() < 1.0);
458        assert!(opt.set_norm_ema_decay(0.5).is_ok());
459        assert!((opt.norm_ema_decay() - 0.5).abs() < 1e-12);
460        // Out-of-range decays are rejected.
461        assert!(opt.set_norm_ema_decay(0.0).is_err());
462        assert!(opt.set_norm_ema_decay(1.0).is_err());
463        assert!(opt.set_norm_ema_decay(-0.1).is_err());
464        assert!(opt.set_norm_ema_decay(1.5).is_err());
465    }
466
467    #[test]
468    fn test_zero_gradient_keeps_norm_ema_zero() {
469        // With identically-zero gradients the EMA must remain exactly zero.
470        let mut opt = QuantumLearnedOptimizer::<f64>::hybrid(0.05, 3);
471        let params = Array1::from_vec(vec![1.0, 1.0, 1.0]);
472        let grads = Array1::from_vec(vec![0.0, 0.0, 0.0]);
473        for _ in 0..5 {
474            let _ = opt.step(&params, &grads).expect("zero-grad step succeeds");
475        }
476        assert!(opt.get_state().grad_norm_ema.abs() < 1e-15);
477    }
478
479    #[test]
480    fn test_backend_accessor_matches_name() {
481        let opt = QuantumLearnedOptimizer::<f64>::annealing(0.05);
482        match opt.backend() {
483            QuantumBackend::Annealing(_) => {}
484            _ => panic!("expected annealing backend"),
485        }
486    }
487
488    /// Compile-time assertion that the wrapper is `Send + Sync`.
489    #[test]
490    fn test_is_send_sync() {
491        fn assert_send_sync<U: Send + Sync>() {}
492        assert_send_sync::<QuantumLearnedOptimizer<f64>>();
493    }
494}