Skip to main content

tsorder_core/
dist.rs

1//! Distribution functions for the rank tests: chi-square survival and the
2//! law of a weighted sum of independent χ²₁ variables (the null of the
3//! Baram–Porat / Robin–Smith statistics), by Imhof's (1961) exact inversion
4//! or Satterthwaite's two-moment approximation.
5//!
6//! Dependency-free: log-gamma by Lanczos, regularized incomplete gamma by
7//! series/continued fraction (Numerical Recipes conventions), Imhof by
8//! composite Simpson with an oscillation-aware step.
9
10/// Natural log of the gamma function (Lanczos, g = 7, ~15 digits).
11#[allow(clippy::excessive_precision)] // published Lanczos coefficients, kept verbatim
12pub fn ln_gamma(x: f64) -> f64 {
13    const G: [f64; 9] = [
14        0.99999999999980993,
15        676.5203681218851,
16        -1259.1392167224028,
17        771.32342877765313,
18        -176.61502916214059,
19        12.507343278686905,
20        -0.13857109526572012,
21        9.9843695780195716e-6,
22        1.5056327351493116e-7,
23    ];
24    if x < 0.5 {
25        // Reflection.
26        return (std::f64::consts::PI / (std::f64::consts::PI * x).sin()).ln() - ln_gamma(1.0 - x);
27    }
28    let x = x - 1.0;
29    let mut a = G[0];
30    let t = x + 7.5;
31    for (i, &g) in G.iter().enumerate().skip(1) {
32        a += g / (x + i as f64);
33    }
34    0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
35}
36
37/// Regularized upper incomplete gamma `Q(a, x) = Γ(a, x)/Γ(a)`.
38pub fn gamma_q(a: f64, x: f64) -> f64 {
39    if x < 0.0 || a <= 0.0 {
40        return f64::NAN;
41    }
42    if x == 0.0 {
43        return 1.0;
44    }
45    if x < a + 1.0 {
46        // 1 - P(a, x) by series.
47        let mut sum = 1.0 / a;
48        let mut term = sum;
49        let mut ap = a;
50        for _ in 0..500 {
51            ap += 1.0;
52            term *= x / ap;
53            sum += term;
54            if term.abs() < sum.abs() * 1e-16 {
55                break;
56            }
57        }
58        1.0 - sum * (-x + a * x.ln() - ln_gamma(a)).exp()
59    } else {
60        // Continued fraction (Lentz).
61        let mut b = x + 1.0 - a;
62        let mut c = 1.0 / 1e-300;
63        let mut d = 1.0 / b;
64        let mut h = d;
65        for i in 1..500 {
66            let an = -(i as f64) * (i as f64 - a);
67            b += 2.0;
68            d = an * d + b;
69            if d.abs() < 1e-300 {
70                d = 1e-300;
71            }
72            c = b + an / c;
73            if c.abs() < 1e-300 {
74                c = 1e-300;
75            }
76            d = 1.0 / d;
77            let del = d * c;
78            h *= del;
79            if (del - 1.0).abs() < 1e-16 {
80                break;
81            }
82        }
83        (-x + a * x.ln() - ln_gamma(a)).exp() * h
84    }
85}
86
87/// Chi-square survival function `P(χ²_df > q)` (df may be non-integer).
88pub fn chi2_sf(q: f64, df: f64) -> f64 {
89    gamma_q(df / 2.0, q / 2.0)
90}
91
92/// Satterthwaite two-moment approximation to `P(Σ λ_i χ²₁ > q)`:
93/// match mean and variance with `g · χ²_ν`, `g = Σλ²/Σλ`, `ν = (Σλ)²/Σλ²`.
94pub fn satterthwaite_sf(q: f64, weights: &[f64]) -> f64 {
95    let s1: f64 = weights.iter().sum();
96    let s2: f64 = weights.iter().map(|l| l * l).sum();
97    if s1 <= 0.0 || s2 <= 0.0 {
98        return if q > 0.0 { 0.0 } else { 1.0 };
99    }
100    chi2_sf(q * s1 / s2, s1 * s1 / s2)
101}
102
103/// Imhof's (1961) exact inversion for `P(Σ λ_i χ²₁ > q)` (central case):
104/// `P = 1/2 + (1/π) ∫₀^∞ sin θ(u) / (u ρ(u)) du` with
105/// `θ(u) = ½ Σ atan(λ_i u) − ½ q u`, `ρ(u) = Π (1 + λ_i² u²)^{1/4}`.
106pub fn imhof_sf(q: f64, weights: &[f64]) -> f64 {
107    let lam: Vec<f64> = weights.iter().copied().filter(|l| *l > 0.0).collect();
108    if lam.is_empty() {
109        return if q > 0.0 { 0.0 } else { 1.0 };
110    }
111    let sum_l: f64 = lam.iter().sum();
112    let integrand = |u: f64| -> f64 {
113        if u == 0.0 {
114            // limit: θ(u)/u → (Σλ − q)/2, ρ → 1.
115            return 0.5 * (sum_l - q);
116        }
117        let mut theta = -0.5 * q * u;
118        let mut ln_rho = 0.0;
119        for &l in &lam {
120            theta += 0.5 * (l * u).atan();
121            ln_rho += 0.25 * (1.0 + l * l * u * u).ln();
122        }
123        theta.sin() / (u * (ln_rho).exp())
124    };
125    // Oscillation-aware step: the phase moves at ≈ (q + Σλ)/2 per unit u.
126    let rate = 0.5 * (q + sum_l) + 1.0;
127    let h = (std::f64::consts::PI / rate / 20.0).min(0.05);
128    let mut total = 0.0;
129    let mut u = 0.0;
130    let mut small_blocks = 0;
131    for _ in 0..200_000 {
132        // Simpson on [u, u + 2h].
133        let block = h / 3.0 * (integrand(u) + 4.0 * integrand(u + h) + integrand(u + 2.0 * h));
134        total += block;
135        u += 2.0 * h;
136        if block.abs() < 1e-13 {
137            small_blocks += 1;
138            if small_blocks > 50 {
139                break;
140            }
141        } else {
142            small_blocks = 0;
143        }
144    }
145    (0.5 + total / std::f64::consts::PI).clamp(0.0, 1.0)
146}
147
148/// Upper-tail quantile table of the asymptotic KPSS level-statistic null
149/// (integral of a squared Brownian bridge): pairs `(α, q)` with
150/// `P(KPSS > q) = α`. Simulated by `examples/unit_root_tables.rs`
151/// (T = 5000, 400 000 replications); matches the published Kwiatkowski et
152/// al. (1992) points 0.347/0.463/0.739 at 10 %/5 %/1 % to the expected
153/// discretisation accuracy.
154const KPSS_TABLE: [(f64, f64); 12] = [
155    (0.0005, 1.3136),
156    (0.001, 1.1739),
157    (0.0025, 1.0087),
158    (0.005, 0.8769),
159    (0.01, 0.7521),
160    (0.025, 0.5837),
161    (0.05, 0.4635),
162    (0.10, 0.3477),
163    (0.15, 0.2847),
164    (0.20, 0.2421),
165    (0.30, 0.1842),
166    (0.50, 0.1189),
167];
168
169/// Lower-tail quantile table of the asymptotic Dickey–Fuller `τ_c` null
170/// (t-statistic with constant under a pure random walk): pairs `(α, q)` with
171/// `P(τ < q) = α`. Same generator; matches the standard asymptotic points
172/// −2.57/−2.86/−3.43 at 10 %/5 %/1 %.
173const DF_TAU_C_TABLE: [(f64, f64); 12] = [
174    (0.0005, -4.2971),
175    (0.001, -4.1176),
176    (0.0025, -3.8743),
177    (0.005, -3.6730),
178    (0.01, -3.4508),
179    (0.025, -3.1363),
180    (0.05, -2.8713),
181    (0.10, -2.5726),
182    (0.15, -2.3749),
183    (0.20, -2.2203),
184    (0.30, -1.9725),
185    (0.50, -1.5691),
186];
187
188fn interp_table(table: &[(f64, f64)], alpha: f64) -> f64 {
189    let a = alpha.clamp(table[0].0, table[table.len() - 1].0);
190    for w in table.windows(2) {
191        let ((a0, q0), (a1, q1)) = (w[0], w[1]);
192        if a <= a1 {
193            // Log-linear in alpha (tail probabilities span decades).
194            let t = (a.ln() - a0.ln()) / (a1.ln() - a0.ln());
195            return q0 + t * (q1 - q0);
196        }
197    }
198    table[table.len() - 1].1
199}
200
201/// Critical value `q(α)` with `P(KPSS > q) = α` for the level-stationarity
202/// KPSS statistic (asymptotic null).
203pub fn kpss_critical(alpha: f64) -> f64 {
204    interp_table(&KPSS_TABLE, alpha)
205}
206
207/// Critical value `q(α)` with `P(τ_c < q) = α` for the Dickey–Fuller
208/// t-statistic with constant (asymptotic null).
209pub fn df_tau_c_critical(alpha: f64) -> f64 {
210    interp_table(&DF_TAU_C_TABLE, alpha)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn chi2_sf_matches_r_pchisq() {
219        // R: pchisq(q, df, lower.tail = FALSE).
220        let cases = [
221            (3.84, 1.0, 0.050043521248705),
222            (5.99, 2.0, 0.0500366270865863),
223            (2.5, 1.7, 0.231036876696032),
224            (10.0, 4.3, 0.0494046995154264),
225            (0.5, 0.5, 0.256322055268539),
226        ];
227        for (q, df, want) in cases {
228            let got = chi2_sf(q, df);
229            assert!((got - want).abs() < 1e-12, "chi2_sf({q},{df}) = {got}");
230        }
231    }
232
233    #[test]
234    fn imhof_matches_exact_chi2_and_r_compquadform() {
235        // Single unit weight: exactly chi-square(1).
236        let got = imhof_sf(3.84, &[1.0]);
237        assert!((got - chi2_sf(3.84, 1.0)).abs() < 1e-6, "{got}");
238        // Equal weights 1,1: exactly chi-square(2).
239        let got = imhof_sf(5.99, &[1.0, 1.0]);
240        assert!((got - chi2_sf(5.99, 2.0)).abs() < 1e-6, "{got}");
241        // R CompQuadForm::imhof references (their own integration error ~1e-4).
242        let cases: [(f64, &[f64], f64); 4] = [
243            (5.0, &[2.0, 1.0], 0.186424873796),
244            (1.2, &[0.9, 0.5, 0.1], 0.449453666487),
245            (10.0, &[3.0, 2.0, 1.0, 0.5], 0.193055225569),
246            (0.3, &[0.2, 0.05], 0.279316877793),
247        ];
248        for (q, l, want) in cases {
249            let got = imhof_sf(q, l);
250            assert!((got - want).abs() < 5e-4, "imhof({q}) = {got} want {want}");
251        }
252    }
253
254    #[test]
255    fn satterthwaite_is_exact_for_equal_weights() {
256        // Equal weights: the two-moment fit is exact (g = λ, ν = k).
257        let got = satterthwaite_sf(5.99, &[1.0, 1.0]);
258        assert!((got - chi2_sf(5.99, 2.0)).abs() < 1e-12);
259        let got = satterthwaite_sf(3.0, &[0.5, 0.5, 0.5]);
260        assert!((got - chi2_sf(6.0, 3.0)).abs() < 1e-12);
261        // Unequal weights: close to Imhof but not equal.
262        let a = satterthwaite_sf(5.0, &[2.0, 1.0]);
263        let b = imhof_sf(5.0, &[2.0, 1.0]);
264        assert!((a - b).abs() < 0.02, "satt {a} vs imhof {b}");
265    }
266}