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, all values identical, or non-finite values.
582///
583/// # References
584///
585/// - Anderson & Darling (1952). "Asymptotic theory of certain goodness of
586///   fit criteria based on stochastic processes". Annals of Mathematical
587///   Statistics, 23(2), 193–212.
588/// - Stephens (1986). "Tests based on EDF statistics". In D'Agostino &
589///   Stephens (Eds.), Goodness-of-Fit Techniques. Marcel Dekker.
590///
591/// # Examples
592///
593/// ```
594/// use u_analytics::testing::anderson_darling_test;
595///
596/// let data = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
597/// let r = anderson_darling_test(&data).unwrap();
598/// assert!(r.p_value > 0.05); // cannot reject normality
599/// ```
600pub fn anderson_darling_test(data: &[f64]) -> Option<AndersonDarlingResult> {
601    let n = data.len();
602    if n < 8 {
603        return None;
604    }
605    if data.iter().any(|v| !v.is_finite()) {
606        return None;
607    }
608
609    let mean = stats::mean(data)?;
610    let sd = stats::std_dev(data)?;
611
612    if sd < 1e-300 {
613        return None; // zero variance
614    }
615
616    // Sort data
617    let mut x: Vec<f64> = data.to_vec();
618    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
619
620    let nf = n as f64;
621
622    // Compute A² statistic
623    let mut s = 0.0;
624    for i in 0..n {
625        let z = (x[i] - mean) / sd;
626        let z_rev = (x[n - 1 - i] - mean) / sd;
627        // Both tails evaluated directly, so an outlier contributes its true
628        // ln-probability instead of a subtraction that rounds to ln(0); the
629        // floor only guards arguments beyond the f64 range of the tail (|z| > 38).
630        let ln_phi = special::standard_normal_cdf(z).max(f64::MIN_POSITIVE).ln();
631        let ln_sf_rev = special::standard_normal_sf(z_rev)
632            .max(f64::MIN_POSITIVE)
633            .ln();
634
635        let coeff = (2 * (i + 1) - 1) as f64;
636        s += coeff * (ln_phi + ln_sf_rev);
637    }
638
639    let a2 = -nf - s / nf;
640
641    // Stephens (1986) correction for sample size
642    let a2_star = a2 * (1.0 + 0.75 / nf + 2.25 / (nf * nf));
643
644    // P-value from piecewise approximation (D'Agostino & Stephens 1986), with the
645    // large-A*² branch clamped to its valid range (see `ad_upper_tail_pvalue`).
646    let p = ad_upper_tail_pvalue(a2_star);
647
648    Some(AndersonDarlingResult {
649        statistic: a2,
650        statistic_star: a2_star,
651        p_value: p,
652    })
653}
654
655/// Result of the Anderson-Darling normality test (Stephens 1974 variant).
656///
657/// This variant uses the `statistic_modified` field name and accepts n ≥ 3,
658/// making it suitable for small-sample normality checking before control
659/// charts and Box-Cox capability analysis.
660#[derive(Debug, Clone, Copy)]
661pub struct AdNormalityResult {
662    /// The A² test statistic (raw).
663    pub statistic: f64,
664    /// The modified statistic A²* = A² · (1 + 0.75/n + 2.25/n²).
665    pub statistic_modified: f64,
666    /// Approximate p-value from Stephens (1974) piecewise approximation.
667    pub p_value: f64,
668}
669
670/// Anderson-Darling normality test (Stephens 1974): H₀: data is normally distributed.
671///
672/// Suitable for small samples (n ≥ 3). Provides the A²* modified statistic and
673/// approximate p-values using Stephens (1974) piecewise exponential formulae.
674/// Prefer this function when you need a lightweight normality pre-check before
675/// applying control charts or Box-Cox capability analysis.
676///
677/// # Algorithm
678///
679/// 1. Sort ascending, compute mean and std_dev.
680/// 2. Standardize: zᵢ = (x_(i) − mean) / std.
681/// 3. A² = −n − (1/n) · Σᵢ₌₀ⁿ⁻¹ (2i+1) · [ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))].
682/// 4. A²* = A² · (1 + 0.75/n + 2.25/n²).
683/// 5. p-value from Stephens (1974) piecewise approximation.
684///
685/// # Returns
686///
687/// `None` if n < 3, any non-finite value, or std_dev < 1e-15 (degenerate data).
688///
689/// # References
690///
691/// - Stephens, M. A. (1974). "EDF statistics for goodness of fit and some
692///   comparisons". *Journal of the American Statistical Association*, 69(347), 730–737.
693///
694/// # Examples
695///
696/// ```
697/// use u_analytics::testing::anderson_darling_normality;
698///
699/// let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
700/// let r = anderson_darling_normality(&data).unwrap();
701/// assert!(r.p_value > 0.05); // cannot reject normality
702/// ```
703pub fn anderson_darling_normality(data: &[f64]) -> Option<AdNormalityResult> {
704    let n = data.len();
705    if n < 3 {
706        return None;
707    }
708    if data.iter().any(|v| !v.is_finite()) {
709        return None;
710    }
711
712    let mean = stats::mean(data)?;
713    let sd = stats::std_dev(data)?;
714
715    if sd < 1e-15 {
716        return None;
717    }
718
719    let mut x: Vec<f64> = data.to_vec();
720    x.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
721
722    let nf = n as f64;
723
724    let mut s = 0.0;
725    for i in 0..n {
726        let z_i = (x[i] - mean) / sd;
727        let z_rev = (x[n - 1 - i] - mean) / sd;
728
729        let ln_phi_i = special::standard_normal_cdf(z_i)
730            .max(f64::MIN_POSITIVE)
731            .ln();
732        let ln_sf_rev = special::standard_normal_sf(z_rev)
733            .max(f64::MIN_POSITIVE)
734            .ln();
735
736        let coeff = (2 * i + 1) as f64;
737        s += coeff * (ln_phi_i + ln_sf_rev);
738    }
739
740    let a2 = -nf - s / nf;
741    let a2_star = a2 * (1.0 + 0.75 / nf + 2.25 / (nf * nf));
742
743    // Piecewise p-value approximation, sharing the range-clamped upper-tail branch
744    // (see `ad_upper_tail_pvalue`) so large A*² plateaus near 0 instead of
745    // overflowing to exactly 1.
746    let p = ad_upper_tail_pvalue(a2_star);
747
748    Some(AdNormalityResult {
749        statistic: a2,
750        statistic_modified: a2_star,
751        p_value: p,
752    })
753}
754
755/// Result of the Shapiro-Wilk normality test.
756#[derive(Debug, Clone, Copy)]
757pub struct ShapiroWilkResult {
758    /// The W statistic (0 < W ≤ 1). Values close to 1 suggest normality.
759    pub w: f64,
760    /// The p-value. Small values reject the null hypothesis of normality.
761    pub p_value: f64,
762}
763
764/// Shapiro-Wilk normality test: H₀: data is normally distributed.
765///
766/// The most powerful general normality test for small to moderate samples.
767///
768/// # Algorithm
769///
770/// Uses the Royston (1992, 1995) algorithm (AS R94):
771/// 1. Compute coefficients from normal order statistics (Blom approximation)
772/// 2. Calculate W = (Σ aᵢ x₍ᵢ₎)² / Σ (xᵢ - x̄)²
773/// 3. Transform W to z-score via log-normal approximation
774/// 4. Compute p-value from standard normal distribution
775///
776/// # Supported range
777///
778/// n = 3..5000. Returns `None` outside this range.
779///
780/// # Returns
781///
782/// `None` if n < 3, n > 5000, all values identical, or non-finite values.
783///
784/// # References
785///
786/// - Shapiro & Wilk (1965). "An analysis of variance test for normality".
787///   Biometrika, 52(3–4), 591–611.
788/// - Royston (1992). "Approximating the Shapiro-Wilk W-test for
789///   non-normality". Statistics and Computing, 2, 117–119.
790/// - Royston (1995). "Remark AS R94: A remark on Algorithm AS 181".
791///   Applied Statistics, 44(4), 547–551.
792///
793/// # Examples
794///
795/// ```
796/// use u_analytics::testing::shapiro_wilk_test;
797///
798/// let data = [-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5];
799/// let r = shapiro_wilk_test(&data).unwrap();
800/// assert!(r.w > 0.9);
801/// assert!(r.p_value > 0.05); // cannot reject normality
802/// ```
803pub fn shapiro_wilk_test(data: &[f64]) -> Option<ShapiroWilkResult> {
804    let n = data.len();
805    if !(3..=5000).contains(&n) {
806        return None;
807    }
808    if data.iter().any(|v| !v.is_finite()) {
809        return None;
810    }
811
812    // Sort data
813    let mut x: Vec<f64> = data.to_vec();
814    x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
815
816    if x[n - 1] - x[0] < 1e-300 {
817        return None; // all values identical
818    }
819
820    let nn2 = n / 2;
821
822    // Special case n = 3
823    if n == 3 {
824        return shapiro_wilk_n3(&x);
825    }
826
827    // Compute coefficients via Royston algorithm
828    let a = sw_coefficients(n, nn2)?;
829
830    // Compute W statistic
831    let w = sw_statistic(&x, &a, n, nn2);
832
833    if !(0.0..=1.0 + 1e-10).contains(&w) {
834        return None;
835    }
836    let w = w.min(1.0);
837
838    // Compute p-value
839    let p_value = sw_p_value(w, n);
840
841    Some(ShapiroWilkResult {
842        w,
843        p_value: p_value.clamp(0.0, 1.0),
844    })
845}
846
847// Shapiro-Wilk: n=3 exact formula
848fn shapiro_wilk_n3(x: &[f64]) -> Option<ShapiroWilkResult> {
849    // For n=3: a = [sqrt(1/2), 0, -sqrt(1/2)]
850    let a1 = std::f64::consts::FRAC_1_SQRT_2; // 0.7071...
851    let mean = (x[0] + x[1] + x[2]) / 3.0;
852    let ss = x.iter().map(|&v| (v - mean).powi(2)).sum::<f64>();
853    if ss < 1e-300 {
854        return None;
855    }
856
857    let numerator = a1 * (x[2] - x[0]);
858    let w = (numerator * numerator) / ss;
859    let w = w.clamp(0.75, 1.0);
860
861    // Exact p-value for n=3: p = 1 - (6/pi) * arccos(sqrt(w))
862    let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
863    let p = p.clamp(0.0, 1.0);
864
865    Some(ShapiroWilkResult { w, p_value: p })
866}
867
868// Royston polynomial coefficients (AS R94)
869const SW_C1: [f64; 6] = [0.0, 0.221157, -0.147981, -2.07119, 4.434685, -2.706056];
870const SW_C2: [f64; 6] = [0.0, 0.042981, -0.293762, -1.752461, 5.682633, -3.582633];
871const SW_C3: [f64; 4] = [0.544, -0.39978, 0.025054, -6.714e-4];
872const SW_C4: [f64; 4] = [1.3822, -0.77857, 0.062767, -0.0020322];
873const SW_C5: [f64; 4] = [-1.5861, -0.31082, -0.083751, 0.0038915];
874const SW_C6: [f64; 3] = [-0.4803, -0.082676, 0.0030302];
875const SW_G: [f64; 2] = [-2.273, 0.459];
876
877// Evaluate polynomial: c[0] + c[1]*x + c[2]*x^2 + ... (Horner's method)
878fn sw_poly(c: &[f64], x: f64) -> f64 {
879    let mut result = c[c.len() - 1];
880    for i in (0..c.len() - 1).rev() {
881        result = result * x + c[i];
882    }
883    result
884}
885
886// Compute Shapiro-Wilk coefficients using Royston's algorithm
887fn sw_coefficients(n: usize, nn2: usize) -> Option<Vec<f64>> {
888    let mut a = vec![0.0; nn2];
889
890    // Blom's approximation for expected normal order statistics
891    let mut m = vec![0.0; nn2];
892    let mut summ2 = 0.0;
893    for (i, mi) in m.iter_mut().enumerate() {
894        // m[i] corresponds to the (i+1)-th order statistic expectation
895        let p = (i as f64 + 1.0 - 0.375) / (n as f64 + 0.25);
896        *mi = special::inverse_normal_cdf(p);
897        summ2 += *mi * *mi;
898    }
899    summ2 *= 2.0;
900    let ssumm2 = summ2.sqrt();
901    let rsn = 1.0 / (n as f64).sqrt();
902
903    // First coefficient: polynomial correction
904    let a1 = sw_poly(&SW_C1, rsn) - m[0] / ssumm2;
905
906    if n <= 5 {
907        // For n=4,5: only a[0] is corrected
908        let fac_sq = summ2 - 2.0 * m[0] * m[0];
909        let one_minus = 1.0 - 2.0 * a1 * a1;
910        if fac_sq <= 0.0 || one_minus <= 0.0 {
911            return None;
912        }
913        let fac = (fac_sq / one_minus).sqrt();
914        a[0] = a1;
915        for i in 1..nn2 {
916            a[i] = -m[i] / fac;
917        }
918    } else {
919        // For n>5: a[0] and a[1] are corrected
920        let a2 = -m[1] / ssumm2 + sw_poly(&SW_C2, rsn);
921        let fac_sq = summ2 - 2.0 * m[0] * m[0] - 2.0 * m[1] * m[1];
922        let one_minus = 1.0 - 2.0 * a1 * a1 - 2.0 * a2 * a2;
923        if fac_sq <= 0.0 || one_minus <= 0.0 {
924            return None;
925        }
926        let fac = (fac_sq / one_minus).sqrt();
927        a[0] = a1;
928        a[1] = a2;
929        for i in 2..nn2 {
930            a[i] = -m[i] / fac;
931        }
932    }
933
934    Some(a)
935}
936
937// Compute W statistic from sorted data, coefficients, and range
938fn sw_statistic(x: &[f64], a: &[f64], n: usize, nn2: usize) -> f64 {
939    // Numerator: (sum a_i * (x_{n+1-i} - x_i))^2
940    let mut sa = 0.0;
941    for i in 0..nn2 {
942        sa += a[i] * (x[n - 1 - i] - x[i]);
943    }
944
945    // Denominator: sum of squares about the mean
946    let mean = x.iter().sum::<f64>() / n as f64;
947    let ss: f64 = x.iter().map(|&v| (v - mean).powi(2)).sum();
948
949    if ss < 1e-300 {
950        return 1.0; // degenerate
951    }
952
953    (sa * sa) / ss
954}
955
956// Compute p-value from W statistic using Royston's transformation
957fn sw_p_value(w: f64, n: usize) -> f64 {
958    let nf = n as f64;
959
960    if n == 3 {
961        // Should not reach here (handled separately), but just in case
962        let p = 1.0 - (6.0 / std::f64::consts::PI) * w.sqrt().acos();
963        return p.clamp(0.0, 1.0);
964    }
965
966    let w1 = 1.0 - w;
967    if w1 <= 0.0 {
968        return 1.0; // perfectly normal
969    }
970
971    let y = w1.ln();
972
973    if n <= 11 {
974        // Small sample: gamma + log transformation
975        let gamma = sw_poly(&SW_G, nf);
976        if y >= gamma {
977            return 0.0; // extremely non-normal
978        }
979        let y2 = -(gamma - y).ln();
980        let m = sw_poly(&SW_C3, nf);
981        let s = sw_poly(&SW_C4, nf).exp();
982        if s < 1e-300 {
983            return 0.0;
984        }
985        let z = (y2 - m) / s;
986        special::standard_normal_sf(z)
987    } else {
988        // Large sample: log-normal transformation
989        let xx = nf.ln();
990        let m = sw_poly(&SW_C5, xx);
991        let s = sw_poly(&SW_C6, xx).exp();
992        if s < 1e-300 {
993            return 0.0;
994        }
995        let z = (y - m) / s;
996        special::standard_normal_sf(z)
997    }
998}
999
1000// ---------------------------------------------------------------------------
1001// Non-parametric tests
1002// ---------------------------------------------------------------------------
1003
1004/// Mann-Whitney U test: H₀: the two populations have the same distribution.
1005///
1006/// Non-parametric alternative to the two-sample t-test. Does not assume
1007/// normality.
1008///
1009/// # Algorithm
1010///
1011/// 1. Combine samples, rank all observations (average ranks for ties)
1012/// 2. U₁ = R₁ - n₁(n₁+1)/2 where R₁ = sum of ranks in sample 1
1013/// 3. p-value (two-sided):
1014///    - **no ties and both samples ≤ 50**: the exact null distribution of U,
1015///      by the recursion P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u)
1016///    - otherwise: normal approximation with continuity correction,
1017///      z = (|U₁ − μ| − ½) / σ, μ = n₁n₂/2, σ² with the tie correction
1018///
1019/// The normal approximation alone understates p for small samples: with
1020/// three observations each and complete separation it gives 0.0495 where
1021/// the smallest achievable p is 0.100.
1022///
1023/// # Returns
1024///
1025/// `None` if either sample has fewer than 2 observations or non-finite values.
1026///
1027/// # References
1028///
1029/// - Mann & Whitney (1947). "On a test of whether one of two random
1030///   variables is stochastically larger than the other". Annals of
1031///   Mathematical Statistics, 18(1), 50–60.
1032///
1033/// # Examples
1034///
1035/// ```
1036/// use u_analytics::testing::mann_whitney_u_test;
1037///
1038/// let a = [1.0, 2.0, 3.0, 4.0, 5.0];
1039/// let b = [6.0, 7.0, 8.0, 9.0, 10.0];
1040/// let r = mann_whitney_u_test(&a, &b).unwrap();
1041/// assert!(r.p_value < 0.05);
1042/// ```
1043pub fn mann_whitney_u_test(a: &[f64], b: &[f64]) -> Option<TestResult> {
1044    let n1 = a.len();
1045    let n2 = b.len();
1046    if n1 < 2 || n2 < 2 {
1047        return None;
1048    }
1049    if a.iter().any(|v| !v.is_finite()) || b.iter().any(|v| !v.is_finite()) {
1050        return None;
1051    }
1052
1053    let n = n1 + n2;
1054    let n1f = n1 as f64;
1055    let n2f = n2 as f64;
1056    let nf = n as f64;
1057
1058    // Combine and rank
1059    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(n);
1060    for &v in a {
1061        combined.push((v, 0)); // group 0 = sample a
1062    }
1063    for &v in b {
1064        combined.push((v, 1)); // group 1 = sample b
1065    }
1066    combined.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
1067
1068    // Assign average ranks and track ties
1069    let ranks = average_ranks(&combined);
1070
1071    // Sum of ranks for sample a
1072    let r1: f64 = combined
1073        .iter()
1074        .zip(ranks.iter())
1075        .filter(|((_, g), _)| *g == 0)
1076        .map(|(_, &r)| r)
1077        .sum();
1078
1079    // U statistic
1080    let u1 = r1 - n1f * (n1f + 1.0) / 2.0;
1081
1082    // Tie correction
1083    let tie_correction = compute_tie_correction(&combined);
1084
1085    // Normal approximation
1086    let mu = n1f * n2f / 2.0;
1087    let sigma_sq = n1f * n2f / 12.0 * (nf + 1.0 - tie_correction / (nf * (nf - 1.0)));
1088
1089    if sigma_sq <= 0.0 {
1090        return None;
1091    }
1092
1093    let p_value = if tie_correction == 0.0 && n1 <= EXACT_MAX_N && n2 <= EXACT_MAX_N {
1094        // Without ties U is an integer in 0..=n1·n2 and its null
1095        // distribution is symmetric about n1·n2/2.
1096        let u = u1.round() as usize;
1097        let tail = u.min(n1 * n2 - u);
1098        (2.0 * mann_whitney_exact_cdf(n1, n2, tail)).min(1.0)
1099    } else {
1100        continuity_corrected_p(u1, mu, sigma_sq)
1101    };
1102
1103    Some(TestResult {
1104        statistic: u1,
1105        df: 0.0, // not applicable for non-parametric
1106        p_value,
1107    })
1108}
1109
1110/// Largest sample size for which the rank tests use their exact null
1111/// distribution (when there are no ties). Above it the normal approximation
1112/// with continuity correction is accurate to well under 0.001 in p.
1113const EXACT_MAX_N: usize = 50;
1114
1115/// Two-sided p of a rank statistic by the normal approximation with
1116/// continuity correction.
1117fn continuity_corrected_p(statistic: f64, mu: f64, sigma_sq: f64) -> f64 {
1118    let z = ((statistic - mu).abs() - 0.5).max(0.0) / sigma_sq.sqrt();
1119    (2.0 * special::standard_normal_sf(z)).min(1.0)
1120}
1121
1122/// P(U ≤ u) for the Mann-Whitney statistic with sample sizes `m`, `n` and
1123/// no ties.
1124///
1125/// Every arrangement of the two samples' ranks is equally likely under H₀,
1126/// and the last-ranked observation belongs to the first sample with
1127/// probability m/(m+n), in which case it adds n to U:
1128/// P(m, n, u) = m/(m+n)·P(m−1, n, u−n) + n/(m+n)·P(m, n−1, u).
1129/// O(m·n·(m·n)) time, O(n·m·n) memory -- a few megabytes at m = n = 50.
1130fn mann_whitney_exact_cdf(m: usize, n: usize, u: usize) -> f64 {
1131    let max_u = m * n;
1132    // row[j] = distribution of U for (i, j), indices 0..=i·j.
1133    let mut prev: Vec<Vec<f64>> = (0..=n).map(|_| vec![1.0]).collect(); // i = 0
1134    for i in 1..=m {
1135        let mut cur: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
1136        cur.push(vec![1.0]); // j = 0: U = 0
1137        for j in 1..=n {
1138            let mut dist = vec![0.0; i * j + 1];
1139            let wi = i as f64 / (i + j) as f64;
1140            let wj = j as f64 / (i + j) as f64;
1141            // Last observation from sample 1: U shifts by j.
1142            for (k, &pk) in prev[j].iter().enumerate() {
1143                dist[k + j] += wi * pk;
1144            }
1145            // Last observation from sample 2: U unchanged.
1146            for (k, &pk) in cur[j - 1].iter().enumerate() {
1147                dist[k] += wj * pk;
1148            }
1149            cur.push(dist);
1150        }
1151        prev = cur;
1152    }
1153    prev[n].iter().take(u.min(max_u) + 1).sum::<f64>().min(1.0)
1154}
1155
1156/// P(T⁺ ≤ t) for the Wilcoxon signed-rank statistic with `n` non-zero,
1157/// untied differences: each rank enters T⁺ with probability ½ independently.
1158fn wilcoxon_exact_cdf(n: usize, t: usize) -> f64 {
1159    let max_t = n * (n + 1) / 2;
1160    let mut dist = vec![0.0; max_t + 1];
1161    dist[0] = 1.0;
1162    let mut reach = 0;
1163    for rank in 1..=n {
1164        reach += rank;
1165        for s in (rank..=reach).rev() {
1166            dist[s] = 0.5 * dist[s] + 0.5 * dist[s - rank];
1167        }
1168        for d in dist.iter_mut().take(rank) {
1169            *d *= 0.5;
1170        }
1171    }
1172    dist.iter().take(t.min(max_t) + 1).sum::<f64>().min(1.0)
1173}
1174
1175/// Wilcoxon signed-rank test: H₀: median of differences = 0.
1176///
1177/// Non-parametric alternative to the paired t-test. Does not assume
1178/// normality of differences.
1179///
1180/// # Algorithm
1181///
1182/// 1. Compute differences dᵢ = xᵢ - yᵢ, discard zeros
1183/// 2. Rank |dᵢ| (average ranks for ties)
1184/// 3. T⁺ = sum of ranks where dᵢ > 0
1185/// 4. p-value (two-sided):
1186///    - **no tied |dᵢ| and n ≤ 50**: the exact null distribution of T⁺
1187///      (each rank enters T⁺ with probability ½)
1188///    - otherwise: normal approximation with continuity correction,
1189///      z = (|T⁺ − μ| − ½) / σ, μ = n(n+1)/4, σ² with the tie correction
1190///
1191/// The normal approximation alone understates p for small samples: five
1192/// differences all of one sign give 0.043 where the exact p is 0.0625.
1193///
1194/// # Returns
1195///
1196/// `None` if fewer than 2 non-zero differences, slices differ in length,
1197/// or non-finite values.
1198///
1199/// # References
1200///
1201/// - Wilcoxon (1945). "Individual comparisons by ranking methods".
1202///   Biometrics Bulletin, 1(6), 80–83.
1203///
1204/// # Examples
1205///
1206/// ```
1207/// use u_analytics::testing::wilcoxon_signed_rank_test;
1208///
1209/// let before = [5.0, 6.0, 7.0, 8.0, 9.0];
1210/// let after  = [6.0, 7.5, 8.0, 9.5, 11.0];
1211/// let r = wilcoxon_signed_rank_test(&after, &before).unwrap();
1212/// assert!(r.statistic > 0.0); // T+ sum of positive ranks
1213/// ```
1214pub fn wilcoxon_signed_rank_test(x: &[f64], y: &[f64]) -> Option<TestResult> {
1215    if x.len() != y.len() || x.len() < 2 {
1216        return None;
1217    }
1218    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
1219        return None;
1220    }
1221
1222    // Compute differences and discard zeros
1223    let diffs: Vec<f64> = x
1224        .iter()
1225        .zip(y.iter())
1226        .map(|(&a, &b)| a - b)
1227        .filter(|&d| d.abs() > 1e-300)
1228        .collect();
1229
1230    let nr = diffs.len();
1231    if nr < 2 {
1232        return None;
1233    }
1234
1235    let nf = nr as f64;
1236
1237    // Sort by absolute difference and rank
1238    let mut abs_diffs: Vec<(f64, usize)> = diffs
1239        .iter()
1240        .enumerate()
1241        .map(|(i, &d)| (d.abs(), i))
1242        .collect();
1243    abs_diffs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1244
1245    // Assign average ranks
1246    let ranks = average_ranks(&abs_diffs);
1247
1248    // T+ = sum of ranks where the original difference is positive
1249    let t_plus: f64 = abs_diffs
1250        .iter()
1251        .zip(ranks.iter())
1252        .filter(|((_, orig_idx), _)| diffs[*orig_idx] > 0.0)
1253        .map(|(_, &r)| r)
1254        .sum();
1255
1256    // Tie correction for variance
1257    let tie_correction_val = compute_tie_correction(&abs_diffs);
1258
1259    // Normal approximation
1260    let mu = nf * (nf + 1.0) / 4.0;
1261    let sigma_sq = nf * (nf + 1.0) * (2.0 * nf + 1.0) / 24.0 - tie_correction_val / 48.0;
1262
1263    if sigma_sq <= 0.0 {
1264        return None;
1265    }
1266
1267    let p_value = if tie_correction_val == 0.0 && nr <= EXACT_MAX_N {
1268        // Without ties T⁺ is an integer in 0..=n(n+1)/2, symmetric about
1269        // n(n+1)/4.
1270        let t = t_plus.round() as usize;
1271        let tail = t.min(nr * (nr + 1) / 2 - t);
1272        (2.0 * wilcoxon_exact_cdf(nr, tail)).min(1.0)
1273    } else {
1274        continuity_corrected_p(t_plus, mu, sigma_sq)
1275    };
1276
1277    Some(TestResult {
1278        statistic: t_plus,
1279        df: 0.0,
1280        p_value,
1281    })
1282}
1283
1284// Assign average ranks to sorted (value, group_or_index) pairs.
1285// Handles ties by assigning the average of the tied ranks.
1286fn average_ranks(sorted: &[(f64, usize)]) -> Vec<f64> {
1287    let n = sorted.len();
1288    let mut ranks = vec![0.0; n];
1289    let mut i = 0;
1290    while i < n {
1291        let mut j = i + 1;
1292        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1293            j += 1;
1294        }
1295        // Positions i..j are tied; average rank = (i+1 + j) / 2
1296        let avg_rank = (i + 1 + j) as f64 / 2.0;
1297        for rank in ranks.iter_mut().take(j).skip(i) {
1298            *rank = avg_rank;
1299        }
1300        i = j;
1301    }
1302    ranks
1303}
1304
1305// Compute tie correction factor: Σ tₖ(tₖ² - 1) for all tie groups
1306fn compute_tie_correction(sorted: &[(f64, usize)]) -> f64 {
1307    let n = sorted.len();
1308    let mut correction = 0.0;
1309    let mut i = 0;
1310    while i < n {
1311        let mut j = i + 1;
1312        while j < n && (sorted[j].0 - sorted[i].0).abs() < 1e-12 {
1313            j += 1;
1314        }
1315        let t = (j - i) as f64;
1316        if t > 1.0 {
1317            correction += t * (t * t - 1.0);
1318        }
1319        i = j;
1320    }
1321    correction
1322}
1323
1324/// Kruskal-Wallis test: H₀: all groups have the same distribution.
1325///
1326/// Non-parametric alternative to one-way ANOVA. Does not assume normality.
1327///
1328/// # Algorithm
1329///
1330/// 1. Combine all groups, rank observations (average ranks for ties)
1331/// 2. H = (12 / N(N+1)) Σ nᵢ (R̄ᵢ - R̄)² with tie correction
1332/// 3. H ~ χ²(k-1) under H₀
1333///
1334/// # Returns
1335///
1336/// `None` if fewer than 2 groups, any group has fewer than 2 observations,
1337/// or non-finite values.
1338///
1339/// # References
1340///
1341/// - Kruskal & Wallis (1952). "Use of ranks in one-criterion variance
1342///   analysis". JASA, 47(260), 583–621.
1343///
1344/// # Examples
1345///
1346/// ```
1347/// use u_analytics::testing::kruskal_wallis_test;
1348///
1349/// let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
1350/// let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
1351/// let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
1352/// let r = kruskal_wallis_test(&[&g1, &g2, &g3]).unwrap();
1353/// assert!(r.p_value < 0.01);
1354/// ```
1355pub fn kruskal_wallis_test(groups: &[&[f64]]) -> Option<TestResult> {
1356    let k = groups.len();
1357    if k < 2 {
1358        return None;
1359    }
1360    for g in groups {
1361        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1362            return None;
1363        }
1364    }
1365
1366    let total_n: usize = groups.iter().map(|g| g.len()).sum();
1367    let nf = total_n as f64;
1368
1369    // Combine all observations with group labels
1370    let mut combined: Vec<(f64, usize)> = Vec::with_capacity(total_n);
1371    for (gi, g) in groups.iter().enumerate() {
1372        for &v in *g {
1373            combined.push((v, gi));
1374        }
1375    }
1376    combined.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1377
1378    let ranks = average_ranks(&combined);
1379
1380    // Sum of ranks per group
1381    let mut rank_sums = vec![0.0; k];
1382    for ((_, gi), &r) in combined.iter().zip(ranks.iter()) {
1383        rank_sums[*gi] += r;
1384    }
1385
1386    // H statistic: H = (12 / N(N+1)) * Σ Rᵢ²/nᵢ - 3(N+1)
1387    let mean_rank = (nf + 1.0) / 2.0;
1388    let mut h = 0.0;
1389    for (gi, g) in groups.iter().enumerate() {
1390        let ni = g.len() as f64;
1391        let mean_rank_i = rank_sums[gi] / ni;
1392        h += ni * (mean_rank_i - mean_rank).powi(2);
1393    }
1394    h *= 12.0 / (nf * (nf + 1.0));
1395
1396    // Tie correction: divide by 1 - Σtₖ(tₖ²-1) / (N³ - N)
1397    let tie_corr = compute_tie_correction(&combined);
1398    let denom = 1.0 - tie_corr / (nf * nf * nf - nf);
1399    if denom > 1e-15 {
1400        h /= denom;
1401    }
1402
1403    let df = (k - 1) as f64;
1404    let p_value = 1.0 - special::chi_squared_cdf(h, df);
1405
1406    Some(TestResult {
1407        statistic: h,
1408        df,
1409        p_value,
1410    })
1411}
1412
1413// ---------------------------------------------------------------------------
1414// Variance tests
1415// ---------------------------------------------------------------------------
1416
1417/// Levene test for equality of variances: H₀: all groups have equal variance.
1418///
1419/// Robust to non-normality. Uses the **median** variant (Brown-Forsythe),
1420/// which is recommended for non-normal data.
1421///
1422/// # Algorithm
1423///
1424/// 1. Compute zᵢⱼ = |xᵢⱼ - median(groupᵢ)|
1425/// 2. Apply one-way ANOVA on the zᵢⱼ values
1426///
1427/// # Returns
1428///
1429/// `None` if fewer than 2 groups, any group < 2 observations, or non-finite values.
1430///
1431/// # References
1432///
1433/// - Levene (1960). "Robust tests for equality of variances". In
1434///   Olkin (Ed.), Contributions to Probability and Statistics.
1435/// - Brown & Forsythe (1974). "Robust tests for the equality of variances".
1436///   JASA, 69(346), 364–367.
1437///
1438/// # Examples
1439///
1440/// ```
1441/// use u_analytics::testing::levene_test;
1442///
1443/// let g1 = [4.9, 5.0, 5.0, 5.1, 5.0]; // tight cluster (low variance)
1444/// let g2 = [0.0, 3.0, 5.0, 7.0, 10.0]; // wide spread (high variance)
1445/// let r = levene_test(&[&g1, &g2]).unwrap();
1446/// assert!(r.p_value < 0.05); // clear variance difference
1447/// ```
1448pub fn levene_test(groups: &[&[f64]]) -> Option<TestResult> {
1449    let k = groups.len();
1450    if k < 2 {
1451        return None;
1452    }
1453    for g in groups {
1454        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1455            return None;
1456        }
1457    }
1458
1459    // Compute z-values: |x - median(group)| (Brown-Forsythe variant)
1460    let z_groups: Vec<Vec<f64>> = groups
1461        .iter()
1462        .map(|g| {
1463            let median = stats::median(g).unwrap_or(0.0);
1464            g.iter().map(|&x| (x - median).abs()).collect()
1465        })
1466        .collect();
1467
1468    // Apply ANOVA on the z-values
1469    let z_refs: Vec<&[f64]> = z_groups.iter().map(|v| v.as_slice()).collect();
1470    let anova = one_way_anova(&z_refs)?;
1471
1472    Some(TestResult {
1473        statistic: anova.f_statistic,
1474        df: anova.df_between as f64,
1475        p_value: anova.p_value,
1476    })
1477}
1478
1479// ---------------------------------------------------------------------------
1480// Multiple comparison correction
1481// ---------------------------------------------------------------------------
1482
1483/// Bonferroni correction: adjusts p-values for multiple comparisons.
1484///
1485/// adjusted_pᵢ = min(pᵢ × m, 1.0) where m = number of tests.
1486///
1487/// # Returns
1488///
1489/// `None` if the slice is empty or contains non-finite values.
1490pub fn bonferroni_correction(p_values: &[f64]) -> Option<Vec<f64>> {
1491    if p_values.is_empty() || p_values.iter().any(|v| !v.is_finite()) {
1492        return None;
1493    }
1494    let m = p_values.len() as f64;
1495    Some(p_values.iter().map(|&p| (p * m).min(1.0)).collect())
1496}
1497
1498/// Benjamini-Hochberg FDR correction.
1499///
1500/// Controls false discovery rate at level α.
1501///
1502/// # Algorithm
1503///
1504/// 1. Sort p-values.
1505/// 2. For rank i (1-indexed): adjusted_pᵢ = pᵢ × m / i.
1506/// 3. Enforce monotonicity (cumulative minimum from right).
1507///
1508/// # Returns
1509///
1510/// `None` if the slice is empty or contains non-finite values.
1511///
1512/// # References
1513///
1514/// Benjamini & Hochberg (1995). "Controlling the false discovery rate".
1515/// JRSS-B, 57(1), 289–300.
1516pub fn benjamini_hochberg(p_values: &[f64]) -> Option<Vec<f64>> {
1517    let m = p_values.len();
1518    if m == 0 || p_values.iter().any(|v| !v.is_finite()) {
1519        return None;
1520    }
1521
1522    // Sort indices by p-value
1523    let mut indices: Vec<usize> = (0..m).collect();
1524    indices.sort_by(|&a, &b| {
1525        p_values[a]
1526            .partial_cmp(&p_values[b])
1527            .unwrap_or(std::cmp::Ordering::Equal)
1528    });
1529
1530    let mf = m as f64;
1531    let mut adjusted = vec![0.0; m];
1532
1533    // Compute adjusted p-values
1534    let mut cummin = f64::INFINITY;
1535    for (rank_rev, &orig_idx) in indices.iter().enumerate().rev() {
1536        let rank = rank_rev + 1; // 1-indexed
1537        let adj = (p_values[orig_idx] * mf / rank as f64).min(1.0);
1538        cummin = cummin.min(adj);
1539        adjusted[orig_idx] = cummin;
1540    }
1541
1542    Some(adjusted)
1543}
1544
1545// ---------------------------------------------------------------------------
1546// Bartlett test for equality of variances
1547// ---------------------------------------------------------------------------
1548
1549/// Bartlett test for equality of variances: H₀: all groups have equal variance.
1550///
1551/// Assumes data are from **normal** distributions. For non-normal data, prefer
1552/// [`levene_test`] (Brown-Forsythe variant).
1553///
1554/// # Algorithm
1555///
1556/// 1. Compute pooled variance: s²ₚ = Σ(nᵢ-1)s²ᵢ / (N-k)
1557/// 2. Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1558/// 3. Correction factor: C = 1 + [1/(3(k-1))] × [Σ 1/(nᵢ-1) - 1/(N-k)]
1559/// 4. Statistic: T = numerator / C ~ χ²(k-1)
1560///
1561/// # Returns
1562///
1563/// `None` if fewer than 2 groups, any group < 2 observations, any group has
1564/// zero variance, or non-finite values.
1565///
1566/// # References
1567///
1568/// - Bartlett (1937). "Properties of sufficiency and statistical tests".
1569///   Proceedings of the Royal Society A, 160(901), 268–282.
1570///
1571/// # Examples
1572///
1573/// ```
1574/// use u_analytics::testing::bartlett_test;
1575///
1576/// let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // variance ~2.5
1577/// let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // variance ~250
1578/// let r = bartlett_test(&[&g1, &g2]).unwrap();
1579/// assert!(r.p_value < 0.01); // strongly different variances
1580/// ```
1581pub fn bartlett_test(groups: &[&[f64]]) -> Option<TestResult> {
1582    let k = groups.len();
1583    if k < 2 {
1584        return None;
1585    }
1586
1587    let mut sizes = Vec::with_capacity(k);
1588    let mut vars = Vec::with_capacity(k);
1589    let mut n_total: usize = 0;
1590
1591    for g in groups {
1592        if g.len() < 2 || g.iter().any(|v| !v.is_finite()) {
1593            return None;
1594        }
1595        let n = g.len();
1596        let v = stats::variance(g)?;
1597        if v <= 0.0 {
1598            return None; // zero variance → ln undefined
1599        }
1600        sizes.push(n);
1601        vars.push(v);
1602        n_total += n;
1603    }
1604
1605    let nk = n_total - k; // N - k
1606    if nk == 0 {
1607        return None;
1608    }
1609    let nk_f = nk as f64;
1610
1611    // Pooled variance
1612    let s2_pooled: f64 = sizes
1613        .iter()
1614        .zip(vars.iter())
1615        .map(|(&n, &v)| (n as f64 - 1.0) * v)
1616        .sum::<f64>()
1617        / nk_f;
1618
1619    if s2_pooled <= 0.0 {
1620        return None;
1621    }
1622
1623    // Numerator: (N-k) ln(s²ₚ) - Σ(nᵢ-1) ln(s²ᵢ)
1624    let num = nk_f * s2_pooled.ln()
1625        - sizes
1626            .iter()
1627            .zip(vars.iter())
1628            .map(|(&n, &v)| (n as f64 - 1.0) * v.ln())
1629            .sum::<f64>();
1630
1631    // Correction factor C
1632    let sum_recip: f64 = sizes.iter().map(|&n| 1.0 / (n as f64 - 1.0)).sum();
1633    let c = 1.0 + (sum_recip - 1.0 / nk_f) / (3.0 * (k as f64 - 1.0));
1634
1635    let statistic = num / c;
1636    let df = (k - 1) as f64;
1637    let p_value = 1.0 - special::chi_squared_cdf(statistic, df);
1638
1639    Some(TestResult {
1640        statistic,
1641        df,
1642        p_value,
1643    })
1644}
1645
1646// ---------------------------------------------------------------------------
1647// Fisher exact test (2×2)
1648// ---------------------------------------------------------------------------
1649
1650/// Fisher exact test for a 2×2 contingency table.
1651///
1652/// Tests H₀: the two categorical variables are independent.
1653/// Unlike the chi-squared test, this is exact and valid for small samples.
1654///
1655/// # Arguments
1656///
1657/// The 2×2 table is specified as four cell counts:
1658///
1659/// ```text
1660///          Col1   Col2
1661///   Row1 |  a   |  b  |
1662///   Row2 |  c   |  d  |
1663/// ```
1664///
1665/// # Algorithm
1666///
1667/// 1. Compute probability of observed table via hypergeometric distribution
1668///    (using log-factorials for numerical stability).
1669/// 2. Enumerate all tables with the same marginals.
1670/// 3. Two-tailed p-value = sum of probabilities ≤ P(observed).
1671///
1672/// # Returns
1673///
1674/// `None` if any marginal total is zero (degenerate table).
1675///
1676/// # References
1677///
1678/// - Fisher (1922). "On the interpretation of χ² from contingency tables,
1679///   and the calculation of P". JRSS, 85(1), 87–94.
1680///
1681/// # Examples
1682///
1683/// ```
1684/// use u_analytics::testing::fisher_exact_test;
1685///
1686/// // Tea-tasting experiment
1687/// let r = fisher_exact_test(3, 1, 1, 3).unwrap();
1688/// assert!(r.p_value > 0.05); // not significant at 5%
1689/// ```
1690pub fn fisher_exact_test(a: u64, b: u64, c: u64, d: u64) -> Option<TestResult> {
1691    let row1 = a + b;
1692    let row2 = c + d;
1693    let col1 = a + c;
1694    let col2 = b + d;
1695    let n = a + b + c + d;
1696
1697    // Degenerate if any marginal is zero
1698    if row1 == 0 || row2 == 0 || col1 == 0 || col2 == 0 {
1699        return None;
1700    }
1701
1702    // Log-probability of a specific table given marginals
1703    let log_prob = |a_i: u64| -> f64 {
1704        let b_i = row1 - a_i;
1705        let c_i = col1 - a_i;
1706        let d_i = row2 - c_i;
1707        ln_factorial(row1) + ln_factorial(row2) + ln_factorial(col1) + ln_factorial(col2)
1708            - ln_factorial(a_i)
1709            - ln_factorial(b_i)
1710            - ln_factorial(c_i)
1711            - ln_factorial(d_i)
1712            - ln_factorial(n)
1713    };
1714
1715    // Range of valid values for cell a
1716    let a_min = col1.saturating_sub(row2);
1717    let a_max = row1.min(col1);
1718
1719    let log_p_obs = log_prob(a);
1720
1721    // Two-tailed: sum probabilities ≤ P(observed)
1722    let mut p_value = 0.0;
1723    for a_i in a_min..=a_max {
1724        let lp = log_prob(a_i);
1725        // Use small tolerance for floating-point comparison
1726        if lp <= log_p_obs + 1e-10 {
1727            p_value += lp.exp();
1728        }
1729    }
1730
1731    // Clamp to [0, 1]
1732    let p_value = p_value.min(1.0);
1733
1734    // Odds ratio: (a*d) / (b*c)
1735    let odds_ratio = if b > 0 && c > 0 {
1736        (a as f64 * d as f64) / (b as f64 * c as f64)
1737    } else {
1738        f64::INFINITY
1739    };
1740
1741    Some(TestResult {
1742        statistic: odds_ratio,
1743        df: 1.0,
1744        p_value,
1745    })
1746}
1747
1748// ---------------------------------------------------------------------------
1749// Mann-Kendall trend test
1750// ---------------------------------------------------------------------------
1751
1752/// Result of the Mann-Kendall trend test.
1753#[derive(Debug, Clone, Copy)]
1754pub struct MannKendallResult {
1755    /// Mann-Kendall S statistic: Σ sign(xⱼ - xᵢ) for all i < j.
1756    pub s_statistic: i64,
1757    /// Variance of S (with tie correction).
1758    pub variance: f64,
1759    /// Z statistic (with continuity correction).
1760    pub z_statistic: f64,
1761    /// Two-tailed p-value.
1762    pub p_value: f64,
1763    /// Kendall's tau: S / [n(n-1)/2]. Range [-1, 1].
1764    pub kendall_tau: f64,
1765    /// Sen's slope estimator: median of (xⱼ - xᵢ)/(j - i) for all i < j.
1766    pub sen_slope: f64,
1767}
1768
1769/// Mann-Kendall non-parametric trend test with Sen's slope estimator.
1770///
1771/// Tests H₀: no monotonic trend vs H₁: monotonic trend exists.
1772/// Assumes serially independent observations (no autocorrelation).
1773///
1774/// # Algorithm
1775///
1776/// 1. S = Σᵢ<ⱼ sign(xⱼ - xᵢ)
1777/// 2. Var(S) = \[n(n-1)(2n+5) - Σ tₖ(tₖ-1)(2tₖ+5)\] / 18 (tie-corrected)
1778/// 3. Z = (S-1)/√Var(S) if S>0, 0 if S=0, (S+1)/√Var(S) if S<0
1779/// 4. Sen's slope = median of all pairwise slopes (xⱼ - xᵢ)/(j - i)
1780///
1781/// References:
1782/// - Mann (1945), "Nonparametric tests against trend"
1783/// - Kendall (1975), "Rank Correlation Methods"
1784/// - Sen (1968), "Estimates of the regression coefficient based on Kendall's tau"
1785///
1786/// # Complexity
1787///
1788/// O(n² log n) — pairwise comparisons O(n²) plus median finding O(n² log n²).
1789///
1790/// # Returns
1791///
1792/// `None` if fewer than 4 data points, non-finite values, or zero variance.
1793///
1794/// # Examples
1795///
1796/// ```
1797/// use u_analytics::testing::mann_kendall_test;
1798///
1799/// // Clear upward trend
1800/// let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
1801/// let r = mann_kendall_test(&data).unwrap();
1802/// assert!(r.p_value < 0.01);
1803/// assert!(r.kendall_tau > 0.8);
1804/// assert!(r.sen_slope > 0.0);
1805/// ```
1806pub fn mann_kendall_test(data: &[f64]) -> Option<MannKendallResult> {
1807    let n = data.len();
1808    if n < 4 || data.iter().any(|v| !v.is_finite()) {
1809        return None;
1810    }
1811
1812    // Step 1: Compute S statistic
1813    let mut s: i64 = 0;
1814    for i in 0..n - 1 {
1815        for j in (i + 1)..n {
1816            let diff = data[j] - data[i];
1817            if diff > 0.0 {
1818                s += 1;
1819            } else if diff < 0.0 {
1820                s -= 1;
1821            }
1822        }
1823    }
1824
1825    // Step 2: Compute tie groups
1826    let mut sorted: Vec<f64> = data.to_vec();
1827    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1828
1829    let mut tie_correction: f64 = 0.0;
1830    let mut current_count: usize = 1;
1831    for i in 1..sorted.len() {
1832        if (sorted[i] - sorted[i - 1]).abs() < 1e-10 {
1833            current_count += 1;
1834        } else {
1835            if current_count > 1 {
1836                let t = current_count as f64;
1837                tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1838            }
1839            current_count = 1;
1840        }
1841    }
1842    if current_count > 1 {
1843        let t = current_count as f64;
1844        tie_correction += t * (t - 1.0) * (2.0 * t + 5.0);
1845    }
1846
1847    // Step 3: Variance with tie correction
1848    let nf = n as f64;
1849    let variance = (nf * (nf - 1.0) * (2.0 * nf + 5.0) - tie_correction) / 18.0;
1850
1851    if variance < 1e-300 {
1852        return None; // All values identical
1853    }
1854
1855    // Step 4: Z with continuity correction
1856    let sigma = variance.sqrt();
1857    let z_statistic = if s > 0 {
1858        (s as f64 - 1.0) / sigma
1859    } else if s < 0 {
1860        (s as f64 + 1.0) / sigma
1861    } else {
1862        0.0
1863    };
1864
1865    // Step 5: Two-tailed p-value
1866    let p_value = 2.0 * special::standard_normal_sf(z_statistic.abs());
1867    let p_value = p_value.clamp(0.0, 1.0);
1868
1869    // Step 6: Kendall's tau
1870    let kendall_tau = (2 * s) as f64 / (nf * (nf - 1.0));
1871
1872    // Step 7: Sen's slope = median of pairwise slopes
1873    let mut slopes = Vec::with_capacity(n * (n - 1) / 2);
1874    for i in 0..n - 1 {
1875        for j in (i + 1)..n {
1876            let dx = (j - i) as f64;
1877            slopes.push((data[j] - data[i]) / dx);
1878        }
1879    }
1880    slopes.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1881    let m = slopes.len();
1882    let sen_slope = if m % 2 == 0 {
1883        (slopes[m / 2 - 1] + slopes[m / 2]) / 2.0
1884    } else {
1885        slopes[m / 2]
1886    };
1887
1888    Some(MannKendallResult {
1889        s_statistic: s,
1890        variance,
1891        z_statistic,
1892        p_value,
1893        kendall_tau,
1894        sen_slope,
1895    })
1896}
1897
1898/// Natural log of n! using Stirling/ln_gamma for large values.
1899fn ln_factorial(n: u64) -> f64 {
1900    if n <= 1 {
1901        return 0.0;
1902    }
1903    // ln(n!) = ln_gamma(n+1)
1904    special::ln_gamma(n as f64 + 1.0)
1905}
1906
1907// ---------------------------------------------------------------------------
1908// Augmented Dickey-Fuller test
1909// ---------------------------------------------------------------------------
1910
1911/// Result of the Augmented Dickey-Fuller (ADF) unit root test.
1912#[derive(Debug, Clone)]
1913pub struct AdfResult {
1914    /// ADF test statistic (t-ratio for γ̂).
1915    pub statistic: f64,
1916    /// Number of lags used.
1917    pub n_lags: usize,
1918    /// Number of observations used in the regression.
1919    pub n_obs: usize,
1920    /// Critical values at 1%, 5%, 10% significance levels.
1921    pub critical_values: [f64; 3],
1922    /// Whether the null hypothesis (unit root) is rejected at each level.
1923    pub rejected: [bool; 3],
1924}
1925
1926/// Model specification for the ADF test.
1927#[derive(Debug, Clone, Copy)]
1928pub enum AdfModel {
1929    /// No constant, no trend: Δyₜ = γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1930    None,
1931    /// Constant only (default): Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1932    Constant,
1933    /// Constant + linear trend: Δyₜ = α + βt + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1934    ConstantTrend,
1935}
1936
1937/// Augmented Dickey-Fuller (ADF) unit root test for stationarity.
1938///
1939/// Tests H₀: unit root (non-stationary) vs H₁: stationary.
1940///
1941/// # Algorithm
1942///
1943/// 1. Constructs Δyₜ = α + γyₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + εₜ
1944/// 2. Estimates via OLS
1945/// 3. Tests t-ratio for γ against Dickey-Fuller critical values
1946///
1947/// When `max_lags` is `None`, lag length is selected by AIC (Schwert rule
1948/// for maximum). When `Some(p)`, exactly `p` lags are used.
1949///
1950/// Reference: Dickey & Fuller (1979), "Distribution of the Estimators for
1951/// Autoregressive Time Series with a Unit Root"
1952///
1953/// # Returns
1954///
1955/// `None` if fewer than 10 data points, non-finite values, or OLS fails.
1956///
1957/// # Examples
1958///
1959/// ```
1960/// use u_analytics::testing::{adf_test, AdfModel};
1961///
1962/// // Stationary series: strong mean-reversion
1963/// let mut data = vec![0.0_f64; 40];
1964/// for i in 1..40 {
1965///     data[i] = 0.3 * data[i - 1] + [0.5, -0.8, 0.3, -0.6, 0.9,
1966///         -0.4, 0.7, -0.2, 0.1, -0.5][i % 10];
1967/// }
1968/// let r = adf_test(&data, AdfModel::Constant, None).unwrap();
1969/// assert!(r.statistic.is_finite());
1970/// assert_eq!(r.critical_values.len(), 3);
1971/// ```
1972pub fn adf_test(data: &[f64], model: AdfModel, max_lags: Option<usize>) -> Option<AdfResult> {
1973    let n = data.len();
1974    if n < 10 || data.iter().any(|v| !v.is_finite()) {
1975        return None;
1976    }
1977
1978    // Compute differences
1979    let dy: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
1980
1981    // Determine lag count
1982    let best_lag = match max_lags {
1983        Some(p) => p, // Use exact lag count when specified
1984        None => {
1985            // Schwert (1989) rule for maximum lag
1986            let schwert = (12.0 * (n as f64 / 100.0).powf(0.25)).floor() as usize;
1987            let p_max = schwert.min(n / 3);
1988            // Select optimal lag by AIC
1989            select_adf_lag(data, &dy, model, p_max)
1990        }
1991    };
1992
1993    // Run OLS regression with selected lag
1994    adf_ols(data, &dy, model, best_lag)
1995}
1996
1997/// Selects optimal lag for ADF by minimizing AIC.
1998fn select_adf_lag(data: &[f64], dy: &[f64], model: AdfModel, p_max: usize) -> usize {
1999    let mut best_aic = f64::INFINITY;
2000    let mut best_p = 0;
2001
2002    for p in 0..=p_max {
2003        if let Some((aic, _)) = adf_ols_aic(data, dy, model, p) {
2004            if aic < best_aic {
2005                best_aic = aic;
2006                best_p = p;
2007            }
2008        }
2009    }
2010
2011    best_p
2012}
2013
2014/// Builds the ADF design matrix and dependent variable.
2015///
2016/// Returns (design_matrix_row_major, y_dep, n_rows, n_cols, gamma_col_index).
2017#[allow(clippy::type_complexity)]
2018fn adf_build_matrix(
2019    data: &[f64],
2020    dy: &[f64],
2021    model: AdfModel,
2022    p: usize,
2023) -> Option<(Vec<f64>, Vec<f64>, usize, usize, usize)> {
2024    let start = p + 1;
2025    if start >= dy.len() || dy.len() - start < 5 {
2026        return None;
2027    }
2028    let m = dy.len() - start;
2029
2030    let y_dep: Vec<f64> = dy[start..].to_vec();
2031
2032    // Count columns: intercept + y_{t-1} + [trend] + p lags
2033    let has_intercept = !matches!(model, AdfModel::None);
2034    let has_trend = matches!(model, AdfModel::ConstantTrend);
2035    let ncols = has_intercept as usize + 1 + has_trend as usize + p;
2036
2037    // Build row-major design matrix
2038    let mut x_data = Vec::with_capacity(m * ncols);
2039    let mut gamma_col = 0;
2040
2041    for i in 0..m {
2042        let t = start + i;
2043        if has_intercept {
2044            x_data.push(1.0); // intercept
2045            gamma_col = 1;
2046        }
2047        x_data.push(data[t]); // y_{t-1}
2048        if has_trend {
2049            x_data.push((t + 1) as f64); // trend
2050        }
2051        for lag in 1..=p {
2052            x_data.push(dy[t - lag]);
2053        }
2054    }
2055
2056    Some((x_data, y_dep, m, ncols, gamma_col))
2057}
2058
2059/// Lightweight OLS for ADF: returns (gamma_t_stat, rss, k).
2060///
2061/// Solves X'Xβ = X'y using Cholesky-like decomposition (Gaussian elimination).
2062fn adf_ols_core(
2063    x_data: &[f64],
2064    y: &[f64],
2065    m: usize,
2066    ncols: usize,
2067    gamma_col: usize,
2068) -> Option<(f64, f64, usize)> {
2069    // Compute X'X (ncols × ncols, symmetric)
2070    let mut xtx = vec![0.0_f64; ncols * ncols];
2071    for i in 0..m {
2072        let row = &x_data[i * ncols..(i + 1) * ncols];
2073        for j in 0..ncols {
2074            for k in j..ncols {
2075                xtx[j * ncols + k] += row[j] * row[k];
2076            }
2077        }
2078    }
2079    // Mirror upper to lower
2080    for j in 0..ncols {
2081        for k in (j + 1)..ncols {
2082            xtx[k * ncols + j] = xtx[j * ncols + k];
2083        }
2084    }
2085
2086    // Compute X'y (ncols × 1)
2087    let mut xty = vec![0.0_f64; ncols];
2088    for i in 0..m {
2089        let row = &x_data[i * ncols..(i + 1) * ncols];
2090        for j in 0..ncols {
2091            xty[j] += row[j] * y[i];
2092        }
2093    }
2094
2095    // Solve via Gaussian elimination with partial pivoting
2096    let mut augmented = vec![0.0_f64; ncols * (ncols + 1)];
2097    for i in 0..ncols {
2098        for j in 0..ncols {
2099            augmented[i * (ncols + 1) + j] = xtx[i * ncols + j];
2100        }
2101        augmented[i * (ncols + 1) + ncols] = xty[i];
2102    }
2103
2104    for col in 0..ncols {
2105        // Partial pivoting
2106        let mut max_row = col;
2107        let mut max_val = augmented[col * (ncols + 1) + col].abs();
2108        for row in (col + 1)..ncols {
2109            let val = augmented[row * (ncols + 1) + col].abs();
2110            if val > max_val {
2111                max_val = val;
2112                max_row = row;
2113            }
2114        }
2115        if max_val < 1e-15 {
2116            return None; // Singular
2117        }
2118        if max_row != col {
2119            for j in 0..=ncols {
2120                let a = col * (ncols + 1) + j;
2121                let b = max_row * (ncols + 1) + j;
2122                augmented.swap(a, b);
2123            }
2124        }
2125
2126        let pivot = augmented[col * (ncols + 1) + col];
2127        for row in (col + 1)..ncols {
2128            let factor = augmented[row * (ncols + 1) + col] / pivot;
2129            for j in col..=ncols {
2130                let above = augmented[col * (ncols + 1) + j];
2131                augmented[row * (ncols + 1) + j] -= factor * above;
2132            }
2133        }
2134    }
2135
2136    // Back-substitution
2137    let mut beta = vec![0.0_f64; ncols];
2138    for i in (0..ncols).rev() {
2139        let mut sum = augmented[i * (ncols + 1) + ncols];
2140        for j in (i + 1)..ncols {
2141            sum -= augmented[i * (ncols + 1) + j] * beta[j];
2142        }
2143        beta[i] = sum / augmented[i * (ncols + 1) + i];
2144    }
2145
2146    // Compute residuals and RSS
2147    let mut rss = 0.0;
2148    for i in 0..m {
2149        let row = &x_data[i * ncols..(i + 1) * ncols];
2150        let y_hat: f64 = row.iter().zip(beta.iter()).map(|(&x, &b)| x * b).sum();
2151        let resid = y[i] - y_hat;
2152        rss += resid * resid;
2153    }
2154
2155    // Standard error of coefficients
2156    let df = m - ncols;
2157    if df == 0 {
2158        return None;
2159    }
2160    let mse = rss / df as f64;
2161
2162    // Compute (X'X)^{-1} via Gauss-Jordan elimination to get variance of γ̂
2163    let mut xtx_aug = vec![0.0_f64; ncols * ncols * 2]; // xtx | I
2164    for i in 0..ncols {
2165        for j in 0..ncols {
2166            xtx_aug[i * 2 * ncols + j] = xtx[i * ncols + j];
2167        }
2168        xtx_aug[i * 2 * ncols + ncols + i] = 1.0;
2169    }
2170
2171    // Gauss-Jordan elimination
2172    for col in 0..ncols {
2173        let mut max_row = col;
2174        let mut max_val = xtx_aug[col * 2 * ncols + col].abs();
2175        for row in (col + 1)..ncols {
2176            let val = xtx_aug[row * 2 * ncols + col].abs();
2177            if val > max_val {
2178                max_val = val;
2179                max_row = row;
2180            }
2181        }
2182        if max_val < 1e-15 {
2183            return None;
2184        }
2185        if max_row != col {
2186            for j in 0..(2 * ncols) {
2187                let a = col * 2 * ncols + j;
2188                let b = max_row * 2 * ncols + j;
2189                xtx_aug.swap(a, b);
2190            }
2191        }
2192
2193        let pivot = xtx_aug[col * 2 * ncols + col];
2194        for j in 0..(2 * ncols) {
2195            xtx_aug[col * 2 * ncols + j] /= pivot;
2196        }
2197        for row in 0..ncols {
2198            if row == col {
2199                continue;
2200            }
2201            let factor = xtx_aug[row * 2 * ncols + col];
2202            for j in 0..(2 * ncols) {
2203                let above = xtx_aug[col * 2 * ncols + j];
2204                xtx_aug[row * 2 * ncols + j] -= factor * above;
2205            }
2206        }
2207    }
2208
2209    // Extract diagonal element for gamma column
2210    let var_gamma = mse * xtx_aug[gamma_col * 2 * ncols + ncols + gamma_col];
2211    if var_gamma <= 0.0 {
2212        return None;
2213    }
2214    let se_gamma = var_gamma.sqrt();
2215    let t_gamma = beta[gamma_col] / se_gamma;
2216
2217    Some((t_gamma, rss, ncols))
2218}
2219
2220/// Runs ADF OLS and returns AIC + number of observations.
2221fn adf_ols_aic(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<(f64, usize)> {
2222    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p)?;
2223    let (_t_stat, rss, k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2224    let aic = 2.0 * k as f64 + m as f64 * (rss / m as f64).ln();
2225    Some((aic, m))
2226}
2227
2228/// Runs the actual ADF OLS and returns the test result.
2229fn adf_ols(data: &[f64], dy: &[f64], model: AdfModel, p: usize) -> Option<AdfResult> {
2230    let (x_data, y_dep, m, ncols, gamma_col) = adf_build_matrix(data, dy, model, p)?;
2231    let (gamma_t, _rss, _k) = adf_ols_core(&x_data, &y_dep, m, ncols, gamma_col)?;
2232
2233    let critical_values = adf_critical_values(model, m);
2234
2235    let rejected = [
2236        gamma_t <= critical_values[0],
2237        gamma_t <= critical_values[1],
2238        gamma_t <= critical_values[2],
2239    ];
2240
2241    Some(AdfResult {
2242        statistic: gamma_t,
2243        n_lags: p,
2244        n_obs: m,
2245        critical_values,
2246        rejected,
2247    })
2248}
2249
2250/// MacKinnon (1994) critical values for ADF test.
2251///
2252/// Returns [1%, 5%, 10%] critical values based on sample size.
2253fn adf_critical_values(model: AdfModel, n: usize) -> [f64; 3] {
2254    // MacKinnon (1994) regression-based approximation:
2255    // cv(n) = τ_∞ + τ₁/n + τ₂/n²
2256    //
2257    // Coefficients from MacKinnon (2010), Table 1.
2258    let (tau_inf, tau1, tau2): ([f64; 3], [f64; 3], [f64; 3]) = match model {
2259        AdfModel::None => (
2260            [-2.5658, -1.9393, -1.6156],
2261            [-1.960, -0.398, -0.181],
2262            [-10.04, 0.0, 0.0],
2263        ),
2264        AdfModel::Constant => (
2265            [-3.4336, -2.8621, -2.5671],
2266            [-5.999, -2.738, -1.438],
2267            [-29.25, -8.36, -4.48],
2268        ),
2269        AdfModel::ConstantTrend => (
2270            [-3.9638, -3.4126, -3.1279],
2271            [-8.353, -4.039, -2.418],
2272            [-47.44, -17.83, -7.58],
2273        ),
2274    };
2275
2276    let nf = n as f64;
2277    let inv_n = 1.0 / nf;
2278    let inv_n2 = inv_n * inv_n;
2279
2280    [
2281        tau_inf[0] + tau1[0] * inv_n + tau2[0] * inv_n2,
2282        tau_inf[1] + tau1[1] * inv_n + tau2[1] * inv_n2,
2283        tau_inf[2] + tau1[2] * inv_n + tau2[2] * inv_n2,
2284    ]
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    use super::*;
2290
2291    // -----------------------------------------------------------------------
2292    // Anderson-Darling large-n p-value overflow (upstream-014)
2293    // -----------------------------------------------------------------------
2294
2295    /// Public-domain Mulberry32 PRNG (matches the upstream-014 reproduction).
2296    fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
2297        let mut a = seed;
2298        move || {
2299            a = a.wrapping_add(0x6d2b_79f5);
2300            let mut t = a;
2301            t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2302            t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2303            ((t ^ (t >> 14)) as f64) / 4_294_967_296.0
2304        }
2305    }
2306
2307    /// Regression (upstream-014): for a fixed, clearly non-normal distribution
2308    /// shape (exponential), as n grows the A*² statistic grows monotonically and
2309    /// the p-value must keep tracking toward 0 — it must NEVER jump to exactly 1.0
2310    /// ("perfectly normal") while A*² is simultaneously large and growing. Before
2311    /// the fix, the p-value overflowed to exactly 1 past n≈7000.
2312    #[test]
2313    fn ad_pvalue_no_overflow_large_nonnormal_n() {
2314        let mut rng = mulberry32(0x5350_4331);
2315        let full: Vec<f64> = (0..20_000)
2316            .map(|_| {
2317                let u = rng();
2318                -(1.0 - u).ln() // inverse-CDF exponential, rate=1
2319            })
2320            .collect();
2321
2322        let mut prev_a2 = 0.0_f64;
2323        let mut prev_p = f64::INFINITY;
2324        for &n in &[3000usize, 5000, 6000, 7000, 8000, 10000, 16000, 20000] {
2325            let r = anderson_darling_normality(&full[..n]).expect("computes");
2326            // A*² must keep growing for an increasingly non-normal large sample.
2327            assert!(
2328                r.statistic_modified > prev_a2,
2329                "A*² must grow with n; n={n} A*²={} prev={prev_a2}",
2330                r.statistic_modified
2331            );
2332            // The core defect: p must never flip to exactly 1.0 while A*² is huge.
2333            assert!(
2334                r.p_value < 0.5,
2335                "clearly non-normal data (n={n}, A*²={}) must not report p={} ≈ normal",
2336                r.statistic_modified,
2337                r.p_value
2338            );
2339            // Monotone non-increasing: p tracks the growing statistic downward.
2340            assert!(
2341                r.p_value <= prev_p + 1e-12,
2342                "p must be non-increasing as A*² grows; n={n} p={} prev={prev_p}",
2343                r.p_value
2344            );
2345            prev_a2 = r.statistic_modified;
2346            prev_p = r.p_value;
2347        }
2348    }
2349
2350    /// Both AD entry points share the range-clamped upper-tail branch, so both
2351    /// must be immune to the overflow.
2352    #[test]
2353    fn ad_both_functions_immune_to_overflow() {
2354        let mut rng = mulberry32(0x0bad_c0de);
2355        let data: Vec<f64> = (0..9000).map(|_| -(1.0 - rng()).ln()).collect();
2356        let a = anderson_darling_test(&data).expect("computes");
2357        let b = anderson_darling_normality(&data).expect("computes");
2358        assert!(
2359            a.statistic_star > 300.0,
2360            "expected large A*², got {}",
2361            a.statistic_star
2362        );
2363        assert!(
2364            a.p_value < 0.5,
2365            "test() overflowed toward normal: p={}",
2366            a.p_value
2367        );
2368        assert!(
2369            b.p_value < 0.5,
2370            "normality() overflowed toward normal: p={}",
2371            b.p_value
2372        );
2373    }
2374
2375    // -----------------------------------------------------------------------
2376    // One-sample t-test
2377    // -----------------------------------------------------------------------
2378
2379    #[test]
2380    fn one_sample_null_true() {
2381        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2382        let r = one_sample_t_test(&data, 5.0).expect("should compute");
2383        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2384    }
2385
2386    #[test]
2387    fn one_sample_null_false() {
2388        let data = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2389        let r = one_sample_t_test(&data, 10.0).expect("should compute");
2390        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2391    }
2392
2393    #[test]
2394    fn one_sample_edge_cases() {
2395        assert!(one_sample_t_test(&[1.0], 0.0).is_none()); // n < 2
2396        assert!(one_sample_t_test(&[5.0, 5.0, 5.0], 5.0).is_none()); // zero var
2397        assert!(one_sample_t_test(&[1.0, f64::NAN, 3.0], 2.0).is_none());
2398    }
2399
2400    // -----------------------------------------------------------------------
2401    // Two-sample t-test
2402    // -----------------------------------------------------------------------
2403
2404    #[test]
2405    fn two_sample_same_mean() {
2406        let a = [5.0, 5.1, 4.9, 5.0, 5.1, 4.9, 5.0, 5.0];
2407        let b = [5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
2408        let r = two_sample_t_test(&a, &b).expect("should compute");
2409        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2410    }
2411
2412    #[test]
2413    fn two_sample_different_means() {
2414        let a = [1.0, 2.0, 3.0, 2.0, 1.5, 2.5];
2415        let b = [10.0, 11.0, 12.0, 10.5, 11.5, 10.5];
2416        let r = two_sample_t_test(&a, &b).expect("should compute");
2417        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2418    }
2419
2420    #[test]
2421    fn two_sample_different_sizes() {
2422        let a = [1.0, 2.0, 3.0];
2423        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
2424        let r = two_sample_t_test(&a, &b).expect("should compute");
2425        assert!(r.p_value < 0.05);
2426    }
2427
2428    #[test]
2429    fn two_sample_edge_cases() {
2430        assert!(two_sample_t_test(&[1.0], &[2.0, 3.0]).is_none());
2431        assert!(two_sample_t_test(&[1.0, 2.0], &[3.0]).is_none());
2432    }
2433
2434    // -----------------------------------------------------------------------
2435    // Paired t-test
2436    // -----------------------------------------------------------------------
2437
2438    #[test]
2439    fn paired_no_difference() {
2440        let x = [5.0, 6.0, 7.0, 8.0, 9.0];
2441        let y = [5.1, 5.9, 7.1, 7.9, 9.1];
2442        let r = paired_t_test(&x, &y).expect("should compute");
2443        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2444    }
2445
2446    #[test]
2447    fn paired_significant_difference() {
2448        // Differences have non-zero variance
2449        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
2450        let after = [6.2, 7.1, 8.3, 9.0, 10.4, 11.1, 12.2, 13.3];
2451        let r = paired_t_test(&before, &after).expect("should compute");
2452        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2453        assert!(r.statistic < 0.0); // after > before
2454    }
2455
2456    #[test]
2457    fn paired_edge_cases() {
2458        assert!(paired_t_test(&[1.0, 2.0], &[3.0]).is_none()); // length mismatch
2459        assert!(paired_t_test(&[1.0], &[2.0]).is_none()); // n < 2
2460    }
2461
2462    // -----------------------------------------------------------------------
2463    // ANOVA
2464    // -----------------------------------------------------------------------
2465
2466    #[test]
2467    fn anova_same_means() {
2468        let g1 = [5.0, 5.1, 4.9, 5.0, 5.1];
2469        let g2 = [5.0, 5.2, 4.8, 5.1, 4.9];
2470        let g3 = [5.1, 4.9, 5.0, 5.0, 5.1];
2471        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2472        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2473    }
2474
2475    #[test]
2476    fn anova_different_means() {
2477        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2478        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2479        let g3 = [10.0, 11.0, 12.0, 11.0, 10.5];
2480        let r = one_way_anova(&[&g1, &g2, &g3]).expect("should compute");
2481        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2482        assert!(r.df_between == 2);
2483        assert!(r.df_within == 12);
2484    }
2485
2486    #[test]
2487    fn anova_ss_decomposition() {
2488        let g1 = [1.0, 2.0, 3.0, 2.0, 1.5];
2489        let g2 = [5.0, 6.0, 7.0, 6.0, 5.5];
2490        let r = one_way_anova(&[&g1, &g2]).expect("should compute");
2491        // SS_total = SS_between + SS_within
2492        let all_data: Vec<f64> = g1.iter().chain(g2.iter()).copied().collect();
2493        let ss_total: f64 = all_data.iter().map(|&x| (x - r.grand_mean).powi(2)).sum();
2494        assert!(
2495            (ss_total - (r.ss_between + r.ss_within)).abs() < 1e-10,
2496            "SS decomposition: {ss_total} vs {} + {}",
2497            r.ss_between,
2498            r.ss_within
2499        );
2500    }
2501
2502    #[test]
2503    fn anova_edge_cases() {
2504        let g1 = [1.0, 2.0, 3.0];
2505        assert!(one_way_anova(&[&g1]).is_none()); // < 2 groups
2506    }
2507
2508    // -----------------------------------------------------------------------
2509    // Chi-squared goodness of fit
2510    // -----------------------------------------------------------------------
2511
2512    #[test]
2513    fn chi2_gof_uniform() {
2514        // Perfect uniform distribution
2515        let observed = [25.0, 25.0, 25.0, 25.0];
2516        let expected = [25.0, 25.0, 25.0, 25.0];
2517        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2518        assert!((r.statistic).abs() < 1e-15);
2519        assert!((r.p_value - 1.0).abs() < 0.01);
2520    }
2521
2522    #[test]
2523    fn chi2_gof_significant() {
2524        let observed = [90.0, 10.0];
2525        let expected = [50.0, 50.0];
2526        let r = chi_squared_goodness_of_fit(&observed, &expected).expect("should compute");
2527        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2528    }
2529
2530    #[test]
2531    fn chi2_gof_edge_cases() {
2532        assert!(chi_squared_goodness_of_fit(&[10.0], &[10.0]).is_none()); // < 2
2533        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0, 0.0]).is_none()); // expected 0
2534        assert!(chi_squared_goodness_of_fit(&[10.0, 20.0], &[10.0]).is_none()); // mismatch
2535    }
2536
2537    // -----------------------------------------------------------------------
2538    // Chi-squared independence
2539    // -----------------------------------------------------------------------
2540
2541    #[test]
2542    fn chi2_independence_significant() {
2543        // Strong association
2544        let table = [30.0, 10.0, 10.0, 50.0];
2545        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2546        assert!(r.p_value < 0.001, "p = {}", r.p_value);
2547        assert!((r.df - 1.0).abs() < 1e-10);
2548    }
2549
2550    #[test]
2551    fn chi2_independence_not_significant() {
2552        // No association
2553        let table = [25.0, 25.0, 25.0, 25.0];
2554        let r = chi_squared_independence(&table, 2, 2).expect("should compute");
2555        assert!(r.p_value > 0.3, "p = {}", r.p_value);
2556    }
2557
2558    #[test]
2559    fn chi2_independence_3x3() {
2560        let table = [10.0, 20.0, 30.0, 40.0, 30.0, 20.0, 20.0, 25.0, 25.0];
2561        let r = chi_squared_independence(&table, 3, 3).expect("should compute");
2562        assert!((r.df - 4.0).abs() < 1e-10);
2563        assert!(r.p_value < 0.05);
2564    }
2565
2566    #[test]
2567    fn chi2_independence_edge_cases() {
2568        assert!(chi_squared_independence(&[10.0, 20.0], 1, 2).is_none()); // 1 row
2569        assert!(chi_squared_independence(&[10.0, 20.0], 2, 1).is_none()); // 1 col
2570        assert!(chi_squared_independence(&[10.0], 2, 2).is_none()); // wrong size
2571    }
2572
2573    // -----------------------------------------------------------------------
2574    // Jarque-Bera
2575    // -----------------------------------------------------------------------
2576
2577    #[test]
2578    fn jb_normal_data() {
2579        // Symmetric, light-tailed data
2580        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2581        let r = jarque_bera_test(&data).expect("should compute");
2582        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2583    }
2584
2585    #[test]
2586    fn jb_skewed_data() {
2587        // Highly right-skewed
2588        let data = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 20.0, 50.0];
2589        let r = jarque_bera_test(&data).expect("should compute");
2590        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2591    }
2592
2593    /// The statistic is the one the cited paper defines, which is the one
2594    /// SciPy, R and statsmodels report -- moment ratios, not the bias-adjusted
2595    /// estimators Excel's SKEW()/KURT() return.
2596    ///
2597    /// Reference values computed from the definition, independently of the
2598    /// implementation: g1 = m3/m2^1.5, g2 = m4/m2^2 - 3, JB = n/6 (g1^2 + g2^2/4).
2599    #[test]
2600    fn jb_is_the_moment_form_the_paper_defines() {
2601        let data = [
2602            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,
2603            3.6, 2.8, 3.2,
2604        ];
2605        let r = jarque_bera_test(&data).expect("20 finite points");
2606        assert!(
2607            (r.statistic - 5.563_714_337_9).abs() < 1e-8,
2608            "JB = {}",
2609            r.statistic
2610        );
2611
2612        // What it used to compute, from the adjusted estimators: 35 % higher on
2613        // this sample. The gap is not a rounding difference to be tolerated.
2614        let n = data.len() as f64;
2615        let big_g1 = u_numflow::stats::skewness(&data).expect("same data");
2616        let big_g2 = u_numflow::stats::kurtosis(&data).expect("same data");
2617        let adjusted = (n / 6.0) * (big_g1 * big_g1 + big_g2 * big_g2 / 4.0);
2618        assert!(
2619            adjusted > r.statistic * 1.3,
2620            "the two forms differ by more than a third here: {adjusted} vs {}",
2621            r.statistic
2622        );
2623    }
2624
2625    /// chi-squared with two degrees of freedom is an exponential, so the
2626    /// p-value is `exp(-JB/2)` exactly -- and stays a number where `1 - cdf`
2627    /// rounds to zero.
2628    #[test]
2629    fn jb_p_value_keeps_its_digits_in_the_tail() {
2630        // A ramp with one far outlier: JB near 93, where `1 - cdf` is exactly
2631        // zero in f64 and the true p-value is about 7e-21.
2632        let mut data: Vec<f64> = (1..20).map(f64::from).collect();
2633        data.push(60.0);
2634        let r = jarque_bera_test(&data).expect("20 finite points");
2635        assert!(r.statistic > 90.0, "JB = {}", r.statistic);
2636        assert!(
2637            1.0 - special::chi_squared_cdf(r.statistic, 2.0) == 0.0,
2638            "this sample is only interesting because the complement underflows"
2639        );
2640        assert!(
2641            r.p_value > 0.0,
2642            "the complement returns 0 here; this must not"
2643        );
2644        assert!(
2645            (r.p_value - 6.702e-21).abs() < 1e-23,
2646            "p = {} (expected about 6.7e-21)",
2647            r.p_value
2648        );
2649        assert!(
2650            (r.p_value - (-r.statistic / 2.0).exp()).abs() <= f64::EPSILON * r.p_value,
2651            "p = {} vs exp(-JB/2) = {}",
2652            r.p_value,
2653            (-r.statistic / 2.0).exp()
2654        );
2655        // And it still agrees with the distribution it claims, where that is
2656        // computable at all.
2657        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])
2658            .expect("10 finite points");
2659        let via_cdf = 1.0 - special::chi_squared_cdf(small.statistic, 2.0);
2660        assert!(
2661            (small.p_value - via_cdf).abs() < 1e-12,
2662            "{} vs {via_cdf}",
2663            small.p_value
2664        );
2665    }
2666
2667    #[test]
2668    fn jb_edge_cases() {
2669        assert!(jarque_bera_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2670    }
2671
2672    // -----------------------------------------------------------------------
2673    // Anderson-Darling
2674    // -----------------------------------------------------------------------
2675
2676    #[test]
2677    fn ad_normal_data() {
2678        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2679        let r = anderson_darling_test(&data).expect("should compute");
2680        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2681        assert!(r.statistic > 0.0, "A2 = {}", r.statistic);
2682        assert!(r.statistic_star > r.statistic, "A*2 should be > A2");
2683    }
2684
2685    #[test]
2686    fn ad_skewed_data() {
2687        // Exponential-like data — not normal
2688        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];
2689        let r = anderson_darling_test(&data).expect("should compute");
2690        assert!(
2691            r.p_value < 0.05,
2692            "p = {} (should reject normality)",
2693            r.p_value
2694        );
2695    }
2696
2697    #[test]
2698    fn ad_bimodal_data() {
2699        // Bimodal data — clearly not normal
2700        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2701        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2702        let r = anderson_darling_test(&data).expect("should compute");
2703        assert!(
2704            r.p_value < 0.01,
2705            "p = {} (bimodal should reject normality)",
2706            r.p_value
2707        );
2708    }
2709
2710    #[test]
2711    fn ad_large_normal_sample() {
2712        let n = 100;
2713        let data: Vec<f64> = (1..=n)
2714            .map(|i| {
2715                let p = (i as f64 - 0.5) / n as f64;
2716                special::inverse_normal_cdf(p)
2717            })
2718            .collect();
2719        let r = anderson_darling_test(&data).expect("should compute");
2720        assert!(r.p_value > 0.05, "p = {} for normal quantiles", r.p_value);
2721    }
2722
2723    #[test]
2724    fn ad_edge_cases() {
2725        assert!(anderson_darling_test(&[1.0, 2.0, 3.0, 4.0]).is_none()); // n < 8
2726        assert!(anderson_darling_test(&[5.0; 10]).is_none()); // constant
2727        assert!(anderson_darling_test(&[1.0, f64::NAN, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).is_none());
2728    }
2729
2730    #[test]
2731    fn ad_p_value_ranges() {
2732        // Test different A*² ranges for p-value formula
2733        // Use datasets that produce different A*² magnitudes
2734
2735        // Near-normal → small A*² (< 0.2 range)
2736        let near_normal = [-1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5];
2737        let r = anderson_darling_test(&near_normal).expect("should compute");
2738        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2739
2740        // Heavy-tailed → large A*² (>= 0.6 range)
2741        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];
2742        let r = anderson_darling_test(&heavy_tail).expect("should compute");
2743        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2744    }
2745
2746    // -----------------------------------------------------------------------
2747    // Anderson-Darling normality (anderson_darling_normality)
2748    // -----------------------------------------------------------------------
2749
2750    #[test]
2751    fn ad_normal_data_large_p() {
2752        // Clearly normal data → cannot reject normality (p > 0.05)
2753        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2754        let r = anderson_darling_normality(&data).unwrap();
2755        assert!(r.p_value > 0.05, "p={}", r.p_value);
2756    }
2757
2758    #[test]
2759    fn ad_exponential_data_small_p() {
2760        // Clearly non-normal (exponential) → reject normality (p < 0.05)
2761        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2762        let r = anderson_darling_normality(&data).unwrap();
2763        assert!(r.p_value < 0.05, "p={}", r.p_value);
2764    }
2765
2766    #[test]
2767    fn ad_statistic_non_negative() {
2768        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
2769        let r = anderson_darling_normality(&data).unwrap();
2770        assert!(r.statistic >= 0.0);
2771        assert!(r.statistic_modified >= r.statistic);
2772    }
2773
2774    #[test]
2775    fn ad_p_value_in_range() {
2776        let data = [5.0, 5.1, 4.9, 5.05, 4.95, 5.02, 4.98, 5.0];
2777        let r = anderson_darling_normality(&data).unwrap();
2778        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
2779    }
2780
2781    #[test]
2782    fn ad_insufficient_data() {
2783        assert!(anderson_darling_normality(&[1.0, 2.0]).is_none()); // n < 3
2784    }
2785
2786    #[test]
2787    fn ad_degenerate_data() {
2788        // All same value → std = 0 → None
2789        assert!(anderson_darling_normality(&[5.0, 5.0, 5.0, 5.0]).is_none());
2790    }
2791
2792    #[test]
2793    fn ad_modified_statistic_formula() {
2794        // A²* > A² for any finite n
2795        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
2796        let r = anderson_darling_normality(&data).unwrap();
2797        assert!(r.statistic_modified > r.statistic - 1e-10);
2798    }
2799
2800    /// Validates A²* = A² · (1 + 0.75/n + 2.25/n²) against manual computation.
2801    ///
2802    /// Reference: Stephens (1974), "EDF statistics for goodness of fit",
2803    /// J. Amer. Statist. Assoc. 69(347), 730–737 (composite normal case).
2804    #[test]
2805    fn ad_correction_factor_formula() {
2806        let data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2807        let n = data.len() as f64;
2808        let r = anderson_darling_normality(&data).unwrap();
2809
2810        // Verify the Stephens correction formula A²* = A² · (1 + 0.75/n + 2.25/n²)
2811        let expected_star = r.statistic * (1.0 + 0.75 / n + 2.25 / (n * n));
2812        assert!(
2813            (r.statistic_modified - expected_star).abs() < 1e-12,
2814            "A²* = {}, expected = {}",
2815            r.statistic_modified,
2816            expected_star
2817        );
2818
2819        // A² must be positive (it is a sum of log terms with negative coefficient)
2820        assert!(
2821            r.statistic >= 0.0,
2822            "A² = {} must be non-negative",
2823            r.statistic
2824        );
2825    }
2826
2827    /// Validates that the A² summation formula implements:
2828    /// A² = -n - (1/n)·Σᵢ₌₀ⁿ⁻¹ (2i+1)·[ln Φ(zᵢ) + ln(1 − Φ(z_{n−1−i}))]
2829    ///
2830    /// This tests the formula structure by checking that a perfectly symmetric
2831    /// dataset centered at 0 produces a smaller A² than a skewed dataset.
2832    #[test]
2833    fn ad_formula_structure_symmetric_vs_skewed() {
2834        // Symmetric around mean → smaller A²
2835        let symmetric = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 0.0];
2836        // Right-skewed → larger A² (heavier right tail deviates from normality)
2837        let skewed: Vec<f64> = (1..=10).map(|i| (i as f64 * 0.5).exp()).collect();
2838
2839        let r_sym = anderson_darling_normality(&symmetric).unwrap();
2840        let r_skew = anderson_darling_normality(&skewed).unwrap();
2841
2842        assert!(
2843            r_sym.statistic < r_skew.statistic,
2844            "Symmetric A²={} should be less than skewed A²={}",
2845            r_sym.statistic,
2846            r_skew.statistic
2847        );
2848    }
2849
2850    /// Validates p-value invariants for the Anderson-Darling test.
2851    ///
2852    /// - Normal data: p > 0.05 (cannot reject normality)
2853    /// - Exponential data: p < 0.05 (reject normality)
2854    #[test]
2855    fn ad_pvalue_invariants() {
2856        // Near-normal data — tightly clustered, should not reject normality
2857        let normal_data = [2.1, 1.9, 2.0, 2.05, 1.95, 2.02, 1.98, 2.01, 2.03, 1.97];
2858        let r_normal = anderson_darling_normality(&normal_data).unwrap();
2859        assert!(
2860            r_normal.p_value > 0.05,
2861            "Normal data: p = {} (expected > 0.05)",
2862            r_normal.p_value
2863        );
2864
2865        // Exponential data — heavy right tail, should reject normality
2866        let exp_data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.3).exp()).collect();
2867        let r_exp = anderson_darling_normality(&exp_data).unwrap();
2868        assert!(
2869            r_exp.p_value < 0.05,
2870            "Exponential data: p = {} (expected < 0.05)",
2871            r_exp.p_value
2872        );
2873    }
2874
2875    // -----------------------------------------------------------------------
2876    // Shapiro-Wilk
2877    // -----------------------------------------------------------------------
2878
2879    #[test]
2880    fn sw_normal_data() {
2881        // Approximately normal data (symmetric, bell-shaped)
2882        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.5, 1.0, 1.5, 2.0];
2883        let r = shapiro_wilk_test(&data).expect("should compute");
2884        assert!(r.w > 0.9, "W = {}", r.w);
2885        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2886    }
2887
2888    #[test]
2889    fn sw_bimodal_data() {
2890        // Bimodal data — clearly not normal
2891        let mut data = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
2892        data.extend_from_slice(&[9.5, 9.6, 9.7, 9.8, 9.9, 10.0]);
2893        let r = shapiro_wilk_test(&data).expect("should compute");
2894        assert!(
2895            r.p_value < 0.01,
2896            "p = {} (bimodal should reject normality)",
2897            r.p_value
2898        );
2899    }
2900
2901    #[test]
2902    fn sw_n3() {
2903        let data = [1.0, 2.0, 3.0];
2904        let r = shapiro_wilk_test(&data).expect("n=3 should work");
2905        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2906        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2907    }
2908
2909    #[test]
2910    fn sw_n4() {
2911        let data = [1.0, 2.0, 3.0, 4.0];
2912        let r = shapiro_wilk_test(&data).expect("n=4 should work");
2913        assert!(r.w > 0.0 && r.w <= 1.0, "W = {}", r.w);
2914        assert!(r.p_value >= 0.0 && r.p_value <= 1.0, "p = {}", r.p_value);
2915    }
2916
2917    #[test]
2918    fn sw_n5() {
2919        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
2920        let r = shapiro_wilk_test(&data).expect("n=5 should work");
2921        assert!(r.w > 0.9, "W = {}", r.w);
2922        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2923    }
2924
2925    #[test]
2926    fn sw_skewed_data() {
2927        // Exponential-like data — not normal
2928        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];
2929        let r = shapiro_wilk_test(&data).expect("should compute");
2930        assert!(
2931            r.p_value < 0.05,
2932            "p = {} (skewed data should reject normality)",
2933            r.p_value
2934        );
2935    }
2936
2937    #[test]
2938    fn sw_large_normal_sample() {
2939        // Generate pseudo-normal data via Box-Muller-like approach
2940        // Use linearly spaced quantiles from standard normal
2941        let n = 100;
2942        let data: Vec<f64> = (1..=n)
2943            .map(|i| {
2944                let p = (i as f64 - 0.5) / n as f64;
2945                special::inverse_normal_cdf(p)
2946            })
2947            .collect();
2948        let r = shapiro_wilk_test(&data).expect("should compute");
2949        assert!(r.w > 0.99, "W = {} for normal quantiles", r.w);
2950        assert!(r.p_value > 0.05, "p = {}", r.p_value);
2951    }
2952
2953    #[test]
2954    fn sw_w_bounded() {
2955        // W should be in (0, 1] for any valid data
2956        let datasets: Vec<Vec<f64>> = vec![
2957            vec![1.0, 2.0, 3.0],
2958            vec![1.0, 1.0, 2.0, 3.0, 3.0],
2959            vec![0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
2960            (0..20).map(|i| (i as f64).powi(2)).collect(),
2961        ];
2962        for (idx, data) in datasets.iter().enumerate() {
2963            let r = shapiro_wilk_test(data).unwrap_or_else(|| panic!("dataset {idx} should work"));
2964            assert!(r.w > 0.0 && r.w <= 1.0, "dataset {idx}: W = {}", r.w);
2965            assert!(
2966                r.p_value >= 0.0 && r.p_value <= 1.0,
2967                "dataset {idx}: p = {}",
2968                r.p_value
2969            );
2970        }
2971    }
2972
2973    #[test]
2974    fn sw_edge_cases() {
2975        assert!(shapiro_wilk_test(&[1.0, 2.0]).is_none()); // n < 3
2976        assert!(shapiro_wilk_test(&[]).is_none()); // empty
2977        assert!(shapiro_wilk_test(&[5.0, 5.0, 5.0]).is_none()); // constant
2978        assert!(shapiro_wilk_test(&[1.0, f64::NAN, 3.0]).is_none()); // NaN
2979        assert!(shapiro_wilk_test(&[1.0, f64::INFINITY, 3.0]).is_none()); // Inf
2980    }
2981
2982    #[test]
2983    fn sw_n5001_rejected() {
2984        let data: Vec<f64> = (0..5001).map(|i| i as f64).collect();
2985        assert!(shapiro_wilk_test(&data).is_none()); // n > 5000
2986    }
2987
2988    // -----------------------------------------------------------------------
2989    // Mann-Whitney U
2990    // -----------------------------------------------------------------------
2991
2992    #[test]
2993    fn mw_clearly_different() {
2994        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
2995        let b = [6.0, 7.0, 8.0, 9.0, 10.0];
2996        let r = mann_whitney_u_test(&a, &b).expect("should compute");
2997        assert!(r.p_value < 0.05, "p = {}", r.p_value);
2998    }
2999
3000    #[test]
3001    fn mw_same_distribution() {
3002        let a = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0];
3003        let b = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0];
3004        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3005        assert!(
3006            r.p_value > 0.3,
3007            "p = {} (interleaved, same dist)",
3008            r.p_value
3009        );
3010    }
3011
3012    #[test]
3013    fn mw_with_ties() {
3014        let a = [1.0, 2.0, 2.0, 3.0, 3.0];
3015        let b = [3.0, 4.0, 4.0, 5.0, 5.0];
3016        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3017        assert!(r.p_value < 0.05, "p = {} (shifted with ties)", r.p_value);
3018    }
3019
3020    #[test]
3021    fn mw_different_sizes() {
3022        let a = [1.0, 2.0, 3.0];
3023        let b = [4.0, 5.0, 6.0, 7.0, 8.0];
3024        let r = mann_whitney_u_test(&a, &b).expect("should compute");
3025        assert!(r.p_value < 0.05);
3026    }
3027
3028    #[test]
3029    fn mw_edge_cases() {
3030        assert!(mann_whitney_u_test(&[1.0], &[2.0, 3.0]).is_none()); // n1 < 2
3031        assert!(mann_whitney_u_test(&[1.0, 2.0], &[3.0]).is_none()); // n2 < 2
3032        assert!(mann_whitney_u_test(&[1.0, f64::NAN], &[2.0, 3.0]).is_none());
3033    }
3034
3035    // -----------------------------------------------------------------------
3036    // Wilcoxon signed-rank
3037    // -----------------------------------------------------------------------
3038
3039    #[test]
3040    fn wilcoxon_significant_increase() {
3041        let before = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
3042        let after = [6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5];
3043        let r = wilcoxon_signed_rank_test(&before, &after).expect("should compute");
3044        assert!(r.p_value < 0.05, "p = {} (consistent increase)", r.p_value);
3045    }
3046
3047    #[test]
3048    fn wilcoxon_no_difference() {
3049        let x = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3050        let y = [5.1, 5.9, 7.1, 7.9, 9.1, 9.9];
3051        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3052        assert!(r.p_value > 0.3, "p = {} (small random diffs)", r.p_value);
3053    }
3054
3055    #[test]
3056    fn wilcoxon_with_ties() {
3057        // Some differences are equal in magnitude
3058        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3059        let y = [0.0, 1.0, 2.0, 3.0, 4.0]; // constant difference = 1.0
3060        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3061        // All differences positive with ties in magnitude
3062        assert!(r.statistic > 0.0);
3063    }
3064
3065    #[test]
3066    fn wilcoxon_with_zero_diffs() {
3067        // Some pairs are equal → zero differences discarded
3068        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
3069        let y = [1.0, 2.0, 3.0, 3.0, 4.0]; // first 3 are zero diffs
3070        let r = wilcoxon_signed_rank_test(&x, &y).expect("should compute");
3071        assert!(r.p_value >= 0.0 && r.p_value <= 1.0);
3072    }
3073
3074    #[test]
3075    fn wilcoxon_edge_cases() {
3076        assert!(wilcoxon_signed_rank_test(&[1.0, 2.0], &[3.0]).is_none()); // mismatch
3077        assert!(wilcoxon_signed_rank_test(&[1.0], &[2.0]).is_none()); // n < 2
3078                                                                      // All zero differences → fewer than 2 non-zero diffs
3079        assert!(wilcoxon_signed_rank_test(&[5.0, 5.0], &[5.0, 5.0]).is_none());
3080    }
3081
3082    /// Oracle: the exact p by enumerating every arrangement of the ranks
3083    /// (Python, independent of the recursion). The normal approximation this
3084    /// replaced reported the second column -- always too small, and below
3085    /// 0.05 in the first and third rows where the exact p is not.
3086    #[test]
3087    fn mann_whitney_small_samples_use_the_exact_distribution() {
3088        // (n1, n2, U, exact p, old normal-approximation p)
3089        let cases = [
3090            (3, 3, 0, 0.1000, 0.0495),
3091            (4, 4, 0, 0.0286, 0.0209),
3092            (4, 4, 1, 0.0571, 0.0433),
3093            (5, 5, 2, 0.0317, 0.0283),
3094            (8, 8, 13, 0.0499, 0.0460),
3095            (10, 10, 23, 0.0433, 0.0413),
3096        ];
3097        for (n1, n2, u, exact, old) in cases {
3098            let (a, b) = separated_samples(n1, n2, u);
3099            let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3100            assert_eq!(r.statistic, u as f64);
3101            assert!(
3102                (r.p_value - exact).abs() < 1e-4,
3103                "n={n1},{n2} U={u}: p={} want {exact} (old {old})",
3104                r.p_value
3105            );
3106        }
3107    }
3108
3109    /// Two samples of sizes `n1`, `n2` with no ties whose U₁ is exactly `u`:
3110    /// sample 1 takes the lowest ranks, then its top member is moved up past
3111    /// `u` members of sample 2 one step at a time.
3112    fn separated_samples(n1: usize, n2: usize, u: usize) -> (Vec<f64>, Vec<f64>) {
3113        let mut ranks_a: Vec<usize> = (1..=n1).collect();
3114        let mut remaining = u;
3115        for k in (0..n1).rev() {
3116            let room = n2.min(remaining);
3117            ranks_a[k] += room;
3118            remaining -= room;
3119            if remaining == 0 {
3120                break;
3121            }
3122        }
3123        let ranks_b: Vec<usize> = (1..=n1 + n2).filter(|r| !ranks_a.contains(r)).collect();
3124        (
3125            ranks_a.iter().map(|&r| r as f64).collect(),
3126            ranks_b.iter().map(|&r| r as f64).collect(),
3127        )
3128    }
3129
3130    /// Oracle: exact p by enumerating all 2ⁿ sign assignments (Python).
3131    #[test]
3132    fn wilcoxon_small_samples_use_the_exact_distribution() {
3133        // (n, T+, exact p)
3134        let cases = [
3135            (5, 0, 0.0625),
3136            (5, 1, 0.125),
3137            (6, 2, 0.09375),
3138            (8, 5, 0.078125),
3139            (10, 8, 0.048828125),
3140            (12, 14, 0.0522460938),
3141        ];
3142        for (n, t_plus, exact) in cases {
3143            // Differences 1..=n; the ranks summing to t_plus are positive.
3144            let positive = ranks_summing_to(n, t_plus);
3145            let x: Vec<f64> = (1..=n)
3146                .map(|r| {
3147                    if positive.contains(&r) {
3148                        r as f64
3149                    } else {
3150                        -(r as f64)
3151                    }
3152                })
3153                .collect();
3154            let y = vec![0.0; n];
3155            let r = wilcoxon_signed_rank_test(&x, &y).expect("valid samples");
3156            assert_eq!(r.statistic, t_plus as f64);
3157            assert!(
3158                (r.p_value - exact).abs() < 1e-9,
3159                "n={n} T+={t_plus}: p={} want {exact}",
3160                r.p_value
3161            );
3162        }
3163    }
3164
3165    /// A set of distinct ranks from 1..=n summing to `target` (greedy from the top).
3166    fn ranks_summing_to(n: usize, target: usize) -> Vec<usize> {
3167        let mut out = Vec::new();
3168        let mut left = target;
3169        for r in (1..=n).rev() {
3170            if r <= left {
3171                out.push(r);
3172                left -= r;
3173            }
3174        }
3175        assert_eq!(left, 0);
3176        out
3177    }
3178
3179    /// Above the exact range, or with ties, the p comes from the normal
3180    /// approximation with continuity correction.
3181    #[test]
3182    fn rank_tests_fall_back_to_continuity_corrected_normal() {
3183        let a: Vec<f64> = (0..60).map(|i| i as f64).collect();
3184        let b: Vec<f64> = (0..60).map(|i| i as f64 + 20.5).collect();
3185        let r = mann_whitney_u_test(&a, &b).expect("valid samples");
3186        let (n1, n2) = (60.0, 60.0);
3187        let mu = n1 * n2 / 2.0;
3188        let sigma = (n1 * n2 * (n1 + n2 + 1.0) / 12.0_f64).sqrt();
3189        let z = ((r.statistic - mu).abs() - 0.5) / sigma;
3190        let want = 2.0 * special::standard_normal_sf(z);
3191        assert!((r.p_value - want).abs() < 1e-12);
3192
3193        // At the boundary the two methods agree closely.
3194        let a: Vec<f64> = (0..50).map(|i| i as f64).collect();
3195        let b: Vec<f64> = (0..50).map(|i| i as f64 + 10.5).collect();
3196        let exact = mann_whitney_u_test(&a, &b).expect("valid samples").p_value;
3197        let mu = 1250.0;
3198        let sigma = (2500.0 * 101.0 / 12.0_f64).sqrt();
3199        let u = mann_whitney_u_test(&a, &b)
3200            .expect("valid samples")
3201            .statistic;
3202        let approx = continuity_corrected_p(u, mu, sigma * sigma);
3203        assert!(
3204            (exact - approx).abs() < 1e-3,
3205            "exact {exact} vs approx {approx}"
3206        );
3207    }
3208
3209    // -----------------------------------------------------------------------
3210    // Kruskal-Wallis
3211    // -----------------------------------------------------------------------
3212
3213    #[test]
3214    fn kw_clearly_different() {
3215        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3216        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3217        let g3 = [11.0, 12.0, 13.0, 14.0, 15.0];
3218        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3219        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3220        assert!((r.df - 2.0).abs() < 1e-10);
3221    }
3222
3223    #[test]
3224    fn kw_same_distribution() {
3225        let g1 = [1.0, 3.0, 5.0, 7.0, 9.0];
3226        let g2 = [2.0, 4.0, 6.0, 8.0, 10.0];
3227        let r = kruskal_wallis_test(&[&g1, &g2]).expect("should compute");
3228        assert!(r.p_value > 0.3, "p = {} (interleaved)", r.p_value);
3229    }
3230
3231    #[test]
3232    fn kw_with_ties() {
3233        let g1 = [1.0, 2.0, 2.0, 3.0];
3234        let g2 = [3.0, 4.0, 4.0, 5.0];
3235        let g3 = [5.0, 6.0, 6.0, 7.0];
3236        let r = kruskal_wallis_test(&[&g1, &g2, &g3]).expect("should compute");
3237        assert!(r.statistic > 0.0);
3238    }
3239
3240    #[test]
3241    fn kw_edge_cases() {
3242        let g1 = [1.0, 2.0, 3.0];
3243        assert!(kruskal_wallis_test(&[&g1]).is_none()); // < 2 groups
3244    }
3245
3246    // -----------------------------------------------------------------------
3247    // Levene (Brown-Forsythe)
3248    // -----------------------------------------------------------------------
3249
3250    #[test]
3251    fn levene_equal_variance() {
3252        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3253        let g2 = [6.0, 7.0, 8.0, 9.0, 10.0];
3254        let r = levene_test(&[&g1, &g2]).expect("should compute");
3255        // Both have same spread, different means → equal variance
3256        assert!(r.p_value > 0.3, "p = {} (equal variance)", r.p_value);
3257    }
3258
3259    #[test]
3260    fn levene_unequal_variance() {
3261        let g1 = [4.5, 4.8, 5.0, 5.2, 5.5]; // small spread
3262        let g2 = [0.0, 2.0, 5.0, 8.0, 10.0]; // large spread
3263        let r = levene_test(&[&g1, &g2]).expect("should compute");
3264        assert!(r.p_value < 0.05, "p = {} (unequal variance)", r.p_value);
3265    }
3266
3267    #[test]
3268    fn levene_three_groups() {
3269        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3270        let g2 = [0.0, 3.0, 5.0, 7.0, 10.0];
3271        let g3 = [-5.0, 0.0, 5.0, 10.0, 15.0];
3272        let r = levene_test(&[&g1, &g2, &g3]).expect("should compute");
3273        assert!(r.df >= 2.0);
3274    }
3275
3276    #[test]
3277    fn levene_edge_cases() {
3278        let g1 = [1.0, 2.0, 3.0];
3279        assert!(levene_test(&[&g1]).is_none()); // < 2 groups
3280    }
3281
3282    // -----------------------------------------------------------------------
3283    // Multiple comparison correction
3284    // -----------------------------------------------------------------------
3285
3286    #[test]
3287    fn bonferroni_basic() {
3288        let ps = [0.01, 0.04, 0.03, 0.005];
3289        let adj = bonferroni_correction(&ps).expect("should compute");
3290        assert!((adj[0] - 0.04).abs() < 1e-10);
3291        assert!((adj[1] - 0.16).abs() < 1e-10);
3292        assert!((adj[2] - 0.12).abs() < 1e-10);
3293        assert!((adj[3] - 0.02).abs() < 1e-10);
3294    }
3295
3296    #[test]
3297    fn bonferroni_capped_at_one() {
3298        let ps = [0.5, 0.6];
3299        let adj = bonferroni_correction(&ps).expect("should compute");
3300        assert!((adj[0] - 1.0).abs() < 1e-10);
3301        assert!((adj[1] - 1.0).abs() < 1e-10);
3302    }
3303
3304    #[test]
3305    fn bh_basic() {
3306        let ps = [0.01, 0.04, 0.03, 0.005];
3307        let adj = benjamini_hochberg(&ps).expect("should compute");
3308        // All adjusted p-values should be >= original
3309        for (i, (&orig, &adjusted)) in ps.iter().zip(adj.iter()).enumerate() {
3310            assert!(
3311                adjusted >= orig - 1e-15,
3312                "adj[{i}] = {adjusted} < original {orig}"
3313            );
3314        }
3315        // Adjusted p-values should still be ordered (weakly) by original order
3316        // after reordering by original p-value
3317    }
3318
3319    #[test]
3320    fn bh_all_significant() {
3321        let ps = [0.001, 0.002, 0.003];
3322        let adj = benjamini_hochberg(&ps).expect("should compute");
3323        for &a in &adj {
3324            assert!(a < 0.05);
3325        }
3326    }
3327
3328    #[test]
3329    fn correction_edge_cases() {
3330        assert!(bonferroni_correction(&[]).is_none());
3331        assert!(benjamini_hochberg(&[]).is_none());
3332        assert!(bonferroni_correction(&[f64::NAN]).is_none());
3333    }
3334
3335    // -----------------------------------------------------------------------
3336    // Bartlett test
3337    // -----------------------------------------------------------------------
3338
3339    #[test]
3340    fn bartlett_equal_variances() {
3341        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0];
3342        let g2 = [12.0, 13.0, 14.0, 15.0, 16.0];
3343        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3344        assert!(
3345            r.p_value > 0.9,
3346            "equal variance → p high, got {}",
3347            r.p_value
3348        );
3349    }
3350
3351    #[test]
3352    fn bartlett_unequal_variances() {
3353        let g1 = [2.0, 3.0, 4.0, 5.0, 6.0]; // var ≈ 2.5
3354        let g2 = [10.0, 20.0, 30.0, 40.0, 50.0]; // var ≈ 250
3355        let r = bartlett_test(&[&g1, &g2]).expect("should compute");
3356        assert!(
3357            r.p_value < 0.01,
3358            "very different variances → p < 0.01, got {}",
3359            r.p_value
3360        );
3361        assert!((r.df - 1.0).abs() < 1e-10); // k-1 = 1
3362    }
3363
3364    #[test]
3365    fn bartlett_three_groups() {
3366        let g1 = [1.0, 2.0, 3.0, 4.0, 5.0];
3367        let g2 = [1.5, 2.5, 3.5, 4.5, 5.5];
3368        let g3 = [10.0, 30.0, 50.0, 70.0, 90.0]; // much higher variance
3369        let r = bartlett_test(&[&g1, &g2, &g3]).expect("should compute");
3370        assert!(r.p_value < 0.05, "one group with high variance");
3371        assert!((r.df - 2.0).abs() < 1e-10); // k-1 = 2
3372    }
3373
3374    #[test]
3375    fn bartlett_edge_cases() {
3376        let g1 = [1.0, 2.0, 3.0];
3377        assert!(bartlett_test(&[&g1]).is_none()); // < 2 groups
3378
3379        let g2 = [5.0, 5.0, 5.0]; // zero variance
3380        assert!(bartlett_test(&[&g1, &g2]).is_none());
3381
3382        let g3 = [1.0]; // group too small
3383        assert!(bartlett_test(&[&g1, &g3]).is_none());
3384    }
3385
3386    // -----------------------------------------------------------------------
3387    // Fisher exact test
3388    // -----------------------------------------------------------------------
3389
3390    #[test]
3391    fn fisher_tea_tasting() {
3392        // Classic Fisher tea-tasting: [[3,1],[1,3]]
3393        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3394        // Known two-tailed p ≈ 0.4857
3395        assert!(
3396            (r.p_value - 0.4857).abs() < 0.01,
3397            "p ≈ 0.4857, got {}",
3398            r.p_value
3399        );
3400    }
3401
3402    #[test]
3403    fn fisher_significant() {
3404        // Strong association: [[10, 0], [0, 10]]
3405        let r = fisher_exact_test(10, 0, 0, 10).expect("should compute");
3406        assert!(r.p_value < 0.001, "perfect association → p very small");
3407    }
3408
3409    #[test]
3410    fn fisher_no_association() {
3411        // Proportional table: [[5, 5], [5, 5]]
3412        let r = fisher_exact_test(5, 5, 5, 5).expect("should compute");
3413        assert!(
3414            r.p_value > 0.9,
3415            "no association → p ≈ 1.0, got {}",
3416            r.p_value
3417        );
3418    }
3419
3420    #[test]
3421    fn fisher_small_table() {
3422        // [[1, 0], [0, 1]]
3423        let r = fisher_exact_test(1, 0, 0, 1).expect("should compute");
3424        assert!(r.p_value > 0.0 && r.p_value <= 1.0);
3425    }
3426
3427    #[test]
3428    fn fisher_asymmetric() {
3429        // [[8, 2], [1, 5]]
3430        let r = fisher_exact_test(8, 2, 1, 5).expect("should compute");
3431        assert!(r.p_value < 0.05, "significant association");
3432    }
3433
3434    #[test]
3435    fn fisher_edge_cases() {
3436        // Zero marginals → None
3437        assert!(fisher_exact_test(0, 0, 1, 2).is_none()); // row1 = 0
3438        assert!(fisher_exact_test(1, 2, 0, 0).is_none()); // row2 = 0
3439        assert!(fisher_exact_test(0, 1, 0, 2).is_none()); // col1 = 0
3440    }
3441
3442    #[test]
3443    fn fisher_odds_ratio() {
3444        let r = fisher_exact_test(3, 1, 1, 3).expect("should compute");
3445        // OR = (3*3)/(1*1) = 9
3446        assert!(
3447            (r.statistic - 9.0).abs() < 1e-10,
3448            "OR = 9, got {}",
3449            r.statistic
3450        );
3451    }
3452
3453    // -----------------------------------------------------------------------
3454    // Mann-Kendall trend test
3455    // -----------------------------------------------------------------------
3456
3457    #[test]
3458    fn mk_increasing_trend() {
3459        let data = [1.0, 2.3, 3.1, 4.5, 5.2, 6.8, 7.1, 8.9, 9.5, 10.2];
3460        let r = mann_kendall_test(&data).expect("should compute");
3461        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3462        assert!(r.kendall_tau > 0.8, "tau = {}", r.kendall_tau);
3463        assert!(r.sen_slope > 0.0, "slope = {}", r.sen_slope);
3464        assert!(r.s_statistic > 0);
3465    }
3466
3467    #[test]
3468    fn mk_decreasing_trend() {
3469        let data = [10.0, 9.2, 8.5, 7.1, 6.3, 5.0, 4.2, 3.1, 2.0, 1.1];
3470        let r = mann_kendall_test(&data).expect("should compute");
3471        assert!(r.p_value < 0.01, "p = {}", r.p_value);
3472        assert!(r.kendall_tau < -0.8, "tau = {}", r.kendall_tau);
3473        assert!(r.sen_slope < 0.0, "slope = {}", r.sen_slope);
3474        assert!(r.s_statistic < 0);
3475    }
3476
3477    #[test]
3478    fn mk_no_trend() {
3479        // Random-looking data with no clear trend
3480        let data = [5.0, 3.0, 7.0, 2.0, 8.0, 4.0, 6.0, 1.0, 9.0, 5.0];
3481        let r = mann_kendall_test(&data).expect("should compute");
3482        // Should not detect significant trend
3483        assert!(
3484            r.p_value > 0.05,
3485            "p = {} (should be > 0.05 for no trend)",
3486            r.p_value
3487        );
3488    }
3489
3490    #[test]
3491    fn mk_perfect_monotone() {
3492        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
3493        let r = mann_kendall_test(&data).expect("should compute");
3494        // Perfect monotone: S = n(n-1)/2 = 45, tau = 1.0
3495        assert_eq!(r.s_statistic, 45);
3496        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3497        assert!((r.sen_slope - 1.0).abs() < 1e-10);
3498    }
3499
3500    #[test]
3501    fn mk_with_ties() {
3502        let data = [1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 5.0];
3503        let r = mann_kendall_test(&data).expect("should compute");
3504        assert!(r.s_statistic > 0);
3505        // Tie correction should reduce variance
3506        let n = data.len() as f64;
3507        let base_var = n * (n - 1.0) * (2.0 * n + 5.0) / 18.0;
3508        assert!(r.variance < base_var, "ties should reduce variance");
3509    }
3510
3511    #[test]
3512    fn mk_edge_cases() {
3513        // Too few data points
3514        assert!(mann_kendall_test(&[1.0, 2.0, 3.0]).is_none());
3515        // NaN
3516        assert!(mann_kendall_test(&[1.0, f64::NAN, 3.0, 4.0]).is_none());
3517        // All identical (zero variance)
3518        assert!(mann_kendall_test(&[5.0, 5.0, 5.0, 5.0]).is_none());
3519    }
3520
3521    #[test]
3522    fn mk_minimum_n() {
3523        // n = 4 should work
3524        let data = [1.0, 2.0, 3.0, 4.0];
3525        let r = mann_kendall_test(&data).expect("n=4 should work");
3526        assert_eq!(r.s_statistic, 6); // C(4,2) = 6 pairs, all positive
3527        assert!((r.kendall_tau - 1.0).abs() < 1e-10);
3528    }
3529
3530    #[test]
3531    fn mk_sen_slope_robust_to_outlier() {
3532        // Mostly linear (slope ≈ 1) with one outlier
3533        let data = [1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
3534        let r = mann_kendall_test(&data).expect("should compute");
3535        // Sen's slope should be close to 1.0 despite the outlier at index 3
3536        assert!(
3537            (r.sen_slope - 1.0).abs() < 0.5,
3538            "sen slope = {}, expected ≈ 1.0",
3539            r.sen_slope
3540        );
3541    }
3542
3543    // -----------------------------------------------------------------------
3544    // ADF test
3545    // -----------------------------------------------------------------------
3546
3547    #[test]
3548    fn adf_stationary_mean_reverting() {
3549        // Strongly mean-reverting AR(1) process: y_t = 0.3*y_{t-1} + noise
3550        // This is clearly stationary (|ρ| < 1)
3551        let data = [
3552            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,
3553            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,
3554            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,
3555        ];
3556        let r = adf_test(&data, AdfModel::Constant, None).expect("should compute");
3557        // Should reject H₀ (series is stationary)
3558        assert!(
3559            r.rejected[2],
3560            "should reject at 10%: stat={}, cv={}",
3561            r.statistic, r.critical_values[2]
3562        );
3563    }
3564
3565    #[test]
3566    fn adf_nonstationary_random_walk() {
3567        // Cumulative sum simulates random walk (non-stationary)
3568        let increments = [
3569            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,
3570            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,
3571            -0.13, 0.15, -0.1,
3572        ];
3573        let mut walk = Vec::with_capacity(increments.len());
3574        let mut cum = 0.0;
3575        for &inc in &increments {
3576            cum += inc;
3577            walk.push(cum);
3578        }
3579        let r = adf_test(&walk, AdfModel::Constant, None).expect("should compute");
3580        // Random walk should NOT reject at 1%
3581        assert!(
3582            !r.rejected[0],
3583            "should NOT reject at 1%: stat={}, cv={}",
3584            r.statistic, r.critical_values[0]
3585        );
3586    }
3587
3588    #[test]
3589    fn adf_with_fixed_lags() {
3590        // Use wider oscillation to avoid near-singular design matrix
3591        let data: Vec<f64> = (0..50)
3592            .map(|i| (i as f64 * 0.5).sin() + 0.02 * i as f64)
3593            .collect();
3594        let r = adf_test(&data, AdfModel::Constant, Some(2)).expect("should compute");
3595        assert_eq!(r.n_lags, 2);
3596        assert!(r.statistic.is_finite());
3597    }
3598
3599    #[test]
3600    fn adf_constant_trend_model() {
3601        // Linear trend is unit-root-like under "constant" model
3602        // but with "constant+trend" model, it should be recognized
3603        let data: Vec<f64> = (0..30)
3604            .map(|i| i as f64 + (i as f64 * 0.3).sin() * 0.5)
3605            .collect();
3606        let r = adf_test(&data, AdfModel::ConstantTrend, None).expect("should compute");
3607        assert!(r.statistic.is_finite());
3608        assert_eq!(r.critical_values.len(), 3);
3609    }
3610
3611    #[test]
3612    fn adf_edge_cases() {
3613        // Too few data points
3614        assert!(adf_test(&[1.0; 9], AdfModel::Constant, None).is_none());
3615        // NaN
3616        let mut data = vec![0.0; 20];
3617        data[5] = f64::NAN;
3618        assert!(adf_test(&data, AdfModel::Constant, None).is_none());
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}