Skip to main content

ipfrs_tensorlogic/
early_stopping.rs

1//! Early stopping monitor for training loops.
2//!
3//! Provides patience-based early stopping with configurable criteria,
4//! minimum delta thresholds, and minimum epoch enforcement.
5
6use std::collections::HashMap;
7
8/// Criterion used to decide when to stop training.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum StopCriterion {
11    /// Stop when loss stops decreasing.
12    MinLoss,
13    /// Stop when accuracy stops increasing.
14    MaxAccuracy,
15    /// Custom metric, lower is better.
16    MinMetric(String),
17    /// Custom metric, higher is better.
18    MaxMetric(String),
19}
20
21/// Decision returned by the monitor after each epoch.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum StopDecision {
24    /// Training should continue.
25    Continue,
26    /// Training should stop, with the given reason.
27    Stop(String),
28}
29
30/// Configuration for the early stopping monitor.
31#[derive(Debug, Clone)]
32pub struct EarlyStoppingConfig {
33    /// Which criterion to optimise.
34    pub criterion: StopCriterion,
35    /// Number of epochs without improvement before stopping.
36    pub patience: usize,
37    /// Minimum change to qualify as improvement.
38    pub min_delta: f64,
39    /// Do not stop before this many epochs have elapsed.
40    pub min_epochs: usize,
41    /// Whether the caller intends to restore the best checkpoint.
42    pub restore_best: bool,
43}
44
45/// Metrics recorded for a single epoch.
46#[derive(Debug, Clone)]
47pub struct EpochMetrics {
48    /// Zero-based epoch index.
49    pub epoch: usize,
50    /// Training or validation loss.
51    pub loss: f64,
52    /// Optional accuracy value.
53    pub accuracy: Option<f64>,
54    /// Arbitrary named metrics.
55    pub custom_metrics: HashMap<String, f64>,
56}
57
58/// Cumulative statistics tracked by the monitor.
59#[derive(Debug, Clone, Default)]
60pub struct EarlyStoppingStats {
61    /// Total epochs observed.
62    pub total_epochs: u64,
63    /// Number of epochs that showed improvement.
64    pub improvements: u64,
65    /// Best metric value seen so far.
66    pub best_value: f64,
67    /// Epoch at which the best value was observed.
68    pub best_epoch: usize,
69    /// Epoch at which training was stopped, if any.
70    pub stopped_at: Option<usize>,
71}
72
73/// Monitors training progress and decides when to stop.
74pub struct EarlyStoppingMonitor {
75    config: EarlyStoppingConfig,
76    history: Vec<EpochMetrics>,
77    best_value: f64,
78    best_epoch: usize,
79    epochs_without_improvement: usize,
80    stopped: bool,
81    stats: EarlyStoppingStats,
82}
83
84impl EarlyStoppingMonitor {
85    /// Create a new monitor with the given configuration.
86    pub fn new(config: EarlyStoppingConfig) -> Self {
87        let initial_best = match &config.criterion {
88            StopCriterion::MinLoss | StopCriterion::MinMetric(_) => f64::INFINITY,
89            StopCriterion::MaxAccuracy | StopCriterion::MaxMetric(_) => f64::NEG_INFINITY,
90        };
91        Self {
92            config,
93            history: Vec::new(),
94            best_value: initial_best,
95            best_epoch: 0,
96            epochs_without_improvement: 0,
97            stopped: false,
98            stats: EarlyStoppingStats {
99                best_value: initial_best,
100                ..Default::default()
101            },
102        }
103    }
104
105    /// Check whether training should stop after observing the given metrics.
106    ///
107    /// Returns [`StopDecision::Continue`] or [`StopDecision::Stop`] with a reason.
108    pub fn check(&mut self, metrics: EpochMetrics) -> StopDecision {
109        if self.stopped {
110            return StopDecision::Stop("Already stopped".to_string());
111        }
112
113        let epoch = metrics.epoch;
114        let value = self.extract_metric(&metrics);
115        self.history.push(metrics);
116        self.stats.total_epochs += 1;
117
118        let value = match value {
119            Some(v) => v,
120            None => {
121                // Metric not available — treat as no improvement.
122                self.epochs_without_improvement += 1;
123                return self.maybe_stop(epoch);
124            }
125        };
126
127        // NaN is never an improvement.
128        if value.is_nan() {
129            self.epochs_without_improvement += 1;
130            return self.maybe_stop(epoch);
131        }
132
133        if self.is_improvement(value) {
134            self.best_value = value;
135            self.best_epoch = epoch;
136            self.epochs_without_improvement = 0;
137            self.stats.improvements += 1;
138            self.stats.best_value = value;
139            self.stats.best_epoch = epoch;
140        } else {
141            self.epochs_without_improvement += 1;
142        }
143
144        self.maybe_stop(epoch)
145    }
146
147    /// Return whether `value` is an improvement over the current best,
148    /// respecting [`EarlyStoppingConfig::min_delta`] and criterion direction.
149    pub fn is_improvement(&self, value: f64) -> bool {
150        if value.is_nan() {
151            return false;
152        }
153        match &self.config.criterion {
154            StopCriterion::MinLoss | StopCriterion::MinMetric(_) => {
155                value < self.best_value - self.config.min_delta
156            }
157            StopCriterion::MaxAccuracy | StopCriterion::MaxMetric(_) => {
158                value > self.best_value + self.config.min_delta
159            }
160        }
161    }
162
163    /// Best metric value observed so far.
164    pub fn best_value(&self) -> f64 {
165        self.best_value
166    }
167
168    /// Epoch at which the best value was observed.
169    pub fn best_epoch(&self) -> usize {
170        self.best_epoch
171    }
172
173    /// Number of consecutive epochs without improvement.
174    pub fn epochs_without_improvement(&self) -> usize {
175        self.epochs_without_improvement
176    }
177
178    /// Whether the monitor has triggered a stop.
179    pub fn should_stop(&self) -> bool {
180        self.stopped
181    }
182
183    /// Reset all state so the monitor can be reused.
184    pub fn reset(&mut self) {
185        let initial_best = match &self.config.criterion {
186            StopCriterion::MinLoss | StopCriterion::MinMetric(_) => f64::INFINITY,
187            StopCriterion::MaxAccuracy | StopCriterion::MaxMetric(_) => f64::NEG_INFINITY,
188        };
189        self.history.clear();
190        self.best_value = initial_best;
191        self.best_epoch = 0;
192        self.epochs_without_improvement = 0;
193        self.stopped = false;
194        self.stats = EarlyStoppingStats {
195            best_value: initial_best,
196            ..Default::default()
197        };
198    }
199
200    /// Full history of epoch metrics.
201    pub fn history(&self) -> &[EpochMetrics] {
202        &self.history
203    }
204
205    /// Cumulative statistics.
206    pub fn stats(&self) -> &EarlyStoppingStats {
207        &self.stats
208    }
209
210    /// How many more epochs of no-improvement are allowed before stopping.
211    pub fn remaining_patience(&self) -> usize {
212        self.config
213            .patience
214            .saturating_sub(self.epochs_without_improvement)
215    }
216
217    /// Extract the metric value that matches the configured criterion.
218    pub fn extract_metric(&self, metrics: &EpochMetrics) -> Option<f64> {
219        match &self.config.criterion {
220            StopCriterion::MinLoss => Some(metrics.loss),
221            StopCriterion::MaxAccuracy => metrics.accuracy,
222            StopCriterion::MinMetric(name) => metrics.custom_metrics.get(name).copied(),
223            StopCriterion::MaxMetric(name) => metrics.custom_metrics.get(name).copied(),
224        }
225    }
226
227    // --- private helpers ---
228
229    fn maybe_stop(&mut self, epoch: usize) -> StopDecision {
230        // Never stop before min_epochs.
231        if self.stats.total_epochs < self.config.min_epochs as u64 {
232            return StopDecision::Continue;
233        }
234        if self.epochs_without_improvement >= self.config.patience {
235            self.stopped = true;
236            self.stats.stopped_at = Some(epoch);
237            let reason = format!(
238                "No improvement for {} epochs (best={:.6} at epoch {})",
239                self.config.patience, self.best_value, self.best_epoch
240            );
241            StopDecision::Stop(reason)
242        } else {
243            StopDecision::Continue
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn default_config() -> EarlyStoppingConfig {
253        EarlyStoppingConfig {
254            criterion: StopCriterion::MinLoss,
255            patience: 3,
256            min_delta: 0.0,
257            min_epochs: 0,
258            restore_best: false,
259        }
260    }
261
262    fn epoch(epoch: usize, loss: f64) -> EpochMetrics {
263        EpochMetrics {
264            epoch,
265            loss,
266            accuracy: None,
267            custom_metrics: HashMap::new(),
268        }
269    }
270
271    fn epoch_with_acc(epoch: usize, loss: f64, acc: f64) -> EpochMetrics {
272        EpochMetrics {
273            epoch,
274            loss,
275            accuracy: Some(acc),
276            custom_metrics: HashMap::new(),
277        }
278    }
279
280    fn epoch_with_custom(epoch: usize, loss: f64, key: &str, val: f64) -> EpochMetrics {
281        let mut m = HashMap::new();
282        m.insert(key.to_string(), val);
283        EpochMetrics {
284            epoch,
285            loss,
286            accuracy: None,
287            custom_metrics: m,
288        }
289    }
290
291    // --- MinLoss tests ---
292
293    #[test]
294    fn min_loss_stops_after_patience_exhausted() {
295        let mut mon = EarlyStoppingMonitor::new(default_config());
296        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
297        assert_eq!(mon.check(epoch(1, 1.1)), StopDecision::Continue);
298        assert_eq!(mon.check(epoch(2, 1.2)), StopDecision::Continue);
299        match mon.check(epoch(3, 1.3)) {
300            StopDecision::Stop(_) => {}
301            StopDecision::Continue => panic!("expected Stop"),
302        }
303    }
304
305    #[test]
306    fn min_loss_continues_on_improvement() {
307        let mut mon = EarlyStoppingMonitor::new(default_config());
308        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
309        assert_eq!(mon.check(epoch(1, 0.9)), StopDecision::Continue);
310        assert_eq!(mon.check(epoch(2, 0.8)), StopDecision::Continue);
311        assert_eq!(mon.check(epoch(3, 0.7)), StopDecision::Continue);
312        assert_eq!(mon.check(epoch(4, 0.6)), StopDecision::Continue);
313    }
314
315    #[test]
316    fn min_loss_resets_patience_on_improvement() {
317        let mut mon = EarlyStoppingMonitor::new(default_config());
318        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
319        assert_eq!(mon.check(epoch(1, 1.1)), StopDecision::Continue); // no improvement
320        assert_eq!(mon.check(epoch(2, 1.2)), StopDecision::Continue); // no improvement
321        assert_eq!(mon.check(epoch(3, 0.5)), StopDecision::Continue); // improvement!
322        assert_eq!(mon.epochs_without_improvement(), 0);
323        assert_eq!(mon.check(epoch(4, 0.6)), StopDecision::Continue); // no improvement (1)
324        assert_eq!(mon.check(epoch(5, 0.7)), StopDecision::Continue); // no improvement (2)
325        match mon.check(epoch(6, 0.8)) {
326            StopDecision::Stop(_) => {}
327            StopDecision::Continue => panic!("expected Stop"),
328        }
329    }
330
331    // --- MaxAccuracy tests ---
332
333    #[test]
334    fn max_accuracy_stops_after_patience() {
335        let config = EarlyStoppingConfig {
336            criterion: StopCriterion::MaxAccuracy,
337            patience: 2,
338            min_delta: 0.0,
339            min_epochs: 0,
340            restore_best: false,
341        };
342        let mut mon = EarlyStoppingMonitor::new(config);
343        assert_eq!(
344            mon.check(epoch_with_acc(0, 1.0, 0.8)),
345            StopDecision::Continue
346        );
347        assert_eq!(
348            mon.check(epoch_with_acc(1, 0.9, 0.79)),
349            StopDecision::Continue
350        );
351        match mon.check(epoch_with_acc(2, 0.8, 0.78)) {
352            StopDecision::Stop(_) => {}
353            StopDecision::Continue => panic!("expected Stop"),
354        }
355    }
356
357    #[test]
358    fn max_accuracy_continues_on_improvement() {
359        let config = EarlyStoppingConfig {
360            criterion: StopCriterion::MaxAccuracy,
361            patience: 2,
362            min_delta: 0.0,
363            min_epochs: 0,
364            restore_best: false,
365        };
366        let mut mon = EarlyStoppingMonitor::new(config);
367        assert_eq!(
368            mon.check(epoch_with_acc(0, 1.0, 0.5)),
369            StopDecision::Continue
370        );
371        assert_eq!(
372            mon.check(epoch_with_acc(1, 0.9, 0.6)),
373            StopDecision::Continue
374        );
375        assert_eq!(
376            mon.check(epoch_with_acc(2, 0.8, 0.7)),
377            StopDecision::Continue
378        );
379        assert!(!mon.should_stop());
380    }
381
382    // --- min_delta tests ---
383
384    #[test]
385    fn min_delta_prevents_tiny_improvements() {
386        let config = EarlyStoppingConfig {
387            criterion: StopCriterion::MinLoss,
388            patience: 2,
389            min_delta: 0.1,
390            min_epochs: 0,
391            restore_best: false,
392        };
393        let mut mon = EarlyStoppingMonitor::new(config);
394        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
395        // Decrease by 0.05 — below min_delta, not improvement.
396        assert_eq!(mon.check(epoch(1, 0.95)), StopDecision::Continue);
397        assert_eq!(mon.epochs_without_improvement(), 1);
398        match mon.check(epoch(2, 0.96)) {
399            StopDecision::Stop(_) => {}
400            StopDecision::Continue => panic!("expected Stop"),
401        }
402    }
403
404    #[test]
405    fn min_delta_allows_large_improvements() {
406        let config = EarlyStoppingConfig {
407            criterion: StopCriterion::MinLoss,
408            patience: 2,
409            min_delta: 0.1,
410            min_epochs: 0,
411            restore_best: false,
412        };
413        let mut mon = EarlyStoppingMonitor::new(config);
414        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
415        assert_eq!(mon.check(epoch(1, 0.8)), StopDecision::Continue); // 0.2 > 0.1
416        assert_eq!(mon.epochs_without_improvement(), 0);
417    }
418
419    #[test]
420    fn min_delta_for_max_accuracy() {
421        let config = EarlyStoppingConfig {
422            criterion: StopCriterion::MaxAccuracy,
423            patience: 2,
424            min_delta: 0.05,
425            min_epochs: 0,
426            restore_best: false,
427        };
428        let mut mon = EarlyStoppingMonitor::new(config);
429        assert_eq!(
430            mon.check(epoch_with_acc(0, 1.0, 0.7)),
431            StopDecision::Continue
432        );
433        // +0.02 — not enough.
434        assert_eq!(
435            mon.check(epoch_with_acc(1, 0.9, 0.72)),
436            StopDecision::Continue
437        );
438        assert_eq!(mon.epochs_without_improvement(), 1);
439        // +0.1 from best — enough.
440        assert_eq!(
441            mon.check(epoch_with_acc(2, 0.8, 0.81)),
442            StopDecision::Continue
443        );
444        assert_eq!(mon.epochs_without_improvement(), 0);
445    }
446
447    // --- min_epochs enforcement ---
448
449    #[test]
450    fn min_epochs_prevents_early_stop() {
451        let config = EarlyStoppingConfig {
452            criterion: StopCriterion::MinLoss,
453            patience: 1,
454            min_delta: 0.0,
455            min_epochs: 5,
456            restore_best: false,
457        };
458        let mut mon = EarlyStoppingMonitor::new(config);
459        // Even with patience=1, should not stop before 5 epochs.
460        for i in 0..4 {
461            assert_eq!(mon.check(epoch(i, 1.0 + i as f64)), StopDecision::Continue);
462        }
463        // Epoch 5 — patience already exhausted, should stop.
464        match mon.check(epoch(4, 5.0)) {
465            StopDecision::Stop(_) => {}
466            StopDecision::Continue => panic!("expected Stop after min_epochs"),
467        }
468    }
469
470    #[test]
471    fn min_epochs_allows_stop_at_boundary() {
472        let config = EarlyStoppingConfig {
473            criterion: StopCriterion::MinLoss,
474            patience: 2,
475            min_delta: 0.0,
476            min_epochs: 3,
477            restore_best: false,
478        };
479        let mut mon = EarlyStoppingMonitor::new(config);
480        assert_eq!(mon.check(epoch(0, 1.0)), StopDecision::Continue);
481        assert_eq!(mon.check(epoch(1, 1.1)), StopDecision::Continue);
482        // Third epoch — min_epochs reached, patience exhausted.
483        match mon.check(epoch(2, 1.2)) {
484            StopDecision::Stop(_) => {}
485            StopDecision::Continue => panic!("expected Stop"),
486        }
487    }
488
489    // --- Custom metrics ---
490
491    #[test]
492    fn custom_min_metric() {
493        let config = EarlyStoppingConfig {
494            criterion: StopCriterion::MinMetric("val_loss".to_string()),
495            patience: 2,
496            min_delta: 0.0,
497            min_epochs: 0,
498            restore_best: false,
499        };
500        let mut mon = EarlyStoppingMonitor::new(config);
501        assert_eq!(
502            mon.check(epoch_with_custom(0, 999.0, "val_loss", 1.0)),
503            StopDecision::Continue
504        );
505        assert_eq!(
506            mon.check(epoch_with_custom(1, 999.0, "val_loss", 0.9)),
507            StopDecision::Continue
508        );
509        assert_eq!(mon.epochs_without_improvement(), 0);
510        assert_eq!(
511            mon.check(epoch_with_custom(2, 999.0, "val_loss", 1.0)),
512            StopDecision::Continue
513        );
514        match mon.check(epoch_with_custom(3, 999.0, "val_loss", 1.1)) {
515            StopDecision::Stop(_) => {}
516            StopDecision::Continue => panic!("expected Stop"),
517        }
518    }
519
520    #[test]
521    fn custom_max_metric() {
522        let config = EarlyStoppingConfig {
523            criterion: StopCriterion::MaxMetric("f1_score".to_string()),
524            patience: 2,
525            min_delta: 0.0,
526            min_epochs: 0,
527            restore_best: false,
528        };
529        let mut mon = EarlyStoppingMonitor::new(config);
530        assert_eq!(
531            mon.check(epoch_with_custom(0, 999.0, "f1_score", 0.5)),
532            StopDecision::Continue
533        );
534        assert_eq!(
535            mon.check(epoch_with_custom(1, 999.0, "f1_score", 0.6)),
536            StopDecision::Continue
537        );
538        assert_eq!(
539            mon.check(epoch_with_custom(2, 999.0, "f1_score", 0.55)),
540            StopDecision::Continue
541        );
542        match mon.check(epoch_with_custom(3, 999.0, "f1_score", 0.54)) {
543            StopDecision::Stop(_) => {}
544            StopDecision::Continue => panic!("expected Stop"),
545        }
546    }
547
548    #[test]
549    fn missing_custom_metric_counts_as_no_improvement() {
550        let config = EarlyStoppingConfig {
551            criterion: StopCriterion::MinMetric("val_loss".to_string()),
552            patience: 2,
553            min_delta: 0.0,
554            min_epochs: 0,
555            restore_best: false,
556        };
557        let mut mon = EarlyStoppingMonitor::new(config);
558        // Epoch 0: metric present.
559        assert_eq!(
560            mon.check(epoch_with_custom(0, 1.0, "val_loss", 0.5)),
561            StopDecision::Continue
562        );
563        // Epoch 1, 2: metric missing — no improvement.
564        assert_eq!(mon.check(epoch(1, 1.0)), StopDecision::Continue);
565        match mon.check(epoch(2, 1.0)) {
566            StopDecision::Stop(_) => {}
567            StopDecision::Continue => panic!("expected Stop"),
568        }
569    }
570
571    // --- reset ---
572
573    #[test]
574    fn reset_clears_all_state() {
575        let mut mon = EarlyStoppingMonitor::new(default_config());
576        mon.check(epoch(0, 1.0));
577        mon.check(epoch(1, 1.1));
578        mon.check(epoch(2, 1.2));
579        mon.check(epoch(3, 1.3)); // stopped
580        assert!(mon.should_stop());
581        assert_eq!(mon.history().len(), 4);
582
583        mon.reset();
584        assert!(!mon.should_stop());
585        assert!(mon.history().is_empty());
586        assert_eq!(mon.epochs_without_improvement(), 0);
587        assert_eq!(mon.best_value(), f64::INFINITY);
588        assert_eq!(mon.stats().total_epochs, 0);
589    }
590
591    // --- improvement detection ---
592
593    #[test]
594    fn is_improvement_min_loss() {
595        let mon = EarlyStoppingMonitor::new(default_config());
596        // best is INFINITY, so any finite value is an improvement.
597        assert!(mon.is_improvement(1.0));
598        assert!(mon.is_improvement(-100.0));
599    }
600
601    #[test]
602    fn is_improvement_max_accuracy() {
603        let config = EarlyStoppingConfig {
604            criterion: StopCriterion::MaxAccuracy,
605            patience: 3,
606            min_delta: 0.0,
607            min_epochs: 0,
608            restore_best: false,
609        };
610        let mon = EarlyStoppingMonitor::new(config);
611        // best is NEG_INFINITY, so any finite value is an improvement.
612        assert!(mon.is_improvement(0.0));
613        assert!(mon.is_improvement(0.5));
614    }
615
616    #[test]
617    fn is_improvement_nan_never_improves() {
618        let mon = EarlyStoppingMonitor::new(default_config());
619        assert!(!mon.is_improvement(f64::NAN));
620    }
621
622    // --- history ---
623
624    #[test]
625    fn history_tracks_all_epochs() {
626        let mut mon = EarlyStoppingMonitor::new(default_config());
627        mon.check(epoch(0, 1.0));
628        mon.check(epoch(1, 0.9));
629        mon.check(epoch(2, 0.8));
630        assert_eq!(mon.history().len(), 3);
631        assert_eq!(mon.history()[0].epoch, 0);
632        assert_eq!(mon.history()[2].epoch, 2);
633    }
634
635    // --- stats ---
636
637    #[test]
638    fn stats_track_improvements() {
639        let mut mon = EarlyStoppingMonitor::new(default_config());
640        mon.check(epoch(0, 1.0)); // improvement (first)
641        mon.check(epoch(1, 0.9)); // improvement
642        mon.check(epoch(2, 0.8)); // improvement
643        mon.check(epoch(3, 0.9)); // no improvement
644        let s = mon.stats();
645        assert_eq!(s.total_epochs, 4);
646        assert_eq!(s.improvements, 3);
647        assert!((s.best_value - 0.8).abs() < 1e-10);
648        assert_eq!(s.best_epoch, 2);
649        assert!(s.stopped_at.is_none());
650    }
651
652    #[test]
653    fn stats_record_stopped_at() {
654        let mut mon = EarlyStoppingMonitor::new(default_config());
655        mon.check(epoch(0, 1.0));
656        mon.check(epoch(1, 1.1));
657        mon.check(epoch(2, 1.2));
658        mon.check(epoch(3, 1.3)); // stop
659        assert_eq!(mon.stats().stopped_at, Some(3));
660    }
661
662    // --- stop reason message ---
663
664    #[test]
665    fn stop_reason_includes_details() {
666        let mut mon = EarlyStoppingMonitor::new(default_config());
667        mon.check(epoch(0, 0.5));
668        mon.check(epoch(1, 0.6));
669        mon.check(epoch(2, 0.7));
670        match mon.check(epoch(3, 0.8)) {
671            StopDecision::Stop(reason) => {
672                assert!(reason.contains("3 epochs"), "reason: {reason}");
673                assert!(reason.contains("0.5"), "reason: {reason}");
674                assert!(reason.contains("epoch 0"), "reason: {reason}");
675            }
676            StopDecision::Continue => panic!("expected Stop"),
677        }
678    }
679
680    // --- edge cases ---
681
682    #[test]
683    fn nan_loss_does_not_improve() {
684        let mut mon = EarlyStoppingMonitor::new(default_config());
685        mon.check(epoch(0, 1.0));
686        mon.check(epoch(1, f64::NAN));
687        assert_eq!(mon.epochs_without_improvement(), 1);
688        assert!((mon.best_value() - 1.0).abs() < 1e-10);
689    }
690
691    #[test]
692    fn identical_values_no_improvement() {
693        let mut mon = EarlyStoppingMonitor::new(default_config());
694        mon.check(epoch(0, 1.0));
695        mon.check(epoch(1, 1.0)); // same — not an improvement
696        assert_eq!(mon.epochs_without_improvement(), 1);
697    }
698
699    #[test]
700    fn identical_values_with_zero_delta_still_no_improvement() {
701        let config = EarlyStoppingConfig {
702            criterion: StopCriterion::MinLoss,
703            patience: 3,
704            min_delta: 0.0,
705            min_epochs: 0,
706            restore_best: false,
707        };
708        let mut mon = EarlyStoppingMonitor::new(config);
709        mon.check(epoch(0, 1.0));
710        mon.check(epoch(1, 1.0));
711        // Strictly less than required — equal is not improvement.
712        assert_eq!(mon.epochs_without_improvement(), 1);
713    }
714
715    // --- remaining patience ---
716
717    #[test]
718    fn remaining_patience_countdown() {
719        let mut mon = EarlyStoppingMonitor::new(default_config());
720        assert_eq!(mon.remaining_patience(), 3);
721        mon.check(epoch(0, 1.0)); // improvement → reset
722        assert_eq!(mon.remaining_patience(), 3);
723        mon.check(epoch(1, 1.1)); // no improvement
724        assert_eq!(mon.remaining_patience(), 2);
725        mon.check(epoch(2, 1.2));
726        assert_eq!(mon.remaining_patience(), 1);
727        mon.check(epoch(3, 1.3)); // stop
728        assert_eq!(mon.remaining_patience(), 0);
729    }
730
731    #[test]
732    fn remaining_patience_resets_on_improvement() {
733        let mut mon = EarlyStoppingMonitor::new(default_config());
734        mon.check(epoch(0, 1.0));
735        mon.check(epoch(1, 1.1)); // no
736        mon.check(epoch(2, 1.2)); // no
737        assert_eq!(mon.remaining_patience(), 1);
738        mon.check(epoch(3, 0.5)); // improvement!
739        assert_eq!(mon.remaining_patience(), 3);
740    }
741
742    // --- extract_metric ---
743
744    #[test]
745    fn extract_metric_min_loss() {
746        let mon = EarlyStoppingMonitor::new(default_config());
747        let m = epoch(0, 0.42);
748        assert_eq!(mon.extract_metric(&m), Some(0.42));
749    }
750
751    #[test]
752    fn extract_metric_max_accuracy_present() {
753        let config = EarlyStoppingConfig {
754            criterion: StopCriterion::MaxAccuracy,
755            ..default_config()
756        };
757        let mon = EarlyStoppingMonitor::new(config);
758        let m = epoch_with_acc(0, 1.0, 0.95);
759        assert_eq!(mon.extract_metric(&m), Some(0.95));
760    }
761
762    #[test]
763    fn extract_metric_max_accuracy_absent() {
764        let config = EarlyStoppingConfig {
765            criterion: StopCriterion::MaxAccuracy,
766            ..default_config()
767        };
768        let mon = EarlyStoppingMonitor::new(config);
769        let m = epoch(0, 1.0);
770        assert_eq!(mon.extract_metric(&m), None);
771    }
772
773    #[test]
774    fn extract_metric_custom_present() {
775        let config = EarlyStoppingConfig {
776            criterion: StopCriterion::MinMetric("rmse".to_string()),
777            ..default_config()
778        };
779        let mon = EarlyStoppingMonitor::new(config);
780        let m = epoch_with_custom(0, 1.0, "rmse", std::f64::consts::PI);
781        assert_eq!(mon.extract_metric(&m), Some(std::f64::consts::PI));
782    }
783
784    #[test]
785    fn extract_metric_custom_absent() {
786        let config = EarlyStoppingConfig {
787            criterion: StopCriterion::MinMetric("rmse".to_string()),
788            ..default_config()
789        };
790        let mon = EarlyStoppingMonitor::new(config);
791        let m = epoch(0, 1.0);
792        assert_eq!(mon.extract_metric(&m), None);
793    }
794
795    // --- already stopped ---
796
797    #[test]
798    fn returns_stop_once_stopped() {
799        let mut mon = EarlyStoppingMonitor::new(default_config());
800        mon.check(epoch(0, 1.0));
801        mon.check(epoch(1, 1.1));
802        mon.check(epoch(2, 1.2));
803        mon.check(epoch(3, 1.3)); // stop
804        assert!(mon.should_stop());
805        match mon.check(epoch(4, 0.1)) {
806            StopDecision::Stop(reason) => assert!(reason.contains("Already")),
807            StopDecision::Continue => panic!("expected Stop"),
808        }
809    }
810
811    // --- best_epoch and best_value after improvements ---
812
813    #[test]
814    fn best_epoch_tracks_correctly() {
815        let mut mon = EarlyStoppingMonitor::new(default_config());
816        mon.check(epoch(0, 1.0));
817        assert_eq!(mon.best_epoch(), 0);
818        mon.check(epoch(1, 0.8));
819        assert_eq!(mon.best_epoch(), 1);
820        mon.check(epoch(2, 0.9));
821        assert_eq!(mon.best_epoch(), 1); // still 1
822    }
823
824    #[test]
825    fn best_value_tracks_correctly() {
826        let mut mon = EarlyStoppingMonitor::new(default_config());
827        mon.check(epoch(0, 1.0));
828        assert!((mon.best_value() - 1.0).abs() < 1e-10);
829        mon.check(epoch(1, 0.5));
830        assert!((mon.best_value() - 0.5).abs() < 1e-10);
831        mon.check(epoch(2, 0.7));
832        assert!((mon.best_value() - 0.5).abs() < 1e-10);
833    }
834}