1use std::collections::VecDeque;
14
15use crate::error::{RillError, checked_increment, ensure_finite};
16use crate::persistence::ValidateState;
17use crate::traits::OnlineStatistic;
18
19pub const MAX_ROBUST_WINDOW_SIZE: usize = 65_536;
24
25pub const MAD_NORMAL_CONSISTENCY_SCALE: f64 = 1.482_602_218_505_602;
27
28pub const MODIFIED_Z_NORMAL_FACTOR: f64 = 0.674_489_750_196_081_7;
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct MedianMadSummary {
34 samples: usize,
35 median: f64,
36 mad: f64,
37}
38
39impl MedianMadSummary {
40 pub const fn samples(&self) -> usize {
42 self.samples
43 }
44
45 pub const fn median(&self) -> f64 {
47 self.median
48 }
49
50 pub const fn mad(&self) -> f64 {
52 self.mad
53 }
54
55 pub fn normal_scaled_mad(&self) -> Result<f64, RillError> {
60 let scaled = self.mad * MAD_NORMAL_CONSISTENCY_SCALE;
61 ensure_finite("normal-scaled MAD", scaled)?;
62 Ok(scaled)
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum ModifiedZScore {
73 Defined {
75 score: f64,
77 median: f64,
79 mad: f64,
81 },
82 ZeroMad {
84 median: f64,
86 observation: f64,
88 },
89}
90
91impl ModifiedZScore {
92 pub const fn score(&self) -> Option<f64> {
94 match self {
95 Self::Defined { score, .. } => Some(*score),
96 Self::ZeroMad { .. } => None,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize))]
113pub struct RollingMedianMad {
114 window: VecDeque<f64>,
115 capacity: usize,
116 min_samples: usize,
117 samples_seen: u64,
118}
119
120#[cfg(feature = "serde")]
121impl<'de> serde::Deserialize<'de> for RollingMedianMad {
122 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
123 where
124 D: serde::Deserializer<'de>,
125 {
126 #[derive(serde::Deserialize)]
127 #[serde(deny_unknown_fields)]
128 struct State {
129 window: BoundedRobustWindow,
130 capacity: usize,
131 min_samples: usize,
132 samples_seen: u64,
133 }
134
135 let state = State::deserialize(deserializer)?;
136 let statistic = Self {
137 window: state.window.0,
138 capacity: state.capacity,
139 min_samples: state.min_samples,
140 samples_seen: state.samples_seen,
141 };
142 statistic
143 .validate_state()
144 .map_err(serde::de::Error::custom)?;
145 Ok(statistic)
146 }
147}
148
149#[cfg(feature = "serde")]
150struct BoundedRobustWindow(VecDeque<f64>);
151
152#[cfg(feature = "serde")]
153impl<'de> serde::Deserialize<'de> for BoundedRobustWindow {
154 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
155 where
156 D: serde::Deserializer<'de>,
157 {
158 struct WindowVisitor;
159
160 impl<'de> serde::de::Visitor<'de> for WindowVisitor {
161 type Value = BoundedRobustWindow;
162
163 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 write!(
165 formatter,
166 "at most {MAX_ROBUST_WINDOW_SIZE} finite rolling-window values"
167 )
168 }
169
170 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
171 where
172 A: serde::de::SeqAccess<'de>,
173 {
174 let initial_capacity = sequence
175 .size_hint()
176 .unwrap_or(0)
177 .min(MAX_ROBUST_WINDOW_SIZE);
178 let mut window = VecDeque::with_capacity(initial_capacity);
179 while let Some(value) = sequence.next_element::<f64>()? {
180 if window.len() == MAX_ROBUST_WINDOW_SIZE {
181 return Err(serde::de::Error::custom(format!(
182 "rolling median/MAD window exceeds maximum {MAX_ROBUST_WINDOW_SIZE}"
183 )));
184 }
185 if !value.is_finite() {
186 return Err(serde::de::Error::custom(
187 "rolling median/MAD window contains a non-finite value",
188 ));
189 }
190 window.push_back(value);
191 }
192 Ok(BoundedRobustWindow(window))
193 }
194 }
195
196 deserializer.deserialize_seq(WindowVisitor)
197 }
198}
199
200impl RollingMedianMad {
201 pub fn new(capacity: usize, min_samples: usize) -> Result<Self, RillError> {
206 validate_rolling_median_mad_config(capacity, min_samples)?;
207 Ok(Self {
208 window: VecDeque::with_capacity(capacity),
209 capacity,
210 min_samples,
211 samples_seen: 0,
212 })
213 }
214
215 pub const fn capacity(&self) -> usize {
217 self.capacity
218 }
219
220 pub const fn min_samples(&self) -> usize {
222 self.min_samples
223 }
224
225 pub fn len(&self) -> usize {
227 self.window.len()
228 }
229
230 pub fn is_empty(&self) -> bool {
232 self.window.is_empty()
233 }
234
235 pub fn is_ready(&self) -> bool {
237 self.window.len() >= self.min_samples
238 }
239
240 pub fn summary(&self) -> Result<MedianMadSummary, RillError> {
247 if !self.is_ready() {
248 return Err(RillError::InsufficientData);
249 }
250
251 let mut scratch = Vec::with_capacity(self.window.len());
252 scratch.extend(self.window.iter().copied());
253 scratch.sort_by(f64::total_cmp);
254 let median = median_of_sorted(&scratch);
255
256 for value in &mut scratch {
257 *value = absolute_distance(*value, median);
258 }
259 scratch.sort_by(f64::total_cmp);
260 let mad = median_of_sorted(&scratch);
261 if !mad.is_finite() {
262 return Err(RillError::InvalidState(
263 "rolling MAD exceeds the finite f64 range".to_owned(),
264 ));
265 }
266
267 Ok(MedianMadSummary {
268 samples: scratch.len(),
269 median,
270 mad,
271 })
272 }
273
274 pub fn modified_z_score(&self, observation: f64) -> Result<ModifiedZScore, RillError> {
279 ensure_finite("observation", observation)?;
280 let summary = self.summary()?;
281 if summary.mad == 0.0 {
282 return Ok(ModifiedZScore::ZeroMad {
283 median: summary.median,
284 observation,
285 });
286 }
287
288 let direct_delta = observation - summary.median;
289 let standardized = if direct_delta.is_finite() {
290 direct_delta / summary.mad
291 } else {
292 observation / summary.mad - summary.median / summary.mad
293 };
294 let score = MODIFIED_Z_NORMAL_FACTOR * standardized;
295 ensure_finite("modified z-score", score)?;
296 Ok(ModifiedZScore::Defined {
297 score,
298 median: summary.median,
299 mad: summary.mad,
300 })
301 }
302}
303
304impl OnlineStatistic for RollingMedianMad {
305 fn update(&mut self, value: f64) -> Result<(), RillError> {
306 ensure_finite("value", value)?;
307 let next_samples_seen = checked_increment(self.samples_seen, "rolling median/MAD")?;
308 if self.window.len() == self.capacity {
309 self.window.pop_front();
310 }
311 self.window.push_back(value);
312 self.samples_seen = next_samples_seen;
313 Ok(())
314 }
315
316 fn samples_seen(&self) -> u64 {
317 self.samples_seen
318 }
319
320 fn reset(&mut self) {
321 self.window.clear();
322 self.samples_seen = 0;
323 }
324}
325
326impl ValidateState for RollingMedianMad {
327 fn validate_state(&self) -> Result<(), RillError> {
328 validate_rolling_median_mad_config(self.capacity, self.min_samples)?;
329 if self.window.len() > self.capacity {
330 return Err(RillError::InvalidState(
331 "rolling median/MAD window exceeds configured capacity".to_owned(),
332 ));
333 }
334 let retained = usize::try_from(self.samples_seen)
335 .unwrap_or(usize::MAX)
336 .min(self.capacity);
337 if self.window.len() != retained {
338 return Err(RillError::InvalidState(
339 "rolling median/MAD window length disagrees with samples_seen".to_owned(),
340 ));
341 }
342 for value in &self.window {
343 ensure_finite("rolling median/MAD window value", *value)?;
344 }
345 Ok(())
346 }
347}
348
349fn validate_rolling_median_mad_config(
350 capacity: usize,
351 min_samples: usize,
352) -> Result<(), RillError> {
353 if capacity == 0 {
354 return Err(RillError::InvalidCapacity(capacity));
355 }
356 if capacity > MAX_ROBUST_WINDOW_SIZE {
357 return Err(RillError::InvalidState(format!(
358 "rolling median/MAD capacity {capacity} exceeds maximum {MAX_ROBUST_WINDOW_SIZE}"
359 )));
360 }
361 if min_samples == 0 || min_samples > capacity {
362 return Err(RillError::InvalidState(format!(
363 "rolling median/MAD min_samples {min_samples} must be in 1..={capacity}"
364 )));
365 }
366 Ok(())
367}
368
369fn median_of_sorted(values: &[f64]) -> f64 {
370 let midpoint = values.len() / 2;
371 if values.len() % 2 == 1 {
372 values[midpoint]
373 } else {
374 finite_midpoint(values[midpoint - 1], values[midpoint])
375 }
376}
377
378fn finite_midpoint(lower: f64, upper: f64) -> f64 {
379 if lower.is_sign_negative() == upper.is_sign_negative() {
380 lower + (upper - lower) / 2.0
381 } else {
382 lower / 2.0 + upper / 2.0
383 }
384}
385
386fn absolute_distance(value: f64, center: f64) -> f64 {
387 let distance = (value - center).abs();
388 if distance.is_finite() {
389 distance
390 } else {
391 f64::INFINITY
392 }
393}
394
395#[derive(Debug, Clone, PartialEq)]
397#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
398pub struct ClippedMean {
399 lower: f64,
400 upper: f64,
401 count: u64,
402 mean: f64,
403}
404
405impl ClippedMean {
406 pub fn new(lower: f64, upper: f64) -> Result<Self, RillError> {
408 ensure_finite("clipped mean lower", lower)?;
409 ensure_finite("clipped mean upper", upper)?;
410 if lower > upper {
411 return Err(RillError::InvalidState(
412 "clipped mean lower bound exceeds upper bound".to_owned(),
413 ));
414 }
415 Ok(Self {
416 lower,
417 upper,
418 count: 0,
419 mean: 0.0,
420 })
421 }
422
423 pub const fn lower(&self) -> f64 {
425 self.lower
426 }
427
428 pub const fn upper(&self) -> f64 {
430 self.upper
431 }
432
433 pub const fn value(&self) -> f64 {
435 self.mean
436 }
437}
438
439impl OnlineStatistic for ClippedMean {
440 fn update(&mut self, value: f64) -> Result<(), RillError> {
441 ensure_finite("value", value)?;
442 let clipped = value.clamp(self.lower, self.upper);
443 let next_count = checked_increment(self.count, "clipped mean count")?;
444 let delta = clipped - self.mean;
445 ensure_finite("clipped mean delta", delta)?;
446 let next_mean = self.mean + delta / next_count as f64;
447 ensure_finite("clipped mean", next_mean)?;
448 self.count = next_count;
449 self.mean = next_mean;
450 Ok(())
451 }
452
453 fn samples_seen(&self) -> u64 {
454 self.count
455 }
456
457 fn reset(&mut self) {
458 self.count = 0;
459 self.mean = 0.0;
460 }
461}
462
463impl ValidateState for ClippedMean {
464 fn validate_state(&self) -> Result<(), RillError> {
465 ClippedMean::new(self.lower, self.upper)?;
466 ensure_finite("clipped mean", self.mean)?;
467 if self.count == 0 && self.mean != 0.0 {
468 return Err(RillError::InvalidState(
469 "empty clipped mean must be zero".to_owned(),
470 ));
471 }
472 if self.count > 0 && (self.mean < self.lower || self.mean > self.upper) {
473 return Err(RillError::InvalidState(
474 "clipped mean lies outside its clipping interval".to_owned(),
475 ));
476 }
477 Ok(())
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 use proptest::collection::vec;
486 use proptest::prelude::*;
487
488 fn offline_median_mad(values: &[f64]) -> (f64, f64) {
489 let mut ordered = values.to_vec();
490 ordered.sort_by(f64::total_cmp);
491 let median = if ordered.len() % 2 == 1 {
492 ordered[ordered.len() / 2]
493 } else {
494 let middle = ordered.len() / 2;
495 (ordered[middle - 1] + ordered[middle]) / 2.0
496 };
497 let mut deviations = values
498 .iter()
499 .map(|value| (value - median).abs())
500 .collect::<Vec<_>>();
501 deviations.sort_by(f64::total_cmp);
502 let mad = if deviations.len() % 2 == 1 {
503 deviations[deviations.len() / 2]
504 } else {
505 let middle = deviations.len() / 2;
506 (deviations[middle - 1] + deviations[middle]) / 2.0
507 };
508 (median, mad)
509 }
510
511 #[test]
512 fn clipped_mean_matches_offline_clipped_calculation() {
513 let values = [-100.0, -1.0, 1.0, 2.0, 100.0];
514 let mut statistic = ClippedMean::new(-2.0, 3.0).unwrap();
515 for value in values {
516 statistic.update(value).unwrap();
517 }
518 let expected = values
519 .iter()
520 .map(|value| value.clamp(-2.0, 3.0))
521 .sum::<f64>()
522 / values.len() as f64;
523 assert!((statistic.value() - expected).abs() < 1e-12);
524 statistic.validate_state().unwrap();
525 }
526
527 #[test]
528 fn failure_is_atomic_and_reset_clears_state() {
529 let mut statistic = ClippedMean::new(-10.0, 10.0).unwrap();
530 statistic.update(1.0).unwrap();
531 let before = statistic.clone();
532 assert!(statistic.update(f64::INFINITY).is_err());
533 assert_eq!(statistic, before);
534 statistic.reset();
535 assert_eq!(statistic.samples_seen(), 0);
536 assert_eq!(statistic.value(), 0.0);
537 }
538
539 #[test]
540 fn rolling_summary_and_modified_z_match_offline_reference() {
541 let mut statistic = RollingMedianMad::new(5, 3).unwrap();
542 for value in [1.0, 2.0, 100.0, 4.0, 5.0] {
543 statistic.update(value).unwrap();
544 }
545 let summary = statistic.summary().unwrap();
546 assert_eq!(summary.samples(), 5);
547 assert_eq!(summary.median(), 4.0);
548 assert_eq!(summary.mad(), 2.0);
549 assert!((summary.normal_scaled_mad().unwrap() - 2.965_204_437_011_204).abs() < 1e-14);
550
551 let score = statistic.modified_z_score(10.0).unwrap();
552 assert_eq!(
553 score,
554 ModifiedZScore::Defined {
555 score: MODIFIED_Z_NORMAL_FACTOR * 3.0,
556 median: 4.0,
557 mad: 2.0,
558 }
559 );
560 }
561
562 #[test]
563 fn rolling_window_evicts_and_tracks_lifetime_samples() {
564 let mut statistic = RollingMedianMad::new(3, 1).unwrap();
565 for value in [1.0, 2.0, 3.0, 100.0] {
566 statistic.update(value).unwrap();
567 }
568 let summary = statistic.summary().unwrap();
569 assert_eq!(statistic.len(), 3);
570 assert_eq!(statistic.samples_seen(), 4);
571 assert_eq!(summary.median(), 3.0);
572 assert_eq!(summary.mad(), 1.0);
573 statistic.validate_state().unwrap();
574 }
575
576 #[test]
577 fn zero_mad_is_explicit_and_never_non_finite() {
578 let mut statistic = RollingMedianMad::new(5, 3).unwrap();
579 for value in [7.0, 7.0, 7.0] {
580 statistic.update(value).unwrap();
581 }
582 assert_eq!(statistic.summary().unwrap().mad(), 0.0);
583 assert_eq!(
584 statistic.modified_z_score(9.0).unwrap(),
585 ModifiedZScore::ZeroMad {
586 median: 7.0,
587 observation: 9.0,
588 }
589 );
590 assert_eq!(statistic.modified_z_score(9.0).unwrap().score(), None);
591 }
592
593 #[test]
594 fn warmup_configuration_and_non_finite_updates_are_strict() {
595 assert!(matches!(
596 RollingMedianMad::new(0, 1),
597 Err(RillError::InvalidCapacity(0))
598 ));
599 assert!(RollingMedianMad::new(10, 0).is_err());
600 assert!(RollingMedianMad::new(10, 11).is_err());
601 assert!(RollingMedianMad::new(MAX_ROBUST_WINDOW_SIZE + 1, 1).is_err());
602
603 let mut statistic = RollingMedianMad::new(4, 3).unwrap();
604 statistic.update(1.0).unwrap();
605 statistic.update(2.0).unwrap();
606 assert!(matches!(
607 statistic.summary(),
608 Err(RillError::InsufficientData)
609 ));
610 let before = statistic.clone();
611 assert!(statistic.update(f64::NAN).is_err());
612 assert_eq!(statistic, before);
613 assert!(statistic.modified_z_score(f64::INFINITY).is_err());
614 assert_eq!(statistic, before);
615 }
616
617 #[test]
618 fn minority_extreme_contamination_does_not_control_the_summary() {
619 let mut statistic = RollingMedianMad::new(101, 101).unwrap();
620 for index in 0..52 {
621 statistic.update([-1.0, 0.0, 1.0][index % 3]).unwrap();
622 }
623 for _ in 0..49 {
624 statistic.update(f64::MAX).unwrap();
625 }
626 let summary = statistic.summary().unwrap();
627 assert_eq!(summary.median(), 1.0);
628 assert_eq!(summary.mad(), 2.0);
629 }
630
631 #[test]
632 fn representable_extreme_summaries_remain_finite() {
633 let mut symmetric = RollingMedianMad::new(2, 2).unwrap();
634 symmetric.update(-f64::MAX).unwrap();
635 symmetric.update(f64::MAX).unwrap();
636 let summary = symmetric.summary().unwrap();
637 assert_eq!(summary.median(), 0.0);
638 assert_eq!(summary.mad(), f64::MAX);
639 assert!(summary.normal_scaled_mad().is_err());
640
641 let mut majority = RollingMedianMad::new(3, 3).unwrap();
642 majority.update(-f64::MAX).unwrap();
643 majority.update(f64::MAX).unwrap();
644 majority.update(f64::MAX).unwrap();
645 let summary = majority.summary().unwrap();
646 assert_eq!(summary.median(), f64::MAX);
647 assert_eq!(summary.mad(), 0.0);
648 }
649
650 proptest! {
651 #[test]
652 fn rolling_summary_matches_independent_offline_calculation(
653 values in vec(-1_000_000.0f64..1_000_000.0, 1..160),
654 capacity in 1usize..64,
655 ) {
656 let mut statistic = RollingMedianMad::new(capacity, 1).unwrap();
657 let mut reference = VecDeque::new();
658 for value in values {
659 statistic.update(value).unwrap();
660 if reference.len() == capacity {
661 reference.pop_front();
662 }
663 reference.push_back(value);
664 let reference_values = reference.iter().copied().collect::<Vec<_>>();
665 let (median, mad) = offline_median_mad(&reference_values);
666 let summary = statistic.summary().unwrap();
667 let median_tolerance = 1e-12 * median.abs().max(1.0);
668 let mad_tolerance = 1e-12 * mad.abs().max(1.0);
669 prop_assert!((summary.median() - median).abs() <= median_tolerance);
670 prop_assert!((summary.mad() - mad).abs() <= mad_tolerance);
671 }
672 }
673 }
674
675 #[cfg(feature = "serde")]
676 #[test]
677 fn serde_roundtrip_preserves_future_updates() {
678 let mut original = ClippedMean::new(-20.0, 20.0).unwrap();
679 for value in 0..100 {
680 original.update(value as f64).unwrap();
681 }
682 let json = serde_json::to_string(&original).unwrap();
683 let mut restored: ClippedMean = serde_json::from_str(&json).unwrap();
684 restored.validate_state().unwrap();
685 for value in 100..200 {
686 original.update(value as f64).unwrap();
687 restored.update(value as f64).unwrap();
688 assert_eq!(original, restored);
689 }
690 }
691
692 #[cfg(feature = "serde")]
693 #[test]
694 fn rolling_serde_roundtrip_preserves_eviction_and_rejects_corrupt_state() {
695 let mut original = RollingMedianMad::new(17, 5).unwrap();
696 for value in 0..40 {
697 original.update(value as f64).unwrap();
698 }
699 let json = serde_json::to_string(&original).unwrap();
700 let mut restored: RollingMedianMad = serde_json::from_str(&json).unwrap();
701 restored.validate_state().unwrap();
702 for value in 40..80 {
703 original.update(value as f64).unwrap();
704 restored.update(value as f64).unwrap();
705 assert_eq!(original, restored);
706 assert_eq!(original.summary().unwrap(), restored.summary().unwrap());
707 }
708
709 let mut corrupt = serde_json::to_value(&original).unwrap();
710 corrupt["capacity"] = serde_json::json!(0);
711 assert!(serde_json::from_value::<RollingMedianMad>(corrupt).is_err());
712
713 let mut unknown = serde_json::to_value(&original).unwrap();
714 unknown["future_field"] = serde_json::json!(true);
715 assert!(serde_json::from_value::<RollingMedianMad>(unknown).is_err());
716
717 let oversized = serde_json::json!({
718 "window": vec![0.0; MAX_ROBUST_WINDOW_SIZE + 1],
719 "capacity": MAX_ROBUST_WINDOW_SIZE,
720 "min_samples": 1,
721 "samples_seen": MAX_ROBUST_WINDOW_SIZE + 1,
722 });
723 assert!(serde_json::from_value::<RollingMedianMad>(oversized).is_err());
724 }
725}