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 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    /// Current e-process value (evidence against H₀).
313    #[must_use]
314    pub fn e_value(&self) -> f64 {
315        self.e_value
316    }
317
318    /// Current CUSUM upper statistic.
319    #[must_use]
320    pub fn cusum_upper(&self) -> f64 {
321        self.cusum_upper
322    }
323
324    /// Current CUSUM lower statistic.
325    #[must_use]
326    pub fn cusum_lower(&self) -> f64 {
327        self.cusum_lower
328    }
329
330    /// Current mean estimate.
331    #[must_use]
332    pub fn mean(&self) -> f64 {
333        self.mean
334    }
335
336    /// Current σ estimate.
337    #[must_use]
338    pub fn sigma(&self) -> f64 {
339        self.sigma_ema.max(self.config.sigma_floor)
340    }
341
342    /// Total frames observed.
343    #[must_use]
344    pub fn frames(&self) -> usize {
345        self.frames
346    }
347
348    /// Access the full evidence ledger.
349    #[must_use]
350    pub fn ledger(&self) -> &[EvidenceEntry] {
351        &self.ledger
352    }
353
354    /// E-process threshold (1/α).
355    #[must_use]
356    pub fn threshold(&self) -> f64 {
357        1.0 / self.config.alpha
358    }
359
360    /// Reset detector state (preserves config).
361    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// =========================================================================
374// Tests
375// =========================================================================
376
377#[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    /// Deterministic LCG for reproducible tests.
395    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        /// Pseudo-normal via CLT (sum of 12 uniforms − 6).
408        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    // --- Basic functionality ---
417
418    #[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    // --- CUSUM detection ---
459
460    #[test]
461    fn unit_cusum_detects_shift() {
462        let mut d = detector_with(0.05, 0.2, 20);
463
464        // 20 warmup frames at baseline 100.
465        for _ in 0..20 {
466            d.observe(100.0);
467        }
468
469        // Inject a sustained upward shift of +10.
470        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    // --- E-process detection ---
510
511    #[test]
512    fn unit_eprocess_threshold() {
513        let mut d = detector_with(0.05, 0.3, 10);
514
515        // 10 warmup frames at baseline.
516        for _ in 0..10 {
517            d.observe(100.0);
518        }
519
520        // Sustained leak: allocations grow by 20%.
521        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        // Long stable run.
549        for _ in 0..1000 {
550            let v = rng.next_normal(100.0, 5.0);
551            d.observe(v);
552        }
553
554        // E-value should stay bounded (not explode) under H₀.
555        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    // --- False positive rate ---
563
564    #[test]
565    fn property_fpr_control() {
566        // Run many independent stable sequences. FPR should be ≤ α + tolerance.
567        let alpha = 0.10; // Higher α for tractable test.
568        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        // Allow generous tolerance: FPR ≤ α + 0.10 (account for CLT-based pseudo-normal).
593        assert!(
594            fpr <= alpha + 0.10,
595            "Empirical FPR {:.3} exceeds α + tolerance ({:.3})",
596            fpr,
597            alpha + 0.10,
598        );
599    }
600
601    // --- Evidence ledger ---
602
603    #[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        // Post-warmup residuals should approximately sum to zero.
646        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    // --- Reset ---
656
657    #[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    // --- E2E: synthetic leak injection ---
671
672    #[test]
673    fn e2e_synthetic_leak_detected() {
674        let mut rng = Lcg::new(0x5678);
675        let mut d = default_detector();
676
677        // Phase 1: 50 stable frames.
678        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        // Phase 2: inject leak (gradual increase of 0.5 per frame).
684        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        // Generate JSONL summary.
700        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        // E-value should stay bounded.
728        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    // --- Edge cases ---
738
739    #[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        // Constant input → Welford σ = 0, but floor should prevent issues.
779        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        // Larger shifts should be detected faster.
789        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    // --- Config defaults ---
815
816    #[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        // Clone should have the same values as the original.
833        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    // --- Accessor methods ---
848
849    #[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        // Constant input → Welford σ = 0, but sigma() should return floor.
868        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); // big downward shift
905        assert!((d.cusum_lower() - alert.cusum_lower).abs() < f64::EPSILON);
906    }
907
908    // --- Reset and reuse ---
909
910    #[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        // After reset, detector should behave like new.
919        assert_eq!(d.frames(), 0);
920        assert!((d.mean() - 0.0).abs() < f64::EPSILON);
921        assert!(d.ledger().is_empty());
922
923        // Feed new data — should work correctly.
924        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        // Force cusum_lower to rise with downward shift.
939        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    // --- EvidenceEntry ---
948
949    #[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    // --- LeakAlert ---
1023
1024    #[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    // --- Warmup boundary ---
1053
1054    #[test]
1055    fn warmup_boundary_exact() {
1056        let mut d = detector_with(0.05, 0.2, 5);
1057        // Frames 1..=5 are warmup — cusum/eprocess should be inert.
1058        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        // Frame 6 is the first post-warmup frame — detectors start running.
1065        let alert = d.observe(100.0);
1066        assert_eq!(alert.frame, 6);
1067        // Cusum/eprocess should now be active (though may still be near 0 for stable input).
1068        assert!(!alert.triggered);
1069    }
1070
1071    #[test]
1072    fn warmup_zero_frames() {
1073        let mut d = detector_with(0.05, 0.2, 0);
1074        // With warmup_frames=0, first observation should go through full detector.
1075        let alert = d.observe(100.0);
1076        assert_eq!(alert.frame, 1);
1077        // E-value and cusum should be computed (not the warmup defaults).
1078        assert!(!alert.e_value.is_nan());
1079    }
1080
1081    // --- Warmup ledger entries ---
1082
1083    #[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    // --- NaN / Infinity handling ---
1097
1098    #[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        // NaN input should not panic.
1105        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    // --- Oscillating input ---
1130
1131    #[test]
1132    fn oscillating_values_no_trigger() {
1133        let mut d = default_detector();
1134        // Alternating high/low with mean ~100, should not trigger.
1135        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    // --- Very large values ---
1147
1148    #[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    // --- Both detectors trigger ---
1169
1170    #[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        // Massive shift should trigger both.
1177        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    // --- CUSUM resets toward zero after shift disappears ---
1193
1194    #[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        // Brief spike.
1201        for _ in 0..3 {
1202            d.observe(120.0);
1203        }
1204        let spike_cusum = d.cusum_upper();
1205        // Return to baseline — cusum should decrease.
1206        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    // --- E-process accumulates under H1 ---
1216
1217    #[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        // Sustained upward shift.
1225        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    // --- Ledger entry field accuracy ---
1235
1236    #[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    // --- Lcg reproducibility ---
1278
1279    #[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    // --- Negative values ---
1313
1314    #[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    // --- AllocLeakDetector Debug ---
1325
1326    #[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    // --- E-value starts at 1.0 ---
1336
1337    #[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        // E-value should still be 1.0 after warmup (not updated during warmup).
1345        assert!((d.e_value() - 1.0).abs() < f64::EPSILON);
1346    }
1347
1348    // --- Custom config combinations ---
1349
1350    #[test]
1351    fn high_alpha_triggers_more_easily() {
1352        // α=0.5 → threshold=2.0 (very low), should trigger quickly.
1353        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        // Compare detection speed with different lambda values.
1374        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        // Smaller lambda should generally detect slower or equal.
1390        assert!(
1391            fast <= slow + 20,
1392            "Higher lambda should detect at least comparably fast: fast={fast}, slow={slow}"
1393        );
1394    }
1395
1396    // --- Welford mean correctness ---
1397
1398    #[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}