Skip to main content

u_analytics/smoothing/
forecast.rs

1//! Forecasts of the smoothers with their prediction intervals.
2//!
3//! The additive smoothers are the exponential smoothing state-space models
4//! with additive errors -- SES is ETS(A,N,N), Holt ETS(A,A,N), additive
5//! Holt-Winters ETS(A,A,A) -- whose h-step forecast variance has a closed form
6//! (Hyndman, Koehler, Ord & Snyder 2008, Table 6.1, class 1):
7//!
8//! ```text
9//! Var(h) = σ² (1 + Σ_{j=1}^{h-1} c_j²)
10//! SES:          c_j = α
11//! Holt:         c_j = α (1 + j β)
12//! Holt-Winters: c_j = α (1 + j β) + γ (1 − α) [j mod m = 0]
13//! ```
14//!
15//! with the smoothing constants of the component form used here (the
16//! state-space form's trend and seasonal constants are αβ and γ(1 − α)), and σ²
17//! the mean squared one-step error over the observations the smoother updates
18//! on (not those its initialisation reproduces). Multiplicative seasonality has
19//! no such closed form (class 3), so its forecasts carry no interval.
20//!
21//! The forecast of the total over steps 1..=H -- what a cumulative quantity
22//! (stock consumed, wear, a budget spent) reaches -- has the variance of the
23//! sum of the h-step errors. Each error is `ε_{n+h} + Σ_{j<h} c_j ε_{n+h−j}`,
24//! so the innovation of step t enters every later step and
25//!
26//! ```text
27//! Var(Σ_{h≤H}) = σ² Σ_{k=0}^{H−1} (1 + C_k)²,   C_k = Σ_{j=1}^{k} c_j
28//! ```
29//!
30//! (the variance of an aggregated forecast, Hyndman et al. 2008, chapter 6) -- wider than
31//! the sum of the marginal variances, narrower than the sum of the marginal
32//! interval widths. [`first_passage`] simulates the same innovations to give
33//! the distribution of the time the cumulative path first reaches a level.
34//!
35//! # Reference
36//!
37//! Hyndman, R.J., Koehler, A.B., Ord, J.K. & Snyder, R.D. (2008).
38//! *Forecasting with Exponential Smoothing: The State Space Approach*.
39//! Springer, chapter 6.
40
41/// One forecast of a smoother: `step` periods after the last observation.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SmoothingForecast {
44    /// Steps ahead, from 1.
45    pub step: usize,
46    /// The point forecast.
47    pub mean: f64,
48    /// Lower end of the prediction interval; `None` when the smoother has no
49    /// closed-form interval (multiplicative seasonality) or no one-step errors
50    /// to estimate σ from.
51    pub lower: Option<f64>,
52    /// Upper end of the prediction interval.
53    pub upper: Option<f64>,
54    /// The point forecast of the total over steps 1..=`step`.
55    pub cumulative_mean: f64,
56    /// Lower end of the total's prediction interval (`None` as for `lower`).
57    pub cumulative_lower: Option<f64>,
58    /// Upper end of the total's prediction interval.
59    pub cumulative_upper: Option<f64>,
60}
61
62/// Mean squared one-step error `data[t] − fitted[t]` for `t >= first`, or
63/// `None` when there is none.
64pub(crate) fn one_step_mse(data: &[f64], fitted: &[f64], first: usize) -> Option<f64> {
65    let errors: Vec<f64> = data
66        .iter()
67        .zip(fitted)
68        .skip(first)
69        .map(|(x, f)| x - f)
70        .collect();
71    (!errors.is_empty()).then(|| errors.iter().map(|e| e * e).sum::<f64>() / errors.len() as f64)
72}
73
74/// The one-step errors from `first` on, `None` before it (where the fitted
75/// value is the initialisation).
76pub(crate) fn residuals(data: &[f64], fitted: &[f64], first: usize) -> Vec<Option<f64>> {
77    data.iter()
78        .zip(fitted)
79        .enumerate()
80        .map(|(t, (x, f))| (t >= first).then(|| x - f))
81        .collect()
82}
83
84/// Forecasts `mean(h)` for h = 1..=horizon with the class-1 interval at
85/// two-sided `level` when `mse` and `c` are given.
86pub(crate) fn with_intervals(
87    horizon: usize,
88    level: f64,
89    mse: Option<f64>,
90    c: Option<&dyn Fn(usize) -> f64>,
91    mean: impl Fn(usize) -> f64,
92) -> Vec<SmoothingForecast> {
93    let z = u_numflow::special::inverse_normal_cdf(0.5 + level / 2.0);
94    let mut sum_c2 = 0.0;
95    // C_{h-1} = Σ_{j<h} c_j, and Σ_{k<h} (1 + C_k)² for the total.
96    let mut sum_c = 0.0;
97    let mut total_weight = 0.0;
98    let mut total = 0.0;
99    (1..=horizon)
100        .map(|h| {
101            let m = mean(h);
102            total += m;
103            let (half, total_half) = match (mse, c) {
104                (Some(mse), Some(c)) => {
105                    if h > 1 {
106                        let cj = c(h - 1);
107                        sum_c2 += cj.powi(2);
108                        sum_c += cj;
109                    }
110                    total_weight += (1.0 + sum_c).powi(2);
111                    (
112                        Some(z * (mse * (1.0 + sum_c2)).sqrt()),
113                        Some(z * (mse * total_weight).sqrt()),
114                    )
115                }
116                _ => (None, None),
117            };
118            SmoothingForecast {
119                step: h,
120                mean: m,
121                lower: half.map(|w| m - w),
122                upper: half.map(|w| m + w),
123                cumulative_mean: total,
124                cumulative_lower: total_half.map(|w| total - w),
125                cumulative_upper: total_half.map(|w| total + w),
126            }
127        })
128        .collect()
129}
130
131/// `level` strictly inside (0, 1).
132pub(crate) fn valid_level(level: f64) -> bool {
133    level > 0.0 && level < 1.0
134}
135
136/// The arguments [`first_passage`] takes: some steps, a non-zero finite
137/// level, quantiles in (0, 1], at least one future.
138pub(crate) fn passage_arguments(
139    horizon: usize,
140    threshold: f64,
141    quantiles: &[f64],
142    paths: usize,
143) -> bool {
144    horizon > 0
145        && paths > 0
146        && threshold.is_finite()
147        && threshold != 0.0
148        && quantiles.iter().all(|&q| q > 0.0 && q <= 1.0)
149}
150
151/// When the forecast total first reaches a level: over simulated futures of
152/// the fitted model ([`SesResult::first_passage`](super::SesResult::first_passage) and the
153/// same method of the other additive smoothers).
154#[derive(Debug, Clone, PartialEq)]
155pub struct FirstPassage {
156    /// The level the total over steps 1, 2, … reaches: from below when
157    /// positive, from above when negative.
158    pub threshold: f64,
159    /// Share of the simulated futures whose total reaches it within the
160    /// horizon.
161    pub probability: f64,
162    /// `(q, time)` for each quantile asked: the time, in steps after the last
163    /// observation, by which a share `q` of the futures has reached the level
164    /// -- interpolated within the step it is reached in (the total is taken to
165    /// grow evenly through a step). `None` when fewer than that share reach it
166    /// within the horizon.
167    pub quantiles: Vec<(f64, Option<f64>)>,
168    /// The number of futures simulated.
169    pub paths: usize,
170}
171
172/// The weights `c_j` an innovation carries `j` steps later in the additive
173/// smoothers: `level + slope · j`, plus `season` every `period` steps --
174/// SES `(α, 0, 0)`, Holt `(α, αβ, 0)`, Holt-Winters `(α, αβ, γ(1 − α))` with its
175/// period (the c_j of the module documentation).
176#[derive(Debug, Clone, Copy)]
177pub(crate) struct InnovationWeights {
178    pub(crate) level: f64,
179    pub(crate) slope: f64,
180    pub(crate) season: f64,
181    /// `0`: no season.
182    pub(crate) period: usize,
183}
184
185impl InnovationWeights {
186    pub(crate) fn c(&self, j: usize) -> f64 {
187        self.level
188            + self.slope * j as f64
189            + if self.period > 0 && j % self.period == 0 {
190                self.season
191            } else {
192                0.0
193            }
194    }
195}
196
197/// Simulates `paths` futures of an additive smoother over `means.len()` steps
198/// -- step h is `means[h−1] + ε_h + Σ_{j<h} c_j ε_{h−j}` with independent
199/// `ε ~ N(0, mse)` drawn from a generator seeded with `seed` -- and reports
200/// when each one's running total first reaches `threshold` (non-zero).
201/// Quantiles are the inverse empirical distribution of those times, a future
202/// that never reaches it counting as later than every horizon.
203///
204/// `O(paths · H)` for `H` steps: with `c_j = a + b·j + g·[j mod m = 0]` the
205/// weighted sum of the earlier innovations is `a·S₀ + b·S₁ + g·R`, where
206/// `S₀ = Σ_{t<h} ε_t`, `S₁ = Σ_{t<h} (h − t) ε_t` and `R` sums the innovations
207/// a whole number of periods back -- each updated in constant time a step.
208pub(crate) fn first_passage(
209    means: &[f64],
210    mse: f64,
211    weights: InnovationWeights,
212    threshold: f64,
213    quantiles: &[f64],
214    paths: usize,
215    seed: u64,
216) -> FirstPassage {
217    use u_numflow::distributions::{Normal, Sample};
218    let horizon = means.len();
219    let InnovationWeights {
220        level: a,
221        slope: b,
222        season: g,
223        period: m,
224    } = weights;
225    // σ = 0 (a perfect fit): every future is the point forecast.
226    let noise = (mse > 0.0).then(|| Normal::new(0.0, mse.sqrt()).expect("a finite, positive σ"));
227    let mut rng = u_numflow::random::create_rng(seed);
228    let reached = |total: f64| {
229        if threshold > 0.0 {
230            total >= threshold
231        } else {
232            total <= threshold
233        }
234    };
235    // Innovations a whole number of periods apart, by residue.
236    let mut by_residue = vec![0.0_f64; m.max(1)];
237    let mut times: Vec<f64> = Vec::with_capacity(paths);
238    for _ in 0..paths {
239        // All of a future's innovations are drawn, in order, whether or not it
240        // reaches the level early -- the draws of the next future do not
241        // depend on where this one stopped.
242        let draws: Vec<f64> = (0..horizon)
243            .map(|_| noise.as_ref().map_or(0.0, |n| n.sample(&mut rng)))
244            .collect();
245        let (mut s0, mut s1) = (0.0_f64, 0.0_f64);
246        by_residue.iter_mut().for_each(|r| *r = 0.0);
247        let mut total = 0.0_f64;
248        let mut time = f64::INFINITY;
249        for h in 0..horizon {
250            // Step h+1: s0 = Σ_{t≤h} ε_t, s1 = Σ_{t≤h} (h+1−t) ε_t over the
251            // earlier steps t = 1..=h (zero-based t < h here).
252            let earlier_season = if m > 0 { by_residue[h % m] } else { 0.0 };
253            let error = draws[h] + a * s0 + b * s1 + g * earlier_season;
254            s0 += draws[h];
255            s1 += s0;
256            if m > 0 {
257                by_residue[h % m] += draws[h];
258            }
259            let next = total + means[h] + error;
260            if reached(next) {
261                // The share of step h+1 the total needs to reach the level.
262                let fraction = ((threshold - total) / (next - total)).clamp(0.0, 1.0);
263                time = h as f64 + fraction;
264                break;
265            }
266            total = next;
267        }
268        times.push(time);
269    }
270    times.sort_by(f64::total_cmp);
271    let crossed = times.iter().filter(|t| t.is_finite()).count();
272    let quantiles = quantiles
273        .iter()
274        .map(|&q| {
275            // The smallest time t with a share >= q of the futures by t.
276            let rank = ((q * paths as f64).ceil() as usize).clamp(1, paths);
277            let t = times[rank - 1];
278            (q, t.is_finite().then_some(t))
279        })
280        .collect();
281    FirstPassage {
282        threshold,
283        probability: crossed as f64 / paths as f64,
284        quantiles,
285        paths,
286    }
287}
288
289/// The fitted values, forecasts and prediction intervals of the additive
290/// smoothers against statsmodels 0.15.0 `ETSModel(..., error="add",
291/// initialization_method="known").smooth(params)` with the same initial states
292/// (`get_prediction(...).summary_frame(alpha=0.1)`), on seeded series rounded to
293/// 3 places: a random walk (SES), a noisy line (Holt) and a period-4 seasonal
294/// line (Holt-Winters), six steps ahead.
295#[cfg(test)]
296#[allow(clippy::approx_constant)] // seeded data, not mathematical constants
297mod statsmodels_reference {
298    use super::*;
299    use crate::smoothing::{
300        HoltLinear, HoltResult, HoltWinters, Seasonality, SesResult, SimpleExponentialSmoothing,
301    };
302
303    // Generated by ets_ref.py - statsmodels 0.15.0, seed 20261028.
304    const SES_DATA: &[f64] = &[
305        49.695, 48.755, 48.385, 48.678, 47.146, 48.813, 49.806, 49.983, 50.957, 49.731, 48.969,
306        49.761, 48.5, 49.557, 50.796, 51.74, 52.288, 55.501, 55.645, 55.541, 55.655, 55.734,
307        56.497, 55.544, 53.348, 52.352, 53.479, 52.5, 53.481, 55.59,
308    ];
309    const SES_FITTED: &[f64] = &[
310        49.695,
311        49.366,
312        49.02265,
313        48.9020225,
314        48.287414625000004,
315        48.47136950625,
316        48.9384901790625,
317        49.30406861639062,
318        49.8825946006539,
319        49.82953649042504,
320        49.52834871877628,
321        49.609776667204585,
322        49.22135483368298,
323        49.338830641893935,
324        49.848839917231054,
325        50.51074594620019,
326        51.13278486503012,
327        52.66166016226957,
328        53.70582910547522,
329        54.34813891855889,
330        54.80554029706328,
331        55.130501193091135,
332        55.60877577550924,
333        55.586104254081,
334        54.80276776515265,
335        53.94499904734923,
336        53.781899380777,
337        53.33323459750505,
338        53.38495248837828,
339    ];
340    const SES_MEAN: &[f64] = &[
341        54.15671911744588,
342        54.15671911744588,
343        54.15671911744588,
344        54.15671911744588,
345        54.15671911744588,
346        54.15671911744588,
347    ];
348    const SES_LOWER: &[f64] = &[
349        51.50271152380141,
350        51.344848484798455,
351        51.195388909183464,
352        51.053118506866284,
353        50.917089987805795,
354        50.786547453385566,
355    ];
356    const SES_UPPER: &[f64] = &[
357        56.81072671109035,
358        56.96858975009331,
359        57.1180493257083,
360        57.26031972802548,
361        57.39634824708597,
362        57.5268907815062,
363    ];
364    const HOLT_DATA: &[f64] = &[
365        18.624, 22.028, 22.022, 22.77, 23.168, 25.438, 23.856, 26.135, 25.839, 25.885, 27.652,
366        29.208, 30.853, 32.14, 31.311, 31.097, 32.108, 31.913, 36.539, 34.939, 34.931, 36.448,
367        37.208, 36.854, 38.229, 38.696, 41.158, 43.842, 42.405, 41.971,
368    ];
369    const HOLT_FITTED: &[f64] = &[
370        25.432,
371        27.199199999999998,
372        28.204383999999997,
373        28.563783679999997,
374        29.4373607936,
375        28.882198198272,
376        29.24092478521344,
377        29.065606754561227,
378        28.724367395805,
379        29.140634388886866,
380        30.018183833625034,
381        31.269498793777935,
382        32.60472786636744,
383        32.97076708061175,
384        32.954889242709385,
385        33.28201140055122,
386        33.29076378321223,
387        35.406274110151855,
388        35.99819837750347,
389        36.26477706771417,
390        37.04618211642346,
391        37.83197057633515,
392        38.08360600617535,
393        38.796218783585445,
394        39.402568947344676,
395        40.89161352981263,
396        43.094671196908386,
397        43.78653210141317,
398    ];
399    const HOLT_MEAN: &[f64] = &[
400        43.88280607600299,
401        44.70529289115808,
402        45.52777970631317,
403        46.35026652146826,
404        47.172753336623344,
405        47.99524015177843,
406    ];
407    const HOLT_LOWER: &[f64] = &[
408        39.881805953904774,
409        40.26724764211682,
410        40.55622511518835,
411        40.758018971303315,
412        40.88214280288195,
413        40.93713118743697,
414    ];
415    const HOLT_UPPER: &[f64] = &[
416        47.88380619810121,
417        49.14333814019934,
418        50.49933429743799,
419        51.9425140716332,
420        53.46336387036474,
421        55.0533491161199,
422    ];
423    const HW_DATA: &[f64] = &[
424        107.195, 99.797, 99.111, 104.953, 111.536, 103.86, 105.869, 111.842, 117.337, 112.456,
425        110.451, 115.115, 124.234, 116.183, 117.719, 123.434, 127.431, 122.666, 123.802, 130.548,
426        135.536, 127.843, 129.797, 133.84, 142.645, 133.886, 134.436, 141.34, 148.881, 140.176,
427        141.191, 145.134,
428    ];
429    const HW_FITTED: &[f64] = &[
430        108.57318749999999,
431        103.53110312499999,
432        104.42071096874999,
433        112.21758513031247,
434        116.37452167699682,
435        110.342324187491,
436        112.08772508093094,
437        118.67183234829378,
438        122.04503373510259,
439        116.55551472827399,
440        116.797425126349,
441        123.78843943015733,
442        129.19938105911905,
443        122.02718587096355,
444        122.78208766197088,
445        129.56537788125988,
446        135.15618721562316,
447        129.1403441795491,
448        129.4136407564024,
449        136.01289275800133,
450        140.4703440485413,
451        134.67208484162995,
452        135.38085344677242,
453        141.08242228813216,
454        147.0508746050361,
455        140.6418295369387,
456        141.43920083128222,
457        147.6014010922094,
458    ];
459    const HW_MEAN: &[f64] = &[
460        152.98701643046797,
461        145.53169868698552,
462        146.42543580819583,
463        152.19971424007915,
464        158.7573450971372,
465        151.30202735365475,
466    ];
467    const HW_LOWER: &[f64] = &[
468        150.4614216897758,
469        142.8721382966387,
470        143.61475408277198,
471        149.22143937008624,
472        155.42143213962436,
473        147.77782707686427,
474    ];
475    const HW_UPPER: &[f64] = &[
476        155.51261117116016,
477        148.19125907733235,
478        149.23611753361968,
479        155.17798911007205,
480        162.09325805465005,
481        154.82622763044523,
482    ];
483
484    fn close(got: f64, want: f64, what: &str) {
485        let tol = 1e-9 * want.abs().max(1.0);
486        assert!((got - want).abs() <= tol, "{what}: {got} vs {want}");
487    }
488
489    fn compare(
490        fitted: &[f64],
491        first: usize,
492        forecasts: &[SmoothingForecast],
493        want_fitted: &[f64],
494        want: [&[f64]; 3],
495    ) {
496        assert_eq!(fitted.len() - first, want_fitted.len());
497        for (got, want) in fitted[first..].iter().zip(want_fitted) {
498            close(*got, *want, "fitted");
499        }
500        for (i, f) in forecasts.iter().enumerate() {
501            close(f.mean, want[0][i], "mean");
502            close(f.lower.expect("interval"), want[1][i], "lower");
503            close(f.upper.expect("interval"), want[2][i], "upper");
504        }
505    }
506
507    #[test]
508    fn ses_matches_ets_ann() {
509        let r = SimpleExponentialSmoothing::new(0.35)
510            .unwrap()
511            .smooth(SES_DATA)
512            .unwrap();
513        let f = r.forecasts(SES_DATA, 6, 0.9).unwrap();
514        compare(
515            &r.fitted,
516            SesResult::FIRST_ERROR,
517            &f,
518            SES_FITTED,
519            [SES_MEAN, SES_LOWER, SES_UPPER],
520        );
521    }
522
523    #[test]
524    fn holt_matches_ets_aan() {
525        let r = HoltLinear::new(0.4, 0.2)
526            .unwrap()
527            .smooth(HOLT_DATA)
528            .unwrap();
529        let f = r.forecasts(HOLT_DATA, 6, 0.9).unwrap();
530        compare(
531            &r.fitted,
532            HoltResult::FIRST_ERROR,
533            &f,
534            HOLT_FITTED,
535            [HOLT_MEAN, HOLT_LOWER, HOLT_UPPER],
536        );
537    }
538
539    #[test]
540    fn additive_holt_winters_matches_ets_aaa() {
541        let r = HoltWinters::new(0.3, 0.1, 0.25, 4, Seasonality::Additive)
542            .unwrap()
543            .smooth(HW_DATA)
544            .unwrap();
545        let f = r.forecasts(HW_DATA, 6, 0.9).unwrap();
546        compare(
547            &r.fitted,
548            r.first_error(),
549            &f,
550            HW_FITTED,
551            [HW_MEAN, HW_LOWER, HW_UPPER],
552        );
553    }
554}
555
556#[cfg(test)]
557mod cumulative {
558    use super::*;
559
560    const SES_03: InnovationWeights = InnovationWeights {
561        level: 0.3,
562        slope: 0.0,
563        season: 0.0,
564        period: 0,
565    };
566
567    /// Var(Σ_{h≤H} e_h) from the covariances of the h-step errors themselves:
568    /// e_h = Σ_{t≤h} w_h(t) ε_t with w_h(h) = 1, w_h(t) = c_{h−t}.
569    fn total_variance_by_covariance(mse: f64, c: &dyn Fn(usize) -> f64, horizon: usize) -> f64 {
570        let w = |h: usize, t: usize| if t == h { 1.0 } else { c(h - t) };
571        let mut v = 0.0;
572        for h in 1..=horizon {
573            for g in 1..=horizon {
574                for t in 1..=h.min(g) {
575                    v += w(h, t) * w(g, t);
576                }
577            }
578        }
579        mse * v
580    }
581
582    #[test]
583    fn the_totals_interval_is_the_sum_of_the_error_covariances() {
584        let z = u_numflow::special::inverse_normal_cdf(0.975);
585        let holt_winters =
586            |j: usize| 0.3 * (1.0 + j as f64 * 0.1) + if j % 4 == 0 { 0.2 * 0.7 } else { 0.0 };
587        let cases: [(&str, &dyn Fn(usize) -> f64); 3] = [
588            ("ses", &|_| 0.4),
589            ("holt", &|j| 0.3 * (1.0 + j as f64 * 0.1)),
590            ("holt_winters", &holt_winters),
591        ];
592        for (name, c) in cases {
593            let fs = with_intervals(12, 0.95, Some(2.5), Some(c), |h| 10.0 + h as f64);
594            for f in &fs {
595                let half = z * total_variance_by_covariance(2.5, c, f.step).sqrt();
596                let total: f64 = (1..=f.step).map(|h| 10.0 + h as f64).sum();
597                assert!(
598                    (f.cumulative_mean - total).abs() < 1e-9,
599                    "{name} h {}",
600                    f.step
601                );
602                let lower = f.cumulative_lower.expect("an interval");
603                assert!(
604                    (lower - (total - half)).abs() < 1e-9 * total.abs().max(1.0),
605                    "{name} h {}",
606                    f.step
607                );
608            }
609            // One step: the total is that step.
610            assert_eq!(fs[0].cumulative_lower, fs[0].lower);
611        }
612        // No error model, no interval for the total either.
613        let fs = with_intervals(3, 0.95, None, None, |_| 1.0);
614        assert!(fs
615            .iter()
616            .all(|f| f.cumulative_lower.is_none() && f.cumulative_upper.is_none()));
617        assert_eq!(fs[2].cumulative_mean, 3.0);
618    }
619
620    #[test]
621    fn without_noise_the_level_is_reached_when_the_forecast_total_reaches_it() {
622        // 760 a step, a level of 4 820: 6 whole steps (4 560) and 260/760 of the 7th.
623        let fp = first_passage(&[760.0; 10], 0.0, SES_03, 4820.0, &[0.1, 0.5, 0.9], 50, 1);
624        assert_eq!(fp.probability, 1.0);
625        for (_, t) in &fp.quantiles {
626            assert!((t.expect("reached") - (6.0 + 260.0 / 760.0)).abs() < 1e-12);
627        }
628        // Never reached within the horizon: no time.
629        let fp = first_passage(&[760.0; 5], 0.0, SES_03, 4820.0, &[0.5], 10, 1);
630        assert_eq!(fp.probability, 0.0);
631        assert_eq!(fp.quantiles, vec![(0.5, None)]);
632        // A negative level is reached from above.
633        let fp = first_passage(&[-2.0; 5], 0.0, SES_03, -5.0, &[0.5], 10, 1);
634        assert!((fp.quantiles[0].1.expect("reached") - 2.5).abs() < 1e-12);
635    }
636
637    #[test]
638    fn the_simulated_totals_spread_as_the_closed_form_says() {
639        // The futures' totals at the horizon have the variance of the total's
640        // interval: check the simulation against the closed form.
641        let c = |j: usize| 0.3 * (1.0 + j as f64 * 0.1);
642        let (mse, horizon, paths) = (4.0, 8, 40_000);
643        let want = total_variance_by_covariance(mse, &c, horizon);
644        let coefficients: Vec<f64> = (1..horizon).map(c).collect();
645        use u_numflow::distributions::{Normal, Sample};
646        let noise = Normal::new(0.0, mse.sqrt()).expect("valid");
647        let mut rng = u_numflow::random::create_rng(9);
648        let totals: Vec<f64> = (0..paths)
649            .map(|_| {
650                let eps: Vec<f64> = (0..horizon).map(|_| noise.sample(&mut rng)).collect();
651                (0..horizon)
652                    .map(|h| {
653                        eps[h]
654                            + (1..=h)
655                                .map(|j| coefficients[j - 1] * eps[h - j])
656                                .sum::<f64>()
657                    })
658                    .sum::<f64>()
659            })
660            .collect();
661        let mean = totals.iter().sum::<f64>() / paths as f64;
662        let var = totals.iter().map(|t| (t - mean).powi(2)).sum::<f64>() / (paths - 1) as f64;
663        assert!(
664            (var / want - 1.0).abs() < 0.03,
665            "simulated {var}, closed form {want}"
666        );
667    }
668
669    #[test]
670    fn the_same_seed_gives_the_same_times_and_the_median_sits_near_the_mean_path() {
671        let a = first_passage(
672            &[760.0; 12],
673            400.0,
674            SES_03,
675            4820.0,
676            &[0.1, 0.5, 0.9],
677            4000,
678            42,
679        );
680        let b = first_passage(
681            &[760.0; 12],
682            400.0,
683            SES_03,
684            4820.0,
685            &[0.1, 0.5, 0.9],
686            4000,
687            42,
688        );
689        assert_eq!(a, b);
690        let t = |q: usize| a.quantiles[q].1.expect("reached");
691        assert!(t(0) < t(1) && t(1) < t(2));
692        assert!(
693            (t(1) - (6.0 + 260.0 / 760.0)).abs() < 0.15,
694            "median {}",
695            t(1)
696        );
697        assert_eq!(a.paths, 4000);
698    }
699
700    /// The times by direct convolution of the innovations, with the same draws.
701    fn times_by_convolution(
702        means: &[f64],
703        mse: f64,
704        w: InnovationWeights,
705        threshold: f64,
706        paths: usize,
707        seed: u64,
708    ) -> Vec<f64> {
709        use u_numflow::distributions::{Normal, Sample};
710        let noise = Normal::new(0.0, mse.sqrt()).expect("valid");
711        let mut rng = u_numflow::random::create_rng(seed);
712        (0..paths)
713            .map(|_| {
714                let eps: Vec<f64> = (0..means.len()).map(|_| noise.sample(&mut rng)).collect();
715                let mut total = 0.0;
716                for h in 0..means.len() {
717                    let next = total
718                        + means[h]
719                        + eps[h]
720                        + (1..=h).map(|j| w.c(j) * eps[h - j]).sum::<f64>();
721                    if next >= threshold {
722                        return h as f64 + (threshold - total) / (next - total);
723                    }
724                    total = next;
725                }
726                f64::INFINITY
727            })
728            .collect()
729    }
730
731    #[test]
732    fn the_running_sums_give_the_times_the_convolution_does() {
733        let hw = InnovationWeights {
734            level: 0.3,
735            slope: 0.03,
736            season: 0.14,
737            period: 4,
738        };
739        let means: Vec<f64> = (1..=40).map(|h| 10.0 + (h % 4) as f64).collect();
740        let mut want = times_by_convolution(&means, 9.0, hw, 250.0, 300, 5);
741        want.sort_by(f64::total_cmp);
742        // (r − ½)/300 ranks to r exactly (r/300 · 300 may round up to r + 1).
743        let qs: Vec<f64> = (1..=300).map(|r| (r as f64 - 0.5) / 300.0).collect();
744        let got = first_passage(&means, 9.0, hw, 250.0, &qs, 300, 5);
745        for ((_, t), w) in got.quantiles.iter().zip(&want) {
746            match t {
747                Some(t) => assert!((t - w).abs() < 1e-9, "{t} vs {w}"),
748                None => assert!(w.is_infinite()),
749            }
750        }
751    }
752}