Skip to main content

ipfrs_tensorlogic/
gradient_noise.rs

1//! Gradient noise injection for training regularization.
2//!
3//! This module provides configurable noise injection into gradient tensors,
4//! useful as a regularization technique during neural network training.
5//! Supported noise types include Gaussian, Uniform, Laplacian, and
6//! Scheduled Gaussian (where noise magnitude decays over training steps).
7//!
8//! # Example
9//!
10//! ```
11//! use ipfrs_tensorlogic::gradient_noise::{
12//!     GradientNoiseConfig, GradientNoiseInjector, NoiseType,
13//! };
14//!
15//! let config = GradientNoiseConfig {
16//!     noise_type: NoiseType::Gaussian,
17//!     initial_scale: 0.01,
18//!     decay_rate: 0.0,
19//!     clip_value: Some(0.05),
20//!     seed: 42,
21//! };
22//!
23//! let mut injector = GradientNoiseInjector::new(config);
24//! let mut gradients = vec![1.0, 2.0, 3.0, 4.0];
25//! injector.inject(&mut gradients);
26//! // gradients now contain added noise
27//! ```
28
29use std::f64::consts::PI;
30
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35/// The type of noise distribution to inject into gradients.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum NoiseType {
38    /// Standard Gaussian (normal) noise with mean 0 and configurable scale.
39    Gaussian,
40    /// Uniform noise in the range `[-scale, +scale]`.
41    Uniform,
42    /// Laplacian noise centred at 0 with configurable scale (heavier tails).
43    Laplacian,
44    /// Gaussian noise whose scale decays over training steps according to
45    /// `scale = initial_scale / (1 + decay_rate * step)`.
46    ScheduledGaussian,
47}
48
49/// Configuration for gradient noise injection.
50#[derive(Debug, Clone)]
51pub struct GradientNoiseConfig {
52    /// The distribution family to sample noise from.
53    pub noise_type: NoiseType,
54    /// Initial noise magnitude (standard deviation for Gaussian, half-width
55    /// for Uniform, scale parameter for Laplacian).
56    pub initial_scale: f64,
57    /// Decay rate applied to `ScheduledGaussian` noise. Ignored for other
58    /// noise types.
59    pub decay_rate: f64,
60    /// If set, clamps each noise sample to `[-clip_value, +clip_value]`.
61    pub clip_value: Option<f64>,
62    /// Seed for the internal xorshift64 PRNG, enabling reproducibility.
63    pub seed: u64,
64}
65
66/// Aggregate statistics about noise injections performed by an injector.
67#[derive(Debug, Clone, Default)]
68pub struct NoiseStats {
69    /// Number of times `inject` has been called.
70    pub total_injections: u64,
71    /// Cumulative number of gradient elements that received noise.
72    pub total_elements: u64,
73    /// Running average of absolute noise magnitude across all elements.
74    pub avg_noise_magnitude: f64,
75    /// Largest absolute noise value ever applied.
76    pub max_noise_applied: f64,
77    /// Current noise scale (accounts for decay in `ScheduledGaussian`).
78    pub current_scale: f64,
79}
80
81/// A batch of noise samples together with summary statistics.
82#[derive(Debug, Clone)]
83pub struct NoiseSample {
84    /// The sampled noise values.
85    pub values: Vec<f64>,
86    /// Arithmetic mean of `values`.
87    pub mean: f64,
88    /// Sample standard deviation of `values`.
89    pub std_dev: f64,
90    /// Training step at which the sample was drawn.
91    pub step: u64,
92}
93
94// ---------------------------------------------------------------------------
95// GradientNoiseInjector
96// ---------------------------------------------------------------------------
97
98/// Injects configurable noise into gradient arrays for training
99/// regularization.
100pub struct GradientNoiseInjector {
101    config: GradientNoiseConfig,
102    rng_state: u64,
103    step: u64,
104    stats: NoiseStats,
105}
106
107impl GradientNoiseInjector {
108    /// Create a new injector from the given configuration.
109    ///
110    /// The internal PRNG is seeded with `config.seed` (or a fallback value if
111    /// the seed is zero, since xorshift64 requires a non-zero state).
112    pub fn new(config: GradientNoiseConfig) -> Self {
113        let seed = if config.seed == 0 {
114            0xDEAD_BEEF_CAFE_BABE
115        } else {
116            config.seed
117        };
118        let current_scale = config.initial_scale;
119        Self {
120            config,
121            rng_state: seed,
122            step: 0,
123            stats: NoiseStats {
124                current_scale,
125                ..NoiseStats::default()
126            },
127        }
128    }
129
130    // -- PRNG helpers -------------------------------------------------------
131
132    /// Advance the xorshift64 state and return a `u64`.
133    fn xorshift64(&mut self) -> u64 {
134        let mut s = self.rng_state;
135        s ^= s << 13;
136        s ^= s >> 7;
137        s ^= s << 17;
138        self.rng_state = s;
139        s
140    }
141
142    /// Return a uniform `f64` in `[0, 1)`.
143    fn uniform_01(&mut self) -> f64 {
144        // Use 53 bits of the state for a double in [0,1).
145        let bits = self.xorshift64() >> 11;
146        (bits as f64) / ((1u64 << 53) as f64)
147    }
148
149    /// Sample from a standard Gaussian (mean 0, std 1) using the Box-Muller
150    /// transform.
151    pub fn next_gaussian(&mut self) -> f64 {
152        loop {
153            let u1 = self.uniform_01();
154            let u2 = self.uniform_01();
155            // Guard against log(0).
156            if u1 <= f64::EPSILON {
157                continue;
158            }
159            let r = (-2.0 * u1.ln()).sqrt();
160            return r * (2.0 * PI * u2).cos();
161        }
162    }
163
164    /// Sample from a uniform distribution on `[low, high)`.
165    pub fn next_uniform(&mut self, low: f64, high: f64) -> f64 {
166        let u = self.uniform_01();
167        low + u * (high - low)
168    }
169
170    /// Sample from a Laplacian distribution with location 0 and the given
171    /// `scale`, using the inverse-CDF method.
172    pub fn next_laplacian(&mut self, scale: f64) -> f64 {
173        let u = self.uniform_01() - 0.5;
174        // Avoid log(0) by clamping |u| away from 0.5.
175        let abs_u = u.abs().min(0.5 - f64::EPSILON);
176        -scale * (1.0 - 2.0 * abs_u).ln() * u.signum()
177    }
178
179    /// Clip `value` to `[-clip, +clip]` if a clip bound is configured,
180    /// otherwise return `value` unchanged.
181    pub fn clip_noise(&self, value: f64) -> f64 {
182        match self.config.clip_value {
183            Some(clip) => value.clamp(-clip, clip),
184            None => value,
185        }
186    }
187
188    // -- Public API ---------------------------------------------------------
189
190    /// Compute the current effective noise scale, accounting for decay when
191    /// using `ScheduledGaussian`.
192    pub fn current_scale(&self) -> f64 {
193        match self.config.noise_type {
194            NoiseType::ScheduledGaussian => {
195                self.config.initial_scale / (1.0 + self.config.decay_rate * self.step as f64)
196            }
197            _ => self.config.initial_scale,
198        }
199    }
200
201    /// Sample a single noise value according to the configured distribution
202    /// and scale, then clip it.
203    fn sample_one(&mut self) -> f64 {
204        let scale = self.current_scale();
205        let raw = match self.config.noise_type {
206            NoiseType::Gaussian => self.next_gaussian() * scale,
207            NoiseType::Uniform => self.next_uniform(-scale, scale),
208            NoiseType::Laplacian => self.next_laplacian(scale),
209            NoiseType::ScheduledGaussian => self.next_gaussian() * scale,
210        };
211        self.clip_noise(raw)
212    }
213
214    /// Inject noise into `gradients` in-place.
215    ///
216    /// Each element of the slice receives an independent noise sample drawn
217    /// from the configured distribution.  Statistics are updated accordingly.
218    pub fn inject(&mut self, gradients: &mut [f64]) {
219        let n = gradients.len() as u64;
220        let mut sum_abs: f64 = 0.0;
221        let mut local_max: f64 = 0.0;
222
223        for g in gradients.iter_mut() {
224            let noise = self.sample_one();
225            *g += noise;
226            let abs_noise = noise.abs();
227            sum_abs += abs_noise;
228            if abs_noise > local_max {
229                local_max = abs_noise;
230            }
231        }
232
233        // Update running statistics.
234        let prev_total = self.stats.total_elements;
235        self.stats.total_injections += 1;
236        self.stats.total_elements += n;
237        if self.stats.max_noise_applied < local_max {
238            self.stats.max_noise_applied = local_max;
239        }
240        // Incremental average update.
241        if n > 0 {
242            let new_avg = sum_abs / n as f64;
243            let total = prev_total + n;
244            self.stats.avg_noise_magnitude = (self.stats.avg_noise_magnitude * prev_total as f64
245                + new_avg * n as f64)
246                / total as f64;
247        }
248        self.stats.current_scale = self.current_scale();
249    }
250
251    /// Generate `count` noise samples without applying them to any gradient
252    /// array.  Returns a [`NoiseSample`] with summary statistics.
253    pub fn sample_noise(&mut self, count: usize) -> NoiseSample {
254        let mut values = Vec::with_capacity(count);
255        for _ in 0..count {
256            values.push(self.sample_one());
257        }
258
259        let (mean, std_dev) = compute_mean_std(&values);
260
261        NoiseSample {
262            values,
263            mean,
264            std_dev,
265            step: self.step,
266        }
267    }
268
269    /// Advance the training step counter by one.  This affects the noise
270    /// scale when using `ScheduledGaussian`.
271    pub fn step(&mut self) {
272        self.step += 1;
273        self.stats.current_scale = self.current_scale();
274    }
275
276    /// Reset the step counter, statistics, and re-seed the PRNG.
277    pub fn reset(&mut self) {
278        self.step = 0;
279        self.rng_state = if self.config.seed == 0 {
280            0xDEAD_BEEF_CAFE_BABE
281        } else {
282            self.config.seed
283        };
284        self.stats = NoiseStats {
285            current_scale: self.config.initial_scale,
286            ..NoiseStats::default()
287        };
288    }
289
290    /// Read-only access to the accumulated statistics.
291    pub fn stats(&self) -> &NoiseStats {
292        &self.stats
293    }
294
295    /// Override the current noise scale (applies to all non-Scheduled types;
296    /// for `ScheduledGaussian` this sets the *initial* scale used in decay).
297    pub fn set_scale(&mut self, scale: f64) {
298        self.config.initial_scale = scale;
299        self.stats.current_scale = self.current_scale();
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Helpers
305// ---------------------------------------------------------------------------
306
307/// Compute arithmetic mean and sample standard deviation for a slice.
308fn compute_mean_std(values: &[f64]) -> (f64, f64) {
309    if values.is_empty() {
310        return (0.0, 0.0);
311    }
312    let n = values.len() as f64;
313    let mean = values.iter().sum::<f64>() / n;
314    if values.len() == 1 {
315        return (mean, 0.0);
316    }
317    let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
318    (mean, var.sqrt())
319}
320
321// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn gaussian_config(seed: u64) -> GradientNoiseConfig {
330        GradientNoiseConfig {
331            noise_type: NoiseType::Gaussian,
332            initial_scale: 0.1,
333            decay_rate: 0.0,
334            clip_value: None,
335            seed,
336        }
337    }
338
339    fn uniform_config(seed: u64) -> GradientNoiseConfig {
340        GradientNoiseConfig {
341            noise_type: NoiseType::Uniform,
342            initial_scale: 1.0,
343            decay_rate: 0.0,
344            clip_value: None,
345            seed,
346        }
347    }
348
349    fn laplacian_config(seed: u64) -> GradientNoiseConfig {
350        GradientNoiseConfig {
351            noise_type: NoiseType::Laplacian,
352            initial_scale: 0.5,
353            decay_rate: 0.0,
354            clip_value: None,
355            seed,
356        }
357    }
358
359    fn scheduled_config(seed: u64) -> GradientNoiseConfig {
360        GradientNoiseConfig {
361            noise_type: NoiseType::ScheduledGaussian,
362            initial_scale: 1.0,
363            decay_rate: 0.1,
364            clip_value: None,
365            seed,
366        }
367    }
368
369    // -- Gaussian distribution tests ----------------------------------------
370
371    #[test]
372    fn gaussian_noise_has_zero_mean_approximately() {
373        let mut inj = GradientNoiseInjector::new(gaussian_config(123));
374        let sample = inj.sample_noise(10_000);
375        // With 10k samples and scale 0.1, mean should be near 0.
376        assert!(
377            sample.mean.abs() < 0.01,
378            "mean = {} is too far from 0",
379            sample.mean
380        );
381    }
382
383    #[test]
384    fn gaussian_noise_std_approximates_scale() {
385        let mut inj = GradientNoiseInjector::new(gaussian_config(456));
386        let sample = inj.sample_noise(10_000);
387        // std should be close to the configured scale (0.1).
388        assert!(
389            (sample.std_dev - 0.1).abs() < 0.02,
390            "std_dev = {} not close to 0.1",
391            sample.std_dev
392        );
393    }
394
395    #[test]
396    fn gaussian_noise_values_are_finite() {
397        let mut inj = GradientNoiseInjector::new(gaussian_config(789));
398        let sample = inj.sample_noise(1000);
399        for v in &sample.values {
400            assert!(v.is_finite(), "non-finite value: {}", v);
401        }
402    }
403
404    // -- Uniform distribution tests -----------------------------------------
405
406    #[test]
407    fn uniform_noise_within_bounds() {
408        let mut inj = GradientNoiseInjector::new(uniform_config(111));
409        let sample = inj.sample_noise(5000);
410        for v in &sample.values {
411            assert!(*v >= -1.0 && *v < 1.0, "value {} out of [-1, 1) range", v);
412        }
413    }
414
415    #[test]
416    fn uniform_noise_mean_near_zero() {
417        let mut inj = GradientNoiseInjector::new(uniform_config(222));
418        let sample = inj.sample_noise(10_000);
419        assert!(
420            sample.mean.abs() < 0.05,
421            "mean = {} is too far from 0",
422            sample.mean
423        );
424    }
425
426    #[test]
427    fn uniform_noise_spreads_across_range() {
428        let mut inj = GradientNoiseInjector::new(uniform_config(333));
429        let sample = inj.sample_noise(5000);
430        let min = sample.values.iter().cloned().fold(f64::INFINITY, f64::min);
431        let max = sample
432            .values
433            .iter()
434            .cloned()
435            .fold(f64::NEG_INFINITY, f64::max);
436        assert!(min < -0.8, "min {} not spread enough", min);
437        assert!(max > 0.8, "max {} not spread enough", max);
438    }
439
440    // -- Laplacian distribution tests ---------------------------------------
441
442    #[test]
443    fn laplacian_noise_mean_near_zero() {
444        let mut inj = GradientNoiseInjector::new(laplacian_config(444));
445        let sample = inj.sample_noise(10_000);
446        assert!(
447            sample.mean.abs() < 0.05,
448            "mean = {} too far from 0",
449            sample.mean
450        );
451    }
452
453    #[test]
454    fn laplacian_noise_values_are_finite() {
455        let mut inj = GradientNoiseInjector::new(laplacian_config(555));
456        let sample = inj.sample_noise(1000);
457        for v in &sample.values {
458            assert!(v.is_finite(), "non-finite Laplacian value: {}", v);
459        }
460    }
461
462    #[test]
463    fn laplacian_has_heavier_tails_than_gaussian() {
464        // Compare the 99th percentile of Laplacian vs Gaussian at same scale.
465        let mut g_inj = GradientNoiseInjector::new(GradientNoiseConfig {
466            noise_type: NoiseType::Gaussian,
467            initial_scale: 0.5,
468            decay_rate: 0.0,
469            clip_value: None,
470            seed: 666,
471        });
472        let mut l_inj = GradientNoiseInjector::new(laplacian_config(666));
473        let mut g_vals: Vec<f64> = g_inj.sample_noise(10_000).values;
474        let mut l_vals: Vec<f64> = l_inj.sample_noise(10_000).values;
475        g_vals.sort_by(|a, b| {
476            a.abs()
477                .partial_cmp(&b.abs())
478                .unwrap_or(std::cmp::Ordering::Equal)
479        });
480        l_vals.sort_by(|a, b| {
481            a.abs()
482                .partial_cmp(&b.abs())
483                .unwrap_or(std::cmp::Ordering::Equal)
484        });
485        let g99 = g_vals[9900].abs();
486        let l99 = l_vals[9900].abs();
487        assert!(
488            l99 > g99,
489            "Laplacian 99th pct {} should exceed Gaussian {}",
490            l99,
491            g99
492        );
493    }
494
495    // -- Scheduled Gaussian decay tests -------------------------------------
496
497    #[test]
498    fn scheduled_gaussian_decays_over_steps() {
499        let mut inj = GradientNoiseInjector::new(scheduled_config(777));
500        let scale0 = inj.current_scale();
501        assert!((scale0 - 1.0).abs() < f64::EPSILON);
502
503        inj.step();
504        let scale1 = inj.current_scale();
505        assert!(scale1 < scale0, "scale should decay");
506
507        for _ in 0..10 {
508            inj.step();
509        }
510        let scale11 = inj.current_scale();
511        assert!(
512            scale11 < scale1,
513            "scale should keep decaying: {} vs {}",
514            scale11,
515            scale1
516        );
517    }
518
519    #[test]
520    fn scheduled_gaussian_scale_formula_correct() {
521        let config = scheduled_config(888);
522        let mut inj = GradientNoiseInjector::new(config);
523        for _ in 0..5 {
524            inj.step();
525        }
526        let expected = 1.0 / (1.0 + 0.1 * 5.0);
527        let actual = inj.current_scale();
528        assert!(
529            (actual - expected).abs() < 1e-12,
530            "expected {}, got {}",
531            expected,
532            actual
533        );
534    }
535
536    #[test]
537    fn scheduled_gaussian_noise_magnitude_decreases() {
538        let mut inj = GradientNoiseInjector::new(scheduled_config(999));
539        let s0 = inj.sample_noise(5000);
540        for _ in 0..50 {
541            inj.step();
542        }
543        let s50 = inj.sample_noise(5000);
544        assert!(
545            s50.std_dev < s0.std_dev,
546            "later std {} should be less than initial {}",
547            s50.std_dev,
548            s0.std_dev
549        );
550    }
551
552    // -- Clipping tests -----------------------------------------------------
553
554    #[test]
555    fn clipping_limits_noise_magnitude() {
556        let config = GradientNoiseConfig {
557            noise_type: NoiseType::Gaussian,
558            initial_scale: 10.0,
559            decay_rate: 0.0,
560            clip_value: Some(0.5),
561            seed: 1010,
562        };
563        let mut inj = GradientNoiseInjector::new(config);
564        let sample = inj.sample_noise(5000);
565        for v in &sample.values {
566            assert!(
567                v.abs() <= 0.5 + f64::EPSILON,
568                "clipped value {} exceeds 0.5",
569                v
570            );
571        }
572    }
573
574    #[test]
575    fn clip_noise_returns_unchanged_without_config() {
576        let config = GradientNoiseConfig {
577            noise_type: NoiseType::Gaussian,
578            initial_scale: 1.0,
579            decay_rate: 0.0,
580            clip_value: None,
581            seed: 1111,
582        };
583        let inj = GradientNoiseInjector::new(config);
584        assert!((inj.clip_noise(999.0) - 999.0).abs() < f64::EPSILON);
585    }
586
587    #[test]
588    fn clip_noise_clamps_symmetric() {
589        let config = GradientNoiseConfig {
590            noise_type: NoiseType::Gaussian,
591            initial_scale: 1.0,
592            decay_rate: 0.0,
593            clip_value: Some(2.0),
594            seed: 1212,
595        };
596        let inj = GradientNoiseInjector::new(config);
597        assert!((inj.clip_noise(5.0) - 2.0).abs() < f64::EPSILON);
598        assert!((inj.clip_noise(-5.0) - (-2.0)).abs() < f64::EPSILON);
599        assert!((inj.clip_noise(1.5) - 1.5).abs() < f64::EPSILON);
600    }
601
602    // -- Step advancement ---------------------------------------------------
603
604    #[test]
605    fn step_increments_counter() {
606        let mut inj = GradientNoiseInjector::new(gaussian_config(1313));
607        assert_eq!(inj.step, 0);
608        inj.step();
609        assert_eq!(inj.step, 1);
610        inj.step();
611        assert_eq!(inj.step, 2);
612    }
613
614    // -- Seed reproducibility -----------------------------------------------
615
616    #[test]
617    fn same_seed_produces_same_sequence() {
618        let mut a = GradientNoiseInjector::new(gaussian_config(4242));
619        let mut b = GradientNoiseInjector::new(gaussian_config(4242));
620        let sa = a.sample_noise(100);
621        let sb = b.sample_noise(100);
622        assert_eq!(sa.values, sb.values);
623    }
624
625    #[test]
626    fn different_seeds_produce_different_sequences() {
627        let mut a = GradientNoiseInjector::new(gaussian_config(1));
628        let mut b = GradientNoiseInjector::new(gaussian_config(2));
629        let sa = a.sample_noise(100);
630        let sb = b.sample_noise(100);
631        // Extremely unlikely to be identical.
632        assert_ne!(sa.values, sb.values);
633    }
634
635    // -- Stats tracking -----------------------------------------------------
636
637    #[test]
638    fn stats_updated_after_inject() {
639        let mut inj = GradientNoiseInjector::new(gaussian_config(1414));
640        let mut grads = vec![0.0; 50];
641        inj.inject(&mut grads);
642        let s = inj.stats();
643        assert_eq!(s.total_injections, 1);
644        assert_eq!(s.total_elements, 50);
645        assert!(s.avg_noise_magnitude > 0.0);
646    }
647
648    #[test]
649    fn stats_accumulate_across_injections() {
650        let mut inj = GradientNoiseInjector::new(gaussian_config(1515));
651        let mut g1 = vec![0.0; 100];
652        let mut g2 = vec![0.0; 200];
653        inj.inject(&mut g1);
654        inj.inject(&mut g2);
655        let s = inj.stats();
656        assert_eq!(s.total_injections, 2);
657        assert_eq!(s.total_elements, 300);
658    }
659
660    #[test]
661    fn max_noise_tracked() {
662        let config = GradientNoiseConfig {
663            noise_type: NoiseType::Gaussian,
664            initial_scale: 5.0,
665            decay_rate: 0.0,
666            clip_value: None,
667            seed: 1616,
668        };
669        let mut inj = GradientNoiseInjector::new(config);
670        let mut grads = vec![0.0; 1000];
671        inj.inject(&mut grads);
672        assert!(inj.stats().max_noise_applied > 0.0);
673    }
674
675    // -- inject modifies gradients ------------------------------------------
676
677    #[test]
678    fn inject_modifies_gradients() {
679        let mut inj = GradientNoiseInjector::new(gaussian_config(1717));
680        let original = vec![1.0, 2.0, 3.0, 4.0, 5.0];
681        let mut grads = original.clone();
682        inj.inject(&mut grads);
683        assert_ne!(grads, original, "gradients should be modified by noise");
684    }
685
686    #[test]
687    fn inject_preserves_length() {
688        let mut inj = GradientNoiseInjector::new(gaussian_config(1818));
689        let mut grads = vec![0.5; 37];
690        inj.inject(&mut grads);
691        assert_eq!(grads.len(), 37);
692    }
693
694    // -- Large gradient arrays ----------------------------------------------
695
696    #[test]
697    fn inject_large_array() {
698        let mut inj = GradientNoiseInjector::new(gaussian_config(1919));
699        let mut grads = vec![0.0; 100_000];
700        inj.inject(&mut grads);
701        let nonzero = grads.iter().filter(|v| v.abs() > f64::EPSILON).count();
702        assert!(nonzero > 99_000, "almost all elements should receive noise");
703    }
704
705    // -- Zero-scale produces no noise ---------------------------------------
706
707    #[test]
708    fn zero_scale_produces_no_noise() {
709        let config = GradientNoiseConfig {
710            noise_type: NoiseType::Gaussian,
711            initial_scale: 0.0,
712            decay_rate: 0.0,
713            clip_value: None,
714            seed: 2020,
715        };
716        let mut inj = GradientNoiseInjector::new(config);
717        let mut grads = vec![1.0, 2.0, 3.0];
718        inj.inject(&mut grads);
719        // With scale 0, noise samples are 0 * gaussian = 0.
720        assert!((grads[0] - 1.0).abs() < f64::EPSILON);
721        assert!((grads[1] - 2.0).abs() < f64::EPSILON);
722        assert!((grads[2] - 3.0).abs() < f64::EPSILON);
723    }
724
725    // -- Reset behaviour ----------------------------------------------------
726
727    #[test]
728    fn reset_clears_stats_and_step() {
729        let mut inj = GradientNoiseInjector::new(gaussian_config(2121));
730        let mut grads = vec![0.0; 10];
731        inj.inject(&mut grads);
732        inj.step();
733        inj.step();
734        inj.reset();
735        assert_eq!(inj.step, 0);
736        assert_eq!(inj.stats().total_injections, 0);
737        assert_eq!(inj.stats().total_elements, 0);
738    }
739
740    #[test]
741    fn reset_reproduces_sequence() {
742        let config = gaussian_config(2222);
743        let mut inj = GradientNoiseInjector::new(config);
744        let first = inj.sample_noise(50);
745        inj.reset();
746        let second = inj.sample_noise(50);
747        assert_eq!(first.values, second.values);
748    }
749
750    // -- set_scale ----------------------------------------------------------
751
752    #[test]
753    fn set_scale_changes_output_magnitude() {
754        let mut inj = GradientNoiseInjector::new(gaussian_config(2323));
755        inj.set_scale(10.0);
756        let sample = inj.sample_noise(5000);
757        // std should now be close to 10.
758        assert!(
759            sample.std_dev > 5.0,
760            "std_dev {} should reflect new scale 10",
761            sample.std_dev
762        );
763    }
764
765    // -- NoiseSample statistics ---------------------------------------------
766
767    #[test]
768    fn sample_noise_reports_correct_step() {
769        let mut inj = GradientNoiseInjector::new(gaussian_config(2424));
770        inj.step();
771        inj.step();
772        inj.step();
773        let sample = inj.sample_noise(10);
774        assert_eq!(sample.step, 3);
775    }
776
777    #[test]
778    fn sample_noise_empty_returns_defaults() {
779        let mut inj = GradientNoiseInjector::new(gaussian_config(2525));
780        let sample = inj.sample_noise(0);
781        assert!(sample.values.is_empty());
782        assert!((sample.mean).abs() < f64::EPSILON);
783        assert!((sample.std_dev).abs() < f64::EPSILON);
784    }
785
786    // -- Zero seed fallback -------------------------------------------------
787
788    #[test]
789    fn zero_seed_uses_fallback() {
790        let config = GradientNoiseConfig {
791            noise_type: NoiseType::Gaussian,
792            initial_scale: 0.1,
793            decay_rate: 0.0,
794            clip_value: None,
795            seed: 0,
796        };
797        let mut inj = GradientNoiseInjector::new(config);
798        // Should not panic; the fallback seed is non-zero.
799        let sample = inj.sample_noise(10);
800        assert_eq!(sample.values.len(), 10);
801    }
802
803    // -- current_scale for non-scheduled types ------------------------------
804
805    #[test]
806    fn current_scale_constant_for_non_scheduled() {
807        let mut inj = GradientNoiseInjector::new(gaussian_config(2626));
808        let s0 = inj.current_scale();
809        for _ in 0..100 {
810            inj.step();
811        }
812        let s100 = inj.current_scale();
813        assert!(
814            (s0 - s100).abs() < f64::EPSILON,
815            "non-scheduled scale should not change"
816        );
817    }
818
819    // -- inject empty slice -------------------------------------------------
820
821    #[test]
822    fn inject_empty_slice_no_panic() {
823        let mut inj = GradientNoiseInjector::new(gaussian_config(2727));
824        let mut grads: Vec<f64> = vec![];
825        inj.inject(&mut grads);
826        assert_eq!(inj.stats().total_injections, 1);
827        assert_eq!(inj.stats().total_elements, 0);
828    }
829}