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 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 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 #[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 self.frame += 1;
357
358 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 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 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 let alert = e_process_triggered;
390
391 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 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 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 pub fn e_value(&self) -> f64 {
448 self.e_value
449 }
450
451 pub fn cusum_plus(&self) -> f64 {
453 self.cusum_plus.s
454 }
455
456 pub fn cusum_minus(&self) -> f64 {
458 self.cusum_minus.s
459 }
460
461 pub fn frames(&self) -> u64 {
463 self.frame
464 }
465
466 pub fn total_alerts(&self) -> u64 {
468 self.total_alerts
469 }
470
471 pub fn ledger(&self) -> &VecDeque<BudgetEvidence> {
473 &self.ledger
474 }
475
476 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 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#[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 #[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 #[test]
548 fn unit_cusum_detects_shift() {
549 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 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 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 #[test]
584 fn unit_eprocess_threshold() {
585 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, ..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 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 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 for _ in 0..1000 {
633 monitor.observe(50.0);
634 }
635 assert_eq!(
637 monitor.total_alerts(),
638 0,
639 "no alerts under H₀ with constant input"
640 );
641 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 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 #[test]
671 fn property_fpr_control() {
672 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 let mut seed: u64 = 0xDEAD_BEEF_1234_5678;
693 let mut had_alert = false;
694
695 for _ in 0..frames_per_run {
696 seed = seed
698 .wrapping_mul(6364136223846793005)
699 .wrapping_add(1442695040888963407);
700 let u = (seed >> 33) as f64 / (1u64 << 31) as f64; let noise = (u - 0.5) * 10.0; 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 assert!(
717 fpr <= alpha + 0.10,
718 "FPR {fpr} exceeds α + tolerance ({alpha} + 0.10)"
719 );
720 }
721
722 #[test]
725 fn e2e_synthetic_leak_injection() {
726 let config = BudgetConfig::calibrated(50.0, 4.0, 10.0, 0.05);
728 let mut monitor = AllocationBudget::new(config);
729
730 for _ in 0..100 {
732 let result = monitor.observe(50.0);
733 assert!(result.is_none(), "no alert during stable phase");
734 }
735
736 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 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 assert!(monitor.e_value() < 1.0);
766 }
767
768 #[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 #[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 #[test]
828 fn summary_reports_drift() {
829 let config = BudgetConfig {
830 mu_0: 10.0,
831 cusum_h: 1000.0, alpha: 1e-20, ..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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}