Skip to main content

ftui_runtime/
allocation_budget.rs

1#![forbid(unsafe_code)]
2
3//! Sequential allocation leak detection using CUSUM and e-process.
4//!
5//! This module monitors per-frame allocation counts/bytes as a time series
6//! and detects sustained mean-shift regressions with formal guarantees.
7//!
8//! # Mathematical Model
9//!
10//! ## CUSUM (Cumulative Sum Control Chart)
11//!
12//! Tracks one-sided cumulative deviation from a reference mean `μ₀`:
13//!
14//! ```text
15//! S_t⁺ = max(0, S_{t-1}⁺ + (x_t − μ₀ − k))   // detect upward shift
16//! S_t⁻ = max(0, S_{t-1}⁻ + (μ₀ − k − x_t))   // detect downward shift
17//! ```
18//!
19//! where `k` is the allowance (slack) parameter, typically `δ/2` for a
20//! target shift of `δ`. Alert when `S_t⁺ ≥ h` or `S_t⁻ ≥ h`.
21//!
22//! CUSUM is quick to detect sustained shifts but is not anytime-valid:
23//! it controls ARL (average run length) rather than Type I error.
24//!
25//! ## E-Process (Anytime-Valid Sequential Test)
26//!
27//! Maintains a wealth process over centered residuals `r_t = x_t − μ₀`:
28//!
29//! ```text
30//! E_0 = 1
31//! E_t = E_{t-1} × exp(λ × r_t − λ² × σ² / 2)
32//! ```
33//!
34//! where:
35//! - `σ²` is the assumed variance under H₀
36//! - `λ` is the betting fraction (adaptive via GRAPA or fixed)
37//!
38//! Alert when `E_t ≥ 1/α`. This provides anytime-valid Type I control:
39//! `P(∃t: E_t ≥ 1/α | H₀) ≤ α`.
40//!
41//! # Dual Detection Strategy
42//!
43//! | Detector | Speed | Guarantee | Use |
44//! |----------|-------|-----------|-----|
45//! | CUSUM | Fast (O(δ) frames) | ARL-based | Quick alerting |
46//! | E-process | Moderate | Anytime-valid α | Formal confirmation |
47//!
48//! An alert fires when **both** detectors agree (reduces false positives)
49//! or when the e-process alone exceeds threshold (formal guarantee).
50//!
51//! # Failure Modes
52//!
53//! | Condition | Behavior | Rationale |
54//! |-----------|----------|-----------|
55//! | `σ² = 0` | Clamp to `σ²_min` | Division by zero guard |
56//! | `E_t` underflow | Clamp to `E_MIN` | Prevents permanent zero-lock |
57//! | `E_t` overflow | Clamp to `E_MAX` | Numerical stability |
58//! | No observations | No state change | Idle is not evidence |
59
60use std::collections::VecDeque;
61
62use crate::evidence_sink::{EVIDENCE_SCHEMA_VERSION, EvidenceSink};
63/// Minimum wealth floor.
64const E_MIN: f64 = 1e-15;
65/// Maximum wealth ceiling.
66const E_MAX: f64 = 1e15;
67/// Minimum variance floor.
68const SIGMA2_MIN: f64 = 1e-6;
69
70fn default_budget_run_id() -> String {
71    format!("budget-{}", std::process::id())
72}
73
74#[derive(Debug, Clone)]
75pub struct EvidenceContext {
76    run_id: String,
77    screen_mode: String,
78    cols: u16,
79    rows: u16,
80}
81
82impl EvidenceContext {
83    #[must_use]
84    pub fn new(
85        run_id: impl Into<String>,
86        screen_mode: impl Into<String>,
87        cols: u16,
88        rows: u16,
89    ) -> Self {
90        Self {
91            run_id: run_id.into(),
92            screen_mode: screen_mode.into(),
93            cols,
94            rows,
95        }
96    }
97
98    fn prefix(&self, event_idx: u64) -> String {
99        format!(
100            r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
101            EVIDENCE_SCHEMA_VERSION,
102            json_escape(&self.run_id),
103            event_idx,
104            json_escape(&self.screen_mode),
105            self.cols,
106            self.rows
107        )
108    }
109}
110
111#[inline]
112fn json_escape(value: &str) -> String {
113    let mut out = String::with_capacity(value.len());
114    for ch in value.chars() {
115        match ch {
116            '"' => out.push_str("\\\""),
117            '\\' => out.push_str("\\\\"),
118            '\n' => out.push_str("\\n"),
119            '\r' => out.push_str("\\r"),
120            '\t' => out.push_str("\\t"),
121            c if c.is_control() => {
122                use std::fmt::Write as _;
123                let _ = write!(out, "\\u{:04X}", c as u32);
124            }
125            _ => out.push(ch),
126        }
127    }
128    out
129}
130
131/// Configuration for the allocation budget monitor.
132#[derive(Debug, Clone)]
133pub struct BudgetConfig {
134    /// Significance level α for e-process. Default: 0.05.
135    pub alpha: f64,
136
137    /// Reference mean μ₀ (expected allocations per frame under H₀).
138    /// This should be calibrated from a stable baseline.
139    pub mu_0: f64,
140
141    /// Assumed variance σ² under H₀. Default: computed from baseline.
142    pub sigma_sq: f64,
143
144    /// CUSUM allowance parameter k. Default: δ/2 where δ = target_shift.
145    pub cusum_k: f64,
146
147    /// CUSUM threshold h. Default: 5.0.
148    pub cusum_h: f64,
149
150    /// Fixed betting fraction λ for e-process. Default: 0.1.
151    pub lambda: f64,
152
153    /// Window size for running statistics. Default: 100.
154    pub window_size: usize,
155}
156
157impl Default for BudgetConfig {
158    fn default() -> Self {
159        Self {
160            alpha: 0.05,
161            mu_0: 0.0,
162            sigma_sq: 1.0,
163            cusum_k: 0.5,
164            cusum_h: 5.0,
165            lambda: 0.1,
166            window_size: 100,
167        }
168    }
169}
170
171impl BudgetConfig {
172    /// Create config calibrated for detecting a shift of `delta` allocations
173    /// above a baseline mean `mu_0` with variance `sigma_sq`.
174    pub fn calibrated(mu_0: f64, sigma_sq: f64, delta: f64, alpha: f64) -> Self {
175        let sigma_sq = sigma_sq.max(SIGMA2_MIN);
176        let lambda = (delta / sigma_sq).min(0.5); // conservative λ
177        Self {
178            alpha,
179            mu_0,
180            sigma_sq,
181            cusum_k: delta / 2.0,
182            cusum_h: 5.0,
183            lambda,
184            window_size: 100,
185        }
186    }
187
188    /// Serialize configuration to JSONL format.
189    #[must_use]
190    pub(crate) fn to_jsonl(&self, context: &EvidenceContext, event_idx: u64) -> String {
191        let prefix = context.prefix(event_idx);
192        format!(
193            r#"{{{prefix},"event":"allocation_budget_config","alpha":{:.6},"mu_0":{:.6},"sigma_sq":{:.6},"cusum_k":{:.6},"cusum_h":{:.6},"lambda":{:.6},"window_size":{}}}"#,
194            self.alpha,
195            self.mu_0,
196            self.sigma_sq,
197            self.cusum_k,
198            self.cusum_h,
199            self.lambda,
200            self.window_size
201        )
202    }
203}
204
205/// CUSUM state for one direction.
206#[derive(Debug, Clone, Default)]
207struct CusumState {
208    /// Cumulative sum statistic.
209    s: f64,
210    /// Number of consecutive frames above threshold.
211    alarm_count: u64,
212}
213
214/// Evidence ledger entry for diagnostics.
215#[derive(Debug, Clone)]
216pub struct BudgetEvidence {
217    /// Frame index.
218    pub frame: u64,
219    /// Observed allocation count/bytes.
220    pub x: f64,
221    /// Residual r_t = x - μ₀.
222    pub residual: f64,
223    /// CUSUM S⁺ after this observation.
224    pub cusum_plus: f64,
225    /// CUSUM S⁻ after this observation.
226    pub cusum_minus: f64,
227    /// E-process value after this observation.
228    pub e_value: f64,
229    /// Whether this observation triggered an alert.
230    pub alert: bool,
231}
232
233impl BudgetEvidence {
234    /// Serialize evidence to JSONL format.
235    #[must_use]
236    pub(crate) fn to_jsonl(&self, context: &EvidenceContext) -> String {
237        let prefix = context.prefix(self.frame);
238        format!(
239            r#"{{{prefix},"event":"allocation_budget_evidence","frame":{},"x":{:.6},"residual":{:.6},"cusum_plus":{:.6},"cusum_minus":{:.6},"e_value":{:.6},"alert":{}}}"#,
240            self.frame,
241            self.x,
242            self.residual,
243            self.cusum_plus,
244            self.cusum_minus,
245            self.e_value,
246            self.alert
247        )
248    }
249}
250
251/// Alert information when a leak/regression is detected.
252#[derive(Debug, Clone)]
253pub struct BudgetAlert {
254    /// Frame at which alert fired.
255    pub frame: u64,
256    /// Estimated shift magnitude (running mean − μ₀).
257    pub estimated_shift: f64,
258    /// E-process value at alert time.
259    pub e_value: f64,
260    /// CUSUM S⁺ at alert time.
261    pub cusum_plus: f64,
262    /// Whether the e-process alone triggered (formal guarantee).
263    pub e_process_triggered: bool,
264    /// Whether CUSUM triggered.
265    pub cusum_triggered: bool,
266}
267
268/// Allocation budget monitor with dual CUSUM + e-process detection.
269#[derive(Debug, Clone)]
270pub struct AllocationBudget {
271    config: BudgetConfig,
272    /// E-process wealth (log-space for numerical stability).
273    log_e_value: f64,
274    /// CUSUM upper (detect increase).
275    cusum_plus: CusumState,
276    /// CUSUM lower (detect decrease).
277    cusum_minus: CusumState,
278    /// Frame counter.
279    frame: u64,
280    /// Running window of recent observations for diagnostics.
281    window: VecDeque<f64>,
282    /// Total alerts fired.
283    total_alerts: u64,
284    /// Evidence ledger (bounded to last N entries).
285    ledger: VecDeque<BudgetEvidence>,
286    /// Max ledger size.
287    ledger_max: usize,
288    /// Evidence sink for JSONL logging.
289    evidence_sink: Option<EvidenceSink>,
290    /// Whether config has been logged to the sink.
291    config_logged: bool,
292    /// Evidence metadata for JSONL logs.
293    evidence_context: EvidenceContext,
294}
295
296impl AllocationBudget {
297    /// Create monitor with default config.
298    pub fn new(config: BudgetConfig) -> Self {
299        Self {
300            config,
301            log_e_value: 0.0,
302            cusum_plus: CusumState::default(),
303            cusum_minus: CusumState::default(),
304            frame: 0,
305            window: VecDeque::new(),
306            total_alerts: 0,
307            ledger: VecDeque::new(),
308            ledger_max: 500,
309            evidence_sink: None,
310            config_logged: false,
311            evidence_context: EvidenceContext::new(default_budget_run_id(), "unknown", 0, 0),
312        }
313    }
314
315    /// Attach an evidence sink for JSONL logging.
316    #[must_use]
317    pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
318        self.evidence_sink = Some(sink);
319        self.config_logged = false;
320        self
321    }
322
323    /// Set evidence context fields for JSONL logs.
324    #[must_use]
325    pub fn with_evidence_context(
326        mut self,
327        run_id: impl Into<String>,
328        screen_mode: impl Into<String>,
329        cols: u16,
330        rows: u16,
331    ) -> Self {
332        self.evidence_context = EvidenceContext::new(run_id, screen_mode, cols, rows);
333        self
334    }
335
336    /// Set evidence context fields for JSONL logs.
337    pub fn set_evidence_context(
338        &mut self,
339        run_id: impl Into<String>,
340        screen_mode: impl Into<String>,
341        cols: u16,
342        rows: u16,
343    ) {
344        self.evidence_context = EvidenceContext::new(run_id, screen_mode, cols, rows);
345    }
346
347    /// Set or clear the evidence sink.
348    pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
349        self.evidence_sink = sink;
350        self.config_logged = false;
351    }
352
353    /// Observe an allocation count/byte measurement for the current frame.
354    /// Returns `Some(alert)` if a regression is detected.
355    pub fn observe(&mut self, x: f64) -> Option<BudgetAlert> {
356        if !x.is_finite()
357            || !self.config.mu_0.is_finite()
358            || !self.config.cusum_k.is_finite()
359            || !self.config.cusum_h.is_finite()
360            || !self.config.sigma_sq.is_finite()
361            || !self.config.lambda.is_finite()
362            || !self.config.alpha.is_finite()
363            || !(0.0..1.0).contains(&self.config.alpha)
364        {
365            return None;
366        }
367        self.frame += 1;
368
369        // Maintain running window.
370        self.window.push_back(x);
371        if self.window.len() > self.config.window_size {
372            self.window.pop_front();
373        }
374
375        let residual = x - self.config.mu_0;
376
377        // --- CUSUM update ---
378        self.cusum_plus.s = (self.cusum_plus.s + residual - self.config.cusum_k).max(0.0);
379        self.cusum_minus.s = (self.cusum_minus.s - residual - self.config.cusum_k).max(0.0);
380
381        let cusum_plus_triggered = self.cusum_plus.s >= self.config.cusum_h;
382        if cusum_plus_triggered {
383            self.cusum_plus.alarm_count += 1;
384        } else {
385            self.cusum_plus.alarm_count = 0;
386        }
387
388        let cusum_minus_triggered = self.cusum_minus.s >= self.config.cusum_h;
389        if cusum_minus_triggered {
390            self.cusum_minus.alarm_count += 1;
391        } else {
392            self.cusum_minus.alarm_count = 0;
393        }
394
395        let cusum_triggered = cusum_plus_triggered || cusum_minus_triggered;
396
397        // --- E-process update ---
398        let sigma_sq = self.config.sigma_sq.max(SIGMA2_MIN);
399        let lambda = self.config.lambda;
400        let log_increment = lambda * residual - lambda * lambda * sigma_sq / 2.0;
401
402        // Update in log-space to prevent precision loss and overflow.
403        self.log_e_value = (self.log_e_value + log_increment).clamp(E_MIN.ln(), E_MAX.ln());
404
405        let e_threshold = 1.0 / self.config.alpha;
406        let e_process_triggered = self.log_e_value >= e_threshold.ln();
407
408        // Alert if e-process alone triggers (formal guarantee)
409        // or both CUSUM and e-process agree.
410        let alert = e_process_triggered || (cusum_triggered && self.log_e_value > 0.0);
411
412        // Record evidence.
413        let entry = BudgetEvidence {
414            frame: self.frame,
415            x,
416            residual,
417            cusum_plus: self.cusum_plus.s,
418            cusum_minus: self.cusum_minus.s,
419            e_value: self.log_e_value.exp(),
420            alert,
421        };
422        if let Some(ref sink) = self.evidence_sink {
423            let context = &self.evidence_context;
424            if !self.config_logged {
425                let _ = sink.write_jsonl(&self.config.to_jsonl(context, 0));
426                self.config_logged = true;
427            }
428            let _ = sink.write_jsonl(&entry.to_jsonl(context));
429        }
430        self.ledger.push_back(entry);
431        if self.ledger.len() > self.ledger_max {
432            self.ledger.pop_front();
433        }
434
435        if alert {
436            self.total_alerts += 1;
437            let estimated_shift = self.running_mean() - self.config.mu_0;
438            let e_value_at_alert = self.log_e_value.exp();
439            let cusum_plus_at_alert = self.cusum_plus.s;
440
441            // Reset after alert.
442            self.log_e_value = 0.0;
443            self.cusum_plus.s = 0.0;
444            self.cusum_minus.s = 0.0;
445            self.cusum_plus.alarm_count = 0;
446            self.cusum_minus.alarm_count = 0;
447
448            Some(BudgetAlert {
449                frame: self.frame,
450                estimated_shift,
451                e_value: e_value_at_alert,
452                cusum_plus: cusum_plus_at_alert,
453                e_process_triggered,
454                cusum_triggered,
455            })
456        } else {
457            None
458        }
459    }
460
461    /// Running mean of the observation window.
462    pub fn running_mean(&self) -> f64 {
463        if self.window.is_empty() {
464            return self.config.mu_0;
465        }
466        self.window.iter().sum::<f64>() / self.window.len() as f64
467    }
468
469    /// Current e-process value.
470    pub fn e_value(&self) -> f64 {
471        self.log_e_value.exp()
472    }
473
474    /// Current CUSUM S⁺ value.
475    pub fn cusum_plus(&self) -> f64 {
476        self.cusum_plus.s
477    }
478
479    /// Current CUSUM S⁻ value.
480    pub fn cusum_minus(&self) -> f64 {
481        self.cusum_minus.s
482    }
483
484    /// Total frames observed.
485    pub fn frames(&self) -> u64 {
486        self.frame
487    }
488
489    /// Total alerts fired.
490    pub fn total_alerts(&self) -> u64 {
491        self.total_alerts
492    }
493
494    /// Access the evidence ledger.
495    pub fn ledger(&self) -> &VecDeque<BudgetEvidence> {
496        &self.ledger
497    }
498
499    /// Reset all state (keep config).
500    pub fn reset(&mut self) {
501        self.log_e_value = 0.0;
502        self.cusum_plus = CusumState::default();
503        self.cusum_minus = CusumState::default();
504        self.frame = 0;
505        self.window.clear();
506        self.total_alerts = 0;
507        self.ledger.clear();
508        self.config_logged = false;
509    }
510
511    /// Summary for diagnostics.
512    pub fn summary(&self) -> BudgetSummary {
513        BudgetSummary {
514            frames: self.frame,
515            total_alerts: self.total_alerts,
516            e_value: self.log_e_value.exp(),
517            cusum_plus: self.cusum_plus.s,
518            cusum_minus: self.cusum_minus.s,
519            running_mean: self.running_mean(),
520            mu_0: self.config.mu_0,
521            drift: self.running_mean() - self.config.mu_0,
522        }
523    }
524}
525
526/// Diagnostic summary.
527#[derive(Debug, Clone)]
528pub struct BudgetSummary {
529    pub frames: u64,
530    pub total_alerts: u64,
531    pub e_value: f64,
532    pub cusum_plus: f64,
533    pub cusum_minus: f64,
534    pub running_mean: f64,
535    pub mu_0: f64,
536    pub drift: f64,
537}
538
539impl BudgetSummary {
540    /// Serialize summary to JSONL format.
541    #[must_use]
542    #[allow(dead_code)]
543    pub(crate) fn to_jsonl(&self, context: &EvidenceContext, event_idx: u64) -> String {
544        let prefix = context.prefix(event_idx);
545        format!(
546            r#"{{{prefix},"event":"allocation_budget_summary","frames":{},"total_alerts":{},"e_value":{:.6},"cusum_plus":{:.6},"cusum_minus":{:.6},"running_mean":{:.6},"mu_0":{:.6},"drift":{:.6}}}"#,
547            self.frames,
548            self.total_alerts,
549            self.e_value,
550            self.cusum_plus,
551            self.cusum_minus,
552            self.running_mean,
553            self.mu_0,
554            self.drift
555        )
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn test_context() -> EvidenceContext {
564        EvidenceContext::new("budget-test", "inline", 80, 24)
565    }
566
567    // ─── CUSUM tests ──────────────────────────────────────────────
568
569    #[test]
570    fn unit_cusum_detects_shift() {
571        // μ₀ = 10, shift to 15 (δ=5). k=2.5, h=5.
572        let config = BudgetConfig {
573            mu_0: 10.0,
574            sigma_sq: 4.0,
575            cusum_k: 2.5,
576            cusum_h: 5.0,
577            lambda: 0.1,
578            alpha: 0.05,
579            ..Default::default()
580        };
581        let mut monitor = AllocationBudget::new(config);
582
583        // Feed stable data first.
584        for _ in 0..20 {
585            monitor.observe(10.0);
586        }
587        assert_eq!(monitor.cusum_plus(), 0.0, "no CUSUM drift under H₀");
588
589        // Now inject shift: x=15 each frame.
590        // residual = 5, increment = 5 - 2.5 = 2.5 per frame.
591        // After 2 frames: S⁺ = 5.0 → should trigger CUSUM.
592        let mut cusum_crossed = false;
593        for _ in 0..5 {
594            monitor.observe(15.0);
595            if monitor.cusum_plus() >= 5.0 || monitor.total_alerts() > 0 {
596                cusum_crossed = true;
597                break;
598            }
599        }
600        assert!(cusum_crossed, "CUSUM should detect shift from 10→15");
601    }
602
603    // ─── E-process tests ──────────────────────────────────────────
604
605    #[test]
606    fn unit_eprocess_threshold() {
607        // λ=0.3, σ²=1, α=0.05, μ₀=0.
608        // With x=2 each frame, residual=2.
609        // log_inc = 0.3*2 - 0.3²*1/2 = 0.6 - 0.045 = 0.555
610        // E grows as exp(0.555*t), threshold = 1/0.05 = 20.
611        // Need t such that exp(0.555*t) ≥ 20 → t ≥ ln(20)/0.555 ≈ 5.4.
612        let config = BudgetConfig {
613            alpha: 0.05,
614            mu_0: 0.0,
615            sigma_sq: 1.0,
616            lambda: 0.3,
617            cusum_k: 1.0,
618            cusum_h: 100.0, // high to prevent CUSUM from interfering
619            ..Default::default()
620        };
621        let mut monitor = AllocationBudget::new(config);
622
623        let mut alert_frame = None;
624        for i in 0..20 {
625            if let Some(_alert) = monitor.observe(2.0) {
626                alert_frame = Some(i + 1);
627                break;
628            }
629        }
630        assert!(alert_frame.is_some(), "e-process should trigger");
631        let frame = alert_frame.unwrap();
632        // Should trigger around frame 6 (ceil of 5.4).
633        assert!(
634            frame <= 8,
635            "should detect quickly: triggered at frame {frame}"
636        );
637    }
638
639    #[test]
640    fn eprocess_stays_bounded_under_null() {
641        // Under H₀ (x = μ₀), e-process should stay near 1.
642        let config = BudgetConfig {
643            alpha: 0.05,
644            mu_0: 50.0,
645            sigma_sq: 10.0,
646            lambda: 0.1,
647            cusum_k: 2.0,
648            cusum_h: 10.0,
649            ..Default::default()
650        };
651        let mut monitor = AllocationBudget::new(config);
652
653        // Feed exactly μ₀.
654        for _ in 0..1000 {
655            monitor.observe(50.0);
656        }
657        // E-process should not have triggered.
658        assert_eq!(
659            monitor.total_alerts(),
660            0,
661            "no alerts under H₀ with constant input"
662        );
663        // Under exact H₀, log_inc = λ*0 - λ²σ²/2 < 0 → E decays.
664        assert!(monitor.e_value() <= 1.0, "E should decay under exact H₀");
665    }
666
667    #[test]
668    fn eprocess_wealth_clamped() {
669        let config = BudgetConfig {
670            alpha: 0.05,
671            mu_0: 0.0,
672            sigma_sq: 1.0,
673            lambda: 0.1,
674            cusum_k: 0.5,
675            cusum_h: 1000.0,
676            ..Default::default()
677        };
678        let mut monitor = AllocationBudget::new(config);
679
680        // Feed large negative residuals → E should decay but not underflow.
681        for _ in 0..10000 {
682            monitor.observe(-100.0);
683        }
684        assert!(
685            monitor.e_value() >= E_MIN,
686            "wealth should not underflow past E_MIN"
687        );
688    }
689
690    // ─── FPR control test ─────────────────────────────────────────
691
692    #[test]
693    fn property_fpr_control() {
694        // Run many stable sequences, count false positive rate.
695        // Under H₀ with exact constant input, there should be 0 false positives.
696        let alpha = 0.05;
697        let n_runs = 100;
698        let frames_per_run = 200;
699        let mut false_positives = 0;
700
701        for _ in 0..n_runs {
702            let config = BudgetConfig {
703                alpha,
704                mu_0: 100.0,
705                sigma_sq: 25.0,
706                lambda: 0.1,
707                cusum_k: 2.5,
708                cusum_h: 10.0,
709                ..Default::default()
710            };
711            let mut monitor = AllocationBudget::new(config);
712
713            // Deterministic PRNG for reproducibility.
714            let mut seed: u64 = 0xDEAD_BEEF_1234_5678;
715            let mut had_alert = false;
716
717            for _ in 0..frames_per_run {
718                // LCG pseudo-random: mean≈100, small noise.
719                seed = seed
720                    .wrapping_mul(6364136223846793005)
721                    .wrapping_add(1442695040888963407);
722                let u = (seed >> 33) as f64 / (1u64 << 31) as f64; // [0, 1)
723                let noise = (u - 0.5) * 10.0; // [-5, 5)
724                let x = 100.0 + noise;
725
726                if monitor.observe(x).is_some() {
727                    had_alert = true;
728                }
729            }
730            if had_alert {
731                false_positives += 1;
732            }
733        }
734
735        let fpr = false_positives as f64 / n_runs as f64;
736        // Under anytime-valid guarantee, FPR ≤ α.
737        // Allow tolerance for finite-sample effects.
738        assert!(
739            fpr <= alpha + 0.10,
740            "FPR {fpr} exceeds α + tolerance ({alpha} + 0.10)"
741        );
742    }
743
744    // ─── Synthetic leak injection ─────────────────────────────────
745
746    #[test]
747    fn e2e_synthetic_leak_injection() {
748        // Baseline at 50, then leak injects +10 starting at frame 100.
749        let config = BudgetConfig::calibrated(50.0, 4.0, 10.0, 0.05);
750        let mut monitor = AllocationBudget::new(config);
751
752        // Stable phase.
753        for _ in 0..100 {
754            let result = monitor.observe(50.0);
755            assert!(result.is_none(), "no alert during stable phase");
756        }
757
758        // Leak phase: x = 60.
759        let mut detected_at = None;
760        for i in 0..100 {
761            if let Some(_alert) = monitor.observe(60.0) {
762                detected_at = Some(i + 1);
763                break;
764            }
765        }
766        assert!(detected_at.is_some(), "should detect leak injection of +10");
767        let frames_to_detect = detected_at.unwrap();
768        assert!(
769            frames_to_detect <= 20,
770            "detection too slow: {frames_to_detect} frames for δ=10"
771        );
772    }
773
774    #[test]
775    fn e2e_stable_run_no_alerts() {
776        let config = BudgetConfig::calibrated(100.0, 16.0, 20.0, 0.05);
777        let mut monitor = AllocationBudget::new(config);
778
779        // Run 500 frames at exact baseline.
780        for _ in 0..500 {
781            let result = monitor.observe(100.0);
782            assert!(result.is_none());
783        }
784
785        assert_eq!(monitor.total_alerts(), 0);
786        // E should have decayed.
787        assert!(monitor.e_value() < 1.0);
788    }
789
790    // ─── Evidence ledger tests ────────────────────────────────────
791
792    #[test]
793    fn ledger_records_observations() {
794        let config = BudgetConfig {
795            mu_0: 10.0,
796            ..Default::default()
797        };
798        let mut monitor = AllocationBudget::new(config);
799
800        for i in 0..5 {
801            monitor.observe(10.0 + i as f64);
802        }
803
804        assert_eq!(monitor.ledger().len(), 5);
805        assert_eq!(monitor.ledger()[0].frame, 1);
806        assert_eq!(monitor.ledger()[4].frame, 5);
807        assert!((monitor.ledger()[0].x - 10.0).abs() < 1e-10);
808        assert!((monitor.ledger()[2].residual - 2.0).abs() < 1e-10);
809    }
810
811    #[test]
812    fn ledger_bounded_size() {
813        let mut monitor = AllocationBudget::new(BudgetConfig::default());
814        monitor.ledger_max = 10;
815
816        for i in 0..100 {
817            monitor.observe(i as f64);
818        }
819
820        assert!(monitor.ledger().len() <= 10);
821    }
822
823    // ─── Reset test ───────────────────────────────────────────────
824
825    #[test]
826    fn reset_clears_state() {
827        let config = BudgetConfig {
828            mu_0: 0.0,
829            ..Default::default()
830        };
831        let mut monitor = AllocationBudget::new(config);
832
833        for _ in 0..50 {
834            monitor.observe(5.0);
835        }
836        assert!(monitor.frames() > 0);
837
838        monitor.reset();
839        assert_eq!(monitor.frames(), 0);
840        assert_eq!(monitor.total_alerts(), 0);
841        assert!((monitor.e_value() - 1.0).abs() < 1e-10);
842        assert_eq!(monitor.cusum_plus(), 0.0);
843        assert_eq!(monitor.cusum_minus(), 0.0);
844        assert!(monitor.ledger().is_empty());
845    }
846
847    // ─── Summary test ─────────────────────────────────────────────
848
849    #[test]
850    fn summary_reports_drift() {
851        let config = BudgetConfig {
852            mu_0: 10.0,
853            cusum_h: 1000.0, // prevent alerts
854            alpha: 1e-20,    // prevent e-process alerts
855            ..Default::default()
856        };
857        let mut monitor = AllocationBudget::new(config);
858
859        for _ in 0..100 {
860            monitor.observe(15.0);
861        }
862
863        let summary = monitor.summary();
864        assert!((summary.running_mean - 15.0).abs() < 1e-10);
865        assert!((summary.drift - 5.0).abs() < 1e-10);
866        assert!((summary.mu_0 - 10.0).abs() < 1e-10);
867    }
868
869    // ─── Calibrated config test ───────────────────────────────────
870
871    #[test]
872    fn calibrated_config_reasonable() {
873        let config = BudgetConfig::calibrated(100.0, 25.0, 10.0, 0.05);
874        assert!((config.mu_0 - 100.0).abs() < 1e-10);
875        assert!((config.sigma_sq - 25.0).abs() < 1e-10);
876        assert!((config.cusum_k - 5.0).abs() < 1e-10);
877        assert!(config.lambda > 0.0 && config.lambda <= 0.5);
878        assert!((config.alpha - 0.05).abs() < 1e-10);
879    }
880
881    // ─── Determinism test ─────────────────────────────────────────
882
883    #[test]
884    fn deterministic_under_same_input() {
885        let run = || {
886            let config = BudgetConfig::calibrated(50.0, 4.0, 5.0, 0.05);
887            let mut monitor = AllocationBudget::new(config);
888            let inputs = [50.0, 51.0, 49.0, 55.0, 48.0, 60.0, 50.0, 52.0, 47.0, 53.0];
889            let mut e_values = Vec::new();
890            for x in inputs {
891                monitor.observe(x);
892                e_values.push(monitor.e_value());
893            }
894            (e_values, monitor.cusum_plus(), monitor.cusum_minus())
895        };
896
897        let (ev1, cp1, cm1) = run();
898        let (ev2, cp2, cm2) = run();
899        assert_eq!(ev1, ev2);
900        assert!((cp1 - cp2).abs() < 1e-15);
901        assert!((cm1 - cm2).abs() < 1e-15);
902    }
903
904    // ─── JSONL schema tests ───────────────────────────────────────
905
906    #[test]
907    fn config_jsonl_parses_and_has_fields() {
908        use serde_json::Value;
909
910        let config = BudgetConfig::default();
911        let context = test_context();
912        let jsonl = config.to_jsonl(&context, 0);
913        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
914
915        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
916        assert_eq!(value["run_id"], "budget-test");
917        assert!(
918            value["event_idx"].is_number(),
919            "event_idx should be numeric"
920        );
921        assert_eq!(value["screen_mode"], "inline");
922        assert!(value["cols"].is_number(), "cols should be numeric");
923        assert!(value["rows"].is_number(), "rows should be numeric");
924        assert_eq!(value["event"], "allocation_budget_config");
925        for key in [
926            "alpha",
927            "mu_0",
928            "sigma_sq",
929            "cusum_k",
930            "cusum_h",
931            "lambda",
932            "window_size",
933        ] {
934            assert!(value.get(key).is_some(), "missing config field {key}");
935        }
936    }
937
938    #[test]
939    fn evidence_jsonl_parses_and_has_fields() {
940        use serde_json::Value;
941
942        let evidence = BudgetEvidence {
943            frame: 3,
944            x: 12.0,
945            residual: 2.0,
946            cusum_plus: 1.5,
947            cusum_minus: 0.5,
948            e_value: 1.2,
949            alert: false,
950        };
951        let context = test_context();
952        let jsonl = evidence.to_jsonl(&context);
953        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
954
955        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
956        assert_eq!(value["run_id"], "budget-test");
957        assert!(
958            value["event_idx"].is_number(),
959            "event_idx should be numeric"
960        );
961        assert_eq!(value["screen_mode"], "inline");
962        assert!(value["cols"].is_number(), "cols should be numeric");
963        assert!(value["rows"].is_number(), "rows should be numeric");
964        assert_eq!(value["event"], "allocation_budget_evidence");
965        for key in [
966            "frame",
967            "x",
968            "residual",
969            "cusum_plus",
970            "cusum_minus",
971            "e_value",
972            "alert",
973        ] {
974            assert!(value.get(key).is_some(), "missing evidence field {key}");
975        }
976    }
977
978    #[test]
979    fn summary_jsonl_parses_and_has_fields() {
980        use serde_json::Value;
981
982        let summary = BudgetSummary {
983            frames: 5,
984            total_alerts: 1,
985            e_value: 2.0,
986            cusum_plus: 3.0,
987            cusum_minus: 1.0,
988            running_mean: 11.0,
989            mu_0: 10.0,
990            drift: 1.0,
991        };
992        let context = test_context();
993        let jsonl = summary.to_jsonl(&context, 5);
994        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
995
996        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
997        assert_eq!(value["run_id"], "budget-test");
998        assert!(
999            value["event_idx"].is_number(),
1000            "event_idx should be numeric"
1001        );
1002        assert_eq!(value["screen_mode"], "inline");
1003        assert!(value["cols"].is_number(), "cols should be numeric");
1004        assert!(value["rows"].is_number(), "rows should be numeric");
1005        assert_eq!(value["event"], "allocation_budget_summary");
1006        for key in [
1007            "frames",
1008            "total_alerts",
1009            "e_value",
1010            "cusum_plus",
1011            "cusum_minus",
1012            "running_mean",
1013            "mu_0",
1014            "drift",
1015        ] {
1016            assert!(value.get(key).is_some(), "missing summary field {key}");
1017        }
1018    }
1019
1020    #[test]
1021    fn evidence_jsonl_is_deterministic_for_fixed_inputs() {
1022        let config = BudgetConfig::calibrated(50.0, 4.0, 5.0, 0.05);
1023        let inputs = [50.0, 51.0, 49.0, 55.0, 48.0, 60.0, 50.0, 52.0, 47.0, 53.0];
1024
1025        let run = || {
1026            let context = test_context();
1027            let mut monitor = AllocationBudget::new(config.clone()).with_evidence_context(
1028                "budget-test",
1029                "inline",
1030                80,
1031                24,
1032            );
1033            for x in inputs {
1034                monitor.observe(x);
1035            }
1036            monitor
1037                .ledger()
1038                .iter()
1039                .map(|entry| entry.to_jsonl(&context))
1040                .collect::<Vec<_>>()
1041        };
1042
1043        let first = run();
1044        let second = run();
1045        assert_eq!(first, second);
1046    }
1047
1048    // ── BudgetConfig defaults ───────────────────────────────────────
1049
1050    #[test]
1051    fn budget_config_default_values() {
1052        let config = BudgetConfig::default();
1053        assert!((config.alpha - 0.05).abs() < f64::EPSILON);
1054        assert!((config.mu_0 - 0.0).abs() < f64::EPSILON);
1055        assert!((config.sigma_sq - 1.0).abs() < f64::EPSILON);
1056        assert!((config.cusum_k - 0.5).abs() < f64::EPSILON);
1057        assert!((config.cusum_h - 5.0).abs() < f64::EPSILON);
1058        assert!((config.lambda - 0.1).abs() < f64::EPSILON);
1059        assert_eq!(config.window_size, 100);
1060    }
1061
1062    #[test]
1063    fn calibrated_clamps_tiny_sigma() {
1064        let config = BudgetConfig::calibrated(0.0, 0.0, 1.0, 0.05);
1065        assert!(config.sigma_sq >= SIGMA2_MIN);
1066    }
1067
1068    #[test]
1069    fn calibrated_lambda_bounded() {
1070        let config = BudgetConfig::calibrated(0.0, 0.001, 1000.0, 0.05);
1071        assert!(config.lambda <= 0.5);
1072    }
1073
1074    // ── json_escape ─────────────────────────────────────────────────
1075
1076    #[test]
1077    fn json_escape_special_chars() {
1078        assert_eq!(json_escape("hello"), "hello");
1079        assert_eq!(json_escape("say \"hi\""), "say \\\"hi\\\"");
1080        assert_eq!(json_escape("back\\slash"), "back\\\\slash");
1081        assert_eq!(json_escape("new\nline"), "new\\nline");
1082        assert_eq!(json_escape("tab\there"), "tab\\there");
1083        assert_eq!(json_escape("cr\rhere"), "cr\\rhere");
1084    }
1085
1086    #[test]
1087    fn json_escape_control_chars() {
1088        let s = "\x01\x02";
1089        let escaped = json_escape(s);
1090        assert!(escaped.contains("\\u0001"));
1091        assert!(escaped.contains("\\u0002"));
1092    }
1093
1094    // ── EvidenceContext ──────────────────────────────────────────────
1095
1096    #[test]
1097    fn evidence_context_prefix_format() {
1098        let ctx = EvidenceContext::new("run-42", "inline", 120, 30);
1099        let prefix = ctx.prefix(7);
1100        assert!(prefix.contains("\"run_id\":\"run-42\""));
1101        assert!(prefix.contains("\"event_idx\":7"));
1102        assert!(prefix.contains("\"screen_mode\":\"inline\""));
1103        assert!(prefix.contains("\"cols\":120"));
1104        assert!(prefix.contains("\"rows\":30"));
1105    }
1106
1107    // ── AllocationBudget constructors / accessors ────────────────────
1108
1109    #[test]
1110    fn new_monitor_initial_state() {
1111        let monitor = AllocationBudget::new(BudgetConfig::default());
1112        assert_eq!(monitor.frames(), 0);
1113        assert_eq!(monitor.total_alerts(), 0);
1114        assert!((monitor.e_value() - 1.0).abs() < f64::EPSILON);
1115        assert!((monitor.cusum_plus() - 0.0).abs() < f64::EPSILON);
1116        assert!((monitor.cusum_minus() - 0.0).abs() < f64::EPSILON);
1117        assert!(monitor.ledger().is_empty());
1118    }
1119
1120    #[test]
1121    fn running_mean_empty_returns_mu0() {
1122        let config = BudgetConfig {
1123            mu_0: 42.0,
1124            ..Default::default()
1125        };
1126        let monitor = AllocationBudget::new(config);
1127        assert!((monitor.running_mean() - 42.0).abs() < f64::EPSILON);
1128    }
1129
1130    #[test]
1131    fn running_mean_with_observations() {
1132        let mut monitor = AllocationBudget::new(BudgetConfig {
1133            mu_0: 0.0,
1134            cusum_h: 1000.0,
1135            alpha: 1e-20,
1136            ..Default::default()
1137        });
1138        monitor.observe(10.0);
1139        monitor.observe(20.0);
1140        monitor.observe(30.0);
1141        assert!((monitor.running_mean() - 20.0).abs() < 1e-10);
1142    }
1143
1144    #[test]
1145    fn window_size_enforced() {
1146        let config = BudgetConfig {
1147            window_size: 5,
1148            mu_0: 0.0,
1149            cusum_h: 1000.0,
1150            alpha: 1e-20,
1151            ..Default::default()
1152        };
1153        let mut monitor = AllocationBudget::new(config);
1154        for i in 0..20 {
1155            monitor.observe(i as f64);
1156        }
1157        let expected_mean = (15.0 + 16.0 + 17.0 + 18.0 + 19.0) / 5.0;
1158        assert!((monitor.running_mean() - expected_mean).abs() < 1e-10);
1159    }
1160
1161    // ── with_evidence_context / set_evidence_context ─────────────────
1162
1163    #[test]
1164    fn with_evidence_context_builder() {
1165        let monitor = AllocationBudget::new(BudgetConfig::default()).with_evidence_context(
1166            "my-run",
1167            "fullscreen",
1168            200,
1169            50,
1170        );
1171        let summary = monitor.summary();
1172        let ctx = EvidenceContext::new("my-run", "fullscreen", 200, 50);
1173        let jsonl = summary.to_jsonl(&ctx, 0);
1174        assert!(jsonl.contains("\"run_id\":\"my-run\""));
1175        assert!(jsonl.contains("\"screen_mode\":\"fullscreen\""));
1176    }
1177
1178    #[test]
1179    fn set_evidence_context_mutates() {
1180        let mut monitor = AllocationBudget::new(BudgetConfig::default());
1181        monitor.set_evidence_context("new-run", "alt", 160, 40);
1182        monitor.observe(1.0);
1183        assert_eq!(monitor.frames(), 1);
1184    }
1185
1186    // ── Alert resets state ──────────────────────────────────────────
1187
1188    #[test]
1189    fn alert_resets_cusum_and_evalue() {
1190        let config = BudgetConfig {
1191            alpha: 0.05,
1192            mu_0: 0.0,
1193            sigma_sq: 1.0,
1194            lambda: 0.5,
1195            cusum_k: 0.5,
1196            cusum_h: 100.0,
1197            ..Default::default()
1198        };
1199        let mut monitor = AllocationBudget::new(config);
1200        let mut alert_seen = false;
1201        for _ in 0..100 {
1202            if monitor.observe(10.0).is_some() {
1203                alert_seen = true;
1204                break;
1205            }
1206        }
1207        assert!(alert_seen, "should have triggered alert");
1208        assert!((monitor.e_value() - 1.0).abs() < f64::EPSILON);
1209        assert!((monitor.cusum_plus() - 0.0).abs() < f64::EPSILON);
1210        assert!((monitor.cusum_minus() - 0.0).abs() < f64::EPSILON);
1211    }
1212
1213    #[test]
1214    fn alert_increments_total_alerts() {
1215        let config = BudgetConfig {
1216            alpha: 0.05,
1217            mu_0: 0.0,
1218            sigma_sq: 1.0,
1219            lambda: 0.5,
1220            cusum_k: 0.5,
1221            cusum_h: 100.0,
1222            ..Default::default()
1223        };
1224        let mut monitor = AllocationBudget::new(config);
1225        assert_eq!(monitor.total_alerts(), 0);
1226        for _ in 0..100 {
1227            if monitor.observe(10.0).is_some() {
1228                break;
1229            }
1230        }
1231        assert!(monitor.total_alerts() >= 1);
1232    }
1233
1234    #[test]
1235    fn alert_contains_expected_fields() {
1236        let config = BudgetConfig {
1237            alpha: 0.05,
1238            mu_0: 0.0,
1239            sigma_sq: 1.0,
1240            lambda: 0.5,
1241            cusum_k: 0.5,
1242            cusum_h: 100.0,
1243            ..Default::default()
1244        };
1245        let mut monitor = AllocationBudget::new(config);
1246        let mut alert = None;
1247        for _ in 0..100 {
1248            if let Some(a) = monitor.observe(10.0) {
1249                alert = Some(a);
1250                break;
1251            }
1252        }
1253        let alert = alert.expect("should have triggered");
1254        assert!(alert.frame > 0);
1255        assert!(alert.e_process_triggered);
1256        assert!(alert.e_value >= 1.0 / 0.05);
1257        assert!(alert.estimated_shift > 0.0);
1258    }
1259
1260    // ── CUSUM downward shift ────────────────────────────────────────
1261
1262    #[test]
1263    fn cusum_minus_detects_decrease() {
1264        let config = BudgetConfig {
1265            mu_0: 100.0,
1266            sigma_sq: 4.0,
1267            cusum_k: 2.5,
1268            cusum_h: 5.0,
1269            lambda: 0.01,
1270            alpha: 1e-100,
1271            ..Default::default()
1272        };
1273        let mut monitor = AllocationBudget::new(config);
1274        for _ in 0..10 {
1275            monitor.observe(90.0);
1276        }
1277        assert!(
1278            monitor.cusum_minus() > 0.0,
1279            "CUSUM- should be positive for downward shift"
1280        );
1281    }
1282
1283    // ── Summary fields ──────────────────────────────────────────────
1284
1285    #[test]
1286    fn summary_initial_state() {
1287        let monitor = AllocationBudget::new(BudgetConfig {
1288            mu_0: 25.0,
1289            ..Default::default()
1290        });
1291        let summary = monitor.summary();
1292        assert_eq!(summary.frames, 0);
1293        assert_eq!(summary.total_alerts, 0);
1294        assert!((summary.e_value - 1.0).abs() < f64::EPSILON);
1295        assert!((summary.mu_0 - 25.0).abs() < f64::EPSILON);
1296        assert!((summary.drift - 0.0).abs() < f64::EPSILON);
1297    }
1298
1299    #[test]
1300    fn budget_summary_clone_debug() {
1301        let summary = BudgetSummary {
1302            frames: 10,
1303            total_alerts: 1,
1304            e_value: 2.5,
1305            cusum_plus: 3.0,
1306            cusum_minus: 1.0,
1307            running_mean: 55.0,
1308            mu_0: 50.0,
1309            drift: 5.0,
1310        };
1311        let cloned = summary.clone();
1312        assert_eq!(cloned.frames, 10);
1313        assert!((cloned.drift - 5.0).abs() < f64::EPSILON);
1314        let dbg = format!("{:?}", summary);
1315        assert!(dbg.contains("BudgetSummary"));
1316    }
1317
1318    #[test]
1319    fn budget_evidence_clone_debug() {
1320        let ev = BudgetEvidence {
1321            frame: 5,
1322            x: 12.0,
1323            residual: 2.0,
1324            cusum_plus: 1.5,
1325            cusum_minus: 0.3,
1326            e_value: 1.1,
1327            alert: false,
1328        };
1329        let cloned = ev.clone();
1330        assert_eq!(cloned.frame, 5);
1331        assert!(!cloned.alert);
1332        let dbg = format!("{:?}", ev);
1333        assert!(dbg.contains("BudgetEvidence"));
1334    }
1335
1336    #[test]
1337    fn budget_alert_clone_debug() {
1338        let alert = BudgetAlert {
1339            frame: 50,
1340            estimated_shift: 3.5,
1341            e_value: 25.0,
1342            cusum_plus: 8.0,
1343            e_process_triggered: true,
1344            cusum_triggered: true,
1345        };
1346        let cloned = alert.clone();
1347        assert_eq!(cloned.frame, 50);
1348        assert!(cloned.e_process_triggered);
1349        let dbg = format!("{:?}", alert);
1350        assert!(dbg.contains("BudgetAlert"));
1351    }
1352
1353    // ── Reset clears config_logged ──────────────────────────────────
1354
1355    #[test]
1356    fn reset_allows_config_re_logging() {
1357        let mut monitor = AllocationBudget::new(BudgetConfig::default());
1358        monitor.observe(1.0);
1359        monitor.reset();
1360        monitor.observe(2.0);
1361        assert_eq!(monitor.frames(), 1);
1362        assert_eq!(monitor.ledger().len(), 1);
1363    }
1364
1365    // ── frames counter ──────────────────────────────────────────────
1366
1367    #[test]
1368    fn frames_increments_per_observe() {
1369        let mut monitor = AllocationBudget::new(BudgetConfig {
1370            cusum_h: 1000.0,
1371            alpha: 1e-20,
1372            ..Default::default()
1373        });
1374        for _ in 0..7 {
1375            monitor.observe(0.0);
1376        }
1377        assert_eq!(monitor.frames(), 7);
1378    }
1379
1380    #[test]
1381    fn observe_with_nan_lambda_is_noop() {
1382        let mut monitor = AllocationBudget::new(BudgetConfig {
1383            lambda: f64::NAN,
1384            ..Default::default()
1385        });
1386
1387        assert!(monitor.observe(10.0).is_none());
1388        assert_eq!(monitor.frames(), 0);
1389        assert!(monitor.ledger().is_empty());
1390        assert_eq!(monitor.e_value(), 1.0);
1391    }
1392
1393    #[test]
1394    fn observe_with_alpha_out_of_range_is_noop() {
1395        let mut monitor = AllocationBudget::new(BudgetConfig {
1396            alpha: 2.0,
1397            ..Default::default()
1398        });
1399
1400        assert!(monitor.observe(10.0).is_none());
1401        assert_eq!(monitor.frames(), 0);
1402        assert!(monitor.ledger().is_empty());
1403        assert_eq!(monitor.e_value(), 1.0);
1404    }
1405}