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