Skip to main content

ipfrs_tensorlogic/
checkpoint_manager.rs

1//! Checkpoint pruning and validation for gradient checkpoints.
2//!
3//! This module provides [`CheckpointPruner`] for retention-policy-based pruning
4//! and [`CheckpointValidator`] for CRC-32 integrity verification.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9// ---------------------------------------------------------------------------
10// CRC-32 (IEEE 802.3 polynomial, table-driven, pure Rust)
11// ---------------------------------------------------------------------------
12
13/// Lookup table for CRC-32 (IEEE 802.3 / Ethernet, reversed polynomial 0xEDB88320).
14const CRC32_TABLE: [u32; 256] = build_crc32_table();
15
16const fn build_crc32_table() -> [u32; 256] {
17    let mut table = [0u32; 256];
18    let mut i = 0usize;
19    while i < 256 {
20        let mut crc = i as u32;
21        let mut j = 0;
22        while j < 8 {
23            if crc & 1 != 0 {
24                crc = (crc >> 1) ^ 0xEDB8_8320u32;
25            } else {
26                crc >>= 1;
27            }
28            j += 1;
29        }
30        table[i] = crc;
31        i += 1;
32    }
33    table
34}
35
36/// Compute the CRC-32 (IEEE polynomial) of `data`.
37///
38/// # Example
39/// ```
40/// use ipfrs_tensorlogic::checkpoint_manager::crc32;
41/// assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
42/// ```
43pub fn crc32(data: &[u8]) -> u32 {
44    let mut crc: u32 = 0xFFFF_FFFF;
45    for &byte in data {
46        let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
47        crc = (crc >> 8) ^ CRC32_TABLE[idx];
48    }
49    crc ^ 0xFFFF_FFFF
50}
51
52// ---------------------------------------------------------------------------
53// Error types
54// ---------------------------------------------------------------------------
55
56/// Errors that can occur during checkpoint validation.
57#[derive(Debug, Error, PartialEq, Eq)]
58pub enum ValidationError {
59    /// The computed CRC-32 does not match the stored value.
60    #[error("CRC-32 mismatch: expected 0x{expected:08X}, actual 0x{actual:08X}")]
61    CrcMismatch { expected: u32, actual: u32 },
62
63    /// The data length does not match the stored size.
64    #[error("size mismatch: expected {expected} bytes, actual {actual} bytes")]
65    SizeMismatch { expected: u64, actual: u64 },
66}
67
68// ---------------------------------------------------------------------------
69// CheckpointRecord
70// ---------------------------------------------------------------------------
71
72/// Metadata about a single gradient checkpoint.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct CheckpointRecord {
75    /// Human-readable checkpoint identifier (e.g. `"checkpoint_round_42"`).
76    pub id: String,
77    /// Content address (CID string) of the checkpoint block.
78    pub cid: String,
79    /// Training round at which this checkpoint was created.
80    pub round: u64,
81    /// Wall-clock creation time in Unix milliseconds.
82    pub created_at_ms: u64,
83    /// Serialised size of the checkpoint data in bytes.
84    pub size_bytes: u64,
85    /// CRC-32 checksum of the checkpoint data.
86    pub crc32: u32,
87    /// Pinned checkpoints survive all pruning rules (unless the policy
88    /// explicitly ignores pins, which the default does not).
89    pub is_pinned: bool,
90}
91
92// ---------------------------------------------------------------------------
93// RetentionPolicy
94// ---------------------------------------------------------------------------
95
96/// Configures which checkpoints the [`CheckpointPruner`] should keep.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct RetentionPolicy {
99    /// Always keep the *N* most recent checkpoints (ordered by `round`).
100    /// Default: 5.
101    pub keep_last_n: usize,
102    /// Pinned checkpoints are never deleted. Default: `true`.
103    pub keep_pinned: bool,
104    /// If set, prune oldest checkpoints until the total size is at or below
105    /// this threshold (bytes). Pinned checkpoints count toward the budget but
106    /// are never deleted because of it (they can only be deleted when
107    /// `keep_pinned` is `false`).
108    pub max_total_bytes: Option<u64>,
109    /// Never prune checkpoints whose age (now_ms − created_at_ms) is less
110    /// than this value. Default: 0 (no minimum age restriction).
111    pub min_age_ms: u64,
112}
113
114impl Default for RetentionPolicy {
115    fn default() -> Self {
116        Self {
117            keep_last_n: 5,
118            keep_pinned: true,
119            max_total_bytes: None,
120            min_age_ms: 0,
121        }
122    }
123}
124
125// ---------------------------------------------------------------------------
126// CheckpointPruner
127// ---------------------------------------------------------------------------
128
129/// Applies a [`RetentionPolicy`] to a collection of [`CheckpointRecord`]s and
130/// determines which records should be deleted.
131///
132/// # Usage
133/// ```
134/// use ipfrs_tensorlogic::checkpoint_manager::{CheckpointRecord, CheckpointPruner, RetentionPolicy};
135///
136/// let records = vec![
137///     CheckpointRecord { id: "cp_1".into(), cid: "Qm1".into(), round: 1,
138///         created_at_ms: 0, size_bytes: 100, crc32: 0, is_pinned: false },
139///     CheckpointRecord { id: "cp_2".into(), cid: "Qm2".into(), round: 2,
140///         created_at_ms: 0, size_bytes: 100, crc32: 0, is_pinned: false },
141/// ];
142/// let policy = RetentionPolicy { keep_last_n: 1, ..Default::default() };
143/// let mut pruner = CheckpointPruner::new(records, policy);
144/// let to_delete = pruner.prune();
145/// assert_eq!(to_delete.len(), 1);
146/// assert_eq!(to_delete[0].id, "cp_1");
147/// ```
148pub struct CheckpointPruner {
149    /// Checkpoint records, kept sorted by `round` ascending at all times.
150    records: Vec<CheckpointRecord>,
151    /// Retention policy to apply.
152    policy: RetentionPolicy,
153    /// Unix timestamp (ms) used as "now" for age-based calculations.
154    /// Defaults to 0, meaning *all* checkpoints are considered arbitrarily old
155    /// unless overridden via [`CheckpointPruner::with_now_ms`].
156    now_ms: u64,
157}
158
159impl CheckpointPruner {
160    /// Create a new pruner.  Records are sorted by `round` ascending.
161    pub fn new(mut records: Vec<CheckpointRecord>, policy: RetentionPolicy) -> Self {
162        records.sort_by_key(|r| r.round);
163        Self {
164            records,
165            policy,
166            now_ms: 0,
167        }
168    }
169
170    /// Override the "current time" used for age-based pruning decisions.
171    ///
172    /// If not called, `now_ms` defaults to `0`, which means every checkpoint
173    /// has an age ≥ 0 and the `min_age_ms` guard applies correctly.
174    pub fn with_now_ms(mut self, now_ms: u64) -> Self {
175        self.now_ms = now_ms;
176        self
177    }
178
179    /// Apply the retention policy and return the list of records to **delete**.
180    ///
181    /// After the call the pruner's internal record list contains only the
182    /// surviving records; repeated calls therefore return an empty list.
183    pub fn prune(&mut self) -> Vec<CheckpointRecord> {
184        // Mark each record with its index (ascending round order).
185        // We build a boolean "keep" mask and then partition.
186        let n = self.records.len();
187        let mut keep = vec![false; n];
188
189        // --- Step 1: keep_last_n ---------------------------------------------------
190        // The records are sorted ascending by round, so the last `keep_last_n`
191        // entries are the most-recent ones.
192        let keep_last_n = self.policy.keep_last_n;
193        if keep_last_n > 0 && n > 0 {
194            let start = n.saturating_sub(keep_last_n);
195            for k in &mut keep[start..] {
196                *k = true;
197            }
198        }
199
200        // --- Step 2: keep_pinned --------------------------------------------------
201        if self.policy.keep_pinned {
202            for (k, rec) in keep.iter_mut().zip(self.records.iter()) {
203                if rec.is_pinned {
204                    *k = true;
205                }
206            }
207        }
208
209        // --- Step 3: min_age_ms ---------------------------------------------------
210        // Protect checkpoints that are too young to prune.
211        for (k, rec) in keep.iter_mut().zip(self.records.iter()) {
212            let age_ms = self.now_ms.saturating_sub(rec.created_at_ms);
213            if age_ms < self.policy.min_age_ms {
214                *k = true;
215            }
216        }
217
218        // --- Step 4: max_total_bytes ----------------------------------------------
219        // If the survivors still exceed the byte budget, prune oldest
220        // non-pinned (or non-protected) survivors from oldest to newest.
221        if let Some(budget) = self.policy.max_total_bytes {
222            // Compute current total across *all* records (we haven't removed
223            // anything yet; deletions happen at the end).
224            let survivor_bytes: u64 = self
225                .records
226                .iter()
227                .enumerate()
228                .filter(|(i, _)| keep[*i])
229                .map(|(_, r)| r.size_bytes)
230                .sum();
231
232            if survivor_bytes > budget {
233                let mut excess = survivor_bytes - budget;
234                // Walk from oldest to newest; try to evict survivors that are
235                // not pinned (and not age-protected).  We need mutable access
236                // to `keep[i]` while also reading `self.records[i]`, so we
237                // collect the eviction decisions in a separate pass.
238                let evict_flags: Vec<bool> = keep
239                    .iter()
240                    .zip(self.records.iter())
241                    .map(|(k, rec)| {
242                        if !k {
243                            return false; // already scheduled for deletion
244                        }
245                        if self.policy.keep_pinned && rec.is_pinned {
246                            return false;
247                        }
248                        let age_ms = self.now_ms.saturating_sub(rec.created_at_ms);
249                        if age_ms < self.policy.min_age_ms {
250                            return false;
251                        }
252                        true // candidate for eviction
253                    })
254                    .collect();
255
256                for (flag, (k, rec)) in evict_flags
257                    .iter()
258                    .zip(keep.iter_mut().zip(self.records.iter()))
259                {
260                    if excess == 0 {
261                        break;
262                    }
263                    if *flag {
264                        *k = false;
265                        excess = excess.saturating_sub(rec.size_bytes);
266                    }
267                }
268            }
269        }
270
271        // --- Partition records ----------------------------------------------------
272        // Collect records to delete (keep[i] == false) and update self.records.
273        let mut to_delete = Vec::new();
274        let mut survivors = Vec::new();
275        for (i, rec) in self.records.drain(..).enumerate() {
276            if keep[i] {
277                survivors.push(rec);
278            } else {
279                to_delete.push(rec);
280            }
281        }
282        self.records = survivors;
283        to_delete
284    }
285
286    /// Number of records currently held by the pruner (i.e. after any pruning).
287    pub fn surviving_count(&self) -> usize {
288        self.records.len()
289    }
290
291    /// Sum of `size_bytes` across all records currently held by the pruner.
292    pub fn total_bytes(&self) -> u64 {
293        self.records.iter().map(|r| r.size_bytes).sum()
294    }
295
296    /// Number of pinned records currently held by the pruner.
297    pub fn pinned_count(&self) -> usize {
298        self.records.iter().filter(|r| r.is_pinned).count()
299    }
300
301    /// Read-only access to the surviving records.
302    pub fn records(&self) -> &[CheckpointRecord] {
303        &self.records
304    }
305}
306
307// ---------------------------------------------------------------------------
308// CheckpointValidator
309// ---------------------------------------------------------------------------
310
311/// Validates checkpoint data against stored CRC-32 and size metadata.
312pub struct CheckpointValidator;
313
314impl CheckpointValidator {
315    /// Compute the CRC-32 of `data`.
316    pub fn compute_crc32(data: &[u8]) -> u32 {
317        crc32(data)
318    }
319
320    /// Return `Ok(())` if the CRC-32 of `data` matches `expected_crc32`.
321    pub fn validate(data: &[u8], expected_crc32: u32) -> Result<(), ValidationError> {
322        let actual = crc32(data);
323        if actual != expected_crc32 {
324            return Err(ValidationError::CrcMismatch {
325                expected: expected_crc32,
326                actual,
327            });
328        }
329        Ok(())
330    }
331
332    /// Validate both the CRC-32 and the recorded size of a checkpoint.
333    ///
334    /// Returns the first error encountered (size is checked after CRC).
335    pub fn validate_record(record: &CheckpointRecord, data: &[u8]) -> Result<(), ValidationError> {
336        // CRC check first.
337        let actual_crc = crc32(data);
338        if actual_crc != record.crc32 {
339            return Err(ValidationError::CrcMismatch {
340                expected: record.crc32,
341                actual: actual_crc,
342            });
343        }
344        // Size check.
345        let actual_size = data.len() as u64;
346        if actual_size != record.size_bytes {
347            return Err(ValidationError::SizeMismatch {
348                expected: record.size_bytes,
349                actual: actual_size,
350            });
351        }
352        Ok(())
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Tests
358// ---------------------------------------------------------------------------
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    // --- helpers -----------------------------------------------------------
365
366    fn make_record(id: &str, round: u64, size_bytes: u64, is_pinned: bool) -> CheckpointRecord {
367        CheckpointRecord {
368            id: id.to_string(),
369            cid: format!("Qm{round}"),
370            round,
371            created_at_ms: round * 1000, // 1 s per round for simplicity
372            size_bytes,
373            crc32: 0,
374            is_pinned,
375        }
376    }
377
378    fn make_record_timed(
379        id: &str,
380        round: u64,
381        size_bytes: u64,
382        is_pinned: bool,
383        created_at_ms: u64,
384    ) -> CheckpointRecord {
385        CheckpointRecord {
386            id: id.to_string(),
387            cid: format!("Qm{round}"),
388            round,
389            created_at_ms,
390            size_bytes,
391            crc32: 0,
392            is_pinned,
393        }
394    }
395
396    // -----------------------------------------------------------------------
397    // CRC-32 tests
398    // -----------------------------------------------------------------------
399
400    #[test]
401    fn test_crc32_known_vector() {
402        // Standard IEEE 802.3 test vector.
403        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
404    }
405
406    #[test]
407    fn test_crc32_empty() {
408        // CRC of empty slice is well-defined.
409        assert_eq!(crc32(b""), 0x0000_0000);
410    }
411
412    #[test]
413    fn test_crc32_single_byte() {
414        // Regression: single-byte input.
415        let v = crc32(b"a");
416        assert_ne!(v, 0); // Must produce a non-zero value.
417    }
418
419    #[test]
420    fn test_crc32_deterministic() {
421        let data = b"hello, checkpoint!";
422        assert_eq!(crc32(data), crc32(data));
423    }
424
425    #[test]
426    fn test_crc32_sensitive_to_changes() {
427        let a = crc32(b"hello");
428        let b = crc32(b"hellp"); // single bit flip in last byte
429        assert_ne!(a, b);
430    }
431
432    // -----------------------------------------------------------------------
433    // CheckpointValidator tests
434    // -----------------------------------------------------------------------
435
436    #[test]
437    fn test_validate_passes_for_correct_data() {
438        let data = b"gradient checkpoint payload";
439        let checksum = crc32(data);
440        assert!(CheckpointValidator::validate(data, checksum).is_ok());
441    }
442
443    #[test]
444    fn test_validate_fails_for_corrupted_data() {
445        let data = b"gradient checkpoint payload";
446        let checksum = crc32(data);
447        let mut corrupted = data.to_vec();
448        corrupted[0] ^= 0xFF; // flip first byte
449        let err = CheckpointValidator::validate(&corrupted, checksum).unwrap_err();
450        assert!(matches!(err, ValidationError::CrcMismatch { .. }));
451    }
452
453    #[test]
454    fn test_validate_record_passes() {
455        let data = b"some checkpoint data";
456        let record = CheckpointRecord {
457            id: "cp_ok".into(),
458            cid: "Qmabc".into(),
459            round: 1,
460            created_at_ms: 0,
461            size_bytes: data.len() as u64,
462            crc32: crc32(data),
463            is_pinned: false,
464        };
465        assert!(CheckpointValidator::validate_record(&record, data).is_ok());
466    }
467
468    #[test]
469    fn test_validate_record_crc_mismatch() {
470        let data = b"some checkpoint data";
471        let record = CheckpointRecord {
472            id: "cp_bad_crc".into(),
473            cid: "Qmabc".into(),
474            round: 1,
475            created_at_ms: 0,
476            size_bytes: data.len() as u64,
477            crc32: 0xDEAD_BEEF, // wrong checksum
478            is_pinned: false,
479        };
480        let err = CheckpointValidator::validate_record(&record, data).unwrap_err();
481        assert!(matches!(err, ValidationError::CrcMismatch { .. }));
482    }
483
484    #[test]
485    fn test_validate_record_size_mismatch() {
486        let data = b"some checkpoint data";
487        let record = CheckpointRecord {
488            id: "cp_bad_size".into(),
489            cid: "Qmabc".into(),
490            round: 1,
491            created_at_ms: 0,
492            size_bytes: 9999, // wrong size
493            crc32: crc32(data),
494            is_pinned: false,
495        };
496        let err = CheckpointValidator::validate_record(&record, data).unwrap_err();
497        assert!(matches!(err, ValidationError::SizeMismatch { .. }));
498    }
499
500    #[test]
501    fn test_compute_crc32_matches_standalone() {
502        let data = b"cross-check";
503        assert_eq!(CheckpointValidator::compute_crc32(data), crc32(data));
504    }
505
506    // -----------------------------------------------------------------------
507    // RetentionPolicy / CheckpointPruner tests
508    // -----------------------------------------------------------------------
509
510    #[test]
511    fn test_keep_last_n_trims_oldest() {
512        let records: Vec<CheckpointRecord> = (1..=5)
513            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
514            .collect();
515        let policy = RetentionPolicy {
516            keep_last_n: 3,
517            ..Default::default()
518        };
519        let mut pruner = CheckpointPruner::new(records, policy);
520        let deleted = pruner.prune();
521        // Rounds 1 and 2 should be deleted.
522        assert_eq!(deleted.len(), 2);
523        let deleted_rounds: Vec<u64> = deleted.iter().map(|r| r.round).collect();
524        assert!(deleted_rounds.contains(&1));
525        assert!(deleted_rounds.contains(&2));
526        assert_eq!(pruner.surviving_count(), 3);
527    }
528
529    #[test]
530    fn test_keep_pinned_preserves_old_checkpoints() {
531        let mut records: Vec<CheckpointRecord> = (1..=5)
532            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
533            .collect();
534        // Pin round 1 (oldest).
535        records[0].is_pinned = true;
536
537        let policy = RetentionPolicy {
538            keep_last_n: 3,
539            keep_pinned: true,
540            ..Default::default()
541        };
542        let mut pruner = CheckpointPruner::new(records, policy);
543        let deleted = pruner.prune();
544        // Round 2 should be deleted; round 1 (pinned) must survive.
545        assert_eq!(deleted.len(), 1);
546        assert_eq!(deleted[0].round, 2);
547        assert_eq!(pruner.surviving_count(), 4);
548        assert_eq!(pruner.pinned_count(), 1);
549    }
550
551    #[test]
552    fn test_keep_pinned_false_allows_pruning_pinned() {
553        let mut records: Vec<CheckpointRecord> = (1..=5)
554            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
555            .collect();
556        records[0].is_pinned = true;
557
558        let policy = RetentionPolicy {
559            keep_last_n: 3,
560            keep_pinned: false,
561            ..Default::default()
562        };
563        let mut pruner = CheckpointPruner::new(records, policy);
564        let deleted = pruner.prune();
565        assert_eq!(deleted.len(), 2);
566        assert_eq!(pruner.surviving_count(), 3);
567    }
568
569    #[test]
570    fn test_max_total_bytes_budget_enforcement() {
571        // 5 records of 100 bytes each → 500 bytes total.
572        // Budget: 250 bytes → must prune at least 2 oldest.
573        let records: Vec<CheckpointRecord> = (1..=5)
574            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
575            .collect();
576        let policy = RetentionPolicy {
577            keep_last_n: 5, // would keep all by default
578            keep_pinned: true,
579            max_total_bytes: Some(250),
580            min_age_ms: 0,
581        };
582        let mut pruner = CheckpointPruner::new(records, policy);
583        let deleted = pruner.prune();
584        // 500 − 250 = 250 bytes to prune → 2 or 3 records (100 each).
585        // The pruner removes from oldest first, so rounds 1 and 2 go first
586        // (200 bytes), then round 3 if still over budget—but 500-200=300 > 250
587        // so round 3 is also evicted: 3 records deleted, 200 bytes remaining.
588        assert!(pruner.total_bytes() <= 250);
589        assert!(!deleted.is_empty());
590    }
591
592    #[test]
593    fn test_min_age_ms_protects_young_checkpoints() {
594        // now_ms = 100_000.  min_age_ms = 50_000.
595        // Records and their ages:
596        //   round 1: created_at=0,      age=100_000 >= 50_000 → pruneable
597        //   round 2: created_at=1_000,  age=99_000  >= 50_000 → pruneable
598        //   round 3: created_at=2_000,  age=98_000  >= 50_000 → pruneable
599        //   round 4: created_at=80_000, age=20_000  < 50_000  → age-protected
600        //
601        // Policy: keep_last_n=1 (would keep round 4 anyway), min_age_ms=50_000.
602        // Rounds 1, 2, 3 are old enough and not in keep_last_n → deleted.
603        // Round 4 survives (keep_last_n AND age-protected).
604        let now_ms: u64 = 100_000;
605        let records = vec![
606            make_record_timed("cp_1", 1, 100, false, 0),
607            make_record_timed("cp_2", 2, 100, false, 1_000),
608            make_record_timed("cp_3", 3, 100, false, 2_000),
609            make_record_timed("cp_4", 4, 100, false, 80_000),
610        ];
611
612        let policy = RetentionPolicy {
613            keep_last_n: 1,
614            keep_pinned: true,
615            max_total_bytes: None,
616            min_age_ms: 50_000,
617        };
618        let mut pruner = CheckpointPruner::new(records, policy).with_now_ms(now_ms);
619        let deleted = pruner.prune();
620
621        let surviving_rounds: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
622        assert!(
623            surviving_rounds.contains(&4),
624            "round 4 must survive (keep_last_n)"
625        );
626
627        // Rounds 1, 2, 3 are old enough and not in keep_last_n → all deleted.
628        assert_eq!(deleted.len(), 3, "rounds 1, 2, 3 should be deleted");
629    }
630
631    #[test]
632    fn test_min_age_ms_protects_within_window() {
633        let now_ms: u64 = 10_000;
634        // All created at time 0 except the youngest, created at 8_000 ms.
635        let records = vec![
636            make_record_timed("cp_1", 1, 100, false, 0),
637            make_record_timed("cp_2", 2, 100, false, 0),
638            make_record_timed("cp_young", 3, 100, false, 8_000), // age 2_000 ms
639        ];
640        let policy = RetentionPolicy {
641            keep_last_n: 1,
642            keep_pinned: true,
643            max_total_bytes: None,
644            min_age_ms: 5_000, // protect checkpoints younger than 5 s
645        };
646        let mut pruner = CheckpointPruner::new(records, policy).with_now_ms(now_ms);
647        let _deleted = pruner.prune();
648        let surviving: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
649        // cp_young has age 2_000 < 5_000 → protected; also kept by keep_last_n.
650        assert!(surviving.contains(&3), "young checkpoint must survive");
651    }
652
653    #[test]
654    fn test_combined_keep_last_n_and_keep_pinned() {
655        // 6 records; rounds 1 and 3 are pinned; keep_last_n = 2.
656        // Expected survivors: rounds 1 (pinned), 3 (pinned), 5, 6 (last 2).
657        // Deleted: rounds 2, 4.
658        let mut records: Vec<CheckpointRecord> = (1..=6)
659            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
660            .collect();
661        records[0].is_pinned = true; // round 1
662        records[2].is_pinned = true; // round 3
663
664        let policy = RetentionPolicy {
665            keep_last_n: 2,
666            keep_pinned: true,
667            max_total_bytes: None,
668            min_age_ms: 0,
669        };
670        let mut pruner = CheckpointPruner::new(records, policy);
671        let deleted = pruner.prune();
672
673        let deleted_rounds: Vec<u64> = deleted.iter().map(|r| r.round).collect();
674        assert!(deleted_rounds.contains(&2), "round 2 must be deleted");
675        assert!(deleted_rounds.contains(&4), "round 4 must be deleted");
676        assert_eq!(deleted.len(), 2, "exactly 2 records should be deleted");
677
678        let surviving: Vec<u64> = pruner.records().iter().map(|r| r.round).collect();
679        assert!(surviving.contains(&1), "pinned round 1 must survive");
680        assert!(surviving.contains(&3), "pinned round 3 must survive");
681        assert!(surviving.contains(&5), "keep_last_n round 5 must survive");
682        assert!(surviving.contains(&6), "keep_last_n round 6 must survive");
683    }
684
685    #[test]
686    fn test_prune_returns_correct_delete_set() {
687        let records: Vec<CheckpointRecord> = (1..=4)
688            .map(|r| make_record(&format!("cp_{r}"), r, 50, false))
689            .collect();
690        let policy = RetentionPolicy {
691            keep_last_n: 2,
692            ..Default::default()
693        };
694        let mut pruner = CheckpointPruner::new(records, policy);
695        let deleted = pruner.prune();
696        assert_eq!(deleted.len(), 2);
697        let ids: Vec<&str> = deleted.iter().map(|r| r.id.as_str()).collect();
698        assert!(ids.contains(&"cp_1"));
699        assert!(ids.contains(&"cp_2"));
700    }
701
702    #[test]
703    fn test_surviving_count_and_total_bytes() {
704        let records: Vec<CheckpointRecord> = (1..=6)
705            .map(|r| make_record(&format!("cp_{r}"), r, 200, false))
706            .collect();
707        let policy = RetentionPolicy {
708            keep_last_n: 4,
709            ..Default::default()
710        };
711        let mut pruner = CheckpointPruner::new(records, policy);
712        pruner.prune();
713        assert_eq!(pruner.surviving_count(), 4);
714        assert_eq!(pruner.total_bytes(), 4 * 200);
715    }
716
717    #[test]
718    fn test_prune_idempotent_after_first_call() {
719        let records: Vec<CheckpointRecord> = (1..=5)
720            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
721            .collect();
722        let policy = RetentionPolicy {
723            keep_last_n: 3,
724            ..Default::default()
725        };
726        let mut pruner = CheckpointPruner::new(records, policy);
727        let first = pruner.prune();
728        let second = pruner.prune();
729        assert_eq!(first.len(), 2);
730        assert!(second.is_empty(), "second prune should delete nothing");
731    }
732
733    #[test]
734    fn test_pinned_count_accuracy() {
735        let mut records: Vec<CheckpointRecord> = (1..=5)
736            .map(|r| make_record(&format!("cp_{r}"), r, 100, false))
737            .collect();
738        records[1].is_pinned = true;
739        records[3].is_pinned = true;
740        let policy = RetentionPolicy::default();
741        let pruner = CheckpointPruner::new(records, policy);
742        assert_eq!(pruner.pinned_count(), 2);
743    }
744}