chronix_analytics/forecast/result.rs
1//! Forecast result types and model metadata.
2
3/// Type of forecast model.
4#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5pub enum ModelType {
6 /// Simple Exponential Smoothing.
7 Ses,
8 /// Holt's Linear Trend (double exponential smoothing).
9 HoltLinear,
10 /// Holt-Winters (triple exponential smoothing with seasonality).
11 HoltWinters,
12 /// ARIMA(p,d,q) — autoregressive integrated moving average.
13 Arima,
14 /// SARIMA(p,d,q)(P,D,Q)m — seasonal ARIMA.
15 Sarima,
16 /// Ordinary Least Squares linear regression.
17 LinearRegression,
18 /// Custom plugin model identified by name.
19 Custom(String),
20}
21
22/// Forecast result with predictions and confidence intervals.
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24pub struct ForecastResult {
25 /// Predicted values.
26 pub values: Vec<f64>,
27 /// Predicted timestamps (nanoseconds).
28 pub timestamps: Vec<i64>,
29 /// Lower confidence bound.
30 pub confidence_lower: Vec<f64>,
31 /// Upper confidence bound.
32 pub confidence_upper: Vec<f64>,
33 /// Confidence level (e.g. 0.95).
34 pub confidence_level: f64,
35}
36
37/// Fitted model parameters — variant per model type.
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub enum ModelParams {
40 /// Simple Exponential Smoothing parameters.
41 Ses {
42 /// Smoothing factor α ∈ (0, 1).
43 alpha: f64,
44 /// Last smoothed level.
45 level: f64,
46 /// Standard deviation of in-sample residuals.
47 residual_std: f64,
48 },
49 /// Holt's Linear Trend parameters.
50 HoltLinear {
51 /// Level smoothing factor α.
52 alpha: f64,
53 /// Trend smoothing factor β.
54 beta: f64,
55 /// Last smoothed level.
56 level: f64,
57 /// Last smoothed trend.
58 trend: f64,
59 /// Damping factor ϕ (1.0 = undamped).
60 phi: f64,
61 /// Standard deviation of in-sample residuals.
62 residual_std: f64,
63 },
64 /// Holt-Winters (triple exponential smoothing) parameters.
65 HoltWinters {
66 /// Level smoothing factor α.
67 alpha: f64,
68 /// Trend smoothing factor β.
69 beta: f64,
70 /// Seasonal smoothing factor γ.
71 gamma: f64,
72 /// Seasonal period length (number of observations per cycle).
73 period: usize,
74 /// Last smoothed level.
75 level: f64,
76 /// Last smoothed trend.
77 trend: f64,
78 /// Seasonal indices (one per period position).
79 seasonal: Vec<f64>,
80 /// Whether multiplicative (true) or additive (false) seasonality is used.
81 multiplicative: bool,
82 /// Standard deviation of in-sample residuals.
83 residual_std: f64,
84 /// Phase offset into the seasonal vector for the first prediction step.
85 /// Set to `n_train % period` during fit so predictions continue the cycle.
86 #[serde(default)]
87 seasonal_phase: usize,
88 },
89 /// ARIMA(p,d,q) parameters.
90 Arima {
91 /// Autoregressive order.
92 p: usize,
93 /// Differencing order.
94 d: usize,
95 /// Moving average order.
96 q: usize,
97 /// Autoregressive coefficients φ₁…φₚ.
98 ar_coeffs: Vec<f64>,
99 /// Moving average coefficients θ₁…θ_q.
100 ma_coeffs: Vec<f64>,
101 /// Mean of the differenced series, added back to every forecast.
102 ///
103 /// Zero when the model carries no constant — see
104 /// [`ArimaModel`](crate::forecast::ArimaModel) for when one is
105 /// included.
106 constant: f64,
107 /// Standard deviation of in-sample residuals.
108 residual_std: f64,
109 /// Effective AR order after Burg estimation.
110 /// May be less than `p` if the algorithm terminated early
111 /// due to near-constant input data.
112 #[serde(default)]
113 effective_ar_order: Option<usize>,
114 },
115 /// Seasonal ARIMA (p,d,q)(P,D,Q)m parameters.
116 Sarima {
117 /// Non-seasonal AR order.
118 p: usize,
119 /// Non-seasonal differencing order.
120 d: usize,
121 /// Non-seasonal MA order.
122 q: usize,
123 /// Seasonal AR order P.
124 sp: usize,
125 /// Seasonal differencing order D.
126 sd: usize,
127 /// Seasonal MA order Q.
128 sq: usize,
129 /// Seasonal period m.
130 m: usize,
131 /// Non-seasonal AR coefficients.
132 ar_coeffs: Vec<f64>,
133 /// Non-seasonal MA coefficients.
134 ma_coeffs: Vec<f64>,
135 /// Seasonal AR coefficients Φ₁…Φ_P.
136 sar_coeffs: Vec<f64>,
137 /// Seasonal MA coefficients Θ₁…Θ_Q.
138 sma_coeffs: Vec<f64>,
139 /// Mean of the fully differenced series, added back to every forecast.
140 constant: f64,
141 /// Standard deviation of in-sample residuals.
142 residual_std: f64,
143 },
144 /// Linear regression parameters.
145 LinearRegression {
146 /// Regression slope (change per unit index).
147 slope: f64,
148 /// Regression intercept.
149 intercept: f64,
150 /// Coefficient of determination R².
151 r_squared: f64,
152 /// Standard deviation of in-sample residuals.
153 residual_std: f64,
154 /// Start timestamp of training data (nanoseconds).
155 start_ts: i64,
156 /// Observation interval (nanoseconds).
157 interval_ns: i64,
158 },
159 /// Opaque parameters for a custom plugin model.
160 Custom {
161 /// Plugin model name.
162 name: String,
163 /// Opaque serialized parameter data (interpreted by the plugin).
164 data: Vec<u8>,
165 },
166}
167
168impl ForecastResult {
169 /// Rescale the prediction interval to a different confidence level.
170 ///
171 /// Every model in this crate builds a **symmetric normal** interval —
172 /// `value ± z·σ_h`, with `σ_h` whatever the model's horizon-dependent
173 /// error scale is — so changing the level is exactly a change of `z`, and
174 /// this rescaling is not an approximation of the model's own answer at
175 /// that level: it *is* that answer.
176 ///
177 /// That is only true because the interval is symmetric and normal. It is
178 /// not true of the empirical residual quantiles from
179 /// [`QuantileForecaster`](crate::forecast::QuantileForecaster), which is
180 /// where to go when the residuals are skewed or the tails matter more than
181 /// the shape.
182 ///
183 /// # Errors
184 ///
185 /// [`ForecastError::InvalidParams`](crate::forecast::ForecastError::InvalidParams)
186 /// when `level` is not in `(0, 1)`, or when the result's own
187 /// `confidence_level` is not, which would make the ratio meaningless.
188 pub fn with_confidence(
189 mut self,
190 level: f64,
191 ) -> Result<Self, crate::forecast::error::ForecastError> {
192 use crate::forecast::error::ForecastError;
193 use crate::forecast::util::z_for_confidence;
194
195 let invalid = |name: &'static str, value: f64| ForecastError::InvalidParams {
196 name,
197 value: value.to_string(),
198 reason: "confidence level must be strictly between 0 and 1",
199 };
200 let target = z_for_confidence(level).ok_or_else(|| invalid("level", level))?;
201 let current = z_for_confidence(self.confidence_level)
202 .ok_or_else(|| invalid("confidence_level", self.confidence_level))?;
203
204 let ratio = target / current;
205 for h in 0..self.values.len() {
206 let (Some(lo), Some(hi)) = (
207 self.confidence_lower.get(h).copied(),
208 self.confidence_upper.get(h).copied(),
209 ) else {
210 continue;
211 };
212 let point = self.values[h];
213 self.confidence_lower[h] = point - (point - lo) * ratio;
214 self.confidence_upper[h] = point + (hi - point) * ratio;
215 }
216 self.confidence_level = level;
217 Ok(self)
218 }
219}
220
221#[cfg(test)]
222#[allow(clippy::unwrap_used, clippy::expect_used)]
223mod tests {
224 use super::*;
225
226 fn sample() -> ForecastResult {
227 ForecastResult {
228 values: vec![10.0, 20.0],
229 timestamps: vec![1, 2],
230 confidence_lower: vec![8.0, 16.0],
231 confidence_upper: vec![12.0, 24.0],
232 confidence_level: 0.95,
233 }
234 }
235
236 #[test]
237 fn normal_quantile_matches_the_textbook_values() {
238 use crate::forecast::util::normal_quantile;
239 for (p, expected) in [
240 (0.5, 0.0),
241 (0.975, 1.959_963_984_540_054),
242 (0.995, 2.575_829_303_548_901),
243 (0.9, 1.281_551_565_544_6),
244 (0.025, -1.959_963_984_540_054),
245 // Deep in the tail, where the central rational fit is not used.
246 (1e-6, -4.753_424_308_822_899),
247 ] {
248 let got = normal_quantile(p);
249 assert!(
250 (got - expected).abs() < 1e-6,
251 "Φ⁻¹({p}) = {got}, expected {expected}"
252 );
253 }
254 assert!(normal_quantile(0.0).is_infinite());
255 assert!(normal_quantile(1.0).is_infinite());
256 assert!(normal_quantile(-0.1).is_nan());
257 assert!(normal_quantile(1.1).is_nan());
258 }
259
260 #[test]
261 fn z_for_confidence_is_the_two_sided_multiplier() {
262 use crate::forecast::util::z_for_confidence;
263 assert!((z_for_confidence(0.95).unwrap() - 1.959_963_98).abs() < 1e-6);
264 assert!((z_for_confidence(0.99).unwrap() - 2.575_829_30).abs() < 1e-6);
265 assert!((z_for_confidence(0.80).unwrap() - 1.281_551_57).abs() < 1e-6);
266 assert!(z_for_confidence(0.0).is_none());
267 assert!(z_for_confidence(1.0).is_none());
268 }
269
270 #[test]
271 fn widening_the_interval_keeps_the_point_forecast() {
272 let widened = sample().with_confidence(0.99).unwrap();
273 assert_eq!(widened.values, vec![10.0, 20.0]);
274 assert_eq!(widened.confidence_level, 0.99);
275 // The half-width scales by z(0.99)/z(0.95) ≈ 1.31417. Taken from
276 // `z_for_confidence` rather than from the textbook constants: this
277 // test is about the rescaling, and the accuracy of Φ⁻¹ itself is
278 // pinned separately above.
279 use crate::forecast::util::z_for_confidence;
280 let ratio = z_for_confidence(0.99).unwrap() / z_for_confidence(0.95).unwrap();
281 assert!((ratio - 1.314_17).abs() < 1e-4, "ratio = {ratio}");
282 assert!((widened.confidence_upper[0] - (10.0 + 2.0 * ratio)).abs() < 1e-9);
283 assert!((widened.confidence_lower[0] - (10.0 - 2.0 * ratio)).abs() < 1e-9);
284 assert!((widened.confidence_upper[1] - (20.0 + 4.0 * ratio)).abs() < 1e-9);
285 }
286
287 #[test]
288 fn narrowing_and_widening_round_trip() {
289 let round = sample()
290 .with_confidence(0.5)
291 .unwrap()
292 .with_confidence(0.95)
293 .unwrap();
294 for h in 0..2 {
295 assert!((round.confidence_lower[h] - sample().confidence_lower[h]).abs() < 1e-9);
296 assert!((round.confidence_upper[h] - sample().confidence_upper[h]).abs() < 1e-9);
297 }
298 }
299
300 #[test]
301 fn an_out_of_range_level_is_rejected() {
302 assert!(sample().with_confidence(0.0).is_err());
303 assert!(sample().with_confidence(1.0).is_err());
304 assert!(sample().with_confidence(-0.5).is_err());
305 }
306}