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