Skip to main content

trustformers_optim/
scheduler.rs

1//! # Learning Rate Schedulers
2//!
3//! This module provides various learning rate scheduling strategies for optimizers.
4//! Learning rate scheduling is crucial for achieving good convergence in deep learning.
5//!
6//! ## Available Schedulers
7//!
8//! - **LinearScheduler**: Linear warmup followed by linear decay
9//! - **CosineScheduler**: Linear warmup followed by cosine annealing
10//! - **PolynomialScheduler**: Polynomial decay with configurable power
11//! - **ConstantWithWarmupScheduler**: Constant LR after warmup
12//! - **ExponentialScheduler**: Exponential decay
13//! - **StepScheduler**: Step-wise decay at specified milestones
14//!
15//! ## Usage Example
16//!
17//! ```rust,no_run
18//! use trustformers_optim::{AdamW, CosineScheduler, LRScheduler};
19//! use trustformers_core::traits::Optimizer;
20//!
21//! let base_lr = 5e-4;
22//! let mut optimizer = AdamW::new(base_lr, (0.9, 0.999), 1e-8, 0.01);
23//!
24//! let mut scheduler = CosineScheduler::new(
25//!     base_lr,
26//!     1000,   // Linear warmup for 1000 steps
27//!     10000,  // Total training steps
28//!     1e-5,   // Minimum learning rate
29//! );
30//!
31//! // Training loop
32//! for step in 0..10000 {
33//!     // Get current learning rate
34//!     let lr = scheduler.get_lr(step);
35//!     optimizer.set_lr(lr);
36//!
37//!     // Training step...
38//!
39//!     scheduler.step();
40//! }
41//! ```
42//!
43//! ## Choosing a Scheduler
44//!
45//! ### For Transformer Pre-training
46//! - **CosineScheduler**: Most common, smooth decay
47//! - **LinearScheduler**: Simple and effective
48//!
49//! ### For Fine-tuning
50//! - **ConstantWithWarmupScheduler**: Stable for small datasets
51//! - **LinearScheduler**: With small decay rate
52//!
53//! ### For Computer Vision
54//! - **StepScheduler**: Traditional for CNNs
55//! - **CosineScheduler**: Modern alternative
56//!
57//! ## Warmup Importance
58//!
59//! Warmup is crucial for:
60//! - Stabilizing training with large learning rates
61//! - Preventing early divergence
62//! - Allowing adaptive optimizers to estimate statistics
63//!
64//! Typical warmup steps:
65//! - 2-10% of total training steps
66//! - 500-2000 steps for most tasks
67
68/// Trait for learning rate schedulers.
69pub trait LRScheduler: Send + Sync {
70    /// Get the learning rate for a given step.
71    fn get_lr(&self, step: usize) -> f32;
72    /// Advance the scheduler by one step.
73    fn step(&mut self);
74}
75
76/// Linear learning rate scheduler with warmup.
77///
78/// Implements linear warmup from 0 to base_lr, followed by linear decay to 0.
79/// This is commonly used for transformer pre-training.
80#[derive(Debug)]
81pub struct LinearScheduler {
82    base_lr: f32,
83    warmup_steps: usize,
84    total_steps: usize,
85    current_step: usize,
86}
87
88impl LinearScheduler {
89    pub fn new(base_lr: f32, warmup_steps: usize, total_steps: usize) -> Self {
90        Self {
91            base_lr,
92            warmup_steps,
93            total_steps,
94            current_step: 0,
95        }
96    }
97}
98
99impl LRScheduler for LinearScheduler {
100    fn get_lr(&self, step: usize) -> f32 {
101        if self.warmup_steps > 0 && step < self.warmup_steps {
102            return self.base_lr * (step as f32) / (self.warmup_steps as f32);
103        }
104
105        // `total_steps <= warmup_steps` would underflow the usize subtraction.
106        let decay_steps = self.total_steps.saturating_sub(self.warmup_steps);
107        if decay_steps == 0 {
108            return 0.0;
109        }
110
111        let progress = (step - self.warmup_steps) as f32 / decay_steps as f32;
112        self.base_lr * (1.0 - progress).max(0.0)
113    }
114
115    fn step(&mut self) {
116        self.current_step += 1;
117    }
118}
119
120/// Cosine annealing learning rate scheduler with warmup.
121///
122/// Implements linear warmup followed by cosine decay to min_lr.
123/// This provides a smoother decay than linear scheduling and often
124/// leads to better final performance.
125#[derive(Debug)]
126pub struct CosineScheduler {
127    base_lr: f32,
128    warmup_steps: usize,
129    total_steps: usize,
130    current_step: usize,
131    min_lr: f32,
132}
133
134impl CosineScheduler {
135    pub fn new(base_lr: f32, warmup_steps: usize, total_steps: usize, min_lr: f32) -> Self {
136        Self {
137            base_lr,
138            warmup_steps,
139            total_steps,
140            current_step: 0,
141            min_lr,
142        }
143    }
144}
145
146impl LRScheduler for CosineScheduler {
147    /// Linear warmup, then cosine decay to `min_lr`.
148    ///
149    /// `progress` is clamped to `[0, 1]`: past `total_steps` the raw cosine turns back
150    /// upward and the learning rate would climb above `min_lr` again.
151    fn get_lr(&self, step: usize) -> f32 {
152        use std::f32::consts::PI;
153
154        if self.warmup_steps > 0 && step < self.warmup_steps {
155            return self.base_lr * (step as f32) / (self.warmup_steps as f32);
156        }
157
158        // `total_steps <= warmup_steps` would underflow the usize subtraction.
159        let decay_steps = self.total_steps.saturating_sub(self.warmup_steps);
160        if decay_steps == 0 {
161            return self.min_lr;
162        }
163
164        let progress = ((step - self.warmup_steps) as f32 / decay_steps as f32).clamp(0.0, 1.0);
165        let cosine_decay = 0.5 * (1.0 + (PI * progress).cos());
166        self.min_lr + (self.base_lr - self.min_lr) * cosine_decay
167    }
168
169    fn step(&mut self) {
170        self.current_step += 1;
171    }
172}
173
174/// Polynomial decay scheduler with configurable power.
175///
176/// Decays learning rate according to: lr = (base_lr - min_lr) * (1 - t)^power + min_lr
177/// where t is the progress ratio. Common powers:
178/// - power = 1.0: Linear decay
179/// - power = 0.5: Square root decay
180/// - power = 2.0: Quadratic decay
181#[derive(Debug)]
182pub struct PolynomialScheduler {
183    base_lr: f32,
184    warmup_steps: usize,
185    total_steps: usize,
186    current_step: usize,
187    min_lr: f32,
188    power: f32,
189}
190
191impl PolynomialScheduler {
192    pub fn new(
193        base_lr: f32,
194        warmup_steps: usize,
195        total_steps: usize,
196        min_lr: f32,
197        power: f32,
198    ) -> Self {
199        Self {
200            base_lr,
201            warmup_steps,
202            total_steps,
203            current_step: 0,
204            min_lr,
205            power,
206        }
207    }
208}
209
210impl LRScheduler for PolynomialScheduler {
211    fn get_lr(&self, step: usize) -> f32 {
212        if step < self.warmup_steps {
213            self.base_lr * (step as f32) / (self.warmup_steps as f32)
214        } else {
215            let progress =
216                (step - self.warmup_steps) as f32 / (self.total_steps - self.warmup_steps) as f32;
217            let decay_factor = (1.0 - progress.min(1.0)).powf(self.power);
218            self.min_lr + (self.base_lr - self.min_lr) * decay_factor
219        }
220    }
221
222    fn step(&mut self) {
223        self.current_step += 1;
224    }
225}
226
227/// Constant learning rate with warmup
228#[derive(Debug)]
229pub struct ConstantWithWarmupScheduler {
230    base_lr: f32,
231    warmup_steps: usize,
232    current_step: usize,
233}
234
235impl ConstantWithWarmupScheduler {
236    pub fn new(base_lr: f32, warmup_steps: usize) -> Self {
237        Self {
238            base_lr,
239            warmup_steps,
240            current_step: 0,
241        }
242    }
243}
244
245impl LRScheduler for ConstantWithWarmupScheduler {
246    fn get_lr(&self, step: usize) -> f32 {
247        if step < self.warmup_steps {
248            self.base_lr * (step as f32) / (self.warmup_steps as f32)
249        } else {
250            self.base_lr
251        }
252    }
253
254    fn step(&mut self) {
255        self.current_step += 1;
256    }
257}
258
259/// Exponential decay scheduler
260#[derive(Debug)]
261pub struct ExponentialScheduler {
262    base_lr: f32,
263    warmup_steps: usize,
264    current_step: usize,
265    decay_rate: f32,
266    decay_steps: usize,
267}
268
269impl ExponentialScheduler {
270    pub fn new(base_lr: f32, warmup_steps: usize, decay_rate: f32, decay_steps: usize) -> Self {
271        Self {
272            base_lr,
273            warmup_steps,
274            current_step: 0,
275            decay_rate,
276            decay_steps,
277        }
278    }
279}
280
281impl LRScheduler for ExponentialScheduler {
282    fn get_lr(&self, step: usize) -> f32 {
283        if step < self.warmup_steps {
284            self.base_lr * (step as f32) / (self.warmup_steps as f32)
285        } else {
286            let decay_step = (step - self.warmup_steps) / self.decay_steps;
287            self.base_lr * self.decay_rate.powf(decay_step as f32)
288        }
289    }
290
291    fn step(&mut self) {
292        self.current_step += 1;
293    }
294}
295
296/// Step decay scheduler (reduce LR at specific steps)
297#[derive(Debug)]
298pub struct StepScheduler {
299    base_lr: f32,
300    warmup_steps: usize,
301    current_step: usize,
302    step_size: usize,
303    gamma: f32,
304}
305
306impl StepScheduler {
307    pub fn new(base_lr: f32, warmup_steps: usize, step_size: usize, gamma: f32) -> Self {
308        Self {
309            base_lr,
310            warmup_steps,
311            current_step: 0,
312            step_size,
313            gamma,
314        }
315    }
316}
317
318impl LRScheduler for StepScheduler {
319    fn get_lr(&self, step: usize) -> f32 {
320        if step < self.warmup_steps {
321            self.base_lr * (step as f32) / (self.warmup_steps as f32)
322        } else {
323            let decay_step = (step - self.warmup_steps) / self.step_size;
324            self.base_lr * self.gamma.powf(decay_step as f32)
325        }
326    }
327
328    fn step(&mut self) {
329        self.current_step += 1;
330    }
331}
332
333/// OneCycle learning rate scheduler.
334///
335/// Implements the OneCycle policy: ramp up LR to max_lr over pct_start of training,
336/// then decay to final_lr for the remainder. This scheduler often enables training
337/// with much higher learning rates.
338#[derive(Debug)]
339pub struct OneCycleScheduler {
340    max_lr: f32,
341    final_lr: f32,
342    total_steps: usize,
343    pct_start: f32,
344    current_step: usize,
345}
346
347impl OneCycleScheduler {
348    /// Creates a one-cycle schedule.
349    ///
350    /// `pct_start` is clamped to the *open* interval `(0, 1)`: the phase formulas
351    /// divide by `pct_start` and `1 − pct_start`, so the closed interval yields
352    /// `inf`/`NaN` at the endpoints.
353    pub fn new(max_lr: f32, total_steps: usize, pct_start: f32, final_lr: f32) -> Self {
354        const MIN_PCT: f32 = 1e-3;
355        Self {
356            max_lr,
357            final_lr,
358            total_steps,
359            pct_start: if pct_start.is_finite() {
360                pct_start.clamp(MIN_PCT, 1.0 - MIN_PCT)
361            } else {
362                0.3
363            },
364            current_step: 0,
365        }
366    }
367}
368
369impl LRScheduler for OneCycleScheduler {
370    fn get_lr(&self, step: usize) -> f32 {
371        use std::f32::consts::PI;
372
373        if self.total_steps == 0 {
374            return self.final_lr;
375        }
376        let step = step.min(self.total_steps);
377        let pct = step as f32 / self.total_steps as f32;
378
379        if pct <= self.pct_start {
380            // Ramp up phase
381            let phase_pct = pct / self.pct_start;
382            let cosine_term = 0.5 * (1.0 - (PI * phase_pct).cos());
383            self.final_lr + (self.max_lr - self.final_lr) * cosine_term
384        } else {
385            // Decay phase
386            let remaining_pct = (pct - self.pct_start) / (1.0 - self.pct_start);
387            let cosine_term = 0.5 * (1.0 + (PI * remaining_pct).cos());
388            self.final_lr + (self.max_lr - self.final_lr) * cosine_term
389        }
390    }
391
392    fn step(&mut self) {
393        self.current_step += 1;
394    }
395}
396
397/// Cosine annealing with warm restarts (SGDR).
398///
399/// Periodically restarts the learning rate schedule. This can help escape
400/// local minima and often improves final performance.
401#[derive(Debug)]
402pub struct CosineWithRestartsScheduler {
403    base_lr: f32,
404    min_lr: f32,
405    t_0: usize,
406    t_mult: f32,
407    current_step: usize,
408    next_restart: usize,
409    current_t: usize,
410}
411
412impl CosineWithRestartsScheduler {
413    /// Creates an SGDR schedule.
414    ///
415    /// `t_0` is forced to at least one step and `t_mult` to at least `1.0`. A cycle
416    /// length of zero — which `t_0 == 0` or `t_mult < 1` produce — makes the
417    /// cycle-search loop in [`LRScheduler::get_lr`] subtract zero forever, hanging the
418    /// process inside a scheduler call, and then divides by zero.
419    ///
420    /// Use [`CosineWithRestartsScheduler::try_new`] to be told about an invalid
421    /// argument instead of having it silently corrected.
422    pub fn new(base_lr: f32, min_lr: f32, t_0: usize, t_mult: f32) -> Self {
423        let t_0 = t_0.max(1);
424        let t_mult = if t_mult.is_finite() { t_mult.max(1.0) } else { 1.0 };
425        Self {
426            base_lr,
427            min_lr,
428            t_0,
429            t_mult,
430            current_step: 0,
431            next_restart: t_0,
432            current_t: t_0,
433        }
434    }
435
436    /// Fallible constructor that rejects a degenerate cycle configuration.
437    ///
438    /// # Errors
439    ///
440    /// Returns an error when `t_0 == 0` or `t_mult < 1.0` (or is not finite).
441    pub fn try_new(
442        base_lr: f32,
443        min_lr: f32,
444        t_0: usize,
445        t_mult: f32,
446    ) -> trustformers_core::errors::Result<Self> {
447        if t_0 == 0 {
448            return Err(
449                trustformers_core::errors::TrustformersError::invalid_config(
450                    "CosineWithRestartsScheduler requires t_0 >= 1".to_string(),
451                ),
452            );
453        }
454        if !t_mult.is_finite() || t_mult < 1.0 {
455            return Err(
456                trustformers_core::errors::TrustformersError::invalid_config(format!(
457                    "CosineWithRestartsScheduler requires a finite t_mult >= 1.0, got {t_mult}"
458                )),
459            );
460        }
461        Ok(Self::new(base_lr, min_lr, t_0, t_mult))
462    }
463}
464
465impl LRScheduler for CosineWithRestartsScheduler {
466    fn get_lr(&self, step: usize) -> f32 {
467        use std::f32::consts::PI;
468
469        let mut step_in_cycle = step;
470        // `new`/`try_new` guarantee `t_0 >= 1` and `t_mult >= 1`, so the cycle length
471        // can never reach zero and this loop always terminates.
472        let mut cycle_length = self.t_0.max(1);
473
474        // Find which cycle we're in
475        while step_in_cycle >= cycle_length {
476            step_in_cycle -= cycle_length;
477            cycle_length = ((cycle_length as f32 * self.t_mult) as usize).max(1);
478        }
479
480        let progress = step_in_cycle as f32 / cycle_length as f32;
481        let cosine_decay = 0.5 * (1.0 + (PI * progress).cos());
482
483        self.min_lr + (self.base_lr - self.min_lr) * cosine_decay
484    }
485
486    fn step(&mut self) {
487        self.current_step += 1;
488
489        if self.current_step >= self.next_restart {
490            self.current_t = (self.current_t as f32 * self.t_mult) as usize;
491            self.next_restart += self.current_t;
492        }
493    }
494}
495
496/// Cyclical learning rate scheduler.
497///
498/// Cycles the learning rate between base_lr and max_lr over step_size_up + step_size_down steps.
499/// This can help find better learning rates and escape local minima.
500#[derive(Debug)]
501pub struct CyclicalScheduler {
502    base_lr: f32,
503    max_lr: f32,
504    step_size_up: usize,
505    step_size_down: usize,
506    current_step: usize,
507    mode: CyclicalMode,
508}
509
510#[derive(Debug, Clone)]
511pub enum CyclicalMode {
512    Triangular,
513    Triangular2,
514    ExpRange(f32), // gamma parameter
515}
516
517impl CyclicalScheduler {
518    pub fn new(
519        base_lr: f32,
520        max_lr: f32,
521        step_size_up: usize,
522        step_size_down: usize,
523        mode: CyclicalMode,
524    ) -> Self {
525        Self {
526            base_lr,
527            max_lr,
528            step_size_up,
529            step_size_down,
530            current_step: 0,
531            mode,
532        }
533    }
534}
535
536impl LRScheduler for CyclicalScheduler {
537    fn get_lr(&self, step: usize) -> f32 {
538        let cycle_length = self.step_size_up + self.step_size_down;
539        let cycle = (step / cycle_length) + 1;
540        let x = (step % cycle_length) as f32;
541
542        let (amplitude, _phase) = if x <= self.step_size_up as f32 {
543            // Ascending phase
544            (x / self.step_size_up as f32, 1.0)
545        } else {
546            // Descending phase
547            (
548                (self.step_size_down as f32 - (x - self.step_size_up as f32))
549                    / self.step_size_down as f32,
550                1.0,
551            )
552        };
553
554        let scale_factor = match &self.mode {
555            CyclicalMode::Triangular => 1.0,
556            CyclicalMode::Triangular2 => 1.0 / (2.0_f32.powi((cycle - 1) as i32)),
557            CyclicalMode::ExpRange(gamma) => gamma.powi(step as i32),
558        };
559
560        self.base_lr + (self.max_lr - self.base_lr) * amplitude * scale_factor
561    }
562
563    fn step(&mut self) {
564        self.current_step += 1;
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_linear_scheduler() {
574        let scheduler = LinearScheduler::new(1e-3, 100, 1000);
575
576        // Test warmup
577        assert_eq!(scheduler.get_lr(0), 0.0);
578        assert_eq!(scheduler.get_lr(50), 5e-4);
579        assert_eq!(scheduler.get_lr(100), 1e-3);
580
581        // Test decay
582        assert_eq!(scheduler.get_lr(550), 5e-4);
583        assert_eq!(scheduler.get_lr(1000), 0.0);
584    }
585
586    #[test]
587    fn test_cosine_scheduler() {
588        let scheduler = CosineScheduler::new(1e-3, 100, 1000, 1e-5);
589
590        // Test warmup
591        assert_eq!(scheduler.get_lr(0), 0.0);
592        assert_eq!(scheduler.get_lr(50), 5e-4);
593        assert_eq!(scheduler.get_lr(100), 1e-3);
594
595        // Test cosine decay - should be smooth
596        let mid_lr = scheduler.get_lr(550);
597        assert!(mid_lr > 1e-5 && mid_lr < 1e-3);
598
599        // Should approach min_lr at the end
600        let end_lr = scheduler.get_lr(1000);
601        assert!((end_lr - 1e-5).abs() < 1e-6);
602    }
603
604    #[test]
605    fn test_polynomial_scheduler() {
606        let scheduler = PolynomialScheduler::new(1e-3, 100, 1000, 1e-5, 2.0);
607
608        // Test warmup
609        assert_eq!(scheduler.get_lr(0), 0.0);
610        assert_eq!(scheduler.get_lr(100), 1e-3);
611
612        // Test polynomial decay
613        let mid_lr = scheduler.get_lr(550);
614        assert!(mid_lr > 1e-5 && mid_lr < 1e-3);
615    }
616
617    #[test]
618    fn test_constant_with_warmup_scheduler() {
619        let scheduler = ConstantWithWarmupScheduler::new(1e-3, 100);
620
621        // Test warmup
622        assert_eq!(scheduler.get_lr(0), 0.0);
623        assert_eq!(scheduler.get_lr(50), 5e-4);
624        assert_eq!(scheduler.get_lr(100), 1e-3);
625
626        // Test constant after warmup
627        assert_eq!(scheduler.get_lr(200), 1e-3);
628        assert_eq!(scheduler.get_lr(1000), 1e-3);
629    }
630
631    #[test]
632    fn test_exponential_scheduler() {
633        let scheduler = ExponentialScheduler::new(1e-3, 100, 0.9, 100);
634
635        // Test warmup
636        assert_eq!(scheduler.get_lr(0), 0.0);
637        assert_eq!(scheduler.get_lr(100), 1e-3);
638
639        // Test exponential decay
640        assert_eq!(scheduler.get_lr(200), 1e-3 * 0.9);
641        assert_eq!(scheduler.get_lr(300), 1e-3 * 0.9 * 0.9);
642    }
643
644    #[test]
645    fn test_step_scheduler() {
646        let scheduler = StepScheduler::new(1e-3, 100, 200, 0.5);
647
648        // Test warmup
649        assert_eq!(scheduler.get_lr(0), 0.0);
650        assert_eq!(scheduler.get_lr(100), 1e-3);
651
652        // Test step decay
653        assert_eq!(scheduler.get_lr(250), 1e-3); // Still first step
654        assert_eq!(scheduler.get_lr(300), 1e-3 * 0.5); // Second step
655        assert_eq!(scheduler.get_lr(500), 1e-3 * 0.5 * 0.5); // Third step
656    }
657
658    #[test]
659    fn test_onecycle_scheduler() {
660        let scheduler = OneCycleScheduler::new(1e-2, 1000, 0.3, 1e-5);
661
662        // Test start
663        assert_eq!(scheduler.get_lr(0), 1e-5);
664
665        // Test peak (around 30% of training)
666        let peak_lr = scheduler.get_lr(150);
667        assert!(peak_lr > 5e-3);
668
669        // Test end
670        let end_lr = scheduler.get_lr(1000);
671        assert!((end_lr - 1e-5).abs() < 1e-6);
672    }
673
674    #[test]
675    fn test_cosine_with_restarts_scheduler() {
676        let scheduler = CosineWithRestartsScheduler::new(1e-3, 1e-5, 100, 2.0);
677
678        // Test initial learning rate
679        assert!((scheduler.get_lr(0) - 1e-3).abs() < 1e-6);
680
681        // Test mid-cycle (should be between min and max)
682        let mid_lr = scheduler.get_lr(50);
683        assert!(mid_lr > 1e-5 && mid_lr < 1e-3);
684
685        // Test near end of first cycle (should be close to minimum)
686        let near_end_lr = scheduler.get_lr(99);
687        assert!(near_end_lr < 2e-4);
688
689        // Test restart (should be back to max)
690        let restart_lr = scheduler.get_lr(100);
691        assert!(restart_lr > 5e-4);
692    }
693
694    #[test]
695    fn test_cyclical_scheduler() {
696        let scheduler = CyclicalScheduler::new(1e-4, 1e-3, 50, 50, CyclicalMode::Triangular);
697
698        // Test base learning rate
699        assert!((scheduler.get_lr(0) - 1e-4).abs() < 1e-6);
700
701        // Test peak learning rate
702        assert!((scheduler.get_lr(50) - 1e-3).abs() < 1e-6);
703
704        // Test return to base
705        assert!((scheduler.get_lr(100) - 1e-4).abs() < 1e-6);
706
707        // Test second cycle
708        assert!((scheduler.get_lr(150) - 1e-3).abs() < 1e-6);
709    }
710}
711
712/// Adaptive learning rate scheduler that reduces LR when a metric has stopped improving.
713///
714/// This scheduler monitors a metric (typically validation loss) and reduces the learning rate
715/// when the metric plateaus for a certain number of epochs. Similar to ReduceLROnPlateau
716/// in PyTorch, this provides adaptive learning rate scheduling based on actual training progress.
717#[derive(Debug, Clone)]
718pub struct AdaptiveScheduler {
719    /// Current learning rate
720    current_lr: f32,
721    /// Factor by which to reduce learning rate (new_lr = lr * factor)
722    factor: f32,
723    /// Number of epochs with no improvement after which LR will be reduced
724    patience: usize,
725    /// Threshold for measuring the new optimum (relative improvement)
726    threshold: f32,
727    /// Minimum learning rate (will not go below this)
728    min_lr: f32,
729    /// Mode: "min" for minimizing (loss), "max" for maximizing (accuracy)
730    mode: String,
731    /// Number of epochs since last improvement
732    epochs_since_improvement: usize,
733    /// Best metric value seen so far
734    best_metric: Option<f32>,
735    /// Step counter
736    current_step: usize,
737}
738
739impl AdaptiveScheduler {
740    /// Creates a new adaptive scheduler.
741    ///
742    /// # Arguments
743    ///
744    /// * `initial_lr` - Initial learning rate
745    /// * `factor` - Factor by which to reduce LR (typical: 0.1 to 0.5)
746    /// * `patience` - Number of epochs to wait before reducing LR (typical: 5-10)
747    /// * `threshold` - Minimum improvement threshold (typical: 1e-4)
748    /// * `min_lr` - Minimum learning rate (typical: 1e-8)
749    /// * `mode` - "min" for loss, "max" for accuracy
750    ///
751    /// # Example
752    ///
753    /// ```
754    /// use trustformers_optim::AdaptiveScheduler;
755    ///
756    /// let scheduler = AdaptiveScheduler::new(1e-3, 0.1, 5, 1e-4, 1e-8, "min");
757    /// ```
758    /// # Panics
759    ///
760    /// Panics when any argument is out of range (`factor` outside `(0, 1)`,
761    /// `patience == 0`, negative `threshold`/`min_lr`, or a `mode` other than `"min"`
762    /// or `"max"`). Prefer [`AdaptiveScheduler::try_new`], which returns an error, for
763    /// values that come from a configuration file.
764    pub fn new(
765        initial_lr: f32,
766        factor: f32,
767        patience: usize,
768        threshold: f32,
769        min_lr: f32,
770        mode: &str,
771    ) -> Self {
772        match Self::try_new(initial_lr, factor, patience, threshold, min_lr, mode) {
773            Ok(scheduler) => scheduler,
774            Err(error) => panic!("invalid AdaptiveScheduler configuration: {error}"),
775        }
776    }
777
778    /// Fallible constructor: every guard reports an error instead of aborting.
779    ///
780    /// The `mode` string is the most dangerous of these — a typo like `"Min"` in a
781    /// config file used to kill the training process.
782    ///
783    /// # Errors
784    ///
785    /// Returns [`ErrorKind::InvalidConfiguration`](trustformers_core::errors::ErrorKind)
786    /// when `factor` is not in `(0, 1)`, `patience` is zero, `threshold` or `min_lr`
787    /// is negative, or `mode` is not `"min"`/`"max"`.
788    pub fn try_new(
789        initial_lr: f32,
790        factor: f32,
791        patience: usize,
792        threshold: f32,
793        min_lr: f32,
794        mode: &str,
795    ) -> trustformers_core::errors::Result<Self> {
796        use trustformers_core::errors::TrustformersError;
797
798        if !(factor > 0.0 && factor < 1.0) {
799            return Err(TrustformersError::invalid_config(format!(
800                "AdaptiveScheduler factor must be in (0, 1), got {factor}"
801            )));
802        }
803        if patience == 0 {
804            return Err(TrustformersError::invalid_config(
805                "AdaptiveScheduler patience must be positive".to_string(),
806            ));
807        }
808        if threshold < 0.0 {
809            return Err(TrustformersError::invalid_config(format!(
810                "AdaptiveScheduler threshold must be non-negative, got {threshold}"
811            )));
812        }
813        if min_lr < 0.0 {
814            return Err(TrustformersError::invalid_config(format!(
815                "AdaptiveScheduler min_lr must be non-negative, got {min_lr}"
816            )));
817        }
818        if mode != "min" && mode != "max" {
819            return Err(TrustformersError::invalid_config(format!(
820                "AdaptiveScheduler mode must be \"min\" or \"max\", got \"{mode}\""
821            )));
822        }
823
824        Ok(Self {
825            current_lr: initial_lr,
826            factor,
827            patience,
828            threshold,
829            min_lr,
830            mode: mode.to_string(),
831            epochs_since_improvement: 0,
832            best_metric: None,
833            current_step: 0,
834        })
835    }
836
837    /// Update the scheduler with a new metric value.
838    /// Returns the new learning rate and whether it was reduced.
839    pub fn step_with_metric(&mut self, metric: f32) -> (f32, bool) {
840        self.current_step += 1;
841        let mut lr_reduced = false;
842
843        let is_improvement = match self.best_metric {
844            None => {
845                // First metric, set as best
846                self.best_metric = Some(metric);
847                true
848            },
849            Some(best) => {
850                let improvement = if self.mode == "min" {
851                    // For minimizing (loss), improvement is when metric decreases
852                    (best - metric) / best.abs().max(1e-8) > self.threshold
853                } else {
854                    // For maximizing (accuracy), improvement is when metric increases
855                    (metric - best) / best.abs().max(1e-8) > self.threshold
856                };
857
858                if improvement {
859                    self.best_metric = Some(metric);
860                }
861
862                improvement
863            },
864        };
865
866        if is_improvement {
867            self.epochs_since_improvement = 0;
868        } else {
869            self.epochs_since_improvement += 1;
870
871            if self.epochs_since_improvement >= self.patience {
872                // Reduce learning rate
873                let new_lr = (self.current_lr * self.factor).max(self.min_lr);
874                if new_lr < self.current_lr {
875                    self.current_lr = new_lr;
876                    lr_reduced = true;
877                    self.epochs_since_improvement = 0; // Reset patience counter
878                }
879            }
880        }
881
882        (self.current_lr, lr_reduced)
883    }
884
885    /// Get current learning rate without updating.
886    pub fn get_current_lr(&self) -> f32 {
887        self.current_lr
888    }
889
890    /// Get the best metric seen so far.
891    pub fn get_best_metric(&self) -> Option<f32> {
892        self.best_metric
893    }
894
895    /// Get epochs since last improvement.
896    pub fn get_epochs_since_improvement(&self) -> usize {
897        self.epochs_since_improvement
898    }
899
900    /// Reset the scheduler state.
901    pub fn reset(&mut self) {
902        self.epochs_since_improvement = 0;
903        self.best_metric = None;
904        self.current_step = 0;
905    }
906
907    /// Set the learning rate manually.
908    pub fn set_lr(&mut self, lr: f32) {
909        self.current_lr = lr;
910    }
911}
912
913impl LRScheduler for AdaptiveScheduler {
914    fn get_lr(&self, _step: usize) -> f32 {
915        self.current_lr
916    }
917
918    fn step(&mut self) {
919        // For adaptive scheduler, stepping is done via step_with_metric
920        // This method is kept for compatibility with the LRScheduler trait
921    }
922}
923
924/// A composite scheduler that chains multiple schedulers together.
925///
926/// This allows combining different scheduling strategies, e.g., warmup + cosine + linear decay.
927/// Each scheduler is active for a specified number of steps.
928pub struct CompositeScheduler {
929    schedulers: Vec<Box<dyn LRScheduler>>,
930    step_boundaries: Vec<usize>,
931    current_step: usize,
932    // reason: reserved for global-step offsetting across composed schedulers;
933    // retained intentionally for an in-progress feature.
934    #[allow(dead_code)]
935    global_step_offset: usize,
936}
937
938impl CompositeScheduler {
939    /// Creates a new composite scheduler.
940    ///
941    /// # Arguments
942    /// * `schedulers` - Vector of schedulers to chain
943    /// * `step_boundaries` - Steps at which to switch to the next scheduler
944    ///
945    /// # Example
946    /// ```rust,no_run
947    /// use trustformers_optim::{LinearScheduler, CosineScheduler, CompositeScheduler, LRScheduler};
948    ///
949    /// let warmup = Box::new(LinearScheduler::new(1e-4, 1000, 1000));
950    /// let main = Box::new(CosineScheduler::new(1e-4, 0, 9000, 1e-6));
951    /// let composite = CompositeScheduler::new(
952    ///     vec![warmup, main],
953    ///     vec![1000, 10000]
954    /// );
955    /// ```
956    /// # Panics
957    ///
958    /// Panics when `schedulers` is empty or its length differs from
959    /// `step_boundaries`. Use [`CompositeScheduler::try_new`] for values that come
960    /// from a configuration file.
961    pub fn new(schedulers: Vec<Box<dyn LRScheduler>>, step_boundaries: Vec<usize>) -> Self {
962        match Self::try_new(schedulers, step_boundaries) {
963            Ok(scheduler) => scheduler,
964            Err(error) => panic!("invalid CompositeScheduler configuration: {error}"),
965        }
966    }
967
968    /// Fallible constructor.
969    ///
970    /// # Errors
971    ///
972    /// Returns an error when `schedulers` is empty or its length differs from
973    /// `step_boundaries`.
974    pub fn try_new(
975        schedulers: Vec<Box<dyn LRScheduler>>,
976        step_boundaries: Vec<usize>,
977    ) -> trustformers_core::errors::Result<Self> {
978        use trustformers_core::errors::TrustformersError;
979
980        if schedulers.is_empty() {
981            return Err(TrustformersError::invalid_config(
982                "CompositeScheduler needs at least one scheduler".to_string(),
983            ));
984        }
985        if schedulers.len() != step_boundaries.len() {
986            return Err(TrustformersError::invalid_config(format!(
987                "CompositeScheduler has {} schedulers but {} boundaries",
988                schedulers.len(),
989                step_boundaries.len()
990            )));
991        }
992
993        Ok(Self {
994            schedulers,
995            step_boundaries,
996            current_step: 0,
997            global_step_offset: 0,
998        })
999    }
1000
1001    fn get_active_scheduler_index(&self, step: usize) -> usize {
1002        for (i, &boundary) in self.step_boundaries.iter().enumerate() {
1003            if step < boundary {
1004                return i;
1005            }
1006        }
1007        self.schedulers.len() - 1
1008    }
1009
1010    fn get_local_step(&self, global_step: usize, scheduler_index: usize) -> usize {
1011        if scheduler_index == 0 {
1012            global_step
1013        } else {
1014            global_step - self.step_boundaries[scheduler_index - 1]
1015        }
1016    }
1017}
1018
1019impl LRScheduler for CompositeScheduler {
1020    fn get_lr(&self, step: usize) -> f32 {
1021        let scheduler_idx = self.get_active_scheduler_index(step);
1022        let local_step = self.get_local_step(step, scheduler_idx);
1023        self.schedulers[scheduler_idx].get_lr(local_step)
1024    }
1025
1026    fn step(&mut self) {
1027        self.current_step += 1;
1028        let _scheduler_idx = self.get_active_scheduler_index(self.current_step);
1029        // Note: Individual schedulers manage their own state
1030    }
1031}
1032
1033/// A phase-based scheduler that applies different scheduling strategies during training phases.
1034///
1035/// This is useful for complex training regimes like pre-training -> fine-tuning -> evaluation.
1036pub struct PhaseBasedScheduler {
1037    phases: Vec<Phase>,
1038    current_phase: usize,
1039    current_step: usize,
1040    phase_start_step: usize,
1041}
1042
1043pub struct Phase {
1044    pub name: String,
1045    pub scheduler: Box<dyn LRScheduler>,
1046    pub duration_steps: usize,
1047    pub lr_multiplier: f32,
1048}
1049
1050impl PhaseBasedScheduler {
1051    /// Creates a new phase-based scheduler.
1052    ///
1053    /// # Example
1054    /// ```rust,no_run
1055    /// use trustformers_optim::{Phase, LinearScheduler, CosineScheduler, ConstantWithWarmupScheduler, PhaseBasedScheduler};
1056    ///
1057    /// let phases = vec![
1058    ///     Phase {
1059    ///         name: "warmup".to_string(),
1060    ///         scheduler: Box::new(LinearScheduler::new(1e-4, 1000, 1000)),
1061    ///         duration_steps: 1000,
1062    ///         lr_multiplier: 1.0,
1063    ///     },
1064    ///     Phase {
1065    ///         name: "main_training".to_string(),
1066    ///         scheduler: Box::new(CosineScheduler::new(1e-4, 0, 9000, 1e-6)),
1067    ///         duration_steps: 9000,
1068    ///         lr_multiplier: 1.0,
1069    ///     },
1070    ///     Phase {
1071    ///         name: "fine_tuning".to_string(),
1072    ///         scheduler: Box::new(ConstantWithWarmupScheduler::new(1e-5, 0)),
1073    ///         duration_steps: 1000,
1074    ///         lr_multiplier: 0.1,
1075    ///     },
1076    /// ];
1077    /// let scheduler = PhaseBasedScheduler::new(phases);
1078    /// ```
1079    /// # Panics
1080    ///
1081    /// Panics when `phases` is empty. Use [`PhaseBasedScheduler::try_new`] for values
1082    /// that come from a configuration file.
1083    pub fn new(phases: Vec<Phase>) -> Self {
1084        match Self::try_new(phases) {
1085            Ok(scheduler) => scheduler,
1086            Err(error) => panic!("invalid PhaseBasedScheduler configuration: {error}"),
1087        }
1088    }
1089
1090    /// Fallible constructor.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error when `phases` is empty.
1095    pub fn try_new(phases: Vec<Phase>) -> trustformers_core::errors::Result<Self> {
1096        if phases.is_empty() {
1097            return Err(
1098                trustformers_core::errors::TrustformersError::invalid_config(
1099                    "PhaseBasedScheduler needs at least one phase".to_string(),
1100                ),
1101            );
1102        }
1103
1104        Ok(Self {
1105            phases,
1106            current_phase: 0,
1107            current_step: 0,
1108            phase_start_step: 0,
1109        })
1110    }
1111
1112    /// Get the current phase name.
1113    pub fn get_current_phase(&self) -> &str {
1114        &self.phases[self.current_phase].name
1115    }
1116
1117    /// Get the current phase index.
1118    pub fn get_current_phase_index(&self) -> usize {
1119        self.current_phase
1120    }
1121
1122    /// Check if training is complete (all phases finished).
1123    pub fn is_complete(&self) -> bool {
1124        self.current_phase >= self.phases.len()
1125    }
1126
1127    fn update_phase(&mut self, step: usize) {
1128        while self.current_phase < self.phases.len() {
1129            let phase_end = self.phase_start_step + self.phases[self.current_phase].duration_steps;
1130
1131            if step < phase_end {
1132                break; // Still in current phase
1133            }
1134
1135            // Move to next phase
1136            self.current_phase += 1;
1137            self.phase_start_step = phase_end;
1138        }
1139    }
1140}
1141
1142impl LRScheduler for PhaseBasedScheduler {
1143    fn get_lr(&self, step: usize) -> f32 {
1144        if self.current_phase >= self.phases.len() {
1145            return 0.0; // Training complete
1146        }
1147
1148        let phase = &self.phases[self.current_phase];
1149        let phase_step = step - self.phase_start_step;
1150        let base_lr = phase.scheduler.get_lr(phase_step);
1151
1152        base_lr * phase.lr_multiplier
1153    }
1154
1155    fn step(&mut self) {
1156        self.current_step += 1;
1157        self.update_phase(self.current_step);
1158    }
1159}
1160
1161/// A dynamic scheduler that adjusts its behavior based on training metrics.
1162///
1163/// This scheduler can dynamically switch between different scheduling strategies
1164/// based on training progress, loss trends, or other metrics.
1165pub struct DynamicScheduler {
1166    primary_scheduler: Box<dyn LRScheduler>,
1167    fallback_scheduler: Box<dyn LRScheduler>,
1168    current_scheduler: usize, // 0 = primary, 1 = fallback
1169    switch_condition: SwitchCondition,
1170    metrics_window: Vec<f32>,
1171    window_size: usize,
1172    current_step: usize,
1173}
1174
1175#[derive(Debug)]
1176pub enum SwitchCondition {
1177    /// Switch when loss stops improving for N steps
1178    LossPlateauSteps(usize),
1179    /// Switch when gradient norm exceeds threshold
1180    GradientNormThreshold(f32),
1181    /// Switch at specific step
1182    StepThreshold(usize),
1183    /// Switch when loss increases by factor
1184    LossIncreaseFactor(f32),
1185}
1186
1187impl DynamicScheduler {
1188    /// Creates a new dynamic scheduler.
1189    pub fn new(
1190        primary_scheduler: Box<dyn LRScheduler>,
1191        fallback_scheduler: Box<dyn LRScheduler>,
1192        switch_condition: SwitchCondition,
1193        window_size: usize,
1194    ) -> Self {
1195        Self {
1196            primary_scheduler,
1197            fallback_scheduler,
1198            current_scheduler: 0,
1199            switch_condition,
1200            metrics_window: Vec::with_capacity(window_size),
1201            window_size,
1202            current_step: 0,
1203        }
1204    }
1205
1206    /// Update with a new metric (e.g., loss value).
1207    pub fn update_metric(&mut self, metric: f32) {
1208        self.metrics_window.push(metric);
1209        if self.metrics_window.len() > self.window_size {
1210            self.metrics_window.remove(0);
1211        }
1212
1213        // Check switch condition
1214        if self.current_scheduler == 0 && self.should_switch() {
1215            self.current_scheduler = 1;
1216        }
1217    }
1218
1219    fn should_switch(&self) -> bool {
1220        match &self.switch_condition {
1221            SwitchCondition::LossPlateauSteps(steps) => {
1222                if self.metrics_window.len() < *steps {
1223                    return false;
1224                }
1225
1226                let recent_avg =
1227                    self.metrics_window.iter().rev().take(*steps).sum::<f32>() / *steps as f32;
1228                let older_avg =
1229                    self.metrics_window.iter().take(self.metrics_window.len() - steps).sum::<f32>()
1230                        / (self.metrics_window.len() - steps) as f32;
1231
1232                recent_avg >= older_avg * 0.995 // Less than 0.5% improvement
1233            },
1234            SwitchCondition::StepThreshold(step) => self.current_step >= *step,
1235            SwitchCondition::LossIncreaseFactor(factor) => {
1236                if self.metrics_window.len() < 2 {
1237                    return false;
1238                }
1239                let latest = self.metrics_window[self.metrics_window.len() - 1];
1240                let previous = self.metrics_window[self.metrics_window.len() - 2];
1241                latest > previous * factor
1242            },
1243            SwitchCondition::GradientNormThreshold(_) => false, // Requires external gradient norm input
1244        }
1245    }
1246
1247    /// Get which scheduler is currently active.
1248    pub fn get_active_scheduler(&self) -> &str {
1249        if self.current_scheduler == 0 {
1250            "primary"
1251        } else {
1252            "fallback"
1253        }
1254    }
1255}
1256
1257impl LRScheduler for DynamicScheduler {
1258    fn get_lr(&self, step: usize) -> f32 {
1259        if self.current_scheduler == 0 {
1260            self.primary_scheduler.get_lr(step)
1261        } else {
1262            self.fallback_scheduler.get_lr(step)
1263        }
1264    }
1265
1266    fn step(&mut self) {
1267        self.current_step += 1;
1268        if self.current_scheduler == 0 {
1269            self.primary_scheduler.step();
1270        } else {
1271            self.fallback_scheduler.step();
1272        }
1273    }
1274}
1275
1276/// A task-specific scheduler optimized for different ML tasks.
1277pub struct TaskSpecificScheduler {
1278    scheduler: Box<dyn LRScheduler>,
1279    task_type: TaskType,
1280    current_step: usize,
1281}
1282
1283#[derive(Debug)]
1284pub enum TaskType {
1285    /// Language model pre-training (warmup + cosine decay)
1286    LanguageModelPretraining,
1287    /// Fine-tuning (low LR, minimal decay)
1288    FineTuning,
1289    /// Computer vision (step decay)
1290    ComputerVision,
1291    /// Reinforcement learning (adaptive)
1292    ReinforcementLearning,
1293    /// GAN training (alternating or constant)
1294    GANTraining,
1295}
1296
1297impl TaskSpecificScheduler {
1298    /// Creates a task-specific scheduler with optimal defaults.
1299    pub fn new(task_type: TaskType, base_lr: f32, total_steps: usize) -> Self {
1300        let scheduler: Box<dyn LRScheduler> = match task_type {
1301            TaskType::LanguageModelPretraining => {
1302                Box::new(CosineScheduler::new(
1303                    base_lr,
1304                    (total_steps as f32 * 0.06) as usize, // 6% warmup
1305                    total_steps,
1306                    base_lr * 0.1, // Decay to 10% of base LR
1307                ))
1308            },
1309            TaskType::FineTuning => {
1310                Box::new(LinearScheduler::new(
1311                    base_lr * 0.1,                       // Lower LR for fine-tuning
1312                    (total_steps as f32 * 0.1) as usize, // 10% warmup
1313                    total_steps,
1314                ))
1315            },
1316            TaskType::ComputerVision => {
1317                Box::new(StepScheduler::new(
1318                    base_lr,
1319                    (total_steps as f32 * 0.05) as usize, // 5% warmup
1320                    total_steps / 3,                      // Step every 1/3 of training
1321                    0.1,                                  // Decay by factor of 10
1322                ))
1323            },
1324            TaskType::ReinforcementLearning => {
1325                Box::new(AdaptiveScheduler::new(
1326                    base_lr,
1327                    0.5,            // Moderate reduction factor
1328                    10,             // Patience
1329                    1e-4,           // Threshold
1330                    base_lr * 1e-3, // Min LR
1331                    "max",          // Maximize reward
1332                ))
1333            },
1334            TaskType::GANTraining => {
1335                Box::new(ConstantWithWarmupScheduler::new(
1336                    base_lr,
1337                    (total_steps as f32 * 0.02) as usize, // 2% warmup
1338                ))
1339            },
1340        };
1341
1342        Self {
1343            scheduler,
1344            task_type,
1345            current_step: 0,
1346        }
1347    }
1348
1349    /// Get the task type.
1350    pub fn get_task_type(&self) -> &TaskType {
1351        &self.task_type
1352    }
1353}
1354
1355impl LRScheduler for TaskSpecificScheduler {
1356    fn get_lr(&self, step: usize) -> f32 {
1357        self.scheduler.get_lr(step)
1358    }
1359
1360    fn step(&mut self) {
1361        self.current_step += 1;
1362        self.scheduler.step();
1363    }
1364}
1365
1366#[cfg(test)]
1367mod boundary_tests {
1368    use super::*;
1369
1370    /// Regression: `t_mult < 1` (or `t_0 == 0`) drove the cycle length to zero, after
1371    /// which `step_in_cycle -= 0` looped forever inside a scheduler call.
1372    #[test]
1373    fn cosine_with_restarts_never_hangs() {
1374        for (t_0, t_mult) in [(0_usize, 2.0_f32), (10, 0.5), (0, 0.0), (5, f32::NAN)] {
1375            let scheduler = CosineWithRestartsScheduler::new(1e-3, 1e-5, t_0, t_mult);
1376            // If the guard were missing this call would never return.
1377            let lr = scheduler.get_lr(1_000);
1378            assert!(lr.is_finite(), "t_0={t_0}, t_mult={t_mult} produced {lr}");
1379        }
1380    }
1381
1382    /// The fallible constructor reports the degenerate cases instead of correcting them.
1383    #[test]
1384    fn cosine_with_restarts_try_new_validates() {
1385        assert!(CosineWithRestartsScheduler::try_new(1e-3, 1e-5, 10, 2.0).is_ok());
1386        assert!(CosineWithRestartsScheduler::try_new(1e-3, 1e-5, 0, 2.0).is_err());
1387        assert!(CosineWithRestartsScheduler::try_new(1e-3, 1e-5, 10, 0.5).is_err());
1388        assert!(CosineWithRestartsScheduler::try_new(1e-3, 1e-5, 10, f32::NAN).is_err());
1389    }
1390
1391    /// Regression: past `total_steps` the unclamped cosine turned back upward and the
1392    /// learning rate climbed above `min_lr` again.
1393    #[test]
1394    fn cosine_lr_never_rises_after_the_schedule_ends() {
1395        let scheduler = CosineScheduler::new(1e-3, 100, 1000, 1e-5);
1396
1397        assert!((scheduler.get_lr(0) - 0.0).abs() < 1e-9);
1398        assert!((scheduler.get_lr(100) - 1e-3).abs() < 1e-9);
1399        let at_end = scheduler.get_lr(1000);
1400        assert!((at_end - 1e-5).abs() < 1e-7, "at total_steps: {at_end}");
1401
1402        for step in [1001_usize, 1500, 2000, 10_000] {
1403            let lr = scheduler.get_lr(step);
1404            assert!(
1405                (lr - 1e-5).abs() < 1e-7,
1406                "step {step} must stay at min_lr, got {lr}"
1407            );
1408        }
1409    }
1410
1411    /// `total_steps <= warmup_steps` used to underflow the usize subtraction.
1412    #[test]
1413    fn degenerate_step_counts_do_not_underflow() {
1414        let cosine = CosineScheduler::new(1e-3, 1000, 100, 1e-5);
1415        assert!(cosine.get_lr(2000).is_finite());
1416
1417        let linear = LinearScheduler::new(1e-3, 1000, 100);
1418        assert!(linear.get_lr(2000).is_finite());
1419    }
1420
1421    /// `pct_start` of exactly 0 or 1 used to divide by zero.
1422    #[test]
1423    fn one_cycle_endpoints_stay_finite() {
1424        for pct_start in [0.0_f32, 1.0, -1.0, 2.0, f32::NAN] {
1425            let scheduler = OneCycleScheduler::new(1e-2, 1000, pct_start, 1e-5);
1426            for step in [0_usize, 1, 500, 999, 1000, 5000] {
1427                let lr = scheduler.get_lr(step);
1428                assert!(lr.is_finite(), "pct_start={pct_start}, step={step} -> {lr}");
1429            }
1430        }
1431
1432        // A zero-length schedule must not divide by zero either.
1433        let empty = OneCycleScheduler::new(1e-2, 0, 0.3, 1e-5);
1434        assert!(empty.get_lr(0).is_finite());
1435    }
1436
1437    /// Regression: eight public constructors aborted the process on a bad argument.
1438    #[test]
1439    fn constructors_report_invalid_configuration() {
1440        assert!(AdaptiveScheduler::try_new(1e-3, 0.1, 5, 1e-4, 1e-8, "min").is_ok());
1441        assert!(AdaptiveScheduler::try_new(1e-3, 1.5, 5, 1e-4, 1e-8, "min").is_err());
1442        assert!(AdaptiveScheduler::try_new(1e-3, 0.1, 0, 1e-4, 1e-8, "min").is_err());
1443        assert!(AdaptiveScheduler::try_new(1e-3, 0.1, 5, -1.0, 1e-8, "min").is_err());
1444        assert!(AdaptiveScheduler::try_new(1e-3, 0.1, 5, 1e-4, -1.0, "min").is_err());
1445        // The stringly-typed mode is the one most likely to come from a config file.
1446        assert!(AdaptiveScheduler::try_new(1e-3, 0.1, 5, 1e-4, 1e-8, "Min").is_err());
1447
1448        assert!(CompositeScheduler::try_new(Vec::new(), Vec::new()).is_err());
1449        assert!(CompositeScheduler::try_new(
1450            vec![Box::new(LinearScheduler::new(1e-3, 10, 100)) as Box<dyn LRScheduler>],
1451            vec![10, 20],
1452        )
1453        .is_err());
1454
1455        assert!(PhaseBasedScheduler::try_new(Vec::new()).is_err());
1456    }
1457}