#[derive(Clone, Debug, PartialEq)]
pub enum CheckpointTrigger {
StepInterval { every_n_steps: u64 },
LossImprovement { min_delta: f64 },
TickInterval { every_n_ticks: u64 },
}
#[derive(Clone, Debug)]
pub struct CheckpointRecord {
pub checkpoint_id: u64,
pub step: u64,
pub loss: f64,
pub trigger: CheckpointTrigger,
pub saved_at_tick: u64,
}
#[derive(Clone, Debug)]
pub struct SchedulerConfig {
pub triggers: Vec<CheckpointTrigger>,
pub max_checkpoints: usize,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
triggers: Vec::new(),
max_checkpoints: 5,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SchedulerStats {
pub total_checkpoints_saved: u64,
pub triggered_by_step: u64,
pub triggered_by_loss: u64,
pub triggered_by_tick: u64,
pub checkpoints_pruned: u64,
}
pub struct TensorCheckpointScheduler {
pub config: SchedulerConfig,
pub checkpoints: Vec<CheckpointRecord>,
pub next_checkpoint_id: u64,
pub last_checkpoint_step: u64,
pub last_checkpoint_tick: u64,
pub best_loss: f64,
pub stats: SchedulerStats,
}
impl TensorCheckpointScheduler {
pub fn new(config: SchedulerConfig) -> Self {
Self {
config,
checkpoints: Vec::new(),
next_checkpoint_id: 0,
last_checkpoint_step: 0,
last_checkpoint_tick: 0,
best_loss: f64::MAX,
stats: SchedulerStats::default(),
}
}
pub fn should_checkpoint(
&self,
current_step: u64,
current_loss: f64,
current_tick: u64,
) -> Option<CheckpointTrigger> {
for trigger in &self.config.triggers {
let fires = match trigger {
CheckpointTrigger::StepInterval { every_n_steps } => {
current_step > 0
&& current_step.saturating_sub(self.last_checkpoint_step) >= *every_n_steps
}
CheckpointTrigger::LossImprovement { min_delta } => {
self.best_loss == f64::MAX || (self.best_loss - current_loss) >= *min_delta
}
CheckpointTrigger::TickInterval { every_n_ticks } => {
current_tick > 0
&& current_tick.saturating_sub(self.last_checkpoint_tick) >= *every_n_ticks
}
};
if fires {
return Some(trigger.clone());
}
}
None
}
pub fn record_checkpoint(
&mut self,
step: u64,
loss: f64,
trigger: CheckpointTrigger,
current_tick: u64,
) -> u64 {
let id = self.next_checkpoint_id;
self.next_checkpoint_id += 1;
let record = CheckpointRecord {
checkpoint_id: id,
step,
loss,
trigger: trigger.clone(),
saved_at_tick: current_tick,
};
self.checkpoints.push(record);
self.last_checkpoint_step = step;
self.last_checkpoint_tick = current_tick;
if loss < self.best_loss {
self.best_loss = loss;
}
self.stats.total_checkpoints_saved += 1;
match &trigger {
CheckpointTrigger::StepInterval { .. } => self.stats.triggered_by_step += 1,
CheckpointTrigger::LossImprovement { .. } => self.stats.triggered_by_loss += 1,
CheckpointTrigger::TickInterval { .. } => self.stats.triggered_by_tick += 1,
}
if self.checkpoints.len() > self.config.max_checkpoints {
self.checkpoints.remove(0);
self.stats.checkpoints_pruned += 1;
}
id
}
pub fn advance(&mut self, step: u64, loss: f64, current_tick: u64) -> Option<u64> {
let trigger = self.should_checkpoint(step, loss, current_tick)?;
let id = self.record_checkpoint(step, loss, trigger, current_tick);
Some(id)
}
pub fn latest_checkpoint(&self) -> Option<&CheckpointRecord> {
self.checkpoints.last()
}
pub fn stats(&self) -> &SchedulerStats {
&self.stats
}
}
#[cfg(test)]
mod tests {
use super::*;
fn step_scheduler(every_n_steps: u64) -> TensorCheckpointScheduler {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::StepInterval { every_n_steps }],
max_checkpoints: 5,
};
TensorCheckpointScheduler::new(config)
}
fn loss_scheduler(min_delta: f64) -> TensorCheckpointScheduler {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::LossImprovement { min_delta }],
max_checkpoints: 5,
};
TensorCheckpointScheduler::new(config)
}
fn tick_scheduler(every_n_ticks: u64) -> TensorCheckpointScheduler {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::TickInterval { every_n_ticks }],
max_checkpoints: 5,
};
TensorCheckpointScheduler::new(config)
}
#[test]
fn step_interval_fires_at_correct_step() {
let mut sched = step_scheduler(10);
let id = sched.advance(10, 1.0, 0);
assert!(id.is_some(), "should fire at step 10");
}
#[test]
fn step_interval_does_not_fire_before_interval() {
let mut sched = step_scheduler(10);
assert!(sched.advance(9, 1.0, 0).is_none());
}
#[test]
fn step_interval_does_not_fire_at_step_zero() {
let mut sched = step_scheduler(1);
assert!(sched.advance(0, 1.0, 0).is_none());
}
#[test]
fn step_interval_fires_multiple_times() {
let mut sched = step_scheduler(5);
let id1 = sched.advance(5, 1.0, 0);
assert!(id1.is_some());
assert!(sched.advance(9, 0.9, 0).is_none());
let id2 = sched.advance(10, 0.9, 0);
assert!(id2.is_some());
assert_ne!(
id1.expect("test: should succeed"),
id2.expect("test: should succeed")
);
}
#[test]
fn step_interval_fires_exactly_at_multiple() {
let mut sched = step_scheduler(3);
assert!(sched.advance(2, 1.0, 0).is_none());
assert!(sched.advance(3, 1.0, 0).is_some());
}
#[test]
fn step_interval_stats_incremented() {
let mut sched = step_scheduler(5);
sched.advance(5, 1.0, 0);
assert_eq!(sched.stats().triggered_by_step, 1);
assert_eq!(sched.stats().total_checkpoints_saved, 1);
assert_eq!(sched.stats().triggered_by_loss, 0);
assert_eq!(sched.stats().triggered_by_tick, 0);
}
#[test]
fn loss_improvement_fires_on_first_observation() {
let mut sched = loss_scheduler(0.01);
assert!(sched.advance(1, 2.0, 1).is_some());
}
#[test]
fn loss_improvement_fires_when_improved_enough() {
let mut sched = loss_scheduler(0.1);
sched.advance(1, 2.0, 1);
let id = sched.advance(2, 1.9, 2);
assert!(id.is_some());
}
#[test]
fn loss_improvement_does_not_fire_for_insufficient_improvement() {
let mut sched = loss_scheduler(0.1);
sched.advance(1, 2.0, 1); assert!(sched.advance(2, 1.95, 2).is_none());
}
#[test]
fn loss_improvement_does_not_fire_when_loss_worsens() {
let mut sched = loss_scheduler(0.01);
sched.advance(1, 1.0, 1); assert!(sched.advance(2, 1.5, 2).is_none());
}
#[test]
fn loss_improvement_stats_incremented() {
let mut sched = loss_scheduler(0.0);
sched.advance(1, 1.0, 1);
assert_eq!(sched.stats().triggered_by_loss, 1);
}
#[test]
fn loss_improvement_best_loss_updated_correctly() {
let mut sched = loss_scheduler(0.1);
sched.advance(1, 5.0, 1); assert!((sched.best_loss - 5.0).abs() < f64::EPSILON);
sched.advance(2, 4.5, 2); assert!((sched.best_loss - 4.5).abs() < f64::EPSILON);
}
#[test]
fn loss_improvement_best_loss_not_updated_when_worse() {
let mut sched = loss_scheduler(0.5);
sched.advance(1, 1.0, 1); sched.advance(2, 1.5, 2); assert!((sched.best_loss - 1.0).abs() < f64::EPSILON);
}
#[test]
fn tick_interval_fires_at_correct_tick() {
let mut sched = tick_scheduler(100);
assert!(sched.advance(1, 1.0, 100).is_some());
}
#[test]
fn tick_interval_does_not_fire_before_interval() {
let mut sched = tick_scheduler(100);
assert!(sched.advance(1, 1.0, 99).is_none());
}
#[test]
fn tick_interval_does_not_fire_at_tick_zero() {
let mut sched = tick_scheduler(1);
assert!(sched.advance(1, 1.0, 0).is_none());
}
#[test]
fn tick_interval_fires_multiple_times() {
let mut sched = tick_scheduler(50);
let id1 = sched.advance(1, 1.0, 50);
assert!(id1.is_some());
assert!(sched.advance(2, 0.9, 99).is_none());
let id2 = sched.advance(3, 0.9, 100);
assert!(id2.is_some());
assert_ne!(
id1.expect("test: should succeed"),
id2.expect("test: should succeed")
);
}
#[test]
fn tick_interval_stats_incremented() {
let mut sched = tick_scheduler(10);
sched.advance(1, 1.0, 10);
assert_eq!(sched.stats().triggered_by_tick, 1);
assert_eq!(sched.stats().total_checkpoints_saved, 1);
}
#[test]
fn multiple_triggers_first_matching_fires() {
let config = SchedulerConfig {
triggers: vec![
CheckpointTrigger::StepInterval { every_n_steps: 5 },
CheckpointTrigger::LossImprovement { min_delta: 0.0 },
],
max_checkpoints: 10,
};
let sched = TensorCheckpointScheduler::new(config);
let trigger = sched.should_checkpoint(0, 1.0, 0);
assert_eq!(
trigger,
Some(CheckpointTrigger::LossImprovement { min_delta: 0.0 })
);
}
#[test]
fn multiple_triggers_step_fires_before_loss_when_first() {
let config = SchedulerConfig {
triggers: vec![
CheckpointTrigger::StepInterval { every_n_steps: 5 },
CheckpointTrigger::LossImprovement { min_delta: 0.0 },
],
max_checkpoints: 10,
};
let mut sched = TensorCheckpointScheduler::new(config);
sched.best_loss = 10.0;
let trigger = sched.should_checkpoint(5, 9.5, 0);
assert_eq!(
trigger,
Some(CheckpointTrigger::StepInterval { every_n_steps: 5 })
);
}
#[test]
fn multiple_triggers_neither_fires_returns_none() {
let config = SchedulerConfig {
triggers: vec![
CheckpointTrigger::StepInterval { every_n_steps: 10 },
CheckpointTrigger::TickInterval { every_n_ticks: 100 },
],
max_checkpoints: 5,
};
let mut sched = TensorCheckpointScheduler::new(config);
sched.last_checkpoint_step = 5;
sched.last_checkpoint_tick = 50;
assert!(sched.should_checkpoint(9, 1.0, 99).is_none());
}
#[test]
fn max_checkpoints_pruning_oldest_removed() {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
max_checkpoints: 3,
};
let mut sched = TensorCheckpointScheduler::new(config);
for step in 1u64..=4 {
sched.advance(step, 1.0, step);
}
assert_eq!(sched.checkpoints.len(), 3);
assert_eq!(sched.checkpoints[0].step, 2);
}
#[test]
fn checkpoints_pruned_stat_incremented() {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
max_checkpoints: 2,
};
let mut sched = TensorCheckpointScheduler::new(config);
for step in 1u64..=4 {
sched.advance(step, 1.0, step);
}
assert_eq!(sched.stats().checkpoints_pruned, 2);
}
#[test]
fn no_pruning_when_within_limit() {
let config = SchedulerConfig {
triggers: vec![CheckpointTrigger::StepInterval { every_n_steps: 1 }],
max_checkpoints: 10,
};
let mut sched = TensorCheckpointScheduler::new(config);
for step in 1u64..=5 {
sched.advance(step, 1.0, step);
}
assert_eq!(sched.stats().checkpoints_pruned, 0);
assert_eq!(sched.checkpoints.len(), 5);
}
#[test]
fn advance_returns_checkpoint_id_when_triggered() {
let mut sched = step_scheduler(5);
let id = sched.advance(5, 1.0, 0);
assert_eq!(id, Some(0));
}
#[test]
fn advance_returns_none_when_no_trigger() {
let mut sched = step_scheduler(10);
assert!(sched.advance(5, 1.0, 0).is_none());
}
#[test]
fn advance_increments_checkpoint_ids_sequentially() {
let mut sched = step_scheduler(1);
let id0 = sched.advance(1, 1.0, 0).expect("test: should succeed");
let id1 = sched.advance(2, 0.9, 0).expect("test: should succeed");
let id2 = sched.advance(3, 0.8, 0).expect("test: should succeed");
assert_eq!(id0, 0);
assert_eq!(id1, 1);
assert_eq!(id2, 2);
}
#[test]
fn latest_checkpoint_returns_last_added() {
let mut sched = step_scheduler(5);
assert!(sched.latest_checkpoint().is_none());
sched.advance(5, 2.0, 10);
sched.advance(10, 1.5, 20);
let latest = sched.latest_checkpoint().expect("should exist");
assert_eq!(latest.step, 10);
assert!((latest.loss - 1.5).abs() < f64::EPSILON);
}
#[test]
fn latest_checkpoint_none_when_no_checkpoints() {
let sched = step_scheduler(10);
assert!(sched.latest_checkpoint().is_none());
}
#[test]
fn stats_returns_reference_to_stats() {
let sched = step_scheduler(5);
let s = sched.stats();
assert_eq!(s.total_checkpoints_saved, 0);
assert_eq!(s.checkpoints_pruned, 0);
}
#[test]
fn checkpoint_record_fields_populated_correctly() {
let mut sched = step_scheduler(5);
sched.advance(5, 0.42, 17);
let rec = sched.latest_checkpoint().expect("should exist");
assert_eq!(rec.checkpoint_id, 0);
assert_eq!(rec.step, 5);
assert!((rec.loss - 0.42).abs() < 1e-10);
assert_eq!(rec.saved_at_tick, 17);
assert_eq!(
rec.trigger,
CheckpointTrigger::StepInterval { every_n_steps: 5 }
);
}
#[test]
fn scheduler_config_default() {
let cfg = SchedulerConfig::default();
assert!(cfg.triggers.is_empty());
assert_eq!(cfg.max_checkpoints, 5);
}
#[test]
fn no_triggers_never_checkpoints() {
let config = SchedulerConfig::default();
let mut sched = TensorCheckpointScheduler::new(config);
for step in 1u64..=100 {
assert!(sched.advance(step, 1.0 / step as f64, step).is_none());
}
assert_eq!(sched.stats().total_checkpoints_saved, 0);
}
#[test]
fn mixed_trigger_types_all_stats_accurate() {
let config = SchedulerConfig {
triggers: vec![
CheckpointTrigger::StepInterval { every_n_steps: 10 },
CheckpointTrigger::TickInterval { every_n_ticks: 20 },
],
max_checkpoints: 100,
};
let mut sched = TensorCheckpointScheduler::new(config);
sched.advance(5, 1.0, 20);
let r = sched.advance(10, 0.9, 25);
assert!(r.is_none());
sched.advance(15, 0.8, 40);
sched.advance(20, 0.7, 60);
assert_eq!(sched.stats().triggered_by_step, 1);
assert_eq!(sched.stats().triggered_by_tick, 2);
assert_eq!(sched.stats().triggered_by_loss, 0);
assert_eq!(sched.stats().total_checkpoints_saved, 3);
}
}