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/// Seasonal strength above which a series is treated as having a seasonal
1251/// unit root, from Wang, Smith & Hyndman (2006).
1252///
1253/// R's `forecast::nsdiffs` uses this exact threshold as its **default** test,
1254/// with the comment "Threshold chosen based on seasonal M3 auto.arima
1255/// accuracy" — it was fitted by minimising MASE over the M3 and M4
1256/// collections rather than derived, which is why it is a bare number and why
1257/// copying it is better than inventing one.
1258pub const SEASONAL_STRENGTH_THRESHOLD: f64 = 0.64;
1259
1260/// How many seasonal differences a series needs, from its seasonal strength.
1261///
1262/// The measure is `max(0, min(1, 1 − Var(remainder) / Var(remainder +
1263/// seasonal)))` over an STL decomposition: the share of the seasonal-plus-noise
1264/// variance that the seasonal component explains. One seasonal difference is
1265/// taken when it exceeds [`SEASONAL_STRENGTH_THRESHOLD`].
1266///
1267/// This is the default test in R's `forecast::nsdiffs`, in preference to OCSB
1268/// — which that package keeps as an option rather than a default. Reading the
1269/// two guards out of its source mattered as much as the formula: a **constant**
1270/// series needs no differencing however the variance ratio comes out (both
1271/// variances are zero), and a series shorter than two periods cannot be
1272/// decomposed at all, let alone differenced.
1273///
1274/// # Why this is not cosmetic
1275///
1276/// The alternative is what stood here: a fixed `D = 1`. Every seasonal
1277/// candidate was seasonally differenced, so a series with strong but
1278/// *stationary* seasonality — a daily load curve that repeats rather than
1279/// drifting — was always over-differenced, and the model that fits it,
1280/// `SARIMA(p,d,q)(P,0,Q)[m]`, could not be proposed at all. Over-differencing
1281/// does not fail; it inflates the forecast variance and widens every interval
1282/// built on it.
1283#[must_use]
1284pub fn select_seasonal_differencing_order(values: &[f64], period: usize, max_d: usize) -> usize {
1285    if period < 2 || max_d == 0 {
1286        return 0;
1287    }
1288    // Fewer than two full periods cannot be decomposed, and `nsdiffs`
1289    // refuses for the same reason.
1290    if values.len() < 2 * period {
1291        return 0;
1292    }
1293    // A constant series has zero variance in both terms, so the ratio is
1294    // meaningless; R checks this before the heuristic and so does this.
1295    let first = values.first().copied().unwrap_or(0.0);
1296    if values.iter().all(|v| (v - first).abs() < f64::EPSILON) {
1297        return 0;
1298    }
1299
1300    let mut current = values.to_vec();
1301    let mut d = 0usize;
1302    while d < max_d {
1303        let Some(strength) = seasonal_strength(&current, period) else {
1304            break;
1305        };
1306        if strength <= SEASONAL_STRENGTH_THRESHOLD {
1307            break;
1308        }
1309        current = seasonal_difference(&current, period, 1);
1310        d += 1;
1311        if current.len() < 2 * period {
1312            break;
1313        }
1314    }
1315    d
1316}
1317
1318/// `max(0, min(1, 1 − Var(remainder) / Var(remainder + seasonal)))`, or
1319/// `None` if the series cannot be decomposed.
1320fn seasonal_strength(values: &[f64], period: usize) -> Option<f64> {
1321    let decomposition = crate::preprocess::decomposition::stl_decompose(
1322        values,
1323        &crate::preprocess::decomposition::StlConfig::new(period),
1324    )
1325    .ok()?;
1326    let remainder_var = variance(&decomposition.residual)?;
1327    let combined: Vec<f64> = decomposition
1328        .residual
1329        .iter()
1330        .zip(&decomposition.seasonal)
1331        .map(|(r, s)| r + s)
1332        .collect();
1333    let combined_var = variance(&combined)?;
1334    if combined_var <= 0.0 {
1335        return Some(0.0);
1336    }
1337    Some((1.0 - remainder_var / combined_var).clamp(0.0, 1.0))
1338}
1339
1340/// Sample variance, or `None` for fewer than two observations.
1341fn variance(values: &[f64]) -> Option<f64> {
1342    if values.len() < 2 {
1343        return None;
1344    }
1345    let n = values.len() as f64;
1346    let mean = values.iter().sum::<f64>() / n;
1347    Some(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0))
1348}
1349
1350// ── auto_arima ──────────────────────────────────────────────────────────
1351
1352/// Result of automatic ARIMA model selection.
1353#[derive(Debug, Clone)]
1354pub struct AutoArimaResult {
1355    /// Best `(p, d, q)` order.
1356    pub order: (usize, usize, usize),
1357    /// AICc score of the best model.
1358    pub aic: f64,
1359    /// Every evaluated candidate, sorted by score ascending.
1360    pub candidates: Vec<(usize, usize, usize, f64)>,
1361    /// Number of model fits performed.
1362    pub evals: usize,
1363    /// Differencing order chosen by the unit-root test, before `max_d` was
1364    /// applied. Exposed so a caller can see that the cap bound the answer.
1365    pub kpss_order: usize,
1366}
1367
1368/// Search strategy for [`auto_arima`].
1369#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1370pub enum SearchStrategy {
1371    /// Exhaustive grid over `p ∈ [0, max_p]`, `q ∈ [0, max_q]`.
1372    #[default]
1373    Exhaustive,
1374    /// Hyndman–Khandakar stepwise search — O(P+Q) fits instead of O(P×Q), at
1375    /// the risk of a local optimum.
1376    Stepwise,
1377}
1378
1379/// Budget controls for [`auto_arima`].
1380#[derive(Debug, Clone, Copy, Default)]
1381pub struct AutoArimaOptions {
1382    /// Hard cap on the number of model fits. `None` means unlimited.
1383    pub max_evals: Option<usize>,
1384    /// Search strategy.
1385    pub strategy: SearchStrategy,
1386}
1387
1388/// Automatically select an ARIMA(p,d,q) order.
1389///
1390/// # How the order is chosen
1391///
1392/// `d` comes from a **KPSS unit-root test**
1393/// ([`select_differencing_order`]), capped at `max_d`; `p` and `q` are then
1394/// chosen by **AICc** at that fixed `d`. Splitting the decision this way is
1395/// not a refinement — it is what makes the information criterion meaningful,
1396/// because AIC/AICc compare models of the *same* data and two differencing
1397/// orders are two different datasets.
1398///
1399/// AICc rather than AIC: the correction matters at exactly the series lengths
1400/// a gateway has, and it converges to AIC as `n` grows.
1401///
1402/// # Strategies
1403///
1404/// - [`SearchStrategy::Exhaustive`] (default) fits every `(p, q)` in the grid
1405///   in parallel and returns the global AICc minimum within it. With `d`
1406///   fixed the grid is `(max_p+1)·(max_q+1)`, which is small enough that this
1407///   is usually the right choice.
1408/// - [`SearchStrategy::Stepwise`] follows Hyndman–Khandakar: seed with
1409///   `(2,q=2)`, `(0,0)`, `(1,0)`, `(0,1)`, then walk to improving neighbours.
1410///
1411/// Use [`AutoArimaOptions::max_evals`] to cap the cost of either.
1412///
1413/// # Errors
1414///
1415/// [`ForecastError::InsufficientData`] below 30 observations, and
1416/// [`ForecastError::NumericalInstability`] when no candidate fits.
1417pub fn auto_arima(
1418    timestamps: &[i64],
1419    values: &[f64],
1420    max_p: usize,
1421    max_d: usize,
1422    max_q: usize,
1423    options: Option<&AutoArimaOptions>,
1424) -> Result<AutoArimaResult, ForecastError> {
1425    validate_input(timestamps, values)?;
1426    if values.len() < 30 {
1427        return Err(ForecastError::InsufficientData {
1428            min: 30,
1429            got: values.len(),
1430        });
1431    }
1432
1433    let kpss_order = select_differencing_order(values, max_d.max(2));
1434    let d = kpss_order.min(max_d);
1435    let opts = options.copied().unwrap_or_default();
1436    let max_evals = opts.max_evals.unwrap_or(usize::MAX);
1437
1438    let score = |p: usize, q: usize| -> Option<f64> {
1439        let min_required = p.max(q) + 2 * d + 10;
1440        if values.len() < min_required {
1441            return None;
1442        }
1443        let mut model = ArimaModel::new(p, d, q);
1444        model.fit(timestamps, values).ok()?;
1445        model.aicc().filter(|a| a.is_finite())
1446    };
1447
1448    let (order, best, mut candidates, evals) = match opts.strategy {
1449        SearchStrategy::Exhaustive => {
1450            use rayon::prelude::*;
1451            let grid: Vec<(usize, usize)> = (0..=max_p)
1452                .flat_map(|p| (0..=max_q).map(move |q| (p, q)))
1453                .filter(|&(p, q)| p + q > 0)
1454                .take(max_evals)
1455                .collect();
1456            let found: Vec<(usize, usize, usize, f64)> = grid
1457                .par_iter()
1458                .filter_map(|&(p, q)| score(p, q).map(|a| (p, d, q, a)))
1459                .collect();
1460            let evals = found.len();
1461            let best = found
1462                .iter()
1463                .copied()
1464                .min_by(|a, b| a.3.total_cmp(&b.3))
1465                .ok_or_else(|| {
1466                    ForecastError::NumericalInstability(
1467                        "no ARIMA model in the grid could be fitted".into(),
1468                    )
1469                })?;
1470            ((best.0, best.1, best.2), best.3, found, evals)
1471        }
1472        SearchStrategy::Stepwise => stepwise(max_p, max_q, d, max_evals, &score)?,
1473    };
1474
1475    candidates.sort_by(|a, b| a.3.total_cmp(&b.3));
1476    Ok(AutoArimaResult {
1477        order,
1478        aic: best,
1479        candidates,
1480        evals,
1481        kpss_order,
1482    })
1483}
1484
1485/// Hyndman–Khandakar stepwise walk over `(p, q)` at a fixed `d`.
1486#[allow(clippy::type_complexity)]
1487fn stepwise(
1488    max_p: usize,
1489    max_q: usize,
1490    d: usize,
1491    max_evals: usize,
1492    score: &dyn Fn(usize, usize) -> Option<f64>,
1493) -> Result<
1494    (
1495        (usize, usize, usize),
1496        f64,
1497        Vec<(usize, usize, usize, f64)>,
1498        usize,
1499    ),
1500    ForecastError,
1501> {
1502    let mut candidates: Vec<(usize, usize, usize, f64)> = Vec::new();
1503    let mut visited: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
1504    let mut evals = 0usize;
1505
1506    let mut best_score = f64::INFINITY;
1507    let mut best = (0usize, 0usize);
1508
1509    let mut try_model = |p: usize,
1510                         q: usize,
1511                         candidates: &mut Vec<(usize, usize, usize, f64)>,
1512                         evals: &mut usize|
1513     -> Option<f64> {
1514        if p > max_p || q > max_q || (p == 0 && q == 0) {
1515            return None;
1516        }
1517        if !visited.insert((p, q)) {
1518            return candidates
1519                .iter()
1520                .find(|c| c.0 == p && c.2 == q)
1521                .map(|c| c.3);
1522        }
1523        if *evals >= max_evals {
1524            return None;
1525        }
1526        let s = score(p, q)?;
1527        *evals += 1;
1528        candidates.push((p, d, q, s));
1529        Some(s)
1530    };
1531
1532    for &(p, q) in &[(2, 2), (0, 0), (1, 0), (0, 1)] {
1533        let (p, q) = (p.min(max_p), q.min(max_q));
1534        if let Some(s) = try_model(p, q, &mut candidates, &mut evals) {
1535            if s < best_score {
1536                best_score = s;
1537                best = (p, q);
1538            }
1539        }
1540    }
1541    if best_score.is_infinite() {
1542        return Err(ForecastError::NumericalInstability(
1543            "no ARIMA model in the stepwise seed set could be fitted".into(),
1544        ));
1545    }
1546
1547    const DELTAS: [(i8, i8); 8] = [
1548        (1, 0),
1549        (-1, 0),
1550        (0, 1),
1551        (0, -1),
1552        (1, -1),
1553        (-1, 1),
1554        (1, 1),
1555        (-1, -1),
1556    ];
1557    loop {
1558        let (cp, cq) = best;
1559        let mut improved = false;
1560        for (dp, dq) in DELTAS {
1561            let (Ok(np), Ok(nq)) = (
1562                usize::try_from(cp as i64 + i64::from(dp)),
1563                usize::try_from(cq as i64 + i64::from(dq)),
1564            ) else {
1565                continue;
1566            };
1567            if let Some(s) = try_model(np, nq, &mut candidates, &mut evals) {
1568                if s < best_score {
1569                    best_score = s;
1570                    best = (np, nq);
1571                    improved = true;
1572                }
1573            }
1574        }
1575        if !improved || evals >= max_evals {
1576            break;
1577        }
1578    }
1579
1580    Ok(((best.0, d, best.1), best_score, candidates, evals))
1581}
1582
1583impl crate::forecast::storage::ModelStore for ArimaModel {}
1584impl crate::forecast::storage::ModelStore for SarimaModel {}
1585
1586#[cfg(test)]
1587#[allow(clippy::unwrap_used, clippy::expect_used)]
1588mod tests {
1589    use super::*;
1590
1591    /// Deterministic uniform noise on `[-amp, amp]`, so every test in this
1592    /// module is reproducible without a RNG dependency.
1593    fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
1594        let mut s = seed;
1595        (0..n)
1596            .map(|_| {
1597                s = s
1598                    .wrapping_mul(6_364_136_223_846_793_005)
1599                    .wrapping_add(1_442_695_040_888_963_407);
1600                ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
1601            })
1602            .collect()
1603    }
1604
1605    fn ts(n: usize) -> Vec<i64> {
1606        (0..n as i64).map(|i| i * 1_000_000_000).collect()
1607    }
1608
1609    // ── Core recursions ─────────────────────────────────────────────
1610
1611    #[test]
1612    fn difference_and_undifference_round_trip() {
1613        let series: Vec<f64> = (0..40).map(|i| (i as f64).powi(2) + 3.0).collect();
1614        for d in 1..=3 {
1615            let diffed = difference(&series, d);
1616            // Undifference the tail back to the original scale.
1617            let split = 30;
1618            let mut preds = diffed[diffed.len() - (40 - split)..].to_vec();
1619            let mut anchors = Vec::new();
1620            let mut level = series[..split].to_vec();
1621            for _ in 0..d {
1622                anchors.push(*level.last().unwrap());
1623                level = difference(&level, 1);
1624            }
1625            for a in anchors.into_iter().rev() {
1626                preds = undifference_lag(&preds, &[a], 1);
1627            }
1628            for (i, v) in preds.iter().enumerate() {
1629                assert!(
1630                    (v - series[split + i]).abs() < 1e-6,
1631                    "d={d} i={i}: {v} vs {}",
1632                    series[split + i]
1633                );
1634            }
1635        }
1636    }
1637
1638    #[test]
1639    fn seasonal_difference_and_undifference_round_trip() {
1640        let m = 4;
1641        let series: Vec<f64> = (0..40)
1642            .map(|i| (i % m) as f64 * 2.0 + i as f64 * 0.1)
1643            .collect();
1644        let diffed = seasonal_difference(&series, m, 1);
1645        let split = 30;
1646        let preds = diffed[diffed.len() - (40 - split)..].to_vec();
1647        let anchor = &series[split - m..split];
1648        let restored = undifference_lag(&preds, anchor, m);
1649        for (i, v) in restored.iter().enumerate() {
1650            assert!((v - series[split + i]).abs() < 1e-9, "i={i}: {v}");
1651        }
1652    }
1653
1654    #[test]
1655    fn expanded_polynomials_match_the_multiplicative_form() {
1656        // (1 - 0.5B)(1 - 0.3B⁴) = 1 - 0.5B - 0.3B⁴ + 0.15B⁵
1657        // In the "plus" convention that is φ* = [0.5, 0, 0, 0.3, -0.15].
1658        let ar = expand_ar(&[0.5], &[0.3], 4);
1659        let expected = [0.5, 0.0, 0.0, 0.3, -0.15];
1660        assert_eq!(ar.len(), expected.len());
1661        for (a, b) in ar.iter().zip(expected) {
1662            assert!((a - b).abs() < 1e-12, "{ar:?}");
1663        }
1664        // (1 + 0.4B)(1 + 0.2B³) = 1 + 0.4B + 0.2B³ + 0.08B⁴
1665        let ma = expand_ma(&[0.4], &[0.2], 3);
1666        let expected = [0.4, 0.0, 0.2, 0.08];
1667        for (a, b) in ma.iter().zip(expected) {
1668            assert!((a - b).abs() < 1e-12, "{ma:?}");
1669        }
1670    }
1671
1672    #[test]
1673    fn expanded_polynomials_degenerate_without_seasonal_terms() {
1674        assert_eq!(expand_ar(&[0.5, -0.2], &[], 12), vec![0.5, -0.2]);
1675        assert_eq!(expand_ma(&[0.4], &[], 12), vec![0.4]);
1676    }
1677
1678    #[test]
1679    fn psi_weights_degenerate_to_sqrt_h() {
1680        let psi = psi_weights(&[], &[], 5);
1681        assert_eq!(psi.len(), 5);
1682        assert!((psi[0] - 1.0).abs() < 1e-12);
1683        for (j, w) in psi.iter().enumerate().skip(1) {
1684            assert!(w.abs() < 1e-12, "ψ_{j} should be 0 for pure noise");
1685        }
1686
1687        let psi_ar = psi_weights(&[0.5], &[], 4);
1688        for (j, expected) in [1.0, 0.5, 0.25, 0.125].into_iter().enumerate() {
1689            assert!((psi_ar[j] - expected).abs() < 1e-12);
1690        }
1691
1692        let psi_ma = psi_weights(&[], &[0.8], 4);
1693        assert!((psi_ma[0] - 1.0).abs() < 1e-12);
1694        assert!((psi_ma[1] - 0.8).abs() < 1e-12);
1695        assert!(psi_ma[2].abs() < 1e-12);
1696        assert!(psi_ma[3].abs() < 1e-12);
1697    }
1698
1699    #[test]
1700    fn burg_recovers_a_known_ar2_and_stays_stable() {
1701        let n = 1000;
1702        let eps = noise(12345, n, 0.25);
1703        let mut vals = vec![0.0; n];
1704        for t in 2..n {
1705            vals[t] = 0.75 * vals[t - 1] - 0.5 * vals[t - 2] + eps[t];
1706        }
1707        let (coeffs, _) = estimate_ar_burg(&vals[200..], 2).unwrap();
1708        assert!((coeffs[0] - 0.75).abs() < 0.15, "{coeffs:?}");
1709        assert!((coeffs[1] + 0.5).abs() < 0.15, "{coeffs:?}");
1710        // Roots of z² − φ₁z − φ₂ must lie inside the unit circle.
1711        let (b, c) = (-coeffs[0], -coeffs[1]);
1712        let disc = b * b - 4.0 * c;
1713        if disc >= 0.0 {
1714            let r1 = (-b + disc.sqrt()) / 2.0;
1715            let r2 = (-b - disc.sqrt()) / 2.0;
1716            assert!(
1717                r1.abs() < 1.0 && r2.abs() < 1.0,
1718                "unstable roots {r1}, {r2}"
1719            );
1720        }
1721    }
1722
1723    // ── ARIMA ───────────────────────────────────────────────────────
1724
1725    /// A random walk is `ARIMA(0,1,0)`: every ψⱼ is 1, so `Var[e_h] = h·σ²`
1726    /// and the variance ratio is **exactly** `h`. Before the integration term
1727    /// entered the ψ-weights this was a constant-width interval.
1728    #[test]
1729    fn random_walk_interval_variance_grows_exactly_linearly() {
1730        let e = noise(7, 200, 1.0);
1731        let mut vals = vec![100.0];
1732        for v in e.iter().take(199) {
1733            vals.push(vals.last().unwrap() + v);
1734        }
1735        let mut model = ArimaModel::new(0, 1, 0);
1736        model.fit(&ts(200), &vals).unwrap();
1737        let r = model.predict(12).unwrap();
1738        let half = |h: usize| r.confidence_upper[h] - r.values[h];
1739        assert!(half(0) > 0.0);
1740        for h in 1..=12 {
1741            let ratio = (half(h - 1) / half(0)).powi(2);
1742            assert!(
1743                (ratio - h as f64).abs() < 1e-9,
1744                "var({h})/var(1) = {ratio}, want {h}"
1745            );
1746        }
1747    }
1748
1749    /// `ARIMA(1,1,0)`: `φ*(B) = (1-φB)(1-B)`, so
1750    /// `ψⱼ = 1 + φ + … + φʲ = (1-φ^{j+1})/(1-φ)`.
1751    #[test]
1752    fn arima_110_interval_matches_the_psi_closed_form() {
1753        let e = noise(11, 240, 1.0);
1754        let mut vals = vec![50.0];
1755        let mut prev_d = 0.0;
1756        for v in e.iter().take(239) {
1757            let d = 0.6 * prev_d + v;
1758            vals.push(vals.last().unwrap() + d);
1759            prev_d = d;
1760        }
1761        let mut model = ArimaModel::new(1, 1, 0);
1762        model.fit(&ts(240), &vals).unwrap();
1763        let ModelParams::Arima {
1764            ar_coeffs,
1765            residual_std,
1766            ..
1767        } = model.params()
1768        else {
1769            panic!()
1770        };
1771        let phi = ar_coeffs[0];
1772        assert!(phi.abs() > 0.1, "need a non-trivial φ, got {phi}");
1773        let r = model.predict(10).unwrap();
1774        for h in 1..=10 {
1775            let factor: f64 = (0..h)
1776                .map(|j| {
1777                    let psi = (1.0 - phi.powi(j as i32 + 1)) / (1.0 - phi);
1778                    psi * psi
1779                })
1780                .sum::<f64>();
1781            let expected = 1.959_963_984_540_054 * residual_std * factor.sqrt();
1782            let half = r.confidence_upper[h - 1] - r.values[h - 1];
1783            assert!(
1784                (half - expected).abs() < 1e-9,
1785                "h={h}: half-width {half} != {expected}"
1786            );
1787        }
1788    }
1789
1790    /// `integrated_ar` must expand `φ(B)(1-B)^d(1-B^m)^D` exactly.
1791    #[test]
1792    fn integrated_ar_expands_the_differencing_operator() {
1793        // (1-B) → xₜ = xₜ₋₁
1794        assert_eq!(integrated_ar(&[], 1, 0, 0), vec![1.0]);
1795        // (1-B)² = 1 - 2B + B² → xₜ = 2xₜ₋₁ - xₜ₋₂
1796        let d2 = integrated_ar(&[], 2, 0, 0);
1797        assert!((d2[0] - 2.0).abs() < 1e-12 && (d2[1] + 1.0).abs() < 1e-12);
1798        // (1-0.5B)(1-B) = 1 - 1.5B + 0.5B²
1799        let m = integrated_ar(&[0.5], 1, 0, 0);
1800        assert!((m[0] - 1.5).abs() < 1e-12 && (m[1] + 0.5).abs() < 1e-12);
1801        // (1-B⁴): coefficient 1 at lag 4, zero elsewhere.
1802        let sd = integrated_ar(&[], 0, 1, 4);
1803        assert_eq!(sd.len(), 4);
1804        assert!((sd[3] - 1.0).abs() < 1e-12);
1805        assert!(sd[..3].iter().all(|c| c.abs() < 1e-12));
1806        // No differencing → unchanged.
1807        assert_eq!(integrated_ar(&[0.3, -0.2], 0, 0, 12), vec![0.3, -0.2]);
1808    }
1809
1810    /// `arma_forecast` must read the **newest** innovations. With a residual
1811    /// buffer longer than the value buffer, `θ₁ = 0.5` and last innovation
1812    /// 5.0, the one-step forecast is exactly 2.5; truncating from the tail
1813    /// gave 1.5.
1814    #[test]
1815    fn arma_forecast_reads_the_newest_innovations() {
1816        let values = [0.0, 0.0, 0.0];
1817        let residuals = [1.0, 2.0, 3.0, 4.0, 5.0];
1818        let out = arma_forecast(&values, &residuals, &[], &[0.5], 1);
1819        assert!((out[0] - 2.5).abs() < 1e-12, "got {}", out[0]);
1820    }
1821
1822    /// `predict` reads `residuals[i]` alongside `difference(history, d)[i]`,
1823    /// so after the streaming buffers are trimmed the two lengths must still
1824    /// differ by exactly `d`.
1825    #[test]
1826    fn update_keeps_history_and_residuals_aligned() {
1827        let vals: Vec<f64> = (0..100)
1828            .map(|i| 10.0 + i as f64 * 0.3 + ((i * 17) % 7) as f64 * 0.4)
1829            .collect();
1830        let mut model = ArimaModel::new(1, 1, 1);
1831        model.fit(&ts(100), &vals).unwrap();
1832        for i in 0..300 {
1833            let v = 40.0 + ((i * 13) % 11) as f64 * 0.5;
1834            model.update((100 + i) as i64 * 1_000_000_000, v).unwrap();
1835        }
1836        assert!(model.history.len() < 300, "history was never trimmed");
1837        assert_eq!(
1838            model.residuals.len(),
1839            model.history.len() - model.d,
1840            "residuals must stay aligned with difference(history, d)"
1841        );
1842
1843        // …and the MA term of the one-step forecast must use the newest
1844        // innovation: ŷ = last + c + θ₁·e_last for ARIMA(p,1,1) with p's
1845        // contribution written out from the differenced history.
1846        let ModelParams::Arima {
1847            ar_coeffs,
1848            ma_coeffs,
1849            constant,
1850            ..
1851        } = model.params().clone()
1852        else {
1853            panic!()
1854        };
1855        let diffed: Vec<f64> = difference(&model.history, 1)
1856            .into_iter()
1857            .map(|v| v - constant)
1858            .collect();
1859        let n = diffed.len();
1860        let expected_diff = constant
1861            + ar_coeffs[0] * diffed[n - 1]
1862            + ma_coeffs[0] * model.residuals[model.residuals.len() - 1];
1863        let expected = model.history[model.history.len() - 1] + expected_diff;
1864        let got = model.predict(1).unwrap().values[0];
1865        assert!((got - expected).abs() < 1e-9, "{got} != {expected}");
1866    }
1867
1868    /// The same alignment invariant for SARIMA.
1869    #[test]
1870    fn sarima_update_keeps_history_and_residuals_aligned() {
1871        let vals = {
1872            (0..120)
1873                .map(|i| 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin())
1874                .collect::<Vec<f64>>()
1875        };
1876        let mut model = SarimaModel::new(1, 1, 1, 0, 1, 0, 12);
1877        model.fit(&ts(120), &vals).unwrap();
1878        for i in 0..400 {
1879            let v = 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin();
1880            model.update((120 + i) as i64 * 1_000_000_000, v).unwrap();
1881        }
1882        assert!(model.history.len() < 400, "history was never trimmed");
1883        assert_eq!(model.residuals.len(), model.centered(&model.history).len());
1884    }
1885
1886    #[test]
1887    fn arima_random_walk() {
1888        let e = noise(42, 200, 1.0);
1889        let mut vals = vec![100.0];
1890        for v in e.iter().take(199) {
1891            vals.push(vals.last().unwrap() + v);
1892        }
1893        let mut model = ArimaModel::new(1, 1, 0);
1894        model.fit(&ts(200), &vals).unwrap();
1895        let result = model.predict(10).unwrap();
1896        assert_eq!(result.values.len(), 10);
1897        assert!((result.values[0] - vals[199]).abs() < 10.0);
1898    }
1899
1900    #[test]
1901    fn arima_linear_trend() {
1902        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1903        let mut model = ArimaModel::new(0, 1, 0);
1904        model.fit(&ts(100), &vals).unwrap();
1905        let result = model.predict(5).unwrap();
1906        // d = 1, so the model carries a drift: the mean first difference is
1907        // exactly 2, and the forecast continues the line.
1908        for (i, v) in result.values.iter().enumerate() {
1909            let expected = 208.0 + (i + 1) as f64 * 2.0;
1910            assert!((v - expected).abs() < 1e-6, "h={i}: {v} vs {expected}");
1911        }
1912    }
1913
1914    /// Above one difference there is no constant, so `ARIMA(0,2,0)` is the
1915    /// pure `2x_{t-1} - x_{t-2}` extrapolation — exact on a line, and short of
1916    /// a quadratic by its (unmodelled) curvature. Both are the right answer
1917    /// for the model that was asked for.
1918    #[test]
1919    fn arima_d2_extrapolates_a_line_exactly() {
1920        let n = 60usize;
1921        let vals: Vec<f64> = (0..n).map(|i| 3.0 * i as f64 - 7.0).collect();
1922        let mut model = ArimaModel::new(0, 2, 0);
1923        model.fit(&ts(n), &vals).unwrap();
1924        let result = model.predict(5).unwrap();
1925        for (h, v) in result.values.iter().enumerate() {
1926            let expected = 3.0 * (n + h) as f64 - 7.0;
1927            assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1928        }
1929    }
1930
1931    #[test]
1932    fn arima_d2_undifference_correctness() {
1933        let n = 100usize;
1934        let vals: Vec<f64> = (0..n).map(|i| (i as f64).powi(2)).collect();
1935        let mut model = ArimaModel::new(0, 2, 0);
1936        model.fit(&ts(n), &vals).unwrap();
1937        let result = model.predict(5).unwrap();
1938        // No constant at d = 2, so the forecast is the linear continuation of
1939        // the last first difference: x_{n+h} = x_{n-1} + (h+1)·(x_{n-1} −
1940        // x_{n-2}). It falls short of the true quadratic by the curvature the
1941        // model does not carry, and it must do so *exactly*.
1942        let slope = vals[n - 1] - vals[n - 2];
1943        for (h, v) in result.values.iter().enumerate() {
1944            let expected = vals[n - 1] + (h + 1) as f64 * slope;
1945            assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1946        }
1947    }
1948
1949    #[test]
1950    fn arima_confidence_intervals_bracket_the_forecast() {
1951        let vals: Vec<f64> = (0..100)
1952            .map(|i| (f64::from(i)).sin() * 5.0 + 50.0)
1953            .collect();
1954        let mut model = ArimaModel::new(2, 0, 1);
1955        model.fit(&ts(100), &vals).unwrap();
1956        let result = model.predict(10).unwrap();
1957        for i in 0..10 {
1958            assert!(result.confidence_lower[i] <= result.values[i]);
1959            assert!(result.confidence_upper[i] >= result.values[i]);
1960        }
1961    }
1962
1963    /// The conditioning warm-up is the whole reason ARIMA(p,0,q) intervals are
1964    /// usable: without it the first innovation is the raw first observation,
1965    /// so a series sitting at 100 with noise of 0.1 gets an interval two
1966    /// orders of magnitude too wide.
1967    #[test]
1968    fn arima_intervals_track_the_noise_not_the_level() {
1969        let n = 200;
1970        let e = noise(7, n, 0.1);
1971        let mut vals = Vec::with_capacity(n);
1972        let mut x = 100.0_f64;
1973        for v in &e {
1974            x = 100.0 + 0.5 * (x - 100.0) + v;
1975            vals.push(x);
1976        }
1977        let mut model = ArimaModel::new(1, 0, 0);
1978        model.fit(&ts(n), &vals).unwrap();
1979        let r = model.predict(3).unwrap();
1980        let half_width = r.confidence_upper[0] - r.values[0];
1981        assert!(
1982            half_width < 0.5,
1983            "one-step 95% half-width {half_width} exceeds the noise scale (0.1)"
1984        );
1985        assert!(
1986            half_width > 0.05,
1987            "interval collapsed to nothing: {half_width}"
1988        );
1989    }
1990
1991    #[test]
1992    fn arima_insufficient_data() {
1993        let mut model = ArimaModel::new(2, 1, 1);
1994        assert!(model.fit(&[0, 1, 2], &[1.0, 2.0, 3.0]).is_err());
1995    }
1996
1997    #[test]
1998    fn arima_update() {
1999        let vals: Vec<f64> = (0..50).map(f64::from).collect();
2000        let mut model = ArimaModel::new(1, 1, 0);
2001        model.fit(&ts(50), &vals).unwrap();
2002        assert!(model.update(50_000_000_000, 50.0).is_ok());
2003        assert!(model.predict(3).unwrap().values.len() == 3);
2004    }
2005
2006    #[test]
2007    fn css_recovers_a_known_arma11() {
2008        let n = 1000;
2009        let eps = noise(99999, n, 0.2);
2010        let mut vals = vec![0.0; n];
2011        for t in 1..n {
2012            vals[t] = 0.6 * vals[t - 1] + eps[t] + 0.4 * eps[t - 1];
2013        }
2014        let vals = &vals[200..];
2015        let mut model = ArimaModel::new(1, 0, 1);
2016        model.fit(&ts(vals.len()), vals).unwrap();
2017        let ModelParams::Arima {
2018            ma_coeffs,
2019            ar_coeffs,
2020            ..
2021        } = model.params()
2022        else {
2023            panic!("wrong variant");
2024        };
2025        assert!((ma_coeffs[0] - 0.4).abs() < 0.3, "θ = {}", ma_coeffs[0]);
2026        assert!((ar_coeffs[0] - 0.6).abs() < 0.2, "φ = {}", ar_coeffs[0]);
2027    }
2028
2029    // ── KPSS and differencing order ─────────────────────────────────
2030
2031    #[test]
2032    fn kpss_accepts_stationary_and_rejects_a_random_walk() {
2033        let white = noise(3, 300, 1.0);
2034        assert!(
2035            kpss_statistic(&white) < KPSS_CRITICAL_5PCT,
2036            "white noise rejected: {}",
2037            kpss_statistic(&white)
2038        );
2039
2040        let mut walk = vec![0.0];
2041        for v in white.iter().take(299) {
2042            walk.push(walk.last().unwrap() + v);
2043        }
2044        assert!(
2045            kpss_statistic(&walk) > KPSS_CRITICAL_5PCT,
2046            "random walk accepted: {}",
2047            kpss_statistic(&walk)
2048        );
2049        assert_eq!(select_differencing_order(&white, 2), 0);
2050        assert_eq!(select_differencing_order(&walk, 2), 1);
2051    }
2052
2053    #[test]
2054    fn kpss_is_scale_invariant() {
2055        let v = noise(11, 200, 1.0);
2056        let scaled: Vec<f64> = v.iter().map(|x| x * 1000.0 + 5.0).collect();
2057        let a = kpss_statistic(&v);
2058        let b = kpss_statistic(&scaled);
2059        assert!((a - b).abs() < 1e-9, "{a} vs {b}");
2060    }
2061
2062    #[test]
2063    fn kpss_handles_degenerate_input() {
2064        assert_eq!(kpss_statistic(&[]), 0.0);
2065        assert_eq!(kpss_statistic(&[1.0, 1.0]), 0.0);
2066        assert_eq!(kpss_statistic(&[7.0; 50]), 0.0);
2067        assert_eq!(select_differencing_order(&[7.0; 50], 2), 0);
2068    }
2069
2070    /// Strong seasonality takes a seasonal difference.
2071    ///
2072    /// This is the M3/M4-tuned behaviour of the measure, and it is worth
2073    /// being explicit that it is a **strength** test, not a unit-root test:
2074    /// a perfectly repeating pattern around a stable level scores near 1 and
2075    /// is differenced. R's `nsdiffs` does the same, deliberately — the
2076    /// threshold was fitted by minimising forecast error, not derived from
2077    /// stationarity theory.
2078    #[test]
2079    fn strong_seasonality_takes_a_seasonal_difference() {
2080        let m = 24usize;
2081        let values: Vec<f64> = (0..(m * 14))
2082            .map(|i| {
2083                let phase = (i % m) as f64 / m as f64 * std::f64::consts::TAU;
2084                100.0 + 20.0 * phase.sin() + ((i * 37) % 11) as f64 * 0.05
2085            })
2086            .collect();
2087        assert_eq!(select_seasonal_differencing_order(&values, m, 1), 1);
2088    }
2089
2090    /// Faint seasonality buried in noise takes none — and this is the case
2091    /// the fixed `D = 1` got wrong.
2092    ///
2093    /// `detect_period` will happily return a period for a series whose
2094    /// seasonal term explains almost none of its variance. Differencing it
2095    /// anyway spends `m` observations, adds a moving-average term the data
2096    /// does not support, and widens every interval built on the result.
2097    #[test]
2098    fn weak_seasonality_takes_no_seasonal_difference() {
2099        let m = 24usize;
2100        let values: Vec<f64> = (0..(m * 14))
2101            .map(|i| {
2102                let phase = (i % m) as f64 / m as f64 * std::f64::consts::TAU;
2103                let noise = (((i * 2_654_435_761usize) % 1000) as f64 / 1000.0 - 0.5) * 40.0;
2104                100.0 + 0.5 * phase.sin() + noise
2105            })
2106            .collect();
2107        assert_eq!(select_seasonal_differencing_order(&values, m, 1), 0);
2108    }
2109
2110    #[test]
2111    fn pure_noise_takes_no_seasonal_difference() {
2112        let m = 24usize;
2113        let values: Vec<f64> = (0..(m * 14))
2114            .map(|i| 100.0 + (((i * 2_654_435_761usize) % 1000) as f64 / 1000.0 - 0.5) * 40.0)
2115            .collect();
2116        assert_eq!(select_seasonal_differencing_order(&values, m, 0), 0);
2117        assert_eq!(select_seasonal_differencing_order(&values, m, 1), 0);
2118    }
2119
2120    /// The measure's blind spot, pinned rather than left to be rediscovered.
2121    ///
2122    /// A **seasonal random walk** — each season's level drifts from cycle to
2123    /// cycle — is the textbook case for a seasonal difference, and the
2124    /// strength heuristic scores it *low*: STL's cycle-subseries smoother
2125    /// cannot fit a seasonal shape that keeps moving, so the variation lands
2126    /// in the remainder and the ratio collapses. This is exactly what a real
2127    /// seasonal unit-root test (OCSB, Canova–Hansen) is for, and why R keeps
2128    /// them as options beside this default. Recorded as a test so the limit
2129    /// is a known quantity rather than a surprise.
2130    /// Smallest |root| of `1 + θ₁z + … + θ_qz^q`, by Durand–Kerner.
2131    ///
2132    /// The MA polynomial is invertible iff every root lies strictly outside
2133    /// the unit circle, so the answer is compared against 1.
2134    fn min_ma_root_modulus(theta: &[f64]) -> f64 {
2135        let q = theta.len();
2136        if q == 0 {
2137            return f64::INFINITY;
2138        }
2139        let mut c: Vec<(f64, f64)> = std::iter::once(1.0)
2140            .chain(theta.iter().copied())
2141            .map(|v| (v, 0.0))
2142            .collect();
2143        let lead = c[q].0;
2144        if lead.abs() < 1e-12 {
2145            return min_ma_root_modulus(&theta[..q - 1]);
2146        }
2147        for v in &mut c {
2148            v.0 /= lead;
2149        }
2150        let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0);
2151        let sub = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0, a.1 - b.1);
2152        let div = |a: (f64, f64), b: (f64, f64)| {
2153            let d = b.0 * b.0 + b.1 * b.1;
2154            ((a.0 * b.0 + a.1 * b.1) / d, (a.1 * b.0 - a.0 * b.1) / d)
2155        };
2156        let eval = |z: (f64, f64)| {
2157            let mut acc = (0.0, 0.0);
2158            for k in (0..=q).rev() {
2159                acc = mul(acc, z);
2160                acc = (acc.0 + c[k].0, acc.1 + c[k].1);
2161            }
2162            acc
2163        };
2164        let mut roots: Vec<(f64, f64)> = (0..q)
2165            .map(|k| {
2166                let a = 0.4 + 0.9 * k as f64;
2167                (a.cos() * 0.9, a.sin() * 0.9)
2168            })
2169            .collect();
2170        for _ in 0..500 {
2171            let mut moved = 0.0f64;
2172            for i in 0..q {
2173                let mut denom = (1.0, 0.0);
2174                for j in 0..q {
2175                    if i != j {
2176                        denom = mul(denom, sub(roots[i], roots[j]));
2177                    }
2178                }
2179                if denom.0.abs() + denom.1.abs() < 1e-300 {
2180                    continue;
2181                }
2182                let delta = div(eval(roots[i]), denom);
2183                roots[i] = sub(roots[i], delta);
2184                moved = moved.max(delta.0.abs() + delta.1.abs());
2185            }
2186            if moved < 1e-14 {
2187                break;
2188            }
2189        }
2190        roots
2191            .iter()
2192            .map(|r| (r.0 * r.0 + r.1 * r.1).sqrt())
2193            .fold(f64::INFINITY, f64::min)
2194    }
2195
2196    #[test]
2197    fn the_root_finder_agrees_with_the_quadratic_formula() {
2198        // `1 + 0.99z - 0.99z²` has a root at ≈ -0.6225: inside the unit
2199        // circle, so this θ is non-invertible *and* inside the ±0.99 box
2200        // every coefficient is bounded to. The box is not the constraint.
2201        let m = min_ma_root_modulus(&[0.99, -0.99]);
2202        assert!((m - 0.6225).abs() < 1e-3, "min |root| was {m}");
2203        // An invertible one, for the other direction.
2204        assert!(min_ma_root_modulus(&[0.5]) > 1.0);
2205    }
2206
2207    #[test]
2208    fn a_fitted_ma_polynomial_is_invertible() {
2209        // `estimate_ma` bounds each coefficient to ±0.99, which does not
2210        // imply invertibility for q > 1 — see the test above. What keeps
2211        // fits inside the invertible region is the **objective**: the CSS
2212        // error recursion diverges outside it, so the optimiser has no
2213        // reason to go there. That is a property of the estimator, not of
2214        // the bounds, so it is worth checking rather than assuming — if the
2215        // estimator is ever replaced (an exact likelihood, say), this is the
2216        // guarantee that quietly changes.
2217        let mut state = 7u64;
2218        let mut next = move || {
2219            state = state
2220                .wrapping_mul(6_364_136_223_846_793_005)
2221                .wrapping_add(1);
2222            ((state >> 33) as f64 / f64::from(u32::MAX >> 1)) - 1.0
2223        };
2224
2225        let mut checked = 0usize;
2226        let mut worst = f64::INFINITY;
2227        for q in 2..=3usize {
2228            for trial in 0..40 {
2229                let n = 160usize;
2230                let mut v = Vec::with_capacity(n);
2231                let (mut prev0, mut prev1, mut walk) = (0.0f64, 0.0f64, 0.0f64);
2232                for i in 0..n {
2233                    let e = next();
2234                    v.push(match trial % 4 {
2235                        0 => e,
2236                        1 => e + 0.95 * prev0 + 0.9 * prev1,
2237                        2 => {
2238                            walk += e;
2239                            walk
2240                        }
2241                        _ => i as f64 * 0.05 + e,
2242                    });
2243                    prev1 = prev0;
2244                    prev0 = e;
2245                }
2246                let ts: Vec<i64> = (0..n as i64).collect();
2247                let mut model = ArimaModel::new(1, 0, q);
2248                if model.fit(&ts, &v).is_err() {
2249                    continue;
2250                }
2251                let ModelParams::Arima { ma_coeffs, .. } = model.params() else {
2252                    continue;
2253                };
2254                let r = min_ma_root_modulus(ma_coeffs);
2255                worst = worst.min(r);
2256                assert!(
2257                    r > 1.0,
2258                    "q={q} trial={trial}: fitted θ={ma_coeffs:?} is non-invertible \
2259                     (min |root| = {r})"
2260                );
2261                checked += 1;
2262            }
2263        }
2264        assert!(checked >= 60, "only {checked} fits were checked");
2265        // Over-differenced noise is the textbook way to manufacture a
2266        // non-invertible MA(1), so it belongs in the sample rather than
2267        // beside it.
2268        let raw: Vec<f64> = (0..121).map(|_| next()).collect();
2269        let over: Vec<f64> = raw.windows(2).map(|w| w[1] - w[0]).collect();
2270        let ts: Vec<i64> = (0..over.len() as i64).collect();
2271        let mut model = ArimaModel::new(1, 0, 2);
2272        if model.fit(&ts, &over).is_ok() {
2273            if let ModelParams::Arima { ma_coeffs, .. } = model.params() {
2274                let r = min_ma_root_modulus(ma_coeffs);
2275                assert!(r > 1.0, "over-differenced fit is non-invertible: {r}");
2276            }
2277        }
2278    }
2279
2280    #[test]
2281    fn the_strength_measure_is_blind_to_a_stochastic_seasonal_level() {
2282        let m = 24usize;
2283        let mut values = vec![0.0f64; 12 * m];
2284        let mut season_level = vec![0.0f64; m];
2285        let mut rng_state = 42u64;
2286        let mut next = || {
2287            rng_state = rng_state
2288                .wrapping_mul(6_364_136_223_846_793_005)
2289                .wrapping_add(1);
2290            ((rng_state >> 33) as f64 / f64::from(u32::MAX >> 1)) - 1.0
2291        };
2292        for cycle in 0..12 {
2293            for season in 0..m {
2294                season_level[season] += next() * 3.0;
2295                values[cycle * m + season] = season_level[season] + next() * 0.1;
2296            }
2297        }
2298        assert_eq!(
2299            select_seasonal_differencing_order(&values, m, 1),
2300            0,
2301            "if this starts returning 1 the measure has changed — check which \
2302             test is being used before assuming it is an improvement",
2303        );
2304    }
2305
2306    #[test]
2307    fn a_constant_series_is_never_seasonally_differenced() {
2308        // Both variances are zero, so the ratio says nothing; the guard has
2309        // to come first. R's `nsdiffs` checks `is.constant` before the
2310        // heuristic for exactly this reason.
2311        let values = vec![7.0f64; 200];
2312        assert_eq!(select_seasonal_differencing_order(&values, 24, 1), 0);
2313    }
2314
2315    #[test]
2316    fn a_series_shorter_than_two_periods_is_never_seasonally_differenced() {
2317        let values: Vec<f64> = (0..30).map(|i| f64::from(i % 24)).collect();
2318        assert_eq!(
2319            select_seasonal_differencing_order(&values, 24, 1),
2320            0,
2321            "STL needs two full periods; there is nothing to measure",
2322        );
2323    }
2324
2325    #[test]
2326    fn a_period_below_two_is_not_seasonal() {
2327        let values: Vec<f64> = (0..100).map(f64::from).collect();
2328        assert_eq!(select_seasonal_differencing_order(&values, 1, 1), 0);
2329        assert_eq!(select_seasonal_differencing_order(&values, 0, 1), 0);
2330    }
2331
2332    #[test]
2333    fn select_differencing_order_respects_the_cap() {
2334        let mut quad: Vec<f64> = (0..200).map(|i| (f64::from(i)).powi(2)).collect();
2335        quad.iter_mut()
2336            .zip(noise(5, 200, 1.0))
2337            .for_each(|(v, e)| *v += e);
2338        assert_eq!(select_differencing_order(&quad, 1), 1);
2339        assert!(select_differencing_order(&quad, 3) >= 2);
2340    }
2341
2342    // ── auto_arima ──────────────────────────────────────────────────
2343
2344    #[test]
2345    fn auto_arima_differences_a_trend() {
2346        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2347        let result = auto_arima(&ts(100), &vals, 2, 2, 2, None).unwrap();
2348        assert!(result.order.1 >= 1, "d should be >= 1 for trending data");
2349        assert!(!result.candidates.is_empty());
2350        assert!(result.aic.is_finite());
2351        assert!(result.evals > 0);
2352    }
2353
2354    /// Why `d` comes from KPSS: scoring it by an information criterion picks
2355    /// `d = 1` on stationary noise, because differencing shrinks the residual
2356    /// sum of squares the criterion is comparing.
2357    #[test]
2358    fn auto_arima_does_not_overdifference_stationary_noise() {
2359        let vals: Vec<f64> = noise(2024, 200, 0.5).iter().map(|v| v + 10.0).collect();
2360        let result = auto_arima(&ts(200), &vals, 2, 2, 2, None).unwrap();
2361        assert_eq!(result.order.1, 0, "stationary series was differenced");
2362        assert_eq!(result.kpss_order, 0);
2363    }
2364
2365    #[test]
2366    fn auto_arima_reports_when_max_d_bound_the_answer() {
2367        let vals: Vec<f64> = (0..200)
2368            .map(|i| (f64::from(i)).powi(2))
2369            .zip(noise(6, 200, 1.0))
2370            .map(|(a, b)| a + b)
2371            .collect();
2372        let result = auto_arima(&ts(200), &vals, 1, 1, 1, None).unwrap();
2373        assert_eq!(result.order.1, 1, "capped at max_d");
2374        assert!(
2375            result.kpss_order >= 2,
2376            "kpss wanted more: {}",
2377            result.kpss_order
2378        );
2379    }
2380
2381    #[test]
2382    fn auto_arima_returns_sorted_candidates_at_one_differencing_order() {
2383        let vals: Vec<f64> = (0..80)
2384            .map(|i| (f64::from(i) * 0.1).sin() * 5.0 + 50.0)
2385            .collect();
2386        let result = auto_arima(&ts(80), &vals, 2, 1, 2, None).unwrap();
2387        for w in result.candidates.windows(2) {
2388            assert!(w[0].3 <= w[1].3, "candidates not sorted");
2389        }
2390        assert!(
2391            result.candidates.iter().all(|c| c.1 == result.order.1),
2392            "candidates must share one differencing order to be comparable"
2393        );
2394    }
2395
2396    #[test]
2397    fn auto_arima_insufficient_data() {
2398        let vals: Vec<f64> = (0..10).map(f64::from).collect();
2399        assert!(auto_arima(&ts(10), &vals, 3, 2, 3, None).is_err());
2400    }
2401
2402    #[test]
2403    fn auto_arima_max_evals_budget() {
2404        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2405        let opts = AutoArimaOptions {
2406            max_evals: Some(3),
2407            ..Default::default()
2408        };
2409        let result = auto_arima(&ts(100), &vals, 3, 2, 3, Some(&opts)).unwrap();
2410        assert!(result.evals <= 3, "got {} evals", result.evals);
2411        assert!(result.aic.is_finite());
2412    }
2413
2414    #[test]
2415    fn auto_arima_stepwise_agrees_with_exhaustive_on_a_small_grid() {
2416        let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2417        let exhaustive = auto_arima(&ts(100), &vals, 2, 1, 2, None).unwrap();
2418        let opts = AutoArimaOptions {
2419            strategy: SearchStrategy::Stepwise,
2420            ..Default::default()
2421        };
2422        let stepwise = auto_arima(&ts(100), &vals, 2, 1, 2, Some(&opts)).unwrap();
2423        let denom = exhaustive.aic.abs().max(1e-6);
2424        assert!(
2425            (stepwise.aic - exhaustive.aic).abs() / denom < 0.10,
2426            "stepwise {} vs exhaustive {}",
2427            stepwise.aic,
2428            exhaustive.aic
2429        );
2430    }
2431
2432    #[test]
2433    fn auto_arima_stepwise_uses_fewer_fits() {
2434        // Noisy, or every candidate fits exactly, RSS is zero and no
2435        // information criterion is defined for any of them.
2436        let vals: Vec<f64> = (0..150)
2437            .map(|i| f64::from(i) * 2.0 + 10.0)
2438            .zip(noise(31, 150, 1.0))
2439            .map(|(a, b)| a + b)
2440            .collect();
2441        let exhaustive = auto_arima(&ts(150), &vals, 5, 2, 5, None).unwrap();
2442        assert!(
2443            exhaustive.evals > 10,
2444            "grid collapsed: {}",
2445            exhaustive.evals
2446        );
2447        let opts = AutoArimaOptions {
2448            strategy: SearchStrategy::Stepwise,
2449            ..Default::default()
2450        };
2451        let stepwise = auto_arima(&ts(150), &vals, 5, 2, 5, Some(&opts)).unwrap();
2452        assert!(
2453            stepwise.evals < exhaustive.evals,
2454            "stepwise {} vs exhaustive {}",
2455            stepwise.evals,
2456            exhaustive.evals
2457        );
2458    }
2459
2460    // ── SARIMA ──────────────────────────────────────────────────────
2461
2462    fn seasonal(n: usize, m: usize, amp: f64, level: f64) -> Vec<f64> {
2463        (0..n)
2464            .map(|i| level + amp * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
2465            .collect()
2466    }
2467
2468    #[test]
2469    fn sarima_seasonal_orders_change_the_forecast() {
2470        let vals = seasonal(240, 12, 5.0, 50.0);
2471        let mut plain = SarimaModel::new(1, 0, 0, 0, 0, 0, 12);
2472        plain.fit(&ts(240), &vals).unwrap();
2473        let mut seas = SarimaModel::new(1, 0, 0, 1, 0, 1, 12);
2474        seas.fit(&ts(240), &vals).unwrap();
2475        let a = plain.predict(12).unwrap();
2476        let b = seas.predict(12).unwrap();
2477        assert_ne!(
2478            a.values, b.values,
2479            "seasonal orders must reach the forecast recursion"
2480        );
2481    }
2482
2483    /// A seasonal model must beat a non-seasonal one on seasonal data — the
2484    /// property that fails the moment the seasonal coefficients stop reaching
2485    /// the forecast recursion.
2486    #[test]
2487    fn sarima_forecasts_the_season() {
2488        let m = 12;
2489        let n = 240;
2490        let vals = seasonal(n, m, 5.0, 50.0);
2491        let truth = seasonal(n + m, m, 5.0, 50.0);
2492        let expected = &truth[n..];
2493
2494        let mut seas = SarimaModel::new(0, 0, 0, 1, 0, 1, m);
2495        seas.fit(&ts(n), &vals).unwrap();
2496        let got = seas.predict(m).unwrap();
2497
2498        let mut plain = ArimaModel::new(1, 0, 0);
2499        plain.fit(&ts(n), &vals).unwrap();
2500        let baseline = plain.predict(m).unwrap();
2501
2502        let err = |p: &[f64]| -> f64 {
2503            p.iter()
2504                .zip(expected)
2505                .map(|(a, b)| (a - b).abs())
2506                .sum::<f64>()
2507                / m as f64
2508        };
2509        let seasonal_err = err(&got.values);
2510        let baseline_err = err(&baseline.values);
2511        assert!(
2512            seasonal_err < baseline_err * 0.5,
2513            "SARIMA MAE {seasonal_err:.4} should beat ARIMA MAE {baseline_err:.4}"
2514        );
2515    }
2516
2517    #[test]
2518    fn sarima_seasonal_differencing_round_trips_the_level() {
2519        // A pure repeating pattern with a linear drift: (0,0,0)(0,1,0)m
2520        // forecasts x_{t} = x_{t-m} + drift, which reproduces the pattern.
2521        let m = 12;
2522        let n = 240;
2523        let vals: Vec<f64> = (0..n)
2524            .map(|i| 50.0 + 5.0 * ((i % m) as f64) + 0.25 * i as f64)
2525            .collect();
2526        let mut model = SarimaModel::new(0, 0, 0, 0, 1, 0, m);
2527        model.fit(&ts(n), &vals).unwrap();
2528        let r = model.predict(m).unwrap();
2529        for (h, v) in r.values.iter().enumerate() {
2530            let expected = 50.0 + 5.0 * ((n + h) % m) as f64 + 0.25 * (n + h) as f64;
2531            assert!((v - expected).abs() < 1.0, "h={h}: {v} vs {expected}");
2532        }
2533    }
2534
2535    #[test]
2536    fn sarima_ignores_seasonal_orders_when_the_period_is_degenerate() {
2537        let vals: Vec<f64> = noise(8, 120, 1.0).iter().map(|v| v + 5.0).collect();
2538        let mut model = SarimaModel::new(1, 0, 0, 2, 1, 2, 1);
2539        model.fit(&ts(120), &vals).unwrap();
2540        let ModelParams::Sarima { sp, sd, sq, .. } = model.params() else {
2541            panic!("wrong variant");
2542        };
2543        assert_eq!((*sp, *sd, *sq), (0, 0, 0));
2544    }
2545
2546    #[test]
2547    fn sarima_insufficient_data() {
2548        let vals = seasonal(20, 12, 5.0, 50.0);
2549        let mut model = SarimaModel::new(1, 0, 0, 1, 1, 0, 12);
2550        assert!(model.fit(&ts(20), &vals).is_err());
2551    }
2552
2553    #[test]
2554    fn sarima_update_advances_the_forecast() {
2555        let vals = seasonal(240, 12, 5.0, 50.0);
2556        let mut model = SarimaModel::new(1, 0, 0, 1, 0, 0, 12);
2557        model.fit(&ts(240), &vals).unwrap();
2558        let before = model.predict(1).unwrap();
2559        model.update(240_000_000_000, 60.0).unwrap();
2560        let after = model.predict(1).unwrap();
2561        assert_ne!(before.values[0], after.values[0]);
2562        assert!(after.timestamps[0] > before.timestamps[0]);
2563    }
2564
2565    #[test]
2566    fn sarima_intervals_widen_with_horizon() {
2567        let vals = seasonal(240, 12, 5.0, 50.0);
2568        let mut model = SarimaModel::new(1, 0, 1, 1, 0, 1, 12);
2569        model.fit(&ts(240), &vals).unwrap();
2570        let r = model.predict(24).unwrap();
2571        let width = |h: usize| r.confidence_upper[h] - r.confidence_lower[h];
2572        // Strict growth, with the ψ-weight closed form: `>=` also passes for
2573        // a constant-width interval, which is exactly the bug it missed.
2574        for h in 1..24 {
2575            assert!(
2576                width(h) > width(h - 1),
2577                "width must grow strictly: h={h} {} vs {}",
2578                width(h),
2579                width(h - 1)
2580            );
2581        }
2582        let ModelParams::Sarima { residual_std, .. } = model.params() else {
2583            panic!()
2584        };
2585        let phi_star = integrated_ar(&model.ar_expanded, 0, 0, 12);
2586        let psi = psi_weights(&phi_star, &model.ma_expanded, 24);
2587        for h in 1..=24 {
2588            let scale: f64 = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
2589            let expected = 2.0 * 1.959_963_984_540_054 * residual_std * scale;
2590            assert!(
2591                (width(h - 1) - expected).abs() < 1e-9,
2592                "h={h}: width {} != {expected}",
2593                width(h - 1)
2594            );
2595        }
2596        for h in 0..24 {
2597            assert!(r.confidence_lower[h] <= r.values[h]);
2598            assert!(r.confidence_upper[h] >= r.values[h]);
2599        }
2600    }
2601}