Skip to main content

chronix_analytics/multivariate/
mv_forecast.rs

1//! Multivariate forecasting: Multi-Linear Regression and VAR models.
2
3use crate::compute::{ComputeEngine, CpuEngine};
4
5use crate::multivariate::context::MultiSeriesContext;
6use crate::multivariate::error::MultivariateError;
7
8/// Result of a multivariate forecast.
9#[derive(Debug, Clone)]
10pub struct MultivariateForecastResult {
11    /// Forecast timestamps (nanoseconds since epoch).
12    pub timestamps: Vec<i64>,
13    /// The series each row of `predictions` belongs to, in the same order.
14    ///
15    /// This exists because the two implementors of
16    /// [`MultivariateForecastModel`] return **different numbers of rows** for
17    /// the same call, and nothing used to say so.
18    /// [`MultiLinearRegression`] is many-predictors-to-one-target and returns
19    /// one row; [`VarModel`] fits every equation of the system and returns one
20    /// row per series. A caller reaching for `predictions[0]` therefore got
21    /// the requested series from one and series index 0 from the other — a
22    /// plausible number belonging to a different series, with nothing in the
23    /// type to catch it.
24    pub series: Vec<String>,
25    /// Predicted values, one row per entry of [`series`](Self::series).
26    pub predictions: Vec<Vec<f64>>,
27    /// The series named by `target_idx` at [`fit`](MultivariateForecastModel::fit).
28    ///
29    /// Prefer [`target_forecast`](Self::target_forecast) to indexing
30    /// `predictions` directly.
31    pub target: String,
32    /// Per-predictor importance / coefficients.
33    pub predictor_importance: Vec<(String, f64)>,
34}
35
36impl MultivariateForecastResult {
37    /// The forecast for one named series, if this model produced it.
38    #[must_use]
39    pub fn for_series(&self, name: &str) -> Option<&[f64]> {
40        let i = self.series.iter().position(|s| s == name)?;
41        self.predictions.get(i).map(Vec::as_slice)
42    }
43
44    /// The forecast for the series the caller asked `fit` for.
45    ///
46    /// Correct for every implementor, which is the point: it is what
47    /// `predictions[0]` was reached for and only sometimes was.
48    #[must_use]
49    pub fn target_forecast(&self) -> Option<&[f64]> {
50        self.for_series(&self.target)
51    }
52}
53
54/// Multivariate forecast model contract.
55pub trait MultivariateForecastModel: Send + Sync {
56    /// Fit the model.
57    ///
58    /// - `ctx` — aligned multi-series context.
59    /// - `target_idx` — the series the caller wants forecast.
60    ///
61    /// A model that forecasts the **whole system** — [`VarModel`] fits one
62    /// equation per series — still records `target_idx` and still returns
63    /// every series it fitted. It is recorded in
64    /// [`MultivariateForecastResult::target`] so that
65    /// [`target_forecast`](MultivariateForecastResult::target_forecast)
66    /// answers correctly whichever model produced the result. `target_idx`
67    /// selects what the caller asked for; it does not promise to be the only
68    /// row that comes back.
69    fn fit(&mut self, ctx: &MultiSeriesContext, target_idx: usize)
70    -> Result<(), MultivariateError>;
71
72    /// Predict `horizon` steps ahead.
73    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError>;
74
75    /// Whether the model supports incremental online learning.
76    fn supports_online_learning(&self) -> bool;
77}
78
79// ─── Multi-Linear Regression ────────────────────────────────────────
80
81/// OLS regression: multiple predictors → single target.
82pub struct MultiLinearRegression {
83    coefficients: Vec<f64>,
84    intercept: f64,
85    r_squared: f64,
86    target_idx: usize,
87    /// Name of the target series, so the result can name its one row.
88    target_name: String,
89    predictor_names: Vec<String>,
90    /// Last observed predictor values — used for persistence forecasting.
91    last_predictors: Vec<f64>,
92    last_ts: i64,
93    interval_ns: i64,
94    fitted: bool,
95}
96
97impl MultiLinearRegression {
98    /// Creates a new unfitted multi-linear regression model.
99    pub fn new() -> Self {
100        Self {
101            coefficients: Vec::new(),
102            intercept: 0.0,
103            r_squared: 0.0,
104            target_idx: 0,
105            target_name: String::new(),
106            predictor_names: Vec::new(),
107            last_predictors: Vec::new(),
108            last_ts: 0,
109            interval_ns: 1_000_000_000,
110            fitted: false,
111        }
112    }
113}
114
115impl Default for MultiLinearRegression {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl MultivariateForecastModel for MultiLinearRegression {
122    fn fit(
123        &mut self,
124        ctx: &MultiSeriesContext,
125        target_idx: usize,
126    ) -> Result<(), MultivariateError> {
127        let k = ctx.matrix.n_series();
128        let n = ctx.matrix.n_timestamps();
129        if target_idx >= k {
130            return Err(MultivariateError::InvalidParameter(format!(
131                "target_idx {target_idx} >= n_series {k}"
132            )));
133        }
134        let n_pred = k - 1;
135        if n < n_pred + 2 {
136            return Err(MultivariateError::InsufficientData {
137                min: n_pred + 2,
138                got: n,
139            });
140        }
141
142        self.target_idx = target_idx;
143        self.target_name = ctx.matrix.series_ids[target_idx].clone();
144        self.predictor_names = ctx
145            .matrix
146            .series_ids
147            .iter()
148            .enumerate()
149            .filter(|(i, _)| *i != target_idx)
150            .map(|(_, name)| name.clone())
151            .collect();
152
153        let y = &ctx.matrix.data[target_idx];
154        // Build X^T X and X^T y using normal equations
155        // Design matrix: [1, x1, x2, ..., x_{k-1}] — augmented with intercept column
156        let p = n_pred + 1; // number of columns including intercept
157        let engine = CpuEngine::default();
158
159        // XtX: p × p
160        let mut xtx = vec![0.0; p * p];
161        // Xty: p × 1
162        let mut xty = vec![0.0; p];
163
164        #[allow(clippy::needless_range_loop)]
165        for t in 0..n {
166            let mut row = Vec::with_capacity(p);
167            row.push(1.0); // intercept
168            for s in 0..k {
169                if s != target_idx {
170                    row.push(ctx.matrix.data[s][t]);
171                }
172            }
173            for i in 0..p {
174                for j in 0..p {
175                    xtx[i * p + j] += row[i] * row[j];
176                }
177                xty[i] += row[i] * y[t];
178            }
179        }
180
181        // Ridge regularisation to handle collinear predictors
182        for i in 0..p {
183            xtx[i * p + i] += 1e-8;
184        }
185
186        // Solve (X^T X + λI) beta = X^T y
187        let beta = engine
188            .batch_matrix_solve(&xtx, &xty, p)
189            .map_err(|e: crate::compute::ComputeError| MultivariateError::Compute(e.to_string()))?;
190
191        self.intercept = beta[0];
192        self.coefficients = beta[1..].to_vec();
193
194        // Compute R²
195        let y_mean = y.iter().sum::<f64>() / n as f64;
196        let ss_tot: f64 = y.iter().map(|&v| (v - y_mean).powi(2)).sum();
197        let mut ss_res = 0.0;
198        #[allow(clippy::needless_range_loop)]
199        for t in 0..n {
200            let mut pred = self.intercept;
201            let mut pidx = 0;
202            for s in 0..k {
203                if s != target_idx {
204                    pred += self.coefficients[pidx] * ctx.matrix.data[s][t];
205                    pidx += 1;
206                }
207            }
208            ss_res += (y[t] - pred).powi(2);
209        }
210        self.r_squared = if ss_tot > 1e-15 {
211            1.0 - ss_res / ss_tot
212        } else {
213            1.0
214        };
215
216        // Store last observed predictor values for persistence forecasting
217        self.last_predictors = Vec::with_capacity(n_pred);
218        let last_t = n - 1;
219        for s in 0..k {
220            if s != target_idx {
221                self.last_predictors.push(ctx.matrix.data[s][last_t]);
222            }
223        }
224
225        // Timing
226        self.last_ts = ctx
227            .matrix
228            .timestamps
229            .last()
230            .copied()
231            .ok_or(MultivariateError::InsufficientData { min: 1, got: 0 })?;
232        if ctx.matrix.timestamps.len() >= 2 {
233            let ts = &ctx.matrix.timestamps;
234            let mut deltas: Vec<i64> = ts.windows(2).map(|w| w[1] - w[0]).collect();
235            deltas.sort_unstable();
236            self.interval_ns = deltas[deltas.len() / 2];
237        }
238
239        self.fitted = true;
240        Ok(())
241    }
242
243    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError> {
244        if !self.fitted {
245            return Err(MultivariateError::NotFitted);
246        }
247        // For multi-linear regression, we need predictor values for future timestamps.
248        // Since we don't have them, we use the last known predictor values (persistence forecast).
249        let timestamps: Vec<i64> = (1..=horizon as i64)
250            .map(|h| {
251                self.last_ts
252                    .saturating_add(h.saturating_mul(self.interval_ns))
253            })
254            .collect();
255
256        // Compute prediction using intercept + coefficients × last known predictor values
257        let pred_val = self.intercept
258            + self
259                .coefficients
260                .iter()
261                .zip(self.last_predictors.iter())
262                .map(|(c, x)| c * x)
263                .sum::<f64>();
264        let predictions = vec![vec![pred_val; horizon]];
265
266        let importance: Vec<(String, f64)> = self
267            .predictor_names
268            .iter()
269            .zip(self.coefficients.iter())
270            .map(|(name, &coef)| (name.clone(), coef.abs()))
271            .collect();
272
273        Ok(MultivariateForecastResult {
274            timestamps,
275            series: vec![self.target_name.clone()],
276            predictions,
277            target: self.target_name.clone(),
278            predictor_importance: importance,
279        })
280    }
281
282    fn supports_online_learning(&self) -> bool {
283        true
284    }
285}
286
287// ─── VAR (Vector AutoRegression) ────────────────────────────────────
288
289// Vector AutoRegression: the stability check uses the QR
290// eigenvalue helpers below for the VAR spectral radius.
291
292/// Reduce a square row-major matrix `h` of size `n×n` to upper Hessenberg
293/// form in-place using Householder reflections.
294fn hessenberg_reduce(h: &mut [f64], n: usize) {
295    for col in 0..n.saturating_sub(2) {
296        // Build Householder vector for column `col`, rows col+1..n
297        let m = n - col - 1;
298        let mut x = vec![0.0; m];
299        for i in 0..m {
300            x[i] = h[(col + 1 + i) * n + col];
301        }
302
303        let x_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
304        if x_norm < 1e-15 {
305            continue;
306        }
307
308        let sign = if x[0] >= 0.0 { 1.0 } else { -1.0 };
309        let alpha = -sign * x_norm;
310        x[0] -= alpha;
311        let v_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
312        if v_norm < 1e-15 {
313            continue;
314        }
315        for v in &mut x {
316            *v /= v_norm;
317        }
318
319        // Apply H = I - 2*v*v^T from the left: h[col+1..n, :] -= 2*v*(v^T * h[col+1..n, :])
320        for j in 0..n {
321            let mut dot = 0.0;
322            for i in 0..m {
323                dot += x[i] * h[(col + 1 + i) * n + j];
324            }
325            for i in 0..m {
326                h[(col + 1 + i) * n + j] -= 2.0 * x[i] * dot;
327            }
328        }
329
330        // Apply from the right: h[:, col+1..n] -= 2*(h[:, col+1..n] * v)*v^T
331        for i in 0..n {
332            let mut dot = 0.0;
333            for j in 0..m {
334                dot += h[i * n + col + 1 + j] * x[j];
335            }
336            for j in 0..m {
337                h[i * n + col + 1 + j] -= 2.0 * dot * x[j];
338            }
339        }
340    }
341}
342
343/// Francis implicit double-shift QR iteration on upper Hessenberg matrix `h`
344/// (in-place). Converges to quasi-upper-triangular (real Schur) form.
345fn qr_francis(h: &mut [f64], n: usize) {
346    if n <= 1 {
347        return;
348    }
349
350    let max_iter = 100 * n;
351    let mut iter_count = 0;
352    let mut nn = n;
353
354    while nn > 1 && iter_count < max_iter {
355        iter_count += 1;
356
357        // Deflation: check if sub-diagonal element is negligible
358        let sub = h[(nn - 1) * n + nn - 2];
359        let diag_sum = h[(nn - 2) * n + nn - 2].abs() + h[(nn - 1) * n + nn - 1].abs();
360        let tol = 1e-14 * diag_sum.max(1e-15);
361
362        if sub.abs() <= tol {
363            nn -= 1;
364            continue;
365        }
366
367        // Check for 2×2 block convergence
368        if nn > 2 {
369            let sub2 = h[(nn - 2) * n + nn - 3];
370            let diag_sum2 = h[(nn - 3) * n + nn - 3].abs() + h[(nn - 2) * n + nn - 2].abs();
371            let tol2 = 1e-14 * diag_sum2.max(1e-15);
372            if sub2.abs() <= tol2 {
373                // 2×2 block at bottom — deflate it (may be complex pair)
374                nn -= 2;
375                continue;
376            }
377        } else {
378            // 2×2 block at top — done
379            break;
380        }
381
382        // Wilkinson shift: eigenvalue of trailing 2×2 closest to h[nn-1,nn-1]
383        let a11 = h[(nn - 2) * n + nn - 2];
384        let a12 = h[(nn - 2) * n + nn - 1];
385        let a21 = h[(nn - 1) * n + nn - 2];
386        let a22 = h[(nn - 1) * n + nn - 1];
387        let trace = a11 + a22;
388        let det = a11 * a22 - a12 * a21;
389
390        // Single implicit QR step with Wilkinson shift
391        let shift = a22;
392        h[0] -= shift;
393        for i in 0..n {
394            if i < nn {
395                h[i * n + i] -= shift;
396            }
397        }
398        // Restore shift after the step
399        // Actually do a proper single-shift QR step via Givens rotations
400        // on the Hessenberg matrix.
401        // Revert the shift subtraction and use a proper implementation.
402        for i in 0..n {
403            if i < nn {
404                h[i * n + i] += shift;
405            }
406        }
407        h[0] += shift;
408
409        // Proper single-shift QR step via Givens rotations
410        let _ = trace; // suppress unused warning
411        let _ = det;
412        qr_step_givens(h, n, nn, a22);
413    }
414}
415
416/// A single implicit QR step with Wilkinson shift applied via Givens rotations
417/// on the upper Hessenberg matrix `h`. Only operates on the leading `nn×nn`
418/// active submatrix.
419fn qr_step_givens(h: &mut [f64], n: usize, nn: usize, shift: f64) {
420    // Apply shift: H - σI
421    for i in 0..nn {
422        h[i * n + i] -= shift;
423    }
424
425    // QR factorization via Givens rotations (chase the bulge)
426    let mut cs = vec![0.0; nn - 1];
427    let mut sn = vec![0.0; nn - 1];
428
429    for i in 0..nn - 1 {
430        let a = h[i * n + i];
431        let b = h[(i + 1) * n + i];
432        let r = a.hypot(b);
433        if r < 1e-30 {
434            cs[i] = 1.0;
435            sn[i] = 0.0;
436            continue;
437        }
438        cs[i] = a / r;
439        sn[i] = b / r;
440
441        // Apply Givens rotation from the left to rows i, i+1
442        for j in 0..n {
443            let t1 = h[i * n + j];
444            let t2 = h[(i + 1) * n + j];
445            h[i * n + j] = cs[i] * t1 + sn[i] * t2;
446            h[(i + 1) * n + j] = -sn[i] * t1 + cs[i] * t2;
447        }
448    }
449
450    // Apply Givens rotations from the right: R * Q
451    for i in 0..nn - 1 {
452        for j in 0..n {
453            let t1 = h[j * n + i];
454            let t2 = h[j * n + i + 1];
455            h[j * n + i] = cs[i] * t1 + sn[i] * t2;
456            h[j * n + i + 1] = -sn[i] * t1 + cs[i] * t2;
457        }
458    }
459
460    // Remove shift: H + σI
461    for i in 0..nn {
462        h[i * n + i] += shift;
463    }
464}
465
466/// Extract the spectral radius (maximum eigenvalue magnitude) from a
467/// quasi-upper-triangular (real Schur) matrix.
468fn spectral_radius_from_schur(h: &[f64], n: usize) -> f64 {
469    let mut max_mag = 0.0f64;
470    let mut i = 0;
471
472    while i < n {
473        if i + 1 < n
474            && h[(i + 1) * n + i].abs()
475                > 1e-14 * (h[i * n + i].abs() + h[(i + 1) * n + i + 1].abs()).max(1e-15)
476        {
477            // 2×2 block → complex conjugate pair
478            let a = h[i * n + i];
479            let b = h[i * n + i + 1];
480            let c = h[(i + 1) * n + i];
481            let d = h[(i + 1) * n + i + 1];
482            // Eigenvalue magnitude: sqrt(|det|) for a 2×2 block
483            // det = ad - bc, trace = a+d
484            // eigenvalues = (trace ± sqrt(trace²-4*det))/2
485            // For complex pair: |λ| = sqrt(det) when trace² < 4*det
486            let det = a * d - b * c;
487            let trace = a + d;
488            let disc = trace * trace - 4.0 * det;
489            let mag = if disc < 0.0 {
490                // Complex pair: both have magnitude sqrt(det)
491                det.abs().sqrt()
492            } else {
493                // Two real eigenvalues from the 2×2 block
494                let sq = disc.sqrt();
495                let e1 = (trace + sq) / 2.0;
496                let e2 = (trace - sq) / 2.0;
497                e1.abs().max(e2.abs())
498            };
499            max_mag = max_mag.max(mag);
500            i += 2;
501        } else {
502            // 1×1 block → real eigenvalue
503            max_mag = max_mag.max(h[i * n + i].abs());
504            i += 1;
505        }
506    }
507
508    max_mag
509}
510
511/// After fitting, the model verifies that the VAR companion matrix has
512/// a spectral radius below [`stability_threshold`](Self::new_with_stability_threshold)
513/// (default: 1.05). Models exceeding this are rejected as unstable.
514/// The QR-based eigenvalue computation correctly captures complex
515/// conjugate eigenvalue pairs (oscillatory dynamics) that power iteration
516/// would miss.
517/// Callers can override the threshold via
518/// [`new_with_stability_threshold()`](Self::new_with_stability_threshold).
519pub struct VarModel {
520    lag_order: usize,
521    /// Coefficient matrices: lag_order matrices of k×k each (row-major).
522    coefficients: Vec<Vec<f64>>,
523    intercepts: Vec<f64>,
524    k: usize,
525    last_values: Vec<Vec<f64>>, // last `lag_order` observations, each of length k
526    last_ts: i64,
527    interval_ns: i64,
528    series_names: Vec<String>,
529    /// The series the caller asked `fit` for. A VAR fits every equation, so
530    /// this selects what the caller wanted out of what the model produced —
531    /// it was `_target_idx` and thrown away, which made `predictions[0]`
532    /// silently mean series 0 rather than the requested one.
533    target_name: String,
534    fitted: bool,
535    /// Maximum allowed spectral radius for stability check.
536    stability_threshold: f64,
537}
538
539impl VarModel {
540    /// Creates a VAR model with the given lag order (default: 1).
541    pub fn new(lag_order: Option<usize>) -> Self {
542        Self::new_with_stability_threshold(lag_order, None)
543    }
544
545    /// Creates a VAR model with a custom stability threshold.
546    ///
547    /// The `stability_threshold` controls the maximum allowed spectral
548    /// radius of the VAR companion matrix (default: 1.05). Set to a
549    /// value > 1.0 to tolerate near-unit-root processes, or closer to
550    /// 1.0 for stricter stability enforcement.
551    pub fn new_with_stability_threshold(
552        lag_order: Option<usize>,
553        stability_threshold: Option<f64>,
554    ) -> Self {
555        Self {
556            lag_order: lag_order.unwrap_or(1),
557            coefficients: Vec::new(),
558            intercepts: Vec::new(),
559            k: 0,
560            last_values: Vec::new(),
561            last_ts: 0,
562            interval_ns: 1_000_000_000,
563            series_names: Vec::new(),
564            target_name: String::new(),
565            fitted: false,
566            stability_threshold: stability_threshold.unwrap_or(1.05),
567        }
568    }
569
570    /// Compute spectral radius of the VAR companion matrix.
571    ///
572    /// Uses the Francis QR algorithm (implicit double-shift)
573    /// to compute all eigenvalues, including complex conjugate pairs that
574    /// power iteration would miss. The spectral radius is max(|λ_i|).
575    ///
576    /// The companion matrix for VAR(p) with k variables is a (pk × pk) matrix.
577    /// The VAR is stable iff all eigenvalues lie inside the unit circle,
578    /// i.e., spectral radius < 1.
579    fn compute_spectral_radius(&self) -> f64 {
580        let k = self.k;
581        let p = self.lag_order;
582        let n = p * k;
583        if n == 0 {
584            return 0.0;
585        }
586
587        // Build companion matrix A of size n×n (row-major):
588        //
589        //   A = [ A_1  A_2  ...  A_p ]
590        //       [  I    0   ...   0  ]
591        //       [  0    I   ...   0  ]
592        //       [ ...             0  ]
593        //
594        // where A_l(i,j) = coefficients[i][l*k + j]
595        let mut h = vec![0.0; n * n];
596
597        // First k rows: the VAR coefficient matrices
598        for i in 0..k {
599            for l in 0..p {
600                for j in 0..k {
601                    h[i * n + l * k + j] = self.coefficients[i][l * k + j];
602                }
603            }
604        }
605
606        // Identity sub-diagonals: rows k..n
607        for i in k..n {
608            h[i * n + (i - k)] = 1.0;
609        }
610
611        // ── QR iteration via implicit shifts (Hessenberg form) ──────
612        // Step 1: Reduce to upper Hessenberg form via Householder reflections.
613        hessenberg_reduce(&mut h, n);
614
615        // Step 2: Francis QR iteration to converge to quasi-upper-triangular
616        // (real Schur) form — 2×2 diagonal blocks represent complex pairs.
617        qr_francis(&mut h, n);
618
619        // Step 3: Extract eigenvalue magnitudes from the diagonal / 2×2 blocks.
620        spectral_radius_from_schur(&h, n)
621    }
622}
623
624impl MultivariateForecastModel for VarModel {
625    fn fit(
626        &mut self,
627        ctx: &MultiSeriesContext,
628        target_idx: usize,
629    ) -> Result<(), MultivariateError> {
630        let k = ctx.matrix.n_series();
631        let n = ctx.matrix.n_timestamps();
632        let p = self.lag_order;
633
634        if target_idx >= k {
635            return Err(MultivariateError::InvalidParameter(format!(
636                "target_idx {target_idx} >= n_series {k}"
637            )));
638        }
639        self.target_name = ctx.matrix.series_ids[target_idx].clone();
640
641        if n <= p + 1 {
642            return Err(MultivariateError::InsufficientData { min: p + 2, got: n });
643        }
644
645        self.k = k;
646        self.series_names = ctx.matrix.series_ids.clone();
647
648        let engine = CpuEngine::default();
649
650        // For each equation i: y_i(t) = c_i + Σ_{l=1..p} Σ_{j=1..k} A_l(i,j) * y_j(t-l)
651        // Solved via OLS per equation.
652        let dim = 1 + p * k; // intercept + p lags × k series
653        self.intercepts = vec![0.0; k];
654        self.coefficients = vec![vec![0.0; p * k]; k]; // per-equation
655
656        for eq in 0..k {
657            let mut xtx = vec![0.0; dim * dim];
658            let mut xty = vec![0.0; dim];
659
660            for t in p..n {
661                let mut row = Vec::with_capacity(dim);
662                row.push(1.0); // intercept
663                for l in 1..=p {
664                    for s in 0..k {
665                        row.push(ctx.matrix.data[s][t - l]);
666                    }
667                }
668                let y = ctx.matrix.data[eq][t];
669                for i in 0..dim {
670                    for j in 0..dim {
671                        xtx[i * dim + j] += row[i] * row[j];
672                    }
673                    xty[i] += row[i] * y;
674                }
675            }
676
677            // Regularize to avoid singular matrix
678            for i in 0..dim {
679                xtx[i * dim + i] += 1e-8;
680            }
681
682            let beta = engine.batch_matrix_solve(&xtx, &xty, dim).map_err(
683                |e: crate::compute::ComputeError| MultivariateError::Compute(e.to_string()),
684            )?;
685
686            self.intercepts[eq] = beta[0];
687            self.coefficients[eq] = beta[1..].to_vec();
688        }
689
690        // Store last p observations for prediction
691        self.last_values = Vec::with_capacity(p);
692        for l in 0..p {
693            let t = n - p + l;
694            let obs: Vec<f64> = (0..k).map(|s| ctx.matrix.data[s][t]).collect();
695            self.last_values.push(obs);
696        }
697
698        // Timing
699        self.last_ts = ctx
700            .matrix
701            .timestamps
702            .last()
703            .copied()
704            .ok_or(MultivariateError::InsufficientData { min: 1, got: 0 })?;
705        if ctx.matrix.timestamps.len() >= 2 {
706            let ts = &ctx.matrix.timestamps;
707            let mut deltas: Vec<i64> = ts.windows(2).map(|w| w[1] - w[0]).collect();
708            deltas.sort_unstable();
709            self.interval_ns = deltas[deltas.len() / 2];
710        }
711
712        self.fitted = true;
713
714        // VAR stability check — verify spectral radius < threshold.
715        // Build the companion matrix and compute spectral radius via
716        // power iteration. Unstable VAR produces exponentially diverging
717        // forecasts. The threshold (default 1.05) is configurable via
718        // `new_with_stability_threshold()`.
719        let stability_threshold = self.stability_threshold;
720        let spectral_radius = self.compute_spectral_radius();
721        if spectral_radius >= stability_threshold {
722            tracing::warn!(
723                spectral_radius = spectral_radius,
724                "VAR model is unstable (spectral radius >= {stability_threshold}) — forecasts may diverge"
725            );
726            return Err(MultivariateError::InvalidParameter(format!(
727                "VAR model is unstable: spectral radius {spectral_radius:.4} >= {stability_threshold}; \
728                 try increasing regularization, reducing lag order, or differencing the data"
729            )));
730        } else if spectral_radius >= 1.0 {
731            tracing::warn!(
732                spectral_radius = spectral_radius,
733                "VAR model is near unit-root (spectral radius close to 1.0) — \
734                 forecasts may drift; consider differencing"
735            );
736        }
737
738        Ok(())
739    }
740
741    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError> {
742        if !self.fitted {
743            return Err(MultivariateError::NotFitted);
744        }
745
746        let k = self.k;
747        let p = self.lag_order;
748        let mut history = self.last_values.clone();
749        let mut predictions = vec![Vec::with_capacity(horizon); k];
750        let timestamps: Vec<i64> = (1..=horizon as i64)
751            .map(|h| {
752                self.last_ts
753                    .saturating_add(h.saturating_mul(self.interval_ns))
754            })
755            .collect();
756
757        for _ in 0..horizon {
758            let mut new_obs = vec![0.0; k];
759            #[allow(clippy::needless_range_loop)]
760            for eq in 0..k {
761                let mut val = self.intercepts[eq];
762                #[allow(clippy::needless_range_loop)]
763                for l in 0..p {
764                    let lag_idx = history.len() - 1 - l;
765                    if lag_idx < history.len() {
766                        for s in 0..k {
767                            val += self.coefficients[eq][l * k + s] * history[lag_idx][s];
768                        }
769                    }
770                }
771                new_obs[eq] = val;
772            }
773            for eq in 0..k {
774                predictions[eq].push(new_obs[eq]);
775            }
776            history.push(new_obs);
777        }
778
779        // Importance: average absolute coefficient per series
780        let mut importance = Vec::with_capacity(k);
781        for s in 0..k {
782            let avg: f64 = (0..k)
783                .flat_map(|eq| (0..p).map(move |l| self.coefficients[eq][l * k + s].abs()))
784                .sum::<f64>()
785                / (k * p) as f64;
786            importance.push((self.series_names[s].clone(), avg));
787        }
788
789        Ok(MultivariateForecastResult {
790            timestamps,
791            series: self.series_names.clone(),
792            predictions,
793            target: self.target_name.clone(),
794            predictor_importance: importance,
795        })
796    }
797
798    fn supports_online_learning(&self) -> bool {
799        false
800    }
801}
802
803// ─── Granger Causality ──────────────────────────────────────────────
804
805/// Result of a pairwise Granger causality test.
806#[derive(Debug, Clone)]
807pub struct GrangerCausalityResult {
808    /// Name of the "cause" series (whose lags were excluded in H₀).
809    pub cause: String,
810    /// Name of the "effect" series being predicted.
811    pub effect: String,
812    /// Lag order used for the test.
813    pub lag_order: usize,
814    /// F-statistic.
815    pub f_statistic: f64,
816    /// Raw (unadjusted) p-value from the F-distribution.
817    pub p_value: f64,
818    /// Benjamini-Hochberg adjusted p-value.
819    /// Controls the false discovery rate across all pairwise tests.
820    pub adjusted_p_value: f64,
821    /// Whether H₀ is rejected after BH correction at α = 0.05.
822    pub significant: bool,
823    /// RSS of the unrestricted model (all lags included).
824    pub rss_unrestricted: f64,
825    /// RSS of the restricted model (cause lags excluded).
826    pub rss_restricted: f64,
827}
828
829impl VarModel {
830    /// Perform pairwise Granger causality tests for all series pairs.
831    ///
832    /// For each pair (cause → effect), fits an unrestricted VAR equation
833    /// (with all series' lags) and a restricted equation (excluding the
834    /// cause's lags), then compares RSS via an F-test.
835    ///
836    /// **Requires** the model to be fitted first via `fit()`.
837    ///
838    /// # F-test
839    ///
840    /// $$F = \frac{(RSS_r - RSS_u) / p}{RSS_u / (T - k_{full})}$$
841    ///
842    /// where $p$ = lag order (number of excluded regressors),
843    /// $T$ = number of usable observations, and $k_{full}$ = number of
844    /// regressors in the unrestricted equation (1 + p × k).
845    pub fn granger_causality(
846        &self,
847        ctx: &MultiSeriesContext,
848    ) -> Result<Vec<GrangerCausalityResult>, MultivariateError> {
849        if !self.fitted {
850            return Err(MultivariateError::NotFitted);
851        }
852
853        let k = self.k;
854        let p = self.lag_order;
855        let n = ctx.matrix.n_timestamps();
856        let t = n - p; // usable observations
857
858        if t <= 1 + p * k {
859            return Err(MultivariateError::InsufficientData {
860                min: 2 + p * k,
861                got: t,
862            });
863        }
864
865        let engine = CpuEngine::default();
866        let dim_full = 1 + p * k; // unrestricted: intercept + p lags × k series
867        let dim_restricted = 1 + p * (k - 1); // restricted: exclude one series' lags
868
869        let mut results = Vec::with_capacity(k * (k - 1));
870
871        for effect_eq in 0..k {
872            for cause_s in 0..k {
873                if cause_s == effect_eq {
874                    continue;
875                }
876
877                // ── Unrestricted model: all lags ──
878                let rss_u = self.ols_rss(ctx, &engine, effect_eq, None, dim_full, p, k)?;
879
880                // ── Restricted model: exclude cause_s lags ──
881                let rss_r =
882                    self.ols_rss(ctx, &engine, effect_eq, Some(cause_s), dim_restricted, p, k)?;
883
884                // ── F-test ──
885                let df1 = p as f64; // numerator df
886                let df2 = (t - dim_full) as f64; // denominator df
887                let f_stat = if rss_u.abs() < 1e-30 {
888                    0.0
889                } else {
890                    ((rss_r - rss_u) / df1) / (rss_u / df2)
891                };
892
893                // p-value from F(df1, df2) using the regularized incomplete
894                // beta function: P(F > f) = 1 - I_{x}(a, b) where
895                // x = df2 / (df2 + df1 * f), a = df2/2, b = df1/2.
896                let p_value = f_distribution_sf(f_stat, df1, df2);
897
898                results.push(GrangerCausalityResult {
899                    cause: self.series_names[cause_s].clone(),
900                    effect: self.series_names[effect_eq].clone(),
901                    lag_order: p,
902                    f_statistic: f_stat,
903                    p_value,
904                    adjusted_p_value: p_value, // will be corrected below
905                    significant: false,        // will be set after BH
906                    rss_unrestricted: rss_u,
907                    rss_restricted: rss_r,
908                });
909            }
910        }
911
912        // Benjamini-Hochberg FDR correction for multiple comparisons.
913        // Sort by p-value ascending, apply BH threshold:
914        //   threshold_i = α × rank_i / m
915        let m = results.len();
916        if m > 0 {
917            let alpha = 0.05;
918            let mut indices: Vec<usize> = (0..m).collect();
919            indices.sort_by(|&a, &b| {
920                results[a]
921                    .p_value
922                    .partial_cmp(&results[b].p_value)
923                    .unwrap_or(std::cmp::Ordering::Equal)
924            });
925
926            // Compute BH-adjusted p-values (step-up procedure).
927            // adjusted_p[i] = min(p[i] * m / rank, min of adjusted_p[j] for j > i)
928            let mut adj_p = vec![0.0; m];
929            for (rank, &idx) in indices.iter().enumerate() {
930                adj_p[idx] = (results[idx].p_value * m as f64 / (rank + 1) as f64).min(1.0);
931            }
932            // Enforce monotonicity: walk backwards through rank order
933            let mut running_min = 1.0;
934            for &idx in indices.iter().rev() {
935                adj_p[idx] = adj_p[idx].min(running_min);
936                running_min = adj_p[idx];
937            }
938            for (i, p_adj) in adj_p.into_iter().enumerate() {
939                results[i].adjusted_p_value = p_adj;
940                results[i].significant = p_adj < alpha;
941            }
942        }
943
944        Ok(results)
945    }
946
947    /// HC3-robust pairwise Granger causality tests.
948    ///
949    /// Same hypothesis as `granger_causality` but uses the HC3
950    /// (MacKinnon & White 1985) covariance estimator, which is valid
951    /// under arbitrary conditional heteroscedasticity.
952    ///
953    /// For each pair (cause → effect) the unrestricted OLS equation is
954    /// fitted, residuals are obtained, and a Wald test with HC3
955    /// sandwich covariance is computed:
956    ///
957    /// $$W = (R\hat\beta)^\top (R\,\hat V_{HC3}\,R^\top)^{-1}(R\hat\beta)$$
958    ///
959    /// where $R$ selects the $p$ coefficients of the candidate cause.
960    /// $F = W / p$ is compared against $F(p, T - kp - 1)$.
961    pub fn granger_causality_robust(
962        &self,
963        ctx: &MultiSeriesContext,
964    ) -> Result<Vec<GrangerCausalityResult>, MultivariateError> {
965        if !self.fitted {
966            return Err(MultivariateError::NotFitted);
967        }
968
969        let k = self.k;
970        let p = self.lag_order;
971        let n = ctx.matrix.n_timestamps();
972        let t = n - p;
973
974        if t <= 1 + p * k {
975            return Err(MultivariateError::InsufficientData {
976                min: 2 + p * k,
977                got: t,
978            });
979        }
980
981        let engine = CpuEngine::default();
982        let dim = 1 + p * k; // unrestricted model dimension
983        let df_den = t as f64 - dim as f64;
984
985        if df_den <= 0.0 {
986            return Err(MultivariateError::InsufficientData {
987                min: dim + p + 1,
988                got: t,
989            });
990        }
991
992        let mut results = Vec::with_capacity(k * (k - 1));
993
994        for effect_eq in 0..k {
995            // Build X (t × dim) and y (t) for the unrestricted equation.
996            let mut x_mat = vec![0.0_f64; t * dim];
997            let mut y_vec = vec![0.0_f64; t];
998
999            for (row, obs) in (p..n).enumerate() {
1000                y_vec[row] = ctx.matrix.data[effect_eq][obs];
1001                x_mat[row * dim] = 1.0;
1002                let mut col = 1;
1003                for l in 1..=p {
1004                    for s in 0..k {
1005                        x_mat[row * dim + col] = ctx.matrix.data[s][obs - l];
1006                        col += 1;
1007                    }
1008                }
1009            }
1010
1011            // X'X (dim × dim)
1012            let mut xtx = vec![0.0_f64; dim * dim];
1013            for row in 0..t {
1014                for i in 0..dim {
1015                    let xi = x_mat[row * dim + i];
1016                    for j in i..dim {
1017                        let v = xi * x_mat[row * dim + j];
1018                        xtx[i * dim + j] += v;
1019                        if i != j {
1020                            xtx[j * dim + i] += v;
1021                        }
1022                    }
1023                }
1024            }
1025            // X'y
1026            let mut xty = vec![0.0_f64; dim];
1027            for row in 0..t {
1028                let y = y_vec[row];
1029                for i in 0..dim {
1030                    xty[i] += x_mat[row * dim + i] * y;
1031                }
1032            }
1033            // Ridge regularization
1034            for i in 0..dim {
1035                xtx[i * dim + i] += 1e-8;
1036            }
1037
1038            // β = (X'X)^{-1} X'y
1039            let beta = engine
1040                .batch_matrix_solve(&xtx, &xty, dim)
1041                .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1042
1043            // Residuals
1044            let mut residuals = vec![0.0_f64; t];
1045            for row in 0..t {
1046                let mut fitted = 0.0;
1047                for j in 0..dim {
1048                    fitted += x_mat[row * dim + j] * beta[j];
1049                }
1050                residuals[row] = y_vec[row] - fitted;
1051            }
1052
1053            // (X'X)^{-1} via column solves
1054            let mut xtx_inv = vec![0.0_f64; dim * dim];
1055            for c in 0..dim {
1056                let mut rhs = vec![0.0_f64; dim];
1057                rhs[c] = 1.0;
1058                let sol = engine
1059                    .batch_matrix_solve(&xtx, &rhs, dim)
1060                    .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1061                for r in 0..dim {
1062                    xtx_inv[r * dim + c] = sol[r];
1063                }
1064            }
1065
1066            // Hat diagonal: h_ii = x_i' (X'X)^{-1} x_i
1067            let mut hat_diag = vec![0.0_f64; t];
1068            for row in 0..t {
1069                let mut h = 0.0;
1070                for i in 0..dim {
1071                    let mut inner = 0.0;
1072                    for j in 0..dim {
1073                        inner += xtx_inv[i * dim + j] * x_mat[row * dim + j];
1074                    }
1075                    h += x_mat[row * dim + i] * inner;
1076                }
1077                hat_diag[row] = h.clamp(0.0, 1.0 - 1e-12);
1078            }
1079
1080            // HC3 meat: M = Σ x_i x_i' e_i² / (1 - h_ii)²
1081            let mut meat = vec![0.0_f64; dim * dim];
1082            for row in 0..t {
1083                let denom = (1.0 - hat_diag[row]).powi(2);
1084                let w = residuals[row].powi(2) / denom;
1085                for i in 0..dim {
1086                    let xi_w = x_mat[row * dim + i] * w;
1087                    for j in i..dim {
1088                        let v = xi_w * x_mat[row * dim + j];
1089                        meat[i * dim + j] += v;
1090                        if i != j {
1091                            meat[j * dim + i] += v;
1092                        }
1093                    }
1094                }
1095            }
1096
1097            // V_HC3 = (X'X)^{-1} M (X'X)^{-1}
1098            let mut tmp = vec![0.0_f64; dim * dim];
1099            for i in 0..dim {
1100                for j in 0..dim {
1101                    let mut s = 0.0;
1102                    for c in 0..dim {
1103                        s += xtx_inv[i * dim + c] * meat[c * dim + j];
1104                    }
1105                    tmp[i * dim + j] = s;
1106                }
1107            }
1108            let mut v_hc3 = vec![0.0_f64; dim * dim];
1109            for i in 0..dim {
1110                for j in 0..dim {
1111                    let mut s = 0.0;
1112                    for c in 0..dim {
1113                        s += tmp[i * dim + c] * xtx_inv[c * dim + j];
1114                    }
1115                    v_hc3[i * dim + j] = s;
1116                }
1117            }
1118
1119            // RSS for the unrestricted model (for the result struct)
1120            let rss_u: f64 = residuals.iter().map(|e| e * e).sum();
1121
1122            // Now test each potential cause against this effect
1123            for cause_s in 0..k {
1124                if cause_s == effect_eq {
1125                    continue;
1126                }
1127
1128                // R selects p columns for cause_s lags in unrestricted model:
1129                // col = 1 + (l-1)*k + cause_s  for l = 1..=p
1130                let cause_cols: Vec<usize> = (1..=p).map(|l| 1 + (l - 1) * k + cause_s).collect();
1131
1132                // Rβ
1133                let r_beta: Vec<f64> = cause_cols.iter().map(|&c| beta[c]).collect();
1134
1135                // R V_HC3 R' (p × p)
1136                let mut r_v_rt = vec![0.0_f64; p * p];
1137                for (i, &ci) in cause_cols.iter().enumerate() {
1138                    for (j, &cj) in cause_cols.iter().enumerate() {
1139                        r_v_rt[i * p + j] = v_hc3[ci * dim + cj];
1140                    }
1141                }
1142                for i in 0..p {
1143                    r_v_rt[i * p + i] += 1e-12;
1144                }
1145
1146                // Wald = (Rβ)' (R V R')^{-1} (Rβ)
1147                let inv_r_beta = engine
1148                    .batch_matrix_solve(&r_v_rt, &r_beta, p)
1149                    .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1150
1151                let wald: f64 = r_beta
1152                    .iter()
1153                    .zip(inv_r_beta.iter())
1154                    .map(|(a, b)| a * b)
1155                    .sum();
1156                let f_stat = (wald / p as f64).max(0.0);
1157                let p_value = f_distribution_sf(f_stat, p as f64, df_den);
1158
1159                // Also compute restricted RSS for the result struct
1160                let dim_r = 1 + p * (k - 1);
1161                let rss_r = self.ols_rss(ctx, &engine, effect_eq, Some(cause_s), dim_r, p, k)?;
1162
1163                results.push(GrangerCausalityResult {
1164                    cause: self.series_names[cause_s].clone(),
1165                    effect: self.series_names[effect_eq].clone(),
1166                    lag_order: p,
1167                    f_statistic: f_stat,
1168                    p_value,
1169                    adjusted_p_value: p_value,
1170                    significant: false,
1171                    rss_unrestricted: rss_u,
1172                    rss_restricted: rss_r,
1173                });
1174            }
1175        }
1176
1177        // BH correction
1178        let m = results.len();
1179        if m > 0 {
1180            let alpha = 0.05;
1181            let mut indices: Vec<usize> = (0..m).collect();
1182            indices.sort_by(|&a, &b| {
1183                results[a]
1184                    .p_value
1185                    .partial_cmp(&results[b].p_value)
1186                    .unwrap_or(std::cmp::Ordering::Equal)
1187            });
1188            let mut adj_p = vec![0.0; m];
1189            for (rank, &idx) in indices.iter().enumerate() {
1190                adj_p[idx] = (results[idx].p_value * m as f64 / (rank + 1) as f64).min(1.0);
1191            }
1192            let mut running_min = 1.0;
1193            for &idx in indices.iter().rev() {
1194                adj_p[idx] = adj_p[idx].min(running_min);
1195                running_min = adj_p[idx];
1196            }
1197            for (i, p_adj) in adj_p.into_iter().enumerate() {
1198                results[i].adjusted_p_value = p_adj;
1199                results[i].significant = p_adj < alpha;
1200            }
1201        }
1202
1203        Ok(results)
1204    }
1205
1206    /// Fit one OLS equation and return RSS.
1207    ///
1208    /// If `exclude_series` is `Some(s)`, the lags of series `s` are
1209    /// omitted from the design matrix (restricted model for Granger test).
1210    fn ols_rss(
1211        &self,
1212        ctx: &MultiSeriesContext,
1213        engine: &CpuEngine,
1214        effect_eq: usize,
1215        exclude_series: Option<usize>,
1216        dim: usize,
1217        p: usize,
1218        k: usize,
1219    ) -> Result<f64, MultivariateError> {
1220        let n = ctx.matrix.n_timestamps();
1221        let mut xtx = vec![0.0; dim * dim];
1222        let mut xty = vec![0.0; dim];
1223
1224        for obs in p..n {
1225            let mut row = Vec::with_capacity(dim);
1226            row.push(1.0); // intercept
1227            for l in 1..=p {
1228                for s in 0..k {
1229                    if exclude_series == Some(s) {
1230                        continue;
1231                    }
1232                    row.push(ctx.matrix.data[s][obs - l]);
1233                }
1234            }
1235            let y = ctx.matrix.data[effect_eq][obs];
1236            for i in 0..dim {
1237                for j in 0..dim {
1238                    xtx[i * dim + j] += row[i] * row[j];
1239                }
1240                xty[i] += row[i] * y;
1241            }
1242        }
1243
1244        // Regularize
1245        for i in 0..dim {
1246            xtx[i * dim + i] += 1e-8;
1247        }
1248
1249        let beta = engine
1250            .batch_matrix_solve(&xtx, &xty, dim)
1251            .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1252
1253        // Compute RSS
1254        let mut rss = 0.0;
1255        for obs in p..n {
1256            let mut pred = beta[0]; // intercept
1257            let mut col = 1;
1258            for l in 1..=p {
1259                for s in 0..k {
1260                    if exclude_series == Some(s) {
1261                        continue;
1262                    }
1263                    pred += beta[col] * ctx.matrix.data[s][obs - l];
1264                    col += 1;
1265                }
1266            }
1267            let residual = ctx.matrix.data[effect_eq][obs] - pred;
1268            rss += residual * residual;
1269        }
1270
1271        Ok(rss)
1272    }
1273}
1274
1275/// Survival function of the F-distribution: P(F > f) for F ~ F(d1, d2).
1276///
1277/// Uses the regularized incomplete beta function relation:
1278/// P(F > f) = I_{x}(d2/2, d1/2) where x = d2 / (d2 + d1 * f).
1279fn f_distribution_sf(f: f64, d1: f64, d2: f64) -> f64 {
1280    if f <= 0.0 || d1 <= 0.0 || d2 <= 0.0 {
1281        return 1.0;
1282    }
1283    let z = d2 / (d2 + d1 * f);
1284    regularized_incomplete_beta(z, d2 / 2.0, d1 / 2.0)
1285}
1286
1287/// Regularized incomplete beta function I_x(a, b) via continued fraction
1288/// (Lentz's method).  Accurate to ~1e-10 for typical F-test parameters.
1289fn regularized_incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
1290    if x <= 0.0 {
1291        return 0.0;
1292    }
1293    if x >= 1.0 {
1294        return 1.0;
1295    }
1296
1297    // Use the symmetry relation when x > (a+1)/(a+b+2) for faster
1298    // convergence of the continued fraction.
1299    if x > (a + 1.0) / (a + b + 2.0) {
1300        return 1.0 - regularized_incomplete_beta(1.0 - x, b, a);
1301    }
1302
1303    let ln_prefix = a * x.ln() + b * (1.0 - x).ln() - ln_beta(a, b) - a.ln();
1304    let prefix = ln_prefix.exp();
1305
1306    // Lentz's continued fraction
1307    let max_iter = 200;
1308    let eps = 1e-14;
1309    let tiny = 1e-30;
1310
1311    let mut c = 1.0;
1312    let mut d = 1.0 - (a + b) * x / (a + 1.0);
1313    if d.abs() < tiny {
1314        d = tiny;
1315    }
1316    d = 1.0 / d;
1317    let mut h = d;
1318
1319    for m in 1..=max_iter {
1320        let m_f64 = m as f64;
1321
1322        // Even step: d_{2m}
1323        let num_even = m_f64 * (b - m_f64) * x / ((a + 2.0 * m_f64 - 1.0) * (a + 2.0 * m_f64));
1324        d = 1.0 + num_even * d;
1325        if d.abs() < tiny {
1326            d = tiny;
1327        }
1328        c = 1.0 + num_even / c;
1329        if c.abs() < tiny {
1330            c = tiny;
1331        }
1332        d = 1.0 / d;
1333        h *= d * c;
1334
1335        // Odd step: d_{2m+1}
1336        let num_odd =
1337            -((a + m_f64) * (a + b + m_f64)) * x / ((a + 2.0 * m_f64) * (a + 2.0 * m_f64 + 1.0));
1338        d = 1.0 + num_odd * d;
1339        if d.abs() < tiny {
1340            d = tiny;
1341        }
1342        c = 1.0 + num_odd / c;
1343        if c.abs() < tiny {
1344            c = tiny;
1345        }
1346        d = 1.0 / d;
1347        let delta = d * c;
1348        h *= delta;
1349
1350        if (delta - 1.0).abs() < eps {
1351            break;
1352        }
1353    }
1354
1355    prefix * h
1356}
1357
1358/// Log of the Beta function: ln B(a,b) = ln Γ(a) + ln Γ(b) − ln Γ(a+b).
1359fn ln_beta(a: f64, b: f64) -> f64 {
1360    use crate::forecast::diagnostics::ln_gamma;
1361    ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b)
1362}
1363#[cfg(test)]
1364mod tests {
1365    use super::*;
1366
1367    fn simple_ctx() -> MultiSeriesContext {
1368        let n = 100;
1369        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1370        // Use independent predictors: x1 = linear, x2 = sinusoidal
1371        let x1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1372        let x2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin() * 10.0).collect();
1373        // y = 2*x1 + 3*x2 + 5
1374        let y: Vec<f64> = (0..n)
1375            .map(|i| 2.0 * (i as f64) + 3.0 * (i as f64 * 0.1).sin() * 10.0 + 5.0)
1376            .collect();
1377        MultiSeriesContext::build(
1378            vec![
1379                ("y".to_string(), ts.clone(), y),
1380                ("x1".to_string(), ts.clone(), x1),
1381                ("x2".to_string(), ts, x2),
1382            ],
1383            None,
1384        )
1385        .unwrap()
1386    }
1387
1388    #[test]
1389    fn multi_linear_regression_fit() {
1390        let ctx = simple_ctx();
1391        let mut model = MultiLinearRegression::new();
1392        model.fit(&ctx, 0).unwrap();
1393        assert!(model.r_squared > 0.99, "R²={}", model.r_squared);
1394        // Coefficients should be close to [2.0, 3.0]
1395        assert!((model.coefficients[0] - 2.0).abs() < 0.5);
1396        assert!((model.coefficients[1] - 3.0).abs() < 0.5);
1397    }
1398
1399    #[test]
1400    fn multi_linear_regression_predict() {
1401        let ctx = simple_ctx();
1402        let mut model = MultiLinearRegression::new();
1403        model.fit(&ctx, 0).unwrap();
1404        let result = model.predict(5).unwrap();
1405        assert_eq!(result.predictions[0].len(), 5);
1406        assert_eq!(result.timestamps.len(), 5);
1407        // With y = 2*x1 + 3*x2 + 5, predictions should use last known predictor values.
1408        // Last x1 = 99.0, last x2 = sin(9.9)*10 ≈ -4.56; expected ≈ 2*99 + 3*(-4.56) + 5 ≈ 189.3
1409        let pred = result.predictions[0][0];
1410        assert!(
1411            pred > 100.0,
1412            "prediction {} should reflect learned coefficients, not just intercept",
1413            pred
1414        );
1415    }
1416
1417    #[test]
1418    fn var_fit_and_predict() {
1419        let n = 200;
1420        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1421        let s1: Vec<f64> = (0..n)
1422            .map(|i| (i as f64 * 0.05).sin() * 10.0 + 50.0)
1423            .collect();
1424        let s2: Vec<f64> = (0..n)
1425            .map(|i| (i as f64 * 0.05).cos() * 10.0 + 50.0)
1426            .collect();
1427        let ctx = MultiSeriesContext::build(
1428            vec![
1429                ("s1".to_string(), ts.clone(), s1),
1430                ("s2".to_string(), ts, s2),
1431            ],
1432            None,
1433        )
1434        .unwrap();
1435
1436        let mut model = VarModel::new(Some(2));
1437        model.fit(&ctx, 0).unwrap();
1438        let result = model.predict(10).unwrap();
1439        assert_eq!(result.predictions.len(), 2); // both series
1440        assert_eq!(result.predictions[0].len(), 10);
1441        assert_eq!(result.predictions[1].len(), 10);
1442    }
1443
1444    #[test]
1445    fn var_predictor_importance() {
1446        let n = 200;
1447        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1448        let s1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1449        let s2: Vec<f64> = (0..n).map(|i| i as f64 * 2.0).collect();
1450        let ctx = MultiSeriesContext::build(
1451            vec![
1452                ("s1".to_string(), ts.clone(), s1),
1453                ("s2".to_string(), ts, s2),
1454            ],
1455            None,
1456        )
1457        .unwrap();
1458
1459        let mut model = VarModel::new(Some(1));
1460        model.fit(&ctx, 0).unwrap();
1461        let result = model.predict(5).unwrap();
1462        assert_eq!(result.predictor_importance.len(), 2);
1463    }
1464
1465    #[test]
1466    fn granger_causality_detects_cause() {
1467        // s2(t) = 0.8 * s1(t-1) + noise → s1 Granger-causes s2
1468        let n = 300;
1469        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1470        // Simple deterministic "pseudo-random" via LCG
1471        let mut rng_state: u64 = 42;
1472        let mut pseudo_rand = || -> f64 {
1473            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1474            ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.5
1475        };
1476
1477        let s1: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin() * 5.0).collect();
1478        let mut s2 = vec![0.0; n];
1479        for i in 1..n {
1480            s2[i] = 0.8 * s1[i - 1] + pseudo_rand();
1481        }
1482
1483        let ctx = MultiSeriesContext::build(
1484            vec![
1485                ("s1".to_string(), ts.clone(), s1),
1486                ("s2".to_string(), ts, s2),
1487            ],
1488            None,
1489        )
1490        .unwrap();
1491
1492        let mut model = VarModel::new(Some(2));
1493        model.fit(&ctx, 0).unwrap();
1494
1495        let results = model.granger_causality(&ctx).unwrap();
1496        assert_eq!(results.len(), 2); // s1→s2 and s2→s1
1497
1498        let s1_causes_s2 = results
1499            .iter()
1500            .find(|r| r.cause == "s1" && r.effect == "s2")
1501            .unwrap();
1502        assert!(
1503            s1_causes_s2.significant,
1504            "s1 should Granger-cause s2: F={:.3}, p={:.4}",
1505            s1_causes_s2.f_statistic, s1_causes_s2.p_value
1506        );
1507        assert!(s1_causes_s2.f_statistic > 1.0);
1508        assert!(s1_causes_s2.p_value < 0.05);
1509    }
1510
1511    #[test]
1512    fn granger_requires_fitted_model() {
1513        let n = 100;
1514        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1515        let s1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1516        let s2: Vec<f64> = (0..n).map(|i| (i as f64).sqrt()).collect();
1517        let ctx = MultiSeriesContext::build(
1518            vec![
1519                ("s1".to_string(), ts.clone(), s1),
1520                ("s2".to_string(), ts, s2),
1521            ],
1522            None,
1523        )
1524        .unwrap();
1525
1526        let model = VarModel::new(Some(1));
1527        let err = model.granger_causality(&ctx);
1528        assert!(err.is_err());
1529    }
1530
1531    #[test]
1532    fn granger_robust_detects_cause() {
1533        // Same causal setup as granger_causality_detects_cause.
1534        let n = 200;
1535        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1536        let mut s1 = vec![0.0; n];
1537        let mut s2 = vec![0.0; n];
1538        let mut rng_state: u64 = 12345;
1539        for i in 1..n {
1540            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1541            let noise1 = ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.5;
1542            s1[i] = 0.7 * s1[i - 1] + noise1;
1543            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1544            let noise2 = ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.2;
1545            // s2 is caused by s1 at lag 1
1546            s2[i] = 0.8 * s1[i - 1] + noise2;
1547        }
1548        let ctx = MultiSeriesContext::build(
1549            vec![
1550                ("s1".to_string(), ts.clone(), s1),
1551                ("s2".to_string(), ts, s2),
1552            ],
1553            None,
1554        )
1555        .unwrap();
1556
1557        let mut model = VarModel::new(Some(1));
1558        model.fit(&ctx, 1).unwrap();
1559        let results = model.granger_causality_robust(&ctx).unwrap();
1560        // s1 → s2 should be significant
1561        let s1_causes_s2 = results
1562            .iter()
1563            .find(|r| r.cause == "s1" && r.effect == "s2")
1564            .unwrap();
1565        assert!(
1566            s1_causes_s2.significant,
1567            "HC3: s1 should Granger-cause s2: F={:.3}, p={:.4}, adj_p={:.4}",
1568            s1_causes_s2.f_statistic, s1_causes_s2.p_value, s1_causes_s2.adjusted_p_value,
1569        );
1570    }
1571
1572    // ── The F-distribution survival function, against ground truth ──
1573    //
1574    // `f_distribution_sf` decides every Granger p-value, and the copy in this
1575    // module was **wrong**: `F(2,1)` at 0.01 returned 0.996593 where the truth
1576    // is 0.990148. It went unnoticed because the only comparison ever made
1577    // between this module and its now-deleted sibling compared *F-statistics*,
1578    // which agreed — and both p-values were ~0 in the deep tail, so the
1579    // comparison was blind to the defect it was meant to find.
1580    //
1581    // These assert against **closed forms**, not against another
1582    // implementation. That is stronger than a table copied from scipy: for
1583    // these parameter pairs the survival function is an elementary
1584    // expression, so the assertion is ground truth and reproduces with no
1585    // third-party install. Comparing against the regularized incomplete beta
1586    // would be circular — that is what the code computes.
1587
1588    /// `F(2, d₂)`: `sf(x) = (1 + 2x/d₂)^(−d₂/2)`, from `I_z(a,1) = z^a`.
1589    #[test]
1590    fn f_distribution_sf_matches_the_closed_form_for_d1_eq_2() {
1591        for d2 in [1.0_f64, 2.0, 3.0, 5.0, 10.0, 30.0, 100.0, 500.0] {
1592            for x in [0.01_f64, 0.1, 0.5, 1.0, 2.0, 3.5, 5.0, 10.0, 50.0, 1000.0] {
1593                let expected = (1.0 + 2.0 * x / d2).powf(-d2 / 2.0);
1594                let got = f_distribution_sf(x, 2.0, d2);
1595                assert!(
1596                    (got - expected).abs() < 1e-9,
1597                    "F(2, {d2}) at {x}: got {got:.12}, closed form {expected:.12}"
1598                );
1599            }
1600        }
1601    }
1602
1603    /// `F(d₁, 2)`: `sf(x) = 1 − (d₁x/(2 + d₁x))^(d₁/2)`, from `I_z(1,b) = 1−(1−z)^b`.
1604    #[test]
1605    fn f_distribution_sf_matches_the_closed_form_for_d2_eq_2() {
1606        for d1 in [1.0_f64, 2.0, 3.0, 5.0, 10.0, 30.0, 100.0] {
1607            for x in [0.01_f64, 0.1, 0.5, 1.0, 2.0, 3.5, 5.0, 10.0, 50.0, 1000.0] {
1608                let expected = 1.0 - (d1 * x / (2.0 + d1 * x)).powf(d1 / 2.0);
1609                let got = f_distribution_sf(x, d1, 2.0);
1610                assert!(
1611                    (got - expected).abs() < 1e-9,
1612                    "F({d1}, 2) at {x}: got {got:.12}, closed form {expected:.12}"
1613                );
1614            }
1615        }
1616    }
1617
1618    /// `F(1,1)` is the square of a standard Cauchy: `sf(x) = 1 − (2/π)·atan(√x)`.
1619    #[test]
1620    fn f_distribution_sf_matches_the_closed_form_for_one_and_one() {
1621        for x in [0.001_f64, 0.01, 0.1, 0.5, 1.0, 2.0, 7.0, 100.0, 10_000.0] {
1622            let expected = 1.0 - 2.0 / std::f64::consts::PI * x.sqrt().atan();
1623            let got = f_distribution_sf(x, 1.0, 1.0);
1624            assert!(
1625                (got - expected).abs() < 1e-9,
1626                "F(1, 1) at {x}: got {got:.12}, closed form {expected:.12}"
1627            );
1628        }
1629    }
1630
1631    /// `sf_{F(d₁,d₂)}(x) = 1 − sf_{F(d₂,d₁)}(1/x)`.
1632    ///
1633    /// The reciprocal of an `F(d₁,d₂)` variate is `F(d₂,d₁)`. This holds for
1634    /// every parameter pair, including those with no elementary form, so it
1635    /// covers the region the closed forms cannot reach — and it exercises the
1636    /// argument-swapping branch of the continued fraction, which is where the
1637    /// sibling `gamma_cf` defect lived.
1638    #[test]
1639    fn f_distribution_sf_is_consistent_under_reciprocal() {
1640        for d1 in [1.0_f64, 3.0, 7.0, 12.0, 40.0] {
1641            for d2 in [1.0_f64, 3.0, 7.0, 12.0, 40.0] {
1642                for x in [0.05_f64, 0.3, 1.0, 2.5, 9.0, 60.0] {
1643                    let a = f_distribution_sf(x, d1, d2);
1644                    let b = 1.0 - f_distribution_sf(1.0 / x, d2, d1);
1645                    assert!(
1646                        (a - b).abs() < 1e-9,
1647                        "F({d1},{d2}) at {x}: {a:.12} vs reciprocal identity {b:.12}"
1648                    );
1649                }
1650            }
1651        }
1652    }
1653
1654    /// A survival function is in `[0, 1]` and non-increasing.
1655    #[test]
1656    fn f_distribution_sf_is_a_survival_function() {
1657        for d1 in [1.0_f64, 2.0, 5.0, 17.0] {
1658            for d2 in [1.0_f64, 2.0, 5.0, 17.0] {
1659                let mut previous = f64::INFINITY;
1660                for k in 0..200 {
1661                    let x = f64::from(k) * 0.25 + 0.01;
1662                    let v = f_distribution_sf(x, d1, d2);
1663                    assert!((0.0..=1.0).contains(&v), "F({d1},{d2}) at {x}: {v}");
1664                    assert!(v <= previous + 1e-12, "F({d1},{d2}) increased at {x}");
1665                    previous = v;
1666                }
1667            }
1668        }
1669    }
1670
1671    #[test]
1672    fn f_distribution_sf_matches_scipy() {
1673        // `scipy.stats.f.sf`, generated with the venv in this repository.
1674        // Ground-truth closed forms cover d1=2, d2=2 and (1,1); this table
1675        // covers the parameter pairs that have no elementary form, which is
1676        // where the continued fraction actually earns its place.
1677        const SCIPY: &[(f64, f64, f64, f64)] = &[
1678            (0.05_f64, 1.0, 1.0, 8.599_513_039_068_979e-1),
1679            (0.5_f64, 1.0, 1.0, 6.081_734_479_693_929e-1),
1680            (1.0_f64, 1.0, 1.0, 5.000_000_000_000_001e-1),
1681            (2.5_f64, 1.0, 1.0, 3.590_170_359_713_762e-1),
1682            (5.0_f64, 1.0, 1.0, 2.677_204_728_012_301e-1),
1683            (20.0_f64, 1.0, 1.0, 1.400_486_960_931_020_5e-1),
1684            (200.0_f64, 1.0, 1.0, 4.494_101_372_651_411e-2),
1685            (0.05_f64, 1.0, 3.0, 8.374_248_972_889_173e-1),
1686            (0.5_f64, 1.0, 3.0, 5.304_777_709_329_569e-1),
1687            (1.0_f64, 1.0, 3.0, 3.910_022_189_557_707e-1),
1688            (2.5_f64, 1.0, 3.0, 2.119_854_426_726_486_8e-1),
1689            (5.0_f64, 1.0, 3.0, 1.113_671_547_140_838_7e-1),
1690            (20.0_f64, 1.0, 3.0, 2.083_515_119_618_485e-2),
1691            (200.0_f64, 1.0, 3.0, 7.658_843_583_400_378e-4),
1692            (0.05_f64, 1.0, 5.0, 8.319_122_479_866_876e-1),
1693            (0.5_f64, 1.0, 5.0, 5.110_840_804_302_803e-1),
1694            (1.0_f64, 1.0, 5.0, 3.632_174_676_491_225_5e-1),
1695            (2.5_f64, 1.0, 5.0, 1.746_878_142_641_194_2e-1),
1696            (5.0_f64, 1.0, 5.0, 7.558_681_842_161_245e-2),
1697            (20.0_f64, 1.0, 5.0, 6.566_271_827_563_008e-3),
1698            (200.0_f64, 1.0, 5.0, 3.182_292_826_705_155e-5),
1699            (0.05_f64, 1.0, 7.0, 8.294_489_446_380_614e-1),
1700            (0.5_f64, 1.0, 7.0, 5.023_540_170_799_178e-1),
1701            (1.0_f64, 1.0, 7.0, 3.506_166_628_202_073_7e-1),
1702            (2.5_f64, 1.0, 7.0, 1.578_593_227_958_298_7e-1),
1703            (5.0_f64, 1.0, 7.0, 6.042_742_018_442_884e-2),
1704            (20.0_f64, 1.0, 7.0, 2.893_495_120_607_208e-3),
1705            (200.0_f64, 1.0, 7.0, 2.097_359_711_842_887e-6),
1706            (0.05_f64, 1.0, 10.0, 8.275_651_592_009_66e-1),
1707            (0.5_f64, 1.0, 10.0, 4.956_475_043_831_195_5e-1),
1708            (1.0_f64, 1.0, 10.0, 3.408_931_323_020_597_5e-1),
1709            (2.5_f64, 1.0, 10.0, 1.449_276_054_040_804_8e-1),
1710            (5.0_f64, 1.0, 10.0, 4.933_219_563_992_176_4e-2),
1711            (20.0_f64, 1.0, 10.0, 1.193_466_830_020_324_4e-3),
1712            (200.0_f64, 1.0, 10.0, 6.149_001_368_036_42e-8),
1713            (0.05_f64, 1.0, 30.0, 8.245_790_072_177_894e-1),
1714            (0.5_f64, 1.0, 30.0, 4.849_569_686_830_377e-1),
1715            (1.0_f64, 1.0, 30.0, 3.253_086_154_260_302_3e-1),
1716            (2.5_f64, 1.0, 30.0, 1.243_334_200_465_653_3e-1),
1717            (5.0_f64, 1.0, 30.0, 3.293_630_592_563_78e-2),
1718            (20.0_f64, 1.0, 30.0, 1.029_210_178_637_816_9e-4),
1719            (200.0_f64, 1.0, 30.0, 8.298_453_462_587_944e-15),
1720            (0.05_f64, 1.0, 100.0, 8.235_194_441_364_071e-1),
1721            (0.5_f64, 1.0, 100.0, 4.811_446_769_857_403_7e-1),
1722            (1.0_f64, 1.0, 100.0, 3.197_241_557_841_232e-1),
1723            (2.5_f64, 1.0, 100.0, 1.170_041_896_091_983_8e-1),
1724            (5.0_f64, 1.0, 100.0, 2.756_960_066_604_534_4e-2),
1725            (20.0_f64, 1.0, 100.0, 2.049_634_555_794_899_3e-5),
1726            (200.0_f64, 1.0, 100.0, 1.351_242_379_602_169_4e-25),
1727            (0.05_f64, 1.0, 500.0, 8.231_546_038_971_818e-1),
1728            (0.5_f64, 1.0, 500.0, 4.798_295_396_595_345_7e-1),
1729            (1.0_f64, 1.0, 500.0, 3.177_942_072_606_256e-1),
1730            (2.5_f64, 1.0, 500.0, 1.144_786_392_733_747_5e-1),
1731            (5.0_f64, 1.0, 500.0, 2.578_771_199_599_521_8e-2),
1732            (20.0_f64, 1.0, 500.0, 9.592_901_087_300_635e-6),
1733            (200.0_f64, 1.0, 500.0, 1.950_412_009_179_663e-38),
1734            (0.05_f64, 3.0, 1.0, 9.791_648_488_038_152e-1),
1735            (0.5_f64, 3.0, 1.0, 7.477_845_036_444_957e-1),
1736            (1.0_f64, 3.0, 1.0, 6.089_977_810_442_295e-1),
1737            (2.5_f64, 3.0, 1.0, 4.279_966_192_993_59e-1),
1738            (5.0_f64, 3.0, 1.0, 3.149_623_575_257_075e-1),
1739            (20.0_f64, 3.0, 1.0, 1.625_751_027_110_831_4e-1),
1740            (200.0_f64, 3.0, 1.0, 5.192_211_792_598_956e-2),
1741            (0.05_f64, 3.0, 3.0, 9.826_133_295_298_128e-1),
1742            (0.5_f64, 3.0, 3.0, 7.082_085_942_090_715e-1),
1743            (1.0_f64, 3.0, 3.0, 5.000_000_000_000_001e-1),
1744            (2.5_f64, 3.0, 3.0, 2.357_618_226_542_65e-1),
1745            (5.0_f64, 3.0, 3.0, 1.095_510_187_085_240_3e-1),
1746            (20.0_f64, 3.0, 3.0, 1.738_667_047_018_715_6e-2),
1747            (200.0_f64, 3.0, 3.0, 5.948_473_159_638_333e-4),
1748            (0.05_f64, 3.0, 5.0, 9.835_627_319_613_537e-1),
1749            (0.5_f64, 3.0, 5.0, 6.984_526_373_049_242e-1),
1750            (1.0_f64, 3.0, 5.0, 4.648_547_899_936_353_3e-1),
1751            (2.5_f64, 3.0, 5.0, 1.739_276_579_365_1e-1),
1752            (5.0_f64, 3.0, 5.0, 5.766_888_562_243_733e-2),
1753            (20.0_f64, 3.0, 5.0, 3.250_005_065_191_810_5e-3),
1754            (200.0_f64, 3.0, 5.0, 1.261_190_950_980_022_2e-5),
1755            (0.05_f64, 3.0, 7.0, 9.840_063_156_479_888e-1),
1756            (0.5_f64, 3.0, 7.0, 6.940_363_875_688_136e-1),
1757            (1.0_f64, 3.0, 7.0, 4.470_796_134_684_835e-1),
1758            (2.5_f64, 3.0, 7.0, 1.435_094_562_789_392_7e-1),
1759            (5.0_f64, 3.0, 7.0, 3.667_335_421_818_645e-2),
1760            (20.0_f64, 3.0, 7.0, 8.225_792_466_075_144e-4),
1761            (200.0_f64, 3.0, 7.0, 3.817_227_678_744_054_4e-7),
1762            (0.05_f64, 3.0, 10.0, 9.843_549_357_040_79e-1),
1763            (0.5_f64, 3.0, 10.0, 6.906_222_455_335_574e-1),
1764            (1.0_f64, 3.0, 10.0, 4.323_372_030_216_968e-1),
1765            (2.5_f64, 3.0, 10.0, 1.190_395_626_582_781_6e-1),
1766            (5.0_f64, 3.0, 10.0, 2.261_392_275_109_628_4e-2),
1767            (20.0_f64, 3.0, 10.0, 1.511_650_265_860_084_4e-4),
1768            (200.0_f64, 3.0, 10.0, 3.183_148_174_576_168_7e-9),
1769            (0.05_f64, 3.0, 30.0, 9.849_263_153_922_542e-1),
1770            (0.5_f64, 3.0, 30.0, 6.851_195_412_952_075e-1),
1771            (1.0_f64, 3.0, 30.0, 4.063_572_668_729_493e-1),
1772            (2.5_f64, 3.0, 30.0, 7.847_395_791_463_87e-2),
1773            (5.0_f64, 3.0, 30.0, 6.254_870_601_873_435e-3),
1774            (20.0_f64, 3.0, 30.0, 2.587_600_271_284_227e-7),
1775            (200.0_f64, 3.0, 30.0, 6.425_629_332_909_392e-20),
1776            (0.05_f64, 3.0, 100.0, 9.851_351_172_793_991e-1),
1777            (0.5_f64, 3.0, 100.0, 6.831_325_288_938_818e-1),
1778            (1.0_f64, 3.0, 100.0, 3.961_862_496_180_044e-1),
1779            (2.5_f64, 3.0, 100.0, 6.383_295_997_908_654e-2),
1780            (5.0_f64, 3.0, 100.0, 2.844_573_100_886_653e-3),
1781            (20.0_f64, 3.0, 100.0, 3.112_606_226_774_384_6e-10),
1782            (200.0_f64, 3.0, 100.0, 4.144_844_518_612_426e-42),
1783            (0.05_f64, 3.0, 500.0, 9.852_077_993_840_09e-1),
1784            (0.5_f64, 3.0, 500.0, 6.824_432_968_133_542e-1),
1785            (1.0_f64, 3.0, 500.0, 3.925_476_468_585_115_7e-1),
1786            (2.5_f64, 3.0, 500.0, 5.881_163_820_983_985e-2),
1787            (5.0_f64, 3.0, 500.0, 2.001_302_671_949_856e-3),
1788            (20.0_f64, 3.0, 500.0, 2.948_423_715_311_517_2e-12),
1789            (200.0_f64, 3.0, 500.0, 3.277_206_998_069_135e-85),
1790            (0.05_f64, 5.0, 1.0, 9.934_337_281_724_37e-1),
1791            (0.5_f64, 5.0, 1.0, 7.835_627_707_303_144e-1),
1792            (1.0_f64, 5.0, 1.0, 6.367_825_323_508_775e-1),
1793            (2.5_f64, 5.0, 1.0, 4.451_217_169_436_3e-1),
1794            (5.0_f64, 5.0, 1.0, 3.265_715_644_624_462_3e-1),
1795            (20.0_f64, 5.0, 1.0, 1.680_877_520_133_123e-1),
1796            (200.0_f64, 5.0, 1.0, 5.363_087_276_333_012e-2),
1797            (0.05_f64, 5.0, 3.0, 9.967_499_949_348_082e-1),
1798            (0.5_f64, 5.0, 3.0, 7.673_760_819_999_214e-1),
1799            (1.0_f64, 5.0, 3.0, 5.351_452_100_063_651e-1),
1800            (2.5_f64, 5.0, 3.0, 2.405_360_338_406_623_6e-1),
1801            (5.0_f64, 5.0, 3.0, 1.078_163_782_679_175_8e-1),
1802            (20.0_f64, 5.0, 3.0, 1.643_726_803_864_62e-2),
1803            (200.0_f64, 5.0, 3.0, 5.539_101_145_966_495e-4),
1804            (0.05_f64, 5.0, 5.0, 9.974_477_392_801_275e-1),
1805            (0.5_f64, 5.0, 5.0, 7.674_886_808_696_213e-1),
1806            (1.0_f64, 5.0, 5.0, 5.000_000_000_000_001e-1),
1807            (2.5_f64, 5.0, 5.0, 1.686_841_555_429_12e-1),
1808            (5.0_f64, 5.0, 5.0, 5.096_973_941_492_917_4e-2),
1809            (20.0_f64, 5.0, 5.0, 2.552_260_719_872_432_3e-3),
1810            (200.0_f64, 5.0, 5.0, 9.433_866_982_533_933e-6),
1811            (0.05_f64, 5.0, 7.0, 9.977_457_456_693_496e-1),
1812            (0.5_f64, 5.0, 7.0, 7.685_843_297_279_157e-1),
1813            (1.0_f64, 5.0, 7.0, 4.812_939_129_582_055e-1),
1814            (2.5_f64, 5.0, 7.0, 1.320_062_236_078_406_7e-1),
1815            (5.0_f64, 5.0, 7.0, 2.874_617_655_405_799e-2),
1816            (20.0_f64, 5.0, 7.0, 5.139_310_327_531_792e-4),
1817            (200.0_f64, 5.0, 7.0, 2.155_804_614_637_414e-7),
1818            (0.05_f64, 5.0, 10.0, 9.979_679_819_314_153e-1),
1819            (0.5_f64, 5.0, 10.0, 7.700_248_806_501_017e-1),
1820            (1.0_f64, 5.0, 10.0, 4.651_194_265_378_001_4e-1),
1821            (2.5_f64, 5.0, 10.0, 1.020_022_766_442_697_7e-1),
1822            (5.0_f64, 5.0, 10.0, 1.486_880_040_811_298_2e-2),
1823            (20.0_f64, 5.0, 10.0, 6.472_349_610_750_509e-5),
1824            (200.0_f64, 5.0, 10.0, 1.102_329_910_570_809e-9),
1825            (0.05_f64, 5.0, 30.0, 9.983_102_236_198_341e-1),
1826            (0.5_f64, 5.0, 30.0, 7.737_335_937_035_952e-1),
1827            (1.0_f64, 5.0, 30.0, 4.346_488_763_398_733e-1),
1828            (2.5_f64, 5.0, 30.0, 5.244_410_472_445_027_6e-2),
1829            (5.0_f64, 5.0, 30.0, 1.896_967_469_088_041_7e-3),
1830            (20.0_f64, 5.0, 30.0, 9.581_174_502_890_943e-9),
1831            (200.0_f64, 5.0, 30.0, 4.352_520_121_551_445e-22),
1832            (0.05_f64, 5.0, 100.0, 9.984_286_736_809_271e-1),
1833            (0.5_f64, 5.0, 100.0, 7.755_895_192_354_815e-1),
1834            (1.0_f64, 5.0, 100.0, 4.218_298_943_719_714_4e-1),
1835            (2.5_f64, 5.0, 100.0, 3.544_824_945_021_782e-2),
1836            (5.0_f64, 5.0, 100.0, 3.953_080_102_324_142_3e-4),
1837            (20.0_f64, 5.0, 100.0, 8.923_890_603_183_405e-14),
1838            (200.0_f64, 5.0, 100.0, 2.043_831_493_329_358_6e-50),
1839            (0.05_f64, 5.0, 500.0, 9.984_690_929_254_346e-1),
1840            (0.5_f64, 5.0, 500.0, 7.763_082_744_903_42e-1),
1841            (1.0_f64, 5.0, 500.0, 4.170_943_401_351_524e-1),
1842            (2.5_f64, 5.0, 500.0, 2.989_775_590_717_396_3e-2),
1843            (5.0_f64, 5.0, 500.0, 1.764_704_885_541_849_8e-4),
1844            (20.0_f64, 5.0, 500.0, 3.364_299_050_353_227e-18),
1845            (200.0_f64, 5.0, 500.0, 8.577_660_241_290_851e-117),
1846            (0.05_f64, 7.0, 1.0, 9.971_065_048_793_928e-1),
1847            (0.5_f64, 7.0, 1.0, 7.997_999_258_337_599e-1),
1848            (1.0_f64, 7.0, 1.0, 6.493_833_371_797_926e-1),
1849            (2.5_f64, 7.0, 1.0, 4.528_161_585_563_629e-1),
1850            (5.0_f64, 7.0, 1.0, 3.317_689_599_284_96e-1),
1851            (20.0_f64, 7.0, 1.0, 1.705_510_553_619_384_6e-1),
1852            (200.0_f64, 7.0, 1.0, 5.439_421_527_116_384e-2),
1853            (0.05_f64, 7.0, 3.0, 9.991_774_207_533_924e-1),
1854            (0.5_f64, 7.0, 3.0, 7.973_063_575_133_491e-1),
1855            (1.0_f64, 7.0, 3.0, 5.529_203_865_315_162e-1),
1856            (2.5_f64, 7.0, 3.0, 2.424_975_282_447_001e-1),
1857            (5.0_f64, 7.0, 3.0, 1.068_320_443_375_101_9e-1),
1858            (20.0_f64, 7.0, 3.0, 1.599_368_435_201_107_8e-2),
1859            (200.0_f64, 7.0, 3.0, 5.354_274_210_490_03e-4),
1860            (0.05_f64, 7.0, 5.0, 9.994_860_689_672_468e-1),
1861            (0.5_f64, 7.0, 5.0, 8.043_267_551_331_769e-1),
1862            (1.0_f64, 7.0, 5.0, 5.187_060_870_417_941e-1),
1863            (2.5_f64, 7.0, 5.0, 1.651_665_724_962_968_2e-1),
1864            (5.0_f64, 7.0, 5.0, 4.758_354_977_857_251_5e-2),
1865            (20.0_f64, 7.0, 5.0, 2.254_254_330_650_468e-3),
1866            (200.0_f64, 7.0, 5.0, 8.156_429_888_424_49e-6),
1867            (0.05_f64, 7.0, 7.0, 9.996_005_787_903_773e-1),
1868            (0.5_f64, 7.0, 7.0, 8.096_434_091_615_682e-1),
1869            (1.0_f64, 7.0, 7.0, 4.999_999_999_999_998_3e-1),
1870            (2.5_f64, 7.0, 7.0, 1.248_783_321_232_528_3e-1),
1871            (5.0_f64, 7.0, 7.0, 2.493_361_528_444_257_3e-2),
1872            (20.0_f64, 7.0, 7.0, 3.994_212_096_226_762_7e-4),
1873            (200.0_f64, 7.0, 7.0, 1.602_201_087_428_974_8e-7),
1874            (0.05_f64, 7.0, 10.0, 9.996_785_729_851_23e-1),
1875            (0.5_f64, 7.0, 10.0, 8.150_431_465_936_292e-1),
1876            (1.0_f64, 7.0, 10.0, 4.834_025_108_823_465e-1),
1877            (2.5_f64, 7.0, 10.0, 9.167_374_844_440_98e-2),
1878            (5.0_f64, 7.0, 10.0, 1.146_120_704_541_107_4e-2),
1879            (20.0_f64, 7.0, 10.0, 4.017_926_806_906_255e-5),
1880            (200.0_f64, 7.0, 10.0, 6.221_655_538_495_649e-10),
1881            (0.05_f64, 7.0, 30.0, 9.997_853_615_351_379e-1),
1882            (0.5_f64, 7.0, 30.0, 8.269_609_583_211_951e-1),
1883            (1.0_f64, 7.0, 30.0, 4.505_706_879_613_486e-1),
1884            (2.5_f64, 7.0, 30.0, 3.777_766_128_993_187_4e-2),
1885            (5.0_f64, 7.0, 30.0, 7.711_805_851_010_107e-4),
1886            (20.0_f64, 7.0, 30.0, 1.100_187_450_229_647_2e-9),
1887            (200.0_f64, 7.0, 30.0, 2.202_044_443_436_217_8e-23),
1888            (0.05_f64, 7.0, 100.0, 9.998_183_096_039_113e-1),
1889            (0.5_f64, 7.0, 100.0, 8.325_416_801_853_718e-1),
1890            (1.0_f64, 7.0, 100.0, 4.359_080_275_908_213_7e-1),
1891            (2.5_f64, 7.0, 100.0, 2.075_541_485_050_993e-2),
1892            (5.0_f64, 7.0, 100.0, 6.953_028_285_653_315e-5),
1893            (20.0_f64, 7.0, 100.0, 1.522_620_759_402_403_3e-16),
1894            (200.0_f64, 7.0, 100.0, 7.676_461_532_132_527e-56),
1895            (0.05_f64, 7.0, 500.0, 9.998_290_563_503_065e-1),
1896            (0.5_f64, 7.0, 500.0, 8.346_733_652_780_742e-1),
1897            (1.0_f64, 7.0, 500.0, 4.303_267_111_651_259e-1),
1898            (2.5_f64, 7.0, 500.0, 1.564_022_931_445_676_8e-2),
1899            (5.0_f64, 7.0, 500.0, 1.724_493_926_108_725_2e-5),
1900            (20.0_f64, 7.0, 500.0, 1.105_913_154_993_826_2e-23),
1901            (200.0_f64, 7.0, 500.0, 1.602_972_532_619_707_2e-140),
1902            (0.05_f64, 10.0, 1.0, 9.988_065_331_699_797e-1),
1903            (0.5_f64, 10.0, 1.0, 8.123_301_291_303_971e-1),
1904            (1.0_f64, 10.0, 1.0, 6.591_068_676_979_402e-1),
1905            (2.5_f64, 10.0, 1.0, 4.587_204_829_251_881e-1),
1906            (5.0_f64, 10.0, 1.0, 3.357_485_282_688_647_5e-1),
1907            (20.0_f64, 10.0, 1.0, 1.724_348_407_990_325e-1),
1908            (200.0_f64, 10.0, 1.0, 5.497_784_197_229_169e-2),
1909            (0.05_f64, 10.0, 3.0, 9.998_488_349_734_14e-1),
1910            (0.5_f64, 10.0, 3.0, 8.219_925_926_248_246e-1),
1911            (1.0_f64, 10.0, 3.0, 5.676_627_969_783_027e-1),
1912            (2.5_f64, 10.0, 3.0, 2.439_174_274_603_792_3e-1),
1913            (5.0_f64, 10.0, 3.0, 1.059_818_885_792_791_2e-1),
1914            (20.0_f64, 10.0, 3.0, 1.564_506_429_592_112_5e-2),
1915            (200.0_f64, 10.0, 3.0, 5.211_599_742_799_054e-4),
1916            (0.05_f64, 10.0, 5.0, 9.999_352_765_038_925e-1),
1917            (0.5_f64, 10.0, 5.0, 8.358_050_491_002_612e-1),
1918            (1.0_f64, 10.0, 5.0, 5.348_805_734_621_997e-1),
1919            (2.5_f64, 10.0, 5.0, 1.618_347_415_219_576_2e-1),
1920            (5.0_f64, 10.0, 5.0, 4.480_822_975_357_040_5e-2),
1921            (20.0_f64, 10.0, 5.0, 2.032_018_068_584_613e-3),
1922            (200.0_f64, 10.0, 5.0, 7.234_158_868_087_465e-6),
1923            (0.05_f64, 10.0, 7.0, 9.999_598_207_319_309e-1),
1924            (0.5_f64, 10.0, 7.0, 8.454_966_997_285_922e-1),
1925            (1.0_f64, 10.0, 7.0, 5.165_974_891_176_535e-1),
1926            (2.5_f64, 10.0, 7.0, 1.183_060_618_644_493_1e-1),
1927            (5.0_f64, 10.0, 7.0, 2.191_557_852_489_411_6e-2),
1928            (20.0_f64, 10.0, 7.0, 3.214_270_148_770_766e-4),
1929            (200.0_f64, 10.0, 7.0, 1.246_086_026_245_263_5e-7),
1930            (0.05_f64, 10.0, 10.0, 9.999_737_541_172_163e-1),
1931            (0.5_f64, 10.0, 10.0, 8.551_541_939_744_957e-1),
1932            (1.0_f64, 10.0, 10.0, 5.000_000_000_000_001e-1),
1933            (2.5_f64, 10.0, 10.0, 8.225_366_322_272_007e-2),
1934            (5.0_f64, 10.0, 10.0, 8.950_061_601_381_901e-3),
1935            (20.0_f64, 10.0, 10.0, 2.624_588_278_370_414_6e-5),
1936            (200.0_f64, 10.0, 10.0, 3.777_237_977_079_334e-10),
1937            (0.05_f64, 10.0, 30.0, 9.999_886_383_739_246e-1),
1938            (0.5_f64, 10.0, 30.0, 8.763_612_630_739_956e-1),
1939            (1.0_f64, 10.0, 30.0, 4.654_242_904_944_112e-1),
1940            (2.5_f64, 10.0, 30.0, 2.556_200_682_455_132e-2),
1941            (5.0_f64, 10.0, 30.0, 2.814_713_926_752_866e-4),
1942            (20.0_f64, 10.0, 30.0, 1.238_523_030_227_409_6e-10),
1943            (200.0_f64, 10.0, 30.0, 1.283_889_691_329_897_6e-24),
1944            (0.05_f64, 10.0, 100.0, 9.999_921_314_059_984e-1),
1945            (0.5_f64, 10.0, 100.0, 8.863_503_911_530_737e-1),
1946            (1.0_f64, 10.0, 100.0, 4.488_172_795_604_992e-1),
1947            (2.5_f64, 10.0, 100.0, 1.009_520_838_004_780_5e-2),
1948            (5.0_f64, 10.0, 100.0, 7.203_294_285_246_187e-6),
1949            (20.0_f64, 10.0, 100.0, 9.052_974_736_110_621e-20),
1950            (200.0_f64, 10.0, 100.0, 2.023_087_478_925_676_3e-61),
1951            (0.05_f64, 10.0, 500.0, 9.999_931_479_117_107e-1),
1952            (0.5_f64, 10.0, 500.0, 8.901_834_974_380_145e-1),
1953            (1.0_f64, 10.0, 500.0, 4.422_289_394_076_182_6e-1),
1954            (2.5_f64, 10.0, 500.0, 6.181_310_892_451_748e-3),
1955            (5.0_f64, 10.0, 500.0, 6.122_346_115_150_638e-7),
1956            (20.0_f64, 10.0, 500.0, 3.451_499_099_408_449_6e-31),
1957            (200.0_f64, 10.0, 500.0, 1.260_108_905_566_712_7e-167),
1958            (0.05_f64, 20.0, 1.0, 9.997_665_525_652_514e-1),
1959            (0.5_f64, 20.0, 1.0, 8.273_216_960_128_282e-1),
1960            (1.0_f64, 20.0, 1.0, 6.707_434_228_282_909e-1),
1961            (2.5_f64, 20.0, 1.0, 4.657_462_675_288_914e-1),
1962            (5.0_f64, 20.0, 1.0, 3.404_734_867_967_272_3e-1),
1963            (20.0_f64, 20.0, 1.0, 1.746_685_268_748_851_8e-1),
1964            (200.0_f64, 20.0, 1.0, 5.566_968_855_192_703e-2),
1965            (0.05_f64, 20.0, 3.0, 9.999_968_983_502_592e-1),
1966            (0.5_f64, 20.0, 3.0, 8.535_611_969_133_787e-1),
1967            (1.0_f64, 20.0, 3.0, 5.867_480_859_375_398e-1),
1968            (2.5_f64, 20.0, 3.0, 2.455_033_268_956_120_8e-1),
1969            (5.0_f64, 20.0, 3.0, 1.048_551_121_300_250_3e-1),
1970            (20.0_f64, 20.0, 3.0, 1.521_997_452_120_014_3e-2),
1971            (200.0_f64, 20.0, 3.0, 5.040_490_534_002_49e-4),
1972            (0.05_f64, 20.0, 5.0, 9.999_996_332_912_71e-1),
1973            (0.5_f64, 20.0, 5.0, 8.774_927_553_181_574e-1),
1974            (1.0_f64, 20.0, 5.0, 5.569_748_153_151_206e-1),
1975            (2.5_f64, 20.0, 5.0, 1.569_969_392_108_339e-1),
1976            (5.0_f64, 20.0, 5.0, 4.128_835_849_042_164e-2),
1977            (20.0_f64, 20.0, 5.0, 1.774_850_437_874_341_7e-3),
1978            (200.0_f64, 20.0, 5.0, 6.198_937_506_367_039e-6),
1979            (0.05_f64, 20.0, 7.0, 9.999_999_022_681_04e-1),
1980            (0.5_f64, 20.0, 7.0, 8.939_292_004_628_931e-1),
1981            (1.0_f64, 20.0, 7.0, 5.401_531_821_063_319e-1),
1982            (2.5_f64, 20.0, 7.0, 1.089_237_236_633_195_4e-1),
1983            (5.0_f64, 20.0, 7.0, 1.823_902_806_509_197_8e-2),
1984            (20.0_f64, 20.0, 7.0, 2.396_075_955_539_662_8e-4),
1985            (200.0_f64, 20.0, 7.0, 8.920_495_843_834_883e-8),
1986            (0.05_f64, 20.0, 10.0, 9.999_999_726_576_813e-1),
1987            (0.5_f64, 20.0, 10.0, 9.102_172_851_562_5e-1),
1988            (1.0_f64, 20.0, 10.0, 5.244_995_315_671_086e-1),
1989            (2.5_f64, 20.0, 10.0, 6.897_514_678_237_858e-2),
1990            (5.0_f64, 20.0, 10.0, 6.161_513_123_423_422e-3),
1991            (20.0_f64, 20.0, 10.0, 1.437_064_754_200_605_2e-5),
1992            (200.0_f64, 20.0, 10.0, 1.895_016_442_662_043_3e-10),
1993            (0.05_f64, 20.0, 30.0, 9.999_999_984_214_671e-1),
1994            (0.5_f64, 20.0, 30.0, 9.453_350_776_055_7e-1),
1995            (1.0_f64, 20.0, 30.0, 4.890_801_931_489_531e-1),
1996            (2.5_f64, 20.0, 30.0, 1.132_846_370_347_176_2e-2),
1997            (5.0_f64, 20.0, 30.0, 4.132_575_555_705_573e-5),
1998            (20.0_f64, 20.0, 30.0, 3.214_720_610_769_998e-12),
1999            (200.0_f64, 20.0, 30.0, 1.466_612_714_077_889e-26),
2000            (0.05_f64, 20.0, 100.0, 9.999_999_996_344_979e-1),
2001            (0.5_f64, 20.0, 100.0, 9.610_073_391_512_228e-1),
2002            (1.0_f64, 20.0, 100.0, 4.692_089_598_227_433e-1),
2003            (2.5_f64, 20.0, 100.0, 1.499_776_663_571_476_4e-3),
2004            (5.0_f64, 20.0, 100.0, 2.632_416_051_112_641_8e-8),
2005            (20.0_f64, 20.0, 100.0, 1.985_975_732_417_538e-26),
2006            (200.0_f64, 20.0, 100.0, 2.319_515_191_433_788e-71),
2007            (0.05_f64, 20.0, 500.0, 9.999_999_997_993_518e-1),
2008            (0.5_f64, 20.0, 500.0, 9.667_241_763_563_634e-1),
2009            (1.0_f64, 20.0, 500.0, 4.603_768_778_460_684_6e-1),
2010            (2.5_f64, 20.0, 500.0, 3.614_471_679_365_32e-4),
2011            (5.0_f64, 20.0, 500.0, 2.419_896_115_024_529_5e-11),
2012            (20.0_f64, 20.0, 500.0, 1.351_873_364_707_244e-51),
2013            (200.0_f64, 20.0, 500.0, 1.201_843_864_451_444_3e-223),
2014            (0.05_f64, 50.0, 1.0, 9.999_553_080_801_258e-1),
2015            (0.5_f64, 50.0, 1.0, 8.365_038_281_044_941e-1),
2016            (1.0_f64, 50.0, 1.0, 6.778_743_548_997_557e-1),
2017            (2.5_f64, 50.0, 1.0, 4.700_290_002_323_09e-1),
2018            (5.0_f64, 50.0, 1.0, 3.433_477_708_108_625_4e-1),
2019            (20.0_f64, 50.0, 1.0, 1.760_256_107_265_665_6e-1),
2020            (200.0_f64, 50.0, 1.0, 5.608_990_729_137_773e-2),
2021            (0.05_f64, 50.0, 3.0, 9.999_999_881_840_26e-1),
2022            (0.5_f64, 50.0, 3.0, 8.740_579_417_973_806e-1),
2023            (1.0_f64, 50.0, 3.0, 5.993_767_746_957_155e-1),
2024            (2.5_f64, 50.0, 3.0, 2.464_112_141_782_734e-1),
2025            (5.0_f64, 50.0, 3.0, 1.041_029_960_001_185_8e-1),
2026            (20.0_f64, 50.0, 3.0, 1.495_494_664_972_292_7e-2),
2027            (200.0_f64, 50.0, 3.0, 4.935_306_370_114_788e-4),
2028            (0.05_f64, 50.0, 5.0, 9.999_999_999_331_19e-1),
2029            (0.5_f64, 50.0, 5.0, 9.052_470_979_699_002e-1),
2030            (1.0_f64, 50.0, 5.0, 5.725_055_335_833_61e-1),
2031            (2.5_f64, 50.0, 5.0, 1.534_912_727_528_461_3e-1),
2032            (5.0_f64, 50.0, 5.0, 3.901_818_940_991_427e-2),
2033            (20.0_f64, 50.0, 5.0, 1.621_991_784_701_261_1e-3),
2034            (200.0_f64, 50.0, 5.0, 5.599_778_288_513_24e-6),
2035            (0.05_f64, 50.0, 7.0, 9.999_999_999_982_813e-1),
2036            (0.5_f64, 50.0, 7.0, 9.263_825_817_270_023e-1),
2037            (1.0_f64, 50.0, 7.0, 5.575_369_842_173_838e-1),
2038            (2.5_f64, 50.0, 7.0, 1.021_853_712_134_265_7e-1),
2039            (5.0_f64, 50.0, 7.0, 1.596_566_743_101_228_8e-2),
2040            (20.0_f64, 50.0, 7.0, 1.955_206_109_766_565e-4),
2041            (200.0_f64, 50.0, 7.0, 7.102_024_313_914_767e-8),
2042            (0.05_f64, 50.0, 10.0, 9.999_999_999_999_66e-1),
2043            (0.5_f64, 50.0, 10.0, 9.468_123_117_221_907e-1),
2044            (1.0_f64, 50.0, 10.0, 5.436_430_945_196_307e-1),
2045            (2.5_f64, 50.0, 10.0, 5.958_672_931_842_764e-2),
2046            (5.0_f64, 50.0, 10.0, 4.615_101_165_139_315e-3),
2047            (20.0_f64, 50.0, 10.0, 9.266_790_041_201_036e-6),
2048            (200.0_f64, 50.0, 10.0, 1.158_252_031_677_620_7e-10),
2049            (0.05_f64, 50.0, 30.0, 9.999_999_999_999_999e-1),
2050            (0.5_f64, 50.0, 30.0, 9.852_667_345_044_902e-1),
2051            (1.0_f64, 50.0, 30.0, 5.108_974_983_279_239e-1),
2052            (2.5_f64, 50.0, 30.0, 4.393_558_041_015_697_4e-3),
2053            (5.0_f64, 50.0, 30.0, 5.660_692_594_295_936e-6),
2054            (20.0_f64, 50.0, 30.0, 1.192_477_741_574_441_5e-13),
2055            (200.0_f64, 50.0, 30.0, 3.224_186_055_438_517_6e-28),
2056            (0.05_f64, 50.0, 100.0, 9.999_999_999_999_999e-1),
2057            (0.5_f64, 50.0, 100.0, 9.961_734_860_330_544e-1),
2058            (1.0_f64, 50.0, 100.0, 4.891_205_078_079_293e-1),
2059            (2.5_f64, 50.0, 100.0, 5.068_369_209_338_273_5e-5),
2060            (5.0_f64, 50.0, 100.0, 4.192_942_714_416_357e-12),
2061            (20.0_f64, 50.0, 100.0, 1.590_676_925_542_226_2e-34),
2062            (200.0_f64, 50.0, 100.0, 8.434_051_290_315_383e-82),
2063            (0.05_f64, 50.0, 500.0, 9.999_999_999_999_999e-1),
2064            (0.5_f64, 50.0, 500.0, 9.984_090_605_193_361e-1),
2065            (1.0_f64, 50.0, 500.0, 4.771_617_689_801_394e-1),
2066            (2.5_f64, 50.0, 500.0, 2.900_216_183_887_641_3e-7),
2067            (5.0_f64, 50.0, 500.0, 7.588_419_680_838_374e-22),
2068            (20.0_f64, 50.0, 500.0, 5.990_923_934_343_106e-90),
2069            (200.0_f64, 50.0, 500.0, 1.590_323_134_507_893_6e-297),
2070        ];
2071        for &(f, d1, d2, expected) in SCIPY {
2072            let got = f_distribution_sf(f, d1, d2);
2073            let tol = 1e-11_f64.max(expected.abs() * 1e-9);
2074            assert!(
2075                (got - expected).abs() < tol,
2076                "F({d1},{d2}) sf({f}): got {got:.17e}, scipy {expected:.17e}"
2077            );
2078        }
2079    }
2080}