Skip to main content

chronix_analytics/forecast/
diagnostics.rs

1//! Model diagnostics for forecast quality assessment.
2//!
3//! Provides statistical tests and metrics to evaluate fitted forecast models:
4//!
5//! - **[`ljung_box`]** — Ljung-Box Q test for residual autocorrelation
6//! - **[`residual_acf`]** — Sample autocorrelation function of residuals
7//! - **[`bic`]** — Bayesian Information Criterion
8//! - **[`aicc`]** — Corrected Akaike Information Criterion
9//! - **Error metrics**: [`rmse`], [`mae`], [`mape`], [`smape`]
10//! - **[`ModelDiagnostics`]** — All-in-one diagnostics report
11
12use std::f64::consts::PI;
13
14// ── Error Metrics ──────────────────────────────────────────────────
15
16/// Root Mean Squared Error.
17///
18/// $$\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2}$$
19///
20/// # Errors
21///
22/// Returns `None` if `actual` and `predicted` have different lengths or are empty.
23#[must_use]
24pub fn rmse(actual: &[f64], predicted: &[f64]) -> Option<f64> {
25    if actual.len() != predicted.len() || actual.is_empty() {
26        return None;
27    }
28    let n = actual.len() as f64;
29    let mse: f64 = actual
30        .iter()
31        .zip(predicted)
32        .map(|(a, p)| (a - p).powi(2))
33        .sum::<f64>()
34        / n;
35    Some(mse.sqrt())
36}
37
38/// Mean Absolute Error.
39///
40/// $$\text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|$$
41#[must_use]
42pub fn mae(actual: &[f64], predicted: &[f64]) -> Option<f64> {
43    if actual.len() != predicted.len() || actual.is_empty() {
44        return None;
45    }
46    let n = actual.len() as f64;
47    let sum: f64 = actual
48        .iter()
49        .zip(predicted)
50        .map(|(a, p)| (a - p).abs())
51        .sum();
52    Some(sum / n)
53}
54
55/// Mean Absolute Percentage Error.
56///
57/// $$\text{MAPE} = \frac{100}{n}\sum_{i=1}^{n}\left|\frac{y_i - \hat{y}_i}{y_i}\right|$$
58///
59/// Skips zero actual values to avoid division by zero.
60#[must_use]
61pub fn mape(actual: &[f64], predicted: &[f64]) -> Option<f64> {
62    if actual.len() != predicted.len() || actual.is_empty() {
63        return None;
64    }
65    let mut sum = 0.0;
66    let mut count = 0usize;
67    for (a, p) in actual.iter().zip(predicted) {
68        if a.abs() > f64::EPSILON {
69            sum += ((a - p) / a).abs();
70            count += 1;
71        }
72    }
73    if count == 0 {
74        return None;
75    }
76    Some(100.0 * sum / count as f64)
77}
78
79/// Symmetric Mean Absolute Percentage Error.
80///
81/// $$\text{sMAPE} = \frac{200}{n}\sum_{i=1}^{n}\frac{|y_i - \hat{y}_i|}{|y_i| + |\hat{y}_i|}$$
82///
83/// More robust than MAPE when values are near zero.
84#[must_use]
85pub fn smape(actual: &[f64], predicted: &[f64]) -> Option<f64> {
86    if actual.len() != predicted.len() || actual.is_empty() {
87        return None;
88    }
89    let mut sum = 0.0;
90    let mut count = 0usize;
91    for (a, p) in actual.iter().zip(predicted) {
92        let denom = a.abs() + p.abs();
93        if denom > f64::EPSILON {
94            sum += (a - p).abs() / denom;
95            count += 1;
96        }
97    }
98    if count == 0 {
99        return None;
100    }
101    Some(200.0 * sum / count as f64)
102}
103
104// ── Residual ACF ───────────────────────────────────────────────────
105
106/// Compute the sample autocorrelation function (ACF) of `residuals`
107/// for lags `0..=max_lag`.
108///
109/// Returns a vector of length `max_lag + 1` where element `k` is the
110/// autocorrelation at lag `k`.  Element 0 is always 1.0.
111///
112/// # Errors
113///
114/// Returns `None` if `residuals` has fewer than 2 elements or
115/// `max_lag >= residuals.len()`.
116#[must_use]
117pub fn residual_acf(residuals: &[f64], max_lag: usize) -> Option<Vec<f64>> {
118    let n = residuals.len();
119    if n < 2 || max_lag >= n {
120        return None;
121    }
122
123    let mean = residuals.iter().sum::<f64>() / n as f64;
124    let var: f64 = residuals.iter().map(|r| (r - mean).powi(2)).sum();
125    if var.abs() < f64::EPSILON {
126        // All residuals identical — no autocorrelation.
127        let mut acf = vec![0.0; max_lag + 1];
128        acf[0] = 1.0;
129        return Some(acf);
130    }
131
132    let mut acf = Vec::with_capacity(max_lag + 1);
133    for lag in 0..=max_lag {
134        let cov: f64 = residuals
135            .iter()
136            .skip(lag)
137            .zip(residuals.iter())
138            .map(|(r_t, r_t_k)| (r_t - mean) * (r_t_k - mean))
139            .sum();
140        acf.push(cov / var);
141    }
142    Some(acf)
143}
144
145// ── Ljung-Box Test ─────────────────────────────────────────────────
146
147/// Result of the Ljung-Box Q test.
148#[derive(Debug, Clone)]
149pub struct LjungBoxResult {
150    /// Test statistic Q.
151    pub q_statistic: f64,
152    /// Degrees of freedom (lags minus fitted AR/MA parameters).
153    pub df: usize,
154    /// Approximate p-value from chi-squared distribution.
155    pub p_value: f64,
156    /// Whether the null hypothesis (no autocorrelation) is rejected
157    /// at the specified significance level.
158    pub significant: bool,
159}
160
161/// Ljung-Box Q test for residual autocorrelation.
162///
163/// Tests the null hypothesis that the first `max_lag` autocorrelations
164/// are jointly zero.
165///
166/// $$Q = n(n+2)\sum_{k=1}^{h}\frac{\hat{\rho}_k^2}{n-k}$$
167///
168/// # Arguments
169///
170/// - `residuals` — model residuals
171/// - `max_lag`   — number of lags to include
172/// - `n_params`  — number of fitted model parameters (subtracted from df)
173/// - `alpha`     — significance level (e.g. 0.05)
174///
175/// # Errors
176///
177/// Returns `None` if the residuals are too short for the requested lags.
178#[must_use]
179pub fn ljung_box(
180    residuals: &[f64],
181    max_lag: usize,
182    n_params: usize,
183    alpha: f64,
184) -> Option<LjungBoxResult> {
185    let n = residuals.len();
186    if max_lag == 0 || max_lag >= n || n_params >= max_lag {
187        return None;
188    }
189
190    let acf = residual_acf(residuals, max_lag)?;
191
192    let n_f = n as f64;
193    let q: f64 = (1..=max_lag)
194        .map(|k| acf[k].powi(2) / (n_f - k as f64))
195        .sum::<f64>()
196        * n_f
197        * (n_f + 2.0);
198
199    let df = max_lag - n_params;
200    let p_value = chi_squared_survival(q, df);
201
202    Some(LjungBoxResult {
203        q_statistic: q,
204        df,
205        p_value,
206        significant: p_value < alpha,
207    })
208}
209
210// ── Information Criteria ───────────────────────────────────────────
211
212/// Akaike Information Criterion.
213///
214/// $$\text{AIC} = n \ln(\text{RSS}/n) + 2k$$
215///
216/// where `rss` is the residual sum of squares, `n` is the sample size, and
217/// `k` is the number of estimated parameters.
218///
219/// **Comparable only between models of the same data.** Two ARIMA orders that
220/// difference the series a different number of times are fitted to different
221/// series, of different lengths and on different scales, and their AIC values
222/// do not rank anything — which is why
223/// [`select_differencing_order`](crate::forecast::select_differencing_order)
224/// exists.
225#[must_use]
226pub fn aic(rss: f64, n: usize, k: usize) -> Option<f64> {
227    let v = mean_residual_variance(rss, n)?;
228    Some(n as f64 * v.ln() + 2.0 * (k as f64))
229}
230
231/// Mean residual variance, floored so that a perfect fit still ranks.
232///
233/// A residual sum of squares of exactly zero makes the conditional
234/// log-likelihood unbounded and every information criterion `-∞`. Returning
235/// `None` there is worse than it sounds: a deterministic series — a synthetic
236/// ramp, a constant, a counter that has not moved — then produces *no*
237/// scorable model at all, and [`auto_arima`](crate::forecast::auto_arima)
238/// fails with "nothing could be fitted" on the easiest input there is. The
239/// variance is floored at the smallest positive double instead, so every
240/// perfect fit scores the same enormous negative number and the parameter
241/// penalty picks the simplest of them, which is the intended answer.
242fn mean_residual_variance(rss: f64, n: usize) -> Option<f64> {
243    if n == 0 || !rss.is_finite() || rss < 0.0 {
244        return None;
245    }
246    Some((rss / n as f64).max(f64::MIN_POSITIVE))
247}
248
249/// Bayesian Information Criterion.
250///
251/// $$\text{BIC} = n \ln(\text{RSS}/n) + k \ln(n)$$
252///
253/// where `rss` is the residual sum of squares, `n` is the sample size,
254/// and `k` is the number of estimated parameters.
255#[must_use]
256pub fn bic(rss: f64, n: usize, k: usize) -> Option<f64> {
257    let v = mean_residual_variance(rss, n)?;
258    let n_f = n as f64;
259    Some(n_f * v.ln() + (k as f64) * n_f.ln())
260}
261
262/// Corrected Akaike Information Criterion (AICc).
263///
264/// $$\text{AICc} = n \ln(\text{RSS}/n) + 2k + \frac{2k(k+1)}{n - k - 1}$$
265///
266/// The correction term prevents overfitting for small samples.
267#[must_use]
268pub fn aicc(rss: f64, n: usize, k: usize) -> Option<f64> {
269    if n <= k + 1 {
270        return None;
271    }
272    let v = mean_residual_variance(rss, n)?;
273    let n_f = n as f64;
274    let k_f = k as f64;
275    let base = n_f * v.ln() + 2.0 * k_f;
276    let correction = 2.0 * k_f * (k_f + 1.0) / (n_f - k_f - 1.0);
277    Some(base + correction)
278}
279
280// ── Composite Diagnostics ──────────────────────────────────────────
281
282/// All-in-one diagnostics report for a fitted forecast model.
283#[derive(Debug, Clone)]
284pub struct ModelDiagnostics {
285    /// RMSE of in-sample residuals.
286    pub rmse: f64,
287    /// MAE of in-sample residuals.
288    pub mae: f64,
289    /// MAPE (if computable — `None` when actuals contain zeros).
290    pub mape: Option<f64>,
291    /// sMAPE.
292    pub smape: Option<f64>,
293    /// Sample autocorrelation function of residuals.
294    pub acf: Vec<f64>,
295    /// Ljung-Box test result.
296    pub ljung_box: Option<LjungBoxResult>,
297    /// BIC.
298    pub bic: Option<f64>,
299    /// AICc.
300    pub aicc: Option<f64>,
301}
302
303/// Compute comprehensive diagnostics from actual values, predictions,
304/// and model metadata.
305///
306/// # Arguments
307///
308/// - `actual`    — observed values
309/// - `predicted` — in-sample fitted values (same length as `actual`)
310/// - `n_params`  — number of estimated model parameters
311/// - `max_lag`   — max ACF / Ljung-Box lag (default: `min(20, n/5)`)
312///
313/// # Errors
314///
315/// Returns `None` if inputs are mismatched or too short.
316#[must_use]
317pub fn compute_diagnostics(
318    actual: &[f64],
319    predicted: &[f64],
320    n_params: usize,
321    max_lag: Option<usize>,
322) -> Option<ModelDiagnostics> {
323    if actual.len() != predicted.len() || actual.is_empty() {
324        return None;
325    }
326
327    let n = actual.len();
328    let residuals: Vec<f64> = actual.iter().zip(predicted).map(|(a, p)| a - p).collect();
329    let rss: f64 = residuals.iter().map(|r| r * r).sum();
330
331    let lag = max_lag.unwrap_or_else(|| 20.min(n / 5).max(1));
332
333    Some(ModelDiagnostics {
334        rmse: rmse(actual, predicted).unwrap_or(0.0),
335        mae: mae(actual, predicted).unwrap_or(0.0),
336        mape: mape(actual, predicted),
337        smape: smape(actual, predicted),
338        acf: residual_acf(&residuals, lag).unwrap_or_default(),
339        ljung_box: ljung_box(&residuals, lag, n_params, 0.05),
340        bic: bic(rss, n, n_params),
341        aicc: aicc(rss, n, n_params),
342    })
343}
344
345// ── Chi-squared survival (upper-tail) via regularized gamma ────────
346
347/// Approximate chi-squared survival function P(X > x) for X ~ χ²(df).
348///
349/// Uses the regularized upper incomplete gamma function:
350/// P(X > x) = Γ(df/2, x/2) / Γ(df/2)
351fn chi_squared_survival(x: f64, df: usize) -> f64 {
352    if df == 0 || x < 0.0 {
353        return 1.0;
354    }
355    let a = df as f64 / 2.0;
356    let z = x / 2.0;
357    regularized_upper_gamma(a, z)
358}
359
360/// Regularized upper incomplete gamma function Q(a, z) = 1 - P(a, z).
361///
362/// Uses Legendre's continued fraction for Q(a, z) when z > a + 1,
363/// and the series expansion for P(a, z) otherwise.
364fn regularized_upper_gamma(a: f64, z: f64) -> f64 {
365    if z < 0.0 {
366        return 1.0;
367    }
368    if z < a + 1.0 {
369        1.0 - gamma_series(a, z)
370    } else {
371        gamma_cf(a, z)
372    }
373}
374
375/// P(a, z) via series expansion: P = e^{-z} z^a / Γ(a) * Σ z^n / (a)_n.
376fn gamma_series(a: f64, z: f64) -> f64 {
377    let ln_gamma_a = ln_gamma(a);
378    let mut term = 1.0 / a;
379    let mut sum = term;
380    for n in 1..200 {
381        term *= z / (a + n as f64);
382        sum += term;
383        if term.abs() < sum.abs() * 1e-14 {
384            break;
385        }
386    }
387    let log_val = a * z.ln() - z - ln_gamma_a + sum.ln();
388    log_val.exp().min(1.0)
389}
390
391/// Q(a, z) via the modified Lentz evaluation of Legendre's continued
392/// fraction, as in Numerical Recipes §6.2 `gcf`.
393///
394/// ```text
395/// Q(a,z) = e^-z z^a / Γ(a) × 1/(z+1-a - 1(1-a)/(z+3-a - 2(2-a)/(z+5-a - …)))
396/// ```
397///
398/// so the `n`-th partial numerator is `-n(n-a)` against the denominator
399/// `z + (2n+1) - a`. What stood here paired those denominators with the
400/// numerators of the continued fraction for the incomplete *beta* function —
401/// `k(a-k)` and `-(a-1+k)k` on alternating terms — and the two do not belong
402/// to the same fraction. `ljung_box` computed its Q statistic exactly and
403/// then reported p = 0.0722 where the true value is 0.0648, an 11 % error in
404/// the only number anybody reads: the series branch covers `z < a + 1`, so
405/// every p-value in the tail came from here.
406///
407/// The guard is also a magnitude one. `x.max(tiny)` turns a legitimate
408/// negative denominator into `+1e-30`, and Legendre's fraction has them.
409fn gamma_cf(a: f64, z: f64) -> f64 {
410    const TINY: f64 = 1e-300;
411    let ln_gamma_a = ln_gamma(a);
412
413    let mut b = z + 1.0 - a;
414    let mut c = 1.0 / TINY;
415    let mut d = 1.0 / if b.abs() < TINY { TINY } else { b };
416    let mut h = d;
417
418    for i in 1..300 {
419        let an = -(i as f64) * (i as f64 - a);
420        b += 2.0;
421        d = an.mul_add(d, b);
422        if d.abs() < TINY {
423            d = TINY;
424        }
425        c = b + an / c;
426        if c.abs() < TINY {
427            c = TINY;
428        }
429        d = 1.0 / d;
430        let delta = d * c;
431        h *= delta;
432        if (delta - 1.0).abs() < 1e-15 {
433            break;
434        }
435    }
436
437    let log_val = a.mul_add(z.ln(), -z) - ln_gamma_a + h.ln();
438    log_val.exp().clamp(0.0, 1.0)
439}
440
441/// Lanczos approximation for ln(Γ(x)).
442///
443/// `pub(crate)` because it is the crate's **only** implementation. There were
444/// two: this one, pinned by `ln_gamma_known_values`, and a copy in
445/// `multivariate::mv_forecast` carrying the g = 7 coefficients with `t = x +
446/// 6.5` — a g = 6 offset. Mismatched Lanczos parameters are not a precision
447/// problem: that copy returned 0.928 for `ln Γ(1)`, which is 0, and it sat
448/// under `ln_beta` → `regularized_incomplete_beta` → `f_distribution_sf`, so
449/// **every Granger causality p-value in the crate was wrong**.
450pub(crate) fn ln_gamma(x: f64) -> f64 {
451    // Coefficients for g = 7, n = 9 (Lanczos).
452    const COEFFS: [f64; 9] = [
453        0.999_999_999_999_809_9,
454        676.520_368_121_885_1,
455        -1_259.139_216_722_402_9,
456        771.323_428_777_653_1,
457        -176.615_029_162_140_6,
458        12.507_343_278_686_905,
459        -0.138_571_095_265_720_12,
460        9.984_369_578_019_572e-6,
461        1.505_632_735_149_311_6e-7,
462    ];
463
464    if x < 0.5 {
465        // Reflection formula
466        let log_pi = PI.ln();
467        return log_pi - (PI * x).sin().abs().ln() - ln_gamma(1.0 - x);
468    }
469
470    let xx = x - 1.0;
471    let mut sum = COEFFS[0];
472    for (i, &c) in COEFFS.iter().enumerate().skip(1) {
473        sum += c / (xx + i as f64);
474    }
475
476    let t = xx + 7.5; // g + 0.5
477    0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
478}
479
480// ── Tests ──────────────────────────────────────────────────────────
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn rmse_basic() {
488        let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
489        let predicted = vec![1.1, 2.2, 2.8, 4.1, 4.9];
490        let r = rmse(&actual, &predicted).unwrap();
491        assert!((r - 0.1414).abs() < 0.01, "rmse = {r}");
492    }
493
494    #[test]
495    fn mae_basic() {
496        let actual = vec![1.0, 2.0, 3.0];
497        let predicted = vec![1.5, 2.5, 2.5];
498        let m = mae(&actual, &predicted).unwrap();
499        assert!((m - 0.5).abs() < 1e-10, "mae = {m}");
500    }
501
502    #[test]
503    fn mape_skips_zeros() {
504        let actual = vec![0.0, 10.0, 20.0];
505        let predicted = vec![1.0, 11.0, 18.0];
506        let m = mape(&actual, &predicted).unwrap();
507        // Only uses indices 1 and 2: (1/10 + 2/20)/2 * 100 = 10%
508        assert!((m - 10.0).abs() < 1e-10, "mape = {m}");
509    }
510
511    #[test]
512    fn smape_basic() {
513        let actual = vec![10.0, 20.0];
514        let predicted = vec![12.0, 18.0];
515        // |10-12|/(10+12) + |20-18|/(20+18) = 2/22 + 2/38
516        // smape = 200/2 * (2/22 + 2/38) = 100 * (0.0909 + 0.0526) = 14.35
517        let s = smape(&actual, &predicted).unwrap();
518        assert!(s > 14.0 && s < 15.0, "smape = {s}");
519    }
520
521    #[test]
522    fn acf_lag0_is_one() {
523        let residuals = vec![1.0, -0.5, 0.3, -0.1, 0.7, -0.4];
524        let acf = residual_acf(&residuals, 3).unwrap();
525        assert!((acf[0] - 1.0).abs() < 1e-10);
526        assert_eq!(acf.len(), 4);
527    }
528
529    #[test]
530    fn acf_white_noise_near_zero() {
531        // Use a longer, more varied pseudo-random sequence.
532        // Linear congruential generator (a=1103515245, c=12345, m=2^31).
533        let mut state = 42u64;
534        let residuals: Vec<f64> = (0..500)
535            .map(|_| {
536                state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
537                (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
538            })
539            .collect();
540        let acf = residual_acf(&residuals, 10).unwrap();
541        for (lag, &r) in acf.iter().enumerate().skip(1) {
542            assert!(
543                r.abs() < 0.15,
544                "acf at lag {lag} = {r} too large for LCG noise"
545            );
546        }
547    }
548
549    #[test]
550    fn ljung_box_white_noise_not_significant() {
551        // LCG pseudo-random data — should NOT reject null.
552        let mut state = 7u64;
553        let residuals: Vec<f64> = (0..500)
554            .map(|_| {
555                state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
556                (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
557            })
558            .collect();
559        let result = ljung_box(&residuals, 10, 2, 0.05).unwrap();
560        assert!(
561            !result.significant,
562            "LCG noise should not be significant: p = {}",
563            result.p_value
564        );
565    }
566
567    #[test]
568    fn ljung_box_autocorrelated_is_significant() {
569        // Highly autocorrelated data.
570        let mut residuals = vec![0.0; 200];
571        residuals[0] = 1.0;
572        for i in 1..200 {
573            residuals[i] = 0.95 * residuals[i - 1] + 0.1 * ((i * 7 % 13) as f64 - 6.0);
574        }
575        let result = ljung_box(&residuals, 10, 0, 0.05).unwrap();
576        assert!(
577            result.significant,
578            "AR(1) residuals should be significant: p = {}, Q = {}",
579            result.p_value, result.q_statistic
580        );
581    }
582
583    #[test]
584    fn bic_basic() {
585        let val = bic(100.0, 50, 3).unwrap();
586        // BIC = 50*ln(2) + 3*ln(50) = 34.66 + 11.74 ≈ 46.4
587        assert!(val > 45.0 && val < 48.0, "bic = {val}");
588    }
589
590    #[test]
591    fn aicc_basic() {
592        let val = aicc(100.0, 50, 3).unwrap();
593        // AICc = 50*ln(2) + 6 + 24/46 ≈ 34.66 + 6 + 0.52 = 41.18
594        assert!(val > 40.0 && val < 42.0, "aicc = {val}");
595    }
596
597    #[test]
598    fn aicc_small_sample_returns_none() {
599        // n <= k + 1 should return None.
600        assert!(aicc(10.0, 3, 3).is_none());
601    }
602
603    #[test]
604    fn compute_diagnostics_full() {
605        let actual: Vec<f64> = (0..100).map(|i| i as f64 * 0.5 + 10.0).collect();
606        let predicted: Vec<f64> = actual
607            .iter()
608            .enumerate()
609            .map(|(i, &v)| v + 0.1 * ((i * 7 % 11) as f64 - 5.0))
610            .collect();
611
612        let diag = compute_diagnostics(&actual, &predicted, 2, None).unwrap();
613        assert!(diag.rmse > 0.0);
614        assert!(diag.mae > 0.0);
615        assert!(diag.mape.is_some());
616        assert!(diag.smape.is_some());
617        assert!(!diag.acf.is_empty());
618        assert!(diag.bic.is_some());
619        assert!(diag.aicc.is_some());
620    }
621
622    #[test]
623    fn empty_inputs_return_none() {
624        assert!(rmse(&[], &[]).is_none());
625        assert!(mae(&[], &[]).is_none());
626        assert!(mape(&[], &[]).is_none());
627        assert!(residual_acf(&[], 1).is_none());
628        assert!(ljung_box(&[], 1, 0, 0.05).is_none());
629        assert!(bic(0.0, 0, 0).is_none());
630    }
631
632    #[test]
633    fn mismatched_lengths_return_none() {
634        assert!(rmse(&[1.0], &[1.0, 2.0]).is_none());
635        assert!(mae(&[1.0], &[1.0, 2.0]).is_none());
636    }
637
638    #[test]
639    fn ln_gamma_known_values() {
640        // Γ(1) = 1 → ln(Γ(1)) = 0
641        assert!((ln_gamma(1.0)).abs() < 1e-10);
642        // Γ(5) = 24 → ln(Γ(5)) ≈ 3.178
643        assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
644        // Γ(0.5) = √π → ln(Γ(0.5)) ≈ 0.5723
645        assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
646    }
647
648    #[test]
649    fn chi_squared_survival_known() {
650        // `scipy.stats.chi2.sf`, across both branches of
651        // `regularized_upper_gamma` and well into the tail — which is the
652        // half that was wrong. The series covers `z < a + 1`, i.e. small `x`
653        // for large `df`, and everything else went through the continued
654        // fraction; that one carried the partial numerators of the
655        // incomplete *beta* function, so `ljung_box` reported p = 0.0722
656        // where the answer is 0.0648.
657        //
658        // Two examples alone would not have caught it: the previous version
659        // of this test used χ²(2) at x = 0 and x = 4, both of which take the
660        // series branch, and it passed throughout.
661        #[rustfmt::skip]
662        const REFERENCE: &[(f64, usize, f64)] = &[
663            (0.1_f64, 1, 0.7518296340458492),
664            (0.5_f64, 1, 0.47950012218695337),
665            (1.0_f64, 1, 0.31731050786291115),
666            (2.0_f64, 1, 0.15729920705028105),
667            (3.84_f64, 1, 0.050043521248705085),
668            (5.0_f64, 1, 0.025347318677468252),
669            (10.0_f64, 1, 0.0015654022580025482),
670            (15.0_f64, 1, 0.00010751117672950056),
671            (25.0_f64, 1, 5.733031437583878e-07),
672            (40.0_f64, 1, 2.5396285894708634e-10),
673            (60.0_f64, 1, 9.485737571073854e-15),
674            (100.0_f64, 1, 1.5239706048320995e-23),
675            (0.1_f64, 2, 0.951229424500714),
676            (0.5_f64, 2, 0.7788007830714049),
677            (1.0_f64, 2, 0.6065306597126334),
678            (2.0_f64, 2, 0.36787944117144245),
679            (3.84_f64, 2, 0.14660696213035015),
680            (5.0_f64, 2, 0.0820849986238988),
681            (10.0_f64, 2, 0.006737946999085468),
682            (15.0_f64, 2, 0.0005530843701478337),
683            (25.0_f64, 2, 3.7266531720786718e-06),
684            (40.0_f64, 2, 2.0611536224385566e-09),
685            (60.0_f64, 2, 9.357622968840163e-14),
686            (100.0_f64, 2, 1.9287498479639183e-22),
687            (0.1_f64, 3, 0.9918374237318764),
688            (0.5_f64, 3, 0.9188914116546758),
689            (1.0_f64, 3, 0.8012519569012009),
690            (2.0_f64, 3, 0.5724067044708798),
691            (3.84_f64, 3, 0.2792676171186097),
692            (5.0_f64, 3, 0.17179714429673323),
693            (10.0_f64, 3, 0.01856613546304323),
694            (15.0_f64, 3, 0.0018166489665723223),
695            (25.0_f64, 3, 1.544049829110137e-05),
696            (40.0_f64, 3, 1.0655090334255846e-08),
697            (60.0_f64, 3, 5.878230727906919e-13),
698            (100.0_f64, 3, 1.5541594313896026e-21),
699            (0.1_f64, 5, 0.9998376833880774),
700            (0.5_f64, 5, 0.9921232932326296),
701            (1.0_f64, 5, 0.9625657732472964),
702            (2.0_f64, 5, 0.8491450360846096),
703            (3.84_f64, 5, 0.5726744598320888),
704            (5.0_f64, 5, 0.41588018699550783),
705            (10.0_f64, 5, 0.07523524614651216),
706            (15.0_f64, 5, 0.010362337915786436),
707            (25.0_f64, 5, 0.0001393337911856263),
708            (40.0_f64, 5, 1.493367900050396e-07),
709            (60.0_f64, 5, 1.2154569777183006e-11),
710            (100.0_f64, 5, 5.285148360943219e-20),
711            (0.1_f64, 10, 0.9999999975020487),
712            (0.5_f64, 10, 0.999993388289439),
713            (1.0_f64, 10, 0.9998278843700441),
714            (2.0_f64, 10, 0.9963401531726563),
715            (3.84_f64, 10, 0.9542763043207358),
716            (5.0_f64, 10, 0.8911780189141513),
717            (10.0_f64, 10, 0.44049328506521246),
718            (15.0_f64, 10, 0.13206185628772055),
719            (25.0_f64, 10, 0.005345505487134069),
720            (40.0_f64, 10, 1.694474393006737e-05),
721            (60.0_f64, 10, 3.6243009520614924e-09),
722            (100.0_f64, 10, 5.4497019829205215e-17),
723            (0.1_f64, 20, 1.0),
724            (0.5_f64, 20, 0.9999999999997906),
725            (1.0_f64, 20, 0.999999999829033),
726            (2.0_f64, 20, 0.9999998885745217),
727            (3.84_f64, 20, 0.9999667953164147),
728            (5.0_f64, 20, 0.9997226479053791),
729            (10.0_f64, 20, 0.9681719426937951),
730            (15.0_f64, 20, 0.7764076130197146),
731            (25.0_f64, 20, 0.20143110494553587),
732            (40.0_f64, 20, 0.0049954123083075785),
733            (60.0_f64, 20, 7.12175086281558e-06),
734            (100.0_f64, 20, 1.2596084591660936e-12),
735            (0.1_f64, 50, 1.0),
736            (0.5_f64, 50, 1.0),
737            (1.0_f64, 50, 1.0),
738            (2.0_f64, 50, 1.0),
739            (3.84_f64, 50, 1.0),
740            (5.0_f64, 50, 1.0),
741            (10.0_f64, 50, 0.9999999998400414),
742            (15.0_f64, 50, 0.999999625021075),
743            (25.0_f64, 50, 0.9988075511517683),
744            (40.0_f64, 50, 0.8432273781737623),
745            (60.0_f64, 50, 0.1572420272383916),
746            (100.0_f64, 50, 3.4549313829848465e-05),
747        ];
748
749        for &(x, df, expected) in REFERENCE {
750            let got = chi_squared_survival(x, df);
751            let err = if expected > 1e-12 {
752                (got - expected).abs() / expected
753            } else {
754                (got - expected).abs()
755            };
756            assert!(
757                err < 1e-10,
758                "sf({x}, {df}) = {got}, expected {expected} (relative error {err:.3e})"
759            );
760        }
761
762        // P(X > 0) = 1 for every df, and the function is monotone falling.
763        for df in 1..40 {
764            assert!((chi_squared_survival(0.0, df) - 1.0).abs() < 1e-12);
765            let mut prev = 1.0;
766            for k in 1..80 {
767                let p = chi_squared_survival(f64::from(k), df);
768                assert!(p <= prev + 1e-12, "sf is not monotone at x={k}, df={df}");
769                assert!(
770                    (0.0..=1.0).contains(&p),
771                    "sf({k}, {df}) = {p} is not a probability"
772                );
773                prev = p;
774            }
775        }
776    }
777}