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}