Skip to main content

bixverse_core/core/math/
stats.rs

1//! Various statistical helpers used in this crate.
2
3use faer::{Mat, linalg::solvers::DenseSolveCore};
4use rand::rngs::StdRng;
5use rand::{Rng, SeedableRng};
6use rayon::prelude::*;
7use statrs::distribution::FisherSnedecor;
8use statrs::distribution::{Continuous, ContinuousCDF, Normal};
9use statrs::function::gamma::ln_gamma;
10
11use crate::core::base::kernel_smooth::bw_sj;
12use crate::core::math::MAD_SCALE;
13use crate::core::math::distributions::{chisq_sf, f_sf, norm_sf, t_pval_two_sided};
14use crate::core::math::special::digamma;
15use crate::core::math::vector_helpers::*;
16use crate::prelude::*;
17
18////////////
19// Consts //
20////////////
21
22/// Multiple of machine epsilon sctransform nudges the first bin edge down by,
23/// so that `min(x)` itself lands inside the leftmost bin rather than outside
24/// R's left-open `cut` interval.
25const OUTLIER_EPS_MULT: f64 = 10.0;
26
27/// Iteration budget for the Newton solve in [`fit_gamma_mle`].
28///
29/// Minka's starting point is within about 1.5% of the root and Newton doubles
30/// the correct digits each step, so five or six iterations is the working
31/// number. This is a runaway guard for pathological samples, not a limit the
32/// solver is expected to reach.
33const GAMMA_MLE_MAX_ITER: usize = 100;
34
35/// Log-scale spread below which [`fit_gamma_mle`] refuses to fit.
36///
37/// The score equation solves `ln k - psi(k) = s`, whose left side behaves like
38/// `1 / (2k)` for large `k`, so `s` at this floor already implies a shape near
39/// 5e11. An exact zero test would not do: a sample of identical values reaches
40/// `s = 0` only up to rounding, and the sign of that last bit decides between
41/// an error and a nonsense fit.
42const GAMMA_MLE_MIN_LOG_SPREAD: f64 = 1e-12;
43
44/// Relative step size below which the [`fit_gamma_mle`] Newton solve stops.
45///
46/// The shape feeds a gamma tail probability, and moving the shape by one part
47/// in 1e-12 moves that tail well below `f64` resolution.
48const GAMMA_MLE_TOL: f64 = 1e-12;
49
50/// Term budget for either [`kolmogorov_sf`] series.
51///
52/// Both branches converge in a handful of terms over the range each is used on,
53/// so this is a runaway guard rather than a working limit.
54const KS_MAX_TERMS: usize = 100;
55
56/// Relative size at which a [`kolmogorov_sf`] term stops contributing.
57const KS_SERIES_EPS: f64 = 1e-14;
58
59/// Crossover between the two [`kolmogorov_sf`] series.
60///
61/// The alternating form converges as `exp(-2 lambda^2)` per term and the
62/// theta-transformed form as `exp(-pi^2 / (8 lambda^2))`, so each is fast
63/// exactly where the other is useless. At one they are both about three terms,
64/// which makes it the natural place to switch.
65const KS_SERIES_CROSSOVER: f64 = 1.0;
66
67/// Stephens' constant term correcting `sqrt(n) * D` towards the exact
68/// Kolmogorov distribution.
69///
70/// ### References
71///
72/// Stephens, Journal of the Royal Statistical Society B, 1970
73const KS_STEPHENS_A: f64 = 0.12;
74
75/// Stephens' `1 / sqrt(n)` term, alongside [`KS_STEPHENS_A`].
76const KS_STEPHENS_B: f64 = 0.11;
77
78//////////////////
79// Effect sizes //
80//////////////////
81
82/// A type alias representing effect size results
83///
84/// ### Fields
85///
86/// * `0` - The calculated effect sizes
87/// * `1` - The corresponding standard errors
88pub type EffectSizeRes<T> = (Vec<T>, Vec<T>);
89
90/// Calculate the Hedge's g effect size and its standard error
91///
92/// ### Params
93///
94/// * `mean_a` - The mean values of group a.
95/// * `mean_b` - The mean values of group b.
96/// * `std_a` - The standard deviations of group a.
97/// * `std_b` - The standard deviations of group b.
98/// * `n_a` - Number of samples in a.
99/// * `n_b` - Number of samples in b.
100/// * `small_sample_correction` - Apply a small sample correction? Recommended
101///   when `n_a` + `n_b` ≤ 35.
102///
103/// ### Returns
104///
105/// A tuple with the effect sizes being the first element, and the standard
106/// errors the second element.
107pub fn hedge_g_effect<T>(
108    mean_a: &[T],
109    mean_b: &[T],
110    std_a: &[T],
111    std_b: &[T],
112    n_a: usize,
113    n_b: usize,
114    small_sample_correction: bool,
115) -> EffectSizeRes<T>
116where
117    T: BixverseFloat,
118{
119    assert_same_len!(mean_a, mean_b, std_a, std_b);
120
121    let n_a_t = T::from_usize(n_a).unwrap();
122    let n_b_t = T::from_usize(n_b).unwrap();
123    let total_n = T::from_usize(n_a + n_b).unwrap();
124    let two = T::from_usize(2).unwrap();
125    let three = T::from_usize(3).unwrap();
126
127    let (effect_sizes, standard_errors): (Vec<T>, Vec<T>) = mean_a
128        .par_iter()
129        .zip(mean_b.par_iter())
130        .zip(std_a.par_iter())
131        .zip(std_b.par_iter())
132        .map(|(((mean_a, mean_b), std_a), std_b)| {
133            let pooled_sd = (((n_a_t - T::one()) * std_a.powi(2)
134                + (n_b_t - T::one()) * std_b.powi(2))
135                / (total_n - two))
136                .sqrt();
137
138            let mut effect_size = (*mean_a - *mean_b) / pooled_sd;
139
140            if small_sample_correction {
141                let correction_factor = ((total_n - three)
142                    / (total_n - T::from_f64(2.25).unwrap()))
143                    * ((total_n - two) / total_n).sqrt();
144                effect_size = correction_factor * effect_size;
145            }
146
147            let standard_error =
148                ((total_n / (n_a_t * n_b_t)) + (effect_size.powi(2) / (two * total_n))).sqrt();
149
150            (effect_size, standard_error)
151        })
152        .unzip();
153
154    (effect_sizes, standard_errors)
155}
156
157///////////////////////
158// Statistical tests //
159///////////////////////
160
161/// Test alternatives for different statistical tests
162#[derive(Clone, Debug, Default)]
163pub enum TestAlternative {
164    /// Two sided test for the Z-score
165    #[default]
166    TwoSided,
167    /// One-sided test for greater than
168    Greater,
169    /// One-sided test for lesser than
170    Less,
171}
172
173/// Helper function to get the test alternative
174///
175/// ### Params
176///
177/// * `s` - String, type of test to run.
178///
179/// ### Returns
180///
181/// Option of the `TestAlternative`
182pub fn get_test_alternative(s: &str) -> Option<TestAlternative> {
183    match s.to_lowercase().as_str() {
184        "twosided" => Some(TestAlternative::TwoSided),
185        "greater" => Some(TestAlternative::Greater),
186        "less" => Some(TestAlternative::Less),
187        _ => None,
188    }
189}
190
191/// Transform Z-scores into p-values (assuming normality).
192///
193/// ### Params
194///
195/// * `z_scores` - The Z scores to transform to p-values
196///
197/// ### Returns
198///
199/// The p-value vector based on the Z scores (two sided)
200pub fn z_scores_to_pval<T>(z_scores: &[T], test_alternative: &str) -> Vec<T>
201where
202    T: BixverseFloat,
203{
204    let test_alternative = get_test_alternative(test_alternative).unwrap_or_default();
205
206    let normal = Normal::new(0.0, 1.0).unwrap();
207
208    let one = T::one();
209    let two = T::from_usize(2).unwrap();
210    let six = T::from_usize(6).unwrap();
211
212    z_scores
213        .iter()
214        .map(|&z| match test_alternative {
215            TestAlternative::TwoSided => {
216                let abs_z = z.abs();
217                if abs_z > six {
218                    let abs_z_f64 = abs_z.to_f64().unwrap();
219                    let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
220                    let p = pdf / abs_z * (one - one / (abs_z * abs_z));
221                    two * p
222                } else {
223                    let abs_z_f64 = abs_z.to_f64().unwrap();
224                    let cdf = T::from_f64(normal.cdf(abs_z_f64)).unwrap();
225                    two * (one - cdf)
226                }
227            }
228            TestAlternative::Greater => {
229                if z > six {
230                    let z_f64 = z.to_f64().unwrap();
231                    let pdf = T::from_f64(normal.pdf(z_f64)).unwrap();
232                    pdf / z * (one - one / (z * z))
233                } else {
234                    let z_f64 = z.to_f64().unwrap();
235                    let cdf = T::from_f64(normal.cdf(z_f64)).unwrap();
236                    one - cdf
237                }
238            }
239            TestAlternative::Less => {
240                let neg_six = -six;
241                if z < neg_six {
242                    let abs_z = z.abs();
243                    let abs_z_f64 = abs_z.to_f64().unwrap();
244                    let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
245                    pdf / abs_z * (one - one / (abs_z * abs_z))
246                } else {
247                    let z_f64 = z.to_f64().unwrap();
248                    T::from_f64(normal.cdf(z_f64)).unwrap()
249                }
250            }
251        })
252        .collect()
253}
254
255/// Calculate the p-value of a hypergeometric test.
256///
257/// ### Params
258///
259/// * `q` - Number of white balls drawn
260/// * `m` - Number of white balls in the urn
261/// * `n` - Number of black balls in the urn
262/// * `k` - Number of balls drawn from the urn
263///
264/// ### Return
265///
266/// The p-value of the hypergeometric test
267pub fn hypergeom_pval<T>(q: usize, m: usize, n: usize, k: usize) -> T
268where
269    T: BixverseFloat,
270{
271    let population = m + n;
272    let (n_f, m_f, k_f) = (
273        T::from_usize(n).unwrap(),
274        T::from_usize(m).unwrap(),
275        T::from_usize(k).unwrap(),
276    );
277    let population_f = T::from_usize(population).unwrap();
278
279    let upper = k.min(m);
280    if q + 1 > upper {
281        return T::zero();
282    }
283
284    // ln_gamma likely only supports f64, so convert. The terms that do not
285    // depend on `i` are computed once.
286    let m_f64 = m_f.to_f64().unwrap();
287    let n_f64 = n_f.to_f64().unwrap();
288    let k_f64 = k_f.to_f64().unwrap();
289    let pop_f64 = population_f.to_f64().unwrap();
290    let invariant = ln_gamma(m_f64 + 1.0) + ln_gamma(n_f64 + 1.0)
291        - (ln_gamma(pop_f64 + 1.0)
292            - ln_gamma(k_f64 + 1.0)
293            - ln_gamma((population_f - k_f).to_f64().unwrap() + 1.0));
294
295    let log_pmf = |i: usize| -> T {
296        let i_f = T::from_usize(i).unwrap();
297        let log_pmf_f64 = invariant
298            - ln_gamma(i_f.to_f64().unwrap() + 1.0)
299            - ln_gamma((m_f - i_f).to_f64().unwrap() + 1.0)
300            - ln_gamma((k_f - i_f).to_f64().unwrap() + 1.0)
301            - ln_gamma((n_f - (k_f - i_f)).to_f64().unwrap() + 1.0);
302        T::from_f64(log_pmf_f64).unwrap()
303    };
304
305    let max_log_prob = ((q + 1)..=upper)
306        .map(log_pmf)
307        .fold(T::neg_infinity(), |a, b| a.max(b));
308
309    let mut sum = T::zero();
310    for i in (q + 1)..=upper {
311        sum += (log_pmf(i) - max_log_prob).exp();
312    }
313
314    sum * max_log_prob.exp()
315}
316
317/// Holm-Bonferroni step-down adjustment.
318///
319/// R's `p.adjust` default, which is worth knowing: code that calls `p.adjust`
320/// without naming a method is doing Holm, not Benjamini-Hochberg. Controls the
321/// family-wise error rate, so it is markedly more conservative than
322/// [calc_fdr].
323///
324/// ### Params
325///
326/// * `pvals` - Unadjusted p-values
327///
328/// ### Returns
329///
330/// The adjusted p-values, in the input order, each capped at 1.
331pub fn p_adjust_holm<T>(pvals: &[T]) -> Vec<T>
332where
333    T: BixverseFloat,
334{
335    let n = pvals.len();
336    let mut order: Vec<usize> = (0..n).collect();
337    order.sort_by(|&a, &b| {
338        pvals[a]
339            .partial_cmp(&pvals[b])
340            .unwrap_or(std::cmp::Ordering::Equal)
341    });
342
343    let one = T::one();
344    let mut out = vec![T::zero(); n];
345    let mut running = T::zero();
346    for (rank, &idx) in order.iter().enumerate() {
347        let scaled = T::from_usize(n - rank).unwrap() * pvals[idx];
348        running = running.max(scaled);
349        out[idx] = running.min(one);
350    }
351    out
352}
353
354/// Calculate the FDR
355///
356/// Benjamini-Hochberg. See [p_adjust_holm] for the family-wise alternative,
357/// which is what R's `p.adjust` does when no method is named.
358///
359/// ### Params
360///
361/// * `pvals` - P-values for which to calculate the FDR
362///
363/// ### Returns
364///
365/// The calculated FDRs
366pub fn p_adjust_fdr<T>(pvals: &[T]) -> Vec<T>
367where
368    T: BixverseFloat,
369{
370    let n = pvals.len();
371    if n == 0 {
372        return Vec::new();
373    }
374    let n_t = T::from_usize(n).unwrap();
375    let one = T::one();
376
377    let mut indexed_pval: Vec<(usize, T)> =
378        pvals.par_iter().enumerate().map(|(i, &x)| (i, x)).collect();
379
380    // Unstable and parallel are both safe here despite the sort deciding ranks.
381    indexed_pval
382        .par_sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
383
384    let adj_pvals_tmp: Vec<T> = indexed_pval
385        .par_iter()
386        .enumerate()
387        .map(|(i, (_, p))| {
388            let i_t = T::from_usize(i + 1).unwrap();
389            (n_t / i_t) * *p
390        })
391        .collect();
392
393    let mut current_min = adj_pvals_tmp[n - 1].min(one);
394    let mut monotonic_adj = vec![current_min; n];
395
396    for i in (0..n - 1).rev() {
397        current_min = current_min.min(adj_pvals_tmp[i]).min(one);
398        monotonic_adj[i] = current_min;
399    }
400
401    let mut adj_pvals = vec![T::zero(); n];
402
403    for (i, &(original_idx, _)) in indexed_pval.iter().enumerate() {
404        adj_pvals[original_idx] = monotonic_adj[i];
405    }
406
407    adj_pvals
408}
409
410/// Deprecated, please use [`p_adjust_fdr()`]
411#[deprecated(since = "0.4.8", note = "Renamed to p_adjust_fdr()")]
412pub fn calc_fdr<T>(pvals: &[T]) -> Vec<T>
413where
414    T: BixverseFloat,
415{
416    p_adjust_fdr(pvals)
417}
418
419////////////
420// MANOVA //
421////////////
422
423/// ManovaResults
424#[derive(Debug, Clone)]
425#[allow(dead_code)]
426pub struct ManovaResult<T>
427where
428    T: BixverseFloat,
429{
430    /// Between-groups SSCP matrix
431    pub sscp_between: Mat<T>,
432    /// Within-groups SSCP matrix
433    pub sscp_within: Mat<T>,
434    /// Total SSCP matrix
435    pub sscp_total: Mat<T>,
436    /// Degrees of freedom between groups
437    pub df_between: usize,
438    /// Degrees of freedom within groups
439    pub df_within: usize,
440    /// Total degrees of freedom
441    pub df_total: usize,
442    /// Number of variables
443    pub n_vars: usize,
444    /// Means for each group
445    pub group_means: Vec<Vec<T>>,
446    /// Overall means
447    pub overall_mean: Vec<T>,
448}
449
450#[allow(dead_code)]
451impl<T> ManovaResult<T>
452where
453    T: BixverseFloat + std::iter::Sum,
454{
455    /// Non-zero eigenvalue of E⁻¹H for two-group MANOVA.
456    ///
457    /// For rank-1 H, the unique non-zero generalised eigenvalue equals
458    /// trace(`E^-1 x H`), avoiding `det(E) / det(E+H)` which overflows f64 for
459    /// moderately large `p (entries of E scale ~ n^3 / 12` for ranked data).
460    ///
461    /// ### Returns
462    ///
463    /// The eigenvalue as `T`, clamped to zero to handle numerical noise.
464    fn rank1_eigenvalue(&self) -> T {
465        debug_assert_eq!(
466            self.df_between, 1,
467            "rank1_eigenvalue assumes two-group MANOVA (df_between == 1)"
468        );
469        let e_inv = self.sscp_within.partial_piv_lu().inverse();
470        let prod = &e_inv * &self.sscp_between;
471        let trace: T = prod.diagonal().column_vector().iter().copied().sum::<T>();
472        // Numerical noise can make this slightly negative for ill-conditioned E.
473        trace.max(T::zero())
474    }
475
476    /// Wilks' Lambda for two-group MANOVA.
477    ///
478    /// Λ = 1 / (1 + λ), where λ is the rank-1 eigenvalue of E⁻¹H.
479    ///
480    /// ### Returns
481    ///
482    /// Wilks' Λ as `T`.
483    pub fn wilks_lambda(&self) -> T {
484        let lambda = self.rank1_eigenvalue();
485        T::one() / (T::one() + lambda)
486    }
487
488    /// Pillai's trace for two-group MANOVA.
489    ///
490    /// V = λ / (1 + λ), where λ is the rank-1 eigenvalue of `E^-1 x H`.
491    ///
492    /// ### Returns
493    ///
494    /// Pillai's V as `T`.
495    pub fn pillai_trace(&self) -> T {
496        let lambda = self.rank1_eigenvalue();
497        lambda / (T::one() + lambda)
498    }
499
500    /// Exact F-test for two-group MANOVA (Hotelling's T^2 form).
501    ///
502    /// F = ((n - p - 1) / p) * λ,   df = (p, n - p - 1)
503    ///
504    /// Returns (NaN, 1.0) when the inputs are degenerate
505    /// (n - p - 1 <= 0, non-finite eigenvalue, etc.).
506    fn two_group_f_test(&self) -> (T, T) {
507        let lambda = self.rank1_eigenvalue();
508        let p = T::from_usize(self.n_vars).unwrap();
509        let n = T::from_usize(self.df_within + self.df_between + 1).unwrap();
510        let df2 = n - p - T::one();
511
512        if !lambda.is_finite() || df2 <= T::zero() {
513            return (T::nan(), T::one());
514        }
515
516        let f_stat = (df2 / p) * lambda;
517
518        let df1_f64 = p.to_f64().unwrap();
519        let df2_f64 = df2.to_f64().unwrap();
520        let f_f64 = f_stat.to_f64().unwrap();
521
522        if !f_f64.is_finite() || f_f64 < 0.0 {
523            return (f_stat, T::one());
524        }
525
526        let f_dist = match FisherSnedecor::new(df1_f64, df2_f64) {
527            Ok(d) => d,
528            Err(_) => return (f_stat, T::one()),
529        };
530        let p_value = T::from_f64(1.0 - f_dist.cdf(f_f64)).unwrap();
531        (f_stat, p_value)
532    }
533
534    /// F-statistic and p-value derived from Wilks' Lambda.
535    ///
536    /// For two-group MANOVA this coincides with the Pillai F-test.
537    ///
538    /// ### Returns
539    ///
540    /// A tuple of `(f_stat, p_value)` as `(T, T)`.
541    pub fn wilks_f_test(&self) -> (T, T) {
542        self.two_group_f_test()
543    }
544
545    /// F-statistic and p-value derived from Pillai's trace.
546    ///
547    /// For two-group MANOVA this coincides with the Wilks F-test.
548    ///
549    /// ### Returns
550    ///
551    /// A tuple of `(f_stat, p_value)` as `(T, T)`.
552    pub fn pillai_f_test(&self) -> (T, T) {
553        self.two_group_f_test()
554    }
555}
556
557/// ManovaSummary
558#[derive(Debug)]
559pub struct ManovaSummary<T>
560where
561    T: BixverseFloat,
562{
563    /// Wilks' lambda value
564    pub wilks_lambda: T,
565    /// Pillai's trace value
566    pub pillai_trace: T,
567    /// Degrees of freedom between groups
568    pub df_between: usize,
569    /// Degrees of freedom within groups
570    pub df_within: usize,
571    /// F statistic according to Wilk
572    pub f_stat_wilk: T,
573    /// P-value according to Wilk
574    pub p_val_wilk: T,
575    /// F statistic according to Pillai
576    pub f_stat_pillai: T,
577    /// P-value according to Pillai
578    pub p_val_pillai: T,
579}
580
581impl<T> ManovaSummary<T>
582where
583    T: BixverseFloat + std::iter::Sum,
584{
585    /// Get the summary results from a `ManovaRes`
586    ///
587    /// ### Params
588    ///
589    /// * `res` - The calculated ManovaResults
590    ///
591    /// ### Returns
592    ///
593    /// The `ManovaSummary`.
594    pub fn from_manova_res(res: &ManovaResult<T>) -> Self {
595        let (f_stat_wilk, p_val_wilk) = res.wilks_f_test();
596        let (f_stat_pillai, p_val_pillai) = res.pillai_f_test();
597
598        ManovaSummary {
599            wilks_lambda: res.wilks_lambda(),
600            pillai_trace: res.pillai_trace(),
601            df_between: res.df_between,
602            df_within: res.df_within,
603            f_stat_wilk,
604            p_val_wilk,
605            f_stat_pillai,
606            p_val_pillai,
607        }
608    }
609}
610
611///////////
612// ANOVA //
613///////////
614
615/// AnovaSummary (based on MANOVA models)
616#[derive(Debug, Clone)]
617#[allow(dead_code)]
618pub struct AnovaSummary<T>
619where
620    T: BixverseFloat,
621{
622    /// Variable index
623    pub variable_index: usize,
624    /// Sum of squares between groups
625    pub ss_between: T,
626    /// Sum of squares within groups
627    pub ss_within: T,
628    /// Mean square between groups
629    pub ms_between: T,
630    /// Mean square within groups
631    pub ms_within: T,
632    /// F statistic
633    pub f_stat: T,
634    /// P-value
635    pub p_val: T,
636}
637
638/// Generates from MANOVA results the AnovaSummary
639///
640/// ### Params
641///
642/// * `res` - The MANOVA result to analyse
643///
644/// ### Returns
645///
646/// A vector of AnovaSummaries
647pub fn summary_aov<T>(res: &ManovaResult<T>) -> Vec<AnovaSummary<T>>
648where
649    T: BixverseFloat,
650{
651    let mut aov_res = Vec::with_capacity(res.n_vars);
652    let df_between_t = T::from_usize(res.df_between).unwrap();
653    let df_within_t = T::from_usize(res.df_within).unwrap();
654
655    let f_dist = FisherSnedecor::new(
656        df_between_t.to_f64().unwrap(),
657        df_within_t.to_f64().unwrap(),
658    )
659    .ok();
660
661    for var_idx in 0..res.n_vars {
662        let ss_between = res.sscp_between[(var_idx, var_idx)];
663        let ss_within = res.sscp_within[(var_idx, var_idx)];
664        let ms_between = ss_between / df_between_t;
665        let ms_within = ss_within / df_within_t;
666
667        let (f_stat, p_val) = if ms_within <= T::zero() {
668            (T::nan(), T::one())
669        } else {
670            let f = ms_between / ms_within;
671            let f_f64 = f.to_f64().unwrap();
672            let pv = match (&f_dist, f_f64.is_finite() && f_f64 >= 0.0) {
673                (Some(d), true) => T::from_f64(1.0 - d.cdf(f_f64)).unwrap(),
674                _ => T::one(),
675            };
676            (f, pv)
677        };
678
679        aov_res.push(AnovaSummary {
680            variable_index: var_idx,
681            ss_between,
682            ss_within,
683            ms_between,
684            ms_within,
685            f_stat,
686            p_val,
687        });
688    }
689
690    aov_res
691}
692
693///////////////////
694// Probabilities //
695///////////////////
696
697/// Implementation of the trigamma function (second derivative of ln(gamma(x)))
698///
699/// ### Params
700///
701/// * `x` - The value for which to calculate the trigamma function.
702///
703/// ### Returns
704///
705/// The trigamma value for the given input.
706pub fn trigamma<T: BixverseFloat>(x: T) -> T {
707    let mut x = x;
708    let mut result = T::zero();
709
710    if x <= T::from_f64(5.0).unwrap() {
711        while x < T::from_f64(5.0).unwrap() {
712            result += T::one() / (x * x);
713            x += T::one();
714        }
715    }
716
717    let xx = x * x;
718    result += T::one() / x
719        + T::one() / (T::from_f64(2.0).unwrap() * xx)
720        + T::one() / (T::from_f64(6.0).unwrap() * xx * x);
721
722    let xxx = xx * x;
723    result += -T::one() / (T::from_f64(30.0).unwrap() * xxx * x)
724        + T::one() / (T::from_f64(42.0).unwrap() * xxx * xx * x)
725        - T::one() / (T::from_f64(30.0).unwrap() * xxx * xxx * x);
726
727    result
728}
729
730///////////
731// Other //
732///////////
733
734/// Logit function
735///
736/// ### Params
737///
738/// * `p` - Probability value (must be in (0, 1))
739///
740/// ### Returns
741///
742/// Log-odds: ln(p / (1-p))
743pub fn logit<T>(p: T) -> T
744where
745    T: BixverseFloat,
746{
747    (p / (T::one() - p)).ln()
748}
749
750/// Inverse logit (sigmoid) function
751///
752/// ### Params
753///
754/// * `q` - Log-odds value
755///
756/// ### Returns
757///
758/// Probability: exp(q) / (1 + exp(q))
759pub fn inv_logit<T>(q: T) -> T
760where
761    T: BixverseFloat,
762{
763    q.exp() / (T::one() + q.exp())
764}
765
766/////////////////////
767// Critical values //
768/////////////////////
769
770/// Calculate the critical value using bootstrap resampling
771///
772/// ### Params
773///
774/// * `values` - Slice of values to resample from.
775/// * `sample_size` - Number of samples to draw in the bootstrap sample.
776/// * `alpha` - The significance level for the critical value.
777/// * `seed` - Random seed for reproducibility.
778///
779/// ### Returns
780///
781/// The critical value at the specified alpha level.
782pub fn calculate_critval<T: BixverseFloat>(
783    values: &[T],
784    sample_size: usize,
785    alpha: &T,
786    seed: usize,
787) -> T {
788    let mut rng = StdRng::seed_from_u64(seed as u64);
789    let mut random_sample: Vec<T> = (0..sample_size)
790        .map(|_| {
791            let index = rng.random_range(0..values.len());
792            values[index]
793        })
794        .collect();
795    random_sample.sort_by(|a, b| b.partial_cmp(a).unwrap());
796    let index = (*alpha * T::from_usize(random_sample.len()).unwrap())
797        .ceil()
798        .to_usize()
799        .unwrap();
800    random_sample[index + 1]
801}
802
803///////////////////////
804// Distribution fits //
805///////////////////////
806
807/// Maximum likelihood fit of a gamma with the location fixed at zero.
808///
809/// The scale drops out of the likelihood analytically as `theta = mean / k`,
810/// which leaves the one-dimensional score equation
811/// `ln k - psi(k) = ln(mean) - mean(ln x)`. The right-hand side is the log of
812/// the ratio of the arithmetic to the geometric mean, so it is non-negative and
813/// zero only for degenerate data. Newton on that equation converges in a
814/// handful of steps from Minka's closed-form starting point.
815///
816/// ### Params
817///
818/// * `x` - Observations, all strictly positive and finite
819///
820/// ### Returns
821///
822/// `(shape, scale)`, or [`CoreErrors::InvalidArgument`] when the sample is
823/// empty, holds a non-positive or non-finite value, or has less log-scale
824/// spread than [`GAMMA_MLE_MIN_LOG_SPREAD`], which pins the shape at absurd
825/// values.
826///
827/// ### References
828///
829/// Minka, Estimating a Gamma distribution, 2002
830pub fn fit_gamma_mle(x: &[f64]) -> Result<(f64, f64), CoreErrors> {
831    if x.is_empty() {
832        return Err(CoreErrors::InvalidArgument(
833            "Cannot fit a gamma distribution to an empty sample.".to_string(),
834        ));
835    }
836
837    let n = x.len() as f64;
838    let mut sum = 0.0;
839    let mut sum_log = 0.0;
840    for &value in x {
841        if !(value.is_finite() && value > 0.0) {
842            return Err(CoreErrors::InvalidArgument(format!(
843                "Gamma fitting needs finite, strictly positive observations; got {value}."
844            )));
845        }
846        sum += value;
847        sum_log += value.ln();
848    }
849
850    let mean = sum / n;
851    // log of the arithmetic-to-geometric mean ratio; zero iff every x is equal
852    let s = mean.ln() - sum_log / n;
853
854    if !s.is_finite() || s <= GAMMA_MLE_MIN_LOG_SPREAD {
855        return Err(CoreErrors::InvalidArgument(format!(
856            "Gamma fitting needs spread on the log scale; got {s}, which is at or below {GAMMA_MLE_MIN_LOG_SPREAD}."
857        )));
858    }
859
860    // Minka's approximation, accurate to about 1.5% before any refinement
861    let mut shape = (3.0 - s + ((3.0 - s) * (3.0 - s) + 24.0 * s).sqrt()) / (12.0 * s);
862
863    for _ in 0..GAMMA_MLE_MAX_ITER {
864        let score = shape.ln() - digamma(shape) - s;
865        let derivative = 1.0 / shape - trigamma(shape);
866        if derivative.abs() < f64::MIN_POSITIVE {
867            break;
868        }
869        let next = shape - score / derivative;
870        // Newton can overshoot into the non-positive half on a near-degenerate
871        // sample; halving towards the current point keeps it in the domain.
872        let next = if next.is_finite() && next > 0.0 {
873            next
874        } else {
875            0.5 * shape
876        };
877        let step = (next - shape).abs();
878        shape = next;
879        if step <= GAMMA_MLE_TOL * shape {
880            break;
881        }
882    }
883
884    Ok((shape, mean / shape))
885}
886
887/////////////////////
888// Goodness of fit //
889/////////////////////
890
891/// Result of a one-sample Kolmogorov-Smirnov test
892#[derive(Clone, Copy, Debug)]
893pub struct KsTestRes {
894    /// The two-sided KS statistic, the largest gap between the empirical and
895    /// the reference CDF
896    pub statistic: f64,
897    /// Asymptotic two-sided p-value under the null that the sample came from
898    /// the reference distribution
899    pub pval: f64,
900}
901
902/// Survival function of the Kolmogorov distribution.
903///
904/// Two series, split at [`KS_SERIES_CROSSOVER`]. Above it the alternating form
905/// `Q = 2 sum (-1)^(j-1) exp(-2 j^2 lambda^2)`; below it the theta-transformed
906/// form `Q = 1 - sqrt(2 pi) / lambda * sum exp(-(2k-1)^2 pi^2 / (8 lambda^2))`,
907/// which is the same function written so that small `lambda` converges fast.
908///
909/// One series alone will not do. The alternating form needs terms in proportion
910/// to `1 / lambda`, so at `lambda = 0.001` it is nowhere near converged after a
911/// hundred of them and its partial sum is 0.02 where the answer is 1.
912///
913/// ### Params
914///
915/// * `lambda` - The scaled KS statistic, non-negative
916///
917/// ### Returns
918///
919/// `Q(lambda)` in `[0, 1]`, decreasing in `lambda`. Zero gives 1.0.
920///
921/// ### References
922///
923/// Press et al., Numerical Recipes, 3rd ed., section 6.14
924fn kolmogorov_sf(lambda: f64) -> f64 {
925    if lambda <= 0.0 {
926        return 1.0;
927    }
928
929    if lambda >= KS_SERIES_CROSSOVER {
930        let a = -2.0 * lambda * lambda;
931        let mut sum = 0.0;
932        let mut sign = 1.0;
933
934        for j in 1..=KS_MAX_TERMS {
935            let term = (a * (j * j) as f64).exp();
936            sum += sign * term;
937            if term <= KS_SERIES_EPS * sum.abs() {
938                break;
939            }
940            sign = -sign;
941        }
942
943        return (2.0 * sum).clamp(0.0, 1.0);
944    }
945
946    // exp(-(2k-1)^2 * pi^2 / (8 lambda^2)), summed over odd squares
947    let a = -std::f64::consts::PI * std::f64::consts::PI / (8.0 * lambda * lambda);
948    let mut sum = 0.0;
949
950    for k in 1..=KS_MAX_TERMS {
951        let odd = (2 * k - 1) as f64;
952        let term = (a * odd * odd).exp();
953        sum += term;
954        if term <= KS_SERIES_EPS * sum {
955            break;
956        }
957    }
958
959    (1.0 - (2.0 * std::f64::consts::PI).sqrt() / lambda * sum).clamp(0.0, 1.0)
960}
961
962/// One-sample Kolmogorov-Smirnov test against a fully specified distribution.
963///
964/// The p-value is the asymptotic Kolmogorov form with Stephens' finite-sample
965/// correction on the statistic, which tracks the exact distribution to about
966/// three decimal places from `n = 5` upwards. `scipy.stats.kstest` evaluates
967/// the exact `kstwo` distribution instead, so the two agree in the range that
968/// matters for a goodness-of-fit decision but not digit for digit.
969///
970/// The reference distribution must not have been fitted on this same sample if
971/// the p-value is to be read literally: estimating parameters from the data
972/// shrinks the statistic and makes the test conservative. That is a known
973/// property of how the calibration diagnostics use it, not something this
974/// function corrects for.
975///
976/// ### Params
977///
978/// * `x` - The sample. Non-finite values are an error rather than being dropped
979/// * `cdf` - The reference CDF, evaluated pointwise. Must be non-decreasing
980///
981/// ### Returns
982///
983/// A [`KsTestRes`], or [`CoreErrors::InvalidArgument`] for an empty sample
984/// or a non-finite observation.
985///
986/// ### References
987///
988/// Stephens, Journal of the Royal Statistical Society B, 1970
989pub fn ks_test_1samp<F>(x: &[f64], cdf: F) -> Result<KsTestRes, CoreErrors>
990where
991    F: Fn(f64) -> f64,
992{
993    if x.is_empty() {
994        return Err(CoreErrors::InvalidArgument(
995            "The Kolmogorov-Smirnov test needs a non-empty sample.".to_string(),
996        ));
997    }
998
999    let mut sorted = x.to_vec();
1000    if let Some(bad) = sorted.iter().find(|v| !v.is_finite()) {
1001        return Err(CoreErrors::InvalidArgument(format!(
1002            "The Kolmogorov-Smirnov test needs finite observations; got {bad}."
1003        )));
1004    }
1005    sorted.sort_unstable_by(f64::total_cmp);
1006
1007    let n = sorted.len();
1008    let inv_n = 1.0 / n as f64;
1009    let mut statistic = 0.0f64;
1010
1011    for (i, &value) in sorted.iter().enumerate() {
1012        let theoretical = cdf(value).clamp(0.0, 1.0);
1013        // The empirical CDF steps at each observation, so both the gap below the
1014        // step and the gap above it have to be checked
1015        let above = (i + 1) as f64 * inv_n - theoretical;
1016        let below = theoretical - i as f64 * inv_n;
1017        statistic = statistic.max(above).max(below);
1018    }
1019
1020    let root_n = (n as f64).sqrt();
1021    let lambda = (root_n + KS_STEPHENS_A + KS_STEPHENS_B / root_n) * statistic;
1022
1023    Ok(KsTestRes {
1024        statistic,
1025        pval: kolmogorov_sf(lambda),
1026    })
1027}
1028
1029//////////////
1030// Outliers //
1031//////////////
1032
1033/// Type of outlier detection for MAD thresholding
1034#[derive(Clone, Debug, Default)]
1035pub enum OutlierDirection {
1036    /// Check if outlier is below OR above the thresholds
1037    #[default]
1038    Both,
1039    /// Check if outlier is below the threshold
1040    Below,
1041    /// Check if outlier is above the threshold
1042    Above,
1043}
1044
1045/// Helper function to get the outlier detection method
1046///
1047/// ### Params
1048///
1049/// * `s` - String, type of test to run.
1050///
1051/// ### Returns
1052///
1053/// Option of the `OutlierDirection`
1054pub fn parse_outlier_type(s: &str) -> Option<OutlierDirection> {
1055    match s.to_lowercase().as_str() {
1056        "below" => Some(OutlierDirection::Below),
1057        "above" => Some(OutlierDirection::Above),
1058        "twosided" => Some(OutlierDirection::Both),
1059        _ => None,
1060    }
1061}
1062
1063/// MAD outlier detection
1064///
1065/// With `r_standard` the MAD is scaled by [`MAD_SCALE`], so `threshold` counts
1066/// standard deviations under normality, as R's `mad()` and scater's
1067/// `isOutlier` do. Without it the raw MAD is used.
1068///
1069/// ### Params
1070///
1071/// * `x` - Slice of values to check for outliers.
1072/// * `threshold` - Number of (scaled or raw) MADs to accept as not being an
1073///   outlier.
1074/// * `direction` - Direction to check for outliers (below, above, or both).
1075/// * `r_standard` - Scale the MAD by [`MAD_SCALE`] like R's `mad()`.
1076///
1077/// ### Returns
1078///
1079/// A tuple with a vector of booleans indicating whether each value in the input
1080/// is an outlier and the value of applied margin.
1081pub fn mad_outlier<T>(
1082    x: &[T],
1083    threshold: T,
1084    direction: OutlierDirection,
1085    r_standard: bool,
1086) -> (Vec<bool>, T)
1087where
1088    T: BixverseFloat,
1089{
1090    let median_val = match median(x) {
1091        Some(m) => m,
1092        None => return (vec![], T::zero()),
1093    };
1094    let scale = r_standard.then(|| T::from_f64(MAD_SCALE).unwrap());
1095    let mad_val = match mad(x, scale) {
1096        Some(m) => m,
1097        None => return (vec![], T::zero()),
1098    };
1099    let margin = threshold * mad_val;
1100    let res = x
1101        .iter()
1102        .map(|&v| match direction {
1103            OutlierDirection::Below => v < median_val - margin,
1104            OutlierDirection::Above => v > median_val + margin,
1105            OutlierDirection::Both => (v - median_val).abs() > margin,
1106        })
1107        .collect::<Vec<bool>>();
1108    (res, margin)
1109}
1110
1111//////////////////////////
1112// Binned robust z-score //
1113//////////////////////////
1114
1115/// Robust z-score of `y` within bins of `x`, sctransform's
1116/// `robust_scale_binned`.
1117///
1118/// Each observation is placed in the half-open bin `(breaks[b], breaks[b + 1]]`
1119/// that contains its `x`, matching R's `cut`, and scored against the median and
1120/// scaled MAD of the `y` values falling in the same bin. Observations outside
1121/// the break range score `0.0`, as R's `score <- rep(0, length(x))` leaves
1122/// them.
1123///
1124/// ### Params
1125///
1126/// * `y` - Values to score.
1127/// * `x` - Binning variable, parallel to `y`.
1128/// * `breaks` - Ascending bin edges, at least two.
1129///
1130/// ### Returns
1131///
1132/// One score per observation, in input order, or
1133/// [`CoreErrors::LengthMismatch`] when `x` and `y` disagree.
1134pub fn robust_scale_binned(y: &[f64], x: &[f64], breaks: &[f64]) -> Result<Vec<f64>, CoreErrors> {
1135    if x.len() != y.len() {
1136        return Err(CoreErrors::LengthMismatch {
1137            name: "y",
1138            expected: x.len(),
1139            found: y.len(),
1140        });
1141    }
1142    if breaks.len() < 2 {
1143        return Err(CoreErrors::LengthMismatch {
1144            name: "breaks",
1145            expected: 2,
1146            found: breaks.len(),
1147        });
1148    }
1149
1150    let n_bins = breaks.len() - 1;
1151    let mut members: Vec<Vec<usize>> = vec![Vec::new(); n_bins];
1152
1153    for (i, &xi) in x.iter().enumerate() {
1154        // R's `cut` is left-open, right-closed, and NA outside the range.
1155        if xi <= breaks[0] || xi > breaks[n_bins] {
1156            continue;
1157        }
1158        // partition_point lands on the first edge >= xi, so the bin below it.
1159        let b = breaks.partition_point(|&e| e < xi) - 1;
1160        members[b.min(n_bins - 1)].push(i);
1161    }
1162
1163    let mut score = vec![0.0_f64; y.len()];
1164    for bin in &members {
1165        if bin.is_empty() {
1166            continue;
1167        }
1168        let vals: Vec<f64> = bin.iter().map(|&i| y[i]).collect();
1169        // Both are Some by construction: the bin is non-empty.
1170        let med = median(&vals).expect("non-empty bin");
1171        let scale = mad(&vals, Some(MAD_SCALE)).expect("non-empty bin") + f64::EPSILON;
1172        for (&i, v) in bin.iter().zip(vals.iter()) {
1173            score[i] = (v - med) / scale;
1174        }
1175    }
1176
1177    Ok(score)
1178}
1179
1180/// Binned robust outlier detection, sctransform's `is_outlier`.
1181///
1182/// Scores `y` against `x` on two bin grids offset by half a bin width, and
1183/// flags an observation only when it is extreme on **both**. The offset grid is
1184/// what stops a point that merely sits near a bin edge from being called an
1185/// outlier. Bin width is half the Sheather-Jones bandwidth scaled by the range
1186/// of `x`, so the grid adapts to how `x` is distributed.
1187///
1188/// ### Params
1189///
1190/// * `y` - Values to test.
1191/// * `x` - Binning variable, parallel to `y`.
1192/// * `th` - Absolute robust z-score above which a point counts as extreme.
1193///   sctransform uses `10.0`.
1194///
1195/// ### Returns
1196///
1197/// One flag per observation, or a [`CoreErrors`] when the inputs disagree
1198/// in length or the bandwidth cannot be estimated.
1199///
1200/// ### References
1201///
1202/// Hafemeister & Satija, Genome Biology, 2019
1203pub fn is_outlier(y: &[f64], x: &[f64], th: f64) -> Result<Vec<bool>, CoreErrors> {
1204    if x.len() != y.len() {
1205        return Err(CoreErrors::LengthMismatch {
1206            name: "y",
1207            expected: x.len(),
1208            found: y.len(),
1209        });
1210    }
1211
1212    let (min, max) = x
1213        .iter()
1214        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1215            (lo.min(v), hi.max(v))
1216        });
1217    let bin_width = (max - min) * bw_sj(x)? / 2.0;
1218    if !bin_width.is_finite() || bin_width <= 0.0 {
1219        return Err(CoreErrors::BandwidthNoBracket);
1220    }
1221    let eps = f64::EPSILON * OUTLIER_EPS_MULT;
1222
1223    // R: seq(from, to, by), i.e. from, from + by, ... while <= to.
1224    let seq_by = |from: f64, to: f64, by: f64| -> Vec<f64> {
1225        let n = ((to - from) / by).floor() as usize;
1226        (0..=n).map(|i| from + i as f64 * by).collect()
1227    };
1228
1229    let breaks1 = seq_by(min - eps, max + bin_width, bin_width);
1230    let breaks2 = seq_by(min - eps - bin_width / 2.0, max + bin_width, bin_width);
1231
1232    let score1 = robust_scale_binned(y, x, &breaks1)?;
1233    let score2 = robust_scale_binned(y, x, &breaks2)?;
1234
1235    Ok(score1
1236        .iter()
1237        .zip(score2.iter())
1238        .map(|(a, b)| a.abs().min(b.abs()) > th)
1239        .collect())
1240}
1241
1242///////////////////
1243// One-way ANOVA //
1244///////////////////
1245
1246/// One-way analysis of variance across an arbitrary number of groups.
1247///
1248/// The [ManovaResult] path in this module is two-group only, so this exists for
1249/// the many-group case: does a feature vary across samples at all. Empty groups
1250/// are dropped rather than counted, so a caller may pass a level set wider than
1251/// the data.
1252///
1253/// ### Params
1254///
1255/// * `values` - Observations
1256/// * `groups` - Level code per observation, parallel to `values`, each in
1257///   `0..n_groups`
1258/// * `n_groups` - Number of levels
1259///
1260/// ### Returns
1261///
1262/// `(f_statistic, p_value)`
1263pub fn one_way_anova<T: BixverseFloat>(
1264    values: &[T],
1265    groups: &[usize],
1266    n_groups: usize,
1267) -> Result<(T, T), CoreErrors> {
1268    if values.len() != groups.len() {
1269        return Err(CoreErrors::ShapeMismatch {
1270            expected: (values.len(), 1),
1271            got: (groups.len(), 1),
1272        });
1273    }
1274    let n = values.len();
1275    let mut counts = vec![0_usize; n_groups];
1276    let mut sums = vec![0.0_f64; n_groups];
1277    for (v, &g) in values.iter().zip(groups.iter()) {
1278        if g >= n_groups {
1279            return Err(CoreErrors::InvalidArgument(format!(
1280                "group code {g} is outside 0..{n_groups}."
1281            )));
1282        }
1283        counts[g] += 1;
1284        sums[g] += v.to_f64().unwrap_or(f64::NAN);
1285    }
1286
1287    let n_used: usize = counts.iter().filter(|&&c| c > 0).count();
1288    if n_used < 2 {
1289        return Err(CoreErrors::InvalidArgument(
1290            "one-way ANOVA needs at least two non-empty groups.".to_string(),
1291        ));
1292    }
1293    let df_between = (n_used - 1) as f64;
1294    let df_within = (n - n_used) as f64;
1295    if df_within <= 0.0 {
1296        return Err(CoreErrors::InvalidArgument(
1297            "one-way ANOVA has no residual degrees of freedom.".to_string(),
1298        ));
1299    }
1300
1301    let grand_mean: f64 = sums.iter().sum::<f64>() / n as f64;
1302    let ss_between: f64 = counts
1303        .iter()
1304        .zip(sums.iter())
1305        .filter(|(c, _)| **c > 0)
1306        .map(|(&c, &s)| {
1307            let d = s / c as f64 - grand_mean;
1308            c as f64 * d * d
1309        })
1310        .sum();
1311    let group_means: Vec<f64> = sums
1312        .iter()
1313        .zip(counts.iter())
1314        .map(|(&s, &c)| s / c as f64)
1315        .collect();
1316    let ss_within: f64 = values
1317        .iter()
1318        .zip(groups.iter())
1319        .map(|(v, &g)| {
1320            let d = v.to_f64().unwrap_or(f64::NAN) - group_means[g];
1321            d * d
1322        })
1323        .sum();
1324
1325    // Finiteness first: a non-finite sum fails both `> 0.0` tests below and
1326    // would fall through to the F = 0 arm.
1327    if !ss_within.is_finite() || !ss_between.is_finite() {
1328        return Err(CoreErrors::InvalidArgument(
1329            "the values contain a non-finite entry.".to_string(),
1330        ));
1331    }
1332    // A feature that is constant within every group but varies between them has
1333    // an infinite F. R prints Inf and a p of 0, so do the same rather than
1334    // dividing by zero and returning NaN.
1335    let f_stat = if ss_within > 0.0 {
1336        (ss_between / df_between) / (ss_within / df_within)
1337    } else if ss_between > 0.0 {
1338        f64::INFINITY
1339    } else {
1340        0.0
1341    };
1342    let p = f_sf(f_stat, df_between, df_within)?;
1343
1344    Ok((
1345        T::from_f64(f_stat).unwrap_or_else(T::nan),
1346        T::from_f64(p).unwrap_or_else(T::nan),
1347    ))
1348}
1349
1350/////////////////////////
1351// Partial correlation //
1352/////////////////////////
1353
1354/// First-order partial correlation of `x` and `y` given a single control `z`.
1355///
1356/// `r_xy.z = (r_xy - r_xz r_yz) / sqrt((1 - r_xz^2)(1 - r_yz^2))`, which for
1357/// one control variable is what inverting the 3x3 correlation matrix reduces
1358/// to. The Spearman variant ranks all three vectors first, with average ranks
1359/// for ties, then runs the same formula.
1360///
1361/// The test is `t = r sqrt((n - 3) / (1 - r^2))` on `n - 3` degrees of freedom,
1362/// two-sided: one degree of freedom is spent on the control on top of the two a
1363/// plain correlation costs.
1364///
1365/// ### Params
1366///
1367/// * `x` - First variable
1368/// * `y` - Second variable
1369/// * `z` - Control variable
1370/// * `spearman` - Rank-transform first, which is what DIALOGUE asks for
1371///
1372/// ### Returns
1373///
1374/// `(estimate, p_value)`
1375pub fn partial_correlation<T: BixverseFloat + Sync>(
1376    x: &[T],
1377    y: &[T],
1378    z: &[T],
1379    spearman: bool,
1380) -> Result<(T, T), CoreErrors> {
1381    let n = x.len();
1382    if y.len() != n {
1383        return Err(CoreErrors::ShapeMismatch {
1384            expected: (n, 3),
1385            got: (y.len(), 3),
1386        });
1387    }
1388    if z.len() != n {
1389        return Err(CoreErrors::ShapeMismatch {
1390            expected: (n, 3),
1391            got: (z.len(), 3),
1392        });
1393    }
1394    if n < 4 {
1395        return Err(CoreErrors::InvalidArgument(format!(
1396            "a first-order partial correlation needs at least 4 observations; got {n}."
1397        )));
1398    }
1399
1400    let (xv, yv, zv) = if spearman {
1401        (rank_vector(x), rank_vector(y), rank_vector(z))
1402    } else {
1403        (x.to_vec(), y.to_vec(), z.to_vec())
1404    };
1405
1406    let r_xy = pearson_correlation(&xv, &yv).unwrap_or(f64::NAN);
1407    let r_xz = pearson_correlation(&xv, &zv).unwrap_or(f64::NAN);
1408    let r_yz = pearson_correlation(&yv, &zv).unwrap_or(f64::NAN);
1409
1410    let denom = ((1.0 - r_xz * r_xz) * (1.0 - r_yz * r_yz)).sqrt();
1411    if !(denom.is_finite() && denom > 0.0) {
1412        return Ok((T::nan(), T::nan()));
1413    }
1414    let r = (r_xy - r_xz * r_yz) / denom;
1415
1416    let df = (n - 3) as f64;
1417    // |r| == 1 leaves nothing to test; R reports a p of 0 there.
1418    let p = if r.abs() >= 1.0 {
1419        0.0
1420    } else {
1421        let t_stat = r * (df / (1.0 - r * r)).sqrt();
1422        t_pval_two_sided(t_stat, df)?
1423    };
1424
1425    Ok((
1426        T::from_f64(r).unwrap_or_else(T::nan),
1427        T::from_f64(p).unwrap_or_else(T::nan),
1428    ))
1429}
1430
1431//////////////////////
1432// Fisher combining //
1433//////////////////////
1434
1435/// Combines independent p-values by Fisher's method.
1436///
1437/// `-2 sum(log p)` is chi-squared on `2m` degrees of freedom under the joint
1438/// null. `NaN` entries are skipped, and a single surviving p-value is returned
1439/// unchanged rather than passed through the chi-squared, which is what
1440/// DIALOGUE's `get.fisher.p.value` does.
1441///
1442/// ### Params
1443///
1444/// * `pvals` - p-values in `[0, 1]`. `NaN` entries are ignored.
1445///
1446/// ### Returns
1447///
1448/// The combined p-value, or `NaN` when nothing survives the `NaN` filter.
1449/// Upstream returns 0.0 in that case, through `pchisq(0, 0)`; that is an
1450/// artefact of R's zero-df convention and is not reproduced here, because "no
1451/// evidence at all" is the one thing a p-value of zero must not mean.
1452pub fn fisher_combine<T: BixverseFloat>(pvals: &[T]) -> Result<T, CoreErrors> {
1453    let kept: Vec<f64> = pvals
1454        .iter()
1455        .filter_map(|p| p.to_f64())
1456        .filter(|p| !p.is_nan())
1457        .collect();
1458    match kept.len() {
1459        0 => Ok(T::nan()),
1460        1 => Ok(T::from_f64(kept[0]).unwrap_or_else(T::nan)),
1461        m => {
1462            let stat: f64 = -2.0 * kept.iter().map(|p| p.ln()).sum::<f64>();
1463            let p = chisq_sf(stat, 2.0 * m as f64)?;
1464            Ok(T::from_f64(p).unwrap_or_else(T::nan))
1465        }
1466    }
1467}
1468
1469///////////////////////
1470// Wilcoxon rank sum //
1471///////////////////////
1472
1473/// One-sided Wilcoxon rank-sum p-value, normal approximation.
1474///
1475/// Matches `wilcox.test(x, y, alternative = "greater", exact = FALSE,
1476/// correct = TRUE)`: continuity correction of a half, and the variance carries
1477/// the tie correction `sum(t^3 - t) / (N (N - 1))`. Deliberately only the
1478/// approximation.
1479///
1480/// ### Params
1481///
1482/// * `x` - First sample, the one tested for being larger
1483/// * `y` - Second sample
1484///
1485/// ### Returns
1486///
1487/// `P(X > Y)` under the null, or [CoreErrors::InvalidArgument] when either
1488/// sample is empty or the tie correction leaves no variance.
1489pub fn wilcox_rank_sum_greater_approx<T: BixverseFloat>(x: &[T], y: &[T]) -> Result<T, CoreErrors> {
1490    let n = x.len();
1491    let m = y.len();
1492    if n == 0 || m == 0 {
1493        return Err(CoreErrors::InvalidArgument(
1494            "the Wilcoxon rank sum needs a non-empty sample on both sides.".to_string(),
1495        ));
1496    }
1497    let total = n + m;
1498
1499    let mut pooled: Vec<T> = Vec::with_capacity(total);
1500    pooled.extend_from_slice(x);
1501    pooled.extend_from_slice(y);
1502    let ranks = rank_vector(&pooled);
1503
1504    let rank_sum: f64 = ranks[..n].iter().filter_map(|r| r.to_f64()).sum();
1505    let w = rank_sum - (n * (n + 1)) as f64 / 2.0;
1506
1507    // Tie correction, from the multiplicities of the pooled ranks.
1508    let mut sorted: Vec<f64> = ranks.iter().filter_map(|r| r.to_f64()).collect();
1509    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1510    let mut tie_term = 0.0_f64;
1511    let mut i = 0;
1512    while i < total {
1513        let mut j = i + 1;
1514        while j < total && sorted[j] == sorted[i] {
1515            j += 1;
1516        }
1517        let t = (j - i) as f64;
1518        tie_term += t * t * t - t;
1519        i = j;
1520    }
1521
1522    let nm = (n * m) as f64;
1523    let total_f = total as f64;
1524    let variance = (nm / 12.0) * ((total_f + 1.0) - tie_term / (total_f * (total_f - 1.0)));
1525    if !(variance.is_finite() && variance > 0.0) {
1526        return Err(CoreErrors::InvalidArgument(
1527            "the Wilcoxon rank sum has zero variance; every observation is tied.".to_string(),
1528        ));
1529    }
1530
1531    let z = (w - nm / 2.0 - 0.5) / variance.sqrt();
1532    Ok(T::from_f64(norm_sf(z)).unwrap_or_else(T::nan))
1533}
1534
1535///////////
1536// Tests //
1537///////////
1538
1539#[cfg(test)]
1540mod tests {
1541    use super::*;
1542    use approx::assert_relative_eq;
1543
1544    use crate::core::math::distributions::gamma_cdf;
1545
1546    /// Numerical Recipes LCG, the same one `dev/gen_edger_fixtures.R` uses, so
1547    /// the reference sample is rebuilt on both sides instead of crossing as
1548    /// text.
1549    fn lcg_unifs(state: &mut u64, n: usize) -> Vec<f64> {
1550        (0..n)
1551            .map(|_| {
1552                *state = (1_664_525_u64
1553                    .wrapping_mul(*state)
1554                    .wrapping_add(1_013_904_223))
1555                    % 4_294_967_296;
1556                *state as f64 / 4_294_967_296.0
1557            })
1558            .collect()
1559    }
1560
1561    /// The sample `outlier_ref.R` builds: 600 points with three planted
1562    /// outliers at 1-based positions 7, 123 and 455.
1563    fn outlier_sample() -> (Vec<f64>, Vec<f64>) {
1564        let mut state = 20_260_101_u64;
1565        let x: Vec<f64> = lcg_unifs(&mut state, 600)
1566            .iter()
1567            .map(|u| u * 4.0 - 2.0)
1568            .collect();
1569        let mut y: Vec<f64> = lcg_unifs(&mut state, 600)
1570            .iter()
1571            .map(|u| u * 2.0 - 1.0)
1572            .collect();
1573        y[6] = 50.0;
1574        y[122] = -40.0;
1575        y[454] = 80.0;
1576        (x, y)
1577    }
1578
1579    /// R: sctransform:::robust_scale_binned(y, x, seq(-2.1, 2.1, by = 0.6))
1580    ///
1581    /// R's `mad()` uses the rounded 1.4826, [`MAD_SCALE`] the exact constant,
1582    /// so scores drift by a relative ~1.5e-6.
1583    #[test]
1584    fn test_robust_scale_binned_matches_sctransform() {
1585        let (x, y) = outlier_sample();
1586        let breaks: Vec<f64> = (0..=7).map(|i| -2.1 + 0.6 * i as f64).collect();
1587
1588        let got = robust_scale_binned(&y, &x, &breaks).unwrap();
1589
1590        let first_ten = [
1591            -0.075_797_230_741_805_32,
1592            -1.194_174_449_336_404_2,
1593            0.347_967_802_118_819_17,
1594            0.508_437_503_502_156_7,
1595            -0.375_397_206_147_169_8,
1596            -0.672_986_180_212_602,
1597            78.088_159_810_413_9,
1598            1.169_443_742_595_915,
1599            -1.467_419_443_801_975_6,
1600            -0.428_119_355_105_514_74,
1601        ];
1602        for (g, e) in got.iter().take(10).zip(first_ten.iter()) {
1603            assert_relative_eq!(*g, *e, max_relative = 1e-5);
1604        }
1605        assert_relative_eq!(
1606            got.iter().sum::<f64>(),
1607            145.563_245_444_483_98,
1608            max_relative = 1e-5
1609        );
1610        assert_relative_eq!(got[6], 78.088_159_810_413_9, max_relative = 1e-5);
1611        assert_relative_eq!(got[122], -58.804_104_377_993_6, max_relative = 1e-5);
1612        assert_relative_eq!(got[454], 113.340_405_508_264_04, max_relative = 1e-5);
1613    }
1614
1615    /// R: sctransform:::is_outlier(y, x, th = 10) flags exactly the three
1616    /// planted points and nothing else.
1617    #[test]
1618    fn test_is_outlier_matches_sctransform() {
1619        let (x, y) = outlier_sample();
1620
1621        let got = is_outlier(&y, &x, 10.0).unwrap();
1622
1623        let flagged: Vec<usize> = got
1624            .iter()
1625            .enumerate()
1626            .filter(|&(_, &f)| f)
1627            .map(|(i, _)| i)
1628            .collect();
1629        assert_eq!(flagged, vec![6, 122, 454]);
1630    }
1631
1632    /// Observations outside the break range keep a score of zero rather than
1633    /// being dropped or panicking, matching R's `rep(0, length(x))` prefill.
1634    #[test]
1635    fn test_robust_scale_binned_leaves_out_of_range_at_zero() {
1636        let x = [0.0, 1.0, 2.0, 99.0];
1637        let y = [1.0, 5.0, 9.0, 1000.0];
1638
1639        let got = robust_scale_binned(&y, &x, &[-0.5, 1.5, 2.5]).unwrap();
1640
1641        assert_eq!(got[3], 0.0);
1642    }
1643
1644    #[test]
1645    fn test_binned_helpers_reject_length_mismatch() {
1646        assert!(matches!(
1647            robust_scale_binned(&[1.0], &[1.0, 2.0], &[0.0, 3.0]),
1648            Err(CoreErrors::LengthMismatch { .. })
1649        ));
1650        assert!(matches!(
1651            is_outlier(&[1.0], &[1.0, 2.0], 10.0),
1652            Err(CoreErrors::LengthMismatch { .. })
1653        ));
1654    }
1655
1656    /// Three unbalanced groups with a real effect, against R's `aov`.
1657    #[test]
1658    fn test_one_way_anova_matches_r_aov() {
1659        // R: set.seed(3); grp sizes 4/6/5
1660        let values: Vec<f64> = vec![
1661            -0.962, -0.293, 0.259, -1.152, 1.696, 1.53, 1.585, 2.617, 0.281, 2.767, -1.245, -1.631,
1662            -1.216, -0.247, -0.348,
1663        ];
1664        let groups: Vec<usize> = vec![0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
1665
1666        let (f_stat, p) = one_way_anova(&values, &groups, 3).unwrap();
1667
1668        assert_relative_eq!(f_stat, 20.398607696686263, max_relative = 1e-12);
1669        assert_relative_eq!(p, 0.00013785463402486508, max_relative = 1e-11);
1670    }
1671
1672    /// Two groups with no signal, so the p-value sits well away from zero.
1673    #[test]
1674    fn test_one_way_anova_null_case() {
1675        // R: set.seed(9); rnorm(16), 8 per group
1676        let values: Vec<f64> = vec![
1677            -0.767, -0.816, -0.142, -0.278, 0.436, -1.187, 1.192, -0.018, -0.248, -0.363, 1.278,
1678            -0.469, 0.071, -0.266, 1.845, -0.839,
1679        ];
1680        let groups: Vec<usize> = vec![0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1];
1681
1682        let (f_stat, p) = one_way_anova(&values, &groups, 2).unwrap();
1683
1684        assert_relative_eq!(f_stat, 0.577_110_410_366_241_4, max_relative = 1e-12);
1685        assert_relative_eq!(p, 0.4600488747180288, max_relative = 1e-12);
1686    }
1687
1688    /// An empty level is dropped rather than counted, so the answer is
1689    /// unchanged by widening the level set.
1690    #[test]
1691    fn test_one_way_anova_ignores_empty_groups() {
1692        let values: Vec<f64> = vec![1.0, 2.0, 5.0, 6.0];
1693        let groups: Vec<usize> = vec![0, 0, 2, 2];
1694        let widened = one_way_anova(&values, &groups, 5).unwrap();
1695        let tight = one_way_anova(&values, &[0, 0, 1, 1], 2).unwrap();
1696        assert_relative_eq!(widened.0, tight.0, max_relative = 1e-14);
1697        assert_relative_eq!(widened.1, tight.1, max_relative = 1e-14);
1698    }
1699
1700    /// A non-finite value must error, not be reported as "does not vary".
1701    ///
1702    /// `NaN > 0.0` is false, so without an explicit finiteness check control
1703    /// reaches the constant-input arm and the function returns `F = 0, p = 1`.
1704    /// The DIALOGUE feature filter *keeps* what varies, so that reading
1705    /// silently discards the feature instead of flagging it.
1706    #[test]
1707    fn test_one_way_anova_rejects_non_finite_values() {
1708        let groups = vec![0usize, 0, 0, 1, 1, 1];
1709        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1710            let values: Vec<f64> = vec![1.0, 2.0, bad, 5.0, 6.0, 7.0];
1711            assert!(
1712                one_way_anova::<f64>(&values, &groups, 2).is_err(),
1713                "accepted {bad} and reported a verdict"
1714            );
1715        }
1716    }
1717
1718    /// Constant within groups but different between them is an infinite F, and
1719    /// a p of zero. Constant throughout is F of zero and a p of one.
1720    #[test]
1721    fn test_one_way_anova_degenerate_variance_branches() {
1722        let groups = vec![0usize, 0, 1, 1];
1723
1724        let separated = vec![1.0, 1.0, 5.0, 5.0];
1725        let (f_stat, p): (f64, f64) = one_way_anova(&separated, &groups, 2).unwrap();
1726        assert!(f_stat.is_infinite() && f_stat > 0.0);
1727        assert_eq!(p, 0.0);
1728
1729        let constant = vec![3.0, 3.0, 3.0, 3.0];
1730        let (f_stat, p): (f64, f64) = one_way_anova(&constant, &groups, 2).unwrap();
1731        assert_eq!(f_stat, 0.0);
1732        assert_eq!(p, 1.0);
1733    }
1734
1735    #[test]
1736    fn test_one_way_anova_rejects_degenerate_input() {
1737        let values: Vec<f64> = vec![1.0, 2.0, 3.0];
1738        // Every observation in one group.
1739        assert!(one_way_anova(&values, &[0, 0, 0], 1).is_err());
1740        // Length mismatch.
1741        assert!(one_way_anova(&values, &[0, 1], 2).is_err());
1742        // One observation per group leaves no residual dof.
1743        assert!(one_way_anova(&values, &[0, 1, 2], 3).is_err());
1744    }
1745
1746    // -- p_adjust_holm --
1747
1748    /// Against R's bare `p.adjust`, whose default method is Holm.
1749    #[test]
1750    fn test_p_adjust_holm_matches_r() {
1751        // R: p.adjust(c(0.001, 0.02, 0.03, 0.04, 0.5))
1752        let got: Vec<f64> = p_adjust_holm(&[0.001, 0.02, 0.03, 0.04, 0.5]);
1753        let expected = [0.005, 0.08, 0.09, 0.09, 0.5];
1754        for (g, e) in got.iter().zip(expected.iter()) {
1755            assert_relative_eq!(g, e, max_relative = 1e-12);
1756        }
1757    }
1758
1759    /// The step-down carry is what makes the sequence non-decreasing: the third
1760    /// value would be 0.09 on its own but is held up by the second.
1761    #[test]
1762    fn test_p_adjust_holm_is_monotone_and_handles_ties() {
1763        // R: p.adjust(c(0.01, 0.01, 0.04))
1764        let got: Vec<f64> = p_adjust_holm(&[0.01, 0.01, 0.04]);
1765        for (g, e) in got.iter().zip([0.03, 0.03, 0.04].iter()) {
1766            assert_relative_eq!(g, e, max_relative = 1e-12);
1767        }
1768        assert!(p_adjust_holm::<f64>(&[]).is_empty());
1769        // Capped at one, and more conservative than Benjamini-Hochberg.
1770        let p = [0.2, 0.3, 0.4];
1771        let holm: Vec<f64> = p_adjust_holm(&p);
1772        let bh: Vec<f64> = p_adjust_fdr(&p);
1773        assert!(holm.iter().all(|v| *v <= 1.0));
1774        assert!(holm.iter().zip(bh.iter()).all(|(h, b)| h >= b));
1775    }
1776
1777    /// Fixture shared by the partial correlation tests.
1778    #[allow(clippy::type_complexity)]
1779    fn pcor_fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1780        // R: set.seed(21); z <- rnorm(30); x <- 0.8 z + noise; y <- 0.6 z + noise
1781        let x = vec![
1782            -0.466, 0.173, 1.413, -0.47, 2.738, 0.383, -0.147, -0.7, 0.902, 0.874, -1.788, -0.305,
1783            -0.468, 0.01, 1.708, 1.331, 0.838, 1.904, -0.397, -1.11, -0.035, 1.094, -0.952, -1.223,
1784            0.17, 1.205, 0.253, -1.441, -0.02, 0.115,
1785        ];
1786        let y = vec![
1787            0.201, 0.87, -0.214, -1.769, 2.378, 0.736, -1.232, 0.596, -0.193, -0.953, -1.238,
1788            -0.229, -0.035, -0.948, 0.638, 0.959, 0.51, 1.172, -0.878, -0.078, 0.903, 0.24, -0.782,
1789            -0.877, -0.096, -0.073, -0.017, -1.358, -0.913, 0.349,
1790        ];
1791        let z = vec![
1792            0.793, 0.522, 1.746, -1.271, 2.197, 0.433, -1.57, -0.935, 0.063, -0.002, -2.277, 0.757,
1793            -0.548, 0.173, 0.563, 1.512, 0.659, 1.122, -0.785, -0.426, 0.393, 0.037, -1.032,
1794            -1.265, -0.227, 0.746, 0.333, -1.124, -0.706, -0.728,
1795        ];
1796        (x, y, z)
1797    }
1798
1799    /// Pearson variant, against `ppcor::pcor.test(x, y, z, method = "pearson")`.
1800    #[test]
1801    fn test_partial_correlation_pearson_matches_ppcor() {
1802        let (x, y, z) = pcor_fixture();
1803        let (est, p) = partial_correlation(&x, &y, &z, false).unwrap();
1804        assert_relative_eq!(est, 0.13928951527176736, max_relative = 1e-12);
1805        assert_relative_eq!(p, 0.47113917629636237, max_relative = 1e-11);
1806    }
1807
1808    /// Spearman variant, which is what DIALOGUE uses. Ranks first, then the
1809    /// same formula.
1810    #[test]
1811    fn test_partial_correlation_spearman_matches_ppcor() {
1812        let (x, y, z) = pcor_fixture();
1813        let (est, p) = partial_correlation(&x, &y, &z, true).unwrap();
1814        assert_relative_eq!(est, 0.11596978538690578, max_relative = 1e-12);
1815        assert_relative_eq!(p, 0.549_124_448_784_373_9, max_relative = 1e-11);
1816    }
1817
1818    /// Controlling for a variable that carries the whole association drives the
1819    /// partial correlation to zero, where the raw correlation is near one.
1820    #[test]
1821    fn test_partial_correlation_removes_a_shared_driver() {
1822        let z: Vec<f64> = (0..40).map(|i| i as f64 * 0.1).collect();
1823        // x and y are z plus independent, deterministic wobbles.
1824        let x: Vec<f64> = z
1825            .iter()
1826            .enumerate()
1827            .map(|(i, v)| v + ((i % 7) as f64) * 0.01)
1828            .collect();
1829        let y: Vec<f64> = z
1830            .iter()
1831            .enumerate()
1832            .map(|(i, v)| v + ((i % 5) as f64) * 0.01)
1833            .collect();
1834
1835        let raw = pearson_correlation(&x, &y).unwrap();
1836        let (partial, _) = partial_correlation(&x, &y, &z, false).unwrap();
1837
1838        assert!(raw > 0.99, "raw correlation was {raw}");
1839        assert!(partial.abs() < 0.5, "partial correlation was {partial}");
1840    }
1841
1842    #[test]
1843    fn test_partial_correlation_rejects_short_and_ragged_input() {
1844        let a = vec![1.0, 2.0, 3.0];
1845        assert!(partial_correlation(&a, &a, &a, false).is_err());
1846        let b = vec![1.0, 2.0, 3.0, 4.0];
1847        assert!(partial_correlation(&b, &a, &b, false).is_err());
1848    }
1849
1850    // -- fisher_combine --
1851
1852    /// Four p-values, against `pchisq(-2 sum(log p), 2m, lower.tail = FALSE)`.
1853    #[test]
1854    fn test_fisher_combine_matches_r() {
1855        let p = vec![0.01, 0.2, 0.5, 0.03];
1856        let got: f64 = fisher_combine(&p).unwrap();
1857        assert_relative_eq!(got, 0.007_616_871_850_449_079, max_relative = 1e-12);
1858    }
1859
1860    /// NaN entries drop out of both the sum and the degrees of freedom.
1861    #[test]
1862    fn test_fisher_combine_skips_nan() {
1863        let p = vec![0.4, f64::NAN, 0.9];
1864        let got: f64 = fisher_combine(&p).unwrap();
1865        assert_relative_eq!(got, 0.727_794_449_111_513_4, max_relative = 1e-12);
1866    }
1867
1868    /// A single surviving p-value is returned unchanged, not passed through the
1869    /// chi-squared.
1870    #[test]
1871    fn test_fisher_combine_single_value_passes_through() {
1872        let got: f64 = fisher_combine(&[0.037]).unwrap();
1873        assert_eq!(got, 0.037);
1874        let got2: f64 = fisher_combine(&[f64::NAN, 0.037, f64::NAN]).unwrap();
1875        assert_eq!(got2, 0.037);
1876    }
1877
1878    /// Nothing to combine gives NaN, deliberately not the 0.0 that R's
1879    /// zero-df `pchisq` would produce.
1880    #[test]
1881    fn test_fisher_combine_empty_is_nan() {
1882        let got: f64 = fisher_combine(&[]).unwrap();
1883        assert!(got.is_nan());
1884        let got2: f64 = fisher_combine(&[f64::NAN, f64::NAN]).unwrap();
1885        assert!(got2.is_nan());
1886    }
1887
1888    // -- wilcox_rank_sum_greater_approx --
1889
1890    /// The 99 permutation nulls DIALOGUE's empirical p-value is built on.
1891    fn wilcox_nulls() -> Vec<f64> {
1892        vec![
1893            -0.8409, 1.3844, -1.2555, 0.0701, 1.7114, -0.6029, -0.4722, -0.6354, -0.2858, 0.1381,
1894            1.2276, -0.8018, -1.0804, -0.1575, -1.0718, -0.139, -0.5973, -2.184, 0.2408, -0.2594,
1895            0.9005, 0.9419, 1.468, 0.7068, 0.819, -0.2935, 1.4186, 1.4988, -0.6571, -0.8528,
1896            0.3159, 1.1097, 2.2155, 1.2171, 1.4792, 0.9516, -1.0095, -2.0005, -1.7622, -0.1426,
1897            1.5501, -0.8024, -0.0746, 1.8957, -0.4566, 0.5622, -0.887, -0.4602, -0.7243, -0.0692,
1898            1.4632, 0.1877, 1.022, -0.5918, -0.1122, -0.925, 0.7533, -0.1126, -0.0641, 0.2333,
1899            -1.1366, 0.8548, -0.5784, 0.4964, -0.7601, -0.3414, -2.1023, -0.3017, -1.2724, -0.2797,
1900            -0.2041, -0.2256, 0.347, 0.0324, 0.4135, -0.1553, 0.9735, 0.1211, 0.1892, -0.5629,
1901            0.4984, -1.7423, 0.9755, -0.0241, 0.6757, -0.7103, 2.3872, -0.4734, -0.0758, -0.5218,
1902            0.926, -1.0624, 0.557, 0.9007, 0.9899, 0.3836, -0.3466, -0.5402, -0.1826,
1903        ]
1904    }
1905
1906    /// The floor of DIALOGUE's empirical p-value is 0.045, not 0.01.
1907    ///
1908    /// One real value beating all 99 nulls. R takes the normal approximation
1909    /// here because the null group has 99 members, so the naive
1910    /// `(#{null >= real} + 1) / 100` is the wrong answer by more than a factor
1911    /// of four, and the 0.1 threshold that assigns cell types to programmes
1912    /// sits inside the gap.
1913    #[test]
1914    fn test_wilcox_rank_sum_greater_beats_every_null() {
1915        let nulls = wilcox_nulls();
1916        let real = nulls.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 0.1;
1917        let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1918        // R: wilcox.test(real, nulls, alternative = "greater")$p.value
1919        assert_relative_eq!(p, 0.044_801_589_130_970_6, max_relative = 1e-11);
1920    }
1921
1922    /// Sitting at the 90th null, with a tie against it, still clears 0.1.
1923    #[test]
1924    fn test_wilcox_rank_sum_greater_at_the_ninetieth_null() {
1925        let nulls = wilcox_nulls();
1926        let mut sorted = nulls.clone();
1927        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1928        let real = sorted[89];
1929        let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1930        assert_relative_eq!(p, 0.085_594_599_535_642_33, max_relative = 1e-11);
1931        assert!(p < 0.1);
1932    }
1933
1934    /// At the median the test is uninformative, as it should be.
1935    #[test]
1936    fn test_wilcox_rank_sum_greater_at_the_median() {
1937        let nulls = wilcox_nulls();
1938        let mut sorted = nulls.clone();
1939        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1940        let real = sorted[49];
1941        let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1942        assert_relative_eq!(p, 0.506_909_903_708_803, max_relative = 1e-11);
1943    }
1944
1945    /// The general branch, pinned against `wilcox.test(..., exact = FALSE)`.
1946    #[test]
1947    fn test_wilcox_rank_sum_greater_small_samples() {
1948        let a = [3.1, 4.5, 2.2, 6.0];
1949        let b = [1.0, 2.5, 3.9, 0.4, 5.5];
1950        let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
1951        assert_relative_eq!(p, 0.195_633_639_641_319_7, max_relative = 1e-11);
1952    }
1953
1954    /// Ties change the variance, and R takes this branch regardless of size.
1955    #[test]
1956    fn test_wilcox_rank_sum_greater_with_ties() {
1957        let a = [1.0, 2.0, 3.0];
1958        let b = [2.0, 3.0, 4.0, 5.0];
1959        let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
1960        assert_relative_eq!(p, 0.947_403_747_439_979_2, max_relative = 1e-11);
1961    }
1962
1963    #[test]
1964    fn test_wilcox_rank_sum_rejects_degenerate_input() {
1965        let a = [1.0, 2.0];
1966        let empty: [f64; 0] = [];
1967        assert!(wilcox_rank_sum_greater_approx(&a, &empty).is_err());
1968        assert!(wilcox_rank_sum_greater_approx(&empty, &a).is_err());
1969        // Everything tied leaves no variance to test against.
1970        assert!(wilcox_rank_sum_greater_approx(&[1.0, 1.0], &[1.0, 1.0]).is_err());
1971    }
1972
1973    /// `logit` and `inv_logit` are inverses, with p = 0.5 sitting at zero log-odds.
1974    #[test]
1975    fn test_logit_and_inv_logit() {
1976        let p: f64 = 0.5;
1977        let log_odds: f64 = logit(p);
1978        assert!((log_odds - 0.0).abs() < 1e-6); // ln(0.5 / 0.5) = ln(1) = 0.0
1979
1980        let recovered_p = inv_logit(log_odds);
1981        assert!((recovered_p - 0.5).abs() < 1e-6);
1982    }
1983
1984    /// Two-sided p-values at the two z-scores whose answers are known by heart.
1985    #[test]
1986    fn test_z_scores_to_pval() {
1987        let z_scores: Vec<f64> = vec![0.0, 1.95996398454]; // ~ 95% confidence interval
1988
1989        let pvals_two_sided = z_scores_to_pval(&z_scores, "twosided");
1990        // Z = 0 -> p = 1.0
1991        assert!((pvals_two_sided[0] - 1.0).abs() < 1e-6);
1992        // Z = 1.96 -> p ≈ 0.05
1993        assert!((pvals_two_sided[1] - 0.05).abs() < 1e-5);
1994    }
1995
1996    /// Heavy ties must not let the sort order leak into the result.
1997    ///
1998    /// `calc_fdr` sorts unstably and in parallel, so tied p-values land in an
1999    /// arbitrary order. The cumulative-min pass is what makes that irrelevant,
2000    /// and this pins it: every tied input must come back with the same adjusted
2001    /// value, and repeated runs must agree.
2002    #[test]
2003    fn test_calc_fdr_is_invariant_to_tie_order() {
2004        // one large tie group, one small, and a few distinct values
2005        let mut pvals: Vec<f64> = vec![0.02; 500];
2006        pvals.extend(vec![0.5; 50]);
2007        pvals.extend([0.001, 0.3, 0.7, 0.9, 0.02, 0.5]);
2008
2009        let first = p_adjust_fdr(&pvals);
2010        let second = p_adjust_fdr(&pvals);
2011        assert_eq!(first, second, "calc_fdr is not deterministic");
2012
2013        // every entry sharing a p-value shares its adjusted value
2014        for (i, p) in pvals.iter().enumerate() {
2015            for (j, q) in pvals.iter().enumerate() {
2016                if p == q {
2017                    assert_eq!(
2018                        first[i], first[j],
2019                        "tied p-values {p} got different FDRs at {i} and {j}"
2020                    );
2021                }
2022            }
2023        }
2024
2025        // and a reversed input gives the same multiset of answers
2026        let mut reversed = pvals.clone();
2027        reversed.reverse();
2028        let rev_fdr = p_adjust_fdr(&reversed);
2029        let mut a = first.clone();
2030        let mut b = rev_fdr;
2031        a.sort_by(|x, y| x.partial_cmp(y).unwrap());
2032        b.sort_by(|x, y| x.partial_cmp(y).unwrap());
2033        for (x, y) in a.iter().zip(b.iter()) {
2034            assert!((x - y).abs() < 1e-15, "{x} vs {y}");
2035        }
2036    }
2037
2038    /// The upper-tail p-value must be non-increasing in Z, including across the
2039    /// switch at `z = 6` from the CDF to the asymptotic tail.
2040    ///
2041    /// Hotspot's module threshold search skips whole histogram bins on the
2042    /// strength of this, so a future tweak to the tail approximation that broke
2043    /// monotonicity would silently give the wrong threshold rather than fail
2044    /// anywhere near here.
2045    #[test]
2046    fn test_upper_tail_pval_monotone_in_z() {
2047        let mut zs: Vec<f64> = vec![
2048            -8.0, -6.0, -1.0, 0.0, 1.0, 3.0, 5.0, 5.9, 5.9999, 6.0, 6.0001, 6.1, 7.0, 10.0, 20.0,
2049            37.0, 39.0,
2050        ];
2051        // and a dense sweep either side of the branch
2052        for i in 0..200 {
2053            zs.push(5.5 + i as f64 * 0.005);
2054        }
2055        zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
2056
2057        let pvals = z_scores_to_pval(&zs, "greater");
2058        for w in pvals.windows(2) {
2059            assert!(
2060                w[1] <= w[0],
2061                "upper-tail p-value increased: {} then {}",
2062                w[0],
2063                w[1]
2064            );
2065        }
2066    }
2067
2068    /// Benjamini-Hochberg adjustment stays monotonic and comes back in the input order.
2069    #[test]
2070    fn test_calc_fdr() {
2071        // Classic Benjamini-Hochberg test case
2072        let pvals: Vec<f64> = vec![0.01, 0.04, 0.03];
2073        // Sorted: 0.01 (idx 0), 0.03 (idx 2), 0.04 (idx 1)
2074        // Adjusted tmp: 0.01*(3/1)=0.03, 0.03*(3/2)=0.045, 0.04*(3/3)=0.04
2075        // Monotonic min backwards: 0.04, min(0.04, 0.045)=0.04, min(0.04, 0.03)=0.03
2076        // Result: [0.03, 0.04, 0.04]
2077        let fdr = p_adjust_fdr(&pvals);
2078
2079        assert!((fdr[0] - 0.03).abs() < 1e-6);
2080        assert!((fdr[1] - 0.04).abs() < 1e-6);
2081        assert!((fdr[2] - 0.04).abs() < 1e-6);
2082    }
2083
2084    /// Only points beyond the median plus `threshold` scaled MADs count as
2085    /// outliers.
2086    #[test]
2087    fn test_mad_outlier() {
2088        let vec: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 100.0];
2089        // Median 3.0, raw MAD 1.0, so margin = 3.0 * MAD_SCALE
2090        let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, true);
2091
2092        assert!((margin - 3.0 * MAD_SCALE).abs() < 1e-12);
2093        assert_eq!(outliers, vec![false, false, false, false, true]);
2094    }
2095
2096    /// Matches R's `abs(x - median(x)) > 3 * mad(x)` and, without scaling,
2097    /// `3 * mad(x, constant = 1)`: 9.0 sits inside the scaled margin but
2098    /// outside the raw one.
2099    #[test]
2100    fn test_mad_outlier_matches_r_scaled_and_raw_mad() {
2101        let vec: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 9.0];
2102
2103        let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, true);
2104        // R: 3 * mad(x) = 6.6717 with the rounded constant 1.4826
2105        assert!((margin - 6.6717).abs() < 1e-4);
2106        assert_eq!(outliers, vec![false; 6]);
2107
2108        let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, false);
2109        assert!((margin - 4.5).abs() < 1e-12);
2110        assert_eq!(outliers, vec![false, false, false, false, false, true]);
2111    }
2112
2113    /// The alternative and outlier-direction parsers ignore case.
2114    #[test]
2115    fn test_parse_helpers() {
2116        assert!(matches!(
2117            get_test_alternative("twosided"),
2118            Some(TestAlternative::TwoSided)
2119        ));
2120        assert!(matches!(
2121            get_test_alternative("GREATER"),
2122            Some(TestAlternative::Greater)
2123        ));
2124
2125        assert!(matches!(
2126            parse_outlier_type("below"),
2127            Some(OutlierDirection::Below)
2128        ));
2129        assert!(matches!(
2130            parse_outlier_type("Twosided"),
2131            Some(OutlierDirection::Both)
2132        ));
2133    }
2134
2135    /// Two-group MANOVA against hand-computed Wilks, Pillai and F statistics.
2136    /// Regression: at Mitch-scale magnitudes `det()` overflowed to NaN and panicked downstream.
2137    #[test]
2138    fn test_manova_no_overflow_large_p() {
2139        // Regression test: previously panicked via det() overflow → NaN →
2140        // FisherSnedecor::cdf XOutOfRange. Mimics Mitch ranked-data magnitudes
2141        // (n ~ 3000, sscp diagonals ~ n^3/12 ~ 2e9).
2142        let p = 80;
2143        let n_total = 2949usize;
2144        let sigma = (n_total as f64).powi(3) / 12.0;
2145
2146        let sscp_within = Mat::from_fn(p, p, |i, j| if i == j { sigma } else { sigma * 0.3 });
2147        // Small between-group signal; H entries ~ sigma * 1e-3
2148        let sscp_between = Mat::from_fn(
2149            p,
2150            p,
2151            |i, j| {
2152                if i == j { sigma * 1.5e-3 } else { sigma * 5e-4 }
2153            },
2154        );
2155        let sscp_total = &sscp_within + &sscp_between;
2156
2157        let res = ManovaResult::<f64> {
2158            sscp_between,
2159            sscp_within,
2160            sscp_total,
2161            df_between: 1,
2162            df_within: n_total - 2,
2163            df_total: n_total - 1,
2164            n_vars: p,
2165            group_means: vec![vec![0.0; p], vec![0.0; p]],
2166            overall_mean: vec![0.0; p],
2167        };
2168
2169        let wilks = res.wilks_lambda();
2170        let pillai = res.pillai_trace();
2171        let (f_w, p_w) = res.wilks_f_test();
2172        let (f_p, p_p) = res.pillai_f_test();
2173
2174        assert!(wilks.is_finite() && wilks > 0.0 && wilks <= 1.0);
2175        assert!((0.0..1.0).contains(&pillai) && pillai.is_finite());
2176        assert!((wilks + pillai - 1.0).abs() < 1e-9);
2177        assert!(f_w.is_finite() && f_w >= 0.0);
2178        assert!(f_p.is_finite() && f_p >= 0.0);
2179        assert!((0.0..=1.0).contains(&p_w));
2180        assert!((0.0..=1.0).contains(&p_p));
2181        // Wilks and Pillai F-tests are identical for two groups.
2182        assert!((f_w - f_p).abs() < 1e-9);
2183        assert!((p_w - p_p).abs() < 1e-9);
2184    }
2185
2186    /// Regression: a constant column has no within-group variance and used to panic.
2187    #[test]
2188    fn test_summary_aov_constant_column() {
2189        // ss_within == 0 used to feed NaN into FisherSnedecor and panic.
2190        let p = 2;
2191        let sscp_within = Mat::from_fn(p, p, |i, j| {
2192            if i == 0 && j == 0 {
2193                0.0
2194            } else if i == j {
2195                10.0
2196            } else {
2197                0.0
2198            }
2199        });
2200        let sscp_between = Mat::from_fn(p, p, |i, j| if i == j { 5.0 } else { 0.0 });
2201        let sscp_total = &sscp_within + &sscp_between;
2202
2203        let res = ManovaResult::<f64> {
2204            sscp_between,
2205            sscp_within,
2206            sscp_total,
2207            df_between: 1,
2208            df_within: 10,
2209            df_total: 11,
2210            n_vars: p,
2211            group_means: vec![vec![0.0; p], vec![0.0; p]],
2212            overall_mean: vec![0.0; p],
2213        };
2214
2215        let aov = summary_aov(&res);
2216        assert_eq!(aov.len(), 2);
2217        assert!(aov[0].f_stat.is_nan());
2218        assert!((aov[0].p_val - 1.0).abs() < 1e-12);
2219        assert!(aov[1].f_stat.is_finite() && aov[1].f_stat > 0.0);
2220        assert!((0.0..=1.0).contains(&aov[1].p_val));
2221    }
2222
2223    ////////////////////////
2224    // Distribution fits //
2225    ////////////////////////
2226
2227    /// The reference is R's `uniroot` on the score equation itself, not
2228    /// `MASS::fitdistr`. `fitdistr` runs `optim` on the two-parameter likelihood
2229    /// and stops at its own `reltol`, leaving a score residual of 4e-7 and a
2230    /// log-likelihood below the one this returns. The exact root is the thing
2231    /// worth pinning.
2232    ///
2233    /// R: `uniroot(function(k) log(k) - digamma(k) - s, c(1e-6, 100),
2234    /// tol = .Machine$double.eps^0.75)`
2235    #[test]
2236    fn test_fit_gamma_mle_matches_the_exact_root() {
2237        let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2238
2239        let (shape, scale) = fit_gamma_mle(&y).unwrap();
2240
2241        assert_relative_eq!(shape, 1.934_295_921_801_258_3, max_relative = 1e-13);
2242        assert_relative_eq!(scale, 1.731_896_325_811_62, max_relative = 1e-13);
2243    }
2244
2245    /// The scale is pinned to `mean / shape` analytically, so the fitted mean
2246    /// has to come back exactly.
2247    #[test]
2248    fn test_fit_gamma_mle_preserves_mean() {
2249        let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2250        let mean = y.iter().sum::<f64>() / y.len() as f64;
2251
2252        let (shape, scale) = fit_gamma_mle(&y).unwrap();
2253
2254        assert_relative_eq!(shape * scale, mean, max_relative = 1e-12);
2255    }
2256
2257    /// The score equation is what the Newton solve claims to zero, so check it
2258    /// directly rather than only against a reference fit.
2259    #[test]
2260    fn test_fit_gamma_mle_solves_score_equation() {
2261        let y: Vec<f64> = (1..=200).map(|i| (i as f64) * 0.07 + 0.3).collect();
2262        let n = y.len() as f64;
2263        let mean = y.iter().sum::<f64>() / n;
2264        let s = mean.ln() - y.iter().map(|v| v.ln()).sum::<f64>() / n;
2265
2266        let (shape, _) = fit_gamma_mle(&y).unwrap();
2267
2268        assert_relative_eq!(shape.ln() - digamma(shape), s, max_relative = 1e-10);
2269    }
2270
2271    /// Every other fixture has a shape above one. A heavy-tailed sample drives
2272    /// it well below, which is a different part of Minka's start-point formula
2273    /// and a different approach to the Newton domain guard.
2274    #[test]
2275    fn test_fit_gamma_mle_handles_shape_below_one() {
2276        let y: Vec<f64> = (1..=400).map(|i| (i as f64).powi(6)).collect();
2277        let n = y.len() as f64;
2278        let mean = y.iter().sum::<f64>() / n;
2279        let s = mean.ln() - y.iter().map(|v| v.ln()).sum::<f64>() / n;
2280
2281        let (shape, scale) = fit_gamma_mle(&y).unwrap();
2282
2283        assert!(shape > 0.0 && shape < 1.0, "shape came back as {shape}");
2284        assert!(scale.is_finite() && scale > 0.0);
2285        assert_relative_eq!(shape.ln() - digamma(shape), s, max_relative = 1e-10);
2286        assert_relative_eq!(shape * scale, mean, max_relative = 1e-12);
2287    }
2288
2289    #[test]
2290    fn test_fit_gamma_mle_rejects_bad_samples() {
2291        assert!(fit_gamma_mle(&[]).is_err());
2292        assert!(fit_gamma_mle(&[1.0, -2.0, 3.0]).is_err());
2293        assert!(fit_gamma_mle(&[1.0, f64::NAN]).is_err());
2294        // no spread on the log scale pins the shape at infinity
2295        assert!(fit_gamma_mle(&[2.0; 20]).is_err());
2296    }
2297
2298    //////////////////////
2299    // Goodness of fit //
2300    //////////////////////
2301
2302    /// The parameters are pinned rather than refitted, so this gates the
2303    /// statistic alone and does not move when the fitter does.
2304    ///
2305    /// R: `ks.test(y, "pgamma", 1.9342959218012583, 1 / 1.73189632581162)`
2306    /// gives D = 0.096888414283814117. Only D is compared: R evaluates the
2307    /// exact `kstwo` distribution for the p-value where this is the asymptotic
2308    /// form.
2309    #[test]
2310    fn test_ks_statistic_matches_r() {
2311        let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2312        let (shape, scale) = (1.934_295_921_801_258_3, 1.731_896_325_811_62);
2313
2314        let res = ks_test_1samp(&y, |x| gamma_cdf(x, shape, scale).unwrap()).unwrap();
2315
2316        assert_relative_eq!(
2317            res.statistic,
2318            0.096_888_414_283_814_12,
2319            max_relative = 1e-12
2320        );
2321        assert!(res.pval > 0.9, "a good fit should not be rejected");
2322    }
2323
2324    /// A sample drawn nowhere near the reference distribution must be rejected.
2325    #[test]
2326    fn test_ks_rejects_wrong_distribution() {
2327        let y: Vec<f64> = (1..=200).map(|i| 50.0 + i as f64 * 0.01).collect();
2328
2329        let res = ks_test_1samp(&y, |x| gamma_cdf(x, 1.0, 1.0).unwrap()).unwrap();
2330
2331        assert!(res.statistic > 0.9);
2332        assert!(res.pval < 1e-6, "got {}", res.pval);
2333    }
2334
2335    /// The statistic has to see the gap below each step of the empirical CDF as
2336    /// well as the gap above it.
2337    #[test]
2338    fn test_ks_statistic_checks_both_sides_of_the_step() {
2339        // uniform reference, sample bunched at the top: the largest gap sits
2340        // below the first observation, which a one-sided scan would miss
2341        let y = [0.9, 0.92, 0.94, 0.96, 0.98];
2342
2343        let res = ks_test_1samp(&y, |x| x.clamp(0.0, 1.0)).unwrap();
2344
2345        assert_relative_eq!(res.statistic, 0.9, max_relative = 1e-12);
2346    }
2347
2348    #[test]
2349    fn test_ks_rejects_bad_samples() {
2350        assert!(ks_test_1samp(&[], |x| x).is_err());
2351        assert!(ks_test_1samp(&[1.0, f64::INFINITY], |x| x).is_err());
2352    }
2353
2354    #[test]
2355    fn test_kolmogorov_sf_bounds() {
2356        assert_eq!(kolmogorov_sf(0.0), 1.0);
2357        assert!(kolmogorov_sf(5.0) < 1e-20);
2358    }
2359
2360    /// The alternating series alone returned 0.02 here, where the answer is one.
2361    #[test]
2362    fn test_kolmogorov_sf_small_lambda_is_one() {
2363        for &lambda in &[1e-6, 1e-3, 0.01, 0.05, 0.1, 0.2] {
2364            let q = kolmogorov_sf(lambda);
2365            assert!(
2366                q >= 0.999,
2367                "Q({lambda}) = {q}, should be indistinguishable from 1"
2368            );
2369        }
2370    }
2371
2372    /// The two series are the same function, so they have to agree where they
2373    /// meet. This is what says the crossover is not a seam.
2374    ///
2375    /// The step either side has to be small enough that `Q` moving across it is
2376    /// below the tolerance: the slope near one is about -0.79, so 1e-12 of
2377    /// lambda buys 8e-13 of `Q`.
2378    #[test]
2379    fn test_kolmogorov_sf_series_agree_at_the_crossover() {
2380        // Both series summed to convergence in Python agree on this to 5e-17:
2381        // Q(1) = 2 * (exp(-2) - exp(-8) + exp(-18) - ...)
2382        const Q_AT_ONE: f64 = 0.269_999_671_677_354_6;
2383
2384        // exactly at the crossover, so the alternating branch
2385        assert_relative_eq!(
2386            kolmogorov_sf(KS_SERIES_CROSSOVER),
2387            Q_AT_ONE,
2388            max_relative = 1e-14
2389        );
2390
2391        // and a hair below it, so the theta branch. `Q` slides by the slope
2392        // times the step, about 8e-13, which sets the tolerance.
2393        assert_relative_eq!(
2394            kolmogorov_sf(KS_SERIES_CROSSOVER - 1e-12),
2395            Q_AT_ONE,
2396            max_relative = 1e-11
2397        );
2398    }
2399
2400    /// Monotone decreasing across both branches and the join between them.
2401    #[test]
2402    fn test_kolmogorov_sf_is_monotone() {
2403        let mut previous = 1.0;
2404        for step in 1..=4000 {
2405            let lambda = step as f64 * 0.001;
2406            let current = kolmogorov_sf(lambda);
2407            assert!(
2408                current <= previous + 1e-12,
2409                "not monotone at lambda = {lambda}"
2410            );
2411            previous = current;
2412        }
2413    }
2414}