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