1use 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
43pub(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
61pub(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
78fn 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
102pub(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 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
173fn 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
187pub(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
209pub(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
230pub(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#[inline]
269pub(crate) fn includes_constant(d: usize, seasonal_d: usize) -> bool {
270 d + seasonal_d <= 1
271}
272
273#[inline]
275pub(crate) fn conditioning_warmup(ar_len: usize, ma_len: usize) -> usize {
276 ar_len.max(ma_len)
277}
278
279fn 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
289pub(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
311fn 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 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
356fn 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
370pub(crate) fn integrated_ar(ar: &[f64], d: usize, seasonal_d: usize, m: usize) -> Vec<f64> {
383 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
401fn 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
427fn 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#[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 constant: f64,
450 warmup: usize,
453 last_ts: i64,
454 interval_ns: i64,
455 fitted: bool,
456}
457
458impl ArimaModel {
459 #[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 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 fn n_params(&self) -> usize {
522 self.p + self.q + usize::from(includes_constant(self.d, 0)) + 1
523 }
524
525 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 #[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 #[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 #[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 #[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(¢ered, self.p)?;
619 let ma_coeffs = Self::estimate_ma(¢ered, &ar_coeffs, self.q);
620 let residuals = innovations(¢ered, &ar_coeffs, &ma_coeffs);
621
622 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(¢ered, &self.residuals, ar_coeffs, ma_coeffs, horizon)
673 .into_iter()
674 .map(|v| v + constant)
675 .collect();
676
677 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783pub struct SarimaConfig {
784 pub p: usize,
786 pub d: usize,
788 pub q: usize,
790 pub sp: usize,
792 pub sd: usize,
794 pub sq: usize,
796 pub m: usize,
798}
799
800#[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 history: Vec<f64>,
821 residuals: Vec<f64>,
823 ar_expanded: Vec<f64>,
825 ma_expanded: Vec<f64>,
827 constant: f64,
829 warmup: usize,
830 last_ts: i64,
831 interval_ns: i64,
832 fitted: bool,
833}
834
835impl SarimaModel {
836 #[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 #[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 fn transform(&self, values: &[f64]) -> Vec<f64> {
889 difference(&seasonal_difference(values, self.m, self.sd), self.cfg_d)
890 }
891
892 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 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 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 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 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 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 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
1160pub const KPSS_CRITICAL_5PCT: f64 = 0.463;
1167
1168#[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 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 #[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#[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(¤t) <= KPSS_CRITICAL_5PCT {
1242 break;
1243 }
1244 current = difference(¤t, 1);
1245 d += 1;
1246 }
1247 d
1248}
1249
1250#[derive(Debug, Clone)]
1254pub struct AutoArimaResult {
1255 pub order: (usize, usize, usize),
1257 pub aic: f64,
1259 pub candidates: Vec<(usize, usize, usize, f64)>,
1261 pub evals: usize,
1263 pub kpss_order: usize,
1266}
1267
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1270pub enum SearchStrategy {
1271 #[default]
1273 Exhaustive,
1274 Stepwise,
1277}
1278
1279#[derive(Debug, Clone, Copy, Default)]
1281pub struct AutoArimaOptions {
1282 pub max_evals: Option<usize>,
1284 pub strategy: SearchStrategy,
1286}
1287
1288pub fn auto_arima(
1318 timestamps: &[i64],
1319 values: &[f64],
1320 max_p: usize,
1321 max_d: usize,
1322 max_q: usize,
1323 options: Option<&AutoArimaOptions>,
1324) -> Result<AutoArimaResult, ForecastError> {
1325 validate_input(timestamps, values)?;
1326 if values.len() < 30 {
1327 return Err(ForecastError::InsufficientData {
1328 min: 30,
1329 got: values.len(),
1330 });
1331 }
1332
1333 let kpss_order = select_differencing_order(values, max_d.max(2));
1334 let d = kpss_order.min(max_d);
1335 let opts = options.copied().unwrap_or_default();
1336 let max_evals = opts.max_evals.unwrap_or(usize::MAX);
1337
1338 let score = |p: usize, q: usize| -> Option<f64> {
1339 let min_required = p.max(q) + 2 * d + 10;
1340 if values.len() < min_required {
1341 return None;
1342 }
1343 let mut model = ArimaModel::new(p, d, q);
1344 model.fit(timestamps, values).ok()?;
1345 model.aicc().filter(|a| a.is_finite())
1346 };
1347
1348 let (order, best, mut candidates, evals) = match opts.strategy {
1349 SearchStrategy::Exhaustive => {
1350 use rayon::prelude::*;
1351 let grid: Vec<(usize, usize)> = (0..=max_p)
1352 .flat_map(|p| (0..=max_q).map(move |q| (p, q)))
1353 .filter(|&(p, q)| p + q > 0)
1354 .take(max_evals)
1355 .collect();
1356 let found: Vec<(usize, usize, usize, f64)> = grid
1357 .par_iter()
1358 .filter_map(|&(p, q)| score(p, q).map(|a| (p, d, q, a)))
1359 .collect();
1360 let evals = found.len();
1361 let best = found
1362 .iter()
1363 .copied()
1364 .min_by(|a, b| a.3.total_cmp(&b.3))
1365 .ok_or_else(|| {
1366 ForecastError::NumericalInstability(
1367 "no ARIMA model in the grid could be fitted".into(),
1368 )
1369 })?;
1370 ((best.0, best.1, best.2), best.3, found, evals)
1371 }
1372 SearchStrategy::Stepwise => stepwise(max_p, max_q, d, max_evals, &score)?,
1373 };
1374
1375 candidates.sort_by(|a, b| a.3.total_cmp(&b.3));
1376 Ok(AutoArimaResult {
1377 order,
1378 aic: best,
1379 candidates,
1380 evals,
1381 kpss_order,
1382 })
1383}
1384
1385#[allow(clippy::type_complexity)]
1387fn stepwise(
1388 max_p: usize,
1389 max_q: usize,
1390 d: usize,
1391 max_evals: usize,
1392 score: &dyn Fn(usize, usize) -> Option<f64>,
1393) -> Result<
1394 (
1395 (usize, usize, usize),
1396 f64,
1397 Vec<(usize, usize, usize, f64)>,
1398 usize,
1399 ),
1400 ForecastError,
1401> {
1402 let mut candidates: Vec<(usize, usize, usize, f64)> = Vec::new();
1403 let mut visited: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
1404 let mut evals = 0usize;
1405
1406 let mut best_score = f64::INFINITY;
1407 let mut best = (0usize, 0usize);
1408
1409 let mut try_model = |p: usize,
1410 q: usize,
1411 candidates: &mut Vec<(usize, usize, usize, f64)>,
1412 evals: &mut usize|
1413 -> Option<f64> {
1414 if p > max_p || q > max_q || (p == 0 && q == 0) {
1415 return None;
1416 }
1417 if !visited.insert((p, q)) {
1418 return candidates
1419 .iter()
1420 .find(|c| c.0 == p && c.2 == q)
1421 .map(|c| c.3);
1422 }
1423 if *evals >= max_evals {
1424 return None;
1425 }
1426 let s = score(p, q)?;
1427 *evals += 1;
1428 candidates.push((p, d, q, s));
1429 Some(s)
1430 };
1431
1432 for &(p, q) in &[(2, 2), (0, 0), (1, 0), (0, 1)] {
1433 let (p, q) = (p.min(max_p), q.min(max_q));
1434 if let Some(s) = try_model(p, q, &mut candidates, &mut evals) {
1435 if s < best_score {
1436 best_score = s;
1437 best = (p, q);
1438 }
1439 }
1440 }
1441 if best_score.is_infinite() {
1442 return Err(ForecastError::NumericalInstability(
1443 "no ARIMA model in the stepwise seed set could be fitted".into(),
1444 ));
1445 }
1446
1447 const DELTAS: [(i8, i8); 8] = [
1448 (1, 0),
1449 (-1, 0),
1450 (0, 1),
1451 (0, -1),
1452 (1, -1),
1453 (-1, 1),
1454 (1, 1),
1455 (-1, -1),
1456 ];
1457 loop {
1458 let (cp, cq) = best;
1459 let mut improved = false;
1460 for (dp, dq) in DELTAS {
1461 let (Ok(np), Ok(nq)) = (
1462 usize::try_from(cp as i64 + i64::from(dp)),
1463 usize::try_from(cq as i64 + i64::from(dq)),
1464 ) else {
1465 continue;
1466 };
1467 if let Some(s) = try_model(np, nq, &mut candidates, &mut evals) {
1468 if s < best_score {
1469 best_score = s;
1470 best = (np, nq);
1471 improved = true;
1472 }
1473 }
1474 }
1475 if !improved || evals >= max_evals {
1476 break;
1477 }
1478 }
1479
1480 Ok(((best.0, d, best.1), best_score, candidates, evals))
1481}
1482
1483impl crate::forecast::storage::ModelStore for ArimaModel {}
1484impl crate::forecast::storage::ModelStore for SarimaModel {}
1485
1486#[cfg(test)]
1487#[allow(clippy::unwrap_used, clippy::expect_used)]
1488mod tests {
1489 use super::*;
1490
1491 fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
1494 let mut s = seed;
1495 (0..n)
1496 .map(|_| {
1497 s = s
1498 .wrapping_mul(6_364_136_223_846_793_005)
1499 .wrapping_add(1_442_695_040_888_963_407);
1500 ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
1501 })
1502 .collect()
1503 }
1504
1505 fn ts(n: usize) -> Vec<i64> {
1506 (0..n as i64).map(|i| i * 1_000_000_000).collect()
1507 }
1508
1509 #[test]
1512 fn difference_and_undifference_round_trip() {
1513 let series: Vec<f64> = (0..40).map(|i| (i as f64).powi(2) + 3.0).collect();
1514 for d in 1..=3 {
1515 let diffed = difference(&series, d);
1516 let split = 30;
1518 let mut preds = diffed[diffed.len() - (40 - split)..].to_vec();
1519 let mut anchors = Vec::new();
1520 let mut level = series[..split].to_vec();
1521 for _ in 0..d {
1522 anchors.push(*level.last().unwrap());
1523 level = difference(&level, 1);
1524 }
1525 for a in anchors.into_iter().rev() {
1526 preds = undifference_lag(&preds, &[a], 1);
1527 }
1528 for (i, v) in preds.iter().enumerate() {
1529 assert!(
1530 (v - series[split + i]).abs() < 1e-6,
1531 "d={d} i={i}: {v} vs {}",
1532 series[split + i]
1533 );
1534 }
1535 }
1536 }
1537
1538 #[test]
1539 fn seasonal_difference_and_undifference_round_trip() {
1540 let m = 4;
1541 let series: Vec<f64> = (0..40)
1542 .map(|i| (i % m) as f64 * 2.0 + i as f64 * 0.1)
1543 .collect();
1544 let diffed = seasonal_difference(&series, m, 1);
1545 let split = 30;
1546 let preds = diffed[diffed.len() - (40 - split)..].to_vec();
1547 let anchor = &series[split - m..split];
1548 let restored = undifference_lag(&preds, anchor, m);
1549 for (i, v) in restored.iter().enumerate() {
1550 assert!((v - series[split + i]).abs() < 1e-9, "i={i}: {v}");
1551 }
1552 }
1553
1554 #[test]
1555 fn expanded_polynomials_match_the_multiplicative_form() {
1556 let ar = expand_ar(&[0.5], &[0.3], 4);
1559 let expected = [0.5, 0.0, 0.0, 0.3, -0.15];
1560 assert_eq!(ar.len(), expected.len());
1561 for (a, b) in ar.iter().zip(expected) {
1562 assert!((a - b).abs() < 1e-12, "{ar:?}");
1563 }
1564 let ma = expand_ma(&[0.4], &[0.2], 3);
1566 let expected = [0.4, 0.0, 0.2, 0.08];
1567 for (a, b) in ma.iter().zip(expected) {
1568 assert!((a - b).abs() < 1e-12, "{ma:?}");
1569 }
1570 }
1571
1572 #[test]
1573 fn expanded_polynomials_degenerate_without_seasonal_terms() {
1574 assert_eq!(expand_ar(&[0.5, -0.2], &[], 12), vec![0.5, -0.2]);
1575 assert_eq!(expand_ma(&[0.4], &[], 12), vec![0.4]);
1576 }
1577
1578 #[test]
1579 fn psi_weights_degenerate_to_sqrt_h() {
1580 let psi = psi_weights(&[], &[], 5);
1581 assert_eq!(psi.len(), 5);
1582 assert!((psi[0] - 1.0).abs() < 1e-12);
1583 for (j, w) in psi.iter().enumerate().skip(1) {
1584 assert!(w.abs() < 1e-12, "ψ_{j} should be 0 for pure noise");
1585 }
1586
1587 let psi_ar = psi_weights(&[0.5], &[], 4);
1588 for (j, expected) in [1.0, 0.5, 0.25, 0.125].into_iter().enumerate() {
1589 assert!((psi_ar[j] - expected).abs() < 1e-12);
1590 }
1591
1592 let psi_ma = psi_weights(&[], &[0.8], 4);
1593 assert!((psi_ma[0] - 1.0).abs() < 1e-12);
1594 assert!((psi_ma[1] - 0.8).abs() < 1e-12);
1595 assert!(psi_ma[2].abs() < 1e-12);
1596 assert!(psi_ma[3].abs() < 1e-12);
1597 }
1598
1599 #[test]
1600 fn burg_recovers_a_known_ar2_and_stays_stable() {
1601 let n = 1000;
1602 let eps = noise(12345, n, 0.25);
1603 let mut vals = vec![0.0; n];
1604 for t in 2..n {
1605 vals[t] = 0.75 * vals[t - 1] - 0.5 * vals[t - 2] + eps[t];
1606 }
1607 let (coeffs, _) = estimate_ar_burg(&vals[200..], 2).unwrap();
1608 assert!((coeffs[0] - 0.75).abs() < 0.15, "{coeffs:?}");
1609 assert!((coeffs[1] + 0.5).abs() < 0.15, "{coeffs:?}");
1610 let (b, c) = (-coeffs[0], -coeffs[1]);
1612 let disc = b * b - 4.0 * c;
1613 if disc >= 0.0 {
1614 let r1 = (-b + disc.sqrt()) / 2.0;
1615 let r2 = (-b - disc.sqrt()) / 2.0;
1616 assert!(
1617 r1.abs() < 1.0 && r2.abs() < 1.0,
1618 "unstable roots {r1}, {r2}"
1619 );
1620 }
1621 }
1622
1623 #[test]
1629 fn random_walk_interval_variance_grows_exactly_linearly() {
1630 let e = noise(7, 200, 1.0);
1631 let mut vals = vec![100.0];
1632 for v in e.iter().take(199) {
1633 vals.push(vals.last().unwrap() + v);
1634 }
1635 let mut model = ArimaModel::new(0, 1, 0);
1636 model.fit(&ts(200), &vals).unwrap();
1637 let r = model.predict(12).unwrap();
1638 let half = |h: usize| r.confidence_upper[h] - r.values[h];
1639 assert!(half(0) > 0.0);
1640 for h in 1..=12 {
1641 let ratio = (half(h - 1) / half(0)).powi(2);
1642 assert!(
1643 (ratio - h as f64).abs() < 1e-9,
1644 "var({h})/var(1) = {ratio}, want {h}"
1645 );
1646 }
1647 }
1648
1649 #[test]
1652 fn arima_110_interval_matches_the_psi_closed_form() {
1653 let e = noise(11, 240, 1.0);
1654 let mut vals = vec![50.0];
1655 let mut prev_d = 0.0;
1656 for v in e.iter().take(239) {
1657 let d = 0.6 * prev_d + v;
1658 vals.push(vals.last().unwrap() + d);
1659 prev_d = d;
1660 }
1661 let mut model = ArimaModel::new(1, 1, 0);
1662 model.fit(&ts(240), &vals).unwrap();
1663 let ModelParams::Arima {
1664 ar_coeffs,
1665 residual_std,
1666 ..
1667 } = model.params()
1668 else {
1669 panic!()
1670 };
1671 let phi = ar_coeffs[0];
1672 assert!(phi.abs() > 0.1, "need a non-trivial φ, got {phi}");
1673 let r = model.predict(10).unwrap();
1674 for h in 1..=10 {
1675 let factor: f64 = (0..h)
1676 .map(|j| {
1677 let psi = (1.0 - phi.powi(j as i32 + 1)) / (1.0 - phi);
1678 psi * psi
1679 })
1680 .sum::<f64>();
1681 let expected = 1.959_963_984_540_054 * residual_std * factor.sqrt();
1682 let half = r.confidence_upper[h - 1] - r.values[h - 1];
1683 assert!(
1684 (half - expected).abs() < 1e-9,
1685 "h={h}: half-width {half} != {expected}"
1686 );
1687 }
1688 }
1689
1690 #[test]
1692 fn integrated_ar_expands_the_differencing_operator() {
1693 assert_eq!(integrated_ar(&[], 1, 0, 0), vec![1.0]);
1695 let d2 = integrated_ar(&[], 2, 0, 0);
1697 assert!((d2[0] - 2.0).abs() < 1e-12 && (d2[1] + 1.0).abs() < 1e-12);
1698 let m = integrated_ar(&[0.5], 1, 0, 0);
1700 assert!((m[0] - 1.5).abs() < 1e-12 && (m[1] + 0.5).abs() < 1e-12);
1701 let sd = integrated_ar(&[], 0, 1, 4);
1703 assert_eq!(sd.len(), 4);
1704 assert!((sd[3] - 1.0).abs() < 1e-12);
1705 assert!(sd[..3].iter().all(|c| c.abs() < 1e-12));
1706 assert_eq!(integrated_ar(&[0.3, -0.2], 0, 0, 12), vec![0.3, -0.2]);
1708 }
1709
1710 #[test]
1715 fn arma_forecast_reads_the_newest_innovations() {
1716 let values = [0.0, 0.0, 0.0];
1717 let residuals = [1.0, 2.0, 3.0, 4.0, 5.0];
1718 let out = arma_forecast(&values, &residuals, &[], &[0.5], 1);
1719 assert!((out[0] - 2.5).abs() < 1e-12, "got {}", out[0]);
1720 }
1721
1722 #[test]
1726 fn update_keeps_history_and_residuals_aligned() {
1727 let vals: Vec<f64> = (0..100)
1728 .map(|i| 10.0 + i as f64 * 0.3 + ((i * 17) % 7) as f64 * 0.4)
1729 .collect();
1730 let mut model = ArimaModel::new(1, 1, 1);
1731 model.fit(&ts(100), &vals).unwrap();
1732 for i in 0..300 {
1733 let v = 40.0 + ((i * 13) % 11) as f64 * 0.5;
1734 model.update((100 + i) as i64 * 1_000_000_000, v).unwrap();
1735 }
1736 assert!(model.history.len() < 300, "history was never trimmed");
1737 assert_eq!(
1738 model.residuals.len(),
1739 model.history.len() - model.d,
1740 "residuals must stay aligned with difference(history, d)"
1741 );
1742
1743 let ModelParams::Arima {
1747 ar_coeffs,
1748 ma_coeffs,
1749 constant,
1750 ..
1751 } = model.params().clone()
1752 else {
1753 panic!()
1754 };
1755 let diffed: Vec<f64> = difference(&model.history, 1)
1756 .into_iter()
1757 .map(|v| v - constant)
1758 .collect();
1759 let n = diffed.len();
1760 let expected_diff = constant
1761 + ar_coeffs[0] * diffed[n - 1]
1762 + ma_coeffs[0] * model.residuals[model.residuals.len() - 1];
1763 let expected = model.history[model.history.len() - 1] + expected_diff;
1764 let got = model.predict(1).unwrap().values[0];
1765 assert!((got - expected).abs() < 1e-9, "{got} != {expected}");
1766 }
1767
1768 #[test]
1770 fn sarima_update_keeps_history_and_residuals_aligned() {
1771 let vals = {
1772 (0..120)
1773 .map(|i| 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin())
1774 .collect::<Vec<f64>>()
1775 };
1776 let mut model = SarimaModel::new(1, 1, 1, 0, 1, 0, 12);
1777 model.fit(&ts(120), &vals).unwrap();
1778 for i in 0..400 {
1779 let v = 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin();
1780 model.update((120 + i) as i64 * 1_000_000_000, v).unwrap();
1781 }
1782 assert!(model.history.len() < 400, "history was never trimmed");
1783 assert_eq!(model.residuals.len(), model.centered(&model.history).len());
1784 }
1785
1786 #[test]
1787 fn arima_random_walk() {
1788 let e = noise(42, 200, 1.0);
1789 let mut vals = vec![100.0];
1790 for v in e.iter().take(199) {
1791 vals.push(vals.last().unwrap() + v);
1792 }
1793 let mut model = ArimaModel::new(1, 1, 0);
1794 model.fit(&ts(200), &vals).unwrap();
1795 let result = model.predict(10).unwrap();
1796 assert_eq!(result.values.len(), 10);
1797 assert!((result.values[0] - vals[199]).abs() < 10.0);
1798 }
1799
1800 #[test]
1801 fn arima_linear_trend() {
1802 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1803 let mut model = ArimaModel::new(0, 1, 0);
1804 model.fit(&ts(100), &vals).unwrap();
1805 let result = model.predict(5).unwrap();
1806 for (i, v) in result.values.iter().enumerate() {
1809 let expected = 208.0 + (i + 1) as f64 * 2.0;
1810 assert!((v - expected).abs() < 1e-6, "h={i}: {v} vs {expected}");
1811 }
1812 }
1813
1814 #[test]
1819 fn arima_d2_extrapolates_a_line_exactly() {
1820 let n = 60usize;
1821 let vals: Vec<f64> = (0..n).map(|i| 3.0 * i as f64 - 7.0).collect();
1822 let mut model = ArimaModel::new(0, 2, 0);
1823 model.fit(&ts(n), &vals).unwrap();
1824 let result = model.predict(5).unwrap();
1825 for (h, v) in result.values.iter().enumerate() {
1826 let expected = 3.0 * (n + h) as f64 - 7.0;
1827 assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1828 }
1829 }
1830
1831 #[test]
1832 fn arima_d2_undifference_correctness() {
1833 let n = 100usize;
1834 let vals: Vec<f64> = (0..n).map(|i| (i as f64).powi(2)).collect();
1835 let mut model = ArimaModel::new(0, 2, 0);
1836 model.fit(&ts(n), &vals).unwrap();
1837 let result = model.predict(5).unwrap();
1838 let slope = vals[n - 1] - vals[n - 2];
1843 for (h, v) in result.values.iter().enumerate() {
1844 let expected = vals[n - 1] + (h + 1) as f64 * slope;
1845 assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1846 }
1847 }
1848
1849 #[test]
1850 fn arima_confidence_intervals_bracket_the_forecast() {
1851 let vals: Vec<f64> = (0..100)
1852 .map(|i| (f64::from(i)).sin() * 5.0 + 50.0)
1853 .collect();
1854 let mut model = ArimaModel::new(2, 0, 1);
1855 model.fit(&ts(100), &vals).unwrap();
1856 let result = model.predict(10).unwrap();
1857 for i in 0..10 {
1858 assert!(result.confidence_lower[i] <= result.values[i]);
1859 assert!(result.confidence_upper[i] >= result.values[i]);
1860 }
1861 }
1862
1863 #[test]
1868 fn arima_intervals_track_the_noise_not_the_level() {
1869 let n = 200;
1870 let e = noise(7, n, 0.1);
1871 let mut vals = Vec::with_capacity(n);
1872 let mut x = 100.0_f64;
1873 for v in &e {
1874 x = 100.0 + 0.5 * (x - 100.0) + v;
1875 vals.push(x);
1876 }
1877 let mut model = ArimaModel::new(1, 0, 0);
1878 model.fit(&ts(n), &vals).unwrap();
1879 let r = model.predict(3).unwrap();
1880 let half_width = r.confidence_upper[0] - r.values[0];
1881 assert!(
1882 half_width < 0.5,
1883 "one-step 95% half-width {half_width} exceeds the noise scale (0.1)"
1884 );
1885 assert!(
1886 half_width > 0.05,
1887 "interval collapsed to nothing: {half_width}"
1888 );
1889 }
1890
1891 #[test]
1892 fn arima_insufficient_data() {
1893 let mut model = ArimaModel::new(2, 1, 1);
1894 assert!(model.fit(&[0, 1, 2], &[1.0, 2.0, 3.0]).is_err());
1895 }
1896
1897 #[test]
1898 fn arima_update() {
1899 let vals: Vec<f64> = (0..50).map(f64::from).collect();
1900 let mut model = ArimaModel::new(1, 1, 0);
1901 model.fit(&ts(50), &vals).unwrap();
1902 assert!(model.update(50_000_000_000, 50.0).is_ok());
1903 assert!(model.predict(3).unwrap().values.len() == 3);
1904 }
1905
1906 #[test]
1907 fn css_recovers_a_known_arma11() {
1908 let n = 1000;
1909 let eps = noise(99999, n, 0.2);
1910 let mut vals = vec![0.0; n];
1911 for t in 1..n {
1912 vals[t] = 0.6 * vals[t - 1] + eps[t] + 0.4 * eps[t - 1];
1913 }
1914 let vals = &vals[200..];
1915 let mut model = ArimaModel::new(1, 0, 1);
1916 model.fit(&ts(vals.len()), vals).unwrap();
1917 let ModelParams::Arima {
1918 ma_coeffs,
1919 ar_coeffs,
1920 ..
1921 } = model.params()
1922 else {
1923 panic!("wrong variant");
1924 };
1925 assert!((ma_coeffs[0] - 0.4).abs() < 0.3, "θ = {}", ma_coeffs[0]);
1926 assert!((ar_coeffs[0] - 0.6).abs() < 0.2, "φ = {}", ar_coeffs[0]);
1927 }
1928
1929 #[test]
1932 fn kpss_accepts_stationary_and_rejects_a_random_walk() {
1933 let white = noise(3, 300, 1.0);
1934 assert!(
1935 kpss_statistic(&white) < KPSS_CRITICAL_5PCT,
1936 "white noise rejected: {}",
1937 kpss_statistic(&white)
1938 );
1939
1940 let mut walk = vec![0.0];
1941 for v in white.iter().take(299) {
1942 walk.push(walk.last().unwrap() + v);
1943 }
1944 assert!(
1945 kpss_statistic(&walk) > KPSS_CRITICAL_5PCT,
1946 "random walk accepted: {}",
1947 kpss_statistic(&walk)
1948 );
1949 assert_eq!(select_differencing_order(&white, 2), 0);
1950 assert_eq!(select_differencing_order(&walk, 2), 1);
1951 }
1952
1953 #[test]
1954 fn kpss_is_scale_invariant() {
1955 let v = noise(11, 200, 1.0);
1956 let scaled: Vec<f64> = v.iter().map(|x| x * 1000.0 + 5.0).collect();
1957 let a = kpss_statistic(&v);
1958 let b = kpss_statistic(&scaled);
1959 assert!((a - b).abs() < 1e-9, "{a} vs {b}");
1960 }
1961
1962 #[test]
1963 fn kpss_handles_degenerate_input() {
1964 assert_eq!(kpss_statistic(&[]), 0.0);
1965 assert_eq!(kpss_statistic(&[1.0, 1.0]), 0.0);
1966 assert_eq!(kpss_statistic(&[7.0; 50]), 0.0);
1967 assert_eq!(select_differencing_order(&[7.0; 50], 2), 0);
1968 }
1969
1970 #[test]
1971 fn select_differencing_order_respects_the_cap() {
1972 let mut quad: Vec<f64> = (0..200).map(|i| (f64::from(i)).powi(2)).collect();
1973 quad.iter_mut()
1974 .zip(noise(5, 200, 1.0))
1975 .for_each(|(v, e)| *v += e);
1976 assert_eq!(select_differencing_order(&quad, 1), 1);
1977 assert!(select_differencing_order(&quad, 3) >= 2);
1978 }
1979
1980 #[test]
1983 fn auto_arima_differences_a_trend() {
1984 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1985 let result = auto_arima(&ts(100), &vals, 2, 2, 2, None).unwrap();
1986 assert!(result.order.1 >= 1, "d should be >= 1 for trending data");
1987 assert!(!result.candidates.is_empty());
1988 assert!(result.aic.is_finite());
1989 assert!(result.evals > 0);
1990 }
1991
1992 #[test]
1996 fn auto_arima_does_not_overdifference_stationary_noise() {
1997 let vals: Vec<f64> = noise(2024, 200, 0.5).iter().map(|v| v + 10.0).collect();
1998 let result = auto_arima(&ts(200), &vals, 2, 2, 2, None).unwrap();
1999 assert_eq!(result.order.1, 0, "stationary series was differenced");
2000 assert_eq!(result.kpss_order, 0);
2001 }
2002
2003 #[test]
2004 fn auto_arima_reports_when_max_d_bound_the_answer() {
2005 let vals: Vec<f64> = (0..200)
2006 .map(|i| (f64::from(i)).powi(2))
2007 .zip(noise(6, 200, 1.0))
2008 .map(|(a, b)| a + b)
2009 .collect();
2010 let result = auto_arima(&ts(200), &vals, 1, 1, 1, None).unwrap();
2011 assert_eq!(result.order.1, 1, "capped at max_d");
2012 assert!(
2013 result.kpss_order >= 2,
2014 "kpss wanted more: {}",
2015 result.kpss_order
2016 );
2017 }
2018
2019 #[test]
2020 fn auto_arima_returns_sorted_candidates_at_one_differencing_order() {
2021 let vals: Vec<f64> = (0..80)
2022 .map(|i| (f64::from(i) * 0.1).sin() * 5.0 + 50.0)
2023 .collect();
2024 let result = auto_arima(&ts(80), &vals, 2, 1, 2, None).unwrap();
2025 for w in result.candidates.windows(2) {
2026 assert!(w[0].3 <= w[1].3, "candidates not sorted");
2027 }
2028 assert!(
2029 result.candidates.iter().all(|c| c.1 == result.order.1),
2030 "candidates must share one differencing order to be comparable"
2031 );
2032 }
2033
2034 #[test]
2035 fn auto_arima_insufficient_data() {
2036 let vals: Vec<f64> = (0..10).map(f64::from).collect();
2037 assert!(auto_arima(&ts(10), &vals, 3, 2, 3, None).is_err());
2038 }
2039
2040 #[test]
2041 fn auto_arima_max_evals_budget() {
2042 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2043 let opts = AutoArimaOptions {
2044 max_evals: Some(3),
2045 ..Default::default()
2046 };
2047 let result = auto_arima(&ts(100), &vals, 3, 2, 3, Some(&opts)).unwrap();
2048 assert!(result.evals <= 3, "got {} evals", result.evals);
2049 assert!(result.aic.is_finite());
2050 }
2051
2052 #[test]
2053 fn auto_arima_stepwise_agrees_with_exhaustive_on_a_small_grid() {
2054 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2055 let exhaustive = auto_arima(&ts(100), &vals, 2, 1, 2, None).unwrap();
2056 let opts = AutoArimaOptions {
2057 strategy: SearchStrategy::Stepwise,
2058 ..Default::default()
2059 };
2060 let stepwise = auto_arima(&ts(100), &vals, 2, 1, 2, Some(&opts)).unwrap();
2061 let denom = exhaustive.aic.abs().max(1e-6);
2062 assert!(
2063 (stepwise.aic - exhaustive.aic).abs() / denom < 0.10,
2064 "stepwise {} vs exhaustive {}",
2065 stepwise.aic,
2066 exhaustive.aic
2067 );
2068 }
2069
2070 #[test]
2071 fn auto_arima_stepwise_uses_fewer_fits() {
2072 let vals: Vec<f64> = (0..150)
2075 .map(|i| f64::from(i) * 2.0 + 10.0)
2076 .zip(noise(31, 150, 1.0))
2077 .map(|(a, b)| a + b)
2078 .collect();
2079 let exhaustive = auto_arima(&ts(150), &vals, 5, 2, 5, None).unwrap();
2080 assert!(
2081 exhaustive.evals > 10,
2082 "grid collapsed: {}",
2083 exhaustive.evals
2084 );
2085 let opts = AutoArimaOptions {
2086 strategy: SearchStrategy::Stepwise,
2087 ..Default::default()
2088 };
2089 let stepwise = auto_arima(&ts(150), &vals, 5, 2, 5, Some(&opts)).unwrap();
2090 assert!(
2091 stepwise.evals < exhaustive.evals,
2092 "stepwise {} vs exhaustive {}",
2093 stepwise.evals,
2094 exhaustive.evals
2095 );
2096 }
2097
2098 fn seasonal(n: usize, m: usize, amp: f64, level: f64) -> Vec<f64> {
2101 (0..n)
2102 .map(|i| level + amp * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
2103 .collect()
2104 }
2105
2106 #[test]
2107 fn sarima_seasonal_orders_change_the_forecast() {
2108 let vals = seasonal(240, 12, 5.0, 50.0);
2109 let mut plain = SarimaModel::new(1, 0, 0, 0, 0, 0, 12);
2110 plain.fit(&ts(240), &vals).unwrap();
2111 let mut seas = SarimaModel::new(1, 0, 0, 1, 0, 1, 12);
2112 seas.fit(&ts(240), &vals).unwrap();
2113 let a = plain.predict(12).unwrap();
2114 let b = seas.predict(12).unwrap();
2115 assert_ne!(
2116 a.values, b.values,
2117 "seasonal orders must reach the forecast recursion"
2118 );
2119 }
2120
2121 #[test]
2125 fn sarima_forecasts_the_season() {
2126 let m = 12;
2127 let n = 240;
2128 let vals = seasonal(n, m, 5.0, 50.0);
2129 let truth = seasonal(n + m, m, 5.0, 50.0);
2130 let expected = &truth[n..];
2131
2132 let mut seas = SarimaModel::new(0, 0, 0, 1, 0, 1, m);
2133 seas.fit(&ts(n), &vals).unwrap();
2134 let got = seas.predict(m).unwrap();
2135
2136 let mut plain = ArimaModel::new(1, 0, 0);
2137 plain.fit(&ts(n), &vals).unwrap();
2138 let baseline = plain.predict(m).unwrap();
2139
2140 let err = |p: &[f64]| -> f64 {
2141 p.iter()
2142 .zip(expected)
2143 .map(|(a, b)| (a - b).abs())
2144 .sum::<f64>()
2145 / m as f64
2146 };
2147 let seasonal_err = err(&got.values);
2148 let baseline_err = err(&baseline.values);
2149 assert!(
2150 seasonal_err < baseline_err * 0.5,
2151 "SARIMA MAE {seasonal_err:.4} should beat ARIMA MAE {baseline_err:.4}"
2152 );
2153 }
2154
2155 #[test]
2156 fn sarima_seasonal_differencing_round_trips_the_level() {
2157 let m = 12;
2160 let n = 240;
2161 let vals: Vec<f64> = (0..n)
2162 .map(|i| 50.0 + 5.0 * ((i % m) as f64) + 0.25 * i as f64)
2163 .collect();
2164 let mut model = SarimaModel::new(0, 0, 0, 0, 1, 0, m);
2165 model.fit(&ts(n), &vals).unwrap();
2166 let r = model.predict(m).unwrap();
2167 for (h, v) in r.values.iter().enumerate() {
2168 let expected = 50.0 + 5.0 * ((n + h) % m) as f64 + 0.25 * (n + h) as f64;
2169 assert!((v - expected).abs() < 1.0, "h={h}: {v} vs {expected}");
2170 }
2171 }
2172
2173 #[test]
2174 fn sarima_ignores_seasonal_orders_when_the_period_is_degenerate() {
2175 let vals: Vec<f64> = noise(8, 120, 1.0).iter().map(|v| v + 5.0).collect();
2176 let mut model = SarimaModel::new(1, 0, 0, 2, 1, 2, 1);
2177 model.fit(&ts(120), &vals).unwrap();
2178 let ModelParams::Sarima { sp, sd, sq, .. } = model.params() else {
2179 panic!("wrong variant");
2180 };
2181 assert_eq!((*sp, *sd, *sq), (0, 0, 0));
2182 }
2183
2184 #[test]
2185 fn sarima_insufficient_data() {
2186 let vals = seasonal(20, 12, 5.0, 50.0);
2187 let mut model = SarimaModel::new(1, 0, 0, 1, 1, 0, 12);
2188 assert!(model.fit(&ts(20), &vals).is_err());
2189 }
2190
2191 #[test]
2192 fn sarima_update_advances_the_forecast() {
2193 let vals = seasonal(240, 12, 5.0, 50.0);
2194 let mut model = SarimaModel::new(1, 0, 0, 1, 0, 0, 12);
2195 model.fit(&ts(240), &vals).unwrap();
2196 let before = model.predict(1).unwrap();
2197 model.update(240_000_000_000, 60.0).unwrap();
2198 let after = model.predict(1).unwrap();
2199 assert_ne!(before.values[0], after.values[0]);
2200 assert!(after.timestamps[0] > before.timestamps[0]);
2201 }
2202
2203 #[test]
2204 fn sarima_intervals_widen_with_horizon() {
2205 let vals = seasonal(240, 12, 5.0, 50.0);
2206 let mut model = SarimaModel::new(1, 0, 1, 1, 0, 1, 12);
2207 model.fit(&ts(240), &vals).unwrap();
2208 let r = model.predict(24).unwrap();
2209 let width = |h: usize| r.confidence_upper[h] - r.confidence_lower[h];
2210 for h in 1..24 {
2213 assert!(
2214 width(h) > width(h - 1),
2215 "width must grow strictly: h={h} {} vs {}",
2216 width(h),
2217 width(h - 1)
2218 );
2219 }
2220 let ModelParams::Sarima { residual_std, .. } = model.params() else {
2221 panic!()
2222 };
2223 let phi_star = integrated_ar(&model.ar_expanded, 0, 0, 12);
2224 let psi = psi_weights(&phi_star, &model.ma_expanded, 24);
2225 for h in 1..=24 {
2226 let scale: f64 = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
2227 let expected = 2.0 * 1.959_963_984_540_054 * residual_std * scale;
2228 assert!(
2229 (width(h - 1) - expected).abs() < 1e-9,
2230 "h={h}: width {} != {expected}",
2231 width(h - 1)
2232 );
2233 }
2234 for h in 0..24 {
2235 assert!(r.confidence_lower[h] <= r.values[h]);
2236 assert!(r.confidence_upper[h] >= r.values[h]);
2237 }
2238 }
2239}