Skip to main content

ftui_render/
alloc_budget.rs

1#![forbid(unsafe_code)]
2
3//! Allocation budget: sequential leak detection using CUSUM + e-process.
4//!
5//! Tracks allocation counts (or bytes) per frame as a time series and
6//! detects sustained drift (allocation leaks or regressions) with formal,
7//! anytime-valid guarantees.
8//!
9//! # Detectors
10//!
11//! 1. **CUSUM** — Cumulative Sum control chart for fast mean-shift detection.
12//!    Sensitive to small, sustained drifts. Signals when the cumulative
13//!    deviation from the reference mean exceeds a threshold.
14//!
15//! 2. **E-process** — Anytime-valid sequential test (test martingale).
16//!    Provides a p-value-like guarantee that holds under optional stopping:
17//!    `P(E_t ever exceeds 1/α | H₀) ≤ α` (Ville's inequality).
18//!
19//! # Usage
20//!
21//! ```
22//! use ftui_render::alloc_budget::{AllocLeakDetector, LeakDetectorConfig};
23//!
24//! let config = LeakDetectorConfig::default();
25//! let mut detector = AllocLeakDetector::new(config);
26//!
27//! // Feed allocation counts per frame.
28//! for count in [100, 102, 98, 105, 101] {
29//!     let alert = detector.observe(count as f64);
30//!     assert!(!alert.triggered);
31//! }
32//! ```
33//!
34//! # Evidence Ledger
35//!
36//! Every observation produces an [`EvidenceEntry`] recording the residual,
37//! CUSUM state, and e-process value. This ledger is inspectable for
38//! diagnostics and can be serialised to JSONL.
39//!
40//! # Failure Modes
41//!
42//! - **False positive**: bounded by α (default 0.05). Under H₀ (no leak),
43//!   the e-process triggers with probability ≤ α across all stopping times.
44//! - **Detection delay**: CUSUM detects a shift of δ within approximately
45//!   `h / δ` frames (where h is the threshold). E-process provides
46//!   complementary evidence with stronger guarantees.
47
48// =========================================================================
49// Configuration
50// =========================================================================
51
52/// Configuration for the allocation leak detector.
53#[derive(Debug, Clone)]
54pub struct LeakDetectorConfig {
55    /// False positive rate bound for the e-process (default: 0.05).
56    pub alpha: f64,
57    /// Betting fraction λ for the e-process likelihood ratio.
58    /// Controls sensitivity vs. evidence accumulation speed.
59    /// Recommended: 0.1–0.5 (default: 0.2).
60    pub lambda: f64,
61    /// CUSUM threshold h. Higher = fewer false positives, slower detection.
62    /// Rule of thumb: h ≈ 8 with k=0.5 gives two-sided ARL₀ ≈ 2000 (default: 8.0).
63    pub cusum_threshold: f64,
64    /// CUSUM reference value k (allowance). Typically δ/2 where δ is the
65    /// minimum shift to detect. (default: 0.5).
66    pub cusum_allowance: f64,
67    /// Number of warmup frames to estimate baseline mean and σ (default: 30).
68    pub warmup_frames: usize,
69    /// EMA decay for running σ estimate (default: 0.95).
70    pub sigma_decay: f64,
71    /// Minimum σ floor to prevent division by zero (default: 1.0).
72    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// =========================================================================
90// Evidence ledger
91// =========================================================================
92
93/// A single observation's evidence record.
94#[derive(Debug, Clone)]
95pub struct EvidenceEntry {
96    /// Frame index (0-based).
97    pub frame: usize,
98    /// Raw observation value.
99    pub value: f64,
100    /// Standardised residual: (value - mean) / σ.
101    pub residual: f64,
102    /// CUSUM upper statistic S⁺.
103    pub cusum_upper: f64,
104    /// CUSUM lower statistic S⁻.
105    pub cusum_lower: f64,
106    /// E-process value (wealth / evidence).
107    pub e_value: f64,
108    /// Running mean estimate.
109    pub mean_estimate: f64,
110    /// Running σ estimate.
111    pub sigma_estimate: f64,
112}
113
114impl EvidenceEntry {
115    /// Serialise to a JSONL line.
116    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// =========================================================================
132// Alert
133// =========================================================================
134
135/// Result of a single observation.
136#[derive(Debug, Clone)]
137pub struct LeakAlert {
138    /// Whether the detector triggered an alert.
139    pub triggered: bool,
140    /// Which detector(s) triggered.
141    pub cusum_triggered: bool,
142    /// Whether the e-process crossed the threshold.
143    pub eprocess_triggered: bool,
144    /// Current e-process value.
145    pub e_value: f64,
146    /// Current CUSUM upper statistic.
147    pub cusum_upper: f64,
148    /// Current CUSUM lower statistic.
149    pub cusum_lower: f64,
150    /// Frame index.
151    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// =========================================================================
169// Detector
170// =========================================================================
171
172/// Sequential allocation leak detector combining CUSUM and e-process.
173///
174/// Feed per-frame allocation counts via [`observe`]. The detector maintains
175/// running estimates of the baseline mean and standard deviation, then
176/// applies both CUSUM and an e-process test to the standardised residuals.
177///
178/// An alert triggers when *either* detector fires. The evidence ledger
179/// records all intermediate state for post-mortem diagnostics.
180#[derive(Debug)]
181pub struct AllocLeakDetector {
182    config: LeakDetectorConfig,
183    /// Running mean (Welford online).
184    mean: f64,
185    /// Running M2 for variance (Welford).
186    m2: f64,
187    /// Running σ estimate (EMA-smoothed).
188    sigma_ema: f64,
189    /// CUSUM upper statistic S⁺ (detects upward shift).
190    cusum_upper: f64,
191    /// CUSUM lower statistic S⁻ (detects downward shift).
192    cusum_lower: f64,
193    /// E-process value (wealth).
194    e_value: f64,
195    /// Total frames observed.
196    frames: usize,
197    /// Evidence ledger (all observations).
198    ledger: Vec<EvidenceEntry>,
199}
200
201impl AllocLeakDetector {
202    /// Create a new detector with the given configuration.
203    #[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    /// Observe a new allocation count (or byte total) for this frame.
219    ///
220    /// Returns a [`LeakAlert`] indicating whether the detector triggered.
221    pub fn observe(&mut self, value: f64) -> LeakAlert {
222        self.frames += 1;
223        let n = self.frames;
224
225        // --- Welford online mean/variance ---
226        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        // EMA-smoothed σ (more responsive to recent changes).
238        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        // Standardised residual.
247        let residual = delta / sigma;
248
249        // During warmup, only accumulate stats.
250        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        // --- CUSUM (two-sided) ---
266        // S⁺ detects upward mean shift (leak/regression).
267        // S⁻ detects downward mean shift (improvement/fix).
268        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        // --- E-process (sub-Gaussian likelihood ratio) ---
275        // E_t = E_{t-1} × exp(λ r_t − λ² / 2)
276        // where r_t is the standardised residual.
277        let lambda = self.config.lambda;
278        let log_factor = lambda * residual - (lambda * lambda) / 2.0;
279        // Clamp to prevent overflow.
280        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    /// Current e-process value (evidence against H₀).
312    #[must_use]
313    pub fn e_value(&self) -> f64 {
314        self.e_value
315    }
316
317    /// Current CUSUM upper statistic.
318    #[must_use]
319    pub fn cusum_upper(&self) -> f64 {
320        self.cusum_upper
321    }
322
323    /// Current CUSUM lower statistic.
324    #[must_use]
325    pub fn cusum_lower(&self) -> f64 {
326        self.cusum_lower
327    }
328
329    /// Current mean estimate.
330    #[must_use]
331    pub fn mean(&self) -> f64 {
332        self.mean
333    }
334
335    /// Current σ estimate.
336    #[must_use]
337    pub fn sigma(&self) -> f64 {
338        self.sigma_ema.max(self.config.sigma_floor)
339    }
340
341    /// Total frames observed.
342    #[must_use]
343    pub fn frames(&self) -> usize {
344        self.frames
345    }
346
347    /// Access the full evidence ledger.
348    #[must_use]
349    pub fn ledger(&self) -> &[EvidenceEntry] {
350        &self.ledger
351    }
352
353    /// E-process threshold (1/α).
354    #[must_use]
355    pub fn threshold(&self) -> f64 {
356        1.0 / self.config.alpha
357    }
358
359    /// Reset detector state (preserves config).
360    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// =========================================================================
373// Tests
374// =========================================================================
375
376#[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    /// Deterministic LCG for reproducible tests.
394    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        /// Pseudo-normal via CLT (sum of 12 uniforms − 6).
407        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    // --- Basic functionality ---
416
417    #[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    // --- CUSUM detection ---
458
459    #[test]
460    fn unit_cusum_detects_shift() {
461        let mut d = detector_with(0.05, 0.2, 20);
462
463        // 20 warmup frames at baseline 100.
464        for _ in 0..20 {
465            d.observe(100.0);
466        }
467
468        // Inject a sustained upward shift of +10.
469        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    // --- E-process detection ---
509
510    #[test]
511    fn unit_eprocess_threshold() {
512        let mut d = detector_with(0.05, 0.3, 10);
513
514        // 10 warmup frames at baseline.
515        for _ in 0..10 {
516            d.observe(100.0);
517        }
518
519        // Sustained leak: allocations grow by 20%.
520        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        // Long stable run.
548        for _ in 0..1000 {
549            let v = rng.next_normal(100.0, 5.0);
550            d.observe(v);
551        }
552
553        // E-value should stay bounded (not explode) under H₀.
554        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    // --- False positive rate ---
562
563    #[test]
564    fn property_fpr_control() {
565        // Run many independent stable sequences. FPR should be ≤ α + tolerance.
566        let alpha = 0.10; // Higher α for tractable test.
567        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        // Allow generous tolerance: FPR ≤ α + 0.10 (account for CLT-based pseudo-normal).
592        assert!(
593            fpr <= alpha + 0.10,
594            "Empirical FPR {:.3} exceeds α + tolerance ({:.3})",
595            fpr,
596            alpha + 0.10,
597        );
598    }
599
600    // --- Evidence ledger ---
601
602    #[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        // Post-warmup residuals should approximately sum to zero.
645        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    // --- Reset ---
655
656    #[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    // --- E2E: synthetic leak injection ---
670
671    #[test]
672    fn e2e_synthetic_leak_detected() {
673        let mut rng = Lcg::new(0x5678);
674        let mut d = default_detector();
675
676        // Phase 1: 50 stable frames.
677        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        // Phase 2: inject leak (gradual increase of 0.5 per frame).
683        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        // Generate JSONL summary.
699        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        // E-value should stay bounded.
727        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    // --- Edge cases ---
737
738    #[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        // Constant input → Welford σ = 0, but floor should prevent issues.
778        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        // Larger shifts should be detected faster.
788        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    // --- Config defaults ---
814
815    #[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        // Clone should have the same values as the original.
832        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    // --- Accessor methods ---
847
848    #[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        // Constant input → Welford σ = 0, but sigma() should return floor.
867        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); // big downward shift
904        assert!((d.cusum_lower() - alert.cusum_lower).abs() < f64::EPSILON);
905    }
906
907    // --- Reset and reuse ---
908
909    #[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        // After reset, detector should behave like new.
918        assert_eq!(d.frames(), 0);
919        assert!((d.mean() - 0.0).abs() < f64::EPSILON);
920        assert!(d.ledger().is_empty());
921
922        // Feed new data — should work correctly.
923        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        // Force cusum_lower to rise with downward shift.
938        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    // --- EvidenceEntry ---
947
948    #[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    // --- LeakAlert ---
1022
1023    #[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    // --- Warmup boundary ---
1052
1053    #[test]
1054    fn warmup_boundary_exact() {
1055        let mut d = detector_with(0.05, 0.2, 5);
1056        // Frames 1..=5 are warmup — cusum/eprocess should be inert.
1057        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        // Frame 6 is the first post-warmup frame — detectors start running.
1064        let alert = d.observe(100.0);
1065        assert_eq!(alert.frame, 6);
1066        // Cusum/eprocess should now be active (though may still be near 0 for stable input).
1067        assert!(!alert.triggered);
1068    }
1069
1070    #[test]
1071    fn warmup_zero_frames() {
1072        let mut d = detector_with(0.05, 0.2, 0);
1073        // With warmup_frames=0, first observation should go through full detector.
1074        let alert = d.observe(100.0);
1075        assert_eq!(alert.frame, 1);
1076        // E-value and cusum should be computed (not the warmup defaults).
1077        assert!(!alert.e_value.is_nan());
1078    }
1079
1080    // --- Warmup ledger entries ---
1081
1082    #[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    // --- NaN / Infinity handling ---
1096
1097    #[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        // NaN input should not panic.
1104        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    // --- Oscillating input ---
1129
1130    #[test]
1131    fn oscillating_values_no_trigger() {
1132        let mut d = default_detector();
1133        // Alternating high/low with mean ~100, should not trigger.
1134        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    // --- Very large values ---
1146
1147    #[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    // --- Both detectors trigger ---
1168
1169    #[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        // Massive shift should trigger both.
1176        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    // --- CUSUM resets toward zero after shift disappears ---
1192
1193    #[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        // Brief spike.
1200        for _ in 0..3 {
1201            d.observe(120.0);
1202        }
1203        let spike_cusum = d.cusum_upper();
1204        // Return to baseline — cusum should decrease.
1205        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    // --- E-process accumulates under H1 ---
1215
1216    #[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        // Sustained upward shift.
1224        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    // --- Ledger entry field accuracy ---
1234
1235    #[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    // --- Lcg reproducibility ---
1277
1278    #[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    // --- Negative values ---
1312
1313    #[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    // --- AllocLeakDetector Debug ---
1324
1325    #[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    // --- E-value starts at 1.0 ---
1335
1336    #[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        // E-value should still be 1.0 after warmup (not updated during warmup).
1344        assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
1345    }
1346
1347    // --- Custom config combinations ---
1348
1349    #[test]
1350    fn high_alpha_triggers_more_easily() {
1351        // α=0.5 → threshold=2.0 (very low), should trigger quickly.
1352        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        // Compare detection speed with different lambda values.
1373        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        // Smaller lambda should generally detect slower or equal.
1389        assert!(
1390            fast <= slow + 20,
1391            "Higher lambda should detect at least comparably fast: fast={fast}, slow={slow}"
1392        );
1393    }
1394
1395    // --- Welford mean correctness ---
1396
1397    #[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}