Skip to main content

ipfrs_tensorlogic/
checkpoint_v2.rs

1//! Versioned tensor checkpoint manager with delta compression.
2//!
3//! [`TensorCheckpointManagerV2`] manages a history of named tensor checkpoints,
4//! automatically selecting between full snapshots and delta-compressed entries
5//! based on a configurable [`CheckpointPolicy`].  Delta entries record only the
6//! tensors that changed relative to the previous checkpoint, enabling efficient
7//! storage of model evolution in federated learning.
8
9use std::collections::HashSet;
10
11// ---------------------------------------------------------------------------
12// CheckpointDelta
13// ---------------------------------------------------------------------------
14
15/// Describes the incremental change between two checkpoint versions.
16#[derive(Debug, Clone)]
17pub struct CheckpointDelta {
18    /// Version this delta was computed from.
19    pub from_version: u64,
20    /// Version this delta describes.
21    pub to_version: u64,
22    /// Names of tensors that changed between the two versions.
23    pub changed_tensors: Vec<String>,
24    /// Approximate compressed size of the changes in bytes.
25    pub delta_bytes: u64,
26}
27
28impl CheckpointDelta {
29    /// Ratio of delta bytes to full checkpoint bytes.
30    ///
31    /// Returns a value in `[0.0, 1.0]` (or higher if compression is not
32    /// beneficial).  A value of `0.0` indicates a zero-size full checkpoint,
33    /// which is guarded against by using `full_bytes.max(1)`.
34    #[must_use]
35    pub fn compression_ratio(&self, full_bytes: u64) -> f64 {
36        self.delta_bytes as f64 / full_bytes.max(1) as f64
37    }
38}
39
40// ---------------------------------------------------------------------------
41// CheckpointEntry
42// ---------------------------------------------------------------------------
43
44/// A single checkpoint record stored by the manager.
45#[derive(Debug, Clone)]
46pub struct CheckpointEntry {
47    /// Monotonically increasing version number assigned by the manager.
48    pub version: u64,
49    /// Unix timestamp (seconds) supplied by the caller at save time.
50    pub timestamp_secs: u64,
51    /// Names of all tensors present in this checkpoint.
52    pub tensor_names: Vec<String>,
53    /// Total uncompressed size of the checkpoint in bytes.
54    pub full_bytes: u64,
55    /// Free-form labels attached to this entry (e.g. `"best"`, `"epoch_10"`).
56    pub tags: Vec<String>,
57    /// If this is a delta checkpoint, the version it was delta'd from.
58    pub delta_from: Option<u64>,
59}
60
61impl CheckpointEntry {
62    /// Returns `true` when this entry was stored as a delta rather than a full
63    /// snapshot.
64    #[must_use]
65    pub fn is_delta(&self) -> bool {
66        self.delta_from.is_some()
67    }
68}
69
70// ---------------------------------------------------------------------------
71// CheckpointPolicy
72// ---------------------------------------------------------------------------
73
74/// Controls eviction and storage-format decisions made by the manager.
75#[derive(Debug, Clone)]
76pub struct CheckpointPolicy {
77    /// Maximum number of checkpoints retained before eviction.  Defaults to
78    /// `10`.
79    pub max_checkpoints: usize,
80    /// Every `keep_every_n` versions a *full* checkpoint is unconditionally
81    /// stored (instead of a delta).  Defaults to `5`.
82    pub keep_every_n: u64,
83    /// If a checkpoint's `full_bytes` exceeds this threshold the manager will
84    /// attempt to store it as a delta instead.  Defaults to `1 MiB`.
85    pub delta_threshold_bytes: u64,
86}
87
88impl Default for CheckpointPolicy {
89    fn default() -> Self {
90        Self {
91            max_checkpoints: 10,
92            keep_every_n: 5,
93            delta_threshold_bytes: 1_048_576,
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// CheckpointStats
100// ---------------------------------------------------------------------------
101
102/// Aggregate statistics maintained by the manager.
103#[derive(Debug, Clone, Default)]
104pub struct CheckpointStats {
105    /// Total number of checkpoint entries currently held.
106    pub total_checkpoints: usize,
107    /// Number of those entries that are full snapshots.
108    pub full_checkpoints: usize,
109    /// Number of those entries that are delta-compressed.
110    pub delta_checkpoints: usize,
111    /// Sum of `full_bytes` across all retained entries.
112    pub total_bytes: u64,
113}
114
115impl CheckpointStats {
116    /// Fraction of retained checkpoints that are delta-compressed.
117    ///
118    /// Returns `0.0` when the manager is empty (guarded by `total.max(1)`).
119    #[must_use]
120    pub fn delta_ratio(&self) -> f64 {
121        self.delta_checkpoints as f64 / self.total_checkpoints.max(1) as f64
122    }
123}
124
125// ---------------------------------------------------------------------------
126// TensorCheckpointManagerV2
127// ---------------------------------------------------------------------------
128
129/// Versioned tensor checkpoint manager with delta compression.
130///
131/// # Example
132///
133/// ```rust
134/// use ipfrs_tensorlogic::checkpoint_v2::{CheckpointPolicy, TensorCheckpointManagerV2};
135///
136/// let mut mgr = TensorCheckpointManagerV2::new(CheckpointPolicy::default());
137/// let v1 = mgr.save(vec!["w1".into(), "b1".into()], 2_000_000, 1_000, vec![]);
138/// let v2 = mgr.save(vec!["w1".into(), "b1".into(), "w2".into()], 2_000_000, 2_000, vec!["best".into()]);
139/// assert_eq!(mgr.latest().expect("example: should succeed in docs").version, v2);
140/// ```
141#[derive(Debug)]
142pub struct TensorCheckpointManagerV2 {
143    /// All retained checkpoints, kept sorted by ascending `version`.
144    pub checkpoints: Vec<CheckpointEntry>,
145    /// Policy governing eviction and storage format.
146    pub policy: CheckpointPolicy,
147    /// Running statistics.
148    pub stats: CheckpointStats,
149    /// Version that will be assigned to the *next* call to [`save`](Self::save).
150    pub next_version: u64,
151}
152
153impl TensorCheckpointManagerV2 {
154    /// Create a new, empty manager with the given policy.
155    #[must_use]
156    pub fn new(policy: CheckpointPolicy) -> Self {
157        Self {
158            checkpoints: Vec::new(),
159            policy,
160            stats: CheckpointStats::default(),
161            next_version: 1,
162        }
163    }
164
165    // -----------------------------------------------------------------------
166    // save
167    // -----------------------------------------------------------------------
168
169    /// Persist a new checkpoint and return its assigned version number.
170    ///
171    /// # Storage format selection
172    ///
173    /// A *full* checkpoint is stored when any of the following is true:
174    ///
175    /// * There are no prior checkpoints.
176    /// * `version % keep_every_n == 0`.
177    /// * `full_bytes <= delta_threshold_bytes`.
178    ///
179    /// Otherwise a *delta* checkpoint is stored referencing the previous
180    /// version.  Its `changed_tensors` field lists tensors absent from the
181    /// previous checkpoint (i.e. newly added tensors).
182    ///
183    /// # Eviction
184    ///
185    /// Before inserting, if the manager is already at `max_checkpoints`
186    /// capacity, the oldest entry with *no* tags is evicted.  If every entry
187    /// is tagged the new checkpoint is still pushed (the list grows beyond the
188    /// configured maximum).
189    pub fn save(
190        &mut self,
191        tensor_names: Vec<String>,
192        full_bytes: u64,
193        timestamp_secs: u64,
194        tags: Vec<String>,
195    ) -> u64 {
196        let version = self.next_version;
197        self.next_version += 1;
198
199        // Decide full vs delta.
200        let delta_from = self.decide_delta_from(version, full_bytes);
201
202        // Determine changed tensors when storing as a delta.
203        let changed_tensors: Vec<String> = if let Some(prev_ver) = delta_from {
204            if let Some(prev) = self.checkpoints.iter().find(|e| e.version == prev_ver) {
205                let prev_set: HashSet<&str> =
206                    prev.tensor_names.iter().map(String::as_str).collect();
207                tensor_names
208                    .iter()
209                    .filter(|n| !prev_set.contains(n.as_str()))
210                    .cloned()
211                    .collect()
212            } else {
213                Vec::new()
214            }
215        } else {
216            Vec::new()
217        };
218
219        let is_delta = delta_from.is_some();
220
221        // Evict if at capacity and there is at least one un-tagged entry.
222        if self.checkpoints.len() >= self.policy.max_checkpoints {
223            if let Some(idx) = self.checkpoints.iter().position(|e| e.tags.is_empty()) {
224                let evicted = self.checkpoints.remove(idx);
225                // Update stats for the evicted entry.
226                self.stats.total_checkpoints = self.stats.total_checkpoints.saturating_sub(1);
227                if evicted.is_delta() {
228                    self.stats.delta_checkpoints = self.stats.delta_checkpoints.saturating_sub(1);
229                } else {
230                    self.stats.full_checkpoints = self.stats.full_checkpoints.saturating_sub(1);
231                }
232                self.stats.total_bytes = self.stats.total_bytes.saturating_sub(evicted.full_bytes);
233            }
234        }
235
236        // Build and push the new entry.
237        let entry = CheckpointEntry {
238            version,
239            timestamp_secs,
240            tensor_names,
241            full_bytes,
242            tags,
243            delta_from,
244        };
245
246        self.stats.total_bytes += entry.full_bytes;
247        if is_delta {
248            self.stats.delta_checkpoints += 1;
249        } else {
250            self.stats.full_checkpoints += 1;
251        }
252        self.stats.total_checkpoints += 1;
253
254        // Insert sorted by version (append is correct because versions are
255        // monotonically increasing).
256        self.checkpoints.push(entry);
257
258        // `changed_tensors` computed above is intentionally discarded here;
259        // it is only used by `compute_delta` on demand.  Keeping it in scope
260        // avoids a dead-variable warning.
261        let _ = changed_tensors;
262
263        version
264    }
265
266    // -----------------------------------------------------------------------
267    // get / latest / versions
268    // -----------------------------------------------------------------------
269
270    /// Look up a checkpoint by exact version number.
271    #[must_use]
272    pub fn get(&self, version: u64) -> Option<&CheckpointEntry> {
273        self.checkpoints.iter().find(|e| e.version == version)
274    }
275
276    /// Return the checkpoint with the highest version number, or `None` when
277    /// the manager is empty.
278    #[must_use]
279    pub fn latest(&self) -> Option<&CheckpointEntry> {
280        self.checkpoints.last()
281    }
282
283    /// Return all retained version numbers in ascending order.
284    #[must_use]
285    pub fn versions(&self) -> Vec<u64> {
286        self.checkpoints.iter().map(|e| e.version).collect()
287    }
288
289    // -----------------------------------------------------------------------
290    // compute_delta
291    // -----------------------------------------------------------------------
292
293    /// Compute a [`CheckpointDelta`] between two existing versions.
294    ///
295    /// * `v1` — the *from* version (must exist).
296    /// * `v2` — the *to* version (must exist, should be `> v1`).
297    ///
298    /// Returns `None` if either version is not found.
299    ///
300    /// Changed tensors are those present in `v2` but not in `v1` (newly added).
301    /// `delta_bytes` is estimated as `full_bytes(v2) * 0.3`.
302    #[must_use]
303    pub fn compute_delta(&self, v1: u64, v2: u64) -> Option<CheckpointDelta> {
304        let entry1 = self.get(v1)?;
305        let entry2 = self.get(v2)?;
306
307        let set1: HashSet<&str> = entry1.tensor_names.iter().map(String::as_str).collect();
308        let changed_tensors: Vec<String> = entry2
309            .tensor_names
310            .iter()
311            .filter(|n| !set1.contains(n.as_str()))
312            .cloned()
313            .collect();
314
315        let delta_bytes = (entry2.full_bytes as f64 * 0.3) as u64;
316
317        Some(CheckpointDelta {
318            from_version: v1,
319            to_version: v2,
320            changed_tensors,
321            delta_bytes,
322        })
323    }
324
325    // -----------------------------------------------------------------------
326    // tag_checkpoint
327    // -----------------------------------------------------------------------
328
329    /// Attach `tag` to the checkpoint at `version`.
330    ///
331    /// Returns `true` if the version exists and the tag was added, `false`
332    /// otherwise (including when `version` does not exist).
333    pub fn tag_checkpoint(&mut self, version: u64, tag: String) -> bool {
334        if let Some(entry) = self.checkpoints.iter_mut().find(|e| e.version == version) {
335            entry.tags.push(tag);
336            true
337        } else {
338            false
339        }
340    }
341
342    // -----------------------------------------------------------------------
343    // stats
344    // -----------------------------------------------------------------------
345
346    /// Return a reference to the current aggregate statistics.
347    #[must_use]
348    pub fn stats(&self) -> &CheckpointStats {
349        &self.stats
350    }
351
352    // -----------------------------------------------------------------------
353    // Private helpers
354    // -----------------------------------------------------------------------
355
356    /// Determine the `delta_from` value for a new checkpoint.
357    ///
358    /// Returns `None` for a full checkpoint, or `Some(prev_version)` for a
359    /// delta.
360    fn decide_delta_from(&self, version: u64, full_bytes: u64) -> Option<u64> {
361        // Full checkpoint conditions.
362        if self.checkpoints.is_empty() {
363            return None;
364        }
365        if version.is_multiple_of(self.policy.keep_every_n) {
366            return None;
367        }
368        if full_bytes <= self.policy.delta_threshold_bytes {
369            return None;
370        }
371        // Delta: reference the most recent checkpoint.
372        self.checkpoints.last().map(|e| e.version)
373    }
374}
375
376// ---------------------------------------------------------------------------
377// Tests
378// ---------------------------------------------------------------------------
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn default_mgr() -> TensorCheckpointManagerV2 {
385        TensorCheckpointManagerV2::new(CheckpointPolicy::default())
386    }
387
388    // 1. new() produces an empty manager.
389    #[test]
390    fn test_new_empty() {
391        let mgr = default_mgr();
392        assert!(mgr.checkpoints.is_empty());
393        assert_eq!(mgr.next_version, 1);
394        assert_eq!(mgr.stats.total_checkpoints, 0);
395    }
396
397    // 2. First checkpoint is always full.
398    #[test]
399    fn test_first_checkpoint_is_full() {
400        let mut mgr = default_mgr();
401        let v = mgr.save(vec!["w".into()], 2_000_000, 0, vec![]);
402        let entry = mgr.get(v).expect("entry should exist");
403        assert!(!entry.is_delta(), "first checkpoint must be full");
404        assert!(entry.delta_from.is_none());
405    }
406
407    // 3. save() assigns monotonically increasing versions.
408    #[test]
409    fn test_monotonic_versions() {
410        let mut mgr = default_mgr();
411        let v1 = mgr.save(vec!["a".into()], 2_000_000, 1, vec![]);
412        let v2 = mgr.save(vec!["a".into()], 2_000_000, 2, vec![]);
413        let v3 = mgr.save(vec!["a".into()], 2_000_000, 3, vec![]);
414        assert!(v1 < v2 && v2 < v3, "versions must be strictly increasing");
415    }
416
417    // 4. Every keep_every_n version is a full checkpoint.
418    #[test]
419    fn test_keep_every_n_is_full() {
420        let policy = CheckpointPolicy {
421            keep_every_n: 5,
422            delta_threshold_bytes: 0, // never skip delta on size grounds
423            max_checkpoints: 20,
424        };
425        let mut mgr = TensorCheckpointManagerV2::new(policy);
426        // Save enough checkpoints so that version 5 is reached.
427        let mut last_full_ver = 0u64;
428        for i in 1u64..=10 {
429            let v = mgr.save(vec!["t".into()], 2_000_000, i, vec![]);
430            if v.is_multiple_of(5) {
431                last_full_ver = v;
432            }
433        }
434        let entry = mgr.get(last_full_ver).expect("version should exist");
435        assert!(
436            !entry.is_delta(),
437            "version divisible by keep_every_n must be full"
438        );
439    }
440
441    // 5. Between milestones, checkpoints are stored as deltas.
442    #[test]
443    fn test_between_milestones_is_delta() {
444        let policy = CheckpointPolicy {
445            keep_every_n: 5,
446            delta_threshold_bytes: 0,
447            max_checkpoints: 20,
448        };
449        let mut mgr = TensorCheckpointManagerV2::new(policy);
450        // v1 is always full; v2, v3, v4 should be deltas.
451        let _v1 = mgr.save(vec!["t".into()], 2_000_000, 1, vec![]);
452        let v2 = mgr.save(vec!["t".into()], 2_000_000, 2, vec![]);
453        assert!(mgr.get(v2).expect("v2 missing").is_delta());
454    }
455
456    // 6. Small checkpoints (below threshold) are stored as full.
457    #[test]
458    fn test_small_checkpoint_is_full() {
459        let mut mgr = default_mgr();
460        // First checkpoint always full — advance to v2 with a large one, then
461        // store a tiny one at v3.
462        let _v1 = mgr.save(vec!["a".into()], 2_000_000, 1, vec![]);
463        let _v2 = mgr.save(vec!["a".into()], 2_000_000, 2, vec![]);
464        let v3 = mgr.save(vec!["a".into()], 100, 3, vec![]); // 100 bytes < 1 MiB
465        assert!(
466            !mgr.get(v3).expect("v3 missing").is_delta(),
467            "checkpoint below delta threshold must be full"
468        );
469    }
470
471    // 7. save() evicts the oldest non-tagged entry when at capacity.
472    #[test]
473    fn test_evicts_oldest_untagged() {
474        let policy = CheckpointPolicy {
475            max_checkpoints: 3,
476            keep_every_n: 100, // prevent forced-full interference
477            delta_threshold_bytes: 0,
478        };
479        let mut mgr = TensorCheckpointManagerV2::new(policy);
480        let v1 = mgr.save(vec!["t".into()], 2_000_000, 1, vec![]);
481        let _v2 = mgr.save(vec!["t".into()], 2_000_000, 2, vec![]);
482        let _v3 = mgr.save(vec!["t".into()], 2_000_000, 3, vec![]);
483        // At capacity; next save should evict v1.
484        let _v4 = mgr.save(vec!["t".into()], 2_000_000, 4, vec![]);
485        assert!(
486            mgr.get(v1).is_none(),
487            "evicted entry should not be retrievable"
488        );
489        assert_eq!(mgr.checkpoints.len(), 3);
490    }
491
492    // 8. Tagged checkpoints are NOT evicted even when they are the oldest.
493    #[test]
494    fn test_does_not_evict_tagged() {
495        let policy = CheckpointPolicy {
496            max_checkpoints: 3,
497            keep_every_n: 100,
498            delta_threshold_bytes: 0,
499        };
500        let mut mgr = TensorCheckpointManagerV2::new(policy);
501        let v1 = mgr.save(vec!["t".into()], 2_000_000, 1, vec!["best".into()]);
502        let _v2 = mgr.save(vec!["t".into()], 2_000_000, 2, vec![]);
503        let _v3 = mgr.save(vec!["t".into()], 2_000_000, 3, vec![]);
504        // At capacity; v1 is tagged so v2 should be evicted instead.
505        let _v4 = mgr.save(vec!["t".into()], 2_000_000, 4, vec![]);
506        assert!(
507            mgr.get(v1).is_some(),
508            "tagged checkpoint must not be evicted"
509        );
510    }
511
512    // 9. get() returns Some for known version and None for unknown.
513    #[test]
514    fn test_get_found_and_not_found() {
515        let mut mgr = default_mgr();
516        let v = mgr.save(vec!["t".into()], 100, 0, vec![]);
517        assert!(mgr.get(v).is_some());
518        assert!(mgr.get(v + 999).is_none());
519    }
520
521    // 10. latest() returns the checkpoint with the highest version.
522    #[test]
523    fn test_latest_returns_highest() {
524        let mut mgr = default_mgr();
525        mgr.save(vec!["a".into()], 100, 1, vec![]);
526        let v_last = mgr.save(vec!["b".into()], 100, 2, vec![]);
527        assert_eq!(mgr.latest().expect("test: should succeed").version, v_last);
528    }
529
530    // 11. versions() returns all version numbers in sorted order.
531    #[test]
532    fn test_versions_sorted() {
533        let mut mgr = default_mgr();
534        let v1 = mgr.save(vec![], 100, 1, vec![]);
535        let v2 = mgr.save(vec![], 100, 2, vec![]);
536        let v3 = mgr.save(vec![], 100, 3, vec![]);
537        let vs = mgr.versions();
538        assert_eq!(vs, vec![v1, v2, v3]);
539        assert!(vs.windows(2).all(|w| w[0] < w[1]));
540    }
541
542    // 12. compute_delta returns Some for two known versions.
543    #[test]
544    fn test_compute_delta_some() {
545        let mut mgr = default_mgr();
546        let v1 = mgr.save(vec!["w1".into()], 100, 1, vec![]);
547        let v2 = mgr.save(vec!["w1".into(), "w2".into()], 100, 2, vec![]);
548        assert!(mgr.compute_delta(v1, v2).is_some());
549    }
550
551    // 13. compute_delta returns None when either version does not exist.
552    #[test]
553    fn test_compute_delta_none_missing_version() {
554        let mut mgr = default_mgr();
555        let v1 = mgr.save(vec!["w1".into()], 100, 1, vec![]);
556        assert!(mgr.compute_delta(v1, 9999).is_none());
557        assert!(mgr.compute_delta(9999, v1).is_none());
558    }
559
560    // 14. compute_delta reports correct changed_tensors.
561    #[test]
562    fn test_compute_delta_changed_tensors_correct() {
563        let mut mgr = default_mgr();
564        let v1 = mgr.save(vec!["w1".into(), "b1".into()], 100, 1, vec![]);
565        let v2 = mgr.save(
566            vec!["w1".into(), "b1".into(), "w2".into(), "b2".into()],
567            100,
568            2,
569            vec![],
570        );
571        let delta = mgr.compute_delta(v1, v2).expect("delta should exist");
572        let mut changed = delta.changed_tensors.clone();
573        changed.sort();
574        assert_eq!(changed, vec!["b2".to_string(), "w2".to_string()]);
575    }
576
577    // 15. tag_checkpoint returns true for existing version, false for unknown.
578    #[test]
579    fn test_tag_checkpoint_returns_correct_bool() {
580        let mut mgr = default_mgr();
581        let v = mgr.save(vec![], 100, 0, vec![]);
582        assert!(mgr.tag_checkpoint(v, "epoch_1".into()));
583        assert!(!mgr.tag_checkpoint(9999, "phantom".into()));
584        let entry = mgr.get(v).expect("entry should exist");
585        assert!(entry.tags.contains(&"epoch_1".to_string()));
586    }
587
588    // 16. stats().delta_ratio() is correct.
589    #[test]
590    fn test_stats_delta_ratio() {
591        let policy = CheckpointPolicy {
592            keep_every_n: 100,
593            delta_threshold_bytes: 0,
594            max_checkpoints: 20,
595        };
596        let mut mgr = TensorCheckpointManagerV2::new(policy);
597        // v1 is always full.
598        mgr.save(vec!["t".into()], 2_000_000, 1, vec![]);
599        // v2, v3 should be deltas (keep_every_n=100, threshold=0).
600        mgr.save(vec!["t".into()], 2_000_000, 2, vec![]);
601        mgr.save(vec!["t".into()], 2_000_000, 3, vec![]);
602
603        let stats = mgr.stats();
604        assert_eq!(stats.total_checkpoints, 3);
605        assert_eq!(stats.full_checkpoints, 1);
606        assert_eq!(stats.delta_checkpoints, 2);
607        let ratio = stats.delta_ratio();
608        assert!((ratio - 2.0 / 3.0).abs() < 1e-9);
609    }
610
611    // 17. CheckpointEntry::is_delta reflects delta_from field.
612    #[test]
613    fn test_checkpoint_entry_is_delta() {
614        let full = CheckpointEntry {
615            version: 1,
616            timestamp_secs: 0,
617            tensor_names: vec![],
618            full_bytes: 0,
619            tags: vec![],
620            delta_from: None,
621        };
622        let delta = CheckpointEntry {
623            version: 2,
624            timestamp_secs: 0,
625            tensor_names: vec![],
626            full_bytes: 0,
627            tags: vec![],
628            delta_from: Some(1),
629        };
630        assert!(!full.is_delta());
631        assert!(delta.is_delta());
632    }
633}