Skip to main content

ipfrs_tensorlogic/
grad_accumulator.rs

1//! Gradient accumulation across mini-batches before optimizer step.
2//!
3//! [`TensorGradAccumulator`] buffers gradient contributions over a configurable
4//! number of accumulation steps and produces the final (optionally clipped)
5//! gradient when the accumulation window is complete.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// AccumulationMode
11// ---------------------------------------------------------------------------
12
13/// How accumulated gradients are combined when the accumulation window closes.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AccumulationMode {
16    /// Accumulate by summing — the result is the raw sum of all contributions.
17    Sum,
18    /// Accumulate by averaging — the result is the sum divided by the number
19    /// of accumulation steps.
20    Mean,
21}
22
23// ---------------------------------------------------------------------------
24// AccumulatorConfig
25// ---------------------------------------------------------------------------
26
27/// Configuration for a [`TensorGradAccumulator`].
28#[derive(Debug, Clone)]
29pub struct AccumulatorConfig {
30    /// Number of mini-batch steps to accumulate before an optimizer step.
31    pub accumulation_steps: usize,
32    /// Combination mode applied at the end of the accumulation window.
33    pub mode: AccumulationMode,
34    /// If set, gradient vectors whose L2 norm exceeds this value are
35    /// rescaled so their norm equals `max_grad_norm`.
36    pub max_grad_norm: Option<f64>,
37}
38
39impl Default for AccumulatorConfig {
40    fn default() -> Self {
41        Self {
42            accumulation_steps: 4,
43            mode: AccumulationMode::Sum,
44            max_grad_norm: None,
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// GradBuffer
51// ---------------------------------------------------------------------------
52
53/// Per-parameter gradient buffer that accumulates contributions across steps.
54#[derive(Debug, Clone)]
55pub struct GradBuffer {
56    /// Name of the parameter this buffer belongs to.
57    pub name: String,
58    /// Running sum of gradient values.
59    pub values: Vec<f64>,
60    /// Number of times gradients have been added to this buffer since the
61    /// last reset.
62    pub steps_accumulated: usize,
63}
64
65// ---------------------------------------------------------------------------
66// AccumulatorStats
67// ---------------------------------------------------------------------------
68
69/// Summary statistics for a [`TensorGradAccumulator`].
70#[derive(Debug, Clone)]
71pub struct AccumulatorStats {
72    /// Number of parameter buffers currently tracked.
73    pub buffer_count: usize,
74    /// Current accumulation step (0-based within the current window).
75    pub current_step: usize,
76    /// Configured number of accumulation steps per window.
77    pub accumulation_steps: usize,
78    /// Total number of successful [`TensorGradAccumulator::step`] calls
79    /// over the lifetime of the accumulator.
80    pub total_accumulations: u64,
81    /// Total number of gradient vectors that were clipped during
82    /// [`TensorGradAccumulator::step`].
83    pub total_clips: u64,
84}
85
86// ---------------------------------------------------------------------------
87// TensorGradAccumulator
88// ---------------------------------------------------------------------------
89
90/// Accumulates gradients across mini-batches before an optimizer step.
91///
92/// # Typical usage
93///
94/// ```
95/// use ipfrs_tensorlogic::grad_accumulator::{
96///     TensorGradAccumulator, AccumulatorConfig, AccumulationMode,
97/// };
98///
99/// let config = AccumulatorConfig {
100///     accumulation_steps: 2,
101///     mode: AccumulationMode::Mean,
102///     max_grad_norm: Some(1.0),
103/// };
104/// let mut acc = TensorGradAccumulator::new(config);
105///
106/// // First mini-batch
107/// acc.accumulate("weight", &[0.5, -0.3]).expect("example: should succeed in docs");
108/// assert!(!acc.is_ready());
109///
110/// // Second mini-batch
111/// acc.accumulate("weight", &[0.7, 0.1]).expect("example: should succeed in docs");
112/// assert!(acc.is_ready());
113///
114/// // Retrieve accumulated gradients and reset
115/// let grads = acc.step().expect("example: should succeed in docs");
116/// assert!(grads.contains_key("weight"));
117/// ```
118pub struct TensorGradAccumulator {
119    config: AccumulatorConfig,
120    buffers: HashMap<String, GradBuffer>,
121    current_step: usize,
122    total_accumulations: u64,
123    total_clips: u64,
124}
125
126impl TensorGradAccumulator {
127    /// Create a new accumulator with the given configuration.
128    pub fn new(config: AccumulatorConfig) -> Self {
129        Self {
130            config,
131            buffers: HashMap::new(),
132            current_step: 0,
133            total_accumulations: 0,
134            total_clips: 0,
135        }
136    }
137
138    /// Add gradients for the named parameter to the accumulation buffer.
139    ///
140    /// If the buffer for `name` already exists its length must match
141    /// `gradients.len()`, otherwise an error is returned.  On the first
142    /// call for a given name the buffer is created with the supplied length.
143    ///
144    /// After all parameter gradients for a mini-batch have been added the
145    /// caller should increment the internal step counter by calling
146    /// [`accumulate`](Self::accumulate) for every parameter in each
147    /// mini-batch.
148    pub fn accumulate(&mut self, name: &str, gradients: &[f64]) -> Result<(), String> {
149        if let Some(buf) = self.buffers.get_mut(name) {
150            if buf.values.len() != gradients.len() {
151                return Err(format!(
152                    "gradient size mismatch for '{}': expected {}, got {}",
153                    name,
154                    buf.values.len(),
155                    gradients.len()
156                ));
157            }
158            for (dst, src) in buf.values.iter_mut().zip(gradients.iter()) {
159                *dst += *src;
160            }
161            buf.steps_accumulated += 1;
162        } else {
163            self.buffers.insert(
164                name.to_string(),
165                GradBuffer {
166                    name: name.to_string(),
167                    values: gradients.to_vec(),
168                    steps_accumulated: 1,
169                },
170            );
171        }
172        // Track how many mini-batch steps we have seen.  We use the
173        // maximum `steps_accumulated` across all buffers as the canonical
174        // step count — this handles the common case where every parameter
175        // is accumulated once per mini-batch.
176        self.current_step = self
177            .buffers
178            .values()
179            .map(|b| b.steps_accumulated)
180            .max()
181            .unwrap_or(0);
182        Ok(())
183    }
184
185    /// Returns `true` when every buffer has accumulated at least
186    /// `accumulation_steps` contributions.
187    pub fn is_ready(&self) -> bool {
188        if self.buffers.is_empty() {
189            return false;
190        }
191        self.buffers
192            .values()
193            .all(|b| b.steps_accumulated >= self.config.accumulation_steps)
194    }
195
196    /// Consume the accumulated gradients and return the final values.
197    ///
198    /// - In [`AccumulationMode::Mean`] mode each gradient vector is divided
199    ///   by the number of steps that were accumulated.
200    /// - If `max_grad_norm` is configured, gradient vectors whose L2 norm
201    ///   exceeds it are rescaled.
202    /// - All buffers are cleared after a successful step.
203    ///
204    /// Returns an error if the accumulator is not ready (see
205    /// [`is_ready`](Self::is_ready)).
206    pub fn step(&mut self) -> Result<HashMap<String, Vec<f64>>, String> {
207        if !self.is_ready() {
208            return Err("accumulator is not ready: not all buffers have enough steps".to_string());
209        }
210
211        let mut result = HashMap::new();
212
213        for (name, buf) in &self.buffers {
214            let mut grad = buf.values.clone();
215
216            // Apply mean scaling if configured.
217            if self.config.mode == AccumulationMode::Mean && buf.steps_accumulated > 0 {
218                let scale = 1.0 / buf.steps_accumulated as f64;
219                for v in &mut grad {
220                    *v *= scale;
221                }
222            }
223
224            // Apply gradient clipping if configured.
225            if let Some(max_norm) = self.config.max_grad_norm {
226                let original_norm = Self::clip_grad_norm(&mut grad, max_norm);
227                if original_norm > max_norm {
228                    self.total_clips += 1;
229                }
230            }
231
232            result.insert(name.clone(), grad);
233        }
234
235        self.total_accumulations += 1;
236        self.buffers.clear();
237        self.current_step = 0;
238
239        Ok(result)
240    }
241
242    /// Clip a gradient vector in-place so that its L2 norm does not exceed
243    /// `max_norm`.
244    ///
245    /// Returns the **original** L2 norm (before any scaling).
246    pub fn clip_grad_norm(gradients: &mut [f64], max_norm: f64) -> f64 {
247        let norm_sq: f64 = gradients.iter().map(|x| x * x).sum();
248        let norm = norm_sq.sqrt();
249        if norm > max_norm && norm > 0.0 {
250            let scale = max_norm / norm;
251            for v in gradients.iter_mut() {
252                *v *= scale;
253            }
254        }
255        norm
256    }
257
258    /// Borrow the buffer for the named parameter, if it exists.
259    pub fn get_buffer(&self, name: &str) -> Option<&GradBuffer> {
260        self.buffers.get(name)
261    }
262
263    /// Number of parameter buffers currently tracked.
264    pub fn buffer_count(&self) -> usize {
265        self.buffers.len()
266    }
267
268    /// Clear all buffers and reset the step counter.  Lifetime statistics
269    /// (`total_accumulations`, `total_clips`) are preserved.
270    pub fn reset(&mut self) {
271        self.buffers.clear();
272        self.current_step = 0;
273    }
274
275    /// Current accumulation step (0-based within the current window).
276    pub fn current_step(&self) -> usize {
277        self.current_step
278    }
279
280    /// Return a snapshot of the accumulator's statistics.
281    pub fn stats(&self) -> AccumulatorStats {
282        AccumulatorStats {
283            buffer_count: self.buffers.len(),
284            current_step: self.current_step,
285            accumulation_steps: self.config.accumulation_steps,
286            total_accumulations: self.total_accumulations,
287            total_clips: self.total_clips,
288        }
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn default_config() -> AccumulatorConfig {
301        AccumulatorConfig::default()
302    }
303
304    fn sum_config(steps: usize) -> AccumulatorConfig {
305        AccumulatorConfig {
306            accumulation_steps: steps,
307            mode: AccumulationMode::Sum,
308            max_grad_norm: None,
309        }
310    }
311
312    fn mean_config(steps: usize) -> AccumulatorConfig {
313        AccumulatorConfig {
314            accumulation_steps: steps,
315            mode: AccumulationMode::Mean,
316            max_grad_norm: None,
317        }
318    }
319
320    // -----------------------------------------------------------------------
321    // 1. Basic construction
322    // -----------------------------------------------------------------------
323
324    #[test]
325    fn test_new_default_config() {
326        let cfg = default_config();
327        assert_eq!(cfg.accumulation_steps, 4);
328        assert_eq!(cfg.mode, AccumulationMode::Sum);
329        assert!(cfg.max_grad_norm.is_none());
330    }
331
332    #[test]
333    fn test_new_accumulator_empty() {
334        let acc = TensorGradAccumulator::new(default_config());
335        assert_eq!(acc.buffer_count(), 0);
336        assert_eq!(acc.current_step(), 0);
337        assert!(!acc.is_ready());
338    }
339
340    // -----------------------------------------------------------------------
341    // 2. Accumulate — Sum mode
342    // -----------------------------------------------------------------------
343
344    #[test]
345    fn test_accumulate_sum_single_step() {
346        let mut acc = TensorGradAccumulator::new(sum_config(1));
347        acc.accumulate("w", &[1.0, 2.0, 3.0]).ok();
348        assert!(acc.is_ready());
349        let grads = acc.step().expect("step should succeed");
350        let w = &grads["w"];
351        assert!((w[0] - 1.0).abs() < 1e-12);
352        assert!((w[1] - 2.0).abs() < 1e-12);
353        assert!((w[2] - 3.0).abs() < 1e-12);
354    }
355
356    #[test]
357    fn test_accumulate_sum_two_steps() {
358        let mut acc = TensorGradAccumulator::new(sum_config(2));
359        acc.accumulate("w", &[1.0, 2.0]).ok();
360        assert!(!acc.is_ready());
361        acc.accumulate("w", &[3.0, 4.0]).ok();
362        assert!(acc.is_ready());
363        let grads = acc.step().expect("step should succeed");
364        let w = &grads["w"];
365        assert!((w[0] - 4.0).abs() < 1e-12);
366        assert!((w[1] - 6.0).abs() < 1e-12);
367    }
368
369    #[test]
370    fn test_accumulate_sum_four_steps() {
371        let mut acc = TensorGradAccumulator::new(sum_config(4));
372        for i in 0..4 {
373            acc.accumulate("w", &[i as f64]).ok();
374        }
375        assert!(acc.is_ready());
376        let grads = acc.step().expect("step should succeed");
377        // 0 + 1 + 2 + 3 = 6
378        assert!((grads["w"][0] - 6.0).abs() < 1e-12);
379    }
380
381    // -----------------------------------------------------------------------
382    // 3. Accumulate — Mean mode
383    // -----------------------------------------------------------------------
384
385    #[test]
386    fn test_accumulate_mean_single_step() {
387        let mut acc = TensorGradAccumulator::new(mean_config(1));
388        acc.accumulate("w", &[4.0]).ok();
389        let grads = acc.step().expect("step");
390        assert!((grads["w"][0] - 4.0).abs() < 1e-12);
391    }
392
393    #[test]
394    fn test_accumulate_mean_two_steps() {
395        let mut acc = TensorGradAccumulator::new(mean_config(2));
396        acc.accumulate("w", &[2.0, 6.0]).ok();
397        acc.accumulate("w", &[4.0, 8.0]).ok();
398        let grads = acc.step().expect("step");
399        // mean of (2+4)/2 = 3, (6+8)/2 = 7
400        assert!((grads["w"][0] - 3.0).abs() < 1e-12);
401        assert!((grads["w"][1] - 7.0).abs() < 1e-12);
402    }
403
404    #[test]
405    fn test_accumulate_mean_four_steps() {
406        let mut acc = TensorGradAccumulator::new(mean_config(4));
407        for _ in 0..4 {
408            acc.accumulate("w", &[8.0]).ok();
409        }
410        let grads = acc.step().expect("step");
411        // sum=32, mean=32/4=8
412        assert!((grads["w"][0] - 8.0).abs() < 1e-12);
413    }
414
415    // -----------------------------------------------------------------------
416    // 4. Gradient clipping
417    // -----------------------------------------------------------------------
418
419    #[test]
420    fn test_clip_grad_norm_scales_down() {
421        let mut g = vec![3.0, 4.0]; // norm = 5
422        let original = TensorGradAccumulator::clip_grad_norm(&mut g, 1.0);
423        assert!((original - 5.0).abs() < 1e-12);
424        let clipped_norm: f64 = g.iter().map(|x| x * x).sum::<f64>().sqrt();
425        assert!((clipped_norm - 1.0).abs() < 1e-10);
426    }
427
428    #[test]
429    fn test_clip_grad_norm_no_change_when_within() {
430        let mut g = vec![0.3, 0.4]; // norm = 0.5
431        let original = TensorGradAccumulator::clip_grad_norm(&mut g, 1.0);
432        assert!((original - 0.5).abs() < 1e-12);
433        assert!((g[0] - 0.3).abs() < 1e-12);
434        assert!((g[1] - 0.4).abs() < 1e-12);
435    }
436
437    #[test]
438    fn test_clip_grad_norm_exact_boundary() {
439        let mut g = vec![3.0, 4.0]; // norm = 5
440        let original = TensorGradAccumulator::clip_grad_norm(&mut g, 5.0);
441        assert!((original - 5.0).abs() < 1e-12);
442        assert!((g[0] - 3.0).abs() < 1e-12);
443        assert!((g[1] - 4.0).abs() < 1e-12);
444    }
445
446    #[test]
447    fn test_clip_grad_norm_zero_vector() {
448        let mut g = vec![0.0, 0.0];
449        let original = TensorGradAccumulator::clip_grad_norm(&mut g, 1.0);
450        assert!((original).abs() < 1e-12);
451        assert!((g[0]).abs() < 1e-12);
452        assert!((g[1]).abs() < 1e-12);
453    }
454
455    #[test]
456    fn test_step_with_clipping() {
457        let config = AccumulatorConfig {
458            accumulation_steps: 1,
459            mode: AccumulationMode::Sum,
460            max_grad_norm: Some(1.0),
461        };
462        let mut acc = TensorGradAccumulator::new(config);
463        acc.accumulate("w", &[3.0, 4.0]).ok(); // norm=5, will be clipped
464        let grads = acc.step().expect("step");
465        let clipped_norm: f64 = grads["w"].iter().map(|x| x * x).sum::<f64>().sqrt();
466        assert!((clipped_norm - 1.0).abs() < 1e-10);
467    }
468
469    #[test]
470    fn test_step_clips_increments_total_clips() {
471        let config = AccumulatorConfig {
472            accumulation_steps: 1,
473            mode: AccumulationMode::Sum,
474            max_grad_norm: Some(1.0),
475        };
476        let mut acc = TensorGradAccumulator::new(config);
477        acc.accumulate("w", &[3.0, 4.0]).ok(); // will clip
478        acc.step().ok();
479        assert_eq!(acc.stats().total_clips, 1);
480    }
481
482    #[test]
483    fn test_step_no_clip_no_increment() {
484        let config = AccumulatorConfig {
485            accumulation_steps: 1,
486            mode: AccumulationMode::Sum,
487            max_grad_norm: Some(10.0),
488        };
489        let mut acc = TensorGradAccumulator::new(config);
490        acc.accumulate("w", &[0.1, 0.2]).ok(); // norm~0.22, well within
491        acc.step().ok();
492        assert_eq!(acc.stats().total_clips, 0);
493    }
494
495    // -----------------------------------------------------------------------
496    // 5. is_ready logic
497    // -----------------------------------------------------------------------
498
499    #[test]
500    fn test_is_ready_empty() {
501        let acc = TensorGradAccumulator::new(sum_config(2));
502        assert!(!acc.is_ready());
503    }
504
505    #[test]
506    fn test_is_ready_partial() {
507        let mut acc = TensorGradAccumulator::new(sum_config(3));
508        acc.accumulate("w", &[1.0]).ok();
509        acc.accumulate("w", &[2.0]).ok();
510        assert!(!acc.is_ready());
511    }
512
513    #[test]
514    fn test_is_ready_exact() {
515        let mut acc = TensorGradAccumulator::new(sum_config(2));
516        acc.accumulate("w", &[1.0]).ok();
517        acc.accumulate("w", &[2.0]).ok();
518        assert!(acc.is_ready());
519    }
520
521    #[test]
522    fn test_is_ready_multi_param_partial() {
523        let mut acc = TensorGradAccumulator::new(sum_config(2));
524        acc.accumulate("w", &[1.0]).ok();
525        acc.accumulate("w", &[2.0]).ok();
526        acc.accumulate("b", &[0.5]).ok(); // only 1 step for "b"
527        assert!(!acc.is_ready());
528    }
529
530    #[test]
531    fn test_is_ready_multi_param_all_ready() {
532        let mut acc = TensorGradAccumulator::new(sum_config(2));
533        acc.accumulate("w", &[1.0]).ok();
534        acc.accumulate("b", &[0.5]).ok();
535        acc.accumulate("w", &[2.0]).ok();
536        acc.accumulate("b", &[0.7]).ok();
537        assert!(acc.is_ready());
538    }
539
540    // -----------------------------------------------------------------------
541    // 6. step() returns correct values
542    // -----------------------------------------------------------------------
543
544    #[test]
545    fn test_step_returns_all_params() {
546        let mut acc = TensorGradAccumulator::new(sum_config(1));
547        acc.accumulate("w", &[1.0, 2.0]).ok();
548        acc.accumulate("b", &[0.5]).ok();
549        let grads = acc.step().expect("step");
550        assert_eq!(grads.len(), 2);
551        assert!(grads.contains_key("w"));
552        assert!(grads.contains_key("b"));
553    }
554
555    #[test]
556    fn test_step_clears_buffers() {
557        let mut acc = TensorGradAccumulator::new(sum_config(1));
558        acc.accumulate("w", &[1.0]).ok();
559        acc.step().ok();
560        assert_eq!(acc.buffer_count(), 0);
561        assert_eq!(acc.current_step(), 0);
562    }
563
564    #[test]
565    fn test_step_error_when_not_ready() {
566        let mut acc = TensorGradAccumulator::new(sum_config(3));
567        acc.accumulate("w", &[1.0]).ok();
568        let err = acc.step().expect_err("should fail");
569        assert!(err.contains("not ready"));
570    }
571
572    // -----------------------------------------------------------------------
573    // 7. Size mismatch error
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn test_accumulate_size_mismatch() {
578        let mut acc = TensorGradAccumulator::new(sum_config(2));
579        acc.accumulate("w", &[1.0, 2.0]).ok();
580        let err = acc
581            .accumulate("w", &[1.0, 2.0, 3.0])
582            .expect_err("should fail");
583        assert!(err.contains("size mismatch"));
584    }
585
586    #[test]
587    fn test_accumulate_size_mismatch_shorter() {
588        let mut acc = TensorGradAccumulator::new(sum_config(2));
589        acc.accumulate("w", &[1.0, 2.0, 3.0]).ok();
590        let err = acc.accumulate("w", &[1.0]).expect_err("should fail");
591        assert!(err.contains("size mismatch"));
592    }
593
594    // -----------------------------------------------------------------------
595    // 8. Reset
596    // -----------------------------------------------------------------------
597
598    #[test]
599    fn test_reset_clears_buffers() {
600        let mut acc = TensorGradAccumulator::new(sum_config(2));
601        acc.accumulate("w", &[1.0]).ok();
602        acc.accumulate("b", &[2.0]).ok();
603        acc.reset();
604        assert_eq!(acc.buffer_count(), 0);
605        assert_eq!(acc.current_step(), 0);
606        assert!(!acc.is_ready());
607    }
608
609    #[test]
610    fn test_reset_preserves_lifetime_stats() {
611        let mut acc = TensorGradAccumulator::new(sum_config(1));
612        acc.accumulate("w", &[1.0]).ok();
613        acc.step().ok();
614        let accums_before = acc.stats().total_accumulations;
615        acc.reset();
616        assert_eq!(acc.stats().total_accumulations, accums_before);
617    }
618
619    // -----------------------------------------------------------------------
620    // 9. Multiple parameters
621    // -----------------------------------------------------------------------
622
623    #[test]
624    fn test_multiple_params_independent() {
625        let mut acc = TensorGradAccumulator::new(mean_config(2));
626        acc.accumulate("w1", &[2.0, 4.0]).ok();
627        acc.accumulate("w2", &[10.0]).ok();
628        acc.accumulate("w1", &[6.0, 8.0]).ok();
629        acc.accumulate("w2", &[20.0]).ok();
630
631        let grads = acc.step().expect("step");
632        // w1: mean of (2+6)/2=4, (4+8)/2=6
633        assert!((grads["w1"][0] - 4.0).abs() < 1e-12);
634        assert!((grads["w1"][1] - 6.0).abs() < 1e-12);
635        // w2: mean of (10+20)/2=15
636        assert!((grads["w2"][0] - 15.0).abs() < 1e-12);
637    }
638
639    // -----------------------------------------------------------------------
640    // 10. get_buffer
641    // -----------------------------------------------------------------------
642
643    #[test]
644    fn test_get_buffer_existing() {
645        let mut acc = TensorGradAccumulator::new(sum_config(2));
646        acc.accumulate("w", &[1.0, 2.0]).ok();
647        let buf = acc.get_buffer("w").expect("should exist");
648        assert_eq!(buf.name, "w");
649        assert_eq!(buf.values.len(), 2);
650        assert_eq!(buf.steps_accumulated, 1);
651    }
652
653    #[test]
654    fn test_get_buffer_missing() {
655        let acc = TensorGradAccumulator::new(sum_config(2));
656        assert!(acc.get_buffer("nonexistent").is_none());
657    }
658
659    // -----------------------------------------------------------------------
660    // 11. Stats tracking
661    // -----------------------------------------------------------------------
662
663    #[test]
664    fn test_stats_initial() {
665        let acc = TensorGradAccumulator::new(sum_config(4));
666        let s = acc.stats();
667        assert_eq!(s.buffer_count, 0);
668        assert_eq!(s.current_step, 0);
669        assert_eq!(s.accumulation_steps, 4);
670        assert_eq!(s.total_accumulations, 0);
671        assert_eq!(s.total_clips, 0);
672    }
673
674    #[test]
675    fn test_stats_after_step() {
676        let mut acc = TensorGradAccumulator::new(sum_config(1));
677        acc.accumulate("w", &[1.0]).ok();
678        acc.step().ok();
679        let s = acc.stats();
680        assert_eq!(s.total_accumulations, 1);
681        assert_eq!(s.buffer_count, 0); // cleared after step
682    }
683
684    #[test]
685    fn test_stats_multiple_steps() {
686        let mut acc = TensorGradAccumulator::new(sum_config(1));
687        for _ in 0..5 {
688            acc.accumulate("w", &[1.0]).ok();
689            acc.step().ok();
690        }
691        assert_eq!(acc.stats().total_accumulations, 5);
692    }
693
694    // -----------------------------------------------------------------------
695    // 12. Empty accumulator edge cases
696    // -----------------------------------------------------------------------
697
698    #[test]
699    fn test_step_empty_accumulator() {
700        let mut acc = TensorGradAccumulator::new(sum_config(1));
701        assert!(acc.step().is_err());
702    }
703
704    #[test]
705    fn test_buffer_count_empty() {
706        let acc = TensorGradAccumulator::new(sum_config(1));
707        assert_eq!(acc.buffer_count(), 0);
708    }
709
710    #[test]
711    fn test_current_step_tracks_max() {
712        let mut acc = TensorGradAccumulator::new(sum_config(3));
713        acc.accumulate("w", &[1.0]).ok();
714        assert_eq!(acc.current_step(), 1);
715        acc.accumulate("w", &[2.0]).ok();
716        assert_eq!(acc.current_step(), 2);
717    }
718
719    // -----------------------------------------------------------------------
720    // 13. Direction preservation after clipping
721    // -----------------------------------------------------------------------
722
723    #[test]
724    fn test_clip_preserves_direction() {
725        let mut g = vec![6.0, 8.0]; // norm = 10, direction = (0.6, 0.8)
726        TensorGradAccumulator::clip_grad_norm(&mut g, 5.0);
727        // after clip: (3.0, 4.0) — direction preserved
728        assert!((g[0] - 3.0).abs() < 1e-10);
729        assert!((g[1] - 4.0).abs() < 1e-10);
730    }
731
732    // -----------------------------------------------------------------------
733    // 14. Accumulate then reset then re-use
734    // -----------------------------------------------------------------------
735
736    #[test]
737    fn test_reset_then_reuse() {
738        let mut acc = TensorGradAccumulator::new(sum_config(1));
739        acc.accumulate("w", &[1.0]).ok();
740        acc.step().ok();
741        acc.reset();
742        // Should be able to accumulate again with different size
743        acc.accumulate("w", &[1.0, 2.0, 3.0]).ok();
744        let grads = acc.step().expect("step");
745        assert_eq!(grads["w"].len(), 3);
746    }
747
748    // -----------------------------------------------------------------------
749    // 15. Mean mode with clipping
750    // -----------------------------------------------------------------------
751
752    #[test]
753    fn test_mean_with_clipping() {
754        let config = AccumulatorConfig {
755            accumulation_steps: 2,
756            mode: AccumulationMode::Mean,
757            max_grad_norm: Some(1.0),
758        };
759        let mut acc = TensorGradAccumulator::new(config);
760        acc.accumulate("w", &[6.0, 8.0]).ok(); // sum will be 12, 16
761        acc.accumulate("w", &[6.0, 8.0]).ok();
762        let grads = acc.step().expect("step");
763        // mean: (12/2, 16/2) = (6, 8), norm = 10, clipped to 1.0
764        let clipped_norm: f64 = grads["w"].iter().map(|x| x * x).sum::<f64>().sqrt();
765        assert!((clipped_norm - 1.0).abs() < 1e-10);
766        assert_eq!(acc.stats().total_clips, 1);
767    }
768}