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