Skip to main content

u_numflow/
stats.rs

1//! Descriptive statistics with numerical stability guarantees.
2//!
3//! All functions in this module handle edge cases explicitly and use
4//! numerically stable algorithms to avoid catastrophic cancellation.
5//!
6//! # Algorithms
7//!
8//! - **Mean**: Kahan compensated summation for O(ε) error independent of n.
9//! - **Variance/StdDev**: Welford's online algorithm.
10//!   Reference: Welford (1962), "Note on a Method for Calculating
11//!   Corrected Sums of Squares and Products", *Technometrics* 4(3).
12//! - **Quantile**: R-7 linear interpolation (default in R, Python, Excel).
13//!   Reference: Hyndman & Fan (1996), "Sample Quantiles in Statistical
14//!   Packages", *The American Statistician* 50(4).
15
16/// Computes the arithmetic mean using Kahan compensated summation.
17///
18/// # Algorithm
19/// Kahan summation accumulates a compensation term to recover lost
20/// low-order bits, achieving O(ε) total error independent of `n`.
21///
22/// # Complexity
23/// Time: O(n), Space: O(1)
24///
25/// # Returns
26/// - `None` if `data` is empty or contains any NaN/Inf.
27///
28/// # Examples
29/// ```
30/// use u_numflow::stats::mean;
31/// let v = [1.0, 2.0, 3.0, 4.0, 5.0];
32/// assert!((mean(&v).unwrap() - 3.0).abs() < 1e-15);
33/// ```
34pub fn mean(data: &[f64]) -> Option<f64> {
35    if data.is_empty() {
36        return None;
37    }
38    if !data.iter().all(|x| x.is_finite()) {
39        return None;
40    }
41    Some(kahan_sum(data) / data.len() as f64)
42}
43
44/// Computes the sample variance using Welford's online algorithm.
45///
46/// Returns the **sample** (unbiased) variance with Bessel's correction
47/// (denominator `n − 1`).
48///
49/// # Algorithm
50/// Welford's method maintains a running mean and sum of squared deviations,
51/// avoiding catastrophic cancellation inherent in the naive formula
52/// `Var = E[X²] − (E[X])²`.
53///
54/// Reference: Welford (1962), *Technometrics* 4(3), pp. 419–420.
55///
56/// # Complexity
57/// Time: O(n), Space: O(1)
58///
59/// # Returns
60/// - `None` if `data.len() < 2` or contains NaN/Inf.
61///
62/// # Examples
63/// ```
64/// use u_numflow::stats::variance;
65/// let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
66/// assert!((variance(&v).unwrap() - 4.571428571428571).abs() < 1e-10);
67/// ```
68pub fn variance(data: &[f64]) -> Option<f64> {
69    if data.len() < 2 {
70        return None;
71    }
72    if !data.iter().all(|x| x.is_finite()) {
73        return None;
74    }
75    let mut acc = WelfordAccumulator::new();
76    for &x in data {
77        acc.update(x);
78    }
79    acc.sample_variance()
80}
81
82/// Computes the population variance using Welford's online algorithm.
83///
84/// Returns the **population** variance (denominator `n`).
85///
86/// # Returns
87/// - `None` if `data` is empty or contains NaN/Inf.
88///
89/// # Examples
90/// ```
91/// use u_numflow::stats::population_variance;
92/// let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
93/// assert!((population_variance(&v).unwrap() - 4.0).abs() < 1e-10);
94/// ```
95pub fn population_variance(data: &[f64]) -> Option<f64> {
96    if data.is_empty() {
97        return None;
98    }
99    if !data.iter().all(|x| x.is_finite()) {
100        return None;
101    }
102    let mut acc = WelfordAccumulator::new();
103    for &x in data {
104        acc.update(x);
105    }
106    acc.population_variance()
107}
108
109/// Computes the sample standard deviation.
110///
111/// Equivalent to `sqrt(variance(data))`.
112///
113/// # Returns
114/// - `None` if `data.len() < 2` or contains NaN/Inf.
115///
116/// # Examples
117/// ```
118/// use u_numflow::stats::std_dev;
119/// let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
120/// let sd = std_dev(&v).unwrap();
121/// assert!((sd - 2.138089935299395).abs() < 1e-10);
122/// ```
123pub fn std_dev(data: &[f64]) -> Option<f64> {
124    variance(data).map(f64::sqrt)
125}
126
127/// Computes the population standard deviation.
128///
129/// Equivalent to `sqrt(population_variance(data))`.
130///
131/// # Returns
132/// - `None` if `data` is empty or contains NaN/Inf.
133pub fn population_std_dev(data: &[f64]) -> Option<f64> {
134    population_variance(data).map(f64::sqrt)
135}
136
137/// Returns the minimum value in the slice.
138///
139/// # Returns
140/// - `None` if `data` is empty or contains NaN.
141///
142/// # Examples
143/// ```
144/// use u_numflow::stats::min;
145/// assert_eq!(min(&[3.0, 1.0, 4.0, 1.0, 5.0]), Some(1.0));
146/// ```
147pub fn min(data: &[f64]) -> Option<f64> {
148    if data.is_empty() {
149        return None;
150    }
151    data.iter().copied().try_fold(f64::INFINITY, |acc, x| {
152        if x.is_nan() {
153            None
154        } else {
155            Some(acc.min(x))
156        }
157    })
158}
159
160/// Returns the maximum value in the slice.
161///
162/// # Returns
163/// - `None` if `data` is empty or contains NaN.
164///
165/// # Examples
166/// ```
167/// use u_numflow::stats::max;
168/// assert_eq!(max(&[3.0, 1.0, 4.0, 1.0, 5.0]), Some(5.0));
169/// ```
170pub fn max(data: &[f64]) -> Option<f64> {
171    if data.is_empty() {
172        return None;
173    }
174    data.iter().copied().try_fold(f64::NEG_INFINITY, |acc, x| {
175        if x.is_nan() {
176            None
177        } else {
178            Some(acc.max(x))
179        }
180    })
181}
182
183/// Computes the median of `data` without mutating the input.
184///
185/// Internally clones and sorts the data, then returns the middle element
186/// (or the average of the two middle elements for even-length data).
187///
188/// # Complexity
189/// Time: O(n log n), Space: O(n)
190///
191/// # Returns
192/// - `None` if `data` is empty or contains NaN.
193///
194/// # Examples
195/// ```
196/// use u_numflow::stats::median;
197/// assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
198/// assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
199/// ```
200pub fn median(data: &[f64]) -> Option<f64> {
201    if data.is_empty() {
202        return None;
203    }
204    if data.iter().any(|x| x.is_nan()) {
205        return None;
206    }
207    let mut sorted = data.to_vec();
208    sorted.sort_unstable_by(|a, b| a.partial_cmp(b).expect("NaN filtered above"));
209    let n = sorted.len();
210    if n % 2 == 1 {
211        Some(sorted[n / 2])
212    } else {
213        Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2.0)
214    }
215}
216
217/// Computes the `p`-th quantile using the R-7 linear interpolation method.
218///
219/// This is the default quantile method in R, NumPy, and Excel.
220///
221/// # Algorithm
222/// For sorted data `x[0..n]` and quantile `p ∈ [0, 1]`:
223/// 1. Compute `h = (n − 1) × p`
224/// 2. Let `j = ⌊h⌋` and `g = h − j`
225/// 3. Return `(1 − g) × x[j] + g × x[j+1]`
226///
227/// Reference: Hyndman & Fan (1996), *The American Statistician* 50(4), pp. 361–365.
228///
229/// # Complexity
230/// Time: O(n log n) (dominated by sort), Space: O(n)
231///
232/// # Panics
233/// Does not panic; returns `None` for invalid inputs.
234///
235/// # Returns
236/// - `None` if `data` is empty, `p` is outside `[0, 1]`, or data contains NaN.
237///
238/// # Examples
239/// ```
240/// use u_numflow::stats::quantile;
241/// let data = [1.0, 2.0, 3.0, 4.0, 5.0];
242/// assert_eq!(quantile(&data, 0.0), Some(1.0));
243/// assert_eq!(quantile(&data, 1.0), Some(5.0));
244/// assert_eq!(quantile(&data, 0.5), Some(3.0));
245/// ```
246pub fn quantile(data: &[f64], p: f64) -> Option<f64> {
247    if data.is_empty() || !(0.0..=1.0).contains(&p) {
248        return None;
249    }
250    if data.iter().any(|x| x.is_nan()) {
251        return None;
252    }
253    let mut sorted = data.to_vec();
254    sorted.sort_unstable_by(|a, b| a.partial_cmp(b).expect("NaN filtered above"));
255    quantile_sorted(&sorted, p)
256}
257
258/// Computes the `p`-th quantile on **pre-sorted** data (R-7 method).
259///
260/// This avoids the O(n log n) sort when calling multiple quantiles on
261/// the same dataset. The caller must guarantee that `sorted_data` is
262/// sorted in non-decreasing order.
263///
264/// # Returns
265/// - `None` if `sorted_data` is empty or `p` is outside `[0, 1]`.
266pub fn quantile_sorted(sorted_data: &[f64], p: f64) -> Option<f64> {
267    let n = sorted_data.len();
268    if n == 0 || !(0.0..=1.0).contains(&p) {
269        return None;
270    }
271    if n == 1 {
272        return Some(sorted_data[0]);
273    }
274
275    let h = (n - 1) as f64 * p;
276    let j = h.floor() as usize;
277    let g = h - h.floor();
278
279    if j + 1 >= n {
280        Some(sorted_data[n - 1])
281    } else {
282        Some((1.0 - g) * sorted_data[j] + g * sorted_data[j + 1])
283    }
284}
285
286/// Computes Fisher's adjusted sample skewness (G₁) with bias correction.
287///
288/// # Formula
289/// ```text
290/// G₁ = [√(n(n−1)) / (n−2)] × (m₃ / m₂^{3/2})
291/// ```
292/// where `m₂`, `m₃` are the biased second and third central moments.
293///
294/// This matches Excel `SKEW()` and `scipy.stats.skew(bias=False)`.
295///
296/// # Algorithm
297/// Two-pass: first computes the mean (Kahan sum), then accumulates
298/// central moments in a single sweep.
299///
300/// Reference: Joanes & Gill (1998), "Comparing measures of sample skewness
301/// and kurtosis", *The Statistician* 47(1), pp. 183–189 (their `G₁`).
302///
303/// See [`skewness_moment`] for the uncorrected `g₁` that published moment
304/// formulas and SciPy/R/statsmodels use.
305///
306/// # Complexity
307/// Time: O(n), Space: O(1)
308///
309/// # Returns
310/// - `None` if `data.len() < 3`, data contains NaN/Inf, or variance is zero.
311///
312/// # Examples
313/// ```
314/// use u_numflow::stats::skewness;
315/// // Symmetric data → skewness = 0
316/// let sym = [1.0, 2.0, 3.0, 4.0, 5.0];
317/// assert!(skewness(&sym).unwrap().abs() < 1e-14);
318///
319/// // Right-skewed data → positive skewness
320/// let right = [1.0, 2.0, 3.0, 4.0, 50.0];
321/// assert!(skewness(&right).unwrap() > 0.0);
322/// ```
323pub fn skewness(data: &[f64]) -> Option<f64> {
324    let n = data.len();
325    if n < 3 {
326        return None;
327    }
328    if !data.iter().all(|x| x.is_finite()) {
329        return None;
330    }
331    let nf = n as f64;
332    let m = kahan_sum(data) / nf;
333    let mut sum2 = 0.0;
334    let mut sum3 = 0.0;
335    for &x in data {
336        let d = x - m;
337        let d2 = d * d;
338        sum2 += d2;
339        sum3 += d2 * d;
340    }
341    let m2 = sum2 / nf;
342    if m2 == 0.0 {
343        return None;
344    }
345    let m3 = sum3 / nf;
346    let g1 = m3 / m2.powf(1.5);
347    let correction = (nf * (nf - 1.0)).sqrt() / (nf - 2.0);
348    Some(correction * g1)
349}
350
351/// Computes Fisher's excess kurtosis (G₂) with bias correction.
352///
353/// # Formula
354/// ```text
355/// G₂ = [n(n+1) / ((n−1)(n−2)(n−3))] × Σ[(xᵢ−x̄)/s]⁴ − [3(n−1)² / ((n−2)(n−3))]
356/// ```
357/// where `s` is the sample standard deviation (n−1 denominator).
358///
359/// This matches Excel `KURT()` and `scipy.stats.kurtosis(bias=False)`.
360/// Returns **0** for a normal distribution, positive for heavy tails
361/// (leptokurtic), negative for light tails (platykurtic).
362///
363/// # Algorithm
364/// Two-pass: first computes the mean (Kahan sum), then accumulates
365/// central moments in a single sweep.
366///
367/// Reference: Joanes & Gill (1998), "Comparing measures of sample skewness
368/// and kurtosis", *The Statistician* 47(1), pp. 183–189.
369///
370/// # Complexity
371/// Time: O(n), Space: O(1)
372///
373/// # Returns
374/// - `None` if `data.len() < 4`, data contains NaN/Inf, or variance is zero.
375///
376/// # Examples
377/// ```
378/// use u_numflow::stats::kurtosis;
379/// // Uniform-ish data → negative excess kurtosis
380/// let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
381/// let k = kurtosis(&data).unwrap();
382/// assert!(k < 0.0); // platykurtic
383/// ```
384pub fn kurtosis(data: &[f64]) -> Option<f64> {
385    let n = data.len();
386    if n < 4 {
387        return None;
388    }
389    if !data.iter().all(|x| x.is_finite()) {
390        return None;
391    }
392    let nf = n as f64;
393    let m = kahan_sum(data) / nf;
394    let mut sum2 = 0.0;
395    let mut sum4 = 0.0;
396    for &x in data {
397        let d = x - m;
398        let d2 = d * d;
399        sum2 += d2;
400        sum4 += d2 * d2;
401    }
402    // Sample variance s² = sum2 / (n-1)
403    let s2 = sum2 / (nf - 1.0);
404    if s2 == 0.0 {
405        return None;
406    }
407    let s4 = s2 * s2;
408    // Σ[(xᵢ − x̄)/s]⁴
409    let sum_z4 = sum4 / s4;
410    let a = nf * (nf + 1.0) / ((nf - 1.0) * (nf - 2.0) * (nf - 3.0));
411    let b = 3.0 * (nf - 1.0) * (nf - 1.0) / ((nf - 2.0) * (nf - 3.0));
412    Some(a * sum_z4 - b)
413}
414
415/// Computes the moment coefficient of skewness, `g₁`.
416///
417/// # Formula
418/// ```text
419/// g₁ = m₃ / m₂^(3/2),   mₖ = Σ(xᵢ − x̄)ᵏ / n
420/// ```
421///
422/// This is the sample skewness with no bias correction -- the `b₁` of the
423/// classical moment tests and SciPy's `skew(bias=True)`. [`skewness`] returns
424/// the bias-adjusted `G₁` that Excel's `SKEW()` reports; the two differ by a
425/// factor of `√(n(n−1))/(n−2)`, which is 8 % at n = 20 and still 1 % at n = 150.
426///
427/// Reach for this one when a published formula is written in moment ratios --
428/// Jarque-Bera, D'Agostino's K² -- or when matching SciPy, R's `e1071::skewness`
429/// type 1, or statsmodels. Reach for [`skewness`] when reporting a descriptive
430/// statistic next to one of those spreadsheets.
431///
432/// Reference: Joanes & Gill (1998), "Comparing measures of sample skewness
433/// and kurtosis", *The Statistician* 47(1), pp. 183-189 (their `g₁`).
434///
435/// # Complexity
436/// Time: O(n), Space: O(1)
437///
438/// # Returns
439/// - `None` if `data.len() < 3`, data contains NaN/Inf, or variance is zero.
440///
441/// # Examples
442/// ```
443/// use u_numflow::stats::{skewness, skewness_moment};
444///
445/// let right = [1.0, 2.0, 3.0, 4.0, 50.0];
446/// let g1 = skewness_moment(&right).unwrap();
447/// let big_g1 = skewness(&right).unwrap();
448/// assert!(g1 > 0.0 && big_g1 > g1, "the adjustment only ever inflates");
449/// ```
450pub fn skewness_moment(data: &[f64]) -> Option<f64> {
451    let (_, m2, m3, _) = central_moments(data, 3)?;
452    Some(m3 / m2.powf(1.5))
453}
454
455/// Computes the moment coefficient of excess kurtosis, `g₂`.
456///
457/// # Formula
458/// ```text
459/// g₂ = m₄ / m₂² − 3,   mₖ = Σ(xᵢ − x̄)ᵏ / n
460/// ```
461///
462/// This is the sample excess kurtosis with no bias correction -- the `b₂ − 3`
463/// of the classical moment tests and SciPy's `kurtosis(bias=True)`.
464/// [`kurtosis`] returns the bias-adjusted `G₂` that Excel's `KURT()` reports.
465/// The two are related by `G₂ = (n−1)·((n+1)·g₂ + 6) / ((n−2)(n−3))`, so they
466/// disagree even on the sign for a mildly platykurtic sample: at n = 20,
467/// `g₂ = −0.3` comes back as `G₂ = −0.02`.
468///
469/// Same choice as [`skewness_moment`]: this one for a published formula written
470/// in moment ratios or for agreeing with SciPy/R/statsmodels, [`kurtosis`] for
471/// a descriptive statistic beside a spreadsheet.
472///
473/// Reference: Joanes & Gill (1998), *The Statistician* 47(1) (their `g₂`).
474///
475/// # Complexity
476/// Time: O(n), Space: O(1)
477///
478/// # Returns
479/// - `None` if `data.len() < 4`, data contains NaN/Inf, or variance is zero.
480///
481/// # Examples
482/// ```
483/// use u_numflow::stats::kurtosis_moment;
484///
485/// // Uniform-ish data is platykurtic: excess kurtosis below zero.
486/// let flat: Vec<f64> = (1..=40).map(|i| i as f64).collect();
487/// assert!(kurtosis_moment(&flat).unwrap() < 0.0);
488/// ```
489pub fn kurtosis_moment(data: &[f64]) -> Option<f64> {
490    let (_, m2, _, m4) = central_moments(data, 4)?;
491    Some(m4 / (m2 * m2) - 3.0)
492}
493
494/// Central moments `(m₁ = 0, m₂, m₃, m₄)` in one pass, to the order asked for.
495///
496/// Returns `None` on the same conditions every estimator built on it refuses:
497/// too few points for `order`, a non-finite value, or no spread at all.
498fn central_moments(data: &[f64], order: usize) -> Option<(f64, f64, f64, f64)> {
499    let n = data.len();
500    if n < order {
501        return None;
502    }
503    if !data.iter().all(|x| x.is_finite()) {
504        return None;
505    }
506    let nf = n as f64;
507    let mean = kahan_sum(data) / nf;
508    let (mut s2, mut s3, mut s4) = (0.0, 0.0, 0.0);
509    for &x in data {
510        let d = x - mean;
511        let d2 = d * d;
512        s2 += d2;
513        s3 += d2 * d;
514        s4 += d2 * d2;
515    }
516    let m2 = s2 / nf;
517    if m2 == 0.0 {
518        return None;
519    }
520    Some((0.0, m2, s3 / nf, s4 / nf))
521}
522
523/// Computes the sample covariance between two datasets.
524///
525/// # Formula
526/// ```text
527/// Cov(X, Y) = Σ(xᵢ − x̄)(yᵢ − ȳ) / (n − 1)
528/// ```
529///
530/// Uses Bessel's correction (n−1 denominator) for an unbiased estimator.
531///
532/// # Complexity
533/// Time: O(n), Space: O(1)
534///
535/// # Returns
536/// - `None` if `x.len() != y.len()`, `n < 2`, or data contains NaN/Inf.
537///
538/// # Examples
539/// ```
540/// use u_numflow::stats::covariance;
541/// let x = [1.0, 2.0, 3.0, 4.0, 5.0];
542/// let y = [2.0, 4.0, 6.0, 8.0, 10.0];
543/// let cov = covariance(&x, &y).unwrap();
544/// assert!((cov - 5.0).abs() < 1e-14); // perfect positive covariance
545/// ```
546pub fn covariance(x: &[f64], y: &[f64]) -> Option<f64> {
547    let n = x.len();
548    if n != y.len() || n < 2 {
549        return None;
550    }
551    if !x.iter().chain(y.iter()).all(|v| v.is_finite()) {
552        return None;
553    }
554    let nf = n as f64;
555    let mean_x = kahan_sum(x) / nf;
556    let mean_y = kahan_sum(y) / nf;
557    let mut sum = 0.0;
558    for i in 0..n {
559        sum += (x[i] - mean_x) * (y[i] - mean_y);
560    }
561    Some(sum / (nf - 1.0))
562}
563
564// ---------------------------------------------------------------------------
565// Kahan compensated summation
566// ---------------------------------------------------------------------------
567
568/// Neumaier compensated summation for O(ε) error independent of `n`.
569///
570/// This is an improved variant of Kahan summation that also handles the
571/// case where the addend is larger in magnitude than the running sum.
572///
573/// # Algorithm
574/// Maintains a running compensation variable `c`. At each step, the
575/// branch ensures the smaller operand's low-order bits are captured.
576///
577/// Reference: Neumaier (1974), "Rundungsfehleranalyse einiger Verfahren
578/// zur Summation endlicher Summen", *Zeitschrift für Angewandte
579/// Mathematik und Mechanik* 54(1), pp. 39–51.
580///
581/// # Complexity
582/// Time: O(n), Space: O(1)
583pub fn kahan_sum(data: &[f64]) -> f64 {
584    let mut sum = 0.0_f64;
585    let mut c = 0.0_f64;
586    for &x in data {
587        let t = sum + x;
588        if sum.abs() >= x.abs() {
589            c += (sum - t) + x;
590        } else {
591            c += (x - t) + sum;
592        }
593        sum = t;
594    }
595    sum + c
596}
597
598// ---------------------------------------------------------------------------
599// Welford online accumulator
600// ---------------------------------------------------------------------------
601
602/// Streaming accumulator for mean, variance, skewness, and kurtosis.
603///
604/// Computes running descriptive statistics in a single pass with O(1)
605/// memory and guaranteed numerical stability, using the extended
606/// Welford algorithm for higher-order moments.
607///
608/// # Algorithm
609/// Maintains central moment sums M₂, M₃, M₄ incrementally. The update
610/// order (M₄ → M₃ → M₂) preserves correctness since each uses the
611/// *previous* values of lower moments.
612///
613/// References:
614/// - Welford (1962), *Technometrics* 4(3), pp. 419–420.
615/// - Pébay (2008), "Formulas for Robust, One-Pass Parallel Computation
616///   of Covariances and Arbitrary-Order Statistical Moments",
617///   Sandia Report SAND2008-6212.
618/// - Terriberry (2007), "Computing Higher-Order Moments Online".
619///
620/// # Examples
621/// ```
622/// use u_numflow::stats::WelfordAccumulator;
623/// let mut acc = WelfordAccumulator::new();
624/// for &x in &[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
625///     acc.update(x);
626/// }
627/// assert!((acc.mean().unwrap() - 5.0).abs() < 1e-15);
628/// assert!((acc.sample_variance().unwrap() - 4.571428571428571).abs() < 1e-10);
629/// ```
630#[derive(Debug, Clone)]
631pub struct WelfordAccumulator {
632    count: u64,
633    mean_acc: f64,
634    m2: f64,
635    m3: f64,
636    m4: f64,
637}
638
639impl WelfordAccumulator {
640    /// Creates a new empty accumulator.
641    pub fn new() -> Self {
642        Self {
643            count: 0,
644            mean_acc: 0.0,
645            m2: 0.0,
646            m3: 0.0,
647            m4: 0.0,
648        }
649    }
650
651    /// Feeds a new sample into the accumulator.
652    ///
653    /// Updates M₂, M₃, M₄ in the correct order (M₄ → M₃ → M₂) so that
654    /// each uses the *previous* values of lower-order moments.
655    ///
656    /// The first sample is handled as a special case: all moments remain
657    /// zero and only the mean is initialized. This avoids intermediate
658    /// overflow when `delta² > f64::MAX` (e.g., `value ≈ 1e166`).
659    pub fn update(&mut self, value: f64) {
660        let n1 = self.count;
661        self.count += 1;
662
663        if n1 == 0 {
664            // First sample: mean = value, all moments stay zero.
665            self.mean_acc = value;
666            return;
667        }
668
669        let n = self.count as f64;
670        let delta = value - self.mean_acc;
671        let delta_n = delta / n;
672        let delta_n2 = delta_n * delta_n;
673        let term1 = delta * delta_n * n1 as f64;
674
675        // M₄ before M₃ before M₂ — order matters!
676        self.m4 += term1 * delta_n2 * (n * n - 3.0 * n + 3.0) + 6.0 * delta_n2 * self.m2
677            - 4.0 * delta_n * self.m3;
678        self.m3 += term1 * delta_n * (n - 2.0) - 3.0 * delta_n * self.m2;
679        self.m2 += term1;
680        self.mean_acc += delta_n;
681    }
682
683    /// Returns the number of samples seen so far.
684    pub fn count(&self) -> u64 {
685        self.count
686    }
687
688    /// Returns the running mean, or `None` if no samples have been added.
689    pub fn mean(&self) -> Option<f64> {
690        if self.count == 0 {
691            None
692        } else {
693            Some(self.mean_acc)
694        }
695    }
696
697    /// Returns the sample variance (n − 1 denominator), or `None` if fewer
698    /// than 2 samples have been added.
699    pub fn sample_variance(&self) -> Option<f64> {
700        if self.count < 2 {
701            None
702        } else {
703            Some(self.m2 / (self.count - 1) as f64)
704        }
705    }
706
707    /// Returns the population variance (n denominator), or `None` if no
708    /// samples have been added.
709    pub fn population_variance(&self) -> Option<f64> {
710        if self.count == 0 {
711            None
712        } else {
713            Some(self.m2 / self.count as f64)
714        }
715    }
716
717    /// Returns the sample standard deviation, or `None` if fewer than 2
718    /// samples have been added.
719    pub fn sample_std_dev(&self) -> Option<f64> {
720        self.sample_variance().map(f64::sqrt)
721    }
722
723    /// Returns the population standard deviation, or `None` if no samples
724    /// have been added.
725    pub fn population_std_dev(&self) -> Option<f64> {
726        self.population_variance().map(f64::sqrt)
727    }
728
729    /// Returns Fisher's adjusted sample skewness (G₁), or `None` if
730    /// fewer than 3 samples have been added or variance is zero.
731    ///
732    /// Uses the same bias correction as Excel `SKEW()`.
733    pub fn skewness(&self) -> Option<f64> {
734        if self.count < 3 {
735            return None;
736        }
737        let n = self.count as f64;
738        if self.m2 == 0.0 {
739            return None;
740        }
741        // Biased skewness: g₁ = √n × M₃ / M₂^(3/2)
742        let g1 = n.sqrt() * self.m3 / self.m2.powf(1.5);
743        // Bias correction: √(n(n−1)) / (n−2)
744        let correction = (n * (n - 1.0)).sqrt() / (n - 2.0);
745        Some(correction * g1)
746    }
747
748    /// Returns Fisher's excess kurtosis (G₂) with bias correction, or
749    /// `None` if fewer than 4 samples have been added or variance is zero.
750    ///
751    /// Uses the same bias correction as Excel `KURT()`.
752    /// Returns 0 for a normal distribution, positive for heavy tails.
753    pub fn kurtosis(&self) -> Option<f64> {
754        if self.count < 4 {
755            return None;
756        }
757        let n = self.count as f64;
758        if self.m2 == 0.0 {
759            return None;
760        }
761        // Biased excess kurtosis: g₂ = n × M₄ / M₂² − 3
762        let g2 = n * self.m4 / (self.m2 * self.m2) - 3.0;
763        // Unbiased (Fisher G₂): [(n−1)/((n−2)(n−3))] × [(n+1)×g₂ + 6]
764        let correction = (n - 1.0) / ((n - 2.0) * (n - 3.0));
765        Some(correction * ((n + 1.0) * g2 + 6.0))
766    }
767
768    /// Merges another accumulator into this one (parallel-friendly).
769    ///
770    /// Uses Chan's parallel algorithm extended to higher-order moments.
771    ///
772    /// References:
773    /// - Chan, Golub & LeVeque (1979), "Updating Formulae and a
774    ///   Pairwise Algorithm for Computing Sample Variances".
775    /// - Pébay (2008), SAND2008-6212 (M₃, M₄ merge formulas).
776    pub fn merge(&mut self, other: &WelfordAccumulator) {
777        if other.count == 0 {
778            return;
779        }
780        if self.count == 0 {
781            *self = other.clone();
782            return;
783        }
784        let na = self.count as f64;
785        let nb = other.count as f64;
786        let total = self.count + other.count;
787        let n = total as f64;
788        let delta = other.mean_acc - self.mean_acc;
789        let delta2 = delta * delta;
790        let delta3 = delta2 * delta;
791        let delta4 = delta2 * delta2;
792
793        let new_mean = self.mean_acc + delta * (nb / n);
794
795        let new_m2 = self.m2 + other.m2 + delta2 * na * nb / n;
796
797        let new_m3 = self.m3
798            + other.m3
799            + delta3 * na * nb * (na - nb) / (n * n)
800            + 3.0 * delta * (na * other.m2 - nb * self.m2) / n;
801
802        let new_m4 = self.m4
803            + other.m4
804            + delta4 * na * nb * (na * na - na * nb + nb * nb) / (n * n * n)
805            + 6.0 * delta2 * (na * na * other.m2 + nb * nb * self.m2) / (n * n)
806            + 4.0 * delta * (na * other.m3 - nb * self.m3) / n;
807
808        self.count = total;
809        self.mean_acc = new_mean;
810        self.m2 = new_m2;
811        self.m3 = new_m3;
812        self.m4 = new_m4;
813    }
814}
815
816impl Default for WelfordAccumulator {
817    fn default() -> Self {
818        Self::new()
819    }
820}
821
822// ---------------------------------------------------------------------------
823// Tests
824// ---------------------------------------------------------------------------
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    // --- mean ---
831
832    /// The moment estimators are the ones published formulas are written in,
833    /// and they are not the adjusted ones the descriptive functions report.
834    #[test]
835    fn moment_and_adjusted_estimators_are_related_but_not_equal() {
836        let data = [
837            2.1, 3.4, 1.9, 5.6, 2.2, 3.1, 4.8, 2.9, 3.3, 7.2, 2.5, 3.0, 4.1, 2.7, 3.8, 2.4, 6.1,
838            3.6, 2.8, 3.2,
839        ];
840        let n = data.len() as f64;
841
842        let g1 = skewness_moment(&data).expect("20 finite points with spread");
843        let big_g1 = skewness(&data).expect("same");
844        // G1 = g1 * sqrt(n(n-1)) / (n-2) -- the adjustment only ever inflates.
845        let expected = g1 * (n * (n - 1.0)).sqrt() / (n - 2.0);
846        assert!((big_g1 - expected).abs() < 1e-12, "{big_g1} vs {expected}");
847        assert!(big_g1 > g1, "at n = 20 the adjustment is about 8 %");
848
849        let g2 = kurtosis_moment(&data).expect("20 finite points with spread");
850        let big_g2 = kurtosis(&data).expect("same");
851        // G2 = (n-1)((n+1) g2 + 6) / ((n-2)(n-3)).
852        let expected = (n - 1.0) * ((n + 1.0) * g2 + 6.0) / ((n - 2.0) * (n - 3.0));
853        assert!((big_g2 - expected).abs() < 1e-12, "{big_g2} vs {expected}");
854
855        // Values computed independently from the definition.
856        assert!((g1 - 1.236_103_873_8).abs() < 1e-9, "g1 = {g1}");
857        assert!((g2 - 0.751_429_343_3).abs() < 1e-9, "g2 = {g2}");
858    }
859
860    /// The correction can carry `g2` across zero, which is why a caller that
861    /// needs one of them cannot substitute the other.
862    #[test]
863    fn the_kurtosis_adjustment_can_change_the_sign_at_a_small_sample() {
864        // 20 points, mildly platykurtic.
865        let data: Vec<f64> = (0..20).map(|i| (i as f64) - 9.5).collect();
866        let g2 = kurtosis_moment(&data).expect("spread");
867        let big_g2 = kurtosis(&data).expect("spread");
868        assert!(g2 < 0.0, "a uniform ramp is platykurtic: {g2}");
869        assert!(g2 < big_g2, "the adjustment lifts it: {g2} -> {big_g2}");
870    }
871
872    #[test]
873    fn moment_estimators_refuse_what_the_adjusted_ones_refuse() {
874        assert_eq!(skewness_moment(&[1.0, 2.0]), None, "fewer than 3");
875        assert_eq!(kurtosis_moment(&[1.0, 2.0, 3.0]), None, "fewer than 4");
876        assert_eq!(skewness_moment(&[2.0; 5]), None, "no spread");
877        assert_eq!(kurtosis_moment(&[2.0; 5]), None, "no spread");
878        assert_eq!(skewness_moment(&[1.0, 2.0, f64::NAN]), None, "not finite");
879        assert_eq!(
880            kurtosis_moment(&[1.0, 2.0, 3.0, f64::INFINITY]),
881            None,
882            "not finite"
883        );
884    }
885
886    #[test]
887    fn test_mean_basic() {
888        assert_eq!(mean(&[1.0, 2.0, 3.0, 4.0, 5.0]), Some(3.0));
889    }
890
891    #[test]
892    fn test_mean_single() {
893        assert_eq!(mean(&[42.0]), Some(42.0));
894    }
895
896    #[test]
897    fn test_mean_empty() {
898        assert_eq!(mean(&[]), None);
899    }
900
901    #[test]
902    fn test_mean_nan() {
903        assert_eq!(mean(&[1.0, f64::NAN, 3.0]), None);
904    }
905
906    #[test]
907    fn test_mean_inf() {
908        assert_eq!(mean(&[1.0, f64::INFINITY, 3.0]), None);
909    }
910
911    // --- variance ---
912
913    #[test]
914    fn test_variance_basic() {
915        let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
916        let var = variance(&v).unwrap();
917        assert!((var - 4.571428571428571).abs() < 1e-10);
918    }
919
920    #[test]
921    fn test_variance_constant() {
922        let v = [5.0; 100];
923        assert!((variance(&v).unwrap()).abs() < 1e-15);
924    }
925
926    #[test]
927    fn test_variance_single() {
928        assert_eq!(variance(&[1.0]), None);
929    }
930
931    #[test]
932    fn test_variance_empty() {
933        assert_eq!(variance(&[]), None);
934    }
935
936    #[test]
937    fn test_population_variance() {
938        let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
939        let var = population_variance(&v).unwrap();
940        assert!((var - 4.0).abs() < 1e-10);
941    }
942
943    // --- std_dev ---
944
945    #[test]
946    fn test_std_dev() {
947        let v = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
948        let sd = std_dev(&v).unwrap();
949        let expected = 4.571428571428571_f64.sqrt();
950        assert!((sd - expected).abs() < 1e-10);
951    }
952
953    // --- min / max ---
954
955    #[test]
956    fn test_min_max() {
957        let v = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
958        assert_eq!(min(&v), Some(1.0));
959        assert_eq!(max(&v), Some(9.0));
960    }
961
962    #[test]
963    fn test_min_max_empty() {
964        assert_eq!(min(&[]), None);
965        assert_eq!(max(&[]), None);
966    }
967
968    #[test]
969    fn test_min_max_nan() {
970        assert_eq!(min(&[1.0, f64::NAN]), None);
971        assert_eq!(max(&[1.0, f64::NAN]), None);
972    }
973
974    // --- median ---
975
976    #[test]
977    fn test_median_odd() {
978        assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
979    }
980
981    #[test]
982    fn test_median_even() {
983        assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
984    }
985
986    #[test]
987    fn test_median_single() {
988        assert_eq!(median(&[7.0]), Some(7.0));
989    }
990
991    #[test]
992    fn test_median_empty() {
993        assert_eq!(median(&[]), None);
994    }
995
996    // --- quantile ---
997
998    #[test]
999    fn test_quantile_extremes() {
1000        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1001        assert_eq!(quantile(&data, 0.0), Some(1.0));
1002        assert_eq!(quantile(&data, 1.0), Some(5.0));
1003    }
1004
1005    #[test]
1006    fn test_quantile_median() {
1007        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1008        assert_eq!(quantile(&data, 0.5), Some(3.0));
1009    }
1010
1011    #[test]
1012    fn test_quantile_interpolation() {
1013        let data = [1.0, 2.0, 3.0, 4.0];
1014        // h = (4-1)*0.25 = 0.75, j=0, g=0.75
1015        // (1-0.75)*1.0 + 0.75*2.0 = 0.25 + 1.5 = 1.75
1016        let q = quantile(&data, 0.25).unwrap();
1017        assert!((q - 1.75).abs() < 1e-15);
1018    }
1019
1020    #[test]
1021    fn test_quantile_invalid_p() {
1022        assert_eq!(quantile(&[1.0, 2.0], -0.1), None);
1023        assert_eq!(quantile(&[1.0, 2.0], 1.1), None);
1024    }
1025
1026    #[test]
1027    fn test_quantile_empty() {
1028        assert_eq!(quantile(&[], 0.5), None);
1029    }
1030
1031    #[test]
1032    fn test_quantile_single() {
1033        assert_eq!(quantile(&[42.0], 0.0), Some(42.0));
1034        assert_eq!(quantile(&[42.0], 0.5), Some(42.0));
1035        assert_eq!(quantile(&[42.0], 1.0), Some(42.0));
1036    }
1037
1038    // --- kahan_sum ---
1039
1040    #[test]
1041    fn test_kahan_sum_basic() {
1042        let v = [1.0, 2.0, 3.0];
1043        assert!((kahan_sum(&v) - 6.0).abs() < 1e-15);
1044    }
1045
1046    #[test]
1047    fn test_kahan_sum_precision() {
1048        // Sum of 1e16 + 1.0 + (-1e16) with naive sum loses the 1.0
1049        let v = [1e16, 1.0, -1e16];
1050        let result = kahan_sum(&v);
1051        assert!(
1052            (result - 1.0).abs() < 1e-10,
1053            "Kahan sum should preserve the 1.0: got {result}"
1054        );
1055    }
1056
1057    // --- WelfordAccumulator ---
1058
1059    #[test]
1060    fn test_welford_empty() {
1061        let acc = WelfordAccumulator::new();
1062        assert_eq!(acc.count(), 0);
1063        assert_eq!(acc.mean(), None);
1064        assert_eq!(acc.sample_variance(), None);
1065    }
1066
1067    #[test]
1068    fn test_welford_single() {
1069        let mut acc = WelfordAccumulator::new();
1070        acc.update(5.0);
1071        assert_eq!(acc.mean(), Some(5.0));
1072        assert_eq!(acc.sample_variance(), None);
1073        assert_eq!(acc.population_variance(), Some(0.0));
1074    }
1075
1076    #[test]
1077    fn test_welford_matches_batch() {
1078        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
1079        let mut acc = WelfordAccumulator::new();
1080        for &x in &data {
1081            acc.update(x);
1082        }
1083        let batch_mean = mean(&data).unwrap();
1084        let batch_var = variance(&data).unwrap();
1085        assert!((acc.mean().unwrap() - batch_mean).abs() < 1e-14);
1086        assert!((acc.sample_variance().unwrap() - batch_var).abs() < 1e-10);
1087    }
1088
1089    #[test]
1090    fn test_welford_merge() {
1091        let data_a = [1.0, 2.0, 3.0, 4.0];
1092        let data_b = [5.0, 6.0, 7.0, 8.0];
1093        let data_all: Vec<f64> = data_a.iter().chain(data_b.iter()).copied().collect();
1094
1095        let mut acc_a = WelfordAccumulator::new();
1096        for &x in &data_a {
1097            acc_a.update(x);
1098        }
1099        let mut acc_b = WelfordAccumulator::new();
1100        for &x in &data_b {
1101            acc_b.update(x);
1102        }
1103        acc_a.merge(&acc_b);
1104
1105        let expected_mean = mean(&data_all).unwrap();
1106        let expected_var = variance(&data_all).unwrap();
1107
1108        assert!((acc_a.mean().unwrap() - expected_mean).abs() < 1e-14);
1109        assert!((acc_a.sample_variance().unwrap() - expected_var).abs() < 1e-10);
1110    }
1111
1112    // --- skewness ---
1113
1114    #[test]
1115    fn test_skewness_symmetric() {
1116        // Symmetric data → skewness = 0
1117        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1118        assert!(skewness(&data).unwrap().abs() < 1e-14);
1119    }
1120
1121    #[test]
1122    fn test_skewness_right_skewed() {
1123        // Right-skewed: one large outlier
1124        let data = [1.0, 2.0, 3.0, 4.0, 50.0];
1125        let s = skewness(&data).unwrap();
1126        assert!(s > 0.0, "expected positive skewness, got {s}");
1127    }
1128
1129    #[test]
1130    fn test_skewness_left_skewed() {
1131        // Left-skewed: one small outlier
1132        let data = [-50.0, 1.0, 2.0, 3.0, 4.0];
1133        let s = skewness(&data).unwrap();
1134        assert!(s < 0.0, "expected negative skewness, got {s}");
1135    }
1136
1137    #[test]
1138    fn test_skewness_edge_cases() {
1139        assert_eq!(skewness(&[]), None);
1140        assert_eq!(skewness(&[1.0]), None);
1141        assert_eq!(skewness(&[1.0, 2.0]), None);
1142        // Constant data → zero variance → None
1143        assert_eq!(skewness(&[5.0, 5.0, 5.0]), None);
1144        // NaN → None
1145        assert_eq!(skewness(&[1.0, f64::NAN, 3.0]), None);
1146    }
1147
1148    #[test]
1149    fn test_skewness_known_value() {
1150        // Data: [1, 2, 3, 4, 8]
1151        // Manual computation:
1152        //   n=5, mean=3.6
1153        //   d = [-2.6, -1.6, -0.6, 0.4, 4.4]
1154        //   m2 = (6.76+2.56+0.36+0.16+19.36)/5 = 5.84
1155        //   m3 = (-17.576-4.096-0.216+0.064+85.184)/5 = 12.672
1156        //   g1 = 12.672 / 5.84^1.5 ≈ 0.8982
1157        //   correction = sqrt(20)/3 ≈ 1.4907
1158        //   G1 ≈ 1.3388
1159        let data = [1.0, 2.0, 3.0, 4.0, 8.0];
1160        let s = skewness(&data).unwrap();
1161        assert!(
1162            (s - 1.339).abs() < 0.01,
1163            "expected skewness ≈ 1.34, got {s}"
1164        );
1165    }
1166
1167    // --- kurtosis ---
1168
1169    #[test]
1170    fn test_kurtosis_uniform_negative() {
1171        // Uniform-ish data should have negative excess kurtosis (platykurtic)
1172        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1173        let k = kurtosis(&data).unwrap();
1174        assert!(k < 0.0, "uniform data should be platykurtic, got {k}");
1175    }
1176
1177    #[test]
1178    fn test_kurtosis_edge_cases() {
1179        assert_eq!(kurtosis(&[]), None);
1180        assert_eq!(kurtosis(&[1.0]), None);
1181        assert_eq!(kurtosis(&[1.0, 2.0]), None);
1182        assert_eq!(kurtosis(&[1.0, 2.0, 3.0]), None);
1183        // Constant → None
1184        assert_eq!(kurtosis(&[5.0, 5.0, 5.0, 5.0]), None);
1185        // NaN → None
1186        assert_eq!(kurtosis(&[1.0, f64::NAN, 3.0, 4.0]), None);
1187    }
1188
1189    #[test]
1190    fn test_kurtosis_heavy_tails() {
1191        // Heavy-tailed data (leptokurtic) → positive excess kurtosis
1192        let data = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0];
1193        let k = kurtosis(&data).unwrap();
1194        assert!(k > 0.0, "heavy-tailed data should be leptokurtic, got {k}");
1195    }
1196
1197    // --- covariance ---
1198
1199    #[test]
1200    fn test_covariance_perfect_positive() {
1201        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1202        let y = [2.0, 4.0, 6.0, 8.0, 10.0];
1203        let cov = covariance(&x, &y).unwrap();
1204        assert!((cov - 5.0).abs() < 1e-14);
1205    }
1206
1207    #[test]
1208    fn test_covariance_perfect_negative() {
1209        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1210        let y = [10.0, 8.0, 6.0, 4.0, 2.0];
1211        let cov = covariance(&x, &y).unwrap();
1212        assert!((cov - (-5.0)).abs() < 1e-14);
1213    }
1214
1215    #[test]
1216    fn test_covariance_zero() {
1217        // Independent: x and -x values cancel
1218        let x = [1.0, 2.0, 3.0, 4.0];
1219        let y = [5.0, 5.0, 5.0, 5.0]; // constant
1220        let cov = covariance(&x, &y).unwrap();
1221        assert!(cov.abs() < 1e-14);
1222    }
1223
1224    #[test]
1225    fn test_covariance_self_is_variance() {
1226        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
1227        let cov_xx = covariance(&data, &data).unwrap();
1228        let var = variance(&data).unwrap();
1229        assert!((cov_xx - var).abs() < 1e-10);
1230    }
1231
1232    #[test]
1233    fn test_covariance_edge_cases() {
1234        assert_eq!(covariance(&[], &[]), None);
1235        assert_eq!(covariance(&[1.0], &[2.0]), None);
1236        assert_eq!(covariance(&[1.0, 2.0], &[1.0]), None); // different lengths
1237        assert_eq!(covariance(&[1.0, f64::NAN], &[1.0, 2.0]), None);
1238    }
1239
1240    // --- WelfordAccumulator: skewness & kurtosis ---
1241
1242    #[test]
1243    fn test_welford_skewness_matches_batch() {
1244        let data = [1.0, 2.0, 3.0, 4.0, 50.0];
1245        let mut acc = WelfordAccumulator::new();
1246        for &x in &data {
1247            acc.update(x);
1248        }
1249        let batch = skewness(&data).unwrap();
1250        let stream = acc.skewness().unwrap();
1251        assert!(
1252            (batch - stream).abs() < 1e-10,
1253            "batch={batch}, stream={stream}"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_welford_kurtosis_matches_batch() {
1259        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 100.0];
1260        let mut acc = WelfordAccumulator::new();
1261        for &x in &data {
1262            acc.update(x);
1263        }
1264        let batch = kurtosis(&data).unwrap();
1265        let stream = acc.kurtosis().unwrap();
1266        assert!(
1267            (batch - stream).abs() < 1e-8,
1268            "batch={batch}, stream={stream}"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_welford_skewness_kurtosis_edge_cases() {
1274        let mut acc = WelfordAccumulator::new();
1275        assert_eq!(acc.skewness(), None);
1276        assert_eq!(acc.kurtosis(), None);
1277        acc.update(1.0);
1278        assert_eq!(acc.skewness(), None);
1279        assert_eq!(acc.kurtosis(), None);
1280        acc.update(2.0);
1281        assert_eq!(acc.skewness(), None);
1282        assert_eq!(acc.kurtosis(), None);
1283        acc.update(3.0);
1284        // skewness available at n=3
1285        assert!(acc.skewness().is_some());
1286        assert_eq!(acc.kurtosis(), None);
1287        acc.update(4.0);
1288        // kurtosis available at n=4
1289        assert!(acc.kurtosis().is_some());
1290    }
1291
1292    #[test]
1293    fn test_welford_merge_skewness_kurtosis() {
1294        let data_a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0];
1295        let data_b = [2.0, 50.0, 4.0, 6.0, 8.0, 100.0];
1296        let all: Vec<f64> = data_a.iter().chain(data_b.iter()).copied().collect();
1297
1298        let mut acc_a = WelfordAccumulator::new();
1299        for &x in &data_a {
1300            acc_a.update(x);
1301        }
1302        let mut acc_b = WelfordAccumulator::new();
1303        for &x in &data_b {
1304            acc_b.update(x);
1305        }
1306        acc_a.merge(&acc_b);
1307
1308        let expected_skew = skewness(&all).unwrap();
1309        let expected_kurt = kurtosis(&all).unwrap();
1310        let merged_skew = acc_a.skewness().unwrap();
1311        let merged_kurt = acc_a.kurtosis().unwrap();
1312
1313        assert!(
1314            (expected_skew - merged_skew).abs() < 1e-8,
1315            "skewness: expected={expected_skew}, merged={merged_skew}"
1316        );
1317        assert!(
1318            (expected_kurt - merged_kurt).abs() < 1e-6,
1319            "kurtosis: expected={expected_kurt}, merged={merged_kurt}"
1320        );
1321    }
1322
1323    // --- numerical stability ---
1324
1325    #[test]
1326    fn test_variance_large_offset() {
1327        // Data with large mean: [1e9 + 1, 1e9 + 2, ..., 1e9 + 5]
1328        // Naive algorithm would suffer catastrophic cancellation.
1329        let data: Vec<f64> = (1..=5).map(|i| 1e9 + i as f64).collect();
1330        let var = variance(&data).unwrap();
1331        // True variance of [1,2,3,4,5] = 2.5
1332        assert!(
1333            (var - 2.5).abs() < 1e-5,
1334            "Variance of offset data should be ~2.5, got {var}"
1335        );
1336    }
1337}
1338
1339#[cfg(test)]
1340mod proptests {
1341    use super::*;
1342    use proptest::prelude::*;
1343
1344    /// Strategy for generating finite f64 vectors of reasonable size.
1345    fn finite_vec(min_len: usize, max_len: usize) -> impl Strategy<Value = Vec<f64>> {
1346        proptest::collection::vec(
1347            prop::num::f64::NORMAL.prop_filter("finite", |x| x.is_finite() && x.abs() < 1e12),
1348            min_len..=max_len,
1349        )
1350    }
1351
1352    proptest! {
1353        #![proptest_config(ProptestConfig::with_cases(500))]
1354
1355        // --- Variance is non-negative ---
1356        #[test]
1357        fn variance_non_negative(data in finite_vec(2, 100)) {
1358            let var = variance(&data).unwrap();
1359            prop_assert!(var >= 0.0, "variance must be >= 0, got {}", var);
1360        }
1361
1362        // --- Variance of constant is zero ---
1363        #[test]
1364        fn variance_of_constant_is_zero(
1365            value in prop::num::f64::NORMAL.prop_filter("finite", |x| x.is_finite()),
1366            n in 2_usize..50,
1367        ) {
1368            let data = vec![value; n];
1369            let var = variance(&data).unwrap();
1370            prop_assert!(var.abs() < 1e-10, "variance of constant should be ~0, got {}", var);
1371        }
1372
1373        // --- std_dev = sqrt(variance) ---
1374        #[test]
1375        fn std_dev_is_sqrt_of_variance(data in finite_vec(2, 100)) {
1376            let var = variance(&data).unwrap();
1377            let sd = std_dev(&data).unwrap();
1378            let diff = (sd * sd - var).abs();
1379            prop_assert!(diff < 1e-10 * var.max(1.0), "sd² should equal variance");
1380        }
1381
1382        // --- Mean linearity: mean(a*x + b) = a*mean(x) + b ---
1383        #[test]
1384        fn mean_linearity(
1385            data in finite_vec(1, 100),
1386            a in -100.0_f64..100.0,
1387            b in -100.0_f64..100.0,
1388        ) {
1389            prop_assume!(a.is_finite() && b.is_finite());
1390            let m = mean(&data).unwrap();
1391            let transformed: Vec<f64> = data.iter().map(|&x| a * x + b).collect();
1392            if let Some(mt) = mean(&transformed) {
1393                let expected = a * m + b;
1394                let tol = 1e-8 * expected.abs().max(1.0);
1395                prop_assert!(
1396                    (mt - expected).abs() < tol,
1397                    "mean(a*x+b)={} != a*mean(x)+b={}",
1398                    mt, expected
1399                );
1400            }
1401        }
1402
1403        // --- quantile(0) = min, quantile(1) = max ---
1404        #[test]
1405        fn quantile_extremes_are_min_max(data in finite_vec(1, 100)) {
1406            let q0 = quantile(&data, 0.0).unwrap();
1407            let q1 = quantile(&data, 1.0).unwrap();
1408            let mn = min(&data).unwrap();
1409            let mx = max(&data).unwrap();
1410            prop_assert!((q0 - mn).abs() < 1e-15, "quantile(0) should be min");
1411            prop_assert!((q1 - mx).abs() < 1e-15, "quantile(1) should be max");
1412        }
1413
1414        // --- Quantiles are monotonic ---
1415        #[test]
1416        fn quantiles_monotonic(
1417            data in finite_vec(2, 100),
1418            p1 in 0.0_f64..=1.0,
1419            p2 in 0.0_f64..=1.0,
1420        ) {
1421            let (lo, hi) = if p1 <= p2 { (p1, p2) } else { (p2, p1) };
1422            let q_lo = quantile(&data, lo).unwrap();
1423            let q_hi = quantile(&data, hi).unwrap();
1424            prop_assert!(q_lo <= q_hi + 1e-15, "quantiles should be monotonic");
1425        }
1426
1427        // --- median = quantile(0.5) ---
1428        #[test]
1429        fn median_equals_quantile_half(data in finite_vec(1, 100)) {
1430            let med = median(&data).unwrap();
1431            let q50 = quantile(&data, 0.5).unwrap();
1432            prop_assert!(
1433                (med - q50).abs() < 1e-14,
1434                "median={} != quantile(0.5)={}",
1435                med, q50
1436            );
1437        }
1438
1439        // --- Welford merge produces same result as sequential ---
1440        #[test]
1441        fn welford_merge_equals_sequential(
1442            data_a in finite_vec(1, 50),
1443            data_b in finite_vec(1, 50),
1444        ) {
1445            let mut sequential = WelfordAccumulator::new();
1446            for &x in data_a.iter().chain(data_b.iter()) {
1447                sequential.update(x);
1448            }
1449
1450            let mut acc_a = WelfordAccumulator::new();
1451            for &x in &data_a { acc_a.update(x); }
1452            let mut acc_b = WelfordAccumulator::new();
1453            for &x in &data_b { acc_b.update(x); }
1454            acc_a.merge(&acc_b);
1455
1456            let seq_mean = sequential.mean().unwrap();
1457            let mrg_mean = acc_a.mean().unwrap();
1458            prop_assert!(
1459                (seq_mean - mrg_mean).abs() < 1e-10 * seq_mean.abs().max(1.0),
1460                "merged mean should match sequential"
1461            );
1462
1463            if sequential.count() >= 2 {
1464                let seq_var = sequential.sample_variance().unwrap();
1465                let mrg_var = acc_a.sample_variance().unwrap();
1466                prop_assert!(
1467                    (seq_var - mrg_var).abs() < 1e-8 * seq_var.max(1.0),
1468                    "merged variance should match sequential"
1469                );
1470            }
1471        }
1472
1473        // --- Skewness of symmetric data is near zero ---
1474        #[test]
1475        fn skewness_of_symmetric_is_zero(
1476            half in proptest::collection::vec(-1e6_f64..1e6, 2..=50),
1477        ) {
1478            prop_assume!(half.iter().all(|x| x.is_finite()));
1479            // Build truly symmetric data around 0: [a, b, c, -c, -b, -a]
1480            let mut data: Vec<f64> = half.clone();
1481            data.extend(half.iter().map(|x| -x));
1482            if let Some(s) = skewness(&data) {
1483                prop_assert!(
1484                    s.abs() < 1e-8,
1485                    "symmetric data should have ~0 skewness, got {}",
1486                    s
1487                );
1488            }
1489        }
1490
1491        // --- Streaming skewness matches batch ---
1492        // Uses bounded range [-1e6, 1e6] to avoid ill-conditioned data
1493        // with 100+ order-of-magnitude dynamic range.
1494        #[test]
1495        fn welford_skewness_matches_batch(
1496            data in proptest::collection::vec(-1e6_f64..1e6, 3..=100)
1497        ) {
1498            let mut acc = WelfordAccumulator::new();
1499            for &x in &data { acc.update(x); }
1500            match (skewness(&data), acc.skewness()) {
1501                (Some(batch), Some(stream)) if batch.is_finite() && stream.is_finite() => {
1502                    let tol = 1e-8 * batch.abs().max(1.0);
1503                    prop_assert!(
1504                        (batch - stream).abs() < tol,
1505                        "batch={} stream={}", batch, stream
1506                    );
1507                }
1508                _ => {} // NaN/None cases: skip
1509            }
1510        }
1511
1512        // --- Streaming kurtosis matches batch ---
1513        #[test]
1514        fn welford_kurtosis_matches_batch(
1515            data in proptest::collection::vec(-1e6_f64..1e6, 4..=100)
1516        ) {
1517            let mut acc = WelfordAccumulator::new();
1518            for &x in &data { acc.update(x); }
1519            match (kurtosis(&data), acc.kurtosis()) {
1520                (Some(batch), Some(stream)) if batch.is_finite() && stream.is_finite() => {
1521                    let tol = 1e-6 * batch.abs().max(1.0);
1522                    prop_assert!(
1523                        (batch - stream).abs() < tol,
1524                        "batch={} stream={}", batch, stream
1525                    );
1526                }
1527                _ => {} // NaN/None cases: skip
1528            }
1529        }
1530
1531        // --- Covariance of x with itself equals variance ---
1532        #[test]
1533        fn covariance_self_is_variance(data in finite_vec(2, 100)) {
1534            let cov = covariance(&data, &data).unwrap();
1535            let var = variance(&data).unwrap();
1536            let tol = 1e-10 * var.max(1.0);
1537            prop_assert!(
1538                (cov - var).abs() < tol,
1539                "Cov(x,x)={} != Var(x)={}", cov, var
1540            );
1541        }
1542
1543        // --- Covariance is symmetric: Cov(x,y) = Cov(y,x) ---
1544        #[test]
1545        fn covariance_symmetric(
1546            x in finite_vec(2, 50),
1547            y in finite_vec(2, 50),
1548        ) {
1549            let n = x.len().min(y.len());
1550            if n >= 2 {
1551                let x_slice = &x[..n];
1552                let y_slice = &y[..n];
1553                let cov_xy = covariance(x_slice, y_slice).unwrap();
1554                let cov_yx = covariance(y_slice, x_slice).unwrap();
1555                prop_assert!(
1556                    (cov_xy - cov_yx).abs() < 1e-10 * cov_xy.abs().max(1.0),
1557                    "Cov(x,y)={} != Cov(y,x)={}", cov_xy, cov_yx
1558                );
1559            }
1560        }
1561    }
1562}