Skip to main content

ipfrs_tensorlogic/
loss_scaler.rs

1//! LossScaler -- dynamic loss scaling for mixed-precision training.
2//!
3//! Mixed-precision training uses reduced-precision floats (FP16/BF16) to cut
4//! memory and compute costs, but reduced dynamic range makes small gradients
5//! underflow to zero.  Loss scaling multiplies the loss (and thus all
6//! gradients) by a large scalar before the backward pass, then divides back
7//! before the optimizer step.  When an overflow is detected the scale is
8//! reduced; after a run of clean steps it is increased again.
9//!
10//! ## Policies
11//!
12//! | Policy    | On success streak         | On overflow           |
13//! |-----------|---------------------------|-----------------------|
14//! | Static    | no change                 | no change             |
15//! | Dynamic   | double every N steps      | halve immediately     |
16//! | Gradual   | multiply by `scale_up_factor` every `scale_up_interval` steps | multiply by `scale_down_factor` |
17//!
18//! # Examples
19//!
20//! ```
21//! use ipfrs_tensorlogic::{LossScaler, LossScalerConfig, ScaleUpdatePolicy};
22//!
23//! let config = LossScalerConfig {
24//!     policy: ScaleUpdatePolicy::Dynamic,
25//!     initial_scale: 65536.0,
26//!     scale_up_interval: 2000,
27//!     ..LossScalerConfig::default()
28//! };
29//! let mut scaler = LossScaler::new(config);
30//!
31//! // Forward pass: scale the loss before backward.
32//! let scaled = scaler.scale_loss(0.5);
33//! assert_eq!(scaled, 0.5 * 65536.0);
34//!
35//! // After backward: check and unscale gradients.
36//! let mut grads = vec![1.0_f64, 2.0, 3.0];
37//! scaler.unscale_gradients(&mut grads);
38//!
39//! // Update scale based on whether an overflow was detected.
40//! let overflow = LossScaler::has_overflow(&grads);
41//! scaler.update(overflow);
42//! ```
43
44// ---------------------------------------------------------------------------
45// Public types
46// ---------------------------------------------------------------------------
47
48/// Determines how the loss scale is adjusted over training.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub enum ScaleUpdatePolicy {
51    /// Fixed scale -- never adjusted.
52    Static,
53    /// Classic dynamic loss scaling: double after `scale_up_interval` clean
54    /// steps, halve immediately on overflow.
55    Dynamic,
56    /// Gradual policy: multiply by `scale_up_factor` after
57    /// `scale_up_interval` clean steps, multiply by `scale_down_factor` on
58    /// overflow (both factors should be in (0, ∞), down_factor < 1).
59    Gradual,
60}
61
62impl std::fmt::Display for ScaleUpdatePolicy {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Static => write!(f, "Static"),
66            Self::Dynamic => write!(f, "Dynamic"),
67            Self::Gradual => write!(f, "Gradual"),
68        }
69    }
70}
71
72/// Configuration parameters for a [`LossScaler`].
73#[derive(Debug, Clone)]
74pub struct LossScalerConfig {
75    /// Which update policy to use.
76    pub policy: ScaleUpdatePolicy,
77    /// Starting loss scale (default 65536.0 = 2^16).
78    pub initial_scale: f64,
79    /// Floor for the loss scale (default 1.0).
80    pub min_scale: f64,
81    /// Ceiling for the loss scale (default 2^24 = 16_777_216.0).
82    pub max_scale: f64,
83    /// Multiplicative factor applied to the scale on a successful streak
84    /// (Dynamic policy uses 2.0 to double; Gradual uses a configurable
85    /// value close to 1, e.g. 1.001).
86    pub scale_up_factor: f64,
87    /// Multiplicative factor applied on overflow (should be < 1, e.g. 0.5).
88    pub scale_down_factor: f64,
89    /// Number of clean (non-overflow) steps required before a scale-up.
90    /// For Dynamic this is often 2000; for Gradual it can be smaller.
91    pub scale_up_interval: u64,
92}
93
94impl Default for LossScalerConfig {
95    fn default() -> Self {
96        Self {
97            policy: ScaleUpdatePolicy::Dynamic,
98            initial_scale: 65_536.0,
99            min_scale: 1.0,
100            max_scale: 16_777_216.0, // 2^24
101            scale_up_factor: 2.0,
102            scale_down_factor: 0.5,
103            scale_up_interval: 2000,
104        }
105    }
106}
107
108/// Snapshot of scaler run-time statistics.
109#[derive(Debug, Clone, Default)]
110pub struct ScalerStats {
111    /// Total number of `update()` calls.
112    pub total_steps: u64,
113    /// Number of steps on which overflow was detected.
114    pub overflow_events: u64,
115    /// Number of times the scale was increased.
116    pub scale_ups: u64,
117    /// Number of times the scale was decreased.
118    pub scale_downs: u64,
119    /// Current loss scale at the time the snapshot was taken.
120    pub current_scale: f64,
121}
122
123/// Dynamic loss scaler for mixed-precision training.
124///
125/// See the [module-level documentation](self) for an overview and examples.
126#[derive(Debug, Clone)]
127pub struct LossScaler {
128    config: LossScalerConfig,
129    current_scale: f64,
130    /// Steps elapsed since the last overflow (or since creation).
131    steps_since_overflow: u64,
132    /// Total overflow events counted (mirrors stats, kept separately for
133    /// clarity).
134    overflow_count: u64,
135    stats: ScalerStats,
136}
137
138impl LossScaler {
139    // -----------------------------------------------------------------------
140    // Construction
141    // -----------------------------------------------------------------------
142
143    /// Create a new [`LossScaler`] from `config`.  The initial scale is taken
144    /// from `config.initial_scale` and clamped into `[min_scale, max_scale]`.
145    pub fn new(config: LossScalerConfig) -> Self {
146        let scale = config
147            .initial_scale
148            .clamp(config.min_scale, config.max_scale);
149        let mut scaler = Self {
150            config,
151            current_scale: scale,
152            steps_since_overflow: 0,
153            overflow_count: 0,
154            stats: ScalerStats::default(),
155        };
156        scaler.stats.current_scale = scaler.current_scale;
157        scaler
158    }
159
160    // -----------------------------------------------------------------------
161    // Core operations
162    // -----------------------------------------------------------------------
163
164    /// Multiply `loss` by the current scale.  Call this *before* the backward
165    /// pass.
166    #[inline]
167    pub fn scale_loss(&self, loss: f64) -> f64 {
168        loss * self.current_scale
169    }
170
171    /// Divide every element of `grads` by the current scale in-place.  Call
172    /// this *after* the backward pass and *before* passing gradients to the
173    /// optimizer.
174    ///
175    /// A zero current scale is handled gracefully: if the scale is exactly 0,
176    /// the gradients are left unchanged (division by zero is avoided).
177    pub fn unscale_gradients(&self, grads: &mut [f64]) {
178        if self.current_scale == 0.0 {
179            return;
180        }
181        let inv = 1.0 / self.current_scale;
182        for g in grads.iter_mut() {
183            *g *= inv;
184        }
185    }
186
187    /// Return `true` if any element of `grads` is `NaN` or `±∞`.
188    pub fn has_overflow(grads: &[f64]) -> bool {
189        grads.iter().any(|&g| !Self::is_finite(g))
190    }
191
192    /// Update the loss scale based on whether an overflow occurred this step.
193    ///
194    /// For [`ScaleUpdatePolicy::Static`] this is a no-op (stats are still
195    /// updated).
196    pub fn update(&mut self, overflow: bool) {
197        self.stats.total_steps += 1;
198
199        if overflow {
200            self.overflow_count += 1;
201            self.stats.overflow_events += 1;
202            self.steps_since_overflow = 0;
203
204            match self.config.policy {
205                ScaleUpdatePolicy::Static => {}
206                ScaleUpdatePolicy::Dynamic => {
207                    self.current_scale *= 0.5;
208                    self.clamp_scale();
209                    self.stats.scale_downs += 1;
210                }
211                ScaleUpdatePolicy::Gradual => {
212                    self.current_scale *= self.config.scale_down_factor;
213                    self.clamp_scale();
214                    self.stats.scale_downs += 1;
215                }
216            }
217        } else {
218            self.steps_since_overflow += 1;
219
220            match self.config.policy {
221                ScaleUpdatePolicy::Static => {}
222                ScaleUpdatePolicy::Dynamic => {
223                    if self.steps_since_overflow >= self.config.scale_up_interval {
224                        self.current_scale *= self.config.scale_up_factor;
225                        self.clamp_scale();
226                        self.stats.scale_ups += 1;
227                        self.steps_since_overflow = 0;
228                    }
229                }
230                ScaleUpdatePolicy::Gradual => {
231                    if self.steps_since_overflow >= self.config.scale_up_interval {
232                        self.current_scale *= self.config.scale_up_factor;
233                        self.clamp_scale();
234                        self.stats.scale_ups += 1;
235                        self.steps_since_overflow = 0;
236                    }
237                }
238            }
239        }
240
241        self.stats.current_scale = self.current_scale;
242    }
243
244    // -----------------------------------------------------------------------
245    // Accessors
246    // -----------------------------------------------------------------------
247
248    /// Return the current loss scale.
249    #[inline]
250    pub fn current_scale(&self) -> f64 {
251        self.current_scale
252    }
253
254    /// Return a reference to the accumulated statistics.
255    #[inline]
256    pub fn stats(&self) -> &ScalerStats {
257        &self.stats
258    }
259
260    /// Read-only access to the configuration.
261    #[inline]
262    pub fn config(&self) -> &LossScalerConfig {
263        &self.config
264    }
265
266    // -----------------------------------------------------------------------
267    // Mutations
268    // -----------------------------------------------------------------------
269
270    /// Reset the scaler to its initial state (scale, counters, stats).
271    pub fn reset(&mut self) {
272        self.current_scale = self
273            .config
274            .initial_scale
275            .clamp(self.config.min_scale, self.config.max_scale);
276        self.steps_since_overflow = 0;
277        self.overflow_count = 0;
278        self.stats = ScalerStats {
279            current_scale: self.current_scale,
280            ..ScalerStats::default()
281        };
282    }
283
284    // -----------------------------------------------------------------------
285    // Helper utilities (public so callers can reuse them)
286    // -----------------------------------------------------------------------
287
288    /// Return `true` iff `x` is neither `NaN` nor `±∞`.
289    #[inline]
290    pub fn is_finite(x: f64) -> bool {
291        x.is_finite()
292    }
293
294    /// Clamp `current_scale` into `[config.min_scale, config.max_scale]`.
295    pub fn clamp_scale(&mut self) {
296        self.current_scale = self
297            .current_scale
298            .clamp(self.config.min_scale, self.config.max_scale);
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Tests
304// ---------------------------------------------------------------------------
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // ---------------------------------------------------------------- helpers
311
312    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
313        (a - b).abs() < tol
314    }
315
316    fn dynamic_scaler() -> LossScaler {
317        LossScaler::new(LossScalerConfig {
318            policy: ScaleUpdatePolicy::Dynamic,
319            initial_scale: 1024.0,
320            min_scale: 1.0,
321            max_scale: 1_048_576.0,
322            scale_up_factor: 2.0,
323            scale_down_factor: 0.5,
324            scale_up_interval: 5,
325        })
326    }
327
328    fn static_scaler() -> LossScaler {
329        LossScaler::new(LossScalerConfig {
330            policy: ScaleUpdatePolicy::Static,
331            initial_scale: 512.0,
332            ..LossScalerConfig::default()
333        })
334    }
335
336    fn gradual_scaler() -> LossScaler {
337        LossScaler::new(LossScalerConfig {
338            policy: ScaleUpdatePolicy::Gradual,
339            initial_scale: 256.0,
340            min_scale: 1.0,
341            max_scale: 1_048_576.0,
342            scale_up_factor: 1.1,
343            scale_down_factor: 0.8,
344            scale_up_interval: 3,
345        })
346    }
347
348    // --------------------------------------------------------- scale_loss
349
350    #[test]
351    fn scale_loss_multiplies_by_scale() {
352        let scaler = dynamic_scaler();
353        let scaled = scaler.scale_loss(2.5);
354        assert!(approx_eq(scaled, 2.5 * 1024.0, 1e-10));
355    }
356
357    #[test]
358    fn scale_loss_zero_loss_remains_zero() {
359        let scaler = dynamic_scaler();
360        assert_eq!(scaler.scale_loss(0.0), 0.0);
361    }
362
363    #[test]
364    fn scale_loss_negative_loss() {
365        let scaler = dynamic_scaler();
366        let scaled = scaler.scale_loss(-1.0);
367        assert!(approx_eq(scaled, -1024.0, 1e-10));
368    }
369
370    // ------------------------------------------------------ unscale_gradients
371
372    #[test]
373    fn unscale_gradients_divides_by_scale() {
374        let scaler = dynamic_scaler();
375        let mut grads = vec![1024.0, 2048.0, 512.0];
376        scaler.unscale_gradients(&mut grads);
377        assert!(approx_eq(grads[0], 1.0, 1e-10));
378        assert!(approx_eq(grads[1], 2.0, 1e-10));
379        assert!(approx_eq(grads[2], 0.5, 1e-10));
380    }
381
382    #[test]
383    fn unscale_gradients_empty_slice_ok() {
384        let scaler = dynamic_scaler();
385        let mut grads: Vec<f64> = vec![];
386        scaler.unscale_gradients(&mut grads); // must not panic
387    }
388
389    #[test]
390    fn unscale_gradients_zero_scale_noop() {
391        // Construct a scaler whose scale is forced to zero via min_scale = 0.
392        let mut scaler = LossScaler::new(LossScalerConfig {
393            policy: ScaleUpdatePolicy::Static,
394            initial_scale: 0.0,
395            min_scale: 0.0,
396            max_scale: 1.0,
397            ..LossScalerConfig::default()
398        });
399        scaler.current_scale = 0.0; // bypass clamp by direct mutation
400        let mut grads = vec![3.0, 4.0];
401        scaler.unscale_gradients(&mut grads);
402        // Values must be unchanged (division-by-zero guard).
403        assert!(approx_eq(grads[0], 3.0, 1e-15));
404        assert!(approx_eq(grads[1], 4.0, 1e-15));
405    }
406
407    #[test]
408    fn unscale_zero_gradient_stays_zero() {
409        let scaler = dynamic_scaler();
410        let mut grads = vec![0.0_f64];
411        scaler.unscale_gradients(&mut grads);
412        assert_eq!(grads[0], 0.0);
413    }
414
415    // -------------------------------------------------------- has_overflow
416
417    #[test]
418    fn has_overflow_clean_gradients_false() {
419        let grads = vec![0.1, 0.2, -0.3, 0.0];
420        assert!(!LossScaler::has_overflow(&grads));
421    }
422
423    #[test]
424    fn has_overflow_nan_detected() {
425        let grads = vec![1.0, f64::NAN, 3.0];
426        assert!(LossScaler::has_overflow(&grads));
427    }
428
429    #[test]
430    fn has_overflow_positive_inf_detected() {
431        let grads = vec![1.0, f64::INFINITY];
432        assert!(LossScaler::has_overflow(&grads));
433    }
434
435    #[test]
436    fn has_overflow_negative_inf_detected() {
437        let grads = vec![f64::NEG_INFINITY, 0.0];
438        assert!(LossScaler::has_overflow(&grads));
439    }
440
441    #[test]
442    fn has_overflow_empty_slice_false() {
443        assert!(!LossScaler::has_overflow(&[]));
444    }
445
446    // ----------------------------------------- Dynamic: scale-up after streak
447
448    #[test]
449    fn dynamic_scale_up_after_interval() {
450        let mut scaler = dynamic_scaler(); // interval = 5
451        let initial = scaler.current_scale();
452        for _ in 0..5 {
453            scaler.update(false);
454        }
455        // After 5 clean steps the scale should have doubled once.
456        assert!(approx_eq(scaler.current_scale(), initial * 2.0, 1e-10));
457        assert_eq!(scaler.stats().scale_ups, 1);
458    }
459
460    #[test]
461    fn dynamic_scale_up_resets_streak_counter() {
462        let mut scaler = dynamic_scaler(); // interval = 5
463                                           // First scale-up at step 5.
464        for _ in 0..5 {
465            scaler.update(false);
466        }
467        let after_first = scaler.current_scale();
468        // Five more clean steps → second scale-up.
469        for _ in 0..5 {
470            scaler.update(false);
471        }
472        assert!(approx_eq(scaler.current_scale(), after_first * 2.0, 1e-10));
473        assert_eq!(scaler.stats().scale_ups, 2);
474    }
475
476    #[test]
477    fn dynamic_no_scale_up_before_interval() {
478        let mut scaler = dynamic_scaler(); // interval = 5
479        let initial = scaler.current_scale();
480        for _ in 0..4 {
481            scaler.update(false);
482        }
483        // 4 clean steps -- still below threshold.
484        assert!(approx_eq(scaler.current_scale(), initial, 1e-10));
485        assert_eq!(scaler.stats().scale_ups, 0);
486    }
487
488    // --------------------------------------- Dynamic: scale-down on overflow
489
490    #[test]
491    fn dynamic_scale_down_on_overflow() {
492        let mut scaler = dynamic_scaler();
493        let initial = scaler.current_scale();
494        scaler.update(true);
495        assert!(approx_eq(scaler.current_scale(), initial * 0.5, 1e-10));
496        assert_eq!(scaler.stats().scale_downs, 1);
497    }
498
499    #[test]
500    fn dynamic_consecutive_overflows_halve_repeatedly() {
501        let mut scaler = dynamic_scaler();
502        let initial = scaler.current_scale();
503        for i in 1..=4 {
504            scaler.update(true);
505            let expected = initial * 0.5_f64.powi(i);
506            assert!(
507                approx_eq(scaler.current_scale(), expected, 1e-8),
508                "after {i} overflows: expected {expected}, got {}",
509                scaler.current_scale()
510            );
511        }
512        assert_eq!(scaler.stats().scale_downs, 4);
513        assert_eq!(scaler.stats().overflow_events, 4);
514    }
515
516    #[test]
517    fn dynamic_overflow_resets_streak() {
518        let mut scaler = dynamic_scaler(); // interval = 5
519                                           // Build a streak of 4.
520        for _ in 0..4 {
521            scaler.update(false);
522        }
523        // Overflow resets the streak.
524        scaler.update(true);
525        // Four more clean -- still below interval of 5.
526        for _ in 0..4 {
527            scaler.update(false);
528        }
529        assert_eq!(scaler.stats().scale_ups, 0);
530    }
531
532    // --------------------------------------------------- min/max clamping
533
534    #[test]
535    fn scale_does_not_exceed_max() {
536        let mut scaler = LossScaler::new(LossScalerConfig {
537            policy: ScaleUpdatePolicy::Dynamic,
538            initial_scale: 512.0,
539            max_scale: 1024.0,
540            scale_up_factor: 4.0,
541            scale_up_interval: 1,
542            ..LossScalerConfig::default()
543        });
544        scaler.update(false); // would multiply by 4 → 2048 > max
545        assert!(scaler.current_scale() <= 1024.0);
546    }
547
548    #[test]
549    fn scale_does_not_fall_below_min() {
550        let mut scaler = LossScaler::new(LossScalerConfig {
551            policy: ScaleUpdatePolicy::Dynamic,
552            initial_scale: 4.0,
553            min_scale: 2.0,
554            scale_down_factor: 0.1,
555            scale_up_interval: 100,
556            ..LossScalerConfig::default()
557        });
558        scaler.update(true); // 4 * 0.5 = 2 → equals min
559        assert!(scaler.current_scale() >= 2.0);
560        scaler.update(true); // further halve → would be 1, but min = 2
561        assert!(scaler.current_scale() >= 2.0);
562    }
563
564    #[test]
565    fn clamp_scale_direct_call() {
566        let mut scaler = dynamic_scaler();
567        scaler.current_scale = 1e18; // manually exceed max
568        scaler.clamp_scale();
569        assert!(scaler.current_scale() <= scaler.config().max_scale);
570
571        scaler.current_scale = -5.0; // below min
572        scaler.clamp_scale();
573        assert!(scaler.current_scale() >= scaler.config().min_scale);
574    }
575
576    // -------------------------------------------------- Static policy
577
578    #[test]
579    fn static_policy_never_changes_on_success() {
580        let mut scaler = static_scaler();
581        let initial = scaler.current_scale();
582        for _ in 0..1000 {
583            scaler.update(false);
584        }
585        assert!(approx_eq(scaler.current_scale(), initial, 1e-10));
586        assert_eq!(scaler.stats().scale_ups, 0);
587    }
588
589    #[test]
590    fn static_policy_never_changes_on_overflow() {
591        let mut scaler = static_scaler();
592        let initial = scaler.current_scale();
593        for _ in 0..50 {
594            scaler.update(true);
595        }
596        assert!(approx_eq(scaler.current_scale(), initial, 1e-10));
597        assert_eq!(scaler.stats().scale_downs, 0);
598        // Stats for events should still be tracked.
599        assert_eq!(scaler.stats().overflow_events, 50);
600    }
601
602    // -------------------------------------------------- Gradual policy
603
604    #[test]
605    fn gradual_scale_up_after_interval() {
606        let mut scaler = gradual_scaler(); // interval = 3, factor = 1.1
607        let initial = scaler.current_scale();
608        for _ in 0..3 {
609            scaler.update(false);
610        }
611        let expected = initial * 1.1;
612        assert!(
613            approx_eq(scaler.current_scale(), expected, 1e-8),
614            "gradual scale_up: expected {expected}, got {}",
615            scaler.current_scale()
616        );
617        assert_eq!(scaler.stats().scale_ups, 1);
618    }
619
620    #[test]
621    fn gradual_scale_down_on_overflow() {
622        let mut scaler = gradual_scaler(); // down_factor = 0.8
623        let initial = scaler.current_scale();
624        scaler.update(true);
625        let expected = initial * 0.8;
626        assert!(
627            approx_eq(scaler.current_scale(), expected, 1e-8),
628            "gradual scale_down: expected {expected}, got {}",
629            scaler.current_scale()
630        );
631        assert_eq!(scaler.stats().scale_downs, 1);
632    }
633
634    #[test]
635    fn gradual_multiple_up_cycles() {
636        let mut scaler = gradual_scaler(); // interval = 3, factor = 1.1
637        let mut expected = scaler.current_scale();
638        for _ in 0..3 {
639            for _ in 0..3 {
640                scaler.update(false);
641            }
642            expected *= 1.1;
643        }
644        assert!(
645            approx_eq(scaler.current_scale(), expected, 1e-6),
646            "after 3 cycles: expected {expected}, got {}",
647            scaler.current_scale()
648        );
649        assert_eq!(scaler.stats().scale_ups, 3);
650    }
651
652    // ------------------------------------------------------- reset
653
654    #[test]
655    fn reset_restores_initial_scale() {
656        let mut scaler = dynamic_scaler();
657        for _ in 0..10 {
658            scaler.update(true);
659        }
660        scaler.reset();
661        assert!(approx_eq(scaler.current_scale(), 1024.0, 1e-10));
662    }
663
664    #[test]
665    fn reset_clears_stats() {
666        let mut scaler = dynamic_scaler();
667        for _ in 0..20 {
668            scaler.update(false);
669        }
670        scaler.update(true);
671        scaler.reset();
672        let s = scaler.stats();
673        assert_eq!(s.total_steps, 0);
674        assert_eq!(s.overflow_events, 0);
675        assert_eq!(s.scale_ups, 0);
676        assert_eq!(s.scale_downs, 0);
677        assert!(approx_eq(s.current_scale, 1024.0, 1e-10));
678    }
679
680    // -------------------------------------------- stats tracking
681
682    #[test]
683    fn stats_total_steps_increments() {
684        let mut scaler = dynamic_scaler();
685        for i in 1..=10_u64 {
686            scaler.update(false);
687            assert_eq!(scaler.stats().total_steps, i);
688        }
689    }
690
691    #[test]
692    fn stats_current_scale_reflects_latest() {
693        let mut scaler = dynamic_scaler(); // interval = 5
694        for _ in 0..5 {
695            scaler.update(false);
696        }
697        assert!(approx_eq(
698            scaler.stats().current_scale,
699            scaler.current_scale(),
700            1e-15
701        ));
702    }
703
704    // -------------------------------------------- is_finite helper
705
706    #[test]
707    fn is_finite_normal_values() {
708        assert!(LossScaler::is_finite(0.0));
709        assert!(LossScaler::is_finite(1.0));
710        assert!(LossScaler::is_finite(-1e300));
711    }
712
713    #[test]
714    fn is_finite_nan_false() {
715        assert!(!LossScaler::is_finite(f64::NAN));
716    }
717
718    #[test]
719    fn is_finite_inf_false() {
720        assert!(!LossScaler::is_finite(f64::INFINITY));
721        assert!(!LossScaler::is_finite(f64::NEG_INFINITY));
722    }
723
724    // -------------------------------------------- scale factor math
725
726    #[test]
727    fn scale_up_factor_math_exact() {
728        // Dynamic doubles: 1024 * 2 = 2048
729        let mut scaler = dynamic_scaler();
730        for _ in 0..5 {
731            scaler.update(false);
732        }
733        assert!(approx_eq(scaler.current_scale(), 2048.0, 1e-10));
734    }
735
736    #[test]
737    fn scale_down_factor_math_exact() {
738        // Dynamic halves: 1024 * 0.5 = 512
739        let mut scaler = dynamic_scaler();
740        scaler.update(true);
741        assert!(approx_eq(scaler.current_scale(), 512.0, 1e-10));
742    }
743
744    // -------------------------------------------- mixed overflow / clean
745
746    #[test]
747    fn mixed_pattern_tracks_correctly() {
748        let mut scaler = dynamic_scaler(); // interval = 5
749                                           // 3 clean, 1 overflow, 2 clean -- no scale-up yet.
750        for _ in 0..3 {
751            scaler.update(false);
752        }
753        scaler.update(true);
754        for _ in 0..2 {
755            scaler.update(false);
756        }
757        let s = scaler.stats();
758        assert_eq!(s.total_steps, 6);
759        assert_eq!(s.overflow_events, 1);
760        assert_eq!(s.scale_ups, 0);
761        assert_eq!(s.scale_downs, 1);
762    }
763}