Skip to main content

rlox_core/training/
normalization.rs

1/// Online running statistics using Welford's algorithm.
2///
3/// Computes running mean, population variance, and standard deviation
4/// in a numerically stable, single-pass manner.
5#[derive(Debug, Clone)]
6pub struct RunningStats {
7    count: u64,
8    mean: f64,
9    m2: f64,
10}
11
12impl RunningStats {
13    /// Create a new empty statistics accumulator.
14    pub fn new() -> Self {
15        Self {
16            count: 0,
17            mean: 0.0,
18            m2: 0.0,
19        }
20    }
21
22    /// Update with a single observation (Welford's online algorithm).
23    pub fn update(&mut self, value: f64) {
24        self.count += 1;
25        let delta = value - self.mean;
26        self.mean += delta / self.count as f64;
27        let delta2 = value - self.mean;
28        self.m2 += delta * delta2;
29    }
30
31    /// Update with a batch of observations.
32    pub fn batch_update(&mut self, values: &[f64]) {
33        for &v in values {
34            self.update(v);
35        }
36    }
37
38    /// Current running mean.
39    pub fn mean(&self) -> f64 {
40        self.mean
41    }
42
43    /// Population variance (divide by n).
44    pub fn var(&self) -> f64 {
45        if self.count < 1 {
46            return 0.0;
47        }
48        self.m2 / self.count as f64
49    }
50
51    /// Population standard deviation.
52    pub fn std(&self) -> f64 {
53        self.var().sqrt()
54    }
55
56    /// Normalize a value to a z-score using current mean and std.
57    /// Returns 0.0 if std is near zero to avoid division by zero.
58    pub fn normalize(&self, value: f64) -> f64 {
59        let s = self.std();
60        if s < 1e-8 {
61            return 0.0;
62        }
63        (value - self.mean) / s
64    }
65
66    /// Number of observations seen.
67    pub fn count(&self) -> u64 {
68        self.count
69    }
70
71    /// Reset all accumulated statistics.
72    pub fn reset(&mut self) {
73        self.count = 0;
74        self.mean = 0.0;
75        self.m2 = 0.0;
76    }
77}
78
79impl Default for RunningStats {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85/// Per-dimension online running statistics using Welford's algorithm.
86///
87/// Maintains independent mean/variance accumulators for each dimension,
88/// enabling proper per-feature observation normalization as in SB3's
89/// `RunningMeanStd`.
90#[derive(Debug, Clone)]
91pub struct RunningStatsVec {
92    dim: usize,
93    count: u64,
94    mean: Vec<f64>,
95    m2: Vec<f64>,
96}
97
98impl RunningStatsVec {
99    /// Create a new accumulator for vectors of the given dimensionality.
100    pub fn new(dim: usize) -> Self {
101        Self {
102            dim,
103            count: 0,
104            mean: vec![0.0; dim],
105            m2: vec![0.0; dim],
106        }
107    }
108
109    /// Update with a single sample of length `dim` (Welford per dimension).
110    ///
111    /// # Panics
112    ///
113    /// Panics if `values.len() != dim`.
114    #[inline]
115    pub fn update(&mut self, values: &[f64]) {
116        assert_eq!(
117            values.len(),
118            self.dim,
119            "expected {} dimensions, got {}",
120            self.dim,
121            values.len()
122        );
123        self.count += 1;
124        let n = self.count as f64;
125        for (i, &val) in values.iter().enumerate().take(self.dim) {
126            let delta = val - self.mean[i];
127            self.mean[i] += delta / n;
128            let delta2 = val - self.mean[i];
129            self.m2[i] += delta * delta2;
130        }
131    }
132
133    /// Update with a flat batch of `batch_size` samples, each of `dim` dimensions.
134    ///
135    /// `data` must have length `batch_size * dim`, laid out as
136    /// `[sample0_dim0, sample0_dim1, ..., sample1_dim0, ...]`.
137    ///
138    /// # Panics
139    ///
140    /// Panics if `data.len() != batch_size * dim`.
141    #[inline]
142    pub fn batch_update(&mut self, data: &[f64], batch_size: usize) {
143        assert_eq!(
144            data.len(),
145            batch_size * self.dim,
146            "expected {} elements (batch_size={} * dim={}), got {}",
147            batch_size * self.dim,
148            batch_size,
149            self.dim,
150            data.len()
151        );
152        for sample in data.chunks_exact(self.dim) {
153            self.update(sample);
154        }
155    }
156
157    /// Return the current per-dimension mean vector (clone).
158    #[inline]
159    pub fn mean(&self) -> Vec<f64> {
160        self.mean.clone()
161    }
162
163    /// Borrow the per-dimension mean as a slice (zero-cost).
164    #[inline]
165    pub fn mean_ref(&self) -> &[f64] {
166        &self.mean
167    }
168
169    /// Return the per-dimension population variance vector.
170    #[inline]
171    pub fn var(&self) -> Vec<f64> {
172        if self.count < 1 {
173            return vec![0.0; self.dim];
174        }
175        let n = self.count as f64;
176        self.m2.iter().map(|&m| m / n).collect()
177    }
178
179    /// Return the per-dimension population standard deviation vector.
180    #[inline]
181    pub fn std(&self) -> Vec<f64> {
182        self.var().iter().map(|&v| v.sqrt()).collect()
183    }
184
185    /// Normalize a single sample: `(values - mean) / max(std, 1e-8)` per dimension.
186    ///
187    /// # Panics
188    ///
189    /// Panics if `values.len() != dim`.
190    #[inline]
191    pub fn normalize(&self, values: &[f64]) -> Vec<f64> {
192        assert_eq!(
193            values.len(),
194            self.dim,
195            "expected {} dimensions, got {}",
196            self.dim,
197            values.len()
198        );
199        let std = self.std();
200        values
201            .iter()
202            .zip(self.mean.iter())
203            .zip(std.iter())
204            .map(|((&v, &m), &s)| (v - m) / s.max(1e-8))
205            .collect()
206    }
207
208    /// Normalize a flat batch of `batch_size` samples.
209    ///
210    /// # Panics
211    ///
212    /// Panics if `data.len() != batch_size * dim`.
213    #[inline]
214    pub fn normalize_batch(&self, data: &[f64], batch_size: usize) -> Vec<f64> {
215        assert_eq!(
216            data.len(),
217            batch_size * self.dim,
218            "expected {} elements (batch_size={} * dim={}), got {}",
219            batch_size * self.dim,
220            batch_size,
221            self.dim,
222            data.len()
223        );
224        let std = self.std();
225        let mut out = Vec::with_capacity(data.len());
226        for sample in data.chunks_exact(self.dim) {
227            for i in 0..self.dim {
228                out.push((sample[i] - self.mean[i]) / std[i].max(1e-8));
229            }
230        }
231        out
232    }
233
234    /// Number of samples seen so far.
235    #[inline]
236    pub fn count(&self) -> u64 {
237        self.count
238    }
239
240    /// Dimensionality of the tracked vectors.
241    #[inline]
242    pub fn dim(&self) -> usize {
243        self.dim
244    }
245
246    /// Reset all accumulated statistics, keeping the dimensionality.
247    pub fn reset(&mut self) {
248        self.count = 0;
249        self.mean.fill(0.0);
250        self.m2.fill(0.0);
251    }
252}
253
254/// Exponential Moving Average (EMA) running statistics for non-stationary signals.
255///
256/// Unlike Welford's algorithm which weights all observations equally,
257/// EMA statistics give exponentially more weight to recent observations.
258/// This makes them suitable for tracking non-stationary distributions
259/// where the underlying mean/variance drift over time.
260///
261/// Update rules:
262///   mean_t = (1 - alpha) * mean_{t-1} + alpha * x_t
263///   var_t  = (1 - alpha) * var_{t-1} + alpha * (x_t - mean_t)^2
264///
265/// The smoothing factor `alpha` controls the effective window:
266///   - alpha = 2/(N+1) gives an N-step equivalent window
267///   - Higher alpha = more responsive to change, noisier estimates
268///   - Lower alpha = smoother estimates, slower to adapt
269#[derive(Debug, Clone)]
270pub struct ExponentialRunningStats {
271    alpha: f64,
272    mean: f64,
273    var: f64,
274    count: u64,
275    initialized: bool,
276}
277
278impl ExponentialRunningStats {
279    /// Create a new EMA statistics tracker.
280    ///
281    /// # Parameters
282    /// - `alpha`: Smoothing factor in (0, 1). Use `from_halflife` or
283    ///   `from_window` for more intuitive parameterization.
284    ///
285    /// # Panics
286    /// Panics if alpha is not in (0, 1).
287    pub fn new(alpha: f64) -> Self {
288        assert!(
289            alpha > 0.0 && alpha < 1.0,
290            "alpha must be in (0, 1), got {alpha}"
291        );
292        Self {
293            alpha,
294            mean: 0.0,
295            var: 0.0,
296            count: 0,
297            initialized: false,
298        }
299    }
300
301    /// Create from an equivalent window size N: alpha = 2 / (N + 1).
302    pub fn from_window(window: usize) -> Self {
303        assert!(window >= 1, "window must be >= 1, got {window}");
304        Self::new(2.0 / (window as f64 + 1.0))
305    }
306
307    /// Create from a half-life (number of steps for weight to decay by 50%).
308    pub fn from_halflife(halflife: f64) -> Self {
309        assert!(halflife > 0.0, "halflife must be > 0, got {halflife}");
310        Self::new(1.0 - (0.5_f64).powf(1.0 / halflife))
311    }
312
313    /// Update with a single observation.
314    pub fn update(&mut self, value: f64) {
315        self.count += 1;
316        if !self.initialized {
317            self.mean = value;
318            self.var = 0.0;
319            self.initialized = true;
320            return;
321        }
322        let delta = value - self.mean;
323        self.mean += self.alpha * delta;
324        // Biased EMA variance estimate
325        self.var = (1.0 - self.alpha) * (self.var + self.alpha * delta * delta);
326    }
327
328    /// Update with a batch of observations (applied sequentially).
329    pub fn batch_update(&mut self, values: &[f64]) {
330        for &v in values {
331            self.update(v);
332        }
333    }
334
335    /// Current EMA mean.
336    pub fn mean(&self) -> f64 {
337        self.mean
338    }
339
340    /// Current EMA variance.
341    pub fn var(&self) -> f64 {
342        self.var
343    }
344
345    /// Current EMA standard deviation.
346    pub fn std(&self) -> f64 {
347        self.var.sqrt()
348    }
349
350    /// Normalize a value using current EMA mean and std.
351    pub fn normalize(&self, value: f64) -> f64 {
352        let s = self.std();
353        if s < 1e-8 {
354            return 0.0;
355        }
356        (value - self.mean) / s
357    }
358
359    /// Number of observations seen.
360    pub fn count(&self) -> u64 {
361        self.count
362    }
363
364    /// The smoothing factor.
365    pub fn alpha(&self) -> f64 {
366        self.alpha
367    }
368
369    /// Reset to initial state.
370    pub fn reset(&mut self) {
371        self.mean = 0.0;
372        self.var = 0.0;
373        self.count = 0;
374        self.initialized = false;
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn running_stats_new_is_empty() {
384        let stats = RunningStats::new();
385        assert_eq!(stats.count(), 0);
386    }
387
388    #[test]
389    fn running_stats_single_sample() {
390        let mut stats = RunningStats::new();
391        stats.update(5.0);
392        assert!((stats.mean() - 5.0).abs() < 1e-10);
393        assert_eq!(stats.count(), 1);
394        let _ = stats.var();
395        let _ = stats.std();
396    }
397
398    #[test]
399    fn running_stats_welford_known_values() {
400        let mut stats = RunningStats::new();
401        for &x in &[2.0_f64, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
402            stats.update(x);
403        }
404        assert!(
405            (stats.mean() - 5.0).abs() < 1e-10,
406            "mean should be 5.0, got {}",
407            stats.mean()
408        );
409        assert!(
410            (stats.var() - 4.0).abs() < 1e-10,
411            "variance should be 4.0, got {}",
412            stats.var()
413        );
414        assert!(
415            (stats.std() - 2.0).abs() < 1e-10,
416            "std should be 2.0, got {}",
417            stats.std()
418        );
419    }
420
421    #[test]
422    fn running_stats_normalize_produces_z_score() {
423        let mut stats = RunningStats::new();
424        for &x in &[2.0_f64, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
425            stats.update(x);
426        }
427        let z = stats.normalize(5.0);
428        assert!(z.abs() < 1e-10, "normalize(mean) should be ~0, got {z}");
429        let z2 = stats.normalize(7.0);
430        assert!(
431            (z2 - 1.0).abs() < 1e-10,
432            "normalize(mean+std) should be ~1, got {z2}"
433        );
434    }
435
436    #[test]
437    fn running_stats_normalize_with_zero_std_does_not_panic() {
438        let mut stats = RunningStats::new();
439        stats.update(5.0);
440        stats.update(5.0);
441        stats.update(5.0);
442        let z = stats.normalize(5.0);
443        assert!(z.is_finite(), "normalize with zero std must be finite");
444    }
445
446    #[test]
447    fn running_stats_large_stream_numerically_stable() {
448        let mut stats = RunningStats::new();
449        let base = 1_000_000.0f64;
450        for i in 0..10_000 {
451            stats.update(base + (i as f64) * 0.001);
452        }
453        let expected_mean = base + 5.0 - 0.001 / 2.0;
454        assert!(
455            (stats.mean() - expected_mean).abs() < 0.01,
456            "mean imprecise for large offset: got {}, expected ~{expected_mean}",
457            stats.mean()
458        );
459    }
460
461    #[test]
462    fn running_stats_reset_clears_state() {
463        let mut stats = RunningStats::new();
464        for &x in &[1.0f64, 2.0, 3.0] {
465            stats.update(x);
466        }
467        stats.reset();
468        assert_eq!(stats.count(), 0);
469    }
470
471    #[test]
472    fn running_stats_nan_input_does_not_silently_corrupt() {
473        let mut stats = RunningStats::new();
474        stats.update(1.0);
475        stats.update(2.0);
476        let mean_before = stats.mean();
477        stats.update(f64::NAN);
478        let mean_after = stats.mean();
479        if mean_after.is_finite() {
480            assert!(
481                (mean_after - mean_before).abs() < 1e-10 || mean_after.is_nan(),
482                "NaN input corrupted finite mean: was {mean_before}, now {mean_after}"
483            );
484        }
485    }
486
487    #[test]
488    fn running_stats_batch_update() {
489        let mut stats = RunningStats::new();
490        stats.batch_update(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]);
491        assert!((stats.mean() - 5.0).abs() < 1e-10);
492        assert_eq!(stats.count(), 8);
493    }
494
495    // -----------------------------------------------------------------------
496    // RunningStatsVec tests
497    // -----------------------------------------------------------------------
498
499    #[test]
500    fn stats_vec_new_is_empty() {
501        let stats = RunningStatsVec::new(3);
502        assert_eq!(stats.count(), 0);
503        assert_eq!(stats.dim(), 3);
504        assert_eq!(stats.mean(), vec![0.0; 3]);
505        assert_eq!(stats.var(), vec![0.0; 3]);
506    }
507
508    #[test]
509    fn stats_vec_single_sample() {
510        let mut stats = RunningStatsVec::new(3);
511        stats.update(&[1.0, 2.0, 3.0]);
512        assert_eq!(stats.count(), 1);
513        assert_eq!(stats.mean(), vec![1.0, 2.0, 3.0]);
514        // Variance of a single sample is 0
515        assert_eq!(stats.var(), vec![0.0, 0.0, 0.0]);
516    }
517
518    #[test]
519    fn stats_vec_known_values_per_dim() {
520        // Dim 0: [2, 4, 4, 4, 5, 5, 7, 9] -> mean=5.0, var=4.0
521        // Dim 1: [1, 1, 1, 1, 1, 1, 1, 1] -> mean=1.0, var=0.0
522        let mut stats = RunningStatsVec::new(2);
523        let samples: &[&[f64]] = &[
524            &[2.0, 1.0],
525            &[4.0, 1.0],
526            &[4.0, 1.0],
527            &[4.0, 1.0],
528            &[5.0, 1.0],
529            &[5.0, 1.0],
530            &[7.0, 1.0],
531            &[9.0, 1.0],
532        ];
533        for s in samples {
534            stats.update(s);
535        }
536        assert_eq!(stats.count(), 8);
537        let mean = stats.mean();
538        assert!((mean[0] - 5.0).abs() < 1e-10, "dim0 mean: {}", mean[0]);
539        assert!((mean[1] - 1.0).abs() < 1e-10, "dim1 mean: {}", mean[1]);
540        let var = stats.var();
541        assert!((var[0] - 4.0).abs() < 1e-10, "dim0 var: {}", var[0]);
542        assert!(var[1].abs() < 1e-10, "dim1 var: {}", var[1]);
543        let std = stats.std();
544        assert!((std[0] - 2.0).abs() < 1e-10, "dim0 std: {}", std[0]);
545        assert!(std[1].abs() < 1e-10, "dim1 std: {}", std[1]);
546    }
547
548    #[test]
549    fn stats_vec_batch_update_matches_sequential() {
550        let mut seq = RunningStatsVec::new(3);
551        let mut batch = RunningStatsVec::new(3);
552
553        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
554        for sample in data.chunks(3) {
555            seq.update(sample);
556        }
557        batch.batch_update(&data, 3);
558
559        assert_eq!(seq.count(), batch.count());
560        for i in 0..3 {
561            assert!(
562                (seq.mean()[i] - batch.mean()[i]).abs() < 1e-10,
563                "dim {i} mean mismatch"
564            );
565            assert!(
566                (seq.var()[i] - batch.var()[i]).abs() < 1e-10,
567                "dim {i} var mismatch"
568            );
569        }
570    }
571
572    #[test]
573    fn stats_vec_normalize_produces_z_scores() {
574        let mut stats = RunningStatsVec::new(2);
575        // Dim 0: [2, 4, 4, 4, 5, 5, 7, 9] -> mean=5, std=2
576        // Dim 1: [10, 20, 30, 40, 50, 60, 70, 80] -> mean=45, std=~22.36
577        let dim0 = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
578        let dim1 = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
579        for i in 0..8 {
580            stats.update(&[dim0[i], dim1[i]]);
581        }
582
583        // Normalizing the mean should give ~0
584        let z = stats.normalize(&[5.0, 45.0]);
585        assert!(z[0].abs() < 1e-10, "z[0] should be ~0, got {}", z[0]);
586        assert!(z[1].abs() < 1e-10, "z[1] should be ~0, got {}", z[1]);
587
588        // Normalizing mean+std should give ~1
589        let std = stats.std();
590        let z2 = stats.normalize(&[5.0 + std[0], 45.0 + std[1]]);
591        assert!(
592            (z2[0] - 1.0).abs() < 1e-10,
593            "z2[0] should be ~1, got {}",
594            z2[0]
595        );
596        assert!(
597            (z2[1] - 1.0).abs() < 1e-10,
598            "z2[1] should be ~1, got {}",
599            z2[1]
600        );
601    }
602
603    #[test]
604    fn stats_vec_normalize_with_zero_std_clamps() {
605        let mut stats = RunningStatsVec::new(2);
606        stats.update(&[5.0, 3.0]);
607        stats.update(&[5.0, 3.0]);
608        // Both dims have zero variance
609        let z = stats.normalize(&[6.0, 4.0]);
610        assert!(z[0].is_finite(), "dim0 normalize must be finite");
611        assert!(z[1].is_finite(), "dim1 normalize must be finite");
612        // (6 - 5) / max(0, 1e-8) = 1e8
613        assert!((z[0] - 1e8).abs() < 1.0, "dim0: {}", z[0]);
614    }
615
616    #[test]
617    fn stats_vec_normalize_batch() {
618        let mut stats = RunningStatsVec::new(2);
619        stats.update(&[0.0, 0.0]);
620        stats.update(&[10.0, 20.0]);
621        // mean=[5, 10], var=[25, 100], std=[5, 10]
622
623        let data = [5.0, 10.0, 10.0, 20.0]; // 2 samples
624        let out = stats.normalize_batch(&data, 2);
625        assert!(out[0].abs() < 1e-10, "sample0 dim0 should be 0");
626        assert!(out[1].abs() < 1e-10, "sample0 dim1 should be 0");
627        assert!((out[2] - 1.0).abs() < 1e-10, "sample1 dim0 should be 1");
628        assert!((out[3] - 1.0).abs() < 1e-10, "sample1 dim1 should be 1");
629    }
630
631    #[test]
632    fn stats_vec_reset_clears_state() {
633        let mut stats = RunningStatsVec::new(2);
634        stats.update(&[1.0, 2.0]);
635        stats.update(&[3.0, 4.0]);
636        stats.reset();
637        assert_eq!(stats.count(), 0);
638        assert_eq!(stats.dim(), 2);
639        assert_eq!(stats.mean(), vec![0.0, 0.0]);
640    }
641
642    #[test]
643    #[should_panic(expected = "expected 3 dimensions, got 2")]
644    fn stats_vec_update_wrong_dim_panics() {
645        let mut stats = RunningStatsVec::new(3);
646        stats.update(&[1.0, 2.0]);
647    }
648
649    #[test]
650    #[should_panic(expected = "expected 6 elements")]
651    fn stats_vec_batch_update_wrong_len_panics() {
652        let mut stats = RunningStatsVec::new(3);
653        stats.batch_update(&[1.0, 2.0, 3.0, 4.0], 2);
654    }
655
656    #[test]
657    #[should_panic(expected = "expected 2 dimensions, got 3")]
658    fn stats_vec_normalize_wrong_dim_panics() {
659        let stats = RunningStatsVec::new(2);
660        stats.normalize(&[1.0, 2.0, 3.0]);
661    }
662
663    #[test]
664    fn stats_vec_large_stream_numerically_stable() {
665        let mut stats = RunningStatsVec::new(2);
666        let base = 1_000_000.0f64;
667        for i in 0..10_000 {
668            let v = i as f64 * 0.001;
669            stats.update(&[base + v, -base - v]);
670        }
671        let expected_mean = base + 5.0 - 0.001 / 2.0;
672        let mean = stats.mean();
673        assert!(
674            (mean[0] - expected_mean).abs() < 0.01,
675            "dim0 mean imprecise: got {}, expected ~{expected_mean}",
676            mean[0]
677        );
678        assert!(
679            (mean[1] + expected_mean).abs() < 0.01,
680            "dim1 mean imprecise: got {}, expected ~{}",
681            mean[1],
682            -expected_mean
683        );
684    }
685
686    #[test]
687    fn stats_vec_hopper_like_multi_scale() {
688        // Simulate Hopper-like observations: dim0 in [-1,1], dim1 in [-10,10]
689        let mut stats = RunningStatsVec::new(2);
690        for i in 0..1000 {
691            let t = i as f64 / 999.0;
692            let pos = -1.0 + 2.0 * t; // [-1, 1]
693            let vel = -10.0 + 20.0 * t; // [-10, 10]
694            stats.update(&[pos, vel]);
695        }
696        let std = stats.std();
697        // The stds should reflect the different scales
698        assert!(
699            std[1] > std[0] * 5.0,
700            "velocity std ({}) should be much larger than position std ({})",
701            std[1],
702            std[0]
703        );
704        // Normalizing should bring both dims to similar scale
705        let z = stats.normalize(&[0.5, 5.0]);
706        assert!(
707            z[0].abs() < 5.0 && z[1].abs() < 5.0,
708            "normalized values should be moderate z-scores, got {:?}",
709            z
710        );
711    }
712
713    // -----------------------------------------------------------------------
714    // ExponentialRunningStats tests
715    // -----------------------------------------------------------------------
716
717    #[test]
718    fn ema_new_basic() {
719        let ema = ExponentialRunningStats::new(0.1);
720        assert_eq!(ema.count(), 0);
721        assert!((ema.alpha() - 0.1).abs() < 1e-10);
722    }
723
724    #[test]
725    fn ema_from_window() {
726        let ema = ExponentialRunningStats::from_window(19);
727        // alpha = 2/(19+1) = 0.1
728        assert!((ema.alpha() - 0.1).abs() < 1e-10);
729    }
730
731    #[test]
732    fn ema_from_halflife() {
733        let ema = ExponentialRunningStats::from_halflife(10.0);
734        // After 10 steps, weight should be ~0.5
735        assert!(ema.alpha() > 0.0 && ema.alpha() < 1.0);
736    }
737
738    #[test]
739    fn ema_first_update_sets_mean() {
740        let mut ema = ExponentialRunningStats::new(0.1);
741        ema.update(5.0);
742        assert!((ema.mean() - 5.0).abs() < 1e-10);
743        assert_eq!(ema.count(), 1);
744    }
745
746    #[test]
747    fn ema_tracks_constant_signal() {
748        let mut ema = ExponentialRunningStats::new(0.1);
749        for _ in 0..100 {
750            ema.update(3.0);
751        }
752        assert!((ema.mean() - 3.0).abs() < 1e-8);
753        assert!(ema.var() < 1e-8);
754    }
755
756    #[test]
757    fn ema_adapts_to_level_shift() {
758        let mut ema = ExponentialRunningStats::new(0.1);
759        // Burn in at level 0
760        for _ in 0..50 {
761            ema.update(0.0);
762        }
763        assert!(ema.mean().abs() < 0.01);
764        // Shift to level 10
765        for _ in 0..100 {
766            ema.update(10.0);
767        }
768        // Should have adapted close to 10
769        assert!((ema.mean() - 10.0).abs() < 0.1);
770    }
771
772    #[test]
773    fn ema_higher_alpha_adapts_faster() {
774        let mut fast = ExponentialRunningStats::new(0.5);
775        let mut slow = ExponentialRunningStats::new(0.01);
776        for _ in 0..20 {
777            fast.update(0.0);
778            slow.update(0.0);
779        }
780        for _ in 0..10 {
781            fast.update(10.0);
782            slow.update(10.0);
783        }
784        // Fast should be closer to 10 than slow
785        assert!(
786            (fast.mean() - 10.0).abs() < (slow.mean() - 10.0).abs(),
787            "fast={}, slow={}",
788            fast.mean(),
789            slow.mean()
790        );
791    }
792
793    #[test]
794    fn ema_normalize_zero_for_mean() {
795        let mut ema = ExponentialRunningStats::new(0.1);
796        for x in 0..100 {
797            ema.update(x as f64);
798        }
799        // Normalize the current mean should be ~0
800        let z = ema.normalize(ema.mean());
801        assert!(z.abs() < 1e-8);
802    }
803
804    #[test]
805    fn ema_reset_clears_state() {
806        let mut ema = ExponentialRunningStats::new(0.1);
807        ema.update(5.0);
808        ema.update(10.0);
809        ema.reset();
810        assert_eq!(ema.count(), 0);
811        assert!((ema.mean()).abs() < 1e-10);
812    }
813
814    #[test]
815    #[should_panic(expected = "alpha must be in (0, 1)")]
816    fn ema_invalid_alpha_panics() {
817        ExponentialRunningStats::new(0.0);
818    }
819
820    #[test]
821    #[should_panic(expected = "alpha must be in (0, 1)")]
822    fn ema_alpha_one_panics() {
823        ExponentialRunningStats::new(1.0);
824    }
825
826    mod proptests {
827        use super::*;
828        use proptest::prelude::*;
829
830        proptest! {
831            #[test]
832            fn running_stats_mean_matches_batch_mean(
833                values in proptest::collection::vec(-1000.0f64..1000.0, 2..200)
834            ) {
835                let mut stats = RunningStats::new();
836                for &v in &values {
837                    stats.update(v);
838                }
839                let batch_mean = values.iter().sum::<f64>() / values.len() as f64;
840                prop_assert!(
841                    (stats.mean() - batch_mean).abs() < 1e-8,
842                    "running mean {:.10} != batch mean {:.10}",
843                    stats.mean(), batch_mean
844                );
845            }
846
847            #[test]
848            fn running_stats_variance_non_negative(
849                values in proptest::collection::vec(-1000.0f64..1000.0, 2..200)
850            ) {
851                let mut stats = RunningStats::new();
852                for &v in &values {
853                    stats.update(v);
854                }
855                prop_assert!(stats.var() >= 0.0, "variance must be non-negative");
856                prop_assert!(stats.std() >= 0.0, "std must be non-negative");
857            }
858
859            #[test]
860            fn running_stats_std_equals_sqrt_var(
861                values in proptest::collection::vec(-100.0f64..100.0, 2..100)
862            ) {
863                let mut stats = RunningStats::new();
864                for &v in &values {
865                    stats.update(v);
866                }
867                let computed_std = stats.var().sqrt();
868                prop_assert!(
869                    (stats.std() - computed_std).abs() < 1e-10,
870                    "std {} != sqrt(var) {}",
871                    stats.std(), computed_std
872                );
873            }
874
875            #[test]
876            fn running_stats_count_matches_updates(
877                values in proptest::collection::vec(-100.0f64..100.0, 0..200)
878            ) {
879                let mut stats = RunningStats::new();
880                for &v in &values {
881                    stats.update(v);
882                }
883                prop_assert_eq!(stats.count() as usize, values.len());
884            }
885
886            #[test]
887            fn stats_vec_per_dim_mean_matches_naive(
888                dim in 1usize..8,
889                n_samples in 2usize..50,
890            ) {
891                // Generate deterministic data from dim and n_samples
892                let mut data = Vec::with_capacity(n_samples * dim);
893                for s in 0..n_samples {
894                    for d in 0..dim {
895                        data.push((s as f64) * 0.1 + (d as f64) * 10.0);
896                    }
897                }
898
899                let mut stats = RunningStatsVec::new(dim);
900                stats.batch_update(&data, n_samples);
901
902                // Compute naive per-dim mean
903                for d in 0..dim {
904                    let sum: f64 = (0..n_samples).map(|s| data[s * dim + d]).sum();
905                    let naive_mean = sum / n_samples as f64;
906                    prop_assert!(
907                        (stats.mean()[d] - naive_mean).abs() < 1e-8,
908                        "dim {d}: running mean {} != naive mean {}",
909                        stats.mean()[d], naive_mean
910                    );
911                }
912            }
913
914            #[test]
915            fn stats_vec_variance_non_negative(
916                dim in 1usize..6,
917                n_samples in 2usize..50,
918            ) {
919                let mut data = Vec::with_capacity(n_samples * dim);
920                for s in 0..n_samples {
921                    for d in 0..dim {
922                        data.push((s as f64) * 0.7 - (d as f64) * 3.0);
923                    }
924                }
925
926                let mut stats = RunningStatsVec::new(dim);
927                stats.batch_update(&data, n_samples);
928
929                for d in 0..dim {
930                    prop_assert!(
931                        stats.var()[d] >= 0.0,
932                        "dim {d} variance must be non-negative, got {}",
933                        stats.var()[d]
934                    );
935                }
936            }
937
938            #[test]
939            fn stats_vec_normalize_roundtrip_z_mean_zero(
940                dim in 1usize..6,
941                n_samples in 5usize..50,
942            ) {
943                let mut data = Vec::with_capacity(n_samples * dim);
944                for s in 0..n_samples {
945                    for d in 0..dim {
946                        data.push((s as f64) * 1.3 + (d as f64) * 7.0);
947                    }
948                }
949
950                let mut stats = RunningStatsVec::new(dim);
951                stats.batch_update(&data, n_samples);
952
953                // Normalizing the mean vector should give zeros
954                let z = stats.normalize(&stats.mean());
955                for (d, &val) in z.iter().enumerate().take(dim) {
956                    prop_assert!(
957                        val.abs() < 1e-8,
958                        "normalize(mean)[{d}] should be ~0, got {}",
959                        val
960                    );
961                }
962            }
963        }
964    }
965}