1use std::fmt::Write as _;
53
54use serde::{Deserialize, Serialize};
55
56use super::OracleReport;
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
66pub struct EValue {
67 pub value: f64,
69 pub time: usize,
71}
72
73impl EValue {
74 #[must_use]
76 pub fn rejects_at(&self, alpha: f64) -> bool {
77 debug_assert!(alpha > 0.0 && alpha <= 1.0);
78 self.value >= 1.0 / alpha
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EProcessConfig {
85 pub p0: f64,
87 pub lambda: f64,
91 pub alpha: f64,
93 pub max_evalue: f64,
95}
96
97impl Default for EProcessConfig {
98 fn default() -> Self {
99 Self {
100 p0: 0.001,
101 lambda: 0.5,
102 alpha: 0.05,
103 max_evalue: 1e15,
104 }
105 }
106}
107
108impl EProcessConfig {
109 pub fn validate(&self) -> Result<(), String> {
113 if !self.p0.is_finite() {
116 return Err(format!("p0 must be finite, got {}", self.p0));
117 }
118 if !self.lambda.is_finite() {
119 return Err(format!("lambda must be finite, got {}", self.lambda));
120 }
121 if !self.alpha.is_finite() {
122 return Err(format!("alpha must be finite, got {}", self.alpha));
123 }
124 if self.p0 <= 0.0 || self.p0 >= 1.0 {
125 return Err(format!("p0 must be in (0, 1), got {}", self.p0));
126 }
127 let lambda_min = -1.0 / (1.0 - self.p0);
131 let lambda_max = 1.0 / self.p0;
132 if self.lambda <= lambda_min || self.lambda >= lambda_max {
133 return Err(format!(
134 "lambda must be in ({:.4}, {:.4}), got {}",
135 lambda_min, lambda_max, self.lambda
136 ));
137 }
138 if self.alpha <= 0.0 || self.alpha > 1.0 {
139 return Err(format!("alpha must be in (0, 1], got {}", self.alpha));
140 }
141 if !self.max_evalue.is_finite() || self.max_evalue < 1.0 {
142 return Err(format!(
143 "max_evalue must be finite and >= 1.0, got {}",
144 self.max_evalue
145 ));
146 }
147 let threshold = 1.0 / self.alpha;
148 if self.max_evalue < threshold {
149 return Err(format!(
150 "max_evalue ({}) must be >= threshold 1/alpha ({:.1}), otherwise rejection is impossible",
151 self.max_evalue, threshold
152 ));
153 }
154 Ok(())
155 }
156
157 #[must_use]
159 pub fn threshold(&self) -> f64 {
160 1.0 / self.alpha
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct EProcess {
169 pub invariant: String,
171 pub config: EProcessConfig,
173 pub current: f64,
175 pub observations: usize,
177 pub violations_observed: usize,
179 pub rejected: bool,
181 pub rejection_time: Option<usize>,
183 pub history: Vec<EValue>,
185 record_history: bool,
187}
188
189impl EProcess {
190 #[must_use]
192 pub fn new(invariant: &str, config: EProcessConfig) -> Self {
193 assert!(
194 config.validate().is_ok(),
195 "EProcessConfig validation failed: {}",
196 config
197 .validate()
198 .expect_err("expected e-process config validation to fail")
199 );
200 Self {
201 invariant: invariant.to_owned(),
202 config,
203 current: 1.0,
204 observations: 0,
205 violations_observed: 0,
206 rejected: false,
207 rejection_time: None,
208 history: Vec::new(),
209 record_history: true,
210 }
211 }
212
213 #[must_use]
215 pub fn new_without_history(invariant: &str, config: EProcessConfig) -> Self {
216 let mut ep = Self::new(invariant, config);
217 ep.record_history = false;
218 ep
219 }
220
221 pub fn observe(&mut self, violated: bool) {
225 let x = if violated { 1.0 } else { 0.0 };
226 let factor = self.config.lambda.mul_add(x - self.config.p0, 1.0);
227
228 let factor = factor.max(1e-15);
230
231 let product = self.current * factor;
235 self.current = if product.is_finite() {
236 product.min(self.config.max_evalue)
237 } else {
238 self.config.max_evalue
239 };
240 self.observations += 1;
241 if violated {
242 self.violations_observed += 1;
243 }
244
245 if self.record_history {
246 self.history.push(EValue {
247 value: self.current,
248 time: self.observations - 1,
249 });
250 }
251
252 if !self.rejected && self.current >= self.config.threshold() {
253 self.rejected = true;
254 self.rejection_time = Some(self.observations - 1);
255 }
256 }
257
258 #[must_use]
260 pub fn e_value(&self) -> f64 {
261 self.current
262 }
263
264 #[must_use]
266 pub fn log10_e_value(&self) -> f64 {
267 self.current.max(1e-300).log10()
268 }
269
270 #[must_use]
272 #[allow(clippy::cast_precision_loss)]
273 pub fn empirical_rate(&self) -> f64 {
274 if self.observations == 0 {
275 0.0
276 } else {
277 self.violations_observed as f64 / self.observations as f64
278 }
279 }
280
281 pub fn reset(&mut self) {
283 self.current = 1.0;
284 self.observations = 0;
285 self.violations_observed = 0;
286 self.rejected = false;
287 self.rejection_time = None;
288 self.history.clear();
289 }
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct MonitorResult {
299 pub invariant: String,
301 pub e_value: f64,
303 pub log10_e_value: f64,
305 pub rejected: bool,
307 pub rejection_time: Option<usize>,
309 pub observations: usize,
311 pub violations_observed: usize,
313 pub empirical_rate: f64,
315}
316
317impl MonitorResult {
318 fn from_eprocess(ep: &EProcess) -> Self {
319 Self {
320 invariant: ep.invariant.clone(),
321 e_value: ep.current,
322 log10_e_value: ep.log10_e_value(),
323 rejected: ep.rejected,
324 rejection_time: ep.rejection_time,
325 observations: ep.observations,
326 violations_observed: ep.violations_observed,
327 empirical_rate: ep.empirical_rate(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct EProcessMonitor {
338 processes: Vec<EProcess>,
340 config: EProcessConfig,
342}
343
344impl EProcessMonitor {
345 #[must_use]
347 pub fn standard() -> Self {
348 Self::standard_with_config(EProcessConfig::default())
349 }
350
351 #[must_use]
353 pub fn standard_with_config(config: EProcessConfig) -> Self {
354 let invariants = ["task_leak", "obligation_leak", "quiescence"];
355 Self::new(&invariants, config)
356 }
357
358 #[must_use]
360 pub fn new(invariants: &[&str], config: EProcessConfig) -> Self {
361 let processes = invariants
362 .iter()
363 .map(|inv| EProcess::new(inv, config.clone()))
364 .collect();
365 Self { processes, config }
366 }
367
368 #[must_use]
370 pub fn all_invariants() -> Self {
371 Self::all_invariants_with_config(EProcessConfig::default())
372 }
373
374 #[must_use]
376 pub fn all_invariants_with_config(config: EProcessConfig) -> Self {
377 let invariants = [
378 "task_leak",
379 "obligation_leak",
380 "quiescence",
381 "loser_drain",
382 "finalizer",
383 "region_tree",
384 "region_leak",
385 "ambient_authority",
386 "deadline_monotone",
387 "cancellation_protocol",
388 "cancel_correctness",
389 "cancel_debt",
390 "cancel_signal_ordering",
391 "runtime_epoch",
392 "channel_atomicity",
393 "waker_dedup",
394 "actor_leak",
395 "supervision",
396 "mailbox",
397 "rref_access",
398 "reply_linearity",
399 "registry_lease",
400 "down_order",
401 "supervisor_quiescence",
402 ];
403 Self::new(&invariants, config)
404 }
405
406 pub fn observe_report(&mut self, report: &OracleReport) {
411 for ep in &mut self.processes {
412 if let Some(entry) = report.entries.iter().find(|e| e.invariant == ep.invariant) {
416 ep.observe(!entry.passed);
417 }
418 }
419 }
420
421 pub fn observe(&mut self, invariant: &str, violated: bool) -> bool {
425 self.processes
426 .iter_mut()
427 .find(|ep| ep.invariant == invariant)
428 .is_some_and(|ep| {
429 ep.observe(violated);
430 true
431 })
432 }
433
434 #[must_use]
436 pub fn any_rejected(&self) -> bool {
437 self.processes.iter().any(|ep| ep.rejected)
438 }
439
440 #[must_use]
442 pub fn rejected_invariants(&self) -> Vec<&str> {
443 self.processes
444 .iter()
445 .filter(|ep| ep.rejected)
446 .map(|ep| ep.invariant.as_str())
447 .collect()
448 }
449
450 #[must_use]
452 pub fn process(&self, invariant: &str) -> Option<&EProcess> {
453 self.processes.iter().find(|ep| ep.invariant == invariant)
454 }
455
456 #[must_use]
458 pub fn results(&self) -> Vec<MonitorResult> {
459 self.processes
460 .iter()
461 .map(MonitorResult::from_eprocess)
462 .collect()
463 }
464
465 #[must_use]
467 pub fn config(&self) -> &EProcessConfig {
468 &self.config
469 }
470
471 pub fn reset(&mut self) {
473 for ep in &mut self.processes {
474 ep.reset();
475 }
476 }
477
478 #[must_use]
480 pub fn to_text(&self) -> String {
481 let mut out = String::new();
482 let _ = writeln!(&mut out, "E-Process Monitor (α = {})", self.config.alpha);
483 let _ = writeln!(
484 &mut out,
485 " Rejection threshold: {:.1}",
486 self.config.threshold()
487 );
488 let _ = writeln!(&mut out, " Invariants: {}", self.processes.len());
489 let rejected = self.rejected_invariants();
490 let _ = writeln!(&mut out, " Rejected: {}", rejected.len());
491 let _ = writeln!(&mut out);
492
493 for ep in &self.processes {
494 let status = if ep.rejected {
495 "REJECTED"
496 } else {
497 "monitoring"
498 };
499 let _ = writeln!(
500 &mut out,
501 " [{status}] {inv}: e={e:.4}, log₁₀(e)={log:.4}, n={n}, violations={v}",
502 inv = ep.invariant,
503 e = ep.current,
504 log = ep.log10_e_value(),
505 n = ep.observations,
506 v = ep.violations_observed,
507 );
508 if let Some(t) = ep.rejection_time {
509 let _ = writeln!(&mut out, " → rejected at observation {t}");
510 }
511 }
512
513 out
514 }
515
516 #[must_use]
518 pub fn to_json(&self) -> serde_json::Value {
519 serde_json::to_value(self).unwrap_or_default()
520 }
521}
522
523#[cfg(test)]
528mod tests {
529 #![allow(
530 clippy::pedantic,
531 clippy::nursery,
532 clippy::expect_fun_call,
533 clippy::map_unwrap_or,
534 clippy::cast_possible_wrap,
535 clippy::future_not_send
536 )]
537 use super::*;
538 use crate::lab::oracle::{OracleEntryReport, OracleReport, OracleStats};
539
540 fn make_clean_report(invariants: &[&str]) -> OracleReport {
541 let entries = invariants
542 .iter()
543 .map(|inv| OracleEntryReport {
544 invariant: inv.to_string(),
545 passed: true,
546 violation: None,
547 stats: OracleStats {
548 entities_tracked: 5,
549 events_recorded: 10,
550 },
551 })
552 .collect::<Vec<_>>();
553 let total = entries.len();
554 OracleReport {
555 entries,
556 total,
557 passed: total,
558 failed: 0,
559 check_time_nanos: 0,
560 }
561 }
562
563 fn make_violation_report(invariants: &[&str], violated: &[&str]) -> OracleReport {
564 let entries = invariants
565 .iter()
566 .map(|inv| {
567 let is_violated = violated.contains(inv);
568 OracleEntryReport {
569 invariant: inv.to_string(),
570 passed: !is_violated,
571 violation: if is_violated {
572 Some("test violation".into())
573 } else {
574 None
575 },
576 stats: OracleStats {
577 entities_tracked: 5,
578 events_recorded: 10,
579 },
580 }
581 })
582 .collect::<Vec<_>>();
583 let total = entries.len();
584 let failed = entries.iter().filter(|e| !e.passed).count();
585 OracleReport {
586 entries,
587 total,
588 passed: total - failed,
589 failed,
590 check_time_nanos: 0,
591 }
592 }
593
594 #[test]
597 fn config_default_valid() {
598 assert!(EProcessConfig::default().validate().is_ok());
599 }
600
601 #[test]
602 fn config_threshold() {
603 let config = EProcessConfig::default();
604 assert!((config.threshold() - 20.0).abs() < 1e-10);
605 }
606
607 #[test]
608 fn config_invalid_p0() {
609 let c = EProcessConfig {
610 p0: 0.0,
611 ..EProcessConfig::default()
612 };
613 assert!(c.validate().is_err());
614 let c = EProcessConfig {
615 p0: 1.0,
616 ..EProcessConfig::default()
617 };
618 assert!(c.validate().is_err());
619 let c = EProcessConfig {
620 p0: -0.1,
621 ..EProcessConfig::default()
622 };
623 assert!(c.validate().is_err());
624 }
625
626 #[test]
627 fn config_invalid_lambda() {
628 let c = EProcessConfig {
629 lambda: -2000.0,
630 ..EProcessConfig::default()
631 };
632 assert!(c.validate().is_err());
633 let c = EProcessConfig {
634 lambda: 2000.0,
635 ..EProcessConfig::default()
636 };
637 assert!(c.validate().is_err());
638 }
639
640 #[test]
641 fn config_invalid_alpha() {
642 let c = EProcessConfig {
643 alpha: 0.0,
644 ..EProcessConfig::default()
645 };
646 assert!(c.validate().is_err());
647 }
648
649 #[test]
650 fn config_invalid_max_evalue() {
651 let c = EProcessConfig {
653 max_evalue: 0.0,
654 ..EProcessConfig::default()
655 };
656 assert!(c.validate().is_err());
657
658 let c = EProcessConfig {
659 max_evalue: -1.0,
660 ..EProcessConfig::default()
661 };
662 assert!(c.validate().is_err());
663
664 let c = EProcessConfig {
665 max_evalue: f64::NAN,
666 ..EProcessConfig::default()
667 };
668 assert!(c.validate().is_err());
669
670 let c = EProcessConfig {
671 max_evalue: f64::INFINITY,
672 ..EProcessConfig::default()
673 };
674 assert!(c.validate().is_err());
675 }
676
677 #[test]
678 fn config_max_evalue_below_threshold() {
679 let c = EProcessConfig {
681 alpha: 0.05,
682 max_evalue: 10.0, ..EProcessConfig::default()
684 };
685 assert!(c.validate().is_err());
686 }
687
688 #[test]
689 fn config_lambda_bounds_correct_for_large_p0() {
690 let c = EProcessConfig {
693 p0: 0.6,
694 lambda: 1.5,
695 alpha: 0.05,
696 max_evalue: 1e15,
697 };
698 assert!(c.validate().is_ok());
699
700 let c = EProcessConfig {
702 p0: 0.6,
703 lambda: 1.7,
704 alpha: 0.05,
705 max_evalue: 1e15,
706 };
707 assert!(
708 c.validate().is_err(),
709 "lambda=1.7 with p0=0.6 should be rejected (negative factor)"
710 );
711 }
712
713 #[test]
716 fn evalue_rejects() {
717 let ev = EValue {
718 value: 25.0,
719 time: 0,
720 };
721 assert!(ev.rejects_at(0.05)); assert!(!ev.rejects_at(0.01)); }
724
725 #[test]
728 fn eprocess_starts_at_one() {
729 let ep = EProcess::new("test", EProcessConfig::default());
730 assert!((ep.current - 1.0).abs() < 1e-10);
731 assert_eq!(ep.observations, 0);
732 assert!(!ep.rejected);
733 }
734
735 #[test]
736 fn eprocess_clean_observations_decrease() {
737 let mut ep = EProcess::new("test", EProcessConfig::default());
738 for _ in 0..10 {
740 ep.observe(false);
741 }
742 assert!(
743 ep.current < 1.0,
744 "clean observations should decrease e-value, got {}",
745 ep.current
746 );
747 assert_eq!(ep.observations, 10);
748 assert_eq!(ep.violations_observed, 0);
749 assert!(!ep.rejected);
750 }
751
752 #[test]
753 fn eprocess_violations_increase() {
754 let mut ep = EProcess::new("test", EProcessConfig::default());
755 ep.observe(true);
756 assert!(
757 ep.current > 1.0,
758 "violation should increase e-value, got {}",
759 ep.current
760 );
761 assert_eq!(ep.violations_observed, 1);
762 }
763
764 #[test]
765 fn eprocess_many_violations_reject() {
766 let mut ep = EProcess::new("test", EProcessConfig::default());
767 for _ in 0..20 {
769 ep.observe(true);
770 }
771 assert!(ep.rejected, "repeated violations should cause rejection");
772 assert!(ep.rejection_time.is_some());
773 }
774
775 #[test]
776 fn eprocess_rejection_is_sticky() {
777 let mut ep = EProcess::new("test", EProcessConfig::default());
778 for _ in 0..20 {
779 ep.observe(true);
780 }
781 let rejection_time = ep.rejection_time;
782 assert!(ep.rejected);
783
784 for _ in 0..100 {
786 ep.observe(false);
787 }
788 assert!(ep.rejected, "rejection should be sticky");
789 assert_eq!(
790 ep.rejection_time, rejection_time,
791 "rejection time should not change"
792 );
793 }
794
795 #[test]
796 fn eprocess_history_recorded() {
797 let mut ep = EProcess::new("test", EProcessConfig::default());
798 ep.observe(false);
799 ep.observe(true);
800 ep.observe(false);
801 assert_eq!(ep.history.len(), 3);
802 assert_eq!(ep.history[0].time, 0);
803 assert_eq!(ep.history[1].time, 1);
804 assert_eq!(ep.history[2].time, 2);
805 }
806
807 #[test]
808 fn eprocess_no_history() {
809 let mut ep = EProcess::new_without_history("test", EProcessConfig::default());
810 ep.observe(false);
811 ep.observe(true);
812 assert!(ep.history.is_empty());
813 }
814
815 #[test]
816 fn eprocess_reset() {
817 let mut ep = EProcess::new("test", EProcessConfig::default());
818 ep.observe(true);
819 ep.observe(true);
820 assert!(ep.current > 1.0);
821 ep.reset();
822 assert!((ep.current - 1.0).abs() < 1e-10);
823 assert_eq!(ep.observations, 0);
824 assert_eq!(ep.violations_observed, 0);
825 assert!(!ep.rejected);
826 assert!(ep.history.is_empty());
827 }
828
829 #[test]
830 fn eprocess_empirical_rate() {
831 let mut ep = EProcess::new("test", EProcessConfig::default());
832 ep.observe(false);
833 ep.observe(true);
834 ep.observe(false);
835 ep.observe(true);
836 assert!((ep.empirical_rate() - 0.5).abs() < 1e-10);
837 }
838
839 #[test]
840 fn eprocess_empirical_rate_zero_observations() {
841 let ep = EProcess::new("test", EProcessConfig::default());
842 assert!((ep.empirical_rate()).abs() < 1e-10);
843 }
844
845 #[test]
846 fn eprocess_log10_evalue() {
847 let mut ep = EProcess::new("test", EProcessConfig::default());
848 ep.current = 100.0;
849 assert!((ep.log10_e_value() - 2.0).abs() < 1e-10);
850 }
851
852 #[test]
853 fn eprocess_evalue_capped() {
854 let mut ep = EProcess::new("test", EProcessConfig::default());
855 for _ in 0..1000 {
857 ep.observe(true);
858 }
859 assert!(
860 ep.current <= ep.config.max_evalue,
861 "e-value should be capped"
862 );
863 assert!(ep.current.is_finite());
864 }
865
866 #[test]
869 fn monitor_standard_has_three_invariants() {
870 let monitor = EProcessMonitor::standard();
871 assert_eq!(monitor.processes.len(), 3);
872 assert!(monitor.process("task_leak").is_some());
873 assert!(monitor.process("obligation_leak").is_some());
874 assert!(monitor.process("quiescence").is_some());
875 }
876
877 #[test]
878 fn monitor_all_invariants_has_spork_invariants_too() {
879 let monitor = EProcessMonitor::all_invariants();
880 assert_eq!(monitor.processes.len(), 24);
881 assert!(monitor.process("reply_linearity").is_some());
882 assert!(monitor.process("registry_lease").is_some());
883 assert!(monitor.process("down_order").is_some());
884 assert!(monitor.process("supervisor_quiescence").is_some());
885 }
886
887 #[test]
888 fn monitor_observe_report_clean() {
889 let mut monitor = EProcessMonitor::standard();
890 let report = make_clean_report(&["task_leak", "obligation_leak", "quiescence"]);
891
892 for _ in 0..10 {
893 monitor.observe_report(&report);
894 }
895
896 assert!(!monitor.any_rejected());
897 assert!(monitor.rejected_invariants().is_empty());
898
899 for ep in &monitor.processes {
901 assert!(
902 ep.current < 1.0,
903 "clean reports should decrease e-value for '{}'",
904 ep.invariant
905 );
906 }
907 }
908
909 #[test]
910 fn monitor_observe_report_violation() {
911 let mut monitor = EProcessMonitor::standard();
912 let invariants = ["task_leak", "obligation_leak", "quiescence"];
913
914 for _ in 0..20 {
916 let report = make_violation_report(&invariants, &["task_leak"]);
917 monitor.observe_report(&report);
918 }
919
920 assert!(monitor.any_rejected());
921 let rejected = monitor.rejected_invariants();
922 assert!(rejected.contains(&"task_leak"));
923 assert!(!rejected.contains(&"obligation_leak"));
924 assert!(!rejected.contains(&"quiescence"));
925 }
926
927 #[test]
928 fn monitor_observe_raw() {
929 let mut monitor = EProcessMonitor::standard();
930
931 assert!(monitor.observe("task_leak", true));
932 assert!(monitor.observe("task_leak", false));
933 assert!(!monitor.observe("nonexistent", true));
934
935 let ep = monitor.process("task_leak").unwrap();
936 assert_eq!(ep.observations, 2);
937 assert_eq!(ep.violations_observed, 1);
938 }
939
940 #[test]
941 fn monitor_results() {
942 let mut monitor = EProcessMonitor::standard();
943 let report = make_clean_report(&["task_leak", "obligation_leak", "quiescence"]);
944 monitor.observe_report(&report);
945
946 let results = monitor.results();
947 assert_eq!(results.len(), 3);
948 for r in &results {
949 assert_eq!(r.observations, 1);
950 assert!(!r.rejected);
951 }
952 }
953
954 #[test]
955 fn monitor_reset() {
956 let mut monitor = EProcessMonitor::standard();
957 monitor.observe("task_leak", true);
958 monitor.observe("task_leak", true);
959 monitor.reset();
960
961 for ep in &monitor.processes {
962 assert!((ep.current - 1.0).abs() < 1e-10);
963 assert_eq!(ep.observations, 0);
964 }
965 }
966
967 #[test]
968 fn monitor_to_text() {
969 let mut monitor = EProcessMonitor::standard();
970 let report = make_clean_report(&["task_leak", "obligation_leak", "quiescence"]);
971 monitor.observe_report(&report);
972
973 let text = monitor.to_text();
974 assert!(text.contains("E-Process Monitor"));
975 assert!(text.contains("task_leak"));
976 assert!(text.contains("monitoring"));
977 }
978
979 #[test]
980 fn monitor_to_json() {
981 let monitor = EProcessMonitor::standard();
982 let json = monitor.to_json();
983 assert!(json["processes"].is_array());
984 assert!(json["config"].is_object());
985 }
986
987 #[test]
988 fn monitor_json_roundtrip() {
989 let mut monitor = EProcessMonitor::standard();
990 let report = make_clean_report(&["task_leak", "obligation_leak", "quiescence"]);
991 monitor.observe_report(&report);
992 monitor.observe_report(&report);
993
994 let json_str = serde_json::to_string(&monitor).unwrap();
995 let deserialized: EProcessMonitor = serde_json::from_str(&json_str).unwrap();
996
997 assert_eq!(deserialized.processes.len(), monitor.processes.len());
998 for (orig, deser) in monitor.processes.iter().zip(deserialized.processes.iter()) {
999 assert_eq!(orig.invariant, deser.invariant);
1000 assert!((orig.current - deser.current).abs() < 1e-10);
1001 assert_eq!(orig.observations, deser.observations);
1002 }
1003 }
1004
1005 #[test]
1008 fn eprocess_martingale_under_null() {
1009 let n_trials: u32 = 1000;
1012 let n_obs: u32 = 50;
1013 let config = EProcessConfig::default();
1014 let p0 = config.p0;
1015
1016 let mut sum_final_e = 0.0;
1017 let mut rng_state: u64 = 42;
1018
1019 for _ in 0..n_trials {
1020 let mut ep = EProcess::new_without_history("test", config.clone());
1021 for _ in 0..n_obs {
1022 rng_state = rng_state
1024 .wrapping_mul(6_364_136_223_846_793_005)
1025 .wrapping_add(1);
1026 let u = f64::from((rng_state >> 33) as u32) / f64::from(1_u32 << 31);
1027 let violated = u < p0;
1028 ep.observe(violated);
1029 }
1030 sum_final_e += ep.current;
1031 }
1032
1033 let avg_e = sum_final_e / f64::from(n_trials);
1034 assert!(
1036 avg_e < 2.0,
1037 "average e-value under H0 should be ≤ 1 (got {avg_e:.4})"
1038 );
1039 }
1040
1041 #[test]
1042 fn eprocess_detects_elevated_rate() {
1043 let config = EProcessConfig::default();
1045 let mut ep = EProcess::new("test", config);
1046
1047 for i in 0..100 {
1049 let violated = i % 10 == 0;
1050 ep.observe(violated);
1051 }
1052
1053 assert!(
1054 ep.rejected,
1055 "elevated violation rate (10%) should be detected within 100 observations"
1056 );
1057 }
1058
1059 #[test]
1062 fn early_stopping_valid() {
1063 let n_trials: u32 = 10_000;
1066 let n_obs: u32 = 100;
1067 let config = EProcessConfig {
1068 alpha: 0.05,
1069 ..EProcessConfig::default()
1070 };
1071 let p0 = config.p0;
1072
1073 let mut false_rejections: u32 = 0;
1074 let mut rng_state: u64 = 999;
1075
1076 for _ in 0..n_trials {
1077 let mut ep = EProcess::new_without_history("test", config.clone());
1078 for _ in 0..n_obs {
1079 rng_state = rng_state
1080 .wrapping_mul(6_364_136_223_846_793_005)
1081 .wrapping_add(1);
1082 let u = f64::from((rng_state >> 33) as u32) / f64::from(1_u32 << 31);
1083 let violated = u < p0;
1084 ep.observe(violated);
1085 }
1086 if ep.rejected {
1087 false_rejections += 1;
1088 }
1089 }
1090
1091 let fpr = f64::from(false_rejections) / f64::from(n_trials);
1092 assert!(
1094 fpr < 0.10,
1095 "false positive rate under optional stopping should be ≤ α, got {fpr:.4}"
1096 );
1097 }
1098
1099 #[test]
1102 fn validate_rejects_nan_p0() {
1103 let config = EProcessConfig {
1104 p0: f64::NAN,
1105 ..EProcessConfig::default()
1106 };
1107 assert!(config.validate().is_err());
1108 }
1109
1110 #[test]
1111 fn validate_rejects_nan_lambda() {
1112 let config = EProcessConfig {
1113 lambda: f64::NAN,
1114 ..EProcessConfig::default()
1115 };
1116 assert!(config.validate().is_err());
1117 }
1118
1119 #[test]
1120 fn validate_rejects_nan_alpha() {
1121 let config = EProcessConfig {
1122 alpha: f64::NAN,
1123 ..EProcessConfig::default()
1124 };
1125 assert!(config.validate().is_err());
1126 }
1127
1128 #[test]
1129 fn validate_rejects_inf_p0() {
1130 let config = EProcessConfig {
1131 p0: f64::INFINITY,
1132 ..EProcessConfig::default()
1133 };
1134 assert!(config.validate().is_err());
1135 }
1136
1137 #[test]
1138 fn validate_rejects_neg_inf_lambda() {
1139 let config = EProcessConfig {
1140 lambda: f64::NEG_INFINITY,
1141 ..EProcessConfig::default()
1142 };
1143 assert!(config.validate().is_err());
1144 }
1145
1146 #[test]
1147 #[should_panic(expected = "EProcessConfig validation failed")]
1148 fn constructor_panics_on_nan_config() {
1149 let config = EProcessConfig {
1150 p0: f64::NAN,
1151 ..EProcessConfig::default()
1152 };
1153 let _ep = EProcess::new("test", config);
1154 }
1155
1156 #[test]
1159 fn monitor_with_oracle_suite() {
1160 let mut suite = crate::lab::oracle::OracleSuite::new();
1161 let report = suite.report(crate::types::Time::ZERO);
1162
1163 let mut monitor = EProcessMonitor::all_invariants();
1164 for _ in 0..10 {
1165 monitor.observe_report(&report);
1166 }
1167
1168 assert!(
1169 !monitor.any_rejected(),
1170 "clean suite should not trigger rejection"
1171 );
1172 for r in monitor.results() {
1173 assert!(!r.rejected);
1174 assert!(
1175 r.e_value < 1.0,
1176 "clean: e-value should be < 1 for {}",
1177 r.invariant
1178 );
1179 }
1180 }
1181}