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 pub fn ledger(&self) -> &[EvidenceEntry] {
349 &self.ledger
350 }
351
352 #[must_use]
354 pub fn threshold(&self) -> f64 {
355 1.0 / self.config.alpha
356 }
357
358 pub fn reset(&mut self) {
360 self.mean = 0.0;
361 self.m2 = 0.0;
362 self.sigma_ema = 0.0;
363 self.cusum_upper = 0.0;
364 self.cusum_lower = 0.0;
365 self.e_value = 1.0;
366 self.frames = 0;
367 self.ledger.clear();
368 }
369}
370
371#[cfg(test)]
376mod tests {
377 use super::*;
378
379 fn default_detector() -> AllocLeakDetector {
380 AllocLeakDetector::new(LeakDetectorConfig::default())
381 }
382
383 fn detector_with(alpha: f64, lambda: f64, warmup: usize) -> AllocLeakDetector {
384 AllocLeakDetector::new(LeakDetectorConfig {
385 alpha,
386 lambda,
387 warmup_frames: warmup,
388 ..LeakDetectorConfig::default()
389 })
390 }
391
392 struct Lcg(u64);
394 impl Lcg {
395 fn new(seed: u64) -> Self {
396 Self(seed)
397 }
398 fn next_u64(&mut self) -> u64 {
399 self.0 = self
400 .0
401 .wrapping_mul(6_364_136_223_846_793_005)
402 .wrapping_add(1);
403 self.0
404 }
405 fn next_normal(&mut self, mean: f64, std: f64) -> f64 {
407 let sum: f64 = (0..12)
408 .map(|_| (self.next_u64() as f64) / (u64::MAX as f64))
409 .sum();
410 mean + std * (sum - 6.0)
411 }
412 }
413
414 #[test]
417 fn new_detector_starts_clean() {
418 let d = default_detector();
419 assert_eq!(d.frames(), 0);
420 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
421 assert_eq!(d.cusum_upper(), 0.0);
422 assert_eq!(d.cusum_lower(), 0.0);
423 assert!(d.ledger().is_empty());
424 }
425
426 #[test]
427 fn warmup_does_not_trigger() {
428 let mut d = default_detector();
429 for i in 0..30 {
430 let alert = d.observe(100.0 + (i as f64) * 0.5);
431 assert!(
432 !alert.triggered,
433 "Should not trigger during warmup (frame {})",
434 i + 1
435 );
436 }
437 assert_eq!(d.frames(), 30);
438 }
439
440 #[test]
441 fn stable_run_no_alert() {
442 let mut rng = Lcg::new(0xCAFE);
443 let mut d = default_detector();
444
445 for _ in 0..500 {
446 let v = rng.next_normal(100.0, 5.0);
447 let alert = d.observe(v);
448 assert!(
449 !alert.triggered,
450 "Stable run should not trigger: frame={}, e={:.4}, cusum_up={:.4}",
451 alert.frame, alert.e_value, alert.cusum_upper,
452 );
453 }
454 }
455
456 #[test]
459 fn unit_cusum_detects_shift() {
460 let mut d = detector_with(0.05, 0.2, 20);
461
462 for _ in 0..20 {
464 d.observe(100.0);
465 }
466
467 let mut detected = false;
469 for i in 0..200 {
470 let alert = d.observe(110.0);
471 if alert.cusum_triggered {
472 detected = true;
473 assert!(
474 i < 50,
475 "CUSUM should detect shift within 50 frames, took {}",
476 i
477 );
478 break;
479 }
480 }
481 assert!(detected, "CUSUM failed to detect +10 mean shift");
482 }
483
484 #[test]
485 fn cusum_detects_downward_shift() {
486 let mut d = detector_with(0.05, 0.2, 20);
487
488 for _ in 0..20 {
489 d.observe(100.0);
490 }
491
492 let mut detected = false;
493 for i in 0..200 {
494 let alert = d.observe(90.0);
495 if alert.cusum_lower > d.config.cusum_threshold {
496 detected = true;
497 assert!(
498 i < 50,
499 "CUSUM should detect downward shift within 50 frames"
500 );
501 break;
502 }
503 }
504 assert!(detected, "CUSUM failed to detect -10 mean shift");
505 }
506
507 #[test]
510 fn unit_eprocess_threshold() {
511 let mut d = detector_with(0.05, 0.3, 10);
512
513 for _ in 0..10 {
515 d.observe(100.0);
516 }
517
518 let mut detected = false;
520 for i in 0..300 {
521 let alert = d.observe(120.0);
522 if alert.eprocess_triggered {
523 detected = true;
524 assert!(
525 alert.e_value >= d.threshold(),
526 "E-value {:.2} should exceed threshold {:.2}",
527 alert.e_value,
528 d.threshold()
529 );
530 assert!(
531 i < 150,
532 "E-process should detect within 150 frames, took {}",
533 i
534 );
535 break;
536 }
537 }
538 assert!(detected, "E-process failed to detect sustained leak");
539 }
540
541 #[test]
542 fn eprocess_value_bounded_under_null() {
543 let mut rng = Lcg::new(0xBEEF);
544 let mut d = detector_with(0.05, 0.2, 20);
545
546 for _ in 0..1000 {
548 let v = rng.next_normal(100.0, 5.0);
549 d.observe(v);
550 }
551
552 assert!(
554 d.e_value() < 100.0,
555 "E-value should stay bounded under null: got {:.2}",
556 d.e_value()
557 );
558 }
559
560 #[test]
563 fn property_fpr_control() {
564 let alpha = 0.10; let n_runs = 200;
567 let frames_per_run = 200;
568
569 let mut false_positives = 0;
570 let mut rng = Lcg::new(0xAAAA);
571
572 for _ in 0..n_runs {
573 let mut d = detector_with(alpha, 0.2, 20);
574 let mut triggered = false;
575
576 for _ in 0..frames_per_run {
577 let v = rng.next_normal(100.0, 5.0);
578 let alert = d.observe(v);
579 if alert.eprocess_triggered {
580 triggered = true;
581 break;
582 }
583 }
584 if triggered {
585 false_positives += 1;
586 }
587 }
588
589 let fpr = false_positives as f64 / n_runs as f64;
590 assert!(
592 fpr <= alpha + 0.10,
593 "Empirical FPR {:.3} exceeds α + tolerance ({:.3})",
594 fpr,
595 alpha + 0.10,
596 );
597 }
598
599 #[test]
602 fn ledger_records_all_frames() {
603 let mut d = default_detector();
604 for i in 0..50 {
605 d.observe(100.0 + i as f64);
606 }
607 assert_eq!(d.ledger().len(), 50);
608 assert_eq!(d.ledger()[0].frame, 1);
609 assert_eq!(d.ledger()[49].frame, 50);
610 }
611
612 #[test]
613 fn ledger_jsonl_valid() {
614 let mut d = default_detector();
615 for _ in 0..40 {
616 d.observe(100.0);
617 }
618
619 for entry in d.ledger() {
620 let line = entry.to_jsonl();
621 assert!(
622 line.starts_with('{') && line.ends_with('}'),
623 "Bad JSONL: {}",
624 line
625 );
626 assert!(line.contains("\"frame\":"));
627 assert!(line.contains("\"value\":"));
628 assert!(line.contains("\"residual\":"));
629 assert!(line.contains("\"cusum_upper\":"));
630 assert!(line.contains("\"e_value\":"));
631 }
632 }
633
634 #[test]
635 fn ledger_residuals_sum_near_zero_under_null() {
636 let mut rng = Lcg::new(0x1234);
637 let mut d = detector_with(0.05, 0.2, 20);
638
639 for _ in 0..500 {
640 d.observe(rng.next_normal(100.0, 5.0));
641 }
642
643 let post_warmup: Vec<f64> = d.ledger()[20..].iter().map(|e| e.residual).collect();
645 let mean_residual: f64 = post_warmup.iter().sum::<f64>() / post_warmup.len() as f64;
646 assert!(
647 mean_residual.abs() < 0.5,
648 "Mean residual should be near zero: got {:.4}",
649 mean_residual
650 );
651 }
652
653 #[test]
656 fn reset_clears_state() {
657 let mut d = default_detector();
658 for _ in 0..100 {
659 d.observe(100.0);
660 }
661 d.reset();
662 assert_eq!(d.frames(), 0);
663 assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
664 assert_eq!(d.cusum_upper(), 0.0);
665 assert!(d.ledger().is_empty());
666 }
667
668 #[test]
671 fn e2e_synthetic_leak_detected() {
672 let mut rng = Lcg::new(0x5678);
673 let mut d = default_detector();
674
675 for _ in 0..50 {
677 d.observe(rng.next_normal(100.0, 3.0));
678 }
679 assert!(!d.ledger().last().unwrap().e_value.is_nan());
680
681 let mut detected_frame = None;
683 for i in 0..200 {
684 let leak = 0.5 * i as f64;
685 let v = rng.next_normal(100.0 + leak, 3.0);
686 let alert = d.observe(v);
687 if alert.triggered && detected_frame.is_none() {
688 detected_frame = Some(alert.frame);
689 }
690 }
691
692 assert!(
693 detected_frame.is_some(),
694 "Detector should catch gradual leak"
695 );
696
697 let last = d.ledger().last().unwrap();
699 let summary = format!(
700 r#"{{"test":"e2e_synthetic_leak","detected_frame":{},"total_frames":{},"final_e_value":{:.4},"final_cusum_upper":{:.4}}}"#,
701 detected_frame.unwrap(),
702 d.frames(),
703 last.e_value,
704 last.cusum_upper,
705 );
706 assert!(summary.contains("\"detected_frame\":"));
707 }
708
709 #[test]
710 fn e2e_stable_run_no_alerts() {
711 let mut rng = Lcg::new(0x9999);
712 let mut d = default_detector();
713
714 let mut any_alert = false;
715 for _ in 0..500 {
716 let v = rng.next_normal(200.0, 10.0);
717 let alert = d.observe(v);
718 if alert.triggered {
719 any_alert = true;
720 }
721 }
722
723 assert!(!any_alert, "Stable run should produce no alerts");
724
725 let max_e = d.ledger().iter().map(|e| e.e_value).fold(0.0f64, f64::max);
727 assert!(
728 max_e < d.threshold(),
729 "Max e-value {:.4} should stay below threshold {:.4}",
730 max_e,
731 d.threshold()
732 );
733 }
734
735 #[test]
738 fn constant_input_no_trigger() {
739 let mut d = default_detector();
740 for _ in 0..200 {
741 let alert = d.observe(42.0);
742 assert!(
743 !alert.triggered,
744 "Constant input should never trigger: frame={}",
745 alert.frame
746 );
747 }
748 }
749
750 #[test]
751 fn zero_input_no_panic() {
752 let mut d = default_detector();
753 for _ in 0..50 {
754 let alert = d.observe(0.0);
755 assert!(!alert.e_value.is_nan(), "E-value should not be NaN");
756 }
757 }
758
759 #[test]
760 fn single_observation() {
761 let mut d = default_detector();
762 let alert = d.observe(100.0);
763 assert!(!alert.triggered);
764 assert_eq!(d.frames(), 1);
765 }
766
767 #[test]
768 fn sigma_floor_prevents_explosion() {
769 let config = LeakDetectorConfig {
770 sigma_floor: 1.0,
771 warmup_frames: 5,
772 ..LeakDetectorConfig::default()
773 };
774 let mut d = AllocLeakDetector::new(config);
775
776 for _ in 0..50 {
778 let alert = d.observe(100.0);
779 assert!(!alert.e_value.is_nan());
780 assert!(!alert.e_value.is_infinite());
781 }
782 }
783
784 #[test]
785 fn detection_speed_proportional_to_shift() {
786 let detect_at = |shift: f64| -> usize {
788 let mut d = detector_with(0.05, 0.2, 20);
789 for _ in 0..20 {
790 d.observe(100.0);
791 }
792 for i in 0..500 {
793 let alert = d.observe(100.0 + shift);
794 if alert.triggered {
795 return i;
796 }
797 }
798 500
799 };
800
801 let small_shift = detect_at(5.0);
802 let large_shift = detect_at(20.0);
803
804 assert!(
805 large_shift <= small_shift,
806 "Large shift ({}) should detect no later than small shift ({})",
807 large_shift,
808 small_shift
809 );
810 }
811}