Skip to main content

ipfrs_tensorlogic/
lr_scheduler.rs

1//! TensorLRScheduler -- learning rate scheduling strategies for tensor
2//! optimization loops.
3//!
4//! Supported schedules:
5//! - **Constant** -- fixed learning rate
6//! - **StepDecay** -- multiply by gamma every `step_size` steps
7//! - **ExponentialDecay** -- multiply by gamma every step
8//! - **CosineAnnealing** -- cosine decay to `min_lr`
9//! - **WarmupLinear** -- linear warmup then constant
10//! - **OneCycleLR** -- ramp up to `max_lr` then cosine decay to `min_lr`
11
12use std::f64::consts::PI;
13
14/// Scheduling strategy variant.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum ScheduleType {
17    /// Always return `initial_lr`.
18    Constant,
19    /// Multiply by `gamma` every `step_size` steps.
20    StepDecay,
21    /// Multiply by `gamma` every step.
22    ExponentialDecay,
23    /// Cosine schedule from `initial_lr` down to `min_lr`.
24    CosineAnnealing,
25    /// Linear warmup from 0 to `initial_lr` over `warmup_steps`, then constant.
26    WarmupLinear,
27    /// Ramp up to `max_lr` during warmup, then cosine decay to `min_lr`.
28    OneCycleLR,
29}
30
31impl std::fmt::Display for ScheduleType {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Constant => write!(f, "Constant"),
35            Self::StepDecay => write!(f, "StepDecay"),
36            Self::ExponentialDecay => write!(f, "ExponentialDecay"),
37            Self::CosineAnnealing => write!(f, "CosineAnnealing"),
38            Self::WarmupLinear => write!(f, "WarmupLinear"),
39            Self::OneCycleLR => write!(f, "OneCycleLR"),
40        }
41    }
42}
43
44/// Configuration for a learning rate scheduler.
45#[derive(Debug, Clone)]
46pub struct LRSchedulerConfig {
47    /// Which schedule to use.
48    pub schedule_type: ScheduleType,
49    /// Starting learning rate (default 0.01).
50    pub initial_lr: f64,
51    /// Floor learning rate for cosine / OneCycle (default 1e-6).
52    pub min_lr: f64,
53    /// Decay factor for Step/Exponential schedules (default 0.1).
54    pub gamma: f64,
55    /// Epoch count between decays for StepDecay (default 30).
56    pub step_size: usize,
57    /// Total training steps for Cosine / OneCycle (default 1000).
58    pub total_steps: usize,
59    /// Number of warmup steps for Warmup / OneCycle (default 100).
60    pub warmup_steps: usize,
61    /// Peak learning rate for OneCycle (default 0.1).
62    pub max_lr: f64,
63}
64
65impl Default for LRSchedulerConfig {
66    fn default() -> Self {
67        Self {
68            schedule_type: ScheduleType::Constant,
69            initial_lr: 0.01,
70            min_lr: 1e-6,
71            gamma: 0.1,
72            step_size: 30,
73            total_steps: 1000,
74            warmup_steps: 100,
75            max_lr: 0.1,
76        }
77    }
78}
79
80/// Run-time statistics snapshot.
81#[derive(Debug, Clone)]
82pub struct LRSchedulerStats {
83    /// Active schedule type.
84    pub schedule_type: ScheduleType,
85    /// Current step counter.
86    pub current_step: usize,
87    /// Current learning rate value.
88    pub current_lr: f64,
89    /// Initial learning rate from config.
90    pub initial_lr: f64,
91}
92
93/// Learning rate scheduler with multiple strategy support.
94#[derive(Debug, Clone)]
95pub struct TensorLRScheduler {
96    config: LRSchedulerConfig,
97    current_step: usize,
98    current_lr: f64,
99}
100
101impl TensorLRScheduler {
102    /// Create a new scheduler from the given config.  The initial learning rate
103    /// is computed for step 0.
104    pub fn new(config: LRSchedulerConfig) -> Self {
105        let lr = Self::compute_lr(&config, 0);
106        Self {
107            config,
108            current_step: 0,
109            current_lr: lr,
110        }
111    }
112
113    /// Advance by one step, compute the new learning rate, and return it.
114    pub fn step(&mut self) -> f64 {
115        self.current_step += 1;
116        self.current_lr = Self::compute_lr(&self.config, self.current_step);
117        self.current_lr
118    }
119
120    /// Return the current learning rate without advancing the step counter.
121    pub fn get_lr(&self) -> f64 {
122        self.current_lr
123    }
124
125    /// Jump to a specific step and recompute the learning rate.
126    pub fn set_step(&mut self, step: usize) {
127        self.current_step = step;
128        self.current_lr = Self::compute_lr(&self.config, step);
129    }
130
131    /// For finite schedules (Cosine, OneCycle, WarmupLinear) return the number
132    /// of remaining steps.  Returns `None` for schedules that run indefinitely.
133    pub fn remaining_steps(&self) -> Option<usize> {
134        match self.config.schedule_type {
135            ScheduleType::CosineAnnealing | ScheduleType::OneCycleLR => {
136                Some(self.config.total_steps.saturating_sub(self.current_step))
137            }
138            ScheduleType::WarmupLinear => {
139                Some(self.config.warmup_steps.saturating_sub(self.current_step))
140            }
141            ScheduleType::Constant | ScheduleType::StepDecay | ScheduleType::ExponentialDecay => {
142                None
143            }
144        }
145    }
146
147    /// Reset to step 0.
148    pub fn reset(&mut self) {
149        self.current_step = 0;
150        self.current_lr = Self::compute_lr(&self.config, 0);
151    }
152
153    /// Return a snapshot of the scheduler state.
154    pub fn stats(&self) -> LRSchedulerStats {
155        LRSchedulerStats {
156            schedule_type: self.config.schedule_type,
157            current_step: self.current_step,
158            current_lr: self.current_lr,
159            initial_lr: self.config.initial_lr,
160        }
161    }
162
163    /// Read-only access to the config.
164    pub fn config(&self) -> &LRSchedulerConfig {
165        &self.config
166    }
167
168    // ------------------------------------------------------------------
169    // Internal: pure function that computes LR for a given step.
170    // ------------------------------------------------------------------
171
172    fn compute_lr(cfg: &LRSchedulerConfig, step: usize) -> f64 {
173        match cfg.schedule_type {
174            ScheduleType::Constant => cfg.initial_lr,
175
176            ScheduleType::StepDecay => {
177                let exponent = (step / cfg.step_size.max(1)) as f64;
178                cfg.initial_lr * cfg.gamma.powf(exponent)
179            }
180
181            ScheduleType::ExponentialDecay => cfg.initial_lr * cfg.gamma.powf(step as f64),
182
183            ScheduleType::CosineAnnealing => {
184                let total = cfg.total_steps.max(1) as f64;
185                let t = (step as f64).min(total);
186                cfg.min_lr + 0.5 * (cfg.initial_lr - cfg.min_lr) * (1.0 + (PI * t / total).cos())
187            }
188
189            ScheduleType::WarmupLinear => {
190                if cfg.warmup_steps == 0 {
191                    return cfg.initial_lr;
192                }
193                if step < cfg.warmup_steps {
194                    cfg.initial_lr * (step as f64) / (cfg.warmup_steps as f64)
195                } else {
196                    cfg.initial_lr
197                }
198            }
199
200            ScheduleType::OneCycleLR => {
201                let warmup = cfg.warmup_steps.max(1);
202                let total = cfg.total_steps.max(warmup + 1);
203                if step < warmup {
204                    // Linear ramp from min_lr to max_lr
205                    let frac = step as f64 / warmup as f64;
206                    cfg.min_lr + frac * (cfg.max_lr - cfg.min_lr)
207                } else {
208                    // Cosine decay from max_lr to min_lr
209                    let decay_steps = (total - warmup).max(1) as f64;
210                    let t = ((step - warmup) as f64).min(decay_steps);
211                    cfg.min_lr
212                        + 0.5 * (cfg.max_lr - cfg.min_lr) * (1.0 + (PI * t / decay_steps).cos())
213                }
214            }
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    // ------------------------------------------------------------------ helpers
224    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
225        (a - b).abs() < tol
226    }
227
228    // --------------------------------------------------- Constant schedule tests
229    #[test]
230    fn constant_always_returns_initial_lr() {
231        let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
232            schedule_type: ScheduleType::Constant,
233            initial_lr: 0.05,
234            ..Default::default()
235        });
236        for _ in 0..50 {
237            let lr = sched.step();
238            assert!(approx_eq(lr, 0.05, 1e-12));
239        }
240    }
241
242    #[test]
243    fn constant_get_lr_matches_step() {
244        let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
245            schedule_type: ScheduleType::Constant,
246            initial_lr: 0.02,
247            ..Default::default()
248        });
249        sched.step();
250        assert!(approx_eq(sched.get_lr(), 0.02, 1e-12));
251    }
252
253    // --------------------------------------------------- StepDecay tests
254    #[test]
255    fn step_decay_decays_at_boundary() {
256        let cfg = LRSchedulerConfig {
257            schedule_type: ScheduleType::StepDecay,
258            initial_lr: 1.0,
259            gamma: 0.5,
260            step_size: 10,
261            ..Default::default()
262        };
263        let mut sched = TensorLRScheduler::new(cfg);
264        // At step 0 => lr = 1.0
265        assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
266        // Advance to step 9 => still in first interval
267        for _ in 0..9 {
268            sched.step();
269        }
270        assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
271        // step 10 => gamma^1 = 0.5
272        sched.step();
273        assert!(approx_eq(sched.get_lr(), 0.5, 1e-12));
274        // step 20 => gamma^2 = 0.25
275        for _ in 0..10 {
276            sched.step();
277        }
278        assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
279    }
280
281    #[test]
282    fn step_decay_within_interval_is_flat() {
283        let cfg = LRSchedulerConfig {
284            schedule_type: ScheduleType::StepDecay,
285            initial_lr: 0.1,
286            gamma: 0.1,
287            step_size: 5,
288            ..Default::default()
289        };
290        let mut sched = TensorLRScheduler::new(cfg);
291        let lr0 = sched.get_lr();
292        for i in 1..5 {
293            sched.step();
294            assert!(
295                approx_eq(sched.get_lr(), lr0, 1e-12),
296                "LR changed at step {i} within interval"
297            );
298        }
299    }
300
301    // ------------------------------------------------- ExponentialDecay tests
302    #[test]
303    fn exponential_monotone_decrease() {
304        let cfg = LRSchedulerConfig {
305            schedule_type: ScheduleType::ExponentialDecay,
306            initial_lr: 1.0,
307            gamma: 0.95,
308            ..Default::default()
309        };
310        let mut sched = TensorLRScheduler::new(cfg);
311        let mut prev = sched.get_lr();
312        for _ in 0..100 {
313            let lr = sched.step();
314            assert!(lr < prev + 1e-15, "LR did not decrease");
315            prev = lr;
316        }
317    }
318
319    #[test]
320    fn exponential_decay_formula() {
321        let cfg = LRSchedulerConfig {
322            schedule_type: ScheduleType::ExponentialDecay,
323            initial_lr: 2.0,
324            gamma: 0.9,
325            ..Default::default()
326        };
327        let mut sched = TensorLRScheduler::new(cfg);
328        for step in 1..=5 {
329            let lr = sched.step();
330            let expected = 2.0 * 0.9_f64.powf(step as f64);
331            assert!(
332                approx_eq(lr, expected, 1e-10),
333                "step {step}: got {lr}, expected {expected}"
334            );
335        }
336    }
337
338    // ------------------------------------------------- CosineAnnealing tests
339    #[test]
340    fn cosine_reaches_min_at_total_steps() {
341        let cfg = LRSchedulerConfig {
342            schedule_type: ScheduleType::CosineAnnealing,
343            initial_lr: 0.1,
344            min_lr: 1e-5,
345            total_steps: 200,
346            ..Default::default()
347        };
348        let mut sched = TensorLRScheduler::new(cfg);
349        for _ in 0..200 {
350            sched.step();
351        }
352        assert!(
353            approx_eq(sched.get_lr(), 1e-5, 1e-10),
354            "LR at total_steps should be min_lr, got {}",
355            sched.get_lr()
356        );
357    }
358
359    #[test]
360    fn cosine_starts_at_initial_lr() {
361        let cfg = LRSchedulerConfig {
362            schedule_type: ScheduleType::CosineAnnealing,
363            initial_lr: 0.05,
364            min_lr: 0.001,
365            total_steps: 500,
366            ..Default::default()
367        };
368        let sched = TensorLRScheduler::new(cfg);
369        assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
370    }
371
372    #[test]
373    fn cosine_midpoint_value() {
374        let cfg = LRSchedulerConfig {
375            schedule_type: ScheduleType::CosineAnnealing,
376            initial_lr: 1.0,
377            min_lr: 0.0,
378            total_steps: 100,
379            ..Default::default()
380        };
381        let mut sched = TensorLRScheduler::new(cfg);
382        // At step 50 => 0.5 * (1 + cos(pi*50/100)) = 0.5 * (1 + cos(pi/2)) = 0.5
383        for _ in 0..50 {
384            sched.step();
385        }
386        assert!(
387            approx_eq(sched.get_lr(), 0.5, 1e-10),
388            "cosine midpoint: got {}",
389            sched.get_lr()
390        );
391    }
392
393    // --------------------------------------------------- WarmupLinear tests
394    #[test]
395    fn warmup_linear_ramp() {
396        let cfg = LRSchedulerConfig {
397            schedule_type: ScheduleType::WarmupLinear,
398            initial_lr: 0.1,
399            warmup_steps: 10,
400            ..Default::default()
401        };
402        let mut sched = TensorLRScheduler::new(cfg);
403        // step 0 => lr = 0.0
404        assert!(approx_eq(sched.get_lr(), 0.0, 1e-12));
405        // step 5 => lr = 0.05
406        for _ in 0..5 {
407            sched.step();
408        }
409        assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
410        // step 10 => lr = 0.1 (reached initial_lr)
411        for _ in 0..5 {
412            sched.step();
413        }
414        assert!(approx_eq(sched.get_lr(), 0.1, 1e-12));
415    }
416
417    #[test]
418    fn warmup_holds_after_warmup() {
419        let cfg = LRSchedulerConfig {
420            schedule_type: ScheduleType::WarmupLinear,
421            initial_lr: 0.01,
422            warmup_steps: 5,
423            ..Default::default()
424        };
425        let mut sched = TensorLRScheduler::new(cfg);
426        for _ in 0..20 {
427            sched.step();
428        }
429        assert!(approx_eq(sched.get_lr(), 0.01, 1e-12));
430    }
431
432    #[test]
433    fn warmup_zero_steps_returns_initial() {
434        let cfg = LRSchedulerConfig {
435            schedule_type: ScheduleType::WarmupLinear,
436            initial_lr: 0.03,
437            warmup_steps: 0,
438            ..Default::default()
439        };
440        let sched = TensorLRScheduler::new(cfg);
441        assert!(approx_eq(sched.get_lr(), 0.03, 1e-12));
442    }
443
444    // --------------------------------------------------- OneCycleLR tests
445    #[test]
446    fn one_cycle_starts_at_min_lr() {
447        let cfg = LRSchedulerConfig {
448            schedule_type: ScheduleType::OneCycleLR,
449            initial_lr: 0.01,
450            min_lr: 1e-4,
451            max_lr: 0.1,
452            warmup_steps: 50,
453            total_steps: 200,
454            ..Default::default()
455        };
456        let sched = TensorLRScheduler::new(cfg);
457        assert!(
458            approx_eq(sched.get_lr(), 1e-4, 1e-12),
459            "OneCycle should start at min_lr, got {}",
460            sched.get_lr()
461        );
462    }
463
464    #[test]
465    fn one_cycle_reaches_peak() {
466        let cfg = LRSchedulerConfig {
467            schedule_type: ScheduleType::OneCycleLR,
468            initial_lr: 0.01,
469            min_lr: 0.0,
470            max_lr: 0.2,
471            warmup_steps: 100,
472            total_steps: 500,
473            ..Default::default()
474        };
475        let mut sched = TensorLRScheduler::new(cfg);
476        for _ in 0..100 {
477            sched.step();
478        }
479        // At warmup boundary: the first decay step with t=0 => cos(0)=1 =>
480        // 0.5*(0.2)*(1+1) = 0.2
481        assert!(
482            approx_eq(sched.get_lr(), 0.2, 1e-10),
483            "OneCycle should reach max_lr at warmup end, got {}",
484            sched.get_lr()
485        );
486    }
487
488    #[test]
489    fn one_cycle_ends_at_min_lr() {
490        let cfg = LRSchedulerConfig {
491            schedule_type: ScheduleType::OneCycleLR,
492            initial_lr: 0.01,
493            min_lr: 1e-5,
494            max_lr: 0.1,
495            warmup_steps: 50,
496            total_steps: 300,
497            ..Default::default()
498        };
499        let mut sched = TensorLRScheduler::new(cfg);
500        for _ in 0..300 {
501            sched.step();
502        }
503        assert!(
504            approx_eq(sched.get_lr(), 1e-5, 1e-10),
505            "OneCycle should end at min_lr, got {}",
506            sched.get_lr()
507        );
508    }
509
510    // -------------------------------------------------- reset / set_step / remaining
511    #[test]
512    fn reset_restores_step_zero() {
513        let cfg = LRSchedulerConfig {
514            schedule_type: ScheduleType::ExponentialDecay,
515            initial_lr: 1.0,
516            gamma: 0.5,
517            ..Default::default()
518        };
519        let mut sched = TensorLRScheduler::new(cfg);
520        for _ in 0..10 {
521            sched.step();
522        }
523        sched.reset();
524        assert_eq!(sched.stats().current_step, 0);
525        assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
526    }
527
528    #[test]
529    fn set_step_jumps_correctly() {
530        let cfg = LRSchedulerConfig {
531            schedule_type: ScheduleType::StepDecay,
532            initial_lr: 1.0,
533            gamma: 0.5,
534            step_size: 10,
535            ..Default::default()
536        };
537        let mut sched = TensorLRScheduler::new(cfg);
538        sched.set_step(25);
539        assert_eq!(sched.stats().current_step, 25);
540        // 25 / 10 = 2 => 0.5^2 = 0.25
541        assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
542    }
543
544    #[test]
545    fn remaining_steps_cosine() {
546        let cfg = LRSchedulerConfig {
547            schedule_type: ScheduleType::CosineAnnealing,
548            total_steps: 100,
549            ..Default::default()
550        };
551        let mut sched = TensorLRScheduler::new(cfg);
552        assert_eq!(sched.remaining_steps(), Some(100));
553        for _ in 0..30 {
554            sched.step();
555        }
556        assert_eq!(sched.remaining_steps(), Some(70));
557    }
558
559    #[test]
560    fn remaining_steps_none_for_constant() {
561        let sched = TensorLRScheduler::new(LRSchedulerConfig {
562            schedule_type: ScheduleType::Constant,
563            ..Default::default()
564        });
565        assert_eq!(sched.remaining_steps(), None);
566    }
567
568    #[test]
569    fn remaining_steps_none_for_step_decay() {
570        let sched = TensorLRScheduler::new(LRSchedulerConfig {
571            schedule_type: ScheduleType::StepDecay,
572            ..Default::default()
573        });
574        assert_eq!(sched.remaining_steps(), None);
575    }
576
577    #[test]
578    fn remaining_steps_none_for_exponential() {
579        let sched = TensorLRScheduler::new(LRSchedulerConfig {
580            schedule_type: ScheduleType::ExponentialDecay,
581            ..Default::default()
582        });
583        assert_eq!(sched.remaining_steps(), None);
584    }
585
586    #[test]
587    fn remaining_steps_warmup() {
588        let cfg = LRSchedulerConfig {
589            schedule_type: ScheduleType::WarmupLinear,
590            warmup_steps: 50,
591            ..Default::default()
592        };
593        let mut sched = TensorLRScheduler::new(cfg);
594        assert_eq!(sched.remaining_steps(), Some(50));
595        for _ in 0..50 {
596            sched.step();
597        }
598        assert_eq!(sched.remaining_steps(), Some(0));
599    }
600
601    #[test]
602    fn remaining_steps_one_cycle() {
603        let cfg = LRSchedulerConfig {
604            schedule_type: ScheduleType::OneCycleLR,
605            warmup_steps: 20,
606            total_steps: 100,
607            ..Default::default()
608        };
609        let mut sched = TensorLRScheduler::new(cfg);
610        assert_eq!(sched.remaining_steps(), Some(100));
611        for _ in 0..40 {
612            sched.step();
613        }
614        assert_eq!(sched.remaining_steps(), Some(60));
615    }
616
617    // -------------------------------------------------- stats
618    #[test]
619    fn stats_snapshot() {
620        let cfg = LRSchedulerConfig {
621            schedule_type: ScheduleType::CosineAnnealing,
622            initial_lr: 0.1,
623            total_steps: 200,
624            ..Default::default()
625        };
626        let mut sched = TensorLRScheduler::new(cfg);
627        for _ in 0..10 {
628            sched.step();
629        }
630        let s = sched.stats();
631        assert_eq!(s.schedule_type, ScheduleType::CosineAnnealing);
632        assert_eq!(s.current_step, 10);
633        assert!(approx_eq(s.initial_lr, 0.1, 1e-12));
634        assert!(approx_eq(s.current_lr, sched.get_lr(), 1e-15));
635    }
636
637    // -------------------------------------------------- step advances LR
638    #[test]
639    fn step_advances_lr() {
640        let cfg = LRSchedulerConfig {
641            schedule_type: ScheduleType::ExponentialDecay,
642            initial_lr: 1.0,
643            gamma: 0.9,
644            ..Default::default()
645        };
646        let mut sched = TensorLRScheduler::new(cfg);
647        let lr0 = sched.get_lr();
648        let lr1 = sched.step();
649        assert!(lr1 < lr0, "step should change LR for decay schedules");
650    }
651
652    // -------------------------------------------------- default config
653    #[test]
654    fn default_config_values() {
655        let cfg = LRSchedulerConfig::default();
656        assert_eq!(cfg.schedule_type, ScheduleType::Constant);
657        assert!(approx_eq(cfg.initial_lr, 0.01, 1e-15));
658        assert!(approx_eq(cfg.min_lr, 1e-6, 1e-15));
659        assert!(approx_eq(cfg.gamma, 0.1, 1e-15));
660        assert_eq!(cfg.step_size, 30);
661        assert_eq!(cfg.total_steps, 1000);
662        assert_eq!(cfg.warmup_steps, 100);
663        assert!(approx_eq(cfg.max_lr, 0.1, 1e-15));
664    }
665
666    // -------------------------------------------------- schedule_type display
667    #[test]
668    fn schedule_type_display() {
669        assert_eq!(format!("{}", ScheduleType::Constant), "Constant");
670        assert_eq!(format!("{}", ScheduleType::OneCycleLR), "OneCycleLR");
671    }
672
673    // -------------------------------------------------- edge cases
674    #[test]
675    fn step_decay_zero_step_size_no_panic() {
676        let cfg = LRSchedulerConfig {
677            schedule_type: ScheduleType::StepDecay,
678            initial_lr: 1.0,
679            gamma: 0.5,
680            step_size: 0,
681            ..Default::default()
682        };
683        let mut sched = TensorLRScheduler::new(cfg);
684        // Should not panic; step_size clamped to 1 internally
685        let _ = sched.step();
686    }
687
688    #[test]
689    fn cosine_beyond_total_steps_clamps() {
690        let cfg = LRSchedulerConfig {
691            schedule_type: ScheduleType::CosineAnnealing,
692            initial_lr: 0.1,
693            min_lr: 0.001,
694            total_steps: 50,
695            ..Default::default()
696        };
697        let mut sched = TensorLRScheduler::new(cfg);
698        for _ in 0..100 {
699            sched.step();
700        }
701        // Should clamp at min_lr, not go negative
702        assert!(
703            approx_eq(sched.get_lr(), 0.001, 1e-10),
704            "cosine should clamp at min_lr beyond total_steps, got {}",
705            sched.get_lr()
706        );
707    }
708
709    #[test]
710    fn one_cycle_warmup_phase_is_monotone_increasing() {
711        let cfg = LRSchedulerConfig {
712            schedule_type: ScheduleType::OneCycleLR,
713            min_lr: 0.0,
714            max_lr: 1.0,
715            warmup_steps: 50,
716            total_steps: 200,
717            ..Default::default()
718        };
719        let mut sched = TensorLRScheduler::new(cfg);
720        let mut prev = sched.get_lr();
721        for _ in 0..50 {
722            let lr = sched.step();
723            assert!(
724                lr >= prev - 1e-15,
725                "warmup should be monotonically increasing"
726            );
727            prev = lr;
728        }
729    }
730
731    #[test]
732    fn config_accessor() {
733        let cfg = LRSchedulerConfig {
734            schedule_type: ScheduleType::StepDecay,
735            gamma: 0.3,
736            ..Default::default()
737        };
738        let sched = TensorLRScheduler::new(cfg);
739        assert!(approx_eq(sched.config().gamma, 0.3, 1e-15));
740    }
741}
742
743// =============================================================================
744// LearningRateScheduler — multi-strategy LR scheduler for distributed ML
745// =============================================================================
746
747/// Multi-strategy learning rate scheduling for distributed ML training.
748///
749/// Supports seven scheduling strategies including plateau detection.
750#[derive(Debug, Clone)]
751pub enum SchedulerStrategy {
752    /// Fixed learning rate that never changes.
753    Constant {
754        /// The fixed learning rate.
755        lr: f64,
756    },
757    /// Multiply learning rate by `decay_factor` every `step_size` epochs.
758    StepDecay {
759        /// Starting learning rate.
760        initial_lr: f64,
761        /// Multiplicative factor applied every `step_size` epochs.
762        decay_factor: f64,
763        /// Number of epochs between each decay application.
764        step_size: u64,
765    },
766    /// Exponential decay: `lr = initial * exp(-decay_rate * epoch)`.
767    ExponentialDecay {
768        /// Starting learning rate.
769        initial_lr: f64,
770        /// Rate of exponential decay.
771        decay_rate: f64,
772    },
773    /// Cosine annealing from `initial_lr` to `min_lr` over `t_max` epochs.
774    CosineAnnealing {
775        /// Starting learning rate.
776        initial_lr: f64,
777        /// Floor learning rate.
778        min_lr: f64,
779        /// Period (number of epochs for one half-cycle).
780        t_max: u64,
781    },
782    /// Linear warmup over `warmup_epochs` then cosine annealing to `min_lr`.
783    WarmupCosine {
784        /// Number of epochs for linear warm-up phase.
785        warmup_epochs: u64,
786        /// Starting LR for warm-up (typically a small value or 0).
787        initial_lr: f64,
788        /// Peak learning rate reached after warm-up.
789        peak_lr: f64,
790        /// Floor learning rate after the cosine phase.
791        min_lr: f64,
792        /// Length of the cosine phase in epochs (not including warmup).
793        t_max: u64,
794    },
795    /// Triangular (cyclic) learning rate oscillating between `base_lr` and `max_lr`.
796    CyclicLR {
797        /// Lower bound of the LR range.
798        base_lr: f64,
799        /// Upper bound of the LR range.
800        max_lr: f64,
801        /// Half the cycle length (each triangle leg = `step_size` epochs).
802        step_size: u64,
803    },
804    /// Reduce learning rate when validation loss stops improving.
805    ReduceOnPlateau {
806        /// Starting learning rate.
807        initial_lr: f64,
808        /// Multiplicative reduction factor (< 1.0).
809        factor: f64,
810        /// Number of epochs with no improvement before reduction.
811        patience: u64,
812        /// Minimum allowed learning rate.
813        min_lr: f64,
814        /// Minimum change in loss to qualify as improvement.
815        threshold: f64,
816    },
817}
818
819/// Internal state tracked across `step` calls.
820#[derive(Debug, Clone)]
821pub struct LrSchedulerState {
822    /// Current epoch index (0-based after the last `reset`).
823    pub current_epoch: u64,
824    /// Most recently computed learning rate.
825    pub current_lr: f64,
826    /// Best (lowest) loss seen so far (used by `ReduceOnPlateau`).
827    pub best_loss: f64,
828    /// Number of consecutive epochs without improvement (plateau counter).
829    pub plateau_count: u64,
830    /// Number of full cycles completed (used by `CyclicLR`).
831    pub cycles_completed: u64,
832}
833
834impl LrSchedulerState {
835    fn new(initial_lr: f64) -> Self {
836        Self {
837            current_epoch: 0,
838            current_lr: initial_lr,
839            best_loss: f64::INFINITY,
840            plateau_count: 0,
841            cycles_completed: 0,
842        }
843    }
844}
845
846/// A single entry in the LR history log.
847#[derive(Debug, Clone)]
848pub struct LrHistory {
849    /// Epoch index for this entry.
850    pub epoch: u64,
851    /// Learning rate computed at this epoch.
852    pub lr: f64,
853    /// Optional loss value provided at this epoch.
854    pub loss: Option<f64>,
855}
856
857/// Aggregate statistics over training.
858#[derive(Debug, Clone)]
859pub struct LrStats {
860    /// Minimum learning rate observed in history.
861    pub min_lr_seen: f64,
862    /// Maximum learning rate observed in history.
863    pub max_lr_seen: f64,
864    /// Number of learning rate reductions triggered by plateau detection.
865    pub plateau_reductions: u64,
866    /// Total number of epochs recorded in history.
867    pub epochs_trained: u64,
868}
869
870const MAX_HISTORY: usize = 1000;
871
872/// Multi-strategy learning rate scheduler.
873///
874/// Supports `Constant`, `StepDecay`, `ExponentialDecay`, `CosineAnnealing`,
875/// `WarmupCosine`, `CyclicLR`, and `ReduceOnPlateau`.
876#[derive(Debug, Clone)]
877pub struct LearningRateScheduler {
878    /// The scheduling strategy in use.
879    pub strategy: SchedulerStrategy,
880    /// Mutable runtime state.
881    pub state: LrSchedulerState,
882    history: Vec<LrHistory>,
883    plateau_reductions: u64,
884}
885
886impl LearningRateScheduler {
887    /// Create a new scheduler.  The initial learning rate is derived from the
888    /// strategy variant's `initial_lr` (or `lr` for `Constant`).
889    pub fn new(strategy: SchedulerStrategy) -> Self {
890        let initial_lr = Self::extract_initial_lr(&strategy);
891        Self {
892            state: LrSchedulerState::new(initial_lr),
893            strategy,
894            history: Vec::new(),
895            plateau_reductions: 0,
896        }
897    }
898
899    // ------------------------------------------------------------------
900    // Public API
901    // ------------------------------------------------------------------
902
903    /// Compute the learning rate for the given `epoch` without mutating
904    /// significant state (except appending to history and updating
905    /// `current_epoch` / `current_lr`).
906    ///
907    /// For `ReduceOnPlateau` this ignores plateau tracking — use
908    /// `step_with_loss` instead.
909    pub fn step(&mut self, epoch: u64) -> f64 {
910        let lr = self.compute_lr(epoch);
911        self.state.current_epoch = epoch;
912        self.state.current_lr = lr;
913        self.push_history(epoch, lr, None);
914        lr
915    }
916
917    /// Advance one step using a loss value.  For `ReduceOnPlateau` this
918    /// updates the internal plateau counter and reduces LR as needed.
919    /// For other strategies it behaves identically to `step`.
920    pub fn step_with_loss(&mut self, epoch: u64, loss: f64) -> f64 {
921        let lr = match &self.strategy {
922            SchedulerStrategy::ReduceOnPlateau {
923                factor,
924                patience,
925                min_lr,
926                threshold,
927                ..
928            } => {
929                let factor = *factor;
930                let patience = *patience;
931                let floor = *min_lr;
932                let threshold = *threshold;
933
934                let improved = loss < self.state.best_loss - threshold;
935                if improved {
936                    self.state.best_loss = loss;
937                    self.state.plateau_count = 0;
938                } else {
939                    self.state.plateau_count += 1;
940                }
941
942                if self.state.plateau_count >= patience {
943                    let reduced = (self.state.current_lr * factor).max(floor);
944                    if reduced < self.state.current_lr {
945                        self.state.current_lr = reduced;
946                        self.plateau_reductions += 1;
947                    }
948                    self.state.plateau_count = 0;
949                }
950                self.state.current_lr
951            }
952            _ => self.compute_lr(epoch),
953        };
954
955        self.state.current_epoch = epoch;
956        self.state.current_lr = lr;
957        self.push_history(epoch, lr, Some(loss));
958        lr
959    }
960
961    /// Return the most recently computed learning rate.
962    pub fn current_lr(&self) -> f64 {
963        self.state.current_lr
964    }
965
966    /// Reset internal state to epoch 0 and recompute the initial LR.
967    pub fn reset(&mut self) {
968        let initial_lr = Self::extract_initial_lr(&self.strategy);
969        self.state = LrSchedulerState::new(initial_lr);
970        self.history.clear();
971        self.plateau_reductions = 0;
972    }
973
974    /// Return a slice of the LR history (capped at the last 1 000 entries).
975    pub fn history(&self) -> &[LrHistory] {
976        &self.history
977    }
978
979    /// Compute aggregate statistics over the recorded history.
980    pub fn stats(&self) -> LrStats {
981        let (min_lr_seen, max_lr_seen) = if self.history.is_empty() {
982            (self.state.current_lr, self.state.current_lr)
983        } else {
984            let min = self
985                .history
986                .iter()
987                .map(|h| h.lr)
988                .fold(f64::INFINITY, f64::min);
989            let max = self
990                .history
991                .iter()
992                .map(|h| h.lr)
993                .fold(f64::NEG_INFINITY, f64::max);
994            (min, max)
995        };
996
997        LrStats {
998            min_lr_seen,
999            max_lr_seen,
1000            plateau_reductions: self.plateau_reductions,
1001            epochs_trained: self.history.len() as u64,
1002        }
1003    }
1004
1005    // ------------------------------------------------------------------
1006    // Helper functions (also `pub` so tests can call them directly)
1007    // ------------------------------------------------------------------
1008
1009    /// Linear interpolation factor for a warmup phase.
1010    ///
1011    /// Returns values in `[0.0, 1.0]` linearly from 0 to 1 as `epoch`
1012    /// goes from 0 to `warmup_epochs`.  Returns 1.0 when `epoch >=
1013    /// warmup_epochs`.
1014    pub fn warmup_factor(epoch: u64, warmup_epochs: u64) -> f64 {
1015        if warmup_epochs == 0 {
1016            return 1.0;
1017        }
1018        (epoch as f64 / warmup_epochs as f64).min(1.0)
1019    }
1020
1021    /// Cosine annealing factor: `(1 + cos(Ï€ * epoch / t_max)) / 2`.
1022    ///
1023    /// Returns values in `[0.0, 1.0]`.  Clamps `epoch` at `t_max`.
1024    pub fn cosine_factor(epoch: u64, t_max: u64) -> f64 {
1025        if t_max == 0 {
1026            return 0.0;
1027        }
1028        let t = (epoch as f64).min(t_max as f64);
1029        (1.0 + (PI * t / t_max as f64).cos()) / 2.0
1030    }
1031
1032    // ------------------------------------------------------------------
1033    // Internal helpers
1034    // ------------------------------------------------------------------
1035
1036    fn extract_initial_lr(strategy: &SchedulerStrategy) -> f64 {
1037        match strategy {
1038            SchedulerStrategy::Constant { lr } => *lr,
1039            SchedulerStrategy::StepDecay { initial_lr, .. } => *initial_lr,
1040            SchedulerStrategy::ExponentialDecay { initial_lr, .. } => *initial_lr,
1041            SchedulerStrategy::CosineAnnealing { initial_lr, .. } => *initial_lr,
1042            SchedulerStrategy::WarmupCosine { initial_lr, .. } => *initial_lr,
1043            SchedulerStrategy::CyclicLR { base_lr, .. } => *base_lr,
1044            SchedulerStrategy::ReduceOnPlateau { initial_lr, .. } => *initial_lr,
1045        }
1046    }
1047
1048    fn compute_lr(&mut self, epoch: u64) -> f64 {
1049        match &self.strategy {
1050            SchedulerStrategy::Constant { lr } => *lr,
1051
1052            SchedulerStrategy::StepDecay {
1053                initial_lr,
1054                decay_factor,
1055                step_size,
1056            } => {
1057                let exponent = if *step_size == 0 {
1058                    epoch
1059                } else {
1060                    epoch / step_size
1061                };
1062                initial_lr * decay_factor.powi(exponent as i32)
1063            }
1064
1065            SchedulerStrategy::ExponentialDecay {
1066                initial_lr,
1067                decay_rate,
1068            } => initial_lr * (-decay_rate * epoch as f64).exp(),
1069
1070            SchedulerStrategy::CosineAnnealing {
1071                initial_lr,
1072                min_lr,
1073                t_max,
1074            } => {
1075                let factor = Self::cosine_factor(epoch, *t_max);
1076                min_lr + (initial_lr - min_lr) * factor
1077            }
1078
1079            SchedulerStrategy::WarmupCosine {
1080                warmup_epochs,
1081                initial_lr,
1082                peak_lr,
1083                min_lr,
1084                t_max,
1085            } => {
1086                let warmup = *warmup_epochs;
1087                let floor = *min_lr;
1088                let peak = *peak_lr;
1089                let start = *initial_lr;
1090                let period = *t_max;
1091
1092                if epoch < warmup {
1093                    let w = Self::warmup_factor(epoch, warmup);
1094                    start + w * (peak - start)
1095                } else {
1096                    let cosine_epoch = epoch - warmup;
1097                    let factor = Self::cosine_factor(cosine_epoch, period);
1098                    floor + (peak - floor) * factor
1099                }
1100            }
1101
1102            SchedulerStrategy::CyclicLR {
1103                base_lr,
1104                max_lr,
1105                step_size,
1106            } => {
1107                let base = *base_lr;
1108                let peak = *max_lr;
1109                let half = (*step_size).max(1);
1110                let cycle_len = 2 * half;
1111                let cycle_epoch = epoch % cycle_len;
1112                let frac = if cycle_epoch < half {
1113                    cycle_epoch as f64 / half as f64
1114                } else {
1115                    (cycle_len - cycle_epoch) as f64 / half as f64
1116                };
1117                // Update cycle count in state without borrowing conflict via a flag
1118                base + frac * (peak - base)
1119            }
1120
1121            SchedulerStrategy::ReduceOnPlateau { .. } => {
1122                // For stateless computation, just return the current LR.
1123                self.state.current_lr
1124            }
1125        }
1126    }
1127
1128    fn push_history(&mut self, epoch: u64, lr: f64, loss: Option<f64>) {
1129        if self.history.len() >= MAX_HISTORY {
1130            self.history.remove(0);
1131        }
1132        self.history.push(LrHistory { epoch, lr, loss });
1133    }
1134}
1135
1136// =============================================================================
1137// Tests for LearningRateScheduler
1138// =============================================================================
1139
1140#[cfg(test)]
1141mod lr_scheduler_tests {
1142    use crate::lr_scheduler::{LearningRateScheduler, SchedulerStrategy};
1143
1144    const TOL: f64 = 1e-10;
1145
1146    fn approx(a: f64, b: f64) -> bool {
1147        (a - b).abs() < TOL
1148    }
1149
1150    // ------------------------------------------------------------------ Constant
1151
1152    #[test]
1153    fn constant_lr_never_changes() {
1154        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.03 });
1155        for epoch in 0..50 {
1156            let lr = sched.step(epoch);
1157            assert!(approx(lr, 0.03), "epoch {epoch}: expected 0.03, got {lr}");
1158        }
1159    }
1160
1161    #[test]
1162    fn constant_current_lr_matches_step() {
1163        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1164        sched.step(0);
1165        assert!(approx(sched.current_lr(), 0.01));
1166    }
1167
1168    #[test]
1169    fn constant_initial_lr_from_new() {
1170        let sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.05 });
1171        assert!(approx(sched.current_lr(), 0.05));
1172    }
1173
1174    // ------------------------------------------------------------------ StepDecay
1175
1176    #[test]
1177    fn step_decay_flat_within_interval() {
1178        let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1179            initial_lr: 1.0,
1180            decay_factor: 0.5,
1181            step_size: 10,
1182        });
1183        for epoch in 0..10 {
1184            let lr = sched.step(epoch);
1185            assert!(approx(lr, 1.0), "epoch {epoch}: expected 1.0, got {lr}");
1186        }
1187    }
1188
1189    #[test]
1190    fn step_decay_applies_at_boundary() {
1191        let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1192            initial_lr: 1.0,
1193            decay_factor: 0.5,
1194            step_size: 10,
1195        });
1196        let lr10 = sched.step(10);
1197        assert!(approx(lr10, 0.5), "expected 0.5 at epoch 10, got {lr10}");
1198        let lr20 = sched.step(20);
1199        assert!(approx(lr20, 0.25), "expected 0.25 at epoch 20, got {lr20}");
1200    }
1201
1202    #[test]
1203    fn step_decay_zero_step_size_no_panic() {
1204        let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1205            initial_lr: 1.0,
1206            decay_factor: 0.5,
1207            step_size: 0,
1208        });
1209        let _ = sched.step(5); // should not panic
1210    }
1211
1212    // ------------------------------------------------------------------ ExponentialDecay
1213
1214    #[test]
1215    fn exponential_decay_formula() {
1216        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1217            initial_lr: 1.0,
1218            decay_rate: 0.1,
1219        });
1220        for epoch in 0u64..=5 {
1221            let lr = sched.step(epoch);
1222            let expected = (-0.1_f64 * epoch as f64).exp();
1223            assert!(
1224                approx(lr, expected),
1225                "epoch {epoch}: expected {expected}, got {lr}"
1226            );
1227        }
1228    }
1229
1230    #[test]
1231    fn exponential_decay_monotone_decreasing() {
1232        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1233            initial_lr: 2.0,
1234            decay_rate: 0.05,
1235        });
1236        let mut prev = sched.step(0);
1237        for epoch in 1..100 {
1238            let lr = sched.step(epoch);
1239            assert!(lr < prev + 1e-15, "epoch {epoch}: LR did not decrease");
1240            prev = lr;
1241        }
1242    }
1243
1244    #[test]
1245    fn exponential_decay_at_epoch_zero_equals_initial() {
1246        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1247            initial_lr: 0.5,
1248            decay_rate: 0.2,
1249        });
1250        assert!(approx(sched.step(0), 0.5));
1251    }
1252
1253    // ------------------------------------------------------------------ CosineAnnealing
1254
1255    #[test]
1256    fn cosine_annealing_starts_at_initial_lr() {
1257        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1258            initial_lr: 0.1,
1259            min_lr: 0.001,
1260            t_max: 100,
1261        });
1262        let lr = sched.step(0);
1263        assert!(approx(lr, 0.1), "expected 0.1, got {lr}");
1264    }
1265
1266    #[test]
1267    fn cosine_annealing_reaches_min_at_t_max() {
1268        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1269            initial_lr: 0.1,
1270            min_lr: 1e-4,
1271            t_max: 100,
1272        });
1273        let lr = sched.step(100);
1274        assert!(approx(lr, 1e-4), "expected min_lr at t_max, got {lr}");
1275    }
1276
1277    #[test]
1278    fn cosine_annealing_midpoint() {
1279        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1280            initial_lr: 1.0,
1281            min_lr: 0.0,
1282            t_max: 100,
1283        });
1284        let lr = sched.step(50);
1285        assert!(approx(lr, 0.5), "cosine midpoint should be 0.5, got {lr}");
1286    }
1287
1288    #[test]
1289    fn cosine_annealing_clamps_beyond_t_max() {
1290        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1291            initial_lr: 0.1,
1292            min_lr: 0.001,
1293            t_max: 50,
1294        });
1295        let lr_at_50 = sched.step(50);
1296        let lr_at_200 = sched.step(200);
1297        assert!(approx(lr_at_50, lr_at_200), "beyond t_max should clamp");
1298    }
1299
1300    // ------------------------------------------------------------------ WarmupCosine
1301
1302    #[test]
1303    fn warmup_cosine_starts_at_initial_lr() {
1304        let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1305            warmup_epochs: 10,
1306            initial_lr: 0.0,
1307            peak_lr: 0.1,
1308            min_lr: 1e-5,
1309            t_max: 90,
1310        });
1311        let lr = sched.step(0);
1312        assert!(approx(lr, 0.0), "expected 0.0 at epoch 0, got {lr}");
1313    }
1314
1315    #[test]
1316    fn warmup_cosine_reaches_peak_after_warmup() {
1317        let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1318            warmup_epochs: 10,
1319            initial_lr: 0.0,
1320            peak_lr: 0.1,
1321            min_lr: 1e-5,
1322            t_max: 90,
1323        });
1324        // At epoch 10 (first cosine epoch=0) => cosine_factor(0, 90) = 1.0
1325        // => min_lr + (peak - min_lr) * 1.0 = peak_lr
1326        let lr = sched.step(10);
1327        assert!(approx(lr, 0.1), "expected peak_lr at warmup end, got {lr}");
1328    }
1329
1330    #[test]
1331    fn warmup_cosine_linear_during_warmup() {
1332        let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1333            warmup_epochs: 10,
1334            initial_lr: 0.0,
1335            peak_lr: 0.1,
1336            min_lr: 1e-5,
1337            t_max: 90,
1338        });
1339        let lr5 = sched.step(5);
1340        // warmup_factor(5, 10) = 0.5 => 0.0 + 0.5 * 0.1 = 0.05
1341        assert!(
1342            approx(lr5, 0.05),
1343            "expected 0.05 at warmup midpoint, got {lr5}"
1344        );
1345    }
1346
1347    #[test]
1348    fn warmup_cosine_descends_after_peak() {
1349        let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1350            warmup_epochs: 5,
1351            initial_lr: 0.0,
1352            peak_lr: 0.1,
1353            min_lr: 0.0,
1354            t_max: 100,
1355        });
1356        let lr_peak = sched.step(5);
1357        let lr_later = sched.step(55); // halfway through cosine => ~0.5 amplitude
1358        assert!(lr_later < lr_peak, "LR should decrease after warmup");
1359    }
1360
1361    // ------------------------------------------------------------------ CyclicLR
1362
1363    #[test]
1364    fn cyclic_lr_starts_at_base() {
1365        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1366            base_lr: 0.001,
1367            max_lr: 0.01,
1368            step_size: 10,
1369        });
1370        let lr = sched.step(0);
1371        assert!(approx(lr, 0.001), "expected base_lr at epoch 0, got {lr}");
1372    }
1373
1374    #[test]
1375    fn cyclic_lr_reaches_max_at_step_size() {
1376        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1377            base_lr: 0.0,
1378            max_lr: 1.0,
1379            step_size: 10,
1380        });
1381        let lr = sched.step(10);
1382        assert!(approx(lr, 1.0), "expected max_lr at step_size, got {lr}");
1383    }
1384
1385    #[test]
1386    fn cyclic_lr_returns_to_base_at_full_cycle() {
1387        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1388            base_lr: 0.001,
1389            max_lr: 0.01,
1390            step_size: 10,
1391        });
1392        let lr = sched.step(20);
1393        assert!(
1394            approx(lr, 0.001),
1395            "expected base_lr after full cycle, got {lr}"
1396        );
1397    }
1398
1399    #[test]
1400    fn cyclic_lr_is_symmetric() {
1401        let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1402            base_lr: 0.0,
1403            max_lr: 1.0,
1404            step_size: 10,
1405        });
1406        let lr5 = sched.step(5);
1407        let lr15 = sched.step(15);
1408        assert!(approx(lr5, lr15), "triangular cycle should be symmetric");
1409    }
1410
1411    // ------------------------------------------------------------------ ReduceOnPlateau
1412
1413    #[test]
1414    fn reduce_on_plateau_decreases_after_patience() {
1415        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1416            initial_lr: 0.1,
1417            factor: 0.5,
1418            patience: 3,
1419            min_lr: 1e-6,
1420            threshold: 1e-4,
1421        });
1422        // Provide improving loss to set best
1423        sched.step_with_loss(0, 1.0);
1424        // No improvement for `patience` steps
1425        sched.step_with_loss(1, 1.0);
1426        sched.step_with_loss(2, 1.0);
1427        sched.step_with_loss(3, 1.0);
1428        let lr = sched.current_lr();
1429        assert!(lr < 0.1, "LR should decrease after plateau, got {lr}");
1430    }
1431
1432    #[test]
1433    fn reduce_on_plateau_does_not_reduce_when_improving() {
1434        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1435            initial_lr: 0.1,
1436            factor: 0.5,
1437            patience: 3,
1438            min_lr: 1e-6,
1439            threshold: 1e-4,
1440        });
1441        // Steadily improving loss
1442        for i in 0u64..20 {
1443            sched.step_with_loss(i, 1.0 / (i as f64 + 1.0));
1444        }
1445        assert!(
1446            approx(sched.current_lr(), 0.1),
1447            "LR should not change when loss keeps improving, got {}",
1448            sched.current_lr()
1449        );
1450    }
1451
1452    #[test]
1453    fn reduce_on_plateau_respects_min_lr() {
1454        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1455            initial_lr: 0.1,
1456            factor: 0.1,
1457            patience: 1,
1458            min_lr: 0.05,
1459            threshold: 1e-4,
1460        });
1461        sched.step_with_loss(0, 1.0);
1462        sched.step_with_loss(1, 1.0); // triggers plateau
1463        let lr = sched.current_lr();
1464        assert!(lr >= 0.05, "LR should not go below min_lr, got {lr}");
1465    }
1466
1467    #[test]
1468    fn reduce_on_plateau_stats_count_reductions() {
1469        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1470            initial_lr: 0.1,
1471            factor: 0.5,
1472            patience: 2,
1473            min_lr: 1e-9,
1474            threshold: 1e-4,
1475        });
1476        sched.step_with_loss(0, 1.0); // sets best
1477        sched.step_with_loss(1, 1.0); // count=1
1478        sched.step_with_loss(2, 1.0); // count=2 => reduce #1
1479        sched.step_with_loss(3, 1.0); // count=1
1480        sched.step_with_loss(4, 1.0); // count=2 => reduce #2
1481        assert_eq!(
1482            sched.stats().plateau_reductions,
1483            2,
1484            "expected 2 plateau reductions"
1485        );
1486    }
1487
1488    // ------------------------------------------------------------------ reset
1489
1490    #[test]
1491    fn reset_clears_history_and_state() {
1492        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1493        for i in 0..10 {
1494            sched.step(i);
1495        }
1496        sched.reset();
1497        assert_eq!(sched.history().len(), 0);
1498        assert_eq!(sched.state.current_epoch, 0);
1499        assert!(approx(sched.current_lr(), 0.01));
1500    }
1501
1502    #[test]
1503    fn reset_clears_plateau_reductions() {
1504        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1505            initial_lr: 0.1,
1506            factor: 0.5,
1507            patience: 1,
1508            min_lr: 1e-9,
1509            threshold: 1e-4,
1510        });
1511        sched.step_with_loss(0, 1.0);
1512        sched.step_with_loss(1, 1.0); // triggers reduction
1513        sched.reset();
1514        assert_eq!(sched.stats().plateau_reductions, 0);
1515    }
1516
1517    // ------------------------------------------------------------------ history
1518
1519    #[test]
1520    fn history_records_each_step() {
1521        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.02 });
1522        for i in 0..5u64 {
1523            sched.step(i);
1524        }
1525        assert_eq!(sched.history().len(), 5);
1526    }
1527
1528    #[test]
1529    fn history_capped_at_1000() {
1530        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1531        for i in 0..1200u64 {
1532            sched.step(i);
1533        }
1534        assert_eq!(sched.history().len(), 1000);
1535    }
1536
1537    #[test]
1538    fn history_loss_recorded_with_step_with_loss() {
1539        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1540        sched.step_with_loss(0, 0.42);
1541        let entry = &sched.history()[0];
1542        assert_eq!(entry.loss, Some(0.42));
1543    }
1544
1545    #[test]
1546    fn history_no_loss_for_plain_step() {
1547        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1548        sched.step(0);
1549        assert_eq!(sched.history()[0].loss, None);
1550    }
1551
1552    // ------------------------------------------------------------------ stats
1553
1554    #[test]
1555    fn stats_min_max_lr() {
1556        let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1557            initial_lr: 1.0,
1558            decay_factor: 0.5,
1559            step_size: 10,
1560        });
1561        sched.step(0); // lr = 1.0
1562        sched.step(10); // lr = 0.5
1563        sched.step(20); // lr = 0.25
1564        let s = sched.stats();
1565        assert!(approx(s.max_lr_seen, 1.0));
1566        assert!(approx(s.min_lr_seen, 0.25));
1567    }
1568
1569    #[test]
1570    fn stats_epochs_trained() {
1571        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.1 });
1572        for i in 0..7u64 {
1573            sched.step(i);
1574        }
1575        assert_eq!(sched.stats().epochs_trained, 7);
1576    }
1577
1578    // ------------------------------------------------------------------ helper functions
1579
1580    #[test]
1581    fn warmup_factor_zero_epochs_returns_one() {
1582        assert!((LearningRateScheduler::warmup_factor(5, 0) - 1.0).abs() < 1e-15);
1583    }
1584
1585    #[test]
1586    fn warmup_factor_linear_interpolation() {
1587        let f = LearningRateScheduler::warmup_factor(5, 10);
1588        assert!((f - 0.5).abs() < 1e-15, "expected 0.5, got {f}");
1589    }
1590
1591    #[test]
1592    fn warmup_factor_clamps_at_one() {
1593        let f = LearningRateScheduler::warmup_factor(20, 10);
1594        assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
1595    }
1596
1597    #[test]
1598    fn cosine_factor_zero_t_max_returns_zero() {
1599        let f = LearningRateScheduler::cosine_factor(5, 0);
1600        assert!((f - 0.0).abs() < 1e-15, "expected 0.0, got {f}");
1601    }
1602
1603    #[test]
1604    fn cosine_factor_at_epoch_zero_returns_one() {
1605        let f = LearningRateScheduler::cosine_factor(0, 100);
1606        assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
1607    }
1608
1609    #[test]
1610    fn cosine_factor_at_t_max_returns_zero() {
1611        let f = LearningRateScheduler::cosine_factor(100, 100);
1612        assert!((f - 0.0).abs() < TOL, "expected 0.0, got {f}");
1613    }
1614
1615    #[test]
1616    fn cosine_factor_midpoint_returns_half() {
1617        let f = LearningRateScheduler::cosine_factor(50, 100);
1618        assert!((f - 0.5).abs() < TOL, "expected 0.5, got {f}");
1619    }
1620
1621    // ------------------------------------------------------------------ state fields
1622
1623    #[test]
1624    fn state_plateau_count_tracked() {
1625        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1626            initial_lr: 0.1,
1627            factor: 0.5,
1628            patience: 5,
1629            min_lr: 1e-6,
1630            threshold: 1e-4,
1631        });
1632        sched.step_with_loss(0, 1.0); // best_loss = 1.0
1633        sched.step_with_loss(1, 1.0); // plateau_count = 1
1634        sched.step_with_loss(2, 1.0); // plateau_count = 2
1635        assert_eq!(sched.state.plateau_count, 2);
1636    }
1637
1638    #[test]
1639    fn state_best_loss_updated_on_improvement() {
1640        let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1641            initial_lr: 0.1,
1642            factor: 0.5,
1643            patience: 5,
1644            min_lr: 1e-6,
1645            threshold: 1e-4,
1646        });
1647        sched.step_with_loss(0, 2.0);
1648        sched.step_with_loss(1, 0.5);
1649        assert!(approx(sched.state.best_loss, 0.5));
1650    }
1651
1652    // ------------------------------------------------------------------ LrHistory fields
1653
1654    #[test]
1655    fn lr_history_epoch_field_correct() {
1656        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1657        sched.step(42);
1658        assert_eq!(sched.history()[0].epoch, 42);
1659    }
1660
1661    #[test]
1662    fn lr_history_lr_field_correct() {
1663        let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.07 });
1664        sched.step(0);
1665        assert!(approx(sched.history()[0].lr, 0.07));
1666    }
1667}