Skip to main content

asupersync/lab/oracle/
eprocess.rs

1//! Anytime-valid invariant monitoring via e-processes.
2//!
3//! # Theory
4//!
5//! An **e-process** `(E_t)` is a non-negative process adapted to a filtration
6//! with `E_0 = 1` and `E[E_t | F_{t-1}] ≤ E_{t-1}` (supermartingale under H₀).
7//!
8//! **Key property (Ville's inequality):** For any stopping time τ and
9//! significance level α,
10//!
11//! ```text
12//!     P_H₀(∃ t : E_t ≥ 1/α) ≤ α
13//! ```
14//!
15//! This means you can **peek at any time** and reject H₀ if `E_t ≥ 1/α`
16//! without inflating the type-I error. No correction for multiple testing
17//! over time is needed.
18//!
19//! # Invariant Monitoring
20//!
21//! We monitor three oracle invariants:
22//!
23//! | Invariant         | H₀ (holds)             | Betting strategy               |
24//! |-------------------|------------------------|---------------------------------|
25//! | **Task leak**     | All tasks complete     | Bet against completion rate     |
26//! | **Obligation leak** | All obligations resolved | Bet against resolution rate   |
27//! | **Quiescence**    | Regions close cleanly  | Bet against clean-close rate    |
28//!
29//! Each observation is an oracle check at a point in time. The e-value for
30//! a single observation uses a **simple betting martingale**:
31//!
32//! ```text
33//!     e_t = E_{t-1} × (1 + λ × (X_t − p₀))
34//! ```
35//!
36//! where:
37//! - `λ ∈ (-1/p₀, 1/(1−p₀))` is the bet size (chosen adaptively or fixed)
38//! - `X_t ∈ {0, 1}` is the observation (1 = violation detected)
39//! - `p₀` is the null hypothesis violation probability (e.g., 0.001)
40//!
41//! Under H₀, `E[X_t] = p₀`, so `E[e_t | E_{t-1}] = E_{t-1}` (martingale).
42//! Under H₁ (actual violation rate `p₁ > p₀`), the e-process grows
43//! exponentially at rate `KL(p₁ ∥ p₀)` per observation.
44//!
45//! # References
46//!
47//! - Ville (1939). *Étude critique de la notion de collectif.*
48//! - Grünwald, de Heide, & Koolen (2024). *Safe Testing.*
49//! - Ramdas, Grünwald, Vovk, & Shafer (2023). *Game-theoretic statistics.*
50//! - Howard, Ramdas, McAuliffe, & Sekhon (2021). *Time-uniform Chernoff bounds.*
51
52use std::fmt::Write as _;
53
54use serde::{Deserialize, Serialize};
55
56use super::OracleReport;
57
58// ---------------------------------------------------------------------------
59// E-value and e-process core
60// ---------------------------------------------------------------------------
61
62/// A single e-value: the evidence against H₀ at a specific time.
63///
64/// `e ≥ 1/α` rejects H₀ at level α.
65#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
66pub struct EValue {
67    /// The e-value (non-negative, starts at 1.0).
68    pub value: f64,
69    /// Observation index (0-based).
70    pub time: usize,
71}
72
73impl EValue {
74    /// Returns true if this e-value rejects H₀ at the given significance level.
75    #[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/// Configuration for the betting martingale.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EProcessConfig {
85    /// Null hypothesis violation probability (default: 0.001).
86    pub p0: f64,
87    /// Bet size λ. Must satisfy `-1/(1−p₀) < λ < 1/p₀` to keep
88    /// betting factors non-negative for binary observations.
89    /// Default: 0.5 (moderate bet).
90    pub lambda: f64,
91    /// Significance level α for rejection (default: 0.05).
92    pub alpha: f64,
93    /// Maximum e-value to prevent numerical overflow (default: 1e15).
94    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    /// Validates the configuration.
110    ///
111    /// Returns `Err` if constraints are violated.
112    pub fn validate(&self) -> Result<(), String> {
113        // Guard against NaN/Inf first — IEEE 754 NaN comparisons always return
114        // false, so range checks alone cannot reject NaN.
115        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        // For binary X ∈ {0,1}, factor = 1 + λ(X - p0) must be ≥ 0:
128        //   X=0 → 1 - λp0 ≥ 0 → λ < 1/p0
129        //   X=1 → 1 + λ(1-p0) ≥ 0 → λ > -1/(1-p0)
130        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    /// Computes the rejection threshold `1/α`.
158    #[must_use]
159    pub fn threshold(&self) -> f64 {
160        1.0 / self.alpha
161    }
162}
163
164/// An e-process tracker for a single invariant.
165///
166/// Maintains the running product martingale and history.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct EProcess {
169    /// Invariant being monitored.
170    pub invariant: String,
171    /// Configuration.
172    pub config: EProcessConfig,
173    /// Current e-value (running product).
174    pub current: f64,
175    /// Number of observations processed.
176    pub observations: usize,
177    /// Number of violations observed.
178    pub violations_observed: usize,
179    /// Whether H₀ has been rejected at any point.
180    pub rejected: bool,
181    /// The observation at which rejection first occurred (if any).
182    pub rejection_time: Option<usize>,
183    /// History of e-values (optional, for diagnostics).
184    pub history: Vec<EValue>,
185    /// Whether to record full history.
186    record_history: bool,
187}
188
189impl EProcess {
190    /// Creates a new e-process for the given invariant.
191    #[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    /// Creates a new e-process without history recording (saves memory).
214    #[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    /// Processes a single observation.
222    ///
223    /// `violated` is true if the oracle detected a violation at this step.
224    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        // Clamp factor to prevent negative or zero values.
229        let factor = factor.max(1e-15);
230
231        // Guard against NaN propagation — if current or factor became NaN
232        // (e.g., from corrupt config), clamp to max_evalue rather than silently
233        // disabling rejection detection.
234        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    /// Returns the current e-value.
259    #[must_use]
260    pub fn e_value(&self) -> f64 {
261        self.current
262    }
263
264    /// Returns the current log₁₀ e-value.
265    #[must_use]
266    pub fn log10_e_value(&self) -> f64 {
267        self.current.max(1e-300).log10()
268    }
269
270    /// Returns the empirical violation rate.
271    #[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    /// Resets the e-process to its initial state.
282    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// ---------------------------------------------------------------------------
293// E-process monitor — multi-invariant
294// ---------------------------------------------------------------------------
295
296/// Result of monitoring a set of invariants.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct MonitorResult {
299    /// Invariant name.
300    pub invariant: String,
301    /// Final e-value.
302    pub e_value: f64,
303    /// log₁₀ of final e-value.
304    pub log10_e_value: f64,
305    /// Whether H₀ was rejected.
306    pub rejected: bool,
307    /// Rejection time (observation index), if any.
308    pub rejection_time: Option<usize>,
309    /// Total observations.
310    pub observations: usize,
311    /// Violations observed.
312    pub violations_observed: usize,
313    /// Empirical violation rate.
314    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/// Monitors multiple invariants via e-processes.
333///
334/// Each invariant gets its own independent e-process. Feed observations
335/// from oracle reports and query anytime-valid rejection status.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct EProcessMonitor {
338    /// Per-invariant e-processes.
339    processes: Vec<EProcess>,
340    /// Shared configuration.
341    config: EProcessConfig,
342}
343
344impl EProcessMonitor {
345    /// Creates a monitor for the standard 3 invariants (task_leak, obligation_leak, quiescence).
346    #[must_use]
347    pub fn standard() -> Self {
348        Self::standard_with_config(EProcessConfig::default())
349    }
350
351    /// Creates a monitor for the standard 3 invariants with custom config.
352    #[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    /// Creates a monitor for arbitrary invariants.
359    #[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    /// Creates a monitor for all oracle invariants.
369    #[must_use]
370    pub fn all_invariants() -> Self {
371        Self::all_invariants_with_config(EProcessConfig::default())
372    }
373
374    /// Creates a monitor for all oracle invariants with custom config.
375    #[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    /// Feeds an oracle report into the monitor.
407    ///
408    /// Each invariant's e-process is updated based on whether a violation
409    /// was detected in the report.
410    pub fn observe_report(&mut self, report: &OracleReport) {
411        for ep in &mut self.processes {
412            // Only update invariants that actually appear in the report.
413            // A missing entry means the oracle didn't check this invariant,
414            // so we must not silently treat it as passing.
415            if let Some(entry) = report.entries.iter().find(|e| e.invariant == ep.invariant) {
416                ep.observe(!entry.passed);
417            }
418        }
419    }
420
421    /// Feeds a raw observation for a specific invariant.
422    ///
423    /// Returns `true` if the invariant was found and updated.
424    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    /// Returns whether any invariant has been rejected.
435    #[must_use]
436    pub fn any_rejected(&self) -> bool {
437        self.processes.iter().any(|ep| ep.rejected)
438    }
439
440    /// Returns invariants that have been rejected.
441    #[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    /// Returns the e-process for a specific invariant.
451    #[must_use]
452    pub fn process(&self, invariant: &str) -> Option<&EProcess> {
453        self.processes.iter().find(|ep| ep.invariant == invariant)
454    }
455
456    /// Returns results for all tracked invariants.
457    #[must_use]
458    pub fn results(&self) -> Vec<MonitorResult> {
459        self.processes
460            .iter()
461            .map(MonitorResult::from_eprocess)
462            .collect()
463    }
464
465    /// Returns the shared configuration.
466    #[must_use]
467    pub fn config(&self) -> &EProcessConfig {
468        &self.config
469    }
470
471    /// Resets all e-processes.
472    pub fn reset(&mut self) {
473        for ep in &mut self.processes {
474            ep.reset();
475        }
476    }
477
478    /// Renders a text summary of the monitor state.
479    #[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    /// Serializes monitor state to JSON.
517    #[must_use]
518    pub fn to_json(&self) -> serde_json::Value {
519        serde_json::to_value(self).unwrap_or_default()
520    }
521}
522
523// ===========================================================================
524// Tests
525// ===========================================================================
526
527#[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    // -- EProcessConfig --
595
596    #[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        // max_evalue must be >= 1.0 and finite
652        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        // max_evalue < 1/alpha makes rejection impossible
680        let c = EProcessConfig {
681            alpha: 0.05,
682            max_evalue: 10.0, // threshold is 20
683            ..EProcessConfig::default()
684        };
685        assert!(c.validate().is_err());
686    }
687
688    #[test]
689    fn config_lambda_bounds_correct_for_large_p0() {
690        // With p0=0.6, correct bounds are (-2.5, 1.667)
691        // lambda=1.5 should be valid (factor at x=0: 1 - 1.5*0.6 = 0.1 > 0)
692        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        // lambda=1.7 should be invalid (factor at x=0: 1 - 1.7*0.6 = -0.02 < 0)
701        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    // -- EValue --
714
715    #[test]
716    fn evalue_rejects() {
717        let ev = EValue {
718            value: 25.0,
719            time: 0,
720        };
721        assert!(ev.rejects_at(0.05)); // 1/0.05 = 20, 25 >= 20
722        assert!(!ev.rejects_at(0.01)); // 1/0.01 = 100, 25 < 100
723    }
724
725    // -- EProcess core --
726
727    #[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        // Under H0 with no violations, e-value should decrease slightly.
739        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        // With repeated violations, should eventually reject.
768        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        // Further clean observations don't un-reject.
785        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        // Drive e-value very high.
856        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    // -- EProcessMonitor --
867
868    #[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        // All e-values should be < 1 (evidence against violation).
900        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        // Feed 20 reports with task_leak violated.
915        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    // -- Martingale property --
1006
1007    #[test]
1008    fn eprocess_martingale_under_null() {
1009        // Under H0, the e-process should not grow systematically.
1010        // Run many trials and check the average final e-value is ≈ 1.
1011        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                // Simple PRNG for deterministic test.
1023                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        // Under H0, E[E_T] ≤ 1 (supermartingale). Allow slack for finite samples.
1035        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        // Under H1 with violation rate 10%, should reject quickly.
1044        let config = EProcessConfig::default();
1045        let mut ep = EProcess::new("test", config);
1046
1047        // Use a deterministic 10% violation pattern to avoid RNG flakiness.
1048        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    // -- Early stopping validity --
1060
1061    #[test]
1062    fn early_stopping_valid() {
1063        // The key property: stopping when E_t first exceeds 1/α should give
1064        // type-I error ≤ α. Test over many null trials.
1065        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        // By Ville's inequality, FPR ≤ α = 0.05. Allow generous slack.
1093        assert!(
1094            fpr < 0.10,
1095            "false positive rate under optional stopping should be ≤ α, got {fpr:.4}"
1096        );
1097    }
1098
1099    // -- NaN / Inf rejection --
1100
1101    #[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    // -- Integration with OracleSuite --
1157
1158    #[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}