Skip to main content

ipfrs_tensorlogic/
gradient_checkpointer.rs

1//! GradientCheckpointer — gradient accumulation, checkpointing, and replay for
2//! distributed training with fault tolerance.
3//!
4//! This module implements a production-grade gradient checkpointing system that
5//! supports configurable accumulation modes (Sum, Mean, WeightedMean), L2-norm
6//! gradient clipping, FNV-1a content checksums, and bounded checkpoint history
7//! with automatic eviction.
8
9use std::collections::{HashMap, VecDeque};
10use thiserror::Error;
11
12// ---------------------------------------------------------------------------
13// Error type
14// ---------------------------------------------------------------------------
15
16/// Errors produced by [`GradientCheckpointer`].
17#[derive(Debug, Error, Clone, PartialEq)]
18pub enum GradientCheckpointerError {
19    /// No accumulated gradients to flush.
20    #[error("no accumulated gradients available")]
21    NoAccumulatedGradients,
22
23    /// The requested checkpoint identifier does not exist.
24    #[error("checkpoint not found")]
25    CheckpointNotFound,
26
27    /// The gradient tensor for `layer` has dimension `got` but the accumulation
28    /// buffer for that layer expects `expected` elements.
29    #[error("dimension mismatch for layer '{layer}': expected {expected}, got {got}")]
30    DimensionMismatch {
31        layer: String,
32        expected: usize,
33        got: usize,
34    },
35}
36
37// ---------------------------------------------------------------------------
38// Core data types
39// ---------------------------------------------------------------------------
40
41/// A gradient tensor associated with a named layer at a particular training step.
42///
43/// `norm` is always the L2 norm of `values` and is stored pre-computed to
44/// avoid redundant recomputation during statistics gathering.
45#[derive(Debug, Clone, PartialEq)]
46pub struct GcGradientTensor {
47    /// Identifier for the model layer this gradient belongs to.
48    pub layer_id: String,
49    /// Raw gradient values (one entry per parameter).
50    pub values: Vec<f64>,
51    /// The global training step at which this gradient was computed.
52    pub step: u64,
53    /// Pre-computed L2 norm of `values`.
54    pub norm: f64,
55}
56
57impl GcGradientTensor {
58    /// Construct a new `GcGradientTensor`, computing the L2 norm automatically.
59    pub fn new(layer_id: impl Into<String>, values: Vec<f64>, step: u64) -> Self {
60        let norm = Self::compute_norm(&values);
61        Self {
62            layer_id: layer_id.into(),
63            values,
64            step,
65            norm,
66        }
67    }
68
69    /// Compute the L2 (Euclidean) norm of a gradient value slice.
70    ///
71    /// Returns `sqrt(sum(v^2))`.
72    #[inline]
73    pub fn compute_norm(values: &[f64]) -> f64 {
74        values.iter().map(|v| v * v).sum::<f64>().sqrt()
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Checkpoint identifier
80// ---------------------------------------------------------------------------
81
82/// Newtype wrapper for checkpoint identifiers.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
84pub struct CheckpointId(pub u64);
85
86impl std::fmt::Display for CheckpointId {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "ckpt:{}", self.0)
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Checkpoint
94// ---------------------------------------------------------------------------
95
96/// A snapshot of accumulated and (optionally clipped) gradients at a
97/// particular training step.
98#[derive(Debug, Clone)]
99pub struct GcGradientCheckpoint {
100    /// Unique identifier for this checkpoint.
101    pub id: CheckpointId,
102    /// The global step counter at the time `flush` was called.
103    pub step: u64,
104    /// Per-layer gradient tensors after accumulation and clipping.
105    pub gradients: HashMap<String, GcGradientTensor>,
106    /// Wall-clock timestamp (seconds since Unix epoch) at creation time,
107    /// supplied by the caller of `flush`.
108    pub created_at: u64,
109    /// Approximate compressed size (in bytes) of the serialised gradient data.
110    /// Currently approximated as `8 * total_values`.
111    pub compressed_size: usize,
112    /// FNV-1a hash over all gradient values (layer order sorted by layer_id).
113    pub checksum: u64,
114}
115
116// ---------------------------------------------------------------------------
117// Accumulation mode
118// ---------------------------------------------------------------------------
119
120/// Controls how multiple gradient tensors for the same layer are combined
121/// during a `flush`.
122#[derive(Debug, Clone)]
123pub enum GcAccumulationMode {
124    /// Element-wise sum of all accumulated tensors.
125    Sum,
126    /// Element-wise mean across all accumulated tensors.
127    Mean,
128    /// Weighted element-wise mean; weights must be the same length as the
129    /// number of accumulated tensors for each layer.  If the layer has fewer
130    /// tensors than weights, only the matching prefix of weights is used.
131    WeightedMean {
132        /// Per-tensor weights used during accumulation.
133        weights: Vec<f64>,
134    },
135}
136
137// ---------------------------------------------------------------------------
138// Configuration
139// ---------------------------------------------------------------------------
140
141/// Configuration for a [`GradientCheckpointer`].
142#[derive(Debug, Clone)]
143pub struct CheckpointerConfig {
144    /// Maximum number of checkpoints to retain.  When a new checkpoint is
145    /// created and this limit is already reached, the oldest checkpoint is
146    /// evicted.
147    pub max_checkpoints: usize,
148    /// Value count threshold above which the compressed_size estimate uses a
149    /// reduced coefficient.  Currently used only for the size approximation.
150    pub compression_threshold: usize,
151    /// Accumulation strategy applied during `flush`.
152    pub accumulation_mode: GcAccumulationMode,
153    /// If `Some(max_norm)`, gradients whose global L2 norm exceeds `max_norm`
154    /// are rescaled so that the global norm equals `max_norm`.
155    pub clip_norm: Option<f64>,
156}
157
158impl Default for CheckpointerConfig {
159    fn default() -> Self {
160        Self {
161            max_checkpoints: 10,
162            compression_threshold: 1000,
163            accumulation_mode: GcAccumulationMode::Sum,
164            clip_norm: None,
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Statistics
171// ---------------------------------------------------------------------------
172
173/// Snapshot of checkpointer statistics.
174#[derive(Debug, Clone, PartialEq)]
175pub struct GcCheckpointerStats {
176    /// Number of checkpoints currently stored.
177    pub total_checkpoints: usize,
178    /// Cumulative global step counter (incremented once per `accumulate` call).
179    pub total_steps: u64,
180    /// Mean global L2 norm across all stored checkpoints.
181    pub avg_checkpoint_norm: f64,
182    /// Maximum global L2 norm observed across all stored checkpoints.
183    pub max_checkpoint_norm: f64,
184    /// Total number of pending (not-yet-flushed) gradient tensors.
185    pub pending_tensors: usize,
186}
187
188// ---------------------------------------------------------------------------
189// Checkpointer
190// ---------------------------------------------------------------------------
191
192/// Production-grade gradient checkpointing engine.
193///
194/// Collects [`GcGradientTensor`] objects via [`accumulate`][Self::accumulate],
195/// combines them into a [`GcGradientCheckpoint`] via [`flush`][Self::flush],
196/// and maintains a bounded history of the most recent checkpoints.
197pub struct GradientCheckpointer {
198    /// Runtime configuration.
199    pub config: CheckpointerConfig,
200    /// Pending gradients, keyed by layer id.
201    accumulated: HashMap<String, Vec<GcGradientTensor>>,
202    /// Bounded history of flushed checkpoints.
203    checkpoints: VecDeque<GcGradientCheckpoint>,
204    /// Monotonically increasing checkpoint identifier source.
205    next_checkpoint_id: u64,
206    /// Cumulative step counter.  Incremented once per `accumulate` call.
207    pub global_step: u64,
208}
209
210impl GradientCheckpointer {
211    /// Create a new checkpointer with the given configuration.
212    pub fn new(config: CheckpointerConfig) -> Self {
213        Self {
214            config,
215            accumulated: HashMap::new(),
216            checkpoints: VecDeque::new(),
217            next_checkpoint_id: 0,
218            global_step: 0,
219        }
220    }
221
222    // -----------------------------------------------------------------------
223    // Accumulation
224    // -----------------------------------------------------------------------
225
226    /// Push a gradient tensor into the accumulation buffer for its layer.
227    ///
228    /// The global step counter is incremented once per call.
229    pub fn accumulate(&mut self, gradient: GcGradientTensor) {
230        self.global_step += 1;
231        self.accumulated
232            .entry(gradient.layer_id.clone())
233            .or_default()
234            .push(gradient);
235    }
236
237    // -----------------------------------------------------------------------
238    // Flush
239    // -----------------------------------------------------------------------
240
241    /// Flush accumulated gradients into a new checkpoint.
242    ///
243    /// Applies the configured accumulation mode over all pending tensors for
244    /// each layer, optionally clips the resulting global norm, computes a
245    /// FNV-1a checksum, and stores the checkpoint.  If the checkpoint count
246    /// exceeds `max_checkpoints`, the oldest entry is evicted.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`GradientCheckpointerError::NoAccumulatedGradients`] when
251    /// there are no pending tensors.
252    pub fn flush(&mut self, now: u64) -> Result<GcGradientCheckpoint, GradientCheckpointerError> {
253        if self.accumulated.is_empty() {
254            return Err(GradientCheckpointerError::NoAccumulatedGradients);
255        }
256
257        // Step 1 — accumulate per layer
258        let mut merged: HashMap<String, GcGradientTensor> = HashMap::new();
259        for (layer_id, tensors) in &self.accumulated {
260            let merged_values = self.combine_tensors(layer_id, tensors)?;
261            let step = tensors.last().map(|t| t.step).unwrap_or(self.global_step);
262            let tensor = GcGradientTensor::new(layer_id.clone(), merged_values, step);
263            merged.insert(layer_id.clone(), tensor);
264        }
265
266        // Step 2 — optional gradient clipping
267        if let Some(max_norm) = self.config.clip_norm {
268            let global_norm = Self::compute_global_norm_map(&merged);
269            if global_norm > max_norm && global_norm > 0.0 {
270                let scale = max_norm / global_norm;
271                for tensor in merged.values_mut() {
272                    for v in &mut tensor.values {
273                        *v *= scale;
274                    }
275                    tensor.norm = GcGradientTensor::compute_norm(&tensor.values);
276                }
277            }
278        }
279
280        // Step 3 — checksum
281        let checksum = compute_checksum(&merged);
282
283        // Step 4 — compressed size estimate
284        let total_values: usize = merged.values().map(|t| t.values.len()).sum();
285        let compressed_size = if total_values > self.config.compression_threshold {
286            (total_values as f64 * 8.0 * 0.6) as usize
287        } else {
288            total_values * 8
289        };
290
291        // Step 5 — build checkpoint
292        let id = CheckpointId(self.next_checkpoint_id);
293        self.next_checkpoint_id += 1;
294
295        let checkpoint = GcGradientCheckpoint {
296            id,
297            step: self.global_step,
298            gradients: merged,
299            created_at: now,
300            compressed_size,
301            checksum,
302        };
303
304        // Step 6 — store with eviction
305        if self.checkpoints.len() >= self.config.max_checkpoints {
306            self.checkpoints.pop_front();
307        }
308        self.checkpoints.push_back(checkpoint.clone());
309
310        // Step 7 — clear accumulation buffer
311        self.accumulated.clear();
312
313        Ok(checkpoint)
314    }
315
316    // -----------------------------------------------------------------------
317    // Replay
318    // -----------------------------------------------------------------------
319
320    /// Return gradient tensors from `checkpoint` sorted by `layer_id`.
321    pub fn replay(&self, checkpoint: &GcGradientCheckpoint) -> Vec<GcGradientTensor> {
322        let mut tensors: Vec<GcGradientTensor> = checkpoint.gradients.values().cloned().collect();
323        tensors.sort_by(|a, b| a.layer_id.cmp(&b.layer_id));
324        tensors
325    }
326
327    // -----------------------------------------------------------------------
328    // Lookup
329    // -----------------------------------------------------------------------
330
331    /// Return a reference to the most recently flushed checkpoint, if any.
332    pub fn latest_checkpoint(&self) -> Option<&GcGradientCheckpoint> {
333        self.checkpoints.back()
334    }
335
336    /// Return a reference to the checkpoint with the given `id`, if it still
337    /// resides in the bounded history.
338    pub fn checkpoint_by_id(&self, id: CheckpointId) -> Option<&GcGradientCheckpoint> {
339        self.checkpoints.iter().find(|c| c.id == id)
340    }
341
342    // -----------------------------------------------------------------------
343    // Diff / norms
344    // -----------------------------------------------------------------------
345
346    /// Compute the per-layer L2 distance between two checkpoints.
347    ///
348    /// For each layer present in either checkpoint the function returns the L2
349    /// distance between the corresponding gradient value vectors.  If a layer
350    /// is absent in one of the checkpoints its contribution is `0.0`.
351    pub fn diff(&self, a: &GcGradientCheckpoint, b: &GcGradientCheckpoint) -> HashMap<String, f64> {
352        let mut result: HashMap<String, f64> = HashMap::new();
353
354        // Collect all layer ids from both checkpoints
355        let mut all_layers: Vec<String> = a
356            .gradients
357            .keys()
358            .chain(b.gradients.keys())
359            .cloned()
360            .collect();
361        all_layers.sort();
362        all_layers.dedup();
363
364        for layer in all_layers {
365            let dist = match (a.gradients.get(&layer), b.gradients.get(&layer)) {
366                (Some(ta), Some(tb)) => l2_distance(&ta.values, &tb.values),
367                (Some(ta), None) => GcGradientTensor::compute_norm(&ta.values),
368                (None, Some(tb)) => GcGradientTensor::compute_norm(&tb.values),
369                (None, None) => 0.0,
370            };
371            result.insert(layer, dist);
372        }
373
374        result
375    }
376
377    /// Compute the global L2 norm of a checkpoint (sqrt of sum of squared
378    /// per-layer norms).
379    pub fn global_norm(&self, checkpoint: &GcGradientCheckpoint) -> f64 {
380        Self::compute_global_norm_map(&checkpoint.gradients)
381    }
382
383    // -----------------------------------------------------------------------
384    // Pending state
385    // -----------------------------------------------------------------------
386
387    /// Return a sorted list of layer IDs that have pending (unflushed) gradients.
388    pub fn pending_layers(&self) -> Vec<&str> {
389        let mut ids: Vec<&str> = self.accumulated.keys().map(String::as_str).collect();
390        ids.sort();
391        ids
392    }
393
394    /// Return the total count of pending gradient tensors across all layers.
395    pub fn pending_count(&self) -> usize {
396        self.accumulated.values().map(Vec::len).sum()
397    }
398
399    // -----------------------------------------------------------------------
400    // Statistics
401    // -----------------------------------------------------------------------
402
403    /// Return a snapshot of current checkpointer statistics.
404    pub fn stats(&self) -> GcCheckpointerStats {
405        let norms: Vec<f64> = self
406            .checkpoints
407            .iter()
408            .map(|c| Self::compute_global_norm_map(&c.gradients))
409            .collect();
410
411        let total = norms.len();
412        let avg_checkpoint_norm = if total == 0 {
413            0.0
414        } else {
415            norms.iter().sum::<f64>() / total as f64
416        };
417        let max_checkpoint_norm = norms.iter().cloned().fold(0.0_f64, f64::max);
418
419        GcCheckpointerStats {
420            total_checkpoints: total,
421            total_steps: self.global_step,
422            avg_checkpoint_norm,
423            max_checkpoint_norm,
424            pending_tensors: self.pending_count(),
425        }
426    }
427
428    // -----------------------------------------------------------------------
429    // Private helpers
430    // -----------------------------------------------------------------------
431
432    fn combine_tensors(
433        &self,
434        layer_id: &str,
435        tensors: &[GcGradientTensor],
436    ) -> Result<Vec<f64>, GradientCheckpointerError> {
437        debug_assert!(
438            !tensors.is_empty(),
439            "combine_tensors called with empty slice"
440        );
441
442        let dim = tensors[0].values.len();
443
444        // Validate that all tensors have the same dimensionality
445        for t in tensors.iter().skip(1) {
446            if t.values.len() != dim {
447                return Err(GradientCheckpointerError::DimensionMismatch {
448                    layer: layer_id.to_string(),
449                    expected: dim,
450                    got: t.values.len(),
451                });
452            }
453        }
454
455        match &self.config.accumulation_mode {
456            GcAccumulationMode::Sum => {
457                let mut result = vec![0.0f64; dim];
458                for t in tensors {
459                    for (r, v) in result.iter_mut().zip(t.values.iter()) {
460                        *r += v;
461                    }
462                }
463                Ok(result)
464            }
465
466            GcAccumulationMode::Mean => {
467                let mut result = vec![0.0f64; dim];
468                for t in tensors {
469                    for (r, v) in result.iter_mut().zip(t.values.iter()) {
470                        *r += v;
471                    }
472                }
473                let n = tensors.len() as f64;
474                for r in &mut result {
475                    *r /= n;
476                }
477                Ok(result)
478            }
479
480            GcAccumulationMode::WeightedMean { weights } => {
481                let mut result = vec![0.0f64; dim];
482                let mut weight_sum = 0.0f64;
483
484                for (i, t) in tensors.iter().enumerate() {
485                    let w = weights.get(i).copied().unwrap_or(1.0);
486                    weight_sum += w;
487                    for (r, v) in result.iter_mut().zip(t.values.iter()) {
488                        *r += v * w;
489                    }
490                }
491
492                if weight_sum != 0.0 {
493                    for r in &mut result {
494                        *r /= weight_sum;
495                    }
496                }
497
498                Ok(result)
499            }
500        }
501    }
502
503    fn compute_global_norm_map(gradients: &HashMap<String, GcGradientTensor>) -> f64 {
504        gradients
505            .values()
506            .map(|t| t.norm * t.norm)
507            .sum::<f64>()
508            .sqrt()
509    }
510}
511
512// ---------------------------------------------------------------------------
513// Free-standing helpers
514// ---------------------------------------------------------------------------
515
516/// Compute FNV-1a hash over a slice of `f64` values.
517///
518/// This is the standard 64-bit FNV-1a hash applied byte-by-byte over the
519/// little-endian bit pattern of each float.
520#[inline]
521pub fn fnv1a_f64_slice(values: &[f64]) -> u64 {
522    let mut h: u64 = 14695981039346656037;
523    for v in values {
524        for b in v.to_bits().to_le_bytes() {
525            h ^= b as u64;
526            h = h.wrapping_mul(1099511628211);
527        }
528    }
529    h
530}
531
532/// Compute the L2 distance between two equal-length slices.
533///
534/// Returns `0.0` when both slices are empty.
535#[inline]
536fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
537    let min_len = a.len().min(b.len());
538    let mut sum = 0.0f64;
539    for i in 0..min_len {
540        let d = a[i] - b[i];
541        sum += d * d;
542    }
543    // If one slice is longer, treat the excess elements as 0 in the other
544    for &v in a.iter().skip(min_len) {
545        sum += v * v;
546    }
547    for &v in b.iter().skip(min_len) {
548        sum += v * v;
549    }
550    sum.sqrt()
551}
552
553/// Compute the FNV-1a checksum of all gradient values in a checkpoint, with
554/// layers processed in sorted order.
555fn compute_checksum(gradients: &HashMap<String, GcGradientTensor>) -> u64 {
556    let mut layer_ids: Vec<&String> = gradients.keys().collect();
557    layer_ids.sort();
558
559    let mut all_values: Vec<f64> = Vec::new();
560    for id in layer_ids {
561        if let Some(t) = gradients.get(id) {
562            all_values.extend_from_slice(&t.values);
563        }
564    }
565
566    fnv1a_f64_slice(&all_values)
567}
568
569// ---------------------------------------------------------------------------
570// Tests
571// ---------------------------------------------------------------------------
572
573#[cfg(test)]
574mod tests {
575    use super::{
576        fnv1a_f64_slice, CheckpointId, CheckpointerConfig, GcAccumulationMode, GcGradientTensor,
577        GradientCheckpointer, GradientCheckpointerError,
578    };
579
580    // -----------------------------------------------------------------------
581    // Helper constructors
582    // -----------------------------------------------------------------------
583
584    fn default_checkpointer() -> GradientCheckpointer {
585        GradientCheckpointer::new(CheckpointerConfig::default())
586    }
587
588    fn tensor(layer: &str, values: Vec<f64>, step: u64) -> GcGradientTensor {
589        GcGradientTensor::new(layer, values, step)
590    }
591
592    // -----------------------------------------------------------------------
593    // GcGradientTensor
594    // -----------------------------------------------------------------------
595
596    #[test]
597    fn test_gradient_tensor_norm_zero() {
598        let t = tensor("l0", vec![0.0, 0.0, 0.0], 1);
599        assert_eq!(t.norm, 0.0);
600    }
601
602    #[test]
603    fn test_gradient_tensor_norm_unit() {
604        let t = tensor("l0", vec![1.0, 0.0, 0.0], 1);
605        assert!((t.norm - 1.0).abs() < 1e-12);
606    }
607
608    #[test]
609    fn test_gradient_tensor_norm_3_4_5() {
610        let t = tensor("l0", vec![3.0, 4.0], 1);
611        assert!((t.norm - 5.0).abs() < 1e-12);
612    }
613
614    #[test]
615    fn test_gradient_tensor_compute_norm_static() {
616        let norm = GcGradientTensor::compute_norm(&[1.0, 2.0, 2.0]);
617        assert!((norm - 3.0).abs() < 1e-12);
618    }
619
620    #[test]
621    fn test_gradient_tensor_layer_id_stored() {
622        let t = tensor("my_layer", vec![1.0], 42);
623        assert_eq!(t.layer_id, "my_layer");
624        assert_eq!(t.step, 42);
625    }
626
627    // -----------------------------------------------------------------------
628    // CheckpointId
629    // -----------------------------------------------------------------------
630
631    #[test]
632    fn test_checkpoint_id_display() {
633        let id = CheckpointId(7);
634        assert_eq!(id.to_string(), "ckpt:7");
635    }
636
637    #[test]
638    fn test_checkpoint_id_ordering() {
639        assert!(CheckpointId(1) < CheckpointId(2));
640        assert_eq!(CheckpointId(5), CheckpointId(5));
641    }
642
643    // -----------------------------------------------------------------------
644    // Accumulate & global_step
645    // -----------------------------------------------------------------------
646
647    #[test]
648    fn test_accumulate_increments_global_step() {
649        let mut cp = default_checkpointer();
650        assert_eq!(cp.global_step, 0);
651        cp.accumulate(tensor("l0", vec![1.0], 1));
652        assert_eq!(cp.global_step, 1);
653        cp.accumulate(tensor("l0", vec![2.0], 2));
654        assert_eq!(cp.global_step, 2);
655    }
656
657    #[test]
658    fn test_pending_count_and_layers() {
659        let mut cp = default_checkpointer();
660        cp.accumulate(tensor("layer_a", vec![1.0], 1));
661        cp.accumulate(tensor("layer_b", vec![2.0], 2));
662        cp.accumulate(tensor("layer_a", vec![3.0], 3));
663
664        assert_eq!(cp.pending_count(), 3);
665        let layers = cp.pending_layers();
666        assert_eq!(layers, vec!["layer_a", "layer_b"]);
667    }
668
669    #[test]
670    fn test_pending_layers_sorted() {
671        let mut cp = default_checkpointer();
672        cp.accumulate(tensor("z_layer", vec![1.0], 1));
673        cp.accumulate(tensor("a_layer", vec![1.0], 2));
674        cp.accumulate(tensor("m_layer", vec![1.0], 3));
675        let layers = cp.pending_layers();
676        assert_eq!(layers, vec!["a_layer", "m_layer", "z_layer"]);
677    }
678
679    #[test]
680    fn test_flush_clears_accumulated() {
681        let mut cp = default_checkpointer();
682        cp.accumulate(tensor("l0", vec![1.0], 1));
683        let _ckpt = cp.flush(100).expect("flush failed");
684        assert_eq!(cp.pending_count(), 0);
685        assert!(cp.pending_layers().is_empty());
686    }
687
688    // -----------------------------------------------------------------------
689    // Flush with no accumulated gradients
690    // -----------------------------------------------------------------------
691
692    #[test]
693    fn test_flush_empty_returns_error() {
694        let mut cp = default_checkpointer();
695        let result = cp.flush(0);
696        assert!(matches!(
697            result,
698            Err(GradientCheckpointerError::NoAccumulatedGradients)
699        ));
700    }
701
702    // -----------------------------------------------------------------------
703    // Accumulation modes
704    // -----------------------------------------------------------------------
705
706    #[test]
707    fn test_flush_sum_mode() {
708        let mut cp = default_checkpointer(); // default mode = Sum
709        cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
710        cp.accumulate(tensor("l0", vec![3.0, 4.0], 2));
711        let ckpt = cp.flush(0).expect("flush failed");
712        let t = ckpt.gradients.get("l0").expect("layer missing");
713        assert!((t.values[0] - 4.0).abs() < 1e-12);
714        assert!((t.values[1] - 6.0).abs() < 1e-12);
715    }
716
717    #[test]
718    fn test_flush_mean_mode() {
719        let config = CheckpointerConfig {
720            accumulation_mode: GcAccumulationMode::Mean,
721            ..Default::default()
722        };
723        let mut cp = GradientCheckpointer::new(config);
724        cp.accumulate(tensor("l0", vec![2.0, 4.0], 1));
725        cp.accumulate(tensor("l0", vec![4.0, 8.0], 2));
726        let ckpt = cp.flush(0).expect("flush failed");
727        let t = ckpt.gradients.get("l0").expect("layer missing");
728        assert!((t.values[0] - 3.0).abs() < 1e-12);
729        assert!((t.values[1] - 6.0).abs() < 1e-12);
730    }
731
732    #[test]
733    fn test_flush_weighted_mean_mode() {
734        let config = CheckpointerConfig {
735            accumulation_mode: GcAccumulationMode::WeightedMean {
736                weights: vec![1.0, 3.0],
737            },
738            ..Default::default()
739        };
740        let mut cp = GradientCheckpointer::new(config);
741        cp.accumulate(tensor("l0", vec![0.0, 0.0], 1)); // weight 1
742        cp.accumulate(tensor("l0", vec![4.0, 8.0], 2)); // weight 3
743        let ckpt = cp.flush(0).expect("flush failed");
744        let t = ckpt.gradients.get("l0").expect("layer missing");
745        // (0*1 + 4*3) / 4 = 3.0
746        assert!((t.values[0] - 3.0).abs() < 1e-12);
747        // (0*1 + 8*3) / 4 = 6.0
748        assert!((t.values[1] - 6.0).abs() < 1e-12);
749    }
750
751    #[test]
752    fn test_flush_weighted_mean_uses_unit_weights_for_missing() {
753        // Only one weight provided; second tensor should fall back to weight 1
754        let config = CheckpointerConfig {
755            accumulation_mode: GcAccumulationMode::WeightedMean { weights: vec![2.0] },
756            ..Default::default()
757        };
758        let mut cp = GradientCheckpointer::new(config);
759        cp.accumulate(tensor("l0", vec![2.0], 1)); // weight 2
760        cp.accumulate(tensor("l0", vec![2.0], 2)); // weight 1 (fallback)
761        let ckpt = cp.flush(0).expect("flush failed");
762        let t = ckpt.gradients.get("l0").expect("layer missing");
763        // (2*2 + 2*1) / 3 = 2.0
764        assert!((t.values[0] - 2.0).abs() < 1e-12);
765    }
766
767    // -----------------------------------------------------------------------
768    // Gradient clipping
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn test_clip_norm_scales_down() {
773        let config = CheckpointerConfig {
774            clip_norm: Some(1.0),
775            ..Default::default()
776        };
777        let mut cp = GradientCheckpointer::new(config);
778        // norm = 5.0 (3-4-5 right triangle)
779        cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
780        let ckpt = cp.flush(0).expect("flush failed");
781        let global = cp.global_norm(&ckpt);
782        assert!(global <= 1.0 + 1e-9, "global norm {} > 1.0", global);
783    }
784
785    #[test]
786    fn test_clip_norm_no_change_when_below_threshold() {
787        let config = CheckpointerConfig {
788            clip_norm: Some(10.0),
789            ..Default::default()
790        };
791        let mut cp = GradientCheckpointer::new(config);
792        cp.accumulate(tensor("l0", vec![1.0, 1.0], 1));
793        let ckpt = cp.flush(0).expect("flush failed");
794        let t = ckpt.gradients.get("l0").expect("layer missing");
795        // Values should be unchanged (norm ≈ 1.414 < 10)
796        assert!((t.values[0] - 1.0).abs() < 1e-10);
797    }
798
799    #[test]
800    fn test_clip_norm_multi_layer() {
801        // Two layers each with norm 3.0 → global norm = sqrt(18) ≈ 4.24
802        let config = CheckpointerConfig {
803            clip_norm: Some(2.0),
804            ..Default::default()
805        };
806        let mut cp = GradientCheckpointer::new(config);
807        cp.accumulate(tensor("l0", vec![3.0, 0.0], 1));
808        cp.accumulate(tensor("l1", vec![0.0, 3.0], 1));
809        let ckpt = cp.flush(0).expect("flush failed");
810        let global = cp.global_norm(&ckpt);
811        assert!(global <= 2.0 + 1e-9, "global norm {} > 2.0", global);
812    }
813
814    // -----------------------------------------------------------------------
815    // Checkpoint eviction
816    // -----------------------------------------------------------------------
817
818    #[test]
819    fn test_max_checkpoints_eviction() {
820        let config = CheckpointerConfig {
821            max_checkpoints: 3,
822            ..Default::default()
823        };
824        let mut cp = GradientCheckpointer::new(config);
825        let mut ids = Vec::new();
826        for i in 0..5u64 {
827            cp.accumulate(tensor("l0", vec![i as f64], i));
828            let ckpt = cp.flush(i).expect("flush failed");
829            ids.push(ckpt.id);
830        }
831        // Only the last 3 checkpoints should remain
832        assert!(cp.checkpoint_by_id(ids[0]).is_none());
833        assert!(cp.checkpoint_by_id(ids[1]).is_none());
834        assert!(cp.checkpoint_by_id(ids[2]).is_some());
835        assert!(cp.checkpoint_by_id(ids[3]).is_some());
836        assert!(cp.checkpoint_by_id(ids[4]).is_some());
837    }
838
839    #[test]
840    fn test_latest_checkpoint_returns_last_flushed() {
841        let mut cp = default_checkpointer();
842        cp.accumulate(tensor("l0", vec![1.0], 1));
843        let first = cp.flush(10).expect("flush failed");
844        cp.accumulate(tensor("l0", vec![2.0], 2));
845        let second = cp.flush(20).expect("flush failed");
846        let latest = cp.latest_checkpoint().expect("no latest");
847        assert_eq!(latest.id, second.id);
848        assert_ne!(latest.id, first.id);
849    }
850
851    #[test]
852    fn test_latest_checkpoint_none_initially() {
853        let cp = default_checkpointer();
854        assert!(cp.latest_checkpoint().is_none());
855    }
856
857    #[test]
858    fn test_checkpoint_by_id_found() {
859        let mut cp = default_checkpointer();
860        cp.accumulate(tensor("l0", vec![1.0], 1));
861        let ckpt = cp.flush(0).expect("flush failed");
862        let found = cp.checkpoint_by_id(ckpt.id).expect("not found");
863        assert_eq!(found.id, ckpt.id);
864    }
865
866    #[test]
867    fn test_checkpoint_by_id_not_found() {
868        let cp = default_checkpointer();
869        assert!(cp.checkpoint_by_id(CheckpointId(9999)).is_none());
870    }
871
872    // -----------------------------------------------------------------------
873    // Replay
874    // -----------------------------------------------------------------------
875
876    #[test]
877    fn test_replay_sorted_by_layer_id() {
878        let mut cp = default_checkpointer();
879        cp.accumulate(tensor("z_layer", vec![3.0], 1));
880        cp.accumulate(tensor("a_layer", vec![1.0], 2));
881        cp.accumulate(tensor("m_layer", vec![2.0], 3));
882        let ckpt = cp.flush(0).expect("flush failed");
883        let replayed = cp.replay(&ckpt);
884        let ids: Vec<&str> = replayed.iter().map(|t| t.layer_id.as_str()).collect();
885        assert_eq!(ids, vec!["a_layer", "m_layer", "z_layer"]);
886    }
887
888    #[test]
889    fn test_replay_values_preserved() {
890        let mut cp = default_checkpointer();
891        cp.accumulate(tensor("l0", vec![1.5, 2.5], 1));
892        let ckpt = cp.flush(0).expect("flush failed");
893        let replayed = cp.replay(&ckpt);
894        assert_eq!(replayed.len(), 1);
895        assert!((replayed[0].values[0] - 1.5).abs() < 1e-12);
896        assert!((replayed[0].values[1] - 2.5).abs() < 1e-12);
897    }
898
899    // -----------------------------------------------------------------------
900    // Diff
901    // -----------------------------------------------------------------------
902
903    #[test]
904    fn test_diff_same_checkpoint_is_zero() {
905        let mut cp = default_checkpointer();
906        cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
907        let ckpt = cp.flush(0).expect("flush failed");
908        let diff = cp.diff(&ckpt, &ckpt);
909        let d = diff["l0"];
910        assert!(d.abs() < 1e-12, "expected 0 distance, got {}", d);
911    }
912
913    #[test]
914    fn test_diff_known_distance() {
915        let mut cp = default_checkpointer();
916        cp.accumulate(tensor("l0", vec![0.0, 0.0], 1));
917        let a = cp.flush(0).expect("flush a");
918        cp.accumulate(tensor("l0", vec![3.0, 4.0], 2));
919        let b = cp.flush(1).expect("flush b");
920        let diff = cp.diff(&a, &b);
921        assert!(
922            (diff["l0"] - 5.0).abs() < 1e-12,
923            "expected 5.0, got {}",
924            diff["l0"]
925        );
926    }
927
928    #[test]
929    fn test_diff_missing_layer_returns_norm() {
930        let mut cp = default_checkpointer();
931        cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
932        let a = cp.flush(0).expect("flush a");
933        // b has l1 but not l0
934        cp.accumulate(tensor("l1", vec![1.0], 2));
935        let b = cp.flush(1).expect("flush b");
936        let diff = cp.diff(&a, &b);
937        // l0 is only in a — distance = norm(l0) = 5.0
938        assert!(
939            (diff["l0"] - 5.0).abs() < 1e-12,
940            "expected 5.0, got {}",
941            diff["l0"]
942        );
943        // l1 is only in b — distance = norm(l1) = 1.0
944        assert!(
945            (diff["l1"] - 1.0).abs() < 1e-12,
946            "expected 1.0, got {}",
947            diff["l1"]
948        );
949    }
950
951    // -----------------------------------------------------------------------
952    // Global norm
953    // -----------------------------------------------------------------------
954
955    #[test]
956    fn test_global_norm_single_layer() {
957        let mut cp = default_checkpointer();
958        cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
959        let ckpt = cp.flush(0).expect("flush failed");
960        let gnorm = cp.global_norm(&ckpt);
961        assert!((gnorm - 5.0).abs() < 1e-12, "expected 5.0, got {}", gnorm);
962    }
963
964    #[test]
965    fn test_global_norm_multi_layer() {
966        let mut cp = default_checkpointer();
967        // layer_0 norm = 3, layer_1 norm = 4 → global = 5
968        cp.accumulate(tensor("l0", vec![3.0, 0.0], 1));
969        cp.accumulate(tensor("l1", vec![0.0, 4.0], 2));
970        let ckpt = cp.flush(0).expect("flush failed");
971        let gnorm = cp.global_norm(&ckpt);
972        assert!((gnorm - 5.0).abs() < 1e-12, "expected 5.0, got {}", gnorm);
973    }
974
975    // -----------------------------------------------------------------------
976    // Stats
977    // -----------------------------------------------------------------------
978
979    #[test]
980    fn test_stats_empty() {
981        let cp = default_checkpointer();
982        let s = cp.stats();
983        assert_eq!(s.total_checkpoints, 0);
984        assert_eq!(s.total_steps, 0);
985        assert_eq!(s.avg_checkpoint_norm, 0.0);
986        assert_eq!(s.max_checkpoint_norm, 0.0);
987        assert_eq!(s.pending_tensors, 0);
988    }
989
990    #[test]
991    fn test_stats_after_flush() {
992        let mut cp = default_checkpointer();
993        cp.accumulate(tensor("l0", vec![3.0, 4.0], 1));
994        let _ = cp.flush(0).expect("flush failed");
995        let s = cp.stats();
996        assert_eq!(s.total_checkpoints, 1);
997        assert_eq!(s.total_steps, 1);
998        // global norm = 5.0
999        assert!((s.avg_checkpoint_norm - 5.0).abs() < 1e-9);
1000        assert!((s.max_checkpoint_norm - 5.0).abs() < 1e-9);
1001        assert_eq!(s.pending_tensors, 0);
1002    }
1003
1004    #[test]
1005    fn test_stats_pending_tensors() {
1006        let mut cp = default_checkpointer();
1007        cp.accumulate(tensor("l0", vec![1.0], 1));
1008        cp.accumulate(tensor("l1", vec![2.0], 2));
1009        let s = cp.stats();
1010        assert_eq!(s.pending_tensors, 2);
1011    }
1012
1013    // -----------------------------------------------------------------------
1014    // Checksum
1015    // -----------------------------------------------------------------------
1016
1017    #[test]
1018    fn test_checksum_deterministic() {
1019        let mut cp = default_checkpointer();
1020        cp.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 1));
1021        let a = cp.flush(0).expect("flush a");
1022        let mut cp2 = default_checkpointer();
1023        cp2.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 1));
1024        let b = cp2.flush(0).expect("flush b");
1025        assert_eq!(a.checksum, b.checksum);
1026    }
1027
1028    #[test]
1029    fn test_checksum_differs_for_different_values() {
1030        let mut cp = default_checkpointer();
1031        cp.accumulate(tensor("l0", vec![1.0], 1));
1032        let a = cp.flush(0).expect("flush a");
1033        cp.accumulate(tensor("l0", vec![2.0], 2));
1034        let b = cp.flush(1).expect("flush b");
1035        assert_ne!(a.checksum, b.checksum);
1036    }
1037
1038    #[test]
1039    fn test_fnv1a_empty_slice() {
1040        let h = fnv1a_f64_slice(&[]);
1041        assert_eq!(h, 14695981039346656037u64);
1042    }
1043
1044    #[test]
1045    fn test_fnv1a_known_value() {
1046        let h1 = fnv1a_f64_slice(&[1.0]);
1047        let h2 = fnv1a_f64_slice(&[1.0]);
1048        assert_eq!(h1, h2);
1049        // A different value should produce a different hash
1050        let h3 = fnv1a_f64_slice(&[2.0]);
1051        assert_ne!(h1, h3);
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Dimension mismatch
1056    // -----------------------------------------------------------------------
1057
1058    #[test]
1059    fn test_dimension_mismatch_error() {
1060        let mut cp = default_checkpointer();
1061        cp.accumulate(tensor("l0", vec![1.0, 2.0], 1));
1062        cp.accumulate(tensor("l0", vec![1.0, 2.0, 3.0], 2)); // wrong dim
1063        let result = cp.flush(0);
1064        assert!(matches!(
1065            result,
1066            Err(GradientCheckpointerError::DimensionMismatch { .. })
1067        ));
1068    }
1069
1070    // -----------------------------------------------------------------------
1071    // Checkpoint metadata
1072    // -----------------------------------------------------------------------
1073
1074    #[test]
1075    fn test_checkpoint_created_at() {
1076        let mut cp = default_checkpointer();
1077        cp.accumulate(tensor("l0", vec![1.0], 1));
1078        let ckpt = cp.flush(12345).expect("flush failed");
1079        assert_eq!(ckpt.created_at, 12345);
1080    }
1081
1082    #[test]
1083    fn test_compressed_size_positive() {
1084        let mut cp = default_checkpointer();
1085        cp.accumulate(tensor("l0", vec![1.0; 10], 1));
1086        let ckpt = cp.flush(0).expect("flush failed");
1087        assert!(ckpt.compressed_size > 0);
1088    }
1089
1090    #[test]
1091    fn test_checkpoint_ids_are_monotonically_increasing() {
1092        let mut cp = default_checkpointer();
1093        let mut prev = CheckpointId(0);
1094        for i in 0..5u64 {
1095            cp.accumulate(tensor("l0", vec![i as f64], i));
1096            let ckpt = cp.flush(i).expect("flush failed");
1097            if i > 0 {
1098                assert!(ckpt.id > prev, "id {:?} not > {:?}", ckpt.id, prev);
1099            }
1100            prev = ckpt.id;
1101        }
1102    }
1103
1104    #[test]
1105    fn test_step_recorded_in_checkpoint() {
1106        let mut cp = default_checkpointer();
1107        cp.accumulate(tensor("l0", vec![1.0], 1));
1108        cp.accumulate(tensor("l0", vec![2.0], 2));
1109        let ckpt = cp.flush(0).expect("flush failed");
1110        // global_step was incremented twice
1111        assert_eq!(ckpt.step, 2);
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // Large-scale / integration
1116    // -----------------------------------------------------------------------
1117
1118    #[test]
1119    fn test_multiple_layers_flush_and_replay() {
1120        let mut cp = default_checkpointer();
1121        for layer in ["encoder", "decoder", "classifier"] {
1122            for step in 0..5u64 {
1123                cp.accumulate(tensor(layer, vec![step as f64, (step + 1) as f64], step));
1124            }
1125        }
1126        let ckpt = cp.flush(999).expect("flush failed");
1127        assert_eq!(ckpt.gradients.len(), 3);
1128        let replayed = cp.replay(&ckpt);
1129        assert_eq!(replayed.len(), 3);
1130        // Verify sorted order
1131        assert_eq!(replayed[0].layer_id, "classifier");
1132        assert_eq!(replayed[1].layer_id, "decoder");
1133        assert_eq!(replayed[2].layer_id, "encoder");
1134    }
1135
1136    #[test]
1137    fn test_multiple_flush_cycles() {
1138        let mut cp = default_checkpointer();
1139        for cycle in 0..5u64 {
1140            cp.accumulate(tensor("l0", vec![cycle as f64], cycle));
1141            let ckpt = cp.flush(cycle).expect("flush failed");
1142            assert_eq!(ckpt.id, CheckpointId(cycle));
1143        }
1144        let s = cp.stats();
1145        assert_eq!(s.total_checkpoints, 5);
1146    }
1147
1148    #[test]
1149    fn test_no_unwrap_path_checkpoint_not_found_error() {
1150        let err = GradientCheckpointerError::CheckpointNotFound;
1151        assert_eq!(err.to_string(), "checkpoint not found");
1152    }
1153
1154    #[test]
1155    fn test_dimension_mismatch_error_message() {
1156        let err = GradientCheckpointerError::DimensionMismatch {
1157            layer: "l0".to_string(),
1158            expected: 4,
1159            got: 3,
1160        };
1161        let msg = err.to_string();
1162        assert!(msg.contains("l0"));
1163        assert!(msg.contains('4'));
1164        assert!(msg.contains('3'));
1165    }
1166}