Skip to main content

chronix_analytics/forecast/
holt_winters.rs

1//! Holt-Winters (triple exponential smoothing) forecast model.
2
3use crate::forecast::error::ForecastError;
4use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
5use crate::forecast::traits::ForecastModel;
6use crate::forecast::util::{median_interval, validate_input};
7
8/// Safe division that avoids division by near-zero while preserving sign.
9/// For multiplicative Holt-Winters, seasonal factors can be negative.
10/// Using `y.max(1e-10)` clamps negative values to a small positive, which
11/// corrupts the decomposition. Instead, we preserve the sign and only
12/// guard against |y| < epsilon.
13#[inline]
14fn safe_div(x: f64, y: f64) -> f64 {
15    const EPS: f64 = 1e-10;
16    if y.abs() < EPS {
17        x / EPS.copysign(y)
18    } else {
19        x / y
20    }
21}
22
23/// Holt-Winters model with additive or multiplicative seasonality.
24#[derive(serde::Serialize, serde::Deserialize)]
25pub struct HoltWintersModel {
26    alpha: Option<f64>,
27    beta: Option<f64>,
28    gamma: Option<f64>,
29    period: Option<usize>,
30    multiplicative: bool,
31    params: ModelParams,
32    last_ts: i64,
33    interval_ns: i64,
34    /// Seasonal phase offset — the index into the seasonal vector for the
35    /// first prediction step. Set to `n_train % period` during fit so that
36    /// predictions continue from where training left off in the seasonal cycle.
37    seasonal_phase: usize,
38    fitted: bool,
39}
40
41impl HoltWintersModel {
42    /// Creates a new Holt-Winters model.
43    ///
44    /// If `period` is `None`, it will be auto-detected via autocorrelation.
45    /// If `multiplicative` is true, multiplicative seasonality is used.
46    pub fn new(
47        alpha: Option<f64>,
48        beta: Option<f64>,
49        gamma: Option<f64>,
50        period: Option<usize>,
51        multiplicative: bool,
52    ) -> Self {
53        Self {
54            alpha,
55            beta,
56            gamma,
57            period,
58            multiplicative,
59            params: ModelParams::HoltWinters {
60                alpha: 0.0,
61                beta: 0.0,
62                gamma: 0.0,
63                period: 0,
64                level: 0.0,
65                trend: 0.0,
66                seasonal: Vec::new(),
67                multiplicative: false,
68                residual_std: 0.0,
69                seasonal_phase: 0,
70            },
71            last_ts: 0,
72            interval_ns: 0,
73            seasonal_phase: 0,
74            fitted: false,
75        }
76    }
77
78    /// Auto-detect the seasonal period, or 1 for "no seasonality".
79    ///
80    /// One definition, in
81    /// [`preprocess::detect_period`](crate::preprocess::detect_period): two
82    /// implementations of one thing means one of them is wrong and nothing
83    /// says which.
84    fn detect_period(values: &[f64]) -> usize {
85        crate::preprocess::detect_period(values, values.len() / 2).unwrap_or(1)
86    }
87
88    /// Simple double exponential smoothing (level + trend, no seasonality).
89    /// Used as graceful degradation when training data is too short for
90    /// full seasonal decomposition.
91    fn fit_non_seasonal(
92        &mut self,
93        timestamps: &[i64],
94        values: &[f64],
95        _start: std::time::Instant,
96    ) -> Result<(), ForecastError> {
97        let alpha = self.alpha.unwrap_or(0.3);
98        let beta = self.beta.unwrap_or(0.05);
99
100        let mut level = values[0];
101        let mut trend = if values.len() > 1 {
102            values[1] - values[0]
103        } else {
104            0.0
105        };
106        let mut sse = 0.0;
107        let mut count = 0;
108
109        for &v in &values[1..] {
110            let forecast = level + trend;
111            let err = v - forecast;
112            sse += err * err;
113            count += 1;
114            let new_level = alpha * v + (1.0 - alpha) * (level + trend);
115            trend = beta * (new_level - level) + (1.0 - beta) * trend;
116            level = new_level;
117        }
118
119        // The shared convention (as in `SesModel` and `HoltLinearModel`): divide the
120        // residual sum of squares by the degrees of freedom, not the residual
121        // count. This path estimates α and β plus the initial level, so
122        // `n - 3`. Dividing by the raw count under-estimates the residual
123        // spread, which narrows every prediction interval derived from it and
124        // makes forecast-deviation triggers fire above their nominal rate.
125        let dof = (values.len() as f64 - 3.0).max(1.0);
126        let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
127
128        self.interval_ns = median_interval(timestamps);
129        self.last_ts = timestamps[timestamps.len() - 1];
130        self.seasonal_phase = 0;
131
132        self.params = ModelParams::HoltWinters {
133            alpha,
134            beta,
135            gamma: 0.0,
136            period: 1,
137            level,
138            trend,
139            seasonal: vec![if self.multiplicative { 1.0 } else { 0.0 }],
140            multiplicative: self.multiplicative,
141            residual_std,
142            seasonal_phase: 0,
143        };
144        self.fitted = true;
145        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters")
146            .record(_start.elapsed().as_secs_f64());
147        Ok(())
148    }
149
150    fn optimize_params(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, f64) {
151        let result = crate::forecast::optimizer::minimize_nelder_mead(
152            |p| Self::compute_mse(values, p[0], p[1], p[2], period, multiplicative),
153            &[0.3, 0.05, 0.1],
154            &[
155                crate::forecast::optimizer::Bound::new(0.01, 0.99),
156                crate::forecast::optimizer::Bound::new(0.001, 0.99),
157                crate::forecast::optimizer::Bound::new(0.01, 0.99),
158            ],
159            500,
160            1e-8,
161        );
162        (
163            result.params[0].clamp(0.01, 0.99),
164            result.params[1].clamp(0.001, 0.99),
165            result.params[2].clamp(0.01, 0.99),
166        )
167    }
168
169    fn compute_mse(
170        values: &[f64],
171        alpha: f64,
172        beta: f64,
173        gamma: f64,
174        period: usize,
175        multiplicative: bool,
176    ) -> f64 {
177        if values.len() < 2 * period {
178            return f64::INFINITY;
179        }
180
181        let (mut level, mut trend, mut seasonal) = Self::initialize(values, period, multiplicative);
182        let mut sse = 0.0;
183
184        #[allow(clippy::needless_range_loop)]
185        for i in period..values.len() {
186            let si = i % period;
187            let forecast = if multiplicative {
188                (level + trend) * seasonal[si]
189            } else {
190                level + trend + seasonal[si]
191            };
192            let err = values[i] - forecast;
193            sse += err * err;
194
195            let new_level = if multiplicative {
196                alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
197            } else {
198                alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
199            };
200
201            let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
202
203            seasonal[si] = if multiplicative {
204                gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
205            } else {
206                gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
207            };
208
209            level = new_level;
210            trend = new_trend;
211        }
212
213        sse / (values.len() - period) as f64
214    }
215
216    fn initialize(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, Vec<f64>) {
217        // Level: average of first season
218        let level: f64 = values[..period].iter().sum::<f64>() / period as f64;
219
220        // Trend: average of first differences between seasons
221        let trend = if values.len() >= 2 * period {
222            let mut sum = 0.0;
223            for i in 0..period {
224                sum += (values[period + i] - values[i]) / period as f64;
225            }
226            sum / period as f64
227        } else {
228            0.0
229        };
230
231        // Seasonal: deviation from level in first season
232        let seasonal: Vec<f64> = if multiplicative {
233            values[..period]
234                .iter()
235                .map(|&v| safe_div(v, level))
236                .collect()
237        } else {
238            values[..period].iter().map(|&v| v - level).collect()
239        };
240
241        (level, trend, seasonal)
242    }
243}
244
245/// `Var[e_h] / sigma^2` for additive Holt-Winters:
246/// `1 + sum_{j=1..h-1} psi_j^2` with
247/// `psi_j = alpha(1 + beta*j) + gamma*1[j mod m == 0]`.
248///
249/// Exactly `1.0` at `h = 1`.
250#[must_use]
251pub fn holt_winters_variance_factor(
252    alpha: f64,
253    beta: f64,
254    gamma: f64,
255    period: usize,
256    h: usize,
257) -> f64 {
258    let m = period.max(1);
259    let mut acc = 1.0;
260    for j in 1..h {
261        let mut psi = alpha * (1.0 + beta * j as f64);
262        if j % m == 0 {
263            psi += gamma;
264        }
265        acc += psi * psi;
266    }
267    acc
268}
269
270impl ForecastModel for HoltWintersModel {
271    #[tracing::instrument(skip_all, level = "debug")]
272    fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
273        let _start = std::time::Instant::now();
274        validate_input(timestamps, values)?;
275        let mut period = self.period.unwrap_or_else(|| Self::detect_period(values));
276
277        // Graceful degradation: if data is insufficient for full seasonal
278        // decomposition (< 2×period), fall back to non-seasonal (period=1)
279        // instead of returning an error. This produces a level+trend model
280        // that is still usable for short-term forecasting.
281        if period >= 2 && values.len() < 2 * period {
282            tracing::warn!(
283                period,
284                n = values.len(),
285                "Insufficient data for seasonal Holt-Winters (need {}); \
286                 falling back to non-seasonal model",
287                2 * period,
288            );
289            period = 1;
290        }
291        if period < 2 {
292            // Non-seasonal: simple double exponential smoothing (level + trend)
293            return self.fit_non_seasonal(timestamps, values, _start);
294        }
295
296        let (alpha, beta, gamma) = match (self.alpha, self.beta, self.gamma) {
297            (Some(a), Some(b), Some(g)) => (a, b, g),
298            _ => Self::optimize_params(values, period, self.multiplicative),
299        };
300
301        let (mut level, mut trend, mut seasonal) =
302            Self::initialize(values, period, self.multiplicative);
303        let mut sse = 0.0;
304        let mut count = 0;
305
306        #[allow(clippy::needless_range_loop)]
307        for i in period..values.len() {
308            let si = i % period;
309            let forecast = if self.multiplicative {
310                (level + trend) * seasonal[si]
311            } else {
312                level + trend + seasonal[si]
313            };
314            let err = values[i] - forecast;
315            sse += err * err;
316            count += 1;
317
318            let new_level = if self.multiplicative {
319                alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
320            } else {
321                alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
322            };
323
324            let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
325
326            seasonal[si] = if self.multiplicative {
327                gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
328            } else {
329                gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
330            };
331
332            level = new_level;
333            trend = new_trend;
334        }
335
336        // The same convention, seasonal case: α, β and γ are estimated along with
337        // the initial level, trend and `period` seasonal states, so the
338        // degrees of freedom are `n - (period + 3)`. `fit` guarantees
339        // `n >= 2 * period` on this path, so the clamp only ever engages for
340        // very large periods.
341        let dof = (values.len() as f64 - period as f64 - 3.0).max(1.0);
342        let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
343
344        self.interval_ns = median_interval(timestamps);
345        self.last_ts = timestamps[timestamps.len() - 1];
346        self.seasonal_phase = values.len() % period;
347
348        self.params = ModelParams::HoltWinters {
349            alpha,
350            beta,
351            gamma,
352            period,
353            level,
354            trend,
355            seasonal,
356            multiplicative: self.multiplicative,
357            residual_std,
358            seasonal_phase: self.seasonal_phase,
359        };
360        self.fitted = true;
361        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
362        Ok(())
363    }
364
365    #[tracing::instrument(skip_all, level = "debug")]
366    fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
367        let _start = std::time::Instant::now();
368        if !self.fitted {
369            return Err(ForecastError::NotFitted);
370        }
371
372        let ModelParams::HoltWinters {
373            alpha,
374            beta,
375            gamma,
376            level,
377            trend,
378            seasonal,
379            multiplicative,
380            period,
381            residual_std,
382            ..
383        } = &self.params
384        else {
385            return Err(ForecastError::InvalidInput(
386                "unexpected model params variant".into(),
387            ));
388        };
389
390        let z = 1.96;
391        let mut values = Vec::with_capacity(horizon);
392        let mut timestamps = Vec::with_capacity(horizon);
393        let mut lower = Vec::with_capacity(horizon);
394        let mut upper = Vec::with_capacity(horizon);
395
396        for h in 1..=horizon {
397            let ts = self
398                .last_ts
399                .saturating_add((h as i64).saturating_mul(self.interval_ns));
400            let si = (self.seasonal_phase + h - 1) % period;
401            let forecast = if *multiplicative {
402                (level + h as f64 * trend) * seasonal[si]
403            } else {
404                level + h as f64 * trend + seasonal[si]
405            };
406
407            // PI variance per Hyndman & Athanasopoulos (FPP3 §7.7) and
408            // Hyndman et al. (2008), Table 6.1 — additive Holt-Winters is a
409            // class-1 (linear) model, so
410            //
411            //   Var[e_h] = sigma^2 * (1 + sum_{j=1..h-1} psi_j^2)
412            //   psi_j    = alpha * (1 + beta * j) + gamma * 1[j mod m == 0]
413            //
414            // The psi-weights are *not* cumulative: each is the coefficient
415            // of a single innovation in the MA(inf) expansion. Summing them
416            // into a running total inflated the h = m factor for hourly data
417            // with a daily cycle from 3.45 to 42.4 (alpha = 0.3, beta = 0.1,
418            // m = 24) — an interval an order of magnitude too wide.
419            //
420            // gamma is the *fitted* value read back from the params, not the
421            // optional configured one: `self.gamma` is `None` whenever the
422            // caller let the model choose, which silently dropped the
423            // seasonal term from every auto-fitted model.
424            let var_sum = holt_winters_variance_factor(*alpha, *beta, *gamma, *period, h);
425            let width = z * residual_std * var_sum.sqrt();
426            values.push(forecast);
427            timestamps.push(ts);
428            lower.push(forecast - width);
429            upper.push(forecast + width);
430        }
431
432        metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
433        Ok(ForecastResult {
434            values,
435            timestamps,
436            confidence_lower: lower,
437            confidence_upper: upper,
438            confidence_level: 0.95,
439        })
440    }
441
442    fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
443        if !self.fitted {
444            return Err(ForecastError::NotFitted);
445        }
446
447        let ModelParams::HoltWinters {
448            alpha,
449            beta,
450            gamma,
451            ref mut level,
452            ref mut trend,
453            ref mut seasonal,
454            multiplicative,
455            period,
456            ..
457        } = self.params
458        else {
459            return Err(ForecastError::InvalidInput(
460                "unexpected model params variant".into(),
461            ));
462        };
463
464        // Determine which seasonal index to update
465        let elapsed = timestamp - self.last_ts;
466        let steps = (elapsed / self.interval_ns).max(1) as usize;
467        let si = (self.seasonal_phase + steps - 1) % period;
468
469        let new_level = if multiplicative {
470            alpha * safe_div(value, seasonal[si]) + (1.0 - alpha) * (*level + *trend)
471        } else {
472            alpha * (value - seasonal[si]) + (1.0 - alpha) * (*level + *trend)
473        };
474
475        *trend = beta * (new_level - *level) + (1.0 - beta) * *trend;
476
477        seasonal[si] = if multiplicative {
478            gamma * safe_div(value, new_level) + (1.0 - gamma) * seasonal[si]
479        } else {
480            gamma * (value - new_level) + (1.0 - gamma) * seasonal[si]
481        };
482
483        *level = new_level;
484        self.last_ts = timestamp;
485        // Advance seasonal phase so subsequent predict/update calls use
486        // the correct position in the seasonal cycle.
487        self.seasonal_phase = (self.seasonal_phase + steps) % period;
488        Ok(())
489    }
490
491    fn model_type(&self) -> ModelType {
492        ModelType::HoltWinters
493    }
494
495    fn params(&self) -> &ModelParams {
496        &self.params
497    }
498}
499
500impl crate::forecast::storage::ModelStore for HoltWintersModel {}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn seasonal_data(n: usize, period: usize) -> (Vec<i64>, Vec<f64>) {
507        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
508        let vals: Vec<f64> = (0..n)
509            .map(|i| {
510                let trend = i as f64 * 0.5;
511                let season =
512                    10.0 * (2.0 * std::f64::consts::PI * (i % period) as f64 / period as f64).sin();
513                trend + season + 50.0
514            })
515            .collect();
516        (ts, vals)
517    }
518
519    /// h = 1 must be exactly z*sigma; h = m must match the closed form
520    /// `1 + sum_{j=1..m-1} (alpha(1+beta*j))^2` (no j is divisible by m in
521    /// that range, so gamma does not enter). alpha = 0.3, beta = 0.1, m = 24:
522    /// 1 + 0.09*(23 + 0.2*276 + 0.01*4324) = 11.9296, sqrt = 3.4539253...
523    #[test]
524    fn hw_interval_matches_hyndman_psi_weights() {
525        let (ts, vals) = seasonal_data(120, 24);
526        let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
527        model.fit(&ts, &vals).unwrap();
528        let ModelParams::HoltWinters { residual_std, .. } = model.params() else {
529            panic!()
530        };
531        let sigma = *residual_std;
532        assert!(sigma > 0.0);
533        let r = model.predict(24).unwrap();
534
535        let z = 1.96;
536        let half1 = r.confidence_upper[0] - r.values[0];
537        assert!(
538            (half1 - z * sigma).abs() < 1e-12,
539            "h=1 half-width {half1} != z*sigma {}",
540            z * sigma
541        );
542
543        let expected_factor = 11.929_6_f64;
544        let ratio = (r.confidence_upper[23] - r.values[23]) / half1;
545        assert!(
546            (ratio * ratio - expected_factor).abs() < 1e-9,
547            "h=24 variance ratio {} != {expected_factor}",
548            ratio * ratio
549        );
550        assert!(
551            (ratio - expected_factor.sqrt()).abs() < 1e-9,
552            "h=24 width ratio {ratio} != {}",
553            expected_factor.sqrt()
554        );
555    }
556
557    /// The seasonal term must come from the *fitted* gamma, so an
558    /// auto-fitted model (constructed with `gamma = None`) still widens at
559    /// the seasonal boundary.
560    #[test]
561    fn hw_variance_factor_uses_fitted_gamma_at_seasonal_boundary() {
562        // Closed form at h = m + 1 = 5 with m = 4: psi_4 = alpha(1+4beta)+gamma.
563        let f = holt_winters_variance_factor(0.5, 0.1, 0.3, 4, 5);
564        let mut expected = 1.0;
565        for j in 1..5 {
566            let mut psi = 0.5 * (1.0 + 0.1 * j as f64);
567            if j % 4 == 0 {
568                psi += 0.3;
569            }
570            expected += psi * psi;
571        }
572        assert!((f - expected).abs() < 1e-12);
573        // gamma actually moved the answer.
574        assert!((f - holt_winters_variance_factor(0.5, 0.1, 0.0, 4, 5)).abs() > 0.1);
575    }
576
577    #[test]
578    fn hw_additive_seasonal() {
579        let (ts, vals) = seasonal_data(200, 24);
580        let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
581        model.fit(&ts, &vals).unwrap();
582        let result = model.predict(24).unwrap();
583        assert_eq!(result.values.len(), 24);
584        // Predictions should show seasonal pattern
585        let range = result.values.iter().copied().fold(f64::INFINITY, f64::min);
586        let max = result
587            .values
588            .iter()
589            .copied()
590            .fold(f64::NEG_INFINITY, f64::max);
591        assert!(max - range > 1.0); // Should have variation from seasonality
592    }
593
594    #[test]
595    fn hw_multiplicative() {
596        let (ts, vals) = seasonal_data(200, 12);
597        // Shift values to be strictly positive for multiplicative
598        let vals: Vec<f64> = vals.iter().map(|v| v + 100.0).collect();
599        let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), true);
600        model.fit(&ts, &vals).unwrap();
601        let result = model.predict(12).unwrap();
602        assert_eq!(result.values.len(), 12);
603    }
604
605    #[test]
606    fn hw_auto_period() {
607        let (ts, vals) = seasonal_data(200, 7);
608        let mut model = HoltWintersModel::new(None, None, None, None, false);
609        // This may not detect period=7 exactly, but should fit
610        let result = model.fit(&ts, &vals);
611        // If auto-detection fails (period < 2), it returns InvalidParams
612        // Otherwise it should succeed
613        if result.is_ok() {
614            let pred = model.predict(7).unwrap();
615            assert_eq!(pred.values.len(), 7);
616        }
617    }
618
619    #[test]
620    fn hw_insufficient_data_degrades_gracefully() {
621        // period=24 but only 10 points → should fall back to non-seasonal
622        let mut model = HoltWintersModel::new(None, None, None, Some(24), false);
623        let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect();
624        let vals: Vec<f64> = (0..10).map(|i| 50.0 + i as f64 * 2.0).collect();
625        model.fit(&ts, &vals).unwrap(); // should NOT error
626        let result = model.predict(5).unwrap();
627        assert_eq!(result.values.len(), 5);
628        // Trend should be roughly increasing
629        assert!(result.values[4] > result.values[0]);
630    }
631
632    #[test]
633    fn hw_non_seasonal_predicts_trend() {
634        // Only 5 points with clear upward trend, no period
635        let ts: Vec<i64> = (0..5).map(|i| i * 1_000_000_000).collect();
636        let vals = vec![10.0, 20.0, 30.0, 40.0, 50.0];
637        let mut model = HoltWintersModel::new(Some(0.8), Some(0.2), None, Some(1), false);
638        model.fit(&ts, &vals).unwrap();
639        let result = model.predict(3).unwrap();
640        assert_eq!(result.values.len(), 3);
641        // Should continue upward
642        assert!(result.values[0] > 45.0);
643    }
644
645    #[test]
646    fn hw_update() {
647        let (ts, vals) = seasonal_data(100, 12);
648        let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), false);
649        model.fit(&ts, &vals).unwrap();
650        model.update(100_000_000_000, 200.0).unwrap();
651        let ModelParams::HoltWinters { level, .. } = model.params() else {
652            panic!()
653        };
654        assert!(*level > 50.0);
655    }
656
657    #[test]
658    fn hw_multiplicative_negative_seasonal_factors() {
659        // Multiplicative HW with data that crosses zero — seasonal factors
660        // can become negative. The old `.max(1e-10)` guard would clamp
661        // negative factors to a small positive value, corrupting the model.
662        let period = 4;
663        let n = 80;
664        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
665        // Pattern: values oscillate around a rising baseline, crossing zero
666        let vals: Vec<f64> = (0..n)
667            .map(|i| {
668                let base = 5.0 + i as f64 * 0.2;
669                let seasonal = match i % period {
670                    0 => 1.2,
671                    1 => 0.8,
672                    2 => -0.3, // negative seasonal factor when level > 0
673                    3 => 1.3,
674                    _ => unreachable!(),
675                };
676                base * seasonal
677            })
678            .collect();
679        let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(period), true);
680        model.fit(&ts, &vals).unwrap();
681        let result = model.predict(period).unwrap();
682        assert_eq!(result.values.len(), period);
683        // The third forecast (index 2) should be negative, matching the
684        // negative seasonal pattern. With the old `.max(1e-10)` bug, all
685        // values would be positive.
686        assert!(
687            result.values[2] < 0.0,
688            "Expected negative forecast at seasonal index 2, got {}",
689            result.values[2]
690        );
691        // Other values should be positive
692        assert!(result.values[0] > 0.0);
693        assert!(result.values[1] > 0.0);
694        assert!(result.values[3] > 0.0);
695    }
696
697    #[test]
698    fn safe_div_preserves_sign() {
699        assert!((safe_div(10.0, -0.5) - -20.0).abs() < 1e-6);
700        assert!((safe_div(10.0, 0.5) - 20.0).abs() < 1e-6);
701        // Near-zero positive → large positive
702        assert!(safe_div(1.0, 1e-15) > 0.0);
703        // Near-zero negative → large negative
704        assert!(safe_div(1.0, -1e-15) < 0.0);
705    }
706}