Skip to main content

u_analytics/
testing.rs

1//! Hypothesis testing.
2//!
3//! Parametric and non-parametric statistical tests: t-tests, ANOVA,
4//! chi-squared tests, and normality tests.
5//!
6//! # Examples
7//!
8//! ```
9//! use u_analytics::testing::{one_sample_t_test, TestResult};
10//!
11//! let data = [5.1, 4.9, 5.2, 5.0, 4.8, 5.3, 5.1, 4.9];
12//! let result = one_sample_t_test(&data, 5.0).unwrap();
13//! assert!(result.p_value > 0.05); // cannot reject H₀: μ = 5.0
14//! ```
15
16use crate::error::{check_option, check_series, TimeSeriesError};
17use u_numflow::special;
18use u_numflow::stats;
19
20/// Result of a hypothesis test.
21#[derive(Debug, Clone, Copy)]
22pub struct TestResult {
23    /// Test statistic (t, F, χ², or z depending on test).
24    pub statistic: f64,
25    /// Degrees of freedom (may be fractional for Welch).
26    pub df: f64,
27    /// Two-tailed p-value.
28    pub p_value: f64,
29}
30
31// ---------------------------------------------------------------------------
32// t-tests
33// ---------------------------------------------------------------------------
34
35/// One-sample t-test: H₀: μ = μ₀.
36///
37/// # Algorithm
38///
39/// t = (x̄ - μ₀) / (s / √n), df = n-1.
40///
41/// # Returns
42///
43/// `None` if fewer than 2 observations or non-finite values.
44///
45/// # Examples
46///
47/// ```
48/// use u_analytics::testing::one_sample_t_test;
49///
50/// let data = [2.0, 4.0, 6.0, 8.0, 10.0];
51/// let r = one_sample_t_test(&data, 6.0).unwrap();
52/// assert!(r.p_value > 0.5); // mean is 6.0
53/// ```
54pub fn one_sample_t_test(data: &[f64], mu0: f64) -> Option<TestResult> {
55    let n = data.len();
56    if n < 2 {
57        return None;
58    }
59    if data.iter().any(|v| !v.is_finite()) || !mu0.is_finite() {
60        return None;
61    }
62
63    let mean = stats::mean(data)?;
64    let sd = stats::std_dev(data)?;
65
66    if sd < 1e-300 {
67        return None; // zero variance
68    }
69
70    let t = (mean - mu0) / (sd / (n as f64).sqrt());
71    let df = (n - 1) as f64;
72    let p_value = 2.0 * special::t_distribution_sf(t.abs(), df);
73
74    Some(TestResult {
75        statistic: t,
76        df,
77        p_value,
78    })
79}
80
81/// Two-sample Welch t-test: H₀: μ₁ = μ₂ (unequal variances).
82///
83/// # Algorithm
84///
85/// t = (x̄₁ - x̄₂) / √(s₁²/n₁ + s₂²/n₂)
86/// df = Welch-Satterthwaite approximation.
87///
88/// # Returns
89///
90/// `None` if either sample has fewer than 2 observations.
91///
92/// # References
93///
94/// Welch (1947). "The generalization of Student's problem when several
95/// different population variances are involved". Biometrika, 34, 28–35.
96///
97/// # Examples
98///
99/// ```
100/// use u_analytics::testing::two_sample_t_test;
101///
102/// let a = [5.1, 4.9, 5.2, 5.0, 4.8];
103/// let b = [7.1, 6.9, 7.2, 7.0, 6.8];
104/// let r = two_sample_t_test(&a, &b).unwrap();
105/// assert!(r.p_value < 0.01); // means clearly differ
106/// ```
107pub fn two_sample_t_test(a: &[f64], b: &[f64]) -> Option<TestResult> {
108    let n1 = a.len();
109    let n2 = b.len();
110    if n1 < 2 || n2 < 2 {
111        return None;
112    }
113    if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
114        return None;
115    }
116
117    let mean1 = stats::mean(a)?;
118    let mean2 = stats::mean(b)?;
119    let var1 = stats::variance(a)?;
120    let var2 = stats::variance(b)?;
121
122    let n1f = n1 as f64;
123    let n2f = n2 as f64;
124
125    let se_sq = var1 / n1f + var2 / n2f;
126    if se_sq < 1e-300 {
127        return None;
128    }
129
130    let t = (mean1 - mean2) / se_sq.sqrt();
131
132    // Welch-Satterthwaite degrees of freedom
133    let v1 = var1 / n1f;
134    let v2 = var2 / n2f;
135    let df = (v1 + v2).powi(2) / (v1 * v1 / (n1f - 1.0) + v2 * v2 / (n2f - 1.0));
136
137    let p_value = 2.0 * special::t_distribution_sf(t.abs(), df);
138
139    Some(TestResult {
140        statistic: t,
141        df,
142        p_value,
143    })
144}
145
146/// Paired t-test: H₀: mean difference = 0.
147///
148/// # Algorithm
149///
150/// Computes differences dᵢ = xᵢ - yᵢ, then applies one-sample t-test
151/// with μ₀ = 0.
152///
153/// # Returns
154///
155/// `None` if fewer than 2 pairs, slices differ in length, or non-finite values.
156///
157/// # Examples
158///
159/// ```
160/// use u_analytics::testing::paired_t_test;
161///
162/// let before = [5.0, 6.0, 7.0, 8.0, 9.0];
163/// let after  = [5.5, 6.2, 7.1, 8.3, 9.4];
164/// let r = paired_t_test(&before, &after).unwrap();
165/// assert!(r.statistic < 0.0); // after > before
166/// ```
167pub fn paired_t_test(x: &[f64], y: &[f64]) -> Option<TestResult> {
168    if x.len() != y.len() || x.len() < 2 {
169        return None;
170    }
171
172    let diffs: Vec<f64> = x.iter().zip(y.iter()).map(|(&a, &b)| a - b).collect();
173    one_sample_t_test(&diffs, 0.0)
174}
175
176// ---------------------------------------------------------------------------
177// ANOVA
178// ---------------------------------------------------------------------------
179
180/// Result of one-way ANOVA.
181#[derive(Debug, Clone)]
182pub struct AnovaResult {
183    /// F-statistic.
184    pub f_statistic: f64,
185    /// Degrees of freedom between groups.
186    pub df_between: usize,
187    /// Degrees of freedom within groups.
188    pub df_within: usize,
189    /// p-value.
190    pub p_value: f64,
191    /// Sum of squares between groups.
192    pub ss_between: f64,
193    /// Sum of squares within groups.
194    pub ss_within: f64,
195    /// Mean square between.
196    pub ms_between: f64,
197    /// Mean square within.
198    pub ms_within: f64,
199    /// Group means.
200    pub group_means: Vec<f64>,
201    /// Grand mean.
202    pub grand_mean: f64,
203}
204
205/// One-way ANOVA: H₀: all group means are equal.
206///
207/// # Algorithm
208///
209/// F = MS_between / MS_within where
210/// MS_between = SS_between / (k-1),
211/// MS_within = SS_within / (N-k).
212///
213/// # Returns
214///
215/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
216/// or non-finite values.
217///
218/// # References
219///
220/// Fisher (1925). "Statistical Methods for Research Workers".
221///
222/// # Examples
223///
224/// ```
225/// use u_analytics::testing::one_way_anova;
226///
227/// let group1 = [5.0, 6.0, 7.0, 5.5, 6.5];
228/// let group2 = [8.0, 9.0, 8.5, 9.5, 8.0];
229/// let group3 = [4.0, 3.0, 3.5, 4.5, 4.0];
230/// let r = one_way_anova(&[&group1, &group2, &group3]).unwrap();
231/// assert!(r.p_value < 0.01); // means clearly differ
232/// ```
233pub fn one_way_anova(groups: &[&[f64]]) -> Option<AnovaResult> {
234    let k = groups.len();
235    if k < 2 {
236        return None;
237    }
238
239    for g in groups {
240        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
241            return None;
242        }
243    }
244
245    let total_n: usize = groups.iter().map(|g| g.len()).sum();
246
247    // Grand mean
248    let grand_sum: f64 = groups.iter().flat_map(|g| g.iter()).sum();
249    let grand_mean = grand_sum / total_n as f64;
250
251    // Group means
252    let group_means: Vec<f64> = groups
253        .iter()
254        .map(|g| g.iter().sum::<f64>() / g.len() as f64)
255        .collect();
256
257    // Sum of squares
258    let ss_between: f64 = groups
259        .iter()
260        .zip(group_means.iter())
261        .map(|(g, &gm)| g.len() as f64 * (gm - grand_mean).powi(2))
262        .sum();
263
264    let ss_within: f64 = groups
265        .iter()
266        .zip(group_means.iter())
267        .map(|(g, &gm)| g.iter().map(|&x| (x - gm).powi(2)).sum::<f64>())
268        .sum();
269
270    let df_between = k - 1;
271    let df_within = total_n - k;
272
273    if df_within == 0 {
274        return None;
275    }
276
277    let ms_between = ss_between / df_between as f64;
278    let ms_within = ss_within / df_within as f64;
279
280    let f_statistic = if ms_within > 1e-300 {
281        ms_between / ms_within
282    } else {
283        f64::INFINITY
284    };
285
286    let p_value = if f_statistic.is_infinite() {
287        0.0
288    } else {
289        special::f_distribution_sf(f_statistic, df_between as f64, df_within as f64)
290    };
291
292    Some(AnovaResult {
293        f_statistic,
294        df_between,
295        df_within,
296        p_value,
297        ss_between,
298        ss_within,
299        ms_between,
300        ms_within,
301        group_means,
302        grand_mean,
303    })
304}
305
306// ---------------------------------------------------------------------------
307// Chi-squared tests
308// ---------------------------------------------------------------------------
309
310/// Chi-squared goodness-of-fit test: H₀: observed matches expected distribution.
311///
312/// # Algorithm
313///
314/// χ² = Σ (Oᵢ - Eᵢ)² / Eᵢ, df = k-1.
315///
316/// # Returns
317///
318/// `None` if fewer than 2 categories, any expected frequency ≤ 0, or
319/// slices differ in length.
320///
321/// # Examples
322///
323/// ```
324/// use u_analytics::testing::chi_squared_goodness_of_fit;
325///
326/// let observed = [50.0, 30.0, 20.0];
327/// let expected = [40.0, 35.0, 25.0];
328/// let r = chi_squared_goodness_of_fit(&observed, &expected).unwrap();
329/// assert!(r.statistic > 0.0);
330/// ```
331pub fn chi_squared_goodness_of_fit(observed: &[f64], expected: &[f64]) -> Option<TestResult> {
332    let k = observed.len();
333    if k < 2 || k != expected.len() {
334        return None;
335    }
336
337    for &e in expected {
338        if e <= 0.0 || !e.is_finite() {
339            return None;
340        }
341    }
342    for &o in observed {
343        if o < 0.0 || !o.is_finite() {
344            return None;
345        }
346    }
347
348    let chi2: f64 = observed
349        .iter()
350        .zip(expected.iter())
351        .map(|(&o, &e)| (o - e).powi(2) / e)
352        .sum();
353
354    let df = (k - 1) as f64;
355    let p_value = special::chi_squared_sf(chi2, df);
356
357    Some(TestResult {
358        statistic: chi2,
359        df,
360        p_value,
361    })
362}
363
364/// Chi-squared test of independence on a contingency table.
365///
366/// # Arguments
367///
368/// * `table` — Flat row-major contingency table (rows × cols observed frequencies).
369/// * `n_rows` — Number of rows.
370/// * `n_cols` — Number of columns.
371///
372/// # Algorithm
373///
374/// Expected: Eᵢⱼ = (row_sumᵢ × col_sumⱼ) / N.
375/// χ² = Σᵢⱼ (Oᵢⱼ - Eᵢⱼ)² / Eᵢⱼ, df = (r-1)(c-1).
376///
377/// # Returns
378///
379/// `None` if fewer than 2 rows or columns, any cell is negative, or
380/// any marginal is zero.
381///
382/// # Examples
383///
384/// ```
385/// use u_analytics::testing::chi_squared_independence;
386///
387/// // 2×2 contingency table
388/// let table = [30.0, 10.0, 20.0, 40.0];
389/// let r = chi_squared_independence(&table, 2, 2).unwrap();
390/// assert!(r.p_value < 0.01);
391/// ```
392pub fn chi_squared_independence(table: &[f64], n_rows: usize, n_cols: usize) -> Option<TestResult> {
393    if n_rows < 2 || n_cols < 2 || table.len() != n_rows * n_cols {
394        return None;
395    }
396
397    for &v in table {
398        if v < 0.0 || !v.is_finite() {
399            return None;
400        }
401    }
402
403    // Row sums and column sums
404    let mut row_sums = vec![0.0; n_rows];
405    let mut col_sums = vec![0.0; n_cols];
406    let mut total = 0.0;
407
408    for i in 0..n_rows {
409        for j in 0..n_cols {
410            let val = table[i * n_cols + j];
411            row_sums[i] += val;
412            col_sums[j] += val;
413            total += val;
414        }
415    }
416
417    if total <= 0.0 {
418        return None;
419    }
420
421    // Check no zero marginals
422    for &r in &row_sums {
423        if r <= 0.0 {
424            return None;
425        }
426    }
427    for &c in &col_sums {
428        if c <= 0.0 {
429            return None;
430        }
431    }
432
433    // Compute chi-squared statistic
434    let mut chi2 = 0.0;
435    for i in 0..n_rows {
436        for j in 0..n_cols {
437            let observed = table[i * n_cols + j];
438            let expected = row_sums[i] * col_sums[j] / total;
439            chi2 += (observed - expected).powi(2) / expected;
440        }
441    }
442
443    let df = ((n_rows - 1) * (n_cols - 1)) as f64;
444    let p_value = special::chi_squared_sf(chi2, df);
445
446    Some(TestResult {
447        statistic: chi2,
448        df,
449        p_value,
450    })
451}
452
453// ---------------------------------------------------------------------------
454// Normality tests
455// ---------------------------------------------------------------------------
456
457/// Jarque-Bera normality test: H₀: data is normally distributed.
458///
459/// # Algorithm
460///
461/// ```text
462/// JB = (n/6) · [g₁² + g₂²/4],   p = exp(−JB/2)
463/// ```
464///
465/// `g₁` and `g₂` are the **moment** coefficients of skewness and excess
466/// kurtosis (`m₃/m₂^(3/2)` and `m₄/m₂² − 3`), which is what Jarque and Bera
467/// wrote the statistic in and what SciPy, R and statsmodels compute. The
468/// bias-adjusted `G₁`/`G₂` that [`u_numflow::stats::skewness`] and
469/// [`u_numflow::stats::kurtosis`] report -- Excel's `SKEW()`/`KURT()` -- are a
470/// different pair of estimators, and substituting them moves JB by tens of
471/// percent at the sample sizes this test is used at.
472///
473/// `JB ~ χ²(2)` under H₀, and a χ² with two degrees of freedom is an
474/// exponential: its survival function is `exp(−JB/2)` exactly. Taking that
475/// instead of `1 − cdf` keeps the p-value to full relative precision in the
476/// tail, where `1 − cdf` rounds to zero.
477///
478/// # Returns
479///
480/// `None` if fewer than 8 observations or non-finite values.
481///
482/// # References
483///
484/// Jarque & Bera (1987). "A test for normality of observations and
485/// regression residuals". International Statistical Review, 55(2), 163–172.
486///
487/// # Examples
488///
489/// ```
490/// use u_analytics::testing::jarque_bera_test;
491///
492/// // Near-normal data
493/// let data = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
494/// let r = jarque_bera_test(&data).unwrap();
495/// assert!(r.p_value > 0.05); // cannot reject normality
496/// ```
497pub fn jarque_bera_test(data: &[f64]) -> Option<TestResult> {
498    let n = data.len();
499    if n < 8 {
500        return None;
501    }
502    if data.iter().any(|v| !v.is_finite()) {
503        return None;
504    }
505
506    let g1 = stats::skewness_moment(data)?;
507    let g2 = stats::kurtosis_moment(data)?;
508
509    let nf = n as f64;
510    let jb = (nf / 6.0) * (g1 * g1 + g2 * g2 / 4.0);
511    // χ²(2) is Exponential(1/2): sf(x) = exp(−x/2), no cancellation in the tail.
512    let p_value = (-jb / 2.0).exp();
513
514    Some(TestResult {
515        statistic: jb,
516        df: 2.0,
517        p_value,
518    })
519}
520
521/// Result of the Anderson-Darling normality test.
522#[derive(Debug, Clone, Copy)]
523pub struct AndersonDarlingResult {
524    /// The A² test statistic (raw, before sample-size correction).
525    pub statistic: f64,
526    /// The modified statistic A*² = A² × (1 + 0.75/n + 2.25/n²).
527    pub statistic_star: f64,
528    /// The p-value. Small values reject the null hypothesis of normality.
529    pub p_value: f64,
530}
531
532/// Upper-tail p-value for the Anderson-Darling A*² statistic, using the
533/// D'Agostino & Stephens (1986) piecewise approximation.
534///
535/// # Numerical range
536///
537/// The large-A*² branch `exp(1.2937 − 5.709·A*² + 0.0186·A*²²)` is a bounded-range
538/// fit: its positive quadratic term turns the exponent back *upward* past the
539/// vertex A*² = 5.709 / (2·0.0186) ≈ 153.47. Left unguarded, for a strongly
540/// non-normal large sample (A*² in the hundreds) the exponent grows positive, the
541/// exponential overflows, and after `clamp(0, 1)` the p-value flips to exactly
542/// `1.0` ("perfectly normal") while A*² is simultaneously huge and still growing —
543/// an internally inconsistent result. Clamping the evaluation point to the vertex
544/// makes the p-value monotonically non-increasing in A*², plateauing at a tiny
545/// (~5e-190) representable floor instead of jumping to 1.
546///
547/// # References
548///
549/// - D'Agostino & Stephens (1986). "Tests based on EDF statistics". In
550///   *Goodness-of-Fit Techniques*. Marcel Dekker.
551fn ad_upper_tail_pvalue(a2_star: f64) -> f64 {
552    // Vertex of the upper-branch quadratic 1.2937 − 5.709·x + 0.0186·x²; beyond it
553    // the fit is invalid and non-monotone, so evaluation is clamped here.
554    const UPPER_BRANCH_VERTEX: f64 = 153.467_741_935_483_87; // 5.709 / (2 * 0.0186)
555
556    let p = if a2_star >= 0.6 {
557        let x = a2_star.min(UPPER_BRANCH_VERTEX);
558        (1.2937 - 5.709 * x + 0.0186 * x * x).exp()
559    } else if a2_star > 0.34 {
560        (0.9177 - 4.279 * a2_star - 1.38 * a2_star * a2_star).exp()
561    } else if a2_star > 0.2 {
562        1.0 - (-8.318 + 42.796 * a2_star - 59.938 * a2_star * a2_star).exp()
563    } else {
564        1.0 - (-13.436 + 101.14 * a2_star - 223.73 * a2_star * a2_star).exp()
565    };
566    p.clamp(0.0, 1.0)
567}
568
569/// Anderson-Darling normality test: H₀: data is normally distributed.
570///
571/// More sensitive to tail deviations than Kolmogorov-Smirnov.
572///
573/// # Algorithm
574///
575/// 1. Standardize sorted data: zᵢ = (x₍ᵢ₎ - x̄) / s
576/// 2. Compute A² = -n - (1/n) Σᵢ (2i-1) [ln Φ(zᵢ) + ln(1 - Φ(z_{n+1-i}))]
577/// 3. Apply Stephens (1986) correction: A*² = A² (1 + 0.75/n + 2.25/n²)
578/// 4. Compute p-value from piecewise exponential approximation
579///
580/// # Returns
581///
582/// `None` if n < 8 (the floor below which the sample-size correction and the
583/// p-value approximation are not tabulated; R's `nortest::ad.test` refuses the
584/// same), all values identical, or non-finite values.
585///
586/// # References
587///
588/// - Anderson & Darling (1952). "Asymptotic theory of certain goodness of
589///   fit criteria based on stochastic processes". Annals of Mathematical
590///   Statistics, 23(2), 193–212.
591/// - Stephens (1986). "Tests based on EDF statistics". In D'Agostino &
592///   Stephens (Eds.), Goodness-of-Fit Techniques. Marcel Dekker.
593///
594/// # Examples
595///
596/// ```
597/// use u_analytics::testing::anderson_darling_test;
598///
599/// let data = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
600/// let r = anderson_darling_test(&data).unwrap();
601/// assert!(r.p_value > 0.05); // cannot reject normality
602/// ```
603pub fn anderson_darling_test(data: &[f64]) -> Option<AndersonDarlingResult> {
604    let n = data.len();
605    if n < 8 {
606        return None;
607    }
608    if data.iter().any(|v| !v.is_finite()) {
609        return None;
610    }
611
612    let mean = stats::mean(data)?;
613    let sd = stats::std_dev(data)?;
614
615    if sd < 1e-300 {
616        return None; // zero variance
617    }
618
619    // Sort data
620    let mut x: Vec<f64> = data.to_vec();
621    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
622
623    let nf = n as f64;
624
625    // Compute A² statistic
626    let mut s = 0.0;
627    for i in 0..n {
628        let z = (x[i] - mean) / sd;
629        let z_rev = (x[n - 1 - i] - mean) / sd;
630        // Both tails evaluated directly, so an outlier contributes its true
631        // ln-probability instead of a subtraction that rounds to ln(0); the
632        // floor only guards arguments beyond the f64 range of the tail (|z| > 38).
633        let ln_phi = special::standard_normal_cdf(z).max(f64::MIN_POSITIVE).ln();
634        let ln_sf_rev = special::standard_normal_sf(z_rev)
635            .max(f64::MIN_POSITIVE)
636            .ln();
637
638        let coeff = (2 * (i + 1) - 1) as f64;
639        s += coeff * (ln_phi + ln_sf_rev);
640    }
641
642    let a2 = -nf - s / nf;
643
644    // Stephens (1986) correction for sample size
645    let a2_star = a2 * (1.0 + 0.75 / nf + 2.25 / (nf * nf));
646
647    // P-value from piecewise approximation (D'Agostino & Stephens 1986), with the
648    // large-A*² branch clamped to its valid range (see `ad_upper_tail_pvalue`).
649    let p = ad_upper_tail_pvalue(a2_star);
650
651    Some(AndersonDarlingResult {
652        statistic: a2,
653        statistic_star: a2_star,
654        p_value: p,
655    })
656}
657
658/// Result of the Shapiro-Wilk normality test.
659#[derive(Debug, Clone, Copy)]
660pub struct ShapiroWilkResult {
661    /// The W statistic (0 < W ≤ 1). Values close to 1 suggest normality.
662    pub w: f64,
663    /// The p-value. Small values reject the null hypothesis of normality.
664    pub p_value: f64,
665}
666
667/// Shapiro-Wilk normality test: H₀: data is normally distributed.
668///
669/// The most powerful general normality test for small to moderate samples.
670///
671/// # Algorithm
672///
673/// Uses the Royston (1992, 1995) algorithm (AS R94):
674/// 1. Compute coefficients from normal order statistics (Blom approximation)
675/// 2. Calculate W = (Σ aᵢ x₍ᵢ₎)² / Σ (xᵢ - x̄)²
676/// 3. Transform W to z-score via log-normal approximation
677/// 4. Compute p-value from standard normal distribution
678///
679/// # Supported range
680///
681/// n = 3..5000. Returns `None` outside this range.
682///
683/// # Returns
684///
685/// `None` if n < 3, n > 5000, all values identical, or non-finite values.
686///
687/// # References
688///
689/// - Shapiro & Wilk (1965). "An analysis of variance test for normality".
690///   Biometrika, 52(3–4), 591–611.
691/// - Royston (1992). "Approximating the Shapiro-Wilk W-test for
692///   non-normality". Statistics and Computing, 2, 117–119.
693/// - Royston (1995). "Remark AS R94: A remark on Algorithm AS 181".
694///   Applied Statistics, 44(4), 547–551.
695///
696/// # Examples
697///
698/// ```
699/// use u_analytics::testing::shapiro_wilk_test;
700///
701/// let data = [-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5];
702/// let r = shapiro_wilk_test(&data).unwrap();
703/// assert!(r.w > 0.9);
704/// assert!(r.p_value > 0.05); // cannot reject normality
705/// ```
706pub fn shapiro_wilk_test(data: &[f64]) -> Option<ShapiroWilkResult> {
707    let n = data.len();
708    if !(3..=5000).contains(&n) {
709        return None;
710    }
711    if data.iter().any(|v| !v.is_finite()) {
712        return None;
713    }
714
715    // Sort data
716    let mut x: Vec<f64> = data.to_vec();
717    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
718
719    if x[n - 1] - x[0] < 1e-300 {
720        return None; // all values identical
721    }
722
723    let nn2 = n / 2;
724
725    // Special case n = 3
726    if n == 3 {
727        return shapiro_wilk_n3(&x);
728    }
729
730    // Compute coefficients via Royston algorithm
731    let a = sw_coefficients(n, nn2)?;
732
733    // Compute W statistic
734    let w = sw_statistic(&x, &a, n, nn2);
735
736    if !(0.0..=1.0 + 1e-10).contains(&w) {
737        return None;
738    }
739    let w = w.min(1.0);
740
741    // Compute p-value
742    let p_value = sw_p_value(w, n);
743
744    Some(ShapiroWilkResult {
745        w,
746        p_value: p_value.clamp(0.0, 1.0),
747    })
748}
749
750// Shapiro-Wilk: n=3 exact formula
751fn shapiro_wilk_n3(x: &[f64]) -> Option<ShapiroWilkResult> {
752    // For n=3: a = [sqrt(1/2), 0, -sqrt(1/2)]
753    let a1 = std::f64::consts::FRAC_1_SQRT_2; // 0.7071...
754    let mean = (x[0] + x[1] + x[2]) / 3.0;
755    let ss = x.iter().map(|&v| (v - mean).powi(2)).sum::<f64>();
756    if ss < 1e-300 {
757        return None;
758    }
759
760    let numerator = a1 * (x[2] - x[0]);
761    let w = (numerator * numerator) / ss;
762    let w = w.clamp(0.75, 1.0);
763
764    // Exact p-value for n=3: p = 1 - (6/pi) * arccos(sqrt(w))
765    let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
766    let p = p.clamp(0.0, 1.0);
767
768    Some(ShapiroWilkResult { w, p_value: p })
769}
770
771// Royston polynomial coefficients (AS R94)
772const SW_C1: [f64; 6] = [0.0, 0.221157, -0.147981, -2.07119, 4.434685, -2.706056];
773const SW_C2: [f64; 6] = [0.0, 0.042981, -0.293762, -1.752461, 5.682633, -3.582633];
774const SW_C3: [f64; 4] = [0.544, -0.39978, 0.025054, -6.714e-4];
775const SW_C4: [f64; 4] = [1.3822, -0.77857, 0.062767, -0.0020322];
776const SW_C5: [f64; 4] = [-1.5861, -0.31082, -0.083751, 0.0038915];
777const SW_C6: [f64; 3] = [-0.4803, -0.082676, 0.0030302];
778const SW_G: [f64; 2] = [-2.273, 0.459];
779
780// Evaluate polynomial: c[0] + c[1]*x + c[2]*x^2 + ... (Horner's method)
781fn sw_poly(c: &[f64], x: f64) -> f64 {
782    let mut result = c[c.len() - 1];
783    for i in (0..c.len() - 1).rev() {
784        result = result * x + c[i];
785    }
786    result
787}
788
789// Compute Shapiro-Wilk coefficients using Royston's algorithm
790fn sw_coefficients(n: usize, nn2: usize) -> Option<Vec<f64>> {
791    let mut a = vec![0.0; nn2];
792
793    // Blom's approximation for expected normal order statistics
794    let mut m = vec![0.0; nn2];
795    let mut summ2 = 0.0;
796    for (i, mi) in m.iter_mut().enumerate() {
797        // m[i] corresponds to the (i+1)-th order statistic expectation
798        let p = (i as f64 + 1.0 - 0.375) / (n as f64 + 0.25);
799        *mi = special::inverse_normal_cdf(p);
800        summ2 += *mi * *mi;
801    }
802    summ2 *= 2.0;
803    let ssumm2 = summ2.sqrt();
804    let rsn = 1.0 / (n as f64).sqrt();
805
806    // First coefficient: polynomial correction
807    let a1 = sw_poly(&SW_C1, rsn) - m[0] / ssumm2;
808
809    if n <= 5 {
810        // For n=4,5: only a[0] is corrected
811        let fac_sq = summ2 - 2.0 * m[0] * m[0];
812        let one_minus = 1.0 - 2.0 * a1 * a1;
813        if fac_sq <= 0.0 || one_minus <= 0.0 {
814            return None;
815        }
816        let fac = (fac_sq / one_minus).sqrt();
817        a[0] = a1;
818        for i in 1..nn2 {
819            a[i] = -m[i] / fac;
820        }
821    } else {
822        // For n>5: a[0] and a[1] are corrected
823        let a2 = -m[1] / ssumm2 + sw_poly(&SW_C2, rsn);
824        let fac_sq = summ2 - 2.0 * m[0] * m[0] - 2.0 * m[1] * m[1];
825        let one_minus = 1.0 - 2.0 * a1 * a1 - 2.0 * a2 * a2;
826        if fac_sq <= 0.0 || one_minus <= 0.0 {
827            return None;
828        }
829        let fac = (fac_sq / one_minus).sqrt();
830        a[0] = a1;
831        a[1] = a2;
832        for i in 2..nn2 {
833            a[i] = -m[i] / fac;
834        }
835    }
836
837    Some(a)
838}
839
840// Compute W statistic from sorted data, coefficients, and range
841fn sw_statistic(x: &[f64], a: &[f64], n: usize, nn2: usize) -> f64 {
842    // Numerator: (sum a_i * (x_{n+1-i} - x_i))^2
843    let mut sa = 0.0;
844    for i in 0..nn2 {
845        sa += a[i] * (x[n - 1 - i] - x[i]);
846    }
847
848    // Denominator: sum of squares about the mean
849    let mean = x.iter().sum::<f64>() / n as f64;
850    let ss: f64 = x.iter().map(|&v| (v - mean).powi(2)).sum();
851
852    if ss < 1e-300 {
853        return 1.0; // degenerate
854    }
855
856    (sa * sa) / ss
857}
858
859// Compute p-value from W statistic using Royston's transformation
860fn sw_p_value(w: f64, n: usize) -> f64 {
861    let nf = n as f64;
862
863    if n == 3 {
864        // Should not reach here (handled separately), but just in case
865        let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
866        return p.clamp(0.0, 1.0);
867    }
868
869    let w1 = 1.0 - w;
870    if w1 <= 0.0 {
871        return 1.0; // perfectly normal
872    }
873
874    let y = w1.ln();
875
876    if n <= 11 {
877        // Small sample: gamma + log transformation
878        let gamma = sw_poly(&SW_G, nf);
879        if y >= gamma {
880            return 0.0; // extremely non-normal
881        }
882        let y2 = -(gamma - y).ln();
883        let m = sw_poly(&SW_C3, nf);
884        let s = sw_poly(&SW_C4, nf).exp();
885        if s < 1e-300 {
886            return 0.0;
887        }
888        let z = (y2 - m) / s;
889        special::standard_normal_sf(z)
890    } else {
891        // Large sample: log-normal transformation
892        let xx = nf.ln();
893        let m = sw_poly(&SW_C5, xx);
894        let s = sw_poly(&SW_C6, xx).exp();
895        if s < 1e-300 {
896            return 0.0;
897        }
898        let z = (y - m) / s;
899        special::standard_normal_sf(z)
900    }
901}
902
903// ---------------------------------------------------------------------------
904// Non-parametric tests
905// ---------------------------------------------------------------------------
906
907/// Mann-Whitney U test: H₀: the two populations have the same distribution.
908///
909/// Non-parametric alternative to the two-sample t-test. Does not assume
910/// normality.
911///
912/// # Algorithm
913///
914/// 1. Combine samples, rank all observations (average ranks for ties)
915/// 2. U₁ = R₁ - n₁(n₁+1)/2 where R₁ = sum of ranks in sample 1
916/// 3. p-value (two-sided):
917///    - **no ties and both samples ≤ 50**: the exact null distribution of U,
918///      by the recursion P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u)
919///    - otherwise: normal approximation with continuity correction,
920///      z = (|U₁ − μ| − ½) / σ, μ = n₁n₂/2, σ² with the tie correction
921///
922/// The normal approximation alone understates p for small samples: with
923/// three observations each and complete separation it gives 0.0495 where
924/// the smallest achievable p is 0.100.
925///
926/// # Returns
927///
928/// `None` if either sample has fewer than 2 observations or non-finite values.
929///
930/// # References
931///
932/// - Mann & Whitney (1947). "On a test of whether one of two random
933///   variables is stochastically larger than the other". Annals of
934///   Mathematical Statistics, 18(1), 50–60.
935///
936/// # Examples
937///
938/// ```
939/// use u_analytics::testing::mann_whitney_u_test;
940///
941/// let a = [1.0, 2.0, 3.0, 4.0, 5.0];
942/// let b = [6.0, 7.0, 8.0, 9.0, 10.0];
943/// let r = mann_whitney_u_test(&a, &b).unwrap();
944/// assert!(r.p_value < 0.05);
945/// ```
946pub fn mann_whitney_u_test(a: &[f64], b: &[f64]) -> Option<TestResult> {
947    let n1 = a.len();
948    let n2 = b.len();
949    if n1 < 2 || n2 < 2 {
950        return None;
951    }
952    if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
953        return None;
954    }
955
956    let n = n1 + n2;
957    let n1f = n1 as f64;
958    let n2f = n2 as f64;
959    let nf = n as f64;
960
961    // Combine and rank
962    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(n);
963    for &v in a {
964        combined.push((v, 0)); // group 0 = sample a
965    }
966    for &v in b {
967        combined.push((v, 1)); // group 1 = sample b
968    }
969    combined.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
970
971    // Assign average ranks and track ties
972    let ranks = average_ranks(&combined);
973
974    // Sum of ranks for sample a
975    let r1: f64 = combined
976        .iter()
977        .zip(ranks.iter())
978        .filter(|((_, g), _)| *g == 0)
979        .map(|(_, &r)| r)
980        .sum();
981
982    // U statistic
983    let u1 = r1 - n1f * (n1f + 1.0) / 2.0;
984
985    // Tie correction
986    let tie_correction = compute_tie_correction(&combined);
987
988    // Normal approximation
989    let mu = n1f * n2f / 2.0;
990    let sigma_sq = n1f * n2f / 12.0 * (nf + 1.0 - tie_correction / (nf * (nf - 1.0)));
991
992    if sigma_sq <= 0.0 {
993        return None;
994    }
995
996    let p_value = if tie_correction == 0.0 && n1 <= EXACT_MAX_N && n2 <= EXACT_MAX_N {
997        // Without ties U is an integer in 0..=n1·n2 and its null
998        // distribution is symmetric about n1·n2/2.
999        let u = u1.round() as usize;
1000        let tail = u.min(n1 * n2 - u);
1001        (2.0 * mann_whitney_exact_cdf(n1, n2, tail)).min(1.0)
1002    } else {
1003        continuity_corrected_p(u1, mu, sigma_sq)
1004    };
1005
1006    Some(TestResult {
1007        statistic: u1,
1008        df: 0.0, // not applicable for non-parametric
1009        p_value,
1010    })
1011}
1012
1013/// Largest sample size for which the rank tests use their exact null
1014/// distribution (when there are no ties). Above it the normal approximation
1015/// with continuity correction is accurate to well under 0.001 in p.
1016const EXACT_MAX_N: usize = 50;
1017
1018/// Two-sided p of a rank statistic by the normal approximation with
1019/// continuity correction.
1020fn continuity_corrected_p(statistic: f64, mu: f64, sigma_sq: f64) -> f64 {
1021    let z = ((statistic - mu).abs() - 0.5).max(0.0) / sigma_sq.sqrt();
1022    (2.0 * special::standard_normal_sf(z)).min(1.0)
1023}
1024
1025/// P(U ≤ u) for the Mann-Whitney statistic with sample sizes `m`, `n` and
1026/// no ties.
1027///
1028/// Every arrangement of the two samples' ranks is equally likely under H₀,
1029/// and the last-ranked observation belongs to the first sample with
1030/// probability m/(m+n), in which case it adds n to U:
1031/// P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u).
1032/// O(m·n·(m·n)) time, O(n·m·n) memory -- a few megabytes at m = n = 50.
1033fn mann_whitney_exact_cdf(m: usize, n: usize, u: usize) -> f64 {
1034    let max_u = m * n;
1035    // row[j] = distribution of U for (i, j), indices 0..=i·j.
1036    let mut prev: Vec<Vec<f64>> = (0..=n).map(|_| vec![1.0]).collect(); // i = 0
1037    for i in 1..=m {
1038        let mut cur: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
1039        cur.push(vec![1.0]); // j = 0: U = 0
1040        for j in 1..=n {
1041            let mut dist = vec![0.0; i * j + 1];
1042            let wi = i as f64 / (i + j) as f64;
1043            let wj = j as f64 / (i + j) as f64;
1044            // Last observation from sample 1: U shifts by j.
1045            for (k, &pk) in prev[j].iter().enumerate() {
1046                dist[k + j] += wi * pk;
1047            }
1048            // Last observation from sample 2: U unchanged.
1049            for (k, &pk) in cur[j - 1].iter().enumerate() {
1050                dist[k] += wj * pk;
1051            }
1052            cur.push(dist);
1053        }
1054        prev = cur;
1055    }
1056    prev[n].iter().take(u.min(max_u) + 1).sum::<f64>().min(1.0)
1057}
1058
1059/// P(T⁺ ≤ t) for the Wilcoxon signed-rank statistic with `n` non-zero,
1060/// untied differences: each rank enters T⁺ with probability ½ independently.
1061fn wilcoxon_exact_cdf(n: usize, t: usize) -> f64 {
1062    let max_t = n * (n + 1) / 2;
1063    let mut dist = vec![0.0; max_t + 1];
1064    dist[0] = 1.0;
1065    let mut reach = 0;
1066    for rank in 1..=n {
1067        reach += rank;
1068        for s in (rank..=reach).rev() {
1069            dist[s] = 0.5 * dist[s] + 0.5 * dist[s - rank];
1070        }
1071        for d in dist.iter_mut().take(rank) {
1072            *d *= 0.5;
1073        }
1074    }
1075    dist.iter().take(t.min(max_t) + 1).sum::<f64>().min(1.0)
1076}
1077
1078/// Wilcoxon signed-rank test: H₀: median of differences = 0.
1079///
1080/// Non-parametric alternative to the paired t-test. Does not assume
1081/// normality of differences.
1082///
1083/// # Algorithm
1084///
1085/// 1. Compute differences dᵢ = xᵢ - yᵢ, discard zeros
1086/// 2. Rank |dᵢ| (average ranks for ties)
1087/// 3. T⁺ = sum of ranks where dᵢ > 0
1088/// 4. p-value (two-sided):
1089///    - **no tied |dᵢ| and n ≤ 50**: the exact null distribution of T⁺
1090///      (each rank enters T⁺ with probability ½)
1091///    - otherwise: normal approximation with continuity correction,
1092///      z = (|T⁺ − μ| − ½) / σ, μ = n(n+1)/4, σ² with the tie correction
1093///
1094/// The normal approximation alone understates p for small samples: five
1095/// differences all of one sign give 0.043 where the exact p is 0.0625.
1096///
1097/// # Returns
1098///
1099/// `None` if fewer than 2 non-zero differences, slices differ in length,
1100/// or non-finite values.
1101///
1102/// # References
1103///
1104/// - Wilcoxon (1945). "Individual comparisons by ranking methods".
1105///   Biometrics Bulletin, 1(6), 80–83.
1106///
1107/// # Examples
1108///
1109/// ```
1110/// use u_analytics::testing::wilcoxon_signed_rank_test;
1111///
1112/// let before = [5.0, 6.0, 7.0, 8.0, 9.0];
1113/// let after  = [6.0, 7.5, 8.0, 9.5, 11.0];
1114/// let r = wilcoxon_signed_rank_test(&after, &before).unwrap();
1115/// assert!(r.statistic > 0.0); // T+ sum of positive ranks
1116/// ```
1117pub fn wilcoxon_signed_rank_test(x: &[f64], y: &[f64]) -> Option<TestResult> {
1118    if x.len() != y.len() || x.len() < 2 {
1119        return None;
1120    }
1121    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
1122        return None;
1123    }
1124
1125    // Compute differences and discard zeros
1126    let diffs: Vec<f64> = x
1127        .iter()
1128        .zip(y.iter())
1129        .map(|(&a, &b)| a - b)
1130        .filter(|&d| d.abs() > 1e-300)
1131        .collect();
1132
1133    let nr = diffs.len();
1134    if nr < 2 {
1135        return None;
1136    }
1137
1138    let nf = nr as f64;
1139
1140    // Sort by absolute difference and rank
1141    let mut abs_diffs: Vec<(f64, usize)> = diffs
1142        .iter()
1143        .enumerate()
1144        .map(|(i, &d)| (d.abs(), i))
1145        .collect();
1146    abs_diffs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1147
1148    // Assign average ranks
1149    let ranks = average_ranks(&abs_diffs);
1150
1151    // T+ = sum of ranks where the original difference is positive
1152    let t_plus: f64 = abs_diffs
1153        .iter()
1154        .zip(ranks.iter())
1155        .filter(|((_, orig_idx), _)| diffs[*orig_idx] > 0.0)
1156        .map(|(_, &r)| r)
1157        .sum();
1158
1159    // Tie correction for variance
1160    let tie_correction_val = compute_tie_correction(&abs_diffs);
1161
1162    // Normal approximation
1163    let mu = nf * (nf + 1.0) / 4.0;
1164    let sigma_sq = nf * (nf + 1.0) * (2.0 * nf + 1.0) / 24.0 - tie_correction_val / 48.0;
1165
1166    if sigma_sq <= 0.0 {
1167        return None;
1168    }
1169
1170    let p_value = if tie_correction_val == 0.0 && nr <= EXACT_MAX_N {
1171        // Without ties T⁺ is an integer in 0..=n(n+1)/2, symmetric about
1172        // n(n+1)/4.
1173        let t = t_plus.round() as usize;
1174        let tail = t.min(nr * (nr + 1) / 2 - t);
1175        (2.0 * wilcoxon_exact_cdf(nr, tail)).min(1.0)
1176    } else {
1177        continuity_corrected_p(t_plus, mu, sigma_sq)
1178    };
1179
1180    Some(TestResult {
1181        statistic: t_plus,
1182        df: 0.0,
1183        p_value,
1184    })
1185}
1186
1187// Assign average ranks to sorted (value, group_or_index) pairs.
1188// Handles ties by assigning the average of the tied ranks.
1189fn average_ranks(sorted: &[(f64, usize)]) -> Vec<f64> {
1190    let n = sorted.len();
1191    let mut ranks = vec![0.0; n];
1192    let mut i = 0;
1193    while i < n {
1194        let mut j = i + 1;
1195        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1196            j += 1;
1197        }
1198        // Positions i..j are tied; average rank = (i+1 + j) / 2
1199        let avg_rank = (i + 1 + j) as f64 / 2.0;
1200        for rank in ranks.iter_mut().take(j).skip(i) {
1201            *rank = avg_rank;
1202        }
1203        i = j;
1204    }
1205    ranks
1206}
1207
1208// Compute tie correction factor: Σ tₖ(tₖ² - 1) for all tie groups
1209fn compute_tie_correction(sorted: &[(f64, usize)]) -> f64 {
1210    let n = sorted.len();
1211    let mut correction = 0.0;
1212    let mut i = 0;
1213    while i < n {
1214        let mut j = i + 1;
1215        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1216            j += 1;
1217        }
1218        let t = (j - i) as f64;
1219        if t > 1.0 {
1220            correction += t * (t * t - 1.0);
1221        }
1222        i = j;
1223    }
1224    correction
1225}
1226
1227/// Kruskal-Wallis test: H₀: all groups have the same distribution.
1228///
1229/// Non-parametric alternative to one-way ANOVA. Does not assume normality.
1230///
1231/// # Algorithm
1232///
1233/// 1. Combine all groups, rank observations (average ranks for ties)
1234/// 2. H = (12 / N(N+1)) Σ nᵢ (R̄ᵢ - R̄)² with tie correction
1235/// 3. H ~ χ²(k-1) under H₀
1236///
1237/// # Returns
1238///
1239/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
1240/// or non-finite values.
1241///
1242/// # References
1243///
1244/// - Kruskal & Wallis (1952). "Use of ranks in one-criterion variance
1245///   analysis". JASA, 47(260), 583–621.
1246///
1247/// # Examples
1248///
1249/// ```
1250/// use u_analytics::testing::kruskal_wallis_test;
1251///
1252/// let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
1253/// let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
1254/// let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
1255/// let r = kruskal_wallis_test(&[&g1, &g2, &g3]).unwrap();
1256/// assert!(r.p_value < 0.01);
1257/// ```
1258pub fn kruskal_wallis_test(groups: &[&[f64]]) -> Option<TestResult> {
1259    let k = groups.len();
1260    if k < 2 {
1261        return None;
1262    }
1263    for g in groups {
1264        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1265            return None;
1266        }
1267    }
1268
1269    let total_n: usize = groups.iter().map(|g| g.len()).sum();
1270    let nf = total_n as f64;
1271
1272    // Combine all observations with group labels
1273    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(total_n);
1274    for (gi, g) in groups.iter().enumerate() {
1275        for &v in *g {
1276            combined.push((v, gi));
1277        }
1278    }
1279    combined.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1280
1281    let ranks = average_ranks(&combined);
1282
1283    // Sum of ranks per group
1284    let mut rank_sums = vec![0.0; k];
1285    for ((_, gi), &r) in combined.iter().zip(ranks.iter()) {
1286        rank_sums[*gi] += r;
1287    }
1288
1289    // H statistic: H = (12 / N(N+1)) * Σ Rᵢ²/nᵢ - 3(N+1)
1290    let mean_rank = (nf + 1.0) / 2.0;
1291    let mut h = 0.0;
1292    for (gi, g) in groups.iter().enumerate() {
1293        let ni = g.len() as f64;
1294        let mean_rank_i = rank_sums[gi] / ni;
1295        h += ni * (mean_rank_i - mean_rank).powi(2);
1296    }
1297    h *= 12.0 / (nf * (nf + 1.0));
1298
1299    // Tie correction: divide by 1 - Σtₖ(tₖ²-1) / (N³ - N)
1300    let tie_corr = compute_tie_correction(&combined);
1301    let denom = 1.0 - tie_corr / (nf * nf * nf - nf);
1302    if denom > 1e-15 {
1303        h /= denom;
1304    }
1305
1306    let df = (k - 1) as f64;
1307    let p_value = special::chi_squared_sf(h, df);
1308
1309    Some(TestResult {
1310        statistic: h,
1311        df,
1312        p_value,
1313    })
1314}
1315
1316// ---------------------------------------------------------------------------
1317// Variance tests
1318// ---------------------------------------------------------------------------
1319
1320/// Levene test for equality of variances: H₀: all groups have equal variance.
1321///
1322/// Robust to non-normality. Uses the **median** variant (Brown-Forsythe),
1323/// which is recommended for non-normal data.
1324///
1325/// # Algorithm
1326///
1327/// 1. Compute zᵢⱼ = |xᵢⱼ - median(groupᵢ)|
1328/// 2. Apply one-way ANOVA on the zᵢⱼ values
1329///
1330/// # Returns
1331///
1332/// `None` if fewer than 2 groups, any group < 2 observations, or non-finite values.
1333///
1334/// # References
1335///
1336/// - Levene (1960). "Robust tests for equality of variances". In
1337///   Olkin (Ed.), Contributions to Probability and Statistics.
1338/// - Brown & Forsythe (1974). "Robust tests for the equality of variances".
1339///   JASA, 69(346), 364–367.
1340///
1341/// # Examples
1342///
1343/// ```
1344/// use u_analytics::testing::levene_test;
1345///
1346/// let g1 = [4.9, 5.0, 5.0, 5.1, 5.0]; // tight cluster (low variance)
1347/// let g2 = [0.0, 3.0, 5.0, 7.0, 10.0]; // wide spread (high variance)
1348/// let r = levene_test(&[&g1, &g2]).unwrap();
1349/// assert!(r.p_value < 0.05); // clear variance difference
1350/// ```
1351pub fn levene_test(groups: &[&[f64]]) -> Option<TestResult> {
1352    let k = groups.len();
1353    if k < 2 {
1354        return None;
1355    }
1356    for g in groups {
1357        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1358            return None;
1359        }
1360    }
1361
1362    // Compute z-values: |x - median(group)| (Brown-Forsythe variant)
1363    let z_groups: Vec<Vec<f64>> = groups
1364        .iter()
1365        .map(|g| {
1366            let median = stats::median(g).unwrap_or(0.0);
1367            g.iter().map(|&x| (x - median).abs()).collect()
1368        })
1369        .collect();
1370
1371    // Apply ANOVA on the z-values
1372    let z_refs: Vec<&[f64]> = z_groups.iter().map(|v| v.as_slice()).collect();
1373    let anova = one_way_anova(&z_refs)?;
1374
1375    Some(TestResult {
1376        statistic: anova.f_statistic,
1377        df: anova.df_between as f64,
1378        p_value: anova.p_value,
1379    })
1380}
1381
1382// ---------------------------------------------------------------------------
1383// Multiple comparison correction
1384// ---------------------------------------------------------------------------
1385
1386/// Bonferroni correction: adjusts p-values for multiple comparisons.
1387///
1388/// adjusted_pᵢ = min(pᵢ × m, 1.0) where m = number of tests.
1389///
1390/// # Returns
1391///
1392/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1393/// (a NaN included).
1394pub fn bonferroni_correction(p_values: &[f64]) -> Option<Vec<f64>> {
1395    if p_values.is_empty() || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1396        return None;
1397    }
1398    let m = p_values.len() as f64;
1399    Some(p_values.iter().map(|&p| (p * m).min(1.0)).collect())
1400}
1401
1402/// Benjamini-Hochberg FDR correction.
1403///
1404/// Controls false discovery rate at level α.
1405///
1406/// # Algorithm
1407///
1408/// 1. Sort p-values.
1409/// 2. For rank i (1-indexed): adjusted_pᵢ = pᵢ × m / i.
1410/// 3. Enforce monotonicity (cumulative minimum from right).
1411///
1412/// # Returns
1413///
1414/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1415/// (a NaN included).
1416///
1417/// # References
1418///
1419/// Benjamini & Hochberg (1995). "Controlling the false discovery rate".
1420/// JRSS-B, 57(1), 289–300.
1421pub fn benjamini_hochberg(p_values: &[f64]) -> Option<Vec<f64>> {
1422    let m = p_values.len();
1423    if m == 0 || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1424        return None;
1425    }
1426
1427    // Sort indices by p-value
1428    let mut indices: Vec<usize> = (0..m).collect();
1429    indices.sort_by(|&a, &b| {
1430        p_values[a]
1431            .partial_cmp(&p_values[b])
1432            .unwrap_or(std::cmp::Ordering::Equal)
1433    });
1434
1435    let mf = m as f64;
1436    let mut adjusted = vec![0.0; m];
1437
1438    // Compute adjusted p-values
1439    let mut cummin = f64::INFINITY;
1440    for (rank_rev, &orig_idx) in indices.iter().enumerate().rev() {
1441        let rank = rank_rev + 1; // 1-indexed
1442        let adj = (p_values[orig_idx] * mf / rank as f64).min(1.0);
1443        cummin = cummin.min(adj);
1444        adjusted[orig_idx] = cummin;
1445    }
1446
1447    Some(adjusted)
1448}
1449
1450// ---------------------------------------------------------------------------
1451// Bartlett test for equality of variances
1452// ---------------------------------------------------------------------------
1453
1454/// Bartlett test for equality of variances: H₀: all groups have equal variance.
1455///
1456/// Assumes data are from **normal** distributions. For non-normal data, prefer
1457/// [`levene_test`] (Brown-Forsythe variant).
1458///
1459/// # Algorithm
1460///
1461/// 1. Compute pooled variance: s²ₚ = Σ(nᵢ-1)s²ᵢ / (N-k)
1462/// 2. Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1463/// 3. Correction factor: C = 1 + [1/(3(k-1))] × [Σ 1/(nᵢ-1) - 1/(N-k)]
1464/// 4. Statistic: T = numerator / C ~ χ²(k-1)
1465///
1466/// # Returns
1467///
1468/// `None` if fewer than 2 groups, any group < 2 observations, any group has
1469/// zero variance, or non-finite values.
1470///
1471/// # References
1472///
1473/// - Bartlett (1937). "Properties of sufficiency and statistical tests".
1474///   Proceedings of the Royal Society A, 160(901), 268–282.
1475///
1476/// # Examples
1477///
1478/// ```
1479/// use u_analytics::testing::bartlett_test;
1480///
1481/// let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // variance ~2.5
1482/// let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // variance ~250
1483/// let r = bartlett_test(&[&g1, &g2]).unwrap();
1484/// assert!(r.p_value < 0.01); // strongly different variances
1485/// ```
1486pub fn bartlett_test(groups: &[&[f64]]) -> Option<TestResult> {
1487    let k = groups.len();
1488    if k < 2 {
1489        return None;
1490    }
1491
1492    let mut sizes = Vec::with_capacity(k);
1493    let mut vars = Vec::with_capacity(k);
1494    let mut n_total: usize = 0;
1495
1496    for g in groups {
1497        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1498            return None;
1499        }
1500        let n = g.len();
1501        let v = stats::variance(g)?;
1502        if v <= 0.0 {
1503            return None; // zero variance → ln undefined
1504        }
1505        sizes.push(n);
1506        vars.push(v);
1507        n_total += n;
1508    }
1509
1510    let nk = n_total - k; // N - k
1511    if nk == 0 {
1512        return None;
1513    }
1514    let nk_f = nk as f64;
1515
1516    // Pooled variance
1517    let s2_pooled: f64 = sizes
1518        .iter()
1519        .zip(vars.iter())
1520        .map(|(&n, &v)| (n as f64 - 1.0) * v)
1521        .sum::<f64>()
1522        / nk_f;
1523
1524    if s2_pooled <= 0.0 {
1525        return None;
1526    }
1527
1528    // Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1529    let num = nk_f * s2_pooled.ln()
1530        - sizes
1531            .iter()
1532            .zip(vars.iter())
1533            .map(|(&n, &v)| (n as f64 - 1.0) * v.ln())
1534            .sum::<f64>();
1535
1536    // Correction factor C
1537    let sum_recip: f64 = sizes.iter().map(|&n| 1.0 / (n as f64 - 1.0)).sum();
1538    let c = 1.0 + (sum_recip - 1.0 / nk_f) / (3.0 * (k as f64 - 1.0));
1539
1540    let statistic = num / c;
1541    let df = (k - 1) as f64;
1542    let p_value = special::chi_squared_sf(statistic, df);
1543
1544    Some(TestResult {
1545        statistic,
1546        df,
1547        p_value,
1548    })
1549}
1550
1551// ---------------------------------------------------------------------------
1552// Fisher exact test (2×2)
1553// ---------------------------------------------------------------------------
1554
1555/// Fisher exact test for a 2×2 contingency table.
1556///
1557/// Tests H₀: the two categorical variables are independent.
1558/// Unlike the chi-squared test, this is exact and valid for small samples.
1559///
1560/// # Arguments
1561///
1562/// The 2×2 table is specified as four cell counts:
1563///
1564/// ```text
1565///          Col1   Col2
1566///   Row1 |  a   |  b  |
1567///   Row2 |  c   |  d  |
1568/// ```
1569///
1570/// # Algorithm
1571///
1572/// 1. Compute probability of observed table via hypergeometric distribution
1573///    (using log-factorials for numerical stability).
1574/// 2. Enumerate all tables with the same marginals.
1575/// 3. Two-tailed p-value = sum of probabilities ≤ P(observed).
1576///
1577/// # Returns
1578///
1579/// `None` if any marginal total is zero (degenerate table).
1580///
1581/// # References
1582///
1583/// - Fisher (1922). "On the interpretation of χ² from contingency tables,
1584///   and the calculation of P". JRSS, 85(1), 87–94.
1585///
1586/// # Examples
1587///
1588/// ```
1589/// use u_analytics::testing::fisher_exact_test;
1590///
1591/// // Tea-tasting experiment
1592/// let r = fisher_exact_test(3, 1, 1, 3).unwrap();
1593/// assert!(r.p_value > 0.05); // not significant at 5%
1594/// ```
1595pub fn fisher_exact_test(a: u64, b: u64, c: u64, d: u64) -> Option<TestResult> {
1596    let row1 = a + b;
1597    let row2 = c + d;
1598    let col1 = a + c;
1599    let col2 = b + d;
1600    let n = a + b + c + d;
1601
1602    // Degenerate if any marginal is zero
1603    if row1 == 0 || row2 == 0 || col1 == 0 || col2 == 0 {
1604        return None;
1605    }
1606
1607    // Log-probability of a specific table given marginals
1608    let log_prob = |a_i: u64| -> f64 {
1609        let b_i = row1 - a_i;
1610        let c_i = col1 - a_i;
1611        let d_i = row2 - c_i;
1612        ln_factorial(row1) + ln_factorial(row2) + ln_factorial(col1) + ln_factorial(col2)
1613            - ln_factorial(a_i)
1614            - ln_factorial(b_i)
1615            - ln_factorial(c_i)
1616            - ln_factorial(d_i)
1617            - ln_factorial(n)
1618    };
1619
1620    // Range of valid values for cell a
1621    let a_min = col1.saturating_sub(row2);
1622    let a_max = row1.min(col1);
1623
1624    let log_p_obs = log_prob(a);
1625
1626    // Two-tailed: sum probabilities ≤ P(observed)
1627    let mut p_value = 0.0;
1628    for a_i in a_min..=a_max {
1629        let lp = log_prob(a_i);
1630        // Use small tolerance for floating-point comparison
1631        if lp <= log_p_obs + 1e-10 {
1632            p_value += lp.exp();
1633        }
1634    }
1635
1636    // Clamp to [0, 1]
1637    let p_value = p_value.min(1.0);
1638
1639    // Odds ratio: (a*d) / (b*c)
1640    let odds_ratio = if b > 0 && c > 0 {
1641        (a as f64 * d as f64) / (b as f64 * c as f64)
1642    } else {
1643        f64::INFINITY
1644    };
1645
1646    Some(TestResult {
1647        statistic: odds_ratio,
1648        df: 1.0,
1649        p_value,
1650    })
1651}
1652
1653// ---------------------------------------------------------------------------
1654// Mann-Kendall trend test
1655// ---------------------------------------------------------------------------
1656
1657/// Result of the Mann-Kendall trend test.
1658#[derive(Debug, Clone, Copy)]
1659pub struct MannKendallResult {
1660    /// Mann-Kendall S statistic: Σ sign(xⱼ - xᵢ) for all i < j.
1661    pub s_statistic: i64,
1662    /// Variance of S (with tie correction).
1663    pub variance: f64,
1664    /// Z statistic (with continuity correction).
1665    pub z_statistic: f64,
1666    /// Two-tailed p-value.
1667    pub p_value: f64,
1668    /// Kendall's tau: S / [n(n-1)/2]. Range [-1, 1].
1669    pub kendall_tau: f64,
1670    /// Sen's slope estimator: median of (xⱼ - xᵢ)/(j - i) for all i < j.
1671    pub sen_slope: f64,
1672}
1673
1674/// Mann-Kendall non-parametric trend test with Sen's slope estimator.
1675///
1676/// Tests H₀: no monotonic trend vs H₁: monotonic trend exists.
1677/// Assumes serially independent observations (no autocorrelation).
1678///
1679/// # Algorithm
1680///
1681/// 1. S = Σᵢ<ⱼ sign(xⱼ - xᵢ)
1682/// 2. Var(S) = \[n(n-1)(2n+5) - Σ tₖ(tₖ-1)(2tₖ+5)\] / 18 (tie-corrected)
1683/// 3. Z = (S-1)/√Var(S) if S>0, 0 if S=0, (S+1)/√Var(S) if S<0
1684/// 4. Sen's slope = median of all pairwise slopes (xⱼ - xᵢ)/(j - i)
1685///
1686/// References:
1687/// - Mann (1945), "Nonparametric tests against trend"
1688/// - Kendall (1975), "Rank Correlation Methods"
1689/// - Sen (1968), "Estimates of the regression coefficient based on Kendall's tau"
1690///
1691/// # Complexity
1692///
1693/// O(n² log n) — pairwise comparisons O(n²) plus median finding O(n² log n²).
1694///
1695/// # Returns
1696///
1697/// `None` if fewer than 4 data points, non-finite values, or zero variance.
1698///
1699/// # Examples
1700///
1701/// ```
1702/// use u_analytics::testing::mann_kendall_test;
1703///
1704/// // Clear upward trend
1705/// let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
1706/// let r = mann_kendall_test(&data).unwrap();
1707/// assert!(r.p_value < 0.01);
1708/// assert!(r.kendall_tau > 0.8);
1709/// assert!(r.sen_slope > 0.0);
1710/// ```
1711pub fn mann_kendall_test(data: &[f64]) -> Option<MannKendallResult> {
1712    let n = data.len();
1713    if n < 4 || data.iter().any(|v| !v.is_finite()) {
1714        return None;
1715    }
1716
1717    // Step 1: Compute S statistic
1718    let mut s: i64 = 0;
1719    for i in 0..n - 1 {
1720        for j in (i + 1)..n {
1721            let diff = data[j] - data[i];
1722            if diff > 0.0 {
1723                s += 1;
1724            } else if diff < 0.0 {
1725                s -= 1;
1726            }
1727        }
1728    }
1729
1730    // Step 2: Compute tie groups
1731    let mut sorted: Vec<f64> = data.to_vec();
1732    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1733
1734    let mut tie_correction: f64 = 0.0;
1735    let mut current_count: usize = 1;
1736    for i in 1..sorted.len() {
1737        if (sorted[i] - sorted[i - 1]).abs() < 1e-10 {
1738            current_count += 1;
1739        } else {
1740            if current_count > 1 {
1741                let t = current_count as f64;
1742                tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1743            }
1744            current_count = 1;
1745        }
1746    }
1747    if current_count > 1 {
1748        let t = current_count as f64;
1749        tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1750    }
1751
1752    // Step 3: Variance with tie correction
1753    let nf = n as f64;
1754    let variance = (nf * (nf - 1.0) * (2.0 * nf + 5.0) - tie_correction) / 18.0;
1755
1756    if variance < 1e-300 {
1757        return None; // All values identical
1758    }
1759
1760    // Step 4: Z with continuity correction
1761    let sigma = variance.sqrt();
1762    let z_statistic = if s > 0 {
1763        (s as f64 - 1.0) / sigma
1764    } else if s < 0 {
1765        (s as f64 + 1.0) / sigma
1766    } else {
1767        0.0
1768    };
1769
1770    // Step 5: Two-tailed p-value
1771    let p_value = 2.0 * special::standard_normal_sf(z_statistic.abs());
1772    let p_value = p_value.clamp(0.0, 1.0);
1773
1774    // Step 6: Kendall's tau
1775    let kendall_tau = (2 * s) as f64 / (nf * (nf - 1.0));
1776
1777    // Step 7: Sen's slope = median of pairwise slopes
1778    let mut slopes = Vec::with_capacity(n * (n - 1) / 2);
1779    for i in 0..n - 1 {
1780        for j in (i + 1)..n {
1781            let dx = (j - i) as f64;
1782            slopes.push((data[j] - data[i]) / dx);
1783        }
1784    }
1785    slopes.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1786    let m = slopes.len();
1787    let sen_slope = if m % 2 == 0 {
1788        (slopes[m / 2 - 1] + slopes[m / 2]) / 2.0
1789    } else {
1790        slopes[m / 2]
1791    };
1792
1793    Some(MannKendallResult {
1794        s_statistic: s,
1795        variance,
1796        z_statistic,
1797        p_value,
1798        kendall_tau,
1799        sen_slope,
1800    })
1801}
1802
1803/// Natural log of n! using Stirling/ln_gamma for large values.
1804fn ln_factorial(n: u64) -> f64 {
1805    if n <= 1 {
1806        return 0.0;
1807    }
1808    // ln(n!) = ln_gamma(n+1)
1809    special::ln_gamma(n as f64 + 1.0)
1810}
1811
1812// ---------------------------------------------------------------------------
1813// KPSS stationarity test
1814// ---------------------------------------------------------------------------
1815
1816/// What the KPSS null hypothesis says the series is stationary around.
1817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1818pub enum KpssModel {
1819    /// A constant level (statsmodels `regression="c"`).
1820    Level,
1821    /// A linear trend (statsmodels `regression="ct"`).
1822    Trend,
1823}
1824
1825/// The truncation lag of the Newey-West long-run variance in [`kpss_test`].
1826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827pub enum KpssLags {
1828    /// Hobijn, Franses & Ooms (1998): chosen from the residuals' own
1829    /// autocovariances (statsmodels' default, `nlags="auto"`).
1830    Hobijn,
1831    /// Schwert (1989): `⌈12·(n/100)^¼⌉` (statsmodels `nlags="legacy"`).
1832    Schwert,
1833    /// Exactly this many, below n.
1834    Fixed(usize),
1835}
1836
1837/// Where a [`KpssResult::p_value`] stands relative to the table it is read from.
1838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1839pub enum KpssPValue {
1840    /// Interpolated inside the table (between 0.01 and 0.10).
1841    Interpolated,
1842    /// The statistic is beyond the 1 % critical value: the p-value is at most 0.01.
1843    AtMost,
1844    /// The statistic is below the 10 % critical value: the p-value is at least 0.10.
1845    AtLeast,
1846}
1847
1848/// Result of the KPSS stationarity test.
1849#[derive(Debug, Clone)]
1850pub struct KpssResult {
1851    /// The KPSS statistic η / σ̂² (Kwiatkowski et al. 1992, eq. 11).
1852    pub statistic: f64,
1853    /// The truncation lag of the long-run variance.
1854    pub n_lags: usize,
1855    /// p-value, linearly interpolated in the table of critical values and
1856    /// clamped to [0.01, 0.10] — see [`KpssResult::p_value_bound`].
1857    pub p_value: f64,
1858    /// Whether `p_value` is interpolated or a bound of the table.
1859    pub p_value_bound: KpssPValue,
1860    /// Critical values at 10 %, 5 %, 2.5 % and 1 % (Kwiatkowski et al. 1992, Table 1).
1861    pub critical_values: [f64; 4],
1862    /// `statistic > critical_value` at each of those levels: stationarity is rejected.
1863    pub rejected: [bool; 4],
1864}
1865
1866/// Significance levels of [`KpssResult::critical_values`], in order.
1867pub const KPSS_LEVELS: [f64; 4] = [0.10, 0.05, 0.025, 0.01];
1868
1869/// The fewest values [`kpss_test`] takes.
1870pub const KPSS_MIN_OBSERVATIONS: usize = 10;
1871
1872/// KPSS test of stationarity: H₀ the series is stationary around a level (or
1873/// a trend), H₁ it has a unit root.
1874///
1875/// The complement of [`adf_test`], whose null is the unit root: a series ADF
1876/// does not reject and KPSS does is non-stationary; differencing it once and
1877/// testing again is how the number of differences is chosen (Hyndman &
1878/// Khandakar 2008, `ndiffs`).
1879///
1880/// # Algorithm
1881///
1882/// Residuals eₜ of the series on a constant (or constant + trend);
1883/// η = Σ Sₜ² / n² with Sₜ the partial sums of eₜ; σ̂² the Newey-West long-run
1884/// variance with Bartlett weights 1 − i/(l + 1); statistic η / σ̂². The p-value
1885/// is interpolated in the table of critical values.
1886///
1887/// # Errors
1888///
1889/// [`TimeSeriesError`]: a non-finite value, fewer than 10 values, a fixed
1890/// lag outside `[0, n − 1]` (`lags`), or no variation about the level or
1891/// trend (σ̂² not positive against the series' size).
1892///
1893/// # References
1894///
1895/// - Kwiatkowski, Phillips, Schmidt & Shin (1992). "Testing the null
1896///   hypothesis of stationarity against the alternative of a unit root."
1897///   *Journal of Econometrics*, 54, 159–178.
1898/// - Hobijn, Franses & Ooms (1998). "Generalisations of the KPSS-test for
1899///   stationarity." Econometric Institute Report 9802/A.
1900/// - Newey & West (1994). "Automatic lag selection in covariance matrix
1901///   estimation." *Review of Economic Studies*, 61(4), 631–653.
1902///
1903/// # Examples
1904///
1905/// ```
1906/// use u_analytics::testing::{kpss_test, KpssLags, KpssModel};
1907///
1908/// // A walk that drifts: stationarity around a level is rejected at 5 %.
1909/// let mut walk = vec![0.0];
1910/// for i in 1..80 {
1911///     walk.push(walk[i - 1] + 1.0 + 0.1 * (((i * 7919) % 13) as f64 - 6.0));
1912/// }
1913/// let r = kpss_test(&walk, KpssModel::Level, KpssLags::Hobijn).unwrap();
1914/// assert!(r.rejected[1]);
1915/// ```
1916pub fn kpss_test(
1917    data: &[f64],
1918    model: KpssModel,
1919    lags: KpssLags,
1920) -> Result<KpssResult, TimeSeriesError> {
1921    check_series(data, KPSS_MIN_OBSERVATIONS)?;
1922    let n = data.len();
1923    if let KpssLags::Fixed(l) = lags {
1924        check_option("lags", l, 0, n - 1)?;
1925    }
1926    let nf = n as f64;
1927
1928    let residuals: Vec<f64> = match model {
1929        KpssModel::Level => {
1930            let mean = data.iter().sum::<f64>() / nf;
1931            data.iter().map(|x| x - mean).collect()
1932        }
1933        KpssModel::Trend => {
1934            // OLS on 1 and t = 1..n.
1935            let t_mean = (nf + 1.0) / 2.0;
1936            let y_mean = data.iter().sum::<f64>() / nf;
1937            let (mut sty, mut stt) = (0.0, 0.0);
1938            for (i, y) in data.iter().enumerate() {
1939                let dt = (i + 1) as f64 - t_mean;
1940                sty += dt * (y - y_mean);
1941                stt += dt * dt;
1942            }
1943            let slope = sty / stt;
1944            data.iter()
1945                .enumerate()
1946                .map(|(i, y)| y - y_mean - slope * ((i + 1) as f64 - t_mean))
1947                .collect()
1948        }
1949    };
1950
1951    // No variation beyond the rounding of the fit, against the series' size.
1952    let sum_sq: f64 = residuals.iter().map(|e| e * e).sum();
1953    let size: f64 = data.iter().map(|x| x * x).sum();
1954    if sum_sq <= 1e-20 * size {
1955        return Err(TimeSeriesError::NoVariation);
1956    }
1957
1958    let autocovariance = |i: usize| -> f64 {
1959        residuals[i..]
1960            .iter()
1961            .zip(&residuals[..n - i])
1962            .map(|(a, b)| a * b)
1963            .sum()
1964    };
1965
1966    let n_lags = match lags {
1967        KpssLags::Fixed(l) => l,
1968        KpssLags::Schwert => ((12.0 * (nf / 100.0).powf(0.25)).ceil() as usize).min(n - 1),
1969        KpssLags::Hobijn => {
1970            // Hobijn et al. (1998), as statsmodels' `_kpss_autolag`.
1971            let cov_lags = nf.powf(2.0 / 9.0) as usize;
1972            let mut s0 = sum_sq / nf;
1973            let mut s1 = 0.0;
1974            for i in 1..=cov_lags.min(n - 1) {
1975                let g = autocovariance(i) / (nf / 2.0);
1976                s0 += g;
1977                s1 += i as f64 * g;
1978            }
1979            let s_hat = s1 / s0;
1980            let gamma = 1.1447 * (s_hat * s_hat).powf(1.0 / 3.0);
1981            ((gamma * nf.powf(1.0 / 3.0)) as usize).min(n - 1)
1982        }
1983    };
1984
1985    let mut long_run = sum_sq;
1986    for i in 1..=n_lags {
1987        long_run += 2.0 * autocovariance(i) * (1.0 - i as f64 / (n_lags as f64 + 1.0));
1988    }
1989    let long_run = long_run / nf;
1990    if long_run <= 0.0 {
1991        return Err(TimeSeriesError::NoVariation);
1992    }
1993
1994    let mut partial = 0.0;
1995    let eta = residuals
1996        .iter()
1997        .map(|e| {
1998            partial += e;
1999            partial * partial
2000        })
2001        .sum::<f64>()
2002        / (nf * nf);
2003    let statistic = eta / long_run;
2004
2005    let critical_values = match model {
2006        KpssModel::Level => [0.347, 0.463, 0.574, 0.739],
2007        KpssModel::Trend => [0.119, 0.146, 0.176, 0.216],
2008    };
2009    let (p_value, p_value_bound) = if statistic <= critical_values[0] {
2010        (KPSS_LEVELS[0], KpssPValue::AtLeast)
2011    } else if statistic >= critical_values[3] {
2012        (KPSS_LEVELS[3], KpssPValue::AtMost)
2013    } else {
2014        let k = critical_values
2015            .windows(2)
2016            .position(|w| statistic <= w[1])
2017            .expect("the statistic lies inside the table");
2018        let (c0, c1) = (critical_values[k], critical_values[k + 1]);
2019        let (p0, p1) = (KPSS_LEVELS[k], KPSS_LEVELS[k + 1]);
2020        (
2021            p0 + (statistic - c0) / (c1 - c0) * (p1 - p0),
2022            KpssPValue::Interpolated,
2023        )
2024    };
2025    let rejected = critical_values.map(|c| statistic > c);
2026
2027    Ok(KpssResult {
2028        statistic,
2029        n_lags,
2030        p_value,
2031        p_value_bound,
2032        critical_values,
2033        rejected,
2034    })
2035}
2036
2037/// KPSS against statsmodels 0.15.0 `kpss(x, regression, nlags)` on a
2038/// stationary AR(1) and a random walk (seeded, rounded to 3 places).
2039#[cfg(test)]
2040mod kpss_reference {
2041    use super::*;
2042
2043    const AR: [f64; 60] = [
2044        0.0, 0.299, -0.154, -0.953, -0.836, -1.326, -0.471, 1.152, -0.031, -0.633, 0.237, 0.452,
2045        0.286, -0.816, -0.355, 0.553, -1.123, -0.907, -2.264, -2.196, -2.72, -1.323, -1.796,
2046        -0.447, -0.022, -0.196, -2.595, -1.577, -0.68, -0.159, -1.594, -1.115, -1.425, -1.379,
2047        0.509, -0.604, -0.275, 0.774, -0.274, -0.222, 0.021, 0.073, -1.196, -0.402, 1.198, -1.068,
2048        0.432, 0.292, -0.524, 1.79, 1.478, -0.608, -0.168, 0.51, 0.015, 0.689, 0.209, 0.75, 1.739,
2049        0.02,
2050    ];
2051    const RW: [f64; 60] = [
2052        0.001, 0.3, 0.026, -0.865, -1.32, -2.312, -2.252, -0.912, -1.404, -2.024, -1.534, -1.177,
2053        -1.072, -2.002, -2.031, -1.336, -2.68, -3.138, -5.039, -6.329, -8.171, -8.406, -9.673,
2054        -9.402, -9.245, -9.432, -11.949, -12.488, -12.537, -12.424, -13.954, -14.432, -15.411,
2055        -16.22, -15.159, -15.967, -16.0, -15.116, -15.7, -15.812, -15.702, -15.638, -16.863,
2056        -16.787, -15.428, -16.975, -16.116, -15.997, -16.638, -14.638, -13.876, -15.075, -15.0,
2057        -14.423, -14.612, -13.929, -13.996, -13.329, -11.89, -12.566,
2058    ];
2059
2060    #[test]
2061    fn statistic_lag_and_p_value_reproduce_statsmodels() {
2062        use KpssLags::{Fixed, Hobijn, Schwert};
2063        use KpssModel::{Level, Trend};
2064        type Case = (&'static [f64; 60], KpssModel, KpssLags, f64, f64, usize);
2065        let cases: [Case; 12] = [
2066            (
2067                &AR,
2068                Level,
2069                Hobijn,
2070                0.5229270606681895,
2071                0.036502914263921285,
2072                3,
2073            ),
2074            (&AR, Level, Schwert, 0.288001639308506, 0.1, 11),
2075            (
2076                &AR,
2077                Level,
2078                Fixed(4),
2079                0.46189194657609306,
2080                0.05047760923444266,
2081                4,
2082            ),
2083            (&AR, Trend, Hobijn, 0.2274846895383343, 0.01, 3),
2084            (
2085                &AR,
2086                Trend,
2087                Schwert,
2088                0.14193842821368732,
2089                0.05752142923391235,
2090                11,
2091            ),
2092            (
2093                &AR,
2094                Trend,
2095                Fixed(4),
2096                0.20574827757371436,
2097                0.013844395909857114,
2098                4,
2099            ),
2100            (&RW, Level, Hobijn, 0.9286533322383663, 0.01, 5),
2101            (
2102                &RW,
2103                Level,
2104                Schwert,
2105                0.5112288964656923,
2106                0.0391376359311504,
2107                11,
2108            ),
2109            (&RW, Level, Fixed(4), 1.09862199299003, 0.01, 4),
2110            (&RW, Trend, Hobijn, 0.26866997231516837, 0.01, 4),
2111            (
2112                &RW,
2113                Trend,
2114                Schwert,
2115                0.14468829763941088,
2116                0.05242907844553539,
2117                11,
2118            ),
2119            (&RW, Trend, Fixed(4), 0.26866997231516837, 0.01, 4),
2120        ];
2121        for (data, model, lags, statistic, p, n_lags) in cases {
2122            let r = kpss_test(data, model, lags).unwrap();
2123            let what = format!("{model:?} {lags:?}");
2124            assert_eq!(r.n_lags, n_lags, "{what}");
2125            assert!(
2126                (r.statistic - statistic).abs() < 1e-10 * statistic,
2127                "{what}: {} vs {statistic}",
2128                r.statistic
2129            );
2130            assert!(
2131                (r.p_value - p).abs() < 1e-12,
2132                "{what}: p {} vs {p}",
2133                r.p_value
2134            );
2135        }
2136    }
2137
2138    #[test]
2139    fn bounds_rejections_and_refusals() {
2140        let r = kpss_test(&RW, KpssModel::Level, KpssLags::Fixed(4)).unwrap();
2141        assert_eq!(r.p_value_bound, KpssPValue::AtMost);
2142        assert_eq!(r.rejected, [true; 4]);
2143        let r = kpss_test(&AR, KpssModel::Level, KpssLags::Schwert).unwrap();
2144        assert_eq!(r.p_value_bound, KpssPValue::AtLeast);
2145        assert_eq!(r.rejected, [false; 4]);
2146        let r = kpss_test(&AR, KpssModel::Level, KpssLags::Hobijn).unwrap();
2147        assert_eq!(r.p_value_bound, KpssPValue::Interpolated);
2148        assert_eq!(r.rejected, [true, true, false, false]);
2149
2150        assert_eq!(
2151            kpss_test(&AR[..9], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2152            TimeSeriesError::TooFewObservations { needed: 10, got: 9 }
2153        );
2154        assert_eq!(
2155            kpss_test(&AR, KpssModel::Level, KpssLags::Fixed(60)).unwrap_err(),
2156            TimeSeriesError::OptionOutOfRange {
2157                option: "lags",
2158                got: 60.0,
2159                min: 0.0,
2160                max: 59.0
2161            }
2162        );
2163        assert_eq!(
2164            kpss_test(&[4.0; 20], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2165            TimeSeriesError::NoVariation
2166        );
2167        let line: Vec<f64> = (0..20).map(|i| 2.0 + 0.5 * i as f64).collect();
2168        assert_eq!(
2169            kpss_test(&line, KpssModel::Trend, KpssLags::Hobijn).unwrap_err(),
2170            TimeSeriesError::NoVariation
2171        );
2172        let mut bad = AR;
2173        bad[3] = f64::INFINITY;
2174        assert_eq!(
2175            kpss_test(&bad[..5], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2176            TimeSeriesError::ValueNotFinite { index: 3 }
2177        );
2178    }
2179
2180    /// The statistic is a ratio of the residuals' own scale: the same in any unit.
2181    #[test]
2182    fn unit_invariant() {
2183        let base = kpss_test(&AR, KpssModel::Trend, KpssLags::Hobijn).unwrap();
2184        for f in [1e-9, 1e9] {
2185            let x: Vec<f64> = AR.iter().map(|v| v * f).collect();
2186            let r = kpss_test(&x, KpssModel::Trend, KpssLags::Hobijn).unwrap();
2187            assert_eq!(r.n_lags, base.n_lags);
2188            assert!((r.statistic - base.statistic).abs() < 1e-9 * base.statistic);
2189        }
2190    }
2191}
2192
2193// ---------------------------------------------------------------------------
2194// Augmented Dickey-Fuller test
2195// ---------------------------------------------------------------------------
2196
2197/// Result of the Augmented Dickey-Fuller (ADF) unit root test.
2198#[derive(Debug, Clone)]
2199pub struct AdfResult {
2200    /// ADF test statistic (t-ratio for γ̂).
2201    pub statistic: f64,
2202    /// Number of lags used.
2203    pub n_lags: usize,
2204    /// Number of observations used in the regression.
2205    pub n_obs: usize,
2206    /// Critical values at 1%, 5%, 10% significance levels.
2207    pub critical_values: [f64; 3],
2208    /// Whether the null hypothesis (unit root) is rejected at each level.
2209    pub rejected: [bool; 3],
2210}
2211
2212/// How [`adf_test`] chooses the number of lagged differences.
2213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2214pub enum AdfLags {
2215    /// The count from 0 to `max` with the smallest AIC, every candidate fitted
2216    /// on the same observations (those the largest count leaves), then the
2217    /// chosen count refitted on all the observations it leaves. `max` `None`
2218    /// is Schwert's rule, `⌊12·(n/100)^¼⌋`, capped at `n / 3`.
2219    Aic {
2220        /// The largest count considered.
2221        max: Option<usize>,
2222    },
2223    /// Exactly this many.
2224    Fixed(usize),
2225}
2226
2227impl Default for AdfLags {
2228    fn default() -> Self {
2229        AdfLags::Aic { max: None }
2230    }
2231}
2232
2233/// Model specification for the ADF test.
2234#[derive(Debug, Clone, Copy)]
2235pub enum AdfModel {
2236    /// No constant, no trend: Δyₜ = γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2237    None,
2238    /// Constant only (default): Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2239    Constant,
2240    /// Constant + linear trend: Δyₜ = α + βt + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2241    ConstantTrend,
2242}
2243
2244/// Augmented Dickey-Fuller (ADF) unit root test for stationarity.
2245///
2246/// Tests H₀: unit root (non-stationary) vs H₁: stationary.
2247///
2248/// # Algorithm
2249///
2250/// 1. Constructs Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2251/// 2. Estimates via OLS
2252/// 3. Tests t-ratio for γ against Dickey-Fuller critical values
2253///
2254/// `lags` chooses the number of lagged differences ([`AdfLags`]): by AIC up
2255/// to a maximum (the default, as `statsmodels.adfuller(autolag="AIC")`), or a
2256/// fixed count.
2257///
2258/// Reference: Dickey & Fuller (1979), "Distribution of the Estimators for
2259/// Autoregressive Time Series with a Unit Root"
2260///
2261/// # Errors
2262///
2263/// [`TimeSeriesError`]: a non-finite value, fewer than 10 values, a lag
2264/// count (`lags`, or the AIC search's `max_lags`) outside `[0, max]` where
2265/// `max` leaves the regression at least 5 rows and a residual degree of
2266/// freedom ([`adf_max_lags`]), or a degenerate regression.
2267///
2268/// # Examples
2269///
2270/// ```
2271/// use u_analytics::testing::{adf_test, AdfLags, AdfModel};
2272///
2273/// // Stationary series: strong mean-reversion
2274/// let mut data = vec![0.0_f64; 40];
2275/// for i in 1..40 {
2276///     data[i] = 0.3 * data[i - 1] + [0.5, -0.8, 0.3, -0.6, 0.9,
2277///         -0.4, 0.7, -0.2, 0.1, -0.5][i % 10];
2278/// }
2279/// let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).unwrap();
2280/// assert!(r.statistic.is_finite());
2281/// assert_eq!(r.critical_values.len(), 3);
2282/// ```
2283pub fn adf_test(
2284    data: &[f64],
2285    model: AdfModel,
2286    lags: AdfLags,
2287) -> Result<AdfResult, TimeSeriesError> {
2288    check_series(data, ADF_MIN_OBSERVATIONS)?;
2289    let n = data.len();
2290    let max = adf_max_lags(n, model);
2291    match lags {
2292        AdfLags::Fixed(p) => {
2293            check_option("lags", p, 0, max)?;
2294        }
2295        AdfLags::Aic { max: Some(m) } => {
2296            check_option("max_lags", m, 0, max)?;
2297        }
2298        AdfLags::Aic { max: None } => {}
2299    }
2300
2301    // Compute differences
2302    let dy: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2303
2304    let best_lag = match lags {
2305        AdfLags::Fixed(p) => p,
2306        AdfLags::Aic { max } => {
2307            // Schwert (1989) rule for the largest count considered.
2308            let schwert = (12.0 * (n as f64 / 100.0).powf(0.25)).floor() as usize;
2309            let p_max = max.unwrap_or_else(|| schwert.min(n / 3));
2310            select_adf_lag(data, &dy, model, p_max).ok_or(TimeSeriesError::DegenerateRegression)?
2311        }
2312    };
2313
2314    // The chosen count, on every observation it leaves.
2315    adf_ols(data, &dy, model, best_lag).ok_or(TimeSeriesError::DegenerateRegression)
2316}
2317
2318/// The fewest values [`adf_test`] takes.
2319pub const ADF_MIN_OBSERVATIONS: usize = 10;
2320
2321/// The fewest rows the ADF test regression is fitted on.
2322const ADF_MIN_REGRESSION_ROWS: usize = 5;
2323
2324/// The largest number of lagged differences [`adf_test`] accepts for `n`
2325/// values (at least [`ADF_MIN_OBSERVATIONS`]) under `model`: with p lags the
2326/// regression keeps n − 1 − p rows (one value is lost to differencing, p to
2327/// the lags) and has the lagged level, the model's deterministic terms and p
2328/// lag columns; it needs at least 5 rows and a residual degree of freedom.
2329pub fn adf_max_lags(n: usize, model: AdfModel) -> usize {
2330    let fixed_columns = match model {
2331        AdfModel::None => 1,
2332        AdfModel::Constant => 2,
2333        AdfModel::ConstantTrend => 3,
2334    };
2335    (n.saturating_sub(1 + ADF_MIN_REGRESSION_ROWS)).min(n.saturating_sub(2 + fixed_columns) / 2)
2336}
2337
2338/// The lag count in `0..=p_max` with the smallest AIC. Every candidate is
2339/// fitted on the observations `p_max` lags leave, so the AICs compare the same
2340/// data — fitted on their own (longer) samples they would not be comparable.
2341/// `None` when no candidate can be fitted.
2342fn select_adf_lag(data: &[f64], dy: &[f64], model: AdfModel, p_max: usize) -> Option<usize> {
2343    let start = p_max;
2344    let mut best: Option<(f64, usize)> = None;
2345    for p in 0..=p_max {
2346        if let Some(aic) = adf_ols_aic(data, dy, model, p, start) {
2347            if best.is_none_or(|(b, _)| aic < b) {
2348                best = Some((aic, p));
2349            }
2350        }
2351    }
2352    best.map(|(_, p)| p)
2353}
2354
2355/// Builds the ADF design matrix and dependent variable.
2356///
2357/// Returns (design_matrix_row_major, y_dep, n_rows, n_cols, gamma_col_index).
2358#[allow(clippy::type_complexity)]
2359fn adf_build_matrix(
2360    data: &[f64],
2361    dy: &[f64],
2362    model: AdfModel,
2363    p: usize,
2364    start: usize,
2365) -> Option<(Vec<f64>, Vec<f64>, usize, usize, usize)> {
2366    // Row t regresses dy[t] on the level data[t] and dy[t-1] ... dy[t-p].
2367    debug_assert!(start >= p, "the first row needs its p lagged differences");
2368    if start >= dy.len() || dy.len() - start < 5 {
2369        return None;
2370    }
2371    let m = dy.len() - start;
2372
2373    let y_dep: Vec<f64> = dy[start..].to_vec();
2374
2375    // Count columns: intercept + y_{t-1} + [trend] + p lags
2376    let has_intercept = !matches!(model, AdfModel::None);
2377    let has_trend = matches!(model, AdfModel::ConstantTrend);
2378    let ncols = has_intercept as usize + 1 + has_trend as usize + p;
2379
2380    // Build row-major design matrix
2381    let mut x_data = Vec::with_capacity(m * ncols);
2382    let mut gamma_col = 0;
2383
2384    for i in 0..m {
2385        let t = start + i;
2386        if has_intercept {
2387            x_data.push(1.0); // intercept
2388            gamma_col = 1;
2389        }
2390        x_data.push(data[t]); // y_{t-1}
2391        if has_trend {
2392            x_data.push((t + 1) as f64); // trend
2393        }
2394        for lag in 1..=p {
2395            x_data.push(dy[t - lag]);
2396        }
2397    }
2398
2399    Some((x_data, y_dep, m, ncols, gamma_col))
2400}
2401
2402/// Lightweight OLS for ADF: returns (gamma_t_stat, rss, k).
2403///
2404/// Solves X'Xβ = X'y using Cholesky-like decomposition (Gaussian elimination).
2405fn adf_ols_core(
2406    x_data: &[f64],
2407    y: &[f64],
2408    m: usize,
2409    ncols: usize,
2410    gamma_col: usize,
2411) -> Option<(f64, f64, usize)> {
2412    // Compute X'X (ncols × ncols, symmetric)
2413    let mut xtx = vec![0.0_f64; ncols * ncols];
2414    for i in 0..m {
2415        let row = &x_data[i * ncols..(i + 1) * ncols];
2416        for j in 0..ncols {
2417            for k in j..ncols {
2418                xtx[j * ncols + k] += row[j] * row[k];
2419            }
2420        }
2421    }
2422    // Mirror upper to lower
2423    for j in 0..ncols {
2424        for k in (j + 1)..ncols {
2425            xtx[k * ncols + j] = xtx[j * ncols + k];
2426        }
2427    }
2428
2429    // Compute X'y (ncols × 1)
2430    let mut xty = vec![0.0_f64; ncols];
2431    for i in 0..m {
2432        let row = &x_data[i * ncols..(i + 1) * ncols];
2433        for j in 0..ncols {
2434            xty[j] += row[j] * y[i];
2435        }
2436    }
2437
2438    // Solve via Gaussian elimination with partial pivoting
2439    let mut augmented = vec![0.0_f64; ncols * (ncols + 1)];
2440    for i in 0..ncols {
2441        for j in 0..ncols {
2442            augmented[i * (ncols + 1) + j] = xtx[i * ncols + j];
2443        }
2444        augmented[i * (ncols + 1) + ncols] = xty[i];
2445    }
2446
2447    for col in 0..ncols {
2448        // Partial pivoting
2449        let mut max_row = col;
2450        let mut max_val = augmented[col * (ncols + 1) + col].abs();
2451        for row in (col + 1)..ncols {
2452            let val = augmented[row * (ncols + 1) + col].abs();
2453            if val > max_val {
2454                max_val = val;
2455                max_row = row;
2456            }
2457        }
2458        if max_val < 1e-15 {
2459            return None; // Singular
2460        }
2461        if max_row != col {
2462            for j in 0..=ncols {
2463                let a = col * (ncols + 1) + j;
2464                let b = max_row * (ncols + 1) + j;
2465                augmented.swap(a, b);
2466            }
2467        }
2468
2469        let pivot = augmented[col * (ncols + 1) + col];
2470        for row in (col + 1)..ncols {
2471            let factor = augmented[row * (ncols + 1) + col] / pivot;
2472            for j in col..=ncols {
2473                let above = augmented[col * (ncols + 1) + j];
2474                augmented[row * (ncols + 1) + j] -= factor * above;
2475            }
2476        }
2477    }
2478
2479    // Back-substitution
2480    let mut beta = vec![0.0_f64; ncols];
2481    for i in (0..ncols).rev() {
2482        let mut sum = augmented[i * (ncols + 1) + ncols];
2483        for j in (i + 1)..ncols {
2484            sum -= augmented[i * (ncols + 1) + j] * beta[j];
2485        }
2486        beta[i] = sum / augmented[i * (ncols + 1) + i];
2487    }
2488
2489    // Compute residuals and RSS
2490    let mut rss = 0.0;
2491    for i in 0..m {
2492        let row = &x_data[i * ncols..(i + 1) * ncols];
2493        let y_hat: f64 = row.iter().zip(beta.iter()).map(|(&x, &b)| x * b).sum();
2494        let resid = y[i] - y_hat;
2495        rss += resid * resid;
2496    }
2497
2498    // An exact fit leaves no residual variance, so the t-ratio divides by
2499    // rounding noise: a series whose differences repeat with a period the lags
2500    // span came out as t = -1.9e8, a "rejection" at every level. Treat a fit
2501    // that explains the differences to eight digits as undefined, which also
2502    // keeps it out of the AIC lag search (where ln(rss) -> -inf would win).
2503    let yy: f64 = y.iter().map(|v| v * v).sum();
2504    if yy == 0.0 || rss <= yy * 1e-16 {
2505        return None;
2506    }
2507
2508    // Standard error of coefficients
2509    let df = m - ncols;
2510    if df == 0 {
2511        return None;
2512    }
2513    let mse = rss / df as f64;
2514
2515    // Compute (X'X)^{-1} via Gauss-Jordan elimination to get variance of γ̂
2516    let mut xtx_aug = vec![0.0_f64; ncols * ncols * 2]; // xtx | I
2517    for i in 0..ncols {
2518        for j in 0..ncols {
2519            xtx_aug[i * 2 * ncols + j] = xtx[i * ncols + j];
2520        }
2521        xtx_aug[i * 2 * ncols + ncols + i] = 1.0;
2522    }
2523
2524    // Gauss-Jordan elimination
2525    for col in 0..ncols {
2526        let mut max_row = col;
2527        let mut max_val = xtx_aug[col * 2 * ncols + col].abs();
2528        for row in (col + 1)..ncols {
2529            let val = xtx_aug[row * 2 * ncols + col].abs();
2530            if val > max_val {
2531                max_val = val;
2532                max_row = row;
2533            }
2534        }
2535        if max_val < 1e-15 {
2536            return None;
2537        }
2538        if max_row != col {
2539            for j in 0..(2 * ncols) {
2540                let a = col * 2 * ncols + j;
2541                let b = max_row * 2 * ncols + j;
2542                xtx_aug.swap(a, b);
2543            }
2544        }
2545
2546        let pivot = xtx_aug[col * 2 * ncols + col];
2547        for j in 0..(2 * ncols) {
2548            xtx_aug[col * 2 * ncols + j] /= pivot;
2549        }
2550        for row in 0..ncols {
2551            if row == col {
2552                continue;
2553            }
2554            let factor = xtx_aug[row * 2 * ncols + col];
2555            for j in 0..(2 * ncols) {
2556                let above = xtx_aug[col * 2 * ncols + j];
2557                xtx_aug[row * 2 * ncols + j] -= factor * above;
2558            }
2559        }
2560    }
2561
2562    // Extract diagonal element for gamma column
2563    let var_gamma = mse * xtx_aug[gamma_col * 2 * ncols + ncols + gamma_col];
2564    if var_gamma <= 0.0 {
2565        return None;
2566    }
2567    let se_gamma = var_gamma.sqrt();
2568    let t_gamma = beta[gamma_col] / se_gamma;
2569
2570    Some((t_gamma, rss, ncols))
2571}
2572
2573/// Runs ADF OLS and returns AIC + number of observations.
2574/// AIC of the regression with `p` lags on the rows from `start` on.
2575fn adf_ols_aic(data: &[f64], dy: &[f64], model: AdfModel, p: usize, start: usize) -> Option<f64> {
2576    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, start)?;
2577    let (_t_stat, rss, k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2578    Some(2.0 * k as f64 + m as f64 * (rss / m as f64).ln())
2579}
2580
2581/// Runs the actual ADF OLS and returns the test result.
2582fn adf_ols(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<AdfResult> {
2583    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, p)?;
2584    let (gamma_t, _rss, _k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2585
2586    let critical_values = adf_critical_values(model, m);
2587
2588    let rejected = [
2589        gamma_t <= critical_values[0],
2590        gamma_t <= critical_values[1],
2591        gamma_t <= critical_values[2],
2592    ];
2593
2594    Some(AdfResult {
2595        statistic: gamma_t,
2596        n_lags: p,
2597        n_obs: m,
2598        critical_values,
2599        rejected,
2600    })
2601}
2602
2603/// MacKinnon (1994) critical values for ADF test.
2604///
2605/// Returns [1%, 5%, 10%] critical values based on sample size.
2606fn adf_critical_values(model: AdfModel, n: usize) -> [f64; 3] {
2607    // MacKinnon (1994) regression-based approximation:
2608    // cv(n) = τ_∞ + τ₁/n + τ₂/n²
2609    //
2610    // Coefficients from MacKinnon (2010), Table 1.
2611    let (tau_inf, tau1, tau2): ([f64; 3], [f64; 3], [f64; 3]) = match model {
2612        AdfModel::None => (
2613            [-2.5658, -1.9393, -1.6156],
2614            [-1.960, -0.398, -0.181],
2615            [-10.04, 0.0, 0.0],
2616        ),
2617        AdfModel::Constant => (
2618            [-3.4336, -2.8621, -2.5671],
2619            [-5.999, -2.738, -1.438],
2620            [-29.25, -8.36, -4.48],
2621        ),
2622        AdfModel::ConstantTrend => (
2623            [-3.9638, -3.4126, -3.1279],
2624            [-8.353, -4.039, -2.418],
2625            [-47.44, -17.83, -7.58],
2626        ),
2627    };
2628
2629    let nf = n as f64;
2630    let inv_n = 1.0 / nf;
2631    let inv_n2 = inv_n * inv_n;
2632
2633    [
2634        tau_inf[0] + tau1[0] * inv_n + tau2[0] * inv_n2,
2635        tau_inf[1] + tau1[1] * inv_n + tau2[1] * inv_n2,
2636        tau_inf[2] + tau1[2] * inv_n + tau2[2] * inv_n2,
2637    ]
2638}
2639
2640#[cfg(test)]
2641mod tests {
2642    use super::*;
2643
2644    #[test]
2645    fn corrections_refuse_what_is_not_a_probability() {
2646        assert!(bonferroni_correction(&[0.01, 1.5]).is_none());
2647        assert!(benjamini_hochberg(&[-0.1, 0.2]).is_none());
2648        assert_eq!(bonferroni_correction(&[0.01, 0.2]), Some(vec![0.02, 0.4]));
2649    }
2650
2651    // -----------------------------------------------------------------------
2652    // Anderson-Darling large-n p-value overflow (upstream-014)
2653    // -----------------------------------------------------------------------
2654
2655    /// Public-domain Mulberry32 PRNG (matches the upstream-014 reproduction).
2656    fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
2657        let mut a = seed;
2658        move || {
2659            a = a.wrapping_add(0x6d2b_79f5);
2660            let mut t = a;
2661            t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2662            t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2663            ((t ^ (t >> 14)) as f64) / 4_294_967_296.0
2664        }
2665    }
2666
2667    /// Regression (upstream-014): for a fixed, clearly non-normal distribution
2668    /// shape (exponential), as n grows the A*² statistic grows monotonically and
2669    /// the p-value must keep tracking toward 0 — it must NEVER jump to exactly 1.0
2670    /// ("perfectly normal") while A*² is simultaneously large and growing. Before
2671    /// the fix, the p-value overflowed to exactly 1 past n≈7000.
2672    #[test]
2673    fn ad_pvalue_no_overflow_large_nonnormal_n() {
2674        let mut rng = mulberry32(0x5350_4331);
2675        let full: Vec<f64> = (0..20_000)
2676            .map(|_| {
2677                let u = rng();
2678                -(1.0 - u).ln() // inverse-CDF exponential, rate=1
2679            })
2680            .collect();
2681
2682        let mut prev_a2 = 0.0_f64;
2683        let mut prev_p = f64::INFINITY;
2684        for &n in &[3000usize, 5000, 6000, 7000, 8000, 10000, 16000, 20000] {
2685            let r = anderson_darling_test(&full[..n]).expect("computes");
2686            // A*² must keep growing for an increasingly non-normal large sample.
2687            assert!(
2688                r.statistic_star > prev_a2,
2689                "A*² must grow with n; n={n} A*²={} prev={prev_a2}",
2690                r.statistic_star
2691            );
2692            // The core defect: p must never flip to exactly 1.0 while A*² is huge.
2693            assert!(
2694                r.p_value < 0.5,
2695                "clearly non-normal data (n={n}, A*²={}) must not report p={} ≈ normal",
2696                r.statistic_star,
2697                r.p_value
2698            );
2699            // Monotone non-increasing: p tracks the growing statistic downward.
2700            assert!(
2701                r.p_value <= prev_p + 1e-12,
2702                "p must be non-increasing as A*² grows; n={n} p={} prev={prev_p}",
2703                r.p_value
2704            );
2705            prev_a2 = r.statistic_star;
2706            prev_p = r.p_value;
2707        }
2708    }
2709
2710    // -----------------------------------------------------------------------
2711    // One-sample t-test
2712    // -----------------------------------------------------------------------
2713
2714    #[test]
2715    fn one_sample_null_true() {
2716        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2717        let r = one_sample_t_test(&data, 5.0).expect("should compute");
2718        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2719    }
2720
2721    #[test]
2722    fn one_sample_null_false() {
2723        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2724        let r = one_sample_t_test(&data, 10.0).expect("should compute");
2725        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2726    }
2727
2728    #[test]
2729    fn one_sample_edge_cases() {
2730        assert!(one_sample_t_test(&[1.0], 0.0).is_none()); // n < 2
2731        assert!(one_sample_t_test(&[5.0, 5.0, 5.0], 5.0).is_none()); // zero var
2732        assert!(one_sample_t_test(&[1.0, f64::NAN, 3.0], 2.0).is_none());
2733    }
2734
2735    // -----------------------------------------------------------------------
2736    // Two-sample t-test
2737    // -----------------------------------------------------------------------
2738
2739    #[test]
2740    fn two_sample_same_mean() {
2741        let a = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2742        let b = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
2743        let r = two_sample_t_test(&a, &b).expect("should compute");
2744        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2745    }
2746
2747    #[test]
2748    fn two_sample_different_means() {
2749        let a = [1.0, 2.0, 3.0, 2.0, 1.5, 2.5];
2750        let b = [10.0, 11.0, 12.0, 10.5, 11.5, 10.5];
2751        let r = two_sample_t_test(&a, &b).expect("should compute");
2752        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2753    }
2754
2755    #[test]
2756    fn two_sample_different_sizes() {
2757        let a = [1.0, 2.0, 3.0];
2758        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2759        let r = two_sample_t_test(&a, &b).expect("should compute");
2760        assert!(r.p_value < 0.05);
2761    }
2762
2763    #[test]
2764    fn two_sample_edge_cases() {
2765        assert!(two_sample_t_test(&[1.0], &[2.0, 3.0]).is_none());
2766        assert!(two_sample_t_test(&[1.0, 2.0], &[3.0]).is_none());
2767    }
2768
2769    // -----------------------------------------------------------------------
2770    // Paired t-test
2771    // -----------------------------------------------------------------------
2772
2773    #[test]
2774    fn paired_no_difference() {
2775        let x = [5.0, 6.0, 7.0, 8.0, 9.0];
2776        let y = [5.1, 5.9, 7.1, 7.9, 9.1];
2777        let r = paired_t_test(&x, &y).expect("should compute");
2778        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2779    }
2780
2781    #[test]
2782    fn paired_significant_difference() {
2783        // Differences have non-zero variance
2784        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2785        let after = [6.2, 7.1, 8.3, 9.0, 10.4, 11.1, 12.2, 13.3];
2786        let r = paired_t_test(&before, &after).expect("should compute");
2787        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2788        assert!(r.statistic < 0.0); // after > before
2789    }
2790
2791    #[test]
2792    fn paired_edge_cases() {
2793        assert!(paired_t_test(&[1.0, 2.0], &[3.0]).is_none()); // length mismatch
2794        assert!(paired_t_test(&[1.0], &[2.0]).is_none()); // n < 2
2795    }
2796
2797    // -----------------------------------------------------------------------
2798    // ANOVA
2799    // -----------------------------------------------------------------------
2800
2801    #[test]
2802    fn anova_same_means() {
2803        let g1 = [5.0, 5.1, 4.9, 5.0, 5.1];
2804        let g2 = [5.0, 5.2, 4.8, 5.1, 4.9];
2805        let g3 = [5.1, 4.9, 5.0, 5.0, 5.1];
2806        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2807        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2808    }
2809
2810    #[test]
2811    fn anova_different_means() {
2812        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2813        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2814        let g3 = [10.0, 11.0, 12.0, 11.0, 10.5];
2815        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2816        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2817        assert!(r.df_between == 2);
2818        assert!(r.df_within == 12);
2819    }
2820
2821    #[test]
2822    fn anova_ss_decomposition() {
2823        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2824        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2825        let r = one_way_anova(&[&g1, &g2]).expect("should compute");
2826        // SS_total = SS_between + SS_within
2827        let all_data: Vec<f64> = g1.iter().chain(g2.iter()).copied().collect();
2828        let ss_total: f64 = all_data.iter().map(|&x| (x - r.grand_mean).powi(2)).sum();
2829        assert!(
2830            (ss_total - (r.ss_between + r.ss_within)).abs() < 1e-10,
2831            "SS decomposition: {ss_total} vs {} + {}",
2832            r.ss_between,
2833            r.ss_within
2834        );
2835    }
2836
2837    #[test]
2838    fn anova_edge_cases() {
2839        let g1 = [1.0, 2.0, 3.0];
2840        assert!(one_way_anova(&[&g1]).is_none()); // < 2 groups
2841    }
2842
2843    // -----------------------------------------------------------------------
2844    // Chi-squared goodness of fit
2845    // -----------------------------------------------------------------------
2846
2847    #[test]
2848    fn chi2_gof_uniform() {
2849        // Perfect uniform distribution
2850        let observed = [25.0, 25.0, 25.0, 25.0];
2851        let expected = [25.0, 25.0, 25.0, 25.0];
2852        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2853        assert!((r.statistic).abs() < 1e-15);
2854        assert!((r.p_value - 1.0).abs() < 0.01);
2855    }
2856
2857    #[test]
2858    fn chi2_gof_significant() {
2859        let observed = [90.0, 10.0];
2860        let expected = [50.0, 50.0];
2861        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2862        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2863    }
2864
2865    #[test]
2866    fn chi2_gof_edge_cases() {
2867        assert!(chi_squared_goodness_of_fit(&[10.0], &[10.0]).is_none()); // < 2
2868        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0, 0.0]).is_none()); // expected 0
2869        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0]).is_none()); // mismatch
2870    }
2871
2872    // -----------------------------------------------------------------------
2873    // Chi-squared independence
2874    // -----------------------------------------------------------------------
2875
2876    #[test]
2877    fn chi2_independence_significant() {
2878        // Strong association
2879        let table = [30.0, 10.0, 10.0, 50.0];
2880        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2881        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2882        assert!((r.df - 1.0).abs() < 1e-10);
2883    }
2884
2885    #[test]
2886    fn chi2_independence_not_significant() {
2887        // No association
2888        let table = [25.0, 25.0, 25.0, 25.0];
2889        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2890        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2891    }
2892
2893    #[test]
2894    fn chi2_independence_3x3() {
2895        let table = [10.0, 20.0, 30.0, 40.0, 30.0, 20.0, 20.0, 25.0, 25.0];
2896        let r = chi_squared_independence(&table, 3, 3).expect("should compute");
2897        assert!((r.df - 4.0).abs() < 1e-10);
2898        assert!(r.p_value < 0.05);
2899    }
2900
2901    #[test]
2902    fn chi2_independence_edge_cases() {
2903        assert!(chi_squared_independence(&[10.0, 20.0], 1, 2).is_none()); // 1 row
2904        assert!(chi_squared_independence(&[10.0, 20.0], 2, 1).is_none()); // 1 col
2905        assert!(chi_squared_independence(&[10.0], 2, 2).is_none()); // wrong size
2906    }
2907
2908    // -----------------------------------------------------------------------
2909    // Jarque-Bera
2910    // -----------------------------------------------------------------------
2911
2912    #[test]
2913    fn jb_normal_data() {
2914        // Symmetric, light-tailed data
2915        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2916        let r = jarque_bera_test(&data).expect("should compute");
2917        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2918    }
2919
2920    #[test]
2921    fn jb_skewed_data() {
2922        // Highly right-skewed
2923        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 20.0, 50.0];
2924        let r = jarque_bera_test(&data).expect("should compute");
2925        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2926    }
2927
2928    /// The statistic is the one the cited paper defines, which is the one
2929    /// SciPy, R and statsmodels report -- moment ratios, not the bias-adjusted
2930    /// estimators Excel's SKEW()/KURT() return.
2931    ///
2932    /// Reference values computed from the definition, independently of the
2933    /// implementation: g1 = m3/m2^1.5, g2 = m4/m2^2 - 3, JB = n/6 (g1^2 + g2^2/4).
2934    #[test]
2935    fn jb_is_the_moment_form_the_paper_defines() {
2936        let data = [
2937            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,
2938            3.6, 2.8, 3.2,
2939        ];
2940        let r = jarque_bera_test(&data).expect("20 finite points");
2941        assert!(
2942            (r.statistic - 5.563_714_337_9).abs() < 1e-8,
2943            "JB = {}",
2944            r.statistic
2945        );
2946
2947        // What it used to compute, from the adjusted estimators: 35 % higher on
2948        // this sample. The gap is not a rounding difference to be tolerated.
2949        let n = data.len() as f64;
2950        let big_g1 = u_numflow::stats::skewness(&data).expect("same data");
2951        let big_g2 = u_numflow::stats::kurtosis(&data).expect("same data");
2952        let adjusted = (n / 6.0) * (big_g1 * big_g1 + big_g2 * big_g2 / 4.0);
2953        assert!(
2954            adjusted > r.statistic * 1.3,
2955            "the two forms differ by more than a third here: {adjusted} vs {}",
2956            r.statistic
2957        );
2958    }
2959
2960    /// chi-squared with two degrees of freedom is an exponential, so the
2961    /// p-value is `exp(-JB/2)` exactly -- and stays a number where `1 - cdf`
2962    /// rounds to zero.
2963    #[test]
2964    fn jb_p_value_keeps_its_digits_in_the_tail() {
2965        // A ramp with one far outlier: JB near 93, where `1 - cdf` is exactly
2966        // zero in f64 and the true p-value is about 7e-21.
2967        let mut data: Vec<f64> = (1..20).map(f64::from).collect();
2968        data.push(60.0);
2969        let r = jarque_bera_test(&data).expect("20 finite points");
2970        assert!(r.statistic > 90.0, "JB = {}", r.statistic);
2971        assert!(
2972            1.0 - special::chi_squared_cdf(r.statistic, 2.0) == 0.0,
2973            "this sample is only interesting because the complement underflows"
2974        );
2975        assert!(
2976            r.p_value > 0.0,
2977            "the complement returns 0 here; this must not"
2978        );
2979        assert!(
2980            (r.p_value - 6.702e-21).abs() < 1e-23,
2981            "p = {} (expected about 6.7e-21)",
2982            r.p_value
2983        );
2984        assert!(
2985            (r.p_value - (-r.statistic / 2.0).exp()).abs() <= f64::EPSILON * r.p_value,
2986            "p = {} vs exp(-JB/2) = {}",
2987            r.p_value,
2988            (-r.statistic / 2.0).exp()
2989        );
2990        // And it still agrees with the distribution it claims, where that is
2991        // computable at all.
2992        let small = jarque_bera_test(&[-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0])
2993            .expect("10 finite points");
2994        let via_cdf = 1.0 - special::chi_squared_cdf(small.statistic, 2.0);
2995        assert!(
2996            (small.p_value - via_cdf).abs() < 1e-12,
2997            "{} vs {via_cdf}",
2998            small.p_value
2999        );
3000    }
3001
3002    #[test]
3003    fn jb_edge_cases() {
3004        assert!(jarque_bera_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
3005    }
3006
3007    // -----------------------------------------------------------------------
3008    // Anderson-Darling
3009    // -----------------------------------------------------------------------
3010
3011    #[test]
3012    fn ad_normal_data() {
3013        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
3014        let r = anderson_darling_test(&data).expect("should compute");
3015        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3016        assert!(r.statistic > 0.0, "A2 = {}", r.statistic);
3017        assert!(r.statistic_star > r.statistic, "A*2 should be > A2");
3018    }
3019
3020    #[test]
3021    fn ad_skewed_data() {
3022        // Exponential-like data — not normal
3023        let data = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
3024        let r = anderson_darling_test(&data).expect("should compute");
3025        assert!(
3026            r.p_value < 0.05,
3027            "p = {} (should reject normality)",
3028            r.p_value
3029        );
3030    }
3031
3032    #[test]
3033    fn ad_bimodal_data() {
3034        // Bimodal data — clearly not normal
3035        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
3036        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
3037        let r = anderson_darling_test(&data).expect("should compute");
3038        assert!(
3039            r.p_value < 0.01,
3040            "p = {} (bimodal should reject normality)",
3041            r.p_value
3042        );
3043    }
3044
3045    #[test]
3046    fn ad_large_normal_sample() {
3047        let n = 100;
3048        let data: Vec<f64> = (1..=n)
3049            .map(|i| {
3050                let p = (i as f64 - 0.5) / n as f64;
3051                special::inverse_normal_cdf(p)
3052            })
3053            .collect();
3054        let r = anderson_darling_test(&data).expect("should compute");
3055        assert!(r.p_value > 0.05, "p = {} for normal quantiles", r.p_value);
3056    }
3057
3058    #[test]
3059    fn ad_edge_cases() {
3060        assert!(anderson_darling_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
3061        assert!(anderson_darling_test(&[5.0; 10]).is_none()); // constant
3062        assert!(anderson_darling_test(&[1.0, f64::NAN, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).is_none());
3063    }
3064
3065    #[test]
3066    fn ad_p_value_ranges() {
3067        // Test different A*² ranges for p-value formula
3068        // Use datasets that produce different A*² magnitudes
3069
3070        // Near-normal → small A*² (< 0.2 range)
3071        let near_normal = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
3072        let r = anderson_darling_test(&near_normal).expect("should compute");
3073        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3074
3075        // Heavy-tailed → large A*² (>= 0.6 range)
3076        let heavy_tail = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
3077        let r = anderson_darling_test(&heavy_tail).expect("should compute");
3078        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3079    }
3080
3081    // -----------------------------------------------------------------------
3082    // Anderson-Darling normality (formerly also `anderson_darling_normality`)
3083    // -----------------------------------------------------------------------
3084
3085    #[test]
3086    fn ad_normal_data_large_p() {
3087        // Clearly normal data → cannot reject normality (p > 0.05)
3088        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3089        let r = anderson_darling_test(&data).unwrap();
3090        assert!(r.p_value > 0.05, "p={}", r.p_value);
3091    }
3092
3093    #[test]
3094    fn ad_exponential_data_small_p() {
3095        // Clearly non-normal (exponential) → reject normality (p < 0.05)
3096        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
3097        let r = anderson_darling_test(&data).unwrap();
3098        assert!(r.p_value < 0.05, "p={}", r.p_value);
3099    }
3100
3101    #[test]
3102    fn ad_statistic_non_negative() {
3103        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3104        let r = anderson_darling_test(&data).unwrap();
3105        assert!(r.statistic >= 0.0);
3106        assert!(r.statistic_star >= r.statistic);
3107    }
3108
3109    #[test]
3110    fn ad_p_value_in_range() {
3111        let data = [5.0, 5.1, 4.9, 5.05, 4.95, 5.02, 4.98, 5.0];
3112        let r = anderson_darling_test(&data).unwrap();
3113        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3114    }
3115
3116    #[test]
3117    fn ad_insufficient_data() {
3118        assert!(anderson_darling_test(&[1.0, 2.0]).is_none()); // n < 3
3119    }
3120
3121    #[test]
3122    fn ad_degenerate_data() {
3123        // All same value → std = 0 → None
3124        assert!(anderson_darling_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3125    }
3126
3127    #[test]
3128    fn ad_modified_statistic_formula() {
3129        // A²* > A² for any finite n
3130        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
3131        let r = anderson_darling_test(&data).unwrap();
3132        assert!(r.statistic_star > r.statistic - 1e-10);
3133        // Below 8 values the correction is not tabulated: refused, not extrapolated.
3134        assert!(anderson_darling_test(&data[..7]).is_none());
3135    }
3136
3137    /// Validates A²* = A² · (1 + 0.75/n + 2.25/n²) against manual computation.
3138    ///
3139    /// Reference: Stephens (1974), "EDF statistics for goodness of fit",
3140    /// J. Amer. Statist. Assoc. 69(347), 730–737 (composite normal case).
3141    #[test]
3142    fn ad_correction_factor_formula() {
3143        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3144        let n = data.len() as f64;
3145        let r = anderson_darling_test(&data).unwrap();
3146
3147        // Verify the Stephens correction formula A²* = A² · (1 + 0.75/n + 2.25/n²)
3148        let expected_star = r.statistic * (1.0 + 0.75 / n + 2.25 / (n * n));
3149        assert!(
3150            (r.statistic_star - expected_star).abs() < 1e-12,
3151            "A²* = {}, expected = {}",
3152            r.statistic_star,
3153            expected_star
3154        );
3155
3156        // A² must be positive (it is a sum of log terms with negative coefficient)
3157        assert!(
3158            r.statistic >= 0.0,
3159            "A² = {} must be non-negative",
3160            r.statistic
3161        );
3162    }
3163
3164    /// Validates that the A² summation formula implements:
3165    /// A² = -n - (1/n)·Σᵢ₌₀ⁿ⁻¹ (2i+1)·[ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))]
3166    ///
3167    /// This tests the formula structure by checking that a perfectly symmetric
3168    /// dataset centered at 0 produces a smaller A² than a skewed dataset.
3169    #[test]
3170    fn ad_formula_structure_symmetric_vs_skewed() {
3171        // Symmetric around mean → smaller A²
3172        let symmetric = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 0.0];
3173        // Right-skewed → larger A² (heavier right tail deviates from normality)
3174        let skewed: Vec<f64> = (1..=10).map(|i| (i as f64 * 0.5).exp()).collect();
3175
3176        let r_sym = anderson_darling_test(&symmetric).unwrap();
3177        let r_skew = anderson_darling_test(&skewed).unwrap();
3178
3179        assert!(
3180            r_sym.statistic < r_skew.statistic,
3181            "Symmetric A²={} should be less than skewed A²={}",
3182            r_sym.statistic,
3183            r_skew.statistic
3184        );
3185    }
3186
3187    /// Validates p-value invariants for the Anderson-Darling test.
3188    ///
3189    /// - Normal data: p > 0.05 (cannot reject normality)
3190    /// - Exponential data: p < 0.05 (reject normality)
3191    #[test]
3192    fn ad_pvalue_invariants() {
3193        // Near-normal data — tightly clustered, should not reject normality
3194        let normal_data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3195        let r_normal = anderson_darling_test(&normal_data).unwrap();
3196        assert!(
3197            r_normal.p_value > 0.05,
3198            "Normal data: p = {} (expected > 0.05)",
3199            r_normal.p_value
3200        );
3201
3202        // Exponential data — heavy right tail, should reject normality
3203        let exp_data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
3204        let r_exp = anderson_darling_test(&exp_data).unwrap();
3205        assert!(
3206            r_exp.p_value < 0.05,
3207            "Exponential data: p = {} (expected < 0.05)",
3208            r_exp.p_value
3209        );
3210    }
3211
3212    // -----------------------------------------------------------------------
3213    // Shapiro-Wilk
3214    // -----------------------------------------------------------------------
3215
3216    #[test]
3217    fn sw_normal_data() {
3218        // Approximately normal data (symmetric, bell-shaped)
3219        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
3220        let r = shapiro_wilk_test(&data).expect("should compute");
3221        assert!(r.w > 0.9, "W = {}", r.w);
3222        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3223    }
3224
3225    #[test]
3226    fn sw_bimodal_data() {
3227        // Bimodal data — clearly not normal
3228        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
3229        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
3230        let r = shapiro_wilk_test(&data).expect("should compute");
3231        assert!(
3232            r.p_value < 0.01,
3233            "p = {} (bimodal should reject normality)",
3234            r.p_value
3235        );
3236    }
3237
3238    #[test]
3239    fn sw_n3() {
3240        let data = [1.0, 2.0, 3.0];
3241        let r = shapiro_wilk_test(&data).expect("n=3 should work");
3242        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3243        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3244    }
3245
3246    #[test]
3247    fn sw_n4() {
3248        let data = [1.0, 2.0, 3.0, 4.0];
3249        let r = shapiro_wilk_test(&data).expect("n=4 should work");
3250        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3251        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3252    }
3253
3254    #[test]
3255    fn sw_n5() {
3256        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
3257        let r = shapiro_wilk_test(&data).expect("n=5 should work");
3258        assert!(r.w > 0.9, "W = {}", r.w);
3259        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3260    }
3261
3262    #[test]
3263    fn sw_skewed_data() {
3264        // Exponential-like data — not normal
3265        let data = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3, 2.1, 3.4, 5.5, 8.9, 14.4, 23.3];
3266        let r = shapiro_wilk_test(&data).expect("should compute");
3267        assert!(
3268            r.p_value < 0.05,
3269            "p = {} (skewed data should reject normality)",
3270            r.p_value
3271        );
3272    }
3273
3274    #[test]
3275    fn sw_large_normal_sample() {
3276        // Generate pseudo-normal data via Box-Muller-like approach
3277        // Use linearly spaced quantiles from standard normal
3278        let n = 100;
3279        let data: Vec<f64> = (1..=n)
3280            .map(|i| {
3281                let p = (i as f64 - 0.5) / n as f64;
3282                special::inverse_normal_cdf(p)
3283            })
3284            .collect();
3285        let r = shapiro_wilk_test(&data).expect("should compute");
3286        assert!(r.w > 0.99, "W = {} for normal quantiles", r.w);
3287        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3288    }
3289
3290    #[test]
3291    fn sw_w_bounded() {
3292        // W should be in (0, 1] for any valid data
3293        let datasets: Vec<Vec<f64>> = vec![
3294            vec![1.0, 2.0, 3.0],
3295            vec![1.0, 1.0, 2.0, 3.0, 3.0],
3296            vec![0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
3297            (0..20).map(|i| (i as f64).powi(2)).collect(),
3298        ];
3299        for (idx, data) in datasets.iter().enumerate() {
3300            let r = shapiro_wilk_test(data).unwrap_or_else(|| panic!("dataset {idx} should work"));
3301            assert!(r.w > 0.0 && r.w <= 1.0, "dataset {idx}: W = {}", r.w);
3302            assert!(
3303                r.p_value >= 0.0 && r.p_value <= 1.0,
3304                "dataset {idx}: p = {}",
3305                r.p_value
3306            );
3307        }
3308    }
3309
3310    #[test]
3311    fn sw_edge_cases() {
3312        assert!(shapiro_wilk_test(&[1.0, 2.0]).is_none()); // n < 3
3313        assert!(shapiro_wilk_test(&[]).is_none()); // empty
3314        assert!(shapiro_wilk_test(&[5.0, 5.0, 5.0]).is_none()); // constant
3315        assert!(shapiro_wilk_test(&[1.0, f64::NAN, 3.0]).is_none()); // NaN
3316        assert!(shapiro_wilk_test(&[1.0, f64::INFINITY, 3.0]).is_none()); // Inf
3317    }
3318
3319    #[test]
3320    fn sw_n5001_rejected() {
3321        let data: Vec<f64> = (0..5001).map(|i| i as f64).collect();
3322        assert!(shapiro_wilk_test(&data).is_none()); // n > 5000
3323    }
3324
3325    // -----------------------------------------------------------------------
3326    // Mann-Whitney U
3327    // -----------------------------------------------------------------------
3328
3329    #[test]
3330    fn mw_clearly_different() {
3331        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
3332        let b = [6.0, 7.0, 8.0, 9.0, 10.0];
3333        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3334        assert!(r.p_value < 0.05, "p = {}", r.p_value);
3335    }
3336
3337    #[test]
3338    fn mw_same_distribution() {
3339        let a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0];
3340        let b = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0];
3341        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3342        assert!(
3343            r.p_value > 0.3,
3344            "p = {} (interleaved, same dist)",
3345            r.p_value
3346        );
3347    }
3348
3349    #[test]
3350    fn mw_with_ties() {
3351        let a = [1.0, 2.0, 2.0, 3.0, 3.0];
3352        let b = [3.0, 4.0, 4.0, 5.0, 5.0];
3353        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3354        assert!(r.p_value < 0.05, "p = {} (shifted with ties)", r.p_value);
3355    }
3356
3357    #[test]
3358    fn mw_different_sizes() {
3359        let a = [1.0, 2.0, 3.0];
3360        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
3361        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3362        assert!(r.p_value < 0.05);
3363    }
3364
3365    #[test]
3366    fn mw_edge_cases() {
3367        assert!(mann_whitney_u_test(&[1.0], &[2.0, 3.0]).is_none()); // n1 < 2
3368        assert!(mann_whitney_u_test(&[1.0, 2.0], &[3.0]).is_none()); // n2 < 2
3369        assert!(mann_whitney_u_test(&[1.0, f64::NAN], &[2.0, 3.0]).is_none());
3370    }
3371
3372    // -----------------------------------------------------------------------
3373    // Wilcoxon signed-rank
3374    // -----------------------------------------------------------------------
3375
3376    #[test]
3377    fn wilcoxon_significant_increase() {
3378        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
3379        let after = [6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5];
3380        let r = wilcoxon_signed_rank_test(&before, &after).expect("should compute");
3381        assert!(r.p_value < 0.05, "p = {} (consistent increase)", r.p_value);
3382    }
3383
3384    #[test]
3385    fn wilcoxon_no_difference() {
3386        let x = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3387        let y = [5.1, 5.9, 7.1, 7.9, 9.1, 9.9];
3388        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3389        assert!(r.p_value > 0.3, "p = {} (small random diffs)", r.p_value);
3390    }
3391
3392    #[test]
3393    fn wilcoxon_with_ties() {
3394        // Some differences are equal in magnitude
3395        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3396        let y = [0.0, 1.0, 2.0, 3.0, 4.0]; // constant difference = 1.0
3397        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3398        // All differences positive with ties in magnitude
3399        assert!(r.statistic > 0.0);
3400    }
3401
3402    #[test]
3403    fn wilcoxon_with_zero_diffs() {
3404        // Some pairs are equal → zero differences discarded
3405        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3406        let y = [1.0, 2.0, 3.0, 3.0, 4.0]; // first 3 are zero diffs
3407        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3408        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3409    }
3410
3411    #[test]
3412    fn wilcoxon_edge_cases() {
3413        assert!(wilcoxon_signed_rank_test(&[1.0, 2.0], &[3.0]).is_none()); // mismatch
3414        assert!(wilcoxon_signed_rank_test(&[1.0], &[2.0]).is_none()); // n < 2
3415                                                                      // All zero differences → fewer than 2 non-zero diffs
3416        assert!(wilcoxon_signed_rank_test(&[5.0, 5.0], &[5.0, 5.0]).is_none());
3417    }
3418
3419    /// Oracle: the exact p by enumerating every arrangement of the ranks
3420    /// (Python, independent of the recursion). The normal approximation this
3421    /// replaced reported the second column -- always too small, and below
3422    /// 0.05 in the first and third rows where the exact p is not.
3423    #[test]
3424    fn mann_whitney_small_samples_use_the_exact_distribution() {
3425        // (n1, n2, U, exact p, old normal-approximation p)
3426        let cases = [
3427            (3, 3, 0, 0.1000, 0.0495),
3428            (4, 4, 0, 0.0286, 0.0209),
3429            (4, 4, 1, 0.0571, 0.0433),
3430            (5, 5, 2, 0.0317, 0.0283),
3431            (8, 8, 13, 0.0499, 0.0460),
3432            (10, 10, 23, 0.0433, 0.0413),
3433        ];
3434        for (n1, n2, u, exact, old) in cases {
3435            let (a, b) = separated_samples(n1, n2, u);
3436            let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3437            assert_eq!(r.statistic, u as f64);
3438            assert!(
3439                (r.p_value - exact).abs() < 1e-4,
3440                "n={n1},{n2} U={u}: p={} want {exact} (old {old})",
3441                r.p_value
3442            );
3443        }
3444    }
3445
3446    /// Two samples of sizes `n1`, `n2` with no ties whose U₁ is exactly `u`:
3447    /// sample 1 takes the lowest ranks, then its top member is moved up past
3448    /// `u` members of sample 2 one step at a time.
3449    fn separated_samples(n1: usize, n2: usize, u: usize) -> (Vec<f64>, Vec<f64>) {
3450        let mut ranks_a: Vec<usize> = (1..=n1).collect();
3451        let mut remaining = u;
3452        for k in (0..n1).rev() {
3453            let room = n2.min(remaining);
3454            ranks_a[k] += room;
3455            remaining -= room;
3456            if remaining == 0 {
3457                break;
3458            }
3459        }
3460        let ranks_b: Vec<usize> = (1..=n1 + n2).filter(|r| !ranks_a.contains(r)).collect();
3461        (
3462            ranks_a.iter().map(|&r| r as f64).collect(),
3463            ranks_b.iter().map(|&r| r as f64).collect(),
3464        )
3465    }
3466
3467    /// Oracle: exact p by enumerating all 2ⁿ sign assignments (Python).
3468    #[test]
3469    fn wilcoxon_small_samples_use_the_exact_distribution() {
3470        // (n, T+, exact p)
3471        let cases = [
3472            (5, 0, 0.0625),
3473            (5, 1, 0.125),
3474            (6, 2, 0.09375),
3475            (8, 5, 0.078125),
3476            (10, 8, 0.048828125),
3477            (12, 14, 0.0522460938),
3478        ];
3479        for (n, t_plus, exact) in cases {
3480            // Differences 1..=n; the ranks summing to t_plus are positive.
3481            let positive = ranks_summing_to(n, t_plus);
3482            let x: Vec<f64> = (1..=n)
3483                .map(|r| {
3484                    if positive.contains(&r) {
3485                        r as f64
3486                    } else {
3487                        -(r as f64)
3488                    }
3489                })
3490                .collect();
3491            let y = vec![0.0; n];
3492            let r = wilcoxon_signed_rank_test(&x, &y).expect("valid samples");
3493            assert_eq!(r.statistic, t_plus as f64);
3494            assert!(
3495                (r.p_value - exact).abs() < 1e-9,
3496                "n={n} T+={t_plus}: p={} want {exact}",
3497                r.p_value
3498            );
3499        }
3500    }
3501
3502    /// A set of distinct ranks from 1..=n summing to `target` (greedy from the top).
3503    fn ranks_summing_to(n: usize, target: usize) -> Vec<usize> {
3504        let mut out = Vec::new();
3505        let mut left = target;
3506        for r in (1..=n).rev() {
3507            if r <= left {
3508                out.push(r);
3509                left -= r;
3510            }
3511        }
3512        assert_eq!(left, 0);
3513        out
3514    }
3515
3516    /// Above the exact range, or with ties, the p comes from the normal
3517    /// approximation with continuity correction.
3518    #[test]
3519    fn rank_tests_fall_back_to_continuity_corrected_normal() {
3520        let a: Vec<f64> = (0..60).map(|i| i as f64).collect();
3521        let b: Vec<f64> = (0..60).map(|i| i as f64 + 20.5).collect();
3522        let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3523        let (n1, n2) = (60.0, 60.0);
3524        let mu = n1 * n2 / 2.0;
3525        let sigma = (n1 * n2 * (n1 + n2 + 1.0) / 12.0_f64).sqrt();
3526        let z = ((r.statistic - mu).abs() - 0.5) / sigma;
3527        let want = 2.0 * special::standard_normal_sf(z);
3528        assert!((r.p_value - want).abs() < 1e-12);
3529
3530        // At the boundary the two methods agree closely.
3531        let a: Vec<f64> = (0..50).map(|i| i as f64).collect();
3532        let b: Vec<f64> = (0..50).map(|i| i as f64 + 10.5).collect();
3533        let exact = mann_whitney_u_test(&a, &b).expect("valid samples").p_value;
3534        let mu = 1250.0;
3535        let sigma = (2500.0 * 101.0 / 12.0_f64).sqrt();
3536        let u = mann_whitney_u_test(&a, &b)
3537            .expect("valid samples")
3538            .statistic;
3539        let approx = continuity_corrected_p(u, mu, sigma * sigma);
3540        assert!(
3541            (exact - approx).abs() < 1e-3,
3542            "exact {exact} vs approx {approx}"
3543        );
3544    }
3545
3546    // -----------------------------------------------------------------------
3547    // Kruskal-Wallis
3548    // -----------------------------------------------------------------------
3549
3550    #[test]
3551    fn kw_clearly_different() {
3552        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3553        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3554        let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
3555        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3556        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3557        assert!((r.df - 2.0).abs() < 1e-10);
3558    }
3559
3560    #[test]
3561    fn kw_same_distribution() {
3562        let g1 = [1.0, 3.0, 5.0, 7.0, 9.0];
3563        let g2 = [2.0, 4.0, 6.0, 8.0, 10.0];
3564        let r = kruskal_wallis_test(&[&g1, &g2]).expect("should compute");
3565        assert!(r.p_value > 0.3, "p = {} (interleaved)", r.p_value);
3566    }
3567
3568    #[test]
3569    fn kw_with_ties() {
3570        let g1 = [1.0, 2.0, 2.0, 3.0];
3571        let g2 = [3.0, 4.0, 4.0, 5.0];
3572        let g3 = [5.0, 6.0, 6.0, 7.0];
3573        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3574        assert!(r.statistic > 0.0);
3575    }
3576
3577    #[test]
3578    fn kw_edge_cases() {
3579        let g1 = [1.0, 2.0, 3.0];
3580        assert!(kruskal_wallis_test(&[&g1]).is_none()); // < 2 groups
3581    }
3582
3583    // -----------------------------------------------------------------------
3584    // Levene (Brown-Forsythe)
3585    // -----------------------------------------------------------------------
3586
3587    #[test]
3588    fn levene_equal_variance() {
3589        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3590        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3591        let r = levene_test(&[&g1, &g2]).expect("should compute");
3592        // Both have same spread, different means → equal variance
3593        assert!(r.p_value > 0.3, "p = {} (equal variance)", r.p_value);
3594    }
3595
3596    #[test]
3597    fn levene_unequal_variance() {
3598        let g1 = [4.5, 4.8, 5.0, 5.2, 5.5]; // small spread
3599        let g2 = [0.0, 2.0, 5.0, 8.0, 10.0]; // large spread
3600        let r = levene_test(&[&g1, &g2]).expect("should compute");
3601        assert!(r.p_value < 0.05, "p = {} (unequal variance)", r.p_value);
3602    }
3603
3604    #[test]
3605    fn levene_three_groups() {
3606        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3607        let g2 = [0.0, 3.0, 5.0, 7.0, 10.0];
3608        let g3 = [-5.0, 0.0, 5.0, 10.0, 15.0];
3609        let r = levene_test(&[&g1, &g2, &g3]).expect("should compute");
3610        assert!(r.df >= 2.0);
3611    }
3612
3613    #[test]
3614    fn levene_edge_cases() {
3615        let g1 = [1.0, 2.0, 3.0];
3616        assert!(levene_test(&[&g1]).is_none()); // < 2 groups
3617    }
3618
3619    // -----------------------------------------------------------------------
3620    // Multiple comparison correction
3621    // -----------------------------------------------------------------------
3622
3623    #[test]
3624    fn bonferroni_basic() {
3625        let ps = [0.01, 0.04, 0.03, 0.005];
3626        let adj = bonferroni_correction(&ps).expect("should compute");
3627        assert!((adj[0] - 0.04).abs() < 1e-10);
3628        assert!((adj[1] - 0.16).abs() < 1e-10);
3629        assert!((adj[2] - 0.12).abs() < 1e-10);
3630        assert!((adj[3] - 0.02).abs() < 1e-10);
3631    }
3632
3633    #[test]
3634    fn bonferroni_capped_at_one() {
3635        let ps = [0.5, 0.6];
3636        let adj = bonferroni_correction(&ps).expect("should compute");
3637        assert!((adj[0] - 1.0).abs() < 1e-10);
3638        assert!((adj[1] - 1.0).abs() < 1e-10);
3639    }
3640
3641    #[test]
3642    fn bh_basic() {
3643        let ps = [0.01, 0.04, 0.03, 0.005];
3644        let adj = benjamini_hochberg(&ps).expect("should compute");
3645        // All adjusted p-values should be >= original
3646        for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3647            assert!(
3648                adjusted >= orig - 1e-15,
3649                "adj[{i}] = {adjusted} < original {orig}"
3650            );
3651        }
3652        // Adjusted p-values should still be ordered (weakly) by original order
3653        // after reordering by original p-value
3654    }
3655
3656    #[test]
3657    fn bh_all_significant() {
3658        let ps = [0.001, 0.002, 0.003];
3659        let adj = benjamini_hochberg(&ps).expect("should compute");
3660        for &a in &adj {
3661            assert!(a < 0.05);
3662        }
3663    }
3664
3665    #[test]
3666    fn correction_edge_cases() {
3667        assert!(bonferroni_correction(&[]).is_none());
3668        assert!(benjamini_hochberg(&[]).is_none());
3669        assert!(bonferroni_correction(&[f64::NAN]).is_none());
3670    }
3671
3672    // -----------------------------------------------------------------------
3673    // Bartlett test
3674    // -----------------------------------------------------------------------
3675
3676    #[test]
3677    fn bartlett_equal_variances() {
3678        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0];
3679        let g2 = [12.0, 13.0, 14.0, 15.0, 16.0];
3680        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3681        assert!(
3682            r.p_value > 0.9,
3683            "equal variance → p high, got {}",
3684            r.p_value
3685        );
3686    }
3687
3688    #[test]
3689    fn bartlett_unequal_variances() {
3690        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // var ≈ 2.5
3691        let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // var ≈ 250
3692        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3693        assert!(
3694            r.p_value < 0.01,
3695            "very different variances → p < 0.01, got {}",
3696            r.p_value
3697        );
3698        assert!((r.df - 1.0).abs() < 1e-10); // k-1 = 1
3699    }
3700
3701    #[test]
3702    fn bartlett_three_groups() {
3703        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3704        let g2 = [1.5, 2.5, 3.5, 4.5, 5.5];
3705        let g3 = [10.0, 30.0, 50.0, 70.0, 90.0]; // much higher variance
3706        let r = bartlett_test(&[&g1, &g2, &g3]).expect("should compute");
3707        assert!(r.p_value < 0.05, "one group with high variance");
3708        assert!((r.df - 2.0).abs() < 1e-10); // k-1 = 2
3709    }
3710
3711    #[test]
3712    fn bartlett_edge_cases() {
3713        let g1 = [1.0, 2.0, 3.0];
3714        assert!(bartlett_test(&[&g1]).is_none()); // < 2 groups
3715
3716        let g2 = [5.0, 5.0, 5.0]; // zero variance
3717        assert!(bartlett_test(&[&g1, &g2]).is_none());
3718
3719        let g3 = [1.0]; // group too small
3720        assert!(bartlett_test(&[&g1, &g3]).is_none());
3721    }
3722
3723    // -----------------------------------------------------------------------
3724    // Fisher exact test
3725    // -----------------------------------------------------------------------
3726
3727    #[test]
3728    fn fisher_tea_tasting() {
3729        // Classic Fisher tea-tasting: [[3,1],[1,3]]
3730        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3731        // Known two-tailed p ≈ 0.4857
3732        assert!(
3733            (r.p_value - 0.4857).abs() < 0.01,
3734            "p ≈ 0.4857, got {}",
3735            r.p_value
3736        );
3737    }
3738
3739    #[test]
3740    fn fisher_significant() {
3741        // Strong association: [[10, 0], [0, 10]]
3742        let r = fisher_exact_test(10, 0, 0, 10).expect("should compute");
3743        assert!(r.p_value < 0.001, "perfect association → p very small");
3744    }
3745
3746    #[test]
3747    fn fisher_no_association() {
3748        // Proportional table: [[5, 5], [5, 5]]
3749        let r = fisher_exact_test(5, 5, 5, 5).expect("should compute");
3750        assert!(
3751            r.p_value > 0.9,
3752            "no association → p ≈ 1.0, got {}",
3753            r.p_value
3754        );
3755    }
3756
3757    #[test]
3758    fn fisher_small_table() {
3759        // [[1, 0], [0, 1]]
3760        let r = fisher_exact_test(1, 0, 0, 1).expect("should compute");
3761        assert!(r.p_value > 0.0 && r.p_value <= 1.0);
3762    }
3763
3764    #[test]
3765    fn fisher_asymmetric() {
3766        // [[8, 2], [1, 5]]
3767        let r = fisher_exact_test(8, 2, 1, 5).expect("should compute");
3768        assert!(r.p_value < 0.05, "significant association");
3769    }
3770
3771    #[test]
3772    fn fisher_edge_cases() {
3773        // Zero marginals → None
3774        assert!(fisher_exact_test(0, 0, 1, 2).is_none()); // row1 = 0
3775        assert!(fisher_exact_test(1, 2, 0, 0).is_none()); // row2 = 0
3776        assert!(fisher_exact_test(0, 1, 0, 2).is_none()); // col1 = 0
3777    }
3778
3779    #[test]
3780    fn fisher_odds_ratio() {
3781        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3782        // OR = (3*3)/(1*1) = 9
3783        assert!(
3784            (r.statistic - 9.0).abs() < 1e-10,
3785            "OR = 9, got {}",
3786            r.statistic
3787        );
3788    }
3789
3790    // -----------------------------------------------------------------------
3791    // Mann-Kendall trend test
3792    // -----------------------------------------------------------------------
3793
3794    #[test]
3795    fn mk_increasing_trend() {
3796        let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
3797        let r = mann_kendall_test(&data).expect("should compute");
3798        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3799        assert!(r.kendall_tau > 0.8, "tau = {}", r.kendall_tau);
3800        assert!(r.sen_slope > 0.0, "slope = {}", r.sen_slope);
3801        assert!(r.s_statistic > 0);
3802    }
3803
3804    #[test]
3805    fn mk_decreasing_trend() {
3806        let data = [10.0, 9.2, 8.5, 7.1, 6.3, 5.0, 4.2, 3.1, 2.0, 1.1];
3807        let r = mann_kendall_test(&data).expect("should compute");
3808        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3809        assert!(r.kendall_tau < -0.8, "tau = {}", r.kendall_tau);
3810        assert!(r.sen_slope < 0.0, "slope = {}", r.sen_slope);
3811        assert!(r.s_statistic < 0);
3812    }
3813
3814    #[test]
3815    fn mk_no_trend() {
3816        // Random-looking data with no clear trend
3817        let data = [5.0, 3.0, 7.0, 2.0, 8.0, 4.0, 6.0, 1.0, 9.0, 5.0];
3818        let r = mann_kendall_test(&data).expect("should compute");
3819        // Should not detect significant trend
3820        assert!(
3821            r.p_value > 0.05,
3822            "p = {} (should be > 0.05 for no trend)",
3823            r.p_value
3824        );
3825    }
3826
3827    #[test]
3828    fn mk_perfect_monotone() {
3829        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
3830        let r = mann_kendall_test(&data).expect("should compute");
3831        // Perfect monotone: S = n(n-1)/2 = 45, tau = 1.0
3832        assert_eq!(r.s_statistic, 45);
3833        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3834        assert!((r.sen_slope - 1.0).abs() < 1e-10);
3835    }
3836
3837    #[test]
3838    fn mk_with_ties() {
3839        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0];
3840        let r = mann_kendall_test(&data).expect("should compute");
3841        assert!(r.s_statistic > 0);
3842        // Tie correction should reduce variance
3843        let n = data.len() as f64;
3844        let base_var = n * (n - 1.0) * (2.0 * n + 5.0) / 18.0;
3845        assert!(r.variance < base_var, "ties should reduce variance");
3846    }
3847
3848    #[test]
3849    fn mk_edge_cases() {
3850        // Too few data points
3851        assert!(mann_kendall_test(&[1.0, 2.0, 3.0]).is_none());
3852        // NaN
3853        assert!(mann_kendall_test(&[1.0, f64::NAN, 3.0, 4.0]).is_none());
3854        // All identical (zero variance)
3855        assert!(mann_kendall_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3856    }
3857
3858    #[test]
3859    fn mk_minimum_n() {
3860        // n = 4 should work
3861        let data = [1.0, 2.0, 3.0, 4.0];
3862        let r = mann_kendall_test(&data).expect("n=4 should work");
3863        assert_eq!(r.s_statistic, 6); // C(4,2) = 6 pairs, all positive
3864        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3865    }
3866
3867    #[test]
3868    fn mk_sen_slope_robust_to_outlier() {
3869        // Mostly linear (slope ≈ 1) with one outlier
3870        let data = [1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3871        let r = mann_kendall_test(&data).expect("should compute");
3872        // Sen's slope should be close to 1.0 despite the outlier at index 3
3873        assert!(
3874            (r.sen_slope - 1.0).abs() < 0.5,
3875            "sen slope = {}, expected ≈ 1.0",
3876            r.sen_slope
3877        );
3878    }
3879
3880    // -----------------------------------------------------------------------
3881    // ADF test
3882    // -----------------------------------------------------------------------
3883
3884    #[test]
3885    fn adf_stationary_mean_reverting() {
3886        // Strongly mean-reverting AR(1) process: y_t = 0.3*y_{t-1} + noise
3887        // This is clearly stationary (|ρ| < 1)
3888        let data = [
3889            0.5, 0.45, -0.2, 0.14, 0.54, -0.04, 0.39, -0.18, 0.35, 0.01, -0.3, 0.21, 0.47, -0.06,
3890            0.38, -0.25, 0.12, 0.44, -0.13, 0.36, -0.09, 0.27, 0.51, -0.15, 0.33, -0.22, 0.18,
3891            0.42, -0.08, 0.31, -0.19, 0.25, 0.48, -0.11, 0.37, -0.24, 0.15, 0.43, -0.07, 0.34,
3892        ];
3893        let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).expect("should compute");
3894        // Should reject H₀ (series is stationary)
3895        assert!(
3896            r.rejected[2],
3897            "should reject at 10%: stat={}, cv={}",
3898            r.statistic, r.critical_values[2]
3899        );
3900    }
3901
3902    #[test]
3903    fn adf_nonstationary_random_walk() {
3904        // Cumulative sum simulates random walk (non-stationary)
3905        let increments = [
3906            0.1, -0.2, 0.15, -0.05, 0.3, -0.1, 0.2, -0.15, 0.25, -0.08, 0.12, -0.18, 0.22, -0.07,
3907            0.16, -0.11, 0.19, -0.14, 0.21, -0.09, 0.13, -0.17, 0.24, -0.06, 0.18, -0.12, 0.2,
3908            -0.13, 0.15, -0.1,
3909        ];
3910        let mut walk = Vec::with_capacity(increments.len());
3911        let mut cum = 0.0;
3912        for &inc in &increments {
3913            cum += inc;
3914            walk.push(cum);
3915        }
3916        let r = adf_test(&walk, AdfModel::Constant, AdfLags::default()).expect("should compute");
3917        // Random walk should NOT reject at 1%
3918        assert!(
3919            !r.rejected[0],
3920            "should NOT reject at 1%: stat={}, cv={}",
3921            r.statistic, r.critical_values[0]
3922        );
3923    }
3924
3925    #[test]
3926    fn adf_with_fixed_lags() {
3927        // A pure sinusoid plus a line obeys an exact second-order recurrence, so
3928        // two lags fit its differences exactly (an undefined t-ratio); the noise
3929        // makes it a series the test can be run on.
3930        let mut rng = mulberry32(0xadf2);
3931        let data: Vec<f64> = (0..50)
3932            .map(|i| (i as f64 * 0.5).sin() + 0.02 * i as f64 + 0.1 * (rng() - 0.5))
3933            .collect();
3934        let r = adf_test(&data, AdfModel::Constant, AdfLags::Fixed(2)).expect("should compute");
3935        assert_eq!(r.n_lags, 2);
3936        assert!(r.statistic.is_finite());
3937    }
3938
3939    #[test]
3940    fn adf_constant_trend_model() {
3941        // Linear trend is unit-root-like under "constant" model
3942        // but with "constant+trend" model, it should be recognized
3943        let data: Vec<f64> = (0..30)
3944            .map(|i| i as f64 + (i as f64 * 0.3).sin() * 0.5)
3945            .collect();
3946        let r =
3947            adf_test(&data, AdfModel::ConstantTrend, AdfLags::default()).expect("should compute");
3948        assert!(r.statistic.is_finite());
3949        assert_eq!(r.critical_values.len(), 3);
3950    }
3951
3952    #[test]
3953    fn adf_edge_cases() {
3954        assert_eq!(
3955            adf_test(&[1.0; 9], AdfModel::Constant, AdfLags::default()).unwrap_err(),
3956            TimeSeriesError::TooFewObservations { needed: 10, got: 9 }
3957        );
3958        let mut data = vec![0.0; 20];
3959        data[5] = f64::NAN;
3960        assert_eq!(
3961            adf_test(&data, AdfModel::Constant, AdfLags::default()).unwrap_err(),
3962            TimeSeriesError::ValueNotFinite { index: 5 }
3963        );
3964        // 20 values under a constant: room for 0 to min(14, 8) lags.
3965        assert_eq!(adf_max_lags(20, AdfModel::Constant), 8);
3966        let walk: Vec<f64> = (0..20).map(|i| ((i * 37) % 11) as f64).collect();
3967        for lags in [AdfLags::Fixed(9), AdfLags::Aic { max: Some(9) }] {
3968            let option = if matches!(lags, AdfLags::Fixed(_)) {
3969                "lags"
3970            } else {
3971                "max_lags"
3972            };
3973            assert_eq!(
3974                adf_test(&walk, AdfModel::Constant, lags).unwrap_err(),
3975                TimeSeriesError::OptionOutOfRange {
3976                    option,
3977                    got: 9.0,
3978                    min: 0.0,
3979                    max: 8.0
3980                }
3981            );
3982        }
3983        assert_eq!(
3984            adf_test(&[3.0; 20], AdfModel::Constant, AdfLags::default()).unwrap_err(),
3985            TimeSeriesError::DegenerateRegression
3986        );
3987    }
3988
3989    /// Differences that repeat with period 10 are fitted exactly by 9 lags. The
3990    /// residual variance is rounding noise, and the t-ratio was -1.9e8 -- a
3991    /// "rejection" of the unit root at every level for what is a random walk
3992    /// with a periodic drift. The exact fit is undefined, and the lag search
3993    /// must not choose it.
3994    #[test]
3995    fn adf_exact_fit_is_undefined_and_not_chosen() {
3996        let shocks = [0.5, -0.8, 0.3, -0.6, 0.9, -0.4, 0.7, -0.2, 0.1, -0.5];
3997        let mut walk = vec![0.0_f64; 60];
3998        for i in 1..walk.len() {
3999            walk[i] = walk[i - 1] + shocks[(i * 7) % 10];
4000        }
4001        assert_eq!(
4002            adf_test(&walk, AdfModel::ConstantTrend, AdfLags::Fixed(9)).unwrap_err(),
4003            TimeSeriesError::DegenerateRegression
4004        );
4005        let r = adf_test(&walk, AdfModel::ConstantTrend, AdfLags::default())
4006            .expect("a lag below 9 fits");
4007        assert!(r.n_lags < 9, "chose the exact fit: {} lags", r.n_lags);
4008        assert!(r.statistic.abs() < 1e3, "t = {}", r.statistic);
4009    }
4010
4011    /// statsmodels 0.15 `adfuller` on the same series: the AIC search fits
4012    /// every candidate on one sample and refits the chosen count on all the
4013    /// observations it leaves (the statistic, the count and `nobs` agree).
4014    #[test]
4015    fn adf_matches_statsmodels() {
4016        let mut x = vec![0.0_f64; 120];
4017        for t in 1..x.len() {
4018            let e = ((t * 7919) % 101) as f64 / 50.0 - 1.0;
4019            x[t] = 0.7 * x[t - 1] + e;
4020        }
4021        for (model, lags, stat, n_lags, n_obs) in [
4022            (
4023                AdfModel::Constant,
4024                AdfLags::Aic { max: Some(8) },
4025                -3.436_052_894_8,
4026                5,
4027                114,
4028            ),
4029            (
4030                AdfModel::Constant,
4031                AdfLags::Aic { max: None },
4032                -3.436_052_894_8,
4033                5,
4034                114,
4035            ),
4036            (
4037                AdfModel::ConstantTrend,
4038                AdfLags::Aic { max: Some(6) },
4039                -3.505_247_069_0,
4040                5,
4041                114,
4042            ),
4043            (
4044                AdfModel::None,
4045                AdfLags::Aic { max: Some(5) },
4046                -3.459_240_021_9,
4047                5,
4048                114,
4049            ),
4050            (
4051                AdfModel::Constant,
4052                AdfLags::Fixed(3),
4053                -4.652_930_484_4,
4054                3,
4055                116,
4056            ),
4057        ] {
4058            let r = adf_test(&x, model, lags).expect("fits");
4059            assert_eq!((r.n_lags, r.n_obs), (n_lags, n_obs), "{model:?} {lags:?}");
4060            assert!(
4061                (r.statistic - stat).abs() < 1e-8,
4062                "{model:?} {lags:?}: {}",
4063                r.statistic
4064            );
4065        }
4066    }
4067
4068    #[test]
4069    fn adf_critical_values_constant() {
4070        // Verify critical values are reasonable for n=100
4071        let cv = adf_critical_values(AdfModel::Constant, 100);
4072        // At n=100: approximately -3.51, -2.89, -2.58
4073        assert!(cv[0] < -3.4 && cv[0] > -3.6, "1% cv = {}", cv[0]);
4074        assert!(cv[1] < -2.8 && cv[1] > -3.0, "5% cv = {}", cv[1]);
4075        assert!(cv[2] < -2.5 && cv[2] > -2.7, "10% cv = {}", cv[2]);
4076    }
4077
4078    #[test]
4079    fn adf_critical_values_ordering() {
4080        let cv = adf_critical_values(AdfModel::Constant, 50);
4081        // 1% < 5% < 10% (more negative for stricter levels)
4082        assert!(cv[0] < cv[1], "1% ({}) should be < 5% ({})", cv[0], cv[1]);
4083        assert!(cv[1] < cv[2], "5% ({}) should be < 10% ({})", cv[1], cv[2]);
4084    }
4085}
4086
4087/// The tests against values SciPy 1.17.1 computes for the same data and the
4088/// same variant (`scipy.stats` call named at each value).
4089#[cfg(test)]
4090mod scipy_reference {
4091    use super::*;
4092
4093    const A: [f64; 8] = [5.1, 4.9, 5.6, 5.8, 6.0, 5.2, 4.7, 5.5];
4094    const B: [f64; 7] = [6.3, 5.9, 6.8, 7.1, 6.0, 6.6, 7.4];
4095    const G1: [f64; 5] = [23.1, 25.4, 22.8, 26.0, 24.3];
4096    const G2: [f64; 6] = [27.2, 28.9, 26.5, 29.4, 27.8, 28.1];
4097    const G3: [f64; 4] = [22.0, 21.4, 24.9, 23.3];
4098
4099    /// `got` within `rel` of `want`, relative (absolute below 1).
4100    fn close(got: f64, want: f64, rel: f64, what: &str) {
4101        let scale = want.abs().max(1.0);
4102        assert!(
4103            (got - want).abs() <= rel * scale,
4104            "{what}: got {got}, SciPy {want} (relative tolerance {rel})"
4105        );
4106    }
4107
4108    #[test]
4109    fn t_tests_reproduce_scipy() {
4110        // ttest_1samp(A, 5.0)
4111        let r = one_sample_t_test(&A, 5.0).expect("valid");
4112        close(r.statistic, 2.197950389679656, 1e-12, "one-sample t");
4113        close(r.p_value, 0.06392412293886501, 1e-9, "one-sample p");
4114        assert_eq!(r.df, 7.0);
4115        // ttest_ind(A, B, equal_var=False)
4116        let r = two_sample_t_test(&A, &B).expect("valid");
4117        close(r.statistic, -4.67567567567568, 1e-12, "Welch t");
4118        close(r.df, 11.56763357082458, 1e-12, "Welch df");
4119        close(r.p_value, 0.0005912510074711228, 1e-9, "Welch p");
4120        // ttest_rel(X, Y)
4121        let x = [12.1, 13.4, 11.8, 14.2, 12.9, 13.0];
4122        let y = [12.9, 13.9, 12.0, 15.1, 13.5, 13.2];
4123        let r = paired_t_test(&x, &y).expect("valid");
4124        close(r.statistic, -4.437601569801824, 1e-12, "paired t");
4125        close(r.p_value, 0.006779965572779817, 1e-9, "paired p");
4126        assert_eq!(r.df, 5.0);
4127    }
4128
4129    #[test]
4130    fn anova_and_equal_variance_tests_reproduce_scipy() {
4131        let groups: [&[f64]; 3] = [&G1, &G2, &G3];
4132        // f_oneway(G1, G2, G3)
4133        let r = one_way_anova(&groups).expect("valid");
4134        close(r.f_statistic, 20.526701958197258, 1e-12, "ANOVA F");
4135        close(r.p_value, 0.00013390844134272023, 1e-9, "ANOVA p");
4136        // levene(G1, G2, G3, center="median")
4137        let r = levene_test(&groups).expect("valid");
4138        close(r.statistic, 0.4752743705616534, 1e-12, "Brown-Forsythe W");
4139        close(r.p_value, 0.6329340379295331, 1e-9, "Brown-Forsythe p");
4140        // bartlett(G1, G2, G3)
4141        let r = bartlett_test(&groups).expect("valid");
4142        close(r.statistic, 0.5282298427149905, 1e-12, "Bartlett T");
4143        close(r.p_value, 0.7678852881660961, 1e-9, "Bartlett p");
4144    }
4145
4146    #[test]
4147    fn kruskal_wallis_with_ties_reproduces_scipy() {
4148        // kruskal([1, 2, 2, 3, 5], [4, 4, 6, 7], [5, 8, 8, 9, 10])
4149        let r = kruskal_wallis_test(&[
4150            &[1.0, 2.0, 2.0, 3.0, 5.0],
4151            &[4.0, 4.0, 6.0, 7.0],
4152            &[5.0, 8.0, 8.0, 9.0, 10.0],
4153        ])
4154        .expect("valid");
4155        close(r.statistic, 9.22394678492239, 1e-12, "H (tie-corrected)");
4156        close(r.p_value, 0.009932198829456881, 1e-9, "Kruskal-Wallis p");
4157    }
4158
4159    #[test]
4160    fn chi_squared_and_fisher_tests_reproduce_scipy() {
4161        // chisquare([18, 22, 30, 30], [25, 25, 25, 25])
4162        let r = chi_squared_goodness_of_fit(&[18.0, 22.0, 30.0, 30.0], &[25.0; 4]).expect("valid");
4163        close(r.statistic, 4.32, 1e-12, "goodness-of-fit chi-squared");
4164        close(r.p_value, 0.22891886433610517, 1e-9, "goodness-of-fit p");
4165        // chi2_contingency([[12, 7, 9], [5, 14, 11]], correction=False)
4166        let r = chi_squared_independence(&[12.0, 7.0, 9.0, 5.0, 14.0, 11.0], 2, 3).expect("valid");
4167        close(
4168            r.statistic,
4169            5.353085901027075,
4170            1e-12,
4171            "independence chi-squared",
4172        );
4173        close(r.p_value, 0.06880059054935043, 1e-9, "independence p");
4174        assert_eq!(r.df, 2.0);
4175        // fisher_exact([[8, 2], [1, 5]]) -- two-sided
4176        let r = fisher_exact_test(8, 2, 1, 5).expect("valid");
4177        close(r.p_value, 0.034965034965034975, 1e-9, "Fisher p");
4178    }
4179
4180    #[test]
4181    fn shapiro_wilk_reproduces_scipy() {
4182        // shapiro([148, 154, 158, 160, 161, 162, 166, 170, 182, 195, 236])
4183        let r = shapiro_wilk_test(&[
4184            148.0, 154.0, 158.0, 160.0, 161.0, 162.0, 166.0, 170.0, 182.0, 195.0, 236.0,
4185        ])
4186        .expect("valid");
4187        close(r.w, 0.7888146948631716, 1e-9, "W, skewed");
4188        close(r.p_value, 0.006703814061898823, 1e-9, "p, skewed");
4189        // shapiro([2.1, 3.4, 1.9, 5.6, 4.4, 3.3, 2.8, 4.0, 3.9, 3.1, 2.6, 4.8, 3.6, 3.0, 4.2])
4190        let r = shapiro_wilk_test(&[
4191            2.1, 3.4, 1.9, 5.6, 4.4, 3.3, 2.8, 4.0, 3.9, 3.1, 2.6, 4.8, 3.6, 3.0, 4.2,
4192        ])
4193        .expect("valid");
4194        close(r.w, 0.9864343014896592, 1e-9, "W, near normal");
4195        close(r.p_value, 0.9958703691266937, 1e-9, "p, near normal");
4196    }
4197
4198    #[test]
4199    fn mann_kendall_tau_and_sen_slope_reproduce_scipy() {
4200        // kendalltau(range(12), MK) and theilslopes(MK, range(12)) -- no ties,
4201        // so Kendall's tau-b is the Mann-Kendall S over n(n-1)/2.
4202        let mk = [4.2, 4.0, 4.6, 4.9, 4.7, 5.3, 5.1, 5.8, 5.6, 6.2, 6.0, 6.7];
4203        let r = mann_kendall_test(&mk).expect("valid");
4204        close(r.kendall_tau, 0.8484848484848484, 1e-12, "Kendall tau");
4205        close(r.sen_slope, 0.22500000000000003, 1e-12, "Sen's slope");
4206    }
4207
4208    #[test]
4209    fn benjamini_hochberg_reproduces_scipy() {
4210        // false_discovery_control([0.01, 0.04, 0.03, 0.005, 0.20, 0.50], method="bh")
4211        let adjusted = benjamini_hochberg(&[0.01, 0.04, 0.03, 0.005, 0.20, 0.50]).expect("valid");
4212        for (got, want) in adjusted.iter().zip([0.03, 0.06, 0.06, 0.03, 0.24, 0.5]) {
4213            close(*got, want, 1e-12, "BH-adjusted p");
4214        }
4215    }
4216}
4217
4218#[cfg(test)]
4219mod proptests {
4220    use super::*;
4221    use proptest::prelude::*;
4222
4223    proptest! {
4224        #[test]
4225        fn one_sample_p_bounded(
4226            data in proptest::collection::vec(-1e3_f64..1e3, 3..=30),
4227            mu0 in -1e3_f64..1e3
4228        ) {
4229            if let Some(r) = one_sample_t_test(&data, mu0) {
4230                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4231            }
4232        }
4233
4234        #[test]
4235        fn two_sample_p_bounded(
4236            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4237            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4238        ) {
4239            if let Some(r) = two_sample_t_test(&a, &b) {
4240                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4241            }
4242        }
4243
4244        #[test]
4245        fn anova_p_bounded(
4246            g1 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4247            g2 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4248            g3 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4249        ) {
4250            if let Some(r) = one_way_anova(&[&g1, &g2, &g3]) {
4251                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4252                prop_assert!(r.f_statistic >= 0.0, "F = {}", r.f_statistic);
4253            }
4254        }
4255
4256        #[test]
4257        fn bonferroni_monotone(
4258            ps in proptest::collection::vec(0.001_f64..1.0, 2..=10)
4259        ) {
4260            let adj = bonferroni_correction(&ps).expect("should compute");
4261            for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
4262                prop_assert!(adjusted >= orig - 1e-15,
4263                    "adj[{i}] = {adjusted} < orig = {orig}");
4264            }
4265        }
4266
4267        #[test]
4268        fn shapiro_wilk_p_bounded(
4269            data in proptest::collection::vec(-1e3_f64..1e3, 3..=50)
4270        ) {
4271            if let Some(r) = shapiro_wilk_test(&data) {
4272                prop_assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
4273                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4274            }
4275        }
4276
4277        #[test]
4278        fn anderson_darling_p_bounded(
4279            data in proptest::collection::vec(-1e3_f64..1e3, 8..=100)
4280        ) {
4281            if let Some(r) = anderson_darling_test(&data) {
4282                prop_assert!(r.statistic >= 0.0, "A2 = {}", r.statistic);
4283                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4284            }
4285        }
4286
4287        #[test]
4288        fn mann_whitney_p_bounded(
4289            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4290            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4291        ) {
4292            if let Some(r) = mann_whitney_u_test(&a, &b) {
4293                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4294                prop_assert!(r.statistic >= 0.0, "U = {}", r.statistic);
4295            }
4296        }
4297
4298        #[test]
4299        fn wilcoxon_p_bounded(
4300            diffs in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4301        ) {
4302            let zeros: Vec<f64> = vec![0.0; diffs.len()];
4303            if let Some(r) = wilcoxon_signed_rank_test(&diffs, &zeros) {
4304                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4305            }
4306        }
4307
4308        #[test]
4309        fn bartlett_p_bounded(
4310            g1 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
4311            g2 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
4312        ) {
4313            if let Some(r) = bartlett_test(&[&g1, &g2]) {
4314                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4315                prop_assert!(r.statistic >= 0.0, "T = {}", r.statistic);
4316            }
4317        }
4318
4319        #[test]
4320        fn fisher_p_bounded(
4321            a in 0_u64..20,
4322            b in 0_u64..20,
4323            c in 0_u64..20,
4324            d in 0_u64..20,
4325        ) {
4326            if let Some(r) = fisher_exact_test(a, b, c, d) {
4327                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4328            }
4329        }
4330
4331        #[test]
4332        fn mann_kendall_p_bounded(
4333            data in proptest::collection::vec(-1e3_f64..1e3, 4..=30)
4334        ) {
4335            if let Some(r) = mann_kendall_test(&data) {
4336                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4337                prop_assert!(r.kendall_tau >= -1.0 && r.kendall_tau <= 1.0,
4338                    "tau = {}", r.kendall_tau);
4339            }
4340        }
4341
4342        #[test]
4343        fn mann_kendall_monotone_increasing(n in 5_usize..=30) {
4344            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
4345            let r = mann_kendall_test(&data).expect("should compute");
4346            prop_assert!((r.kendall_tau - 1.0).abs() < 1e-10,
4347                "tau should be 1.0 for monotone, got {}", r.kendall_tau);
4348            prop_assert!(r.sen_slope > 0.0, "slope should be positive");
4349        }
4350    }
4351}
4352
4353/// Bonferroni's adjustment is `min(p m, 1)` (Dunn 1961); the expected values
4354/// are that definition worked by hand, not another library's output.
4355#[cfg(test)]
4356mod definition_reference {
4357    use super::*;
4358
4359    #[test]
4360    fn bonferroni_is_p_times_m_capped_at_one() {
4361        let adjusted = bonferroni_correction(&[0.01, 0.02, 0.04, 0.3, 0.0004]).expect("valid");
4362        let want = [0.05, 0.1, 0.2, 1.0, 0.002];
4363        assert_eq!(adjusted.len(), want.len());
4364        for (got, want) in adjusted.iter().zip(want) {
4365            assert!((got - want).abs() <= 1e-15, "got {got}, want {want}");
4366        }
4367    }
4368}
4369
4370/// P-values far in the upper tail against SciPy 1.17.1 -- sizes at which
4371/// `1 - cdf` has no digits left (the smallest here is 3e-31).
4372#[cfg(test)]
4373mod scipy_tail_reference {
4374    use super::*;
4375
4376    fn close(got: f64, want: f64, what: &str) {
4377        assert!(
4378            (got - want).abs() <= 1e-9 * want.abs(),
4379            "{what}: got {got:e}, SciPy {want:e}"
4380        );
4381    }
4382
4383    #[test]
4384    fn t_tests_keep_their_tail() {
4385        let a = [100.2, 99.8, 100.5, 100.1, 99.9, 100.3, 100.0, 99.7];
4386        let b = [120.1, 119.7, 120.4, 120.0, 119.8, 120.3];
4387        // ttest_1samp(a, 90.0)
4388        let r = one_sample_t_test(&a, 90.0).expect("valid");
4389        close(r.p_value, 1.6824197797300066e-12, "one-sample p");
4390        // ttest_ind(a, b, equal_var=False)
4391        let r = two_sample_t_test(&a, &b).expect("valid");
4392        close(r.p_value, 8.838218005160137e-19, "Welch p");
4393    }
4394
4395    #[test]
4396    fn f_and_chi_squared_tests_keep_their_tail() {
4397        // f_oneway(g1, g2, g3)
4398        let (g1, g2, g3) = (
4399            [10.1, 10.3, 9.9, 10.0, 10.2],
4400            [12.0, 12.2, 11.9, 12.1, 12.3],
4401            [14.1, 13.9, 14.0, 14.2, 13.8],
4402        );
4403        let r = one_way_anova(&[&g1, &g2, &g3]).expect("valid");
4404        close(r.p_value, 2.297566098988527e-13, "ANOVA p");
4405        // chi2_contingency([[80, 20], [30, 70]], correction=False)
4406        let r = chi_squared_independence(&[80.0, 20.0, 30.0, 70.0], 2, 2).expect("valid");
4407        close(r.p_value, 1.1885852395646684e-12, "independence p");
4408        // chisquare([90, 10, 5, 95], [50, 50, 50, 50])
4409        let r = chi_squared_goodness_of_fit(&[90.0, 10.0, 5.0, 95.0], &[50.0; 4]).expect("valid");
4410        close(r.p_value, 3.1567404374222543e-31, "goodness-of-fit p");
4411        // kruskal(1..=10, 11..=20, 21..=30)
4412        let low: Vec<f64> = (1..=10).map(f64::from).collect();
4413        let mid: Vec<f64> = (11..=20).map(f64::from).collect();
4414        let high: Vec<f64> = (21..=30).map(f64::from).collect();
4415        let r = kruskal_wallis_test(&[&low, &mid, &high]).expect("valid");
4416        close(r.p_value, 2.49000508218493e-06, "Kruskal-Wallis p");
4417    }
4418}