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    /// Predicted values: one row per target series.
14    pub predictions: Vec<Vec<f64>>,
15    /// Per-predictor importance / coefficients.
16    pub predictor_importance: Vec<(String, f64)>,
17}
18
19/// Multivariate forecast model contract.
20pub trait MultivariateForecastModel: Send + Sync {
21    /// Fit the model.
22    ///
23    /// - `ctx` — aligned multi-series context.
24    /// - `target_idx` — index of the target series to forecast.
25    fn fit(&mut self, ctx: &MultiSeriesContext, target_idx: usize)
26        -> Result<(), MultivariateError>;
27
28    /// Predict `horizon` steps ahead.
29    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError>;
30
31    /// Whether the model supports incremental online learning.
32    fn supports_online_learning(&self) -> bool;
33}
34
35// ─── Multi-Linear Regression ────────────────────────────────────────
36
37/// OLS regression: multiple predictors → single target.
38pub struct MultiLinearRegression {
39    coefficients: Vec<f64>,
40    intercept: f64,
41    r_squared: f64,
42    target_idx: usize,
43    predictor_names: Vec<String>,
44    /// Last observed predictor values — used for persistence forecasting.
45    last_predictors: Vec<f64>,
46    last_ts: i64,
47    interval_ns: i64,
48    fitted: bool,
49}
50
51impl MultiLinearRegression {
52    /// Creates a new unfitted multi-linear regression model.
53    pub fn new() -> Self {
54        Self {
55            coefficients: Vec::new(),
56            intercept: 0.0,
57            r_squared: 0.0,
58            target_idx: 0,
59            predictor_names: Vec::new(),
60            last_predictors: Vec::new(),
61            last_ts: 0,
62            interval_ns: 1_000_000_000,
63            fitted: false,
64        }
65    }
66}
67
68impl Default for MultiLinearRegression {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl MultivariateForecastModel for MultiLinearRegression {
75    fn fit(
76        &mut self,
77        ctx: &MultiSeriesContext,
78        target_idx: usize,
79    ) -> Result<(), MultivariateError> {
80        let k = ctx.matrix.n_series();
81        let n = ctx.matrix.n_timestamps();
82        if target_idx >= k {
83            return Err(MultivariateError::InvalidParameter(format!(
84                "target_idx {target_idx} >= n_series {k}"
85            )));
86        }
87        let n_pred = k - 1;
88        if n < n_pred + 2 {
89            return Err(MultivariateError::InsufficientData {
90                min: n_pred + 2,
91                got: n,
92            });
93        }
94
95        self.target_idx = target_idx;
96        self.predictor_names = ctx
97            .matrix
98            .series_ids
99            .iter()
100            .enumerate()
101            .filter(|(i, _)| *i != target_idx)
102            .map(|(_, name)| name.clone())
103            .collect();
104
105        let y = &ctx.matrix.data[target_idx];
106        // Build X^T X and X^T y using normal equations
107        // Design matrix: [1, x1, x2, ..., x_{k-1}] — augmented with intercept column
108        let p = n_pred + 1; // number of columns including intercept
109        let engine = CpuEngine::default();
110
111        // XtX: p × p
112        let mut xtx = vec![0.0; p * p];
113        // Xty: p × 1
114        let mut xty = vec![0.0; p];
115
116        #[allow(clippy::needless_range_loop)]
117        for t in 0..n {
118            let mut row = Vec::with_capacity(p);
119            row.push(1.0); // intercept
120            for s in 0..k {
121                if s != target_idx {
122                    row.push(ctx.matrix.data[s][t]);
123                }
124            }
125            for i in 0..p {
126                for j in 0..p {
127                    xtx[i * p + j] += row[i] * row[j];
128                }
129                xty[i] += row[i] * y[t];
130            }
131        }
132
133        // Ridge regularisation to handle collinear predictors
134        for i in 0..p {
135            xtx[i * p + i] += 1e-8;
136        }
137
138        // Solve (X^T X + λI) beta = X^T y
139        let beta = engine
140            .batch_matrix_solve(&xtx, &xty, p)
141            .map_err(|e: crate::compute::ComputeError| MultivariateError::Compute(e.to_string()))?;
142
143        self.intercept = beta[0];
144        self.coefficients = beta[1..].to_vec();
145
146        // Compute R²
147        let y_mean = y.iter().sum::<f64>() / n as f64;
148        let ss_tot: f64 = y.iter().map(|&v| (v - y_mean).powi(2)).sum();
149        let mut ss_res = 0.0;
150        #[allow(clippy::needless_range_loop)]
151        for t in 0..n {
152            let mut pred = self.intercept;
153            let mut pidx = 0;
154            for s in 0..k {
155                if s != target_idx {
156                    pred += self.coefficients[pidx] * ctx.matrix.data[s][t];
157                    pidx += 1;
158                }
159            }
160            ss_res += (y[t] - pred).powi(2);
161        }
162        self.r_squared = if ss_tot > 1e-15 {
163            1.0 - ss_res / ss_tot
164        } else {
165            1.0
166        };
167
168        // Store last observed predictor values for persistence forecasting
169        self.last_predictors = Vec::with_capacity(n_pred);
170        let last_t = n - 1;
171        for s in 0..k {
172            if s != target_idx {
173                self.last_predictors.push(ctx.matrix.data[s][last_t]);
174            }
175        }
176
177        // Timing
178        self.last_ts = ctx
179            .matrix
180            .timestamps
181            .last()
182            .copied()
183            .ok_or(MultivariateError::InsufficientData { min: 1, got: 0 })?;
184        if ctx.matrix.timestamps.len() >= 2 {
185            let ts = &ctx.matrix.timestamps;
186            let mut deltas: Vec<i64> = ts.windows(2).map(|w| w[1] - w[0]).collect();
187            deltas.sort_unstable();
188            self.interval_ns = deltas[deltas.len() / 2];
189        }
190
191        self.fitted = true;
192        Ok(())
193    }
194
195    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError> {
196        if !self.fitted {
197            return Err(MultivariateError::NotFitted);
198        }
199        // For multi-linear regression, we need predictor values for future timestamps.
200        // Since we don't have them, we use the last known predictor values (persistence forecast).
201        let timestamps: Vec<i64> = (1..=horizon as i64)
202            .map(|h| {
203                self.last_ts
204                    .saturating_add(h.saturating_mul(self.interval_ns))
205            })
206            .collect();
207
208        // Compute prediction using intercept + coefficients × last known predictor values
209        let pred_val = self.intercept
210            + self
211                .coefficients
212                .iter()
213                .zip(self.last_predictors.iter())
214                .map(|(c, x)| c * x)
215                .sum::<f64>();
216        let predictions = vec![vec![pred_val; horizon]];
217
218        let importance: Vec<(String, f64)> = self
219            .predictor_names
220            .iter()
221            .zip(self.coefficients.iter())
222            .map(|(name, &coef)| (name.clone(), coef.abs()))
223            .collect();
224
225        Ok(MultivariateForecastResult {
226            timestamps,
227            predictions,
228            predictor_importance: importance,
229        })
230    }
231
232    fn supports_online_learning(&self) -> bool {
233        true
234    }
235}
236
237// ─── VAR (Vector AutoRegression) ────────────────────────────────────
238
239// Vector AutoRegression: the stability check uses the QR
240// eigenvalue helpers below for the VAR spectral radius.
241
242/// Reduce a square row-major matrix `h` of size `n×n` to upper Hessenberg
243/// form in-place using Householder reflections.
244fn hessenberg_reduce(h: &mut [f64], n: usize) {
245    for col in 0..n.saturating_sub(2) {
246        // Build Householder vector for column `col`, rows col+1..n
247        let m = n - col - 1;
248        let mut x = vec![0.0; m];
249        for i in 0..m {
250            x[i] = h[(col + 1 + i) * n + col];
251        }
252
253        let x_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
254        if x_norm < 1e-15 {
255            continue;
256        }
257
258        let sign = if x[0] >= 0.0 { 1.0 } else { -1.0 };
259        let alpha = -sign * x_norm;
260        x[0] -= alpha;
261        let v_norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
262        if v_norm < 1e-15 {
263            continue;
264        }
265        for v in &mut x {
266            *v /= v_norm;
267        }
268
269        // Apply H = I - 2*v*v^T from the left: h[col+1..n, :] -= 2*v*(v^T * h[col+1..n, :])
270        for j in 0..n {
271            let mut dot = 0.0;
272            for i in 0..m {
273                dot += x[i] * h[(col + 1 + i) * n + j];
274            }
275            for i in 0..m {
276                h[(col + 1 + i) * n + j] -= 2.0 * x[i] * dot;
277            }
278        }
279
280        // Apply from the right: h[:, col+1..n] -= 2*(h[:, col+1..n] * v)*v^T
281        for i in 0..n {
282            let mut dot = 0.0;
283            for j in 0..m {
284                dot += h[i * n + col + 1 + j] * x[j];
285            }
286            for j in 0..m {
287                h[i * n + col + 1 + j] -= 2.0 * dot * x[j];
288            }
289        }
290    }
291}
292
293/// Francis implicit double-shift QR iteration on upper Hessenberg matrix `h`
294/// (in-place). Converges to quasi-upper-triangular (real Schur) form.
295fn qr_francis(h: &mut [f64], n: usize) {
296    if n <= 1 {
297        return;
298    }
299
300    let max_iter = 100 * n;
301    let mut iter_count = 0;
302    let mut nn = n;
303
304    while nn > 1 && iter_count < max_iter {
305        iter_count += 1;
306
307        // Deflation: check if sub-diagonal element is negligible
308        let sub = h[(nn - 1) * n + nn - 2];
309        let diag_sum = h[(nn - 2) * n + nn - 2].abs() + h[(nn - 1) * n + nn - 1].abs();
310        let tol = 1e-14 * diag_sum.max(1e-15);
311
312        if sub.abs() <= tol {
313            nn -= 1;
314            continue;
315        }
316
317        // Check for 2×2 block convergence
318        if nn > 2 {
319            let sub2 = h[(nn - 2) * n + nn - 3];
320            let diag_sum2 = h[(nn - 3) * n + nn - 3].abs() + h[(nn - 2) * n + nn - 2].abs();
321            let tol2 = 1e-14 * diag_sum2.max(1e-15);
322            if sub2.abs() <= tol2 {
323                // 2×2 block at bottom — deflate it (may be complex pair)
324                nn -= 2;
325                continue;
326            }
327        } else {
328            // 2×2 block at top — done
329            break;
330        }
331
332        // Wilkinson shift: eigenvalue of trailing 2×2 closest to h[nn-1,nn-1]
333        let a11 = h[(nn - 2) * n + nn - 2];
334        let a12 = h[(nn - 2) * n + nn - 1];
335        let a21 = h[(nn - 1) * n + nn - 2];
336        let a22 = h[(nn - 1) * n + nn - 1];
337        let trace = a11 + a22;
338        let det = a11 * a22 - a12 * a21;
339
340        // Single implicit QR step with Wilkinson shift
341        let shift = a22;
342        h[0] -= shift;
343        for i in 0..n {
344            if i < nn {
345                h[i * n + i] -= shift;
346            }
347        }
348        // Restore shift after the step
349        // Actually do a proper single-shift QR step via Givens rotations
350        // on the Hessenberg matrix.
351        // Revert the shift subtraction and use a proper implementation.
352        for i in 0..n {
353            if i < nn {
354                h[i * n + i] += shift;
355            }
356        }
357        h[0] += shift;
358
359        // Proper single-shift QR step via Givens rotations
360        let _ = trace; // suppress unused warning
361        let _ = det;
362        qr_step_givens(h, n, nn, a22);
363    }
364}
365
366/// A single implicit QR step with Wilkinson shift applied via Givens rotations
367/// on the upper Hessenberg matrix `h`. Only operates on the leading `nn×nn`
368/// active submatrix.
369fn qr_step_givens(h: &mut [f64], n: usize, nn: usize, shift: f64) {
370    // Apply shift: H - σI
371    for i in 0..nn {
372        h[i * n + i] -= shift;
373    }
374
375    // QR factorization via Givens rotations (chase the bulge)
376    let mut cs = vec![0.0; nn - 1];
377    let mut sn = vec![0.0; nn - 1];
378
379    for i in 0..nn - 1 {
380        let a = h[i * n + i];
381        let b = h[(i + 1) * n + i];
382        let r = a.hypot(b);
383        if r < 1e-30 {
384            cs[i] = 1.0;
385            sn[i] = 0.0;
386            continue;
387        }
388        cs[i] = a / r;
389        sn[i] = b / r;
390
391        // Apply Givens rotation from the left to rows i, i+1
392        for j in 0..n {
393            let t1 = h[i * n + j];
394            let t2 = h[(i + 1) * n + j];
395            h[i * n + j] = cs[i] * t1 + sn[i] * t2;
396            h[(i + 1) * n + j] = -sn[i] * t1 + cs[i] * t2;
397        }
398    }
399
400    // Apply Givens rotations from the right: R * Q
401    for i in 0..nn - 1 {
402        for j in 0..n {
403            let t1 = h[j * n + i];
404            let t2 = h[j * n + i + 1];
405            h[j * n + i] = cs[i] * t1 + sn[i] * t2;
406            h[j * n + i + 1] = -sn[i] * t1 + cs[i] * t2;
407        }
408    }
409
410    // Remove shift: H + σI
411    for i in 0..nn {
412        h[i * n + i] += shift;
413    }
414}
415
416/// Extract the spectral radius (maximum eigenvalue magnitude) from a
417/// quasi-upper-triangular (real Schur) matrix.
418fn spectral_radius_from_schur(h: &[f64], n: usize) -> f64 {
419    let mut max_mag = 0.0f64;
420    let mut i = 0;
421
422    while i < n {
423        if i + 1 < n
424            && h[(i + 1) * n + i].abs()
425                > 1e-14 * (h[i * n + i].abs() + h[(i + 1) * n + i + 1].abs()).max(1e-15)
426        {
427            // 2×2 block → complex conjugate pair
428            let a = h[i * n + i];
429            let b = h[i * n + i + 1];
430            let c = h[(i + 1) * n + i];
431            let d = h[(i + 1) * n + i + 1];
432            // Eigenvalue magnitude: sqrt(|det|) for a 2×2 block
433            // det = ad - bc, trace = a+d
434            // eigenvalues = (trace ± sqrt(trace²-4*det))/2
435            // For complex pair: |λ| = sqrt(det) when trace² < 4*det
436            let det = a * d - b * c;
437            let trace = a + d;
438            let disc = trace * trace - 4.0 * det;
439            let mag = if disc < 0.0 {
440                // Complex pair: both have magnitude sqrt(det)
441                det.abs().sqrt()
442            } else {
443                // Two real eigenvalues from the 2×2 block
444                let sq = disc.sqrt();
445                let e1 = (trace + sq) / 2.0;
446                let e2 = (trace - sq) / 2.0;
447                e1.abs().max(e2.abs())
448            };
449            max_mag = max_mag.max(mag);
450            i += 2;
451        } else {
452            // 1×1 block → real eigenvalue
453            max_mag = max_mag.max(h[i * n + i].abs());
454            i += 1;
455        }
456    }
457
458    max_mag
459}
460
461/// After fitting, the model verifies that the VAR companion matrix has
462/// a spectral radius below [`stability_threshold`](Self::new_with_stability_threshold)
463/// (default: 1.05). Models exceeding this are rejected as unstable.
464/// The QR-based eigenvalue computation correctly captures complex
465/// conjugate eigenvalue pairs (oscillatory dynamics) that power iteration
466/// would miss.
467/// Callers can override the threshold via
468/// [`new_with_stability_threshold()`](Self::new_with_stability_threshold).
469pub struct VarModel {
470    lag_order: usize,
471    /// Coefficient matrices: lag_order matrices of k×k each (row-major).
472    coefficients: Vec<Vec<f64>>,
473    intercepts: Vec<f64>,
474    k: usize,
475    last_values: Vec<Vec<f64>>, // last `lag_order` observations, each of length k
476    last_ts: i64,
477    interval_ns: i64,
478    series_names: Vec<String>,
479    fitted: bool,
480    /// Maximum allowed spectral radius for stability check.
481    stability_threshold: f64,
482}
483
484impl VarModel {
485    /// Creates a VAR model with the given lag order (default: 1).
486    pub fn new(lag_order: Option<usize>) -> Self {
487        Self::new_with_stability_threshold(lag_order, None)
488    }
489
490    /// Creates a VAR model with a custom stability threshold.
491    ///
492    /// The `stability_threshold` controls the maximum allowed spectral
493    /// radius of the VAR companion matrix (default: 1.05). Set to a
494    /// value > 1.0 to tolerate near-unit-root processes, or closer to
495    /// 1.0 for stricter stability enforcement.
496    pub fn new_with_stability_threshold(
497        lag_order: Option<usize>,
498        stability_threshold: Option<f64>,
499    ) -> Self {
500        Self {
501            lag_order: lag_order.unwrap_or(1),
502            coefficients: Vec::new(),
503            intercepts: Vec::new(),
504            k: 0,
505            last_values: Vec::new(),
506            last_ts: 0,
507            interval_ns: 1_000_000_000,
508            series_names: Vec::new(),
509            fitted: false,
510            stability_threshold: stability_threshold.unwrap_or(1.05),
511        }
512    }
513
514    /// Compute spectral radius of the VAR companion matrix.
515    ///
516    /// Uses the Francis QR algorithm (implicit double-shift)
517    /// to compute all eigenvalues, including complex conjugate pairs that
518    /// power iteration would miss. The spectral radius is max(|λ_i|).
519    ///
520    /// The companion matrix for VAR(p) with k variables is a (pk × pk) matrix.
521    /// The VAR is stable iff all eigenvalues lie inside the unit circle,
522    /// i.e., spectral radius < 1.
523    fn compute_spectral_radius(&self) -> f64 {
524        let k = self.k;
525        let p = self.lag_order;
526        let n = p * k;
527        if n == 0 {
528            return 0.0;
529        }
530
531        // Build companion matrix A of size n×n (row-major):
532        //
533        //   A = [ A_1  A_2  ...  A_p ]
534        //       [  I    0   ...   0  ]
535        //       [  0    I   ...   0  ]
536        //       [ ...             0  ]
537        //
538        // where A_l(i,j) = coefficients[i][l*k + j]
539        let mut h = vec![0.0; n * n];
540
541        // First k rows: the VAR coefficient matrices
542        for i in 0..k {
543            for l in 0..p {
544                for j in 0..k {
545                    h[i * n + l * k + j] = self.coefficients[i][l * k + j];
546                }
547            }
548        }
549
550        // Identity sub-diagonals: rows k..n
551        for i in k..n {
552            h[i * n + (i - k)] = 1.0;
553        }
554
555        // ── QR iteration via implicit shifts (Hessenberg form) ──────
556        // Step 1: Reduce to upper Hessenberg form via Householder reflections.
557        hessenberg_reduce(&mut h, n);
558
559        // Step 2: Francis QR iteration to converge to quasi-upper-triangular
560        // (real Schur) form — 2×2 diagonal blocks represent complex pairs.
561        qr_francis(&mut h, n);
562
563        // Step 3: Extract eigenvalue magnitudes from the diagonal / 2×2 blocks.
564        spectral_radius_from_schur(&h, n)
565    }
566}
567
568impl MultivariateForecastModel for VarModel {
569    fn fit(
570        &mut self,
571        ctx: &MultiSeriesContext,
572        _target_idx: usize,
573    ) -> Result<(), MultivariateError> {
574        let k = ctx.matrix.n_series();
575        let n = ctx.matrix.n_timestamps();
576        let p = self.lag_order;
577
578        if n <= p + 1 {
579            return Err(MultivariateError::InsufficientData { min: p + 2, got: n });
580        }
581
582        self.k = k;
583        self.series_names = ctx.matrix.series_ids.clone();
584
585        let engine = CpuEngine::default();
586
587        // For each equation i: y_i(t) = c_i + Σ_{l=1..p} Σ_{j=1..k} A_l(i,j) * y_j(t-l)
588        // Solved via OLS per equation.
589        let dim = 1 + p * k; // intercept + p lags × k series
590        self.intercepts = vec![0.0; k];
591        self.coefficients = vec![vec![0.0; p * k]; k]; // per-equation
592
593        for eq in 0..k {
594            let mut xtx = vec![0.0; dim * dim];
595            let mut xty = vec![0.0; dim];
596
597            for t in p..n {
598                let mut row = Vec::with_capacity(dim);
599                row.push(1.0); // intercept
600                for l in 1..=p {
601                    for s in 0..k {
602                        row.push(ctx.matrix.data[s][t - l]);
603                    }
604                }
605                let y = ctx.matrix.data[eq][t];
606                for i in 0..dim {
607                    for j in 0..dim {
608                        xtx[i * dim + j] += row[i] * row[j];
609                    }
610                    xty[i] += row[i] * y;
611                }
612            }
613
614            // Regularize to avoid singular matrix
615            for i in 0..dim {
616                xtx[i * dim + i] += 1e-8;
617            }
618
619            let beta = engine.batch_matrix_solve(&xtx, &xty, dim).map_err(
620                |e: crate::compute::ComputeError| MultivariateError::Compute(e.to_string()),
621            )?;
622
623            self.intercepts[eq] = beta[0];
624            self.coefficients[eq] = beta[1..].to_vec();
625        }
626
627        // Store last p observations for prediction
628        self.last_values = Vec::with_capacity(p);
629        for l in 0..p {
630            let t = n - p + l;
631            let obs: Vec<f64> = (0..k).map(|s| ctx.matrix.data[s][t]).collect();
632            self.last_values.push(obs);
633        }
634
635        // Timing
636        self.last_ts = ctx
637            .matrix
638            .timestamps
639            .last()
640            .copied()
641            .ok_or(MultivariateError::InsufficientData { min: 1, got: 0 })?;
642        if ctx.matrix.timestamps.len() >= 2 {
643            let ts = &ctx.matrix.timestamps;
644            let mut deltas: Vec<i64> = ts.windows(2).map(|w| w[1] - w[0]).collect();
645            deltas.sort_unstable();
646            self.interval_ns = deltas[deltas.len() / 2];
647        }
648
649        self.fitted = true;
650
651        // VAR stability check — verify spectral radius < threshold.
652        // Build the companion matrix and compute spectral radius via
653        // power iteration. Unstable VAR produces exponentially diverging
654        // forecasts. The threshold (default 1.05) is configurable via
655        // `new_with_stability_threshold()`.
656        let stability_threshold = self.stability_threshold;
657        let spectral_radius = self.compute_spectral_radius();
658        if spectral_radius >= stability_threshold {
659            tracing::warn!(
660                spectral_radius = spectral_radius,
661                "VAR model is unstable (spectral radius >= {stability_threshold}) — forecasts may diverge"
662            );
663            return Err(MultivariateError::InvalidParameter(format!(
664                "VAR model is unstable: spectral radius {spectral_radius:.4} >= {stability_threshold}; \
665                 try increasing regularization, reducing lag order, or differencing the data"
666            )));
667        } else if spectral_radius >= 1.0 {
668            tracing::warn!(
669                spectral_radius = spectral_radius,
670                "VAR model is near unit-root (spectral radius close to 1.0) — \
671                 forecasts may drift; consider differencing"
672            );
673        }
674
675        Ok(())
676    }
677
678    fn predict(&self, horizon: usize) -> Result<MultivariateForecastResult, MultivariateError> {
679        if !self.fitted {
680            return Err(MultivariateError::NotFitted);
681        }
682
683        let k = self.k;
684        let p = self.lag_order;
685        let mut history = self.last_values.clone();
686        let mut predictions = vec![Vec::with_capacity(horizon); k];
687        let timestamps: Vec<i64> = (1..=horizon as i64)
688            .map(|h| {
689                self.last_ts
690                    .saturating_add(h.saturating_mul(self.interval_ns))
691            })
692            .collect();
693
694        for _ in 0..horizon {
695            let mut new_obs = vec![0.0; k];
696            #[allow(clippy::needless_range_loop)]
697            for eq in 0..k {
698                let mut val = self.intercepts[eq];
699                #[allow(clippy::needless_range_loop)]
700                for l in 0..p {
701                    let lag_idx = history.len() - 1 - l;
702                    if lag_idx < history.len() {
703                        for s in 0..k {
704                            val += self.coefficients[eq][l * k + s] * history[lag_idx][s];
705                        }
706                    }
707                }
708                new_obs[eq] = val;
709            }
710            for eq in 0..k {
711                predictions[eq].push(new_obs[eq]);
712            }
713            history.push(new_obs);
714        }
715
716        // Importance: average absolute coefficient per series
717        let mut importance = Vec::with_capacity(k);
718        for s in 0..k {
719            let avg: f64 = (0..k)
720                .flat_map(|eq| (0..p).map(move |l| self.coefficients[eq][l * k + s].abs()))
721                .sum::<f64>()
722                / (k * p) as f64;
723            importance.push((self.series_names[s].clone(), avg));
724        }
725
726        Ok(MultivariateForecastResult {
727            timestamps,
728            predictions,
729            predictor_importance: importance,
730        })
731    }
732
733    fn supports_online_learning(&self) -> bool {
734        false
735    }
736}
737
738// ─── Granger Causality ──────────────────────────────────────────────
739
740/// Result of a pairwise Granger causality test.
741#[derive(Debug, Clone)]
742pub struct GrangerCausalityResult {
743    /// Name of the "cause" series (whose lags were excluded in H₀).
744    pub cause: String,
745    /// Name of the "effect" series being predicted.
746    pub effect: String,
747    /// Lag order used for the test.
748    pub lag_order: usize,
749    /// F-statistic.
750    pub f_statistic: f64,
751    /// Raw (unadjusted) p-value from the F-distribution.
752    pub p_value: f64,
753    /// Benjamini-Hochberg adjusted p-value.
754    /// Controls the false discovery rate across all pairwise tests.
755    pub adjusted_p_value: f64,
756    /// Whether H₀ is rejected after BH correction at α = 0.05.
757    pub significant: bool,
758    /// RSS of the unrestricted model (all lags included).
759    pub rss_unrestricted: f64,
760    /// RSS of the restricted model (cause lags excluded).
761    pub rss_restricted: f64,
762}
763
764impl VarModel {
765    /// Perform pairwise Granger causality tests for all series pairs.
766    ///
767    /// For each pair (cause → effect), fits an unrestricted VAR equation
768    /// (with all series' lags) and a restricted equation (excluding the
769    /// cause's lags), then compares RSS via an F-test.
770    ///
771    /// **Requires** the model to be fitted first via `fit()`.
772    ///
773    /// # F-test
774    ///
775    /// $$F = \frac{(RSS_r - RSS_u) / p}{RSS_u / (T - k_{full})}$$
776    ///
777    /// where $p$ = lag order (number of excluded regressors),
778    /// $T$ = number of usable observations, and $k_{full}$ = number of
779    /// regressors in the unrestricted equation (1 + p × k).
780    pub fn granger_causality(
781        &self,
782        ctx: &MultiSeriesContext,
783    ) -> Result<Vec<GrangerCausalityResult>, MultivariateError> {
784        if !self.fitted {
785            return Err(MultivariateError::NotFitted);
786        }
787
788        let k = self.k;
789        let p = self.lag_order;
790        let n = ctx.matrix.n_timestamps();
791        let t = n - p; // usable observations
792
793        if t <= 1 + p * k {
794            return Err(MultivariateError::InsufficientData {
795                min: 2 + p * k,
796                got: t,
797            });
798        }
799
800        let engine = CpuEngine::default();
801        let dim_full = 1 + p * k; // unrestricted: intercept + p lags × k series
802        let dim_restricted = 1 + p * (k - 1); // restricted: exclude one series' lags
803
804        let mut results = Vec::with_capacity(k * (k - 1));
805
806        for effect_eq in 0..k {
807            for cause_s in 0..k {
808                if cause_s == effect_eq {
809                    continue;
810                }
811
812                // ── Unrestricted model: all lags ──
813                let rss_u = self.ols_rss(ctx, &engine, effect_eq, None, dim_full, p, k)?;
814
815                // ── Restricted model: exclude cause_s lags ──
816                let rss_r =
817                    self.ols_rss(ctx, &engine, effect_eq, Some(cause_s), dim_restricted, p, k)?;
818
819                // ── F-test ──
820                let df1 = p as f64; // numerator df
821                let df2 = (t - dim_full) as f64; // denominator df
822                let f_stat = if rss_u.abs() < 1e-30 {
823                    0.0
824                } else {
825                    ((rss_r - rss_u) / df1) / (rss_u / df2)
826                };
827
828                // p-value from F(df1, df2) using the regularized incomplete
829                // beta function: P(F > f) = 1 - I_{x}(a, b) where
830                // x = df2 / (df2 + df1 * f), a = df2/2, b = df1/2.
831                let p_value = f_distribution_sf(f_stat, df1, df2);
832
833                results.push(GrangerCausalityResult {
834                    cause: self.series_names[cause_s].clone(),
835                    effect: self.series_names[effect_eq].clone(),
836                    lag_order: p,
837                    f_statistic: f_stat,
838                    p_value,
839                    adjusted_p_value: p_value, // will be corrected below
840                    significant: false,        // will be set after BH
841                    rss_unrestricted: rss_u,
842                    rss_restricted: rss_r,
843                });
844            }
845        }
846
847        // Benjamini-Hochberg FDR correction for multiple comparisons.
848        // Sort by p-value ascending, apply BH threshold:
849        //   threshold_i = α × rank_i / m
850        let m = results.len();
851        if m > 0 {
852            let alpha = 0.05;
853            let mut indices: Vec<usize> = (0..m).collect();
854            indices.sort_by(|&a, &b| {
855                results[a]
856                    .p_value
857                    .partial_cmp(&results[b].p_value)
858                    .unwrap_or(std::cmp::Ordering::Equal)
859            });
860
861            // Compute BH-adjusted p-values (step-up procedure).
862            // adjusted_p[i] = min(p[i] * m / rank, min of adjusted_p[j] for j > i)
863            let mut adj_p = vec![0.0; m];
864            for (rank, &idx) in indices.iter().enumerate() {
865                adj_p[idx] = (results[idx].p_value * m as f64 / (rank + 1) as f64).min(1.0);
866            }
867            // Enforce monotonicity: walk backwards through rank order
868            let mut running_min = 1.0;
869            for &idx in indices.iter().rev() {
870                adj_p[idx] = adj_p[idx].min(running_min);
871                running_min = adj_p[idx];
872            }
873            for (i, p_adj) in adj_p.into_iter().enumerate() {
874                results[i].adjusted_p_value = p_adj;
875                results[i].significant = p_adj < alpha;
876            }
877        }
878
879        Ok(results)
880    }
881
882    /// HC3-robust pairwise Granger causality tests.
883    ///
884    /// Same hypothesis as `granger_causality` but uses the HC3
885    /// (MacKinnon & White 1985) covariance estimator, which is valid
886    /// under arbitrary conditional heteroscedasticity.
887    ///
888    /// For each pair (cause → effect) the unrestricted OLS equation is
889    /// fitted, residuals are obtained, and a Wald test with HC3
890    /// sandwich covariance is computed:
891    ///
892    /// $$W = (R\hat\beta)^\top (R\,\hat V_{HC3}\,R^\top)^{-1}(R\hat\beta)$$
893    ///
894    /// where $R$ selects the $p$ coefficients of the candidate cause.
895    /// $F = W / p$ is compared against $F(p, T - kp - 1)$.
896    pub fn granger_causality_robust(
897        &self,
898        ctx: &MultiSeriesContext,
899    ) -> Result<Vec<GrangerCausalityResult>, MultivariateError> {
900        if !self.fitted {
901            return Err(MultivariateError::NotFitted);
902        }
903
904        let k = self.k;
905        let p = self.lag_order;
906        let n = ctx.matrix.n_timestamps();
907        let t = n - p;
908
909        if t <= 1 + p * k {
910            return Err(MultivariateError::InsufficientData {
911                min: 2 + p * k,
912                got: t,
913            });
914        }
915
916        let engine = CpuEngine::default();
917        let dim = 1 + p * k; // unrestricted model dimension
918        let df_den = t as f64 - dim as f64;
919
920        if df_den <= 0.0 {
921            return Err(MultivariateError::InsufficientData {
922                min: dim + p + 1,
923                got: t,
924            });
925        }
926
927        let mut results = Vec::with_capacity(k * (k - 1));
928
929        for effect_eq in 0..k {
930            // Build X (t × dim) and y (t) for the unrestricted equation.
931            let mut x_mat = vec![0.0_f64; t * dim];
932            let mut y_vec = vec![0.0_f64; t];
933
934            for (row, obs) in (p..n).enumerate() {
935                y_vec[row] = ctx.matrix.data[effect_eq][obs];
936                x_mat[row * dim] = 1.0;
937                let mut col = 1;
938                for l in 1..=p {
939                    for s in 0..k {
940                        x_mat[row * dim + col] = ctx.matrix.data[s][obs - l];
941                        col += 1;
942                    }
943                }
944            }
945
946            // X'X (dim × dim)
947            let mut xtx = vec![0.0_f64; dim * dim];
948            for row in 0..t {
949                for i in 0..dim {
950                    let xi = x_mat[row * dim + i];
951                    for j in i..dim {
952                        let v = xi * x_mat[row * dim + j];
953                        xtx[i * dim + j] += v;
954                        if i != j {
955                            xtx[j * dim + i] += v;
956                        }
957                    }
958                }
959            }
960            // X'y
961            let mut xty = vec![0.0_f64; dim];
962            for row in 0..t {
963                let y = y_vec[row];
964                for i in 0..dim {
965                    xty[i] += x_mat[row * dim + i] * y;
966                }
967            }
968            // Ridge regularization
969            for i in 0..dim {
970                xtx[i * dim + i] += 1e-8;
971            }
972
973            // β = (X'X)^{-1} X'y
974            let beta = engine
975                .batch_matrix_solve(&xtx, &xty, dim)
976                .map_err(|e| MultivariateError::Compute(e.to_string()))?;
977
978            // Residuals
979            let mut residuals = vec![0.0_f64; t];
980            for row in 0..t {
981                let mut fitted = 0.0;
982                for j in 0..dim {
983                    fitted += x_mat[row * dim + j] * beta[j];
984                }
985                residuals[row] = y_vec[row] - fitted;
986            }
987
988            // (X'X)^{-1} via column solves
989            let mut xtx_inv = vec![0.0_f64; dim * dim];
990            for c in 0..dim {
991                let mut rhs = vec![0.0_f64; dim];
992                rhs[c] = 1.0;
993                let sol = engine
994                    .batch_matrix_solve(&xtx, &rhs, dim)
995                    .map_err(|e| MultivariateError::Compute(e.to_string()))?;
996                for r in 0..dim {
997                    xtx_inv[r * dim + c] = sol[r];
998                }
999            }
1000
1001            // Hat diagonal: h_ii = x_i' (X'X)^{-1} x_i
1002            let mut hat_diag = vec![0.0_f64; t];
1003            for row in 0..t {
1004                let mut h = 0.0;
1005                for i in 0..dim {
1006                    let mut inner = 0.0;
1007                    for j in 0..dim {
1008                        inner += xtx_inv[i * dim + j] * x_mat[row * dim + j];
1009                    }
1010                    h += x_mat[row * dim + i] * inner;
1011                }
1012                hat_diag[row] = h.clamp(0.0, 1.0 - 1e-12);
1013            }
1014
1015            // HC3 meat: M = Σ x_i x_i' e_i² / (1 - h_ii)²
1016            let mut meat = vec![0.0_f64; dim * dim];
1017            for row in 0..t {
1018                let denom = (1.0 - hat_diag[row]).powi(2);
1019                let w = residuals[row].powi(2) / denom;
1020                for i in 0..dim {
1021                    let xi_w = x_mat[row * dim + i] * w;
1022                    for j in i..dim {
1023                        let v = xi_w * x_mat[row * dim + j];
1024                        meat[i * dim + j] += v;
1025                        if i != j {
1026                            meat[j * dim + i] += v;
1027                        }
1028                    }
1029                }
1030            }
1031
1032            // V_HC3 = (X'X)^{-1} M (X'X)^{-1}
1033            let mut tmp = vec![0.0_f64; dim * dim];
1034            for i in 0..dim {
1035                for j in 0..dim {
1036                    let mut s = 0.0;
1037                    for c in 0..dim {
1038                        s += xtx_inv[i * dim + c] * meat[c * dim + j];
1039                    }
1040                    tmp[i * dim + j] = s;
1041                }
1042            }
1043            let mut v_hc3 = vec![0.0_f64; dim * dim];
1044            for i in 0..dim {
1045                for j in 0..dim {
1046                    let mut s = 0.0;
1047                    for c in 0..dim {
1048                        s += tmp[i * dim + c] * xtx_inv[c * dim + j];
1049                    }
1050                    v_hc3[i * dim + j] = s;
1051                }
1052            }
1053
1054            // RSS for the unrestricted model (for the result struct)
1055            let rss_u: f64 = residuals.iter().map(|e| e * e).sum();
1056
1057            // Now test each potential cause against this effect
1058            for cause_s in 0..k {
1059                if cause_s == effect_eq {
1060                    continue;
1061                }
1062
1063                // R selects p columns for cause_s lags in unrestricted model:
1064                // col = 1 + (l-1)*k + cause_s  for l = 1..=p
1065                let cause_cols: Vec<usize> = (1..=p).map(|l| 1 + (l - 1) * k + cause_s).collect();
1066
1067                // Rβ
1068                let r_beta: Vec<f64> = cause_cols.iter().map(|&c| beta[c]).collect();
1069
1070                // R V_HC3 R' (p × p)
1071                let mut r_v_rt = vec![0.0_f64; p * p];
1072                for (i, &ci) in cause_cols.iter().enumerate() {
1073                    for (j, &cj) in cause_cols.iter().enumerate() {
1074                        r_v_rt[i * p + j] = v_hc3[ci * dim + cj];
1075                    }
1076                }
1077                for i in 0..p {
1078                    r_v_rt[i * p + i] += 1e-12;
1079                }
1080
1081                // Wald = (Rβ)' (R V R')^{-1} (Rβ)
1082                let inv_r_beta = engine
1083                    .batch_matrix_solve(&r_v_rt, &r_beta, p)
1084                    .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1085
1086                let wald: f64 = r_beta
1087                    .iter()
1088                    .zip(inv_r_beta.iter())
1089                    .map(|(a, b)| a * b)
1090                    .sum();
1091                let f_stat = (wald / p as f64).max(0.0);
1092                let p_value = f_distribution_sf(f_stat, p as f64, df_den);
1093
1094                // Also compute restricted RSS for the result struct
1095                let dim_r = 1 + p * (k - 1);
1096                let rss_r = self.ols_rss(ctx, &engine, effect_eq, Some(cause_s), dim_r, p, k)?;
1097
1098                results.push(GrangerCausalityResult {
1099                    cause: self.series_names[cause_s].clone(),
1100                    effect: self.series_names[effect_eq].clone(),
1101                    lag_order: p,
1102                    f_statistic: f_stat,
1103                    p_value,
1104                    adjusted_p_value: p_value,
1105                    significant: false,
1106                    rss_unrestricted: rss_u,
1107                    rss_restricted: rss_r,
1108                });
1109            }
1110        }
1111
1112        // BH correction
1113        let m = results.len();
1114        if m > 0 {
1115            let alpha = 0.05;
1116            let mut indices: Vec<usize> = (0..m).collect();
1117            indices.sort_by(|&a, &b| {
1118                results[a]
1119                    .p_value
1120                    .partial_cmp(&results[b].p_value)
1121                    .unwrap_or(std::cmp::Ordering::Equal)
1122            });
1123            let mut adj_p = vec![0.0; m];
1124            for (rank, &idx) in indices.iter().enumerate() {
1125                adj_p[idx] = (results[idx].p_value * m as f64 / (rank + 1) as f64).min(1.0);
1126            }
1127            let mut running_min = 1.0;
1128            for &idx in indices.iter().rev() {
1129                adj_p[idx] = adj_p[idx].min(running_min);
1130                running_min = adj_p[idx];
1131            }
1132            for (i, p_adj) in adj_p.into_iter().enumerate() {
1133                results[i].adjusted_p_value = p_adj;
1134                results[i].significant = p_adj < alpha;
1135            }
1136        }
1137
1138        Ok(results)
1139    }
1140
1141    /// Fit one OLS equation and return RSS.
1142    ///
1143    /// If `exclude_series` is `Some(s)`, the lags of series `s` are
1144    /// omitted from the design matrix (restricted model for Granger test).
1145    fn ols_rss(
1146        &self,
1147        ctx: &MultiSeriesContext,
1148        engine: &CpuEngine,
1149        effect_eq: usize,
1150        exclude_series: Option<usize>,
1151        dim: usize,
1152        p: usize,
1153        k: usize,
1154    ) -> Result<f64, MultivariateError> {
1155        let n = ctx.matrix.n_timestamps();
1156        let mut xtx = vec![0.0; dim * dim];
1157        let mut xty = vec![0.0; dim];
1158
1159        for obs in p..n {
1160            let mut row = Vec::with_capacity(dim);
1161            row.push(1.0); // intercept
1162            for l in 1..=p {
1163                for s in 0..k {
1164                    if exclude_series == Some(s) {
1165                        continue;
1166                    }
1167                    row.push(ctx.matrix.data[s][obs - l]);
1168                }
1169            }
1170            let y = ctx.matrix.data[effect_eq][obs];
1171            for i in 0..dim {
1172                for j in 0..dim {
1173                    xtx[i * dim + j] += row[i] * row[j];
1174                }
1175                xty[i] += row[i] * y;
1176            }
1177        }
1178
1179        // Regularize
1180        for i in 0..dim {
1181            xtx[i * dim + i] += 1e-8;
1182        }
1183
1184        let beta = engine
1185            .batch_matrix_solve(&xtx, &xty, dim)
1186            .map_err(|e| MultivariateError::Compute(e.to_string()))?;
1187
1188        // Compute RSS
1189        let mut rss = 0.0;
1190        for obs in p..n {
1191            let mut pred = beta[0]; // intercept
1192            let mut col = 1;
1193            for l in 1..=p {
1194                for s in 0..k {
1195                    if exclude_series == Some(s) {
1196                        continue;
1197                    }
1198                    pred += beta[col] * ctx.matrix.data[s][obs - l];
1199                    col += 1;
1200                }
1201            }
1202            let residual = ctx.matrix.data[effect_eq][obs] - pred;
1203            rss += residual * residual;
1204        }
1205
1206        Ok(rss)
1207    }
1208}
1209
1210/// Survival function of the F-distribution: P(F > f) for F ~ F(d1, d2).
1211///
1212/// Uses the regularized incomplete beta function relation:
1213/// P(F > f) = I_{x}(d2/2, d1/2) where x = d2 / (d2 + d1 * f).
1214fn f_distribution_sf(f: f64, d1: f64, d2: f64) -> f64 {
1215    if f <= 0.0 || d1 <= 0.0 || d2 <= 0.0 {
1216        return 1.0;
1217    }
1218    let x = d2 / (d2 + d1 * f);
1219    regularized_incomplete_beta(d2 / 2.0, d1 / 2.0, x)
1220}
1221
1222/// Regularized incomplete beta function I_x(a, b) via continued fraction
1223/// (Lentz's method).  Accurate to ~1e-10 for typical F-test parameters.
1224fn regularized_incomplete_beta(a: f64, b: f64, x: f64) -> f64 {
1225    if x <= 0.0 {
1226        return 0.0;
1227    }
1228    if x >= 1.0 {
1229        return 1.0;
1230    }
1231
1232    // Use the symmetry relation when x > (a+1)/(a+b+2) for convergence
1233    if x > (a + 1.0) / (a + b + 2.0) {
1234        return 1.0 - regularized_incomplete_beta(b, a, 1.0 - x);
1235    }
1236
1237    let ln_prefix = a * x.ln() + b * (1.0 - x).ln() - ln_beta(a, b) - a.ln();
1238    let prefix = ln_prefix.exp();
1239
1240    // Lentz's continued fraction for I_x(a,b)
1241    let max_iter = 200;
1242    let eps = 1e-14;
1243    let tiny = 1e-30;
1244
1245    let mut c = 1.0;
1246    let mut d = 1.0 - (a + b) * x / (a + 1.0);
1247    if d.abs() < tiny {
1248        d = tiny;
1249    }
1250    d = 1.0 / d;
1251    let mut h = d;
1252
1253    for m in 1..=max_iter {
1254        let m_f = m as f64;
1255
1256        // Even step
1257        let num_even = m_f * (b - m_f) * x / ((a + 2.0 * m_f - 1.0) * (a + 2.0 * m_f));
1258        d = 1.0 + num_even * d;
1259        if d.abs() < tiny {
1260            d = tiny;
1261        }
1262        d = 1.0 / d;
1263        c = 1.0 + num_even / c;
1264        if c.abs() < tiny {
1265            c = tiny;
1266        }
1267        h *= d * c;
1268
1269        // Odd step
1270        let num_odd = -((a + m_f) * (a + b + m_f)) * x / ((a + 2.0 * m_f) * (a + 2.0 * m_f + 1.0));
1271        d = 1.0 + num_odd * d;
1272        if d.abs() < tiny {
1273            d = tiny;
1274        }
1275        d = 1.0 / d;
1276        c = 1.0 + num_odd / c;
1277        if c.abs() < tiny {
1278            c = tiny;
1279        }
1280        let delta = d * c;
1281        h *= delta;
1282
1283        if (delta - 1.0).abs() < eps {
1284            break;
1285        }
1286    }
1287
1288    prefix * h
1289}
1290
1291/// Log of the Beta function: ln B(a,b) = ln Γ(a) + ln Γ(b) − ln Γ(a+b).
1292fn ln_beta(a: f64, b: f64) -> f64 {
1293    ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b)
1294}
1295
1296/// Lanczos approximation for ln Γ(x).
1297fn ln_gamma(x: f64) -> f64 {
1298    const COEFFS: [f64; 7] = [
1299        0.999_999_999_999_809_9,
1300        676.5203681218851,
1301        -1259.1392167224028,
1302        771.323_428_777_653_1,
1303        -176.615_029_162_140_6,
1304        12.507343278686905,
1305        -0.13857109526572012,
1306    ];
1307    let x = x - 1.0;
1308    let mut y = COEFFS[0];
1309    for (i, &c) in COEFFS.iter().enumerate().skip(1) {
1310        y += c / (x + i as f64);
1311    }
1312    let t = x + 6.5;
1313    0.5 * (2.0 * std::f64::consts::PI).ln() + (t).ln() * (x + 0.5) - t + y.ln()
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    fn simple_ctx() -> MultiSeriesContext {
1321        let n = 100;
1322        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1323        // Use independent predictors: x1 = linear, x2 = sinusoidal
1324        let x1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1325        let x2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin() * 10.0).collect();
1326        // y = 2*x1 + 3*x2 + 5
1327        let y: Vec<f64> = (0..n)
1328            .map(|i| 2.0 * (i as f64) + 3.0 * (i as f64 * 0.1).sin() * 10.0 + 5.0)
1329            .collect();
1330        MultiSeriesContext::build(
1331            vec![
1332                ("y".to_string(), ts.clone(), y),
1333                ("x1".to_string(), ts.clone(), x1),
1334                ("x2".to_string(), ts, x2),
1335            ],
1336            None,
1337        )
1338        .unwrap()
1339    }
1340
1341    #[test]
1342    fn multi_linear_regression_fit() {
1343        let ctx = simple_ctx();
1344        let mut model = MultiLinearRegression::new();
1345        model.fit(&ctx, 0).unwrap();
1346        assert!(model.r_squared > 0.99, "R²={}", model.r_squared);
1347        // Coefficients should be close to [2.0, 3.0]
1348        assert!((model.coefficients[0] - 2.0).abs() < 0.5);
1349        assert!((model.coefficients[1] - 3.0).abs() < 0.5);
1350    }
1351
1352    #[test]
1353    fn multi_linear_regression_predict() {
1354        let ctx = simple_ctx();
1355        let mut model = MultiLinearRegression::new();
1356        model.fit(&ctx, 0).unwrap();
1357        let result = model.predict(5).unwrap();
1358        assert_eq!(result.predictions[0].len(), 5);
1359        assert_eq!(result.timestamps.len(), 5);
1360        // With y = 2*x1 + 3*x2 + 5, predictions should use last known predictor values.
1361        // Last x1 = 99.0, last x2 = sin(9.9)*10 ≈ -4.56; expected ≈ 2*99 + 3*(-4.56) + 5 ≈ 189.3
1362        let pred = result.predictions[0][0];
1363        assert!(
1364            pred > 100.0,
1365            "prediction {} should reflect learned coefficients, not just intercept",
1366            pred
1367        );
1368    }
1369
1370    #[test]
1371    fn var_fit_and_predict() {
1372        let n = 200;
1373        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1374        let s1: Vec<f64> = (0..n)
1375            .map(|i| (i as f64 * 0.05).sin() * 10.0 + 50.0)
1376            .collect();
1377        let s2: Vec<f64> = (0..n)
1378            .map(|i| (i as f64 * 0.05).cos() * 10.0 + 50.0)
1379            .collect();
1380        let ctx = MultiSeriesContext::build(
1381            vec![
1382                ("s1".to_string(), ts.clone(), s1),
1383                ("s2".to_string(), ts, s2),
1384            ],
1385            None,
1386        )
1387        .unwrap();
1388
1389        let mut model = VarModel::new(Some(2));
1390        model.fit(&ctx, 0).unwrap();
1391        let result = model.predict(10).unwrap();
1392        assert_eq!(result.predictions.len(), 2); // both series
1393        assert_eq!(result.predictions[0].len(), 10);
1394        assert_eq!(result.predictions[1].len(), 10);
1395    }
1396
1397    #[test]
1398    fn var_predictor_importance() {
1399        let n = 200;
1400        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1401        let s1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1402        let s2: Vec<f64> = (0..n).map(|i| i as f64 * 2.0).collect();
1403        let ctx = MultiSeriesContext::build(
1404            vec![
1405                ("s1".to_string(), ts.clone(), s1),
1406                ("s2".to_string(), ts, s2),
1407            ],
1408            None,
1409        )
1410        .unwrap();
1411
1412        let mut model = VarModel::new(Some(1));
1413        model.fit(&ctx, 0).unwrap();
1414        let result = model.predict(5).unwrap();
1415        assert_eq!(result.predictor_importance.len(), 2);
1416    }
1417
1418    #[test]
1419    fn granger_causality_detects_cause() {
1420        // s2(t) = 0.8 * s1(t-1) + noise → s1 Granger-causes s2
1421        let n = 300;
1422        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1423        // Simple deterministic "pseudo-random" via LCG
1424        let mut rng_state: u64 = 42;
1425        let mut pseudo_rand = || -> f64 {
1426            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1427            ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.5
1428        };
1429
1430        let s1: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin() * 5.0).collect();
1431        let mut s2 = vec![0.0; n];
1432        for i in 1..n {
1433            s2[i] = 0.8 * s1[i - 1] + pseudo_rand();
1434        }
1435
1436        let ctx = MultiSeriesContext::build(
1437            vec![
1438                ("s1".to_string(), ts.clone(), s1),
1439                ("s2".to_string(), ts, s2),
1440            ],
1441            None,
1442        )
1443        .unwrap();
1444
1445        let mut model = VarModel::new(Some(2));
1446        model.fit(&ctx, 0).unwrap();
1447
1448        let results = model.granger_causality(&ctx).unwrap();
1449        assert_eq!(results.len(), 2); // s1→s2 and s2→s1
1450
1451        let s1_causes_s2 = results
1452            .iter()
1453            .find(|r| r.cause == "s1" && r.effect == "s2")
1454            .unwrap();
1455        assert!(
1456            s1_causes_s2.significant,
1457            "s1 should Granger-cause s2: F={:.3}, p={:.4}",
1458            s1_causes_s2.f_statistic, s1_causes_s2.p_value
1459        );
1460        assert!(s1_causes_s2.f_statistic > 1.0);
1461        assert!(s1_causes_s2.p_value < 0.05);
1462    }
1463
1464    #[test]
1465    fn granger_requires_fitted_model() {
1466        let n = 100;
1467        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1468        let s1: Vec<f64> = (0..n).map(|i| i as f64).collect();
1469        let s2: Vec<f64> = (0..n).map(|i| (i as f64).sqrt()).collect();
1470        let ctx = MultiSeriesContext::build(
1471            vec![
1472                ("s1".to_string(), ts.clone(), s1),
1473                ("s2".to_string(), ts, s2),
1474            ],
1475            None,
1476        )
1477        .unwrap();
1478
1479        let model = VarModel::new(Some(1));
1480        let err = model.granger_causality(&ctx);
1481        assert!(err.is_err());
1482    }
1483
1484    #[test]
1485    fn granger_robust_detects_cause() {
1486        // Same causal setup as granger_causality_detects_cause.
1487        let n = 200;
1488        let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1489        let mut s1 = vec![0.0; n];
1490        let mut s2 = vec![0.0; n];
1491        let mut rng_state: u64 = 12345;
1492        for i in 1..n {
1493            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1494            let noise1 = ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.5;
1495            s1[i] = 0.7 * s1[i - 1] + noise1;
1496            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
1497            let noise2 = ((rng_state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.2;
1498            // s2 is caused by s1 at lag 1
1499            s2[i] = 0.8 * s1[i - 1] + noise2;
1500        }
1501        let ctx = MultiSeriesContext::build(
1502            vec![
1503                ("s1".to_string(), ts.clone(), s1),
1504                ("s2".to_string(), ts, s2),
1505            ],
1506            None,
1507        )
1508        .unwrap();
1509
1510        let mut model = VarModel::new(Some(1));
1511        model.fit(&ctx, 1).unwrap();
1512        let results = model.granger_causality_robust(&ctx).unwrap();
1513        // s1 → s2 should be significant
1514        let s1_causes_s2 = results
1515            .iter()
1516            .find(|r| r.cause == "s1" && r.effect == "s2")
1517            .unwrap();
1518        assert!(
1519            s1_causes_s2.significant,
1520            "HC3: s1 should Granger-cause s2: F={:.3}, p={:.4}, adj_p={:.4}",
1521            s1_causes_s2.f_statistic, s1_causes_s2.p_value, s1_causes_s2.adjusted_p_value,
1522        );
1523    }
1524}