Skip to main content

stats_claw/streaming/
moments.rs

1//! Welford's online mean/variance accumulator.
2
3use super::count_to_f64;
4
5/// Welford's online accumulator for the running mean and variance of a stream.
6///
7/// Maintains the count, running mean, and the sum of squared deviations (`M2`) so
8/// that the mean and the Bessel-corrected sample variance are available after any
9/// number of updates without storing the values themselves. Numerically stable:
10/// it avoids the catastrophic cancellation of the naive "sum of squares minus
11/// square of sum" formula.
12///
13/// # Invariants
14///
15/// The struct holds exactly three scalar fields, so `size_of::<RunningMoments>()`
16/// is constant regardless of stream length — the bounded-memory guarantee.
17///
18/// # Examples
19///
20/// ```
21/// use stats_claw::streaming::RunningMoments;
22///
23/// let mut m = RunningMoments::new();
24/// for x in [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
25///     m.update(x);
26/// }
27/// // Mean of the eight values is 5.0.
28/// assert!((m.mean() - 5.0).abs() < 1e-12);
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct RunningMoments {
32    /// Number of values consumed so far.
33    count: u64,
34    /// Running arithmetic mean of the values consumed so far.
35    mean: f64,
36    /// Running sum of squared deviations from the current mean (Welford's `M2`).
37    m2: f64,
38}
39
40impl RunningMoments {
41    /// Creates an empty accumulator that has consumed no values.
42    ///
43    /// # Returns
44    ///
45    /// A `RunningMoments` with zero count; [`Self::mean`] is `0.0` until the first
46    /// [`Self::update`].
47    #[must_use]
48    pub const fn new() -> Self {
49        Self {
50            count: 0,
51            mean: 0.0,
52            m2: 0.0,
53        }
54    }
55
56    /// Folds one observation into the running summary using Welford's update.
57    ///
58    /// # Arguments
59    ///
60    /// * `x` — the next value of the stream. Any finite `f64`; `NaN`/`±∞`
61    ///   propagate into the running statistics unchanged.
62    pub fn update(&mut self, x: f64) {
63        self.count += 1;
64        let n = count_to_f64(self.count);
65        let delta = x - self.mean;
66        self.mean += delta / n;
67        let delta2 = x - self.mean;
68        self.m2 = delta.mul_add(delta2, self.m2);
69    }
70
71    /// Returns the running arithmetic mean of all values consumed so far.
72    ///
73    /// # Returns
74    ///
75    /// The mean, or `0.0` if no values have been consumed yet.
76    #[must_use]
77    pub const fn mean(&self) -> f64 {
78        self.mean
79    }
80
81    /// Returns the running Bessel-corrected sample variance.
82    ///
83    /// # Returns
84    ///
85    /// The sample variance `M2 / (n - 1)`, or `0.0` when fewer than two values
86    /// have been consumed (variance is undefined for a single observation, and
87    /// `0.0` is the natural, panic-free convention here).
88    #[must_use]
89    pub fn variance(&self) -> f64 {
90        if self.count < 2 {
91            return 0.0;
92        }
93        self.m2 / count_to_f64(self.count - 1)
94    }
95
96    /// Returns the running sample standard deviation (the square root of
97    /// [`Self::variance`]).
98    #[must_use]
99    pub fn std_dev(&self) -> f64 {
100        self.variance().sqrt()
101    }
102
103    /// Returns the number of values consumed so far.
104    #[must_use]
105    pub const fn count(&self) -> u64 {
106        self.count
107    }
108}
109
110impl Default for RunningMoments {
111    /// Returns an empty accumulator, equivalent to [`RunningMoments::new`].
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117/// Kani formal-verification harnesses for Welford's accumulator.
118///
119/// Compiled only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
120/// build/test/clippy. They fold a bounded number of *symbolic finite* updates and
121/// prove the invariants hold for every such stream, not the sampled fixtures a
122/// `#[cfg(test)]` suite would use.
123#[cfg(kani)]
124mod verification {
125    use super::RunningMoments;
126
127    /// Upper bound on `|x|` for the variance proof.
128    ///
129    /// A fully symbolic *finite* `f64` (up to `f64::MAX ≈ 1.8e308`) breaks the
130    /// non-negativity property: `x·x` and the running sums overflow to `±∞`, and
131    /// `∞ − ∞` yields `NaN`, so `variance()` can be `NaN` for extreme-magnitude
132    /// inputs — a genuine limitation, not a spurious solver artifact. Bounding
133    /// `|x| ≤ 1e150` keeps every intermediate (`x·x ≤ 1e300`, and the few-term
134    /// sums) comfortably below `f64::MAX`, isolating the pure sign argument.
135    const MAX_ABS: f64 = 1e150;
136
137    /// Draws a symbolic `f64` constrained to be finite and bounded by [`MAX_ABS`].
138    ///
139    /// Welford's monotone-`M2` argument holds only where the arithmetic stays
140    /// finite; the module contract already documents that non-finite inputs
141    /// propagate into the statistics unchanged, so the proofs scope to the
142    /// non-overflowing finite regime.
143    ///
144    /// # Returns
145    ///
146    /// A finite `f64` with `|x| ≤ MAX_ABS`.
147    fn any_bounded() -> f64 {
148        let x: f64 = kani::any();
149        kani::assume(x.is_finite());
150        kani::assume(x.abs() <= MAX_ABS);
151        x
152    }
153
154    /// Proves that after any three symbolic magnitude-bounded (`|x| ≤ MAX_ABS`)
155    /// updates the accumulator neither panics nor overflows and reports a
156    /// non-negative, non-`NaN` variance. Kani confirms the sign argument survives
157    /// `f64` rounding, not just in exact arithmetic.
158    ///
159    /// The `M2` update adds `delta · delta2`, whose two factors are
160    /// `(x − mean_old)` and `(x − mean_new) = delta · (n−1)/n`; they share a sign,
161    /// so the exact product is `≥ 0` and the fused multiply-add of two non-negative
162    /// reals rounds to a non-negative `f64`. Hence `M2 ≥ 0`, and the Bessel divisor
163    /// `n − 1 > 0` once `count ≥ 2`, so `variance() ≥ 0`. (`assert!(v >= 0.0)` also
164    /// rejects `NaN`, which is never `≥ 0`.) Three updates suffice to exercise the
165    /// `count ≥ 2` variance path; the loop-free unrolling needs no unwind bound.
166    #[kani::proof]
167    fn moments_variance_non_negative() {
168        let mut m = RunningMoments::new();
169        m.update(any_bounded());
170        m.update(any_bounded());
171        m.update(any_bounded());
172        let v = m.variance();
173        assert!(v >= 0.0, "variance was negative or NaN: {v}");
174        assert_eq!(m.count(), 3, "count diverged from the number of updates");
175    }
176
177    /// Proves a single symbolic finite update is panic-/overflow-free and that the
178    /// one-observation variance convention (`0.0`, undefined for `n < 2`) holds
179    /// exactly.
180    #[kani::proof]
181    fn moments_single_update_variance_zero() {
182        let mut m = RunningMoments::new();
183        m.update(any_bounded());
184        let v = m.variance();
185        assert!(
186            v == 0.0,
187            "single-sample variance must be exactly 0.0, was {v}"
188        );
189    }
190}