Skip to main content

chronix_analytics/forecast/
arima.rs

1//! ARIMA(p,d,q) and SARIMA(p,d,q)(P,D,Q)m forecast models.
2//!
3//! # Estimation
4//!
5//! Both models are estimated by **conditional sum of squares** (CSS), the
6//! standard fast alternative to full maximum likelihood:
7//!
8//! 1. The series is differenced — seasonally first (`D` times at lag `m`),
9//!    then regularly (`d` times).
10//! 2. The autoregressive part is seeded by the **Burg** algorithm, which
11//!    always returns a stable AR polynomial and behaves well on short series.
12//! 3. The remaining parameters are refined by minimising the sum of squared
13//!    one-step innovations. `ArimaModel` optimises the moving-average
14//!    coefficients with the AR part held at its Burg estimate; `SarimaModel`
15//!    optimises all four blocks (φ, Φ, θ, Θ) jointly, because the seasonal
16//!    and non-seasonal AR parts interact multiplicatively and cannot be
17//!    estimated independently.
18//!
19//! The first `max(deg φ*, deg θ*)` innovations are **conditioned away**: they
20//! are computed from pre-sample history the model does not have, and for
21//! `ARIMA(p,0,q)` the first of them is the raw first observation, so counting
22//! them would make the residual variance measure the series' level rather than
23//! its noise. That is what "conditional" in CSS means.
24//!
25//! # Seasonality
26//!
27//! `SarimaModel` expands the multiplicative form
28//!
29//! ```text
30//! φ(B)·Φ(Bᵐ)·(1-B)ᵈ·(1-Bᵐ)ᴰ·xₜ = θ(B)·Θ(Bᵐ)·εₜ
31//! ```
32//!
33//! into a single AR polynomial of degree `p + P·m` and a single MA polynomial
34//! of degree `q + Q·m`, then runs the ordinary ARMA recursion over them. One
35//! recursion and one forecast path, so the seasonal orders reach the forecast
36//! by construction.
37
38use crate::forecast::error::ForecastError;
39use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
40use crate::forecast::traits::ForecastModel;
41use crate::forecast::util::{median_interval, validate_input};
42
43// ── Shared ARMA core ────────────────────────────────────────────────────
44//
45// One definition of each operation, used by both models. `ArimaModel` is the
46// `P = D = Q = 0` case of `SarimaModel`; keeping the recursions here is what
47// makes that true rather than merely claimed.
48
49/// Difference a series `d` times at lag 1.
50pub(crate) fn difference(values: &[f64], d: usize) -> Vec<f64> {
51    let mut current = values.to_vec();
52    for _ in 0..d {
53        if current.len() < 2 {
54            return Vec::new();
55        }
56        current = current.windows(2).map(|w| w[1] - w[0]).collect();
57    }
58    current
59}
60
61/// Difference a series `d` times at lag `m` (seasonal differencing).
62pub(crate) fn seasonal_difference(values: &[f64], m: usize, d: usize) -> Vec<f64> {
63    if m == 0 {
64        return values.to_vec();
65    }
66    let mut current = values.to_vec();
67    for _ in 0..d {
68        if current.len() <= m {
69            return Vec::new();
70        }
71        current = (m..current.len())
72            .map(|i| current[i] - current[i - m])
73            .collect();
74    }
75    current
76}
77
78/// Reconstruct the original scale from lag-`m` differenced predictions.
79///
80/// `anchor` is the tail of the *previous* difference level, and must hold at
81/// least `m` values: prediction `h` adds back the value `m` steps earlier,
82/// which for `h < m` comes from history and for `h >= m` from an already
83/// reconstructed prediction.
84///
85/// Kahan compensated summation bounds the rounding error that otherwise
86/// accumulates across a long horizon, where every value is the sum of every
87/// prediction before it.
88fn undifference_lag(predictions: &[f64], anchor: &[f64], m: usize) -> Vec<f64> {
89    let mut buf: Vec<f64> = anchor.to_vec();
90    let mut comp = vec![0.0_f64; m.max(1)];
91    for (h, &v) in predictions.iter().enumerate() {
92        let base = buf[buf.len() - m];
93        let slot = h % m.max(1);
94        let y = v - comp[slot];
95        let t = base + y;
96        comp[slot] = (t - base) - y;
97        buf.push(t);
98    }
99    buf[anchor.len()..].to_vec()
100}
101
102/// Estimate AR coefficients with the Burg algorithm.
103///
104/// Burg minimises the sum of forward and backward prediction errors. Unlike
105/// Yule–Walker it always produces a stable AR polynomial and behaves better on
106/// short series, and it runs in O(N·p) through the Levinson recursion.
107///
108/// Returns `(coefficients, effective_order)`; the effective order is below `p`
109/// when the recursion stops early on near-constant data.
110///
111/// # Errors
112///
113/// [`ForecastError::InsufficientData`] when fewer than `p + 1` observations
114/// are supplied.
115pub(crate) fn estimate_ar_burg(
116    values: &[f64],
117    p: usize,
118) -> Result<(Vec<f64>, usize), ForecastError> {
119    if values.len() < p + 1 {
120        return Err(ForecastError::InsufficientData {
121            min: p + 1,
122            got: values.len(),
123        });
124    }
125    if p == 0 {
126        return Ok((Vec::new(), 0));
127    }
128
129    let n = values.len();
130    let mut ef: Vec<f64> = values.to_vec();
131    let mut eb: Vec<f64> = values.to_vec();
132    let mut ar = vec![0.0; p];
133    let mut effective_order = p;
134
135    for m in 0..p {
136        let mut num = 0.0;
137        let mut den = 0.0;
138        for t in (m + 1)..n {
139            num += ef[t] * eb[t - 1];
140            den += ef[t] * ef[t] + eb[t - 1] * eb[t - 1];
141        }
142        if den.abs() < 1e-15 {
143            // Near-constant data: the reflection coefficient is 0/0. Stop and
144            // report the order actually reached rather than dividing.
145            effective_order = m;
146            tracing::warn!(
147                requested_order = p,
148                effective_order = m,
149                "Burg AR estimation terminated early due to numerical instability"
150            );
151            break;
152        }
153        let k = 2.0 * num / den;
154
155        let mut ar_new = vec![0.0; p];
156        for j in 0..m {
157            ar_new[j] = ar[j] - k * ar[m - 1 - j];
158        }
159        ar_new[m] = k;
160        ar = ar_new;
161
162        let ef_prev = ef.clone();
163        let eb_prev = eb.clone();
164        for t in (m + 1)..n {
165            ef[t] = ef_prev[t] - k * eb_prev[t - 1];
166            eb[t] = eb_prev[t - 1] - k * ef_prev[t];
167        }
168    }
169
170    Ok((ar, effective_order))
171}
172
173/// Multiply two polynomials given as coefficient vectors (index = power of B).
174fn poly_mul(a: &[f64], b: &[f64]) -> Vec<f64> {
175    let mut out = vec![0.0; a.len() + b.len() - 1];
176    for (i, &ai) in a.iter().enumerate() {
177        if ai == 0.0 {
178            continue;
179        }
180        for (j, &bj) in b.iter().enumerate() {
181            out[i + j] += ai * bj;
182        }
183    }
184    out
185}
186
187/// Expand `φ(B)·Φ(Bᵐ)` into one AR coefficient vector of degree `p + P·m`.
188///
189/// Both inputs and the output use the recursion convention
190/// `xₜ = Σ φᵢ·xₜ₋ᵢ + …`, i.e. the AR *polynomial* is `1 − Σ φᵢBⁱ`; the sign
191/// flip happens here so no caller has to remember it.
192pub(crate) fn expand_ar(phi: &[f64], seasonal_phi: &[f64], m: usize) -> Vec<f64> {
193    if seasonal_phi.is_empty() || m == 0 {
194        return phi.to_vec();
195    }
196    let mut a = vec![0.0; phi.len() + 1];
197    a[0] = 1.0;
198    for (i, &c) in phi.iter().enumerate() {
199        a[i + 1] = -c;
200    }
201    let mut s = vec![0.0; seasonal_phi.len() * m + 1];
202    s[0] = 1.0;
203    for (k, &c) in seasonal_phi.iter().enumerate() {
204        s[(k + 1) * m] = -c;
205    }
206    poly_mul(&a, &s)[1..].iter().map(|c| -c).collect()
207}
208
209/// Expand `θ(B)·Θ(Bᵐ)` into one MA coefficient vector of degree `q + Q·m`.
210///
211/// Convention `xₜ = … + Σ θⱼ·εₜ₋ⱼ + εₜ`, i.e. the MA polynomial is
212/// `1 + Σ θⱼBʲ`, so no sign flip is needed — but stating it is what stops the
213/// two conventions being mixed, which is the classic way a seasonal MA term
214/// ends up with the wrong sign.
215pub(crate) fn expand_ma(theta: &[f64], seasonal_theta: &[f64], m: usize) -> Vec<f64> {
216    if seasonal_theta.is_empty() || m == 0 {
217        return theta.to_vec();
218    }
219    let mut b = vec![0.0; theta.len() + 1];
220    b[0] = 1.0;
221    b[1..].copy_from_slice(theta);
222    let mut t = vec![0.0; seasonal_theta.len() * m + 1];
223    t[0] = 1.0;
224    for (k, &c) in seasonal_theta.iter().enumerate() {
225        t[(k + 1) * m] = c;
226    }
227    poly_mul(&b, &t)[1..].to_vec()
228}
229
230/// One-step innovations `εₜ` of `xₜ = Σφᵢxₜ₋ᵢ + Σθⱼεₜ₋ⱼ + εₜ`, conditioned on
231/// zero pre-sample history.
232///
233/// The returned vector is aligned with `values`; entries before
234/// [`warmup`](conditioning_warmup) are conditioned on history that does not
235/// exist and must not be used as residuals.
236pub(crate) fn innovations(values: &[f64], ar: &[f64], ma: &[f64]) -> Vec<f64> {
237    let mut eps = vec![0.0; values.len()];
238    for t in 0..values.len() {
239        let mut pred = 0.0;
240        for (j, &c) in ar.iter().enumerate() {
241            if t > j {
242                pred += c * values[t - j - 1];
243            }
244        }
245        for (j, &c) in ma.iter().enumerate() {
246            if t > j {
247                pred += c * eps[t - j - 1];
248            }
249        }
250        eps[t] = values[t] - pred;
251    }
252    eps
253}
254
255/// Whether the model carries a constant, by the Hyndman–Khandakar rule
256/// `d + D <= 1`.
257///
258/// The constant is the mean of the *fully differenced* series, so what it
259/// means depends on how much differencing precedes it: with no differencing
260/// it is the **level** the series reverts to, and with one it is the **drift**
261/// per step. Without it, `ARIMA(1,0,0)` on a series sitting at 100 has to
262/// explain that level with a near-unit root, and `ARIMA(0,1,0)` on a trend
263/// forecasts a flat line.
264///
265/// Above one difference a constant implies a polynomial trend that keeps
266/// accelerating, which is essentially never what is wanted from a metric —
267/// which is why `auto.arima` draws the line in the same place.
268#[inline]
269pub(crate) fn includes_constant(d: usize, seasonal_d: usize) -> bool {
270    d + seasonal_d <= 1
271}
272
273/// Number of leading innovations that are conditioned on absent history.
274#[inline]
275pub(crate) fn conditioning_warmup(ar_len: usize, ma_len: usize) -> usize {
276    ar_len.max(ma_len)
277}
278
279/// Conditional sum of squares: the CSS objective.
280fn css(values: &[f64], ar: &[f64], ma: &[f64]) -> f64 {
281    let warmup = conditioning_warmup(ar.len(), ma.len());
282    if values.len() <= warmup {
283        return f64::INFINITY;
284    }
285    let eps = innovations(values, ar, ma);
286    eps[warmup..].iter().map(|e| e * e).sum()
287}
288
289/// ψ-weights of the MA(∞) representation `xₜ = Σⱼ ψⱼ·εₜ₋ⱼ`, `ψ₀ = 1`.
290///
291/// The forecast variance at horizon `h` is `σ²·Σⱼ₌₀^{h-1} ψⱼ²`. Recursion:
292/// `ψⱼ = Σᵢ₌₁ᵖ φᵢ·ψⱼ₋ᵢ + θⱼ`, with `θⱼ = 0` for `j > q`.
293pub(crate) fn psi_weights(ar: &[f64], ma: &[f64], horizon: usize) -> Vec<f64> {
294    let mut psi = Vec::with_capacity(horizon.max(1));
295    psi.push(1.0);
296    for j in 1..horizon {
297        let mut val = 0.0;
298        for (i, &c) in ar.iter().enumerate() {
299            if j > i {
300                val += c * psi[j - i - 1];
301            }
302        }
303        if j <= ma.len() {
304            val += ma[j - 1];
305        }
306        psi.push(val);
307    }
308    psi
309}
310
311/// Extend a differenced series `horizon` steps under the ARMA recursion,
312/// returning only the new values. Future innovations are zero, which is the
313/// conditional expectation.
314fn arma_forecast(
315    values: &[f64],
316    residuals: &[f64],
317    ar: &[f64],
318    ma: &[f64],
319    horizon: usize,
320) -> Vec<f64> {
321    let n = values.len();
322    let mut extended = values.to_vec();
323    let mut eps = residuals.to_vec();
324    // Align the innovation history with the value history: a shorter residual
325    // vector (after streaming updates trimmed it) must not shift the MA lags.
326    // Both adjustments happen at the *front*: `truncate` dropped the newest
327    // innovations, which are exactly the ones the MA lags read, so an
328    // over-long buffer shifted every lag by the excess.
329    if eps.len() < n {
330        let mut padded = vec![0.0; n - eps.len()];
331        padded.extend_from_slice(&eps);
332        eps = padded;
333    } else if eps.len() > n {
334        eps.drain(..eps.len() - n);
335    }
336
337    for _ in 0..horizon {
338        let idx = extended.len();
339        let mut pred = 0.0;
340        for (j, &c) in ar.iter().enumerate() {
341            if idx > j {
342                pred += c * extended[idx - j - 1];
343            }
344        }
345        for (j, &c) in ma.iter().enumerate() {
346            if idx > j {
347                pred += c * eps[idx - j - 1];
348            }
349        }
350        extended.push(pred);
351        eps.push(0.0);
352    }
353    extended[n..].to_vec()
354}
355
356/// Residual standard deviation over the innovations that are not conditioned
357/// on absent history, with a degrees-of-freedom correction for the estimated
358/// parameters.
359fn residual_std(residuals: &[f64], warmup: usize, n_params: usize) -> f64 {
360    let usable = residuals.get(warmup..).unwrap_or(&[]);
361    if usable.len() < 2 {
362        return 0.0;
363    }
364    let mean: f64 = usable.iter().sum::<f64>() / usable.len() as f64;
365    let dof = usable.len().saturating_sub(n_params).max(1);
366    let var: f64 = usable.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / dof as f64;
367    var.sqrt()
368}
369
370/// The **full** autoregressive operator of an ARIMA/SARIMA model,
371/// `φ(B)·Φ(Bᵐ)·(1-B)ᵈ·(1-Bᵐ)ᴰ`, expanded into a single coefficient vector in
372/// the same convention as `ar` (`xₜ = Σᵢ cᵢ·xₜ₋ᵢ + …`).
373///
374/// # Why the differencing has to be in here
375///
376/// The ψ-weights of the *stationary* ARMA part describe the error of the
377/// differenced series. The forecast is issued on the original scale, and
378/// integration accumulates those errors: for `ARIMA(0,1,0)` every ψⱼ is 1, so
379/// `Var[e_h] = h·σ²` and the interval widens as `√h`. Taking ψ from the ARMA
380/// part alone gives `ψ = [1]` and a **constant-width** interval for a random
381/// walk — the textbook example of an interval that must widen.
382pub(crate) fn integrated_ar(ar: &[f64], d: usize, seasonal_d: usize, m: usize) -> Vec<f64> {
383    // φ(B) = 1 - φ₁B - … - φₚBᵖ
384    let mut poly = Vec::with_capacity(ar.len() + 1);
385    poly.push(1.0);
386    poly.extend(ar.iter().map(|c| -c));
387    for _ in 0..d {
388        poly = poly_mul(&poly, &[1.0, -1.0]);
389    }
390    if m > 0 && seasonal_d > 0 {
391        let mut sd = vec![0.0; m + 1];
392        sd[0] = 1.0;
393        sd[m] = -1.0;
394        for _ in 0..seasonal_d {
395            poly = poly_mul(&poly, &sd);
396        }
397    }
398    poly[1..].iter().map(|c| -c).collect()
399}
400
401/// Build symmetric 95 % prediction intervals from the ψ-weights of the
402/// **integrated** model — see [`integrated_ar`].
403fn prediction_intervals(
404    values: &[f64],
405    ar: &[f64],
406    ma: &[f64],
407    sigma: f64,
408    horizon: usize,
409    d: usize,
410    seasonal_d: usize,
411    m: usize,
412) -> (Vec<f64>, Vec<f64>) {
413    const Z95: f64 = 1.959_963_984_540_054;
414    let phi_star = integrated_ar(ar, d, seasonal_d, m);
415    let psi = psi_weights(&phi_star, ma, horizon);
416    let mut lower = Vec::with_capacity(horizon);
417    let mut upper = Vec::with_capacity(horizon);
418    for h in 1..=horizon {
419        let scale = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
420        let width = Z95 * sigma * scale;
421        lower.push(values[h - 1] - width);
422        upper.push(values[h - 1] + width);
423    }
424    (lower, upper)
425}
426
427/// Forecast timestamps at `last_ts + h·interval`, saturating rather than
428/// wrapping on overflow.
429fn forecast_timestamps(last_ts: i64, interval_ns: i64, horizon: usize) -> Vec<i64> {
430    (1..=horizon as i64)
431        .map(|h| last_ts.saturating_add(h.saturating_mul(interval_ns)))
432        .collect()
433}
434
435// ── ARIMA ───────────────────────────────────────────────────────────────
436
437/// ARIMA(p,d,q) — non-seasonal, estimated by Burg + conditional sum of
438/// squares.
439#[derive(serde::Serialize, serde::Deserialize)]
440pub struct ArimaModel {
441    p: usize,
442    d: usize,
443    q: usize,
444    params: ModelParams,
445    history: Vec<f64>,
446    residuals: Vec<f64>,
447    /// Mean of the differenced series; the level for `d = 0`, the drift for
448    /// `d = 1`, and zero above that.
449    constant: f64,
450    /// Leading innovations conditioned on absent history; excluded from every
451    /// residual statistic.
452    warmup: usize,
453    last_ts: i64,
454    interval_ns: i64,
455    fitted: bool,
456}
457
458impl ArimaModel {
459    /// Creates a new ARIMA model with the given orders.
460    #[must_use]
461    pub fn new(p: usize, d: usize, q: usize) -> Self {
462        Self {
463            p,
464            d,
465            q,
466            params: ModelParams::Arima {
467                p,
468                d,
469                q,
470                ar_coeffs: Vec::new(),
471                ma_coeffs: Vec::new(),
472                constant: 0.0,
473                residual_std: 0.0,
474                effective_ar_order: None,
475            },
476            history: Vec::new(),
477            residuals: Vec::new(),
478            constant: 0.0,
479            warmup: 0,
480            last_ts: 0,
481            interval_ns: 0,
482            fitted: false,
483        }
484    }
485
486    /// Estimate MA coefficients by conditional sum of squares, with the AR
487    /// part held at its Burg estimate.
488    ///
489    /// Brent's method for `q == 1`, Nelder–Mead above it. The objective skips
490    /// the conditioning warm-up, so the first observations — which for `d = 0`
491    /// carry the *level* of the series rather than its noise — do not steer
492    /// the optimiser.
493    fn estimate_ma(values: &[f64], ar: &[f64], q: usize) -> Vec<f64> {
494        if q == 0 {
495            return Vec::new();
496        }
497        let objective = |theta: &[f64]| css(values, ar, theta);
498        if q == 1 {
499            let opt = crate::forecast::optimizer::minimize_brent(
500                |x| objective(&[x]),
501                -0.99,
502                0.99,
503                1e-8,
504                200,
505            );
506            vec![opt.params[0]]
507        } else {
508            let bounds: Vec<crate::forecast::optimizer::Bound> = (0..q)
509                .map(|_| crate::forecast::optimizer::Bound::new(-0.99, 0.99))
510                .collect();
511            let x0 = vec![0.0; q];
512            crate::forecast::optimizer::minimize_nelder_mead(objective, &x0, &bounds, 500, 1e-8)
513                .params
514        }
515    }
516
517    /// Free parameters, counting the innovation variance and the constant if
518    /// the model carries one. This is the `k` of every information criterion
519    /// below; counting a constant that is not there is a quiet way to make
520    /// two model families incomparable.
521    fn n_params(&self) -> usize {
522        self.p + self.q + usize::from(includes_constant(self.d, 0)) + 1
523    }
524
525    /// Residual sum of squares over the innovations that are not conditioned
526    /// on absent history, and how many there are.
527    fn usable_rss(&self) -> Option<(f64, usize)> {
528        let warmup = self.warmup.min(self.residuals.len());
529        let usable = &self.residuals[warmup..];
530        if usable.is_empty() {
531            return None;
532        }
533        Some((usable.iter().map(|r| r * r).sum(), usable.len()))
534    }
535
536    /// Akaike Information Criterion, `n·ln(RSS/n) + 2k`.
537    ///
538    /// `n` counts only the innovations the conditional likelihood is defined
539    /// over, which is what makes the value comparable between two models of
540    /// the same series at the same differencing order. It is *not* comparable
541    /// across differencing orders — a differenced series is a different
542    /// series — which is why [`auto_arima`] chooses `d` by a unit-root test
543    /// rather than by this number.
544    #[must_use]
545    pub fn aic(&self) -> Option<f64> {
546        if !self.fitted {
547            return None;
548        }
549        let (rss, n) = self.usable_rss()?;
550        crate::forecast::diagnostics::aic(rss, n, self.n_params())
551    }
552
553    /// Bayesian Information Criterion, `n·ln(RSS/n) + k·ln(n)`.
554    #[must_use]
555    pub fn bic(&self) -> Option<f64> {
556        if !self.fitted {
557            return None;
558        }
559        let (rss, n) = self.usable_rss()?;
560        crate::forecast::diagnostics::bic(rss, n, self.n_params())
561    }
562
563    /// Corrected Akaike Information Criterion (AICc).
564    #[must_use]
565    pub fn aicc(&self) -> Option<f64> {
566        if !self.fitted {
567            return None;
568        }
569        let (rss, n) = self.usable_rss()?;
570        crate::forecast::diagnostics::aicc(rss, n, self.n_params())
571    }
572
573    /// Comprehensive model diagnostics.
574    ///
575    /// Requires both the original training values and the in-sample
576    /// predictions (fitted values) for the model.
577    #[must_use]
578    pub fn diagnostics(
579        &self,
580        actual: &[f64],
581        predicted: &[f64],
582    ) -> Option<crate::forecast::diagnostics::ModelDiagnostics> {
583        if !self.fitted {
584            return None;
585        }
586        crate::forecast::diagnostics::compute_diagnostics(actual, predicted, self.n_params(), None)
587    }
588}
589
590impl ForecastModel for ArimaModel {
591    #[tracing::instrument(skip_all, level = "debug")]
592    fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
593        let _start = std::time::Instant::now();
594        validate_input(timestamps, values)?;
595        let min_required = self.p.max(self.q) + 2 * self.d + 10;
596        if values.len() < min_required {
597            return Err(ForecastError::InsufficientData {
598                min: min_required,
599                got: values.len(),
600            });
601        }
602
603        self.history = values.to_vec();
604        let diffed = difference(values, self.d);
605        if diffed.is_empty() {
606            return Err(ForecastError::InsufficientData {
607                min: min_required,
608                got: values.len(),
609            });
610        }
611        self.constant = if includes_constant(self.d, 0) {
612            diffed.iter().sum::<f64>() / diffed.len() as f64
613        } else {
614            0.0
615        };
616        let centered: Vec<f64> = diffed.iter().map(|v| v - self.constant).collect();
617
618        let (ar_coeffs, effective_ar_order) = estimate_ar_burg(&centered, self.p)?;
619        let ma_coeffs = Self::estimate_ma(&centered, &ar_coeffs, self.q);
620        let residuals = innovations(&centered, &ar_coeffs, &ma_coeffs);
621
622        // Emit the effective order so a dashboard can see silent degradation.
623        metrics::gauge!("chronix_forecast_arima_effective_order",
624            "requested" => format!("{}", self.p))
625        .set(effective_ar_order as f64);
626
627        self.warmup = conditioning_warmup(ar_coeffs.len(), ma_coeffs.len());
628        let sigma = residual_std(&residuals, self.warmup, self.n_params());
629
630        self.residuals = residuals;
631        self.interval_ns = median_interval(timestamps);
632        self.last_ts = timestamps[timestamps.len() - 1];
633
634        self.params = ModelParams::Arima {
635            p: self.p,
636            d: self.d,
637            q: self.q,
638            ar_coeffs,
639            ma_coeffs,
640            constant: self.constant,
641            residual_std: sigma,
642            effective_ar_order: (effective_ar_order < self.p).then_some(effective_ar_order),
643        };
644        self.fitted = true;
645        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "arima")
646            .record(_start.elapsed().as_secs_f64());
647        Ok(())
648    }
649
650    #[tracing::instrument(skip_all, level = "debug")]
651    fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
652        let _start = std::time::Instant::now();
653        if !self.fitted {
654            return Err(ForecastError::NotFitted);
655        }
656        let ModelParams::Arima {
657            ar_coeffs,
658            ma_coeffs,
659            constant,
660            residual_std: sigma,
661            ..
662        } = &self.params
663        else {
664            return Err(ForecastError::InvalidInput(
665                "unexpected model params variant".into(),
666            ));
667        };
668
669        let diffed = difference(&self.history, self.d);
670        let centered: Vec<f64> = diffed.iter().map(|v| v - constant).collect();
671        let diff_preds: Vec<f64> =
672            arma_forecast(&centered, &self.residuals, ar_coeffs, ma_coeffs, horizon)
673                .into_iter()
674                .map(|v| v + constant)
675                .collect();
676
677        // Undifference outward: anchor `i` is the last value of the series
678        // differenced `i` times, so index 0 is the original scale.
679        let mut anchors = Vec::with_capacity(self.d);
680        let mut level = self.history.clone();
681        for _ in 0..self.d {
682            anchors.push(
683                *level
684                    .last()
685                    .ok_or(ForecastError::InsufficientData { min: 1, got: 0 })?,
686            );
687            level = difference(&level, 1);
688        }
689        let mut values = diff_preds;
690        for anchor in anchors.into_iter().rev() {
691            values = undifference_lag(&values, &[anchor], 1);
692        }
693
694        let (lower, upper) =
695            prediction_intervals(&values, ar_coeffs, ma_coeffs, *sigma, horizon, self.d, 0, 0);
696
697        metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "arima")
698            .record(_start.elapsed().as_secs_f64());
699        Ok(ForecastResult {
700            values,
701            timestamps: forecast_timestamps(self.last_ts, self.interval_ns, horizon),
702            confidence_lower: lower,
703            confidence_upper: upper,
704            confidence_level: 0.95,
705        })
706    }
707
708    fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
709        if !self.fitted {
710            return Err(ForecastError::NotFitted);
711        }
712        self.history.push(value);
713        self.last_ts = timestamp;
714
715        let ModelParams::Arima {
716            ar_coeffs,
717            ma_coeffs,
718            constant,
719            ..
720        } = &self.params
721        else {
722            return Err(ForecastError::InvalidInput(
723                "unexpected model params variant".into(),
724            ));
725        };
726        let diffed: Vec<f64> = difference(&self.history, self.d)
727            .into_iter()
728            .map(|v| v - constant)
729            .collect();
730        let Some(idx) = diffed.len().checked_sub(1) else {
731            return Ok(());
732        };
733        let mut pred = 0.0;
734        for (j, &c) in ar_coeffs.iter().enumerate() {
735            if idx > j {
736                pred += c * diffed[idx - j - 1];
737            }
738        }
739        for (j, &c) in ma_coeffs.iter().enumerate() {
740            if let Some(e) = idx.checked_sub(j + 1).and_then(|k| self.residuals.get(k)) {
741                pred += c * e;
742            }
743        }
744        self.residuals.push(diffed[idx] - pred);
745
746        // Bound the buffers so a long-running stream does not grow without
747        // limit, keeping enough context for the model orders.
748        //
749        // `residuals` is aligned one-for-one with `difference(history, d)`,
750        // which is the only reason `predict` may read them at the same index.
751        // Trimming the two to *independent* caps broke that alignment by `d`
752        // after the first trim, silently shifting every MA lag from then on.
753        // One cap, and the residual buffer follows the history buffer.
754        let max_history = (self.p + self.d + self.q + 1).max(64) * 4;
755        if self.history.len() > max_history {
756            self.history.drain(..self.history.len() - max_history);
757        }
758        let want_residuals = self.history.len().saturating_sub(self.d);
759        if self.residuals.len() > want_residuals {
760            self.residuals
761                .drain(..self.residuals.len() - want_residuals);
762        }
763        Ok(())
764    }
765
766    fn model_type(&self) -> ModelType {
767        ModelType::Arima
768    }
769
770    fn params(&self) -> &ModelParams {
771        &self.params
772    }
773}
774
775// ── SARIMA ──────────────────────────────────────────────────────────────
776
777/// Configuration for constructing a [`SarimaModel`].
778///
779/// A config struct rather than a seven-parameter constructor: `(1, 1, 1, 0,
780/// 1, 1, 24)` is unreadable at a call site, and swapping two of those numbers
781/// changes the model silently.
782#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783pub struct SarimaConfig {
784    /// Non-seasonal autoregressive order `p`.
785    pub p: usize,
786    /// Non-seasonal differencing order `d`.
787    pub d: usize,
788    /// Non-seasonal moving-average order `q`.
789    pub q: usize,
790    /// Seasonal autoregressive order `P`.
791    pub sp: usize,
792    /// Seasonal differencing order `D`.
793    pub sd: usize,
794    /// Seasonal moving-average order `Q`.
795    pub sq: usize,
796    /// Seasonal period `m` (e.g. 24 for hourly data with daily seasonality).
797    pub m: usize,
798}
799
800/// SARIMA(p,d,q)(P,D,Q)m.
801///
802/// The multiplicative form is expanded into a single AR polynomial of degree
803/// `p + P·m` and a single MA polynomial of degree `q + Q·m`, so the seasonal
804/// orders participate in the forecast recursion rather than only in the
805/// stored parameters. All four coefficient blocks are estimated jointly by
806/// conditional sum of squares, seeded from a Burg fit of the non-seasonal AR
807/// part — the seasonal and non-seasonal AR factors multiply, so estimating
808/// one while pretending the other is absent biases both.
809#[derive(serde::Serialize, serde::Deserialize)]
810pub struct SarimaModel {
811    cfg_p: usize,
812    cfg_d: usize,
813    cfg_q: usize,
814    sp: usize,
815    sd: usize,
816    sq: usize,
817    m: usize,
818    params: ModelParams,
819    /// Original-scale training values.
820    history: Vec<f64>,
821    /// Innovations aligned with the fully differenced series.
822    residuals: Vec<f64>,
823    /// Expanded AR polynomial `φ*` of degree `p + P·m`.
824    ar_expanded: Vec<f64>,
825    /// Expanded MA polynomial `θ*` of degree `q + Q·m`.
826    ma_expanded: Vec<f64>,
827    /// Mean of the fully differenced series; see [`includes_constant`].
828    constant: f64,
829    warmup: usize,
830    last_ts: i64,
831    interval_ns: i64,
832    fitted: bool,
833}
834
835impl SarimaModel {
836    /// Creates a new SARIMA model from a [`SarimaConfig`].
837    #[must_use]
838    pub fn from_config(cfg: SarimaConfig) -> Self {
839        Self::new(cfg.p, cfg.d, cfg.q, cfg.sp, cfg.sd, cfg.sq, cfg.m)
840    }
841
842    /// Creates a new SARIMA model with explicit order parameters.
843    ///
844    /// A period `m` of 0 or 1 leaves no seasonal structure to model; the
845    /// seasonal orders are then ignored and the model degenerates to
846    /// ARIMA(p,d,q), which is what those periods mean.
847    #[must_use]
848    pub fn new(p: usize, d: usize, q: usize, sp: usize, sd: usize, sq: usize, m: usize) -> Self {
849        let seasonal = m > 1;
850        let (sp, sd, sq) = if seasonal { (sp, sd, sq) } else { (0, 0, 0) };
851        Self {
852            cfg_p: p,
853            cfg_d: d,
854            cfg_q: q,
855            sp,
856            sd,
857            sq,
858            m,
859            params: ModelParams::Sarima {
860                p,
861                d,
862                q,
863                sp,
864                sd,
865                sq,
866                m,
867                ar_coeffs: Vec::new(),
868                ma_coeffs: Vec::new(),
869                sar_coeffs: Vec::new(),
870                sma_coeffs: Vec::new(),
871                constant: 0.0,
872                residual_std: 0.0,
873            },
874            history: Vec::new(),
875            residuals: Vec::new(),
876            ar_expanded: Vec::new(),
877            ma_expanded: Vec::new(),
878            constant: 0.0,
879            warmup: 0,
880            last_ts: 0,
881            interval_ns: 0,
882            fitted: false,
883        }
884    }
885
886    /// Fully difference the series: seasonally `D` times at lag `m`, then
887    /// regularly `d` times.
888    fn transform(&self, values: &[f64]) -> Vec<f64> {
889        difference(&seasonal_difference(values, self.m, self.sd), self.cfg_d)
890    }
891
892    /// The fully differenced series with the constant removed — the scale the
893    /// ARMA recursion is defined on.
894    fn centered(&self, values: &[f64]) -> Vec<f64> {
895        self.transform(values)
896            .into_iter()
897            .map(|v| v - self.constant)
898            .collect()
899    }
900
901    /// Free parameters, counting the innovation variance and the constant.
902    fn n_free(&self) -> usize {
903        self.cfg_p
904            + self.sp
905            + self.cfg_q
906            + self.sq
907            + usize::from(includes_constant(self.cfg_d, self.sd))
908            + 1
909    }
910
911    /// The expanded polynomials implied by a packed parameter vector
912    /// `[φ₁…φ_p, Φ₁…Φ_P, θ₁…θ_q, Θ₁…Θ_Q]`.
913    fn expand(&self, x: &[f64]) -> (Vec<f64>, Vec<f64>) {
914        let (p, sp, q) = (self.cfg_p, self.sp, self.cfg_q);
915        let ar = expand_ar(&x[..p], &x[p..p + sp], self.m);
916        let ma = expand_ma(&x[p + sp..p + sp + q], &x[p + sp + q..], self.m);
917        (ar, ma)
918    }
919}
920
921impl ForecastModel for SarimaModel {
922    #[tracing::instrument(skip_all, level = "debug")]
923    fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
924        let _start = std::time::Instant::now();
925        validate_input(timestamps, values)?;
926
927        // The expanded polynomials reach back `p + P·m` steps, and each round
928        // of differencing costs observations up front. Ten usable innovations
929        // is the floor below which the CSS objective is noise.
930        let reach = (self.cfg_p + self.sp * self.m).max(self.cfg_q + self.sq * self.m);
931        let min_required = reach + 2 * self.cfg_d + 2 * self.sd * self.m + 10;
932        if values.len() < min_required {
933            return Err(ForecastError::InsufficientData {
934                min: min_required,
935                got: values.len(),
936            });
937        }
938
939        self.history = values.to_vec();
940        let differenced = self.transform(values);
941        if differenced.is_empty() {
942            return Err(ForecastError::InsufficientData {
943                min: min_required,
944                got: values.len(),
945            });
946        }
947        self.constant = if includes_constant(self.cfg_d, self.sd) {
948            differenced.iter().sum::<f64>() / differenced.len() as f64
949        } else {
950            0.0
951        };
952        let w: Vec<f64> = differenced.iter().map(|v| v - self.constant).collect();
953
954        let n_params = self.cfg_p + self.sp + self.cfg_q + self.sq;
955        let mut x0 = vec![0.0; n_params];
956        if self.cfg_p > 0 {
957            let (phi, _) = estimate_ar_burg(&w, self.cfg_p)?;
958            x0[..self.cfg_p].copy_from_slice(&phi);
959        }
960
961        let best = if n_params == 0 {
962            x0
963        } else {
964            let objective = |x: &[f64]| {
965                let (ar, ma) = self.expand(x);
966                css(&w, &ar, &ma)
967            };
968            if n_params == 1 {
969                let opt = crate::forecast::optimizer::minimize_brent(
970                    |v| objective(&[v]),
971                    -0.99,
972                    0.99,
973                    1e-8,
974                    200,
975                );
976                vec![opt.params[0]]
977            } else {
978                let bounds: Vec<crate::forecast::optimizer::Bound> = (0..n_params)
979                    .map(|_| crate::forecast::optimizer::Bound::new(-0.99, 0.99))
980                    .collect();
981                crate::forecast::optimizer::minimize_nelder_mead(
982                    objective, &x0, &bounds, 1000, 1e-9,
983                )
984                .params
985            }
986        };
987
988        let (ar_expanded, ma_expanded) = self.expand(&best);
989        let residuals = innovations(&w, &ar_expanded, &ma_expanded);
990        self.warmup = conditioning_warmup(ar_expanded.len(), ma_expanded.len());
991        let sigma = residual_std(&residuals, self.warmup, self.n_free());
992
993        let (p, sp, q) = (self.cfg_p, self.sp, self.cfg_q);
994        self.params = ModelParams::Sarima {
995            p,
996            d: self.cfg_d,
997            q,
998            sp,
999            sd: self.sd,
1000            sq: self.sq,
1001            m: self.m,
1002            ar_coeffs: best[..p].to_vec(),
1003            ma_coeffs: best[p + sp..p + sp + q].to_vec(),
1004            sar_coeffs: best[p..p + sp].to_vec(),
1005            sma_coeffs: best[p + sp + q..].to_vec(),
1006            constant: self.constant,
1007            residual_std: sigma,
1008        };
1009        self.ar_expanded = ar_expanded;
1010        self.ma_expanded = ma_expanded;
1011        self.residuals = residuals;
1012        self.interval_ns = median_interval(timestamps);
1013        self.last_ts = timestamps[timestamps.len() - 1];
1014        self.fitted = true;
1015
1016        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "sarima")
1017            .record(_start.elapsed().as_secs_f64());
1018        Ok(())
1019    }
1020
1021    #[tracing::instrument(skip_all, level = "debug")]
1022    fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
1023        let _start = std::time::Instant::now();
1024        if !self.fitted {
1025            return Err(ForecastError::NotFitted);
1026        }
1027        let ModelParams::Sarima {
1028            residual_std: sigma,
1029            ..
1030        } = &self.params
1031        else {
1032            return Err(ForecastError::InvalidInput(
1033                "unexpected model params variant".into(),
1034            ));
1035        };
1036
1037        let w = self.centered(&self.history);
1038        let mut values: Vec<f64> = arma_forecast(
1039            &w,
1040            &self.residuals,
1041            &self.ar_expanded,
1042            &self.ma_expanded,
1043            horizon,
1044        )
1045        .into_iter()
1046        .map(|v| v + self.constant)
1047        .collect();
1048
1049        // Undifference in the reverse of the order differencing was applied:
1050        // regular first (innermost), then seasonal. Each level's anchor is the
1051        // tail of the series differenced one step less than the predictions.
1052        let seasonal_base = seasonal_difference(&self.history, self.m, self.sd);
1053        let mut regular_levels = Vec::with_capacity(self.cfg_d);
1054        let mut level = seasonal_base.clone();
1055        for _ in 0..self.cfg_d {
1056            regular_levels.push(level.clone());
1057            level = difference(&level, 1);
1058        }
1059        for hist in regular_levels.into_iter().rev() {
1060            let anchor = *hist
1061                .last()
1062                .ok_or(ForecastError::InsufficientData { min: 1, got: 0 })?;
1063            values = undifference_lag(&values, &[anchor], 1);
1064        }
1065
1066        let mut seasonal_levels = Vec::with_capacity(self.sd);
1067        let mut level = self.history.clone();
1068        for _ in 0..self.sd {
1069            seasonal_levels.push(level.clone());
1070            level = seasonal_difference(&level, self.m, 1);
1071        }
1072        for hist in seasonal_levels.into_iter().rev() {
1073            if hist.len() < self.m {
1074                return Err(ForecastError::InsufficientData {
1075                    min: self.m,
1076                    got: hist.len(),
1077                });
1078            }
1079            let anchor = &hist[hist.len() - self.m..];
1080            values = undifference_lag(&values, anchor, self.m);
1081        }
1082
1083        // The interval is computed from the ψ-weights of the *integrated*
1084        // operator: differencing has unit lead coefficient, so the one-step
1085        // error is the same on both scales, but beyond one step integration
1086        // accumulates the earlier errors and the interval on the original
1087        // scale is strictly wider than the one on the differenced scale.
1088        let (lower, upper) = prediction_intervals(
1089            &values,
1090            &self.ar_expanded,
1091            &self.ma_expanded,
1092            *sigma,
1093            horizon,
1094            self.cfg_d,
1095            self.sd,
1096            self.m,
1097        );
1098
1099        metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "sarima")
1100            .record(_start.elapsed().as_secs_f64());
1101        Ok(ForecastResult {
1102            values,
1103            timestamps: forecast_timestamps(self.last_ts, self.interval_ns, horizon),
1104            confidence_lower: lower,
1105            confidence_upper: upper,
1106            confidence_level: 0.95,
1107        })
1108    }
1109
1110    fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
1111        if !self.fitted {
1112            return Err(ForecastError::NotFitted);
1113        }
1114        self.history.push(value);
1115        self.last_ts = timestamp;
1116
1117        let w = self.centered(&self.history);
1118        let Some(idx) = w.len().checked_sub(1) else {
1119            return Ok(());
1120        };
1121        let mut pred = 0.0;
1122        for (j, &c) in self.ar_expanded.iter().enumerate() {
1123            if idx > j {
1124                pred += c * w[idx - j - 1];
1125            }
1126        }
1127        for (j, &c) in self.ma_expanded.iter().enumerate() {
1128            if let Some(e) = idx.checked_sub(j + 1).and_then(|k| self.residuals.get(k)) {
1129                pred += c * e;
1130            }
1131        }
1132        self.residuals.push(w[idx] - pred);
1133
1134        // Keep enough original-scale history to re-derive the differenced
1135        // series the recursion reads, plus a margin — and keep `residuals`
1136        // aligned one-for-one with that differenced series, which is the
1137        // invariant `predict` depends on. See `ArimaModel::update`.
1138        let reach = self.ar_expanded.len().max(self.ma_expanded.len());
1139        let max_history = (reach + self.cfg_d + self.sd * self.m + 1).max(64) * 4;
1140        if self.history.len() > max_history {
1141            self.history.drain(..self.history.len() - max_history);
1142        }
1143        let want_residuals = self.centered(&self.history).len();
1144        if self.residuals.len() > want_residuals {
1145            self.residuals
1146                .drain(..self.residuals.len() - want_residuals);
1147        }
1148        Ok(())
1149    }
1150
1151    fn model_type(&self) -> ModelType {
1152        ModelType::Sarima
1153    }
1154
1155    fn params(&self) -> &ModelParams {
1156        &self.params
1157    }
1158}
1159
1160// ── Unit-root testing: how many times to difference ─────────────────────
1161
1162/// Critical value of the KPSS level-stationarity statistic at 5 %.
1163///
1164/// Kwiatkowski, Phillips, Schmidt & Shin (1992), Table 1, level-stationary
1165/// case: 10 % → 0.347, **5 % → 0.463**, 2.5 % → 0.574, 1 % → 0.739.
1166pub const KPSS_CRITICAL_5PCT: f64 = 0.463;
1167
1168/// KPSS test statistic for level stationarity.
1169///
1170/// The null hypothesis is that the series is stationary around a constant, so
1171/// a statistic **above** the critical value is evidence that it is not and
1172/// that a difference is called for. That direction is the opposite of an
1173/// augmented Dickey–Fuller test and is the usual way this test is misread.
1174///
1175/// The long-run variance uses a Bartlett kernel with the Schwert short
1176/// truncation lag `⌊4·(n/100)^¼⌋`, which is the default in both R's
1177/// `tseries::kpss.test` and `urca::ur.kpss`.
1178///
1179/// Returns 0 for a series with fewer than three observations or no variance,
1180/// both of which are stationary by any reading.
1181#[must_use]
1182pub fn kpss_statistic(values: &[f64]) -> f64 {
1183    let n = values.len();
1184    if n < 3 {
1185        return 0.0;
1186    }
1187    let n_f = n as f64;
1188    let mean = values.iter().sum::<f64>() / n_f;
1189
1190    // Partial sums of the demeaned series.
1191    let mut partial = 0.0;
1192    let mut sum_sq_partial = 0.0;
1193    let mut gamma0 = 0.0;
1194    let resid: Vec<f64> = values.iter().map(|v| v - mean).collect();
1195    for &e in &resid {
1196        partial += e;
1197        sum_sq_partial += partial * partial;
1198        gamma0 += e * e;
1199    }
1200    gamma0 /= n_f;
1201    if gamma0 <= 0.0 {
1202        return 0.0;
1203    }
1204
1205    // Bartlett-weighted long-run variance.
1206    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1207    let lag = (4.0 * (n_f / 100.0).powf(0.25)).floor() as usize;
1208    let mut long_run = gamma0;
1209    for j in 1..=lag.min(n - 1) {
1210        let gamma_j: f64 = resid[j..]
1211            .iter()
1212            .zip(&resid[..n - j])
1213            .map(|(a, b)| a * b)
1214            .sum::<f64>()
1215            / n_f;
1216        long_run += 2.0 * (1.0 - j as f64 / (lag + 1) as f64) * gamma_j;
1217    }
1218    if long_run <= 0.0 {
1219        return 0.0;
1220    }
1221
1222    sum_sq_partial / (n_f * n_f * long_run)
1223}
1224
1225/// Choose the differencing order `d` by successive KPSS tests.
1226///
1227/// This is the Hyndman–Khandakar rule used by R's `auto.arima`: difference
1228/// while the series still rejects level stationarity, up to `max_d`.
1229///
1230/// It exists because **AIC cannot choose `d`**. Differencing changes the
1231/// series the likelihood is computed over — a different number of
1232/// observations, on a different scale — so the value is not comparable across
1233/// orders, and comparing it anyway systematically over-differences: on
1234/// stationary noise the `d = 1` fits win every time, and the resulting model
1235/// forecasts a random walk.
1236#[must_use]
1237pub fn select_differencing_order(values: &[f64], max_d: usize) -> usize {
1238    let mut current = values.to_vec();
1239    let mut d = 0;
1240    while d < max_d {
1241        if current.len() < 4 || kpss_statistic(&current) <= KPSS_CRITICAL_5PCT {
1242            break;
1243        }
1244        current = difference(&current, 1);
1245        d += 1;
1246    }
1247    d
1248}
1249
1250// ── auto_arima ──────────────────────────────────────────────────────────
1251
1252/// Result of automatic ARIMA model selection.
1253#[derive(Debug, Clone)]
1254pub struct AutoArimaResult {
1255    /// Best `(p, d, q)` order.
1256    pub order: (usize, usize, usize),
1257    /// AICc score of the best model.
1258    pub aic: f64,
1259    /// Every evaluated candidate, sorted by score ascending.
1260    pub candidates: Vec<(usize, usize, usize, f64)>,
1261    /// Number of model fits performed.
1262    pub evals: usize,
1263    /// Differencing order chosen by the unit-root test, before `max_d` was
1264    /// applied. Exposed so a caller can see that the cap bound the answer.
1265    pub kpss_order: usize,
1266}
1267
1268/// Search strategy for [`auto_arima`].
1269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1270pub enum SearchStrategy {
1271    /// Exhaustive grid over `p ∈ [0, max_p]`, `q ∈ [0, max_q]`.
1272    #[default]
1273    Exhaustive,
1274    /// Hyndman–Khandakar stepwise search — O(P+Q) fits instead of O(P×Q), at
1275    /// the risk of a local optimum.
1276    Stepwise,
1277}
1278
1279/// Budget controls for [`auto_arima`].
1280#[derive(Debug, Clone, Copy, Default)]
1281pub struct AutoArimaOptions {
1282    /// Hard cap on the number of model fits. `None` means unlimited.
1283    pub max_evals: Option<usize>,
1284    /// Search strategy.
1285    pub strategy: SearchStrategy,
1286}
1287
1288/// Automatically select an ARIMA(p,d,q) order.
1289///
1290/// # How the order is chosen
1291///
1292/// `d` comes from a **KPSS unit-root test**
1293/// ([`select_differencing_order`]), capped at `max_d`; `p` and `q` are then
1294/// chosen by **AICc** at that fixed `d`. Splitting the decision this way is
1295/// not a refinement — it is what makes the information criterion meaningful,
1296/// because AIC/AICc compare models of the *same* data and two differencing
1297/// orders are two different datasets.
1298///
1299/// AICc rather than AIC: the correction matters at exactly the series lengths
1300/// a gateway has, and it converges to AIC as `n` grows.
1301///
1302/// # Strategies
1303///
1304/// - [`SearchStrategy::Exhaustive`] (default) fits every `(p, q)` in the grid
1305///   in parallel and returns the global AICc minimum within it. With `d`
1306///   fixed the grid is `(max_p+1)·(max_q+1)`, which is small enough that this
1307///   is usually the right choice.
1308/// - [`SearchStrategy::Stepwise`] follows Hyndman–Khandakar: seed with
1309///   `(2,q=2)`, `(0,0)`, `(1,0)`, `(0,1)`, then walk to improving neighbours.
1310///
1311/// Use [`AutoArimaOptions::max_evals`] to cap the cost of either.
1312///
1313/// # Errors
1314///
1315/// [`ForecastError::InsufficientData`] below 30 observations, and
1316/// [`ForecastError::NumericalInstability`] when no candidate fits.
1317pub fn auto_arima(
1318    timestamps: &[i64],
1319    values: &[f64],
1320    max_p: usize,
1321    max_d: usize,
1322    max_q: usize,
1323    options: Option<&AutoArimaOptions>,
1324) -> Result<AutoArimaResult, ForecastError> {
1325    validate_input(timestamps, values)?;
1326    if values.len() < 30 {
1327        return Err(ForecastError::InsufficientData {
1328            min: 30,
1329            got: values.len(),
1330        });
1331    }
1332
1333    let kpss_order = select_differencing_order(values, max_d.max(2));
1334    let d = kpss_order.min(max_d);
1335    let opts = options.copied().unwrap_or_default();
1336    let max_evals = opts.max_evals.unwrap_or(usize::MAX);
1337
1338    let score = |p: usize, q: usize| -> Option<f64> {
1339        let min_required = p.max(q) + 2 * d + 10;
1340        if values.len() < min_required {
1341            return None;
1342        }
1343        let mut model = ArimaModel::new(p, d, q);
1344        model.fit(timestamps, values).ok()?;
1345        model.aicc().filter(|a| a.is_finite())
1346    };
1347
1348    let (order, best, mut candidates, evals) = match opts.strategy {
1349        SearchStrategy::Exhaustive => {
1350            use rayon::prelude::*;
1351            let grid: Vec<(usize, usize)> = (0..=max_p)
1352                .flat_map(|p| (0..=max_q).map(move |q| (p, q)))
1353                .filter(|&(p, q)| p + q > 0)
1354                .take(max_evals)
1355                .collect();
1356            let found: Vec<(usize, usize, usize, f64)> = grid
1357                .par_iter()
1358                .filter_map(|&(p, q)| score(p, q).map(|a| (p, d, q, a)))
1359                .collect();
1360            let evals = found.len();
1361            let best = found
1362                .iter()
1363                .copied()
1364                .min_by(|a, b| a.3.total_cmp(&b.3))
1365                .ok_or_else(|| {
1366                    ForecastError::NumericalInstability(
1367                        "no ARIMA model in the grid could be fitted".into(),
1368                    )
1369                })?;
1370            ((best.0, best.1, best.2), best.3, found, evals)
1371        }
1372        SearchStrategy::Stepwise => stepwise(max_p, max_q, d, max_evals, &score)?,
1373    };
1374
1375    candidates.sort_by(|a, b| a.3.total_cmp(&b.3));
1376    Ok(AutoArimaResult {
1377        order,
1378        aic: best,
1379        candidates,
1380        evals,
1381        kpss_order,
1382    })
1383}
1384
1385/// Hyndman–Khandakar stepwise walk over `(p, q)` at a fixed `d`.
1386#[allow(clippy::type_complexity)]
1387fn stepwise(
1388    max_p: usize,
1389    max_q: usize,
1390    d: usize,
1391    max_evals: usize,
1392    score: &dyn Fn(usize, usize) -> Option<f64>,
1393) -> Result<
1394    (
1395        (usize, usize, usize),
1396        f64,
1397        Vec<(usize, usize, usize, f64)>,
1398        usize,
1399    ),
1400    ForecastError,
1401> {
1402    let mut candidates: Vec<(usize, usize, usize, f64)> = Vec::new();
1403    let mut visited: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
1404    let mut evals = 0usize;
1405
1406    let mut best_score = f64::INFINITY;
1407    let mut best = (0usize, 0usize);
1408
1409    let mut try_model = |p: usize,
1410                         q: usize,
1411                         candidates: &mut Vec<(usize, usize, usize, f64)>,
1412                         evals: &mut usize|
1413     -> Option<f64> {
1414        if p > max_p || q > max_q || (p == 0 && q == 0) {
1415            return None;
1416        }
1417        if !visited.insert((p, q)) {
1418            return candidates
1419                .iter()
1420                .find(|c| c.0 == p && c.2 == q)
1421                .map(|c| c.3);
1422        }
1423        if *evals >= max_evals {
1424            return None;
1425        }
1426        let s = score(p, q)?;
1427        *evals += 1;
1428        candidates.push((p, d, q, s));
1429        Some(s)
1430    };
1431
1432    for &(p, q) in &[(2, 2), (0, 0), (1, 0), (0, 1)] {
1433        let (p, q) = (p.min(max_p), q.min(max_q));
1434        if let Some(s) = try_model(p, q, &mut candidates, &mut evals) {
1435            if s < best_score {
1436                best_score = s;
1437                best = (p, q);
1438            }
1439        }
1440    }
1441    if best_score.is_infinite() {
1442        return Err(ForecastError::NumericalInstability(
1443            "no ARIMA model in the stepwise seed set could be fitted".into(),
1444        ));
1445    }
1446
1447    const DELTAS: [(i8, i8); 8] = [
1448        (1, 0),
1449        (-1, 0),
1450        (0, 1),
1451        (0, -1),
1452        (1, -1),
1453        (-1, 1),
1454        (1, 1),
1455        (-1, -1),
1456    ];
1457    loop {
1458        let (cp, cq) = best;
1459        let mut improved = false;
1460        for (dp, dq) in DELTAS {
1461            let (Ok(np), Ok(nq)) = (
1462                usize::try_from(cp as i64 + i64::from(dp)),
1463                usize::try_from(cq as i64 + i64::from(dq)),
1464            ) else {
1465                continue;
1466            };
1467            if let Some(s) = try_model(np, nq, &mut candidates, &mut evals) {
1468                if s < best_score {
1469                    best_score = s;
1470                    best = (np, nq);
1471                    improved = true;
1472                }
1473            }
1474        }
1475        if !improved || evals >= max_evals {
1476            break;
1477        }
1478    }
1479
1480    Ok(((best.0, d, best.1), best_score, candidates, evals))
1481}
1482
1483impl crate::forecast::storage::ModelStore for ArimaModel {}
1484impl crate::forecast::storage::ModelStore for SarimaModel {}
1485
1486#[cfg(test)]
1487#[allow(clippy::unwrap_used, clippy::expect_used)]
1488mod tests {
1489    use super::*;
1490
1491    /// Deterministic uniform noise on `[-amp, amp]`, so every test in this
1492    /// module is reproducible without a RNG dependency.
1493    fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
1494        let mut s = seed;
1495        (0..n)
1496            .map(|_| {
1497                s = s
1498                    .wrapping_mul(6_364_136_223_846_793_005)
1499                    .wrapping_add(1_442_695_040_888_963_407);
1500                ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
1501            })
1502            .collect()
1503    }
1504
1505    fn ts(n: usize) -> Vec<i64> {
1506        (0..n as i64).map(|i| i * 1_000_000_000).collect()
1507    }
1508
1509    // ── Core recursions ─────────────────────────────────────────────
1510
1511    #[test]
1512    fn difference_and_undifference_round_trip() {
1513        let series: Vec<f64> = (0..40).map(|i| (i as f64).powi(2) + 3.0).collect();
1514        for d in 1..=3 {
1515            let diffed = difference(&series, d);
1516            // Undifference the tail back to the original scale.
1517            let split = 30;
1518            let mut preds = diffed[diffed.len() - (40 - split)..].to_vec();
1519            let mut anchors = Vec::new();
1520            let mut level = series[..split].to_vec();
1521            for _ in 0..d {
1522                anchors.push(*level.last().unwrap());
1523                level = difference(&level, 1);
1524            }
1525            for a in anchors.into_iter().rev() {
1526                preds = undifference_lag(&preds, &[a], 1);
1527            }
1528            for (i, v) in preds.iter().enumerate() {
1529                assert!(
1530                    (v - series[split + i]).abs() < 1e-6,
1531                    "d={d} i={i}: {v} vs {}",
1532                    series[split + i]
1533                );
1534            }
1535        }
1536    }
1537
1538    #[test]
1539    fn seasonal_difference_and_undifference_round_trip() {
1540        let m = 4;
1541        let series: Vec<f64> = (0..40)
1542            .map(|i| (i % m) as f64 * 2.0 + i as f64 * 0.1)
1543            .collect();
1544        let diffed = seasonal_difference(&series, m, 1);
1545        let split = 30;
1546        let preds = diffed[diffed.len() - (40 - split)..].to_vec();
1547        let anchor = &series[split - m..split];
1548        let restored = undifference_lag(&preds, anchor, m);
1549        for (i, v) in restored.iter().enumerate() {
1550            assert!((v - series[split + i]).abs() < 1e-9, "i={i}: {v}");
1551        }
1552    }
1553
1554    #[test]
1555    fn expanded_polynomials_match_the_multiplicative_form() {
1556        // (1 - 0.5B)(1 - 0.3B⁴) = 1 - 0.5B - 0.3B⁴ + 0.15B⁵
1557        // In the "plus" convention that is φ* = [0.5, 0, 0, 0.3, -0.15].
1558        let ar = expand_ar(&[0.5], &[0.3], 4);
1559        let expected = [0.5, 0.0, 0.0, 0.3, -0.15];
1560        assert_eq!(ar.len(), expected.len());
1561        for (a, b) in ar.iter().zip(expected) {
1562            assert!((a - b).abs() < 1e-12, "{ar:?}");
1563        }
1564        // (1 + 0.4B)(1 + 0.2B³) = 1 + 0.4B + 0.2B³ + 0.08B⁴
1565        let ma = expand_ma(&[0.4], &[0.2], 3);
1566        let expected = [0.4, 0.0, 0.2, 0.08];
1567        for (a, b) in ma.iter().zip(expected) {
1568            assert!((a - b).abs() < 1e-12, "{ma:?}");
1569        }
1570    }
1571
1572    #[test]
1573    fn expanded_polynomials_degenerate_without_seasonal_terms() {
1574        assert_eq!(expand_ar(&[0.5, -0.2], &[], 12), vec![0.5, -0.2]);
1575        assert_eq!(expand_ma(&[0.4], &[], 12), vec![0.4]);
1576    }
1577
1578    #[test]
1579    fn psi_weights_degenerate_to_sqrt_h() {
1580        let psi = psi_weights(&[], &[], 5);
1581        assert_eq!(psi.len(), 5);
1582        assert!((psi[0] - 1.0).abs() < 1e-12);
1583        for (j, w) in psi.iter().enumerate().skip(1) {
1584            assert!(w.abs() < 1e-12, "ψ_{j} should be 0 for pure noise");
1585        }
1586
1587        let psi_ar = psi_weights(&[0.5], &[], 4);
1588        for (j, expected) in [1.0, 0.5, 0.25, 0.125].into_iter().enumerate() {
1589            assert!((psi_ar[j] - expected).abs() < 1e-12);
1590        }
1591
1592        let psi_ma = psi_weights(&[], &[0.8], 4);
1593        assert!((psi_ma[0] - 1.0).abs() < 1e-12);
1594        assert!((psi_ma[1] - 0.8).abs() < 1e-12);
1595        assert!(psi_ma[2].abs() < 1e-12);
1596        assert!(psi_ma[3].abs() < 1e-12);
1597    }
1598
1599    #[test]
1600    fn burg_recovers_a_known_ar2_and_stays_stable() {
1601        let n = 1000;
1602        let eps = noise(12345, n, 0.25);
1603        let mut vals = vec![0.0; n];
1604        for t in 2..n {
1605            vals[t] = 0.75 * vals[t - 1] - 0.5 * vals[t - 2] + eps[t];
1606        }
1607        let (coeffs, _) = estimate_ar_burg(&vals[200..], 2).unwrap();
1608        assert!((coeffs[0] - 0.75).abs() < 0.15, "{coeffs:?}");
1609        assert!((coeffs[1] + 0.5).abs() < 0.15, "{coeffs:?}");
1610        // Roots of z² − φ₁z − φ₂ must lie inside the unit circle.
1611        let (b, c) = (-coeffs[0], -coeffs[1]);
1612        let disc = b * b - 4.0 * c;
1613        if disc >= 0.0 {
1614            let r1 = (-b + disc.sqrt()) / 2.0;
1615            let r2 = (-b - disc.sqrt()) / 2.0;
1616            assert!(
1617                r1.abs() < 1.0 && r2.abs() < 1.0,
1618                "unstable roots {r1}, {r2}"
1619            );
1620        }
1621    }
1622
1623    // ── ARIMA ───────────────────────────────────────────────────────
1624
1625    /// A random walk is `ARIMA(0,1,0)`: every ψⱼ is 1, so `Var[e_h] = h·σ²`
1626    /// and the variance ratio is **exactly** `h`. Before the integration term
1627    /// entered the ψ-weights this was a constant-width interval.
1628    #[test]
1629    fn random_walk_interval_variance_grows_exactly_linearly() {
1630        let e = noise(7, 200, 1.0);
1631        let mut vals = vec![100.0];
1632        for v in e.iter().take(199) {
1633            vals.push(vals.last().unwrap() + v);
1634        }
1635        let mut model = ArimaModel::new(0, 1, 0);
1636        model.fit(&ts(200), &vals).unwrap();
1637        let r = model.predict(12).unwrap();
1638        let half = |h: usize| r.confidence_upper[h] - r.values[h];
1639        assert!(half(0) > 0.0);
1640        for h in 1..=12 {
1641            let ratio = (half(h - 1) / half(0)).powi(2);
1642            assert!(
1643                (ratio - h as f64).abs() < 1e-9,
1644                "var({h})/var(1) = {ratio}, want {h}"
1645            );
1646        }
1647    }
1648
1649    /// `ARIMA(1,1,0)`: `φ*(B) = (1-φB)(1-B)`, so
1650    /// `ψⱼ = 1 + φ + … + φʲ = (1-φ^{j+1})/(1-φ)`.
1651    #[test]
1652    fn arima_110_interval_matches_the_psi_closed_form() {
1653        let e = noise(11, 240, 1.0);
1654        let mut vals = vec![50.0];
1655        let mut prev_d = 0.0;
1656        for v in e.iter().take(239) {
1657            let d = 0.6 * prev_d + v;
1658            vals.push(vals.last().unwrap() + d);
1659            prev_d = d;
1660        }
1661        let mut model = ArimaModel::new(1, 1, 0);
1662        model.fit(&ts(240), &vals).unwrap();
1663        let ModelParams::Arima {
1664            ar_coeffs,
1665            residual_std,
1666            ..
1667        } = model.params()
1668        else {
1669            panic!()
1670        };
1671        let phi = ar_coeffs[0];
1672        assert!(phi.abs() > 0.1, "need a non-trivial φ, got {phi}");
1673        let r = model.predict(10).unwrap();
1674        for h in 1..=10 {
1675            let factor: f64 = (0..h)
1676                .map(|j| {
1677                    let psi = (1.0 - phi.powi(j as i32 + 1)) / (1.0 - phi);
1678                    psi * psi
1679                })
1680                .sum::<f64>();
1681            let expected = 1.959_963_984_540_054 * residual_std * factor.sqrt();
1682            let half = r.confidence_upper[h - 1] - r.values[h - 1];
1683            assert!(
1684                (half - expected).abs() < 1e-9,
1685                "h={h}: half-width {half} != {expected}"
1686            );
1687        }
1688    }
1689
1690    /// `integrated_ar` must expand `φ(B)(1-B)^d(1-B^m)^D` exactly.
1691    #[test]
1692    fn integrated_ar_expands_the_differencing_operator() {
1693        // (1-B) → xₜ = xₜ₋₁
1694        assert_eq!(integrated_ar(&[], 1, 0, 0), vec![1.0]);
1695        // (1-B)² = 1 - 2B + B² → xₜ = 2xₜ₋₁ - xₜ₋₂
1696        let d2 = integrated_ar(&[], 2, 0, 0);
1697        assert!((d2[0] - 2.0).abs() < 1e-12 && (d2[1] + 1.0).abs() < 1e-12);
1698        // (1-0.5B)(1-B) = 1 - 1.5B + 0.5B²
1699        let m = integrated_ar(&[0.5], 1, 0, 0);
1700        assert!((m[0] - 1.5).abs() < 1e-12 && (m[1] + 0.5).abs() < 1e-12);
1701        // (1-B⁴): coefficient 1 at lag 4, zero elsewhere.
1702        let sd = integrated_ar(&[], 0, 1, 4);
1703        assert_eq!(sd.len(), 4);
1704        assert!((sd[3] - 1.0).abs() < 1e-12);
1705        assert!(sd[..3].iter().all(|c| c.abs() < 1e-12));
1706        // No differencing → unchanged.
1707        assert_eq!(integrated_ar(&[0.3, -0.2], 0, 0, 12), vec![0.3, -0.2]);
1708    }
1709
1710    /// `arma_forecast` must read the **newest** innovations. With a residual
1711    /// buffer longer than the value buffer, `θ₁ = 0.5` and last innovation
1712    /// 5.0, the one-step forecast is exactly 2.5; truncating from the tail
1713    /// gave 1.5.
1714    #[test]
1715    fn arma_forecast_reads_the_newest_innovations() {
1716        let values = [0.0, 0.0, 0.0];
1717        let residuals = [1.0, 2.0, 3.0, 4.0, 5.0];
1718        let out = arma_forecast(&values, &residuals, &[], &[0.5], 1);
1719        assert!((out[0] - 2.5).abs() < 1e-12, "got {}", out[0]);
1720    }
1721
1722    /// `predict` reads `residuals[i]` alongside `difference(history, d)[i]`,
1723    /// so after the streaming buffers are trimmed the two lengths must still
1724    /// differ by exactly `d`.
1725    #[test]
1726    fn update_keeps_history_and_residuals_aligned() {
1727        let vals: Vec<f64> = (0..100)
1728            .map(|i| 10.0 + i as f64 * 0.3 + ((i * 17) % 7) as f64 * 0.4)
1729            .collect();
1730        let mut model = ArimaModel::new(1, 1, 1);
1731        model.fit(&ts(100), &vals).unwrap();
1732        for i in 0..300 {
1733            let v = 40.0 + ((i * 13) % 11) as f64 * 0.5;
1734            model.update((100 + i) as i64 * 1_000_000_000, v).unwrap();
1735        }
1736        assert!(model.history.len() < 300, "history was never trimmed");
1737        assert_eq!(
1738            model.residuals.len(),
1739            model.history.len() - model.d,
1740            "residuals must stay aligned with difference(history, d)"
1741        );
1742
1743        // …and the MA term of the one-step forecast must use the newest
1744        // innovation: ŷ = last + c + θ₁·e_last for ARIMA(p,1,1) with p's
1745        // contribution written out from the differenced history.
1746        let ModelParams::Arima {
1747            ar_coeffs,
1748            ma_coeffs,
1749            constant,
1750            ..
1751        } = model.params().clone()
1752        else {
1753            panic!()
1754        };
1755        let diffed: Vec<f64> = difference(&model.history, 1)
1756            .into_iter()
1757            .map(|v| v - constant)
1758            .collect();
1759        let n = diffed.len();
1760        let expected_diff = constant
1761            + ar_coeffs[0] * diffed[n - 1]
1762            + ma_coeffs[0] * model.residuals[model.residuals.len() - 1];
1763        let expected = model.history[model.history.len() - 1] + expected_diff;
1764        let got = model.predict(1).unwrap().values[0];
1765        assert!((got - expected).abs() < 1e-9, "{got} != {expected}");
1766    }
1767
1768    /// The same alignment invariant for SARIMA.
1769    #[test]
1770    fn sarima_update_keeps_history_and_residuals_aligned() {
1771        let vals = {
1772            (0..120)
1773                .map(|i| 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin())
1774                .collect::<Vec<f64>>()
1775        };
1776        let mut model = SarimaModel::new(1, 1, 1, 0, 1, 0, 12);
1777        model.fit(&ts(120), &vals).unwrap();
1778        for i in 0..400 {
1779            let v = 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin();
1780            model.update((120 + i) as i64 * 1_000_000_000, v).unwrap();
1781        }
1782        assert!(model.history.len() < 400, "history was never trimmed");
1783        assert_eq!(model.residuals.len(), model.centered(&model.history).len());
1784    }
1785
1786    #[test]
1787    fn arima_random_walk() {
1788        let e = noise(42, 200, 1.0);
1789        let mut vals = vec![100.0];
1790        for v in e.iter().take(199) {
1791            vals.push(vals.last().unwrap() + v);
1792        }
1793        let mut model = ArimaModel::new(1, 1, 0);
1794        model.fit(&ts(200), &vals).unwrap();
1795        let result = model.predict(10).unwrap();
1796        assert_eq!(result.values.len(), 10);
1797        assert!((result.values[0] - vals[199]).abs() < 10.0);
1798    }
1799
1800    #[test]
1801    fn arima_linear_trend() {
1802        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1803        let mut model = ArimaModel::new(0, 1, 0);
1804        model.fit(&ts(100), &vals).unwrap();
1805        let result = model.predict(5).unwrap();
1806        // d = 1, so the model carries a drift: the mean first difference is
1807        // exactly 2, and the forecast continues the line.
1808        for (i, v) in result.values.iter().enumerate() {
1809            let expected = 208.0 + (i + 1) as f64 * 2.0;
1810            assert!((v - expected).abs() < 1e-6, "h={i}: {v} vs {expected}");
1811        }
1812    }
1813
1814    /// Above one difference there is no constant, so `ARIMA(0,2,0)` is the
1815    /// pure `2x_{t-1} - x_{t-2}` extrapolation — exact on a line, and short of
1816    /// a quadratic by its (unmodelled) curvature. Both are the right answer
1817    /// for the model that was asked for.
1818    #[test]
1819    fn arima_d2_extrapolates_a_line_exactly() {
1820        let n = 60usize;
1821        let vals: Vec<f64> = (0..n).map(|i| 3.0 * i as f64 - 7.0).collect();
1822        let mut model = ArimaModel::new(0, 2, 0);
1823        model.fit(&ts(n), &vals).unwrap();
1824        let result = model.predict(5).unwrap();
1825        for (h, v) in result.values.iter().enumerate() {
1826            let expected = 3.0 * (n + h) as f64 - 7.0;
1827            assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1828        }
1829    }
1830
1831    #[test]
1832    fn arima_d2_undifference_correctness() {
1833        let n = 100usize;
1834        let vals: Vec<f64> = (0..n).map(|i| (i as f64).powi(2)).collect();
1835        let mut model = ArimaModel::new(0, 2, 0);
1836        model.fit(&ts(n), &vals).unwrap();
1837        let result = model.predict(5).unwrap();
1838        // No constant at d = 2, so the forecast is the linear continuation of
1839        // the last first difference: x_{n+h} = x_{n-1} + (h+1)·(x_{n-1} −
1840        // x_{n-2}). It falls short of the true quadratic by the curvature the
1841        // model does not carry, and it must do so *exactly*.
1842        let slope = vals[n - 1] - vals[n - 2];
1843        for (h, v) in result.values.iter().enumerate() {
1844            let expected = vals[n - 1] + (h + 1) as f64 * slope;
1845            assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1846        }
1847    }
1848
1849    #[test]
1850    fn arima_confidence_intervals_bracket_the_forecast() {
1851        let vals: Vec<f64> = (0..100)
1852            .map(|i| (f64::from(i)).sin() * 5.0 + 50.0)
1853            .collect();
1854        let mut model = ArimaModel::new(2, 0, 1);
1855        model.fit(&ts(100), &vals).unwrap();
1856        let result = model.predict(10).unwrap();
1857        for i in 0..10 {
1858            assert!(result.confidence_lower[i] <= result.values[i]);
1859            assert!(result.confidence_upper[i] >= result.values[i]);
1860        }
1861    }
1862
1863    /// The conditioning warm-up is the whole reason ARIMA(p,0,q) intervals are
1864    /// usable: without it the first innovation is the raw first observation,
1865    /// so a series sitting at 100 with noise of 0.1 gets an interval two
1866    /// orders of magnitude too wide.
1867    #[test]
1868    fn arima_intervals_track_the_noise_not_the_level() {
1869        let n = 200;
1870        let e = noise(7, n, 0.1);
1871        let mut vals = Vec::with_capacity(n);
1872        let mut x = 100.0_f64;
1873        for v in &e {
1874            x = 100.0 + 0.5 * (x - 100.0) + v;
1875            vals.push(x);
1876        }
1877        let mut model = ArimaModel::new(1, 0, 0);
1878        model.fit(&ts(n), &vals).unwrap();
1879        let r = model.predict(3).unwrap();
1880        let half_width = r.confidence_upper[0] - r.values[0];
1881        assert!(
1882            half_width < 0.5,
1883            "one-step 95% half-width {half_width} exceeds the noise scale (0.1)"
1884        );
1885        assert!(
1886            half_width > 0.05,
1887            "interval collapsed to nothing: {half_width}"
1888        );
1889    }
1890
1891    #[test]
1892    fn arima_insufficient_data() {
1893        let mut model = ArimaModel::new(2, 1, 1);
1894        assert!(model.fit(&[0, 1, 2], &[1.0, 2.0, 3.0]).is_err());
1895    }
1896
1897    #[test]
1898    fn arima_update() {
1899        let vals: Vec<f64> = (0..50).map(f64::from).collect();
1900        let mut model = ArimaModel::new(1, 1, 0);
1901        model.fit(&ts(50), &vals).unwrap();
1902        assert!(model.update(50_000_000_000, 50.0).is_ok());
1903        assert!(model.predict(3).unwrap().values.len() == 3);
1904    }
1905
1906    #[test]
1907    fn css_recovers_a_known_arma11() {
1908        let n = 1000;
1909        let eps = noise(99999, n, 0.2);
1910        let mut vals = vec![0.0; n];
1911        for t in 1..n {
1912            vals[t] = 0.6 * vals[t - 1] + eps[t] + 0.4 * eps[t - 1];
1913        }
1914        let vals = &vals[200..];
1915        let mut model = ArimaModel::new(1, 0, 1);
1916        model.fit(&ts(vals.len()), vals).unwrap();
1917        let ModelParams::Arima {
1918            ma_coeffs,
1919            ar_coeffs,
1920            ..
1921        } = model.params()
1922        else {
1923            panic!("wrong variant");
1924        };
1925        assert!((ma_coeffs[0] - 0.4).abs() < 0.3, "θ = {}", ma_coeffs[0]);
1926        assert!((ar_coeffs[0] - 0.6).abs() < 0.2, "φ = {}", ar_coeffs[0]);
1927    }
1928
1929    // ── KPSS and differencing order ─────────────────────────────────
1930
1931    #[test]
1932    fn kpss_accepts_stationary_and_rejects_a_random_walk() {
1933        let white = noise(3, 300, 1.0);
1934        assert!(
1935            kpss_statistic(&white) < KPSS_CRITICAL_5PCT,
1936            "white noise rejected: {}",
1937            kpss_statistic(&white)
1938        );
1939
1940        let mut walk = vec![0.0];
1941        for v in white.iter().take(299) {
1942            walk.push(walk.last().unwrap() + v);
1943        }
1944        assert!(
1945            kpss_statistic(&walk) > KPSS_CRITICAL_5PCT,
1946            "random walk accepted: {}",
1947            kpss_statistic(&walk)
1948        );
1949        assert_eq!(select_differencing_order(&white, 2), 0);
1950        assert_eq!(select_differencing_order(&walk, 2), 1);
1951    }
1952
1953    #[test]
1954    fn kpss_is_scale_invariant() {
1955        let v = noise(11, 200, 1.0);
1956        let scaled: Vec<f64> = v.iter().map(|x| x * 1000.0 + 5.0).collect();
1957        let a = kpss_statistic(&v);
1958        let b = kpss_statistic(&scaled);
1959        assert!((a - b).abs() < 1e-9, "{a} vs {b}");
1960    }
1961
1962    #[test]
1963    fn kpss_handles_degenerate_input() {
1964        assert_eq!(kpss_statistic(&[]), 0.0);
1965        assert_eq!(kpss_statistic(&[1.0, 1.0]), 0.0);
1966        assert_eq!(kpss_statistic(&[7.0; 50]), 0.0);
1967        assert_eq!(select_differencing_order(&[7.0; 50], 2), 0);
1968    }
1969
1970    #[test]
1971    fn select_differencing_order_respects_the_cap() {
1972        let mut quad: Vec<f64> = (0..200).map(|i| (f64::from(i)).powi(2)).collect();
1973        quad.iter_mut()
1974            .zip(noise(5, 200, 1.0))
1975            .for_each(|(v, e)| *v += e);
1976        assert_eq!(select_differencing_order(&quad, 1), 1);
1977        assert!(select_differencing_order(&quad, 3) >= 2);
1978    }
1979
1980    // ── auto_arima ──────────────────────────────────────────────────
1981
1982    #[test]
1983    fn auto_arima_differences_a_trend() {
1984        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1985        let result = auto_arima(&ts(100), &vals, 2, 2, 2, None).unwrap();
1986        assert!(result.order.1 >= 1, "d should be >= 1 for trending data");
1987        assert!(!result.candidates.is_empty());
1988        assert!(result.aic.is_finite());
1989        assert!(result.evals > 0);
1990    }
1991
1992    /// Why `d` comes from KPSS: scoring it by an information criterion picks
1993    /// `d = 1` on stationary noise, because differencing shrinks the residual
1994    /// sum of squares the criterion is comparing.
1995    #[test]
1996    fn auto_arima_does_not_overdifference_stationary_noise() {
1997        let vals: Vec<f64> = noise(2024, 200, 0.5).iter().map(|v| v + 10.0).collect();
1998        let result = auto_arima(&ts(200), &vals, 2, 2, 2, None).unwrap();
1999        assert_eq!(result.order.1, 0, "stationary series was differenced");
2000        assert_eq!(result.kpss_order, 0);
2001    }
2002
2003    #[test]
2004    fn auto_arima_reports_when_max_d_bound_the_answer() {
2005        let vals: Vec<f64> = (0..200)
2006            .map(|i| (f64::from(i)).powi(2))
2007            .zip(noise(6, 200, 1.0))
2008            .map(|(a, b)| a + b)
2009            .collect();
2010        let result = auto_arima(&ts(200), &vals, 1, 1, 1, None).unwrap();
2011        assert_eq!(result.order.1, 1, "capped at max_d");
2012        assert!(
2013            result.kpss_order >= 2,
2014            "kpss wanted more: {}",
2015            result.kpss_order
2016        );
2017    }
2018
2019    #[test]
2020    fn auto_arima_returns_sorted_candidates_at_one_differencing_order() {
2021        let vals: Vec<f64> = (0..80)
2022            .map(|i| (f64::from(i) * 0.1).sin() * 5.0 + 50.0)
2023            .collect();
2024        let result = auto_arima(&ts(80), &vals, 2, 1, 2, None).unwrap();
2025        for w in result.candidates.windows(2) {
2026            assert!(w[0].3 <= w[1].3, "candidates not sorted");
2027        }
2028        assert!(
2029            result.candidates.iter().all(|c| c.1 == result.order.1),
2030            "candidates must share one differencing order to be comparable"
2031        );
2032    }
2033
2034    #[test]
2035    fn auto_arima_insufficient_data() {
2036        let vals: Vec<f64> = (0..10).map(f64::from).collect();
2037        assert!(auto_arima(&ts(10), &vals, 3, 2, 3, None).is_err());
2038    }
2039
2040    #[test]
2041    fn auto_arima_max_evals_budget() {
2042        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2043        let opts = AutoArimaOptions {
2044            max_evals: Some(3),
2045            ..Default::default()
2046        };
2047        let result = auto_arima(&ts(100), &vals, 3, 2, 3, Some(&opts)).unwrap();
2048        assert!(result.evals <= 3, "got {} evals", result.evals);
2049        assert!(result.aic.is_finite());
2050    }
2051
2052    #[test]
2053    fn auto_arima_stepwise_agrees_with_exhaustive_on_a_small_grid() {
2054        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2055        let exhaustive = auto_arima(&ts(100), &vals, 2, 1, 2, None).unwrap();
2056        let opts = AutoArimaOptions {
2057            strategy: SearchStrategy::Stepwise,
2058            ..Default::default()
2059        };
2060        let stepwise = auto_arima(&ts(100), &vals, 2, 1, 2, Some(&opts)).unwrap();
2061        let denom = exhaustive.aic.abs().max(1e-6);
2062        assert!(
2063            (stepwise.aic - exhaustive.aic).abs() / denom < 0.10,
2064            "stepwise {} vs exhaustive {}",
2065            stepwise.aic,
2066            exhaustive.aic
2067        );
2068    }
2069
2070    #[test]
2071    fn auto_arima_stepwise_uses_fewer_fits() {
2072        // Noisy, or every candidate fits exactly, RSS is zero and no
2073        // information criterion is defined for any of them.
2074        let vals: Vec<f64> = (0..150)
2075            .map(|i| f64::from(i) * 2.0 + 10.0)
2076            .zip(noise(31, 150, 1.0))
2077            .map(|(a, b)| a + b)
2078            .collect();
2079        let exhaustive = auto_arima(&ts(150), &vals, 5, 2, 5, None).unwrap();
2080        assert!(
2081            exhaustive.evals > 10,
2082            "grid collapsed: {}",
2083            exhaustive.evals
2084        );
2085        let opts = AutoArimaOptions {
2086            strategy: SearchStrategy::Stepwise,
2087            ..Default::default()
2088        };
2089        let stepwise = auto_arima(&ts(150), &vals, 5, 2, 5, Some(&opts)).unwrap();
2090        assert!(
2091            stepwise.evals < exhaustive.evals,
2092            "stepwise {} vs exhaustive {}",
2093            stepwise.evals,
2094            exhaustive.evals
2095        );
2096    }
2097
2098    // ── SARIMA ──────────────────────────────────────────────────────
2099
2100    fn seasonal(n: usize, m: usize, amp: f64, level: f64) -> Vec<f64> {
2101        (0..n)
2102            .map(|i| level + amp * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
2103            .collect()
2104    }
2105
2106    #[test]
2107    fn sarima_seasonal_orders_change_the_forecast() {
2108        let vals = seasonal(240, 12, 5.0, 50.0);
2109        let mut plain = SarimaModel::new(1, 0, 0, 0, 0, 0, 12);
2110        plain.fit(&ts(240), &vals).unwrap();
2111        let mut seas = SarimaModel::new(1, 0, 0, 1, 0, 1, 12);
2112        seas.fit(&ts(240), &vals).unwrap();
2113        let a = plain.predict(12).unwrap();
2114        let b = seas.predict(12).unwrap();
2115        assert_ne!(
2116            a.values, b.values,
2117            "seasonal orders must reach the forecast recursion"
2118        );
2119    }
2120
2121    /// A seasonal model must beat a non-seasonal one on seasonal data — the
2122    /// property that fails the moment the seasonal coefficients stop reaching
2123    /// the forecast recursion.
2124    #[test]
2125    fn sarima_forecasts_the_season() {
2126        let m = 12;
2127        let n = 240;
2128        let vals = seasonal(n, m, 5.0, 50.0);
2129        let truth = seasonal(n + m, m, 5.0, 50.0);
2130        let expected = &truth[n..];
2131
2132        let mut seas = SarimaModel::new(0, 0, 0, 1, 0, 1, m);
2133        seas.fit(&ts(n), &vals).unwrap();
2134        let got = seas.predict(m).unwrap();
2135
2136        let mut plain = ArimaModel::new(1, 0, 0);
2137        plain.fit(&ts(n), &vals).unwrap();
2138        let baseline = plain.predict(m).unwrap();
2139
2140        let err = |p: &[f64]| -> f64 {
2141            p.iter()
2142                .zip(expected)
2143                .map(|(a, b)| (a - b).abs())
2144                .sum::<f64>()
2145                / m as f64
2146        };
2147        let seasonal_err = err(&got.values);
2148        let baseline_err = err(&baseline.values);
2149        assert!(
2150            seasonal_err < baseline_err * 0.5,
2151            "SARIMA MAE {seasonal_err:.4} should beat ARIMA MAE {baseline_err:.4}"
2152        );
2153    }
2154
2155    #[test]
2156    fn sarima_seasonal_differencing_round_trips_the_level() {
2157        // A pure repeating pattern with a linear drift: (0,0,0)(0,1,0)m
2158        // forecasts x_{t} = x_{t-m} + drift, which reproduces the pattern.
2159        let m = 12;
2160        let n = 240;
2161        let vals: Vec<f64> = (0..n)
2162            .map(|i| 50.0 + 5.0 * ((i % m) as f64) + 0.25 * i as f64)
2163            .collect();
2164        let mut model = SarimaModel::new(0, 0, 0, 0, 1, 0, m);
2165        model.fit(&ts(n), &vals).unwrap();
2166        let r = model.predict(m).unwrap();
2167        for (h, v) in r.values.iter().enumerate() {
2168            let expected = 50.0 + 5.0 * ((n + h) % m) as f64 + 0.25 * (n + h) as f64;
2169            assert!((v - expected).abs() < 1.0, "h={h}: {v} vs {expected}");
2170        }
2171    }
2172
2173    #[test]
2174    fn sarima_ignores_seasonal_orders_when_the_period_is_degenerate() {
2175        let vals: Vec<f64> = noise(8, 120, 1.0).iter().map(|v| v + 5.0).collect();
2176        let mut model = SarimaModel::new(1, 0, 0, 2, 1, 2, 1);
2177        model.fit(&ts(120), &vals).unwrap();
2178        let ModelParams::Sarima { sp, sd, sq, .. } = model.params() else {
2179            panic!("wrong variant");
2180        };
2181        assert_eq!((*sp, *sd, *sq), (0, 0, 0));
2182    }
2183
2184    #[test]
2185    fn sarima_insufficient_data() {
2186        let vals = seasonal(20, 12, 5.0, 50.0);
2187        let mut model = SarimaModel::new(1, 0, 0, 1, 1, 0, 12);
2188        assert!(model.fit(&ts(20), &vals).is_err());
2189    }
2190
2191    #[test]
2192    fn sarima_update_advances_the_forecast() {
2193        let vals = seasonal(240, 12, 5.0, 50.0);
2194        let mut model = SarimaModel::new(1, 0, 0, 1, 0, 0, 12);
2195        model.fit(&ts(240), &vals).unwrap();
2196        let before = model.predict(1).unwrap();
2197        model.update(240_000_000_000, 60.0).unwrap();
2198        let after = model.predict(1).unwrap();
2199        assert_ne!(before.values[0], after.values[0]);
2200        assert!(after.timestamps[0] > before.timestamps[0]);
2201    }
2202
2203    #[test]
2204    fn sarima_intervals_widen_with_horizon() {
2205        let vals = seasonal(240, 12, 5.0, 50.0);
2206        let mut model = SarimaModel::new(1, 0, 1, 1, 0, 1, 12);
2207        model.fit(&ts(240), &vals).unwrap();
2208        let r = model.predict(24).unwrap();
2209        let width = |h: usize| r.confidence_upper[h] - r.confidence_lower[h];
2210        // Strict growth, with the ψ-weight closed form: `>=` also passes for
2211        // a constant-width interval, which is exactly the bug it missed.
2212        for h in 1..24 {
2213            assert!(
2214                width(h) > width(h - 1),
2215                "width must grow strictly: h={h} {} vs {}",
2216                width(h),
2217                width(h - 1)
2218            );
2219        }
2220        let ModelParams::Sarima { residual_std, .. } = model.params() else {
2221            panic!()
2222        };
2223        let phi_star = integrated_ar(&model.ar_expanded, 0, 0, 12);
2224        let psi = psi_weights(&phi_star, &model.ma_expanded, 24);
2225        for h in 1..=24 {
2226            let scale: f64 = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
2227            let expected = 2.0 * 1.959_963_984_540_054 * residual_std * scale;
2228            assert!(
2229                (width(h - 1) - expected).abs() < 1e-9,
2230                "h={h}: width {} != {expected}",
2231                width(h - 1)
2232            );
2233        }
2234        for h in 0..24 {
2235            assert!(r.confidence_lower[h] <= r.values[h]);
2236            assert!(r.confidence_upper[h] >= r.values[h]);
2237        }
2238    }
2239}