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.
273    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            e_value: 1.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        self.frame += 1;
357
358        // Maintain running window.
359        self.window.push_back(x);
360        if self.window.len() > self.config.window_size {
361            self.window.pop_front();
362        }
363
364        let residual = x - self.config.mu_0;
365
366        // --- CUSUM update ---
367        self.cusum_plus.s = (self.cusum_plus.s + residual - self.config.cusum_k).max(0.0);
368        self.cusum_minus.s = (self.cusum_minus.s - residual - self.config.cusum_k).max(0.0);
369
370        let cusum_triggered =
371            self.cusum_plus.s >= self.config.cusum_h || self.cusum_minus.s >= self.config.cusum_h;
372
373        if cusum_triggered {
374            self.cusum_plus.alarm_count += 1;
375            self.cusum_minus.alarm_count += 1;
376        }
377
378        // --- E-process update ---
379        let sigma_sq = self.config.sigma_sq.max(SIGMA2_MIN);
380        let lambda = self.config.lambda;
381        let log_increment = lambda * residual - lambda * lambda * sigma_sq / 2.0;
382        self.e_value = (self.e_value * log_increment.exp()).clamp(E_MIN, E_MAX);
383
384        let e_threshold = 1.0 / self.config.alpha;
385        let e_process_triggered = self.e_value >= e_threshold;
386
387        // Alert if e-process alone triggers (formal guarantee)
388        // or both CUSUM and e-process agree.
389        let alert = e_process_triggered;
390
391        // Record evidence.
392        let entry = BudgetEvidence {
393            frame: self.frame,
394            x,
395            residual,
396            cusum_plus: self.cusum_plus.s,
397            cusum_minus: self.cusum_minus.s,
398            e_value: self.e_value,
399            alert,
400        };
401        if let Some(ref sink) = self.evidence_sink {
402            let context = &self.evidence_context;
403            if !self.config_logged {
404                let _ = sink.write_jsonl(&self.config.to_jsonl(context, 0));
405                self.config_logged = true;
406            }
407            let _ = sink.write_jsonl(&entry.to_jsonl(context));
408        }
409        self.ledger.push_back(entry);
410        if self.ledger.len() > self.ledger_max {
411            self.ledger.pop_front();
412        }
413
414        if alert {
415            self.total_alerts += 1;
416            let estimated_shift = self.running_mean() - self.config.mu_0;
417            let e_value_at_alert = self.e_value;
418            let cusum_plus_at_alert = self.cusum_plus.s;
419
420            // Reset after alert.
421            self.e_value = 1.0;
422            self.cusum_plus.s = 0.0;
423            self.cusum_minus.s = 0.0;
424
425            Some(BudgetAlert {
426                frame: self.frame,
427                estimated_shift,
428                e_value: e_value_at_alert,
429                cusum_plus: cusum_plus_at_alert,
430                e_process_triggered,
431                cusum_triggered,
432            })
433        } else {
434            None
435        }
436    }
437
438    /// Running mean of the observation window.
439    pub fn running_mean(&self) -> f64 {
440        if self.window.is_empty() {
441            return self.config.mu_0;
442        }
443        self.window.iter().sum::<f64>() / self.window.len() as f64
444    }
445
446    /// Current e-process value.
447    pub fn e_value(&self) -> f64 {
448        self.e_value
449    }
450
451    /// Current CUSUM S⁺ value.
452    pub fn cusum_plus(&self) -> f64 {
453        self.cusum_plus.s
454    }
455
456    /// Current CUSUM S⁻ value.
457    pub fn cusum_minus(&self) -> f64 {
458        self.cusum_minus.s
459    }
460
461    /// Total frames observed.
462    pub fn frames(&self) -> u64 {
463        self.frame
464    }
465
466    /// Total alerts fired.
467    pub fn total_alerts(&self) -> u64 {
468        self.total_alerts
469    }
470
471    /// Access the evidence ledger.
472    pub fn ledger(&self) -> &VecDeque<BudgetEvidence> {
473        &self.ledger
474    }
475
476    /// Reset all state (keep config).
477    pub fn reset(&mut self) {
478        self.e_value = 1.0;
479        self.cusum_plus = CusumState::default();
480        self.cusum_minus = CusumState::default();
481        self.frame = 0;
482        self.window.clear();
483        self.total_alerts = 0;
484        self.ledger.clear();
485        self.config_logged = false;
486    }
487
488    /// Summary for diagnostics.
489    pub fn summary(&self) -> BudgetSummary {
490        BudgetSummary {
491            frames: self.frame,
492            total_alerts: self.total_alerts,
493            e_value: self.e_value,
494            cusum_plus: self.cusum_plus.s,
495            cusum_minus: self.cusum_minus.s,
496            running_mean: self.running_mean(),
497            mu_0: self.config.mu_0,
498            drift: self.running_mean() - self.config.mu_0,
499        }
500    }
501}
502
503/// Diagnostic summary.
504#[derive(Debug, Clone)]
505pub struct BudgetSummary {
506    pub frames: u64,
507    pub total_alerts: u64,
508    pub e_value: f64,
509    pub cusum_plus: f64,
510    pub cusum_minus: f64,
511    pub running_mean: f64,
512    pub mu_0: f64,
513    pub drift: f64,
514}
515
516impl BudgetSummary {
517    /// Serialize summary to JSONL format.
518    #[must_use]
519    #[allow(dead_code)]
520    #[allow(dead_code)]
521    pub(crate) fn to_jsonl(&self, context: &EvidenceContext, event_idx: u64) -> String {
522        let prefix = context.prefix(event_idx);
523        format!(
524            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}}}"#,
525            self.frames,
526            self.total_alerts,
527            self.e_value,
528            self.cusum_plus,
529            self.cusum_minus,
530            self.running_mean,
531            self.mu_0,
532            self.drift
533        )
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    fn test_context() -> EvidenceContext {
542        EvidenceContext::new("budget-test", "inline", 80, 24)
543    }
544
545    // ─── CUSUM tests ──────────────────────────────────────────────
546
547    #[test]
548    fn unit_cusum_detects_shift() {
549        // μ₀ = 10, shift to 15 (δ=5). k=2.5, h=5.
550        let config = BudgetConfig {
551            mu_0: 10.0,
552            sigma_sq: 4.0,
553            cusum_k: 2.5,
554            cusum_h: 5.0,
555            lambda: 0.1,
556            alpha: 0.05,
557            ..Default::default()
558        };
559        let mut monitor = AllocationBudget::new(config);
560
561        // Feed stable data first.
562        for _ in 0..20 {
563            monitor.observe(10.0);
564        }
565        assert_eq!(monitor.cusum_plus(), 0.0, "no CUSUM drift under H₀");
566
567        // Now inject shift: x=15 each frame.
568        // residual = 5, increment = 5 - 2.5 = 2.5 per frame.
569        // After 2 frames: S⁺ = 5.0 → should trigger CUSUM.
570        let mut cusum_crossed = false;
571        for _ in 0..5 {
572            monitor.observe(15.0);
573            if monitor.cusum_plus() >= 5.0 || monitor.total_alerts() > 0 {
574                cusum_crossed = true;
575                break;
576            }
577        }
578        assert!(cusum_crossed, "CUSUM should detect shift from 10→15");
579    }
580
581    // ─── E-process tests ──────────────────────────────────────────
582
583    #[test]
584    fn unit_eprocess_threshold() {
585        // λ=0.3, σ²=1, α=0.05, μ₀=0.
586        // With x=2 each frame, residual=2.
587        // log_inc = 0.3*2 - 0.3²*1/2 = 0.6 - 0.045 = 0.555
588        // E grows as exp(0.555*t), threshold = 1/0.05 = 20.
589        // Need t such that exp(0.555*t) ≥ 20 → t ≥ ln(20)/0.555 ≈ 5.4.
590        let config = BudgetConfig {
591            alpha: 0.05,
592            mu_0: 0.0,
593            sigma_sq: 1.0,
594            lambda: 0.3,
595            cusum_k: 1.0,
596            cusum_h: 100.0, // high to prevent CUSUM from interfering
597            ..Default::default()
598        };
599        let mut monitor = AllocationBudget::new(config);
600
601        let mut alert_frame = None;
602        for i in 0..20 {
603            if let Some(_alert) = monitor.observe(2.0) {
604                alert_frame = Some(i + 1);
605                break;
606            }
607        }
608        assert!(alert_frame.is_some(), "e-process should trigger");
609        let frame = alert_frame.unwrap();
610        // Should trigger around frame 6 (ceil of 5.4).
611        assert!(
612            frame <= 8,
613            "should detect quickly: triggered at frame {frame}"
614        );
615    }
616
617    #[test]
618    fn eprocess_stays_bounded_under_null() {
619        // Under H₀ (x = μ₀), e-process should stay near 1.
620        let config = BudgetConfig {
621            alpha: 0.05,
622            mu_0: 50.0,
623            sigma_sq: 10.0,
624            lambda: 0.1,
625            cusum_k: 2.0,
626            cusum_h: 10.0,
627            ..Default::default()
628        };
629        let mut monitor = AllocationBudget::new(config);
630
631        // Feed exactly μ₀.
632        for _ in 0..1000 {
633            monitor.observe(50.0);
634        }
635        // E-process should not have triggered.
636        assert_eq!(
637            monitor.total_alerts(),
638            0,
639            "no alerts under H₀ with constant input"
640        );
641        // Under exact H₀, log_inc = λ*0 - λ²σ²/2 < 0 → E decays.
642        assert!(monitor.e_value() <= 1.0, "E should decay under exact H₀");
643    }
644
645    #[test]
646    fn eprocess_wealth_clamped() {
647        let config = BudgetConfig {
648            alpha: 0.05,
649            mu_0: 0.0,
650            sigma_sq: 1.0,
651            lambda: 0.1,
652            cusum_k: 0.5,
653            cusum_h: 1000.0,
654            ..Default::default()
655        };
656        let mut monitor = AllocationBudget::new(config);
657
658        // Feed large negative residuals → E should decay but not underflow.
659        for _ in 0..10000 {
660            monitor.observe(-100.0);
661        }
662        assert!(
663            monitor.e_value() >= E_MIN,
664            "wealth should not underflow past E_MIN"
665        );
666    }
667
668    // ─── FPR control test ─────────────────────────────────────────
669
670    #[test]
671    fn property_fpr_control() {
672        // Run many stable sequences, count false positive rate.
673        // Under H₀ with exact constant input, there should be 0 false positives.
674        let alpha = 0.05;
675        let n_runs = 100;
676        let frames_per_run = 200;
677        let mut false_positives = 0;
678
679        for _ in 0..n_runs {
680            let config = BudgetConfig {
681                alpha,
682                mu_0: 100.0,
683                sigma_sq: 25.0,
684                lambda: 0.1,
685                cusum_k: 2.5,
686                cusum_h: 10.0,
687                ..Default::default()
688            };
689            let mut monitor = AllocationBudget::new(config);
690
691            // Deterministic PRNG for reproducibility.
692            let mut seed: u64 = 0xDEAD_BEEF_1234_5678;
693            let mut had_alert = false;
694
695            for _ in 0..frames_per_run {
696                // LCG pseudo-random: mean≈100, small noise.
697                seed = seed
698                    .wrapping_mul(6364136223846793005)
699                    .wrapping_add(1442695040888963407);
700                let u = (seed >> 33) as f64 / (1u64 << 31) as f64; // [0, 1)
701                let noise = (u - 0.5) * 10.0; // [-5, 5)
702                let x = 100.0 + noise;
703
704                if monitor.observe(x).is_some() {
705                    had_alert = true;
706                }
707            }
708            if had_alert {
709                false_positives += 1;
710            }
711        }
712
713        let fpr = false_positives as f64 / n_runs as f64;
714        // Under anytime-valid guarantee, FPR ≤ α.
715        // Allow tolerance for finite-sample effects.
716        assert!(
717            fpr <= alpha + 0.10,
718            "FPR {fpr} exceeds α + tolerance ({alpha} + 0.10)"
719        );
720    }
721
722    // ─── Synthetic leak injection ─────────────────────────────────
723
724    #[test]
725    fn e2e_synthetic_leak_injection() {
726        // Baseline at 50, then leak injects +10 starting at frame 100.
727        let config = BudgetConfig::calibrated(50.0, 4.0, 10.0, 0.05);
728        let mut monitor = AllocationBudget::new(config);
729
730        // Stable phase.
731        for _ in 0..100 {
732            let result = monitor.observe(50.0);
733            assert!(result.is_none(), "no alert during stable phase");
734        }
735
736        // Leak phase: x = 60.
737        let mut detected_at = None;
738        for i in 0..100 {
739            if let Some(_alert) = monitor.observe(60.0) {
740                detected_at = Some(i + 1);
741                break;
742            }
743        }
744        assert!(detected_at.is_some(), "should detect leak injection of +10");
745        let frames_to_detect = detected_at.unwrap();
746        assert!(
747            frames_to_detect <= 20,
748            "detection too slow: {frames_to_detect} frames for δ=10"
749        );
750    }
751
752    #[test]
753    fn e2e_stable_run_no_alerts() {
754        let config = BudgetConfig::calibrated(100.0, 16.0, 20.0, 0.05);
755        let mut monitor = AllocationBudget::new(config);
756
757        // Run 500 frames at exact baseline.
758        for _ in 0..500 {
759            let result = monitor.observe(100.0);
760            assert!(result.is_none());
761        }
762
763        assert_eq!(monitor.total_alerts(), 0);
764        // E should have decayed.
765        assert!(monitor.e_value() < 1.0);
766    }
767
768    // ─── Evidence ledger tests ────────────────────────────────────
769
770    #[test]
771    fn ledger_records_observations() {
772        let config = BudgetConfig {
773            mu_0: 10.0,
774            ..Default::default()
775        };
776        let mut monitor = AllocationBudget::new(config);
777
778        for i in 0..5 {
779            monitor.observe(10.0 + i as f64);
780        }
781
782        assert_eq!(monitor.ledger().len(), 5);
783        assert_eq!(monitor.ledger()[0].frame, 1);
784        assert_eq!(monitor.ledger()[4].frame, 5);
785        assert!((monitor.ledger()[0].x - 10.0).abs() < 1e-10);
786        assert!((monitor.ledger()[2].residual - 2.0).abs() < 1e-10);
787    }
788
789    #[test]
790    fn ledger_bounded_size() {
791        let mut monitor = AllocationBudget::new(BudgetConfig::default());
792        monitor.ledger_max = 10;
793
794        for i in 0..100 {
795            monitor.observe(i as f64);
796        }
797
798        assert!(monitor.ledger().len() <= 10);
799    }
800
801    // ─── Reset test ───────────────────────────────────────────────
802
803    #[test]
804    fn reset_clears_state() {
805        let config = BudgetConfig {
806            mu_0: 0.0,
807            ..Default::default()
808        };
809        let mut monitor = AllocationBudget::new(config);
810
811        for _ in 0..50 {
812            monitor.observe(5.0);
813        }
814        assert!(monitor.frames() > 0);
815
816        monitor.reset();
817        assert_eq!(monitor.frames(), 0);
818        assert_eq!(monitor.total_alerts(), 0);
819        assert!((monitor.e_value() - 1.0).abs() < 1e-10);
820        assert_eq!(monitor.cusum_plus(), 0.0);
821        assert_eq!(monitor.cusum_minus(), 0.0);
822        assert!(monitor.ledger().is_empty());
823    }
824
825    // ─── Summary test ─────────────────────────────────────────────
826
827    #[test]
828    fn summary_reports_drift() {
829        let config = BudgetConfig {
830            mu_0: 10.0,
831            cusum_h: 1000.0, // prevent alerts
832            alpha: 1e-20,    // prevent e-process alerts
833            ..Default::default()
834        };
835        let mut monitor = AllocationBudget::new(config);
836
837        for _ in 0..100 {
838            monitor.observe(15.0);
839        }
840
841        let summary = monitor.summary();
842        assert!((summary.running_mean - 15.0).abs() < 1e-10);
843        assert!((summary.drift - 5.0).abs() < 1e-10);
844        assert!((summary.mu_0 - 10.0).abs() < 1e-10);
845    }
846
847    // ─── Calibrated config test ───────────────────────────────────
848
849    #[test]
850    fn calibrated_config_reasonable() {
851        let config = BudgetConfig::calibrated(100.0, 25.0, 10.0, 0.05);
852        assert!((config.mu_0 - 100.0).abs() < 1e-10);
853        assert!((config.sigma_sq - 25.0).abs() < 1e-10);
854        assert!((config.cusum_k - 5.0).abs() < 1e-10);
855        assert!(config.lambda > 0.0 && config.lambda <= 0.5);
856        assert!((config.alpha - 0.05).abs() < 1e-10);
857    }
858
859    // ─── Determinism test ─────────────────────────────────────────
860
861    #[test]
862    fn deterministic_under_same_input() {
863        let run = || {
864            let config = BudgetConfig::calibrated(50.0, 4.0, 5.0, 0.05);
865            let mut monitor = AllocationBudget::new(config);
866            let inputs = [50.0, 51.0, 49.0, 55.0, 48.0, 60.0, 50.0, 52.0, 47.0, 53.0];
867            let mut e_values = Vec::new();
868            for x in inputs {
869                monitor.observe(x);
870                e_values.push(monitor.e_value());
871            }
872            (e_values, monitor.cusum_plus(), monitor.cusum_minus())
873        };
874
875        let (ev1, cp1, cm1) = run();
876        let (ev2, cp2, cm2) = run();
877        assert_eq!(ev1, ev2);
878        assert!((cp1 - cp2).abs() < 1e-15);
879        assert!((cm1 - cm2).abs() < 1e-15);
880    }
881
882    // ─── JSONL schema tests ───────────────────────────────────────
883
884    #[test]
885    fn config_jsonl_parses_and_has_fields() {
886        use serde_json::Value;
887
888        let config = BudgetConfig::default();
889        let context = test_context();
890        let jsonl = config.to_jsonl(&context, 0);
891        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
892
893        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
894        assert_eq!(value["run_id"], "budget-test");
895        assert!(
896            value["event_idx"].is_number(),
897            "event_idx should be numeric"
898        );
899        assert_eq!(value["screen_mode"], "inline");
900        assert!(value["cols"].is_number(), "cols should be numeric");
901        assert!(value["rows"].is_number(), "rows should be numeric");
902        assert_eq!(value["event"], "allocation_budget_config");
903        for key in [
904            "alpha",
905            "mu_0",
906            "sigma_sq",
907            "cusum_k",
908            "cusum_h",
909            "lambda",
910            "window_size",
911        ] {
912            assert!(value.get(key).is_some(), "missing config field {key}");
913        }
914    }
915
916    #[test]
917    fn evidence_jsonl_parses_and_has_fields() {
918        use serde_json::Value;
919
920        let evidence = BudgetEvidence {
921            frame: 3,
922            x: 12.0,
923            residual: 2.0,
924            cusum_plus: 1.5,
925            cusum_minus: 0.5,
926            e_value: 1.2,
927            alert: false,
928        };
929        let context = test_context();
930        let jsonl = evidence.to_jsonl(&context);
931        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
932
933        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
934        assert_eq!(value["run_id"], "budget-test");
935        assert!(
936            value["event_idx"].is_number(),
937            "event_idx should be numeric"
938        );
939        assert_eq!(value["screen_mode"], "inline");
940        assert!(value["cols"].is_number(), "cols should be numeric");
941        assert!(value["rows"].is_number(), "rows should be numeric");
942        assert_eq!(value["event"], "allocation_budget_evidence");
943        for key in [
944            "frame",
945            "x",
946            "residual",
947            "cusum_plus",
948            "cusum_minus",
949            "e_value",
950            "alert",
951        ] {
952            assert!(value.get(key).is_some(), "missing evidence field {key}");
953        }
954    }
955
956    #[test]
957    fn summary_jsonl_parses_and_has_fields() {
958        use serde_json::Value;
959
960        let summary = BudgetSummary {
961            frames: 5,
962            total_alerts: 1,
963            e_value: 2.0,
964            cusum_plus: 3.0,
965            cusum_minus: 1.0,
966            running_mean: 11.0,
967            mu_0: 10.0,
968            drift: 1.0,
969        };
970        let context = test_context();
971        let jsonl = summary.to_jsonl(&context, 5);
972        let value: Value = serde_json::from_str(&jsonl).expect("valid JSONL");
973
974        assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
975        assert_eq!(value["run_id"], "budget-test");
976        assert!(
977            value["event_idx"].is_number(),
978            "event_idx should be numeric"
979        );
980        assert_eq!(value["screen_mode"], "inline");
981        assert!(value["cols"].is_number(), "cols should be numeric");
982        assert!(value["rows"].is_number(), "rows should be numeric");
983        assert_eq!(value["event"], "allocation_budget_summary");
984        for key in [
985            "frames",
986            "total_alerts",
987            "e_value",
988            "cusum_plus",
989            "cusum_minus",
990            "running_mean",
991            "mu_0",
992            "drift",
993        ] {
994            assert!(value.get(key).is_some(), "missing summary field {key}");
995        }
996    }
997
998    #[test]
999    fn evidence_jsonl_is_deterministic_for_fixed_inputs() {
1000        let config = BudgetConfig::calibrated(50.0, 4.0, 5.0, 0.05);
1001        let inputs = [50.0, 51.0, 49.0, 55.0, 48.0, 60.0, 50.0, 52.0, 47.0, 53.0];
1002
1003        let run = || {
1004            let context = test_context();
1005            let mut monitor = AllocationBudget::new(config.clone()).with_evidence_context(
1006                "budget-test",
1007                "inline",
1008                80,
1009                24,
1010            );
1011            for x in inputs {
1012                monitor.observe(x);
1013            }
1014            monitor
1015                .ledger()
1016                .iter()
1017                .map(|entry| entry.to_jsonl(&context))
1018                .collect::<Vec<_>>()
1019        };
1020
1021        let first = run();
1022        let second = run();
1023        assert_eq!(first, second);
1024    }
1025
1026    // ── BudgetConfig defaults ───────────────────────────────────────
1027
1028    #[test]
1029    fn budget_config_default_values() {
1030        let config = BudgetConfig::default();
1031        assert!((config.alpha - 0.05).abs() < f64::EPSILON);
1032        assert!((config.mu_0 - 0.0).abs() < f64::EPSILON);
1033        assert!((config.sigma_sq - 1.0).abs() < f64::EPSILON);
1034        assert!((config.cusum_k - 0.5).abs() < f64::EPSILON);
1035        assert!((config.cusum_h - 5.0).abs() < f64::EPSILON);
1036        assert!((config.lambda - 0.1).abs() < f64::EPSILON);
1037        assert_eq!(config.window_size, 100);
1038    }
1039
1040    #[test]
1041    fn calibrated_clamps_tiny_sigma() {
1042        let config = BudgetConfig::calibrated(0.0, 0.0, 1.0, 0.05);
1043        assert!(config.sigma_sq >= SIGMA2_MIN);
1044    }
1045
1046    #[test]
1047    fn calibrated_lambda_bounded() {
1048        let config = BudgetConfig::calibrated(0.0, 0.001, 1000.0, 0.05);
1049        assert!(config.lambda <= 0.5);
1050    }
1051
1052    // ── json_escape ─────────────────────────────────────────────────
1053
1054    #[test]
1055    fn json_escape_special_chars() {
1056        assert_eq!(json_escape("hello"), "hello");
1057        assert_eq!(json_escape("say \"hi\""), "say \\\"hi\\\"");
1058        assert_eq!(json_escape("back\\slash"), "back\\\\slash");
1059        assert_eq!(json_escape("new\nline"), "new\\nline");
1060        assert_eq!(json_escape("tab\there"), "tab\\there");
1061        assert_eq!(json_escape("cr\rhere"), "cr\\rhere");
1062    }
1063
1064    #[test]
1065    fn json_escape_control_chars() {
1066        let s = "\x01\x02";
1067        let escaped = json_escape(s);
1068        assert!(escaped.contains("\\u0001"));
1069        assert!(escaped.contains("\\u0002"));
1070    }
1071
1072    // ── EvidenceContext ──────────────────────────────────────────────
1073
1074    #[test]
1075    fn evidence_context_prefix_format() {
1076        let ctx = EvidenceContext::new("run-42", "inline", 120, 30);
1077        let prefix = ctx.prefix(7);
1078        assert!(prefix.contains("\"run_id\":\"run-42\""));
1079        assert!(prefix.contains("\"event_idx\":7"));
1080        assert!(prefix.contains("\"screen_mode\":\"inline\""));
1081        assert!(prefix.contains("\"cols\":120"));
1082        assert!(prefix.contains("\"rows\":30"));
1083    }
1084
1085    // ── AllocationBudget constructors / accessors ────────────────────
1086
1087    #[test]
1088    fn new_monitor_initial_state() {
1089        let monitor = AllocationBudget::new(BudgetConfig::default());
1090        assert_eq!(monitor.frames(), 0);
1091        assert_eq!(monitor.total_alerts(), 0);
1092        assert!((monitor.e_value() - 1.0).abs() < f64::EPSILON);
1093        assert!((monitor.cusum_plus() - 0.0).abs() < f64::EPSILON);
1094        assert!((monitor.cusum_minus() - 0.0).abs() < f64::EPSILON);
1095        assert!(monitor.ledger().is_empty());
1096    }
1097
1098    #[test]
1099    fn running_mean_empty_returns_mu0() {
1100        let config = BudgetConfig {
1101            mu_0: 42.0,
1102            ..Default::default()
1103        };
1104        let monitor = AllocationBudget::new(config);
1105        assert!((monitor.running_mean() - 42.0).abs() < f64::EPSILON);
1106    }
1107
1108    #[test]
1109    fn running_mean_with_observations() {
1110        let mut monitor = AllocationBudget::new(BudgetConfig {
1111            mu_0: 0.0,
1112            cusum_h: 1000.0,
1113            alpha: 1e-20,
1114            ..Default::default()
1115        });
1116        monitor.observe(10.0);
1117        monitor.observe(20.0);
1118        monitor.observe(30.0);
1119        assert!((monitor.running_mean() - 20.0).abs() < 1e-10);
1120    }
1121
1122    #[test]
1123    fn window_size_enforced() {
1124        let config = BudgetConfig {
1125            window_size: 5,
1126            mu_0: 0.0,
1127            cusum_h: 1000.0,
1128            alpha: 1e-20,
1129            ..Default::default()
1130        };
1131        let mut monitor = AllocationBudget::new(config);
1132        for i in 0..20 {
1133            monitor.observe(i as f64);
1134        }
1135        let expected_mean = (15.0 + 16.0 + 17.0 + 18.0 + 19.0) / 5.0;
1136        assert!((monitor.running_mean() - expected_mean).abs() < 1e-10);
1137    }
1138
1139    // ── with_evidence_context / set_evidence_context ─────────────────
1140
1141    #[test]
1142    fn with_evidence_context_builder() {
1143        let monitor = AllocationBudget::new(BudgetConfig::default()).with_evidence_context(
1144            "my-run",
1145            "fullscreen",
1146            200,
1147            50,
1148        );
1149        let summary = monitor.summary();
1150        let ctx = EvidenceContext::new("my-run", "fullscreen", 200, 50);
1151        let jsonl = summary.to_jsonl(&ctx, 0);
1152        assert!(jsonl.contains("\"run_id\":\"my-run\""));
1153        assert!(jsonl.contains("\"screen_mode\":\"fullscreen\""));
1154    }
1155
1156    #[test]
1157    fn set_evidence_context_mutates() {
1158        let mut monitor = AllocationBudget::new(BudgetConfig::default());
1159        monitor.set_evidence_context("new-run", "alt", 160, 40);
1160        monitor.observe(1.0);
1161        assert_eq!(monitor.frames(), 1);
1162    }
1163
1164    // ── Alert resets state ──────────────────────────────────────────
1165
1166    #[test]
1167    fn alert_resets_cusum_and_evalue() {
1168        let config = BudgetConfig {
1169            alpha: 0.05,
1170            mu_0: 0.0,
1171            sigma_sq: 1.0,
1172            lambda: 0.5,
1173            cusum_k: 0.5,
1174            cusum_h: 100.0,
1175            ..Default::default()
1176        };
1177        let mut monitor = AllocationBudget::new(config);
1178        let mut alert_seen = false;
1179        for _ in 0..100 {
1180            if monitor.observe(10.0).is_some() {
1181                alert_seen = true;
1182                break;
1183            }
1184        }
1185        assert!(alert_seen, "should have triggered alert");
1186        assert!((monitor.e_value() - 1.0).abs() < f64::EPSILON);
1187        assert!((monitor.cusum_plus() - 0.0).abs() < f64::EPSILON);
1188        assert!((monitor.cusum_minus() - 0.0).abs() < f64::EPSILON);
1189    }
1190
1191    #[test]
1192    fn alert_increments_total_alerts() {
1193        let config = BudgetConfig {
1194            alpha: 0.05,
1195            mu_0: 0.0,
1196            sigma_sq: 1.0,
1197            lambda: 0.5,
1198            cusum_k: 0.5,
1199            cusum_h: 100.0,
1200            ..Default::default()
1201        };
1202        let mut monitor = AllocationBudget::new(config);
1203        assert_eq!(monitor.total_alerts(), 0);
1204        for _ in 0..100 {
1205            if monitor.observe(10.0).is_some() {
1206                break;
1207            }
1208        }
1209        assert!(monitor.total_alerts() >= 1);
1210    }
1211
1212    #[test]
1213    fn alert_contains_expected_fields() {
1214        let config = BudgetConfig {
1215            alpha: 0.05,
1216            mu_0: 0.0,
1217            sigma_sq: 1.0,
1218            lambda: 0.5,
1219            cusum_k: 0.5,
1220            cusum_h: 100.0,
1221            ..Default::default()
1222        };
1223        let mut monitor = AllocationBudget::new(config);
1224        let mut alert = None;
1225        for _ in 0..100 {
1226            if let Some(a) = monitor.observe(10.0) {
1227                alert = Some(a);
1228                break;
1229            }
1230        }
1231        let alert = alert.expect("should have triggered");
1232        assert!(alert.frame > 0);
1233        assert!(alert.e_process_triggered);
1234        assert!(alert.e_value >= 1.0 / 0.05);
1235        assert!(alert.estimated_shift > 0.0);
1236    }
1237
1238    // ── CUSUM downward shift ────────────────────────────────────────
1239
1240    #[test]
1241    fn cusum_minus_detects_decrease() {
1242        let config = BudgetConfig {
1243            mu_0: 100.0,
1244            sigma_sq: 4.0,
1245            cusum_k: 2.5,
1246            cusum_h: 5.0,
1247            lambda: 0.01,
1248            alpha: 1e-100,
1249            ..Default::default()
1250        };
1251        let mut monitor = AllocationBudget::new(config);
1252        for _ in 0..10 {
1253            monitor.observe(90.0);
1254        }
1255        assert!(
1256            monitor.cusum_minus() > 0.0,
1257            "CUSUM- should be positive for downward shift"
1258        );
1259    }
1260
1261    // ── Summary fields ──────────────────────────────────────────────
1262
1263    #[test]
1264    fn summary_initial_state() {
1265        let monitor = AllocationBudget::new(BudgetConfig {
1266            mu_0: 25.0,
1267            ..Default::default()
1268        });
1269        let summary = monitor.summary();
1270        assert_eq!(summary.frames, 0);
1271        assert_eq!(summary.total_alerts, 0);
1272        assert!((summary.e_value - 1.0).abs() < f64::EPSILON);
1273        assert!((summary.mu_0 - 25.0).abs() < f64::EPSILON);
1274        assert!((summary.drift - 0.0).abs() < f64::EPSILON);
1275    }
1276
1277    #[test]
1278    fn budget_summary_clone_debug() {
1279        let summary = BudgetSummary {
1280            frames: 10,
1281            total_alerts: 1,
1282            e_value: 2.5,
1283            cusum_plus: 3.0,
1284            cusum_minus: 1.0,
1285            running_mean: 55.0,
1286            mu_0: 50.0,
1287            drift: 5.0,
1288        };
1289        let cloned = summary.clone();
1290        assert_eq!(cloned.frames, 10);
1291        assert!((cloned.drift - 5.0).abs() < f64::EPSILON);
1292        let dbg = format!("{:?}", summary);
1293        assert!(dbg.contains("BudgetSummary"));
1294    }
1295
1296    #[test]
1297    fn budget_evidence_clone_debug() {
1298        let ev = BudgetEvidence {
1299            frame: 5,
1300            x: 12.0,
1301            residual: 2.0,
1302            cusum_plus: 1.5,
1303            cusum_minus: 0.3,
1304            e_value: 1.1,
1305            alert: false,
1306        };
1307        let cloned = ev.clone();
1308        assert_eq!(cloned.frame, 5);
1309        assert!(!cloned.alert);
1310        let dbg = format!("{:?}", ev);
1311        assert!(dbg.contains("BudgetEvidence"));
1312    }
1313
1314    #[test]
1315    fn budget_alert_clone_debug() {
1316        let alert = BudgetAlert {
1317            frame: 50,
1318            estimated_shift: 3.5,
1319            e_value: 25.0,
1320            cusum_plus: 8.0,
1321            e_process_triggered: true,
1322            cusum_triggered: true,
1323        };
1324        let cloned = alert.clone();
1325        assert_eq!(cloned.frame, 50);
1326        assert!(cloned.e_process_triggered);
1327        let dbg = format!("{:?}", alert);
1328        assert!(dbg.contains("BudgetAlert"));
1329    }
1330
1331    // ── Reset clears config_logged ──────────────────────────────────
1332
1333    #[test]
1334    fn reset_allows_config_re_logging() {
1335        let mut monitor = AllocationBudget::new(BudgetConfig::default());
1336        monitor.observe(1.0);
1337        monitor.reset();
1338        monitor.observe(2.0);
1339        assert_eq!(monitor.frames(), 1);
1340        assert_eq!(monitor.ledger().len(), 1);
1341    }
1342
1343    // ── frames counter ──────────────────────────────────────────────
1344
1345    #[test]
1346    fn frames_increments_per_observe() {
1347        let mut monitor = AllocationBudget::new(BudgetConfig {
1348            cusum_h: 1000.0,
1349            alpha: 1e-20,
1350            ..Default::default()
1351        });
1352        for _ in 0..7 {
1353            monitor.observe(0.0);
1354        }
1355        assert_eq!(monitor.frames(), 7);
1356    }
1357}