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/// Whether two sample values are one rank: equal, or equal up to the rounding
1189/// of the arithmetic that made them (a relative 1e-12, so the same samples in
1190/// another unit tie the same pairs; a fixed gap would call every value of a
1191/// sample in picovolts tied).
1192fn tied(a: f64, b: f64) -> bool {
1193    a == b || (a - b).abs() <= 1e-12 * a.abs().max(b.abs())
1194}
1195
1196// Assign average ranks to sorted (value, group_or_index) pairs.
1197// Handles ties by assigning the average of the tied ranks.
1198fn average_ranks(sorted: &[(f64, usize)]) -> Vec<f64> {
1199    let n = sorted.len();
1200    let mut ranks = vec![0.0; n];
1201    let mut i = 0;
1202    while i < n {
1203        let mut j = i + 1;
1204        while j < n && tied(sorted[j].0, sorted[i].0) {
1205            j += 1;
1206        }
1207        // Positions i..j are tied; average rank = (i+1 + j) / 2
1208        let avg_rank = (i + 1 + j) as f64 / 2.0;
1209        for rank in ranks.iter_mut().take(j).skip(i) {
1210            *rank = avg_rank;
1211        }
1212        i = j;
1213    }
1214    ranks
1215}
1216
1217// Compute tie correction factor: Σ tₖ(tₖ² - 1) for all tie groups
1218fn compute_tie_correction(sorted: &[(f64, usize)]) -> f64 {
1219    let n = sorted.len();
1220    let mut correction = 0.0;
1221    let mut i = 0;
1222    while i < n {
1223        let mut j = i + 1;
1224        while j < n && tied(sorted[j].0, sorted[i].0) {
1225            j += 1;
1226        }
1227        let t = (j - i) as f64;
1228        if t > 1.0 {
1229            correction += t * (t * t - 1.0);
1230        }
1231        i = j;
1232    }
1233    correction
1234}
1235
1236/// Kruskal-Wallis test: H₀: all groups have the same distribution.
1237///
1238/// Non-parametric alternative to one-way ANOVA. Does not assume normality.
1239///
1240/// # Algorithm
1241///
1242/// 1. Combine all groups, rank observations (average ranks for ties)
1243/// 2. H = (12 / N(N+1)) Σ nᵢ (R̄ᵢ - R̄)² with tie correction
1244/// 3. H ~ χ²(k-1) under H₀
1245///
1246/// # Returns
1247///
1248/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
1249/// or non-finite values.
1250///
1251/// # References
1252///
1253/// - Kruskal & Wallis (1952). "Use of ranks in one-criterion variance
1254///   analysis". JASA, 47(260), 583–621.
1255///
1256/// # Examples
1257///
1258/// ```
1259/// use u_analytics::testing::kruskal_wallis_test;
1260///
1261/// let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
1262/// let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
1263/// let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
1264/// let r = kruskal_wallis_test(&[&g1, &g2, &g3]).unwrap();
1265/// assert!(r.p_value < 0.01);
1266/// ```
1267pub fn kruskal_wallis_test(groups: &[&[f64]]) -> Option<TestResult> {
1268    let k = groups.len();
1269    if k < 2 {
1270        return None;
1271    }
1272    for g in groups {
1273        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1274            return None;
1275        }
1276    }
1277
1278    let total_n: usize = groups.iter().map(|g| g.len()).sum();
1279    let nf = total_n as f64;
1280
1281    // Combine all observations with group labels
1282    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(total_n);
1283    for (gi, g) in groups.iter().enumerate() {
1284        for &v in *g {
1285            combined.push((v, gi));
1286        }
1287    }
1288    combined.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1289
1290    let ranks = average_ranks(&combined);
1291
1292    // Sum of ranks per group
1293    let mut rank_sums = vec![0.0; k];
1294    for ((_, gi), &r) in combined.iter().zip(ranks.iter()) {
1295        rank_sums[*gi] += r;
1296    }
1297
1298    // H statistic: H = (12 / N(N+1)) * Σ Rᵢ²/nᵢ - 3(N+1)
1299    let mean_rank = (nf + 1.0) / 2.0;
1300    let mut h = 0.0;
1301    for (gi, g) in groups.iter().enumerate() {
1302        let ni = g.len() as f64;
1303        let mean_rank_i = rank_sums[gi] / ni;
1304        h += ni * (mean_rank_i - mean_rank).powi(2);
1305    }
1306    h *= 12.0 / (nf * (nf + 1.0));
1307
1308    // Tie correction: divide by 1 - Σtₖ(tₖ²-1) / (N³ - N)
1309    let tie_corr = compute_tie_correction(&combined);
1310    let denom = 1.0 - tie_corr / (nf * nf * nf - nf);
1311    if denom > 1e-15 {
1312        h /= denom;
1313    }
1314
1315    let df = (k - 1) as f64;
1316    let p_value = special::chi_squared_sf(h, df);
1317
1318    Some(TestResult {
1319        statistic: h,
1320        df,
1321        p_value,
1322    })
1323}
1324
1325// ---------------------------------------------------------------------------
1326// Variance tests
1327// ---------------------------------------------------------------------------
1328
1329/// Levene test for equality of variances: H₀: all groups have equal variance.
1330///
1331/// Robust to non-normality. Uses the **median** variant (Brown-Forsythe),
1332/// which is recommended for non-normal data.
1333///
1334/// # Algorithm
1335///
1336/// 1. Compute zᵢⱼ = |xᵢⱼ - median(groupᵢ)|
1337/// 2. Apply one-way ANOVA on the zᵢⱼ values
1338///
1339/// # Returns
1340///
1341/// `None` if fewer than 2 groups, any group < 2 observations, or non-finite values.
1342///
1343/// # References
1344///
1345/// - Levene (1960). "Robust tests for equality of variances". In
1346///   Olkin (Ed.), Contributions to Probability and Statistics.
1347/// - Brown & Forsythe (1974). "Robust tests for the equality of variances".
1348///   JASA, 69(346), 364–367.
1349///
1350/// # Examples
1351///
1352/// ```
1353/// use u_analytics::testing::levene_test;
1354///
1355/// let g1 = [4.9, 5.0, 5.0, 5.1, 5.0]; // tight cluster (low variance)
1356/// let g2 = [0.0, 3.0, 5.0, 7.0, 10.0]; // wide spread (high variance)
1357/// let r = levene_test(&[&g1, &g2]).unwrap();
1358/// assert!(r.p_value < 0.05); // clear variance difference
1359/// ```
1360pub fn levene_test(groups: &[&[f64]]) -> Option<TestResult> {
1361    let k = groups.len();
1362    if k < 2 {
1363        return None;
1364    }
1365    for g in groups {
1366        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1367            return None;
1368        }
1369    }
1370
1371    // Compute z-values: |x - median(group)| (Brown-Forsythe variant)
1372    let z_groups: Vec<Vec<f64>> = groups
1373        .iter()
1374        .map(|g| {
1375            let median = stats::median(g).unwrap_or(0.0);
1376            g.iter().map(|&x| (x - median).abs()).collect()
1377        })
1378        .collect();
1379
1380    // Apply ANOVA on the z-values
1381    let z_refs: Vec<&[f64]> = z_groups.iter().map(|v| v.as_slice()).collect();
1382    let anova = one_way_anova(&z_refs)?;
1383
1384    Some(TestResult {
1385        statistic: anova.f_statistic,
1386        df: anova.df_between as f64,
1387        p_value: anova.p_value,
1388    })
1389}
1390
1391// ---------------------------------------------------------------------------
1392// Multiple comparison correction
1393// ---------------------------------------------------------------------------
1394
1395/// Bonferroni correction: adjusts p-values for multiple comparisons.
1396///
1397/// adjusted_pᵢ = min(pᵢ × m, 1.0) where m = number of tests.
1398///
1399/// # Returns
1400///
1401/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1402/// (a NaN included).
1403pub fn bonferroni_correction(p_values: &[f64]) -> Option<Vec<f64>> {
1404    if p_values.is_empty() || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1405        return None;
1406    }
1407    let m = p_values.len() as f64;
1408    Some(p_values.iter().map(|&p| (p * m).min(1.0)).collect())
1409}
1410
1411/// Benjamini-Hochberg FDR correction.
1412///
1413/// Controls false discovery rate at level α.
1414///
1415/// # Algorithm
1416///
1417/// 1. Sort p-values.
1418/// 2. For rank i (1-indexed): adjusted_pᵢ = pᵢ × m / i.
1419/// 3. Enforce monotonicity (cumulative minimum from right).
1420///
1421/// # Returns
1422///
1423/// `None` if the slice is empty or a value is not a probability in `[0, 1]`
1424/// (a NaN included).
1425///
1426/// # References
1427///
1428/// Benjamini & Hochberg (1995). "Controlling the false discovery rate".
1429/// JRSS-B, 57(1), 289–300.
1430pub fn benjamini_hochberg(p_values: &[f64]) -> Option<Vec<f64>> {
1431    let m = p_values.len();
1432    if m == 0 || p_values.iter().any(|v| !(0.0..=1.0).contains(v)) {
1433        return None;
1434    }
1435
1436    // Sort indices by p-value
1437    let mut indices: Vec<usize> = (0..m).collect();
1438    indices.sort_by(|&a, &b| {
1439        p_values[a]
1440            .partial_cmp(&p_values[b])
1441            .unwrap_or(std::cmp::Ordering::Equal)
1442    });
1443
1444    let mf = m as f64;
1445    let mut adjusted = vec![0.0; m];
1446
1447    // Compute adjusted p-values
1448    let mut cummin = f64::INFINITY;
1449    for (rank_rev, &orig_idx) in indices.iter().enumerate().rev() {
1450        let rank = rank_rev + 1; // 1-indexed
1451        let adj = (p_values[orig_idx] * mf / rank as f64).min(1.0);
1452        cummin = cummin.min(adj);
1453        adjusted[orig_idx] = cummin;
1454    }
1455
1456    Some(adjusted)
1457}
1458
1459// ---------------------------------------------------------------------------
1460// Bartlett test for equality of variances
1461// ---------------------------------------------------------------------------
1462
1463/// Bartlett test for equality of variances: H₀: all groups have equal variance.
1464///
1465/// Assumes data are from **normal** distributions. For non-normal data, prefer
1466/// [`levene_test`] (Brown-Forsythe variant).
1467///
1468/// # Algorithm
1469///
1470/// 1. Compute pooled variance: s²ₚ = Σ(nᵢ-1)s²ᵢ / (N-k)
1471/// 2. Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1472/// 3. Correction factor: C = 1 + [1/(3(k-1))] × [Σ 1/(nᵢ-1) - 1/(N-k)]
1473/// 4. Statistic: T = numerator / C ~ χ²(k-1)
1474///
1475/// # Returns
1476///
1477/// `None` if fewer than 2 groups, any group < 2 observations, any group has
1478/// zero variance, or non-finite values.
1479///
1480/// # References
1481///
1482/// - Bartlett (1937). "Properties of sufficiency and statistical tests".
1483///   Proceedings of the Royal Society A, 160(901), 268–282.
1484///
1485/// # Examples
1486///
1487/// ```
1488/// use u_analytics::testing::bartlett_test;
1489///
1490/// let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // variance ~2.5
1491/// let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // variance ~250
1492/// let r = bartlett_test(&[&g1, &g2]).unwrap();
1493/// assert!(r.p_value < 0.01); // strongly different variances
1494/// ```
1495pub fn bartlett_test(groups: &[&[f64]]) -> Option<TestResult> {
1496    let k = groups.len();
1497    if k < 2 {
1498        return None;
1499    }
1500
1501    let mut sizes = Vec::with_capacity(k);
1502    let mut vars = Vec::with_capacity(k);
1503    let mut n_total: usize = 0;
1504
1505    for g in groups {
1506        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1507            return None;
1508        }
1509        let n = g.len();
1510        let v = stats::variance(g)?;
1511        if v <= 0.0 {
1512            return None; // zero variance → ln undefined
1513        }
1514        sizes.push(n);
1515        vars.push(v);
1516        n_total += n;
1517    }
1518
1519    let nk = n_total - k; // N - k
1520    if nk == 0 {
1521        return None;
1522    }
1523    let nk_f = nk as f64;
1524
1525    // Pooled variance
1526    let s2_pooled: f64 = sizes
1527        .iter()
1528        .zip(vars.iter())
1529        .map(|(&n, &v)| (n as f64 - 1.0) * v)
1530        .sum::<f64>()
1531        / nk_f;
1532
1533    if s2_pooled <= 0.0 {
1534        return None;
1535    }
1536
1537    // Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1538    let num = nk_f * s2_pooled.ln()
1539        - sizes
1540            .iter()
1541            .zip(vars.iter())
1542            .map(|(&n, &v)| (n as f64 - 1.0) * v.ln())
1543            .sum::<f64>();
1544
1545    // Correction factor C
1546    let sum_recip: f64 = sizes.iter().map(|&n| 1.0 / (n as f64 - 1.0)).sum();
1547    let c = 1.0 + (sum_recip - 1.0 / nk_f) / (3.0 * (k as f64 - 1.0));
1548
1549    let statistic = num / c;
1550    let df = (k - 1) as f64;
1551    let p_value = special::chi_squared_sf(statistic, df);
1552
1553    Some(TestResult {
1554        statistic,
1555        df,
1556        p_value,
1557    })
1558}
1559
1560// ---------------------------------------------------------------------------
1561// Fisher exact test (2×2)
1562// ---------------------------------------------------------------------------
1563
1564/// Fisher exact test for a 2×2 contingency table.
1565///
1566/// Tests H₀: the two categorical variables are independent.
1567/// Unlike the chi-squared test, this is exact and valid for small samples.
1568///
1569/// # Arguments
1570///
1571/// The 2×2 table is specified as four cell counts:
1572///
1573/// ```text
1574///          Col1   Col2
1575///   Row1 |  a   |  b  |
1576///   Row2 |  c   |  d  |
1577/// ```
1578///
1579/// # Algorithm
1580///
1581/// 1. Compute probability of observed table via hypergeometric distribution
1582///    (using log-factorials for numerical stability).
1583/// 2. Enumerate all tables with the same marginals.
1584/// 3. Two-tailed p-value = sum of probabilities ≤ P(observed).
1585///
1586/// # Returns
1587///
1588/// `None` if any marginal total is zero (degenerate table).
1589///
1590/// # References
1591///
1592/// - Fisher (1922). "On the interpretation of χ² from contingency tables,
1593///   and the calculation of P". JRSS, 85(1), 87–94.
1594///
1595/// # Examples
1596///
1597/// ```
1598/// use u_analytics::testing::fisher_exact_test;
1599///
1600/// // Tea-tasting experiment
1601/// let r = fisher_exact_test(3, 1, 1, 3).unwrap();
1602/// assert!(r.p_value > 0.05); // not significant at 5%
1603/// ```
1604pub fn fisher_exact_test(a: u64, b: u64, c: u64, d: u64) -> Option<TestResult> {
1605    let row1 = a + b;
1606    let row2 = c + d;
1607    let col1 = a + c;
1608    let col2 = b + d;
1609    let n = a + b + c + d;
1610
1611    // Degenerate if any marginal is zero
1612    if row1 == 0 || row2 == 0 || col1 == 0 || col2 == 0 {
1613        return None;
1614    }
1615
1616    // Log-probability of a specific table given marginals
1617    let log_prob = |a_i: u64| -> f64 {
1618        let b_i = row1 - a_i;
1619        let c_i = col1 - a_i;
1620        let d_i = row2 - c_i;
1621        ln_factorial(row1) + ln_factorial(row2) + ln_factorial(col1) + ln_factorial(col2)
1622            - ln_factorial(a_i)
1623            - ln_factorial(b_i)
1624            - ln_factorial(c_i)
1625            - ln_factorial(d_i)
1626            - ln_factorial(n)
1627    };
1628
1629    // Range of valid values for cell a
1630    let a_min = col1.saturating_sub(row2);
1631    let a_max = row1.min(col1);
1632
1633    let log_p_obs = log_prob(a);
1634
1635    // Two-tailed: sum probabilities ≤ P(observed)
1636    let mut p_value = 0.0;
1637    for a_i in a_min..=a_max {
1638        let lp = log_prob(a_i);
1639        // Use small tolerance for floating-point comparison
1640        if lp <= log_p_obs + 1e-10 {
1641            p_value += lp.exp();
1642        }
1643    }
1644
1645    // Clamp to [0, 1]
1646    let p_value = p_value.min(1.0);
1647
1648    // Odds ratio: (a*d) / (b*c)
1649    let odds_ratio = if b > 0 && c > 0 {
1650        (a as f64 * d as f64) / (b as f64 * c as f64)
1651    } else {
1652        f64::INFINITY
1653    };
1654
1655    Some(TestResult {
1656        statistic: odds_ratio,
1657        df: 1.0,
1658        p_value,
1659    })
1660}
1661
1662// ---------------------------------------------------------------------------
1663// Mann-Kendall trend test
1664// ---------------------------------------------------------------------------
1665
1666/// Result of the Mann-Kendall trend test.
1667#[derive(Debug, Clone, Copy)]
1668pub struct MannKendallResult {
1669    /// Mann-Kendall S statistic: Σ sign(xⱼ - xᵢ) for all i < j.
1670    pub s_statistic: i64,
1671    /// Variance of S (with tie correction).
1672    pub variance: f64,
1673    /// Z statistic (with continuity correction).
1674    pub z_statistic: f64,
1675    /// Two-tailed p-value.
1676    pub p_value: f64,
1677    /// Kendall's tau: S / [n(n-1)/2]. Range [-1, 1].
1678    pub kendall_tau: f64,
1679    /// Sen's slope estimator: median of (xⱼ - xᵢ)/(j - i) for all i < j.
1680    pub sen_slope: f64,
1681}
1682
1683/// Mann-Kendall non-parametric trend test with Sen's slope estimator.
1684///
1685/// Tests H₀: no monotonic trend vs H₁: monotonic trend exists.
1686/// Assumes serially independent observations (no autocorrelation).
1687///
1688/// # Algorithm
1689///
1690/// 1. S = Σᵢ<ⱼ sign(xⱼ - xᵢ)
1691/// 2. Var(S) = \[n(n-1)(2n+5) - Σ tₖ(tₖ-1)(2tₖ+5)\] / 18 (tie-corrected)
1692/// 3. Z = (S-1)/√Var(S) if S>0, 0 if S=0, (S+1)/√Var(S) if S<0
1693/// 4. Sen's slope = median of all pairwise slopes (xⱼ - xᵢ)/(j - i)
1694///
1695/// References:
1696/// - Mann (1945), "Nonparametric tests against trend"
1697/// - Kendall (1975), "Rank Correlation Methods"
1698/// - Sen (1968), "Estimates of the regression coefficient based on Kendall's tau"
1699///
1700/// # Complexity
1701///
1702/// O(n² log n) — pairwise comparisons O(n²) plus median finding O(n² log n²).
1703///
1704/// # Returns
1705///
1706/// `None` if fewer than 4 data points, non-finite values, or zero variance.
1707///
1708/// # Examples
1709///
1710/// ```
1711/// use u_analytics::testing::mann_kendall_test;
1712///
1713/// // Clear upward trend
1714/// let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
1715/// let r = mann_kendall_test(&data).unwrap();
1716/// assert!(r.p_value < 0.01);
1717/// assert!(r.kendall_tau > 0.8);
1718/// assert!(r.sen_slope > 0.0);
1719/// ```
1720pub fn mann_kendall_test(data: &[f64]) -> Option<MannKendallResult> {
1721    let n = data.len();
1722    if n < 4 || data.iter().any(|v| !v.is_finite()) {
1723        return None;
1724    }
1725
1726    // Step 1: Compute S statistic
1727    let mut s: i64 = 0;
1728    for i in 0..n - 1 {
1729        for j in (i + 1)..n {
1730            let diff = data[j] - data[i];
1731            if diff > 0.0 {
1732                s += 1;
1733            } else if diff < 0.0 {
1734                s -= 1;
1735            }
1736        }
1737    }
1738
1739    // Step 2: Compute tie groups
1740    let mut sorted: Vec<f64> = data.to_vec();
1741    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1742
1743    // Tied means equal, as in S above: a tolerance in the unit of the data
1744    // would call a whole series tied (or none of it) depending on whether it
1745    // is written in volts or picovolts.
1746    let mut tie_correction: f64 = 0.0;
1747    let mut current_count: usize = 1;
1748    for i in 1..sorted.len() {
1749        if sorted[i] == sorted[i - 1] {
1750            current_count += 1;
1751        } else {
1752            if current_count > 1 {
1753                let t = current_count as f64;
1754                tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1755            }
1756            current_count = 1;
1757        }
1758    }
1759    if current_count > 1 {
1760        let t = current_count as f64;
1761        tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1762    }
1763
1764    // Step 3: Variance with tie correction
1765    let nf = n as f64;
1766    let variance = (nf * (nf - 1.0) * (2.0 * nf + 5.0) - tie_correction) / 18.0;
1767
1768    if variance < 1e-300 {
1769        return None; // All values identical
1770    }
1771
1772    // Step 4: Z with continuity correction
1773    let sigma = variance.sqrt();
1774    let z_statistic = if s > 0 {
1775        (s as f64 - 1.0) / sigma
1776    } else if s < 0 {
1777        (s as f64 + 1.0) / sigma
1778    } else {
1779        0.0
1780    };
1781
1782    // Step 5: Two-tailed p-value
1783    let p_value = 2.0 * special::standard_normal_sf(z_statistic.abs());
1784    let p_value = p_value.clamp(0.0, 1.0);
1785
1786    // Step 6: Kendall's tau
1787    let kendall_tau = (2 * s) as f64 / (nf * (nf - 1.0));
1788
1789    // Step 7: Sen's slope = median of pairwise slopes
1790    let mut slopes = Vec::with_capacity(n * (n - 1) / 2);
1791    for i in 0..n - 1 {
1792        for j in (i + 1)..n {
1793            let dx = (j - i) as f64;
1794            slopes.push((data[j] - data[i]) / dx);
1795        }
1796    }
1797    slopes.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1798    let m = slopes.len();
1799    let sen_slope = if m % 2 == 0 {
1800        (slopes[m / 2 - 1] + slopes[m / 2]) / 2.0
1801    } else {
1802        slopes[m / 2]
1803    };
1804
1805    Some(MannKendallResult {
1806        s_statistic: s,
1807        variance,
1808        z_statistic,
1809        p_value,
1810        kendall_tau,
1811        sen_slope,
1812    })
1813}
1814
1815/// Natural log of n! using Stirling/ln_gamma for large values.
1816fn ln_factorial(n: u64) -> f64 {
1817    if n <= 1 {
1818        return 0.0;
1819    }
1820    // ln(n!) = ln_gamma(n+1)
1821    special::ln_gamma(n as f64 + 1.0)
1822}
1823
1824// ---------------------------------------------------------------------------
1825// KPSS stationarity test
1826// ---------------------------------------------------------------------------
1827
1828/// What the KPSS null hypothesis says the series is stationary around.
1829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1830pub enum KpssModel {
1831    /// A constant level (statsmodels `regression="c"`).
1832    Level,
1833    /// A linear trend (statsmodels `regression="ct"`).
1834    Trend,
1835}
1836
1837/// The truncation lag of the Newey-West long-run variance in [`kpss_test`].
1838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1839pub enum KpssLags {
1840    /// Hobijn, Franses & Ooms (1998): chosen from the residuals' own
1841    /// autocovariances (statsmodels' default, `nlags="auto"`).
1842    Hobijn,
1843    /// Schwert (1989): `⌈12·(n/100)^¼⌉` (statsmodels `nlags="legacy"`).
1844    Schwert,
1845    /// Exactly this many, below n.
1846    Fixed(usize),
1847}
1848
1849/// Where a [`KpssResult::p_value`] stands relative to the table it is read from.
1850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1851pub enum KpssPValue {
1852    /// Interpolated inside the table (between 0.01 and 0.10).
1853    Interpolated,
1854    /// The statistic is beyond the 1 % critical value: the p-value is at most 0.01.
1855    AtMost,
1856    /// The statistic is below the 10 % critical value: the p-value is at least 0.10.
1857    AtLeast,
1858}
1859
1860/// Result of the KPSS stationarity test.
1861#[derive(Debug, Clone)]
1862pub struct KpssResult {
1863    /// The KPSS statistic η / σ̂² (Kwiatkowski et al. 1992, eq. 11).
1864    pub statistic: f64,
1865    /// The truncation lag of the long-run variance.
1866    pub n_lags: usize,
1867    /// p-value, linearly interpolated in the table of critical values and
1868    /// clamped to [0.01, 0.10] — see [`KpssResult::p_value_bound`].
1869    pub p_value: f64,
1870    /// Whether `p_value` is interpolated or a bound of the table.
1871    pub p_value_bound: KpssPValue,
1872    /// Critical values at 10 %, 5 %, 2.5 % and 1 % (Kwiatkowski et al. 1992, Table 1).
1873    pub critical_values: [f64; 4],
1874    /// `statistic > critical_value` at each of those levels: stationarity is rejected.
1875    pub rejected: [bool; 4],
1876}
1877
1878/// Significance levels of [`KpssResult::critical_values`], in order.
1879pub const KPSS_LEVELS: [f64; 4] = [0.10, 0.05, 0.025, 0.01];
1880
1881/// The values [`kpss_test`] takes for a fixed truncation lag on `data`:
1882/// `0..=n − 1`.
1883///
1884/// # Errors
1885///
1886/// The series' own refusals, as [`kpss_test`] gives them: a non-finite value
1887/// or fewer than [`KPSS_MIN_OBSERVATIONS`] values.
1888pub fn kpss_lags_domain(data: &[f64]) -> Result<RangeInclusive<usize>, TimeSeriesError> {
1889    check_series(data, KPSS_MIN_OBSERVATIONS)?;
1890    Ok(0..=data.len() - 1)
1891}
1892
1893/// The fewest values [`kpss_test`] takes.
1894pub const KPSS_MIN_OBSERVATIONS: usize = 10;
1895
1896/// KPSS test of stationarity: H₀ the series is stationary around a level (or
1897/// a trend), H₁ it has a unit root.
1898///
1899/// The complement of [`adf_test`], whose null is the unit root: a series ADF
1900/// does not reject and KPSS does is non-stationary; differencing it once and
1901/// testing again is how the number of differences is chosen (Hyndman &
1902/// Khandakar 2008, `ndiffs`).
1903///
1904/// # Algorithm
1905///
1906/// Residuals eₜ of the series on a constant (or constant + trend);
1907/// η = Σ Sₜ² / n² with Sₜ the partial sums of eₜ; σ̂² the Newey-West long-run
1908/// variance with Bartlett weights 1 − i/(l + 1); statistic η / σ̂². The p-value
1909/// is interpolated in the table of critical values.
1910///
1911/// # Errors
1912///
1913/// [`TimeSeriesError`]: a non-finite value, fewer than 10 values, a fixed
1914/// lag outside `[0, n − 1]` (`lags`), or no variation about the level or
1915/// trend (σ̂² not positive against the series' size).
1916///
1917/// # References
1918///
1919/// - Kwiatkowski, Phillips, Schmidt & Shin (1992). "Testing the null
1920///   hypothesis of stationarity against the alternative of a unit root."
1921///   *Journal of Econometrics*, 54, 159–178.
1922/// - Hobijn, Franses & Ooms (1998). "Generalisations of the KPSS-test for
1923///   stationarity." Econometric Institute Report 9802/A.
1924/// - Newey & West (1994). "Automatic lag selection in covariance matrix
1925///   estimation." *Review of Economic Studies*, 61(4), 631–653.
1926///
1927/// # Examples
1928///
1929/// ```
1930/// use u_analytics::testing::{kpss_test, KpssLags, KpssModel};
1931///
1932/// // A walk that drifts: stationarity around a level is rejected at 5 %.
1933/// let mut walk = vec![0.0];
1934/// for i in 1..80 {
1935///     walk.push(walk[i - 1] + 1.0 + 0.1 * (((i * 7919) % 13) as f64 - 6.0));
1936/// }
1937/// let r = kpss_test(&walk, KpssModel::Level, KpssLags::Hobijn).unwrap();
1938/// assert!(r.rejected[1]);
1939/// ```
1940pub fn kpss_test(
1941    data: &[f64],
1942    model: KpssModel,
1943    lags: KpssLags,
1944) -> Result<KpssResult, TimeSeriesError> {
1945    let domain = kpss_lags_domain(data)?;
1946    let n = data.len();
1947    if let KpssLags::Fixed(l) = lags {
1948        check_option("lags", l, &domain)?;
1949    }
1950    let nf = n as f64;
1951
1952    let residuals: Vec<f64> = match model {
1953        KpssModel::Level => {
1954            let mean = data.iter().sum::<f64>() / nf;
1955            data.iter().map(|x| x - mean).collect()
1956        }
1957        KpssModel::Trend => {
1958            // OLS on 1 and t = 1..n.
1959            let t_mean = (nf + 1.0) / 2.0;
1960            let y_mean = data.iter().sum::<f64>() / nf;
1961            let (mut sty, mut stt) = (0.0, 0.0);
1962            for (i, y) in data.iter().enumerate() {
1963                let dt = (i + 1) as f64 - t_mean;
1964                sty += dt * (y - y_mean);
1965                stt += dt * dt;
1966            }
1967            let slope = sty / stt;
1968            data.iter()
1969                .enumerate()
1970                .map(|(i, y)| y - y_mean - slope * ((i + 1) as f64 - t_mean))
1971                .collect()
1972        }
1973    };
1974
1975    // No variation beyond the rounding of the fit, against the series' size.
1976    let sum_sq: f64 = residuals.iter().map(|e| e * e).sum();
1977    let size: f64 = data.iter().map(|x| x * x).sum();
1978    if sum_sq <= 1e-20 * size {
1979        return Err(TimeSeriesError::NoVariation);
1980    }
1981
1982    let autocovariance = |i: usize| -> f64 {
1983        residuals[i..]
1984            .iter()
1985            .zip(&residuals[..n - i])
1986            .map(|(a, b)| a * b)
1987            .sum()
1988    };
1989
1990    let n_lags = match lags {
1991        KpssLags::Fixed(l) => l,
1992        KpssLags::Schwert => ((12.0 * (nf / 100.0).powf(0.25)).ceil() as usize).min(n - 1),
1993        KpssLags::Hobijn => {
1994            // Hobijn et al. (1998), as statsmodels' `_kpss_autolag`.
1995            let cov_lags = nf.powf(2.0 / 9.0) as usize;
1996            let mut s0 = sum_sq / nf;
1997            let mut s1 = 0.0;
1998            for i in 1..=cov_lags.min(n - 1) {
1999                let g = autocovariance(i) / (nf / 2.0);
2000                s0 += g;
2001                s1 += i as f64 * g;
2002            }
2003            let s_hat = s1 / s0;
2004            let gamma = 1.1447 * (s_hat * s_hat).powf(1.0 / 3.0);
2005            ((gamma * nf.powf(1.0 / 3.0)) as usize).min(n - 1)
2006        }
2007    };
2008
2009    let mut long_run = sum_sq;
2010    for i in 1..=n_lags {
2011        long_run += 2.0 * autocovariance(i) * (1.0 - i as f64 / (n_lags as f64 + 1.0));
2012    }
2013    let long_run = long_run / nf;
2014    if long_run <= 0.0 {
2015        return Err(TimeSeriesError::NoVariation);
2016    }
2017
2018    let mut partial = 0.0;
2019    let eta = residuals
2020        .iter()
2021        .map(|e| {
2022            partial += e;
2023            partial * partial
2024        })
2025        .sum::<f64>()
2026        / (nf * nf);
2027    let statistic = eta / long_run;
2028
2029    let critical_values = match model {
2030        KpssModel::Level => [0.347, 0.463, 0.574, 0.739],
2031        KpssModel::Trend => [0.119, 0.146, 0.176, 0.216],
2032    };
2033    let (p_value, p_value_bound) = if statistic <= critical_values[0] {
2034        (KPSS_LEVELS[0], KpssPValue::AtLeast)
2035    } else if statistic >= critical_values[3] {
2036        (KPSS_LEVELS[3], KpssPValue::AtMost)
2037    } else {
2038        let k = critical_values
2039            .windows(2)
2040            .position(|w| statistic <= w[1])
2041            .expect("the statistic lies inside the table");
2042        let (c0, c1) = (critical_values[k], critical_values[k + 1]);
2043        let (p0, p1) = (KPSS_LEVELS[k], KPSS_LEVELS[k + 1]);
2044        (
2045            p0 + (statistic - c0) / (c1 - c0) * (p1 - p0),
2046            KpssPValue::Interpolated,
2047        )
2048    };
2049    let rejected = critical_values.map(|c| statistic > c);
2050
2051    Ok(KpssResult {
2052        statistic,
2053        n_lags,
2054        p_value,
2055        p_value_bound,
2056        critical_values,
2057        rejected,
2058    })
2059}
2060
2061/// KPSS against statsmodels 0.15.0 `kpss(x, regression, nlags)` on a
2062/// stationary AR(1) and a random walk (seeded, rounded to 3 places).
2063#[cfg(test)]
2064mod kpss_reference {
2065    use super::*;
2066
2067    const AR: [f64; 60] = [
2068        0.0, 0.299, -0.154, -0.953, -0.836, -1.326, -0.471, 1.152, -0.031, -0.633, 0.237, 0.452,
2069        0.286, -0.816, -0.355, 0.553, -1.123, -0.907, -2.264, -2.196, -2.72, -1.323, -1.796,
2070        -0.447, -0.022, -0.196, -2.595, -1.577, -0.68, -0.159, -1.594, -1.115, -1.425, -1.379,
2071        0.509, -0.604, -0.275, 0.774, -0.274, -0.222, 0.021, 0.073, -1.196, -0.402, 1.198, -1.068,
2072        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,
2073        0.02,
2074    ];
2075    const RW: [f64; 60] = [
2076        0.001, 0.3, 0.026, -0.865, -1.32, -2.312, -2.252, -0.912, -1.404, -2.024, -1.534, -1.177,
2077        -1.072, -2.002, -2.031, -1.336, -2.68, -3.138, -5.039, -6.329, -8.171, -8.406, -9.673,
2078        -9.402, -9.245, -9.432, -11.949, -12.488, -12.537, -12.424, -13.954, -14.432, -15.411,
2079        -16.22, -15.159, -15.967, -16.0, -15.116, -15.7, -15.812, -15.702, -15.638, -16.863,
2080        -16.787, -15.428, -16.975, -16.116, -15.997, -16.638, -14.638, -13.876, -15.075, -15.0,
2081        -14.423, -14.612, -13.929, -13.996, -13.329, -11.89, -12.566,
2082    ];
2083
2084    #[test]
2085    fn statistic_lag_and_p_value_reproduce_statsmodels() {
2086        use KpssLags::{Fixed, Hobijn, Schwert};
2087        use KpssModel::{Level, Trend};
2088        type Case = (&'static [f64; 60], KpssModel, KpssLags, f64, f64, usize);
2089        let cases: [Case; 12] = [
2090            (
2091                &AR,
2092                Level,
2093                Hobijn,
2094                0.5229270606681895,
2095                0.036502914263921285,
2096                3,
2097            ),
2098            (&AR, Level, Schwert, 0.288001639308506, 0.1, 11),
2099            (
2100                &AR,
2101                Level,
2102                Fixed(4),
2103                0.46189194657609306,
2104                0.05047760923444266,
2105                4,
2106            ),
2107            (&AR, Trend, Hobijn, 0.2274846895383343, 0.01, 3),
2108            (
2109                &AR,
2110                Trend,
2111                Schwert,
2112                0.14193842821368732,
2113                0.05752142923391235,
2114                11,
2115            ),
2116            (
2117                &AR,
2118                Trend,
2119                Fixed(4),
2120                0.20574827757371436,
2121                0.013844395909857114,
2122                4,
2123            ),
2124            (&RW, Level, Hobijn, 0.9286533322383663, 0.01, 5),
2125            (
2126                &RW,
2127                Level,
2128                Schwert,
2129                0.5112288964656923,
2130                0.0391376359311504,
2131                11,
2132            ),
2133            (&RW, Level, Fixed(4), 1.09862199299003, 0.01, 4),
2134            (&RW, Trend, Hobijn, 0.26866997231516837, 0.01, 4),
2135            (
2136                &RW,
2137                Trend,
2138                Schwert,
2139                0.14468829763941088,
2140                0.05242907844553539,
2141                11,
2142            ),
2143            (&RW, Trend, Fixed(4), 0.26866997231516837, 0.01, 4),
2144        ];
2145        for (data, model, lags, statistic, p, n_lags) in cases {
2146            let r = kpss_test(data, model, lags).unwrap();
2147            let what = format!("{model:?} {lags:?}");
2148            assert_eq!(r.n_lags, n_lags, "{what}");
2149            assert!(
2150                (r.statistic - statistic).abs() < 1e-10 * statistic,
2151                "{what}: {} vs {statistic}",
2152                r.statistic
2153            );
2154            assert!(
2155                (r.p_value - p).abs() < 1e-12,
2156                "{what}: p {} vs {p}",
2157                r.p_value
2158            );
2159        }
2160    }
2161
2162    #[test]
2163    fn bounds_rejections_and_refusals() {
2164        let r = kpss_test(&RW, KpssModel::Level, KpssLags::Fixed(4)).unwrap();
2165        assert_eq!(r.p_value_bound, KpssPValue::AtMost);
2166        assert_eq!(r.rejected, [true; 4]);
2167        let r = kpss_test(&AR, KpssModel::Level, KpssLags::Schwert).unwrap();
2168        assert_eq!(r.p_value_bound, KpssPValue::AtLeast);
2169        assert_eq!(r.rejected, [false; 4]);
2170        let r = kpss_test(&AR, KpssModel::Level, KpssLags::Hobijn).unwrap();
2171        assert_eq!(r.p_value_bound, KpssPValue::Interpolated);
2172        assert_eq!(r.rejected, [true, true, false, false]);
2173
2174        assert_eq!(
2175            kpss_test(&AR[..9], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2176            TimeSeriesError::TooFewObservations { needed: 10, got: 9 }
2177        );
2178        assert_eq!(
2179            kpss_test(&AR, KpssModel::Level, KpssLags::Fixed(60)).unwrap_err(),
2180            TimeSeriesError::OptionOutOfRange {
2181                option: "lags",
2182                got: 60.0,
2183                min: 0.0,
2184                max: 59.0
2185            }
2186        );
2187        assert_eq!(
2188            kpss_test(&[4.0; 20], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2189            TimeSeriesError::NoVariation
2190        );
2191        let line: Vec<f64> = (0..20).map(|i| 2.0 + 0.5 * i as f64).collect();
2192        assert_eq!(
2193            kpss_test(&line, KpssModel::Trend, KpssLags::Hobijn).unwrap_err(),
2194            TimeSeriesError::NoVariation
2195        );
2196        let mut bad = AR;
2197        bad[3] = f64::INFINITY;
2198        assert_eq!(
2199            kpss_test(&bad[..5], KpssModel::Level, KpssLags::Hobijn).unwrap_err(),
2200            TimeSeriesError::ValueNotFinite { index: 3 }
2201        );
2202    }
2203
2204    /// The statistic is a ratio of the residuals' own scale: the same in any unit.
2205    #[test]
2206    fn unit_invariant() {
2207        let base = kpss_test(&AR, KpssModel::Trend, KpssLags::Hobijn).unwrap();
2208        for f in [1e-9, 1e9] {
2209            let x: Vec<f64> = AR.iter().map(|v| v * f).collect();
2210            let r = kpss_test(&x, KpssModel::Trend, KpssLags::Hobijn).unwrap();
2211            assert_eq!(r.n_lags, base.n_lags);
2212            assert!((r.statistic - base.statistic).abs() < 1e-9 * base.statistic);
2213        }
2214    }
2215}
2216
2217// ---------------------------------------------------------------------------
2218// Augmented Dickey-Fuller test
2219// ---------------------------------------------------------------------------
2220
2221/// Result of the Augmented Dickey-Fuller (ADF) unit root test.
2222#[derive(Debug, Clone)]
2223pub struct AdfResult {
2224    /// ADF test statistic (t-ratio for γ̂).
2225    pub statistic: f64,
2226    /// Number of lags used.
2227    pub n_lags: usize,
2228    /// Number of observations used in the regression.
2229    pub n_obs: usize,
2230    /// Critical values at 1%, 5%, 10% significance levels.
2231    pub critical_values: [f64; 3],
2232    /// Whether the null hypothesis (unit root) is rejected at each level.
2233    pub rejected: [bool; 3],
2234}
2235
2236/// How [`adf_test`] chooses the number of lagged differences.
2237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2238pub enum AdfLags {
2239    /// The count from 0 to `max` with the smallest AIC, every candidate fitted
2240    /// on the same observations (those the largest count leaves), then the
2241    /// chosen count refitted on all the observations it leaves. `max` `None`
2242    /// is Schwert's rule, `⌊12·(n/100)^¼⌋`, capped at `n / 3`.
2243    Aic {
2244        /// The largest count considered.
2245        max: Option<usize>,
2246    },
2247    /// Exactly this many.
2248    Fixed(usize),
2249}
2250
2251impl Default for AdfLags {
2252    fn default() -> Self {
2253        AdfLags::Aic { max: None }
2254    }
2255}
2256
2257/// Model specification for the ADF test.
2258#[derive(Debug, Clone, Copy)]
2259pub enum AdfModel {
2260    /// No constant, no trend: Δyₜ = γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2261    None,
2262    /// Constant only (default): Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2263    Constant,
2264    /// Constant + linear trend: Δyₜ = α + βt + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2265    ConstantTrend,
2266}
2267
2268/// Augmented Dickey-Fuller (ADF) unit root test for stationarity.
2269///
2270/// Tests H₀: unit root (non-stationary) vs H₁: stationary.
2271///
2272/// # Algorithm
2273///
2274/// 1. Constructs Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
2275/// 2. Estimates via OLS
2276/// 3. Tests t-ratio for γ against Dickey-Fuller critical values
2277///
2278/// `lags` chooses the number of lagged differences ([`AdfLags`]): by AIC up
2279/// to a maximum (the default, as `statsmodels.adfuller(autolag="AIC")`), or a
2280/// fixed count.
2281///
2282/// Reference: Dickey & Fuller (1979), "Distribution of the Estimators for
2283/// Autoregressive Time Series with a Unit Root"
2284///
2285/// # Errors
2286///
2287/// [`TimeSeriesError`]: a non-finite value, fewer than 10 values, a lag
2288/// count (`lags`, or the AIC search's `max_lags`) outside `[0, max]` where
2289/// `max` leaves the regression at least 5 rows and a residual degree of
2290/// freedom ([`adf_lags_domain`]), or a degenerate regression.
2291///
2292/// # Examples
2293///
2294/// ```
2295/// use u_analytics::testing::{adf_test, AdfLags, AdfModel};
2296///
2297/// // Stationary series: strong mean-reversion
2298/// let mut data = vec![0.0_f64; 40];
2299/// for i in 1..40 {
2300///     data[i] = 0.3 * data[i - 1] + [0.5, -0.8, 0.3, -0.6, 0.9,
2301///         -0.4, 0.7, -0.2, 0.1, -0.5][i % 10];
2302/// }
2303/// let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).unwrap();
2304/// assert!(r.statistic.is_finite());
2305/// assert_eq!(r.critical_values.len(), 3);
2306/// ```
2307pub fn adf_test(
2308    data: &[f64],
2309    model: AdfModel,
2310    lags: AdfLags,
2311) -> Result<AdfResult, TimeSeriesError> {
2312    let domain = adf_lags_domain(data, model)?;
2313    let n = data.len();
2314    match lags {
2315        AdfLags::Fixed(p) => {
2316            check_option("lags", p, &domain)?;
2317        }
2318        AdfLags::Aic { max: Some(m) } => {
2319            check_option("max_lags", m, &domain)?;
2320        }
2321        AdfLags::Aic { max: None } => {}
2322    }
2323
2324    // Compute differences
2325    let dy: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2326
2327    let best_lag = match lags {
2328        AdfLags::Fixed(p) => p,
2329        AdfLags::Aic { max } => {
2330            // Schwert (1989) rule for the largest count considered.
2331            let schwert = (12.0 * (n as f64 / 100.0).powf(0.25)).floor() as usize;
2332            let p_max = max.unwrap_or_else(|| schwert.min(n / 3));
2333            select_adf_lag(data, &dy, model, p_max).ok_or(TimeSeriesError::DegenerateRegression)?
2334        }
2335    };
2336
2337    // The chosen count, on every observation it leaves.
2338    adf_ols(data, &dy, model, best_lag).ok_or(TimeSeriesError::DegenerateRegression)
2339}
2340
2341/// The fewest values [`adf_test`] takes.
2342pub const ADF_MIN_OBSERVATIONS: usize = 10;
2343
2344/// The fewest rows the ADF test regression is fitted on.
2345const ADF_MIN_REGRESSION_ROWS: usize = 5;
2346
2347/// The numbers of lagged differences [`adf_test`] takes on `data` under
2348/// `model` -- for a fixed count (`lags`) and for the bound of the AIC search
2349/// (`max_lags`) alike: `0..=p`, where with p lags the regression keeps
2350/// n − 1 − p rows (one value is lost to differencing, p to the lags) and has
2351/// the lagged level, the model's deterministic terms and p lag columns; it
2352/// needs at least 5 rows and a residual degree of freedom.
2353///
2354/// # Errors
2355///
2356/// The series' own refusals, as [`adf_test`] gives them: a non-finite value
2357/// or fewer than [`ADF_MIN_OBSERVATIONS`] values.
2358///
2359/// # Examples
2360///
2361/// ```
2362/// use u_analytics::testing::{adf_lags_domain, AdfModel};
2363///
2364/// let data: Vec<f64> = (0..20).map(|i| (i as f64).sin()).collect();
2365/// assert_eq!(adf_lags_domain(&data, AdfModel::Constant).unwrap(), 0..=8);
2366/// ```
2367pub fn adf_lags_domain(
2368    data: &[f64],
2369    model: AdfModel,
2370) -> Result<RangeInclusive<usize>, TimeSeriesError> {
2371    check_series(data, ADF_MIN_OBSERVATIONS)?;
2372    let n = data.len();
2373    let fixed_columns = match model {
2374        AdfModel::None => 1,
2375        AdfModel::Constant => 2,
2376        AdfModel::ConstantTrend => 3,
2377    };
2378    Ok(0..=(n - 1 - ADF_MIN_REGRESSION_ROWS).min((n - 2 - fixed_columns) / 2))
2379}
2380
2381/// The lag count in `0..=p_max` with the smallest AIC. Every candidate is
2382/// fitted on the observations `p_max` lags leave, so the AICs compare the same
2383/// data — fitted on their own (longer) samples they would not be comparable.
2384/// `None` when no candidate can be fitted.
2385fn select_adf_lag(data: &[f64], dy: &[f64], model: AdfModel, p_max: usize) -> Option<usize> {
2386    let start = p_max;
2387    let mut best: Option<(f64, usize)> = None;
2388    for p in 0..=p_max {
2389        if let Some(aic) = adf_ols_aic(data, dy, model, p, start) {
2390            if best.is_none_or(|(b, _)| aic < b) {
2391                best = Some((aic, p));
2392            }
2393        }
2394    }
2395    best.map(|(_, p)| p)
2396}
2397
2398/// Builds the ADF design matrix and dependent variable.
2399///
2400/// Returns (design_matrix_row_major, y_dep, n_rows, n_cols, gamma_col_index).
2401#[allow(clippy::type_complexity)]
2402fn adf_build_matrix(
2403    data: &[f64],
2404    dy: &[f64],
2405    model: AdfModel,
2406    p: usize,
2407    start: usize,
2408) -> Option<(Vec<f64>, Vec<f64>, usize, usize, usize)> {
2409    // Row t regresses dy[t] on the level data[t] and dy[t-1] ... dy[t-p].
2410    debug_assert!(start >= p, "the first row needs its p lagged differences");
2411    if start >= dy.len() || dy.len() - start < 5 {
2412        return None;
2413    }
2414    let m = dy.len() - start;
2415
2416    let y_dep: Vec<f64> = dy[start..].to_vec();
2417
2418    // Count columns: intercept + y_{t-1} + [trend] + p lags
2419    let has_intercept = !matches!(model, AdfModel::None);
2420    let has_trend = matches!(model, AdfModel::ConstantTrend);
2421    let ncols = has_intercept as usize + 1 + has_trend as usize + p;
2422
2423    // Build row-major design matrix
2424    let mut x_data = Vec::with_capacity(m * ncols);
2425    let mut gamma_col = 0;
2426
2427    for i in 0..m {
2428        let t = start + i;
2429        if has_intercept {
2430            x_data.push(1.0); // intercept
2431            gamma_col = 1;
2432        }
2433        x_data.push(data[t]); // y_{t-1}
2434        if has_trend {
2435            x_data.push((t + 1) as f64); // trend
2436        }
2437        for lag in 1..=p {
2438            x_data.push(dy[t - lag]);
2439        }
2440    }
2441
2442    Some((x_data, y_dep, m, ncols, gamma_col))
2443}
2444
2445/// Lightweight OLS for ADF: returns (gamma_t_stat, rss, k).
2446///
2447/// Solves X'Xβ = X'y using Cholesky-like decomposition (Gaussian elimination).
2448fn adf_ols_core(
2449    x_data: &[f64],
2450    y: &[f64],
2451    m: usize,
2452    ncols: usize,
2453    gamma_col: usize,
2454) -> Option<(f64, f64, usize)> {
2455    // Compute X'X (ncols × ncols, symmetric)
2456    let mut xtx = vec![0.0_f64; ncols * ncols];
2457    for i in 0..m {
2458        let row = &x_data[i * ncols..(i + 1) * ncols];
2459        for j in 0..ncols {
2460            for k in j..ncols {
2461                xtx[j * ncols + k] += row[j] * row[k];
2462            }
2463        }
2464    }
2465    // Mirror upper to lower
2466    for j in 0..ncols {
2467        for k in (j + 1)..ncols {
2468            xtx[k * ncols + j] = xtx[j * ncols + k];
2469        }
2470    }
2471
2472    // Compute X'y (ncols × 1)
2473    let mut xty = vec![0.0_f64; ncols];
2474    for i in 0..m {
2475        let row = &x_data[i * ncols..(i + 1) * ncols];
2476        for j in 0..ncols {
2477            xty[j] += row[j] * y[i];
2478        }
2479    }
2480
2481    // A pivot is nothing when it is rounding against the sizes of the two
2482    // regressors it couples: X'X is in the squares of the data's units, so a
2483    // fixed floor would call a series in small units singular.
2484    let own_size: Vec<f64> = (0..ncols).map(|i| xtx[i * ncols + i]).collect();
2485    let negligible = |pivot: f64, col: usize, row: usize| {
2486        pivot <= 1e-15 * (own_size[col] * own_size[row]).sqrt()
2487    };
2488
2489    // Solve via Gaussian elimination with partial pivoting
2490    let mut augmented = vec![0.0_f64; ncols * (ncols + 1)];
2491    for i in 0..ncols {
2492        for j in 0..ncols {
2493            augmented[i * (ncols + 1) + j] = xtx[i * ncols + j];
2494        }
2495        augmented[i * (ncols + 1) + ncols] = xty[i];
2496    }
2497
2498    for col in 0..ncols {
2499        // Partial pivoting
2500        let mut max_row = col;
2501        let mut max_val = augmented[col * (ncols + 1) + col].abs();
2502        for row in (col + 1)..ncols {
2503            let val = augmented[row * (ncols + 1) + col].abs();
2504            if val > max_val {
2505                max_val = val;
2506                max_row = row;
2507            }
2508        }
2509        if negligible(max_val, col, max_row) {
2510            return None; // Singular
2511        }
2512        if max_row != col {
2513            for j in 0..=ncols {
2514                let a = col * (ncols + 1) + j;
2515                let b = max_row * (ncols + 1) + j;
2516                augmented.swap(a, b);
2517            }
2518        }
2519
2520        let pivot = augmented[col * (ncols + 1) + col];
2521        for row in (col + 1)..ncols {
2522            let factor = augmented[row * (ncols + 1) + col] / pivot;
2523            for j in col..=ncols {
2524                let above = augmented[col * (ncols + 1) + j];
2525                augmented[row * (ncols + 1) + j] -= factor * above;
2526            }
2527        }
2528    }
2529
2530    // Back-substitution
2531    let mut beta = vec![0.0_f64; ncols];
2532    for i in (0..ncols).rev() {
2533        let mut sum = augmented[i * (ncols + 1) + ncols];
2534        for j in (i + 1)..ncols {
2535            sum -= augmented[i * (ncols + 1) + j] * beta[j];
2536        }
2537        beta[i] = sum / augmented[i * (ncols + 1) + i];
2538    }
2539
2540    // Compute residuals and RSS
2541    let mut rss = 0.0;
2542    for i in 0..m {
2543        let row = &x_data[i * ncols..(i + 1) * ncols];
2544        let y_hat: f64 = row.iter().zip(beta.iter()).map(|(&x, &b)| x * b).sum();
2545        let resid = y[i] - y_hat;
2546        rss += resid * resid;
2547    }
2548
2549    // An exact fit leaves no residual variance, so the t-ratio divides by
2550    // rounding noise: a series whose differences repeat with a period the lags
2551    // span came out as t = -1.9e8, a "rejection" at every level. Treat a fit
2552    // that explains the differences to eight digits as undefined, which also
2553    // keeps it out of the AIC lag search (where ln(rss) -> -inf would win).
2554    let yy: f64 = y.iter().map(|v| v * v).sum();
2555    if yy == 0.0 || rss <= yy * 1e-16 {
2556        return None;
2557    }
2558
2559    // Standard error of coefficients
2560    let df = m - ncols;
2561    if df == 0 {
2562        return None;
2563    }
2564    let mse = rss / df as f64;
2565
2566    // Compute (X'X)^{-1} via Gauss-Jordan elimination to get variance of γ̂
2567    let mut xtx_aug = vec![0.0_f64; ncols * ncols * 2]; // xtx | I
2568    for i in 0..ncols {
2569        for j in 0..ncols {
2570            xtx_aug[i * 2 * ncols + j] = xtx[i * ncols + j];
2571        }
2572        xtx_aug[i * 2 * ncols + ncols + i] = 1.0;
2573    }
2574
2575    // Gauss-Jordan elimination
2576    for col in 0..ncols {
2577        let mut max_row = col;
2578        let mut max_val = xtx_aug[col * 2 * ncols + col].abs();
2579        for row in (col + 1)..ncols {
2580            let val = xtx_aug[row * 2 * ncols + col].abs();
2581            if val > max_val {
2582                max_val = val;
2583                max_row = row;
2584            }
2585        }
2586        if negligible(max_val, col, max_row) {
2587            return None;
2588        }
2589        if max_row != col {
2590            for j in 0..(2 * ncols) {
2591                let a = col * 2 * ncols + j;
2592                let b = max_row * 2 * ncols + j;
2593                xtx_aug.swap(a, b);
2594            }
2595        }
2596
2597        let pivot = xtx_aug[col * 2 * ncols + col];
2598        for j in 0..(2 * ncols) {
2599            xtx_aug[col * 2 * ncols + j] /= pivot;
2600        }
2601        for row in 0..ncols {
2602            if row == col {
2603                continue;
2604            }
2605            let factor = xtx_aug[row * 2 * ncols + col];
2606            for j in 0..(2 * ncols) {
2607                let above = xtx_aug[col * 2 * ncols + j];
2608                xtx_aug[row * 2 * ncols + j] -= factor * above;
2609            }
2610        }
2611    }
2612
2613    // Extract diagonal element for gamma column
2614    let var_gamma = mse * xtx_aug[gamma_col * 2 * ncols + ncols + gamma_col];
2615    if var_gamma <= 0.0 {
2616        return None;
2617    }
2618    let se_gamma = var_gamma.sqrt();
2619    let t_gamma = beta[gamma_col] / se_gamma;
2620
2621    Some((t_gamma, rss, ncols))
2622}
2623
2624/// Runs ADF OLS and returns AIC + number of observations.
2625/// AIC of the regression with `p` lags on the rows from `start` on.
2626fn adf_ols_aic(data: &[f64], dy: &[f64], model: AdfModel, p: usize, start: usize) -> Option<f64> {
2627    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, start)?;
2628    let (_t_stat, rss, k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2629    Some(2.0 * k as f64 + m as f64 * (rss / m as f64).ln())
2630}
2631
2632/// Runs the actual ADF OLS and returns the test result.
2633fn adf_ols(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<AdfResult> {
2634    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, p)?;
2635    let (gamma_t, _rss, _k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2636
2637    let critical_values = adf_critical_values(model, m);
2638
2639    let rejected = [
2640        gamma_t <= critical_values[0],
2641        gamma_t <= critical_values[1],
2642        gamma_t <= critical_values[2],
2643    ];
2644
2645    Some(AdfResult {
2646        statistic: gamma_t,
2647        n_lags: p,
2648        n_obs: m,
2649        critical_values,
2650        rejected,
2651    })
2652}
2653
2654/// MacKinnon (1994) critical values for ADF test.
2655///
2656/// Returns [1%, 5%, 10%] critical values based on sample size.
2657fn adf_critical_values(model: AdfModel, n: usize) -> [f64; 3] {
2658    // MacKinnon (1994) regression-based approximation:
2659    // cv(n) = τ_∞ + τ₁/n + τ₂/n²
2660    //
2661    // Coefficients from MacKinnon (2010), Table 1.
2662    let (tau_inf, tau1, tau2): ([f64; 3], [f64; 3], [f64; 3]) = match model {
2663        AdfModel::None => (
2664            [-2.5658, -1.9393, -1.6156],
2665            [-1.960, -0.398, -0.181],
2666            [-10.04, 0.0, 0.0],
2667        ),
2668        AdfModel::Constant => (
2669            [-3.4336, -2.8621, -2.5671],
2670            [-5.999, -2.738, -1.438],
2671            [-29.25, -8.36, -4.48],
2672        ),
2673        AdfModel::ConstantTrend => (
2674            [-3.9638, -3.4126, -3.1279],
2675            [-8.353, -4.039, -2.418],
2676            [-47.44, -17.83, -7.58],
2677        ),
2678    };
2679
2680    let nf = n as f64;
2681    let inv_n = 1.0 / nf;
2682    let inv_n2 = inv_n * inv_n;
2683
2684    [
2685        tau_inf[0] + tau1[0] * inv_n + tau2[0] * inv_n2,
2686        tau_inf[1] + tau1[1] * inv_n + tau2[1] * inv_n2,
2687        tau_inf[2] + tau1[2] * inv_n + tau2[2] * inv_n2,
2688    ]
2689}
2690
2691#[cfg(test)]
2692mod tests {
2693    use super::*;
2694
2695    #[test]
2696    fn corrections_refuse_what_is_not_a_probability() {
2697        assert!(bonferroni_correction(&[0.01, 1.5]).is_none());
2698        assert!(benjamini_hochberg(&[-0.1, 0.2]).is_none());
2699        assert_eq!(bonferroni_correction(&[0.01, 0.2]), Some(vec![0.02, 0.4]));
2700    }
2701
2702    // -----------------------------------------------------------------------
2703    // Anderson-Darling large-n p-value overflow (upstream-014)
2704    // -----------------------------------------------------------------------
2705
2706    /// Public-domain Mulberry32 PRNG (matches the upstream-014 reproduction).
2707    fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
2708        let mut a = seed;
2709        move || {
2710            a = a.wrapping_add(0x6d2b_79f5);
2711            let mut t = a;
2712            t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2713            t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2714            ((t ^ (t >> 14)) as f64) / 4_294_967_296.0
2715        }
2716    }
2717
2718    /// Regression (upstream-014): for a fixed, clearly non-normal distribution
2719    /// shape (exponential), as n grows the A*² statistic grows monotonically and
2720    /// the p-value must keep tracking toward 0 — it must NEVER jump to exactly 1.0
2721    /// ("perfectly normal") while A*² is simultaneously large and growing. Before
2722    /// the fix, the p-value overflowed to exactly 1 past n≈7000.
2723    #[test]
2724    fn ad_pvalue_no_overflow_large_nonnormal_n() {
2725        let mut rng = mulberry32(0x5350_4331);
2726        let full: Vec<f64> = (0..20_000)
2727            .map(|_| {
2728                let u = rng();
2729                -(1.0 - u).ln() // inverse-CDF exponential, rate=1
2730            })
2731            .collect();
2732
2733        let mut prev_a2 = 0.0_f64;
2734        let mut prev_p = f64::INFINITY;
2735        for &n in &[3000usize, 5000, 6000, 7000, 8000, 10000, 16000, 20000] {
2736            let r = anderson_darling_test(&full[..n]).expect("computes");
2737            // A*² must keep growing for an increasingly non-normal large sample.
2738            assert!(
2739                r.statistic_star > prev_a2,
2740                "A*² must grow with n; n={n} A*²={} prev={prev_a2}",
2741                r.statistic_star
2742            );
2743            // The core defect: p must never flip to exactly 1.0 while A*² is huge.
2744            assert!(
2745                r.p_value < 0.5,
2746                "clearly non-normal data (n={n}, A*²={}) must not report p={} ≈ normal",
2747                r.statistic_star,
2748                r.p_value
2749            );
2750            // Monotone non-increasing: p tracks the growing statistic downward.
2751            assert!(
2752                r.p_value <= prev_p + 1e-12,
2753                "p must be non-increasing as A*² grows; n={n} p={} prev={prev_p}",
2754                r.p_value
2755            );
2756            prev_a2 = r.statistic_star;
2757            prev_p = r.p_value;
2758        }
2759    }
2760
2761    // -----------------------------------------------------------------------
2762    // One-sample t-test
2763    // -----------------------------------------------------------------------
2764
2765    #[test]
2766    fn one_sample_null_true() {
2767        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2768        let r = one_sample_t_test(&data, 5.0).expect("should compute");
2769        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2770    }
2771
2772    #[test]
2773    fn one_sample_null_false() {
2774        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2775        let r = one_sample_t_test(&data, 10.0).expect("should compute");
2776        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2777    }
2778
2779    #[test]
2780    fn one_sample_edge_cases() {
2781        assert!(one_sample_t_test(&[1.0], 0.0).is_none()); // n < 2
2782        assert!(one_sample_t_test(&[5.0, 5.0, 5.0], 5.0).is_none()); // zero var
2783        assert!(one_sample_t_test(&[1.0, f64::NAN, 3.0], 2.0).is_none());
2784    }
2785
2786    // -----------------------------------------------------------------------
2787    // Two-sample t-test
2788    // -----------------------------------------------------------------------
2789
2790    #[test]
2791    fn two_sample_same_mean() {
2792        let a = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2793        let b = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
2794        let r = two_sample_t_test(&a, &b).expect("should compute");
2795        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2796    }
2797
2798    #[test]
2799    fn two_sample_different_means() {
2800        let a = [1.0, 2.0, 3.0, 2.0, 1.5, 2.5];
2801        let b = [10.0, 11.0, 12.0, 10.5, 11.5, 10.5];
2802        let r = two_sample_t_test(&a, &b).expect("should compute");
2803        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2804    }
2805
2806    #[test]
2807    fn two_sample_different_sizes() {
2808        let a = [1.0, 2.0, 3.0];
2809        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2810        let r = two_sample_t_test(&a, &b).expect("should compute");
2811        assert!(r.p_value < 0.05);
2812    }
2813
2814    #[test]
2815    fn two_sample_edge_cases() {
2816        assert!(two_sample_t_test(&[1.0], &[2.0, 3.0]).is_none());
2817        assert!(two_sample_t_test(&[1.0, 2.0], &[3.0]).is_none());
2818    }
2819
2820    // -----------------------------------------------------------------------
2821    // Paired t-test
2822    // -----------------------------------------------------------------------
2823
2824    #[test]
2825    fn paired_no_difference() {
2826        let x = [5.0, 6.0, 7.0, 8.0, 9.0];
2827        let y = [5.1, 5.9, 7.1, 7.9, 9.1];
2828        let r = paired_t_test(&x, &y).expect("should compute");
2829        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2830    }
2831
2832    #[test]
2833    fn paired_significant_difference() {
2834        // Differences have non-zero variance
2835        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2836        let after = [6.2, 7.1, 8.3, 9.0, 10.4, 11.1, 12.2, 13.3];
2837        let r = paired_t_test(&before, &after).expect("should compute");
2838        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2839        assert!(r.statistic < 0.0); // after > before
2840    }
2841
2842    #[test]
2843    fn paired_edge_cases() {
2844        assert!(paired_t_test(&[1.0, 2.0], &[3.0]).is_none()); // length mismatch
2845        assert!(paired_t_test(&[1.0], &[2.0]).is_none()); // n < 2
2846    }
2847
2848    // -----------------------------------------------------------------------
2849    // ANOVA
2850    // -----------------------------------------------------------------------
2851
2852    #[test]
2853    fn anova_same_means() {
2854        let g1 = [5.0, 5.1, 4.9, 5.0, 5.1];
2855        let g2 = [5.0, 5.2, 4.8, 5.1, 4.9];
2856        let g3 = [5.1, 4.9, 5.0, 5.0, 5.1];
2857        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2858        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2859    }
2860
2861    #[test]
2862    fn anova_different_means() {
2863        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2864        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2865        let g3 = [10.0, 11.0, 12.0, 11.0, 10.5];
2866        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2867        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2868        assert!(r.df_between == 2);
2869        assert!(r.df_within == 12);
2870    }
2871
2872    #[test]
2873    fn anova_ss_decomposition() {
2874        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2875        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2876        let r = one_way_anova(&[&g1, &g2]).expect("should compute");
2877        // SS_total = SS_between + SS_within
2878        let all_data: Vec<f64> = g1.iter().chain(g2.iter()).copied().collect();
2879        let ss_total: f64 = all_data.iter().map(|&x| (x - r.grand_mean).powi(2)).sum();
2880        assert!(
2881            (ss_total - (r.ss_between + r.ss_within)).abs() < 1e-10,
2882            "SS decomposition: {ss_total} vs {} + {}",
2883            r.ss_between,
2884            r.ss_within
2885        );
2886    }
2887
2888    #[test]
2889    fn anova_edge_cases() {
2890        let g1 = [1.0, 2.0, 3.0];
2891        assert!(one_way_anova(&[&g1]).is_none()); // < 2 groups
2892    }
2893
2894    // -----------------------------------------------------------------------
2895    // Chi-squared goodness of fit
2896    // -----------------------------------------------------------------------
2897
2898    #[test]
2899    fn chi2_gof_uniform() {
2900        // Perfect uniform distribution
2901        let observed = [25.0, 25.0, 25.0, 25.0];
2902        let expected = [25.0, 25.0, 25.0, 25.0];
2903        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2904        assert!((r.statistic).abs() < 1e-15);
2905        assert!((r.p_value - 1.0).abs() < 0.01);
2906    }
2907
2908    #[test]
2909    fn chi2_gof_significant() {
2910        let observed = [90.0, 10.0];
2911        let expected = [50.0, 50.0];
2912        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2913        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2914    }
2915
2916    #[test]
2917    fn chi2_gof_edge_cases() {
2918        assert!(chi_squared_goodness_of_fit(&[10.0], &[10.0]).is_none()); // < 2
2919        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0, 0.0]).is_none()); // expected 0
2920        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0]).is_none()); // mismatch
2921    }
2922
2923    // -----------------------------------------------------------------------
2924    // Chi-squared independence
2925    // -----------------------------------------------------------------------
2926
2927    #[test]
2928    fn chi2_independence_significant() {
2929        // Strong association
2930        let table = [30.0, 10.0, 10.0, 50.0];
2931        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2932        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2933        assert!((r.df - 1.0).abs() < 1e-10);
2934    }
2935
2936    #[test]
2937    fn chi2_independence_not_significant() {
2938        // No association
2939        let table = [25.0, 25.0, 25.0, 25.0];
2940        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2941        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2942    }
2943
2944    #[test]
2945    fn chi2_independence_3x3() {
2946        let table = [10.0, 20.0, 30.0, 40.0, 30.0, 20.0, 20.0, 25.0, 25.0];
2947        let r = chi_squared_independence(&table, 3, 3).expect("should compute");
2948        assert!((r.df - 4.0).abs() < 1e-10);
2949        assert!(r.p_value < 0.05);
2950    }
2951
2952    #[test]
2953    fn chi2_independence_edge_cases() {
2954        assert!(chi_squared_independence(&[10.0, 20.0], 1, 2).is_none()); // 1 row
2955        assert!(chi_squared_independence(&[10.0, 20.0], 2, 1).is_none()); // 1 col
2956        assert!(chi_squared_independence(&[10.0], 2, 2).is_none()); // wrong size
2957    }
2958
2959    // -----------------------------------------------------------------------
2960    // Jarque-Bera
2961    // -----------------------------------------------------------------------
2962
2963    #[test]
2964    fn jb_normal_data() {
2965        // Symmetric, light-tailed data
2966        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2967        let r = jarque_bera_test(&data).expect("should compute");
2968        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2969    }
2970
2971    #[test]
2972    fn jb_skewed_data() {
2973        // Highly right-skewed
2974        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 20.0, 50.0];
2975        let r = jarque_bera_test(&data).expect("should compute");
2976        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2977    }
2978
2979    /// The statistic is the one the cited paper defines, which is the one
2980    /// SciPy, R and statsmodels report -- moment ratios, not the bias-adjusted
2981    /// estimators Excel's SKEW()/KURT() return.
2982    ///
2983    /// Reference values computed from the definition, independently of the
2984    /// implementation: g1 = m3/m2^1.5, g2 = m4/m2^2 - 3, JB = n/6 (g1^2 + g2^2/4).
2985    #[test]
2986    fn jb_is_the_moment_form_the_paper_defines() {
2987        let data = [
2988            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,
2989            3.6, 2.8, 3.2,
2990        ];
2991        let r = jarque_bera_test(&data).expect("20 finite points");
2992        assert!(
2993            (r.statistic - 5.563_714_337_9).abs() < 1e-8,
2994            "JB = {}",
2995            r.statistic
2996        );
2997
2998        // What it used to compute, from the adjusted estimators: 35 % higher on
2999        // this sample. The gap is not a rounding difference to be tolerated.
3000        let n = data.len() as f64;
3001        let big_g1 = u_numflow::stats::skewness(&data).expect("same data");
3002        let big_g2 = u_numflow::stats::kurtosis(&data).expect("same data");
3003        let adjusted = (n / 6.0) * (big_g1 * big_g1 + big_g2 * big_g2 / 4.0);
3004        assert!(
3005            adjusted > r.statistic * 1.3,
3006            "the two forms differ by more than a third here: {adjusted} vs {}",
3007            r.statistic
3008        );
3009    }
3010
3011    /// chi-squared with two degrees of freedom is an exponential, so the
3012    /// p-value is `exp(-JB/2)` exactly -- and stays a number where `1 - cdf`
3013    /// rounds to zero.
3014    #[test]
3015    fn jb_p_value_keeps_its_digits_in_the_tail() {
3016        // A ramp with one far outlier: JB near 93, where `1 - cdf` is exactly
3017        // zero in f64 and the true p-value is about 7e-21.
3018        let mut data: Vec<f64> = (1..20).map(f64::from).collect();
3019        data.push(60.0);
3020        let r = jarque_bera_test(&data).expect("20 finite points");
3021        assert!(r.statistic > 90.0, "JB = {}", r.statistic);
3022        assert!(
3023            1.0 - special::chi_squared_cdf(r.statistic, 2.0) == 0.0,
3024            "this sample is only interesting because the complement underflows"
3025        );
3026        assert!(
3027            r.p_value > 0.0,
3028            "the complement returns 0 here; this must not"
3029        );
3030        assert!(
3031            (r.p_value - 6.702e-21).abs() < 1e-23,
3032            "p = {} (expected about 6.7e-21)",
3033            r.p_value
3034        );
3035        assert!(
3036            (r.p_value - (-r.statistic / 2.0).exp()).abs() <= f64::EPSILON * r.p_value,
3037            "p = {} vs exp(-JB/2) = {}",
3038            r.p_value,
3039            (-r.statistic / 2.0).exp()
3040        );
3041        // And it still agrees with the distribution it claims, where that is
3042        // computable at all.
3043        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])
3044            .expect("10 finite points");
3045        let via_cdf = 1.0 - special::chi_squared_cdf(small.statistic, 2.0);
3046        assert!(
3047            (small.p_value - via_cdf).abs() < 1e-12,
3048            "{} vs {via_cdf}",
3049            small.p_value
3050        );
3051    }
3052
3053    #[test]
3054    fn jb_edge_cases() {
3055        assert!(jarque_bera_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
3056    }
3057
3058    // -----------------------------------------------------------------------
3059    // Anderson-Darling
3060    // -----------------------------------------------------------------------
3061
3062    #[test]
3063    fn ad_normal_data() {
3064        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
3065        let r = anderson_darling_test(&data).expect("should compute");
3066        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3067        assert!(r.statistic > 0.0, "A2 = {}", r.statistic);
3068        assert!(r.statistic_star > r.statistic, "A*2 should be > A2");
3069    }
3070
3071    #[test]
3072    fn ad_skewed_data() {
3073        // Exponential-like data — not normal
3074        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];
3075        let r = anderson_darling_test(&data).expect("should compute");
3076        assert!(
3077            r.p_value < 0.05,
3078            "p = {} (should reject normality)",
3079            r.p_value
3080        );
3081    }
3082
3083    #[test]
3084    fn ad_bimodal_data() {
3085        // Bimodal data — clearly not normal
3086        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
3087        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
3088        let r = anderson_darling_test(&data).expect("should compute");
3089        assert!(
3090            r.p_value < 0.01,
3091            "p = {} (bimodal should reject normality)",
3092            r.p_value
3093        );
3094    }
3095
3096    #[test]
3097    fn ad_large_normal_sample() {
3098        let n = 100;
3099        let data: Vec<f64> = (1..=n)
3100            .map(|i| {
3101                let p = (i as f64 - 0.5) / n as f64;
3102                special::inverse_normal_cdf(p)
3103            })
3104            .collect();
3105        let r = anderson_darling_test(&data).expect("should compute");
3106        assert!(r.p_value > 0.05, "p = {} for normal quantiles", r.p_value);
3107    }
3108
3109    #[test]
3110    fn ad_edge_cases() {
3111        assert!(anderson_darling_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
3112        assert!(anderson_darling_test(&[5.0; 10]).is_none()); // constant
3113        assert!(anderson_darling_test(&[1.0, f64::NAN, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).is_none());
3114    }
3115
3116    #[test]
3117    fn ad_p_value_ranges() {
3118        // Test different A*² ranges for p-value formula
3119        // Use datasets that produce different A*² magnitudes
3120
3121        // Near-normal → small A*² (< 0.2 range)
3122        let near_normal = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
3123        let r = anderson_darling_test(&near_normal).expect("should compute");
3124        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3125
3126        // Heavy-tailed → large A*² (>= 0.6 range)
3127        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];
3128        let r = anderson_darling_test(&heavy_tail).expect("should compute");
3129        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3130    }
3131
3132    // -----------------------------------------------------------------------
3133    // Anderson-Darling normality (formerly also `anderson_darling_normality`)
3134    // -----------------------------------------------------------------------
3135
3136    #[test]
3137    fn ad_normal_data_large_p() {
3138        // Clearly normal data → cannot reject normality (p > 0.05)
3139        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3140        let r = anderson_darling_test(&data).unwrap();
3141        assert!(r.p_value > 0.05, "p={}", r.p_value);
3142    }
3143
3144    #[test]
3145    fn ad_exponential_data_small_p() {
3146        // Clearly non-normal (exponential) → reject normality (p < 0.05)
3147        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
3148        let r = anderson_darling_test(&data).unwrap();
3149        assert!(r.p_value < 0.05, "p={}", r.p_value);
3150    }
3151
3152    #[test]
3153    fn ad_statistic_non_negative() {
3154        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3155        let r = anderson_darling_test(&data).unwrap();
3156        assert!(r.statistic >= 0.0);
3157        assert!(r.statistic_star >= r.statistic);
3158    }
3159
3160    #[test]
3161    fn ad_p_value_in_range() {
3162        let data = [5.0, 5.1, 4.9, 5.05, 4.95, 5.02, 4.98, 5.0];
3163        let r = anderson_darling_test(&data).unwrap();
3164        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3165    }
3166
3167    #[test]
3168    fn ad_insufficient_data() {
3169        assert!(anderson_darling_test(&[1.0, 2.0]).is_none()); // n < 3
3170    }
3171
3172    #[test]
3173    fn ad_degenerate_data() {
3174        // All same value → std = 0 → None
3175        assert!(anderson_darling_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3176    }
3177
3178    #[test]
3179    fn ad_modified_statistic_formula() {
3180        // A²* > A² for any finite n
3181        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
3182        let r = anderson_darling_test(&data).unwrap();
3183        assert!(r.statistic_star > r.statistic - 1e-10);
3184        // Below 8 values the correction is not tabulated: refused, not extrapolated.
3185        assert!(anderson_darling_test(&data[..7]).is_none());
3186    }
3187
3188    /// Validates A²* = A² · (1 + 0.75/n + 2.25/n²) against manual computation.
3189    ///
3190    /// Reference: Stephens (1974), "EDF statistics for goodness of fit",
3191    /// J. Amer. Statist. Assoc. 69(347), 730–737 (composite normal case).
3192    #[test]
3193    fn ad_correction_factor_formula() {
3194        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3195        let n = data.len() as f64;
3196        let r = anderson_darling_test(&data).unwrap();
3197
3198        // Verify the Stephens correction formula A²* = A² · (1 + 0.75/n + 2.25/n²)
3199        let expected_star = r.statistic * (1.0 + 0.75 / n + 2.25 / (n * n));
3200        assert!(
3201            (r.statistic_star - expected_star).abs() < 1e-12,
3202            "A²* = {}, expected = {}",
3203            r.statistic_star,
3204            expected_star
3205        );
3206
3207        // A² must be positive (it is a sum of log terms with negative coefficient)
3208        assert!(
3209            r.statistic >= 0.0,
3210            "A² = {} must be non-negative",
3211            r.statistic
3212        );
3213    }
3214
3215    /// Validates that the A² summation formula implements:
3216    /// A² = -n - (1/n)·Σᵢ₌₀ⁿ⁻¹ (2i+1)·[ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))]
3217    ///
3218    /// This tests the formula structure by checking that a perfectly symmetric
3219    /// dataset centered at 0 produces a smaller A² than a skewed dataset.
3220    #[test]
3221    fn ad_formula_structure_symmetric_vs_skewed() {
3222        // Symmetric around mean → smaller A²
3223        let symmetric = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 0.0];
3224        // Right-skewed → larger A² (heavier right tail deviates from normality)
3225        let skewed: Vec<f64> = (1..=10).map(|i| (i as f64 * 0.5).exp()).collect();
3226
3227        let r_sym = anderson_darling_test(&symmetric).unwrap();
3228        let r_skew = anderson_darling_test(&skewed).unwrap();
3229
3230        assert!(
3231            r_sym.statistic < r_skew.statistic,
3232            "Symmetric A²={} should be less than skewed A²={}",
3233            r_sym.statistic,
3234            r_skew.statistic
3235        );
3236    }
3237
3238    /// Validates p-value invariants for the Anderson-Darling test.
3239    ///
3240    /// - Normal data: p > 0.05 (cannot reject normality)
3241    /// - Exponential data: p < 0.05 (reject normality)
3242    #[test]
3243    fn ad_pvalue_invariants() {
3244        // Near-normal data — tightly clustered, should not reject normality
3245        let normal_data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
3246        let r_normal = anderson_darling_test(&normal_data).unwrap();
3247        assert!(
3248            r_normal.p_value > 0.05,
3249            "Normal data: p = {} (expected > 0.05)",
3250            r_normal.p_value
3251        );
3252
3253        // Exponential data — heavy right tail, should reject normality
3254        let exp_data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
3255        let r_exp = anderson_darling_test(&exp_data).unwrap();
3256        assert!(
3257            r_exp.p_value < 0.05,
3258            "Exponential data: p = {} (expected < 0.05)",
3259            r_exp.p_value
3260        );
3261    }
3262
3263    // -----------------------------------------------------------------------
3264    // Shapiro-Wilk
3265    // -----------------------------------------------------------------------
3266
3267    #[test]
3268    fn sw_normal_data() {
3269        // Approximately normal data (symmetric, bell-shaped)
3270        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
3271        let r = shapiro_wilk_test(&data).expect("should compute");
3272        assert!(r.w > 0.9, "W = {}", r.w);
3273        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3274    }
3275
3276    #[test]
3277    fn sw_bimodal_data() {
3278        // Bimodal data — clearly not normal
3279        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
3280        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
3281        let r = shapiro_wilk_test(&data).expect("should compute");
3282        assert!(
3283            r.p_value < 0.01,
3284            "p = {} (bimodal should reject normality)",
3285            r.p_value
3286        );
3287    }
3288
3289    #[test]
3290    fn sw_n3() {
3291        let data = [1.0, 2.0, 3.0];
3292        let r = shapiro_wilk_test(&data).expect("n=3 should work");
3293        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3294        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3295    }
3296
3297    #[test]
3298    fn sw_n4() {
3299        let data = [1.0, 2.0, 3.0, 4.0];
3300        let r = shapiro_wilk_test(&data).expect("n=4 should work");
3301        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3302        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3303    }
3304
3305    #[test]
3306    fn sw_n5() {
3307        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
3308        let r = shapiro_wilk_test(&data).expect("n=5 should work");
3309        assert!(r.w > 0.9, "W = {}", r.w);
3310        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3311    }
3312
3313    #[test]
3314    fn sw_skewed_data() {
3315        // Exponential-like data — not normal
3316        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];
3317        let r = shapiro_wilk_test(&data).expect("should compute");
3318        assert!(
3319            r.p_value < 0.05,
3320            "p = {} (skewed data should reject normality)",
3321            r.p_value
3322        );
3323    }
3324
3325    #[test]
3326    fn sw_large_normal_sample() {
3327        // Generate pseudo-normal data via Box-Muller-like approach
3328        // Use linearly spaced quantiles from standard normal
3329        let n = 100;
3330        let data: Vec<f64> = (1..=n)
3331            .map(|i| {
3332                let p = (i as f64 - 0.5) / n as f64;
3333                special::inverse_normal_cdf(p)
3334            })
3335            .collect();
3336        let r = shapiro_wilk_test(&data).expect("should compute");
3337        assert!(r.w > 0.99, "W = {} for normal quantiles", r.w);
3338        assert!(r.p_value > 0.05, "p = {}", r.p_value);
3339    }
3340
3341    #[test]
3342    fn sw_w_bounded() {
3343        // W should be in (0, 1] for any valid data
3344        let datasets: Vec<Vec<f64>> = vec![
3345            vec![1.0, 2.0, 3.0],
3346            vec![1.0, 1.0, 2.0, 3.0, 3.0],
3347            vec![0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
3348            (0..20).map(|i| (i as f64).powi(2)).collect(),
3349        ];
3350        for (idx, data) in datasets.iter().enumerate() {
3351            let r = shapiro_wilk_test(data).unwrap_or_else(|| panic!("dataset {idx} should work"));
3352            assert!(r.w > 0.0 && r.w <= 1.0, "dataset {idx}: W = {}", r.w);
3353            assert!(
3354                r.p_value >= 0.0 && r.p_value <= 1.0,
3355                "dataset {idx}: p = {}",
3356                r.p_value
3357            );
3358        }
3359    }
3360
3361    #[test]
3362    fn sw_edge_cases() {
3363        assert!(shapiro_wilk_test(&[1.0, 2.0]).is_none()); // n < 3
3364        assert!(shapiro_wilk_test(&[]).is_none()); // empty
3365        assert!(shapiro_wilk_test(&[5.0, 5.0, 5.0]).is_none()); // constant
3366        assert!(shapiro_wilk_test(&[1.0, f64::NAN, 3.0]).is_none()); // NaN
3367        assert!(shapiro_wilk_test(&[1.0, f64::INFINITY, 3.0]).is_none()); // Inf
3368    }
3369
3370    #[test]
3371    fn sw_n5001_rejected() {
3372        let data: Vec<f64> = (0..5001).map(|i| i as f64).collect();
3373        assert!(shapiro_wilk_test(&data).is_none()); // n > 5000
3374    }
3375
3376    // -----------------------------------------------------------------------
3377    // Mann-Whitney U
3378    // -----------------------------------------------------------------------
3379
3380    #[test]
3381    fn mw_clearly_different() {
3382        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
3383        let b = [6.0, 7.0, 8.0, 9.0, 10.0];
3384        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3385        assert!(r.p_value < 0.05, "p = {}", r.p_value);
3386    }
3387
3388    #[test]
3389    fn mw_same_distribution() {
3390        let a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0];
3391        let b = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0];
3392        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3393        assert!(
3394            r.p_value > 0.3,
3395            "p = {} (interleaved, same dist)",
3396            r.p_value
3397        );
3398    }
3399
3400    #[test]
3401    fn mw_with_ties() {
3402        let a = [1.0, 2.0, 2.0, 3.0, 3.0];
3403        let b = [3.0, 4.0, 4.0, 5.0, 5.0];
3404        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3405        assert!(r.p_value < 0.05, "p = {} (shifted with ties)", r.p_value);
3406    }
3407
3408    #[test]
3409    fn mw_different_sizes() {
3410        let a = [1.0, 2.0, 3.0];
3411        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
3412        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3413        assert!(r.p_value < 0.05);
3414    }
3415
3416    #[test]
3417    fn mw_edge_cases() {
3418        assert!(mann_whitney_u_test(&[1.0], &[2.0, 3.0]).is_none()); // n1 < 2
3419        assert!(mann_whitney_u_test(&[1.0, 2.0], &[3.0]).is_none()); // n2 < 2
3420        assert!(mann_whitney_u_test(&[1.0, f64::NAN], &[2.0, 3.0]).is_none());
3421    }
3422
3423    // -----------------------------------------------------------------------
3424    // Wilcoxon signed-rank
3425    // -----------------------------------------------------------------------
3426
3427    #[test]
3428    fn wilcoxon_significant_increase() {
3429        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
3430        let after = [6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5];
3431        let r = wilcoxon_signed_rank_test(&before, &after).expect("should compute");
3432        assert!(r.p_value < 0.05, "p = {} (consistent increase)", r.p_value);
3433    }
3434
3435    #[test]
3436    fn wilcoxon_no_difference() {
3437        let x = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3438        let y = [5.1, 5.9, 7.1, 7.9, 9.1, 9.9];
3439        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3440        assert!(r.p_value > 0.3, "p = {} (small random diffs)", r.p_value);
3441    }
3442
3443    #[test]
3444    fn wilcoxon_with_ties() {
3445        // Some differences are equal in magnitude
3446        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3447        let y = [0.0, 1.0, 2.0, 3.0, 4.0]; // constant difference = 1.0
3448        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3449        // All differences positive with ties in magnitude
3450        assert!(r.statistic > 0.0);
3451    }
3452
3453    #[test]
3454    fn wilcoxon_with_zero_diffs() {
3455        // Some pairs are equal → zero differences discarded
3456        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3457        let y = [1.0, 2.0, 3.0, 3.0, 4.0]; // first 3 are zero diffs
3458        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3459        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3460    }
3461
3462    #[test]
3463    fn wilcoxon_edge_cases() {
3464        assert!(wilcoxon_signed_rank_test(&[1.0, 2.0], &[3.0]).is_none()); // mismatch
3465        assert!(wilcoxon_signed_rank_test(&[1.0], &[2.0]).is_none()); // n < 2
3466                                                                      // All zero differences → fewer than 2 non-zero diffs
3467        assert!(wilcoxon_signed_rank_test(&[5.0, 5.0], &[5.0, 5.0]).is_none());
3468    }
3469
3470    /// Oracle: the exact p by enumerating every arrangement of the ranks
3471    /// (Python, independent of the recursion). The normal approximation this
3472    /// replaced reported the second column -- always too small, and below
3473    /// 0.05 in the first and third rows where the exact p is not.
3474    #[test]
3475    fn mann_whitney_small_samples_use_the_exact_distribution() {
3476        // (n1, n2, U, exact p, old normal-approximation p)
3477        let cases = [
3478            (3, 3, 0, 0.1000, 0.0495),
3479            (4, 4, 0, 0.0286, 0.0209),
3480            (4, 4, 1, 0.0571, 0.0433),
3481            (5, 5, 2, 0.0317, 0.0283),
3482            (8, 8, 13, 0.0499, 0.0460),
3483            (10, 10, 23, 0.0433, 0.0413),
3484        ];
3485        for (n1, n2, u, exact, old) in cases {
3486            let (a, b) = separated_samples(n1, n2, u);
3487            let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3488            assert_eq!(r.statistic, u as f64);
3489            assert!(
3490                (r.p_value - exact).abs() < 1e-4,
3491                "n={n1},{n2} U={u}: p={} want {exact} (old {old})",
3492                r.p_value
3493            );
3494        }
3495    }
3496
3497    /// Two samples of sizes `n1`, `n2` with no ties whose U₁ is exactly `u`:
3498    /// sample 1 takes the lowest ranks, then its top member is moved up past
3499    /// `u` members of sample 2 one step at a time.
3500    fn separated_samples(n1: usize, n2: usize, u: usize) -> (Vec<f64>, Vec<f64>) {
3501        let mut ranks_a: Vec<usize> = (1..=n1).collect();
3502        let mut remaining = u;
3503        for k in (0..n1).rev() {
3504            let room = n2.min(remaining);
3505            ranks_a[k] += room;
3506            remaining -= room;
3507            if remaining == 0 {
3508                break;
3509            }
3510        }
3511        let ranks_b: Vec<usize> = (1..=n1 + n2).filter(|r| !ranks_a.contains(r)).collect();
3512        (
3513            ranks_a.iter().map(|&r| r as f64).collect(),
3514            ranks_b.iter().map(|&r| r as f64).collect(),
3515        )
3516    }
3517
3518    /// Oracle: exact p by enumerating all 2ⁿ sign assignments (Python).
3519    #[test]
3520    fn wilcoxon_small_samples_use_the_exact_distribution() {
3521        // (n, T+, exact p)
3522        let cases = [
3523            (5, 0, 0.0625),
3524            (5, 1, 0.125),
3525            (6, 2, 0.09375),
3526            (8, 5, 0.078125),
3527            (10, 8, 0.048828125),
3528            (12, 14, 0.0522460938),
3529        ];
3530        for (n, t_plus, exact) in cases {
3531            // Differences 1..=n; the ranks summing to t_plus are positive.
3532            let positive = ranks_summing_to(n, t_plus);
3533            let x: Vec<f64> = (1..=n)
3534                .map(|r| {
3535                    if positive.contains(&r) {
3536                        r as f64
3537                    } else {
3538                        -(r as f64)
3539                    }
3540                })
3541                .collect();
3542            let y = vec![0.0; n];
3543            let r = wilcoxon_signed_rank_test(&x, &y).expect("valid samples");
3544            assert_eq!(r.statistic, t_plus as f64);
3545            assert!(
3546                (r.p_value - exact).abs() < 1e-9,
3547                "n={n} T+={t_plus}: p={} want {exact}",
3548                r.p_value
3549            );
3550        }
3551    }
3552
3553    /// A set of distinct ranks from 1..=n summing to `target` (greedy from the top).
3554    fn ranks_summing_to(n: usize, target: usize) -> Vec<usize> {
3555        let mut out = Vec::new();
3556        let mut left = target;
3557        for r in (1..=n).rev() {
3558            if r <= left {
3559                out.push(r);
3560                left -= r;
3561            }
3562        }
3563        assert_eq!(left, 0);
3564        out
3565    }
3566
3567    /// Above the exact range, or with ties, the p comes from the normal
3568    /// approximation with continuity correction.
3569    #[test]
3570    fn rank_tests_fall_back_to_continuity_corrected_normal() {
3571        let a: Vec<f64> = (0..60).map(|i| i as f64).collect();
3572        let b: Vec<f64> = (0..60).map(|i| i as f64 + 20.5).collect();
3573        let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3574        let (n1, n2) = (60.0, 60.0);
3575        let mu = n1 * n2 / 2.0;
3576        let sigma = (n1 * n2 * (n1 + n2 + 1.0) / 12.0_f64).sqrt();
3577        let z = ((r.statistic - mu).abs() - 0.5) / sigma;
3578        let want = 2.0 * special::standard_normal_sf(z);
3579        assert!((r.p_value - want).abs() < 1e-12);
3580
3581        // At the boundary the two methods agree closely.
3582        let a: Vec<f64> = (0..50).map(|i| i as f64).collect();
3583        let b: Vec<f64> = (0..50).map(|i| i as f64 + 10.5).collect();
3584        let exact = mann_whitney_u_test(&a, &b).expect("valid samples").p_value;
3585        let mu = 1250.0;
3586        let sigma = (2500.0 * 101.0 / 12.0_f64).sqrt();
3587        let u = mann_whitney_u_test(&a, &b)
3588            .expect("valid samples")
3589            .statistic;
3590        let approx = continuity_corrected_p(u, mu, sigma * sigma);
3591        assert!(
3592            (exact - approx).abs() < 1e-3,
3593            "exact {exact} vs approx {approx}"
3594        );
3595    }
3596
3597    // -----------------------------------------------------------------------
3598    // Kruskal-Wallis
3599    // -----------------------------------------------------------------------
3600
3601    #[test]
3602    fn kw_clearly_different() {
3603        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3604        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3605        let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
3606        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3607        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3608        assert!((r.df - 2.0).abs() < 1e-10);
3609    }
3610
3611    #[test]
3612    fn kw_same_distribution() {
3613        let g1 = [1.0, 3.0, 5.0, 7.0, 9.0];
3614        let g2 = [2.0, 4.0, 6.0, 8.0, 10.0];
3615        let r = kruskal_wallis_test(&[&g1, &g2]).expect("should compute");
3616        assert!(r.p_value > 0.3, "p = {} (interleaved)", r.p_value);
3617    }
3618
3619    #[test]
3620    fn kw_with_ties() {
3621        let g1 = [1.0, 2.0, 2.0, 3.0];
3622        let g2 = [3.0, 4.0, 4.0, 5.0];
3623        let g3 = [5.0, 6.0, 6.0, 7.0];
3624        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3625        assert!(r.statistic > 0.0);
3626    }
3627
3628    #[test]
3629    fn kw_edge_cases() {
3630        let g1 = [1.0, 2.0, 3.0];
3631        assert!(kruskal_wallis_test(&[&g1]).is_none()); // < 2 groups
3632    }
3633
3634    // -----------------------------------------------------------------------
3635    // Levene (Brown-Forsythe)
3636    // -----------------------------------------------------------------------
3637
3638    #[test]
3639    fn levene_equal_variance() {
3640        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3641        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3642        let r = levene_test(&[&g1, &g2]).expect("should compute");
3643        // Both have same spread, different means → equal variance
3644        assert!(r.p_value > 0.3, "p = {} (equal variance)", r.p_value);
3645    }
3646
3647    #[test]
3648    fn levene_unequal_variance() {
3649        let g1 = [4.5, 4.8, 5.0, 5.2, 5.5]; // small spread
3650        let g2 = [0.0, 2.0, 5.0, 8.0, 10.0]; // large spread
3651        let r = levene_test(&[&g1, &g2]).expect("should compute");
3652        assert!(r.p_value < 0.05, "p = {} (unequal variance)", r.p_value);
3653    }
3654
3655    #[test]
3656    fn levene_three_groups() {
3657        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3658        let g2 = [0.0, 3.0, 5.0, 7.0, 10.0];
3659        let g3 = [-5.0, 0.0, 5.0, 10.0, 15.0];
3660        let r = levene_test(&[&g1, &g2, &g3]).expect("should compute");
3661        assert!(r.df >= 2.0);
3662    }
3663
3664    #[test]
3665    fn levene_edge_cases() {
3666        let g1 = [1.0, 2.0, 3.0];
3667        assert!(levene_test(&[&g1]).is_none()); // < 2 groups
3668    }
3669
3670    // -----------------------------------------------------------------------
3671    // Multiple comparison correction
3672    // -----------------------------------------------------------------------
3673
3674    #[test]
3675    fn bonferroni_basic() {
3676        let ps = [0.01, 0.04, 0.03, 0.005];
3677        let adj = bonferroni_correction(&ps).expect("should compute");
3678        assert!((adj[0] - 0.04).abs() < 1e-10);
3679        assert!((adj[1] - 0.16).abs() < 1e-10);
3680        assert!((adj[2] - 0.12).abs() < 1e-10);
3681        assert!((adj[3] - 0.02).abs() < 1e-10);
3682    }
3683
3684    #[test]
3685    fn bonferroni_capped_at_one() {
3686        let ps = [0.5, 0.6];
3687        let adj = bonferroni_correction(&ps).expect("should compute");
3688        assert!((adj[0] - 1.0).abs() < 1e-10);
3689        assert!((adj[1] - 1.0).abs() < 1e-10);
3690    }
3691
3692    #[test]
3693    fn bh_basic() {
3694        let ps = [0.01, 0.04, 0.03, 0.005];
3695        let adj = benjamini_hochberg(&ps).expect("should compute");
3696        // All adjusted p-values should be >= original
3697        for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3698            assert!(
3699                adjusted >= orig - 1e-15,
3700                "adj[{i}] = {adjusted} < original {orig}"
3701            );
3702        }
3703        // Adjusted p-values should still be ordered (weakly) by original order
3704        // after reordering by original p-value
3705    }
3706
3707    #[test]
3708    fn bh_all_significant() {
3709        let ps = [0.001, 0.002, 0.003];
3710        let adj = benjamini_hochberg(&ps).expect("should compute");
3711        for &a in &adj {
3712            assert!(a < 0.05);
3713        }
3714    }
3715
3716    #[test]
3717    fn correction_edge_cases() {
3718        assert!(bonferroni_correction(&[]).is_none());
3719        assert!(benjamini_hochberg(&[]).is_none());
3720        assert!(bonferroni_correction(&[f64::NAN]).is_none());
3721    }
3722
3723    // -----------------------------------------------------------------------
3724    // Bartlett test
3725    // -----------------------------------------------------------------------
3726
3727    #[test]
3728    fn bartlett_equal_variances() {
3729        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0];
3730        let g2 = [12.0, 13.0, 14.0, 15.0, 16.0];
3731        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3732        assert!(
3733            r.p_value > 0.9,
3734            "equal variance → p high, got {}",
3735            r.p_value
3736        );
3737    }
3738
3739    #[test]
3740    fn bartlett_unequal_variances() {
3741        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // var ≈ 2.5
3742        let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // var ≈ 250
3743        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3744        assert!(
3745            r.p_value < 0.01,
3746            "very different variances → p < 0.01, got {}",
3747            r.p_value
3748        );
3749        assert!((r.df - 1.0).abs() < 1e-10); // k-1 = 1
3750    }
3751
3752    #[test]
3753    fn bartlett_three_groups() {
3754        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3755        let g2 = [1.5, 2.5, 3.5, 4.5, 5.5];
3756        let g3 = [10.0, 30.0, 50.0, 70.0, 90.0]; // much higher variance
3757        let r = bartlett_test(&[&g1, &g2, &g3]).expect("should compute");
3758        assert!(r.p_value < 0.05, "one group with high variance");
3759        assert!((r.df - 2.0).abs() < 1e-10); // k-1 = 2
3760    }
3761
3762    #[test]
3763    fn bartlett_edge_cases() {
3764        let g1 = [1.0, 2.0, 3.0];
3765        assert!(bartlett_test(&[&g1]).is_none()); // < 2 groups
3766
3767        let g2 = [5.0, 5.0, 5.0]; // zero variance
3768        assert!(bartlett_test(&[&g1, &g2]).is_none());
3769
3770        let g3 = [1.0]; // group too small
3771        assert!(bartlett_test(&[&g1, &g3]).is_none());
3772    }
3773
3774    // -----------------------------------------------------------------------
3775    // Fisher exact test
3776    // -----------------------------------------------------------------------
3777
3778    #[test]
3779    fn fisher_tea_tasting() {
3780        // Classic Fisher tea-tasting: [[3,1],[1,3]]
3781        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3782        // Known two-tailed p ≈ 0.4857
3783        assert!(
3784            (r.p_value - 0.4857).abs() < 0.01,
3785            "p ≈ 0.4857, got {}",
3786            r.p_value
3787        );
3788    }
3789
3790    #[test]
3791    fn fisher_significant() {
3792        // Strong association: [[10, 0], [0, 10]]
3793        let r = fisher_exact_test(10, 0, 0, 10).expect("should compute");
3794        assert!(r.p_value < 0.001, "perfect association → p very small");
3795    }
3796
3797    #[test]
3798    fn fisher_no_association() {
3799        // Proportional table: [[5, 5], [5, 5]]
3800        let r = fisher_exact_test(5, 5, 5, 5).expect("should compute");
3801        assert!(
3802            r.p_value > 0.9,
3803            "no association → p ≈ 1.0, got {}",
3804            r.p_value
3805        );
3806    }
3807
3808    #[test]
3809    fn fisher_small_table() {
3810        // [[1, 0], [0, 1]]
3811        let r = fisher_exact_test(1, 0, 0, 1).expect("should compute");
3812        assert!(r.p_value > 0.0 && r.p_value <= 1.0);
3813    }
3814
3815    #[test]
3816    fn fisher_asymmetric() {
3817        // [[8, 2], [1, 5]]
3818        let r = fisher_exact_test(8, 2, 1, 5).expect("should compute");
3819        assert!(r.p_value < 0.05, "significant association");
3820    }
3821
3822    #[test]
3823    fn fisher_edge_cases() {
3824        // Zero marginals → None
3825        assert!(fisher_exact_test(0, 0, 1, 2).is_none()); // row1 = 0
3826        assert!(fisher_exact_test(1, 2, 0, 0).is_none()); // row2 = 0
3827        assert!(fisher_exact_test(0, 1, 0, 2).is_none()); // col1 = 0
3828    }
3829
3830    #[test]
3831    fn fisher_odds_ratio() {
3832        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3833        // OR = (3*3)/(1*1) = 9
3834        assert!(
3835            (r.statistic - 9.0).abs() < 1e-10,
3836            "OR = 9, got {}",
3837            r.statistic
3838        );
3839    }
3840
3841    // -----------------------------------------------------------------------
3842    // Mann-Kendall trend test
3843    // -----------------------------------------------------------------------
3844
3845    #[test]
3846    fn mk_increasing_trend() {
3847        let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
3848        let r = mann_kendall_test(&data).expect("should compute");
3849        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3850        assert!(r.kendall_tau > 0.8, "tau = {}", r.kendall_tau);
3851        assert!(r.sen_slope > 0.0, "slope = {}", r.sen_slope);
3852        assert!(r.s_statistic > 0);
3853    }
3854
3855    #[test]
3856    fn mk_decreasing_trend() {
3857        let data = [10.0, 9.2, 8.5, 7.1, 6.3, 5.0, 4.2, 3.1, 2.0, 1.1];
3858        let r = mann_kendall_test(&data).expect("should compute");
3859        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3860        assert!(r.kendall_tau < -0.8, "tau = {}", r.kendall_tau);
3861        assert!(r.sen_slope < 0.0, "slope = {}", r.sen_slope);
3862        assert!(r.s_statistic < 0);
3863    }
3864
3865    #[test]
3866    fn mk_no_trend() {
3867        // Random-looking data with no clear trend
3868        let data = [5.0, 3.0, 7.0, 2.0, 8.0, 4.0, 6.0, 1.0, 9.0, 5.0];
3869        let r = mann_kendall_test(&data).expect("should compute");
3870        // Should not detect significant trend
3871        assert!(
3872            r.p_value > 0.05,
3873            "p = {} (should be > 0.05 for no trend)",
3874            r.p_value
3875        );
3876    }
3877
3878    #[test]
3879    fn mk_perfect_monotone() {
3880        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
3881        let r = mann_kendall_test(&data).expect("should compute");
3882        // Perfect monotone: S = n(n-1)/2 = 45, tau = 1.0
3883        assert_eq!(r.s_statistic, 45);
3884        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3885        assert!((r.sen_slope - 1.0).abs() < 1e-10);
3886    }
3887
3888    #[test]
3889    fn mk_with_ties() {
3890        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0];
3891        let r = mann_kendall_test(&data).expect("should compute");
3892        assert!(r.s_statistic > 0);
3893        // Tie correction should reduce variance
3894        let n = data.len() as f64;
3895        let base_var = n * (n - 1.0) * (2.0 * n + 5.0) / 18.0;
3896        assert!(r.variance < base_var, "ties should reduce variance");
3897    }
3898
3899    /// Which values are tied does not depend on the unit the series is
3900    /// written in: the same series in volts or in picovolts has the same
3901    /// ties, so the same S, variance, Z and p; only Sen's slope scales.
3902    #[test]
3903    fn mk_does_not_depend_on_the_unit() {
3904        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0, 4.5, 6.0, 7.0, 6.5];
3905        let base = mann_kendall_test(&data).expect("computes");
3906        for scale in [1e-15, 1e-12, 1e-6, 1e6, 1e12] {
3907            let scaled: Vec<f64> = data.iter().map(|v| v * scale).collect();
3908            let r = mann_kendall_test(&scaled)
3909                .unwrap_or_else(|| panic!("scale {scale:e}: the series has variation"));
3910            assert_eq!(r.s_statistic, base.s_statistic, "scale {scale:e}");
3911            assert!(
3912                (r.variance - base.variance).abs() <= 1e-12 * base.variance,
3913                "scale {scale:e}: variance {} vs {}",
3914                r.variance,
3915                base.variance
3916            );
3917            assert!(
3918                (r.p_value - base.p_value).abs() <= 1e-12,
3919                "scale {scale:e}: p {} vs {}",
3920                r.p_value,
3921                base.p_value
3922            );
3923            assert!(
3924                (r.sen_slope / scale - base.sen_slope).abs() <= 1e-9,
3925                "scale {scale:e}: slope"
3926            );
3927        }
3928    }
3929
3930    /// The rank tests tie values that are equal, whatever the unit: the same
3931    /// samples in volts or in picovolts rank, tie and test the same.
3932    #[test]
3933    fn rank_tests_do_not_depend_on_the_unit() {
3934        let a = [1.0, 2.0, 2.0, 3.5, 4.0, 4.0, 5.5, 6.0];
3935        let b = [2.0, 3.0, 3.5, 5.0, 5.5, 7.0, 7.5, 8.0, 8.0];
3936        let c = [0.5, 1.5, 2.0, 2.5, 3.0];
3937        let paired_x = [1.5, 2.5, 3.0, 4.5, 5.0, 6.5, 7.0, 8.5];
3938        let paired_y = [1.0, 2.0, 3.5, 4.0, 5.5, 6.0, 7.5, 8.0];
3939        let scaled = |v: &[f64], k: f64| -> Vec<f64> { v.iter().map(|x| x * k).collect() };
3940        let mw = mann_whitney_u_test(&a, &b).expect("computes");
3941        let wx = wilcoxon_signed_rank_test(&paired_x, &paired_y).expect("computes");
3942        let kw = kruskal_wallis_test(&[&a, &b, &c]).expect("computes");
3943        for k in [1e-15, 1e-12, 1e-6, 1e6, 1e12] {
3944            let (sa, sb, sc) = (scaled(&a, k), scaled(&b, k), scaled(&c, k));
3945            let got = mann_whitney_u_test(&sa, &sb).expect("computes");
3946            assert!(
3947                (got.statistic - mw.statistic).abs() < 1e-12,
3948                "mann-whitney U at {k:e}"
3949            );
3950            assert!(
3951                (got.p_value - mw.p_value).abs() < 1e-12,
3952                "mann-whitney p at {k:e}"
3953            );
3954            let got = wilcoxon_signed_rank_test(&scaled(&paired_x, k), &scaled(&paired_y, k))
3955                .expect("computes");
3956            assert!(
3957                (got.statistic - wx.statistic).abs() < 1e-12,
3958                "wilcoxon T at {k:e}"
3959            );
3960            assert!(
3961                (got.p_value - wx.p_value).abs() < 1e-12,
3962                "wilcoxon p at {k:e}"
3963            );
3964            let got = kruskal_wallis_test(&[&sa, &sb, &sc]).expect("computes");
3965            assert!(
3966                (got.statistic - kw.statistic).abs() < 1e-12,
3967                "kruskal H at {k:e}"
3968            );
3969            assert!(
3970                (got.p_value - kw.p_value).abs() < 1e-12,
3971                "kruskal p at {k:e}"
3972            );
3973        }
3974    }
3975
3976    #[test]
3977    fn mk_edge_cases() {
3978        // Too few data points
3979        assert!(mann_kendall_test(&[1.0, 2.0, 3.0]).is_none());
3980        // NaN
3981        assert!(mann_kendall_test(&[1.0, f64::NAN, 3.0, 4.0]).is_none());
3982        // All identical (zero variance)
3983        assert!(mann_kendall_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3984    }
3985
3986    #[test]
3987    fn mk_minimum_n() {
3988        // n = 4 should work
3989        let data = [1.0, 2.0, 3.0, 4.0];
3990        let r = mann_kendall_test(&data).expect("n=4 should work");
3991        assert_eq!(r.s_statistic, 6); // C(4,2) = 6 pairs, all positive
3992        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3993    }
3994
3995    #[test]
3996    fn mk_sen_slope_robust_to_outlier() {
3997        // Mostly linear (slope ≈ 1) with one outlier
3998        let data = [1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3999        let r = mann_kendall_test(&data).expect("should compute");
4000        // Sen's slope should be close to 1.0 despite the outlier at index 3
4001        assert!(
4002            (r.sen_slope - 1.0).abs() < 0.5,
4003            "sen slope = {}, expected ≈ 1.0",
4004            r.sen_slope
4005        );
4006    }
4007
4008    // -----------------------------------------------------------------------
4009    // ADF test
4010    // -----------------------------------------------------------------------
4011
4012    #[test]
4013    fn adf_stationary_mean_reverting() {
4014        // Strongly mean-reverting AR(1) process: y_t = 0.3*y_{t-1} + noise
4015        // This is clearly stationary (|ρ| < 1)
4016        let data = [
4017            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,
4018            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,
4019            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,
4020        ];
4021        let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).expect("should compute");
4022        // Should reject H₀ (series is stationary)
4023        assert!(
4024            r.rejected[2],
4025            "should reject at 10%: stat={}, cv={}",
4026            r.statistic, r.critical_values[2]
4027        );
4028    }
4029
4030    #[test]
4031    fn adf_nonstationary_random_walk() {
4032        // Cumulative sum simulates random walk (non-stationary)
4033        let increments = [
4034            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,
4035            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,
4036            -0.13, 0.15, -0.1,
4037        ];
4038        let mut walk = Vec::with_capacity(increments.len());
4039        let mut cum = 0.0;
4040        for &inc in &increments {
4041            cum += inc;
4042            walk.push(cum);
4043        }
4044        let r = adf_test(&walk, AdfModel::Constant, AdfLags::default()).expect("should compute");
4045        // Random walk should NOT reject at 1%
4046        assert!(
4047            !r.rejected[0],
4048            "should NOT reject at 1%: stat={}, cv={}",
4049            r.statistic, r.critical_values[0]
4050        );
4051    }
4052
4053    #[test]
4054    fn adf_with_fixed_lags() {
4055        // A pure sinusoid plus a line obeys an exact second-order recurrence, so
4056        // two lags fit its differences exactly (an undefined t-ratio); the noise
4057        // makes it a series the test can be run on.
4058        let mut rng = mulberry32(0xadf2);
4059        let data: Vec<f64> = (0..50)
4060            .map(|i| (i as f64 * 0.5).sin() + 0.02 * i as f64 + 0.1 * (rng() - 0.5))
4061            .collect();
4062        let r = adf_test(&data, AdfModel::Constant, AdfLags::Fixed(2)).expect("should compute");
4063        assert_eq!(r.n_lags, 2);
4064        assert!(r.statistic.is_finite());
4065    }
4066
4067    #[test]
4068    fn adf_constant_trend_model() {
4069        // Linear trend is unit-root-like under "constant" model
4070        // but with "constant+trend" model, it should be recognized
4071        let data: Vec<f64> = (0..30)
4072            .map(|i| i as f64 + (i as f64 * 0.3).sin() * 0.5)
4073            .collect();
4074        let r =
4075            adf_test(&data, AdfModel::ConstantTrend, AdfLags::default()).expect("should compute");
4076        assert!(r.statistic.is_finite());
4077        assert_eq!(r.critical_values.len(), 3);
4078    }
4079
4080    #[test]
4081    fn adf_edge_cases() {
4082        assert_eq!(
4083            adf_test(&[1.0; 9], AdfModel::Constant, AdfLags::default()).unwrap_err(),
4084            TimeSeriesError::TooFewObservations { needed: 10, got: 9 }
4085        );
4086        let mut data = vec![0.0; 20];
4087        data[5] = f64::NAN;
4088        assert_eq!(
4089            adf_test(&data, AdfModel::Constant, AdfLags::default()).unwrap_err(),
4090            TimeSeriesError::ValueNotFinite { index: 5 }
4091        );
4092        // 20 values under a constant: room for 0 to min(14, 8) lags.
4093        let walk: Vec<f64> = (0..20).map(|i| ((i * 37) % 11) as f64).collect();
4094        assert_eq!(adf_lags_domain(&walk, AdfModel::Constant).unwrap(), 0..=8);
4095        assert_eq!(
4096            adf_lags_domain(&walk, AdfModel::ConstantTrend).unwrap(),
4097            0..=7
4098        );
4099        assert_eq!(
4100            adf_lags_domain(&walk[..9], AdfModel::Constant).unwrap_err(),
4101            TimeSeriesError::TooFewObservations { needed: 10, got: 9 }
4102        );
4103        for lags in [AdfLags::Fixed(9), AdfLags::Aic { max: Some(9) }] {
4104            let option = if matches!(lags, AdfLags::Fixed(_)) {
4105                "lags"
4106            } else {
4107                "max_lags"
4108            };
4109            assert_eq!(
4110                adf_test(&walk, AdfModel::Constant, lags).unwrap_err(),
4111                TimeSeriesError::OptionOutOfRange {
4112                    option,
4113                    got: 9.0,
4114                    min: 0.0,
4115                    max: 8.0
4116                }
4117            );
4118        }
4119        assert_eq!(
4120            adf_test(&[3.0; 20], AdfModel::Constant, AdfLags::default()).unwrap_err(),
4121            TimeSeriesError::DegenerateRegression
4122        );
4123    }
4124
4125    /// Differences that repeat with period 10 are fitted exactly by 9 lags. The
4126    /// residual variance is rounding noise, and the t-ratio was -1.9e8 -- a
4127    /// "rejection" of the unit root at every level for what is a random walk
4128    /// with a periodic drift. The exact fit is undefined, and the lag search
4129    /// must not choose it.
4130    #[test]
4131    fn adf_exact_fit_is_undefined_and_not_chosen() {
4132        let shocks = [0.5, -0.8, 0.3, -0.6, 0.9, -0.4, 0.7, -0.2, 0.1, -0.5];
4133        let mut walk = vec![0.0_f64; 60];
4134        for i in 1..walk.len() {
4135            walk[i] = walk[i - 1] + shocks[(i * 7) % 10];
4136        }
4137        assert_eq!(
4138            adf_test(&walk, AdfModel::ConstantTrend, AdfLags::Fixed(9)).unwrap_err(),
4139            TimeSeriesError::DegenerateRegression
4140        );
4141        let r = adf_test(&walk, AdfModel::ConstantTrend, AdfLags::default())
4142            .expect("a lag below 9 fits");
4143        assert!(r.n_lags < 9, "chose the exact fit: {} lags", r.n_lags);
4144        assert!(r.statistic.abs() < 1e3, "t = {}", r.statistic);
4145    }
4146
4147    /// statsmodels 0.15 `adfuller` on the same series: the AIC search fits
4148    /// every candidate on one sample and refits the chosen count on all the
4149    /// observations it leaves (the statistic, the count and `nobs` agree).
4150    #[test]
4151    fn adf_matches_statsmodels() {
4152        let mut x = vec![0.0_f64; 120];
4153        for t in 1..x.len() {
4154            let e = ((t * 7919) % 101) as f64 / 50.0 - 1.0;
4155            x[t] = 0.7 * x[t - 1] + e;
4156        }
4157        for (model, lags, stat, n_lags, n_obs) in [
4158            (
4159                AdfModel::Constant,
4160                AdfLags::Aic { max: Some(8) },
4161                -3.436_052_894_8,
4162                5,
4163                114,
4164            ),
4165            (
4166                AdfModel::Constant,
4167                AdfLags::Aic { max: None },
4168                -3.436_052_894_8,
4169                5,
4170                114,
4171            ),
4172            (
4173                AdfModel::ConstantTrend,
4174                AdfLags::Aic { max: Some(6) },
4175                -3.505_247_069_0,
4176                5,
4177                114,
4178            ),
4179            (
4180                AdfModel::None,
4181                AdfLags::Aic { max: Some(5) },
4182                -3.459_240_021_9,
4183                5,
4184                114,
4185            ),
4186            (
4187                AdfModel::Constant,
4188                AdfLags::Fixed(3),
4189                -4.652_930_484_4,
4190                3,
4191                116,
4192            ),
4193        ] {
4194            let r = adf_test(&x, model, lags).expect("fits");
4195            assert_eq!((r.n_lags, r.n_obs), (n_lags, n_obs), "{model:?} {lags:?}");
4196            assert!(
4197                (r.statistic - stat).abs() < 1e-8,
4198                "{model:?} {lags:?}: {}",
4199                r.statistic
4200            );
4201        }
4202    }
4203
4204    #[test]
4205    fn adf_critical_values_constant() {
4206        // Verify critical values are reasonable for n=100
4207        let cv = adf_critical_values(AdfModel::Constant, 100);
4208        // At n=100: approximately -3.51, -2.89, -2.58
4209        assert!(cv[0] < -3.4 && cv[0] > -3.6, "1% cv = {}", cv[0]);
4210        assert!(cv[1] < -2.8 && cv[1] > -3.0, "5% cv = {}", cv[1]);
4211        assert!(cv[2] < -2.5 && cv[2] > -2.7, "10% cv = {}", cv[2]);
4212    }
4213
4214    #[test]
4215    fn adf_critical_values_ordering() {
4216        let cv = adf_critical_values(AdfModel::Constant, 50);
4217        // 1% < 5% < 10% (more negative for stricter levels)
4218        assert!(cv[0] < cv[1], "1% ({}) should be < 5% ({})", cv[0], cv[1]);
4219        assert!(cv[1] < cv[2], "5% ({}) should be < 10% ({})", cv[1], cv[2]);
4220    }
4221}
4222
4223/// The tests against values SciPy 1.17.1 computes for the same data and the
4224/// same variant (`scipy.stats` call named at each value).
4225#[cfg(test)]
4226mod scipy_reference {
4227    use super::*;
4228
4229    const A: [f64; 8] = [5.1, 4.9, 5.6, 5.8, 6.0, 5.2, 4.7, 5.5];
4230    const B: [f64; 7] = [6.3, 5.9, 6.8, 7.1, 6.0, 6.6, 7.4];
4231    const G1: [f64; 5] = [23.1, 25.4, 22.8, 26.0, 24.3];
4232    const G2: [f64; 6] = [27.2, 28.9, 26.5, 29.4, 27.8, 28.1];
4233    const G3: [f64; 4] = [22.0, 21.4, 24.9, 23.3];
4234
4235    /// `got` within `rel` of `want`, relative (absolute below 1).
4236    fn close(got: f64, want: f64, rel: f64, what: &str) {
4237        let scale = want.abs().max(1.0);
4238        assert!(
4239            (got - want).abs() <= rel * scale,
4240            "{what}: got {got}, SciPy {want} (relative tolerance {rel})"
4241        );
4242    }
4243
4244    #[test]
4245    fn t_tests_reproduce_scipy() {
4246        // ttest_1samp(A, 5.0)
4247        let r = one_sample_t_test(&A, 5.0).expect("valid");
4248        close(r.statistic, 2.197950389679656, 1e-12, "one-sample t");
4249        close(r.p_value, 0.06392412293886501, 1e-9, "one-sample p");
4250        assert_eq!(r.df, 7.0);
4251        // ttest_ind(A, B, equal_var=False)
4252        let r = two_sample_t_test(&A, &B).expect("valid");
4253        close(r.statistic, -4.67567567567568, 1e-12, "Welch t");
4254        close(r.df, 11.56763357082458, 1e-12, "Welch df");
4255        close(r.p_value, 0.0005912510074711228, 1e-9, "Welch p");
4256        // ttest_rel(X, Y)
4257        let x = [12.1, 13.4, 11.8, 14.2, 12.9, 13.0];
4258        let y = [12.9, 13.9, 12.0, 15.1, 13.5, 13.2];
4259        let r = paired_t_test(&x, &y).expect("valid");
4260        close(r.statistic, -4.437601569801824, 1e-12, "paired t");
4261        close(r.p_value, 0.006779965572779817, 1e-9, "paired p");
4262        assert_eq!(r.df, 5.0);
4263    }
4264
4265    #[test]
4266    fn anova_and_equal_variance_tests_reproduce_scipy() {
4267        let groups: [&[f64]; 3] = [&G1, &G2, &G3];
4268        // f_oneway(G1, G2, G3)
4269        let r = one_way_anova(&groups).expect("valid");
4270        close(r.f_statistic, 20.526701958197258, 1e-12, "ANOVA F");
4271        close(r.p_value, 0.00013390844134272023, 1e-9, "ANOVA p");
4272        // levene(G1, G2, G3, center="median")
4273        let r = levene_test(&groups).expect("valid");
4274        close(r.statistic, 0.4752743705616534, 1e-12, "Brown-Forsythe W");
4275        close(r.p_value, 0.6329340379295331, 1e-9, "Brown-Forsythe p");
4276        // bartlett(G1, G2, G3)
4277        let r = bartlett_test(&groups).expect("valid");
4278        close(r.statistic, 0.5282298427149905, 1e-12, "Bartlett T");
4279        close(r.p_value, 0.7678852881660961, 1e-9, "Bartlett p");
4280    }
4281
4282    #[test]
4283    fn kruskal_wallis_with_ties_reproduces_scipy() {
4284        // kruskal([1, 2, 2, 3, 5], [4, 4, 6, 7], [5, 8, 8, 9, 10])
4285        let r = kruskal_wallis_test(&[
4286            &[1.0, 2.0, 2.0, 3.0, 5.0],
4287            &[4.0, 4.0, 6.0, 7.0],
4288            &[5.0, 8.0, 8.0, 9.0, 10.0],
4289        ])
4290        .expect("valid");
4291        close(r.statistic, 9.22394678492239, 1e-12, "H (tie-corrected)");
4292        close(r.p_value, 0.009932198829456881, 1e-9, "Kruskal-Wallis p");
4293    }
4294
4295    #[test]
4296    fn chi_squared_and_fisher_tests_reproduce_scipy() {
4297        // chisquare([18, 22, 30, 30], [25, 25, 25, 25])
4298        let r = chi_squared_goodness_of_fit(&[18.0, 22.0, 30.0, 30.0], &[25.0; 4]).expect("valid");
4299        close(r.statistic, 4.32, 1e-12, "goodness-of-fit chi-squared");
4300        close(r.p_value, 0.22891886433610517, 1e-9, "goodness-of-fit p");
4301        // chi2_contingency([[12, 7, 9], [5, 14, 11]], correction=False)
4302        let r = chi_squared_independence(&[12.0, 7.0, 9.0, 5.0, 14.0, 11.0], 2, 3).expect("valid");
4303        close(
4304            r.statistic,
4305            5.353085901027075,
4306            1e-12,
4307            "independence chi-squared",
4308        );
4309        close(r.p_value, 0.06880059054935043, 1e-9, "independence p");
4310        assert_eq!(r.df, 2.0);
4311        // fisher_exact([[8, 2], [1, 5]]) -- two-sided
4312        let r = fisher_exact_test(8, 2, 1, 5).expect("valid");
4313        close(r.p_value, 0.034965034965034975, 1e-9, "Fisher p");
4314    }
4315
4316    #[test]
4317    fn shapiro_wilk_reproduces_scipy() {
4318        // shapiro([148, 154, 158, 160, 161, 162, 166, 170, 182, 195, 236])
4319        let r = shapiro_wilk_test(&[
4320            148.0, 154.0, 158.0, 160.0, 161.0, 162.0, 166.0, 170.0, 182.0, 195.0, 236.0,
4321        ])
4322        .expect("valid");
4323        close(r.w, 0.7888146948631716, 1e-9, "W, skewed");
4324        close(r.p_value, 0.006703814061898823, 1e-9, "p, skewed");
4325        // 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])
4326        let r = shapiro_wilk_test(&[
4327            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,
4328        ])
4329        .expect("valid");
4330        close(r.w, 0.9864343014896592, 1e-9, "W, near normal");
4331        close(r.p_value, 0.9958703691266937, 1e-9, "p, near normal");
4332    }
4333
4334    #[test]
4335    fn mann_kendall_tau_and_sen_slope_reproduce_scipy() {
4336        // kendalltau(range(12), MK) and theilslopes(MK, range(12)) -- no ties,
4337        // so Kendall's tau-b is the Mann-Kendall S over n(n-1)/2.
4338        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];
4339        let r = mann_kendall_test(&mk).expect("valid");
4340        close(r.kendall_tau, 0.8484848484848484, 1e-12, "Kendall tau");
4341        close(r.sen_slope, 0.22500000000000003, 1e-12, "Sen's slope");
4342    }
4343
4344    #[test]
4345    fn benjamini_hochberg_reproduces_scipy() {
4346        // false_discovery_control([0.01, 0.04, 0.03, 0.005, 0.20, 0.50], method="bh")
4347        let adjusted = benjamini_hochberg(&[0.01, 0.04, 0.03, 0.005, 0.20, 0.50]).expect("valid");
4348        for (got, want) in adjusted.iter().zip([0.03, 0.06, 0.06, 0.03, 0.24, 0.5]) {
4349            close(*got, want, 1e-12, "BH-adjusted p");
4350        }
4351    }
4352}
4353
4354#[cfg(test)]
4355mod proptests {
4356    use super::*;
4357    use proptest::prelude::*;
4358
4359    proptest! {
4360        #[test]
4361        fn one_sample_p_bounded(
4362            data in proptest::collection::vec(-1e3_f64..1e3, 3..=30),
4363            mu0 in -1e3_f64..1e3
4364        ) {
4365            if let Some(r) = one_sample_t_test(&data, mu0) {
4366                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4367            }
4368        }
4369
4370        #[test]
4371        fn two_sample_p_bounded(
4372            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4373            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4374        ) {
4375            if let Some(r) = two_sample_t_test(&a, &b) {
4376                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4377            }
4378        }
4379
4380        #[test]
4381        fn anova_p_bounded(
4382            g1 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4383            g2 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4384            g3 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
4385        ) {
4386            if let Some(r) = one_way_anova(&[&g1, &g2, &g3]) {
4387                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4388                prop_assert!(r.f_statistic >= 0.0, "F = {}", r.f_statistic);
4389            }
4390        }
4391
4392        #[test]
4393        fn bonferroni_monotone(
4394            ps in proptest::collection::vec(0.001_f64..1.0, 2..=10)
4395        ) {
4396            let adj = bonferroni_correction(&ps).expect("should compute");
4397            for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
4398                prop_assert!(adjusted >= orig - 1e-15,
4399                    "adj[{i}] = {adjusted} < orig = {orig}");
4400            }
4401        }
4402
4403        #[test]
4404        fn shapiro_wilk_p_bounded(
4405            data in proptest::collection::vec(-1e3_f64..1e3, 3..=50)
4406        ) {
4407            if let Some(r) = shapiro_wilk_test(&data) {
4408                prop_assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
4409                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4410            }
4411        }
4412
4413        #[test]
4414        fn anderson_darling_p_bounded(
4415            data in proptest::collection::vec(-1e3_f64..1e3, 8..=100)
4416        ) {
4417            if let Some(r) = anderson_darling_test(&data) {
4418                prop_assert!(r.statistic >= 0.0, "A2 = {}", r.statistic);
4419                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4420            }
4421        }
4422
4423        #[test]
4424        fn mann_whitney_p_bounded(
4425            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4426            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4427        ) {
4428            if let Some(r) = mann_whitney_u_test(&a, &b) {
4429                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4430                prop_assert!(r.statistic >= 0.0, "U = {}", r.statistic);
4431            }
4432        }
4433
4434        #[test]
4435        fn wilcoxon_p_bounded(
4436            diffs in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
4437        ) {
4438            let zeros: Vec<f64> = vec![0.0; diffs.len()];
4439            if let Some(r) = wilcoxon_signed_rank_test(&diffs, &zeros) {
4440                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4441            }
4442        }
4443
4444        #[test]
4445        fn bartlett_p_bounded(
4446            g1 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
4447            g2 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
4448        ) {
4449            if let Some(r) = bartlett_test(&[&g1, &g2]) {
4450                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4451                prop_assert!(r.statistic >= 0.0, "T = {}", r.statistic);
4452            }
4453        }
4454
4455        #[test]
4456        fn fisher_p_bounded(
4457            a in 0_u64..20,
4458            b in 0_u64..20,
4459            c in 0_u64..20,
4460            d in 0_u64..20,
4461        ) {
4462            if let Some(r) = fisher_exact_test(a, b, c, d) {
4463                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4464            }
4465        }
4466
4467        #[test]
4468        fn mann_kendall_p_bounded(
4469            data in proptest::collection::vec(-1e3_f64..1e3, 4..=30)
4470        ) {
4471            if let Some(r) = mann_kendall_test(&data) {
4472                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
4473                prop_assert!(r.kendall_tau >= -1.0 && r.kendall_tau <= 1.0,
4474                    "tau = {}", r.kendall_tau);
4475            }
4476        }
4477
4478        #[test]
4479        fn mann_kendall_monotone_increasing(n in 5_usize..=30) {
4480            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
4481            let r = mann_kendall_test(&data).expect("should compute");
4482            prop_assert!((r.kendall_tau - 1.0).abs() < 1e-10,
4483                "tau should be 1.0 for monotone, got {}", r.kendall_tau);
4484            prop_assert!(r.sen_slope > 0.0, "slope should be positive");
4485        }
4486    }
4487}
4488
4489/// Bonferroni's adjustment is `min(p m, 1)` (Dunn 1961); the expected values
4490/// are that definition worked by hand, not another library's output.
4491#[cfg(test)]
4492mod definition_reference {
4493    use super::*;
4494
4495    #[test]
4496    fn bonferroni_is_p_times_m_capped_at_one() {
4497        let adjusted = bonferroni_correction(&[0.01, 0.02, 0.04, 0.3, 0.0004]).expect("valid");
4498        let want = [0.05, 0.1, 0.2, 1.0, 0.002];
4499        assert_eq!(adjusted.len(), want.len());
4500        for (got, want) in adjusted.iter().zip(want) {
4501            assert!((got - want).abs() <= 1e-15, "got {got}, want {want}");
4502        }
4503    }
4504}
4505
4506/// P-values far in the upper tail against SciPy 1.17.1 -- sizes at which
4507/// `1 - cdf` has no digits left (the smallest here is 3e-31).
4508#[cfg(test)]
4509mod scipy_tail_reference {
4510    use super::*;
4511
4512    fn close(got: f64, want: f64, what: &str) {
4513        assert!(
4514            (got - want).abs() <= 1e-9 * want.abs(),
4515            "{what}: got {got:e}, SciPy {want:e}"
4516        );
4517    }
4518
4519    #[test]
4520    fn t_tests_keep_their_tail() {
4521        let a = [100.2, 99.8, 100.5, 100.1, 99.9, 100.3, 100.0, 99.7];
4522        let b = [120.1, 119.7, 120.4, 120.0, 119.8, 120.3];
4523        // ttest_1samp(a, 90.0)
4524        let r = one_sample_t_test(&a, 90.0).expect("valid");
4525        close(r.p_value, 1.6824197797300066e-12, "one-sample p");
4526        // ttest_ind(a, b, equal_var=False)
4527        let r = two_sample_t_test(&a, &b).expect("valid");
4528        close(r.p_value, 8.838218005160137e-19, "Welch p");
4529    }
4530
4531    #[test]
4532    fn f_and_chi_squared_tests_keep_their_tail() {
4533        // f_oneway(g1, g2, g3)
4534        let (g1, g2, g3) = (
4535            [10.1, 10.3, 9.9, 10.0, 10.2],
4536            [12.0, 12.2, 11.9, 12.1, 12.3],
4537            [14.1, 13.9, 14.0, 14.2, 13.8],
4538        );
4539        let r = one_way_anova(&[&g1, &g2, &g3]).expect("valid");
4540        close(r.p_value, 2.297566098988527e-13, "ANOVA p");
4541        // chi2_contingency([[80, 20], [30, 70]], correction=False)
4542        let r = chi_squared_independence(&[80.0, 20.0, 30.0, 70.0], 2, 2).expect("valid");
4543        close(r.p_value, 1.1885852395646684e-12, "independence p");
4544        // chisquare([90, 10, 5, 95], [50, 50, 50, 50])
4545        let r = chi_squared_goodness_of_fit(&[90.0, 10.0, 5.0, 95.0], &[50.0; 4]).expect("valid");
4546        close(r.p_value, 3.1567404374222543e-31, "goodness-of-fit p");
4547        // kruskal(1..=10, 11..=20, 21..=30)
4548        let low: Vec<f64> = (1..=10).map(f64::from).collect();
4549        let mid: Vec<f64> = (11..=20).map(f64::from).collect();
4550        let high: Vec<f64> = (21..=30).map(f64::from).collect();
4551        let r = kruskal_wallis_test(&[&low, &mid, &high]).expect("valid");
4552        close(r.p_value, 2.49000508218493e-06, "Kruskal-Wallis p");
4553    }
4554}