1#[derive(Clone, Debug, PartialEq)]
14pub enum CheckpointTrigger {
15 StepInterval { every_n_steps: u64 },
17 LossImprovement { min_delta: f64 },
19 TickInterval { every_n_ticks: u64 },
21}
22
23#[derive(Clone, Debug)]
25pub struct CheckpointRecord {
26 pub checkpoint_id: u64,
28 pub step: u64,
30 pub loss: f64,
32 pub trigger: CheckpointTrigger,
34 pub saved_at_tick: u64,
36}
37
38#[derive(Clone, Debug)]
40pub struct SchedulerConfig {
41 pub triggers: Vec<CheckpointTrigger>,
43 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#[derive(Clone, Debug, Default)]
58pub struct SchedulerStats {
59 pub total_checkpoints_saved: u64,
61 pub triggered_by_step: u64,
63 pub triggered_by_loss: u64,
65 pub triggered_by_tick: u64,
67 pub checkpoints_pruned: u64,
69}
70
71pub struct TensorCheckpointScheduler {
77 pub config: SchedulerConfig,
79 pub checkpoints: Vec<CheckpointRecord>,
81 pub next_checkpoint_id: u64,
83 pub last_checkpoint_step: u64,
85 pub last_checkpoint_tick: u64,
87 pub best_loss: f64,
89 pub stats: SchedulerStats,
91}
92
93impl TensorCheckpointScheduler {
94 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 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 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 self.last_checkpoint_step = step;
172 self.last_checkpoint_tick = current_tick;
173
174 if loss < self.best_loss {
176 self.best_loss = loss;
177 }
178
179 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 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 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 pub fn latest_checkpoint(&self) -> Option<&CheckpointRecord> {
208 self.checkpoints.last()
209 }
210
211 pub fn stats(&self) -> &SchedulerStats {
213 &self.stats
214 }
215}
216
217#[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 #[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 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 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 #[test]
306 fn loss_improvement_fires_on_first_observation() {
307 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 sched.advance(1, 2.0, 1);
317 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); 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); 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); assert!((sched.best_loss - 5.0).abs() < f64::EPSILON);
349 sched.advance(2, 4.5, 2); 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); sched.advance(2, 1.5, 2); assert!((sched.best_loss - 1.0).abs() < f64::EPSILON);
360 }
361
362 #[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 #[test]
407 fn multiple_triggers_first_matching_fires() {
408 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 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 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 sched.best_loss = 10.0;
442 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 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 #[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 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 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 #[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 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 sched.advance(5, 1.0, 20);
613
614 let r = sched.advance(10, 0.9, 25);
619 assert!(r.is_none());
620
621 sched.advance(15, 0.8, 40);
625
626 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}