1use crate::drift::detector::{DriftDetector, DriftLevel};
29use crate::error::{RillError, checked_increment, ensure_finite};
30use crate::persistence::ValidateState;
31
32pub const KSWIN_PORTABLE_STATE_VERSION: u32 = 1;
34
35#[derive(Debug, Clone)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[non_exhaustive]
39pub struct KswinConfig {
40 pub alpha: f64,
43
44 pub window_size: usize,
48
49 pub check_interval: usize,
54}
55
56#[derive(Debug, Clone, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
63pub struct KswinPortableStateV1 {
64 pub version: u32,
66 pub alpha: f64,
68 pub window_size: usize,
70 pub check_interval: usize,
72 pub reference_window: Vec<f64>,
74 pub current_window: Vec<f64>,
76 pub samples: u64,
78 pub last_check_sample: u64,
80 pub last_pvalue: f64,
82 pub last_statistic: f64,
84 pub current_level: DriftLevel,
86}
87
88impl ValidateState for KswinPortableStateV1 {
89 fn validate_state(&self) -> Result<(), RillError> {
90 if self.version != KSWIN_PORTABLE_STATE_VERSION {
91 return Err(RillError::IncompatibleStateVersion {
92 expected: KSWIN_PORTABLE_STATE_VERSION,
93 actual: self.version,
94 });
95 }
96 Kswin::new(KswinConfig {
97 alpha: self.alpha,
98 window_size: self.window_size,
99 check_interval: self.check_interval,
100 })?;
101 if self.reference_window.len() > self.window_size
102 || self.current_window.len() > self.window_size
103 {
104 return Err(RillError::InvalidState(
105 "KSWIN portable window exceeds window_size".to_owned(),
106 ));
107 }
108 let retained = self
109 .reference_window
110 .len()
111 .checked_add(self.current_window.len())
112 .ok_or_else(|| RillError::InvalidState("KSWIN retained length overflow".to_owned()))?;
113 if self.samples < retained as u64 || self.last_check_sample > self.samples {
114 return Err(RillError::InvalidState(
115 "KSWIN portable counters are inconsistent".to_owned(),
116 ));
117 }
118 for &value in self.reference_window.iter().chain(&self.current_window) {
119 ensure_finite("portable KSWIN window value", value)?;
120 }
121 ensure_finite("portable KSWIN p-value", self.last_pvalue)?;
122 ensure_finite("portable KSWIN statistic", self.last_statistic)?;
123 if !(0.0..=1.0).contains(&self.last_pvalue) || !(0.0..=1.0).contains(&self.last_statistic) {
124 return Err(RillError::InvalidState(
125 "KSWIN statistic and p-value must be in [0, 1]".to_owned(),
126 ));
127 }
128 if self.current_level == DriftLevel::Warning {
129 return Err(RillError::InvalidState(
130 "KSWIN does not emit warning levels".to_owned(),
131 ));
132 }
133 if self.current_level == DriftLevel::Drift
134 && (!self.current_window.is_empty() || self.reference_window.len() != self.window_size)
135 {
136 return Err(RillError::InvalidState(
137 "KSWIN drift state must have a full reference and empty current window".to_owned(),
138 ));
139 }
140 Ok(())
141 }
142}
143
144impl Default for KswinConfig {
145 fn default() -> Self {
146 Self {
147 alpha: 0.005,
148 window_size: 100,
149 check_interval: 100,
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190pub struct Kswin {
191 config: KswinConfig,
192 reference_window: Vec<f64>,
193 current_window: Vec<f64>,
194 samples: u64,
195 last_check_sample: u64,
196 last_pvalue: f64,
197 last_statistic: f64,
198 current_level: DriftLevel,
199}
200
201impl Kswin {
202 pub fn new(config: KswinConfig) -> Result<Self, RillError> {
209 ensure_finite("alpha", config.alpha)?;
210 if config.alpha <= 0.0 || config.alpha >= 1.0 {
211 return Err(RillError::InvalidSignificanceLevel(config.alpha));
212 }
213 if config.window_size == 0 {
214 return Err(RillError::InvalidCapacity(config.window_size));
215 }
216 if config.check_interval == 0 {
217 return Err(RillError::InvalidCapacity(config.check_interval));
218 }
219 Ok(Self {
220 config,
221 reference_window: Vec::new(),
222 current_window: Vec::new(),
223 samples: 0,
224 last_check_sample: 0,
225 last_pvalue: 1.0,
226 last_statistic: 0.0,
227 current_level: DriftLevel::None,
228 })
229 }
230
231 pub const fn last_statistic(&self) -> f64 {
233 self.last_statistic
234 }
235
236 pub const fn last_pvalue(&self) -> f64 {
238 self.last_pvalue
239 }
240
241 pub fn reference_window_len(&self) -> usize {
243 self.reference_window.len()
244 }
245
246 pub fn current_window_len(&self) -> usize {
248 self.current_window.len()
249 }
250
251 pub const fn config(&self) -> &KswinConfig {
253 &self.config
254 }
255
256 pub fn export_state_v1(&self) -> KswinPortableStateV1 {
258 KswinPortableStateV1 {
259 version: KSWIN_PORTABLE_STATE_VERSION,
260 alpha: self.config.alpha,
261 window_size: self.config.window_size,
262 check_interval: self.config.check_interval,
263 reference_window: self.reference_window.clone(),
264 current_window: self.current_window.clone(),
265 samples: self.samples,
266 last_check_sample: self.last_check_sample,
267 last_pvalue: self.last_pvalue,
268 last_statistic: self.last_statistic,
269 current_level: self.current_level,
270 }
271 }
272
273 pub fn restore_state_v1(
275 config: KswinConfig,
276 state: KswinPortableStateV1,
277 ) -> Result<Self, RillError> {
278 state.validate_state()?;
279 if config.alpha != state.alpha
280 || config.window_size != state.window_size
281 || config.check_interval != state.check_interval
282 {
283 return Err(RillError::InvalidState(
284 "KSWIN portable state configuration mismatch".to_owned(),
285 ));
286 }
287 Kswin::new(config.clone())?;
288 Ok(Self {
289 config,
290 reference_window: state.reference_window,
291 current_window: state.current_window,
292 samples: state.samples,
293 last_check_sample: state.last_check_sample,
294 last_pvalue: state.last_pvalue,
295 last_statistic: state.last_statistic,
296 current_level: state.current_level,
297 })
298 }
299}
300
301impl Default for Kswin {
302 fn default() -> Self {
303 Self::new(KswinConfig::default()).expect("default config is valid")
304 }
305}
306
307impl DriftDetector for Kswin {
308 fn update(&mut self, value: f64) -> Result<DriftLevel, RillError> {
309 ensure_finite("value", value)?;
310 self.samples = checked_increment(self.samples, "samples")?;
311 self.current_level = DriftLevel::None;
312
313 if self.current_window.len() >= self.config.window_size {
316 std::mem::swap(&mut self.reference_window, &mut self.current_window);
317 self.current_window.clear();
318 }
319
320 self.current_window.push(value);
321
322 let both_full = self.reference_window.len() >= self.config.window_size
325 && self.current_window.len() >= self.config.window_size;
326 let interval_ok =
327 self.samples - self.last_check_sample >= self.config.check_interval as u64;
328
329 if both_full && interval_ok {
330 let d = ks_statistic(&self.reference_window, &self.current_window);
331 let p = ks_pvalue(d, self.reference_window.len(), self.current_window.len());
332 self.last_statistic = d;
333 self.last_pvalue = p;
334 self.last_check_sample = self.samples;
335
336 if p < self.config.alpha {
337 self.current_level = DriftLevel::Drift;
338 std::mem::swap(&mut self.reference_window, &mut self.current_window);
341 self.current_window.clear();
342 }
343 }
344
345 Ok(self.current_level)
346 }
347
348 fn detected(&self) -> bool {
349 self.current_level == DriftLevel::Drift
350 }
351
352 fn warning(&self) -> bool {
353 self.current_level == DriftLevel::Warning
354 }
355
356 fn level(&self) -> DriftLevel {
357 self.current_level
358 }
359
360 fn samples_seen(&self) -> u64 {
361 self.samples
362 }
363
364 fn reset(&mut self) {
365 self.reference_window.clear();
366 self.current_window.clear();
367 self.samples = 0;
368 self.last_check_sample = 0;
369 self.last_pvalue = 1.0;
370 self.last_statistic = 0.0;
371 self.current_level = DriftLevel::None;
372 }
373
374 fn last_value(&self) -> f64 {
375 self.last_pvalue
376 }
377}
378
379pub(crate) fn ks_statistic(a: &[f64], b: &[f64]) -> f64 {
387 if a.is_empty() || b.is_empty() {
388 return 0.0;
389 }
390 let mut a_sorted = a.to_vec();
391 let mut b_sorted = b.to_vec();
392 a_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
393 b_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
394
395 let n1 = a_sorted.len() as f64;
396 let n2 = b_sorted.len() as f64;
397
398 let mut i = 0usize;
399 let mut j = 0usize;
400 let mut max_d = 0.0_f64;
401
402 while i < a_sorted.len() && j < b_sorted.len() {
403 if a_sorted[i] < b_sorted[j] {
404 i += 1;
405 } else if a_sorted[i] > b_sorted[j] {
406 j += 1;
407 } else {
408 i += 1;
411 j += 1;
412 }
413 let cdf_a = i as f64 / n1;
414 let cdf_b = j as f64 / n2;
415 let d = (cdf_a - cdf_b).abs();
416 if d > max_d {
417 max_d = d;
418 }
419 }
420
421 max_d
422}
423
424pub(crate) fn ks_survival(lambda: f64) -> f64 {
432 if lambda <= 0.0 {
433 return 1.0;
434 }
435 let a2 = -2.0 * lambda * lambda;
436 let mut sum = 0.0_f64;
437 let mut fac = 2.0_f64; for k in 1..=100u32 {
439 let term = fac * (a2 * (k as f64) * (k as f64)).exp();
440 sum += term;
441 if term.abs() <= 1e-12 * sum.abs().max(1e-300) {
443 break;
444 }
445 fac = -fac;
446 }
447 sum.clamp(0.0, 1.0)
449}
450
451pub(crate) fn ks_pvalue(d: f64, n1: usize, n2: usize) -> f64 {
458 if d <= 0.0 || n1 == 0 || n2 == 0 {
459 return 1.0;
460 }
461 let n_eff = (n1 as f64 * n2 as f64) / (n1 + n2) as f64;
462 let lambda = (n_eff.sqrt() + 0.12 + 0.11 / n_eff.sqrt()) * d;
463 ks_survival(lambda)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn next_unit(seed: &mut u64) -> f64 {
472 *seed = seed
473 .wrapping_mul(6364136223846793005)
474 .wrapping_add(1442695040888963407);
475 ((*seed >> 11) as f64) / ((1u64 << 53) as f64)
476 }
477
478 fn next_normal(seed: &mut u64, mean: f64, std: f64) -> f64 {
480 let u1 = next_unit(seed).max(1e-10);
481 let u2 = next_unit(seed);
482 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
483 mean + std * z
484 }
485
486 #[test]
487 fn default_config_is_valid() {
488 let kswin = Kswin::default();
489 assert_eq!(kswin.samples_seen(), 0);
490 assert_eq!(kswin.level(), DriftLevel::None);
491 assert!(!kswin.detected());
492 assert!(!kswin.warning());
493 assert_eq!(kswin.reference_window_len(), 0);
494 assert_eq!(kswin.current_window_len(), 0);
495 }
496
497 #[test]
498 fn detects_mean_shift() {
499 let mut kswin = Kswin::new(KswinConfig {
500 alpha: 0.01,
501 window_size: 50,
502 check_interval: 50,
503 })
504 .unwrap();
505
506 let mut seed = 42u64;
507 for _ in 0..100 {
509 let v = next_normal(&mut seed, 0.0, 0.5);
510 kswin.update(v).unwrap();
511 }
512 assert_eq!(kswin.level(), DriftLevel::None);
513
514 let mut detected = false;
516 for _ in 0..150 {
517 let v = next_normal(&mut seed, 5.0, 0.5);
518 let level = kswin.update(v).unwrap();
519 if level == DriftLevel::Drift {
520 detected = true;
521 break;
522 }
523 }
524 assert!(detected, "KSWIN should detect the mean shift");
525 }
526
527 #[test]
528 fn detects_variance_change() {
529 let mut kswin = Kswin::new(KswinConfig {
530 alpha: 0.01,
531 window_size: 50,
532 check_interval: 50,
533 })
534 .unwrap();
535
536 let mut seed = 7u64;
537 for _ in 0..100 {
539 let v = next_normal(&mut seed, 0.0, 0.1);
540 kswin.update(v).unwrap();
541 }
542 assert_eq!(kswin.level(), DriftLevel::None);
543
544 let mut detected = false;
546 for _ in 0..150 {
547 let v = next_normal(&mut seed, 0.0, 3.0);
548 let level = kswin.update(v).unwrap();
549 if level == DriftLevel::Drift {
550 detected = true;
551 break;
552 }
553 }
554 assert!(
555 detected,
556 "KSWIN should detect the variance change (a distribution shape change)"
557 );
558 }
559
560 #[test]
561 fn detects_distribution_shape_change() {
562 let mut kswin = Kswin::new(KswinConfig {
563 alpha: 0.01,
564 window_size: 50,
565 check_interval: 50,
566 })
567 .unwrap();
568
569 let mut seed = 99u64;
570 for _ in 0..100 {
572 let v = next_unit(&mut seed) - 0.5;
573 kswin.update(v).unwrap();
574 }
575 assert_eq!(kswin.level(), DriftLevel::None);
576
577 let mut detected = false;
579 for _ in 0..200 {
580 let v = next_normal(&mut seed, 0.0, 1.0);
581 let level = kswin.update(v).unwrap();
582 if level == DriftLevel::Drift {
583 detected = true;
584 break;
585 }
586 }
587 assert!(
588 detected,
589 "KSWIN should detect the distribution shape change (uniform -> normal)"
590 );
591 }
592
593 #[test]
594 fn no_false_positive_on_stable_stream() {
595 let mut kswin = Kswin::new(KswinConfig {
596 alpha: 0.005,
597 window_size: 100,
598 check_interval: 100,
599 })
600 .unwrap();
601
602 let mut seed = 13u64;
603 for _ in 0..1000 {
604 let v = next_normal(&mut seed, 0.0, 1.0);
605 kswin.update(v).unwrap();
606 }
607 assert!(
608 !kswin.detected(),
609 "false positive: drift reported on stable stream (p-value={})",
610 kswin.last_pvalue()
611 );
612 }
613
614 #[test]
615 fn portable_state_restore_preserves_future_results() {
616 let config = KswinConfig {
617 alpha: 0.01,
618 window_size: 20,
619 check_interval: 20,
620 };
621 let mut original = Kswin::new(config.clone()).unwrap();
622 for i in 0..55 {
623 original.update((i % 9) as f64 / 10.0).unwrap();
624 }
625 let state = original.export_state_v1();
626 state.validate_state().unwrap();
627 let mut restored = Kswin::restore_state_v1(config, state).unwrap();
628 for i in 0..100 {
629 let value = if i < 25 { 0.5 } else { 5.0 + (i % 3) as f64 };
630 assert_eq!(
631 original.update(value).unwrap(),
632 restored.update(value).unwrap()
633 );
634 assert_eq!(original.export_state_v1(), restored.export_state_v1());
635 }
636 }
637
638 #[test]
639 fn portable_state_rejects_mismatch_and_corruption() {
640 let detector = Kswin::default();
641 let mut wrong_config = KswinConfig::default();
642 wrong_config.check_interval += 1;
643 assert!(Kswin::restore_state_v1(wrong_config, detector.export_state_v1()).is_err());
644
645 let mut corrupt = detector.export_state_v1();
646 corrupt.last_pvalue = 2.0;
647 assert!(corrupt.validate_state().is_err());
648 let mut corrupt = detector.export_state_v1();
649 corrupt.last_check_sample = 1;
650 assert!(corrupt.validate_state().is_err());
651 }
652
653 #[test]
654 fn rejects_non_finite_input() {
655 let mut kswin = Kswin::default();
656 assert!(kswin.update(f64::NAN).is_err());
657 assert!(kswin.update(f64::INFINITY).is_err());
658 assert!(kswin.update(f64::NEG_INFINITY).is_err());
659 assert_eq!(kswin.samples_seen(), 0);
660 }
661
662 #[test]
663 fn rejects_invalid_config() {
664 assert!(
666 Kswin::new(KswinConfig {
667 alpha: 0.0,
668 ..Default::default()
669 })
670 .is_err()
671 );
672 assert!(
674 Kswin::new(KswinConfig {
675 alpha: 1.0,
676 ..Default::default()
677 })
678 .is_err()
679 );
680 assert!(
682 Kswin::new(KswinConfig {
683 alpha: f64::NAN,
684 ..Default::default()
685 })
686 .is_err()
687 );
688 assert!(
690 Kswin::new(KswinConfig {
691 window_size: 0,
692 ..Default::default()
693 })
694 .is_err()
695 );
696 assert!(
698 Kswin::new(KswinConfig {
699 check_interval: 0,
700 ..Default::default()
701 })
702 .is_err()
703 );
704 }
705
706 #[test]
707 fn reset_clears_state() {
708 let mut kswin = Kswin::new(KswinConfig {
709 window_size: 20,
710 check_interval: 20,
711 ..Default::default()
712 })
713 .unwrap();
714 for i in 0..50 {
715 kswin.update(i as f64).unwrap();
716 }
717 assert!(kswin.samples_seen() > 0);
718 assert!(kswin.reference_window_len() > 0 || kswin.current_window_len() > 0);
719 kswin.reset();
720 assert_eq!(kswin.samples_seen(), 0);
721 assert_eq!(kswin.reference_window_len(), 0);
722 assert_eq!(kswin.current_window_len(), 0);
723 assert_eq!(kswin.level(), DriftLevel::None);
724 assert_eq!(kswin.last_pvalue(), 1.0);
725 assert_eq!(kswin.last_statistic(), 0.0);
726 }
727
728 #[test]
729 fn min_samples_gates_detection() {
730 let mut kswin = Kswin::new(KswinConfig {
731 alpha: 0.001,
732 window_size: 50,
733 check_interval: 50,
734 })
735 .unwrap();
736 for _ in 0..49 {
739 kswin.update(0.0).unwrap();
740 }
741 assert_eq!(kswin.level(), DriftLevel::None);
742 for _ in 0..49 {
745 kswin.update(1000.0).unwrap();
746 }
747 assert_eq!(kswin.level(), DriftLevel::None);
749 }
750
751 #[test]
752 fn ks_statistic_symmetric() {
753 let a = [1.0, 2.0, 3.0, 4.0, 5.0];
754 let b = [1.5, 2.5, 3.5, 4.5, 5.5];
755 let d_ab = ks_statistic(&a, &b);
756 let d_ba = ks_statistic(&b, &a);
757 assert!(
758 (d_ab - d_ba).abs() < 1e-12,
759 "ks_statistic should be symmetric: {} vs {}",
760 d_ab,
761 d_ba
762 );
763 }
764
765 #[test]
766 fn ks_statistic_identical_distributions() {
767 let a = [1.0, 2.0, 3.0, 4.0, 5.0];
768 let b = [1.0, 2.0, 3.0, 4.0, 5.0];
769 let d = ks_statistic(&a, &b);
770 assert!(
771 d.abs() < 1e-12,
772 "ks_statistic of identical samples should be 0, got {}",
773 d
774 );
775 }
776
777 #[test]
778 fn ks_statistic_disjoint_distributions() {
779 let a = [1.0, 2.0, 3.0];
780 let b = [10.0, 11.0, 12.0];
781 let d = ks_statistic(&a, &b);
782 assert!(
783 (d - 1.0).abs() < 1e-12,
784 "ks_statistic of disjoint samples should be 1, got {}",
785 d
786 );
787 }
788
789 #[test]
790 fn ks_pvalue_decreases_with_larger_d() {
791 let n1 = 50usize;
792 let n2 = 50usize;
793 let p_small = ks_pvalue(0.1, n1, n2);
794 let p_medium = ks_pvalue(0.3, n1, n2);
795 let p_large = ks_pvalue(0.6, n1, n2);
796 assert!(
797 p_small > p_medium,
798 "p-value should decrease as D increases: {} vs {}",
799 p_small,
800 p_medium
801 );
802 assert!(
803 p_medium > p_large,
804 "p-value should decrease as D increases: {} vs {}",
805 p_medium,
806 p_large
807 );
808 }
809
810 #[test]
811 fn ks_survival_known_values() {
812 assert!((ks_survival(0.0) - 1.0).abs() < 1e-12);
814 assert!(ks_survival(10.0) < 1e-10);
816 let p1 = ks_survival(0.5);
818 let p2 = ks_survival(1.0);
819 let p3 = ks_survival(2.0);
820 assert!(p1 > p2);
821 assert!(p2 > p3);
822 assert!(
824 (ks_survival(1.0) - 0.27).abs() < 0.02,
825 "Q_KS(1) should be approximately 0.27, got {}",
826 ks_survival(1.0)
827 );
828 }
829
830 #[test]
831 fn ks_pvalue_is_in_unit_interval() {
832 let mut seed = 42u64;
833 for _ in 0..100 {
834 let d = next_unit(&mut seed); let p = ks_pvalue(d, 30, 40);
836 assert!(
837 (0.0..=1.0).contains(&p),
838 "p-value out of range: {} for d={}",
839 p,
840 d
841 );
842 }
843 }
844
845 #[test]
846 fn window_rotation_after_drift() {
847 let mut kswin = Kswin::new(KswinConfig {
848 alpha: 0.05,
849 window_size: 30,
850 check_interval: 30,
851 })
852 .unwrap();
853 for _ in 0..30 {
855 kswin.update(0.0).unwrap();
856 }
857 let mut detected = false;
859 for _ in 0..60 {
860 let level = kswin.update(100.0).unwrap();
861 if level == DriftLevel::Drift {
862 detected = true;
863 break;
864 }
865 }
866 assert!(detected, "should detect the distribution change");
867 assert!(
870 kswin.reference_window_len() > 0,
871 "reference window should have data after rotation"
872 );
873 let ref_mean: f64 =
875 kswin.reference_window.iter().sum::<f64>() / kswin.reference_window.len() as f64;
876 assert!(
877 (ref_mean - 100.0).abs() < 1e-9,
878 "reference should contain the new distribution, mean={}",
879 ref_mean
880 );
881 }
882
883 #[test]
884 fn last_value_returns_last_pvalue() {
885 let mut kswin = Kswin::new(KswinConfig {
886 alpha: 0.05,
887 window_size: 20,
888 check_interval: 20,
889 })
890 .unwrap();
891 assert!((kswin.last_value() - 1.0).abs() < 1e-12);
893 for _ in 0..20 {
895 kswin.update(0.0).unwrap();
896 }
897 for _ in 0..20 {
898 kswin.update(0.0).unwrap();
899 }
900 assert!(
902 kswin.last_value() > 0.5,
903 "p-value for identical distributions should be high, got {}",
904 kswin.last_value()
905 );
906 }
907
908 #[cfg(feature = "serde")]
909 #[test]
910 fn serde_roundtrip() {
911 let mut kswin = Kswin::new(KswinConfig {
912 alpha: 0.01,
913 window_size: 30,
914 check_interval: 30,
915 })
916 .unwrap();
917 for i in 0..60 {
918 kswin.update(i as f64 * 0.1).unwrap();
919 }
920 let json = serde_json::to_string(&kswin).unwrap();
921 let restored: Kswin = serde_json::from_str(&json).unwrap();
922 assert_eq!(restored.samples_seen(), 60);
923 assert_eq!(restored.config().window_size, 30);
924 assert_eq!(restored.config().alpha, 0.01);
925 assert!((restored.last_pvalue() - kswin.last_pvalue()).abs() < 1e-12);
926 assert!((restored.last_statistic() - kswin.last_statistic()).abs() < 1e-12);
927 }
928
929 #[cfg(feature = "serde")]
930 #[test]
931 fn config_serde_roundtrip() {
932 let config = KswinConfig {
933 alpha: 0.007,
934 window_size: 75,
935 check_interval: 50,
936 };
937 let json = serde_json::to_string(&config).unwrap();
938 let restored: KswinConfig = serde_json::from_str(&json).unwrap();
939 assert!((restored.alpha - 0.007).abs() < 1e-12);
940 assert_eq!(restored.window_size, 75);
941 assert_eq!(restored.check_interval, 50);
942 }
943}