Skip to main content

trustformers_optim/came/
mod.rs

1//! # CAME Optimizer Module
2//!
3//! Contains both the original CAME implementation and the new advanced
4//! CAME optimizer with factored second-moment estimation and confidence guidance.
5
6// reason: research-stage module — reserved API/scaffolding fields and methods
7// retained intentionally for in-progress features; not yet on active call paths.
8#![allow(dead_code)]
9
10pub mod legacy;
11
12pub use legacy::{CAMEConfig, CAME};
13
14// New advanced CAME implementation as specified in Wave 15 Workstream BB
15
16use trustformers_core::errors::TrustformersError;
17
18/// Error type for the advanced optimizer implementations.
19#[derive(Debug, thiserror::Error)]
20pub enum OptimError {
21    /// Parameter and gradient length mismatch.
22    #[error("length mismatch: param length {param} != grad length {grad}")]
23    LengthMismatch { param: usize, grad: usize },
24    /// Row/col dimensions inconsistent with total size.
25    #[error("dimension mismatch: rows * cols ({rows} * {cols} = {product}) != size {size}")]
26    DimensionMismatch {
27        rows: usize,
28        cols: usize,
29        product: usize,
30        size: usize,
31    },
32    /// State not initialised for a parameter group index.
33    #[error("no state initialised for parameter group index {0}")]
34    StateNotInitialised(usize),
35    /// Unexpected numerical issue (NaN/Inf).
36    #[error("numerical error: {0}")]
37    NumericalError(String),
38}
39
40impl From<OptimError> for TrustformersError {
41    fn from(e: OptimError) -> Self {
42        TrustformersError::invalid_operation(e.to_string())
43    }
44}
45
46/// Configuration for the advanced CAME optimizer (Luo et al., 2023).
47///
48/// Reference: "CAME: Confidence-guided Adaptive Memory Efficient Optimization"
49#[derive(Debug, Clone)]
50pub struct CameConfig {
51    /// Learning rate (default 2e-4).
52    pub lr: f64,
53    /// (β1, β2, β3) — momentum, RMS, confidence decay rates.
54    /// Default: (0.9, 0.999, 0.9999).
55    pub betas: (f64, f64, f64),
56    /// (ε1, ε2) — numerical stability constants.
57    /// Default: (1e-30, 1e-16).
58    pub eps: (f64, f64),
59    /// Decoupled weight decay (default 0.0).
60    pub weight_decay: f64,
61    /// RMS gradient clipping threshold (default 1.0).
62    pub clip_threshold: f64,
63    /// Exponent for second-moment decay schedule: β2_t = min(1 − t^decay_rate, β2).
64    /// Default: -0.8.
65    pub decay_rate: f64,
66}
67
68impl Default for CameConfig {
69    fn default() -> Self {
70        Self {
71            lr: 2e-4,
72            betas: (0.9, 0.999, 0.9999),
73            eps: (1e-30, 1e-16),
74            weight_decay: 0.0,
75            clip_threshold: 1.0,
76            decay_rate: -0.8,
77        }
78    }
79}
80
81/// Per-parameter optimizer state for the advanced CAME optimizer.
82#[derive(Debug, Clone)]
83pub struct CameParamState {
84    /// Number of update steps taken.
85    pub step: u64,
86    /// Exponential moving average of gradients (first moment).
87    pub exp_avg: Vec<f32>,
88    /// Factored second moment — row factor `[rows]`.
89    pub exp_avg_sq_row: Vec<f32>,
90    /// Factored second moment — column factor `[cols]`.
91    pub exp_avg_sq_col: Vec<f32>,
92    /// Full second moment for 1-D parameters (`None` for 2-D params).
93    pub exp_avg_sq: Option<Vec<f32>>,
94    /// Instantaneous second-moment row factor (for confidence estimation).
95    pub exp_avg_insta_sq_row: Vec<f32>,
96    /// Instantaneous second-moment column factor (for confidence estimation).
97    pub exp_avg_insta_sq_col: Vec<f32>,
98}
99
100impl CameParamState {
101    /// Create a zeroed state for a 2-D parameter with the given dimensions.
102    pub fn new_2d(size: usize, rows: usize, cols: usize) -> Self {
103        Self {
104            step: 0,
105            exp_avg: vec![0.0_f32; size],
106            exp_avg_sq_row: vec![0.0_f32; rows],
107            exp_avg_sq_col: vec![0.0_f32; cols],
108            exp_avg_sq: None,
109            exp_avg_insta_sq_row: vec![0.0_f32; rows],
110            exp_avg_insta_sq_col: vec![0.0_f32; cols],
111        }
112    }
113
114    /// Create a zeroed state for a 1-D parameter.
115    pub fn new_1d(size: usize) -> Self {
116        Self {
117            step: 0,
118            exp_avg: vec![0.0_f32; size],
119            exp_avg_sq_row: Vec::new(),
120            exp_avg_sq_col: Vec::new(),
121            exp_avg_sq: Some(vec![0.0_f32; size]),
122            exp_avg_insta_sq_row: Vec::new(),
123            exp_avg_insta_sq_col: Vec::new(),
124        }
125    }
126}
127
128/// Compute the Root-Mean-Square of `v`.
129#[inline]
130fn rms(v: &[f32]) -> f32 {
131    if v.is_empty() {
132        return 0.0;
133    }
134    let sq_sum: f32 = v.iter().map(|x| x * x).sum();
135    (sq_sum / v.len() as f32).sqrt()
136}
137
138/// Perform one CAME update step for a single parameter group.
139///
140/// # Arguments
141///
142/// * `param`  – mutable slice of parameter values (length = `rows * cols`).
143/// * `grad`   – gradient slice (same length).
144/// * `state`  – mutable per-parameter state.
145/// * `config` – optimizer configuration.
146/// * `rows`   – matrix row count (set to 1 for 1-D parameters).
147/// * `cols`   – matrix column count (= `param.len()` for 1-D parameters).
148///
149/// # Errors
150///
151/// Returns [`OptimError`] on dimension mismatches or numerical issues.
152pub fn came_update(
153    param: &mut [f32],
154    grad: &[f32],
155    state: &mut CameParamState,
156    config: &CameConfig,
157    rows: usize,
158    cols: usize,
159) -> Result<(), OptimError> {
160    // --- Validate dimensions ------------------------------------------------
161    let size = param.len();
162    if grad.len() != size {
163        return Err(OptimError::LengthMismatch {
164            param: size,
165            grad: grad.len(),
166        });
167    }
168    let expected = rows * cols;
169    if expected != size {
170        return Err(OptimError::DimensionMismatch {
171            rows,
172            cols,
173            product: expected,
174            size,
175        });
176    }
177
178    // --- Step counter -------------------------------------------------------
179    state.step += 1;
180    let step = state.step as f64;
181
182    // --- Dynamic β2_t -------------------------------------------------------
183    // β2_t = min(1 - step^decay_rate, β2)
184    let beta2_t = (1.0 - step.powf(config.decay_rate)).min(config.betas.1) as f32;
185
186    let beta1 = config.betas.0 as f32;
187    let beta3 = config.betas.2 as f32;
188    let eps1 = config.eps.0 as f32;
189    let eps2 = config.eps.1 as f32;
190
191    // --- RMS gradient clip --------------------------------------------------
192    let grad_rms = rms(grad);
193    let clip_scale = if grad_rms > config.clip_threshold as f32 {
194        config.clip_threshold as f32 / (grad_rms + eps1)
195    } else {
196        1.0
197    };
198
199    // Lazily clipped gradient (we avoid a heap allocation by applying the
200    // scale inline in the loops below).
201
202    // --- First moment update ------------------------------------------------
203    for (m, &g) in state.exp_avg.iter_mut().zip(grad.iter()) {
204        let g_clipped = g * clip_scale;
205        *m = beta1 * *m + (1.0 - beta1) * g_clipped;
206    }
207
208    // --- Second-moment and confidence update --------------------------------
209    if rows == 1 {
210        // ---- 1-D path: full second moment -----------------------------------
211        let sq = state
212            .exp_avg_sq
213            .as_mut()
214            .ok_or_else(|| OptimError::NumericalError("1-D state missing exp_avg_sq".into()))?;
215        for (s, &g) in sq.iter_mut().zip(grad.iter()) {
216            let g_clipped = g * clip_scale;
217            *s = beta2_t * *s + (1.0 - beta2_t) * (g_clipped * g_clipped + eps1);
218        }
219
220        // Parameter update
221        for ((p, &m), &s) in param.iter_mut().zip(state.exp_avg.iter()).zip(sq.iter()) {
222            let denom = s.sqrt() + eps2;
223            let update = m / denom;
224            if config.weight_decay != 0.0 {
225                *p -= config.lr as f32 * config.weight_decay as f32 * *p;
226            }
227            *p -= config.lr as f32 * update;
228        }
229    } else {
230        // ---- 2-D path: factored second moment + confidence ------------------
231        // grad² row-means and col-means
232        let mut row_mean = vec![0.0_f32; rows];
233        let mut col_mean = vec![0.0_f32; cols];
234
235        for i in 0..rows {
236            let mut s = 0.0_f32;
237            for j in 0..cols {
238                let g = grad[i * cols + j] * clip_scale;
239                s += g * g;
240            }
241            row_mean[i] = s / cols as f32 + eps1;
242        }
243        for j in 0..cols {
244            let mut s = 0.0_f32;
245            for i in 0..rows {
246                let g = grad[i * cols + j] * clip_scale;
247                s += g * g;
248            }
249            col_mean[j] = s / rows as f32 + eps1;
250        }
251
252        // Smoothed second-moment factors
253        for (r, &rm) in state.exp_avg_sq_row.iter_mut().zip(row_mean.iter()) {
254            *r = beta2_t * *r + (1.0 - beta2_t) * rm;
255        }
256        for (c, &cm) in state.exp_avg_sq_col.iter_mut().zip(col_mean.iter()) {
257            *c = beta2_t * *c + (1.0 - beta2_t) * cm;
258        }
259
260        // Instantaneous second-moment factors (for confidence), use β3
261        for (r, &rm) in state.exp_avg_insta_sq_row.iter_mut().zip(row_mean.iter()) {
262            *r = beta3 * *r + (1.0 - beta3) * rm;
263        }
264        for (c, &cm) in state.exp_avg_insta_sq_col.iter_mut().zip(col_mean.iter()) {
265            *c = beta3 * *c + (1.0 - beta3) * cm;
266        }
267
268        // Compute R = mean of smoothed row factors (used to normalize outer-product)
269        let row_sum: f32 = state.exp_avg_sq_row.iter().sum();
270        let row_normaliser = (row_sum / rows as f32).max(eps1);
271
272        // Parameter update with confidence weighting
273        for i in 0..rows {
274            let smoothed_row = state.exp_avg_sq_row[i];
275            let insta_row = state.exp_avg_insta_sq_row[i];
276
277            for j in 0..cols {
278                let smoothed_col = state.exp_avg_sq_col[j];
279                let insta_col = state.exp_avg_insta_sq_col[j];
280
281                // RMS estimate from factored moments
282                let v_approx = (smoothed_row * smoothed_col / row_normaliser).sqrt();
283
284                // Confidence weight: ratio of smoothed vs instantaneous
285                let smoothed_insta_row = (insta_row * insta_col / row_normaliser).sqrt();
286                let confidence = if smoothed_insta_row > eps1 {
287                    (v_approx / (smoothed_insta_row + eps2)).min(1.0_f32)
288                } else {
289                    1.0_f32
290                };
291
292                let denom = v_approx + eps2;
293                let idx = i * cols + j;
294                let m = state.exp_avg[idx];
295                let update = confidence * m / denom;
296
297                let p = &mut param[idx];
298                if config.weight_decay != 0.0 {
299                    *p -= config.lr as f32 * config.weight_decay as f32 * *p;
300                }
301                *p -= config.lr as f32 * update;
302            }
303        }
304    }
305
306    Ok(())
307}
308
309/// Per-parameter group descriptor stored alongside the state.
310#[derive(Debug, Clone)]
311struct ParamGroupMeta {
312    size: usize,
313    rows: usize,
314    cols: usize,
315}
316
317/// Advanced CAME optimizer (factored second-moment + confidence guidance).
318///
319/// Reference: "CAME: Confidence-guided Adaptive Memory Efficient Optimization"
320/// (Luo et al., 2023)
321#[derive(Debug)]
322pub struct CameOptimizer {
323    /// Hyperparameter configuration.
324    pub config: CameConfig,
325    /// Per-parameter states.
326    pub states: Vec<CameParamState>,
327    /// Metadata (size/rows/cols) for each parameter group.
328    meta: Vec<ParamGroupMeta>,
329}
330
331impl CameOptimizer {
332    /// Create a new optimizer with the given configuration.
333    pub fn new(config: CameConfig) -> Self {
334        Self {
335            config,
336            states: Vec::new(),
337            meta: Vec::new(),
338        }
339    }
340
341    /// Register a parameter group and initialise its state.
342    ///
343    /// For 2-D matrices set `rows` and `cols` appropriately.
344    /// For 1-D tensors use `rows = 1` and `cols = param_size`.
345    pub fn add_param_group(&mut self, param_size: usize, rows: usize, cols: usize) {
346        let state = if rows == 1 {
347            CameParamState::new_1d(param_size)
348        } else {
349            CameParamState::new_2d(param_size, rows, cols)
350        };
351        self.states.push(state);
352        self.meta.push(ParamGroupMeta {
353            size: param_size,
354            rows,
355            cols,
356        });
357    }
358
359    /// Perform one update step across all parameter groups.
360    ///
361    /// # Arguments
362    ///
363    /// * `params` – mutable reference to all parameter vectors (one per group).
364    /// * `grads`  – gradient vectors (same order as `params`).
365    ///
366    /// # Errors
367    ///
368    /// Returns [`OptimError`] on any dimension mismatch.
369    pub fn step(&mut self, params: &mut [Vec<f32>], grads: &[Vec<f32>]) -> Result<(), OptimError> {
370        for (idx, ((param, grad), state)) in
371            params.iter_mut().zip(grads.iter()).zip(self.states.iter_mut()).enumerate()
372        {
373            let meta = self.meta.get(idx).ok_or(OptimError::StateNotInitialised(idx))?;
374            came_update(param, grad, state, &self.config, meta.rows, meta.cols)?;
375        }
376        Ok(())
377    }
378}
379
380// ---------------------------------------------------------------------------
381// Tests
382// ---------------------------------------------------------------------------
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use approx::assert_relative_eq;
388
389    // -----------------------------------------------------------------------
390    // 1. Config defaults
391    // -----------------------------------------------------------------------
392    #[test]
393    fn test_came_config_defaults() {
394        let cfg = CameConfig::default();
395        assert_relative_eq!(cfg.lr, 2e-4);
396        assert_relative_eq!(cfg.betas.0, 0.9);
397        assert_relative_eq!(cfg.betas.1, 0.999);
398        assert_relative_eq!(cfg.betas.2, 0.9999);
399        assert_relative_eq!(cfg.eps.0, 1e-30);
400        assert_relative_eq!(cfg.eps.1, 1e-16);
401        assert_relative_eq!(cfg.weight_decay, 0.0);
402        assert_relative_eq!(cfg.clip_threshold, 1.0);
403        assert_relative_eq!(cfg.decay_rate, -0.8);
404    }
405
406    // -----------------------------------------------------------------------
407    // 2. State initialisation — 2-D
408    // -----------------------------------------------------------------------
409    #[test]
410    fn test_state_init_2d() {
411        let state = CameParamState::new_2d(6, 2, 3);
412        assert_eq!(state.step, 0);
413        assert_eq!(state.exp_avg.len(), 6);
414        assert_eq!(state.exp_avg_sq_row.len(), 2);
415        assert_eq!(state.exp_avg_sq_col.len(), 3);
416        assert!(state.exp_avg_sq.is_none());
417        assert_eq!(state.exp_avg_insta_sq_row.len(), 2);
418        assert_eq!(state.exp_avg_insta_sq_col.len(), 3);
419        assert!(state.exp_avg.iter().all(|&x| x == 0.0));
420    }
421
422    // -----------------------------------------------------------------------
423    // 3. State initialisation — 1-D
424    // -----------------------------------------------------------------------
425    #[test]
426    fn test_state_init_1d() {
427        let state = CameParamState::new_1d(5);
428        assert_eq!(state.step, 0);
429        assert_eq!(state.exp_avg.len(), 5);
430        assert!(state.exp_avg_sq_row.is_empty());
431        assert!(state.exp_avg_sq_col.is_empty());
432        assert!(state.exp_avg_sq.is_some());
433        assert_eq!(state.exp_avg_sq.as_ref().map(|v| v.len()), Some(5));
434    }
435
436    // -----------------------------------------------------------------------
437    // 4. Step counter increments
438    // -----------------------------------------------------------------------
439    #[test]
440    fn test_step_counter() {
441        let cfg = CameConfig::default();
442        let mut state = CameParamState::new_1d(2);
443        let mut param = vec![1.0_f32; 2];
444        let grad = vec![0.1_f32; 2];
445
446        came_update(&mut param, &grad, &mut state, &cfg, 1, 2).expect("update failed");
447        assert_eq!(state.step, 1);
448        came_update(&mut param, &grad, &mut state, &cfg, 1, 2).expect("update failed");
449        assert_eq!(state.step, 2);
450    }
451
452    // -----------------------------------------------------------------------
453    // 5. Factored second moment update (2-D)
454    // -----------------------------------------------------------------------
455    #[test]
456    fn test_factored_second_moment_update() {
457        let cfg = CameConfig {
458            lr: 0.0,
459            ..CameConfig::default()
460        };
461        let rows = 2_usize;
462        let cols = 3_usize;
463        let size = rows * cols;
464        let mut state = CameParamState::new_2d(size, rows, cols);
465        let mut param = vec![0.0_f32; size];
466        let grad = vec![1.0_f32; size];
467
468        // After step 1 all row/col factors must be positive
469        came_update(&mut param, &grad, &mut state, &cfg, rows, cols).expect("update failed");
470        assert!(state.exp_avg_sq_row.iter().all(|&x| x > 0.0));
471        assert!(state.exp_avg_sq_col.iter().all(|&x| x > 0.0));
472    }
473
474    // -----------------------------------------------------------------------
475    // 6. Dynamic β2 schedule
476    // -----------------------------------------------------------------------
477    #[test]
478    fn test_dynamic_beta2_schedule() {
479        let cfg = CameConfig::default();
480        // At step 1: beta2_t = min(1 - 1^(-0.8), 0.999) = min(0.0, 0.999) = 0.0
481        let step = 1_f64;
482        let beta2_t = (1.0 - step.powf(cfg.decay_rate)).min(cfg.betas.1);
483        assert_relative_eq!(beta2_t, 0.0, epsilon = 1e-9);
484
485        // At step 100: 1 - 100^(-0.8) ≈ 1 - 0.025 = 0.975 < 0.999, so not capped
486        let step100 = 100_f64;
487        let beta2_100 = (1.0 - step100.powf(cfg.decay_rate)).min(cfg.betas.1);
488        assert!(beta2_100 > 0.9 && beta2_100 < 1.0);
489    }
490
491    // -----------------------------------------------------------------------
492    // 7. Confidence adaptation (insta rows updated with β3)
493    // -----------------------------------------------------------------------
494    #[test]
495    fn test_confidence_adaptation() {
496        let cfg = CameConfig::default();
497        let rows = 2_usize;
498        let cols = 2_usize;
499        let size = rows * cols;
500        let mut state = CameParamState::new_2d(size, rows, cols);
501        let mut param = vec![0.0_f32; size];
502        let grad = vec![1.0_f32; size];
503
504        came_update(&mut param, &grad, &mut state, &cfg, rows, cols).expect("update failed");
505
506        // Instantaneous factors are updated with β3 = 0.9999 — they should be non-zero
507        assert!(state.exp_avg_insta_sq_row.iter().all(|&x| x > 0.0));
508        assert!(state.exp_avg_insta_sq_col.iter().all(|&x| x > 0.0));
509    }
510
511    // -----------------------------------------------------------------------
512    // 8. Weight decay applied
513    // -----------------------------------------------------------------------
514    #[test]
515    fn test_weight_decay() {
516        let cfg = CameConfig {
517            lr: 1e-1,
518            weight_decay: 0.1,
519            ..CameConfig::default()
520        };
521        let mut state = CameParamState::new_1d(2);
522        let initial_param = vec![1.0_f32; 2];
523        let mut param = initial_param.clone();
524        let grad = vec![0.0_f32; 2]; // zero grad — only weight decay effect
525
526        came_update(&mut param, &grad, &mut state, &cfg, 1, 2).expect("update failed");
527
528        // Parameters must be strictly smaller in absolute value
529        for (p_new, p_old) in param.iter().zip(initial_param.iter()) {
530            assert!(
531                p_new.abs() < p_old.abs(),
532                "weight decay did not reduce param"
533            );
534        }
535    }
536
537    // -----------------------------------------------------------------------
538    // 9. Single-step update moves in the right direction
539    // -----------------------------------------------------------------------
540    #[test]
541    fn test_single_step_direction() {
542        let cfg = CameConfig::default();
543        let mut state = CameParamState::new_1d(3);
544        let mut param = vec![0.5_f32; 3];
545        let grad = vec![0.1_f32; 3]; // positive gradient
546
547        let param_before = param.clone();
548        came_update(&mut param, &grad, &mut state, &cfg, 1, 3).expect("update failed");
549
550        // With positive gradient, parameters should decrease
551        for (p_new, p_old) in param.iter().zip(param_before.iter()) {
552            assert!(
553                p_new < p_old,
554                "param did not decrease with positive gradient"
555            );
556        }
557    }
558
559    // -----------------------------------------------------------------------
560    // 10. Gradient clipping — first moment is smaller under aggressive clip
561    // -----------------------------------------------------------------------
562    #[test]
563    fn test_gradient_clipping() {
564        // The clip_scale = clip_threshold / (rms(grad) + eps1) when rms > threshold.
565        // With a large gradient the clipped first moment should be smaller than the
566        // unclipped first moment.
567        let cfg_tight = CameConfig {
568            clip_threshold: 0.1,
569            ..CameConfig::default()
570        };
571        let cfg_loose = CameConfig {
572            clip_threshold: 1000.0,
573            ..CameConfig::default()
574        };
575
576        let large_grad = vec![5.0_f32; 4];
577
578        let mut state_tight = CameParamState::new_1d(4);
579        let mut param_tight = vec![0.0_f32; 4];
580        came_update(
581            &mut param_tight,
582            &large_grad,
583            &mut state_tight,
584            &cfg_tight,
585            1,
586            4,
587        )
588        .expect("tight update failed");
589
590        let mut state_loose = CameParamState::new_1d(4);
591        let mut param_loose = vec![0.0_f32; 4];
592        came_update(
593            &mut param_loose,
594            &large_grad,
595            &mut state_loose,
596            &cfg_loose,
597            1,
598            4,
599        )
600        .expect("loose update failed");
601
602        // Under tight clipping the first moment exp_avg values must be smaller in
603        // absolute value because the effective gradient fed into the EMA was scaled down.
604        let m_tight: f32 = state_tight.exp_avg.iter().map(|x| x.abs()).sum();
605        let m_loose: f32 = state_loose.exp_avg.iter().map(|x| x.abs()).sum();
606        assert!(
607            m_tight < m_loose,
608            "tight clipping did not reduce first moment: m_tight={m_tight} m_loose={m_loose}"
609        );
610    }
611
612    // -----------------------------------------------------------------------
613    // 11. Multi-step convergence on a quadratic (1-D)
614    // -----------------------------------------------------------------------
615    #[test]
616    fn test_convergence_quadratic() {
617        // Minimise f(x) = x^2 / 2, gradient = x
618        let cfg = CameConfig {
619            lr: 1e-2,
620            ..CameConfig::default()
621        };
622        let mut state = CameParamState::new_1d(1);
623        let mut param = vec![5.0_f32];
624
625        for _ in 0..2000 {
626            let grad = param.clone(); // gradient of x^2/2 is x
627            came_update(&mut param, &grad, &mut state, &cfg, 1, 1).expect("update failed");
628        }
629
630        assert!(
631            param[0].abs() < 0.1,
632            "CAME did not converge on quadratic: final param = {}",
633            param[0]
634        );
635    }
636
637    // -----------------------------------------------------------------------
638    // 12. Dimension mismatch error returned (not panicked)
639    // -----------------------------------------------------------------------
640    #[test]
641    fn test_dimension_mismatch_error() {
642        let cfg = CameConfig::default();
643        let mut state = CameParamState::new_1d(4);
644        let mut param = vec![0.0_f32; 4];
645        let grad = vec![0.0_f32; 5]; // wrong size
646
647        let result = came_update(&mut param, &grad, &mut state, &cfg, 1, 4);
648        assert!(result.is_err());
649        matches!(result.unwrap_err(), OptimError::LengthMismatch { .. });
650    }
651
652    // -----------------------------------------------------------------------
653    // 13. CameOptimizer multi-param step
654    // -----------------------------------------------------------------------
655    #[test]
656    fn test_came_optimizer_multi_param() {
657        let cfg = CameConfig::default();
658        let mut optimizer = CameOptimizer::new(cfg);
659        optimizer.add_param_group(4, 2, 2);
660        optimizer.add_param_group(3, 1, 3);
661
662        let mut params = vec![vec![1.0_f32; 4], vec![1.0_f32; 3]];
663        let grads = vec![vec![0.1_f32; 4], vec![0.1_f32; 3]];
664
665        optimizer.step(&mut params, &grads).expect("step failed");
666        assert_eq!(optimizer.states[0].step, 1);
667        assert_eq!(optimizer.states[1].step, 1);
668    }
669}
670
671#[cfg(test)]
672mod extended_tests {
673    use super::*;
674    use approx::assert_relative_eq;
675
676    #[test]
677    fn test_came_state_step_zero_at_init() {
678        let state = CameParamState::new_2d(6, 2, 3);
679        assert_eq!(state.step, 0);
680        let state1d = CameParamState::new_1d(4);
681        assert_eq!(state1d.step, 0);
682    }
683
684    #[test]
685    fn test_came_confidence_factors_nonzero_after_step() {
686        let cfg = CameConfig::default();
687        let mut state = CameParamState::new_2d(6, 2, 3);
688        let mut param = vec![0.5_f32; 6];
689        let grad = vec![0.1_f32; 6];
690        came_update(&mut param, &grad, &mut state, &cfg, 2, 3).expect("update failed");
691        assert!(
692            state.exp_avg_insta_sq_row.iter().all(|&x| x > 0.0),
693            "insta_sq_row should be nonzero after update"
694        );
695        assert!(
696            state.exp_avg_insta_sq_col.iter().all(|&x| x > 0.0),
697            "insta_sq_col should be nonzero after update"
698        );
699    }
700
701    #[test]
702    fn test_came_positive_grad_decreases_params() {
703        let cfg = CameConfig::default();
704        let mut state = CameParamState::new_1d(4);
705        let mut param = vec![1.0_f32; 4];
706        let grad = vec![0.5_f32; 4];
707        let before = param.clone();
708        came_update(&mut param, &grad, &mut state, &cfg, 1, 4).expect("update failed");
709        for (p_new, p_old) in param.iter().zip(before.iter()) {
710            assert!(
711                p_new < p_old,
712                "param should decrease with positive gradient"
713            );
714        }
715    }
716
717    #[test]
718    fn test_came_1d_vs_2d_single_element_both_decrease() {
719        let cfg = CameConfig::default();
720        let grad = vec![0.2_f32];
721
722        // 1D path: new_1d, rows=1, cols=1
723        let mut state_1d = CameParamState::new_1d(1);
724        let mut param_1d = vec![1.0_f32];
725        came_update(&mut param_1d, &grad, &mut state_1d, &cfg, 1, 1).expect("1d update failed");
726        assert!(param_1d[0] < 1.0, "1D param should decrease");
727
728        // True 2D path: 2 rows x 2 cols (rows != 1 to take the factored path)
729        let grad_2d = vec![0.2_f32; 4];
730        let mut state_2d = CameParamState::new_2d(4, 2, 2);
731        let mut param_2d = vec![1.0_f32; 4];
732        came_update(&mut param_2d, &grad_2d, &mut state_2d, &cfg, 2, 2).expect("2d update failed");
733        for &p in &param_2d {
734            assert!(p < 1.0, "2D param should decrease");
735        }
736    }
737
738    #[test]
739    fn test_came_weight_decay_larger_shrinks_more() {
740        let grad = vec![0.0_f32; 3];
741
742        let cfg_small = CameConfig {
743            lr: 0.1,
744            weight_decay: 0.01,
745            ..CameConfig::default()
746        };
747        let mut state_small = CameParamState::new_1d(3);
748        let mut param_small = vec![1.0_f32; 3];
749        came_update(&mut param_small, &grad, &mut state_small, &cfg_small, 1, 3)
750            .expect("small wd update failed");
751
752        let cfg_large = CameConfig {
753            lr: 0.1,
754            weight_decay: 0.1,
755            ..CameConfig::default()
756        };
757        let mut state_large = CameParamState::new_1d(3);
758        let mut param_large = vec![1.0_f32; 3];
759        came_update(&mut param_large, &grad, &mut state_large, &cfg_large, 1, 3)
760            .expect("large wd update failed");
761
762        for (ps, pl) in param_small.iter().zip(param_large.iter()) {
763            assert!(
764                ps.abs() > pl.abs(),
765                "larger weight_decay should shrink more: small={ps}, large={pl}"
766            );
767        }
768    }
769
770    #[test]
771    fn test_came_zero_grad_zero_wd_params_unchanged() {
772        let cfg = CameConfig {
773            lr: 0.1,
774            weight_decay: 0.0,
775            ..CameConfig::default()
776        };
777        let mut state = CameParamState::new_1d(3);
778        let mut param = vec![2.0_f32; 3];
779        let original = param.clone();
780        let grad = vec![0.0_f32; 3];
781        came_update(&mut param, &grad, &mut state, &cfg, 1, 3).expect("update failed");
782        for (p_new, p_old) in param.iter().zip(original.iter()) {
783            assert_relative_eq!(*p_new, *p_old, epsilon = 1e-6);
784        }
785    }
786
787    #[test]
788    fn test_came_multiple_steps_move_toward_zero() {
789        let cfg = CameConfig {
790            lr: 1e-2,
791            weight_decay: 0.0,
792            ..CameConfig::default()
793        };
794        let mut state = CameParamState::new_1d(1);
795        let mut param = vec![3.0_f32];
796        for _ in 0..500 {
797            let grad = param.clone();
798            came_update(&mut param, &grad, &mut state, &cfg, 1, 1).expect("update failed");
799        }
800        assert!(
801            param[0].abs() < 3.0,
802            "param should move toward 0 over many steps"
803        );
804    }
805
806    #[test]
807    fn test_came_state_not_initialised_no_panic() {
808        let cfg = CameConfig::default();
809        let mut optimizer = CameOptimizer::new(cfg);
810        // No add_param_group calls — zip with 0 states = 0 iterations, no panic
811        let mut params = vec![vec![1.0_f32; 3]];
812        let grads = vec![vec![0.1_f32; 3]];
813        let result = optimizer.step(&mut params, &grads);
814        // Should not panic; either Ok or Err is acceptable
815        let _ = result;
816    }
817
818    #[test]
819    fn test_came_batch_2d_params_step_count() {
820        let cfg = CameConfig::default();
821        let mut optimizer = CameOptimizer::new(cfg);
822        optimizer.add_param_group(6, 2, 3);
823        optimizer.add_param_group(9, 3, 3);
824        let mut params = vec![vec![0.5_f32; 6], vec![0.5_f32; 9]];
825        let grads = vec![vec![0.1_f32; 6], vec![0.1_f32; 9]];
826        optimizer.step(&mut params, &grads).expect("step failed");
827        assert_eq!(optimizer.states[0].step, 1);
828        assert_eq!(optimizer.states[1].step, 1);
829    }
830
831    #[test]
832    fn test_came_clipping_bounds_param_change() {
833        // Clipping affects the first moment (exp_avg). After step 1:
834        // exp_avg_tight[i] = (1-beta1) * clip_scale * grad[i]  (small clip_scale for tight)
835        // exp_avg_loose[i] = (1-beta1) * 1.0 * grad[i]         (no clipping needed)
836        // We verify by checking that the first moments differ.
837        let large_grad = vec![100.0_f32; 4];
838
839        let cfg_tight = CameConfig {
840            lr: 1.0,
841            clip_threshold: 0.001,
842            weight_decay: 0.0,
843            ..CameConfig::default()
844        };
845        let mut s_tight = CameParamState::new_1d(4);
846        let mut p_tight = vec![0.0_f32; 4];
847        came_update(&mut p_tight, &large_grad, &mut s_tight, &cfg_tight, 1, 4)
848            .expect("tight failed");
849
850        let cfg_loose = CameConfig {
851            lr: 1.0,
852            clip_threshold: 1000.0,
853            weight_decay: 0.0,
854            ..CameConfig::default()
855        };
856        let mut s_loose = CameParamState::new_1d(4);
857        let mut p_loose = vec![0.0_f32; 4];
858        came_update(&mut p_loose, &large_grad, &mut s_loose, &cfg_loose, 1, 4)
859            .expect("loose failed");
860
861        // The tight-clipped first moment should be much smaller in magnitude
862        let m_tight: f32 = s_tight.exp_avg.iter().map(|x| x.abs()).sum();
863        let m_loose: f32 = s_loose.exp_avg.iter().map(|x| x.abs()).sum();
864        assert!(
865            m_tight < m_loose,
866            "tight clipping should reduce first moment: tight={m_tight}, loose={m_loose}"
867        );
868    }
869
870    #[test]
871    fn test_came_2d_factored_memory_efficiency() {
872        let rows = 100_usize;
873        let cols = 200_usize;
874        let size = rows * cols;
875        let state = CameParamState::new_2d(size, rows, cols);
876        let factored_size = state.exp_avg_sq_row.len() + state.exp_avg_sq_col.len();
877        assert!(
878            factored_size < size,
879            "factored memory ({factored_size}) should be less than full size ({size})"
880        );
881    }
882
883    #[test]
884    fn test_came_beta3_effect_on_insta_sq() {
885        let rows = 2_usize;
886        let cols = 2_usize;
887        let grad = vec![1.0_f32; 4];
888
889        let cfg_high = CameConfig {
890            betas: (0.9, 0.999, 0.9999),
891            ..CameConfig::default()
892        };
893        let mut state_high = CameParamState::new_2d(4, rows, cols);
894        let mut param_high = vec![0.5_f32; 4];
895        came_update(
896            &mut param_high,
897            &grad,
898            &mut state_high,
899            &cfg_high,
900            rows,
901            cols,
902        )
903        .expect("high beta3 update failed");
904
905        let cfg_low = CameConfig {
906            betas: (0.9, 0.999, 0.5),
907            ..CameConfig::default()
908        };
909        let mut state_low = CameParamState::new_2d(4, rows, cols);
910        let mut param_low = vec![0.5_f32; 4];
911        came_update(&mut param_low, &grad, &mut state_low, &cfg_low, rows, cols)
912            .expect("low beta3 update failed");
913
914        let sum_high: f32 = state_high.exp_avg_insta_sq_row.iter().sum();
915        let sum_low: f32 = state_low.exp_avg_insta_sq_row.iter().sum();
916        assert!(
917            sum_high < sum_low,
918            "higher β3 should give smaller insta_sq update: high={sum_high}, low={sum_low}"
919        );
920    }
921
922    #[test]
923    fn test_came_three_groups_distinct_states() {
924        let cfg = CameConfig::default();
925        let mut optimizer = CameOptimizer::new(cfg);
926        optimizer.add_param_group(2, 1, 2);
927        optimizer.add_param_group(4, 2, 2);
928        optimizer.add_param_group(6, 2, 3);
929
930        let mut params = vec![vec![1.0_f32; 2], vec![1.0_f32; 4], vec![1.0_f32; 6]];
931        let grads = vec![vec![0.1_f32; 2], vec![0.1_f32; 4], vec![0.1_f32; 6]];
932        optimizer.step(&mut params, &grads).expect("step failed");
933        assert_eq!(optimizer.states[0].step, 1);
934        assert_eq!(optimizer.states[1].step, 1);
935        assert_eq!(optimizer.states[2].step, 1);
936        assert_eq!(optimizer.states[0].exp_avg.len(), 2);
937        assert_eq!(optimizer.states[1].exp_avg.len(), 4);
938        assert_eq!(optimizer.states[2].exp_avg.len(), 6);
939    }
940
941    #[test]
942    fn test_came_lr_scaling_effect() {
943        let grad = vec![0.1_f32; 3];
944
945        let cfg_small_lr = CameConfig {
946            lr: 1e-4,
947            weight_decay: 0.0,
948            ..CameConfig::default()
949        };
950        let mut s_small = CameParamState::new_1d(3);
951        let mut p_small = vec![2.0_f32; 3];
952        came_update(&mut p_small, &grad, &mut s_small, &cfg_small_lr, 1, 3)
953            .expect("small lr failed");
954
955        let cfg_large_lr = CameConfig {
956            lr: 1e-1,
957            weight_decay: 0.0,
958            ..CameConfig::default()
959        };
960        let mut s_large = CameParamState::new_1d(3);
961        let mut p_large = vec![2.0_f32; 3];
962        came_update(&mut p_large, &grad, &mut s_large, &cfg_large_lr, 1, 3)
963            .expect("large lr failed");
964
965        let change_small: f32 = (2.0 - p_small[0]).abs();
966        let change_large: f32 = (2.0 - p_large[0]).abs();
967        assert!(
968            change_large > change_small,
969            "larger lr should produce larger change: small={change_small}, large={change_large}"
970        );
971    }
972
973    #[test]
974    fn test_came_dimension_mismatch_rows_cols_wrong() {
975        let cfg = CameConfig::default();
976        let mut state = CameParamState::new_2d(9, 3, 3);
977        // param has 8 elements but rows*cols=9
978        let mut param = vec![0.0_f32; 8];
979        let grad = vec![0.0_f32; 8];
980        let result = came_update(&mut param, &grad, &mut state, &cfg, 3, 3);
981        assert!(result.is_err(), "should return error on dimension mismatch");
982    }
983
984    #[test]
985    fn test_came_exp_avg_direction_matches_grad() {
986        let cfg = CameConfig::default();
987        let mut state = CameParamState::new_1d(3);
988        let mut param = vec![0.0_f32; 3];
989        let grad = vec![0.5_f32, -0.5_f32, 0.3_f32];
990        came_update(&mut param, &grad, &mut state, &cfg, 1, 3).expect("update failed");
991        assert!(
992            state.exp_avg[0] > 0.0,
993            "positive grad → positive exp_avg[0]"
994        );
995        assert!(
996            state.exp_avg[1] < 0.0,
997            "negative grad → negative exp_avg[1]"
998        );
999        assert!(
1000            state.exp_avg[2] > 0.0,
1001            "positive grad → positive exp_avg[2]"
1002        );
1003    }
1004}