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)).
442fn ln_gamma(x: f64) -> f64 {
443    // Coefficients for g = 7, n = 9 (Lanczos).
444    const COEFFS: [f64; 9] = [
445        0.999_999_999_999_809_9,
446        676.520_368_121_885_1,
447        -1_259.139_216_722_402_9,
448        771.323_428_777_653_1,
449        -176.615_029_162_140_6,
450        12.507_343_278_686_905,
451        -0.138_571_095_265_720_12,
452        9.984_369_578_019_572e-6,
453        1.505_632_735_149_311_6e-7,
454    ];
455
456    if x < 0.5 {
457        // Reflection formula
458        let log_pi = PI.ln();
459        return log_pi - (PI * x).sin().abs().ln() - ln_gamma(1.0 - x);
460    }
461
462    let xx = x - 1.0;
463    let mut sum = COEFFS[0];
464    for (i, &c) in COEFFS.iter().enumerate().skip(1) {
465        sum += c / (xx + i as f64);
466    }
467
468    let t = xx + 7.5; // g + 0.5
469    0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
470}
471
472// ── Tests ──────────────────────────────────────────────────────────
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn rmse_basic() {
480        let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
481        let predicted = vec![1.1, 2.2, 2.8, 4.1, 4.9];
482        let r = rmse(&actual, &predicted).unwrap();
483        assert!((r - 0.1414).abs() < 0.01, "rmse = {r}");
484    }
485
486    #[test]
487    fn mae_basic() {
488        let actual = vec![1.0, 2.0, 3.0];
489        let predicted = vec![1.5, 2.5, 2.5];
490        let m = mae(&actual, &predicted).unwrap();
491        assert!((m - 0.5).abs() < 1e-10, "mae = {m}");
492    }
493
494    #[test]
495    fn mape_skips_zeros() {
496        let actual = vec![0.0, 10.0, 20.0];
497        let predicted = vec![1.0, 11.0, 18.0];
498        let m = mape(&actual, &predicted).unwrap();
499        // Only uses indices 1 and 2: (1/10 + 2/20)/2 * 100 = 10%
500        assert!((m - 10.0).abs() < 1e-10, "mape = {m}");
501    }
502
503    #[test]
504    fn smape_basic() {
505        let actual = vec![10.0, 20.0];
506        let predicted = vec![12.0, 18.0];
507        // |10-12|/(10+12) + |20-18|/(20+18) = 2/22 + 2/38
508        // smape = 200/2 * (2/22 + 2/38) = 100 * (0.0909 + 0.0526) = 14.35
509        let s = smape(&actual, &predicted).unwrap();
510        assert!(s > 14.0 && s < 15.0, "smape = {s}");
511    }
512
513    #[test]
514    fn acf_lag0_is_one() {
515        let residuals = vec![1.0, -0.5, 0.3, -0.1, 0.7, -0.4];
516        let acf = residual_acf(&residuals, 3).unwrap();
517        assert!((acf[0] - 1.0).abs() < 1e-10);
518        assert_eq!(acf.len(), 4);
519    }
520
521    #[test]
522    fn acf_white_noise_near_zero() {
523        // Use a longer, more varied pseudo-random sequence.
524        // Linear congruential generator (a=1103515245, c=12345, m=2^31).
525        let mut state = 42u64;
526        let residuals: Vec<f64> = (0..500)
527            .map(|_| {
528                state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
529                (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
530            })
531            .collect();
532        let acf = residual_acf(&residuals, 10).unwrap();
533        for (lag, &r) in acf.iter().enumerate().skip(1) {
534            assert!(
535                r.abs() < 0.15,
536                "acf at lag {lag} = {r} too large for LCG noise"
537            );
538        }
539    }
540
541    #[test]
542    fn ljung_box_white_noise_not_significant() {
543        // LCG pseudo-random data — should NOT reject null.
544        let mut state = 7u64;
545        let residuals: Vec<f64> = (0..500)
546            .map(|_| {
547                state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
548                (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
549            })
550            .collect();
551        let result = ljung_box(&residuals, 10, 2, 0.05).unwrap();
552        assert!(
553            !result.significant,
554            "LCG noise should not be significant: p = {}",
555            result.p_value
556        );
557    }
558
559    #[test]
560    fn ljung_box_autocorrelated_is_significant() {
561        // Highly autocorrelated data.
562        let mut residuals = vec![0.0; 200];
563        residuals[0] = 1.0;
564        for i in 1..200 {
565            residuals[i] = 0.95 * residuals[i - 1] + 0.1 * ((i * 7 % 13) as f64 - 6.0);
566        }
567        let result = ljung_box(&residuals, 10, 0, 0.05).unwrap();
568        assert!(
569            result.significant,
570            "AR(1) residuals should be significant: p = {}, Q = {}",
571            result.p_value, result.q_statistic
572        );
573    }
574
575    #[test]
576    fn bic_basic() {
577        let val = bic(100.0, 50, 3).unwrap();
578        // BIC = 50*ln(2) + 3*ln(50) = 34.66 + 11.74 ≈ 46.4
579        assert!(val > 45.0 && val < 48.0, "bic = {val}");
580    }
581
582    #[test]
583    fn aicc_basic() {
584        let val = aicc(100.0, 50, 3).unwrap();
585        // AICc = 50*ln(2) + 6 + 24/46 ≈ 34.66 + 6 + 0.52 = 41.18
586        assert!(val > 40.0 && val < 42.0, "aicc = {val}");
587    }
588
589    #[test]
590    fn aicc_small_sample_returns_none() {
591        // n <= k + 1 should return None.
592        assert!(aicc(10.0, 3, 3).is_none());
593    }
594
595    #[test]
596    fn compute_diagnostics_full() {
597        let actual: Vec<f64> = (0..100).map(|i| i as f64 * 0.5 + 10.0).collect();
598        let predicted: Vec<f64> = actual
599            .iter()
600            .enumerate()
601            .map(|(i, &v)| v + 0.1 * ((i * 7 % 11) as f64 - 5.0))
602            .collect();
603
604        let diag = compute_diagnostics(&actual, &predicted, 2, None).unwrap();
605        assert!(diag.rmse > 0.0);
606        assert!(diag.mae > 0.0);
607        assert!(diag.mape.is_some());
608        assert!(diag.smape.is_some());
609        assert!(!diag.acf.is_empty());
610        assert!(diag.bic.is_some());
611        assert!(diag.aicc.is_some());
612    }
613
614    #[test]
615    fn empty_inputs_return_none() {
616        assert!(rmse(&[], &[]).is_none());
617        assert!(mae(&[], &[]).is_none());
618        assert!(mape(&[], &[]).is_none());
619        assert!(residual_acf(&[], 1).is_none());
620        assert!(ljung_box(&[], 1, 0, 0.05).is_none());
621        assert!(bic(0.0, 0, 0).is_none());
622    }
623
624    #[test]
625    fn mismatched_lengths_return_none() {
626        assert!(rmse(&[1.0], &[1.0, 2.0]).is_none());
627        assert!(mae(&[1.0], &[1.0, 2.0]).is_none());
628    }
629
630    #[test]
631    fn ln_gamma_known_values() {
632        // Γ(1) = 1 → ln(Γ(1)) = 0
633        assert!((ln_gamma(1.0)).abs() < 1e-10);
634        // Γ(5) = 24 → ln(Γ(5)) ≈ 3.178
635        assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
636        // Γ(0.5) = √π → ln(Γ(0.5)) ≈ 0.5723
637        assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
638    }
639
640    #[test]
641    fn chi_squared_survival_known() {
642        // `scipy.stats.chi2.sf`, across both branches of
643        // `regularized_upper_gamma` and well into the tail — which is the
644        // half that was wrong. The series covers `z < a + 1`, i.e. small `x`
645        // for large `df`, and everything else went through the continued
646        // fraction; that one carried the partial numerators of the
647        // incomplete *beta* function, so `ljung_box` reported p = 0.0722
648        // where the answer is 0.0648.
649        //
650        // Two examples alone would not have caught it: the previous version
651        // of this test used χ²(2) at x = 0 and x = 4, both of which take the
652        // series branch, and it passed throughout.
653        #[rustfmt::skip]
654        const REFERENCE: &[(f64, usize, f64)] = &[
655            (0.1_f64, 1, 0.7518296340458492),
656            (0.5_f64, 1, 0.47950012218695337),
657            (1.0_f64, 1, 0.31731050786291115),
658            (2.0_f64, 1, 0.15729920705028105),
659            (3.84_f64, 1, 0.050043521248705085),
660            (5.0_f64, 1, 0.025347318677468252),
661            (10.0_f64, 1, 0.0015654022580025482),
662            (15.0_f64, 1, 0.00010751117672950056),
663            (25.0_f64, 1, 5.733031437583878e-07),
664            (40.0_f64, 1, 2.5396285894708634e-10),
665            (60.0_f64, 1, 9.485737571073854e-15),
666            (100.0_f64, 1, 1.5239706048320995e-23),
667            (0.1_f64, 2, 0.951229424500714),
668            (0.5_f64, 2, 0.7788007830714049),
669            (1.0_f64, 2, 0.6065306597126334),
670            (2.0_f64, 2, 0.36787944117144245),
671            (3.84_f64, 2, 0.14660696213035015),
672            (5.0_f64, 2, 0.0820849986238988),
673            (10.0_f64, 2, 0.006737946999085468),
674            (15.0_f64, 2, 0.0005530843701478337),
675            (25.0_f64, 2, 3.7266531720786718e-06),
676            (40.0_f64, 2, 2.0611536224385566e-09),
677            (60.0_f64, 2, 9.357622968840163e-14),
678            (100.0_f64, 2, 1.9287498479639183e-22),
679            (0.1_f64, 3, 0.9918374237318764),
680            (0.5_f64, 3, 0.9188914116546758),
681            (1.0_f64, 3, 0.8012519569012009),
682            (2.0_f64, 3, 0.5724067044708798),
683            (3.84_f64, 3, 0.2792676171186097),
684            (5.0_f64, 3, 0.17179714429673323),
685            (10.0_f64, 3, 0.01856613546304323),
686            (15.0_f64, 3, 0.0018166489665723223),
687            (25.0_f64, 3, 1.544049829110137e-05),
688            (40.0_f64, 3, 1.0655090334255846e-08),
689            (60.0_f64, 3, 5.878230727906919e-13),
690            (100.0_f64, 3, 1.5541594313896026e-21),
691            (0.1_f64, 5, 0.9998376833880774),
692            (0.5_f64, 5, 0.9921232932326296),
693            (1.0_f64, 5, 0.9625657732472964),
694            (2.0_f64, 5, 0.8491450360846096),
695            (3.84_f64, 5, 0.5726744598320888),
696            (5.0_f64, 5, 0.41588018699550783),
697            (10.0_f64, 5, 0.07523524614651216),
698            (15.0_f64, 5, 0.010362337915786436),
699            (25.0_f64, 5, 0.0001393337911856263),
700            (40.0_f64, 5, 1.493367900050396e-07),
701            (60.0_f64, 5, 1.2154569777183006e-11),
702            (100.0_f64, 5, 5.285148360943219e-20),
703            (0.1_f64, 10, 0.9999999975020487),
704            (0.5_f64, 10, 0.999993388289439),
705            (1.0_f64, 10, 0.9998278843700441),
706            (2.0_f64, 10, 0.9963401531726563),
707            (3.84_f64, 10, 0.9542763043207358),
708            (5.0_f64, 10, 0.8911780189141513),
709            (10.0_f64, 10, 0.44049328506521246),
710            (15.0_f64, 10, 0.13206185628772055),
711            (25.0_f64, 10, 0.005345505487134069),
712            (40.0_f64, 10, 1.694474393006737e-05),
713            (60.0_f64, 10, 3.6243009520614924e-09),
714            (100.0_f64, 10, 5.4497019829205215e-17),
715            (0.1_f64, 20, 1.0),
716            (0.5_f64, 20, 0.9999999999997906),
717            (1.0_f64, 20, 0.999999999829033),
718            (2.0_f64, 20, 0.9999998885745217),
719            (3.84_f64, 20, 0.9999667953164147),
720            (5.0_f64, 20, 0.9997226479053791),
721            (10.0_f64, 20, 0.9681719426937951),
722            (15.0_f64, 20, 0.7764076130197146),
723            (25.0_f64, 20, 0.20143110494553587),
724            (40.0_f64, 20, 0.0049954123083075785),
725            (60.0_f64, 20, 7.12175086281558e-06),
726            (100.0_f64, 20, 1.2596084591660936e-12),
727            (0.1_f64, 50, 1.0),
728            (0.5_f64, 50, 1.0),
729            (1.0_f64, 50, 1.0),
730            (2.0_f64, 50, 1.0),
731            (3.84_f64, 50, 1.0),
732            (5.0_f64, 50, 1.0),
733            (10.0_f64, 50, 0.9999999998400414),
734            (15.0_f64, 50, 0.999999625021075),
735            (25.0_f64, 50, 0.9988075511517683),
736            (40.0_f64, 50, 0.8432273781737623),
737            (60.0_f64, 50, 0.1572420272383916),
738            (100.0_f64, 50, 3.4549313829848465e-05),
739        ];
740
741        for &(x, df, expected) in REFERENCE {
742            let got = chi_squared_survival(x, df);
743            let err = if expected > 1e-12 {
744                (got - expected).abs() / expected
745            } else {
746                (got - expected).abs()
747            };
748            assert!(
749                err < 1e-10,
750                "sf({x}, {df}) = {got}, expected {expected} (relative error {err:.3e})"
751            );
752        }
753
754        // P(X > 0) = 1 for every df, and the function is monotone falling.
755        for df in 1..40 {
756            assert!((chi_squared_survival(0.0, df) - 1.0).abs() < 1e-12);
757            let mut prev = 1.0;
758            for k in 1..80 {
759                let p = chi_squared_survival(f64::from(k), df);
760                assert!(p <= prev + 1e-12, "sf is not monotone at x={k}, df={df}");
761                assert!(
762                    (0.0..=1.0).contains(&p),
763                    "sf({k}, {df}) = {p} is not a probability"
764                );
765                prev = p;
766            }
767        }
768    }
769}