Skip to main content

optirs_gpu/
sparse_optimizer.rs

1//! # Sparse optimizer step (CSR/COO + lazy-state sparse SGD / Adam)
2//!
3//! This module implements **sparse optimizer updates** that touch only the
4//! coordinates with non-zero gradients. It is the CPU reference implementation
5//! of the kind of update used to train very large embedding tables, where on
6//! every step only a handful of the millions of rows actually receive a
7//! gradient. Touching only those coordinates (both for the parameter update and
8//! for the optimizer moment state) is the entire point of "sparse / lazy"
9//! optimizers.
10//!
11//! ## Sparse gradient representations
12//!
13//! * [`CooGradient`] -- a *coordinate list* for a **1-D** parameter / embedding
14//!   row view. It stores the touched `indices`, their gradient `values` and the
15//!   logical dimension `dim` of the dense vector it represents. It is kept in a
16//!   canonical form: indices are strictly ascending (hence sorted and unique).
17//! * [`CsrGradient`] -- *compressed sparse row* for a **2-D** embedding table.
18//!   It stores `row_offsets`, `col_indices` and `values` together with the dense
19//!   `shape`. Within every row the column indices are strictly ascending.
20//!
21//! The two representations convert losslessly to one another: a `CsrGradient`
22//! of shape `(r, c)` corresponds to a `CooGradient` over the flattened
23//! `dim = r * c` view using linear indices `row * c + col`. See
24//! [`CsrGradient::to_coo`] / [`CsrGradient::from_coo`].
25//!
26//! ## Sparse SGD
27//!
28//! [`SparseSgd`] performs `params[i] -= lr * grad[i]` only for the non-zero
29//! coordinates `i`, optionally with (coupled) weight decay and momentum applied
30//! lazily on the touched coordinates. Untouched coordinates are left
31//! bit-identical.
32//!
33//! ## Lazy Adam (the hard part)
34//!
35//! [`SparseAdam`] maintains, per coordinate, the first/second moment estimates
36//! `m[i]`, `v[i]` and `last_step[i]` -- the global optimizer step at which the
37//! coordinate was last updated. Two precisely-defined semantics are supported,
38//! selected through [`LazyAdamMode`]:
39//!
40//! ### `LazyAdamMode::Lazy` (pure TensorFlow `LazyAdam`)
41//!
42//! When coordinate `i` is touched at global step `t` with gradient `g`, the
43//! moments are updated with the **current gradient only**, using a single decay
44//! factor regardless of how long the coordinate was dormant:
45//!
46//! ```text
47//!   m[i] = beta1 * m[i] + (1 - beta1) * g
48//!   v[i] = beta2 * v[i] + (1 - beta2) * g^2
49//! ```
50//!
51//! Bias correction uses the **global** step `t`:
52//!
53//! ```text
54//!   m_hat = m[i] / (1 - beta1^t)
55//!   v_hat = v[i] / (1 - beta2^t)
56//!   params[i] -= lr * m_hat / (sqrt(v_hat) + eps)
57//! ```
58//!
59//! This is cheap and matches TensorFlow's `LazyAdam`, but the moments do **not**
60//! account for the EMA decay that conceptually elapsed while the coordinate was
61//! dormant.
62//!
63//! ### `LazyAdamMode::DormancyDecay` (lazy with dormancy catch-up)
64//!
65//! Here we model exactly what dense Adam would have done to the moments had the
66//! gradient been zero during the dormant steps. If the coordinate was last
67//! updated at step `s = last_step[i]` and is now touched at step `t`, dense Adam
68//! would multiply the old moments by `beta^(t - s)` (one factor of `beta` per
69//! elapsed step, each carrying a zero gradient) before incorporating the new
70//! gradient. Hence:
71//!
72//! ```text
73//!   gap  = t - s                      (>= 1)
74//!   m[i] = beta1^gap * m[i] + (1 - beta1) * g
75//!   v[i] = beta2^gap * v[i] + (1 - beta2) * g^2
76//! ```
77//!
78//! followed by the same global-step bias correction and update as above. For a
79//! coordinate touched on **every** step `gap == 1`, so this reduces exactly to
80//! the standard Adam recursion; for an intermittently-touched coordinate the
81//! moments `m[i]`, `v[i]` at touch time are **identical** to those that dense
82//! Adam (fed explicit zero gradients on the dormant steps) would hold. The
83//! parameter trajectory still differs from dense Adam, because lazy Adam --- by
84//! design --- performs **no** parameter update on the dormant steps.
85//!
86//! In both modes bias correction uses the global optimizer step count `t`
87//! (number of `step` calls so far), never a per-coordinate visit count.
88
89use crate::GpuOptimError;
90use scirs2_core::ndarray::{Array1, Array2};
91
92/// Sparse gradient for a **1-D** dense parameter / embedding-row view, stored as
93/// a coordinate list (COO).
94///
95/// The representation is kept canonical: `indices` is strictly ascending, so the
96/// entries are sorted and free of duplicates, and every index is `< dim`.
97#[derive(Debug, Clone, PartialEq)]
98pub struct CooGradient {
99    /// Touched coordinate indices, strictly ascending.
100    indices: Vec<usize>,
101    /// Gradient value for each touched coordinate (parallel to `indices`).
102    values: Vec<f64>,
103    /// Logical dimension of the dense vector this gradient applies to.
104    dim: usize,
105}
106
107impl CooGradient {
108    /// Build a canonical COO gradient from already sorted, unique indices.
109    ///
110    /// # Errors
111    /// Returns [`GpuOptimError::InvalidState`] when `indices` and `values` have
112    /// mismatched lengths or when `indices` is not strictly ascending, and
113    /// [`GpuOptimError::DimensionMismatch`] when any index is `>= dim`.
114    pub fn new(indices: Vec<usize>, values: Vec<f64>, dim: usize) -> Result<Self, GpuOptimError> {
115        let grad = Self {
116            indices,
117            values,
118            dim,
119        };
120        grad.validate()?;
121        Ok(grad)
122    }
123
124    /// Build a COO gradient from arbitrary (possibly unsorted, possibly
125    /// duplicated) coordinate/value pairs.
126    ///
127    /// Duplicate indices are accumulated (their gradient values are summed),
128    /// which matches the semantics of looking up the same embedding row multiple
129    /// times within one mini-batch. The result is canonicalized to strictly
130    /// ascending indices.
131    ///
132    /// # Errors
133    /// Returns an error when lengths mismatch or when any index is `>= dim`.
134    pub fn new_unsorted(
135        indices: Vec<usize>,
136        values: Vec<f64>,
137        dim: usize,
138    ) -> Result<Self, GpuOptimError> {
139        if indices.len() != values.len() {
140            return Err(GpuOptimError::InvalidState(format!(
141                "COO indices/values length mismatch: {} vs {}",
142                indices.len(),
143                values.len()
144            )));
145        }
146        let mut pairs: Vec<(usize, f64)> = indices.into_iter().zip(values).collect();
147        for &(idx, _) in &pairs {
148            if idx >= dim {
149                return Err(GpuOptimError::DimensionMismatch {
150                    expected: vec![dim],
151                    actual: vec![idx],
152                });
153            }
154        }
155        pairs.sort_by_key(|&(idx, _)| idx);
156
157        let mut out_indices: Vec<usize> = Vec::with_capacity(pairs.len());
158        let mut out_values: Vec<f64> = Vec::with_capacity(pairs.len());
159        for (idx, val) in pairs {
160            if let Some(&last) = out_indices.last() {
161                if last == idx {
162                    if let Some(slot) = out_values.last_mut() {
163                        *slot += val;
164                    }
165                    continue;
166                }
167            }
168            out_indices.push(idx);
169            out_values.push(val);
170        }
171
172        Ok(Self {
173            indices: out_indices,
174            values: out_values,
175            dim,
176        })
177    }
178
179    /// Create an empty (all-zero) sparse gradient of dimension `dim`.
180    pub fn empty(dim: usize) -> Self {
181        Self {
182            indices: Vec::new(),
183            values: Vec::new(),
184            dim,
185        }
186    }
187
188    /// Validate the structural invariants: matching lengths, strictly ascending
189    /// indices and in-bounds indices.
190    ///
191    /// # Errors
192    /// See [`CooGradient::new`].
193    pub fn validate(&self) -> Result<(), GpuOptimError> {
194        if self.indices.len() != self.values.len() {
195            return Err(GpuOptimError::InvalidState(format!(
196                "COO indices/values length mismatch: {} vs {}",
197                self.indices.len(),
198                self.values.len()
199            )));
200        }
201        for pair in self.indices.windows(2) {
202            if pair[0] >= pair[1] {
203                return Err(GpuOptimError::InvalidState(format!(
204                    "COO indices must be strictly ascending, found {} >= {}",
205                    pair[0], pair[1]
206                )));
207            }
208        }
209        if let Some(&max_idx) = self.indices.last() {
210            if max_idx >= self.dim {
211                return Err(GpuOptimError::DimensionMismatch {
212                    expected: vec![self.dim],
213                    actual: vec![max_idx],
214                });
215            }
216        }
217        Ok(())
218    }
219
220    /// Logical dimension of the dense vector.
221    pub fn dim(&self) -> usize {
222        self.dim
223    }
224
225    /// Number of stored (non-zero) coordinates.
226    pub fn nnz(&self) -> usize {
227        self.indices.len()
228    }
229
230    /// True when no coordinate is touched.
231    pub fn is_empty(&self) -> bool {
232        self.indices.is_empty()
233    }
234
235    /// Touched coordinate indices (strictly ascending).
236    pub fn indices(&self) -> &[usize] {
237        &self.indices
238    }
239
240    /// Gradient values (parallel to [`CooGradient::indices`]).
241    pub fn values(&self) -> &[f64] {
242        &self.values
243    }
244
245    /// Iterate over `(index, value)` pairs.
246    pub fn iter(&self) -> impl Iterator<Item = (usize, f64)> + '_ {
247        self.indices
248            .iter()
249            .copied()
250            .zip(self.values.iter().copied())
251    }
252}
253
254/// Sparse gradient for a **2-D** embedding table, stored in compressed sparse
255/// row (CSR) form.
256///
257/// Invariants (checked by [`CsrGradient::validate`]):
258/// * `row_offsets.len() == shape.0 + 1`, `row_offsets[0] == 0`, monotonically
259///   non-decreasing, and `row_offsets[shape.0] == col_indices.len()`.
260/// * `col_indices.len() == values.len()`.
261/// * within every row the column indices are strictly ascending and `< shape.1`.
262#[derive(Debug, Clone, PartialEq)]
263pub struct CsrGradient {
264    /// Row pointer array of length `shape.0 + 1`.
265    row_offsets: Vec<usize>,
266    /// Column index of every stored value.
267    col_indices: Vec<usize>,
268    /// Stored gradient values (parallel to `col_indices`).
269    values: Vec<f64>,
270    /// Dense shape `(rows, cols)` of the embedding table.
271    shape: (usize, usize),
272}
273
274impl CsrGradient {
275    /// Build a CSR gradient and validate it.
276    ///
277    /// # Errors
278    /// Returns [`GpuOptimError::InvalidState`] / [`GpuOptimError::DimensionMismatch`]
279    /// when any structural invariant is violated.
280    pub fn new(
281        row_offsets: Vec<usize>,
282        col_indices: Vec<usize>,
283        values: Vec<f64>,
284        shape: (usize, usize),
285    ) -> Result<Self, GpuOptimError> {
286        let grad = Self {
287            row_offsets,
288            col_indices,
289            values,
290            shape,
291        };
292        grad.validate()?;
293        Ok(grad)
294    }
295
296    /// Create an empty (all-zero) CSR gradient of the given shape.
297    pub fn empty(shape: (usize, usize)) -> Self {
298        Self {
299            row_offsets: vec![0; shape.0 + 1],
300            col_indices: Vec::new(),
301            values: Vec::new(),
302            shape,
303        }
304    }
305
306    /// Validate all CSR structural invariants.
307    ///
308    /// # Errors
309    /// See the type-level documentation for the checked invariants.
310    pub fn validate(&self) -> Result<(), GpuOptimError> {
311        let (rows, cols) = self.shape;
312        if self.row_offsets.len() != rows + 1 {
313            return Err(GpuOptimError::DimensionMismatch {
314                expected: vec![rows + 1],
315                actual: vec![self.row_offsets.len()],
316            });
317        }
318        if self.col_indices.len() != self.values.len() {
319            return Err(GpuOptimError::InvalidState(format!(
320                "CSR col_indices/values length mismatch: {} vs {}",
321                self.col_indices.len(),
322                self.values.len()
323            )));
324        }
325        if self.row_offsets[0] != 0 {
326            return Err(GpuOptimError::InvalidState(format!(
327                "CSR row_offsets must start at 0, found {}",
328                self.row_offsets[0]
329            )));
330        }
331        for pair in self.row_offsets.windows(2) {
332            if pair[0] > pair[1] {
333                return Err(GpuOptimError::InvalidState(format!(
334                    "CSR row_offsets must be non-decreasing, found {} > {}",
335                    pair[0], pair[1]
336                )));
337            }
338        }
339        if self.row_offsets[rows] != self.col_indices.len() {
340            return Err(GpuOptimError::InvalidState(format!(
341                "CSR final row_offset {} must equal nnz {}",
342                self.row_offsets[rows],
343                self.col_indices.len()
344            )));
345        }
346        for r in 0..rows {
347            let start = self.row_offsets[r];
348            let end = self.row_offsets[r + 1];
349            let row_cols = &self.col_indices[start..end];
350            for &c in row_cols {
351                if c >= cols {
352                    return Err(GpuOptimError::DimensionMismatch {
353                        expected: vec![cols],
354                        actual: vec![c],
355                    });
356                }
357            }
358            for pair in row_cols.windows(2) {
359                if pair[0] >= pair[1] {
360                    return Err(GpuOptimError::InvalidState(format!(
361                        "CSR column indices within row {} must be strictly ascending, found {} >= {}",
362                        r, pair[0], pair[1]
363                    )));
364                }
365            }
366        }
367        Ok(())
368    }
369
370    /// Dense shape `(rows, cols)`.
371    pub fn shape(&self) -> (usize, usize) {
372        self.shape
373    }
374
375    /// Number of rows in the dense table.
376    pub fn rows(&self) -> usize {
377        self.shape.0
378    }
379
380    /// Number of columns in the dense table.
381    pub fn cols(&self) -> usize {
382        self.shape.1
383    }
384
385    /// Number of stored (non-zero) entries.
386    pub fn nnz(&self) -> usize {
387        self.col_indices.len()
388    }
389
390    /// Column indices and values stored for row `r`.
391    ///
392    /// # Errors
393    /// Returns [`GpuOptimError::DimensionMismatch`] when `r` is out of range.
394    pub fn row(&self, r: usize) -> Result<(&[usize], &[f64]), GpuOptimError> {
395        if r >= self.shape.0 {
396            return Err(GpuOptimError::DimensionMismatch {
397                expected: vec![self.shape.0],
398                actual: vec![r],
399            });
400        }
401        let start = self.row_offsets[r];
402        let end = self.row_offsets[r + 1];
403        Ok((&self.col_indices[start..end], &self.values[start..end]))
404    }
405
406    /// Iterate over `(row, col, value)` triplets in row-major (and within a row,
407    /// column-ascending) order.
408    pub fn iter(&self) -> impl Iterator<Item = (usize, usize, f64)> + '_ {
409        (0..self.shape.0).flat_map(move |r| {
410            let start = self.row_offsets[r];
411            let end = self.row_offsets[r + 1];
412            (start..end).map(move |k| (r, self.col_indices[k], self.values[k]))
413        })
414    }
415
416    /// Convert to a flattened 1-D [`CooGradient`] using linear indices
417    /// `row * cols + col` over a `rows * cols` view. The resulting indices are
418    /// globally ascending, so the COO gradient is already canonical.
419    pub fn to_coo(&self) -> CooGradient {
420        let cols = self.shape.1;
421        let mut indices = Vec::with_capacity(self.col_indices.len());
422        let mut values = Vec::with_capacity(self.values.len());
423        for (r, c, val) in self.iter() {
424            indices.push(r * cols + c);
425            values.push(val);
426        }
427        CooGradient {
428            indices,
429            values,
430            dim: self.shape.0 * cols,
431        }
432    }
433
434    /// Build a CSR gradient from a flattened 1-D [`CooGradient`] of dimension
435    /// `shape.0 * shape.1`, decoding each linear index into `(row, col)`.
436    ///
437    /// # Errors
438    /// Returns [`GpuOptimError::DimensionMismatch`] when `coo.dim()` does not
439    /// equal `shape.0 * shape.1`, or [`GpuOptimError::InvalidState`] when the
440    /// table would have zero columns while carrying entries.
441    pub fn from_coo(coo: &CooGradient, shape: (usize, usize)) -> Result<Self, GpuOptimError> {
442        let (rows, cols) = shape;
443        let expected_dim = rows * cols;
444        if coo.dim() != expected_dim {
445            return Err(GpuOptimError::DimensionMismatch {
446                expected: vec![expected_dim],
447                actual: vec![coo.dim()],
448            });
449        }
450        if cols == 0 {
451            if coo.nnz() == 0 {
452                return Ok(Self::empty(shape));
453            }
454            return Err(GpuOptimError::InvalidState(
455                "cannot decode a CSR gradient with zero columns but non-zero entries".to_string(),
456            ));
457        }
458
459        let mut row_offsets = vec![0usize; rows + 1];
460        let mut col_indices = Vec::with_capacity(coo.nnz());
461        let mut values = Vec::with_capacity(coo.nnz());
462        // Count entries per row first.
463        for &lin in coo.indices() {
464            let r = lin / cols;
465            row_offsets[r + 1] += 1;
466        }
467        for r in 0..rows {
468            row_offsets[r + 1] += row_offsets[r];
469        }
470        // The COO indices are ascending, so decoded (row, col) pairs are emitted
471        // in row-major, column-ascending order: a simple sequential fill is
472        // sufficient and preserves the CSR invariants.
473        for (lin, val) in coo.iter() {
474            let c = lin % cols;
475            col_indices.push(c);
476            values.push(val);
477        }
478
479        let grad = Self {
480            row_offsets,
481            col_indices,
482            values,
483            shape,
484        };
485        grad.validate()?;
486        Ok(grad)
487    }
488}
489
490impl From<&CsrGradient> for CooGradient {
491    fn from(csr: &CsrGradient) -> Self {
492        csr.to_coo()
493    }
494}
495
496/// Semantics used by [`SparseAdam`] for catching up the moment estimates of a
497/// coordinate that has been dormant. See the module-level documentation for the
498/// exact update equations of each variant.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum LazyAdamMode {
501    /// Pure TensorFlow `LazyAdam`: moments decay by a single factor of `beta`
502    /// regardless of dormancy. Cheapest; does not model the EMA decay that
503    /// elapsed while the coordinate was untouched.
504    Lazy,
505    /// Lazy Adam with dormancy catch-up: moments are decayed by `beta^(t - s)`
506    /// (where `s` is the last touched step) before the current gradient is
507    /// incorporated, exactly reproducing the moments that dense Adam --- fed
508    /// zero gradients on the dormant steps --- would hold.
509    DormancyDecay,
510}
511
512/// Configuration for [`SparseAdam`] (and its 2-D table variant
513/// [`SparseAdamTable`]).
514#[derive(Debug, Clone, Copy)]
515pub struct SparseAdamConfig {
516    /// Learning rate.
517    pub lr: f64,
518    /// First moment decay `beta1`.
519    pub beta1: f64,
520    /// Second moment decay `beta2`.
521    pub beta2: f64,
522    /// Numerical stabilizer `epsilon`.
523    pub epsilon: f64,
524    /// Coupled (L2) weight decay applied on touched coordinates only.
525    pub weight_decay: f64,
526    /// Dormancy catch-up semantics.
527    pub mode: LazyAdamMode,
528}
529
530impl Default for SparseAdamConfig {
531    fn default() -> Self {
532        Self {
533            lr: 1e-3,
534            beta1: 0.9,
535            beta2: 0.999,
536            epsilon: 1e-8,
537            weight_decay: 0.0,
538            mode: LazyAdamMode::Lazy,
539        }
540    }
541}
542
543/// Apply one lazy-Adam update to a single coordinate, in place.
544///
545/// `t` is the *global* optimizer step (>= 1). `last_step` is the global step at
546/// which this coordinate was previously updated (0 if never). Both moment
547/// references and `last_step` are advanced; `param` receives the bias-corrected
548/// update.
549fn lazy_adam_coordinate(
550    param: &mut f64,
551    m: &mut f64,
552    v: &mut f64,
553    last_step: &mut usize,
554    grad: f64,
555    t: usize,
556    cfg: &SparseAdamConfig,
557) {
558    // Coupled (L2) weight decay: fold into the effective gradient.
559    let g = grad + cfg.weight_decay * *param;
560
561    let (decay1, decay2) = match cfg.mode {
562        LazyAdamMode::Lazy => (cfg.beta1, cfg.beta2),
563        LazyAdamMode::DormancyDecay => {
564            let gap = (t - *last_step) as i32;
565            (cfg.beta1.powi(gap), cfg.beta2.powi(gap))
566        }
567    };
568
569    *m = decay1 * *m + (1.0 - cfg.beta1) * g;
570    *v = decay2 * *v + (1.0 - cfg.beta2) * g * g;
571
572    let bias1 = 1.0 - cfg.beta1.powi(t as i32);
573    let bias2 = 1.0 - cfg.beta2.powi(t as i32);
574    let m_hat = *m / bias1;
575    let v_hat = *v / bias2;
576
577    *param -= cfg.lr * m_hat / (v_hat.sqrt() + cfg.epsilon);
578    *last_step = t;
579}
580
581/// Sparse / lazy Adam optimizer over a **1-D** dense parameter vector.
582///
583/// Per-coordinate moment state (`m`, `v`) and `last_step` are stored densely
584/// (Adam always needs one moment pair per parameter) but are only *read and
585/// written* for coordinates that carry a gradient on a given step, which is what
586/// makes the step cost proportional to the number of non-zero coordinates.
587#[derive(Debug, Clone)]
588pub struct SparseAdam {
589    config: SparseAdamConfig,
590    m: Vec<f64>,
591    v: Vec<f64>,
592    last_step: Vec<usize>,
593    dim: usize,
594    global_step: usize,
595}
596
597impl SparseAdam {
598    /// Create a new optimizer. State is allocated lazily on the first
599    /// [`SparseAdam::step`] from the length of the parameter vector.
600    pub fn new(config: SparseAdamConfig) -> Self {
601        Self {
602            config,
603            m: Vec::new(),
604            v: Vec::new(),
605            last_step: Vec::new(),
606            dim: 0,
607            global_step: 0,
608        }
609    }
610
611    /// The number of optimizer steps performed so far (the global `t`).
612    pub fn global_step(&self) -> usize {
613        self.global_step
614    }
615
616    /// Immutable view of the configuration.
617    pub fn config(&self) -> &SparseAdamConfig {
618        &self.config
619    }
620
621    fn ensure_state(&mut self, dim: usize) -> Result<(), GpuOptimError> {
622        if self.global_step == 0 {
623            self.dim = dim;
624            self.m = vec![0.0; dim];
625            self.v = vec![0.0; dim];
626            self.last_step = vec![0usize; dim];
627        } else if self.dim != dim {
628            return Err(GpuOptimError::DimensionMismatch {
629                expected: vec![self.dim],
630                actual: vec![dim],
631            });
632        }
633        Ok(())
634    }
635
636    /// Perform one sparse Adam update in place, touching only the coordinates
637    /// present in `grad`.
638    ///
639    /// # Errors
640    /// Returns [`GpuOptimError::DimensionMismatch`] when the parameter length and
641    /// the gradient dimension disagree (or differ from a previous step), and
642    /// propagates validation errors from a malformed gradient.
643    pub fn step(
644        &mut self,
645        params: &mut Array1<f64>,
646        grad: &CooGradient,
647    ) -> Result<(), GpuOptimError> {
648        grad.validate()?;
649        if grad.dim() != params.len() {
650            return Err(GpuOptimError::DimensionMismatch {
651                expected: vec![params.len()],
652                actual: vec![grad.dim()],
653            });
654        }
655        self.ensure_state(params.len())?;
656        self.global_step += 1;
657        let t = self.global_step;
658
659        for (idx, g) in grad.iter() {
660            let mut p = params[idx];
661            lazy_adam_coordinate(
662                &mut p,
663                &mut self.m[idx],
664                &mut self.v[idx],
665                &mut self.last_step[idx],
666                g,
667                t,
668                &self.config,
669            );
670            params[idx] = p;
671        }
672        Ok(())
673    }
674}
675
676/// Sparse / lazy Adam optimizer over a **2-D** embedding table.
677///
678/// Mirrors [`SparseAdam`] but operates on an [`Array2`] with a [`CsrGradient`].
679/// Moment state is stored flat, indexed by `row * cols + col`.
680#[derive(Debug, Clone)]
681pub struct SparseAdamTable {
682    config: SparseAdamConfig,
683    m: Vec<f64>,
684    v: Vec<f64>,
685    last_step: Vec<usize>,
686    shape: (usize, usize),
687    global_step: usize,
688}
689
690impl SparseAdamTable {
691    /// Create a new table optimizer. State is allocated lazily on the first
692    /// [`SparseAdamTable::step`].
693    pub fn new(config: SparseAdamConfig) -> Self {
694        Self {
695            config,
696            m: Vec::new(),
697            v: Vec::new(),
698            last_step: Vec::new(),
699            shape: (0, 0),
700            global_step: 0,
701        }
702    }
703
704    /// The number of optimizer steps performed so far.
705    pub fn global_step(&self) -> usize {
706        self.global_step
707    }
708
709    fn ensure_state(&mut self, shape: (usize, usize)) -> Result<(), GpuOptimError> {
710        if self.global_step == 0 {
711            self.shape = shape;
712            let n = shape.0 * shape.1;
713            self.m = vec![0.0; n];
714            self.v = vec![0.0; n];
715            self.last_step = vec![0usize; n];
716        } else if self.shape != shape {
717            return Err(GpuOptimError::DimensionMismatch {
718                expected: vec![self.shape.0, self.shape.1],
719                actual: vec![shape.0, shape.1],
720            });
721        }
722        Ok(())
723    }
724
725    /// Perform one sparse Adam update on the embedding table in place.
726    ///
727    /// # Errors
728    /// Returns [`GpuOptimError::DimensionMismatch`] on any shape disagreement and
729    /// propagates gradient validation errors.
730    pub fn step(
731        &mut self,
732        params: &mut Array2<f64>,
733        grad: &CsrGradient,
734    ) -> Result<(), GpuOptimError> {
735        grad.validate()?;
736        let shape = params.dim();
737        if grad.shape() != shape {
738            return Err(GpuOptimError::DimensionMismatch {
739                expected: vec![shape.0, shape.1],
740                actual: vec![grad.shape().0, grad.shape().1],
741            });
742        }
743        self.ensure_state(shape)?;
744        self.global_step += 1;
745        let t = self.global_step;
746        let cols = shape.1;
747
748        for (r, c, g) in grad.iter() {
749            let flat = r * cols + c;
750            let mut p = params[[r, c]];
751            lazy_adam_coordinate(
752                &mut p,
753                &mut self.m[flat],
754                &mut self.v[flat],
755                &mut self.last_step[flat],
756                g,
757                t,
758                &self.config,
759            );
760            params[[r, c]] = p;
761        }
762        Ok(())
763    }
764}
765
766/// Configuration for [`SparseSgd`] (and its 2-D table variant
767/// [`SparseSgdTable`]).
768#[derive(Debug, Clone, Copy)]
769pub struct SparseSgdConfig {
770    /// Learning rate.
771    pub lr: f64,
772    /// Coupled (L2) weight decay applied on touched coordinates only.
773    pub weight_decay: f64,
774    /// Momentum factor; `0.0` disables the momentum buffer.
775    pub momentum: f64,
776    /// Whether to use Nesterov momentum (requires `momentum > 0`).
777    pub nesterov: bool,
778}
779
780impl Default for SparseSgdConfig {
781    fn default() -> Self {
782        Self {
783            lr: 1e-2,
784            weight_decay: 0.0,
785            momentum: 0.0,
786            nesterov: false,
787        }
788    }
789}
790
791/// Apply one sparse SGD update to a single coordinate, in place.
792///
793/// Momentum is *lazy*: the per-coordinate buffer is only decayed/advanced when
794/// the coordinate is touched (no dormancy catch-up), matching the common sparse
795/// SGD behaviour for embeddings.
796fn sparse_sgd_coordinate(param: &mut f64, buf: &mut f64, grad: f64, cfg: &SparseSgdConfig) {
797    let mut d = grad + cfg.weight_decay * *param;
798    if cfg.momentum > 0.0 {
799        *buf = cfg.momentum * *buf + d;
800        if cfg.nesterov {
801            d += cfg.momentum * *buf;
802        } else {
803            d = *buf;
804        }
805    }
806    *param -= cfg.lr * d;
807}
808
809/// Sparse SGD optimizer over a **1-D** dense parameter vector.
810///
811/// With `momentum == 0` and `weight_decay == 0` this is the canonical sparse
812/// update `params[i] -= lr * grad[i]`, touching only the non-zero coordinates
813/// and leaving every other coordinate bit-identical.
814#[derive(Debug, Clone)]
815pub struct SparseSgd {
816    config: SparseSgdConfig,
817    momentum_buf: Vec<f64>,
818    dim: usize,
819    initialized: bool,
820}
821
822impl SparseSgd {
823    /// Create a new optimizer. State (if momentum is used) is allocated lazily.
824    pub fn new(config: SparseSgdConfig) -> Self {
825        Self {
826            config,
827            momentum_buf: Vec::new(),
828            dim: 0,
829            initialized: false,
830        }
831    }
832
833    /// Immutable view of the configuration.
834    pub fn config(&self) -> &SparseSgdConfig {
835        &self.config
836    }
837
838    fn ensure_state(&mut self, dim: usize) -> Result<(), GpuOptimError> {
839        if !self.initialized {
840            self.dim = dim;
841            if self.config.momentum > 0.0 {
842                self.momentum_buf = vec![0.0; dim];
843            }
844            self.initialized = true;
845        } else if self.dim != dim {
846            return Err(GpuOptimError::DimensionMismatch {
847                expected: vec![self.dim],
848                actual: vec![dim],
849            });
850        }
851        Ok(())
852    }
853
854    /// Perform one sparse SGD update in place, touching only the coordinates
855    /// present in `grad`.
856    ///
857    /// # Errors
858    /// Returns [`GpuOptimError::DimensionMismatch`] on dimension disagreement and
859    /// propagates gradient validation errors.
860    pub fn step(
861        &mut self,
862        params: &mut Array1<f64>,
863        grad: &CooGradient,
864    ) -> Result<(), GpuOptimError> {
865        grad.validate()?;
866        if grad.dim() != params.len() {
867            return Err(GpuOptimError::DimensionMismatch {
868                expected: vec![params.len()],
869                actual: vec![grad.dim()],
870            });
871        }
872        self.ensure_state(params.len())?;
873
874        let use_momentum = self.config.momentum > 0.0;
875        for (idx, g) in grad.iter() {
876            let mut p = params[idx];
877            if use_momentum {
878                let mut buf = self.momentum_buf[idx];
879                sparse_sgd_coordinate(&mut p, &mut buf, g, &self.config);
880                self.momentum_buf[idx] = buf;
881            } else {
882                let mut scratch = 0.0;
883                sparse_sgd_coordinate(&mut p, &mut scratch, g, &self.config);
884            }
885            params[idx] = p;
886        }
887        Ok(())
888    }
889}
890
891/// Sparse SGD optimizer over a **2-D** embedding table (CSR gradients).
892#[derive(Debug, Clone)]
893pub struct SparseSgdTable {
894    config: SparseSgdConfig,
895    momentum_buf: Vec<f64>,
896    shape: (usize, usize),
897    initialized: bool,
898}
899
900impl SparseSgdTable {
901    /// Create a new table optimizer.
902    pub fn new(config: SparseSgdConfig) -> Self {
903        Self {
904            config,
905            momentum_buf: Vec::new(),
906            shape: (0, 0),
907            initialized: false,
908        }
909    }
910
911    fn ensure_state(&mut self, shape: (usize, usize)) -> Result<(), GpuOptimError> {
912        if !self.initialized {
913            self.shape = shape;
914            if self.config.momentum > 0.0 {
915                self.momentum_buf = vec![0.0; shape.0 * shape.1];
916            }
917            self.initialized = true;
918        } else if self.shape != shape {
919            return Err(GpuOptimError::DimensionMismatch {
920                expected: vec![self.shape.0, self.shape.1],
921                actual: vec![shape.0, shape.1],
922            });
923        }
924        Ok(())
925    }
926
927    /// Perform one sparse SGD update on the embedding table in place.
928    ///
929    /// # Errors
930    /// Returns [`GpuOptimError::DimensionMismatch`] on shape disagreement and
931    /// propagates gradient validation errors.
932    pub fn step(
933        &mut self,
934        params: &mut Array2<f64>,
935        grad: &CsrGradient,
936    ) -> Result<(), GpuOptimError> {
937        grad.validate()?;
938        let shape = params.dim();
939        if grad.shape() != shape {
940            return Err(GpuOptimError::DimensionMismatch {
941                expected: vec![shape.0, shape.1],
942                actual: vec![grad.shape().0, grad.shape().1],
943            });
944        }
945        self.ensure_state(shape)?;
946
947        let use_momentum = self.config.momentum > 0.0;
948        let cols = shape.1;
949        for (r, c, g) in grad.iter() {
950            let mut p = params[[r, c]];
951            if use_momentum {
952                let flat = r * cols + c;
953                let mut buf = self.momentum_buf[flat];
954                sparse_sgd_coordinate(&mut p, &mut buf, g, &self.config);
955                self.momentum_buf[flat] = buf;
956            } else {
957                let mut scratch = 0.0;
958                sparse_sgd_coordinate(&mut p, &mut scratch, g, &self.config);
959            }
960            params[[r, c]] = p;
961        }
962        Ok(())
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use approx::assert_relative_eq;
970    use scirs2_core::ndarray::{Array1, Array2};
971
972    /// Dense reference Adam over a small vector; updates every coordinate on
973    /// every call (zero gradients included), which is the textbook recursion.
974    struct DenseAdam {
975        m: Vec<f64>,
976        v: Vec<f64>,
977        t: usize,
978        lr: f64,
979        beta1: f64,
980        beta2: f64,
981        epsilon: f64,
982    }
983
984    impl DenseAdam {
985        fn new(dim: usize, cfg: &SparseAdamConfig) -> Self {
986            Self {
987                m: vec![0.0; dim],
988                v: vec![0.0; dim],
989                t: 0,
990                lr: cfg.lr,
991                beta1: cfg.beta1,
992                beta2: cfg.beta2,
993                epsilon: cfg.epsilon,
994            }
995        }
996
997        fn step(&mut self, params: &mut [f64], grad: &[f64]) {
998            self.t += 1;
999            let bias1 = 1.0 - self.beta1.powi(self.t as i32);
1000            let bias2 = 1.0 - self.beta2.powi(self.t as i32);
1001            for i in 0..params.len() {
1002                self.m[i] = self.beta1 * self.m[i] + (1.0 - self.beta1) * grad[i];
1003                self.v[i] = self.beta2 * self.v[i] + (1.0 - self.beta2) * grad[i] * grad[i];
1004                let m_hat = self.m[i] / bias1;
1005                let v_hat = self.v[i] / bias2;
1006                params[i] -= self.lr * m_hat / (v_hat.sqrt() + self.epsilon);
1007            }
1008        }
1009    }
1010
1011    // ----- COO construction / validation -----
1012
1013    #[test]
1014    fn test_sparse_coo_new_valid() {
1015        let g = CooGradient::new(vec![0, 2, 5], vec![1.0, -2.0, 3.0], 8).expect("valid coo");
1016        assert_eq!(g.nnz(), 3);
1017        assert_eq!(g.dim(), 8);
1018        let collected: Vec<(usize, f64)> = g.iter().collect();
1019        assert_eq!(collected, vec![(0, 1.0), (2, -2.0), (5, 3.0)]);
1020    }
1021
1022    #[test]
1023    fn test_sparse_coo_out_of_bounds_errs() {
1024        let err = CooGradient::new(vec![0, 9], vec![1.0, 2.0], 8);
1025        assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1026    }
1027
1028    #[test]
1029    fn test_sparse_coo_length_mismatch_errs() {
1030        let err = CooGradient::new(vec![0, 1, 2], vec![1.0, 2.0], 8);
1031        assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1032    }
1033
1034    #[test]
1035    fn test_sparse_coo_unsorted_errs() {
1036        let err = CooGradient::new(vec![2, 1], vec![1.0, 2.0], 8);
1037        assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1038    }
1039
1040    #[test]
1041    fn test_sparse_coo_new_unsorted_accumulates_duplicates() {
1042        let g = CooGradient::new_unsorted(vec![3, 1, 3], vec![1.0, 5.0, 2.0], 8)
1043            .expect("canonicalizes");
1044        assert_eq!(g.indices(), &[1, 3]);
1045        assert_eq!(g.values(), &[5.0, 3.0]);
1046    }
1047
1048    // ----- CSR construction / validation -----
1049
1050    #[test]
1051    fn test_sparse_csr_new_valid() {
1052        // shape (3, 4); row0: cols {0,2}; row1: {}; row2: {1,3}
1053        let csr = CsrGradient::new(
1054            vec![0, 2, 2, 4],
1055            vec![0, 2, 1, 3],
1056            vec![1.0, 2.0, 3.0, 4.0],
1057            (3, 4),
1058        )
1059        .expect("valid csr");
1060        assert_eq!(csr.nnz(), 4);
1061        let (cols, vals) = csr.row(2).expect("row 2");
1062        assert_eq!(cols, &[1, 3]);
1063        assert_eq!(vals, &[3.0, 4.0]);
1064    }
1065
1066    #[test]
1067    fn test_sparse_csr_bad_offsets_len_errs() {
1068        let err = CsrGradient::new(vec![0, 2], vec![0, 1], vec![1.0, 2.0], (3, 4));
1069        assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1070    }
1071
1072    #[test]
1073    fn test_sparse_csr_col_out_of_bounds_errs() {
1074        let err = CsrGradient::new(vec![0, 1, 1, 1], vec![9], vec![1.0], (3, 4));
1075        assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1076    }
1077
1078    #[test]
1079    fn test_sparse_csr_unsorted_cols_errs() {
1080        let err = CsrGradient::new(vec![0, 2, 2, 2], vec![2, 1], vec![1.0, 2.0], (3, 4));
1081        assert!(matches!(err, Err(GpuOptimError::InvalidState(_))));
1082    }
1083
1084    // ----- COO <-> CSR round trip -----
1085
1086    #[test]
1087    fn test_sparse_coo_csr_round_trip() {
1088        let csr = CsrGradient::new(
1089            vec![0, 2, 2, 4],
1090            vec![0, 2, 1, 3],
1091            vec![1.0, 2.0, 3.0, 4.0],
1092            (3, 4),
1093        )
1094        .expect("valid csr");
1095
1096        // CSR -> COO -> CSR preserves the matrix.
1097        let coo = csr.to_coo();
1098        assert_eq!(coo.dim(), 12);
1099        assert_eq!(coo.indices(), &[0, 2, 9, 11]); // 0,2, 2*4+1=9, 2*4+3=11
1100        let csr2 = CsrGradient::from_coo(&coo, (3, 4)).expect("rebuild csr");
1101        assert_eq!(csr, csr2);
1102
1103        // COO -> CSR -> COO preserves the vector too.
1104        let coo2 = csr2.to_coo();
1105        assert_eq!(coo, coo2);
1106
1107        // The infallible From impl agrees.
1108        let coo3: CooGradient = (&csr).into();
1109        assert_eq!(coo, coo3);
1110    }
1111
1112    #[test]
1113    fn test_sparse_csr_from_coo_dim_mismatch_errs() {
1114        let coo = CooGradient::new(vec![0, 5], vec![1.0, 2.0], 10).expect("coo");
1115        let err = CsrGradient::from_coo(&coo, (3, 4)); // 3*4 = 12 != 10
1116        assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1117    }
1118
1119    // ----- Sparse SGD touches only nonzero coordinates -----
1120
1121    #[test]
1122    fn test_sparse_sgd_touches_only_nonzero() {
1123        let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1124        let original = params.clone();
1125        let grad = CooGradient::new(vec![1, 3], vec![0.5, -0.5], 5).expect("coo");
1126
1127        let cfg = SparseSgdConfig {
1128            lr: 0.1,
1129            ..Default::default()
1130        };
1131        let mut opt = SparseSgd::new(cfg);
1132        opt.step(&mut params, &grad).expect("sgd step");
1133
1134        // Untouched coordinates are bit-identical.
1135        assert_eq!(params[0].to_bits(), original[0].to_bits());
1136        assert_eq!(params[2].to_bits(), original[2].to_bits());
1137        assert_eq!(params[4].to_bits(), original[4].to_bits());
1138
1139        // Touched coordinates moved by exactly -lr * g.
1140        assert_relative_eq!(params[1], 2.0 - 0.1 * 0.5, epsilon = 1e-12);
1141        assert_relative_eq!(params[3], 4.0 - 0.1 * -0.5, epsilon = 1e-12);
1142    }
1143
1144    #[test]
1145    fn test_sparse_sgd_momentum_lazy() {
1146        // With momentum, the buffer only advances on touched coordinates.
1147        let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1148        let cfg = SparseSgdConfig {
1149            lr: 0.1,
1150            momentum: 0.9,
1151            ..Default::default()
1152        };
1153        let mut opt = SparseSgd::new(cfg);
1154
1155        // Touch coord 1 twice with the same gradient.
1156        let g = CooGradient::new(vec![1], vec![1.0], 3).expect("coo");
1157        opt.step(&mut params, &g).expect("step 1");
1158        // buf = 0.9*0 + 1 = 1; p -= 0.1*1 => -0.1
1159        assert_relative_eq!(params[1], -0.1, epsilon = 1e-12);
1160        opt.step(&mut params, &g).expect("step 2");
1161        // buf = 0.9*1 + 1 = 1.9; p -= 0.1*1.9 => -0.1 - 0.19 = -0.29
1162        assert_relative_eq!(params[1], -0.29, epsilon = 1e-12);
1163        // Untouched coords stay exactly zero.
1164        assert_eq!(params[0].to_bits(), 0.0_f64.to_bits());
1165        assert_eq!(params[2].to_bits(), 0.0_f64.to_bits());
1166    }
1167
1168    #[test]
1169    fn test_sparse_sgd_table_csr() {
1170        let mut params = Array2::<f64>::zeros((2, 3));
1171        params[[0, 0]] = 1.0;
1172        params[[1, 2]] = 2.0;
1173        let original = params.clone();
1174
1175        // Touch only (0,1) and (1,0).
1176        let csr =
1177            CsrGradient::new(vec![0, 1, 2], vec![1, 0], vec![0.5, -1.0], (2, 3)).expect("csr");
1178        let cfg = SparseSgdConfig {
1179            lr: 0.1,
1180            ..Default::default()
1181        };
1182        let mut opt = SparseSgdTable::new(cfg);
1183        opt.step(&mut params, &csr).expect("table step");
1184
1185        assert_relative_eq!(params[[0, 1]], -0.05, epsilon = 1e-12);
1186        assert_relative_eq!(params[[1, 0]], 0.1, epsilon = 1e-12);
1187        // Everything else bit-identical.
1188        assert_eq!(params[[0, 0]].to_bits(), original[[0, 0]].to_bits());
1189        assert_eq!(params[[1, 2]].to_bits(), original[[1, 2]].to_bits());
1190        assert_eq!(params[[0, 2]].to_bits(), original[[0, 2]].to_bits());
1191    }
1192
1193    // ----- Lazy Adam: every-step coordinate matches dense Adam -----
1194
1195    fn assert_every_step_matches_dense(mode: LazyAdamMode) {
1196        let cfg = SparseAdamConfig {
1197            lr: 0.05,
1198            beta1: 0.9,
1199            beta2: 0.999,
1200            epsilon: 1e-8,
1201            weight_decay: 0.0,
1202            mode,
1203        };
1204        let dim = 4;
1205        let touched = 2usize;
1206        let mut sparse = SparseAdam::new(cfg);
1207        let mut sparse_params = Array1::from_vec(vec![0.5, -0.3, 0.7, 0.1]);
1208
1209        let mut dense = DenseAdam::new(1, &cfg);
1210        let mut dense_param = vec![sparse_params[touched]];
1211
1212        let grads = [0.4_f64, -0.2, 0.05, 0.0, 0.33, -0.7, 0.15];
1213        for (k, &g) in grads.iter().enumerate() {
1214            // Sparse: touch only `touched` (but advance global step each call).
1215            let coo = CooGradient::new(vec![touched], vec![g], dim).expect("coo");
1216            sparse.step(&mut sparse_params, &coo).expect("sparse step");
1217
1218            // Dense scalar Adam on the same coordinate / gradient.
1219            dense.step(&mut dense_param, &[g]);
1220
1221            assert_relative_eq!(
1222                sparse_params[touched],
1223                dense_param[0],
1224                epsilon = 1e-12,
1225                max_relative = 1e-10
1226            );
1227            assert_eq!(sparse.global_step(), k + 1);
1228        }
1229    }
1230
1231    #[test]
1232    fn test_sparse_adam_every_step_matches_dense_lazy() {
1233        assert_every_step_matches_dense(LazyAdamMode::Lazy);
1234    }
1235
1236    #[test]
1237    fn test_sparse_adam_every_step_matches_dense_dormancy() {
1238        assert_every_step_matches_dense(LazyAdamMode::DormancyDecay);
1239    }
1240
1241    // ----- Lazy Adam: bias correction at small step counts -----
1242
1243    #[test]
1244    fn test_sparse_adam_bias_correction_first_step() {
1245        // At t = 1, m_hat / sqrt(v_hat) = g / |g| = sign(g), so the parameter
1246        // moves by approximately -lr * sign(g).
1247        let cfg = SparseAdamConfig {
1248            lr: 0.1,
1249            epsilon: 1e-8,
1250            ..Default::default()
1251        };
1252        let mut opt = SparseAdam::new(cfg);
1253        let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1254        let grad = CooGradient::new(vec![1], vec![2.0], 3).expect("coo");
1255        opt.step(&mut params, &grad).expect("step");
1256        assert_relative_eq!(params[1], -0.1, epsilon = 1e-6);
1257
1258        // A second, negative gradient at t = 2 also yields ~ -lr * sign(g).
1259        let mut params2 = Array1::from_vec(vec![0.0]);
1260        let mut opt2 = SparseAdam::new(SparseAdamConfig {
1261            lr: 0.1,
1262            epsilon: 1e-8,
1263            ..Default::default()
1264        });
1265        let g_pos = CooGradient::new(vec![0], vec![1.0], 1).expect("coo");
1266        let g_neg = CooGradient::new(vec![0], vec![-1.0], 1).expect("coo");
1267        opt2.step(&mut params2, &g_pos).expect("step");
1268        let after_first = params2[0];
1269        opt2.step(&mut params2, &g_neg).expect("step");
1270        // Second step pushes back upward (gradient sign flipped).
1271        assert!(params2[0] > after_first);
1272    }
1273
1274    // ----- Lazy Adam: intermittent coordinate, DormancyDecay semantics -----
1275
1276    #[test]
1277    fn test_sparse_adam_intermittent_dormancy_decay() {
1278        let cfg = SparseAdamConfig {
1279            lr: 0.05,
1280            beta1: 0.9,
1281            beta2: 0.999,
1282            epsilon: 1e-8,
1283            weight_decay: 0.0,
1284            mode: LazyAdamMode::DormancyDecay,
1285        };
1286        let dim = 2;
1287        let j = 1usize;
1288        let mut opt = SparseAdam::new(cfg);
1289        let mut params = Array1::from_vec(vec![0.0, 1.0]);
1290
1291        // Dense reference fed explicit zero gradients on the dormant step.
1292        let mut dense = DenseAdam::new(dim, &cfg);
1293        let mut dense_params = vec![0.0, 1.0];
1294
1295        // t=1: touch coord j with g=0.5
1296        opt.step(
1297            &mut params,
1298            &CooGradient::new(vec![j], vec![0.5], dim).expect("coo"),
1299        )
1300        .expect("t1");
1301        dense.step(&mut dense_params, &[0.0, 0.5]);
1302
1303        // t=2: touch coord 0 only (j dormant)
1304        opt.step(
1305            &mut params,
1306            &CooGradient::new(vec![0], vec![0.3], dim).expect("coo"),
1307        )
1308        .expect("t2");
1309        dense.step(&mut dense_params, &[0.3, 0.0]);
1310
1311        // t=3: touch coord j again with g=-0.2 (gap = 3 - 1 = 2)
1312        opt.step(
1313            &mut params,
1314            &CooGradient::new(vec![j], vec![-0.2], dim).expect("coo"),
1315        )
1316        .expect("t3");
1317        dense.step(&mut dense_params, &[0.0, -0.2]);
1318
1319        // Defining property of DormancyDecay: moments at coord j match dense Adam
1320        // fed zero gradients during dormancy.
1321        assert_relative_eq!(opt.m[j], dense.m[j], epsilon = 1e-12);
1322        assert_relative_eq!(opt.v[j], dense.v[j], epsilon = 1e-12);
1323
1324        // Hand-computed lazy parameter trajectory (independent reference that
1325        // skips the dormant-step parameter update).
1326        let (b1, b2, lr, eps) = (cfg.beta1, cfg.beta2, cfg.lr, cfg.epsilon);
1327        let mut p = 1.0_f64;
1328        let mut m = 0.0_f64;
1329        let mut vv = 0.0_f64;
1330        // t = 1, gap = 1
1331        m = b1.powi(1) * m + (1.0 - b1) * 0.5;
1332        vv = b2.powi(1) * vv + (1.0 - b2) * 0.5 * 0.5;
1333        p -= lr * (m / (1.0 - b1.powi(1))) / ((vv / (1.0 - b2.powi(1))).sqrt() + eps);
1334        // t = 3, gap = 2 (no update happened at t = 2 for coord j)
1335        m = b1.powi(2) * m + (1.0 - b1) * -0.2;
1336        vv = b2.powi(2) * vv + (1.0 - b2) * 0.2 * 0.2;
1337        p -= lr * (m / (1.0 - b1.powi(3))) / ((vv / (1.0 - b2.powi(3))).sqrt() + eps);
1338
1339        assert_relative_eq!(params[j], p, epsilon = 1e-12);
1340
1341        // The lazy parameter must differ from dense (dense nudged j at t = 2).
1342        assert!((params[j] - dense_params[j]).abs() > 1e-9);
1343    }
1344
1345    // ----- Lazy Adam: intermittent coordinate, pure Lazy semantics -----
1346
1347    #[test]
1348    fn test_sparse_adam_intermittent_pure_lazy() {
1349        let cfg = SparseAdamConfig {
1350            lr: 0.05,
1351            beta1: 0.9,
1352            beta2: 0.999,
1353            epsilon: 1e-8,
1354            weight_decay: 0.0,
1355            mode: LazyAdamMode::Lazy,
1356        };
1357        let dim = 2;
1358        let j = 1usize;
1359        let mut opt = SparseAdam::new(cfg);
1360        let mut params = Array1::from_vec(vec![0.0, 1.0]);
1361
1362        // t=1 touch j (g=0.5), t=2 touch coord 0, t=3 touch j (g=-0.2)
1363        opt.step(
1364            &mut params,
1365            &CooGradient::new(vec![j], vec![0.5], dim).expect("coo"),
1366        )
1367        .expect("t1");
1368        opt.step(
1369            &mut params,
1370            &CooGradient::new(vec![0], vec![0.3], dim).expect("coo"),
1371        )
1372        .expect("t2");
1373        opt.step(
1374            &mut params,
1375            &CooGradient::new(vec![j], vec![-0.2], dim).expect("coo"),
1376        )
1377        .expect("t3");
1378
1379        // Pure-lazy reference: single beta decay on the touch at t=3, but bias
1380        // correction still uses the global step t (1, then 3).
1381        let (b1, b2, lr, eps) = (cfg.beta1, cfg.beta2, cfg.lr, cfg.epsilon);
1382        let mut p = 1.0_f64;
1383        let mut m = 0.0_f64;
1384        let mut vv = 0.0_f64;
1385        // visit at t = 1
1386        m = b1 * m + (1.0 - b1) * 0.5;
1387        vv = b2 * vv + (1.0 - b2) * 0.5 * 0.5;
1388        p -= lr * (m / (1.0 - b1.powi(1))) / ((vv / (1.0 - b2.powi(1))).sqrt() + eps);
1389        // visit at t = 3 (single beta decay, global-step bias correction)
1390        m = b1 * m + (1.0 - b1) * -0.2;
1391        vv = b2 * vv + (1.0 - b2) * 0.2 * 0.2;
1392        p -= lr * (m / (1.0 - b1.powi(3))) / ((vv / (1.0 - b2.powi(3))).sqrt() + eps);
1393
1394        assert_relative_eq!(params[j], p, epsilon = 1e-12);
1395    }
1396
1397    // ----- Lazy Adam: 2-D embedding table variant -----
1398
1399    #[test]
1400    fn test_sparse_adam_table_matches_1d() {
1401        // A 1x4 table with a single touched column must match the 1-D optimizer.
1402        let cfg = SparseAdamConfig {
1403            lr: 0.05,
1404            ..Default::default()
1405        };
1406        let mut table_opt = SparseAdamTable::new(cfg);
1407        let mut table =
1408            Array2::<f64>::from_shape_vec((1, 4), vec![0.5, -0.3, 0.7, 0.1]).expect("table");
1409
1410        let mut vec_opt = SparseAdam::new(cfg);
1411        let mut vec_params = Array1::from_vec(vec![0.5, -0.3, 0.7, 0.1]);
1412
1413        let col = 2usize;
1414        let grads = [0.4_f64, -0.2, 0.33];
1415        for &g in &grads {
1416            let csr = CsrGradient::new(vec![0, 1], vec![col], vec![g], (1, 4)).expect("csr");
1417            table_opt.step(&mut table, &csr).expect("table step");
1418
1419            let coo = CooGradient::new(vec![col], vec![g], 4).expect("coo");
1420            vec_opt.step(&mut vec_params, &coo).expect("vec step");
1421
1422            assert_relative_eq!(table[[0, col]], vec_params[col], epsilon = 1e-12);
1423        }
1424        // Untouched columns of the table are bit-identical to their originals.
1425        assert_eq!(table[[0, 0]].to_bits(), 0.5_f64.to_bits());
1426        assert_eq!(table[[0, 3]].to_bits(), 0.1_f64.to_bits());
1427    }
1428
1429    #[test]
1430    fn test_sparse_adam_dim_mismatch_errs() {
1431        let mut opt = SparseAdam::new(SparseAdamConfig::default());
1432        let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1433        // Gradient claims a different dimension than params.
1434        let grad = CooGradient::new(vec![0], vec![1.0], 5).expect("coo");
1435        let err = opt.step(&mut params, &grad);
1436        assert!(matches!(err, Err(GpuOptimError::DimensionMismatch { .. })));
1437    }
1438}