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 * (1.0 - special::t_distribution_cdf(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 * (1.0 - special::t_distribution_cdf(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        1.0 - special::f_distribution_cdf(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 = 1.0 - special::chi_squared_cdf(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 = 1.0 - special::chi_squared_cdf(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 = 1.0 - special::chi_squared_cdf(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 = 1.0 - special::chi_squared_cdf(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// Augmented Dickey-Fuller test
1813// ---------------------------------------------------------------------------
1814
1815/// Result of the Augmented Dickey-Fuller (ADF) unit root test.
1816#[derive(Debug, Clone)]
1817pub struct AdfResult {
1818    /// ADF test statistic (t-ratio for γ̂).
1819    pub statistic: f64,
1820    /// Number of lags used.
1821    pub n_lags: usize,
1822    /// Number of observations used in the regression.
1823    pub n_obs: usize,
1824    /// Critical values at 1%, 5%, 10% significance levels.
1825    pub critical_values: [f64; 3],
1826    /// Whether the null hypothesis (unit root) is rejected at each level.
1827    pub rejected: [bool; 3],
1828}
1829
1830/// How [`adf_test`] chooses the number of lagged differences.
1831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1832pub enum AdfLags {
1833    /// The count from 0 to `max` with the smallest AIC, every candidate fitted
1834    /// on the same observations (those the largest count leaves), then the
1835    /// chosen count refitted on all the observations it leaves. `max` `None`
1836    /// is Schwert's rule, `⌊12·(n/100)^¼⌋`, capped at `n / 3`.
1837    Aic {
1838        /// The largest count considered.
1839        max: Option<usize>,
1840    },
1841    /// Exactly this many.
1842    Fixed(usize),
1843}
1844
1845impl Default for AdfLags {
1846    fn default() -> Self {
1847        AdfLags::Aic { max: None }
1848    }
1849}
1850
1851/// Model specification for the ADF test.
1852#[derive(Debug, Clone, Copy)]
1853pub enum AdfModel {
1854    /// No constant, no trend: Δyₜ = γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1855    None,
1856    /// Constant only (default): Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1857    Constant,
1858    /// Constant + linear trend: Δyₜ = α + βt + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1859    ConstantTrend,
1860}
1861
1862/// Augmented Dickey-Fuller (ADF) unit root test for stationarity.
1863///
1864/// Tests H₀: unit root (non-stationary) vs H₁: stationary.
1865///
1866/// # Algorithm
1867///
1868/// 1. Constructs Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1869/// 2. Estimates via OLS
1870/// 3. Tests t-ratio for γ against Dickey-Fuller critical values
1871///
1872/// `lags` chooses the number of lagged differences ([`AdfLags`]): by AIC up
1873/// to a maximum (the default, as `statsmodels.adfuller(autolag="AIC")`), or a
1874/// fixed count.
1875///
1876/// Reference: Dickey & Fuller (1979), "Distribution of the Estimators for
1877/// Autoregressive Time Series with a Unit Root"
1878///
1879/// # Returns
1880///
1881/// `None` if fewer than 10 data points, non-finite values, or OLS fails.
1882///
1883/// # Examples
1884///
1885/// ```
1886/// use u_analytics::testing::{adf_test, AdfLags, AdfModel};
1887///
1888/// // Stationary series: strong mean-reversion
1889/// let mut data = vec![0.0_f64; 40];
1890/// for i in 1..40 {
1891///     data[i] = 0.3 * data[i - 1] + [0.5, -0.8, 0.3, -0.6, 0.9,
1892///         -0.4, 0.7, -0.2, 0.1, -0.5][i % 10];
1893/// }
1894/// let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).unwrap();
1895/// assert!(r.statistic.is_finite());
1896/// assert_eq!(r.critical_values.len(), 3);
1897/// ```
1898pub fn adf_test(data: &[f64], model: AdfModel, lags: AdfLags) -> Option<AdfResult> {
1899    let n = data.len();
1900    if n < 10 || data.iter().any(|v| !v.is_finite()) {
1901        return None;
1902    }
1903
1904    // Compute differences
1905    let dy: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
1906
1907    let best_lag = match lags {
1908        AdfLags::Fixed(p) => p,
1909        AdfLags::Aic { max } => {
1910            // Schwert (1989) rule for the largest count considered.
1911            let schwert = (12.0 * (n as f64 / 100.0).powf(0.25)).floor() as usize;
1912            let p_max = max.unwrap_or_else(|| schwert.min(n / 3));
1913            select_adf_lag(data, &dy, model, p_max)?
1914        }
1915    };
1916
1917    // The chosen count, on every observation it leaves.
1918    adf_ols(data, &dy, model, best_lag)
1919}
1920
1921/// The lag count in `0..=p_max` with the smallest AIC. Every candidate is
1922/// fitted on the observations `p_max` lags leave, so the AICs compare the same
1923/// data — fitted on their own (longer) samples they would not be comparable.
1924/// `None` when no candidate can be fitted.
1925fn select_adf_lag(data: &[f64], dy: &[f64], model: AdfModel, p_max: usize) -> Option<usize> {
1926    let start = p_max;
1927    let mut best: Option<(f64, usize)> = None;
1928    for p in 0..=p_max {
1929        if let Some(aic) = adf_ols_aic(data, dy, model, p, start) {
1930            if best.is_none_or(|(b, _)| aic < b) {
1931                best = Some((aic, p));
1932            }
1933        }
1934    }
1935    best.map(|(_, p)| p)
1936}
1937
1938/// Builds the ADF design matrix and dependent variable.
1939///
1940/// Returns (design_matrix_row_major, y_dep, n_rows, n_cols, gamma_col_index).
1941#[allow(clippy::type_complexity)]
1942fn adf_build_matrix(
1943    data: &[f64],
1944    dy: &[f64],
1945    model: AdfModel,
1946    p: usize,
1947    start: usize,
1948) -> Option<(Vec<f64>, Vec<f64>, usize, usize, usize)> {
1949    // Row t regresses dy[t] on the level data[t] and dy[t-1] ... dy[t-p].
1950    debug_assert!(start >= p, "the first row needs its p lagged differences");
1951    if start >= dy.len() || dy.len() - start < 5 {
1952        return None;
1953    }
1954    let m = dy.len() - start;
1955
1956    let y_dep: Vec<f64> = dy[start..].to_vec();
1957
1958    // Count columns: intercept + y_{t-1} + [trend] + p lags
1959    let has_intercept = !matches!(model, AdfModel::None);
1960    let has_trend = matches!(model, AdfModel::ConstantTrend);
1961    let ncols = has_intercept as usize + 1 + has_trend as usize + p;
1962
1963    // Build row-major design matrix
1964    let mut x_data = Vec::with_capacity(m * ncols);
1965    let mut gamma_col = 0;
1966
1967    for i in 0..m {
1968        let t = start + i;
1969        if has_intercept {
1970            x_data.push(1.0); // intercept
1971            gamma_col = 1;
1972        }
1973        x_data.push(data[t]); // y_{t-1}
1974        if has_trend {
1975            x_data.push((t + 1) as f64); // trend
1976        }
1977        for lag in 1..=p {
1978            x_data.push(dy[t - lag]);
1979        }
1980    }
1981
1982    Some((x_data, y_dep, m, ncols, gamma_col))
1983}
1984
1985/// Lightweight OLS for ADF: returns (gamma_t_stat, rss, k).
1986///
1987/// Solves X'Xβ = X'y using Cholesky-like decomposition (Gaussian elimination).
1988fn adf_ols_core(
1989    x_data: &[f64],
1990    y: &[f64],
1991    m: usize,
1992    ncols: usize,
1993    gamma_col: usize,
1994) -> Option<(f64, f64, usize)> {
1995    // Compute X'X (ncols × ncols, symmetric)
1996    let mut xtx = vec![0.0_f64; ncols * ncols];
1997    for i in 0..m {
1998        let row = &x_data[i * ncols..(i + 1) * ncols];
1999        for j in 0..ncols {
2000            for k in j..ncols {
2001                xtx[j * ncols + k] += row[j] * row[k];
2002            }
2003        }
2004    }
2005    // Mirror upper to lower
2006    for j in 0..ncols {
2007        for k in (j + 1)..ncols {
2008            xtx[k * ncols + j] = xtx[j * ncols + k];
2009        }
2010    }
2011
2012    // Compute X'y (ncols × 1)
2013    let mut xty = vec![0.0_f64; ncols];
2014    for i in 0..m {
2015        let row = &x_data[i * ncols..(i + 1) * ncols];
2016        for j in 0..ncols {
2017            xty[j] += row[j] * y[i];
2018        }
2019    }
2020
2021    // Solve via Gaussian elimination with partial pivoting
2022    let mut augmented = vec![0.0_f64; ncols * (ncols + 1)];
2023    for i in 0..ncols {
2024        for j in 0..ncols {
2025            augmented[i * (ncols + 1) + j] = xtx[i * ncols + j];
2026        }
2027        augmented[i * (ncols + 1) + ncols] = xty[i];
2028    }
2029
2030    for col in 0..ncols {
2031        // Partial pivoting
2032        let mut max_row = col;
2033        let mut max_val = augmented[col * (ncols + 1) + col].abs();
2034        for row in (col + 1)..ncols {
2035            let val = augmented[row * (ncols + 1) + col].abs();
2036            if val > max_val {
2037                max_val = val;
2038                max_row = row;
2039            }
2040        }
2041        if max_val < 1e-15 {
2042            return None; // Singular
2043        }
2044        if max_row != col {
2045            for j in 0..=ncols {
2046                let a = col * (ncols + 1) + j;
2047                let b = max_row * (ncols + 1) + j;
2048                augmented.swap(a, b);
2049            }
2050        }
2051
2052        let pivot = augmented[col * (ncols + 1) + col];
2053        for row in (col + 1)..ncols {
2054            let factor = augmented[row * (ncols + 1) + col] / pivot;
2055            for j in col..=ncols {
2056                let above = augmented[col * (ncols + 1) + j];
2057                augmented[row * (ncols + 1) + j] -= factor * above;
2058            }
2059        }
2060    }
2061
2062    // Back-substitution
2063    let mut beta = vec![0.0_f64; ncols];
2064    for i in (0..ncols).rev() {
2065        let mut sum = augmented[i * (ncols + 1) + ncols];
2066        for j in (i + 1)..ncols {
2067            sum -= augmented[i * (ncols + 1) + j] * beta[j];
2068        }
2069        beta[i] = sum / augmented[i * (ncols + 1) + i];
2070    }
2071
2072    // Compute residuals and RSS
2073    let mut rss = 0.0;
2074    for i in 0..m {
2075        let row = &x_data[i * ncols..(i + 1) * ncols];
2076        let y_hat: f64 = row.iter().zip(beta.iter()).map(|(&x, &b)| x * b).sum();
2077        let resid = y[i] - y_hat;
2078        rss += resid * resid;
2079    }
2080
2081    // An exact fit leaves no residual variance, so the t-ratio divides by
2082    // rounding noise: a series whose differences repeat with a period the lags
2083    // span came out as t = -1.9e8, a "rejection" at every level. Treat a fit
2084    // that explains the differences to eight digits as undefined, which also
2085    // keeps it out of the AIC lag search (where ln(rss) -> -inf would win).
2086    let yy: f64 = y.iter().map(|v| v * v).sum();
2087    if yy == 0.0 || rss <= yy * 1e-16 {
2088        return None;
2089    }
2090
2091    // Standard error of coefficients
2092    let df = m - ncols;
2093    if df == 0 {
2094        return None;
2095    }
2096    let mse = rss / df as f64;
2097
2098    // Compute (X'X)^{-1} via Gauss-Jordan elimination to get variance of γ̂
2099    let mut xtx_aug = vec![0.0_f64; ncols * ncols * 2]; // xtx | I
2100    for i in 0..ncols {
2101        for j in 0..ncols {
2102            xtx_aug[i * 2 * ncols + j] = xtx[i * ncols + j];
2103        }
2104        xtx_aug[i * 2 * ncols + ncols + i] = 1.0;
2105    }
2106
2107    // Gauss-Jordan elimination
2108    for col in 0..ncols {
2109        let mut max_row = col;
2110        let mut max_val = xtx_aug[col * 2 * ncols + col].abs();
2111        for row in (col + 1)..ncols {
2112            let val = xtx_aug[row * 2 * ncols + col].abs();
2113            if val > max_val {
2114                max_val = val;
2115                max_row = row;
2116            }
2117        }
2118        if max_val < 1e-15 {
2119            return None;
2120        }
2121        if max_row != col {
2122            for j in 0..(2 * ncols) {
2123                let a = col * 2 * ncols + j;
2124                let b = max_row * 2 * ncols + j;
2125                xtx_aug.swap(a, b);
2126            }
2127        }
2128
2129        let pivot = xtx_aug[col * 2 * ncols + col];
2130        for j in 0..(2 * ncols) {
2131            xtx_aug[col * 2 * ncols + j] /= pivot;
2132        }
2133        for row in 0..ncols {
2134            if row == col {
2135                continue;
2136            }
2137            let factor = xtx_aug[row * 2 * ncols + col];
2138            for j in 0..(2 * ncols) {
2139                let above = xtx_aug[col * 2 * ncols + j];
2140                xtx_aug[row * 2 * ncols + j] -= factor * above;
2141            }
2142        }
2143    }
2144
2145    // Extract diagonal element for gamma column
2146    let var_gamma = mse * xtx_aug[gamma_col * 2 * ncols + ncols + gamma_col];
2147    if var_gamma <= 0.0 {
2148        return None;
2149    }
2150    let se_gamma = var_gamma.sqrt();
2151    let t_gamma = beta[gamma_col] / se_gamma;
2152
2153    Some((t_gamma, rss, ncols))
2154}
2155
2156/// Runs ADF OLS and returns AIC + number of observations.
2157/// AIC of the regression with `p` lags on the rows from `start` on.
2158fn adf_ols_aic(data: &[f64], dy: &[f64], model: AdfModel, p: usize, start: usize) -> Option<f64> {
2159    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, start)?;
2160    let (_t_stat, rss, k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2161    Some(2.0 * k as f64 + m as f64 * (rss / m as f64).ln())
2162}
2163
2164/// Runs the actual ADF OLS and returns the test result.
2165fn adf_ols(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<AdfResult> {
2166    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p, p)?;
2167    let (gamma_t, _rss, _k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2168
2169    let critical_values = adf_critical_values(model, m);
2170
2171    let rejected = [
2172        gamma_t <= critical_values[0],
2173        gamma_t <= critical_values[1],
2174        gamma_t <= critical_values[2],
2175    ];
2176
2177    Some(AdfResult {
2178        statistic: gamma_t,
2179        n_lags: p,
2180        n_obs: m,
2181        critical_values,
2182        rejected,
2183    })
2184}
2185
2186/// MacKinnon (1994) critical values for ADF test.
2187///
2188/// Returns [1%, 5%, 10%] critical values based on sample size.
2189fn adf_critical_values(model: AdfModel, n: usize) -> [f64; 3] {
2190    // MacKinnon (1994) regression-based approximation:
2191    // cv(n) = τ_∞ + τ₁/n + τ₂/n²
2192    //
2193    // Coefficients from MacKinnon (2010), Table 1.
2194    let (tau_inf, tau1, tau2): ([f64; 3], [f64; 3], [f64; 3]) = match model {
2195        AdfModel::None => (
2196            [-2.5658, -1.9393, -1.6156],
2197            [-1.960, -0.398, -0.181],
2198            [-10.04, 0.0, 0.0],
2199        ),
2200        AdfModel::Constant => (
2201            [-3.4336, -2.8621, -2.5671],
2202            [-5.999, -2.738, -1.438],
2203            [-29.25, -8.36, -4.48],
2204        ),
2205        AdfModel::ConstantTrend => (
2206            [-3.9638, -3.4126, -3.1279],
2207            [-8.353, -4.039, -2.418],
2208            [-47.44, -17.83, -7.58],
2209        ),
2210    };
2211
2212    let nf = n as f64;
2213    let inv_n = 1.0 / nf;
2214    let inv_n2 = inv_n * inv_n;
2215
2216    [
2217        tau_inf[0] + tau1[0] * inv_n + tau2[0] * inv_n2,
2218        tau_inf[1] + tau1[1] * inv_n + tau2[1] * inv_n2,
2219        tau_inf[2] + tau1[2] * inv_n + tau2[2] * inv_n2,
2220    ]
2221}
2222
2223#[cfg(test)]
2224mod tests {
2225    use super::*;
2226
2227    #[test]
2228    fn corrections_refuse_what_is_not_a_probability() {
2229        assert!(bonferroni_correction(&[0.01, 1.5]).is_none());
2230        assert!(benjamini_hochberg(&[-0.1, 0.2]).is_none());
2231        assert_eq!(bonferroni_correction(&[0.01, 0.2]), Some(vec![0.02, 0.4]));
2232    }
2233
2234    // -----------------------------------------------------------------------
2235    // Anderson-Darling large-n p-value overflow (upstream-014)
2236    // -----------------------------------------------------------------------
2237
2238    /// Public-domain Mulberry32 PRNG (matches the upstream-014 reproduction).
2239    fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
2240        let mut a = seed;
2241        move || {
2242            a = a.wrapping_add(0x6d2b_79f5);
2243            let mut t = a;
2244            t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2245            t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2246            ((t ^ (t >> 14)) as f64) / 4_294_967_296.0
2247        }
2248    }
2249
2250    /// Regression (upstream-014): for a fixed, clearly non-normal distribution
2251    /// shape (exponential), as n grows the A*² statistic grows monotonically and
2252    /// the p-value must keep tracking toward 0 — it must NEVER jump to exactly 1.0
2253    /// ("perfectly normal") while A*² is simultaneously large and growing. Before
2254    /// the fix, the p-value overflowed to exactly 1 past n≈7000.
2255    #[test]
2256    fn ad_pvalue_no_overflow_large_nonnormal_n() {
2257        let mut rng = mulberry32(0x5350_4331);
2258        let full: Vec<f64> = (0..20_000)
2259            .map(|_| {
2260                let u = rng();
2261                -(1.0 - u).ln() // inverse-CDF exponential, rate=1
2262            })
2263            .collect();
2264
2265        let mut prev_a2 = 0.0_f64;
2266        let mut prev_p = f64::INFINITY;
2267        for &n in &[3000usize, 5000, 6000, 7000, 8000, 10000, 16000, 20000] {
2268            let r = anderson_darling_test(&full[..n]).expect("computes");
2269            // A*² must keep growing for an increasingly non-normal large sample.
2270            assert!(
2271                r.statistic_star > prev_a2,
2272                "A*² must grow with n; n={n} A*²={} prev={prev_a2}",
2273                r.statistic_star
2274            );
2275            // The core defect: p must never flip to exactly 1.0 while A*² is huge.
2276            assert!(
2277                r.p_value < 0.5,
2278                "clearly non-normal data (n={n}, A*²={}) must not report p={} ≈ normal",
2279                r.statistic_star,
2280                r.p_value
2281            );
2282            // Monotone non-increasing: p tracks the growing statistic downward.
2283            assert!(
2284                r.p_value <= prev_p + 1e-12,
2285                "p must be non-increasing as A*² grows; n={n} p={} prev={prev_p}",
2286                r.p_value
2287            );
2288            prev_a2 = r.statistic_star;
2289            prev_p = r.p_value;
2290        }
2291    }
2292
2293    // -----------------------------------------------------------------------
2294    // One-sample t-test
2295    // -----------------------------------------------------------------------
2296
2297    #[test]
2298    fn one_sample_null_true() {
2299        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2300        let r = one_sample_t_test(&data, 5.0).expect("should compute");
2301        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2302    }
2303
2304    #[test]
2305    fn one_sample_null_false() {
2306        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2307        let r = one_sample_t_test(&data, 10.0).expect("should compute");
2308        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2309    }
2310
2311    #[test]
2312    fn one_sample_edge_cases() {
2313        assert!(one_sample_t_test(&[1.0], 0.0).is_none()); // n < 2
2314        assert!(one_sample_t_test(&[5.0, 5.0, 5.0], 5.0).is_none()); // zero var
2315        assert!(one_sample_t_test(&[1.0, f64::NAN, 3.0], 2.0).is_none());
2316    }
2317
2318    // -----------------------------------------------------------------------
2319    // Two-sample t-test
2320    // -----------------------------------------------------------------------
2321
2322    #[test]
2323    fn two_sample_same_mean() {
2324        let a = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2325        let b = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
2326        let r = two_sample_t_test(&a, &b).expect("should compute");
2327        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2328    }
2329
2330    #[test]
2331    fn two_sample_different_means() {
2332        let a = [1.0, 2.0, 3.0, 2.0, 1.5, 2.5];
2333        let b = [10.0, 11.0, 12.0, 10.5, 11.5, 10.5];
2334        let r = two_sample_t_test(&a, &b).expect("should compute");
2335        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2336    }
2337
2338    #[test]
2339    fn two_sample_different_sizes() {
2340        let a = [1.0, 2.0, 3.0];
2341        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2342        let r = two_sample_t_test(&a, &b).expect("should compute");
2343        assert!(r.p_value < 0.05);
2344    }
2345
2346    #[test]
2347    fn two_sample_edge_cases() {
2348        assert!(two_sample_t_test(&[1.0], &[2.0, 3.0]).is_none());
2349        assert!(two_sample_t_test(&[1.0, 2.0], &[3.0]).is_none());
2350    }
2351
2352    // -----------------------------------------------------------------------
2353    // Paired t-test
2354    // -----------------------------------------------------------------------
2355
2356    #[test]
2357    fn paired_no_difference() {
2358        let x = [5.0, 6.0, 7.0, 8.0, 9.0];
2359        let y = [5.1, 5.9, 7.1, 7.9, 9.1];
2360        let r = paired_t_test(&x, &y).expect("should compute");
2361        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2362    }
2363
2364    #[test]
2365    fn paired_significant_difference() {
2366        // Differences have non-zero variance
2367        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2368        let after = [6.2, 7.1, 8.3, 9.0, 10.4, 11.1, 12.2, 13.3];
2369        let r = paired_t_test(&before, &after).expect("should compute");
2370        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2371        assert!(r.statistic < 0.0); // after > before
2372    }
2373
2374    #[test]
2375    fn paired_edge_cases() {
2376        assert!(paired_t_test(&[1.0, 2.0], &[3.0]).is_none()); // length mismatch
2377        assert!(paired_t_test(&[1.0], &[2.0]).is_none()); // n < 2
2378    }
2379
2380    // -----------------------------------------------------------------------
2381    // ANOVA
2382    // -----------------------------------------------------------------------
2383
2384    #[test]
2385    fn anova_same_means() {
2386        let g1 = [5.0, 5.1, 4.9, 5.0, 5.1];
2387        let g2 = [5.0, 5.2, 4.8, 5.1, 4.9];
2388        let g3 = [5.1, 4.9, 5.0, 5.0, 5.1];
2389        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2390        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2391    }
2392
2393    #[test]
2394    fn anova_different_means() {
2395        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2396        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2397        let g3 = [10.0, 11.0, 12.0, 11.0, 10.5];
2398        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2399        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2400        assert!(r.df_between == 2);
2401        assert!(r.df_within == 12);
2402    }
2403
2404    #[test]
2405    fn anova_ss_decomposition() {
2406        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2407        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2408        let r = one_way_anova(&[&g1, &g2]).expect("should compute");
2409        // SS_total = SS_between + SS_within
2410        let all_data: Vec<f64> = g1.iter().chain(g2.iter()).copied().collect();
2411        let ss_total: f64 = all_data.iter().map(|&x| (x - r.grand_mean).powi(2)).sum();
2412        assert!(
2413            (ss_total - (r.ss_between + r.ss_within)).abs() < 1e-10,
2414            "SS decomposition: {ss_total} vs {} + {}",
2415            r.ss_between,
2416            r.ss_within
2417        );
2418    }
2419
2420    #[test]
2421    fn anova_edge_cases() {
2422        let g1 = [1.0, 2.0, 3.0];
2423        assert!(one_way_anova(&[&g1]).is_none()); // < 2 groups
2424    }
2425
2426    // -----------------------------------------------------------------------
2427    // Chi-squared goodness of fit
2428    // -----------------------------------------------------------------------
2429
2430    #[test]
2431    fn chi2_gof_uniform() {
2432        // Perfect uniform distribution
2433        let observed = [25.0, 25.0, 25.0, 25.0];
2434        let expected = [25.0, 25.0, 25.0, 25.0];
2435        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2436        assert!((r.statistic).abs() < 1e-15);
2437        assert!((r.p_value - 1.0).abs() < 0.01);
2438    }
2439
2440    #[test]
2441    fn chi2_gof_significant() {
2442        let observed = [90.0, 10.0];
2443        let expected = [50.0, 50.0];
2444        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2445        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2446    }
2447
2448    #[test]
2449    fn chi2_gof_edge_cases() {
2450        assert!(chi_squared_goodness_of_fit(&[10.0], &[10.0]).is_none()); // < 2
2451        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0, 0.0]).is_none()); // expected 0
2452        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0]).is_none()); // mismatch
2453    }
2454
2455    // -----------------------------------------------------------------------
2456    // Chi-squared independence
2457    // -----------------------------------------------------------------------
2458
2459    #[test]
2460    fn chi2_independence_significant() {
2461        // Strong association
2462        let table = [30.0, 10.0, 10.0, 50.0];
2463        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2464        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2465        assert!((r.df - 1.0).abs() < 1e-10);
2466    }
2467
2468    #[test]
2469    fn chi2_independence_not_significant() {
2470        // No association
2471        let table = [25.0, 25.0, 25.0, 25.0];
2472        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2473        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2474    }
2475
2476    #[test]
2477    fn chi2_independence_3x3() {
2478        let table = [10.0, 20.0, 30.0, 40.0, 30.0, 20.0, 20.0, 25.0, 25.0];
2479        let r = chi_squared_independence(&table, 3, 3).expect("should compute");
2480        assert!((r.df - 4.0).abs() < 1e-10);
2481        assert!(r.p_value < 0.05);
2482    }
2483
2484    #[test]
2485    fn chi2_independence_edge_cases() {
2486        assert!(chi_squared_independence(&[10.0, 20.0], 1, 2).is_none()); // 1 row
2487        assert!(chi_squared_independence(&[10.0, 20.0], 2, 1).is_none()); // 1 col
2488        assert!(chi_squared_independence(&[10.0], 2, 2).is_none()); // wrong size
2489    }
2490
2491    // -----------------------------------------------------------------------
2492    // Jarque-Bera
2493    // -----------------------------------------------------------------------
2494
2495    #[test]
2496    fn jb_normal_data() {
2497        // Symmetric, light-tailed data
2498        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2499        let r = jarque_bera_test(&data).expect("should compute");
2500        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2501    }
2502
2503    #[test]
2504    fn jb_skewed_data() {
2505        // Highly right-skewed
2506        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 20.0, 50.0];
2507        let r = jarque_bera_test(&data).expect("should compute");
2508        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2509    }
2510
2511    /// The statistic is the one the cited paper defines, which is the one
2512    /// SciPy, R and statsmodels report -- moment ratios, not the bias-adjusted
2513    /// estimators Excel's SKEW()/KURT() return.
2514    ///
2515    /// Reference values computed from the definition, independently of the
2516    /// implementation: g1 = m3/m2^1.5, g2 = m4/m2^2 - 3, JB = n/6 (g1^2 + g2^2/4).
2517    #[test]
2518    fn jb_is_the_moment_form_the_paper_defines() {
2519        let data = [
2520            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,
2521            3.6, 2.8, 3.2,
2522        ];
2523        let r = jarque_bera_test(&data).expect("20 finite points");
2524        assert!(
2525            (r.statistic - 5.563_714_337_9).abs() < 1e-8,
2526            "JB = {}",
2527            r.statistic
2528        );
2529
2530        // What it used to compute, from the adjusted estimators: 35 % higher on
2531        // this sample. The gap is not a rounding difference to be tolerated.
2532        let n = data.len() as f64;
2533        let big_g1 = u_numflow::stats::skewness(&data).expect("same data");
2534        let big_g2 = u_numflow::stats::kurtosis(&data).expect("same data");
2535        let adjusted = (n / 6.0) * (big_g1 * big_g1 + big_g2 * big_g2 / 4.0);
2536        assert!(
2537            adjusted > r.statistic * 1.3,
2538            "the two forms differ by more than a third here: {adjusted} vs {}",
2539            r.statistic
2540        );
2541    }
2542
2543    /// chi-squared with two degrees of freedom is an exponential, so the
2544    /// p-value is `exp(-JB/2)` exactly -- and stays a number where `1 - cdf`
2545    /// rounds to zero.
2546    #[test]
2547    fn jb_p_value_keeps_its_digits_in_the_tail() {
2548        // A ramp with one far outlier: JB near 93, where `1 - cdf` is exactly
2549        // zero in f64 and the true p-value is about 7e-21.
2550        let mut data: Vec<f64> = (1..20).map(f64::from).collect();
2551        data.push(60.0);
2552        let r = jarque_bera_test(&data).expect("20 finite points");
2553        assert!(r.statistic > 90.0, "JB = {}", r.statistic);
2554        assert!(
2555            1.0 - special::chi_squared_cdf(r.statistic, 2.0) == 0.0,
2556            "this sample is only interesting because the complement underflows"
2557        );
2558        assert!(
2559            r.p_value > 0.0,
2560            "the complement returns 0 here; this must not"
2561        );
2562        assert!(
2563            (r.p_value - 6.702e-21).abs() < 1e-23,
2564            "p = {} (expected about 6.7e-21)",
2565            r.p_value
2566        );
2567        assert!(
2568            (r.p_value - (-r.statistic / 2.0).exp()).abs() <= f64::EPSILON * r.p_value,
2569            "p = {} vs exp(-JB/2) = {}",
2570            r.p_value,
2571            (-r.statistic / 2.0).exp()
2572        );
2573        // And it still agrees with the distribution it claims, where that is
2574        // computable at all.
2575        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])
2576            .expect("10 finite points");
2577        let via_cdf = 1.0 - special::chi_squared_cdf(small.statistic, 2.0);
2578        assert!(
2579            (small.p_value - via_cdf).abs() < 1e-12,
2580            "{} vs {via_cdf}",
2581            small.p_value
2582        );
2583    }
2584
2585    #[test]
2586    fn jb_edge_cases() {
2587        assert!(jarque_bera_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2588    }
2589
2590    // -----------------------------------------------------------------------
2591    // Anderson-Darling
2592    // -----------------------------------------------------------------------
2593
2594    #[test]
2595    fn ad_normal_data() {
2596        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2597        let r = anderson_darling_test(&data).expect("should compute");
2598        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2599        assert!(r.statistic > 0.0, "A2 = {}", r.statistic);
2600        assert!(r.statistic_star > r.statistic, "A*2 should be > A2");
2601    }
2602
2603    #[test]
2604    fn ad_skewed_data() {
2605        // Exponential-like data — not normal
2606        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];
2607        let r = anderson_darling_test(&data).expect("should compute");
2608        assert!(
2609            r.p_value < 0.05,
2610            "p = {} (should reject normality)",
2611            r.p_value
2612        );
2613    }
2614
2615    #[test]
2616    fn ad_bimodal_data() {
2617        // Bimodal data — clearly not normal
2618        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2619        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2620        let r = anderson_darling_test(&data).expect("should compute");
2621        assert!(
2622            r.p_value < 0.01,
2623            "p = {} (bimodal should reject normality)",
2624            r.p_value
2625        );
2626    }
2627
2628    #[test]
2629    fn ad_large_normal_sample() {
2630        let n = 100;
2631        let data: Vec<f64> = (1..=n)
2632            .map(|i| {
2633                let p = (i as f64 - 0.5) / n as f64;
2634                special::inverse_normal_cdf(p)
2635            })
2636            .collect();
2637        let r = anderson_darling_test(&data).expect("should compute");
2638        assert!(r.p_value > 0.05, "p = {} for normal quantiles", r.p_value);
2639    }
2640
2641    #[test]
2642    fn ad_edge_cases() {
2643        assert!(anderson_darling_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2644        assert!(anderson_darling_test(&[5.0; 10]).is_none()); // constant
2645        assert!(anderson_darling_test(&[1.0, f64::NAN, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).is_none());
2646    }
2647
2648    #[test]
2649    fn ad_p_value_ranges() {
2650        // Test different A*² ranges for p-value formula
2651        // Use datasets that produce different A*² magnitudes
2652
2653        // Near-normal → small A*² (< 0.2 range)
2654        let near_normal = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
2655        let r = anderson_darling_test(&near_normal).expect("should compute");
2656        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2657
2658        // Heavy-tailed → large A*² (>= 0.6 range)
2659        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];
2660        let r = anderson_darling_test(&heavy_tail).expect("should compute");
2661        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2662    }
2663
2664    // -----------------------------------------------------------------------
2665    // Anderson-Darling normality (formerly also `anderson_darling_normality`)
2666    // -----------------------------------------------------------------------
2667
2668    #[test]
2669    fn ad_normal_data_large_p() {
2670        // Clearly normal data → cannot reject normality (p > 0.05)
2671        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2672        let r = anderson_darling_test(&data).unwrap();
2673        assert!(r.p_value > 0.05, "p={}", r.p_value);
2674    }
2675
2676    #[test]
2677    fn ad_exponential_data_small_p() {
2678        // Clearly non-normal (exponential) → reject normality (p < 0.05)
2679        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2680        let r = anderson_darling_test(&data).unwrap();
2681        assert!(r.p_value < 0.05, "p={}", r.p_value);
2682    }
2683
2684    #[test]
2685    fn ad_statistic_non_negative() {
2686        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
2687        let r = anderson_darling_test(&data).unwrap();
2688        assert!(r.statistic >= 0.0);
2689        assert!(r.statistic_star >= r.statistic);
2690    }
2691
2692    #[test]
2693    fn ad_p_value_in_range() {
2694        let data = [5.0, 5.1, 4.9, 5.05, 4.95, 5.02, 4.98, 5.0];
2695        let r = anderson_darling_test(&data).unwrap();
2696        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2697    }
2698
2699    #[test]
2700    fn ad_insufficient_data() {
2701        assert!(anderson_darling_test(&[1.0, 2.0]).is_none()); // n < 3
2702    }
2703
2704    #[test]
2705    fn ad_degenerate_data() {
2706        // All same value → std = 0 → None
2707        assert!(anderson_darling_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
2708    }
2709
2710    #[test]
2711    fn ad_modified_statistic_formula() {
2712        // A²* > A² for any finite n
2713        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
2714        let r = anderson_darling_test(&data).unwrap();
2715        assert!(r.statistic_star > r.statistic - 1e-10);
2716        // Below 8 values the correction is not tabulated: refused, not extrapolated.
2717        assert!(anderson_darling_test(&data[..7]).is_none());
2718    }
2719
2720    /// Validates A²* = A² · (1 + 0.75/n + 2.25/n²) against manual computation.
2721    ///
2722    /// Reference: Stephens (1974), "EDF statistics for goodness of fit",
2723    /// J. Amer. Statist. Assoc. 69(347), 730–737 (composite normal case).
2724    #[test]
2725    fn ad_correction_factor_formula() {
2726        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2727        let n = data.len() as f64;
2728        let r = anderson_darling_test(&data).unwrap();
2729
2730        // Verify the Stephens correction formula A²* = A² · (1 + 0.75/n + 2.25/n²)
2731        let expected_star = r.statistic * (1.0 + 0.75 / n + 2.25 / (n * n));
2732        assert!(
2733            (r.statistic_star - expected_star).abs() < 1e-12,
2734            "A²* = {}, expected = {}",
2735            r.statistic_star,
2736            expected_star
2737        );
2738
2739        // A² must be positive (it is a sum of log terms with negative coefficient)
2740        assert!(
2741            r.statistic >= 0.0,
2742            "A² = {} must be non-negative",
2743            r.statistic
2744        );
2745    }
2746
2747    /// Validates that the A² summation formula implements:
2748    /// A² = -n - (1/n)·Σᵢ₌₀ⁿ⁻¹ (2i+1)·[ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))]
2749    ///
2750    /// This tests the formula structure by checking that a perfectly symmetric
2751    /// dataset centered at 0 produces a smaller A² than a skewed dataset.
2752    #[test]
2753    fn ad_formula_structure_symmetric_vs_skewed() {
2754        // Symmetric around mean → smaller A²
2755        let symmetric = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 0.0];
2756        // Right-skewed → larger A² (heavier right tail deviates from normality)
2757        let skewed: Vec<f64> = (1..=10).map(|i| (i as f64 * 0.5).exp()).collect();
2758
2759        let r_sym = anderson_darling_test(&symmetric).unwrap();
2760        let r_skew = anderson_darling_test(&skewed).unwrap();
2761
2762        assert!(
2763            r_sym.statistic < r_skew.statistic,
2764            "Symmetric A²={} should be less than skewed A²={}",
2765            r_sym.statistic,
2766            r_skew.statistic
2767        );
2768    }
2769
2770    /// Validates p-value invariants for the Anderson-Darling test.
2771    ///
2772    /// - Normal data: p > 0.05 (cannot reject normality)
2773    /// - Exponential data: p < 0.05 (reject normality)
2774    #[test]
2775    fn ad_pvalue_invariants() {
2776        // Near-normal data — tightly clustered, should not reject normality
2777        let normal_data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2778        let r_normal = anderson_darling_test(&normal_data).unwrap();
2779        assert!(
2780            r_normal.p_value > 0.05,
2781            "Normal data: p = {} (expected > 0.05)",
2782            r_normal.p_value
2783        );
2784
2785        // Exponential data — heavy right tail, should reject normality
2786        let exp_data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2787        let r_exp = anderson_darling_test(&exp_data).unwrap();
2788        assert!(
2789            r_exp.p_value < 0.05,
2790            "Exponential data: p = {} (expected < 0.05)",
2791            r_exp.p_value
2792        );
2793    }
2794
2795    // -----------------------------------------------------------------------
2796    // Shapiro-Wilk
2797    // -----------------------------------------------------------------------
2798
2799    #[test]
2800    fn sw_normal_data() {
2801        // Approximately normal data (symmetric, bell-shaped)
2802        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2803        let r = shapiro_wilk_test(&data).expect("should compute");
2804        assert!(r.w > 0.9, "W = {}", r.w);
2805        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2806    }
2807
2808    #[test]
2809    fn sw_bimodal_data() {
2810        // Bimodal data — clearly not normal
2811        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2812        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2813        let r = shapiro_wilk_test(&data).expect("should compute");
2814        assert!(
2815            r.p_value < 0.01,
2816            "p = {} (bimodal should reject normality)",
2817            r.p_value
2818        );
2819    }
2820
2821    #[test]
2822    fn sw_n3() {
2823        let data = [1.0, 2.0, 3.0];
2824        let r = shapiro_wilk_test(&data).expect("n=3 should work");
2825        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2826        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2827    }
2828
2829    #[test]
2830    fn sw_n4() {
2831        let data = [1.0, 2.0, 3.0, 4.0];
2832        let r = shapiro_wilk_test(&data).expect("n=4 should work");
2833        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2834        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2835    }
2836
2837    #[test]
2838    fn sw_n5() {
2839        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
2840        let r = shapiro_wilk_test(&data).expect("n=5 should work");
2841        assert!(r.w > 0.9, "W = {}", r.w);
2842        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2843    }
2844
2845    #[test]
2846    fn sw_skewed_data() {
2847        // Exponential-like data — not normal
2848        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];
2849        let r = shapiro_wilk_test(&data).expect("should compute");
2850        assert!(
2851            r.p_value < 0.05,
2852            "p = {} (skewed data should reject normality)",
2853            r.p_value
2854        );
2855    }
2856
2857    #[test]
2858    fn sw_large_normal_sample() {
2859        // Generate pseudo-normal data via Box-Muller-like approach
2860        // Use linearly spaced quantiles from standard normal
2861        let n = 100;
2862        let data: Vec<f64> = (1..=n)
2863            .map(|i| {
2864                let p = (i as f64 - 0.5) / n as f64;
2865                special::inverse_normal_cdf(p)
2866            })
2867            .collect();
2868        let r = shapiro_wilk_test(&data).expect("should compute");
2869        assert!(r.w > 0.99, "W = {} for normal quantiles", r.w);
2870        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2871    }
2872
2873    #[test]
2874    fn sw_w_bounded() {
2875        // W should be in (0, 1] for any valid data
2876        let datasets: Vec<Vec<f64>> = vec![
2877            vec![1.0, 2.0, 3.0],
2878            vec![1.0, 1.0, 2.0, 3.0, 3.0],
2879            vec![0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
2880            (0..20).map(|i| (i as f64).powi(2)).collect(),
2881        ];
2882        for (idx, data) in datasets.iter().enumerate() {
2883            let r = shapiro_wilk_test(data).unwrap_or_else(|| panic!("dataset {idx} should work"));
2884            assert!(r.w > 0.0 && r.w <= 1.0, "dataset {idx}: W = {}", r.w);
2885            assert!(
2886                r.p_value >= 0.0 && r.p_value <= 1.0,
2887                "dataset {idx}: p = {}",
2888                r.p_value
2889            );
2890        }
2891    }
2892
2893    #[test]
2894    fn sw_edge_cases() {
2895        assert!(shapiro_wilk_test(&[1.0, 2.0]).is_none()); // n < 3
2896        assert!(shapiro_wilk_test(&[]).is_none()); // empty
2897        assert!(shapiro_wilk_test(&[5.0, 5.0, 5.0]).is_none()); // constant
2898        assert!(shapiro_wilk_test(&[1.0, f64::NAN, 3.0]).is_none()); // NaN
2899        assert!(shapiro_wilk_test(&[1.0, f64::INFINITY, 3.0]).is_none()); // Inf
2900    }
2901
2902    #[test]
2903    fn sw_n5001_rejected() {
2904        let data: Vec<f64> = (0..5001).map(|i| i as f64).collect();
2905        assert!(shapiro_wilk_test(&data).is_none()); // n > 5000
2906    }
2907
2908    // -----------------------------------------------------------------------
2909    // Mann-Whitney U
2910    // -----------------------------------------------------------------------
2911
2912    #[test]
2913    fn mw_clearly_different() {
2914        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
2915        let b = [6.0, 7.0, 8.0, 9.0, 10.0];
2916        let r = mann_whitney_u_test(&a, &b).expect("should compute");
2917        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2918    }
2919
2920    #[test]
2921    fn mw_same_distribution() {
2922        let a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0];
2923        let b = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0];
2924        let r = mann_whitney_u_test(&a, &b).expect("should compute");
2925        assert!(
2926            r.p_value > 0.3,
2927            "p = {} (interleaved, same dist)",
2928            r.p_value
2929        );
2930    }
2931
2932    #[test]
2933    fn mw_with_ties() {
2934        let a = [1.0, 2.0, 2.0, 3.0, 3.0];
2935        let b = [3.0, 4.0, 4.0, 5.0, 5.0];
2936        let r = mann_whitney_u_test(&a, &b).expect("should compute");
2937        assert!(r.p_value < 0.05, "p = {} (shifted with ties)", r.p_value);
2938    }
2939
2940    #[test]
2941    fn mw_different_sizes() {
2942        let a = [1.0, 2.0, 3.0];
2943        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2944        let r = mann_whitney_u_test(&a, &b).expect("should compute");
2945        assert!(r.p_value < 0.05);
2946    }
2947
2948    #[test]
2949    fn mw_edge_cases() {
2950        assert!(mann_whitney_u_test(&[1.0], &[2.0, 3.0]).is_none()); // n1 < 2
2951        assert!(mann_whitney_u_test(&[1.0, 2.0], &[3.0]).is_none()); // n2 < 2
2952        assert!(mann_whitney_u_test(&[1.0, f64::NAN], &[2.0, 3.0]).is_none());
2953    }
2954
2955    // -----------------------------------------------------------------------
2956    // Wilcoxon signed-rank
2957    // -----------------------------------------------------------------------
2958
2959    #[test]
2960    fn wilcoxon_significant_increase() {
2961        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2962        let after = [6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5];
2963        let r = wilcoxon_signed_rank_test(&before, &after).expect("should compute");
2964        assert!(r.p_value < 0.05, "p = {} (consistent increase)", r.p_value);
2965    }
2966
2967    #[test]
2968    fn wilcoxon_no_difference() {
2969        let x = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
2970        let y = [5.1, 5.9, 7.1, 7.9, 9.1, 9.9];
2971        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
2972        assert!(r.p_value > 0.3, "p = {} (small random diffs)", r.p_value);
2973    }
2974
2975    #[test]
2976    fn wilcoxon_with_ties() {
2977        // Some differences are equal in magnitude
2978        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
2979        let y = [0.0, 1.0, 2.0, 3.0, 4.0]; // constant difference = 1.0
2980        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
2981        // All differences positive with ties in magnitude
2982        assert!(r.statistic > 0.0);
2983    }
2984
2985    #[test]
2986    fn wilcoxon_with_zero_diffs() {
2987        // Some pairs are equal → zero differences discarded
2988        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
2989        let y = [1.0, 2.0, 3.0, 3.0, 4.0]; // first 3 are zero diffs
2990        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
2991        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2992    }
2993
2994    #[test]
2995    fn wilcoxon_edge_cases() {
2996        assert!(wilcoxon_signed_rank_test(&[1.0, 2.0], &[3.0]).is_none()); // mismatch
2997        assert!(wilcoxon_signed_rank_test(&[1.0], &[2.0]).is_none()); // n < 2
2998                                                                      // All zero differences → fewer than 2 non-zero diffs
2999        assert!(wilcoxon_signed_rank_test(&[5.0, 5.0], &[5.0, 5.0]).is_none());
3000    }
3001
3002    /// Oracle: the exact p by enumerating every arrangement of the ranks
3003    /// (Python, independent of the recursion). The normal approximation this
3004    /// replaced reported the second column -- always too small, and below
3005    /// 0.05 in the first and third rows where the exact p is not.
3006    #[test]
3007    fn mann_whitney_small_samples_use_the_exact_distribution() {
3008        // (n1, n2, U, exact p, old normal-approximation p)
3009        let cases = [
3010            (3, 3, 0, 0.1000, 0.0495),
3011            (4, 4, 0, 0.0286, 0.0209),
3012            (4, 4, 1, 0.0571, 0.0433),
3013            (5, 5, 2, 0.0317, 0.0283),
3014            (8, 8, 13, 0.0499, 0.0460),
3015            (10, 10, 23, 0.0433, 0.0413),
3016        ];
3017        for (n1, n2, u, exact, old) in cases {
3018            let (a, b) = separated_samples(n1, n2, u);
3019            let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3020            assert_eq!(r.statistic, u as f64);
3021            assert!(
3022                (r.p_value - exact).abs() < 1e-4,
3023                "n={n1},{n2} U={u}: p={} want {exact} (old {old})",
3024                r.p_value
3025            );
3026        }
3027    }
3028
3029    /// Two samples of sizes `n1`, `n2` with no ties whose U₁ is exactly `u`:
3030    /// sample 1 takes the lowest ranks, then its top member is moved up past
3031    /// `u` members of sample 2 one step at a time.
3032    fn separated_samples(n1: usize, n2: usize, u: usize) -> (Vec<f64>, Vec<f64>) {
3033        let mut ranks_a: Vec<usize> = (1..=n1).collect();
3034        let mut remaining = u;
3035        for k in (0..n1).rev() {
3036            let room = n2.min(remaining);
3037            ranks_a[k] += room;
3038            remaining -= room;
3039            if remaining == 0 {
3040                break;
3041            }
3042        }
3043        let ranks_b: Vec<usize> = (1..=n1 + n2).filter(|r| !ranks_a.contains(r)).collect();
3044        (
3045            ranks_a.iter().map(|&r| r as f64).collect(),
3046            ranks_b.iter().map(|&r| r as f64).collect(),
3047        )
3048    }
3049
3050    /// Oracle: exact p by enumerating all 2ⁿ sign assignments (Python).
3051    #[test]
3052    fn wilcoxon_small_samples_use_the_exact_distribution() {
3053        // (n, T+, exact p)
3054        let cases = [
3055            (5, 0, 0.0625),
3056            (5, 1, 0.125),
3057            (6, 2, 0.09375),
3058            (8, 5, 0.078125),
3059            (10, 8, 0.048828125),
3060            (12, 14, 0.0522460938),
3061        ];
3062        for (n, t_plus, exact) in cases {
3063            // Differences 1..=n; the ranks summing to t_plus are positive.
3064            let positive = ranks_summing_to(n, t_plus);
3065            let x: Vec<f64> = (1..=n)
3066                .map(|r| {
3067                    if positive.contains(&r) {
3068                        r as f64
3069                    } else {
3070                        -(r as f64)
3071                    }
3072                })
3073                .collect();
3074            let y = vec![0.0; n];
3075            let r = wilcoxon_signed_rank_test(&x, &y).expect("valid samples");
3076            assert_eq!(r.statistic, t_plus as f64);
3077            assert!(
3078                (r.p_value - exact).abs() < 1e-9,
3079                "n={n} T+={t_plus}: p={} want {exact}",
3080                r.p_value
3081            );
3082        }
3083    }
3084
3085    /// A set of distinct ranks from 1..=n summing to `target` (greedy from the top).
3086    fn ranks_summing_to(n: usize, target: usize) -> Vec<usize> {
3087        let mut out = Vec::new();
3088        let mut left = target;
3089        for r in (1..=n).rev() {
3090            if r <= left {
3091                out.push(r);
3092                left -= r;
3093            }
3094        }
3095        assert_eq!(left, 0);
3096        out
3097    }
3098
3099    /// Above the exact range, or with ties, the p comes from the normal
3100    /// approximation with continuity correction.
3101    #[test]
3102    fn rank_tests_fall_back_to_continuity_corrected_normal() {
3103        let a: Vec<f64> = (0..60).map(|i| i as f64).collect();
3104        let b: Vec<f64> = (0..60).map(|i| i as f64 + 20.5).collect();
3105        let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3106        let (n1, n2) = (60.0, 60.0);
3107        let mu = n1 * n2 / 2.0;
3108        let sigma = (n1 * n2 * (n1 + n2 + 1.0) / 12.0_f64).sqrt();
3109        let z = ((r.statistic - mu).abs() - 0.5) / sigma;
3110        let want = 2.0 * special::standard_normal_sf(z);
3111        assert!((r.p_value - want).abs() < 1e-12);
3112
3113        // At the boundary the two methods agree closely.
3114        let a: Vec<f64> = (0..50).map(|i| i as f64).collect();
3115        let b: Vec<f64> = (0..50).map(|i| i as f64 + 10.5).collect();
3116        let exact = mann_whitney_u_test(&a, &b).expect("valid samples").p_value;
3117        let mu = 1250.0;
3118        let sigma = (2500.0 * 101.0 / 12.0_f64).sqrt();
3119        let u = mann_whitney_u_test(&a, &b)
3120            .expect("valid samples")
3121            .statistic;
3122        let approx = continuity_corrected_p(u, mu, sigma * sigma);
3123        assert!(
3124            (exact - approx).abs() < 1e-3,
3125            "exact {exact} vs approx {approx}"
3126        );
3127    }
3128
3129    // -----------------------------------------------------------------------
3130    // Kruskal-Wallis
3131    // -----------------------------------------------------------------------
3132
3133    #[test]
3134    fn kw_clearly_different() {
3135        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3136        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3137        let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
3138        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3139        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3140        assert!((r.df - 2.0).abs() < 1e-10);
3141    }
3142
3143    #[test]
3144    fn kw_same_distribution() {
3145        let g1 = [1.0, 3.0, 5.0, 7.0, 9.0];
3146        let g2 = [2.0, 4.0, 6.0, 8.0, 10.0];
3147        let r = kruskal_wallis_test(&[&g1, &g2]).expect("should compute");
3148        assert!(r.p_value > 0.3, "p = {} (interleaved)", r.p_value);
3149    }
3150
3151    #[test]
3152    fn kw_with_ties() {
3153        let g1 = [1.0, 2.0, 2.0, 3.0];
3154        let g2 = [3.0, 4.0, 4.0, 5.0];
3155        let g3 = [5.0, 6.0, 6.0, 7.0];
3156        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3157        assert!(r.statistic > 0.0);
3158    }
3159
3160    #[test]
3161    fn kw_edge_cases() {
3162        let g1 = [1.0, 2.0, 3.0];
3163        assert!(kruskal_wallis_test(&[&g1]).is_none()); // < 2 groups
3164    }
3165
3166    // -----------------------------------------------------------------------
3167    // Levene (Brown-Forsythe)
3168    // -----------------------------------------------------------------------
3169
3170    #[test]
3171    fn levene_equal_variance() {
3172        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3173        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3174        let r = levene_test(&[&g1, &g2]).expect("should compute");
3175        // Both have same spread, different means → equal variance
3176        assert!(r.p_value > 0.3, "p = {} (equal variance)", r.p_value);
3177    }
3178
3179    #[test]
3180    fn levene_unequal_variance() {
3181        let g1 = [4.5, 4.8, 5.0, 5.2, 5.5]; // small spread
3182        let g2 = [0.0, 2.0, 5.0, 8.0, 10.0]; // large spread
3183        let r = levene_test(&[&g1, &g2]).expect("should compute");
3184        assert!(r.p_value < 0.05, "p = {} (unequal variance)", r.p_value);
3185    }
3186
3187    #[test]
3188    fn levene_three_groups() {
3189        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3190        let g2 = [0.0, 3.0, 5.0, 7.0, 10.0];
3191        let g3 = [-5.0, 0.0, 5.0, 10.0, 15.0];
3192        let r = levene_test(&[&g1, &g2, &g3]).expect("should compute");
3193        assert!(r.df >= 2.0);
3194    }
3195
3196    #[test]
3197    fn levene_edge_cases() {
3198        let g1 = [1.0, 2.0, 3.0];
3199        assert!(levene_test(&[&g1]).is_none()); // < 2 groups
3200    }
3201
3202    // -----------------------------------------------------------------------
3203    // Multiple comparison correction
3204    // -----------------------------------------------------------------------
3205
3206    #[test]
3207    fn bonferroni_basic() {
3208        let ps = [0.01, 0.04, 0.03, 0.005];
3209        let adj = bonferroni_correction(&ps).expect("should compute");
3210        assert!((adj[0] - 0.04).abs() < 1e-10);
3211        assert!((adj[1] - 0.16).abs() < 1e-10);
3212        assert!((adj[2] - 0.12).abs() < 1e-10);
3213        assert!((adj[3] - 0.02).abs() < 1e-10);
3214    }
3215
3216    #[test]
3217    fn bonferroni_capped_at_one() {
3218        let ps = [0.5, 0.6];
3219        let adj = bonferroni_correction(&ps).expect("should compute");
3220        assert!((adj[0] - 1.0).abs() < 1e-10);
3221        assert!((adj[1] - 1.0).abs() < 1e-10);
3222    }
3223
3224    #[test]
3225    fn bh_basic() {
3226        let ps = [0.01, 0.04, 0.03, 0.005];
3227        let adj = benjamini_hochberg(&ps).expect("should compute");
3228        // All adjusted p-values should be >= original
3229        for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3230            assert!(
3231                adjusted >= orig - 1e-15,
3232                "adj[{i}] = {adjusted} < original {orig}"
3233            );
3234        }
3235        // Adjusted p-values should still be ordered (weakly) by original order
3236        // after reordering by original p-value
3237    }
3238
3239    #[test]
3240    fn bh_all_significant() {
3241        let ps = [0.001, 0.002, 0.003];
3242        let adj = benjamini_hochberg(&ps).expect("should compute");
3243        for &a in &adj {
3244            assert!(a < 0.05);
3245        }
3246    }
3247
3248    #[test]
3249    fn correction_edge_cases() {
3250        assert!(bonferroni_correction(&[]).is_none());
3251        assert!(benjamini_hochberg(&[]).is_none());
3252        assert!(bonferroni_correction(&[f64::NAN]).is_none());
3253    }
3254
3255    // -----------------------------------------------------------------------
3256    // Bartlett test
3257    // -----------------------------------------------------------------------
3258
3259    #[test]
3260    fn bartlett_equal_variances() {
3261        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0];
3262        let g2 = [12.0, 13.0, 14.0, 15.0, 16.0];
3263        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3264        assert!(
3265            r.p_value > 0.9,
3266            "equal variance → p high, got {}",
3267            r.p_value
3268        );
3269    }
3270
3271    #[test]
3272    fn bartlett_unequal_variances() {
3273        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // var ≈ 2.5
3274        let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // var ≈ 250
3275        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3276        assert!(
3277            r.p_value < 0.01,
3278            "very different variances → p < 0.01, got {}",
3279            r.p_value
3280        );
3281        assert!((r.df - 1.0).abs() < 1e-10); // k-1 = 1
3282    }
3283
3284    #[test]
3285    fn bartlett_three_groups() {
3286        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3287        let g2 = [1.5, 2.5, 3.5, 4.5, 5.5];
3288        let g3 = [10.0, 30.0, 50.0, 70.0, 90.0]; // much higher variance
3289        let r = bartlett_test(&[&g1, &g2, &g3]).expect("should compute");
3290        assert!(r.p_value < 0.05, "one group with high variance");
3291        assert!((r.df - 2.0).abs() < 1e-10); // k-1 = 2
3292    }
3293
3294    #[test]
3295    fn bartlett_edge_cases() {
3296        let g1 = [1.0, 2.0, 3.0];
3297        assert!(bartlett_test(&[&g1]).is_none()); // < 2 groups
3298
3299        let g2 = [5.0, 5.0, 5.0]; // zero variance
3300        assert!(bartlett_test(&[&g1, &g2]).is_none());
3301
3302        let g3 = [1.0]; // group too small
3303        assert!(bartlett_test(&[&g1, &g3]).is_none());
3304    }
3305
3306    // -----------------------------------------------------------------------
3307    // Fisher exact test
3308    // -----------------------------------------------------------------------
3309
3310    #[test]
3311    fn fisher_tea_tasting() {
3312        // Classic Fisher tea-tasting: [[3,1],[1,3]]
3313        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3314        // Known two-tailed p ≈ 0.4857
3315        assert!(
3316            (r.p_value - 0.4857).abs() < 0.01,
3317            "p ≈ 0.4857, got {}",
3318            r.p_value
3319        );
3320    }
3321
3322    #[test]
3323    fn fisher_significant() {
3324        // Strong association: [[10, 0], [0, 10]]
3325        let r = fisher_exact_test(10, 0, 0, 10).expect("should compute");
3326        assert!(r.p_value < 0.001, "perfect association → p very small");
3327    }
3328
3329    #[test]
3330    fn fisher_no_association() {
3331        // Proportional table: [[5, 5], [5, 5]]
3332        let r = fisher_exact_test(5, 5, 5, 5).expect("should compute");
3333        assert!(
3334            r.p_value > 0.9,
3335            "no association → p ≈ 1.0, got {}",
3336            r.p_value
3337        );
3338    }
3339
3340    #[test]
3341    fn fisher_small_table() {
3342        // [[1, 0], [0, 1]]
3343        let r = fisher_exact_test(1, 0, 0, 1).expect("should compute");
3344        assert!(r.p_value > 0.0 && r.p_value <= 1.0);
3345    }
3346
3347    #[test]
3348    fn fisher_asymmetric() {
3349        // [[8, 2], [1, 5]]
3350        let r = fisher_exact_test(8, 2, 1, 5).expect("should compute");
3351        assert!(r.p_value < 0.05, "significant association");
3352    }
3353
3354    #[test]
3355    fn fisher_edge_cases() {
3356        // Zero marginals → None
3357        assert!(fisher_exact_test(0, 0, 1, 2).is_none()); // row1 = 0
3358        assert!(fisher_exact_test(1, 2, 0, 0).is_none()); // row2 = 0
3359        assert!(fisher_exact_test(0, 1, 0, 2).is_none()); // col1 = 0
3360    }
3361
3362    #[test]
3363    fn fisher_odds_ratio() {
3364        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3365        // OR = (3*3)/(1*1) = 9
3366        assert!(
3367            (r.statistic - 9.0).abs() < 1e-10,
3368            "OR = 9, got {}",
3369            r.statistic
3370        );
3371    }
3372
3373    // -----------------------------------------------------------------------
3374    // Mann-Kendall trend test
3375    // -----------------------------------------------------------------------
3376
3377    #[test]
3378    fn mk_increasing_trend() {
3379        let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
3380        let r = mann_kendall_test(&data).expect("should compute");
3381        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3382        assert!(r.kendall_tau > 0.8, "tau = {}", r.kendall_tau);
3383        assert!(r.sen_slope > 0.0, "slope = {}", r.sen_slope);
3384        assert!(r.s_statistic > 0);
3385    }
3386
3387    #[test]
3388    fn mk_decreasing_trend() {
3389        let data = [10.0, 9.2, 8.5, 7.1, 6.3, 5.0, 4.2, 3.1, 2.0, 1.1];
3390        let r = mann_kendall_test(&data).expect("should compute");
3391        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3392        assert!(r.kendall_tau < -0.8, "tau = {}", r.kendall_tau);
3393        assert!(r.sen_slope < 0.0, "slope = {}", r.sen_slope);
3394        assert!(r.s_statistic < 0);
3395    }
3396
3397    #[test]
3398    fn mk_no_trend() {
3399        // Random-looking data with no clear trend
3400        let data = [5.0, 3.0, 7.0, 2.0, 8.0, 4.0, 6.0, 1.0, 9.0, 5.0];
3401        let r = mann_kendall_test(&data).expect("should compute");
3402        // Should not detect significant trend
3403        assert!(
3404            r.p_value > 0.05,
3405            "p = {} (should be > 0.05 for no trend)",
3406            r.p_value
3407        );
3408    }
3409
3410    #[test]
3411    fn mk_perfect_monotone() {
3412        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
3413        let r = mann_kendall_test(&data).expect("should compute");
3414        // Perfect monotone: S = n(n-1)/2 = 45, tau = 1.0
3415        assert_eq!(r.s_statistic, 45);
3416        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3417        assert!((r.sen_slope - 1.0).abs() < 1e-10);
3418    }
3419
3420    #[test]
3421    fn mk_with_ties() {
3422        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0];
3423        let r = mann_kendall_test(&data).expect("should compute");
3424        assert!(r.s_statistic > 0);
3425        // Tie correction should reduce variance
3426        let n = data.len() as f64;
3427        let base_var = n * (n - 1.0) * (2.0 * n + 5.0) / 18.0;
3428        assert!(r.variance < base_var, "ties should reduce variance");
3429    }
3430
3431    #[test]
3432    fn mk_edge_cases() {
3433        // Too few data points
3434        assert!(mann_kendall_test(&[1.0, 2.0, 3.0]).is_none());
3435        // NaN
3436        assert!(mann_kendall_test(&[1.0, f64::NAN, 3.0, 4.0]).is_none());
3437        // All identical (zero variance)
3438        assert!(mann_kendall_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3439    }
3440
3441    #[test]
3442    fn mk_minimum_n() {
3443        // n = 4 should work
3444        let data = [1.0, 2.0, 3.0, 4.0];
3445        let r = mann_kendall_test(&data).expect("n=4 should work");
3446        assert_eq!(r.s_statistic, 6); // C(4,2) = 6 pairs, all positive
3447        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3448    }
3449
3450    #[test]
3451    fn mk_sen_slope_robust_to_outlier() {
3452        // Mostly linear (slope ≈ 1) with one outlier
3453        let data = [1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3454        let r = mann_kendall_test(&data).expect("should compute");
3455        // Sen's slope should be close to 1.0 despite the outlier at index 3
3456        assert!(
3457            (r.sen_slope - 1.0).abs() < 0.5,
3458            "sen slope = {}, expected ≈ 1.0",
3459            r.sen_slope
3460        );
3461    }
3462
3463    // -----------------------------------------------------------------------
3464    // ADF test
3465    // -----------------------------------------------------------------------
3466
3467    #[test]
3468    fn adf_stationary_mean_reverting() {
3469        // Strongly mean-reverting AR(1) process: y_t = 0.3*y_{t-1} + noise
3470        // This is clearly stationary (|ρ| < 1)
3471        let data = [
3472            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,
3473            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,
3474            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,
3475        ];
3476        let r = adf_test(&data, AdfModel::Constant, AdfLags::default()).expect("should compute");
3477        // Should reject H₀ (series is stationary)
3478        assert!(
3479            r.rejected[2],
3480            "should reject at 10%: stat={}, cv={}",
3481            r.statistic, r.critical_values[2]
3482        );
3483    }
3484
3485    #[test]
3486    fn adf_nonstationary_random_walk() {
3487        // Cumulative sum simulates random walk (non-stationary)
3488        let increments = [
3489            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,
3490            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,
3491            -0.13, 0.15, -0.1,
3492        ];
3493        let mut walk = Vec::with_capacity(increments.len());
3494        let mut cum = 0.0;
3495        for &inc in &increments {
3496            cum += inc;
3497            walk.push(cum);
3498        }
3499        let r = adf_test(&walk, AdfModel::Constant, AdfLags::default()).expect("should compute");
3500        // Random walk should NOT reject at 1%
3501        assert!(
3502            !r.rejected[0],
3503            "should NOT reject at 1%: stat={}, cv={}",
3504            r.statistic, r.critical_values[0]
3505        );
3506    }
3507
3508    #[test]
3509    fn adf_with_fixed_lags() {
3510        // A pure sinusoid plus a line obeys an exact second-order recurrence, so
3511        // two lags fit its differences exactly (an undefined t-ratio); the noise
3512        // makes it a series the test can be run on.
3513        let mut rng = mulberry32(0xadf2);
3514        let data: Vec<f64> = (0..50)
3515            .map(|i| (i as f64 * 0.5).sin() + 0.02 * i as f64 + 0.1 * (rng() - 0.5))
3516            .collect();
3517        let r = adf_test(&data, AdfModel::Constant, AdfLags::Fixed(2)).expect("should compute");
3518        assert_eq!(r.n_lags, 2);
3519        assert!(r.statistic.is_finite());
3520    }
3521
3522    #[test]
3523    fn adf_constant_trend_model() {
3524        // Linear trend is unit-root-like under "constant" model
3525        // but with "constant+trend" model, it should be recognized
3526        let data: Vec<f64> = (0..30)
3527            .map(|i| i as f64 + (i as f64 * 0.3).sin() * 0.5)
3528            .collect();
3529        let r =
3530            adf_test(&data, AdfModel::ConstantTrend, AdfLags::default()).expect("should compute");
3531        assert!(r.statistic.is_finite());
3532        assert_eq!(r.critical_values.len(), 3);
3533    }
3534
3535    #[test]
3536    fn adf_edge_cases() {
3537        // Too few data points
3538        assert!(adf_test(&[1.0; 9], AdfModel::Constant, AdfLags::default()).is_none());
3539        // NaN
3540        let mut data = vec![0.0; 20];
3541        data[5] = f64::NAN;
3542        assert!(adf_test(&data, AdfModel::Constant, AdfLags::default()).is_none());
3543    }
3544
3545    /// Differences that repeat with period 10 are fitted exactly by 9 lags. The
3546    /// residual variance is rounding noise, and the t-ratio was -1.9e8 -- a
3547    /// "rejection" of the unit root at every level for what is a random walk
3548    /// with a periodic drift. The exact fit is undefined, and the lag search
3549    /// must not choose it.
3550    #[test]
3551    fn adf_exact_fit_is_undefined_and_not_chosen() {
3552        let shocks = [0.5, -0.8, 0.3, -0.6, 0.9, -0.4, 0.7, -0.2, 0.1, -0.5];
3553        let mut walk = vec![0.0_f64; 60];
3554        for i in 1..walk.len() {
3555            walk[i] = walk[i - 1] + shocks[(i * 7) % 10];
3556        }
3557        assert!(adf_test(&walk, AdfModel::ConstantTrend, AdfLags::Fixed(9)).is_none());
3558        let r = adf_test(&walk, AdfModel::ConstantTrend, AdfLags::default())
3559            .expect("a lag below 9 fits");
3560        assert!(r.n_lags < 9, "chose the exact fit: {} lags", r.n_lags);
3561        assert!(r.statistic.abs() < 1e3, "t = {}", r.statistic);
3562    }
3563
3564    /// statsmodels 0.15 `adfuller` on the same series: the AIC search fits
3565    /// every candidate on one sample and refits the chosen count on all the
3566    /// observations it leaves (the statistic, the count and `nobs` agree).
3567    #[test]
3568    fn adf_matches_statsmodels() {
3569        let mut x = vec![0.0_f64; 120];
3570        for t in 1..x.len() {
3571            let e = ((t * 7919) % 101) as f64 / 50.0 - 1.0;
3572            x[t] = 0.7 * x[t - 1] + e;
3573        }
3574        for (model, lags, stat, n_lags, n_obs) in [
3575            (
3576                AdfModel::Constant,
3577                AdfLags::Aic { max: Some(8) },
3578                -3.436_052_894_8,
3579                5,
3580                114,
3581            ),
3582            (
3583                AdfModel::Constant,
3584                AdfLags::Aic { max: None },
3585                -3.436_052_894_8,
3586                5,
3587                114,
3588            ),
3589            (
3590                AdfModel::ConstantTrend,
3591                AdfLags::Aic { max: Some(6) },
3592                -3.505_247_069_0,
3593                5,
3594                114,
3595            ),
3596            (
3597                AdfModel::None,
3598                AdfLags::Aic { max: Some(5) },
3599                -3.459_240_021_9,
3600                5,
3601                114,
3602            ),
3603            (
3604                AdfModel::Constant,
3605                AdfLags::Fixed(3),
3606                -4.652_930_484_4,
3607                3,
3608                116,
3609            ),
3610        ] {
3611            let r = adf_test(&x, model, lags).expect("fits");
3612            assert_eq!((r.n_lags, r.n_obs), (n_lags, n_obs), "{model:?} {lags:?}");
3613            assert!(
3614                (r.statistic - stat).abs() < 1e-8,
3615                "{model:?} {lags:?}: {}",
3616                r.statistic
3617            );
3618        }
3619    }
3620
3621    #[test]
3622    fn adf_critical_values_constant() {
3623        // Verify critical values are reasonable for n=100
3624        let cv = adf_critical_values(AdfModel::Constant, 100);
3625        // At n=100: approximately -3.51, -2.89, -2.58
3626        assert!(cv[0] < -3.4 && cv[0] > -3.6, "1% cv = {}", cv[0]);
3627        assert!(cv[1] < -2.8 && cv[1] > -3.0, "5% cv = {}", cv[1]);
3628        assert!(cv[2] < -2.5 && cv[2] > -2.7, "10% cv = {}", cv[2]);
3629    }
3630
3631    #[test]
3632    fn adf_critical_values_ordering() {
3633        let cv = adf_critical_values(AdfModel::Constant, 50);
3634        // 1% < 5% < 10% (more negative for stricter levels)
3635        assert!(cv[0] < cv[1], "1% ({}) should be < 5% ({})", cv[0], cv[1]);
3636        assert!(cv[1] < cv[2], "5% ({}) should be < 10% ({})", cv[1], cv[2]);
3637    }
3638}
3639
3640#[cfg(test)]
3641mod proptests {
3642    use super::*;
3643    use proptest::prelude::*;
3644
3645    proptest! {
3646        #[test]
3647        fn one_sample_p_bounded(
3648            data in proptest::collection::vec(-1e3_f64..1e3, 3..=30),
3649            mu0 in -1e3_f64..1e3
3650        ) {
3651            if let Some(r) = one_sample_t_test(&data, mu0) {
3652                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3653            }
3654        }
3655
3656        #[test]
3657        fn two_sample_p_bounded(
3658            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3659            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3660        ) {
3661            if let Some(r) = two_sample_t_test(&a, &b) {
3662                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3663            }
3664        }
3665
3666        #[test]
3667        fn anova_p_bounded(
3668            g1 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3669            g2 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3670            g3 in proptest::collection::vec(-1e3_f64..1e3, 3..=15),
3671        ) {
3672            if let Some(r) = one_way_anova(&[&g1, &g2, &g3]) {
3673                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3674                prop_assert!(r.f_statistic >= 0.0, "F = {}", r.f_statistic);
3675            }
3676        }
3677
3678        #[test]
3679        fn bonferroni_monotone(
3680            ps in proptest::collection::vec(0.001_f64..1.0, 2..=10)
3681        ) {
3682            let adj = bonferroni_correction(&ps).expect("should compute");
3683            for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3684                prop_assert!(adjusted >= orig - 1e-15,
3685                    "adj[{i}] = {adjusted} < orig = {orig}");
3686            }
3687        }
3688
3689        #[test]
3690        fn shapiro_wilk_p_bounded(
3691            data in proptest::collection::vec(-1e3_f64..1e3, 3..=50)
3692        ) {
3693            if let Some(r) = shapiro_wilk_test(&data) {
3694                prop_assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
3695                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3696            }
3697        }
3698
3699        #[test]
3700        fn anderson_darling_p_bounded(
3701            data in proptest::collection::vec(-1e3_f64..1e3, 8..=100)
3702        ) {
3703            if let Some(r) = anderson_darling_test(&data) {
3704                prop_assert!(r.statistic >= 0.0, "A2 = {}", r.statistic);
3705                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3706            }
3707        }
3708
3709        #[test]
3710        fn mann_whitney_p_bounded(
3711            a in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3712            b in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3713        ) {
3714            if let Some(r) = mann_whitney_u_test(&a, &b) {
3715                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3716                prop_assert!(r.statistic >= 0.0, "U = {}", r.statistic);
3717            }
3718        }
3719
3720        #[test]
3721        fn wilcoxon_p_bounded(
3722            diffs in proptest::collection::vec(-1e3_f64..1e3, 3..=20),
3723        ) {
3724            let zeros: Vec<f64> = vec![0.0; diffs.len()];
3725            if let Some(r) = wilcoxon_signed_rank_test(&diffs, &zeros) {
3726                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3727            }
3728        }
3729
3730        #[test]
3731        fn bartlett_p_bounded(
3732            g1 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
3733            g2 in proptest::collection::vec(0.1_f64..100.0, 3..=15),
3734        ) {
3735            if let Some(r) = bartlett_test(&[&g1, &g2]) {
3736                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3737                prop_assert!(r.statistic >= 0.0, "T = {}", r.statistic);
3738            }
3739        }
3740
3741        #[test]
3742        fn fisher_p_bounded(
3743            a in 0_u64..20,
3744            b in 0_u64..20,
3745            c in 0_u64..20,
3746            d in 0_u64..20,
3747        ) {
3748            if let Some(r) = fisher_exact_test(a, b, c, d) {
3749                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3750            }
3751        }
3752
3753        #[test]
3754        fn mann_kendall_p_bounded(
3755            data in proptest::collection::vec(-1e3_f64..1e3, 4..=30)
3756        ) {
3757            if let Some(r) = mann_kendall_test(&data) {
3758                prop_assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
3759                prop_assert!(r.kendall_tau >= -1.0 && r.kendall_tau <= 1.0,
3760                    "tau = {}", r.kendall_tau);
3761            }
3762        }
3763
3764        #[test]
3765        fn mann_kendall_monotone_increasing(n in 5_usize..=30) {
3766            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
3767            let r = mann_kendall_test(&data).expect("should compute");
3768            prop_assert!((r.kendall_tau - 1.0).abs() < 1e-10,
3769                "tau should be 1.0 for monotone, got {}", r.kendall_tau);
3770            prop_assert!(r.sen_slope > 0.0, "slope should be positive");
3771        }
3772    }
3773}