Skip to main content

ipfrs_tensorlogic/
checkpoint_scheduler.rs

1//! TensorCheckpointScheduler — automatic scheduling of tensor checkpoint saves.
2//!
3//! Manages checkpoint saves triggered by configurable policies:
4//! - **StepInterval**: save every N training steps
5//! - **LossImprovement**: save when loss decreases by at least `min_delta`
6//! - **TickInterval**: save every N wall-clock ticks (monotonic counter)
7//!
8//! The scheduler maintains a bounded history of checkpoint records, pruning
9//! the oldest when the configured maximum is exceeded. All statistics are
10//! tracked per-trigger-type for observability.
11
12/// Specifies the condition under which an automatic checkpoint save is triggered.
13#[derive(Clone, Debug, PartialEq)]
14pub enum CheckpointTrigger {
15    /// Trigger every `every_n_steps` training steps.
16    StepInterval { every_n_steps: u64 },
17    /// Trigger when the loss improves (decreases) by at least `min_delta`.
18    LossImprovement { min_delta: f64 },
19    /// Trigger every `every_n_ticks` monotonic ticks.
20    TickInterval { every_n_ticks: u64 },
21}
22
23/// A record of a single checkpoint save event.
24#[derive(Clone, Debug)]
25pub struct CheckpointRecord {
26    /// Unique, monotonically-increasing identifier for this checkpoint.
27    pub checkpoint_id: u64,
28    /// Training step at which this checkpoint was saved.
29    pub step: u64,
30    /// Loss value at save time.
31    pub loss: f64,
32    /// The trigger that caused this checkpoint to be saved.
33    pub trigger: CheckpointTrigger,
34    /// Tick counter value at save time.
35    pub saved_at_tick: u64,
36}
37
38/// Configuration for [`TensorCheckpointScheduler`].
39#[derive(Clone, Debug)]
40pub struct SchedulerConfig {
41    /// Ordered list of triggers; the first matching trigger fires the checkpoint.
42    pub triggers: Vec<CheckpointTrigger>,
43    /// Maximum number of checkpoint records to retain; oldest is removed when exceeded.
44    pub max_checkpoints: usize,
45}
46
47impl Default for SchedulerConfig {
48    fn default() -> Self {
49        Self {
50            triggers: Vec::new(),
51            max_checkpoints: 5,
52        }
53    }
54}
55
56/// Cumulative statistics for [`TensorCheckpointScheduler`].
57#[derive(Clone, Debug, Default)]
58pub struct SchedulerStats {
59    /// Total number of checkpoints saved (across all trigger types).
60    pub total_checkpoints_saved: u64,
61    /// Checkpoints triggered by a [`CheckpointTrigger::StepInterval`] rule.
62    pub triggered_by_step: u64,
63    /// Checkpoints triggered by a [`CheckpointTrigger::LossImprovement`] rule.
64    pub triggered_by_loss: u64,
65    /// Checkpoints triggered by a [`CheckpointTrigger::TickInterval`] rule.
66    pub triggered_by_tick: u64,
67    /// Number of checkpoint records deleted due to exceeding `max_checkpoints`.
68    pub checkpoints_pruned: u64,
69}
70
71/// Manages automatic scheduling of tensor checkpoint saves.
72///
73/// Evaluates a configurable list of [`CheckpointTrigger`]s on each call to
74/// [`advance`][TensorCheckpointScheduler::advance] and records a checkpoint
75/// whenever the first matching trigger fires.
76pub struct TensorCheckpointScheduler {
77    /// Configuration (triggers + retention policy).
78    pub config: SchedulerConfig,
79    /// Ordered (oldest-first) list of saved checkpoint records.
80    pub checkpoints: Vec<CheckpointRecord>,
81    /// Next checkpoint ID to assign (starts at 0, increments after use).
82    pub next_checkpoint_id: u64,
83    /// Training step at which the last checkpoint was saved.
84    pub last_checkpoint_step: u64,
85    /// Tick value at which the last checkpoint was saved.
86    pub last_checkpoint_tick: u64,
87    /// Best (lowest) loss seen so far; initialised to `f64::MAX`.
88    pub best_loss: f64,
89    /// Running statistics.
90    pub stats: SchedulerStats,
91}
92
93impl TensorCheckpointScheduler {
94    /// Create a new scheduler with the given configuration.
95    pub fn new(config: SchedulerConfig) -> Self {
96        Self {
97            config,
98            checkpoints: Vec::new(),
99            next_checkpoint_id: 0,
100            last_checkpoint_step: 0,
101            last_checkpoint_tick: 0,
102            best_loss: f64::MAX,
103            stats: SchedulerStats::default(),
104        }
105    }
106
107    /// Evaluate all configured triggers and return the first one that fires,
108    /// or `None` if no trigger is currently satisfied.
109    ///
110    /// Trigger semantics:
111    /// - `StepInterval { every_n_steps: n }`: fires when
112    ///   `current_step > 0` AND `(current_step − last_checkpoint_step) >= n`.
113    /// - `LossImprovement { min_delta: d }`: fires when
114    ///   `best_loss == f64::MAX` (first observation) OR
115    ///   `(best_loss − current_loss) >= d`.
116    /// - `TickInterval { every_n_ticks: n }`: fires when
117    ///   `current_tick > 0` AND `(current_tick − last_checkpoint_tick) >= n`.
118    pub fn should_checkpoint(
119        &self,
120        current_step: u64,
121        current_loss: f64,
122        current_tick: u64,
123    ) -> Option<CheckpointTrigger> {
124        for trigger in &self.config.triggers {
125            let fires = match trigger {
126                CheckpointTrigger::StepInterval { every_n_steps } => {
127                    current_step > 0
128                        && current_step.saturating_sub(self.last_checkpoint_step) >= *every_n_steps
129                }
130                CheckpointTrigger::LossImprovement { min_delta } => {
131                    self.best_loss == f64::MAX || (self.best_loss - current_loss) >= *min_delta
132                }
133                CheckpointTrigger::TickInterval { every_n_ticks } => {
134                    current_tick > 0
135                        && current_tick.saturating_sub(self.last_checkpoint_tick) >= *every_n_ticks
136                }
137            };
138            if fires {
139                return Some(trigger.clone());
140            }
141        }
142        None
143    }
144
145    /// Record a checkpoint save event.
146    ///
147    /// Updates internal state (step/tick cursors, best loss) and statistics,
148    /// then prunes the oldest record if the history exceeds `max_checkpoints`.
149    ///
150    /// Returns the newly assigned checkpoint ID.
151    pub fn record_checkpoint(
152        &mut self,
153        step: u64,
154        loss: f64,
155        trigger: CheckpointTrigger,
156        current_tick: u64,
157    ) -> u64 {
158        let id = self.next_checkpoint_id;
159        self.next_checkpoint_id += 1;
160
161        let record = CheckpointRecord {
162            checkpoint_id: id,
163            step,
164            loss,
165            trigger: trigger.clone(),
166            saved_at_tick: current_tick,
167        };
168        self.checkpoints.push(record);
169
170        // Update cursors.
171        self.last_checkpoint_step = step;
172        self.last_checkpoint_tick = current_tick;
173
174        // Update best loss.
175        if loss < self.best_loss {
176            self.best_loss = loss;
177        }
178
179        // Update stats.
180        self.stats.total_checkpoints_saved += 1;
181        match &trigger {
182            CheckpointTrigger::StepInterval { .. } => self.stats.triggered_by_step += 1,
183            CheckpointTrigger::LossImprovement { .. } => self.stats.triggered_by_loss += 1,
184            CheckpointTrigger::TickInterval { .. } => self.stats.triggered_by_tick += 1,
185        }
186
187        // Prune oldest if over limit.
188        if self.checkpoints.len() > self.config.max_checkpoints {
189            self.checkpoints.remove(0);
190            self.stats.checkpoints_pruned += 1;
191        }
192
193        id
194    }
195
196    /// Advance the scheduler by one observation.
197    ///
198    /// Evaluates triggers; if one fires, records a checkpoint and returns
199    /// `Some(checkpoint_id)`. Returns `None` when no checkpoint is needed.
200    pub fn advance(&mut self, step: u64, loss: f64, current_tick: u64) -> Option<u64> {
201        let trigger = self.should_checkpoint(step, loss, current_tick)?;
202        let id = self.record_checkpoint(step, loss, trigger, current_tick);
203        Some(id)
204    }
205
206    /// Return a reference to the most recently saved checkpoint record, if any.
207    pub fn latest_checkpoint(&self) -> Option<&CheckpointRecord> {
208        self.checkpoints.last()
209    }
210
211    /// Return a reference to the cumulative statistics.
212    pub fn stats(&self) -> &SchedulerStats {
213        &self.stats
214    }
215}
216
217// ────────────────────────────────────────────────────────────────────────────
218// Tests
219// ────────────────────────────────────────────────────────────────────────────
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn step_scheduler(every_n_steps: u64) -> TensorCheckpointScheduler {
226        let config = SchedulerConfig {
227            triggers: vec![CheckpointTrigger::StepInterval { every_n_steps }],
228            max_checkpoints: 5,
229        };
230        TensorCheckpointScheduler::new(config)
231    }
232
233    fn loss_scheduler(min_delta: f64) -> TensorCheckpointScheduler {
234        let config = SchedulerConfig {
235            triggers: vec![CheckpointTrigger::LossImprovement { min_delta }],
236            max_checkpoints: 5,
237        };
238        TensorCheckpointScheduler::new(config)
239    }
240
241    fn tick_scheduler(every_n_ticks: u64) -> TensorCheckpointScheduler {
242        let config = SchedulerConfig {
243            triggers: vec![CheckpointTrigger::TickInterval { every_n_ticks }],
244            max_checkpoints: 5,
245        };
246        TensorCheckpointScheduler::new(config)
247    }
248
249    // ── StepInterval ────────────────────────────────────────────────────────
250
251    #[test]
252    fn step_interval_fires_at_correct_step() {
253        let mut sched = step_scheduler(10);
254        let id = sched.advance(10, 1.0, 0);
255        assert!(id.is_some(), "should fire at step 10");
256    }
257
258    #[test]
259    fn step_interval_does_not_fire_before_interval() {
260        let mut sched = step_scheduler(10);
261        assert!(sched.advance(9, 1.0, 0).is_none());
262    }
263
264    #[test]
265    fn step_interval_does_not_fire_at_step_zero() {
266        let mut sched = step_scheduler(1);
267        // step == 0 should never trigger even with n=1
268        assert!(sched.advance(0, 1.0, 0).is_none());
269    }
270
271    #[test]
272    fn step_interval_fires_multiple_times() {
273        let mut sched = step_scheduler(5);
274        let id1 = sched.advance(5, 1.0, 0);
275        assert!(id1.is_some());
276        // After recording last_checkpoint_step=5, next fire should be at 10
277        assert!(sched.advance(9, 0.9, 0).is_none());
278        let id2 = sched.advance(10, 0.9, 0);
279        assert!(id2.is_some());
280        assert_ne!(
281            id1.expect("test: should succeed"),
282            id2.expect("test: should succeed")
283        );
284    }
285
286    #[test]
287    fn step_interval_fires_exactly_at_multiple() {
288        let mut sched = step_scheduler(3);
289        assert!(sched.advance(2, 1.0, 0).is_none());
290        assert!(sched.advance(3, 1.0, 0).is_some());
291    }
292
293    #[test]
294    fn step_interval_stats_incremented() {
295        let mut sched = step_scheduler(5);
296        sched.advance(5, 1.0, 0);
297        assert_eq!(sched.stats().triggered_by_step, 1);
298        assert_eq!(sched.stats().total_checkpoints_saved, 1);
299        assert_eq!(sched.stats().triggered_by_loss, 0);
300        assert_eq!(sched.stats().triggered_by_tick, 0);
301    }
302
303    // ── LossImprovement ─────────────────────────────────────────────────────
304
305    #[test]
306    fn loss_improvement_fires_on_first_observation() {
307        // best_loss starts at f64::MAX so any loss triggers it
308        let mut sched = loss_scheduler(0.01);
309        assert!(sched.advance(1, 2.0, 1).is_some());
310    }
311
312    #[test]
313    fn loss_improvement_fires_when_improved_enough() {
314        let mut sched = loss_scheduler(0.1);
315        // seed best_loss
316        sched.advance(1, 2.0, 1);
317        // improvement of exactly 0.1 should fire
318        let id = sched.advance(2, 1.9, 2);
319        assert!(id.is_some());
320    }
321
322    #[test]
323    fn loss_improvement_does_not_fire_for_insufficient_improvement() {
324        let mut sched = loss_scheduler(0.1);
325        sched.advance(1, 2.0, 1); // seeds best_loss = 2.0
326                                  // improvement is only 0.05 < 0.1
327        assert!(sched.advance(2, 1.95, 2).is_none());
328    }
329
330    #[test]
331    fn loss_improvement_does_not_fire_when_loss_worsens() {
332        let mut sched = loss_scheduler(0.01);
333        sched.advance(1, 1.0, 1); // best_loss = 1.0
334        assert!(sched.advance(2, 1.5, 2).is_none());
335    }
336
337    #[test]
338    fn loss_improvement_stats_incremented() {
339        let mut sched = loss_scheduler(0.0);
340        sched.advance(1, 1.0, 1);
341        assert_eq!(sched.stats().triggered_by_loss, 1);
342    }
343
344    #[test]
345    fn loss_improvement_best_loss_updated_correctly() {
346        let mut sched = loss_scheduler(0.1);
347        sched.advance(1, 5.0, 1); // best_loss becomes 5.0
348        assert!((sched.best_loss - 5.0).abs() < f64::EPSILON);
349        sched.advance(2, 4.5, 2); // improvement = 0.5 >= 0.1 → fires, best_loss → 4.5
350                                  // 4.5 < 5.0 so best_loss is updated
351        assert!((sched.best_loss - 4.5).abs() < f64::EPSILON);
352    }
353
354    #[test]
355    fn loss_improvement_best_loss_not_updated_when_worse() {
356        let mut sched = loss_scheduler(0.5);
357        sched.advance(1, 1.0, 1); // best_loss = 1.0
358        sched.advance(2, 1.5, 2); // no trigger, loss is worse
359        assert!((sched.best_loss - 1.0).abs() < f64::EPSILON);
360    }
361
362    // ── TickInterval ────────────────────────────────────────────────────────
363
364    #[test]
365    fn tick_interval_fires_at_correct_tick() {
366        let mut sched = tick_scheduler(100);
367        assert!(sched.advance(1, 1.0, 100).is_some());
368    }
369
370    #[test]
371    fn tick_interval_does_not_fire_before_interval() {
372        let mut sched = tick_scheduler(100);
373        assert!(sched.advance(1, 1.0, 99).is_none());
374    }
375
376    #[test]
377    fn tick_interval_does_not_fire_at_tick_zero() {
378        let mut sched = tick_scheduler(1);
379        assert!(sched.advance(1, 1.0, 0).is_none());
380    }
381
382    #[test]
383    fn tick_interval_fires_multiple_times() {
384        let mut sched = tick_scheduler(50);
385        let id1 = sched.advance(1, 1.0, 50);
386        assert!(id1.is_some());
387        assert!(sched.advance(2, 0.9, 99).is_none());
388        let id2 = sched.advance(3, 0.9, 100);
389        assert!(id2.is_some());
390        assert_ne!(
391            id1.expect("test: should succeed"),
392            id2.expect("test: should succeed")
393        );
394    }
395
396    #[test]
397    fn tick_interval_stats_incremented() {
398        let mut sched = tick_scheduler(10);
399        sched.advance(1, 1.0, 10);
400        assert_eq!(sched.stats().triggered_by_tick, 1);
401        assert_eq!(sched.stats().total_checkpoints_saved, 1);
402    }
403
404    // ── Multiple triggers ───────────────────────────────────────────────────
405
406    #[test]
407    fn multiple_triggers_first_matching_fires() {
408        // Step fires at step 5; Loss fires on first call (best_loss == MAX).
409        // If order is [Step, Loss], first evaluation is Step. Step not satisfied
410        // (step 0), then Loss fires. But since step=0 doesn't satisfy StepInterval,
411        // LossImprovement fires.
412        let config = SchedulerConfig {
413            triggers: vec![
414                CheckpointTrigger::StepInterval { every_n_steps: 5 },
415                CheckpointTrigger::LossImprovement { min_delta: 0.0 },
416            ],
417            max_checkpoints: 10,
418        };
419        let sched = TensorCheckpointScheduler::new(config);
420        // step=0 → StepInterval not satisfied; LossImprovement fires (best_loss==MAX)
421        let trigger = sched.should_checkpoint(0, 1.0, 0);
422        assert_eq!(
423            trigger,
424            Some(CheckpointTrigger::LossImprovement { min_delta: 0.0 })
425        );
426    }
427
428    #[test]
429    fn multiple_triggers_step_fires_before_loss_when_first() {
430        // Order is [Step, Loss]. At step=5, Step fires first.
431        let config = SchedulerConfig {
432            triggers: vec![
433                CheckpointTrigger::StepInterval { every_n_steps: 5 },
434                CheckpointTrigger::LossImprovement { min_delta: 0.0 },
435            ],
436            max_checkpoints: 10,
437        };
438        let mut sched = TensorCheckpointScheduler::new(config);
439        // Seed best_loss with a large value at step 0 indirectly:
440        // manually set best_loss to skip the MAX guard.
441        sched.best_loss = 10.0;
442        // Now at step=5, StepInterval fires. LossImprovement would not fire
443        // (10.0 - 9.5 = 0.5 >= 0.0 so it would too, but Step is checked first).
444        let trigger = sched.should_checkpoint(5, 9.5, 0);
445        assert_eq!(
446            trigger,
447            Some(CheckpointTrigger::StepInterval { every_n_steps: 5 })
448        );
449    }
450
451    #[test]
452    fn multiple_triggers_neither_fires_returns_none() {
453        let config = SchedulerConfig {
454            triggers: vec![
455                CheckpointTrigger::StepInterval { every_n_steps: 10 },
456                CheckpointTrigger::TickInterval { every_n_ticks: 100 },
457            ],
458            max_checkpoints: 5,
459        };
460        let mut sched = TensorCheckpointScheduler::new(config);
461        // Seed last positions so intervals are not met.
462        sched.last_checkpoint_step = 5;
463        sched.last_checkpoint_tick = 50;
464        assert!(sched.should_checkpoint(9, 1.0, 99).is_none());
465    }
466
467    // ── max_checkpoints pruning ─────────────────────────────────────────────
468
469    #[test]
470    fn max_checkpoints_pruning_oldest_removed() {
471        let config = SchedulerConfig {
472            triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
473            max_checkpoints: 3,
474        };
475        let mut sched = TensorCheckpointScheduler::new(config);
476        for step in 1u64..=4 {
477            sched.advance(step, 1.0, step);
478        }
479        assert_eq!(sched.checkpoints.len(), 3);
480        // Oldest (step 1) should be gone; first kept is step 2.
481        assert_eq!(sched.checkpoints[0].step, 2);
482    }
483
484    #[test]
485    fn checkpoints_pruned_stat_incremented() {
486        let config = SchedulerConfig {
487            triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
488            max_checkpoints: 2,
489        };
490        let mut sched = TensorCheckpointScheduler::new(config);
491        for step in 1u64..=4 {
492            sched.advance(step, 1.0, step);
493        }
494        // 4 saved, 2 max → 2 pruned
495        assert_eq!(sched.stats().checkpoints_pruned, 2);
496    }
497
498    #[test]
499    fn no_pruning_when_within_limit() {
500        let config = SchedulerConfig {
501            triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
502            max_checkpoints: 10,
503        };
504        let mut sched = TensorCheckpointScheduler::new(config);
505        for step in 1u64..=5 {
506            sched.advance(step, 1.0, step);
507        }
508        assert_eq!(sched.stats().checkpoints_pruned, 0);
509        assert_eq!(sched.checkpoints.len(), 5);
510    }
511
512    // ── advance / latest_checkpoint / stats ─────────────────────────────────
513
514    #[test]
515    fn advance_returns_checkpoint_id_when_triggered() {
516        let mut sched = step_scheduler(5);
517        let id = sched.advance(5, 1.0, 0);
518        assert_eq!(id, Some(0));
519    }
520
521    #[test]
522    fn advance_returns_none_when_no_trigger() {
523        let mut sched = step_scheduler(10);
524        assert!(sched.advance(5, 1.0, 0).is_none());
525    }
526
527    #[test]
528    fn advance_increments_checkpoint_ids_sequentially() {
529        let mut sched = step_scheduler(1);
530        let id0 = sched.advance(1, 1.0, 0).expect("test: should succeed");
531        let id1 = sched.advance(2, 0.9, 0).expect("test: should succeed");
532        let id2 = sched.advance(3, 0.8, 0).expect("test: should succeed");
533        assert_eq!(id0, 0);
534        assert_eq!(id1, 1);
535        assert_eq!(id2, 2);
536    }
537
538    #[test]
539    fn latest_checkpoint_returns_last_added() {
540        let mut sched = step_scheduler(5);
541        assert!(sched.latest_checkpoint().is_none());
542        sched.advance(5, 2.0, 10);
543        sched.advance(10, 1.5, 20);
544        let latest = sched.latest_checkpoint().expect("should exist");
545        assert_eq!(latest.step, 10);
546        assert!((latest.loss - 1.5).abs() < f64::EPSILON);
547    }
548
549    #[test]
550    fn latest_checkpoint_none_when_no_checkpoints() {
551        let sched = step_scheduler(10);
552        assert!(sched.latest_checkpoint().is_none());
553    }
554
555    #[test]
556    fn stats_returns_reference_to_stats() {
557        let sched = step_scheduler(5);
558        let s = sched.stats();
559        assert_eq!(s.total_checkpoints_saved, 0);
560        assert_eq!(s.checkpoints_pruned, 0);
561    }
562
563    #[test]
564    fn checkpoint_record_fields_populated_correctly() {
565        let mut sched = step_scheduler(5);
566        sched.advance(5, 0.42, 17);
567        let rec = sched.latest_checkpoint().expect("should exist");
568        assert_eq!(rec.checkpoint_id, 0);
569        assert_eq!(rec.step, 5);
570        assert!((rec.loss - 0.42).abs() < 1e-10);
571        assert_eq!(rec.saved_at_tick, 17);
572        assert_eq!(
573            rec.trigger,
574            CheckpointTrigger::StepInterval { every_n_steps: 5 }
575        );
576    }
577
578    #[test]
579    fn scheduler_config_default() {
580        let cfg = SchedulerConfig::default();
581        assert!(cfg.triggers.is_empty());
582        assert_eq!(cfg.max_checkpoints, 5);
583    }
584
585    #[test]
586    fn no_triggers_never_checkpoints() {
587        let config = SchedulerConfig::default();
588        let mut sched = TensorCheckpointScheduler::new(config);
589        for step in 1u64..=100 {
590            assert!(sched.advance(step, 1.0 / step as f64, step).is_none());
591        }
592        assert_eq!(sched.stats().total_checkpoints_saved, 0);
593    }
594
595    #[test]
596    fn mixed_trigger_types_all_stats_accurate() {
597        // StepInterval(10) checked before TickInterval(20).
598        // record_checkpoint always updates BOTH cursors regardless of trigger type.
599        let config = SchedulerConfig {
600            triggers: vec![
601                CheckpointTrigger::StepInterval { every_n_steps: 10 },
602                CheckpointTrigger::TickInterval { every_n_ticks: 20 },
603            ],
604            max_checkpoints: 100,
605        };
606        let mut sched = TensorCheckpointScheduler::new(config);
607
608        // advance(5, 1.0, 20):
609        //   StepInterval: 5-0=5 < 10 → no
610        //   TickInterval: 20-0=20 >= 20 → fires
611        //   After: last_step=5, last_tick=20, triggered_by_tick=1
612        sched.advance(5, 1.0, 20);
613
614        // advance(10, 0.9, 25):
615        //   StepInterval: 10-5=5 < 10 → no
616        //   TickInterval: 25-20=5 < 20 → no
617        //   No checkpoint.
618        let r = sched.advance(10, 0.9, 25);
619        assert!(r.is_none());
620
621        // advance(15, 0.8, 40):
622        //   StepInterval: 15-5=10 >= 10 → fires
623        //   After: last_step=15, last_tick=40, triggered_by_step=1
624        sched.advance(15, 0.8, 40);
625
626        // advance(20, 0.7, 60):
627        //   StepInterval: 20-15=5 < 10 → no
628        //   TickInterval: 60-40=20 >= 20 → fires
629        //   After: last_tick=60, triggered_by_tick=2
630        sched.advance(20, 0.7, 60);
631
632        assert_eq!(sched.stats().triggered_by_step, 1);
633        assert_eq!(sched.stats().triggered_by_tick, 2);
634        assert_eq!(sched.stats().triggered_by_loss, 0);
635        assert_eq!(sched.stats().total_checkpoints_saved, 3);
636    }
637}