Skip to main content

chronix_analytics/forecast/
quantile.rs

1//! Quantile forecasting via empirical residual quantiles.
2//!
3//! The built-in models produce *Gaussian* prediction intervals from a single
4//! in-sample `residual_std`: `ŷ ± z·σ·f(h)`. That makes two assumptions that
5//! do not hold for the workloads Chronix targets:
6//!
7//! 1. **Symmetric, normal errors.** Energy series (PV generation, household
8//!    load, battery state of charge) are heavily skewed and heteroscedastic.
9//!    A symmetric interval under-covers the long tail while wasting width on
10//!    the short one.
11//! 2. **A single error scale for every horizon.** Forecast error grows with
12//!    the horizon, and how it grows depends on the model and the data, not on
13//!    a closed-form factor.
14//!
15//! Note that empirical residual quantiles are *additive* offsets, so they do
16//! not by themselves respect a physical domain: a series bounded at zero can
17//! still receive a negative lower bound where the point forecast is noisy.
18//! Set [`QuantileConfig::lower_bound`] / [`upper_bound`] for that — learning
19//! the residual shape and declaring the domain are separate jobs.
20//!
21//! [`upper_bound`]: QuantileConfig::upper_bound
22//!
23//! [`QuantileForecaster`] drops both assumptions. It calibrates on the
24//! **empirical distribution of walk-forward residuals, bucketed per horizon
25//! step**, so the interval shape is learned from the data: asymmetric where
26//! the data is asymmetric, and widening at whatever rate the data actually
27//! widens.
28//!
29//! ## Calibration is strictly out-of-sample
30//!
31//! Residuals come from rolling-origin evaluation (Hyndman & Athanasopoulos,
32//! *FPP3* §5.10): the model is refit — or replayed — on data up to each
33//! origin, then asked for `horizon` steps, and only genuinely unseen points
34//! contribute residuals. In-sample residuals would be optimistically small
35//! and the intervals correspondingly too narrow.
36//!
37//! ## Optional conformal calibration
38//!
39//! With [`QuantileConfig::conformal`] enabled the residual quantile is taken
40//! at the finite-sample-corrected rank `⌈(n+1)(1-α)⌉/n` (Vovk et al.;
41//! Gibbs & Candès, *Adaptive Conformal Inference*, NeurIPS 2021). Under
42//! exchangeability that yields marginal coverage of at least `1-α` rather
43//! than merely asymptotic coverage — the difference matters at the small
44//! calibration sizes an embedded gateway actually has.
45
46use crate::forecast::error::ForecastError;
47use crate::forecast::result::ForecastResult;
48use crate::forecast::traits::ForecastModel;
49
50/// How to build the residual sample used for calibration.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CalibrationStrategy {
53    /// Refit the model from scratch at every origin.
54    ///
55    /// The most faithful simulation of production behaviour and the most
56    /// expensive. Use for offline calibration.
57    Refit,
58    /// Fit once on the initial window, then advance the origin using the
59    /// model's online [`update`](ForecastModel::update).
60    ///
61    /// Matches how a long-running forecaster actually behaves and costs
62    /// roughly one fit plus `n` updates, which is what makes calibration
63    /// affordable on a gateway.
64    OnlineUpdate,
65}
66
67/// Configuration for [`QuantileForecaster`].
68#[derive(Debug, Clone)]
69pub struct QuantileConfig {
70    /// Quantile levels to produce, each in `(0, 1)`.
71    pub levels: Vec<f64>,
72    /// Maximum horizon to calibrate. Residuals are bucketed per step
73    /// `1..=horizon`.
74    pub horizon: usize,
75    /// Number of observations in the initial training window.
76    pub initial_window: usize,
77    /// Observations to advance between rolling origins.
78    pub step: usize,
79    /// How to advance the model between origins.
80    pub strategy: CalibrationStrategy,
81    /// Apply the finite-sample conformal rank correction.
82    pub conformal: bool,
83    /// Optional hard lower bound on predicted quantiles.
84    ///
85    /// Residual quantiles are *additive* offsets on the point forecast, so
86    /// they can cross a physical limit — a PV series bounded at zero can
87    /// still get a negative lower bound when the point forecast is noisy.
88    /// Learning the residual *shape* from data and stating the *domain*
89    /// explicitly are different jobs; this is the second one.
90    pub lower_bound: Option<f64>,
91    /// Optional hard upper bound on predicted quantiles (e.g. inverter
92    /// nameplate capacity, 100% state of charge).
93    pub upper_bound: Option<f64>,
94}
95
96impl Default for QuantileConfig {
97    fn default() -> Self {
98        Self {
99            levels: vec![0.1, 0.5, 0.9],
100            horizon: 24,
101            initial_window: 0, // 0 = derive from the data (see `resolve`)
102            step: 1,
103            strategy: CalibrationStrategy::OnlineUpdate,
104            conformal: true,
105            lower_bound: None,
106            upper_bound: None,
107        }
108    }
109}
110
111impl QuantileConfig {
112    /// Constrain predicted quantiles to `[lo, hi]`.
113    #[must_use]
114    pub fn with_bounds(mut self, lo: Option<f64>, hi: Option<f64>) -> Self {
115        self.lower_bound = lo;
116        self.upper_bound = hi;
117        self
118    }
119
120    /// Clamp a value to the configured domain.
121    fn clamp(&self, v: f64) -> f64 {
122        let v = self.lower_bound.map_or(v, |lo| v.max(lo));
123        self.upper_bound.map_or(v, |hi| v.min(hi))
124    }
125
126    /// Config producing a symmetric central interval at `level` coverage
127    /// (e.g. `0.9` → the 5% and 95% quantiles plus the median).
128    #[must_use]
129    pub fn central(level: f64, horizon: usize) -> Self {
130        let tail = (1.0 - level) / 2.0;
131        Self {
132            levels: vec![tail, 0.5, 1.0 - tail],
133            horizon,
134            ..Self::default()
135        }
136    }
137
138    fn validate(&self) -> Result<(), ForecastError> {
139        if self.levels.is_empty() {
140            return Err(ForecastError::InvalidInput(
141                "at least one quantile level is required".into(),
142            ));
143        }
144        for &q in &self.levels {
145            if !(q > 0.0 && q < 1.0) {
146                return Err(ForecastError::InvalidInput(format!(
147                    "quantile level must be in (0, 1), got {q}"
148                )));
149            }
150        }
151        if self.horizon == 0 {
152            return Err(ForecastError::InvalidInput(
153                "horizon must be at least 1".into(),
154            ));
155        }
156        if self.step == 0 {
157            return Err(ForecastError::InvalidInput(
158                "step must be at least 1".into(),
159            ));
160        }
161        if let (Some(lo), Some(hi)) = (self.lower_bound, self.upper_bound)
162            && lo > hi
163        {
164            return Err(ForecastError::InvalidInput(format!(
165                "lower_bound {lo} exceeds upper_bound {hi}"
166            )));
167        }
168        Ok(())
169    }
170
171    /// Choose an initial window when the caller left it at 0.
172    ///
173    /// Half the series, clamped so that at least one rolling origin exists.
174    fn resolve_initial_window(&self, n: usize) -> usize {
175        if self.initial_window > 0 {
176            return self.initial_window;
177        }
178        (n / 2).max(1)
179    }
180}
181
182/// A per-horizon set of predicted quantiles.
183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184pub struct QuantileForecast {
185    /// Timestamps of the forecast points (nanoseconds).
186    pub timestamps: Vec<i64>,
187    /// Point forecast from the underlying model, one per horizon step.
188    pub point: Vec<f64>,
189    /// Requested quantile levels, ascending.
190    pub levels: Vec<f64>,
191    /// `quantiles[i][h]` is the `levels[i]` quantile at horizon step `h`.
192    pub quantiles: Vec<Vec<f64>>,
193    /// Number of residuals backing each horizon step's calibration.
194    ///
195    /// Small counts mean wide sampling error in the quantile estimate; the
196    /// caller can use this to decide whether to trust a given horizon.
197    pub calibration_counts: Vec<usize>,
198}
199
200impl QuantileForecast {
201    /// Values for `level`, if it was among the requested levels.
202    ///
203    /// Matched with a small tolerance rather than exactly: levels are
204    /// routinely derived arithmetically (`(1.0 - 0.9) / 2.0` is
205    /// `0.049999999999999996`, not `0.05`), so exact `f64` equality would
206    /// fail for the very levels [`QuantileConfig::central`] produces.
207    #[must_use]
208    pub fn level(&self, level: f64) -> Option<&[f64]> {
209        const TOL: f64 = 1e-9;
210        self.levels
211            .iter()
212            .position(|l| (*l - level).abs() < TOL)
213            .map(|i| self.quantiles[i].as_slice())
214    }
215
216    /// Number of horizon steps.
217    #[must_use]
218    pub fn horizon(&self) -> usize {
219        self.point.len()
220    }
221}
222
223/// Wraps a [`ForecastModel`] with empirical, horizon-aware quantile intervals.
224///
225/// # Example
226///
227/// ```no_run
228/// use chronix_analytics::forecast::{
229///     HoltWintersModel, QuantileConfig, QuantileForecaster,
230/// };
231///
232/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
233/// # let (timestamps, values): (Vec<i64>, Vec<f64>) = (vec![], vec![]);
234/// let config = QuantileConfig::central(0.9, 24);
235///
236/// let mut qf = QuantileForecaster::new(
237///     || Box::new(HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.1), Some(24), false)),
238///     config,
239/// );
240/// qf.fit(&timestamps, &values)?;
241///
242/// let forecast = qf.predict(24)?;
243/// let p05 = forecast.level(0.05).expect("requested level");
244/// # Ok(())
245/// # }
246/// ```
247pub struct QuantileForecaster {
248    /// Builds a fresh, unfitted model. A factory (rather than a single model
249    /// instance) is what lets `Refit` start each rolling origin from a clean
250    /// state without needing to reconstruct a model from its parameters.
251    factory: Box<dyn Fn() -> Box<dyn ForecastModel> + Send + Sync>,
252    model: Box<dyn ForecastModel>,
253    config: QuantileConfig,
254    /// `residuals[h - 1]` holds the out-of-sample errors observed at horizon
255    /// step `h`.
256    residuals: Vec<Vec<f64>>,
257    fitted: bool,
258}
259
260impl QuantileForecaster {
261    /// Build a forecaster from a model factory.
262    ///
263    /// `factory` must return a fresh, unfitted model on every call.
264    pub fn new<F>(factory: F, config: QuantileConfig) -> Self
265    where
266        F: Fn() -> Box<dyn ForecastModel> + Send + Sync + 'static,
267    {
268        let model = factory();
269        Self {
270            factory: Box::new(factory),
271            model,
272            config,
273            residuals: Vec::new(),
274            fitted: false,
275        }
276    }
277
278    /// Fit the underlying model and calibrate the residual distribution.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`ForecastError::InvalidInput`] if the configuration is
283    /// invalid or the series is too short to produce a single rolling origin,
284    /// or propagates the underlying model's fit errors.
285    pub fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
286        self.config.validate()?;
287        if timestamps.len() != values.len() {
288            return Err(ForecastError::InvalidInput(
289                "timestamps and values must have the same length".into(),
290            ));
291        }
292
293        let n = values.len();
294        let window = self.config.resolve_initial_window(n);
295        let horizon = self.config.horizon;
296
297        if n < window + 1 {
298            return Err(ForecastError::InvalidInput(format!(
299                "need more than {window} observations to calibrate, got {n}"
300            )));
301        }
302
303        self.residuals = vec![Vec::new(); horizon];
304        self.collect_residuals(timestamps, values, window)?;
305
306        // Final model is fit on everything, so predictions use all the data.
307        self.model.fit(timestamps, values)?;
308        self.fitted = true;
309        Ok(())
310    }
311
312    /// Walk the rolling origins, recording out-of-sample residuals per
313    /// horizon step.
314    fn collect_residuals(
315        &mut self,
316        timestamps: &[i64],
317        values: &[f64],
318        window: usize,
319    ) -> Result<(), ForecastError> {
320        let n = values.len();
321        let horizon = self.config.horizon;
322
323        match self.config.strategy {
324            CalibrationStrategy::Refit => {
325                let mut origin = window;
326                while origin < n {
327                    let mut model = (self.factory)();
328                    model.fit(&timestamps[..origin], &values[..origin])?;
329                    let avail = (n - origin).min(horizon);
330                    if avail > 0 {
331                        let f = model.predict(avail)?;
332                        record(&mut self.residuals, &f.values, &values[origin..], avail);
333                    }
334                    origin += self.config.step;
335                }
336            }
337            CalibrationStrategy::OnlineUpdate => {
338                // Fit once, then advance with online updates — the cheap path
339                // and the one that mirrors a live forecaster.
340                self.model.fit(&timestamps[..window], &values[..window])?;
341                let mut origin = window;
342                while origin < n {
343                    let avail = (n - origin).min(horizon);
344                    if avail > 0 {
345                        let f = self.model.predict(avail)?;
346                        record(&mut self.residuals, &f.values, &values[origin..], avail);
347                    }
348                    // Advance the origin by feeding the observations we just
349                    // scored against.
350                    let next = (origin + self.config.step).min(n);
351                    for i in origin..next {
352                        self.model.update(timestamps[i], values[i])?;
353                    }
354                    if next == origin {
355                        break;
356                    }
357                    origin = next;
358                }
359            }
360        }
361        Ok(())
362    }
363
364    /// Produce point forecasts plus calibrated quantiles for `horizon` steps.
365    ///
366    /// Horizons beyond the calibrated range reuse the last calibrated step's
367    /// residual distribution, which is the most conservative available
368    /// estimate rather than a silent extrapolation.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`ForecastError::NotFitted`] if [`fit`](Self::fit) has not
373    /// been called, or propagates the underlying model's prediction errors.
374    pub fn predict(&self, horizon: usize) -> Result<QuantileForecast, ForecastError> {
375        if !self.fitted {
376            return Err(ForecastError::NotFitted);
377        }
378        let base = self.model.predict(horizon)?;
379
380        let mut levels = self.config.levels.clone();
381        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
382
383        let mut quantiles = vec![Vec::with_capacity(horizon); levels.len()];
384        let mut counts = Vec::with_capacity(horizon);
385
386        for h in 0..horizon {
387            // Fall back to the widest calibrated horizon rather than
388            // extrapolating a distribution we never observed.
389            let bucket = h.min(self.residuals.len().saturating_sub(1));
390            let sample = self.residuals.get(bucket).map_or(&[][..], Vec::as_slice);
391            counts.push(sample.len());
392
393            let mut sorted: Vec<f64> = sample.iter().copied().filter(|v| v.is_finite()).collect();
394            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
395
396            for (i, &q) in levels.iter().enumerate() {
397                let offset = empirical_quantile(&sorted, q, self.config.conformal);
398                quantiles[i].push(self.config.clamp(base.values[h] + offset));
399            }
400        }
401
402        // Quantiles must not cross: a finite sample can otherwise produce a
403        // 90% bound below the 50% bound at a given horizon.
404        enforce_monotonicity(&mut quantiles);
405
406        // The bounds are a physical fact about the quantity, so they hold
407        // for the point forecast too: a PV forecast of −66 W at night with
408        // an interval of [0, 450] is a contradiction the reader has to
409        // resolve by hand.
410        let point = base.values.iter().map(|&v| self.config.clamp(v)).collect();
411        Ok(QuantileForecast {
412            timestamps: base.timestamps,
413            point,
414            levels,
415            quantiles,
416            calibration_counts: counts,
417        })
418    }
419
420    /// Rewrite a [`ForecastResult`]'s bounds using the calibrated empirical
421    /// quantiles, keeping the existing API shape.
422    ///
423    /// # Errors
424    ///
425    /// As [`predict`](Self::predict).
426    pub fn predict_interval(
427        &self,
428        horizon: usize,
429        level: f64,
430    ) -> Result<ForecastResult, ForecastError> {
431        if !self.fitted {
432            return Err(ForecastError::NotFitted);
433        }
434        let tail = (1.0 - level) / 2.0;
435        let base = self.model.predict(horizon)?;
436        let (lo, hi) = self.quantiles_only(&base, horizon, tail, 1.0 - tail);
437
438        // The point forecast is clamped for the same reason the bounds are:
439        // `predict` already does it, and an unclamped point inside a clamped
440        // interval is a contradiction the reader has to resolve by hand.
441        let values = base.values.iter().map(|&v| self.config.clamp(v)).collect();
442
443        Ok(ForecastResult {
444            values,
445            timestamps: base.timestamps,
446            confidence_lower: lo,
447            confidence_upper: hi,
448            confidence_level: level,
449        })
450    }
451
452    fn quantiles_only(
453        &self,
454        base: &ForecastResult,
455        horizon: usize,
456        lo_level: f64,
457        hi_level: f64,
458    ) -> (Vec<f64>, Vec<f64>) {
459        let mut lo = Vec::with_capacity(horizon);
460        let mut hi = Vec::with_capacity(horizon);
461        for h in 0..horizon {
462            let bucket = h.min(self.residuals.len().saturating_sub(1));
463            let sample = self.residuals.get(bucket).map_or(&[][..], Vec::as_slice);
464            let mut sorted: Vec<f64> = sample.iter().copied().filter(|v| v.is_finite()).collect();
465            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
466            lo.push(self.config.clamp(
467                base.values[h] + empirical_quantile(&sorted, lo_level, self.config.conformal),
468            ));
469            hi.push(self.config.clamp(
470                base.values[h] + empirical_quantile(&sorted, hi_level, self.config.conformal),
471            ));
472        }
473        (lo, hi)
474    }
475
476    /// Number of calibration residuals collected for horizon step `h`
477    /// (1-based). Returns 0 for uncalibrated horizons.
478    #[must_use]
479    pub fn calibration_size(&self, h: usize) -> usize {
480        h.checked_sub(1)
481            .and_then(|i| self.residuals.get(i))
482            .map_or(0, Vec::len)
483    }
484
485    /// The wrapped model.
486    #[must_use]
487    pub fn model(&self) -> &dyn ForecastModel {
488        &*self.model
489    }
490}
491
492/// Record residuals for one rolling origin into the per-horizon buckets.
493fn record(residuals: &mut [Vec<f64>], predicted: &[f64], actual: &[f64], avail: usize) {
494    for h in 0..avail.min(predicted.len()).min(actual.len()) {
495        let e = actual[h] - predicted[h];
496        if e.is_finite() {
497            residuals[h].push(e);
498        }
499    }
500}
501
502/// Empirical quantile of a sorted sample using linear interpolation
503/// (Hyndman & Fan type 7, the R/NumPy default).
504///
505/// With `conformal`, the rank is moved **outward** by the split-conformal
506/// finite-sample correction: `⌈(n+1)q⌉/n` for an upper quantile, and its
507/// mirror image `1 − ⌈(n+1)(1−q)⌉/n` for a lower one. Returns 0.0 for an
508/// empty sample, which degrades the interval to the point forecast rather
509/// than inventing a spread.
510///
511/// # The mirror is the whole point
512///
513/// `⌈(n+1)q⌉/n` is derived for the upper tail, and applying it to both tails
514/// moves the *lower* bound up rather than down — it made the correction
515/// **narrow** the interval on the low side. At `n = 40` and `q = 0.05` the
516/// rank went from 0.05 to 0.075, so a nominal 90 % interval lost coverage
517/// below exactly where the conformal argument was supposed to add it. The
518/// only test on this asserted the upper tail (`conformal_correction_widens_
519/// the_upper_tail`), and the coverage test allowed 0.70 against a nominal
520/// 0.90, so neither could see it — a numeric result checked only for a loose
521/// bound is uncovered (QUALITY rule 2).
522///
523/// The consequence was not confined to statistics: forecast-deviation
524/// triggers fire on an observation falling outside the interval, so a
525/// silently narrow lower bound raises the alert rate below the forecast above
526/// its nominal level, for every trigger built on one.
527fn empirical_quantile(sorted: &[f64], q: f64, conformal: bool) -> f64 {
528    if sorted.is_empty() {
529        return 0.0;
530    }
531    let n = sorted.len();
532    if n == 1 {
533        return sorted[0];
534    }
535
536    let q = if conformal {
537        let nf = (n + 1) as f64;
538        // The correction moves a *tail* rank outward. The median is not a
539        // tail: `q >= 0.5` pushed q = 0.5 to ⌈41·0.5⌉/40 = 0.525 at n = 40,
540        // so the level advertised as the median was the 52.5 % quantile and
541        // the "median" forecast was biased high by construction.
542        let adjusted = if q > 0.5 {
543            (nf * q).ceil() / n as f64
544        } else if q < 0.5 {
545            1.0 - (nf * (1.0 - q)).ceil() / n as f64
546        } else {
547            0.5
548        };
549        adjusted.clamp(0.0, 1.0)
550    } else {
551        q
552    };
553
554    let pos = q * (n - 1) as f64;
555    let lo = pos.floor() as usize;
556    let hi = pos.ceil() as usize;
557    if lo == hi {
558        sorted[lo.min(n - 1)]
559    } else {
560        let frac = pos - lo as f64;
561        let a = sorted[lo.min(n - 1)];
562        let b = sorted[hi.min(n - 1)];
563        a + (b - a) * frac
564    }
565}
566
567/// Enforce non-crossing quantiles across levels at each horizon step.
568fn enforce_monotonicity(quantiles: &mut [Vec<f64>]) {
569    if quantiles.len() < 2 {
570        return;
571    }
572    let horizon = quantiles[0].len();
573    for h in 0..horizon {
574        for i in 1..quantiles.len() {
575            let prev = quantiles[i - 1][h];
576            if quantiles[i][h] < prev {
577                quantiles[i][h] = prev;
578            }
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::forecast::{HoltWintersModel, SesModel};
587
588    fn seasonal_series(n: usize, period: usize) -> (Vec<i64>, Vec<f64>) {
589        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
590        let vals: Vec<f64> = (0..n)
591            .map(|i| {
592                let phase = (i % period) as f64 / period as f64 * std::f64::consts::TAU;
593                10.0 + 5.0 * phase.sin() + ((i * 37) % 11) as f64 * 0.1
594            })
595            .collect();
596        (ts, vals)
597    }
598
599    #[test]
600    fn empirical_quantile_matches_type7() {
601        let s = [1.0, 2.0, 3.0, 4.0];
602        // Type-7 median of 4 points is the midpoint of the middle pair.
603        assert!((empirical_quantile(&s, 0.5, false) - 2.5).abs() < 1e-9);
604        assert!((empirical_quantile(&s, 0.0, false) - 1.0).abs() < 1e-9);
605        assert!((empirical_quantile(&s, 1.0, false) - 4.0).abs() < 1e-9);
606    }
607
608    /// The conformal correction is a *tail* correction; the median must be
609    /// the plain empirical median. With `sorted[i] = i + 1`, n = 40, the
610    /// type-7 median is `(sorted[19] + sorted[20]) / 2 = 20.5`. The old
611    /// `q >= 0.5` branch produced rank ⌈41·0.5⌉/40 = 0.525 → 21.475.
612    #[test]
613    fn conformal_leaves_the_median_alone() {
614        let s: Vec<f64> = (1..=40).map(f64::from).collect();
615        assert!((empirical_quantile(&s, 0.5, false) - 20.5).abs() < 1e-12);
616        assert!(
617            (empirical_quantile(&s, 0.5, true) - 20.5).abs() < 1e-12,
618            "conformal median = {}, want 20.5",
619            empirical_quantile(&s, 0.5, true)
620        );
621    }
622
623    /// `predict_interval` must clamp the point forecast to the configured
624    /// domain, exactly as `predict` does — otherwise a PV forecast prints a
625    /// negative point inside a `[0, 5000]` interval.
626    #[test]
627    fn predict_interval_clamps_the_point_forecast() {
628        let ts: Vec<i64> = (0..60).map(|i| i as i64 * 1_000_000_000).collect();
629        // A series that trends hard negative so the point forecast leaves
630        // the declared domain.
631        let vals: Vec<f64> = (0..60).map(|i| 100.0 - 4.0 * i as f64).collect();
632        let cfg = QuantileConfig {
633            levels: vec![0.1, 0.5, 0.9],
634            horizon: 3,
635            initial_window: 30,
636            step: 1,
637            strategy: CalibrationStrategy::OnlineUpdate,
638            conformal: true,
639            lower_bound: Some(0.0),
640            upper_bound: Some(5000.0),
641        };
642        let mut f = QuantileForecaster::new(
643            || Box::new(SesModel::new(Some(0.3))) as Box<dyn ForecastModel>,
644            cfg,
645        );
646        f.fit(&ts, &vals).unwrap();
647        let r = f.predict_interval(3, 0.9).unwrap();
648        for (i, &v) in r.values.iter().enumerate() {
649            assert!(
650                (0.0..=5000.0).contains(&v),
651                "point forecast {v} at h={} outside the configured [0, 5000]",
652                i + 1
653            );
654        }
655    }
656
657    /// The correction must widen **both** tails.
658    ///
659    /// Asserting only the upper one is how the mirrored form went missing: the
660    /// same expression applied to a lower quantile moves it up, and an
661    /// interval that is wider above and narrower below still passes an
662    /// upper-tail-only test while under-covering.
663    #[test]
664    fn conformal_correction_widens_both_tails() {
665        let s: Vec<f64> = (1..=10).map(f64::from).collect();
666        for &q in &[0.9, 0.95, 0.99] {
667            let (plain, conf) = (
668                empirical_quantile(&s, q, false),
669                empirical_quantile(&s, q, true),
670            );
671            assert!(
672                conf >= plain,
673                "upper tail q={q} must not tighten: {conf} < {plain}"
674            );
675        }
676        for &q in &[0.1, 0.05, 0.01] {
677            let (plain, conf) = (
678                empirical_quantile(&s, q, false),
679                empirical_quantile(&s, q, true),
680            );
681            assert!(
682                conf <= plain,
683                "lower tail q={q} must not tighten: {conf} > {plain}"
684            );
685        }
686        // …and it must stay an interval: the corrected bounds cannot cross.
687        assert!(empirical_quantile(&s, 0.05, true) <= empirical_quantile(&s, 0.95, true));
688    }
689
690    /// A larger calibration sample needs a smaller correction: the finite-
691    /// sample term vanishes as `n` grows, which is what makes it a correction
692    /// rather than a fudge factor.
693    #[test]
694    fn the_conformal_correction_shrinks_as_the_sample_grows() {
695        let widen = |n: usize| {
696            let s: Vec<f64> = (1..=n).map(|i| i as f64).collect();
697            let lo = empirical_quantile(&s, 0.05, false) - empirical_quantile(&s, 0.05, true);
698            lo / n as f64
699        };
700        assert!(
701            widen(400) < widen(40),
702            "the correction must shrink relative to the sample as n grows"
703        );
704    }
705
706    #[test]
707    fn empty_sample_degrades_to_point_forecast() {
708        assert_eq!(empirical_quantile(&[], 0.9, true), 0.0);
709    }
710
711    #[test]
712    fn calibrates_and_orders_quantiles() {
713        let (ts, vals) = seasonal_series(200, 24);
714        let cfg = QuantileConfig {
715            levels: vec![0.1, 0.5, 0.9],
716            horizon: 12,
717            initial_window: 100,
718            step: 4,
719            strategy: CalibrationStrategy::OnlineUpdate,
720            conformal: true,
721            lower_bound: None,
722            upper_bound: None,
723        };
724        let mut qf = QuantileForecaster::new(
725            || {
726                Box::new(HoltWintersModel::new(
727                    Some(0.3),
728                    Some(0.1),
729                    Some(0.1),
730                    Some(24),
731                    false,
732                ))
733            },
734            cfg,
735        );
736        qf.fit(&ts, &vals).expect("fit");
737
738        assert!(qf.calibration_size(1) > 0, "horizon 1 must be calibrated");
739
740        let f = qf.predict(12).expect("predict");
741        assert_eq!(f.horizon(), 12);
742        assert_eq!(f.levels.len(), 3);
743        for h in 0..12 {
744            assert!(
745                f.quantiles[0][h] <= f.quantiles[1][h] && f.quantiles[1][h] <= f.quantiles[2][h],
746                "quantiles crossed at horizon {h}"
747            );
748        }
749        assert!(f.level(0.9).is_some());
750        assert!(f.level(0.42).is_none());
751
752        // Levels derived arithmetically must still be findable.
753        let central = QuantileConfig::central(0.9, 4);
754        let mut qf2 = QuantileForecaster::new(
755            || {
756                Box::new(HoltWintersModel::new(
757                    Some(0.3),
758                    Some(0.1),
759                    Some(0.1),
760                    Some(24),
761                    false,
762                ))
763            },
764            central,
765        );
766        qf2.fit(&ts, &vals).expect("fit");
767        let f2 = qf2.predict(4).expect("predict");
768        assert!(
769            f2.level(0.05).is_some(),
770            "central(0.9) level 0.05 must match"
771        );
772        assert!(
773            f2.level(0.95).is_some(),
774            "central(0.9) level 0.95 must match"
775        );
776    }
777
778    /// The whole point of per-horizon buckets: uncertainty must be allowed to
779    /// grow with the horizon instead of being a fixed multiple of one sigma.
780    #[test]
781    fn interval_width_grows_with_horizon() {
782        let ts: Vec<i64> = (0..300).map(|i| i as i64 * 1_000_000_000).collect();
783        // Random walk: h-step error variance grows linearly in h.
784        let mut v = 0.0f64;
785        let mut seed = 12345u64;
786        let vals: Vec<f64> = (0..300)
787            .map(|_| {
788                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
789                let u = ((seed >> 33) as f64 / f64::from(u32::MAX)) - 0.5;
790                v += u;
791                v
792            })
793            .collect();
794
795        let cfg = QuantileConfig {
796            levels: vec![0.1, 0.9],
797            horizon: 20,
798            initial_window: 150,
799            step: 1,
800            strategy: CalibrationStrategy::OnlineUpdate,
801            conformal: false,
802            lower_bound: None,
803            upper_bound: None,
804        };
805        let mut qf = QuantileForecaster::new(|| Box::new(SesModel::new(Some(0.5))), cfg);
806        qf.fit(&ts, &vals).expect("fit");
807        let f = qf.predict(20).expect("predict");
808
809        let width = |h: usize| f.quantiles[1][h] - f.quantiles[0][h];
810        assert!(
811            width(19) > width(0),
812            "20-step interval ({}) should exceed 1-step ({})",
813            width(19),
814            width(0)
815        );
816    }
817
818    #[test]
819    fn rejects_invalid_config() {
820        let (ts, vals) = seasonal_series(50, 12);
821        let bad = QuantileConfig {
822            levels: vec![1.5],
823            ..QuantileConfig::default()
824        };
825        let mut qf = QuantileForecaster::new(|| Box::new(SesModel::new(Some(0.5))), bad);
826        assert!(qf.fit(&ts, &vals).is_err());
827
828        let short = QuantileConfig {
829            initial_window: 1000,
830            ..QuantileConfig::default()
831        };
832        let mut qf = QuantileForecaster::new(|| Box::new(SesModel::new(Some(0.5))), short);
833        assert!(qf.fit(&ts, &vals).is_err());
834    }
835
836    #[test]
837    fn bounds_are_respected() {
838        let (ts, vals) = seasonal_series(200, 24);
839        let cfg = QuantileConfig {
840            levels: vec![0.05, 0.95],
841            horizon: 6,
842            initial_window: 100,
843            step: 4,
844            strategy: CalibrationStrategy::OnlineUpdate,
845            conformal: true,
846            lower_bound: Some(0.0),
847            upper_bound: Some(20.0),
848        };
849        let mut qf = QuantileForecaster::new(|| Box::new(SesModel::new(Some(0.5))), cfg);
850        qf.fit(&ts, &vals).expect("fit");
851        let f = qf.predict(6).expect("predict");
852        for level in &f.quantiles {
853            for &v in level {
854                assert!((0.0..=20.0).contains(&v), "bound violated: {v}");
855            }
856        }
857
858        // predict_interval must clamp too.
859        let ci = qf.predict_interval(6, 0.9).expect("interval");
860        assert!(ci.confidence_lower.iter().all(|v| *v >= 0.0));
861        assert!(ci.confidence_upper.iter().all(|v| *v <= 20.0));
862    }
863
864    #[test]
865    fn rejects_inverted_bounds() {
866        let (ts, vals) = seasonal_series(60, 12);
867        let cfg = QuantileConfig::default().with_bounds(Some(10.0), Some(1.0));
868        let mut qf = QuantileForecaster::new(|| Box::new(SesModel::new(Some(0.5))), cfg);
869        assert!(qf.fit(&ts, &vals).is_err());
870    }
871
872    #[test]
873    fn predict_before_fit_errors() {
874        let qf = QuantileForecaster::new(
875            || Box::new(SesModel::new(Some(0.5))),
876            QuantileConfig::default(),
877        );
878        assert!(matches!(qf.predict(4), Err(ForecastError::NotFitted)));
879    }
880
881    #[test]
882    fn central_config_is_symmetric() {
883        let cfg = QuantileConfig::central(0.9, 12);
884        assert_eq!(cfg.levels.len(), 3);
885        for (got, want) in cfg.levels.iter().zip([0.05, 0.5, 0.95]) {
886            assert!((got - want).abs() < 1e-9, "got {got}, want {want}");
887        }
888        assert_eq!(cfg.horizon, 12);
889    }
890
891    /// Empirical intervals must actually cover at roughly the nominal rate on
892    /// held-out data — the property Gaussian intervals lose on skewed series.
893    #[test]
894    fn achieves_approximately_nominal_coverage() {
895        let (ts, vals) = seasonal_series(400, 24);
896        let split = 300;
897
898        let cfg = QuantileConfig {
899            levels: vec![0.05, 0.95],
900            horizon: 1,
901            initial_window: 150,
902            step: 1,
903            strategy: CalibrationStrategy::OnlineUpdate,
904            conformal: true,
905            lower_bound: None,
906            upper_bound: None,
907        };
908        let mut qf = QuantileForecaster::new(
909            || {
910                Box::new(HoltWintersModel::new(
911                    Some(0.3),
912                    Some(0.1),
913                    Some(0.1),
914                    Some(24),
915                    false,
916                ))
917            },
918            cfg,
919        );
920        qf.fit(&ts[..split], &vals[..split]).expect("fit");
921
922        let mut covered = 0;
923        let mut total = 0;
924        for i in split..vals.len() {
925            let f = qf.predict(1).expect("predict");
926            let (lo, hi) = (f.quantiles[0][0], f.quantiles[1][0]);
927            if vals[i] >= lo && vals[i] <= hi {
928                covered += 1;
929            }
930            total += 1;
931            // Not re-calibrating here; just advancing the point forecast.
932            qf.model.update(ts[i], vals[i]).expect("update");
933        }
934        let coverage = f64::from(covered) / f64::from(total);
935        // A 90 % interval is asserted near 90 %, not merely above 70 %. The
936        // looser bar was satisfied by an interval whose lower bound the
937        // conformal correction was actively tightening, which is what let
938        // that defect sit under a passing coverage test.
939        assert!(
940            coverage >= 0.85,
941            "90% interval covered only {coverage:.2} of held-out points"
942        );
943    }
944}