1use crate::forecast::error::ForecastError;
47use crate::forecast::result::ForecastResult;
48use crate::forecast::traits::ForecastModel;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CalibrationStrategy {
53 Refit,
58 OnlineUpdate,
65}
66
67#[derive(Debug, Clone)]
69pub struct QuantileConfig {
70 pub levels: Vec<f64>,
72 pub horizon: usize,
75 pub initial_window: usize,
77 pub step: usize,
79 pub strategy: CalibrationStrategy,
81 pub conformal: bool,
83 pub lower_bound: Option<f64>,
91 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, step: 1,
103 strategy: CalibrationStrategy::OnlineUpdate,
104 conformal: true,
105 lower_bound: None,
106 upper_bound: None,
107 }
108 }
109}
110
111impl QuantileConfig {
112 #[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 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 #[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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184pub struct QuantileForecast {
185 pub timestamps: Vec<i64>,
187 pub point: Vec<f64>,
189 pub levels: Vec<f64>,
191 pub quantiles: Vec<Vec<f64>>,
193 pub calibration_counts: Vec<usize>,
198}
199
200impl QuantileForecast {
201 #[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 #[must_use]
218 pub fn horizon(&self) -> usize {
219 self.point.len()
220 }
221}
222
223pub struct QuantileForecaster {
248 factory: Box<dyn Fn() -> Box<dyn ForecastModel> + Send + Sync>,
252 model: Box<dyn ForecastModel>,
253 config: QuantileConfig,
254 residuals: Vec<Vec<f64>>,
257 fitted: bool,
258}
259
260impl QuantileForecaster {
261 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 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 self.model.fit(timestamps, values)?;
308 self.fitted = true;
309 Ok(())
310 }
311
312 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(×tamps[..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 self.model.fit(×tamps[..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 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 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 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 enforce_monotonicity(&mut quantiles);
405
406 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 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 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 #[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 #[must_use]
487 pub fn model(&self) -> &dyn ForecastModel {
488 &*self.model
489 }
490}
491
492fn 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
502fn 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 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
567fn 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 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 #[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 #[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 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 #[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 assert!(empirical_quantile(&s, 0.05, true) <= empirical_quantile(&s, 0.95, true));
688 }
689
690 #[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 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 #[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 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 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 #[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 qf.model.update(ts[i], vals[i]).expect("update");
933 }
934 let coverage = f64::from(covered) / f64::from(total);
935 assert!(
940 coverage >= 0.85,
941 "90% interval covered only {coverage:.2} of held-out points"
942 );
943 }
944}