Skip to main content

ipfrs_tensorlogic/
adaptive_optimizer.rs

1//! AdaptiveOptimizer — Adam, AdaGrad, RMSProp, and AdamW optimizers for
2//! distributed gradient descent.
3//!
4//! # Overview
5//!
6//! This module implements four widely-used adaptive gradient optimizers:
7//!
8//! * **Adam** — first- and second-moment estimates with bias correction.
9//! * **AdaGrad** — cumulative squared-gradient denominator, good for sparse gradients.
10//! * **RMSProp** — exponentially-decaying squared-gradient estimate with optional momentum.
11//! * **AdamW** — Adam with decoupled weight-decay regularisation (Loshchilov & Hutter 2019).
12//!
13//! All optimizers share a common [`AdaptiveOptimizer`] driver that
14//! manages per-group [`OptimizerState`] lazily and exposes helpers for
15//! gradient clipping, norm computation, and statistics.
16
17use std::collections::HashMap;
18use thiserror::Error;
19
20// ─────────────────────────────── errors ─────────────────────────────────────
21
22/// Errors produced by the adaptive optimizer.
23#[derive(Debug, Error, Clone, PartialEq)]
24pub enum OptimizerError {
25    /// Parameter tensor and gradient tensor have incompatible sizes.
26    #[error("dimension mismatch in group '{name}': params len={params}, grad len={grad}")]
27    DimensionMismatch {
28        /// Name of the parameter group.
29        name: String,
30        /// Length of the parameter vector.
31        params: usize,
32        /// Length of the gradient vector.
33        grad: usize,
34    },
35
36    /// A parameter group is empty (zero parameters).
37    #[error("parameter group '{0}' is empty")]
38    EmptyGroup(String),
39}
40
41// ────────────────────────────── algorithm ───────────────────────────────────
42
43/// Choice of adaptive gradient algorithm and its hyper-parameters.
44#[derive(Debug, Clone, PartialEq)]
45pub enum OptimizerAlgorithm {
46    /// Adam optimiser (Kingma & Ba, 2015).
47    Adam {
48        /// Learning rate. Default 0.001.
49        lr: f64,
50        /// Exponential decay for first-moment estimates. Default 0.9.
51        beta1: f64,
52        /// Exponential decay for second-moment estimates. Default 0.999.
53        beta2: f64,
54        /// Numerical stability constant. Default 1e-8.
55        epsilon: f64,
56    },
57
58    /// Adaptive gradient descent (Duchi et al., 2011).
59    AdaGrad {
60        /// Learning rate. Default 0.01.
61        lr: f64,
62        /// Numerical stability constant. Default 1e-8.
63        epsilon: f64,
64    },
65
66    /// Root-mean-square propagation (Hinton, 2012).
67    RmsProp {
68        /// Learning rate. Default 0.01.
69        lr: f64,
70        /// Smoothing constant (decay for squared-gradient average). Default 0.99.
71        alpha: f64,
72        /// Numerical stability constant. Default 1e-8.
73        epsilon: f64,
74        /// Momentum coefficient. Default 0.0 (no momentum).
75        momentum: f64,
76    },
77
78    /// Adam with decoupled weight decay (Loshchilov & Hutter, 2019).
79    AdamW {
80        /// Learning rate. Default 0.001.
81        lr: f64,
82        /// Exponential decay for first-moment estimates. Default 0.9.
83        beta1: f64,
84        /// Exponential decay for second-moment estimates. Default 0.999.
85        beta2: f64,
86        /// Numerical stability constant. Default 1e-8.
87        epsilon: f64,
88        /// Decoupled weight-decay coefficient. Default 0.01.
89        weight_decay: f64,
90    },
91}
92
93impl OptimizerAlgorithm {
94    /// Construct an [`Adam`][Self::Adam] instance with default hyper-parameters.
95    #[must_use]
96    pub fn adam_default() -> Self {
97        Self::Adam {
98            lr: 0.001,
99            beta1: 0.9,
100            beta2: 0.999,
101            epsilon: 1e-8,
102        }
103    }
104
105    /// Construct an [`AdaGrad`][Self::AdaGrad] instance with default hyper-parameters.
106    #[must_use]
107    pub fn adagrad_default() -> Self {
108        Self::AdaGrad {
109            lr: 0.01,
110            epsilon: 1e-8,
111        }
112    }
113
114    /// Construct an [`RmsProp`][Self::RmsProp] instance with default hyper-parameters.
115    #[must_use]
116    pub fn rmsprop_default() -> Self {
117        Self::RmsProp {
118            lr: 0.01,
119            alpha: 0.99,
120            epsilon: 1e-8,
121            momentum: 0.0,
122        }
123    }
124
125    /// Construct an [`AdamW`][Self::AdamW] instance with default hyper-parameters.
126    #[must_use]
127    pub fn adamw_default() -> Self {
128        Self::AdamW {
129            lr: 0.001,
130            beta1: 0.9,
131            beta2: 0.999,
132            epsilon: 1e-8,
133            weight_decay: 0.01,
134        }
135    }
136}
137
138// ──────────────────────────── parameter group ───────────────────────────────
139
140/// A named group of parameters together with their current gradients.
141///
142/// Both `params` and `grad` must have equal length when passed to
143/// [`AdaptiveOptimizer::step_group`].
144#[derive(Debug, Clone)]
145pub struct ParameterGroup {
146    /// Unique identifier used as the key in the optimizer state map.
147    pub name: String,
148    /// Current parameter values (updated in-place by the optimizer step).
149    pub params: Vec<f64>,
150    /// Gradient of the loss with respect to each parameter.
151    pub grad: Vec<f64>,
152}
153
154impl ParameterGroup {
155    /// Create a new parameter group with all-zero gradients.
156    #[must_use]
157    pub fn new(name: impl Into<String>, params: Vec<f64>) -> Self {
158        let n = params.len();
159        Self {
160            name: name.into(),
161            params,
162            grad: vec![0.0; n],
163        }
164    }
165
166    /// Create a parameter group with explicit gradients.
167    #[must_use]
168    pub fn with_grad(name: impl Into<String>, params: Vec<f64>, grad: Vec<f64>) -> Self {
169        Self {
170            name: name.into(),
171            params,
172            grad,
173        }
174    }
175}
176
177// ────────────────────────────── optimizer state ─────────────────────────────
178
179/// Per-parameter-group optimizer state (first moment, second moment, step counter).
180///
181/// This is the *internal* accumulator state maintained by [`AdaptiveOptimizer`].
182/// It corresponds to the PyTorch `state_dict` entries for a parameter group.
183#[derive(Debug, Clone)]
184pub struct OptimizerState {
185    /// First-moment (mean) vector; length equals the number of parameters.
186    pub m: Vec<f64>,
187    /// Second-moment (uncentered variance) vector; same length as `m`.
188    pub v: Vec<f64>,
189    /// Number of optimizer steps taken for this group (1-indexed when used).
190    pub step: u64,
191}
192
193impl OptimizerState {
194    /// Construct a zeroed state for `n` parameters.
195    #[must_use]
196    pub fn zeros(n: usize) -> Self {
197        Self {
198            m: vec![0.0; n],
199            v: vec![0.0; n],
200            step: 0,
201        }
202    }
203
204    /// Reset state back to zeros without reallocating.
205    pub fn reset(&mut self) {
206        self.m.iter_mut().for_each(|x| *x = 0.0);
207        self.v.iter_mut().for_each(|x| *x = 0.0);
208        self.step = 0;
209    }
210}
211
212// ───────────────────────── statistics snapshot ──────────────────────────────
213
214/// A lightweight statistics snapshot returned by [`AdaptiveOptimizer::stats`].
215#[derive(Debug, Clone, PartialEq)]
216pub struct OptimizerStats {
217    /// Total number of global optimizer steps executed.
218    pub total_steps: u64,
219    /// Number of parameter groups currently tracked.
220    pub parameter_groups: usize,
221    /// Total number of scalar parameters across all groups.
222    pub total_parameters: usize,
223    /// L2 norm of the gradient vector measured at the most recent step.
224    pub last_grad_norm: f64,
225}
226
227// ─────────────────────────── main optimizer ─────────────────────────────────
228
229/// Adaptive gradient optimizer supporting Adam, AdaGrad, RMSProp, and AdamW.
230///
231/// # Example
232///
233/// ```
234/// use ipfrs_tensorlogic::adaptive_optimizer::{
235///     AdaptiveOptimizer, OptimizerAlgorithm, ParameterGroup,
236/// };
237///
238/// let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::adam_default());
239/// let mut groups = vec![
240///     ParameterGroup::with_grad("w", vec![0.5, -0.3], vec![0.1, -0.2]),
241/// ];
242/// let norm = opt.step(&mut groups).expect("example: should succeed in docs");
243/// assert!(norm > 0.0);
244/// ```
245#[derive(Debug, Clone)]
246pub struct AdaptiveOptimizer {
247    /// The algorithm (and its hyper-parameters) used for all steps.
248    pub algorithm: OptimizerAlgorithm,
249    /// Per-group optimizer states; keyed by [`ParameterGroup::name`].
250    pub states: HashMap<String, OptimizerState>,
251    /// Global step counter (incremented once per call to [`step`][Self::step]).
252    pub global_step: u64,
253    /// Cached gradient norm from the last [`step`][Self::step] call.
254    last_grad_norm: f64,
255}
256
257impl AdaptiveOptimizer {
258    /// Create a new optimizer wrapping the given algorithm.
259    #[must_use]
260    pub fn new(algorithm: OptimizerAlgorithm) -> Self {
261        Self {
262            algorithm,
263            states: HashMap::new(),
264            global_step: 0,
265            last_grad_norm: 0.0,
266        }
267    }
268
269    // ─── public API ──────────────────────────────────────────────────────────
270
271    /// Perform one optimizer step across all `groups`.
272    ///
273    /// Returns the global L2 gradient norm computed *before* any update.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`OptimizerError::DimensionMismatch`] if `params.len() != grad.len()`
278    /// for any group, or [`OptimizerError::EmptyGroup`] if a group has zero parameters.
279    pub fn step(&mut self, groups: &mut [ParameterGroup]) -> Result<f64, OptimizerError> {
280        // Validate all groups first — fail fast before mutating any state.
281        for g in groups.iter() {
282            Self::validate_group(g)?;
283        }
284
285        // Compute global gradient norm before the update.
286        let norm = Self::global_grad_norm(groups);
287        self.last_grad_norm = norm;
288        self.global_step += 1;
289
290        // Update each group.
291        for g in groups.iter_mut() {
292            self.step_group(g)?;
293        }
294
295        Ok(norm)
296    }
297
298    /// Perform one optimizer step for a *single* parameter group.
299    ///
300    /// This is the low-level primitive used by [`step`][Self::step].
301    ///
302    /// # Errors
303    ///
304    /// Returns [`OptimizerError::DimensionMismatch`] or [`OptimizerError::EmptyGroup`].
305    pub fn step_group(&mut self, group: &mut ParameterGroup) -> Result<(), OptimizerError> {
306        Self::validate_group(group)?;
307        let n = group.params.len();
308        let key = group.name.clone();
309
310        // Lazily initialise state.
311        let state = self
312            .states
313            .entry(key)
314            .or_insert_with(|| OptimizerState::zeros(n));
315
316        // Ensure state vector lengths match (handles groups that grew).
317        if state.m.len() != n {
318            *state = OptimizerState::zeros(n);
319        }
320
321        match &self.algorithm.clone() {
322            OptimizerAlgorithm::Adam {
323                lr,
324                beta1,
325                beta2,
326                epsilon,
327            } => Self::apply_adam(group, state, *lr, *beta1, *beta2, *epsilon),
328            OptimizerAlgorithm::AdaGrad { lr, epsilon } => {
329                Self::apply_adagrad(group, state, *lr, *epsilon);
330            }
331            OptimizerAlgorithm::RmsProp {
332                lr,
333                alpha,
334                epsilon,
335                momentum,
336            } => Self::apply_rmsprop(group, state, *lr, *alpha, *epsilon, *momentum),
337            OptimizerAlgorithm::AdamW {
338                lr,
339                beta1,
340                beta2,
341                epsilon,
342                weight_decay,
343            } => Self::apply_adamw(group, state, *lr, *beta1, *beta2, *epsilon, *weight_decay),
344        }
345
346        Ok(())
347    }
348
349    /// Zero-out all gradients in `groups`.
350    pub fn zero_grad(groups: &mut [ParameterGroup]) {
351        for g in groups.iter_mut() {
352            g.grad.iter_mut().for_each(|x| *x = 0.0);
353        }
354    }
355
356    /// Compute the global L2 norm of all gradients across `groups`.
357    #[must_use]
358    pub fn global_grad_norm(groups: &[ParameterGroup]) -> f64 {
359        let sum_sq: f64 = groups
360            .iter()
361            .flat_map(|g| g.grad.iter())
362            .map(|&x| x * x)
363            .sum();
364        sum_sq.sqrt()
365    }
366
367    /// Scale all gradients so the global norm does not exceed `max_norm`.
368    ///
369    /// If the current global norm is ≤ `max_norm` or is not finite (e.g. NaN/inf),
370    /// gradients are left unchanged.
371    pub fn clip_grad_norm(groups: &mut [ParameterGroup], max_norm: f64) {
372        let norm = Self::global_grad_norm(groups);
373        if norm > max_norm && norm.is_finite() && max_norm > 0.0 {
374            let scale = max_norm / norm;
375            for g in groups.iter_mut() {
376                g.grad.iter_mut().for_each(|x| *x *= scale);
377            }
378        }
379    }
380
381    /// Clear the optimizer state for a specific group (by name).
382    pub fn reset_state(&mut self, group_name: &str) {
383        self.states.remove(group_name);
384    }
385
386    /// Clear all optimizer states and reset the global step counter.
387    pub fn reset_all(&mut self) {
388        self.states.clear();
389        self.global_step = 0;
390        self.last_grad_norm = 0.0;
391    }
392
393    /// Return a statistics snapshot.
394    #[must_use]
395    pub fn stats(&self, groups: &[ParameterGroup]) -> OptimizerStats {
396        let total_parameters = groups.iter().map(|g| g.params.len()).sum();
397        OptimizerStats {
398            total_steps: self.global_step,
399            parameter_groups: groups.len(),
400            total_parameters,
401            last_grad_norm: self.last_grad_norm,
402        }
403    }
404
405    // ─── private update kernels ──────────────────────────────────────────────
406
407    /// Validate that a group is non-empty and that `params` and `grad` agree in length.
408    fn validate_group(g: &ParameterGroup) -> Result<(), OptimizerError> {
409        if g.params.is_empty() {
410            return Err(OptimizerError::EmptyGroup(g.name.clone()));
411        }
412        if g.params.len() != g.grad.len() {
413            return Err(OptimizerError::DimensionMismatch {
414                name: g.name.clone(),
415                params: g.params.len(),
416                grad: g.grad.len(),
417            });
418        }
419        Ok(())
420    }
421
422    /// Adam update rule.
423    ///
424    /// ```text
425    /// state.step += 1
426    /// for i in 0..n:
427    ///     m[i] = β₁·m[i] + (1−β₁)·g
428    ///     v[i] = β₂·v[i] + (1−β₂)·g²
429    ///     m̂ = m[i] / (1 − β₁^t)
430    ///     v̂ = v[i] / (1 − β₂^t)
431    ///     θ[i] -= lr · m̂ / (√v̂ + ε)
432    /// ```
433    fn apply_adam(
434        group: &mut ParameterGroup,
435        state: &mut OptimizerState,
436        lr: f64,
437        beta1: f64,
438        beta2: f64,
439        epsilon: f64,
440    ) {
441        state.step += 1;
442        let t = state.step as f64;
443        let bc1 = 1.0 - beta1.powf(t);
444        let bc2 = 1.0 - beta2.powf(t);
445
446        for i in 0..group.params.len() {
447            let g = group.grad[i];
448            state.m[i] = beta1 * state.m[i] + (1.0 - beta1) * g;
449            state.v[i] = beta2 * state.v[i] + (1.0 - beta2) * g * g;
450            let m_hat = state.m[i] / bc1;
451            let v_hat = state.v[i] / bc2;
452            group.params[i] -= lr * m_hat / (v_hat.sqrt() + epsilon);
453        }
454    }
455
456    /// AdaGrad update rule.
457    ///
458    /// ```text
459    /// v[i] += g²
460    /// θ[i] -= lr · g / (√v[i] + ε)
461    /// ```
462    /// The first-moment slot `m` is not used.
463    fn apply_adagrad(
464        group: &mut ParameterGroup,
465        state: &mut OptimizerState,
466        lr: f64,
467        epsilon: f64,
468    ) {
469        state.step += 1;
470        for i in 0..group.params.len() {
471            let g = group.grad[i];
472            state.v[i] += g * g;
473            group.params[i] -= lr * g / (state.v[i].sqrt() + epsilon);
474        }
475    }
476
477    /// RMSProp update rule (with optional momentum).
478    ///
479    /// ```text
480    /// v[i] = α·v[i] + (1−α)·g²
481    /// m[i] = momentum·m[i] + lr·g / √(v[i] + ε)
482    /// θ[i] -= m[i]
483    /// ```
484    fn apply_rmsprop(
485        group: &mut ParameterGroup,
486        state: &mut OptimizerState,
487        lr: f64,
488        alpha: f64,
489        epsilon: f64,
490        momentum: f64,
491    ) {
492        state.step += 1;
493        for i in 0..group.params.len() {
494            let g = group.grad[i];
495            state.v[i] = alpha * state.v[i] + (1.0 - alpha) * g * g;
496            let delta = lr * g / (state.v[i] + epsilon).sqrt();
497            state.m[i] = momentum * state.m[i] + delta;
498            group.params[i] -= state.m[i];
499        }
500    }
501
502    /// AdamW update rule (Adam + decoupled weight decay).
503    ///
504    /// ```text
505    /// θ[i] -= lr·wd·θ[i]     ← weight decay applied first
506    /// then Adam update
507    /// ```
508    fn apply_adamw(
509        group: &mut ParameterGroup,
510        state: &mut OptimizerState,
511        lr: f64,
512        beta1: f64,
513        beta2: f64,
514        epsilon: f64,
515        weight_decay: f64,
516    ) {
517        state.step += 1;
518        let t = state.step as f64;
519        let bc1 = 1.0 - beta1.powf(t);
520        let bc2 = 1.0 - beta2.powf(t);
521
522        for i in 0..group.params.len() {
523            // Decoupled weight decay.
524            group.params[i] -= lr * weight_decay * group.params[i];
525
526            let g = group.grad[i];
527            state.m[i] = beta1 * state.m[i] + (1.0 - beta1) * g;
528            state.v[i] = beta2 * state.v[i] + (1.0 - beta2) * g * g;
529            let m_hat = state.m[i] / bc1;
530            let v_hat = state.v[i] / bc2;
531            group.params[i] -= lr * m_hat / (v_hat.sqrt() + epsilon);
532        }
533    }
534}
535
536// ═══════════════════════════════ tests ═══════════════════════════════════════
537
538#[cfg(test)]
539mod tests {
540    use super::{
541        AdaptiveOptimizer, OptimizerAlgorithm, OptimizerError, OptimizerState, ParameterGroup,
542    };
543
544    // ── helpers ──────────────────────────────────────────────────────────────
545
546    fn adam_opt() -> AdaptiveOptimizer {
547        AdaptiveOptimizer::new(OptimizerAlgorithm::adam_default())
548    }
549
550    fn adagrad_opt() -> AdaptiveOptimizer {
551        AdaptiveOptimizer::new(OptimizerAlgorithm::adagrad_default())
552    }
553
554    fn rmsprop_opt() -> AdaptiveOptimizer {
555        AdaptiveOptimizer::new(OptimizerAlgorithm::rmsprop_default())
556    }
557
558    fn adamw_opt() -> AdaptiveOptimizer {
559        AdaptiveOptimizer::new(OptimizerAlgorithm::adamw_default())
560    }
561
562    fn simple_group(name: &str, p: f64, g: f64) -> ParameterGroup {
563        ParameterGroup::with_grad(name, vec![p], vec![g])
564    }
565
566    // ── construction ─────────────────────────────────────────────────────────
567
568    #[test]
569    fn test_new_optimizer_initial_state() {
570        let opt = adam_opt();
571        assert_eq!(opt.global_step, 0);
572        assert!(opt.states.is_empty());
573    }
574
575    #[test]
576    fn test_parameter_group_new_zeros_grad() {
577        let g = ParameterGroup::new("layer", vec![1.0, 2.0, 3.0]);
578        assert_eq!(g.grad, vec![0.0, 0.0, 0.0]);
579        assert_eq!(g.params.len(), 3);
580    }
581
582    #[test]
583    fn test_parameter_group_with_grad() {
584        let g = ParameterGroup::with_grad("w", vec![1.0], vec![0.5]);
585        assert_eq!(g.params[0], 1.0);
586        assert_eq!(g.grad[0], 0.5);
587    }
588
589    #[test]
590    fn test_optimizer_state_zeros() {
591        let s = OptimizerState::zeros(4);
592        assert_eq!(s.m, vec![0.0; 4]);
593        assert_eq!(s.v, vec![0.0; 4]);
594        assert_eq!(s.step, 0);
595    }
596
597    #[test]
598    fn test_optimizer_state_reset() {
599        let mut s = OptimizerState {
600            m: vec![1.0, 2.0],
601            v: vec![3.0, 4.0],
602            step: 10,
603        };
604        s.reset();
605        assert_eq!(s.m, vec![0.0, 0.0]);
606        assert_eq!(s.v, vec![0.0, 0.0]);
607        assert_eq!(s.step, 0);
608    }
609
610    // ── validation errors ────────────────────────────────────────────────────
611
612    #[test]
613    fn test_step_dimension_mismatch_error() {
614        let mut opt = adam_opt();
615        let mut groups = vec![ParameterGroup {
616            name: "bad".to_string(),
617            params: vec![1.0, 2.0],
618            grad: vec![0.1],
619        }];
620        let err = opt.step(&mut groups).unwrap_err();
621        assert!(matches!(err, OptimizerError::DimensionMismatch { .. }));
622    }
623
624    #[test]
625    fn test_step_empty_group_error() {
626        let mut opt = adam_opt();
627        let mut groups = vec![ParameterGroup {
628            name: "empty".to_string(),
629            params: vec![],
630            grad: vec![],
631        }];
632        let err = opt.step(&mut groups).unwrap_err();
633        assert!(matches!(err, OptimizerError::EmptyGroup(_)));
634    }
635
636    #[test]
637    fn test_step_group_dimension_mismatch() {
638        let mut opt = adam_opt();
639        let mut g = ParameterGroup {
640            name: "x".to_string(),
641            params: vec![1.0],
642            grad: vec![0.1, 0.2],
643        };
644        assert!(opt.step_group(&mut g).is_err());
645    }
646
647    // ── Adam ──────────────────────────────────────────────────────────────────
648
649    #[test]
650    fn test_adam_step_reduces_param_toward_zero() {
651        let mut opt = adam_opt();
652        let mut groups = vec![simple_group("w", 1.0, 1.0)];
653        opt.step(&mut groups).expect("test: should succeed");
654        // With positive gradient the parameter should decrease.
655        assert!(groups[0].params[0] < 1.0);
656    }
657
658    #[test]
659    fn test_adam_global_step_increments() {
660        let mut opt = adam_opt();
661        let mut groups = vec![simple_group("w", 1.0, 0.1)];
662        opt.step(&mut groups).expect("test: should succeed");
663        opt.step(&mut groups).expect("test: should succeed");
664        assert_eq!(opt.global_step, 2);
665    }
666
667    #[test]
668    fn test_adam_state_step_increments_per_group() {
669        let mut opt = adam_opt();
670        let mut groups = vec![simple_group("a", 0.5, 0.2)];
671        opt.step(&mut groups).expect("test: should succeed");
672        opt.step(&mut groups).expect("test: should succeed");
673        assert_eq!(opt.states["a"].step, 2);
674    }
675
676    #[test]
677    fn test_adam_moment_vectors_are_nonzero_after_step() {
678        let mut opt = adam_opt();
679        let mut groups = vec![simple_group("m", 0.0, 1.0)];
680        opt.step(&mut groups).expect("test: should succeed");
681        let s = &opt.states["m"];
682        assert_ne!(s.m[0], 0.0);
683        assert_ne!(s.v[0], 0.0);
684    }
685
686    #[test]
687    fn test_adam_multiple_params() {
688        let mut opt = adam_opt();
689        let mut groups = vec![ParameterGroup::with_grad(
690            "layer",
691            vec![1.0, -1.0, 0.0],
692            vec![0.5, -0.5, 1.0],
693        )];
694        let norm = opt.step(&mut groups).expect("test: should succeed");
695        assert!(norm > 0.0);
696        assert_eq!(groups[0].params.len(), 3);
697    }
698
699    #[test]
700    fn test_adam_zero_gradient_leaves_param_almost_unchanged() {
701        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::Adam {
702            lr: 0.001,
703            beta1: 0.9,
704            beta2: 0.999,
705            epsilon: 1e-8,
706        });
707        let initial = 5.0_f64;
708        let mut groups = vec![simple_group("p", initial, 0.0)];
709        opt.step(&mut groups).expect("test: should succeed");
710        // Zero gradient → m and v remain zero → m_hat and v_hat → 0/bc → 0.
711        // Update: 0 / (sqrt(0) + eps) = 0 → param unchanged.
712        assert!((groups[0].params[0] - initial).abs() < 1e-12);
713    }
714
715    // ── AdaGrad ───────────────────────────────────────────────────────────────
716
717    #[test]
718    fn test_adagrad_step_lowers_param_for_positive_grad() {
719        let mut opt = adagrad_opt();
720        let mut groups = vec![simple_group("w", 2.0, 1.0)];
721        opt.step(&mut groups).expect("test: should succeed");
722        assert!(groups[0].params[0] < 2.0);
723    }
724
725    #[test]
726    fn test_adagrad_accumulates_squared_grad_in_v() {
727        let mut opt = adagrad_opt();
728        let mut groups = vec![simple_group("a", 0.0, 3.0)];
729        opt.step(&mut groups).expect("test: should succeed");
730        // v[0] should be 3^2 = 9.
731        assert!((opt.states["a"].v[0] - 9.0).abs() < 1e-10);
732    }
733
734    #[test]
735    fn test_adagrad_large_gradient_decays_lr() {
736        // After many steps the effective lr → 0.
737        let mut opt = adagrad_opt();
738        let mut groups = vec![simple_group("w", 1.0, 100.0)];
739        let p_after_1 = {
740            opt.step(&mut groups).expect("test: should succeed");
741            groups[0].params[0]
742        };
743        // Reset param but keep state (simulating large cumulative gradient).
744        groups[0].params[0] = 1.0;
745        groups[0].grad[0] = 100.0;
746        opt.step(&mut groups).expect("test: should succeed");
747        let p_after_2 = groups[0].params[0];
748        // The second step should move the parameter less than the first
749        // because v is larger.
750        let delta1 = (1.0 - p_after_1).abs();
751        let delta2 = (1.0 - p_after_2).abs();
752        assert!(delta2 < delta1);
753    }
754
755    // ── RMSProp ───────────────────────────────────────────────────────────────
756
757    #[test]
758    fn test_rmsprop_step_moves_param() {
759        let mut opt = rmsprop_opt();
760        let mut groups = vec![simple_group("w", 1.0, 1.0)];
761        let before = groups[0].params[0];
762        opt.step(&mut groups).expect("test: should succeed");
763        assert_ne!(groups[0].params[0], before);
764    }
765
766    #[test]
767    fn test_rmsprop_with_momentum() {
768        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::RmsProp {
769            lr: 0.01,
770            alpha: 0.99,
771            epsilon: 1e-8,
772            momentum: 0.9,
773        });
774        let mut groups = vec![simple_group("w", 1.0, 1.0)];
775        opt.step(&mut groups).expect("test: should succeed");
776        // m should be non-zero because momentum is active.
777        assert_ne!(opt.states["w"].m[0], 0.0);
778    }
779
780    #[test]
781    fn test_rmsprop_v_decays_toward_zero_on_zero_grad() {
782        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::RmsProp {
783            lr: 0.01,
784            alpha: 0.9,
785            epsilon: 1e-8,
786            momentum: 0.0,
787        });
788        // Warm up v with a non-zero gradient.
789        let mut groups = vec![simple_group("w", 1.0, 1.0)];
790        opt.step(&mut groups).expect("test: should succeed");
791        let v_after_1 = opt.states["w"].v[0];
792
793        // Now apply zero gradient — v should decay.
794        groups[0].grad[0] = 0.0;
795        opt.step(&mut groups).expect("test: should succeed");
796        let v_after_2 = opt.states["w"].v[0];
797        assert!(v_after_2 < v_after_1);
798    }
799
800    // ── AdamW ─────────────────────────────────────────────────────────────────
801
802    #[test]
803    fn test_adamw_applies_weight_decay() {
804        // Compare AdamW with wd > 0 vs Adam (wd=0) on same initial state.
805        let mut opt_adamw = adamw_opt(); // wd = 0.01
806        let mut opt_adam = AdaptiveOptimizer::new(OptimizerAlgorithm::Adam {
807            lr: 0.001,
808            beta1: 0.9,
809            beta2: 0.999,
810            epsilon: 1e-8,
811        });
812
813        let init_param = 2.0_f64;
814        let grad_val = 0.1_f64;
815
816        let mut groups_wd = vec![simple_group("p", init_param, grad_val)];
817        let mut groups_no_wd = vec![simple_group("p", init_param, grad_val)];
818
819        opt_adamw
820            .step(&mut groups_wd)
821            .expect("test: should succeed");
822        opt_adam
823            .step(&mut groups_no_wd)
824            .expect("test: should succeed");
825
826        // Weight decay shrinks the parameter more.
827        assert!(groups_wd[0].params[0] < groups_no_wd[0].params[0]);
828    }
829
830    #[test]
831    fn test_adamw_zero_weight_decay_equals_adam() {
832        let mut opt_adamw = AdaptiveOptimizer::new(OptimizerAlgorithm::AdamW {
833            lr: 0.001,
834            beta1: 0.9,
835            beta2: 0.999,
836            epsilon: 1e-8,
837            weight_decay: 0.0,
838        });
839        let mut opt_adam = AdaptiveOptimizer::new(OptimizerAlgorithm::Adam {
840            lr: 0.001,
841            beta1: 0.9,
842            beta2: 0.999,
843            epsilon: 1e-8,
844        });
845
846        let mut g1 = vec![simple_group("w", 1.0, 0.5)];
847        let mut g2 = vec![simple_group("w", 1.0, 0.5)];
848
849        opt_adamw.step(&mut g1).expect("test: should succeed");
850        opt_adam.step(&mut g2).expect("test: should succeed");
851
852        let diff = (g1[0].params[0] - g2[0].params[0]).abs();
853        assert!(diff < 1e-14, "expected Adam≈AdamW(wd=0), diff={diff}");
854    }
855
856    // ── gradient utilities ────────────────────────────────────────────────────
857
858    #[test]
859    fn test_global_grad_norm_single_value() {
860        let groups = vec![simple_group("w", 0.0, 3.0)];
861        let norm = AdaptiveOptimizer::global_grad_norm(&groups);
862        assert!((norm - 3.0).abs() < 1e-10);
863    }
864
865    #[test]
866    fn test_global_grad_norm_two_groups() {
867        let groups = vec![simple_group("a", 0.0, 3.0), simple_group("b", 0.0, 4.0)];
868        let norm = AdaptiveOptimizer::global_grad_norm(&groups);
869        // sqrt(9 + 16) = 5
870        assert!((norm - 5.0).abs() < 1e-10);
871    }
872
873    #[test]
874    fn test_global_grad_norm_zero_gradients() {
875        let groups = vec![ParameterGroup::new("w", vec![1.0, 2.0])];
876        let norm = AdaptiveOptimizer::global_grad_norm(&groups);
877        assert_eq!(norm, 0.0);
878    }
879
880    #[test]
881    fn test_clip_grad_norm_scales_down() {
882        let mut groups = vec![simple_group("a", 0.0, 3.0), simple_group("b", 0.0, 4.0)];
883        AdaptiveOptimizer::clip_grad_norm(&mut groups, 1.0);
884        let new_norm = AdaptiveOptimizer::global_grad_norm(&groups);
885        assert!((new_norm - 1.0).abs() < 1e-10);
886    }
887
888    #[test]
889    fn test_clip_grad_norm_no_op_when_below_max() {
890        let mut groups = vec![simple_group("w", 0.0, 0.3)];
891        AdaptiveOptimizer::clip_grad_norm(&mut groups, 5.0);
892        assert!((groups[0].grad[0] - 0.3).abs() < 1e-14);
893    }
894
895    #[test]
896    fn test_clip_grad_norm_preserves_direction() {
897        let mut groups = vec![ParameterGroup::with_grad(
898            "w",
899            vec![0.0, 0.0],
900            vec![3.0, 4.0],
901        )];
902        AdaptiveOptimizer::clip_grad_norm(&mut groups, 1.0);
903        // Ratio should be preserved.
904        let ratio = groups[0].grad[0] / groups[0].grad[1];
905        assert!((ratio - 0.75).abs() < 1e-10, "ratio={ratio}");
906    }
907
908    #[test]
909    fn test_zero_grad_clears_all() {
910        let mut groups = vec![simple_group("a", 1.0, 2.0), simple_group("b", 3.0, 4.0)];
911        AdaptiveOptimizer::zero_grad(&mut groups);
912        for g in &groups {
913            for &v in &g.grad {
914                assert_eq!(v, 0.0);
915            }
916        }
917    }
918
919    // ── state management ──────────────────────────────────────────────────────
920
921    #[test]
922    fn test_reset_state_clears_single_group() {
923        let mut opt = adam_opt();
924        let mut groups = vec![simple_group("w", 1.0, 0.5)];
925        opt.step(&mut groups).expect("test: should succeed");
926        assert!(opt.states.contains_key("w"));
927        opt.reset_state("w");
928        assert!(!opt.states.contains_key("w"));
929    }
930
931    #[test]
932    fn test_reset_all_clears_everything() {
933        let mut opt = adam_opt();
934        let mut groups = vec![simple_group("a", 1.0, 0.1), simple_group("b", 2.0, 0.2)];
935        opt.step(&mut groups).expect("test: should succeed");
936        opt.reset_all();
937        assert!(opt.states.is_empty());
938        assert_eq!(opt.global_step, 0);
939    }
940
941    #[test]
942    fn test_reset_state_nonexistent_key_is_noop() {
943        let mut opt = adam_opt();
944        opt.reset_state("nonexistent"); // Should not panic.
945        assert!(opt.states.is_empty());
946    }
947
948    // ── statistics ────────────────────────────────────────────────────────────
949
950    #[test]
951    fn test_stats_initial() {
952        let opt = adam_opt();
953        let groups = vec![
954            ParameterGroup::new("a", vec![1.0, 2.0]),
955            ParameterGroup::new("b", vec![3.0]),
956        ];
957        let s = opt.stats(&groups);
958        assert_eq!(s.total_steps, 0);
959        assert_eq!(s.parameter_groups, 2);
960        assert_eq!(s.total_parameters, 3);
961        assert_eq!(s.last_grad_norm, 0.0);
962    }
963
964    #[test]
965    fn test_stats_after_step() {
966        let mut opt = adam_opt();
967        let mut groups = vec![ParameterGroup::with_grad(
968            "w",
969            vec![1.0, 2.0],
970            vec![3.0, 4.0],
971        )];
972        opt.step(&mut groups).expect("test: should succeed");
973        let s = opt.stats(&groups);
974        assert_eq!(s.total_steps, 1);
975        assert!((s.last_grad_norm - 5.0).abs() < 1e-10);
976    }
977
978    // ── step returns gradient norm ────────────────────────────────────────────
979
980    #[test]
981    fn test_step_returns_correct_grad_norm() {
982        let mut opt = adam_opt();
983        let mut groups = vec![ParameterGroup::with_grad(
984            "w",
985            vec![0.0, 0.0],
986            vec![3.0, 4.0],
987        )];
988        let norm = opt.step(&mut groups).expect("test: should succeed");
989        assert!((norm - 5.0).abs() < 1e-10);
990    }
991
992    #[test]
993    fn test_step_returns_zero_norm_for_zero_grads() {
994        let mut opt = adam_opt();
995        let mut groups = vec![ParameterGroup::new("w", vec![1.0, 2.0])];
996        let norm = opt.step(&mut groups).expect("test: should succeed");
997        assert_eq!(norm, 0.0);
998    }
999
1000    // ── multi-group steps ─────────────────────────────────────────────────────
1001
1002    #[test]
1003    fn test_multiple_groups_each_have_independent_state() {
1004        let mut opt = adam_opt();
1005        let mut groups = vec![
1006            simple_group("layer1", 1.0, 0.1),
1007            simple_group("layer2", -1.0, -0.1),
1008        ];
1009        opt.step(&mut groups).expect("test: should succeed");
1010        // Both groups should have their own state.
1011        assert!(opt.states.contains_key("layer1"));
1012        assert!(opt.states.contains_key("layer2"));
1013    }
1014
1015    #[test]
1016    fn test_step_group_individually_matches_bulk_step() {
1017        // Run both paths on the same initial conditions and verify they agree.
1018        let mut opt_bulk = adam_opt();
1019        let mut opt_single = adam_opt();
1020
1021        let mut groups_bulk = vec![simple_group("w1", 1.0, 0.5), simple_group("w2", -0.5, -0.3)];
1022        let mut groups_single = groups_bulk.clone();
1023
1024        opt_bulk
1025            .step(&mut groups_bulk)
1026            .expect("test: should succeed");
1027        for g in groups_single.iter_mut() {
1028            opt_single.step_group(g).expect("test: should succeed");
1029        }
1030
1031        for (gb, gs) in groups_bulk.iter().zip(groups_single.iter()) {
1032            let diff = (gb.params[0] - gs.params[0]).abs();
1033            assert!(diff < 1e-14, "param mismatch for {}: {diff}", gb.name);
1034        }
1035    }
1036
1037    // ── convergence smoke test ────────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_adam_converges_simple_quadratic() {
1041        // Minimise f(x) = x^2/2 ⟹ grad = x, optimum at x=0.
1042        // Use a higher learning rate and enough steps to converge reliably.
1043        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::Adam {
1044            lr: 0.1,
1045            beta1: 0.9,
1046            beta2: 0.999,
1047            epsilon: 1e-8,
1048        });
1049        let mut groups = vec![ParameterGroup::new("x", vec![5.0])];
1050        for _ in 0..2000 {
1051            groups[0].grad[0] = groups[0].params[0]; // grad of x²/2
1052            opt.step(&mut groups).expect("test: should succeed");
1053        }
1054        assert!(
1055            groups[0].params[0].abs() < 0.01,
1056            "did not converge: x={}",
1057            groups[0].params[0]
1058        );
1059    }
1060
1061    #[test]
1062    fn test_adagrad_converges_simple_quadratic() {
1063        // AdaGrad with a larger initial lr converges for a quadratic.
1064        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::AdaGrad {
1065            lr: 1.0,
1066            epsilon: 1e-8,
1067        });
1068        let mut groups = vec![ParameterGroup::new("x", vec![3.0])];
1069        for _ in 0..500 {
1070            groups[0].grad[0] = groups[0].params[0];
1071            opt.step(&mut groups).expect("test: should succeed");
1072        }
1073        assert!(
1074            groups[0].params[0].abs() < 0.1,
1075            "did not converge: x={}",
1076            groups[0].params[0]
1077        );
1078    }
1079
1080    #[test]
1081    fn test_rmsprop_converges_simple_quadratic() {
1082        let mut opt = rmsprop_opt();
1083        let mut groups = vec![ParameterGroup::new("x", vec![3.0])];
1084        for _ in 0..3000 {
1085            groups[0].grad[0] = groups[0].params[0];
1086            opt.step(&mut groups).expect("test: should succeed");
1087        }
1088        assert!(
1089            groups[0].params[0].abs() < 0.1,
1090            "did not converge: x={}",
1091            groups[0].params[0]
1092        );
1093    }
1094
1095    #[test]
1096    fn test_adamw_converges_simple_quadratic() {
1097        let mut opt = AdaptiveOptimizer::new(OptimizerAlgorithm::AdamW {
1098            lr: 0.01,
1099            beta1: 0.9,
1100            beta2: 0.999,
1101            epsilon: 1e-8,
1102            weight_decay: 0.001,
1103        });
1104        let mut groups = vec![ParameterGroup::new("x", vec![3.0])];
1105        for _ in 0..5000 {
1106            groups[0].grad[0] = groups[0].params[0];
1107            opt.step(&mut groups).expect("test: should succeed");
1108        }
1109        assert!(
1110            groups[0].params[0].abs() < 0.1,
1111            "did not converge: x={}",
1112            groups[0].params[0]
1113        );
1114    }
1115
1116    // ── default constructors ──────────────────────────────────────────────────
1117
1118    #[test]
1119    fn test_algorithm_default_constructors() {
1120        let adam = OptimizerAlgorithm::adam_default();
1121        assert!(matches!(adam, OptimizerAlgorithm::Adam { lr, .. } if (lr - 0.001).abs() < 1e-15));
1122
1123        let adagrad = OptimizerAlgorithm::adagrad_default();
1124        assert!(
1125            matches!(adagrad, OptimizerAlgorithm::AdaGrad { lr, .. } if (lr - 0.01).abs() < 1e-15)
1126        );
1127
1128        let rmsprop = OptimizerAlgorithm::rmsprop_default();
1129        assert!(
1130            matches!(rmsprop, OptimizerAlgorithm::RmsProp { lr, .. } if (lr - 0.01).abs() < 1e-15)
1131        );
1132
1133        let adamw = OptimizerAlgorithm::adamw_default();
1134        assert!(
1135            matches!(adamw, OptimizerAlgorithm::AdamW { weight_decay, .. } if (weight_decay - 0.01).abs() < 1e-15)
1136        );
1137    }
1138
1139    // ── lazy state initialisation ─────────────────────────────────────────────
1140
1141    #[test]
1142    fn test_state_lazily_initialised_on_first_step() {
1143        let mut opt = adam_opt();
1144        assert!(opt.states.is_empty());
1145        let mut groups = vec![simple_group("w", 0.0, 1.0)];
1146        opt.step(&mut groups).expect("test: should succeed");
1147        assert!(opt.states.contains_key("w"));
1148    }
1149
1150    // ── error message content ─────────────────────────────────────────────────
1151
1152    #[test]
1153    fn test_dimension_mismatch_error_contains_name() {
1154        let mut opt = adam_opt();
1155        let mut groups = vec![ParameterGroup {
1156            name: "my_layer".to_string(),
1157            params: vec![1.0],
1158            grad: vec![0.1, 0.2],
1159        }];
1160        let err = opt.step(&mut groups).unwrap_err();
1161        let msg = err.to_string();
1162        assert!(msg.contains("my_layer"), "error message: {msg}");
1163    }
1164
1165    #[test]
1166    fn test_empty_group_error_contains_name() {
1167        let mut opt = adam_opt();
1168        let mut groups = vec![ParameterGroup {
1169            name: "empty_layer".to_string(),
1170            params: vec![],
1171            grad: vec![],
1172        }];
1173        let err = opt.step(&mut groups).unwrap_err();
1174        let msg = err.to_string();
1175        assert!(msg.contains("empty_layer"), "error message: {msg}");
1176    }
1177
1178    // ── clone / debug ─────────────────────────────────────────────────────────
1179
1180    #[test]
1181    fn test_optimizer_clone_is_independent() {
1182        let mut opt = adam_opt();
1183        let mut groups = vec![simple_group("w", 1.0, 0.5)];
1184        opt.step(&mut groups).expect("test: should succeed");
1185        let mut opt2 = opt.clone();
1186        opt2.reset_all();
1187        // Original should be unaffected.
1188        assert_eq!(opt.global_step, 1);
1189    }
1190
1191    #[test]
1192    fn test_optimizer_debug_does_not_panic() {
1193        let opt = adam_opt();
1194        let _ = format!("{opt:?}");
1195    }
1196}