Skip to main content

rill_ml/drift/
page_hinkley.rs

1//! Page-Hinkley sequential change detection.
2//!
3//! The Page-Hinkley test detects sustained shifts in the mean of a scalar
4//! stream. It is well suited for detecting average-value changes in target
5//! values or prediction errors.
6//!
7//! ## Algorithm
8//!
9//! For each new observation `x_t`:
10//!
11//! 1. Update the running mean `x̄` incrementally.
12//! 2. Update the cumulative sum: `S_t = α · S_{t-1} + (x_t − x̄ − δ)`
13//!    where `α` is the forgetting factor and `δ` is the allowed drift
14//!    magnitude.
15//! 3. Track the running minimum: `m_t = min(m_{t-1}, S_t)`.
16//! 4. Compute the test statistic: `PH_t = S_t − m_t`.
17//! 5. Signal drift when `PH_t > threshold`.
18//!
19//! ## Space complexity
20//!
21//! `O(1)` — the detector stores only the running mean, cumulative sum,
22//! minimum, and a counter.
23
24use crate::drift::detector::{DriftDetector, DriftLevel};
25use crate::error::{RillError, checked_increment, ensure_finite};
26use crate::persistence::ValidateState;
27
28/// Portable Page-Hinkley state schema version.
29pub const PAGE_HINKLEY_PORTABLE_STATE_VERSION: u32 = 1;
30
31/// Configuration for [`PageHinkley`].
32#[derive(Debug, Clone)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[non_exhaustive]
35pub struct PageHinkleyConfig {
36    /// The detection threshold (λ). When the test statistic exceeds this
37    /// value, a drift is reported. Must be finite and strictly positive.
38    /// Larger values reduce false positives but increase detection latency.
39    pub threshold: f64,
40
41    /// The warning threshold. When the test statistic exceeds this value
42    /// but not [`threshold`](Self::threshold), a warning is reported.
43    /// Must be in `[0, threshold]`. Set to `0.0` to disable warnings.
44    pub warning_threshold: f64,
45
46    /// The forgetting factor (α) applied to the cumulative sum at each step.
47    /// Must be in `(0, 1]`. Smaller values make the detector forget old
48    /// observations faster. `1.0` gives the standard (non-forgetting)
49    /// Page-Hinkley test.
50    pub alpha: f64,
51
52    /// The allowed drift magnitude (δ). The cumulative sum is penalised by
53    /// this amount at each step, making the detector less sensitive to
54    /// small fluctuations. Must be finite and non-negative.
55    pub delta: f64,
56
57    /// Minimum number of samples before any detection is reported.
58    /// Must be greater than zero.
59    pub min_samples: u64,
60}
61
62/// Versioned, portable Page-Hinkley state.
63///
64/// This DTO is the stable persistence surface for Page-Hinkley continuity.
65/// The detector's direct serde representation remains Preview.
66#[derive(Debug, Clone, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
69pub struct PageHinkleyPortableStateV1 {
70    /// Portable schema version; always `1`.
71    pub version: u32,
72    /// Threshold from the configuration that produced this state.
73    pub threshold: f64,
74    /// Warning threshold from the originating configuration.
75    pub warning_threshold: f64,
76    /// Forgetting factor from the originating configuration.
77    pub alpha: f64,
78    /// Allowed drift magnitude from the originating configuration.
79    pub delta: f64,
80    /// Minimum samples from the originating configuration.
81    pub min_samples: u64,
82    /// Running mean.
83    pub mean: f64,
84    /// Total observations incorporated.
85    pub samples: u64,
86    /// Current cumulative sum.
87    pub cumulative_sum: f64,
88    /// Smallest cumulative sum observed.
89    pub minimum_cumulative_sum: f64,
90    /// Last reported detector level.
91    pub current_level: DriftLevel,
92}
93
94impl ValidateState for PageHinkleyPortableStateV1 {
95    fn validate_state(&self) -> Result<(), RillError> {
96        if self.version != PAGE_HINKLEY_PORTABLE_STATE_VERSION {
97            return Err(RillError::IncompatibleStateVersion {
98                expected: PAGE_HINKLEY_PORTABLE_STATE_VERSION,
99                actual: self.version,
100            });
101        }
102        let config = PageHinkleyConfig {
103            threshold: self.threshold,
104            warning_threshold: self.warning_threshold,
105            alpha: self.alpha,
106            delta: self.delta,
107            min_samples: self.min_samples,
108        };
109        PageHinkley::new(config.clone())?;
110        ensure_finite("portable Page-Hinkley mean", self.mean)?;
111        ensure_finite("portable Page-Hinkley cumulative_sum", self.cumulative_sum)?;
112        ensure_finite(
113            "portable Page-Hinkley minimum_cumulative_sum",
114            self.minimum_cumulative_sum,
115        )?;
116        if self.minimum_cumulative_sum > self.cumulative_sum {
117            return Err(RillError::InvalidState(
118                "Page-Hinkley minimum cumulative sum exceeds cumulative sum".to_owned(),
119            ));
120        }
121        if self.samples == 0
122            && (self.mean != 0.0
123                || self.cumulative_sum != 0.0
124                || self.minimum_cumulative_sum != 0.0
125                || self.current_level != DriftLevel::None)
126        {
127            return Err(RillError::InvalidState(
128                "empty Page-Hinkley state must use zero accumulators and no level".to_owned(),
129            ));
130        }
131        if self.samples < self.min_samples && self.current_level != DriftLevel::None {
132            return Err(RillError::InvalidState(
133                "Page-Hinkley state reports a level before min_samples".to_owned(),
134            ));
135        }
136        Ok(())
137    }
138}
139
140impl Default for PageHinkleyConfig {
141    fn default() -> Self {
142        Self {
143            threshold: 50.0,
144            warning_threshold: 25.0,
145            alpha: 1.0,
146            delta: 0.005,
147            min_samples: 30,
148        }
149    }
150}
151
152/// Page-Hinkley sequential change detector.
153///
154/// Detects sustained mean shifts in a scalar stream. See the module
155/// documentation for the algorithm.
156///
157/// # Examples
158///
159/// ```
160/// use rill_ml::drift::{DriftDetector, DriftLevel, PageHinkley};
161///
162/// let mut ph = PageHinkley::default();
163///
164/// // Stable stream: no drift.
165/// for _ in 0..200 {
166///     ph.update(0.0).unwrap();
167/// }
168/// assert_eq!(ph.level(), DriftLevel::None);
169///
170/// // Sudden shift.
171/// for _ in 0..100 {
172///     ph.update(5.0).unwrap();
173/// }
174/// assert!(ph.detected());
175/// ```
176#[derive(Debug, Clone)]
177#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
178pub struct PageHinkley {
179    config: PageHinkleyConfig,
180    mean: f64,
181    samples: u64,
182    cum_sum: f64,
183    min_cum_sum: f64,
184    current_level: DriftLevel,
185}
186
187impl PageHinkley {
188    /// Create a new Page-Hinkley detector with the given configuration.
189    ///
190    /// Returns an error if:
191    /// - `threshold` is not finite or not strictly positive.
192    /// - `warning_threshold` is negative or greater than `threshold`.
193    /// - `alpha` is not in `(0, 1]`.
194    /// - `delta` is not finite or is negative.
195    /// - `min_samples` is zero.
196    pub fn new(config: PageHinkleyConfig) -> Result<Self, RillError> {
197        ensure_finite("threshold", config.threshold)?;
198        if config.threshold <= 0.0 {
199            return Err(RillError::InvalidParameter {
200                name: "threshold",
201                value: config.threshold,
202            });
203        }
204        ensure_finite("warning_threshold", config.warning_threshold)?;
205        if config.warning_threshold < 0.0 || config.warning_threshold > config.threshold {
206            return Err(RillError::InvalidParameter {
207                name: "warning_threshold",
208                value: config.warning_threshold,
209            });
210        }
211        ensure_finite("alpha", config.alpha)?;
212        if config.alpha <= 0.0 || config.alpha > 1.0 {
213            return Err(RillError::InvalidParameter {
214                name: "alpha",
215                value: config.alpha,
216            });
217        }
218        ensure_finite("delta", config.delta)?;
219        if config.delta < 0.0 {
220            return Err(RillError::InvalidParameter {
221                name: "delta",
222                value: config.delta,
223            });
224        }
225        if config.min_samples == 0 {
226            return Err(RillError::InvalidParameter {
227                name: "min_samples",
228                value: 0.0,
229            });
230        }
231        Ok(Self {
232            config,
233            mean: 0.0,
234            samples: 0,
235            cum_sum: 0.0,
236            min_cum_sum: 0.0,
237            current_level: DriftLevel::None,
238        })
239    }
240
241    /// The current running mean of the observed stream.
242    pub const fn mean(&self) -> f64 {
243        self.mean
244    }
245
246    /// The current cumulative sum `S_t`.
247    pub const fn cum_sum(&self) -> f64 {
248        self.cum_sum
249    }
250
251    /// The current test statistic `PH_t = S_t − min(S)`.
252    pub const fn ph_statistic(&self) -> f64 {
253        self.cum_sum - self.min_cum_sum
254    }
255
256    /// The configuration of this detector.
257    pub const fn config(&self) -> &PageHinkleyConfig {
258        &self.config
259    }
260
261    /// Export the stable portable state without exposing the detector's
262    /// Preview internal serde layout.
263    pub fn export_state_v1(&self) -> PageHinkleyPortableStateV1 {
264        PageHinkleyPortableStateV1 {
265            version: PAGE_HINKLEY_PORTABLE_STATE_VERSION,
266            threshold: self.config.threshold,
267            warning_threshold: self.config.warning_threshold,
268            alpha: self.config.alpha,
269            delta: self.config.delta,
270            min_samples: self.config.min_samples,
271            mean: self.mean,
272            samples: self.samples,
273            cumulative_sum: self.cum_sum,
274            minimum_cumulative_sum: self.min_cum_sum,
275            current_level: self.current_level,
276        }
277    }
278
279    /// Restore from a validated portable state.
280    ///
281    /// The supplied configuration must exactly match the configuration stored
282    /// in the state, preventing accidental continuation under new semantics.
283    pub fn restore_state_v1(
284        config: PageHinkleyConfig,
285        state: PageHinkleyPortableStateV1,
286    ) -> Result<Self, RillError> {
287        state.validate_state()?;
288        if config.threshold != state.threshold
289            || config.warning_threshold != state.warning_threshold
290            || config.alpha != state.alpha
291            || config.delta != state.delta
292            || config.min_samples != state.min_samples
293        {
294            return Err(RillError::InvalidState(
295                "Page-Hinkley portable state configuration mismatch".to_owned(),
296            ));
297        }
298        PageHinkley::new(config.clone())?;
299        Ok(Self {
300            config,
301            mean: state.mean,
302            samples: state.samples,
303            cum_sum: state.cumulative_sum,
304            min_cum_sum: state.minimum_cumulative_sum,
305            current_level: state.current_level,
306        })
307    }
308}
309
310impl Default for PageHinkley {
311    fn default() -> Self {
312        Self::new(PageHinkleyConfig::default()).expect("default config is valid")
313    }
314}
315
316impl DriftDetector for PageHinkley {
317    fn update(&mut self, value: f64) -> Result<DriftLevel, RillError> {
318        ensure_finite("value", value)?;
319        self.samples = checked_increment(self.samples, "samples")?;
320        // Incremental mean update.
321        let delta = value - self.mean;
322        self.mean += delta / self.samples as f64;
323        // Cumulative sum with optional forgetting.
324        self.cum_sum = self.config.alpha * self.cum_sum + (value - self.mean - self.config.delta);
325        // Track the running minimum of the cumulative sum.
326        if self.cum_sum < self.min_cum_sum {
327            self.min_cum_sum = self.cum_sum;
328        }
329        // Determine the level, respecting the minimum-samples gate.
330        if self.samples < self.config.min_samples {
331            self.current_level = DriftLevel::None;
332        } else {
333            let stat = self.ph_statistic();
334            if stat > self.config.threshold {
335                self.current_level = DriftLevel::Drift;
336            } else if stat > self.config.warning_threshold {
337                self.current_level = DriftLevel::Warning;
338            } else {
339                self.current_level = DriftLevel::None;
340            }
341        }
342        Ok(self.current_level)
343    }
344
345    fn detected(&self) -> bool {
346        self.current_level == DriftLevel::Drift
347    }
348
349    fn warning(&self) -> bool {
350        self.current_level == DriftLevel::Warning
351    }
352
353    fn level(&self) -> DriftLevel {
354        self.current_level
355    }
356
357    fn samples_seen(&self) -> u64 {
358        self.samples
359    }
360
361    fn reset(&mut self) {
362        self.mean = 0.0;
363        self.samples = 0;
364        self.cum_sum = 0.0;
365        self.min_cum_sum = 0.0;
366        self.current_level = DriftLevel::None;
367    }
368
369    fn last_value(&self) -> f64 {
370        self.ph_statistic()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    /// Deterministic pseudo-random number in `[0, 1)` using a simple LCG.
379    fn next_unit(seed: &mut u64) -> f64 {
380        *seed = seed
381            .wrapping_mul(6364136223846793005)
382            .wrapping_add(1442695040888963407);
383        ((*seed >> 11) as f64) / ((1u64 << 53) as f64)
384    }
385
386    #[test]
387    fn default_config_is_valid() {
388        let ph = PageHinkley::default();
389        assert_eq!(ph.samples_seen(), 0);
390        assert_eq!(ph.level(), DriftLevel::None);
391        assert!(!ph.detected());
392        assert!(!ph.warning());
393    }
394
395    #[test]
396    fn detects_sudden_mean_shift() {
397        let mut ph = PageHinkley::new(PageHinkleyConfig {
398            threshold: 10.0,
399            warning_threshold: 5.0,
400            alpha: 1.0,
401            delta: 0.01,
402            min_samples: 10,
403        })
404        .unwrap();
405        // Stable stream around 0.
406        let mut seed = 42u64;
407        for _ in 0..100 {
408            let noise = 0.1 * (next_unit(&mut seed) - 0.5);
409            ph.update(noise).unwrap();
410        }
411        assert_eq!(ph.level(), DriftLevel::None);
412        // Sudden shift to mean 5.
413        let mut detected = false;
414        for _ in 0..100 {
415            let noise = 0.1 * (next_unit(&mut seed) - 0.5);
416            let level = ph.update(5.0 + noise).unwrap();
417            if level == DriftLevel::Drift {
418                detected = true;
419                break;
420            }
421        }
422        assert!(detected, "should detect the mean shift");
423    }
424
425    #[test]
426    fn no_false_positive_on_stable_stream() {
427        let mut ph = PageHinkley::new(PageHinkleyConfig {
428            threshold: 20.0,
429            warning_threshold: 10.0,
430            alpha: 0.99,
431            delta: 0.01,
432            min_samples: 30,
433        })
434        .unwrap();
435        // 1000 samples of Gaussian-ish noise around 0 with small variance.
436        let mut seed = 7u64;
437        for _ in 0..1000 {
438            let noise = 0.5 * (next_unit(&mut seed) - 0.5);
439            ph.update(noise).unwrap();
440        }
441        assert!(
442            !ph.detected(),
443            "false positive: drift reported on stable stream (stat={})",
444            ph.ph_statistic()
445        );
446    }
447
448    #[test]
449    fn works_on_prediction_error_stream() {
450        // Simulate prediction errors: initially small, then large after drift.
451        let mut ph = PageHinkley::new(PageHinkleyConfig {
452            threshold: 5.0,
453            warning_threshold: 2.0,
454            alpha: 1.0,
455            delta: 0.0,
456            min_samples: 5,
457        })
458        .unwrap();
459        // Low-error phase.
460        for _ in 0..50 {
461            ph.update(0.1).unwrap();
462        }
463        assert_eq!(ph.level(), DriftLevel::None);
464        // High-error phase.
465        let mut detected_step = None;
466        for i in 0..100 {
467            let level = ph.update(2.0).unwrap();
468            if level == DriftLevel::Drift {
469                detected_step = Some(i);
470                break;
471            }
472        }
473        assert!(detected_step.is_some(), "should detect error increase");
474    }
475
476    #[test]
477    fn warning_before_drift() {
478        let mut ph = PageHinkley::new(PageHinkleyConfig {
479            threshold: 100.0,
480            warning_threshold: 0.5,
481            alpha: 1.0,
482            delta: 0.0,
483            min_samples: 5,
484        })
485        .unwrap();
486        // Baseline phase: feed 0.0 so the running mean settles at 0.
487        for _ in 0..50 {
488            ph.update(0.0).unwrap();
489        }
490        // Shift phase: feed 1.0; the mean lags so cum_sum grows.
491        for _ in 0..100 {
492            ph.update(1.0).unwrap();
493            if ph.warning() || ph.detected() {
494                break;
495            }
496        }
497        assert!(
498            ph.warning() || ph.detected(),
499            "expected warning or drift, got {:?}, stat={}",
500            ph.level(),
501            ph.ph_statistic()
502        );
503    }
504
505    #[test]
506    fn min_samples_gates_detection() {
507        let mut ph = PageHinkley::new(PageHinkleyConfig {
508            threshold: 0.001,
509            warning_threshold: 0.0,
510            alpha: 1.0,
511            delta: 0.0,
512            min_samples: 100,
513        })
514        .unwrap();
515        // Baseline phase: 98 zeros establish a mean near 0.
516        for _ in 0..98 {
517            ph.update(0.0).unwrap();
518        }
519        // Sample 99: shift to 1000.0, but 99 < min_samples=100 → no detection.
520        ph.update(1000.0).unwrap();
521        assert_eq!(ph.level(), DriftLevel::None);
522        // Sample 100: ≥ min_samples, detection can now trigger.
523        ph.update(1000.0).unwrap();
524        assert!(ph.detected() || ph.warning());
525    }
526
527    #[test]
528    fn reset_clears_state() {
529        let mut ph = PageHinkley::new(PageHinkleyConfig {
530            threshold: 1.0,
531            warning_threshold: 0.5,
532            alpha: 1.0,
533            delta: 0.0,
534            min_samples: 5,
535        })
536        .unwrap();
537        // Baseline phase: 10 zeros establish a mean near 0.
538        for _ in 0..10 {
539            ph.update(0.0).unwrap();
540        }
541        // Shift phase: 10 tens trigger detection (mean lags behind).
542        for _ in 0..10 {
543            ph.update(10.0).unwrap();
544        }
545        assert!(ph.detected() || ph.warning());
546        ph.reset();
547        assert_eq!(ph.samples_seen(), 0);
548        assert_eq!(ph.level(), DriftLevel::None);
549        assert_eq!(ph.mean(), 0.0);
550        assert_eq!(ph.cum_sum(), 0.0);
551        assert_eq!(ph.ph_statistic(), 0.0);
552    }
553
554    #[test]
555    fn rejects_non_finite_input() {
556        let mut ph = PageHinkley::default();
557        assert!(ph.update(f64::NAN).is_err());
558        assert!(ph.update(f64::INFINITY).is_err());
559        assert!(ph.update(f64::NEG_INFINITY).is_err());
560        assert_eq!(ph.samples_seen(), 0);
561    }
562
563    #[test]
564    fn rejects_invalid_config() {
565        // threshold <= 0
566        assert!(
567            PageHinkley::new(PageHinkleyConfig {
568                threshold: 0.0,
569                ..Default::default()
570            })
571            .is_err()
572        );
573        // threshold NaN
574        assert!(
575            PageHinkley::new(PageHinkleyConfig {
576                threshold: f64::NAN,
577                ..Default::default()
578            })
579            .is_err()
580        );
581        // warning_threshold > threshold
582        assert!(
583            PageHinkley::new(PageHinkleyConfig {
584                threshold: 10.0,
585                warning_threshold: 20.0,
586                ..Default::default()
587            })
588            .is_err()
589        );
590        // warning_threshold < 0
591        assert!(
592            PageHinkley::new(PageHinkleyConfig {
593                warning_threshold: -1.0,
594                ..Default::default()
595            })
596            .is_err()
597        );
598        // alpha <= 0
599        assert!(
600            PageHinkley::new(PageHinkleyConfig {
601                alpha: 0.0,
602                ..Default::default()
603            })
604            .is_err()
605        );
606        // alpha > 1
607        assert!(
608            PageHinkley::new(PageHinkleyConfig {
609                alpha: 1.5,
610                ..Default::default()
611            })
612            .is_err()
613        );
614        // delta < 0
615        assert!(
616            PageHinkley::new(PageHinkleyConfig {
617                delta: -1.0,
618                ..Default::default()
619            })
620            .is_err()
621        );
622        // min_samples == 0
623        assert!(
624            PageHinkley::new(PageHinkleyConfig {
625                min_samples: 0,
626                ..Default::default()
627            })
628            .is_err()
629        );
630    }
631
632    #[test]
633    fn forgetting_factor_detects_drift() {
634        // Both the forgetting (alpha < 1) and standard (alpha = 1) variants
635        // should detect a sustained mean shift. The forgetting factor decays
636        // old contributions, so for a single shift the standard variant is
637        // typically faster — we only assert both detect the drift.
638        let config_forgetting = PageHinkleyConfig {
639            threshold: 5.0,
640            warning_threshold: 0.0,
641            alpha: 0.8,
642            delta: 0.0,
643            min_samples: 10,
644        };
645        let config_standard = PageHinkleyConfig {
646            alpha: 1.0,
647            ..config_forgetting
648        };
649        let mut ph_f = PageHinkley::new(config_forgetting).unwrap();
650        let mut ph_s = PageHinkley::new(config_standard).unwrap();
651        // Long stable phase at mean 0.
652        for _ in 0..500 {
653            ph_f.update(0.0).unwrap();
654            ph_s.update(0.0).unwrap();
655        }
656        assert_eq!(ph_f.level(), DriftLevel::None);
657        assert_eq!(ph_s.level(), DriftLevel::None);
658        // Shift to mean 2.0.
659        let mut steps_f = None;
660        let mut steps_s = None;
661        for i in 0..200 {
662            let lv_f = ph_f.update(2.0).unwrap();
663            let lv_s = ph_s.update(2.0).unwrap();
664            if steps_f.is_none() && lv_f == DriftLevel::Drift {
665                steps_f = Some(i);
666            }
667            if steps_s.is_none() && lv_s == DriftLevel::Drift {
668                steps_s = Some(i);
669            }
670            if steps_f.is_some() && steps_s.is_some() {
671                break;
672            }
673        }
674        assert!(steps_f.is_some(), "forgetting variant should detect drift");
675        assert!(steps_s.is_some(), "standard variant should detect drift");
676    }
677
678    #[test]
679    fn ph_statistic_is_non_negative() {
680        let mut ph = PageHinkley::default();
681        let mut seed = 123u64;
682        for _ in 0..200 {
683            let v = next_unit(&mut seed) * 2.0 - 1.0;
684            ph.update(v).unwrap();
685            assert!(
686                ph.ph_statistic() >= 0.0,
687                "PH statistic should be non-negative, got {}",
688                ph.ph_statistic()
689            );
690        }
691    }
692
693    #[test]
694    fn mean_tracks_stream_average() {
695        let mut ph = PageHinkley::default();
696        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
697        for &v in &values {
698            ph.update(v).unwrap();
699        }
700        assert!((ph.mean() - 3.0).abs() < 1e-9);
701    }
702
703    #[test]
704    fn portable_state_restore_preserves_future_results() {
705        let config = PageHinkleyConfig {
706            threshold: 3.0,
707            warning_threshold: 1.0,
708            alpha: 0.95,
709            delta: 0.01,
710            min_samples: 5,
711        };
712        let mut original = PageHinkley::new(config.clone()).unwrap();
713        for value in [0.0, 0.1, -0.1, 0.2, 0.0, 1.0, 1.5] {
714            original.update(value).unwrap();
715        }
716        let state = original.export_state_v1();
717        state.validate_state().unwrap();
718        let mut restored = PageHinkley::restore_state_v1(config, state).unwrap();
719        for value in [2.0, 2.0, 0.5, -0.25, 4.0] {
720            assert_eq!(
721                original.update(value).unwrap(),
722                restored.update(value).unwrap()
723            );
724            assert_eq!(original.export_state_v1(), restored.export_state_v1());
725        }
726    }
727
728    #[test]
729    fn portable_state_rejects_mismatch_and_corruption() {
730        let detector = PageHinkley::default();
731        let wrong_config = PageHinkleyConfig {
732            delta: 0.25,
733            ..Default::default()
734        };
735        assert!(PageHinkley::restore_state_v1(wrong_config, detector.export_state_v1()).is_err());
736
737        let mut corrupt = detector.export_state_v1();
738        corrupt.minimum_cumulative_sum = 1.0;
739        assert!(corrupt.validate_state().is_err());
740        let mut corrupt = detector.export_state_v1();
741        corrupt.version = 99;
742        assert!(matches!(
743            corrupt.validate_state(),
744            Err(RillError::IncompatibleStateVersion { .. })
745        ));
746    }
747
748    #[cfg(feature = "serde")]
749    #[test]
750    fn serde_roundtrip() {
751        let mut ph = PageHinkley::new(PageHinkleyConfig {
752            threshold: 15.0,
753            warning_threshold: 7.0,
754            alpha: 0.95,
755            delta: 0.02,
756            min_samples: 20,
757        })
758        .unwrap();
759        for i in 0..50 {
760            ph.update(i as f64 * 0.1).unwrap();
761        }
762        let json = serde_json::to_string(&ph).unwrap();
763        let restored: PageHinkley = serde_json::from_str(&json).unwrap();
764        assert_eq!(restored.samples_seen(), 50);
765        assert!((restored.mean() - ph.mean()).abs() < 1e-12);
766        assert!((restored.cum_sum() - ph.cum_sum()).abs() < 1e-12);
767        assert_eq!(restored.level(), ph.level());
768    }
769
770    #[cfg(feature = "serde")]
771    #[test]
772    fn config_serde_roundtrip() {
773        let config = PageHinkleyConfig {
774            threshold: 42.0,
775            warning_threshold: 21.0,
776            alpha: 0.7,
777            delta: 0.3,
778            min_samples: 15,
779        };
780        let json = serde_json::to_string(&config).unwrap();
781        let restored: PageHinkleyConfig = serde_json::from_str(&json).unwrap();
782        assert!((restored.threshold - 42.0).abs() < 1e-12);
783        assert!((restored.warning_threshold - 21.0).abs() < 1e-12);
784        assert!((restored.alpha - 0.7).abs() < 1e-12);
785        assert!((restored.delta - 0.3).abs() < 1e-12);
786        assert_eq!(restored.min_samples, 15);
787    }
788}