1use crate::drift::detector::{DriftDetector, DriftLevel};
25use crate::error::{RillError, checked_increment, ensure_finite};
26use crate::persistence::ValidateState;
27
28pub const PAGE_HINKLEY_PORTABLE_STATE_VERSION: u32 = 1;
30
31#[derive(Debug, Clone)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[non_exhaustive]
35pub struct PageHinkleyConfig {
36 pub threshold: f64,
40
41 pub warning_threshold: f64,
45
46 pub alpha: f64,
51
52 pub delta: f64,
56
57 pub min_samples: u64,
60}
61
62#[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 pub version: u32,
72 pub threshold: f64,
74 pub warning_threshold: f64,
76 pub alpha: f64,
78 pub delta: f64,
80 pub min_samples: u64,
82 pub mean: f64,
84 pub samples: u64,
86 pub cumulative_sum: f64,
88 pub minimum_cumulative_sum: f64,
90 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#[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 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 pub const fn mean(&self) -> f64 {
243 self.mean
244 }
245
246 pub const fn cum_sum(&self) -> f64 {
248 self.cum_sum
249 }
250
251 pub const fn ph_statistic(&self) -> f64 {
253 self.cum_sum - self.min_cum_sum
254 }
255
256 pub const fn config(&self) -> &PageHinkleyConfig {
258 &self.config
259 }
260
261 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 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 let delta = value - self.mean;
322 self.mean += delta / self.samples as f64;
323 self.cum_sum = self.config.alpha * self.cum_sum + (value - self.mean - self.config.delta);
325 if self.cum_sum < self.min_cum_sum {
327 self.min_cum_sum = self.cum_sum;
328 }
329 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 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 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 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 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 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 for _ in 0..50 {
461 ph.update(0.1).unwrap();
462 }
463 assert_eq!(ph.level(), DriftLevel::None);
464 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 for _ in 0..50 {
488 ph.update(0.0).unwrap();
489 }
490 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 for _ in 0..98 {
517 ph.update(0.0).unwrap();
518 }
519 ph.update(1000.0).unwrap();
521 assert_eq!(ph.level(), DriftLevel::None);
522 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 for _ in 0..10 {
539 ph.update(0.0).unwrap();
540 }
541 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 assert!(
567 PageHinkley::new(PageHinkleyConfig {
568 threshold: 0.0,
569 ..Default::default()
570 })
571 .is_err()
572 );
573 assert!(
575 PageHinkley::new(PageHinkleyConfig {
576 threshold: f64::NAN,
577 ..Default::default()
578 })
579 .is_err()
580 );
581 assert!(
583 PageHinkley::new(PageHinkleyConfig {
584 threshold: 10.0,
585 warning_threshold: 20.0,
586 ..Default::default()
587 })
588 .is_err()
589 );
590 assert!(
592 PageHinkley::new(PageHinkleyConfig {
593 warning_threshold: -1.0,
594 ..Default::default()
595 })
596 .is_err()
597 );
598 assert!(
600 PageHinkley::new(PageHinkleyConfig {
601 alpha: 0.0,
602 ..Default::default()
603 })
604 .is_err()
605 );
606 assert!(
608 PageHinkley::new(PageHinkleyConfig {
609 alpha: 1.5,
610 ..Default::default()
611 })
612 .is_err()
613 );
614 assert!(
616 PageHinkley::new(PageHinkleyConfig {
617 delta: -1.0,
618 ..Default::default()
619 })
620 .is_err()
621 );
622 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 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 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 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}