Skip to main content

ftui_runtime/
bocpd.rs

1#![forbid(unsafe_code)]
2
3//! Bayesian Online Change-Point Detection (BOCPD) for Resize Regime Detection.
4//!
5//! This module implements BOCPD to replace heuristic threshold-based regime
6//! detection in the resize coalescer. It provides a principled Bayesian
7//! approach to detecting transitions between Steady and Burst regimes.
8//!
9//! # Mathematical Model
10//!
11//! ## Observation Model
12//!
13//! We observe inter-arrival times `x_t` between consecutive resize events.
14//! The observation model conditions on the current regime:
15//!
16//! ```text
17//! Steady regime: x_t ~ Exponential(λ_steady)
18//!                where λ_steady = 1/μ_steady, μ_steady ≈ 200ms (slow events)
19//!
20//! Burst regime:  x_t ~ Exponential(λ_burst)
21//!                where λ_burst = 1/μ_burst, μ_burst ≈ 20ms (rapid events)
22//! ```
23//!
24//! The likelihood for observation x under each regime:
25//!
26//! ```text
27//! P(x | steady) = λ_steady × exp(-λ_steady × x)
28//! P(x | burst)  = λ_burst × exp(-λ_burst × x)
29//! ```
30//!
31//! ## Run-Length Model
32//!
33//! BOCPD maintains a distribution over run-lengths `r_t`, where r represents
34//! the number of observations since the last changepoint.
35//!
36//! The run-length posterior is recursively updated:
37//!
38//! ```text
39//! P(r_t = 0 | x_1:t) ∝ Σᵣ P(r_{t-1} = r | x_1:t-1) × H(r) × P(x_t | r)
40//! P(r_t = r+1 | x_1:t) ∝ P(r_{t-1} = r | x_1:t-1) × (1 - H(r)) × P(x_t | r)
41//! ```
42//!
43//! where H(r) is the hazard function (probability of changepoint at run-length r).
44//!
45//! ## Hazard Function
46//!
47//! We use a constant hazard model with geometric prior on run-length:
48//!
49//! ```text
50//! H(r) = 1/λ_hazard
51//!
52//! where λ_hazard is the expected run-length between changepoints.
53//! Default: λ_hazard = 50 (expect changepoint every ~50 observations)
54//! ```
55//!
56//! This implies:
57//! - P(changepoint at r) = (1 - 1/λ)^r × (1/λ)
58//! - E[run-length] = λ_hazard
59//!
60//! ## Run-Length Truncation
61//!
62//! To achieve O(K) complexity per update, we truncate the run-length
63//! distribution at maximum K:
64//!
65//! ```text
66//! K = 100 (default)
67//!
68//! For r ≥ K: P(r_t = r | x_1:t) is merged into P(r_t = K | x_1:t)
69//! ```
70//!
71//! This approximation is accurate when K >> λ_hazard, since most mass
72//! concentrates on recent run-lengths.
73//!
74//! ## Regime Posterior
75//!
76//! We maintain a separate regime indicator:
77//!
78//! ```text
79//! Regime detection via likelihood ratio:
80//!
81//! LR_t = P(x_1:t | burst) / P(x_1:t | steady)
82//!
83//! P(burst | x_1:t) = LR_t × P(burst) / (LR_t × P(burst) + P(steady))
84//!
85//! where P(burst) = 0.2 (prior probability of burst regime)
86//! ```
87//!
88//! The regime posterior is integrated over all run-lengths:
89//!
90//! ```text
91//! P(burst | x_1:t) = Σᵣ P(burst | r, x_1:t) × P(r | x_1:t)
92//! ```
93//!
94//! # Decision Rule
95//!
96//! The coalescing delay is selected based on the burst posterior:
97//!
98//! ```text
99//! Let p_burst = P(burst | x_1:t)
100//!
101//! If p_burst < 0.3:  delay = steady_delay_ms  (16ms, responsive)
102//! If p_burst > 0.7:  delay = burst_delay_ms   (40ms, coalescing)
103//! Otherwise:         delay = interpolate(16ms, 40ms, p_burst)
104//!
105//! Always respect hard_deadline_ms (100ms) regardless of regime.
106//! ```
107//!
108//! ## Log-Bayes Factor for Explainability
109//!
110//! We compute the log10 Bayes factor for each decision:
111//!
112//! ```text
113//! LBF = log10(P(x_t | burst) / P(x_t | steady))
114//!
115//! Interpretation:
116//! - LBF > 1:  Strong evidence for burst
117//! - LBF > 2:  Decisive evidence for burst
118//! - LBF < -1: Strong evidence for steady
119//! - LBF < -2: Decisive evidence for steady
120//! ```
121//!
122//! # Invariants
123//!
124//! 1. **Normalized posterior**: Σᵣ P(r_t = r) = 1 (up to numerical precision)
125//! 2. **Deterministic**: Same observation sequence → same posteriors
126//! 3. **Bounded complexity**: O(K) per observation update
127//! 4. **Bounded memory**: O(K) state vector
128//! 5. **Monotonic regime confidence**: p_burst increases with rapid events
129//!
130//! # Failure Modes
131//!
132//! | Condition | Behavior | Rationale |
133//! |-----------|----------|-----------|
134//! | x = 0 (instant event) | x = ε = 1ms | Avoid log(0) in likelihood |
135//! | x > 10s (very slow) | Clamp to 10s | Numerical stability |
136//! | All posterior mass at r=K | Normal operation | Truncation working |
137//! | λ_hazard = 0 | Use default λ=50 | Avoid division by zero |
138//! | K = 0 | Use default K=100 | Ensure valid state |
139//!
140//! # Configuration
141//!
142//! ```text
143//! BocpdConfig {
144//!     // Observation model
145//!     mu_steady_ms: 200.0,    // Expected inter-arrival in steady (ms)
146//!     mu_burst_ms: 20.0,      // Expected inter-arrival in burst (ms)
147//!
148//!     // Hazard function
149//!     hazard_lambda: 50.0,    // Expected run-length between changepoints
150//!
151//!     // Truncation
152//!     max_run_length: 100,    // K for O(K) complexity
153//!
154//!     // Decision thresholds
155//!     steady_threshold: 0.3,  // p_burst below this → steady
156//!     burst_threshold: 0.7,   // p_burst above this → burst
157//!
158//!     // Priors
159//!     burst_prior: 0.2,       // P(burst) a priori
160//! }
161//! ```
162//!
163//! # Performance
164//!
165//! - **Time complexity**: O(K) per observation
166//! - **Space complexity**: O(K) for run-length posterior
167//! - **Default K=100**: ~100 multiplications per resize event
168//! - **Suitable for**: Up to 1000 events/second without concern
169//!
170//! # References
171//!
172//! - Adams & MacKay (2007): "Bayesian Online Changepoint Detection"
173//! - The run-length truncation follows standard BOCPD practice
174//! - Hazard function choice is geometric (constant hazard)
175
176use std::fmt;
177use web_time::Instant;
178
179// =============================================================================
180// Configuration
181// =============================================================================
182
183/// Configuration for the BOCPD regime detector.
184#[derive(Debug, Clone)]
185pub struct BocpdConfig {
186    /// Expected inter-arrival time in steady regime (ms).
187    /// Longer values indicate slower, more spaced events.
188    /// Default: 200.0 ms
189    pub mu_steady_ms: f64,
190
191    /// Expected inter-arrival time in burst regime (ms).
192    /// Shorter values indicate rapid, clustered events.
193    /// Default: 20.0 ms
194    pub mu_burst_ms: f64,
195
196    /// Expected run-length between changepoints (hazard parameter).
197    /// Higher values mean changepoints are expected less frequently.
198    /// Default: 50.0
199    pub hazard_lambda: f64,
200
201    /// Maximum run-length for truncation (K).
202    /// Controls complexity: O(K) per update.
203    /// Default: 100
204    pub max_run_length: usize,
205
206    /// Threshold below which we classify as Steady.
207    /// If P(burst) < steady_threshold → Steady regime.
208    /// Default: 0.3
209    pub steady_threshold: f64,
210
211    /// Threshold above which we classify as Burst.
212    /// If P(burst) > burst_threshold → Burst regime.
213    /// Default: 0.7
214    pub burst_threshold: f64,
215
216    /// Prior probability of burst regime.
217    /// Used to initialize the regime posterior.
218    /// Default: 0.2
219    pub burst_prior: f64,
220
221    /// Minimum observation value (ms) to avoid log(0).
222    /// Default: 1.0 ms
223    pub min_observation_ms: f64,
224
225    /// Maximum observation value (ms) for numerical stability.
226    /// Default: 10000.0 ms (10 seconds)
227    pub max_observation_ms: f64,
228
229    /// Enable evidence logging.
230    /// Default: false
231    pub enable_logging: bool,
232}
233
234impl Default for BocpdConfig {
235    fn default() -> Self {
236        Self {
237            mu_steady_ms: 200.0,
238            mu_burst_ms: 20.0,
239            hazard_lambda: 50.0,
240            max_run_length: 100,
241            steady_threshold: 0.3,
242            burst_threshold: 0.7,
243            burst_prior: 0.2,
244            min_observation_ms: 1.0,
245            max_observation_ms: 10000.0,
246            enable_logging: false,
247        }
248    }
249}
250
251impl BocpdConfig {
252    /// Create a configuration tuned for responsive UI.
253    ///
254    /// Lower thresholds for faster regime detection.
255    #[must_use]
256    pub fn responsive() -> Self {
257        Self {
258            mu_steady_ms: 150.0,
259            mu_burst_ms: 15.0,
260            hazard_lambda: 30.0,
261            steady_threshold: 0.25,
262            burst_threshold: 0.6,
263            ..Default::default()
264        }
265    }
266
267    /// Create a configuration tuned for aggressive coalescing.
268    ///
269    /// Higher thresholds to stay in burst mode longer.
270    #[must_use]
271    pub fn aggressive_coalesce() -> Self {
272        Self {
273            mu_steady_ms: 250.0,
274            mu_burst_ms: 25.0,
275            hazard_lambda: 80.0,
276            steady_threshold: 0.4,
277            burst_threshold: 0.8,
278            burst_prior: 0.3,
279            ..Default::default()
280        }
281    }
282
283    /// Enable evidence logging.
284    #[must_use]
285    pub fn with_logging(mut self, enabled: bool) -> Self {
286        self.enable_logging = enabled;
287        self
288    }
289}
290
291// =============================================================================
292// Regime Enum
293// =============================================================================
294
295/// Detected regime from BOCPD analysis.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
297pub enum BocpdRegime {
298    /// Low event rate, prioritize responsiveness.
299    #[default]
300    Steady,
301    /// High event rate, prioritize coalescing.
302    Burst,
303    /// Transitional state (P(burst) between thresholds).
304    Transitional,
305}
306
307impl BocpdRegime {
308    /// Stable string representation for logging.
309    #[must_use]
310    pub const fn as_str(self) -> &'static str {
311        match self {
312            Self::Steady => "steady",
313            Self::Burst => "burst",
314            Self::Transitional => "transitional",
315        }
316    }
317}
318
319impl fmt::Display for BocpdRegime {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(f, "{}", self.as_str())
322    }
323}
324
325// =============================================================================
326// Evidence for Explainability
327// =============================================================================
328
329/// Evidence from a BOCPD update step.
330///
331/// Provides explainability for regime detection decisions.
332#[derive(Debug, Clone)]
333pub struct BocpdEvidence {
334    /// Posterior probability of burst regime.
335    pub p_burst: f64,
336
337    /// Log10 Bayes factor (positive favors burst).
338    pub log_bayes_factor: f64,
339
340    /// Current observation (inter-arrival time in ms).
341    pub observation_ms: f64,
342
343    /// Classified regime based on thresholds.
344    pub regime: BocpdRegime,
345
346    /// Likelihood under steady model.
347    pub likelihood_steady: f64,
348
349    /// Likelihood under burst model.
350    pub likelihood_burst: f64,
351
352    /// Expected run-length (mean of posterior).
353    pub expected_run_length: f64,
354
355    /// Run-length posterior variance.
356    pub run_length_variance: f64,
357
358    /// Run-length posterior mode (argmax).
359    pub run_length_mode: usize,
360
361    /// 95th percentile of run-length posterior.
362    pub run_length_p95: usize,
363
364    /// Tail mass at the truncation bucket (r = K).
365    pub run_length_tail_mass: f64,
366
367    /// Recommended delay based on current regime (ms), if provided.
368    pub recommended_delay_ms: Option<u64>,
369
370    /// Whether a hard deadline forced the decision, if provided.
371    pub hard_deadline_forced: Option<bool>,
372
373    /// Number of observations processed.
374    pub observation_count: u64,
375
376    /// Timestamp of this evidence.
377    pub timestamp: Instant,
378}
379
380impl BocpdEvidence {
381    /// Generate JSONL representation for logging.
382    #[must_use]
383    pub fn to_jsonl(&self) -> String {
384        const SCHEMA_VERSION: &str = "bocpd-v1";
385        let delay_ms = self
386            .recommended_delay_ms
387            .map(|v| v.to_string())
388            .unwrap_or_else(|| "null".to_string());
389        let forced = self
390            .hard_deadline_forced
391            .map(|v| v.to_string())
392            .unwrap_or_else(|| "null".to_string());
393        format!(
394            r#"{{"schema_version":"{}","event":"bocpd","p_burst":{:.4},"log_bf":{:.3},"obs_ms":{:.1},"regime":"{}","ll_steady":{:.6},"ll_burst":{:.6},"runlen_mean":{:.1},"runlen_var":{:.3},"runlen_mode":{},"runlen_p95":{},"runlen_tail":{:.4},"delay_ms":{},"forced_deadline":{},"n_obs":{}}}"#,
395            SCHEMA_VERSION,
396            self.p_burst,
397            self.log_bayes_factor,
398            self.observation_ms,
399            self.regime.as_str(),
400            self.likelihood_steady,
401            self.likelihood_burst,
402            self.expected_run_length,
403            self.run_length_variance,
404            self.run_length_mode,
405            self.run_length_p95,
406            self.run_length_tail_mass,
407            delay_ms,
408            forced,
409            self.observation_count,
410        )
411    }
412}
413
414impl fmt::Display for BocpdEvidence {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        writeln!(f, "BOCPD Evidence:")?;
417        writeln!(
418            f,
419            "  Regime: {} (P(burst) = {:.3})",
420            self.regime, self.p_burst
421        )?;
422        writeln!(
423            f,
424            "  Log BF: {:+.3} (positive favors burst)",
425            self.log_bayes_factor
426        )?;
427        writeln!(f, "  Observation: {:.1} ms", self.observation_ms)?;
428        writeln!(
429            f,
430            "  Likelihoods: steady={:.6}, burst={:.6}",
431            self.likelihood_steady, self.likelihood_burst
432        )?;
433        writeln!(f, "  E[run-length]: {:.1}", self.expected_run_length)?;
434        write!(f, "  Observations: {}", self.observation_count)
435    }
436}
437
438#[derive(Debug, Clone, Copy)]
439struct RunLengthSummary {
440    mean: f64,
441    variance: f64,
442    mode: usize,
443    p95: usize,
444    tail_mass: f64,
445}
446
447// =============================================================================
448// BOCPD Detector
449// =============================================================================
450
451/// Bayesian Online Change-Point Detection for regime classification.
452///
453/// Maintains a truncated run-length posterior and computes the
454/// probability of being in burst vs steady regime.
455#[derive(Debug, Clone)]
456pub struct BocpdDetector {
457    /// Configuration.
458    config: BocpdConfig,
459
460    /// Run-length posterior: P(r_t = r | x_1:t) for r in 0..=K.
461    /// Indexed by run-length, normalized to sum to 1.
462    run_length_posterior: Vec<f64>,
463
464    /// Current burst probability P(burst | x_1:t).
465    p_burst: f64,
466
467    /// Timestamp of last observation.
468    last_event_time: Option<Instant>,
469
470    /// Number of observations processed.
471    observation_count: u64,
472
473    /// Last evidence for inspection.
474    last_evidence: Option<BocpdEvidence>,
475
476    /// Pre-computed rate parameters for efficiency.
477    lambda_steady: f64, // 1 / mu_steady_ms
478    lambda_burst: f64, // 1 / mu_burst_ms
479    hazard: f64,       // 1 / hazard_lambda
480}
481
482impl BocpdDetector {
483    /// Create a new BOCPD detector with the given configuration.
484    pub fn new(config: BocpdConfig) -> Self {
485        let mut config = config;
486        config.max_run_length = config.max_run_length.max(1);
487        config.mu_steady_ms = config.mu_steady_ms.max(1.0);
488        config.mu_burst_ms = config.mu_burst_ms.max(1.0);
489        config.hazard_lambda = config.hazard_lambda.max(1.0);
490        config.min_observation_ms = config.min_observation_ms.max(0.1);
491        config.max_observation_ms = config.max_observation_ms.max(config.min_observation_ms);
492        config.steady_threshold = config.steady_threshold.clamp(0.0, 1.0);
493        config.burst_threshold = config.burst_threshold.clamp(0.0, 1.0);
494        if config.burst_threshold < config.steady_threshold {
495            std::mem::swap(&mut config.steady_threshold, &mut config.burst_threshold);
496        }
497        config.burst_prior = config.burst_prior.clamp(0.001, 0.999);
498
499        let k = config.max_run_length;
500
501        // Initialize uniform run-length posterior
502        let initial_prob = 1.0 / (k + 1) as f64;
503        let run_length_posterior = vec![initial_prob; k + 1];
504
505        // Pre-compute rate parameters
506        let lambda_steady = 1.0 / config.mu_steady_ms;
507        let lambda_burst = 1.0 / config.mu_burst_ms;
508        let hazard = 1.0 / config.hazard_lambda;
509
510        Self {
511            p_burst: config.burst_prior,
512            run_length_posterior,
513            last_event_time: None,
514            observation_count: 0,
515            last_evidence: None,
516            lambda_steady,
517            lambda_burst,
518            hazard,
519            config,
520        }
521    }
522
523    /// Create with default configuration.
524    pub fn with_defaults() -> Self {
525        Self::new(BocpdConfig::default())
526    }
527
528    /// Get current burst probability.
529    #[inline]
530    pub fn p_burst(&self) -> f64 {
531        self.p_burst
532    }
533
534    /// Get the run-length posterior distribution.
535    ///
536    /// Returns a slice where element `i` is `P(r_t = i | x_1:t)`.
537    /// The length is bounded by `max_run_length + 1`.
538    #[inline]
539    pub fn run_length_posterior(&self) -> &[f64] {
540        &self.run_length_posterior
541    }
542
543    /// Get current classified regime.
544    #[inline]
545    pub fn regime(&self) -> BocpdRegime {
546        if self.p_burst < self.config.steady_threshold {
547            BocpdRegime::Steady
548        } else if self.p_burst > self.config.burst_threshold {
549            BocpdRegime::Burst
550        } else {
551            BocpdRegime::Transitional
552        }
553    }
554
555    /// Get the expected run-length (mean of posterior).
556    pub fn expected_run_length(&self) -> f64 {
557        self.run_length_posterior
558            .iter()
559            .enumerate()
560            .map(|(r, p)| r as f64 * p)
561            .sum()
562    }
563
564    fn run_length_summary(&self) -> RunLengthSummary {
565        let mean = self.expected_run_length();
566        let mut variance = 0.0;
567        let mut mode = 0;
568        let mut mode_p = -1.0;
569        let mut cumulative = 0.0;
570        let mut p95 = self.config.max_run_length;
571
572        for (r, p) in self.run_length_posterior.iter().enumerate() {
573            if *p > mode_p {
574                mode_p = *p;
575                mode = r;
576            }
577            let diff = r as f64 - mean;
578            variance += p * diff * diff;
579            if cumulative < 0.95 {
580                cumulative += p;
581                if cumulative >= 0.95 {
582                    p95 = r;
583                }
584            }
585        }
586
587        RunLengthSummary {
588            mean,
589            variance,
590            mode,
591            p95,
592            tail_mass: self.run_length_posterior[self.config.max_run_length],
593        }
594    }
595
596    /// Get the last evidence.
597    pub fn last_evidence(&self) -> Option<&BocpdEvidence> {
598        self.last_evidence.as_ref()
599    }
600
601    /// Update the last evidence with decision context (delay + deadline).
602    ///
603    /// Call this after a decision is made (e.g., in the coalescer) to
604    /// include chosen delay and hard-deadline forcing in JSONL logs.
605    pub fn set_decision_context(
606        &mut self,
607        steady_delay_ms: u64,
608        burst_delay_ms: u64,
609        hard_deadline_forced: bool,
610    ) {
611        let recommended_delay = self.recommended_delay(steady_delay_ms, burst_delay_ms);
612        if let Some(ref mut evidence) = self.last_evidence {
613            evidence.recommended_delay_ms = Some(recommended_delay);
614            evidence.hard_deadline_forced = Some(hard_deadline_forced);
615        }
616    }
617
618    /// Return the latest JSONL evidence entry if logging is enabled.
619    #[must_use]
620    pub fn evidence_jsonl(&self) -> Option<String> {
621        if !self.config.enable_logging {
622            return None;
623        }
624        self.last_evidence.as_ref().map(BocpdEvidence::to_jsonl)
625    }
626
627    /// Return JSONL evidence with decision context applied.
628    #[must_use]
629    pub fn decision_log_jsonl(
630        &self,
631        steady_delay_ms: u64,
632        burst_delay_ms: u64,
633        hard_deadline_forced: bool,
634    ) -> Option<String> {
635        if !self.config.enable_logging {
636            return None;
637        }
638        let mut evidence = self.last_evidence.clone()?;
639        evidence.recommended_delay_ms =
640            Some(self.recommended_delay(steady_delay_ms, burst_delay_ms));
641        evidence.hard_deadline_forced = Some(hard_deadline_forced);
642        Some(evidence.to_jsonl())
643    }
644
645    /// Get observation count.
646    #[inline]
647    pub fn observation_count(&self) -> u64 {
648        self.observation_count
649    }
650
651    /// Get configuration.
652    pub fn config(&self) -> &BocpdConfig {
653        &self.config
654    }
655
656    /// Process a new resize event.
657    ///
658    /// Call this when a resize event occurs. Returns the classified regime.
659    pub fn observe_event(&mut self, now: Instant) -> BocpdRegime {
660        // Compute inter-arrival time
661        let observation_ms = self
662            .last_event_time
663            .map(|last| now.duration_since(last).as_secs_f64() * 1000.0)
664            .unwrap_or(self.config.mu_steady_ms); // Default to steady-like on first event
665
666        // Clamp observation
667        let x = observation_ms
668            .max(self.config.min_observation_ms)
669            .min(self.config.max_observation_ms);
670
671        // Update posterior
672        self.update_posterior(x, now);
673
674        // Update last event time
675        self.last_event_time = Some(now);
676
677        self.regime()
678    }
679
680    /// Update the run-length posterior with a new observation.
681    fn update_posterior(&mut self, x: f64, now: Instant) {
682        self.observation_count += 1;
683
684        // Compute likelihoods
685        let ll_steady = self.exponential_pdf(x, self.lambda_steady);
686        let ll_burst = self.exponential_pdf(x, self.lambda_burst);
687
688        // Compute Bayes factor
689        let log_bf = if ll_steady > 0.0 && ll_burst > 0.0 {
690            (ll_burst / ll_steady).log10()
691        } else {
692            0.0
693        };
694
695        // ==== BOCPD Run-Length Update ====
696        let k = self.config.max_run_length;
697        let mut new_posterior = vec![0.0; k + 1];
698
699        // Growth probability: P(r_t = r+1) ∝ P(r_{t-1} = r) × (1 - H(r)) × likelihood
700        for r in 0..k {
701            let growth_prob = self.run_length_posterior[r] * (1.0 - self.hazard);
702            new_posterior[r + 1] += growth_prob * self.predictive_likelihood(r, x);
703        }
704
705        // Merge probability at r=K (truncation)
706        new_posterior[k] +=
707            self.run_length_posterior[k] * (1.0 - self.hazard) * self.predictive_likelihood(k, x);
708
709        // Changepoint probability: P(r_t = 0) ∝ Σ P(r_{t-1}) × H × likelihood
710        let cp_prob: f64 = self
711            .run_length_posterior
712            .iter()
713            .enumerate()
714            .map(|(r, &p)| p * self.hazard * self.predictive_likelihood(r, x))
715            .sum();
716        new_posterior[0] = cp_prob;
717
718        // Normalize
719        let total: f64 = new_posterior.iter().sum();
720        if total > 0.0 {
721            for p in &mut new_posterior {
722                *p /= total;
723            }
724        } else {
725            // Reset to uniform if numerical issues
726            let uniform = 1.0 / (k + 1) as f64;
727            new_posterior.fill(uniform);
728        }
729
730        self.run_length_posterior = new_posterior;
731
732        // ==== Update Burst Probability ====
733        // Use a Bayesian update with the likelihood ratio
734        let prior_odds = self.p_burst / (1.0 - self.p_burst).max(1e-10);
735        let likelihood_ratio = ll_burst / ll_steady.max(1e-10);
736        let posterior_odds = prior_odds * likelihood_ratio;
737        self.p_burst = (posterior_odds / (1.0 + posterior_odds)).clamp(0.001, 0.999);
738
739        // Store evidence
740        let summary = self.run_length_summary();
741        self.last_evidence = Some(BocpdEvidence {
742            p_burst: self.p_burst,
743            log_bayes_factor: log_bf,
744            observation_ms: x,
745            regime: self.regime(),
746            likelihood_steady: ll_steady,
747            likelihood_burst: ll_burst,
748            expected_run_length: summary.mean,
749            run_length_variance: summary.variance,
750            run_length_mode: summary.mode,
751            run_length_p95: summary.p95,
752            run_length_tail_mass: summary.tail_mass,
753            recommended_delay_ms: None,
754            hard_deadline_forced: None,
755            observation_count: self.observation_count,
756            timestamp: now,
757        });
758    }
759
760    /// Compute exponential PDF: λ × exp(-λx).
761    #[inline]
762    fn exponential_pdf(&self, x: f64, lambda: f64) -> f64 {
763        lambda * (-lambda * x).exp()
764    }
765
766    /// Predictive likelihood for observation given run-length.
767    ///
768    /// We use a mixture model weighted by regime probability:
769    /// P(x | r) = p_burst × P(x | burst) + (1 - p_burst) × P(x | steady)
770    #[inline]
771    fn predictive_likelihood(&self, _r: usize, x: f64) -> f64 {
772        // Note: A more sophisticated model would condition on r,
773        // but for simplicity we use the current regime estimate.
774        let ll_steady = self.exponential_pdf(x, self.lambda_steady);
775        let ll_burst = self.exponential_pdf(x, self.lambda_burst);
776        self.p_burst * ll_burst + (1.0 - self.p_burst) * ll_steady
777    }
778
779    /// Reset the detector to initial state.
780    pub fn reset(&mut self) {
781        let k = self.config.max_run_length;
782        let initial_prob = 1.0 / (k + 1) as f64;
783        self.run_length_posterior = vec![initial_prob; k + 1];
784        self.p_burst = self.config.burst_prior;
785        self.last_event_time = None;
786        self.observation_count = 0;
787        self.last_evidence = None;
788    }
789
790    /// Compute recommended coalesce delay based on current regime.
791    ///
792    /// Interpolates between steady_delay and burst_delay based on p_burst.
793    pub fn recommended_delay(&self, steady_delay_ms: u64, burst_delay_ms: u64) -> u64 {
794        if self.p_burst < self.config.steady_threshold {
795            steady_delay_ms
796        } else if self.p_burst > self.config.burst_threshold {
797            burst_delay_ms
798        } else {
799            // Linear interpolation in transitional region
800            let denom = (self.config.burst_threshold - self.config.steady_threshold).max(1e-6);
801            let t = ((self.p_burst - self.config.steady_threshold) / denom).clamp(0.0, 1.0);
802            let delay = steady_delay_ms as f64 * (1.0 - t) + burst_delay_ms as f64 * t;
803            delay.round() as u64
804        }
805    }
806}
807
808impl Default for BocpdDetector {
809    fn default() -> Self {
810        Self::with_defaults()
811    }
812}
813
814// =============================================================================
815// Tests
816// =============================================================================
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use std::time::Duration;
822
823    #[test]
824    fn test_default_config() {
825        let config = BocpdConfig::default();
826        assert!((config.mu_steady_ms - 200.0).abs() < 0.01);
827        assert!((config.mu_burst_ms - 20.0).abs() < 0.01);
828        assert_eq!(config.max_run_length, 100);
829    }
830
831    #[test]
832    fn test_initial_state() {
833        let detector = BocpdDetector::with_defaults();
834        assert!((detector.p_burst() - 0.2).abs() < 0.01); // Default prior
835        assert_eq!(detector.regime(), BocpdRegime::Steady);
836        assert_eq!(detector.observation_count(), 0);
837    }
838
839    #[test]
840    fn test_steady_detection() {
841        let mut detector = BocpdDetector::with_defaults();
842        let start = Instant::now();
843
844        // Simulate slow events (200ms apart) - should stay in steady
845        for i in 0..10 {
846            let t = start + Duration::from_millis(200 * (i + 1));
847            detector.observe_event(t);
848        }
849
850        assert!(
851            detector.p_burst() < 0.5,
852            "p_burst={} should be low",
853            detector.p_burst()
854        );
855        assert_eq!(detector.regime(), BocpdRegime::Steady);
856    }
857
858    #[test]
859    fn test_burst_detection() {
860        let mut detector = BocpdDetector::with_defaults();
861        let start = Instant::now();
862
863        // Simulate rapid events (10ms apart) - should trigger burst
864        for i in 0..20 {
865            let t = start + Duration::from_millis(10 * (i + 1));
866            detector.observe_event(t);
867        }
868
869        assert!(
870            detector.p_burst() > 0.5,
871            "p_burst={} should be high",
872            detector.p_burst()
873        );
874        assert!(matches!(
875            detector.regime(),
876            BocpdRegime::Burst | BocpdRegime::Transitional
877        ));
878    }
879
880    #[test]
881    fn test_regime_transition() {
882        let mut detector = BocpdDetector::with_defaults();
883        let start = Instant::now();
884
885        // Start slow (steady)
886        for i in 0..5 {
887            let t = start + Duration::from_millis(200 * (i + 1));
888            detector.observe_event(t);
889        }
890        let initial_p_burst = detector.p_burst();
891
892        // Then rapid events (burst)
893        let burst_start = start + Duration::from_millis(1000);
894        for i in 0..20 {
895            let t = burst_start + Duration::from_millis(10 * (i + 1));
896            detector.observe_event(t);
897        }
898
899        assert!(
900            detector.p_burst() > initial_p_burst,
901            "p_burst should increase during burst"
902        );
903    }
904
905    #[test]
906    fn test_evidence_stored() {
907        let mut detector = BocpdDetector::with_defaults();
908        let t = Instant::now();
909        detector.observe_event(t);
910
911        let evidence = detector.last_evidence().expect("Evidence should be stored");
912        assert_eq!(evidence.observation_count, 1);
913        assert!(evidence.log_bayes_factor.is_finite());
914    }
915
916    #[test]
917    fn test_reset() {
918        let mut detector = BocpdDetector::with_defaults();
919        let start = Instant::now();
920
921        // Process some events
922        for i in 0..10 {
923            let t = start + Duration::from_millis(10 * (i + 1));
924            detector.observe_event(t);
925        }
926
927        detector.reset();
928
929        assert!((detector.p_burst() - 0.2).abs() < 0.01);
930        assert_eq!(detector.observation_count(), 0);
931        assert!(detector.last_evidence().is_none());
932    }
933
934    #[test]
935    fn test_recommended_delay() {
936        let mut detector = BocpdDetector::with_defaults();
937
938        // In steady regime (default)
939        assert_eq!(detector.recommended_delay(16, 40), 16);
940
941        // Manually set to burst
942        detector.p_burst = 0.9;
943        assert_eq!(detector.recommended_delay(16, 40), 40);
944
945        // Transitional (interpolated)
946        detector.p_burst = 0.5;
947        let delay = detector.recommended_delay(16, 40);
948        assert!(
949            delay > 16 && delay < 40,
950            "delay={} should be interpolated",
951            delay
952        );
953    }
954
955    #[test]
956    fn test_deterministic() {
957        let mut det1 = BocpdDetector::with_defaults();
958        let mut det2 = BocpdDetector::with_defaults();
959        let start = Instant::now();
960
961        for i in 0..10 {
962            let t = start + Duration::from_millis(15 * (i + 1));
963            det1.observe_event(t);
964            det2.observe_event(t);
965        }
966
967        assert!((det1.p_burst() - det2.p_burst()).abs() < 1e-10);
968        assert_eq!(det1.regime(), det2.regime());
969    }
970
971    #[test]
972    fn test_posterior_normalized() {
973        let mut detector = BocpdDetector::with_defaults();
974        let start = Instant::now();
975
976        for i in 0..20 {
977            let t = start + Duration::from_millis(25 * (i + 1));
978            detector.observe_event(t);
979
980            let sum: f64 = detector.run_length_posterior.iter().sum();
981            assert!(
982                (sum - 1.0).abs() < 1e-6,
983                "Posterior not normalized: sum={}",
984                sum
985            );
986        }
987    }
988
989    #[test]
990    fn test_p_burst_bounded() {
991        let mut detector = BocpdDetector::with_defaults();
992        let start = Instant::now();
993
994        // Extreme rapid events
995        for i in 0..100 {
996            let t = start + Duration::from_millis(i + 1);
997            detector.observe_event(t);
998            assert!(detector.p_burst() >= 0.0 && detector.p_burst() <= 1.0);
999        }
1000    }
1001
1002    #[test]
1003    fn config_sanitization_clamps_thresholds_and_priors() {
1004        let config = BocpdConfig {
1005            steady_threshold: 0.9,
1006            burst_threshold: 0.1,
1007            burst_prior: 2.0,
1008            max_run_length: 0,
1009            mu_steady_ms: 0.0,
1010            mu_burst_ms: 0.0,
1011            hazard_lambda: 0.0,
1012            min_observation_ms: 0.0,
1013            max_observation_ms: 0.0,
1014            ..Default::default()
1015        };
1016
1017        let detector = BocpdDetector::new(config);
1018        let cfg = detector.config();
1019
1020        assert!(
1021            cfg.steady_threshold <= cfg.burst_threshold,
1022            "thresholds should be ordered after sanitization"
1023        );
1024        assert_eq!(cfg.max_run_length, 1);
1025        assert!(cfg.mu_steady_ms >= 1.0);
1026        assert!(cfg.mu_burst_ms >= 1.0);
1027        assert!(cfg.hazard_lambda >= 1.0);
1028        assert!(cfg.min_observation_ms >= 0.1);
1029        assert!(cfg.max_observation_ms >= cfg.min_observation_ms);
1030        assert!(
1031            (0.0..=1.0).contains(&detector.p_burst()),
1032            "p_burst should be clamped into [0,1]"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_jsonl_output() {
1038        let mut detector = BocpdDetector::with_defaults();
1039        let t = Instant::now();
1040        detector.observe_event(t);
1041        detector.config.enable_logging = true;
1042
1043        let jsonl = detector
1044            .decision_log_jsonl(16, 40, false)
1045            .expect("jsonl should be emitted when enabled");
1046
1047        assert!(jsonl.contains("bocpd-v1"));
1048        assert!(jsonl.contains("p_burst"));
1049        assert!(jsonl.contains("regime"));
1050        assert!(jsonl.contains("runlen_mean"));
1051        assert!(jsonl.contains("runlen_mode"));
1052        assert!(jsonl.contains("runlen_p95"));
1053        assert!(jsonl.contains("delay_ms"));
1054        assert!(jsonl.contains("forced_deadline"));
1055    }
1056
1057    #[test]
1058    fn evidence_jsonl_respects_config() {
1059        let mut detector = BocpdDetector::with_defaults();
1060        let t = Instant::now();
1061        detector.observe_event(t);
1062
1063        assert!(detector.evidence_jsonl().is_none());
1064
1065        detector.config.enable_logging = true;
1066        assert!(detector.evidence_jsonl().is_some());
1067    }
1068
1069    // Property test: expected run-length is non-negative
1070    #[test]
1071    fn prop_expected_runlen_non_negative() {
1072        let mut detector = BocpdDetector::with_defaults();
1073        let start = Instant::now();
1074
1075        for i in 0..50 {
1076            let t = start + Duration::from_millis((i % 30 + 5) * (i + 1));
1077            detector.observe_event(t);
1078            assert!(detector.expected_run_length() >= 0.0);
1079        }
1080    }
1081
1082    // ── Config presets ────────────────────────────────────────────
1083
1084    #[test]
1085    fn responsive_config_values() {
1086        let cfg = BocpdConfig::responsive();
1087        assert!((cfg.mu_steady_ms - 150.0).abs() < f64::EPSILON);
1088        assert!((cfg.mu_burst_ms - 15.0).abs() < f64::EPSILON);
1089        assert!((cfg.hazard_lambda - 30.0).abs() < f64::EPSILON);
1090        assert!((cfg.steady_threshold - 0.25).abs() < f64::EPSILON);
1091        assert!((cfg.burst_threshold - 0.6).abs() < f64::EPSILON);
1092    }
1093
1094    #[test]
1095    fn aggressive_coalesce_config_values() {
1096        let cfg = BocpdConfig::aggressive_coalesce();
1097        assert!((cfg.mu_steady_ms - 250.0).abs() < f64::EPSILON);
1098        assert!((cfg.mu_burst_ms - 25.0).abs() < f64::EPSILON);
1099        assert!((cfg.hazard_lambda - 80.0).abs() < f64::EPSILON);
1100        assert!((cfg.steady_threshold - 0.4).abs() < f64::EPSILON);
1101        assert!((cfg.burst_threshold - 0.8).abs() < f64::EPSILON);
1102        assert!((cfg.burst_prior - 0.3).abs() < f64::EPSILON);
1103    }
1104
1105    #[test]
1106    fn with_logging_builder() {
1107        let cfg = BocpdConfig::default().with_logging(true);
1108        assert!(cfg.enable_logging);
1109        let cfg2 = cfg.with_logging(false);
1110        assert!(!cfg2.enable_logging);
1111    }
1112
1113    // ── BocpdRegime traits ────────────────────────────────────────
1114
1115    #[test]
1116    fn regime_as_str_values() {
1117        assert_eq!(BocpdRegime::Steady.as_str(), "steady");
1118        assert_eq!(BocpdRegime::Burst.as_str(), "burst");
1119        assert_eq!(BocpdRegime::Transitional.as_str(), "transitional");
1120    }
1121
1122    #[test]
1123    fn regime_display_matches_as_str() {
1124        for regime in [
1125            BocpdRegime::Steady,
1126            BocpdRegime::Burst,
1127            BocpdRegime::Transitional,
1128        ] {
1129            assert_eq!(format!("{regime}"), regime.as_str());
1130        }
1131    }
1132
1133    #[test]
1134    fn regime_default_is_steady() {
1135        assert_eq!(BocpdRegime::default(), BocpdRegime::Steady);
1136    }
1137
1138    #[test]
1139    fn regime_copy() {
1140        let r = BocpdRegime::Burst;
1141        let r2 = r;
1142        assert_eq!(r, r2);
1143        let r3 = r;
1144        assert_eq!(r, r3);
1145    }
1146
1147    // ── BocpdEvidence ─────────────────────────────────────────────
1148
1149    #[test]
1150    fn evidence_to_jsonl_has_all_fields() {
1151        let mut detector = BocpdDetector::with_defaults();
1152        let t = Instant::now();
1153        detector.observe_event(t);
1154        let evidence = detector.last_evidence().unwrap();
1155        let jsonl = evidence.to_jsonl();
1156
1157        for key in [
1158            "schema_version",
1159            "bocpd-v1",
1160            "p_burst",
1161            "log_bf",
1162            "obs_ms",
1163            "regime",
1164            "ll_steady",
1165            "ll_burst",
1166            "runlen_mean",
1167            "runlen_var",
1168            "runlen_mode",
1169            "runlen_p95",
1170            "runlen_tail",
1171            "delay_ms",
1172            "forced_deadline",
1173            "n_obs",
1174        ] {
1175            assert!(jsonl.contains(key), "missing field {key} in {jsonl}");
1176        }
1177    }
1178
1179    #[test]
1180    fn evidence_display_contains_regime_and_pburst() {
1181        let mut detector = BocpdDetector::with_defaults();
1182        let t = Instant::now();
1183        detector.observe_event(t);
1184        let evidence = detector.last_evidence().unwrap();
1185        let display = format!("{evidence}");
1186        assert!(display.contains("BOCPD Evidence:"));
1187        assert!(display.contains("Regime:"));
1188        assert!(display.contains("P(burst)"));
1189        assert!(display.contains("Log BF:"));
1190        assert!(display.contains("Observation:"));
1191        assert!(display.contains("Observations:"));
1192    }
1193
1194    #[test]
1195    fn evidence_null_optionals_in_jsonl() {
1196        let mut detector = BocpdDetector::with_defaults();
1197        let t = Instant::now();
1198        detector.observe_event(t);
1199        let evidence = detector.last_evidence().unwrap();
1200        let jsonl = evidence.to_jsonl();
1201        // Before set_decision_context, these should be null
1202        assert!(jsonl.contains("\"delay_ms\":null"));
1203        assert!(jsonl.contains("\"forced_deadline\":null"));
1204    }
1205
1206    // ── Detector accessors ────────────────────────────────────────
1207
1208    #[test]
1209    fn initial_detector_state() {
1210        let detector = BocpdDetector::with_defaults();
1211        assert!((detector.p_burst() - 0.2).abs() < 0.01);
1212        assert_eq!(detector.observation_count(), 0);
1213        assert!(detector.last_evidence().is_none());
1214        assert_eq!(detector.regime(), BocpdRegime::Steady);
1215    }
1216
1217    #[test]
1218    fn run_length_posterior_sums_to_one() {
1219        let detector = BocpdDetector::with_defaults();
1220        let sum: f64 = detector.run_length_posterior().iter().sum();
1221        assert!((sum - 1.0).abs() < 1e-10);
1222    }
1223
1224    #[test]
1225    fn config_accessor_returns_config() {
1226        let cfg = BocpdConfig::responsive();
1227        let detector = BocpdDetector::new(cfg);
1228        assert!((detector.config().mu_steady_ms - 150.0).abs() < f64::EPSILON);
1229    }
1230
1231    // ── observe_event edge cases ──────────────────────────────────
1232
1233    #[test]
1234    fn first_event_uses_steady_default() {
1235        let mut detector = BocpdDetector::with_defaults();
1236        let t = Instant::now();
1237        detector.observe_event(t);
1238        // First event should use mu_steady_ms as observation
1239        let evidence = detector.last_evidence().unwrap();
1240        assert!(
1241            (evidence.observation_ms - 200.0).abs() < 1.0,
1242            "first observation should be ~mu_steady_ms"
1243        );
1244    }
1245
1246    #[test]
1247    fn rapid_events_increase_pburst() {
1248        let mut detector = BocpdDetector::with_defaults();
1249        let start = Instant::now();
1250        // First event (baseline)
1251        detector.observe_event(start);
1252        let initial = detector.p_burst();
1253        // Rapid events (5ms apart)
1254        for i in 1..20 {
1255            let t = start + Duration::from_millis(5 * i);
1256            detector.observe_event(t);
1257        }
1258        assert!(
1259            detector.p_burst() > initial,
1260            "p_burst should increase with rapid events"
1261        );
1262    }
1263
1264    #[test]
1265    fn slow_events_decrease_pburst() {
1266        let mut detector = BocpdDetector::with_defaults();
1267        let start = Instant::now();
1268        // Seed with some rapid events to raise p_burst
1269        for i in 0..10 {
1270            let t = start + Duration::from_millis(5 * (i + 1));
1271            detector.observe_event(t);
1272        }
1273        let after_burst = detector.p_burst();
1274        // Now slow events (500ms apart)
1275        let slow_start = start + Duration::from_millis(50);
1276        for i in 0..20 {
1277            let t = slow_start + Duration::from_millis(500 * (i + 1));
1278            detector.observe_event(t);
1279        }
1280        assert!(
1281            detector.p_burst() < after_burst,
1282            "p_burst should decrease with slow events"
1283        );
1284    }
1285
1286    // ── burst-to-steady recovery ──────────────────────────────────
1287
1288    #[test]
1289    fn burst_to_steady_recovery() {
1290        let mut detector = BocpdDetector::with_defaults();
1291        let start = Instant::now();
1292        // Drive into burst with rapid events
1293        for i in 0..30 {
1294            let t = start + Duration::from_millis(5 * (i + 1));
1295            detector.observe_event(t);
1296        }
1297        let burst_p = detector.p_burst();
1298        assert!(burst_p > 0.5, "should be in burst, got p={burst_p}");
1299        // Recover with slow events
1300        let slow_start = start + Duration::from_millis(150);
1301        for i in 0..30 {
1302            let t = slow_start + Duration::from_millis(200 * (i + 1));
1303            detector.observe_event(t);
1304        }
1305        let steady_p = detector.p_burst();
1306        assert!(
1307            steady_p < burst_p,
1308            "p_burst should decrease during recovery"
1309        );
1310    }
1311
1312    // ── set_decision_context ──────────────────────────────────────
1313
1314    #[test]
1315    fn set_decision_context_populates_evidence() {
1316        let mut detector = BocpdDetector::with_defaults();
1317        let t = Instant::now();
1318        detector.observe_event(t);
1319        detector.set_decision_context(16, 40, false);
1320        let evidence = detector.last_evidence().unwrap();
1321        assert!(evidence.recommended_delay_ms.is_some());
1322        assert_eq!(evidence.hard_deadline_forced, Some(false));
1323    }
1324
1325    #[test]
1326    fn set_decision_context_forced_deadline() {
1327        let mut detector = BocpdDetector::with_defaults();
1328        let t = Instant::now();
1329        detector.observe_event(t);
1330        detector.set_decision_context(16, 40, true);
1331        let evidence = detector.last_evidence().unwrap();
1332        assert_eq!(evidence.hard_deadline_forced, Some(true));
1333    }
1334
1335    // ── decision_log_jsonl ────────────────────────────────────────
1336
1337    #[test]
1338    fn decision_log_jsonl_none_when_logging_disabled() {
1339        let mut detector = BocpdDetector::with_defaults();
1340        let t = Instant::now();
1341        detector.observe_event(t);
1342        assert!(detector.decision_log_jsonl(16, 40, false).is_none());
1343    }
1344
1345    #[test]
1346    fn decision_log_jsonl_has_delay_when_logging_enabled() {
1347        let mut detector = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1348        let t = Instant::now();
1349        detector.observe_event(t);
1350        let jsonl = detector
1351            .decision_log_jsonl(16, 40, true)
1352            .expect("should emit when logging enabled");
1353        assert!(jsonl.contains("\"delay_ms\":"));
1354        assert!(!jsonl.contains("\"delay_ms\":null"));
1355        assert!(jsonl.contains("\"forced_deadline\":true"));
1356    }
1357
1358    // ── recommended_delay ─────────────────────────────────────────
1359
1360    #[test]
1361    fn recommended_delay_interpolation_in_transitional() {
1362        let mut detector = BocpdDetector::with_defaults();
1363        // Set p_burst to middle of transitional range
1364        detector.p_burst = 0.5;
1365        let delay = detector.recommended_delay(16, 40);
1366        assert!(
1367            delay > 16 && delay < 40,
1368            "transitional delay={delay} should be interpolated"
1369        );
1370    }
1371
1372    #[test]
1373    fn recommended_delay_steady_when_low_pburst() {
1374        let detector = BocpdDetector::with_defaults();
1375        // Default p_burst is 0.2, below steady_threshold of 0.3
1376        assert_eq!(detector.recommended_delay(16, 40), 16);
1377    }
1378
1379    #[test]
1380    fn recommended_delay_burst_when_high_pburst() {
1381        let mut detector = BocpdDetector::with_defaults();
1382        detector.p_burst = 0.9;
1383        assert_eq!(detector.recommended_delay(16, 40), 40);
1384    }
1385
1386    // ── run-length summary ────────────────────────────────────────
1387
1388    #[test]
1389    fn expected_run_length_initial_uniform() {
1390        let detector = BocpdDetector::with_defaults();
1391        let erl = detector.expected_run_length();
1392        // Uniform on 0..=100 → mean = 50
1393        assert!((erl - 50.0).abs() < 1.0);
1394    }
1395
1396    // ── evidence fields accuracy ──────────────────────────────────
1397
1398    #[test]
1399    fn evidence_observation_count_matches_events() {
1400        let mut detector = BocpdDetector::with_defaults();
1401        let start = Instant::now();
1402        for i in 0..7 {
1403            let t = start + Duration::from_millis(20 * (i + 1));
1404            detector.observe_event(t);
1405        }
1406        let evidence = detector.last_evidence().unwrap();
1407        assert_eq!(evidence.observation_count, 7);
1408    }
1409
1410    #[test]
1411    fn evidence_likelihoods_are_positive() {
1412        let mut detector = BocpdDetector::with_defaults();
1413        let start = Instant::now();
1414        for i in 0..5 {
1415            let t = start + Duration::from_millis(50 * (i + 1));
1416            detector.observe_event(t);
1417        }
1418        let evidence = detector.last_evidence().unwrap();
1419        assert!(evidence.likelihood_steady > 0.0);
1420        assert!(evidence.likelihood_burst > 0.0);
1421    }
1422
1423    // ── responsive vs default ─────────────────────────────────────
1424
1425    #[test]
1426    fn responsive_detects_burst_faster() {
1427        let start = Instant::now();
1428        let mut default_det = BocpdDetector::with_defaults();
1429        let mut responsive_det = BocpdDetector::new(BocpdConfig::responsive());
1430        // Feed identical rapid events
1431        for i in 0..15 {
1432            let t = start + Duration::from_millis(5 * (i + 1));
1433            default_det.observe_event(t);
1434            responsive_det.observe_event(t);
1435        }
1436        // Responsive should have higher burst probability (lower thresholds)
1437        // or at least detect burst regime sooner
1438        let d_regime = default_det.regime();
1439        let r_regime = responsive_det.regime();
1440        // If default is still transitional, responsive should be at least transitional or burst
1441        if d_regime == BocpdRegime::Steady {
1442            assert_ne!(
1443                r_regime,
1444                BocpdRegime::Steady,
1445                "responsive should not be steady when default is"
1446            );
1447        }
1448    }
1449
1450    // ── reset behavior ────────────────────────────────────────────
1451
1452    #[test]
1453    fn reset_restores_initial_state() {
1454        let mut detector = BocpdDetector::with_defaults();
1455        let start = Instant::now();
1456        for i in 0..20 {
1457            let t = start + Duration::from_millis(5 * (i + 1));
1458            detector.observe_event(t);
1459        }
1460        assert!(detector.p_burst() > 0.5);
1461        detector.reset();
1462        assert!((detector.p_burst() - 0.2).abs() < 0.01);
1463        assert_eq!(detector.observation_count(), 0);
1464        assert!(detector.last_evidence().is_none());
1465        assert!(detector.last_event_time.is_none());
1466    }
1467
1468    // ── posterior normalization under stress ───────────────────────
1469
1470    #[test]
1471    fn posterior_stays_normalized_under_alternating_traffic() {
1472        let mut detector = BocpdDetector::with_defaults();
1473        let start = Instant::now();
1474        for i in 0..100 {
1475            // Alternate rapid and slow
1476            let gap = if i % 2 == 0 { 5 } else { 300 };
1477            let t = start + Duration::from_millis(gap * (i + 1));
1478            detector.observe_event(t);
1479            let sum: f64 = detector.run_length_posterior().iter().sum();
1480            assert!(
1481                (sum - 1.0).abs() < 1e-6,
1482                "posterior not normalized at step {i}: sum={sum}"
1483            );
1484        }
1485    }
1486
1487    // ── BocpdConfig presets ─────────────────────────────────────────
1488
1489    #[test]
1490    fn responsive_config_values_dup() {
1491        let config = BocpdConfig::responsive();
1492        assert!((config.mu_steady_ms - 150.0).abs() < f64::EPSILON);
1493        assert!((config.mu_burst_ms - 15.0).abs() < f64::EPSILON);
1494        assert!((config.hazard_lambda - 30.0).abs() < f64::EPSILON);
1495        assert!((config.steady_threshold - 0.25).abs() < f64::EPSILON);
1496        assert!((config.burst_threshold - 0.6).abs() < f64::EPSILON);
1497    }
1498
1499    #[test]
1500    fn aggressive_coalesce_config_values_dup() {
1501        let config = BocpdConfig::aggressive_coalesce();
1502        assert!((config.mu_steady_ms - 250.0).abs() < f64::EPSILON);
1503        assert!((config.mu_burst_ms - 25.0).abs() < f64::EPSILON);
1504        assert!((config.hazard_lambda - 80.0).abs() < f64::EPSILON);
1505        assert!((config.steady_threshold - 0.4).abs() < f64::EPSILON);
1506        assert!((config.burst_threshold - 0.8).abs() < f64::EPSILON);
1507        assert!((config.burst_prior - 0.3).abs() < f64::EPSILON);
1508    }
1509
1510    #[test]
1511    fn with_logging_builder_dup() {
1512        let config = BocpdConfig::default().with_logging(true);
1513        assert!(config.enable_logging);
1514        let config2 = config.with_logging(false);
1515        assert!(!config2.enable_logging);
1516    }
1517
1518    // ── BocpdRegime ─────────────────────────────────────────────────
1519
1520    #[test]
1521    fn regime_as_str() {
1522        assert_eq!(BocpdRegime::Steady.as_str(), "steady");
1523        assert_eq!(BocpdRegime::Burst.as_str(), "burst");
1524        assert_eq!(BocpdRegime::Transitional.as_str(), "transitional");
1525    }
1526
1527    #[test]
1528    fn regime_display() {
1529        assert_eq!(format!("{}", BocpdRegime::Steady), "steady");
1530        assert_eq!(format!("{}", BocpdRegime::Burst), "burst");
1531        assert_eq!(format!("{}", BocpdRegime::Transitional), "transitional");
1532    }
1533
1534    #[test]
1535    fn regime_default_is_steady_dup() {
1536        assert_eq!(BocpdRegime::default(), BocpdRegime::Steady);
1537    }
1538
1539    #[test]
1540    fn regime_clone_eq() {
1541        let r = BocpdRegime::Burst;
1542        assert_eq!(r, r.clone());
1543        assert_ne!(BocpdRegime::Steady, BocpdRegime::Burst);
1544    }
1545
1546    // ── BocpdDetector constructors ──────────────────────────────────
1547
1548    #[test]
1549    fn detector_default_impl() {
1550        let det = BocpdDetector::default();
1551        assert_eq!(det.regime(), BocpdRegime::Steady);
1552        assert_eq!(det.observation_count(), 0);
1553    }
1554
1555    #[test]
1556    fn detector_config_accessor() {
1557        let config = BocpdConfig {
1558            mu_steady_ms: 300.0,
1559            ..Default::default()
1560        };
1561        let det = BocpdDetector::new(config);
1562        assert!((det.config().mu_steady_ms - 300.0).abs() < f64::EPSILON);
1563    }
1564
1565    #[test]
1566    fn detector_run_length_posterior_accessor() {
1567        let det = BocpdDetector::with_defaults();
1568        let posterior = det.run_length_posterior();
1569        // Default max_run_length = 100, so K+1 = 101 elements
1570        assert_eq!(posterior.len(), 101);
1571        let sum: f64 = posterior.iter().sum();
1572        assert!((sum - 1.0).abs() < 1e-10);
1573    }
1574
1575    #[test]
1576    fn detector_expected_run_length_initial() {
1577        let det = BocpdDetector::with_defaults();
1578        let erl = det.expected_run_length();
1579        // Uniform posterior over 0..=100 → mean = 50.0
1580        assert!((erl - 50.0).abs() < 1e-10);
1581    }
1582
1583    #[test]
1584    fn detector_last_evidence_initially_none() {
1585        let det = BocpdDetector::with_defaults();
1586        assert!(det.last_evidence().is_none());
1587    }
1588
1589    // ── set_decision_context ────────────────────────────────────────
1590
1591    #[test]
1592    fn set_decision_context_updates_evidence() {
1593        let mut det = BocpdDetector::with_defaults();
1594        det.observe_event(Instant::now());
1595        det.set_decision_context(16, 40, false);
1596
1597        let ev = det.last_evidence().unwrap();
1598        assert_eq!(ev.recommended_delay_ms, Some(16)); // steady default
1599        assert_eq!(ev.hard_deadline_forced, Some(false));
1600    }
1601
1602    #[test]
1603    fn set_decision_context_noop_without_evidence() {
1604        let mut det = BocpdDetector::with_defaults();
1605        // No observe_event called, so no evidence
1606        det.set_decision_context(16, 40, true);
1607        assert!(det.last_evidence().is_none());
1608    }
1609
1610    // ── evidence_jsonl ──────────────────────────────────────────────
1611
1612    #[test]
1613    fn evidence_jsonl_none_when_disabled() {
1614        let mut det = BocpdDetector::with_defaults();
1615        det.observe_event(Instant::now());
1616        assert!(det.evidence_jsonl().is_none());
1617    }
1618
1619    #[test]
1620    fn decision_log_jsonl_none_when_disabled() {
1621        let mut det = BocpdDetector::with_defaults();
1622        det.observe_event(Instant::now());
1623        assert!(det.decision_log_jsonl(16, 40, false).is_none());
1624    }
1625
1626    #[test]
1627    fn decision_log_jsonl_none_without_evidence() {
1628        let det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1629        // No observe_event called
1630        assert!(det.decision_log_jsonl(16, 40, false).is_none());
1631    }
1632
1633    // ── BocpdEvidence Display ───────────────────────────────────────
1634
1635    #[test]
1636    fn evidence_display_format() {
1637        let mut det = BocpdDetector::with_defaults();
1638        det.observe_event(Instant::now());
1639        let ev = det.last_evidence().unwrap();
1640        let display = format!("{}", ev);
1641        assert!(display.contains("BOCPD Evidence:"));
1642        assert!(display.contains("Regime:"));
1643        assert!(display.contains("P(burst)"));
1644        assert!(display.contains("Log BF:"));
1645        assert!(display.contains("Observation:"));
1646        assert!(display.contains("Likelihoods:"));
1647        assert!(display.contains("E[run-length]:"));
1648        assert!(display.contains("Observations:"));
1649    }
1650
1651    // ── BocpdEvidence to_jsonl with optional fields ─────────────────
1652
1653    #[test]
1654    fn evidence_jsonl_with_decision_context() {
1655        let mut det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1656        det.observe_event(Instant::now());
1657        det.set_decision_context(16, 40, true);
1658
1659        let jsonl = det.evidence_jsonl().unwrap();
1660        assert!(jsonl.contains("\"delay_ms\":16"));
1661        assert!(jsonl.contains("\"forced_deadline\":true"));
1662    }
1663
1664    #[test]
1665    fn evidence_jsonl_null_optional_fields() {
1666        let mut det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1667        det.observe_event(Instant::now());
1668
1669        let jsonl = det.evidence_jsonl().unwrap();
1670        assert!(jsonl.contains("\"delay_ms\":null"));
1671        assert!(jsonl.contains("\"forced_deadline\":null"));
1672    }
1673
1674    // ── recommended_delay edge cases ────────────────────────────────
1675
1676    #[test]
1677    fn recommended_delay_at_exact_thresholds() {
1678        let mut det = BocpdDetector::with_defaults();
1679        // At exactly steady_threshold (0.3) → transitional
1680        det.p_burst = 0.3;
1681        let delay = det.recommended_delay(16, 40);
1682        assert_eq!(delay, 16); // t = (0.3 - 0.3) / (0.7 - 0.3) = 0
1683
1684        // At exactly burst_threshold (0.7) → transitional
1685        det.p_burst = 0.7;
1686        let delay = det.recommended_delay(16, 40);
1687        assert_eq!(delay, 40); // t = (0.7 - 0.3) / (0.7 - 0.3) = 1
1688    }
1689
1690    #[test]
1691    fn recommended_delay_midpoint() {
1692        let mut det = BocpdDetector::with_defaults();
1693        det.p_burst = 0.5; // midpoint of [0.3, 0.7]
1694        let delay = det.recommended_delay(16, 40);
1695        assert_eq!(delay, 28); // 16 * 0.5 + 40 * 0.5 = 28
1696    }
1697
1698    // ── reset clears last_event_time ────────────────────────────────
1699
1700    #[test]
1701    fn reset_clears_last_event_time() {
1702        let mut det = BocpdDetector::with_defaults();
1703        let start = Instant::now();
1704        det.observe_event(start);
1705        det.observe_event(start + Duration::from_millis(10));
1706        assert_eq!(det.observation_count(), 2);
1707
1708        det.reset();
1709        assert_eq!(det.observation_count(), 0);
1710        assert!(det.last_evidence().is_none());
1711        // After reset, first event should use default mu_steady_ms
1712        let _ = det.observe_event(start + Duration::from_millis(100));
1713        assert_eq!(det.observation_count(), 1);
1714    }
1715
1716    // ── First event uses default inter-arrival ──────────────────────
1717
1718    #[test]
1719    fn first_event_uses_steady_default_dup() {
1720        let mut det = BocpdDetector::with_defaults();
1721        let t = Instant::now();
1722        det.observe_event(t);
1723        let ev = det.last_evidence().unwrap();
1724        // First event should use mu_steady_ms as default observation
1725        assert!((ev.observation_ms - 200.0).abs() < f64::EPSILON);
1726    }
1727
1728    // ── Observation clamping ────────────────────────────────────────
1729
1730    #[test]
1731    fn observation_clamped_to_bounds() {
1732        let mut det = BocpdDetector::with_defaults();
1733        let start = Instant::now();
1734        // First event (uses default, not clamped)
1735        det.observe_event(start);
1736        // Second event 0ms later → should be clamped to min_observation_ms
1737        det.observe_event(start);
1738        let ev = det.last_evidence().unwrap();
1739        assert!(ev.observation_ms >= det.config().min_observation_ms);
1740    }
1741
1742    // ── BocpdConfig clone/debug ─────────────────────────────────────
1743
1744    #[test]
1745    fn config_clone_debug() {
1746        let config = BocpdConfig::default();
1747        let cloned = config.clone();
1748        assert!((cloned.mu_steady_ms - 200.0).abs() < f64::EPSILON);
1749        let dbg = format!("{:?}", config);
1750        assert!(dbg.contains("BocpdConfig"));
1751    }
1752
1753    #[test]
1754    fn detector_clone_debug() {
1755        let det = BocpdDetector::with_defaults();
1756        let cloned = det.clone();
1757        assert!((cloned.p_burst() - det.p_burst()).abs() < f64::EPSILON);
1758        let dbg = format!("{:?}", det);
1759        assert!(dbg.contains("BocpdDetector"));
1760    }
1761
1762    #[test]
1763    fn evidence_clone() {
1764        let mut det = BocpdDetector::with_defaults();
1765        det.observe_event(Instant::now());
1766        let ev = det.last_evidence().unwrap().clone();
1767        assert_eq!(ev.observation_count, 1);
1768    }
1769}