Skip to main content

rill_ml/stats/
robust.rs

1//! Bounded robust streaming summaries.
2//!
3//! [`ClippedMean`] is exact for a caller-selected fixed clipping interval. The
4//! bounds must come from domain knowledge or an independently validated
5//! calibration path; estimating them from this statistic would hide tail
6//! behaviour and invalidate the stated semantics.
7//!
8//! [`RollingMedianMad`] is exact inside a bounded recent-observation window.
9//! It is therefore a rolling approximation of an unbounded stream, not a
10//! constant-memory estimate of the lifetime distribution. Updates are `O(1)`;
11//! queries are `O(W log W)` and use one `O(W)` scratch vector.
12
13use std::collections::VecDeque;
14
15use crate::error::{RillError, checked_increment, ensure_finite};
16use crate::persistence::ValidateState;
17use crate::traits::OnlineStatistic;
18
19/// Maximum accepted window size for [`RollingMedianMad`].
20///
21/// This hard limit keeps both persisted state and query-time scratch memory
22/// bounded even when configuration or restored state is untrusted.
23pub const MAX_ROBUST_WINDOW_SIZE: usize = 65_536;
24
25/// Normal-consistency multiplier commonly applied to MAD (`1 / 0.67448975...`).
26pub const MAD_NORMAL_CONSISTENCY_SCALE: f64 = 1.482_602_218_505_602;
27
28/// Normal-consistency factor used by the modified z-score.
29pub const MODIFIED_Z_NORMAL_FACTOR: f64 = 0.674_489_750_196_081_7;
30
31/// Exact median and median absolute deviation for the current rolling window.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct MedianMadSummary {
34    samples: usize,
35    median: f64,
36    mad: f64,
37}
38
39impl MedianMadSummary {
40    /// Number of observations represented by this summary.
41    pub const fn samples(&self) -> usize {
42        self.samples
43    }
44
45    /// Exact median of the current window.
46    pub const fn median(&self) -> f64 {
47        self.median
48    }
49
50    /// Exact median absolute deviation from [`Self::median`].
51    pub const fn mad(&self) -> f64 {
52        self.mad
53    }
54
55    /// MAD scaled for consistency with standard deviation under normal data.
56    ///
57    /// Returns an error when the finite raw MAD cannot be scaled into a finite
58    /// `f64`. A zero MAD remains zero.
59    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/// Result of comparing one observation with a rolling median/MAD summary.
67///
68/// The modified z-score is `0.67448975... * (x - median) / MAD`. A zero MAD
69/// makes that expression undefined and is represented explicitly instead of
70/// returning a non-finite number or silently inventing a fallback scale.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum ModifiedZScore {
73    /// The score is finite and defined.
74    Defined {
75        /// Modified z-score for the supplied observation.
76        score: f64,
77        /// Median used to compute the score.
78        median: f64,
79        /// Raw MAD used to compute the score.
80        mad: f64,
81    },
82    /// The current window has zero MAD, so no score is defined.
83    ZeroMad {
84        /// Median of the zero-scale window.
85        median: f64,
86        /// Observation the caller attempted to score.
87        observation: f64,
88    },
89}
90
91impl ModifiedZScore {
92    /// Return the finite score, or `None` when the current MAD is zero.
93    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/// Exact median/MAD over a bounded FIFO window.
102///
103/// This Preview statistic retains at most [`MAX_ROBUST_WINDOW_SIZE`] finite
104/// observations. `min_samples` is an explicit warm-up threshold: queries fail
105/// with [`RillError::InsufficientData`] until the current window reaches it.
106///
107/// The window protects locality and memory, but it does not make arbitrary
108/// contamination harmless. Median/MAD can be controlled when at least half of
109/// the active window is replaced by adversarial values. Callers must choose a
110/// window and alert policy appropriate to their stream.
111#[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    /// Create a bounded rolling median/MAD statistic.
202    ///
203    /// `capacity` must be in `1..=MAX_ROBUST_WINDOW_SIZE`, and `min_samples`
204    /// must be in `1..=capacity`.
205    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    /// Configured FIFO window capacity.
216    pub const fn capacity(&self) -> usize {
217        self.capacity
218    }
219
220    /// Minimum current-window sample count required by queries.
221    pub const fn min_samples(&self) -> usize {
222        self.min_samples
223    }
224
225    /// Number of observations currently retained in the window.
226    pub fn len(&self) -> usize {
227        self.window.len()
228    }
229
230    /// Whether the current window is empty.
231    pub fn is_empty(&self) -> bool {
232        self.window.is_empty()
233    }
234
235    /// Whether the current window has reached its query warm-up threshold.
236    pub fn is_ready(&self) -> bool {
237        self.window.len() >= self.min_samples
238    }
239
240    /// Compute the exact median and raw MAD of the current window.
241    ///
242    /// The method uses one scratch vector bounded by `capacity`. Extremely
243    /// separated finite values are still accepted; an error is returned only
244    /// if the selected median absolute deviation itself exceeds finite `f64`
245    /// range.
246    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    /// Compute a modified robust z-score for `observation`.
275    ///
276    /// No outlier threshold is embedded in this statistic. The caller owns
277    /// alert policy, including treatment of [`ModifiedZScore::ZeroMad`].
278    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/// Exact online mean after clipping each observation to fixed bounds.
396#[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    /// Create a clipped mean with finite bounds satisfying `lower <= upper`.
407    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    /// Fixed lower clipping bound.
424    pub const fn lower(&self) -> f64 {
425        self.lower
426    }
427
428    /// Fixed upper clipping bound.
429    pub const fn upper(&self) -> f64 {
430        self.upper
431    }
432
433    /// Current exact mean of clipped observations, or `0.0` when empty.
434    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}