1#![forbid(unsafe_code)]
2
3#[derive(Debug, Clone)]
54pub struct LeakDetectorConfig {
55 pub alpha: f64,
57 pub lambda: f64,
61 pub cusum_threshold: f64,
64 pub cusum_allowance: f64,
67 pub warmup_frames: usize,
69 pub sigma_decay: f64,
71 pub sigma_floor: f64,
73}
74
75impl Default for LeakDetectorConfig {
76 fn default() -> Self {
77 Self {
78 alpha: 0.05,
79 lambda: 0.2,
80 cusum_threshold: 8.0,
81 cusum_allowance: 0.5,
82 warmup_frames: 30,
83 sigma_decay: 0.95,
84 sigma_floor: 1.0,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
95pub struct EvidenceEntry {
96 pub frame: usize,
98 pub value: f64,
100 pub residual: f64,
102 pub cusum_upper: f64,
104 pub cusum_lower: f64,
106 pub e_value: f64,
108 pub mean_estimate: f64,
110 pub sigma_estimate: f64,
112}
113
114impl EvidenceEntry {
115 pub fn to_jsonl(&self) -> String {
117 format!(
118 r#"{{"frame":{},"value":{:.2},"residual":{:.4},"cusum_upper":{:.4},"cusum_lower":{:.4},"e_value":{:.6},"mean":{:.2},"sigma":{:.4}}}"#,
119 self.frame,
120 self.value,
121 self.residual,
122 self.cusum_upper,
123 self.cusum_lower,
124 self.e_value,
125 self.mean_estimate,
126 self.sigma_estimate,
127 )
128 }
129}
130
131#[derive(Debug, Clone)]
137pub struct LeakAlert {
138 pub triggered: bool,
140 pub cusum_triggered: bool,
142 pub eprocess_triggered: bool,
144 pub e_value: f64,
146 pub cusum_upper: f64,
148 pub cusum_lower: f64,
150 pub frame: usize,
152}
153
154impl LeakAlert {
155 fn no_alert(frame: usize, e_value: f64, cusum_upper: f64, cusum_lower: f64) -> Self {
156 Self {
157 triggered: false,
158 cusum_triggered: false,
159 eprocess_triggered: false,
160 e_value,
161 cusum_upper,
162 cusum_lower,
163 frame,
164 }
165 }
166}
167
168#[derive(Debug)]
181pub struct AllocLeakDetector {
182 config: LeakDetectorConfig,
183 mean: f64,
185 m2: f64,
187 sigma_ema: f64,
189 cusum_upper: f64,
191 cusum_lower: f64,
193 e_value: f64,
195 frames: usize,
197 ledger: Vec<EvidenceEntry>,
199}
200
201impl AllocLeakDetector {
202 #[must_use]
204 pub fn new(config: LeakDetectorConfig) -> Self {
205 Self {
206 config,
207 mean: 0.0,
208 m2: 0.0,
209 sigma_ema: 0.0,
210 cusum_upper: 0.0,
211 cusum_lower: 0.0,
212 e_value: 1.0,
213 frames: 0,
214 ledger: Vec::new(),
215 }
216 }
217
218 pub fn observe(&mut self, value: f64) -> LeakAlert {
222 self.frames += 1;
223 let n = self.frames;
224
225 let delta = value - self.mean;
227 self.mean += delta / n as f64;
228 let delta2 = value - self.mean;
229 self.m2 += delta * delta2;
230
231 let welford_sigma = if n > 1 {
232 (self.m2 / (n - 1) as f64).sqrt()
233 } else {
234 0.0
235 };
236
237 if n == 1 {
239 self.sigma_ema = welford_sigma.max(self.config.sigma_floor);
240 } else {
241 self.sigma_ema = self.config.sigma_decay * self.sigma_ema
242 + (1.0 - self.config.sigma_decay) * welford_sigma;
243 }
244 let sigma = self.sigma_ema.max(self.config.sigma_floor);
245
246 let residual = delta / sigma;
248
249 if n <= self.config.warmup_frames {
251 let entry = EvidenceEntry {
252 frame: n,
253 value,
254 residual,
255 cusum_upper: 0.0,
256 cusum_lower: 0.0,
257 e_value: 1.0,
258 mean_estimate: self.mean,
259 sigma_estimate: sigma,
260 };
261 self.ledger.push(entry);
262 return LeakAlert::no_alert(n, 1.0, 0.0, 0.0);
263 }
264
265 self.cusum_upper = (self.cusum_upper + residual - self.config.cusum_allowance).max(0.0);
269 self.cusum_lower = (self.cusum_lower - residual - self.config.cusum_allowance).max(0.0);
270
271 let cusum_triggered = self.cusum_upper > self.config.cusum_threshold
272 || self.cusum_lower > self.config.cusum_threshold;
273
274 let lambda = self.config.lambda;
278 let log_factor = lambda * residual - (lambda * lambda) / 2.0;
279 let factor = log_factor.clamp(-10.0, 10.0).exp();
281 self.e_value *= factor;
282
283 let threshold = 1.0 / self.config.alpha;
284 let eprocess_triggered = self.e_value >= threshold;
285
286 let triggered = cusum_triggered || eprocess_triggered;
287
288 let entry = EvidenceEntry {
289 frame: n,
290 value,
291 residual,
292 cusum_upper: self.cusum_upper,
293 cusum_lower: self.cusum_lower,
294 e_value: self.e_value,
295 mean_estimate: self.mean,
296 sigma_estimate: sigma,
297 };
298 self.ledger.push(entry);
299
300 LeakAlert {
301 triggered,
302 cusum_triggered,
303 eprocess_triggered,
304 e_value: self.e_value,
305 cusum_upper: self.cusum_upper,
306 cusum_lower: self.cusum_lower,
307 frame: n,
308 }
309 }
310
311 #[must_use]
313 pub fn e_value(&self) -> f64 {
314 self.e_value
315 }
316
317 #[must_use]
319 pub fn cusum_upper(&self) -> f64 {
320 self.cusum_upper
321 }
322
323 #[must_use]
325 pub fn cusum_lower(&self) -> f64 {
326 self.cusum_lower
327 }
328
329 #[must_use]
331 pub fn mean(&self) -> f64 {
332 self.mean
333 }
334
335 #[must_use]
337 pub fn sigma(&self) -> f64 {
338 self.sigma_ema.max(self.config.sigma_floor)
339 }
340
341 #[must_use]
343 pub fn frames(&self) -> usize {
344 self.frames
345 }
346
347 #[must_use]
349 pub fn ledger(&self) -> &[EvidenceEntry] {
350 &self.ledger
351 }
352
353 #[must_use]
355 pub fn threshold(&self) -> f64 {
356 1.0 / self.config.alpha
357 }
358
359 pub fn reset(&mut self) {
361 self.mean = 0.0;
362 self.m2 = 0.0;
363 self.sigma_ema = 0.0;
364 self.cusum_upper = 0.0;
365 self.cusum_lower = 0.0;
366 self.e_value = 1.0;
367 self.frames = 0;
368 self.ledger.clear();
369 }
370}
371
372#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn default_detector() -> AllocLeakDetector {
381 AllocLeakDetector::new(LeakDetectorConfig::default())
382 }
383
384 fn detector_with(alpha: f64, lambda: f64, warmup: usize) -> AllocLeakDetector {
385 AllocLeakDetector::new(LeakDetectorConfig {
386 alpha,
387 lambda,
388 warmup_frames: warmup,
389 ..LeakDetectorConfig::default()
390 })
391 }
392
393 struct Lcg(u64);
395 impl Lcg {
396 fn new(seed: u64) -> Self {
397 Self(seed)
398 }
399 fn next_u64(&mut self) -> u64 {
400 self.0 = self
401 .0
402 .wrapping_mul(6_364_136_223_846_793_005)
403 .wrapping_add(1);
404 self.0
405 }
406 fn next_normal(&mut self, mean: f64, std: f64) -> f64 {
408 let sum: f64 = (0..12)
409 .map(|_| (self.next_u64() as f64) / (u64::MAX as f64))
410 .sum();
411 mean + std * (sum - 6.0)
412 }
413 }
414
415 #[test]
418 fn new_detector_starts_clean() {
419 let d = default_detector();
420 assert_eq!(d.frames(), 0);
421 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
422 assert_eq!(d.cusum_upper(), 0.0);
423 assert_eq!(d.cusum_lower(), 0.0);
424 assert!(d.ledger().is_empty());
425 }
426
427 #[test]
428 fn warmup_does_not_trigger() {
429 let mut d = default_detector();
430 for i in 0..30 {
431 let alert = d.observe(100.0 + (i as f64) * 0.5);
432 assert!(
433 !alert.triggered,
434 "Should not trigger during warmup (frame {})",
435 i + 1
436 );
437 }
438 assert_eq!(d.frames(), 30);
439 }
440
441 #[test]
442 fn stable_run_no_alert() {
443 let mut rng = Lcg::new(0xCAFE);
444 let mut d = default_detector();
445
446 for _ in 0..500 {
447 let v = rng.next_normal(100.0, 5.0);
448 let alert = d.observe(v);
449 assert!(
450 !alert.triggered,
451 "Stable run should not trigger: frame={}, e={:.4}, cusum_up={:.4}",
452 alert.frame, alert.e_value, alert.cusum_upper,
453 );
454 }
455 }
456
457 #[test]
460 fn unit_cusum_detects_shift() {
461 let mut d = detector_with(0.05, 0.2, 20);
462
463 for _ in 0..20 {
465 d.observe(100.0);
466 }
467
468 let mut detected = false;
470 for i in 0..200 {
471 let alert = d.observe(110.0);
472 if alert.cusum_triggered {
473 detected = true;
474 assert!(
475 i < 50,
476 "CUSUM should detect shift within 50 frames, took {}",
477 i
478 );
479 break;
480 }
481 }
482 assert!(detected, "CUSUM failed to detect +10 mean shift");
483 }
484
485 #[test]
486 fn cusum_detects_downward_shift() {
487 let mut d = detector_with(0.05, 0.2, 20);
488
489 for _ in 0..20 {
490 d.observe(100.0);
491 }
492
493 let mut detected = false;
494 for i in 0..200 {
495 let alert = d.observe(90.0);
496 if alert.cusum_lower > d.config.cusum_threshold {
497 detected = true;
498 assert!(
499 i < 50,
500 "CUSUM should detect downward shift within 50 frames"
501 );
502 break;
503 }
504 }
505 assert!(detected, "CUSUM failed to detect -10 mean shift");
506 }
507
508 #[test]
511 fn unit_eprocess_threshold() {
512 let mut d = detector_with(0.05, 0.3, 10);
513
514 for _ in 0..10 {
516 d.observe(100.0);
517 }
518
519 let mut detected = false;
521 for i in 0..300 {
522 let alert = d.observe(120.0);
523 if alert.eprocess_triggered {
524 detected = true;
525 assert!(
526 alert.e_value >= d.threshold(),
527 "E-value {:.2} should exceed threshold {:.2}",
528 alert.e_value,
529 d.threshold()
530 );
531 assert!(
532 i < 150,
533 "E-process should detect within 150 frames, took {}",
534 i
535 );
536 break;
537 }
538 }
539 assert!(detected, "E-process failed to detect sustained leak");
540 }
541
542 #[test]
543 fn eprocess_value_bounded_under_null() {
544 let mut rng = Lcg::new(0xBEEF);
545 let mut d = detector_with(0.05, 0.2, 20);
546
547 for _ in 0..1000 {
549 let v = rng.next_normal(100.0, 5.0);
550 d.observe(v);
551 }
552
553 assert!(
555 d.e_value() < 100.0,
556 "E-value should stay bounded under null: got {:.2}",
557 d.e_value()
558 );
559 }
560
561 #[test]
564 fn property_fpr_control() {
565 let alpha = 0.10; let n_runs = 200;
568 let frames_per_run = 200;
569
570 let mut false_positives = 0;
571 let mut rng = Lcg::new(0xAAAA);
572
573 for _ in 0..n_runs {
574 let mut d = detector_with(alpha, 0.2, 20);
575 let mut triggered = false;
576
577 for _ in 0..frames_per_run {
578 let v = rng.next_normal(100.0, 5.0);
579 let alert = d.observe(v);
580 if alert.eprocess_triggered {
581 triggered = true;
582 break;
583 }
584 }
585 if triggered {
586 false_positives += 1;
587 }
588 }
589
590 let fpr = false_positives as f64 / n_runs as f64;
591 assert!(
593 fpr <= alpha + 0.10,
594 "Empirical FPR {:.3} exceeds α + tolerance ({:.3})",
595 fpr,
596 alpha + 0.10,
597 );
598 }
599
600 #[test]
603 fn ledger_records_all_frames() {
604 let mut d = default_detector();
605 for i in 0..50 {
606 d.observe(100.0 + i as f64);
607 }
608 assert_eq!(d.ledger().len(), 50);
609 assert_eq!(d.ledger()[0].frame, 1);
610 assert_eq!(d.ledger()[49].frame, 50);
611 }
612
613 #[test]
614 fn ledger_jsonl_valid() {
615 let mut d = default_detector();
616 for _ in 0..40 {
617 d.observe(100.0);
618 }
619
620 for entry in d.ledger() {
621 let line = entry.to_jsonl();
622 assert!(
623 line.starts_with('{') && line.ends_with('}'),
624 "Bad JSONL: {}",
625 line
626 );
627 assert!(line.contains("\"frame\":"));
628 assert!(line.contains("\"value\":"));
629 assert!(line.contains("\"residual\":"));
630 assert!(line.contains("\"cusum_upper\":"));
631 assert!(line.contains("\"e_value\":"));
632 }
633 }
634
635 #[test]
636 fn ledger_residuals_sum_near_zero_under_null() {
637 let mut rng = Lcg::new(0x1234);
638 let mut d = detector_with(0.05, 0.2, 20);
639
640 for _ in 0..500 {
641 d.observe(rng.next_normal(100.0, 5.0));
642 }
643
644 let post_warmup: Vec<f64> = d.ledger()[20..].iter().map(|e| e.residual).collect();
646 let mean_residual: f64 = post_warmup.iter().sum::<f64>() / post_warmup.len() as f64;
647 assert!(
648 mean_residual.abs() < 0.5,
649 "Mean residual should be near zero: got {:.4}",
650 mean_residual
651 );
652 }
653
654 #[test]
657 fn reset_clears_state() {
658 let mut d = default_detector();
659 for _ in 0..100 {
660 d.observe(100.0);
661 }
662 d.reset();
663 assert_eq!(d.frames(), 0);
664 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
665 assert_eq!(d.cusum_upper(), 0.0);
666 assert!(d.ledger().is_empty());
667 }
668
669 #[test]
672 fn e2e_synthetic_leak_detected() {
673 let mut rng = Lcg::new(0x5678);
674 let mut d = default_detector();
675
676 for _ in 0..50 {
678 d.observe(rng.next_normal(100.0, 3.0));
679 }
680 assert!(!d.ledger().last().unwrap().e_value.is_nan());
681
682 let mut detected_frame = None;
684 for i in 0..200 {
685 let leak = 0.5 * i as f64;
686 let v = rng.next_normal(100.0 + leak, 3.0);
687 let alert = d.observe(v);
688 if alert.triggered && detected_frame.is_none() {
689 detected_frame = Some(alert.frame);
690 }
691 }
692
693 assert!(
694 detected_frame.is_some(),
695 "Detector should catch gradual leak"
696 );
697
698 let last = d.ledger().last().unwrap();
700 let summary = format!(
701 r#"{{"test":"e2e_synthetic_leak","detected_frame":{},"total_frames":{},"final_e_value":{:.4},"final_cusum_upper":{:.4}}}"#,
702 detected_frame.unwrap(),
703 d.frames(),
704 last.e_value,
705 last.cusum_upper,
706 );
707 assert!(summary.contains("\"detected_frame\":"));
708 }
709
710 #[test]
711 fn e2e_stable_run_no_alerts() {
712 let mut rng = Lcg::new(0x9999);
713 let mut d = default_detector();
714
715 let mut any_alert = false;
716 for _ in 0..500 {
717 let v = rng.next_normal(200.0, 10.0);
718 let alert = d.observe(v);
719 if alert.triggered {
720 any_alert = true;
721 }
722 }
723
724 assert!(!any_alert, "Stable run should produce no alerts");
725
726 let max_e = d.ledger().iter().map(|e| e.e_value).fold(0.0f64, f64::max);
728 assert!(
729 max_e < d.threshold(),
730 "Max e-value {:.4} should stay below threshold {:.4}",
731 max_e,
732 d.threshold()
733 );
734 }
735
736 #[test]
739 fn constant_input_no_trigger() {
740 let mut d = default_detector();
741 for _ in 0..200 {
742 let alert = d.observe(42.0);
743 assert!(
744 !alert.triggered,
745 "Constant input should never trigger: frame={}",
746 alert.frame
747 );
748 }
749 }
750
751 #[test]
752 fn zero_input_no_panic() {
753 let mut d = default_detector();
754 for _ in 0..50 {
755 let alert = d.observe(0.0);
756 assert!(!alert.e_value.is_nan(), "E-value should not be NaN");
757 }
758 }
759
760 #[test]
761 fn single_observation() {
762 let mut d = default_detector();
763 let alert = d.observe(100.0);
764 assert!(!alert.triggered);
765 assert_eq!(d.frames(), 1);
766 }
767
768 #[test]
769 fn sigma_floor_prevents_explosion() {
770 let config = LeakDetectorConfig {
771 sigma_floor: 1.0,
772 warmup_frames: 5,
773 ..LeakDetectorConfig::default()
774 };
775 let mut d = AllocLeakDetector::new(config);
776
777 for _ in 0..50 {
779 let alert = d.observe(100.0);
780 assert!(!alert.e_value.is_nan());
781 assert!(!alert.e_value.is_infinite());
782 }
783 }
784
785 #[test]
786 fn detection_speed_proportional_to_shift() {
787 let detect_at = |shift: f64| -> usize {
789 let mut d = detector_with(0.05, 0.2, 20);
790 for _ in 0..20 {
791 d.observe(100.0);
792 }
793 for i in 0..500 {
794 let alert = d.observe(100.0 + shift);
795 if alert.triggered {
796 return i;
797 }
798 }
799 500
800 };
801
802 let small_shift = detect_at(5.0);
803 let large_shift = detect_at(20.0);
804
805 assert!(
806 large_shift <= small_shift,
807 "Large shift ({}) should detect no later than small shift ({})",
808 large_shift,
809 small_shift
810 );
811 }
812
813 #[test]
816 fn config_default_field_values() {
817 let c = LeakDetectorConfig::default();
818 assert!((c.alpha - 0.05).abs() < f64::EPSILON);
819 assert!((c.lambda - 0.2).abs() < f64::EPSILON);
820 assert!((c.cusum_threshold - 8.0).abs() < f64::EPSILON);
821 assert!((c.cusum_allowance - 0.5).abs() < f64::EPSILON);
822 assert_eq!(c.warmup_frames, 30);
823 assert!((c.sigma_decay - 0.95).abs() < f64::EPSILON);
824 assert!((c.sigma_floor - 1.0).abs() < f64::EPSILON);
825 }
826
827 #[test]
828 fn config_clone_is_independent() {
829 let c1 = LeakDetectorConfig::default();
830 let c2 = c1.clone();
831 assert!((c2.alpha - c1.alpha).abs() < f64::EPSILON);
833 assert!((c2.lambda - c1.lambda).abs() < f64::EPSILON);
834 assert_eq!(c2.warmup_frames, c1.warmup_frames);
835 }
836
837 #[test]
838 fn config_debug_contains_fields() {
839 let c = LeakDetectorConfig::default();
840 let dbg = format!("{c:?}");
841 assert!(dbg.contains("alpha"));
842 assert!(dbg.contains("lambda"));
843 assert!(dbg.contains("cusum_threshold"));
844 }
845
846 #[test]
849 fn mean_tracks_input() {
850 let mut d = default_detector();
851 d.observe(10.0);
852 assert!((d.mean() - 10.0).abs() < f64::EPSILON);
853 d.observe(20.0);
854 assert!((d.mean() - 15.0).abs() < f64::EPSILON);
855 d.observe(30.0);
856 assert!((d.mean() - 20.0).abs() < f64::EPSILON);
857 }
858
859 #[test]
860 fn sigma_respects_floor() {
861 let config = LeakDetectorConfig {
862 sigma_floor: 5.0,
863 ..LeakDetectorConfig::default()
864 };
865 let mut d = AllocLeakDetector::new(config);
866 d.observe(100.0);
868 assert!(d.sigma() >= 5.0, "sigma should be at least the floor");
869 }
870
871 #[test]
872 fn threshold_is_inverse_alpha() {
873 let d = detector_with(0.05, 0.2, 20);
874 assert!((d.threshold() - 20.0).abs() < f64::EPSILON);
875
876 let d2 = detector_with(0.10, 0.2, 20);
877 assert!((d2.threshold() - 10.0).abs() < f64::EPSILON);
878
879 let d3 = detector_with(0.01, 0.2, 20);
880 assert!((d3.threshold() - 100.0).abs() < f64::EPSILON);
881 }
882
883 #[test]
884 fn frames_increments_per_observe() {
885 let mut d = default_detector();
886 assert_eq!(d.frames(), 0);
887 d.observe(1.0);
888 assert_eq!(d.frames(), 1);
889 d.observe(2.0);
890 assert_eq!(d.frames(), 2);
891 for _ in 0..98 {
892 d.observe(3.0);
893 }
894 assert_eq!(d.frames(), 100);
895 }
896
897 #[test]
898 fn cusum_lower_accessor_matches_alert() {
899 let mut d = detector_with(0.05, 0.2, 5);
900 for _ in 0..5 {
901 d.observe(100.0);
902 }
903 let alert = d.observe(50.0); assert!((d.cusum_lower() - alert.cusum_lower).abs() < f64::EPSILON);
905 }
906
907 #[test]
910 fn reset_then_reuse_works() {
911 let mut d = default_detector();
912 for _ in 0..50 {
913 d.observe(100.0);
914 }
915 d.reset();
916
917 assert_eq!(d.frames(), 0);
919 assert!((d.mean() - 0.0).abs() < f64::EPSILON);
920 assert!(d.ledger().is_empty());
921
922 for _ in 0..50 {
924 let alert = d.observe(200.0);
925 assert!(!alert.triggered);
926 }
927 assert_eq!(d.frames(), 50);
928 assert!((d.mean() - 200.0).abs() < 1.0);
929 }
930
931 #[test]
932 fn reset_clears_cusum_lower() {
933 let mut d = default_detector();
934 for _ in 0..50 {
935 d.observe(100.0);
936 }
937 for _ in 0..20 {
939 d.observe(50.0);
940 }
941 assert!(d.cusum_lower() > 0.0, "cusum_lower should have risen");
942 d.reset();
943 assert_eq!(d.cusum_lower(), 0.0);
944 }
945
946 #[test]
949 fn evidence_entry_clone_is_independent() {
950 let e1 = EvidenceEntry {
951 frame: 1,
952 value: 100.0,
953 residual: 0.5,
954 cusum_upper: 1.0,
955 cusum_lower: 0.0,
956 e_value: 1.2,
957 mean_estimate: 99.0,
958 sigma_estimate: 5.0,
959 };
960 let e2 = e1.clone();
961 assert_eq!(e2.frame, 1);
962 assert!((e2.value - 100.0).abs() < f64::EPSILON);
963 assert!((e2.residual - 0.5).abs() < f64::EPSILON);
964 }
965
966 #[test]
967 fn evidence_entry_debug_format() {
968 let e = EvidenceEntry {
969 frame: 42,
970 value: 100.0,
971 residual: 0.123,
972 cusum_upper: 2.5,
973 cusum_lower: 0.1,
974 e_value: 1.5,
975 mean_estimate: 99.5,
976 sigma_estimate: 3.0,
977 };
978 let dbg = format!("{e:?}");
979 assert!(dbg.contains("frame: 42"));
980 assert!(dbg.contains("100.0"));
981 }
982
983 #[test]
984 fn jsonl_field_values_accurate() {
985 let e = EvidenceEntry {
986 frame: 7,
987 value: 123.45,
988 residual: -0.5678,
989 cusum_upper: 3.25,
990 cusum_lower: 0.0,
991 e_value: 2.75,
992 mean_estimate: 120.00,
993 sigma_estimate: 4.5678,
994 };
995 let line = e.to_jsonl();
996 assert!(line.contains("\"frame\":7"));
997 assert!(line.contains("\"value\":123.45"));
998 assert!(line.contains("\"residual\":-0.5678"));
999 assert!(line.contains("\"cusum_upper\":3.2500"));
1000 assert!(line.contains("\"cusum_lower\":0.0000"));
1001 assert!(line.contains("\"mean\":120.00"));
1002 assert!(line.contains("\"sigma\":4.5678"));
1003 }
1004
1005 #[test]
1006 fn jsonl_contains_e_value_key() {
1007 let e = EvidenceEntry {
1008 frame: 1,
1009 value: 0.0,
1010 residual: 0.0,
1011 cusum_upper: 0.0,
1012 cusum_lower: 0.0,
1013 e_value: 1.0,
1014 mean_estimate: 0.0,
1015 sigma_estimate: 1.0,
1016 };
1017 let line = e.to_jsonl();
1018 assert!(line.contains("\"e_value\":1.000000"));
1019 }
1020
1021 #[test]
1024 fn leak_alert_no_alert_fields() {
1025 let alert = LeakAlert::no_alert(42, 1.5, 3.0, 0.5);
1026 assert!(!alert.triggered);
1027 assert!(!alert.cusum_triggered);
1028 assert!(!alert.eprocess_triggered);
1029 assert_eq!(alert.frame, 42);
1030 assert!((alert.e_value - 1.5).abs() < f64::EPSILON);
1031 assert!((alert.cusum_upper - 3.0).abs() < f64::EPSILON);
1032 assert!((alert.cusum_lower - 0.5).abs() < f64::EPSILON);
1033 }
1034
1035 #[test]
1036 fn leak_alert_clone() {
1037 let a1 = LeakAlert::no_alert(1, 2.0, 3.0, 4.0);
1038 let a2 = a1.clone();
1039 assert_eq!(a2.frame, 1);
1040 assert!(!a2.triggered);
1041 }
1042
1043 #[test]
1044 fn leak_alert_debug() {
1045 let a = LeakAlert::no_alert(10, 1.0, 0.0, 0.0);
1046 let dbg = format!("{a:?}");
1047 assert!(dbg.contains("triggered: false"));
1048 assert!(dbg.contains("frame: 10"));
1049 }
1050
1051 #[test]
1054 fn warmup_boundary_exact() {
1055 let mut d = detector_with(0.05, 0.2, 5);
1056 for i in 1..=5 {
1058 let alert = d.observe(100.0);
1059 assert!(!alert.triggered, "warmup frame {i} should not trigger");
1060 assert!((alert.cusum_upper - 0.0).abs() < f64::EPSILON);
1061 assert!((alert.e_value - 1.0).abs() < f64::EPSILON);
1062 }
1063 let alert = d.observe(100.0);
1065 assert_eq!(alert.frame, 6);
1066 assert!(!alert.triggered);
1068 }
1069
1070 #[test]
1071 fn warmup_zero_frames() {
1072 let mut d = detector_with(0.05, 0.2, 0);
1073 let alert = d.observe(100.0);
1075 assert_eq!(alert.frame, 1);
1076 assert!(!alert.e_value.is_nan());
1078 }
1079
1080 #[test]
1083 fn warmup_ledger_entries_have_zero_cusum() {
1084 let mut d = detector_with(0.05, 0.2, 10);
1085 for _ in 0..10 {
1086 d.observe(100.0);
1087 }
1088 for entry in d.ledger() {
1089 assert!((entry.cusum_upper - 0.0).abs() < f64::EPSILON);
1090 assert!((entry.cusum_lower - 0.0).abs() < f64::EPSILON);
1091 assert!((entry.e_value - 1.0).abs() < f64::EPSILON);
1092 }
1093 }
1094
1095 #[test]
1098 fn nan_input_does_not_panic() {
1099 let mut d = default_detector();
1100 for _ in 0..10 {
1101 d.observe(100.0);
1102 }
1103 let _alert = d.observe(f64::NAN);
1105 assert_eq!(d.frames(), 11);
1106 }
1107
1108 #[test]
1109 fn infinity_input_does_not_panic() {
1110 let mut d = default_detector();
1111 for _ in 0..10 {
1112 d.observe(100.0);
1113 }
1114 let _alert = d.observe(f64::INFINITY);
1115 assert_eq!(d.frames(), 11);
1116 }
1117
1118 #[test]
1119 fn negative_infinity_input_does_not_panic() {
1120 let mut d = default_detector();
1121 for _ in 0..10 {
1122 d.observe(100.0);
1123 }
1124 let _alert = d.observe(f64::NEG_INFINITY);
1125 assert_eq!(d.frames(), 11);
1126 }
1127
1128 #[test]
1131 fn oscillating_values_no_trigger() {
1132 let mut d = default_detector();
1133 for i in 0..300 {
1135 let v = if i % 2 == 0 { 105.0 } else { 95.0 };
1136 let alert = d.observe(v);
1137 assert!(
1138 !alert.triggered,
1139 "Oscillating input should not trigger: frame={}",
1140 alert.frame
1141 );
1142 }
1143 }
1144
1145 #[test]
1148 fn very_large_values_no_panic() {
1149 let mut d = default_detector();
1150 for _ in 0..50 {
1151 d.observe(1e15);
1152 }
1153 assert_eq!(d.frames(), 50);
1154 assert!(!d.mean().is_nan());
1155 }
1156
1157 #[test]
1158 fn very_small_values_no_panic() {
1159 let mut d = default_detector();
1160 for _ in 0..50 {
1161 d.observe(1e-15);
1162 }
1163 assert_eq!(d.frames(), 50);
1164 assert!(!d.mean().is_nan());
1165 }
1166
1167 #[test]
1170 fn both_detectors_can_trigger_simultaneously() {
1171 let mut d = detector_with(0.05, 0.5, 5);
1172 for _ in 0..5 {
1173 d.observe(100.0);
1174 }
1175 let mut both_triggered = false;
1177 for _ in 0..500 {
1178 let alert = d.observe(200.0);
1179 if alert.cusum_triggered && alert.eprocess_triggered {
1180 both_triggered = true;
1181 assert!(alert.triggered);
1182 break;
1183 }
1184 }
1185 assert!(
1186 both_triggered,
1187 "Both detectors should trigger on massive shift"
1188 );
1189 }
1190
1191 #[test]
1194 fn cusum_recovers_after_transient_spike() {
1195 let mut d = detector_with(0.05, 0.2, 10);
1196 for _ in 0..10 {
1197 d.observe(100.0);
1198 }
1199 for _ in 0..3 {
1201 d.observe(120.0);
1202 }
1203 let spike_cusum = d.cusum_upper();
1204 for _ in 0..50 {
1206 d.observe(100.0);
1207 }
1208 assert!(
1209 d.cusum_upper() < spike_cusum,
1210 "CUSUM should decrease after return to baseline"
1211 );
1212 }
1213
1214 #[test]
1217 fn eprocess_grows_under_sustained_shift() {
1218 let mut d = detector_with(0.05, 0.2, 10);
1219 for _ in 0..10 {
1220 d.observe(100.0);
1221 }
1222 let e_before = d.e_value();
1223 for _ in 0..50 {
1225 d.observe(115.0);
1226 }
1227 assert!(
1228 d.e_value() > e_before,
1229 "E-process should grow under sustained shift"
1230 );
1231 }
1232
1233 #[test]
1236 fn ledger_entry_mean_estimate_converges() {
1237 let mut d = default_detector();
1238 for _ in 0..200 {
1239 d.observe(50.0);
1240 }
1241 let last = d.ledger().last().unwrap();
1242 assert!(
1243 (last.mean_estimate - 50.0).abs() < 0.01,
1244 "Mean estimate should converge to 50.0, got {:.4}",
1245 last.mean_estimate
1246 );
1247 }
1248
1249 #[test]
1250 fn ledger_entry_sigma_estimate_is_positive() {
1251 let mut rng = Lcg::new(0xDEAD);
1252 let mut d = default_detector();
1253 for _ in 0..100 {
1254 d.observe(rng.next_normal(100.0, 5.0));
1255 }
1256 for entry in d.ledger() {
1257 assert!(
1258 entry.sigma_estimate > 0.0,
1259 "Sigma estimate should be positive at frame {}",
1260 entry.frame
1261 );
1262 }
1263 }
1264
1265 #[test]
1266 fn ledger_entries_have_sequential_frames() {
1267 let mut d = default_detector();
1268 for _ in 0..50 {
1269 d.observe(100.0);
1270 }
1271 for (i, entry) in d.ledger().iter().enumerate() {
1272 assert_eq!(entry.frame, i + 1, "Frame should be sequential");
1273 }
1274 }
1275
1276 #[test]
1279 fn lcg_is_deterministic() {
1280 let mut rng1 = Lcg::new(42);
1281 let mut rng2 = Lcg::new(42);
1282 for _ in 0..100 {
1283 assert_eq!(rng1.next_u64(), rng2.next_u64());
1284 }
1285 }
1286
1287 #[test]
1288 fn lcg_different_seeds_differ() {
1289 let mut rng1 = Lcg::new(1);
1290 let mut rng2 = Lcg::new(2);
1291 let v1 = rng1.next_u64();
1292 let v2 = rng2.next_u64();
1293 assert_ne!(v1, v2);
1294 }
1295
1296 #[test]
1297 fn lcg_next_normal_centered() {
1298 let mut rng = Lcg::new(0xFACE);
1299 let mut sum = 0.0;
1300 let n = 10_000;
1301 for _ in 0..n {
1302 sum += rng.next_normal(50.0, 10.0);
1303 }
1304 let mean = sum / n as f64;
1305 assert!(
1306 (mean - 50.0).abs() < 1.0,
1307 "CLT-based normal mean should be near 50.0, got {mean:.2}"
1308 );
1309 }
1310
1311 #[test]
1314 fn negative_observations_work() {
1315 let mut d = default_detector();
1316 for _ in 0..50 {
1317 d.observe(-100.0);
1318 }
1319 assert!((d.mean() - (-100.0)).abs() < 0.01);
1320 assert_eq!(d.frames(), 50);
1321 }
1322
1323 #[test]
1326 fn detector_debug_format() {
1327 let d = default_detector();
1328 let dbg = format!("{d:?}");
1329 assert!(dbg.contains("AllocLeakDetector"));
1330 assert!(dbg.contains("mean"));
1331 assert!(dbg.contains("e_value"));
1332 }
1333
1334 #[test]
1337 fn evalue_starts_at_one_and_stays_during_warmup() {
1338 let mut d = detector_with(0.05, 0.2, 10);
1339 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
1340 for _ in 0..10 {
1341 d.observe(100.0);
1342 }
1343 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
1345 }
1346
1347 #[test]
1350 fn high_alpha_triggers_more_easily() {
1351 let mut d = detector_with(0.5, 0.3, 5);
1353 for _ in 0..5 {
1354 d.observe(100.0);
1355 }
1356 let mut triggered = false;
1357 for _ in 0..100 {
1358 let alert = d.observe(110.0);
1359 if alert.eprocess_triggered {
1360 triggered = true;
1361 break;
1362 }
1363 }
1364 assert!(
1365 triggered,
1366 "High alpha (low threshold) should trigger on small shift"
1367 );
1368 }
1369
1370 #[test]
1371 fn small_lambda_accumulates_slower() {
1372 let detect_frames = |lambda: f64| -> usize {
1374 let mut d = detector_with(0.05, lambda, 10);
1375 for _ in 0..10 {
1376 d.observe(100.0);
1377 }
1378 for i in 0..500 {
1379 let alert = d.observe(115.0);
1380 if alert.eprocess_triggered {
1381 return i;
1382 }
1383 }
1384 500
1385 };
1386 let fast = detect_frames(0.4);
1387 let slow = detect_frames(0.1);
1388 assert!(
1390 fast <= slow + 20,
1391 "Higher lambda should detect at least comparably fast: fast={fast}, slow={slow}"
1392 );
1393 }
1394
1395 #[test]
1398 fn welford_mean_matches_exact_mean() {
1399 let mut d = default_detector();
1400 let values = [10.0, 20.0, 30.0, 40.0, 50.0];
1401 for &v in &values {
1402 d.observe(v);
1403 }
1404 let expected = values.iter().sum::<f64>() / values.len() as f64;
1405 assert!(
1406 (d.mean() - expected).abs() < 1e-10,
1407 "Welford mean {:.4} should match exact mean {:.4}",
1408 d.mean(),
1409 expected
1410 );
1411 }
1412}