1#![forbid(unsafe_code)]
2
3use std::collections::VecDeque;
61
62use crate::evidence_sink::{EVIDENCE_SCHEMA_VERSION, EvidenceSink};
63const E_MIN: f64 = 1e-15;
65const E_MAX: f64 = 1e15;
67const 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#[derive(Debug, Clone)]
133pub struct BudgetConfig {
134 pub alpha: f64,
136
137 pub mu_0: f64,
140
141 pub sigma_sq: f64,
143
144 pub cusum_k: f64,
146
147 pub cusum_h: f64,
149
150 pub lambda: f64,
152
153 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 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); 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 #[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#[derive(Debug, Clone, Default)]
207struct CusumState {
208 s: f64,
210 alarm_count: u64,
212}
213
214#[derive(Debug, Clone)]
216pub struct BudgetEvidence {
217 pub frame: u64,
219 pub x: f64,
221 pub residual: f64,
223 pub cusum_plus: f64,
225 pub cusum_minus: f64,
227 pub e_value: f64,
229 pub alert: bool,
231}
232
233impl BudgetEvidence {
234 #[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#[derive(Debug, Clone)]
253pub struct BudgetAlert {
254 pub frame: u64,
256 pub estimated_shift: f64,
258 pub e_value: f64,
260 pub cusum_plus: f64,
262 pub e_process_triggered: bool,
264 pub cusum_triggered: bool,
266}
267
268#[derive(Debug, Clone)]
270pub struct AllocationBudget {
271 config: BudgetConfig,
272 log_e_value: f64,
274 cusum_plus: CusumState,
276 cusum_minus: CusumState,
278 frame: u64,
280 window: VecDeque<f64>,
282 total_alerts: u64,
284 ledger: VecDeque<BudgetEvidence>,
286 ledger_max: usize,
288 evidence_sink: Option<EvidenceSink>,
290 config_logged: bool,
292 evidence_context: EvidenceContext,
294}
295
296impl AllocationBudget {
297 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 #[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 #[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 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 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
349 self.evidence_sink = sink;
350 self.config_logged = false;
351 }
352
353 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 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 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 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 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 let alert = e_process_triggered || (cusum_triggered && self.log_e_value > 0.0);
411
412 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 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 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 pub fn e_value(&self) -> f64 {
471 self.log_e_value.exp()
472 }
473
474 pub fn cusum_plus(&self) -> f64 {
476 self.cusum_plus.s
477 }
478
479 pub fn cusum_minus(&self) -> f64 {
481 self.cusum_minus.s
482 }
483
484 pub fn frames(&self) -> u64 {
486 self.frame
487 }
488
489 pub fn total_alerts(&self) -> u64 {
491 self.total_alerts
492 }
493
494 pub fn ledger(&self) -> &VecDeque<BudgetEvidence> {
496 &self.ledger
497 }
498
499 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 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#[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 #[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 #[test]
570 fn unit_cusum_detects_shift() {
571 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 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 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 #[test]
606 fn unit_eprocess_threshold() {
607 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, ..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 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 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 for _ in 0..1000 {
655 monitor.observe(50.0);
656 }
657 assert_eq!(
659 monitor.total_alerts(),
660 0,
661 "no alerts under H₀ with constant input"
662 );
663 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 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 #[test]
693 fn property_fpr_control() {
694 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 let mut seed: u64 = 0xDEAD_BEEF_1234_5678;
715 let mut had_alert = false;
716
717 for _ in 0..frames_per_run {
718 seed = seed
720 .wrapping_mul(6364136223846793005)
721 .wrapping_add(1442695040888963407);
722 let u = (seed >> 33) as f64 / (1u64 << 31) as f64; let noise = (u - 0.5) * 10.0; 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 assert!(
739 fpr <= alpha + 0.10,
740 "FPR {fpr} exceeds α + tolerance ({alpha} + 0.10)"
741 );
742 }
743
744 #[test]
747 fn e2e_synthetic_leak_injection() {
748 let config = BudgetConfig::calibrated(50.0, 4.0, 10.0, 0.05);
750 let mut monitor = AllocationBudget::new(config);
751
752 for _ in 0..100 {
754 let result = monitor.observe(50.0);
755 assert!(result.is_none(), "no alert during stable phase");
756 }
757
758 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 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 assert!(monitor.e_value() < 1.0);
788 }
789
790 #[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 #[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 #[test]
850 fn summary_reports_drift() {
851 let config = BudgetConfig {
852 mu_0: 10.0,
853 cusum_h: 1000.0, alpha: 1e-20, ..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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}