Skip to main content

ftui_runtime/
voi_sampling.rs

1#![forbid(unsafe_code)]
2
3//! Value-of-Information (VOI) Sampling Policy for expensive measurements.
4//!
5//! This module decides **when** to sample costly latency/cost measurements so
6//! overhead stays low while guarantees remain intact.
7//!
8//! # Mathematical Model
9//!
10//! We treat "violation" observations as Bernoulli random variables:
11//!
12//! ```text
13//! X_t ∈ {0,1},  X_t = 1  ⇔  measurement violates SLA threshold
14//! ```
15//!
16//! We maintain a Beta prior/posterior over the violation probability `p`:
17//!
18//! ```text
19//! p ~ Beta(α, β)
20//! α ← α + X_t
21//! β ← β + (1 − X_t)
22//! ```
23//!
24//! The posterior variance is:
25//!
26//! ```text
27//! Var[p] = αβ / ((α+β)^2 (α+β+1))
28//! ```
29//!
30//! ## Expected VOI (Variance Reduction)
31//!
32//! The expected variance **after one more sample** is:
33//!
34//! ```text
35//! E[Var[p] | one sample] =
36//!   p̂ · Var[Beta(α+1,β)] + (1−p̂) · Var[Beta(α,β+1)]
37//! ```
38//!
39//! where `p̂ = α / (α+β)` is the posterior mean.
40//!
41//! The **value of information** (VOI) is the expected reduction:
42//!
43//! ```text
44//! VOI = Var[p] − E[Var[p] | one sample]  ≥ 0
45//! ```
46//!
47//! ## Anytime-Valid Safety (E-Process Layer)
48//!
49//! We optionally track an e-process over the same Bernoulli stream to keep
50//! decisions anytime-valid. Sampling decisions depend only on **past** data,
51//! so the e-process remains valid under adaptive sampling:
52//!
53//! ```text
54//! W_0 = 1
55//! W_t = W_{t-1} × (1 + λ (X_t − μ₀))
56//! ```
57//!
58//! where `μ₀` is the baseline violation rate under H₀ and λ is a betting
59//! fraction (clamped for stability).
60//!
61//! ## Decision Rule (Explainable)
62//!
63//! We compute a scalar **score**:
64//!
65//! ```text
66//! score = VOI × value_scale × (1 + boundary_weight × boundary_score)
67//! boundary_score = 1 / (1 + |log W − log W*|)
68//! ```
69//!
70//! where `W* = 1/α` is the e-value threshold.
71//!
72//! Then:
73//! 1) If `max_interval` exceeded ⇒ **sample** (forced).
74//! 2) If `min_interval` not met ⇒ **skip** (guard).
75//! 3) Else **sample** iff `score ≥ cost`.
76//!
77//! This yields a deterministic, explainable policy that preferentially samples
78//! when uncertainty is high **and** evidence is near the decision boundary.
79//!
80//! # Perf JSONL Schema
81//!
82//! The microbench emits JSONL lines per decision:
83//!
84//! ```text
85//! {"test":"voi_sampling","case":"decision","idx":N,"elapsed_ns":N,"sample":true,"violated":false,"e_value":1.23}
86//! ```
87//!
88//! # Key Invariants
89//!
90//! 1. **Deterministic**: same inputs → same decisions.
91//! 2. **VOI non-negative**: expected variance reduction ≥ 0.
92//! 3. **Anytime-valid**: e-process remains valid under adaptive sampling.
93//! 4. **Bounded silence**: max-interval forces periodic sampling.
94//!
95//! # Failure Modes
96//!
97//! | Condition | Behavior | Rationale |
98//! |-----------|----------|-----------|
99//! | α,β ≤ 0 | Clamp to ε | Avoid invalid Beta |
100//! | μ₀ ≤ 0 or ≥ 1 | Clamp to (ε, 1−ε) | Avoid degenerate e-process |
101//! | λ out of range | Clamp to valid range | Prevent negative wealth |
102//! | cost ≤ 0 | Clamp to ε | Avoid divide-by-zero in evidence |
103//! | max_interval = 0 | Disabled | Explicit opt-out |
104//!
105//! # Usage
106//!
107//! ```ignore
108//! use ftui_runtime::voi_sampling::{VoiConfig, VoiSampler};
109//! use std::time::Instant;
110//!
111//! let mut sampler = VoiSampler::new(VoiConfig::default());
112//! let decision = sampler.decide(Instant::now());
113//! if decision.should_sample {
114//!     let violated = false; // measure and evaluate
115//!     sampler.observe(violated);
116//! }
117//! ```
118
119use std::collections::{BTreeMap, VecDeque};
120use std::sync::atomic::{AtomicU64, Ordering};
121use web_time::{Duration, Instant};
122
123const EPS: f64 = 1e-12;
124const MU_0_MIN: f64 = 1e-6;
125const MU_0_MAX: f64 = 1.0 - 1e-6;
126const LAMBDA_EPS: f64 = 1e-9;
127const E_MIN: f64 = 1e-12;
128const E_MAX: f64 = 1e12;
129const VAR_MAX: f64 = 0.25; // Max Beta variance as α,β → 0
130
131// ---------------------------------------------------------------------------
132// Monotonic counters (exported for observability dashboards / tests)
133// ---------------------------------------------------------------------------
134
135static VOI_SAMPLES_TAKEN_TOTAL: AtomicU64 = AtomicU64::new(0);
136static VOI_SAMPLES_SKIPPED_TOTAL: AtomicU64 = AtomicU64::new(0);
137
138/// Total VOI samples taken (monotonic counter for metrics export).
139#[must_use]
140pub fn voi_samples_taken_total() -> u64 {
141    VOI_SAMPLES_TAKEN_TOTAL.load(Ordering::Relaxed)
142}
143
144/// Total VOI samples skipped (monotonic counter for metrics export).
145#[must_use]
146pub fn voi_samples_skipped_total() -> u64 {
147    VOI_SAMPLES_SKIPPED_TOTAL.load(Ordering::Relaxed)
148}
149
150/// Configuration for the VOI sampling policy.
151#[derive(Debug, Clone)]
152pub struct VoiConfig {
153    /// Significance level α for the e-process threshold (W* = 1/α).
154    /// Default: 0.05.
155    pub alpha: f64,
156
157    /// Beta prior α for violation probability. Default: 1.0.
158    pub prior_alpha: f64,
159
160    /// Beta prior β for violation probability. Default: 1.0.
161    pub prior_beta: f64,
162
163    /// Baseline violation rate μ₀ under H₀. Default: 0.05.
164    pub mu_0: f64,
165
166    /// E-process betting fraction λ. Default: 0.5 (clamped).
167    pub lambda: f64,
168
169    /// Value scaling factor for VOI. Default: 1.0.
170    pub value_scale: f64,
171
172    /// Weight for boundary proximity. Default: 1.0.
173    pub boundary_weight: f64,
174
175    /// Sampling cost (in normalized units). Default: 0.01.
176    pub sample_cost: f64,
177
178    /// Minimum interval between samples (ms). Default: 0.
179    pub min_interval_ms: u64,
180
181    /// Maximum interval between samples (ms). 0 disables time forcing.
182    /// Default: 250.
183    pub max_interval_ms: u64,
184
185    /// Minimum events between samples. Default: 0.
186    pub min_interval_events: u64,
187
188    /// Maximum events between samples. 0 disables event forcing.
189    /// Default: 20.
190    pub max_interval_events: u64,
191
192    /// Enable JSONL-compatible logging.
193    pub enable_logging: bool,
194
195    /// Maximum log entries to retain.
196    pub max_log_entries: usize,
197}
198
199impl Default for VoiConfig {
200    fn default() -> Self {
201        Self {
202            alpha: 0.05,
203            prior_alpha: 1.0,
204            prior_beta: 1.0,
205            mu_0: 0.05,
206            lambda: 0.5,
207            value_scale: 1.0,
208            boundary_weight: 1.0,
209            sample_cost: 0.01,
210            min_interval_ms: 0,
211            max_interval_ms: 250,
212            min_interval_events: 0,
213            max_interval_events: 20,
214            enable_logging: false,
215            max_log_entries: 2048,
216        }
217    }
218}
219
220/// Sampling decision with full evidence.
221#[derive(Debug, Clone)]
222pub struct VoiDecision {
223    pub event_idx: u64,
224    pub should_sample: bool,
225    pub forced_by_interval: bool,
226    pub blocked_by_min_interval: bool,
227    pub voi_gain: f64,
228    pub score: f64,
229    pub cost: f64,
230    pub log_bayes_factor: f64,
231    pub posterior_mean: f64,
232    pub posterior_variance: f64,
233    pub e_value: f64,
234    pub e_threshold: f64,
235    pub boundary_score: f64,
236    pub events_since_sample: u64,
237    pub time_since_sample_ms: f64,
238    pub reason: &'static str,
239}
240
241impl VoiDecision {
242    /// Serialize decision to JSONL.
243    #[must_use]
244    pub fn to_jsonl(&self) -> String {
245        format!(
246            r#"{{"event":"voi_decision","idx":{},"should_sample":{},"forced":{},"blocked":{},"voi_gain":{:.6},"score":{:.6},"cost":{:.6},"log_bayes_factor":{:.4},"posterior_mean":{:.6},"posterior_variance":{:.6},"e_value":{:.6},"e_threshold":{:.6},"boundary_score":{:.6},"events_since_sample":{},"time_since_sample_ms":{:.3},"reason":"{}"}}"#,
247            self.event_idx,
248            self.should_sample,
249            self.forced_by_interval,
250            self.blocked_by_min_interval,
251            self.voi_gain,
252            self.score,
253            self.cost,
254            self.log_bayes_factor,
255            self.posterior_mean,
256            self.posterior_variance,
257            self.e_value,
258            self.e_threshold,
259            self.boundary_score,
260            self.events_since_sample,
261            self.time_since_sample_ms,
262            self.reason
263        )
264    }
265}
266
267/// Observation result after a sample is taken.
268#[derive(Debug, Clone)]
269pub struct VoiObservation {
270    pub event_idx: u64,
271    pub sample_idx: u64,
272    pub violated: bool,
273    pub posterior_mean: f64,
274    pub posterior_variance: f64,
275    pub alpha: f64,
276    pub beta: f64,
277    pub e_value: f64,
278    pub e_threshold: f64,
279}
280
281impl VoiObservation {
282    /// Serialize observation to JSONL.
283    #[must_use]
284    pub fn to_jsonl(&self) -> String {
285        format!(
286            r#"{{"event":"voi_observe","idx":{},"sample_idx":{},"violated":{},"posterior_mean":{:.6},"posterior_variance":{:.6},"alpha":{:.3},"beta":{:.3},"e_value":{:.6},"e_threshold":{:.6}}}"#,
287            self.event_idx,
288            self.sample_idx,
289            self.violated,
290            self.posterior_mean,
291            self.posterior_variance,
292            self.alpha,
293            self.beta,
294            self.e_value,
295            self.e_threshold
296        )
297    }
298}
299
300/// Log entry for VOI sampling.
301#[derive(Debug, Clone)]
302pub enum VoiLogEntry {
303    Decision(VoiDecision),
304    Observation(VoiObservation),
305}
306
307impl VoiLogEntry {
308    /// Serialize log entry to JSONL.
309    #[must_use]
310    pub fn to_jsonl(&self) -> String {
311        match self {
312            Self::Decision(decision) => decision.to_jsonl(),
313            Self::Observation(obs) => obs.to_jsonl(),
314        }
315    }
316}
317
318/// Summary statistics for VOI sampling.
319#[derive(Debug, Clone)]
320pub struct VoiSummary {
321    pub total_events: u64,
322    pub total_samples: u64,
323    pub forced_samples: u64,
324    pub skipped_events: u64,
325    pub current_mean: f64,
326    pub current_variance: f64,
327    pub e_value: f64,
328    pub e_threshold: f64,
329    pub avg_events_between_samples: f64,
330    pub avg_ms_between_samples: f64,
331}
332
333/// Snapshot of VOI sampler state for debug overlays.
334#[derive(Debug, Clone)]
335pub struct VoiSamplerSnapshot {
336    pub captured_ms: u64,
337    pub alpha: f64,
338    pub beta: f64,
339    pub posterior_mean: f64,
340    pub posterior_variance: f64,
341    pub expected_variance_after: f64,
342    pub voi_gain: f64,
343    pub last_decision: Option<VoiDecision>,
344    pub last_observation: Option<VoiObservation>,
345    pub recent_logs: Vec<VoiLogEntry>,
346}
347
348/// VOI-driven sampler with Beta-Bernoulli posterior and e-process control.
349#[derive(Debug, Clone)]
350pub struct VoiSampler {
351    config: VoiConfig,
352    alpha: f64,
353    beta: f64,
354    mu_0: f64,
355    lambda: f64,
356    e_value: f64,
357    e_threshold: f64,
358    event_idx: u64,
359    sample_idx: u64,
360    forced_samples: u64,
361    last_sample_event: u64,
362    last_sample_time: Instant,
363    start_time: Instant,
364    last_decision_forced: bool,
365    logs: VecDeque<VoiLogEntry>,
366    last_decision: Option<VoiDecision>,
367    last_observation: Option<VoiObservation>,
368}
369
370impl VoiSampler {
371    /// Create a new VOI sampler with given config.
372    pub fn new(config: VoiConfig) -> Self {
373        Self::new_at(config, Instant::now())
374    }
375
376    /// Create a new VOI sampler at a specific time (for deterministic tests).
377    pub fn new_at(config: VoiConfig, now: Instant) -> Self {
378        let mut cfg = config;
379
380        let prior_alpha = if cfg.prior_alpha.is_nan() {
381            EPS
382        } else {
383            cfg.prior_alpha.max(EPS)
384        };
385        let prior_beta = if cfg.prior_beta.is_nan() {
386            EPS
387        } else {
388            cfg.prior_beta.max(EPS)
389        };
390        let mu_0 = if cfg.mu_0.is_nan() {
391            0.5
392        } else {
393            cfg.mu_0.clamp(MU_0_MIN, MU_0_MAX)
394        };
395        let lambda_max = (1.0 / (1.0 - mu_0)) - LAMBDA_EPS;
396        let lambda = if cfg.lambda.is_nan() {
397            LAMBDA_EPS
398        } else {
399            cfg.lambda.clamp(LAMBDA_EPS, lambda_max)
400        };
401
402        cfg.value_scale = if cfg.value_scale.is_nan() {
403            EPS
404        } else {
405            cfg.value_scale.max(EPS)
406        };
407        cfg.boundary_weight = if cfg.boundary_weight.is_nan() {
408            0.0
409        } else {
410            cfg.boundary_weight.max(0.0)
411        };
412        cfg.sample_cost = if cfg.sample_cost.is_nan() {
413            EPS
414        } else {
415            cfg.sample_cost.max(EPS)
416        };
417        cfg.max_log_entries = cfg.max_log_entries.max(1);
418
419        let e_threshold = 1.0 / cfg.alpha.max(EPS);
420
421        Self {
422            config: cfg,
423            alpha: prior_alpha,
424            beta: prior_beta,
425            mu_0,
426            lambda,
427            e_value: 1.0,
428            e_threshold,
429            event_idx: 0,
430            sample_idx: 0,
431            forced_samples: 0,
432            last_sample_event: 0,
433            last_sample_time: now,
434            start_time: now,
435            last_decision_forced: false,
436            logs: VecDeque::new(),
437            last_decision: None,
438            last_observation: None,
439        }
440    }
441
442    /// Access the sampler configuration.
443    #[must_use]
444    pub fn config(&self) -> &VoiConfig {
445        &self.config
446    }
447
448    /// Current posterior parameters (alpha, beta).
449    #[must_use]
450    pub fn posterior_params(&self) -> (f64, f64) {
451        (self.alpha, self.beta)
452    }
453
454    /// Current posterior mean.
455    #[must_use]
456    pub fn posterior_mean(&self) -> f64 {
457        beta_mean(self.alpha, self.beta)
458    }
459
460    /// Current posterior variance.
461    #[must_use]
462    pub fn posterior_variance(&self) -> f64 {
463        beta_variance(self.alpha, self.beta)
464    }
465
466    /// Expected posterior variance after one additional sample.
467    #[must_use]
468    pub fn expected_variance_after(&self) -> f64 {
469        expected_variance_after(self.alpha, self.beta)
470    }
471
472    /// Most recent decision, if any.
473    #[must_use]
474    pub fn last_decision(&self) -> Option<&VoiDecision> {
475        self.last_decision.as_ref()
476    }
477
478    /// Most recent observation, if any.
479    #[must_use]
480    pub fn last_observation(&self) -> Option<&VoiObservation> {
481        self.last_observation.as_ref()
482    }
483
484    /// Decide whether to sample at time `now`.
485    pub fn decide(&mut self, now: Instant) -> VoiDecision {
486        self.event_idx += 1;
487
488        let events_since_sample = if self.sample_idx == 0 {
489            self.event_idx
490        } else {
491            self.event_idx.saturating_sub(self.last_sample_event)
492        };
493        let time_since_sample = if now >= self.last_sample_time {
494            now.saturating_duration_since(self.last_sample_time)
495        } else {
496            Duration::ZERO
497        };
498
499        let forced_by_events = self.config.max_interval_events > 0
500            && events_since_sample >= self.config.max_interval_events;
501        let forced_by_time = self.config.max_interval_ms > 0
502            && time_since_sample >= Duration::from_millis(self.config.max_interval_ms);
503        let forced = forced_by_events || forced_by_time;
504
505        let blocked_by_events = self.sample_idx > 0
506            && self.config.min_interval_events > 0
507            && events_since_sample < self.config.min_interval_events;
508        let blocked_by_time = self.sample_idx > 0
509            && self.config.min_interval_ms > 0
510            && time_since_sample < Duration::from_millis(self.config.min_interval_ms);
511        let blocked = blocked_by_events || blocked_by_time;
512
513        let variance = beta_variance(self.alpha, self.beta);
514        let expected_after = expected_variance_after(self.alpha, self.beta);
515        let voi_gain = (variance - expected_after).max(0.0);
516
517        let boundary_score = boundary_score(self.e_value, self.e_threshold);
518        let score = voi_gain
519            * self.config.value_scale
520            * (1.0 + self.config.boundary_weight * boundary_score);
521        let cost = self.config.sample_cost;
522        let log_bayes_factor = log10_ratio(score, cost);
523
524        let should_sample = if forced {
525            true
526        } else if blocked {
527            false
528        } else {
529            score >= cost
530        };
531
532        let reason = if forced {
533            "forced_interval"
534        } else if blocked {
535            "min_interval"
536        } else if should_sample {
537            "voi_ge_cost"
538        } else {
539            "voi_lt_cost"
540        };
541
542        let decision = VoiDecision {
543            event_idx: self.event_idx,
544            should_sample,
545            forced_by_interval: forced,
546            blocked_by_min_interval: blocked,
547            voi_gain,
548            score,
549            cost,
550            log_bayes_factor,
551            posterior_mean: beta_mean(self.alpha, self.beta),
552            posterior_variance: variance,
553            e_value: self.e_value,
554            e_threshold: self.e_threshold,
555            boundary_score,
556            events_since_sample,
557            time_since_sample_ms: time_since_sample.as_secs_f64() * 1000.0,
558            reason,
559        };
560
561        self.last_decision = Some(decision.clone());
562        self.last_decision_forced = forced;
563
564        // --- Tracing observability (bd-37a.4) ---
565        let _span = tracing::debug_span!(
566            "voi.evaluate",
567            decision_context = %reason,
568            voi_estimate = %voi_gain,
569            sample_cost = %cost,
570            sample_decision = should_sample,
571        )
572        .entered();
573
574        tracing::debug!(
575            target: "ftui.voi",
576            voi_gain = %voi_gain,
577            score = %score,
578            cost = %cost,
579            log_bayes_factor = %log_bayes_factor,
580            posterior_mean = %decision.posterior_mean,
581            posterior_variance = %variance,
582            boundary_score = %boundary_score,
583            e_value = %self.e_value,
584            reason = %reason,
585            event_idx = self.event_idx,
586            "voi calculation"
587        );
588
589        tracing::debug!(
590            target: "ftui.voi",
591            voi_estimate_value = %voi_gain,
592            "voi estimate histogram"
593        );
594
595        if should_sample {
596            VOI_SAMPLES_TAKEN_TOTAL.fetch_add(1, Ordering::Relaxed);
597        } else {
598            VOI_SAMPLES_SKIPPED_TOTAL.fetch_add(1, Ordering::Relaxed);
599        }
600
601        if self.config.enable_logging {
602            self.push_log(VoiLogEntry::Decision(decision.clone()));
603        }
604
605        decision
606    }
607
608    /// Record a sampled observation at time `now`.
609    pub fn observe_at(&mut self, violated: bool, now: Instant) -> VoiObservation {
610        self.sample_idx += 1;
611        self.last_sample_event = self.event_idx;
612        self.last_sample_time = now;
613        if self.last_decision_forced {
614            self.forced_samples += 1;
615        }
616
617        if violated {
618            self.alpha += 1.0;
619        } else {
620            self.beta += 1.0;
621        }
622
623        self.update_eprocess(violated);
624
625        let obs_posterior_mean = beta_mean(self.alpha, self.beta);
626        let obs_posterior_variance = beta_variance(self.alpha, self.beta);
627        let obs_voi_estimate =
628            (obs_posterior_variance - expected_variance_after(self.alpha, self.beta)).max(0.0);
629
630        let observation = VoiObservation {
631            event_idx: self.event_idx,
632            sample_idx: self.sample_idx,
633            violated,
634            posterior_mean: obs_posterior_mean,
635            posterior_variance: obs_posterior_variance,
636            alpha: self.alpha,
637            beta: self.beta,
638            e_value: self.e_value,
639            e_threshold: self.e_threshold,
640        };
641
642        // --- TRACE: individual utility estimate after observation ---
643        tracing::trace!(
644            target: "ftui.voi",
645            violated = violated,
646            alpha = %self.alpha,
647            beta = %self.beta,
648            posterior_mean = %obs_posterior_mean,
649            posterior_variance = %obs_posterior_variance,
650            e_value = %self.e_value,
651            voi_estimate_value = %obs_voi_estimate,
652            sample_idx = self.sample_idx,
653            "utility estimate after observation"
654        );
655
656        self.last_observation = Some(observation.clone());
657        if self.config.enable_logging {
658            self.push_log(VoiLogEntry::Observation(observation.clone()));
659        }
660
661        observation
662    }
663
664    /// Record a sampled observation using `Instant::now()`.
665    pub fn observe(&mut self, violated: bool) -> VoiObservation {
666        self.observe_at(violated, Instant::now())
667    }
668
669    /// Current summary statistics.
670    #[must_use]
671    pub fn summary(&self) -> VoiSummary {
672        let skipped_events = self.event_idx.saturating_sub(self.sample_idx);
673        let avg_events_between_samples = if self.sample_idx > 0 {
674            self.event_idx as f64 / self.sample_idx as f64
675        } else {
676            0.0
677        };
678        let elapsed_ms = self.start_time.elapsed().as_secs_f64() * 1000.0;
679        let avg_ms_between_samples = if self.sample_idx > 0 {
680            elapsed_ms / self.sample_idx as f64
681        } else {
682            0.0
683        };
684
685        VoiSummary {
686            total_events: self.event_idx,
687            total_samples: self.sample_idx,
688            forced_samples: self.forced_samples,
689            skipped_events,
690            current_mean: beta_mean(self.alpha, self.beta),
691            current_variance: beta_variance(self.alpha, self.beta),
692            e_value: self.e_value,
693            e_threshold: self.e_threshold,
694            avg_events_between_samples,
695            avg_ms_between_samples,
696        }
697    }
698
699    /// Access current logs.
700    #[must_use]
701    pub fn logs(&self) -> &VecDeque<VoiLogEntry> {
702        &self.logs
703    }
704
705    /// Render logs as JSONL.
706    #[must_use]
707    pub fn logs_to_jsonl(&self) -> String {
708        self.logs
709            .iter()
710            .map(VoiLogEntry::to_jsonl)
711            .collect::<Vec<_>>()
712            .join("\n")
713    }
714
715    /// Create a snapshot of the sampler state for debug overlays.
716    #[must_use]
717    pub fn snapshot(&self, max_logs: usize, captured_ms: u64) -> VoiSamplerSnapshot {
718        let expected_after = expected_variance_after(self.alpha, self.beta);
719        let variance = beta_variance(self.alpha, self.beta);
720        let voi_gain = (variance - expected_after).max(0.0);
721        let mut recent_logs: Vec<VoiLogEntry> = self
722            .logs
723            .iter()
724            .rev()
725            .take(max_logs.max(1))
726            .cloned()
727            .collect();
728        recent_logs.reverse();
729
730        VoiSamplerSnapshot {
731            captured_ms,
732            alpha: self.alpha,
733            beta: self.beta,
734            posterior_mean: beta_mean(self.alpha, self.beta),
735            posterior_variance: variance,
736            expected_variance_after: expected_after,
737            voi_gain,
738            last_decision: self.last_decision.clone(),
739            last_observation: self.last_observation.clone(),
740            recent_logs,
741        }
742    }
743
744    fn push_log(&mut self, entry: VoiLogEntry) {
745        if self.logs.len() >= self.config.max_log_entries {
746            self.logs.pop_front();
747        }
748        self.logs.push_back(entry);
749    }
750
751    fn update_eprocess(&mut self, violated: bool) {
752        let x = if violated { 1.0 } else { 0.0 };
753        let factor = 1.0 + self.lambda * (x - self.mu_0);
754        let next = self.e_value * factor.max(EPS);
755        self.e_value = next.clamp(E_MIN, E_MAX);
756    }
757
758    /// Increment forced sample counter (for testing/integration).
759    pub fn mark_forced_sample(&mut self) {
760        self.forced_samples += 1;
761    }
762}
763
764fn beta_mean(alpha: f64, beta: f64) -> f64 {
765    alpha / (alpha + beta)
766}
767
768fn beta_variance(alpha: f64, beta: f64) -> f64 {
769    let sum = alpha + beta;
770    if sum <= 0.0 {
771        return 0.0;
772    }
773    let var = (alpha * beta) / (sum * sum * (sum + 1.0));
774    var.min(VAR_MAX)
775}
776
777fn expected_variance_after(alpha: f64, beta: f64) -> f64 {
778    let p = beta_mean(alpha, beta);
779    let var_success = beta_variance(alpha + 1.0, beta);
780    let var_failure = beta_variance(alpha, beta + 1.0);
781    p * var_success + (1.0 - p) * var_failure
782}
783
784fn boundary_score(e_value: f64, threshold: f64) -> f64 {
785    let e = e_value.max(EPS);
786    let t = threshold.max(EPS);
787    let gap = (e.ln() - t.ln()).abs();
788    1.0 / (1.0 + gap)
789}
790
791fn log10_ratio(score: f64, cost: f64) -> f64 {
792    let ratio = (score + EPS) / (cost + EPS);
793    ratio.ln() / std::f64::consts::LN_10
794}
795
796// =============================================================================
797// Deferred refinement scheduler (bd-2vr05.15.4.4)
798// =============================================================================
799
800/// Configuration for VOI-guided deferred refinement scheduling.
801///
802/// This scheduler only allocates work from spare frame budget and uses a
803/// deterministic VOI score with fairness boosts to avoid starvation.
804#[derive(Debug, Clone, PartialEq)]
805pub struct DeferredRefinementConfig {
806    /// Minimum spare budget to keep reserved for non-optional work.
807    pub min_spare_budget_us: u64,
808    /// Hard cap on optional refinements scheduled per frame.
809    pub max_refinements_per_frame: usize,
810    /// Minimum effective VOI required to schedule a refinement.
811    pub voi_gain_cutoff: f64,
812    /// Fairness boost added per skipped frame for a region.
813    pub fairness_boost_per_skip: f64,
814    /// Maximum fairness boost for a region.
815    pub fairness_boost_cap: f64,
816}
817
818impl Default for DeferredRefinementConfig {
819    fn default() -> Self {
820        Self {
821            min_spare_budget_us: 500,
822            max_refinements_per_frame: 2,
823            voi_gain_cutoff: 0.01,
824            fairness_boost_per_skip: 0.02,
825            fairness_boost_cap: 1.0,
826        }
827    }
828}
829
830/// Candidate optional refinement unit for a region/paragraph bucket.
831#[derive(Debug, Clone, Copy, PartialEq)]
832pub struct RefinementCandidate {
833    /// Deterministic region identifier.
834    pub region_id: u64,
835    /// Estimated runtime cost for this refinement.
836    pub estimated_cost_us: u64,
837    /// Predicted value-of-information gain (higher is better).
838    pub voi_gain: f64,
839}
840
841/// Selected refinement with explainable scoring terms.
842#[derive(Debug, Clone, Copy, PartialEq)]
843pub struct RefinementSelection {
844    pub region_id: u64,
845    pub estimated_cost_us: u64,
846    pub voi_gain: f64,
847    pub fairness_boost: f64,
848    pub effective_voi: f64,
849    pub score: f64,
850}
851
852/// Frame-level refinement plan.
853#[derive(Debug, Clone, PartialEq)]
854pub struct DeferredRefinementPlan {
855    pub frame_budget_us: u64,
856    pub mandatory_work_us: u64,
857    pub reserved_spare_us: u64,
858    pub optional_budget_us: u64,
859    pub spent_optional_us: u64,
860    pub selected: Vec<RefinementSelection>,
861}
862
863impl DeferredRefinementPlan {
864    /// Hard budget predicate for CI assertions.
865    #[must_use]
866    pub fn hard_budget_respected(&self) -> bool {
867        self.mandatory_work_us
868            .saturating_add(self.reserved_spare_us)
869            .saturating_add(self.spent_optional_us)
870            <= self.frame_budget_us
871    }
872}
873
874/// Deterministic VOI-guided scheduler for optional deferred refinements.
875#[derive(Debug, Clone)]
876pub struct DeferredRefinementScheduler {
877    config: DeferredRefinementConfig,
878    skipped_frames: BTreeMap<u64, u32>,
879}
880
881impl DeferredRefinementScheduler {
882    /// Create a scheduler from explicit config.
883    #[must_use]
884    pub fn new(config: DeferredRefinementConfig) -> Self {
885        Self {
886            config,
887            skipped_frames: BTreeMap::new(),
888        }
889    }
890
891    /// Access scheduler config.
892    #[must_use]
893    pub fn config(&self) -> &DeferredRefinementConfig {
894        &self.config
895    }
896
897    /// Number of consecutive frames this region has been skipped.
898    #[must_use]
899    pub fn skipped_frames_for(&self, region_id: u64) -> u32 {
900        self.skipped_frames.get(&region_id).copied().unwrap_or(0)
901    }
902
903    /// Compute a deterministic frame plan under a hard budget cap.
904    ///
905    /// - `frame_budget_us`: total frame budget.
906    /// - `mandatory_work_us`: non-optional cost already committed.
907    /// - `candidates`: optional refinements for this frame.
908    pub fn plan_frame(
909        &mut self,
910        frame_budget_us: u64,
911        mandatory_work_us: u64,
912        candidates: &[RefinementCandidate],
913    ) -> DeferredRefinementPlan {
914        let reserved_spare_us = self.config.min_spare_budget_us;
915        let available_after_mandatory = frame_budget_us.saturating_sub(mandatory_work_us);
916        let optional_budget_us = available_after_mandatory.saturating_sub(reserved_spare_us);
917
918        let mut scored = Vec::with_capacity(candidates.len());
919        for candidate in candidates.iter().copied() {
920            let skip_count = self.skipped_frames_for(candidate.region_id);
921            let fairness_boost = (skip_count as f64 * self.config.fairness_boost_per_skip)
922                .min(self.config.fairness_boost_cap);
923            let voi_gain = if candidate.voi_gain.is_finite() {
924                candidate.voi_gain.max(0.0)
925            } else {
926                0.0
927            };
928            let effective_voi = voi_gain + fairness_boost;
929            let normalized_cost = candidate.estimated_cost_us.max(1) as f64;
930            let score = effective_voi / normalized_cost;
931            scored.push((
932                candidate,
933                fairness_boost,
934                effective_voi,
935                score,
936                candidate.region_id,
937            ));
938        }
939
940        // Deterministic ordering:
941        // 1) score DESC, 2) effective_voi DESC, 3) region_id ASC.
942        scored.sort_by(|a, b| {
943            b.3.total_cmp(&a.3)
944                .then_with(|| b.2.total_cmp(&a.2))
945                .then_with(|| a.4.cmp(&b.4))
946        });
947
948        let mut remaining_optional_us = optional_budget_us;
949        let mut selected = Vec::with_capacity(self.config.max_refinements_per_frame);
950        let mut selected_ids = BTreeMap::<u64, ()>::new();
951
952        for (candidate, fairness_boost, effective_voi, score, _) in scored {
953            if selected.len() >= self.config.max_refinements_per_frame {
954                break;
955            }
956            if effective_voi < self.config.voi_gain_cutoff {
957                continue;
958            }
959            if candidate.estimated_cost_us > remaining_optional_us {
960                continue;
961            }
962            selected.push(RefinementSelection {
963                region_id: candidate.region_id,
964                estimated_cost_us: candidate.estimated_cost_us,
965                voi_gain: if candidate.voi_gain.is_finite() {
966                    candidate.voi_gain.max(0.0)
967                } else {
968                    0.0
969                },
970                fairness_boost,
971                effective_voi,
972                score,
973            });
974            selected_ids.insert(candidate.region_id, ());
975            remaining_optional_us =
976                remaining_optional_us.saturating_sub(candidate.estimated_cost_us);
977        }
978
979        // Update fairness accounting for all regions visible this frame.
980        for candidate in candidates {
981            if selected_ids.contains_key(&candidate.region_id) {
982                self.skipped_frames.insert(candidate.region_id, 0);
983            } else {
984                let next = self
985                    .skipped_frames_for(candidate.region_id)
986                    .saturating_add(1);
987                self.skipped_frames.insert(candidate.region_id, next);
988            }
989        }
990
991        let spent_optional_us = optional_budget_us.saturating_sub(remaining_optional_us);
992        let plan = DeferredRefinementPlan {
993            frame_budget_us,
994            mandatory_work_us,
995            reserved_spare_us,
996            optional_budget_us,
997            spent_optional_us,
998            selected,
999        };
1000
1001        debug_assert!(plan.hard_budget_respected());
1002        plan
1003    }
1004}
1005
1006// =============================================================================
1007// Tests
1008// =============================================================================
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use proptest::prelude::*;
1014    use std::collections::HashMap;
1015    use std::sync::{Arc, Mutex};
1016    use tracing_subscriber::layer::SubscriberExt;
1017    use tracing_subscriber::registry::LookupSpan;
1018
1019    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
1020    const FNV_PRIME: u64 = 0x100000001b3;
1021
1022    fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
1023        for byte in bytes {
1024            *hash ^= *byte as u64;
1025            *hash = hash.wrapping_mul(FNV_PRIME);
1026        }
1027    }
1028
1029    fn hash_u64(hash: &mut u64, value: u64) {
1030        hash_bytes(hash, &value.to_le_bytes());
1031    }
1032
1033    fn hash_f64(hash: &mut u64, value: f64) {
1034        hash_u64(hash, value.to_bits());
1035    }
1036
1037    fn decision_checksum(decisions: &[VoiDecision]) -> u64 {
1038        let mut hash = FNV_OFFSET_BASIS;
1039        for decision in decisions {
1040            hash_u64(&mut hash, decision.event_idx);
1041            hash_u64(&mut hash, decision.should_sample as u64);
1042            hash_u64(&mut hash, decision.forced_by_interval as u64);
1043            hash_u64(&mut hash, decision.blocked_by_min_interval as u64);
1044            hash_f64(&mut hash, decision.voi_gain);
1045            hash_f64(&mut hash, decision.score);
1046            hash_f64(&mut hash, decision.cost);
1047            hash_f64(&mut hash, decision.log_bayes_factor);
1048            hash_f64(&mut hash, decision.posterior_mean);
1049            hash_f64(&mut hash, decision.posterior_variance);
1050            hash_f64(&mut hash, decision.e_value);
1051            hash_f64(&mut hash, decision.e_threshold);
1052            hash_f64(&mut hash, decision.boundary_score);
1053            hash_u64(&mut hash, decision.events_since_sample);
1054            hash_f64(&mut hash, decision.time_since_sample_ms);
1055        }
1056        hash
1057    }
1058
1059    #[test]
1060    fn voi_gain_non_negative() {
1061        let mut sampler = VoiSampler::new(VoiConfig::default());
1062        let decision = sampler.decide(Instant::now());
1063        assert!(decision.voi_gain >= 0.0);
1064    }
1065
1066    #[test]
1067    fn forced_by_max_interval() {
1068        let config = VoiConfig {
1069            max_interval_events: 2,
1070            sample_cost: 1.0, // discourage sampling unless forced
1071            ..Default::default()
1072        };
1073        let mut sampler = VoiSampler::new(config);
1074        let now = Instant::now();
1075
1076        let d1 = sampler.decide(now);
1077        assert!(!d1.forced_by_interval);
1078
1079        let d2 = sampler.decide(now + Duration::from_millis(1));
1080        assert!(d2.forced_by_interval);
1081        assert!(d2.should_sample);
1082    }
1083
1084    #[test]
1085    fn min_interval_blocks_sampling_after_first() {
1086        let config = VoiConfig {
1087            min_interval_events: 5,
1088            sample_cost: 0.0, // otherwise would sample
1089            ..Default::default()
1090        };
1091        let mut sampler = VoiSampler::new(config);
1092
1093        let first = sampler.decide(Instant::now());
1094        assert!(first.should_sample);
1095        sampler.observe(false);
1096
1097        let second = sampler.decide(Instant::now());
1098        assert!(second.blocked_by_min_interval);
1099        assert!(!second.should_sample);
1100    }
1101
1102    #[test]
1103    fn variance_shrinks_with_samples() {
1104        let mut sampler = VoiSampler::new(VoiConfig::default());
1105        let mut now = Instant::now();
1106        let mut variances = Vec::new();
1107        for _ in 0..5 {
1108            let decision = sampler.decide(now);
1109            if decision.should_sample {
1110                sampler.observe_at(false, now);
1111            }
1112            variances.push(beta_variance(sampler.alpha, sampler.beta));
1113            now += Duration::from_millis(1);
1114        }
1115        for window in variances.windows(2) {
1116            assert!(window[1] <= window[0] + 1e-9);
1117        }
1118    }
1119
1120    #[test]
1121    fn decision_checksum_is_stable() {
1122        let config = VoiConfig {
1123            sample_cost: 0.01,
1124            ..Default::default()
1125        };
1126        let mut now = Instant::now();
1127        let mut sampler = VoiSampler::new_at(config, now);
1128
1129        let mut state: u64 = 42;
1130        let mut decisions = Vec::new();
1131
1132        for _ in 0..32 {
1133            let decision = sampler.decide(now);
1134            let violated = lcg_next(&mut state).is_multiple_of(10);
1135            if decision.should_sample {
1136                sampler.observe_at(violated, now);
1137            }
1138            decisions.push(decision);
1139            now += Duration::from_millis(5 + (lcg_next(&mut state) % 7));
1140        }
1141
1142        let checksum = decision_checksum(&decisions);
1143        assert_eq!(checksum, 0x0b51_d8b6_47a7_b00c);
1144    }
1145
1146    #[test]
1147    fn logs_render_jsonl() {
1148        let config = VoiConfig {
1149            enable_logging: true,
1150            ..Default::default()
1151        };
1152        let mut sampler = VoiSampler::new(config);
1153        let decision = sampler.decide(Instant::now());
1154        if decision.should_sample {
1155            sampler.observe(false);
1156        }
1157        let jsonl = sampler.logs_to_jsonl();
1158        assert!(jsonl.contains("\"event\":\"voi_decision\""));
1159    }
1160
1161    proptest! {
1162        #[test]
1163        fn prop_voi_gain_non_negative(alpha in 0.01f64..10.0, beta in 0.01f64..10.0) {
1164            let var = beta_variance(alpha, beta);
1165            let expected_after = expected_variance_after(alpha, beta);
1166            prop_assert!(var + 1e-12 >= expected_after);
1167        }
1168
1169        #[test]
1170        fn prop_e_value_stays_positive(seq in proptest::collection::vec(any::<bool>(), 1..50)) {
1171            let mut sampler = VoiSampler::new(VoiConfig::default());
1172            let mut now = Instant::now();
1173            for violated in seq {
1174                let decision = sampler.decide(now);
1175                if decision.should_sample {
1176                    sampler.observe_at(violated, now);
1177                }
1178                now += Duration::from_millis(1);
1179                prop_assert!(sampler.e_value >= E_MIN - 1e-12);
1180            }
1181        }
1182    }
1183
1184    // =========================================================================
1185    // Perf microbench (JSONL + budget gate)
1186    // =========================================================================
1187
1188    #[test]
1189    fn perf_voi_sampling_budget() {
1190        use std::io::Write as _;
1191
1192        const RUNS: usize = 60;
1193        let mut sampler = VoiSampler::new(VoiConfig::default());
1194        let mut now = Instant::now();
1195        let mut samples = Vec::with_capacity(RUNS);
1196        let mut jsonl = Vec::new();
1197
1198        for i in 0..RUNS {
1199            let start = Instant::now();
1200            let decision = sampler.decide(now);
1201            let violated = i % 11 == 0;
1202            if decision.should_sample {
1203                sampler.observe_at(violated, now);
1204            }
1205            let elapsed_ns = start.elapsed().as_nanos() as u64;
1206            samples.push(elapsed_ns);
1207
1208            writeln!(
1209                &mut jsonl,
1210                "{{\"test\":\"voi_sampling\",\"case\":\"decision\",\"idx\":{},\
1211\"elapsed_ns\":{},\"sample\":{},\"violated\":{},\"e_value\":{:.6}}}",
1212                i, elapsed_ns, decision.should_sample, violated, sampler.e_value
1213            )
1214            .expect("jsonl write failed");
1215
1216            now += Duration::from_millis(1);
1217        }
1218
1219        fn percentile(samples: &mut [u64], p: f64) -> u64 {
1220            samples.sort_unstable();
1221            let idx = ((samples.len() as f64 - 1.0) * p).round() as usize;
1222            samples[idx]
1223        }
1224
1225        let mut samples_sorted = samples.clone();
1226        let _p50 = percentile(&mut samples_sorted, 0.50);
1227        let p95 = percentile(&mut samples_sorted, 0.95);
1228        let p99 = percentile(&mut samples_sorted, 0.99);
1229
1230        let (budget_p95, budget_p99) = if cfg!(debug_assertions) {
1231            (200_000, 400_000)
1232        } else {
1233            (20_000, 40_000)
1234        };
1235
1236        assert!(p95 <= budget_p95, "p95 {p95}ns exceeds {budget_p95}ns");
1237        assert!(p99 <= budget_p99, "p99 {p99}ns exceeds {budget_p99}ns");
1238
1239        let text = String::from_utf8(jsonl).expect("jsonl utf8");
1240        print!("{text}");
1241        assert_eq!(text.lines().count(), RUNS);
1242    }
1243
1244    // =========================================================================
1245    // Deterministic JSONL output for E2E harness
1246    // =========================================================================
1247
1248    #[test]
1249    fn e2e_deterministic_jsonl() {
1250        use std::io::Write as _;
1251
1252        let seed = std::env::var("VOI_SEED")
1253            .ok()
1254            .and_then(|s| s.parse::<u64>().ok())
1255            .unwrap_or(0);
1256
1257        let config = VoiConfig {
1258            enable_logging: false,
1259            ..Default::default()
1260        };
1261        let mut now = Instant::now();
1262        let mut sampler = VoiSampler::new_at(config, now);
1263        let mut state = seed;
1264        let mut decisions = Vec::new();
1265        let mut jsonl = Vec::new();
1266
1267        for idx in 0..40u64 {
1268            let decision = sampler.decide(now);
1269            let violated = lcg_next(&mut state).is_multiple_of(7);
1270            if decision.should_sample {
1271                sampler.observe_at(violated, now);
1272            }
1273            decisions.push(decision.clone());
1274
1275            writeln!(
1276                &mut jsonl,
1277                "{{\"event\":\"voi_decision\",\"seed\":{},\"idx\":{},\
1278\"sample\":{},\"violated\":{},\"voi_gain\":{:.6}}}",
1279                seed, idx, decision.should_sample, violated, decision.voi_gain
1280            )
1281            .expect("jsonl write failed");
1282
1283            now += Duration::from_millis(3 + (lcg_next(&mut state) % 5));
1284        }
1285
1286        let checksum = decision_checksum(&decisions);
1287        writeln!(
1288            &mut jsonl,
1289            "{{\"event\":\"voi_checksum\",\"seed\":{},\"checksum\":\"{checksum:016x}\",\"decisions\":{}}}",
1290            seed,
1291            decisions.len()
1292        )
1293        .expect("jsonl write failed");
1294
1295        let text = String::from_utf8(jsonl).expect("jsonl utf8");
1296        print!("{text}");
1297        assert!(text.contains("\"event\":\"voi_checksum\""));
1298    }
1299
1300    fn lcg_next(state: &mut u64) -> u64 {
1301        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
1302        *state
1303    }
1304
1305    // =========================================================================
1306    // Additional coverage tests
1307    // =========================================================================
1308
1309    #[test]
1310    fn default_config_values() {
1311        let cfg = VoiConfig::default();
1312        assert!((cfg.alpha - 0.05).abs() < f64::EPSILON);
1313        assert!((cfg.prior_alpha - 1.0).abs() < f64::EPSILON);
1314        assert!((cfg.prior_beta - 1.0).abs() < f64::EPSILON);
1315        assert!((cfg.mu_0 - 0.05).abs() < f64::EPSILON);
1316        assert!((cfg.lambda - 0.5).abs() < f64::EPSILON);
1317        assert_eq!(cfg.max_interval_ms, 250);
1318        assert_eq!(cfg.max_interval_events, 20);
1319        assert_eq!(cfg.min_interval_ms, 0);
1320        assert_eq!(cfg.min_interval_events, 0);
1321        assert!(!cfg.enable_logging);
1322    }
1323
1324    #[test]
1325    fn config_clamping_prior_alpha_beta() {
1326        let config = VoiConfig {
1327            prior_alpha: -1.0,
1328            prior_beta: 0.0,
1329            ..Default::default()
1330        };
1331        let sampler = VoiSampler::new(config);
1332        let (a, b) = sampler.posterior_params();
1333        assert!(a > 0.0, "alpha should be clamped above zero");
1334        assert!(b > 0.0, "beta should be clamped above zero");
1335    }
1336
1337    #[test]
1338    fn config_clamping_mu_0() {
1339        let config = VoiConfig {
1340            mu_0: -0.5,
1341            ..Default::default()
1342        };
1343        let sampler = VoiSampler::new(config);
1344        let mean = sampler.posterior_mean();
1345        assert!((0.0..=1.0).contains(&mean));
1346    }
1347
1348    #[test]
1349    fn config_clamping_sample_cost() {
1350        let config = VoiConfig {
1351            sample_cost: -1.0,
1352            ..Default::default()
1353        };
1354        let mut sampler = VoiSampler::new(config);
1355        let d = sampler.decide(Instant::now());
1356        assert!(d.cost > 0.0, "cost should be clamped above zero");
1357    }
1358
1359    #[test]
1360    fn accessor_config() {
1361        let config = VoiConfig {
1362            alpha: 0.1,
1363            ..Default::default()
1364        };
1365        let sampler = VoiSampler::new(config);
1366        assert!((sampler.config().alpha - 0.1).abs() < f64::EPSILON);
1367    }
1368
1369    #[test]
1370    fn accessor_posterior_params() {
1371        let config = VoiConfig {
1372            prior_alpha: 3.0,
1373            prior_beta: 7.0,
1374            ..Default::default()
1375        };
1376        let sampler = VoiSampler::new(config);
1377        let (a, b) = sampler.posterior_params();
1378        assert!((a - 3.0).abs() < f64::EPSILON);
1379        assert!((b - 7.0).abs() < f64::EPSILON);
1380    }
1381
1382    #[test]
1383    fn accessor_posterior_mean() {
1384        let config = VoiConfig {
1385            prior_alpha: 2.0,
1386            prior_beta: 8.0,
1387            ..Default::default()
1388        };
1389        let sampler = VoiSampler::new(config);
1390        // mean = 2/(2+8) = 0.2
1391        assert!((sampler.posterior_mean() - 0.2).abs() < 1e-9);
1392    }
1393
1394    #[test]
1395    fn accessor_posterior_variance() {
1396        let sampler = VoiSampler::new(VoiConfig::default());
1397        let var = sampler.posterior_variance();
1398        assert!(var >= 0.0);
1399        assert!(var <= 0.25); // max variance for Beta
1400    }
1401
1402    #[test]
1403    fn accessor_expected_variance_after() {
1404        let sampler = VoiSampler::new(VoiConfig::default());
1405        let before = sampler.posterior_variance();
1406        let after = sampler.expected_variance_after();
1407        assert!(
1408            after <= before + 1e-12,
1409            "expected variance after should not exceed current"
1410        );
1411    }
1412
1413    #[test]
1414    fn last_decision_initially_none() {
1415        let sampler = VoiSampler::new(VoiConfig::default());
1416        assert!(sampler.last_decision().is_none());
1417    }
1418
1419    #[test]
1420    fn last_decision_after_decide() {
1421        let mut sampler = VoiSampler::new(VoiConfig::default());
1422        sampler.decide(Instant::now());
1423        assert!(sampler.last_decision().is_some());
1424    }
1425
1426    #[test]
1427    fn last_observation_initially_none() {
1428        let sampler = VoiSampler::new(VoiConfig::default());
1429        assert!(sampler.last_observation().is_none());
1430    }
1431
1432    #[test]
1433    fn last_observation_after_observe() {
1434        let mut sampler = VoiSampler::new(VoiConfig::default());
1435        sampler.decide(Instant::now());
1436        sampler.observe(false);
1437        assert!(sampler.last_observation().is_some());
1438        assert!(!sampler.last_observation().unwrap().violated);
1439    }
1440
1441    #[test]
1442    fn observe_violation_updates_alpha() {
1443        let mut sampler = VoiSampler::new(VoiConfig::default());
1444        let (a_before, _) = sampler.posterior_params();
1445        sampler.decide(Instant::now());
1446        sampler.observe(true);
1447        let (a_after, _) = sampler.posterior_params();
1448        assert!((a_after - a_before - 1.0).abs() < 1e-9);
1449    }
1450
1451    #[test]
1452    fn observe_no_violation_updates_beta() {
1453        let mut sampler = VoiSampler::new(VoiConfig::default());
1454        let (_, b_before) = sampler.posterior_params();
1455        sampler.decide(Instant::now());
1456        sampler.observe(false);
1457        let (_, b_after) = sampler.posterior_params();
1458        assert!((b_after - b_before - 1.0).abs() < 1e-9);
1459    }
1460
1461    #[test]
1462    fn e_value_positive_after_violations() {
1463        let mut sampler = VoiSampler::new(VoiConfig::default());
1464        let mut now = Instant::now();
1465        for _ in 0..10 {
1466            sampler.decide(now);
1467            sampler.observe_at(true, now);
1468            now += Duration::from_millis(1);
1469        }
1470        let summary = sampler.summary();
1471        assert!(summary.e_value > 0.0);
1472    }
1473
1474    #[test]
1475    fn summary_initial_state() {
1476        let sampler = VoiSampler::new(VoiConfig::default());
1477        let summary = sampler.summary();
1478        assert_eq!(summary.total_events, 0);
1479        assert_eq!(summary.total_samples, 0);
1480        assert_eq!(summary.forced_samples, 0);
1481        assert_eq!(summary.skipped_events, 0);
1482        assert!((summary.avg_events_between_samples).abs() < f64::EPSILON);
1483    }
1484
1485    #[test]
1486    fn summary_after_observations() {
1487        let mut sampler = VoiSampler::new(VoiConfig::default());
1488        let now = Instant::now();
1489        sampler.decide(now);
1490        sampler.observe_at(false, now);
1491        sampler.decide(now + Duration::from_millis(10));
1492        let summary = sampler.summary();
1493        assert_eq!(summary.total_events, 2);
1494        assert_eq!(summary.total_samples, 1);
1495        assert_eq!(summary.skipped_events, 1);
1496    }
1497
1498    #[test]
1499    fn mark_forced_sample_increments() {
1500        let mut sampler = VoiSampler::new(VoiConfig::default());
1501        assert_eq!(sampler.summary().forced_samples, 0);
1502        sampler.mark_forced_sample();
1503        sampler.mark_forced_sample();
1504        assert_eq!(sampler.summary().forced_samples, 2);
1505    }
1506
1507    #[test]
1508    fn snapshot_captures_state() {
1509        let mut sampler = VoiSampler::new(VoiConfig {
1510            enable_logging: true,
1511            ..Default::default()
1512        });
1513        let now = Instant::now();
1514        sampler.decide(now);
1515        sampler.observe_at(false, now);
1516
1517        let snap = sampler.snapshot(10, 42);
1518        assert_eq!(snap.captured_ms, 42);
1519        assert!(snap.alpha > 0.0);
1520        assert!(snap.beta > 0.0);
1521        assert!((0.0..=1.0).contains(&snap.posterior_mean));
1522        assert!(snap.last_decision.is_some());
1523        assert!(snap.last_observation.is_some());
1524    }
1525
1526    #[test]
1527    fn log_rotation_respects_max_entries() {
1528        let config = VoiConfig {
1529            enable_logging: true,
1530            max_log_entries: 3,
1531            ..Default::default()
1532        };
1533        let mut sampler = VoiSampler::new(config);
1534        let mut now = Instant::now();
1535
1536        for _ in 0..10 {
1537            let d = sampler.decide(now);
1538            if d.should_sample {
1539                sampler.observe_at(false, now);
1540            }
1541            now += Duration::from_millis(300);
1542        }
1543
1544        assert!(sampler.logs().len() <= 3);
1545    }
1546
1547    #[test]
1548    fn logs_empty_when_logging_disabled() {
1549        let config = VoiConfig {
1550            enable_logging: false,
1551            ..Default::default()
1552        };
1553        let mut sampler = VoiSampler::new(config);
1554        sampler.decide(Instant::now());
1555        assert!(sampler.logs().is_empty());
1556    }
1557
1558    #[test]
1559    fn decision_jsonl_format() {
1560        let mut sampler = VoiSampler::new(VoiConfig::default());
1561        let decision = sampler.decide(Instant::now());
1562        let jsonl = decision.to_jsonl();
1563        assert!(jsonl.starts_with('{'));
1564        assert!(jsonl.ends_with('}'));
1565        assert!(jsonl.contains("\"event\":\"voi_decision\""));
1566        assert!(jsonl.contains("\"should_sample\":"));
1567        assert!(jsonl.contains("\"reason\":"));
1568    }
1569
1570    #[test]
1571    fn observation_jsonl_format() {
1572        let mut sampler = VoiSampler::new(VoiConfig::default());
1573        sampler.decide(Instant::now());
1574        let obs = sampler.observe(false);
1575        let jsonl = obs.to_jsonl();
1576        assert!(jsonl.starts_with('{'));
1577        assert!(jsonl.ends_with('}'));
1578        assert!(jsonl.contains("\"event\":\"voi_observe\""));
1579        assert!(jsonl.contains("\"violated\":false"));
1580    }
1581
1582    #[test]
1583    fn log_entry_jsonl_decision_variant() {
1584        let mut sampler = VoiSampler::new(VoiConfig::default());
1585        let decision = sampler.decide(Instant::now());
1586        let entry = VoiLogEntry::Decision(decision);
1587        let jsonl = entry.to_jsonl();
1588        assert!(jsonl.contains("\"event\":\"voi_decision\""));
1589    }
1590
1591    #[test]
1592    fn log_entry_jsonl_observation_variant() {
1593        let mut sampler = VoiSampler::new(VoiConfig::default());
1594        sampler.decide(Instant::now());
1595        let obs = sampler.observe(true);
1596        let entry = VoiLogEntry::Observation(obs);
1597        let jsonl = entry.to_jsonl();
1598        assert!(jsonl.contains("\"event\":\"voi_observe\""));
1599        assert!(jsonl.contains("\"violated\":true"));
1600    }
1601
1602    #[test]
1603    fn time_based_max_interval_forces_sample() {
1604        let config = VoiConfig {
1605            max_interval_ms: 100,
1606            max_interval_events: 0, // disable event forcing
1607            sample_cost: 100.0,     // very high cost to prevent VOI sampling
1608            ..Default::default()
1609        };
1610        let now = Instant::now();
1611        let mut sampler = VoiSampler::new_at(config, now);
1612
1613        // First decision (no prior sample, but event interval might force)
1614        let _d1 = sampler.decide(now + Duration::from_millis(1));
1615        sampler.observe_at(false, now + Duration::from_millis(1));
1616
1617        // Second decision well within time window
1618        let d2 = sampler.decide(now + Duration::from_millis(10));
1619        assert!(!d2.forced_by_interval, "should not force within 100ms");
1620
1621        // Decision after 100+ ms
1622        let d3 = sampler.decide(now + Duration::from_millis(110));
1623        assert!(d3.forced_by_interval, "should force after 100ms");
1624        assert!(d3.should_sample);
1625    }
1626
1627    #[test]
1628    fn time_based_min_interval_blocks() {
1629        let config = VoiConfig {
1630            min_interval_ms: 50,
1631            min_interval_events: 0,
1632            ..Default::default()
1633        };
1634        let now = Instant::now();
1635        let mut sampler = VoiSampler::new_at(config, now);
1636
1637        // First sample
1638        let d1 = sampler.decide(now);
1639        assert!(d1.should_sample);
1640        sampler.observe_at(false, now);
1641
1642        // Too soon
1643        let d2 = sampler.decide(now + Duration::from_millis(10));
1644        assert!(d2.blocked_by_min_interval);
1645        assert!(!d2.should_sample);
1646
1647        // After min interval
1648        let d3 = sampler.decide(now + Duration::from_millis(60));
1649        assert!(!d3.blocked_by_min_interval);
1650    }
1651
1652    #[test]
1653    fn decision_reason_strings() {
1654        // forced
1655        let config = VoiConfig {
1656            max_interval_events: 1,
1657            ..Default::default()
1658        };
1659        let mut sampler = VoiSampler::new(config);
1660        let d = sampler.decide(Instant::now());
1661        assert_eq!(d.reason, "forced_interval");
1662    }
1663
1664    #[test]
1665    fn decision_reason_min_interval() {
1666        let config = VoiConfig {
1667            min_interval_events: 100,
1668            sample_cost: 0.0,
1669            ..Default::default()
1670        };
1671        let mut sampler = VoiSampler::new(config);
1672        sampler.decide(Instant::now());
1673        sampler.observe(false);
1674        let d = sampler.decide(Instant::now());
1675        assert_eq!(d.reason, "min_interval");
1676    }
1677
1678    #[test]
1679    fn beta_mean_basic() {
1680        assert!((beta_mean(1.0, 1.0) - 0.5).abs() < 1e-9);
1681        assert!((beta_mean(2.0, 8.0) - 0.2).abs() < 1e-9);
1682        assert!((beta_mean(5.0, 5.0) - 0.5).abs() < 1e-9);
1683    }
1684
1685    #[test]
1686    fn beta_variance_basic() {
1687        // Var(Beta(1,1)) = 1*1 / (4*3) = 1/12
1688        let var = beta_variance(1.0, 1.0);
1689        assert!((var - 1.0 / 12.0).abs() < 1e-9);
1690    }
1691
1692    #[test]
1693    fn beta_variance_degenerate() {
1694        assert!((beta_variance(0.0, 0.0)).abs() < f64::EPSILON);
1695        assert!((beta_variance(-1.0, -1.0)).abs() < f64::EPSILON);
1696    }
1697
1698    #[test]
1699    fn boundary_score_at_threshold() {
1700        // When e_value equals threshold, gap = 0, score = 1.0
1701        let score = boundary_score(20.0, 20.0);
1702        assert!((score - 1.0).abs() < 1e-9);
1703    }
1704
1705    #[test]
1706    fn boundary_score_far_from_threshold() {
1707        // Large gap → small score
1708        let score = boundary_score(1.0, 1e6);
1709        assert!(score < 0.1);
1710    }
1711
1712    #[test]
1713    fn logs_to_jsonl_multiple_entries() {
1714        let config = VoiConfig {
1715            enable_logging: true,
1716            ..Default::default()
1717        };
1718        let mut sampler = VoiSampler::new(config);
1719        let mut now = Instant::now();
1720        for _ in 0..5 {
1721            let d = sampler.decide(now);
1722            if d.should_sample {
1723                sampler.observe_at(false, now);
1724            }
1725            now += Duration::from_millis(300);
1726        }
1727        let jsonl = sampler.logs_to_jsonl();
1728        let line_count = jsonl.lines().count();
1729        assert!(
1730            line_count >= 2,
1731            "should have at least 2 log lines, got {line_count}"
1732        );
1733    }
1734
1735    // =========================================================================
1736    // Tracing capture infrastructure
1737    // =========================================================================
1738
1739    #[derive(Debug, Clone)]
1740    #[allow(dead_code)]
1741    struct CapturedSpan {
1742        name: String,
1743        target: String,
1744        level: tracing::Level,
1745        fields: HashMap<String, String>,
1746        parent_name: Option<String>,
1747    }
1748
1749    #[derive(Debug, Clone)]
1750    #[allow(dead_code)]
1751    struct CapturedEvent {
1752        level: tracing::Level,
1753        target: String,
1754        message: String,
1755        fields: HashMap<String, String>,
1756        parent_span_name: Option<String>,
1757    }
1758
1759    struct SpanCapture {
1760        spans: Arc<Mutex<Vec<CapturedSpan>>>,
1761        events: Arc<Mutex<Vec<CapturedEvent>>>,
1762        span_index: Arc<Mutex<HashMap<u64, usize>>>,
1763    }
1764
1765    impl SpanCapture {
1766        fn new() -> (Self, CaptureHandle) {
1767            let spans = Arc::new(Mutex::new(Vec::new()));
1768            let events = Arc::new(Mutex::new(Vec::new()));
1769            let span_index = Arc::new(Mutex::new(HashMap::new()));
1770
1771            let handle = CaptureHandle {
1772                spans: spans.clone(),
1773                events: events.clone(),
1774            };
1775
1776            (
1777                Self {
1778                    spans,
1779                    events,
1780                    span_index,
1781                },
1782                handle,
1783            )
1784        }
1785    }
1786
1787    struct CaptureHandle {
1788        spans: Arc<Mutex<Vec<CapturedSpan>>>,
1789        events: Arc<Mutex<Vec<CapturedEvent>>>,
1790    }
1791
1792    impl CaptureHandle {
1793        fn spans(&self) -> Vec<CapturedSpan> {
1794            self.spans.lock().unwrap().clone()
1795        }
1796
1797        fn events(&self) -> Vec<CapturedEvent> {
1798            self.events.lock().unwrap().clone()
1799        }
1800    }
1801
1802    struct FieldVisitor(Vec<(String, String)>);
1803
1804    impl tracing::field::Visit for FieldVisitor {
1805        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1806            self.0
1807                .push((field.name().to_string(), format!("{value:?}")));
1808        }
1809
1810        fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
1811            self.0.push((field.name().to_string(), value.to_string()));
1812        }
1813
1814        fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
1815            self.0.push((field.name().to_string(), value.to_string()));
1816        }
1817
1818        fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
1819            self.0.push((field.name().to_string(), value.to_string()));
1820        }
1821
1822        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1823            self.0.push((field.name().to_string(), value.to_string()));
1824        }
1825
1826        fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
1827            self.0.push((field.name().to_string(), value.to_string()));
1828        }
1829    }
1830
1831    impl<S> tracing_subscriber::Layer<S> for SpanCapture
1832    where
1833        S: tracing::Subscriber + for<'a> LookupSpan<'a>,
1834    {
1835        fn on_new_span(
1836            &self,
1837            attrs: &tracing::span::Attributes<'_>,
1838            id: &tracing::span::Id,
1839            ctx: tracing_subscriber::layer::Context<'_, S>,
1840        ) {
1841            let mut visitor = FieldVisitor(Vec::new());
1842            attrs.record(&mut visitor);
1843
1844            let parent_name = ctx
1845                .current_span()
1846                .id()
1847                .and_then(|pid| ctx.span(pid))
1848                .map(|span_ref| span_ref.name().to_string());
1849
1850            let mut fields: HashMap<String, String> = visitor.0.into_iter().collect();
1851            for field in attrs.metadata().fields() {
1852                fields.entry(field.name().to_string()).or_default();
1853            }
1854
1855            let mut spans = self.spans.lock().unwrap();
1856            let idx = spans.len();
1857            spans.push(CapturedSpan {
1858                name: attrs.metadata().name().to_string(),
1859                target: attrs.metadata().target().to_string(),
1860                level: *attrs.metadata().level(),
1861                fields,
1862                parent_name,
1863            });
1864
1865            self.span_index.lock().unwrap().insert(id.into_u64(), idx);
1866        }
1867
1868        fn on_record(
1869            &self,
1870            id: &tracing::span::Id,
1871            values: &tracing::span::Record<'_>,
1872            _ctx: tracing_subscriber::layer::Context<'_, S>,
1873        ) {
1874            let mut visitor = FieldVisitor(Vec::new());
1875            values.record(&mut visitor);
1876
1877            let index = self.span_index.lock().unwrap();
1878            if let Some(&idx) = index.get(&id.into_u64()) {
1879                let mut spans = self.spans.lock().unwrap();
1880                if let Some(span) = spans.get_mut(idx) {
1881                    for (k, v) in visitor.0 {
1882                        span.fields.insert(k, v);
1883                    }
1884                }
1885            }
1886        }
1887
1888        fn on_event(
1889            &self,
1890            event: &tracing::Event<'_>,
1891            ctx: tracing_subscriber::layer::Context<'_, S>,
1892        ) {
1893            let mut visitor = FieldVisitor(Vec::new());
1894            event.record(&mut visitor);
1895
1896            let fields: HashMap<String, String> = visitor.0.clone().into_iter().collect();
1897            let message = visitor
1898                .0
1899                .iter()
1900                .find(|(k, _)| k == "message")
1901                .map(|(_, v)| v.clone())
1902                .unwrap_or_default();
1903
1904            let parent_span_name = ctx
1905                .current_span()
1906                .id()
1907                .and_then(|id| ctx.span(id))
1908                .map(|span_ref| span_ref.name().to_string());
1909
1910            self.events.lock().unwrap().push(CapturedEvent {
1911                level: *event.metadata().level(),
1912                target: event.metadata().target().to_string(),
1913                message,
1914                fields,
1915                parent_span_name,
1916            });
1917        }
1918    }
1919
1920    fn with_captured_tracing<F>(f: F) -> CaptureHandle
1921    where
1922        F: FnOnce(),
1923    {
1924        let (layer, handle) = SpanCapture::new();
1925        let subscriber = tracing_subscriber::registry().with(layer);
1926        tracing::subscriber::with_default(subscriber, f);
1927        handle
1928    }
1929
1930    // =========================================================================
1931    // Tracing span field assertions (bd-37a.4)
1932    // =========================================================================
1933
1934    #[test]
1935    fn span_voi_evaluate_has_required_fields() {
1936        let handle = with_captured_tracing(|| {
1937            let mut sampler = VoiSampler::new(VoiConfig::default());
1938            sampler.decide(Instant::now());
1939        });
1940
1941        let spans = handle.spans();
1942        let voi_spans: Vec<_> = spans.iter().filter(|s| s.name == "voi.evaluate").collect();
1943        assert!(
1944            !voi_spans.is_empty(),
1945            "expected at least one voi.evaluate span, got none"
1946        );
1947
1948        let span = &voi_spans[0];
1949        assert!(
1950            span.fields.contains_key("decision_context"),
1951            "missing decision_context field"
1952        );
1953        assert!(
1954            span.fields.contains_key("voi_estimate"),
1955            "missing voi_estimate field"
1956        );
1957        assert!(
1958            span.fields.contains_key("sample_cost"),
1959            "missing sample_cost field"
1960        );
1961        assert!(
1962            span.fields.contains_key("sample_decision"),
1963            "missing sample_decision field"
1964        );
1965    }
1966
1967    #[test]
1968    fn span_voi_evaluate_decision_context_values() {
1969        let handle = with_captured_tracing(|| {
1970            // Force a high-cost scenario so first decision is not forced
1971            let config = VoiConfig {
1972                max_interval_events: 0,
1973                max_interval_ms: 0,
1974                sample_cost: 1000.0,
1975                ..Default::default()
1976            };
1977            let mut sampler = VoiSampler::new(config);
1978            sampler.decide(Instant::now());
1979        });
1980
1981        let spans = handle.spans();
1982        let voi_spans: Vec<_> = spans.iter().filter(|s| s.name == "voi.evaluate").collect();
1983        assert!(!voi_spans.is_empty());
1984
1985        let ctx = &voi_spans[0].fields["decision_context"];
1986        assert!(
1987            ctx == "voi_lt_cost" || ctx == "voi_ge_cost",
1988            "unexpected context: {ctx}"
1989        );
1990    }
1991
1992    #[test]
1993    fn span_voi_evaluate_forced_interval_context() {
1994        let handle = with_captured_tracing(|| {
1995            let config = VoiConfig {
1996                max_interval_events: 1,
1997                ..Default::default()
1998            };
1999            let mut sampler = VoiSampler::new(config);
2000            sampler.decide(Instant::now());
2001        });
2002
2003        let spans = handle.spans();
2004        let voi_spans: Vec<_> = spans.iter().filter(|s| s.name == "voi.evaluate").collect();
2005        assert!(!voi_spans.is_empty());
2006        assert_eq!(voi_spans[0].fields["decision_context"], "forced_interval");
2007    }
2008
2009    // =========================================================================
2010    // DEBUG log assertions
2011    // =========================================================================
2012
2013    #[test]
2014    fn debug_log_voi_calculation() {
2015        let handle = with_captured_tracing(|| {
2016            let mut sampler = VoiSampler::new(VoiConfig::default());
2017            sampler.decide(Instant::now());
2018        });
2019
2020        let events = handle.events();
2021        let debug_events: Vec<_> = events
2022            .iter()
2023            .filter(|e| {
2024                e.level == tracing::Level::DEBUG
2025                    && e.target == "ftui.voi"
2026                    && e.fields.contains_key("voi_gain")
2027            })
2028            .collect();
2029
2030        assert!(
2031            !debug_events.is_empty(),
2032            "expected at least one DEBUG voi calculation event"
2033        );
2034
2035        let evt = &debug_events[0];
2036        assert!(evt.fields.contains_key("score"), "missing score field");
2037        assert!(evt.fields.contains_key("cost"), "missing cost field");
2038        assert!(
2039            evt.fields.contains_key("posterior_mean"),
2040            "missing posterior_mean"
2041        );
2042        assert!(
2043            evt.fields.contains_key("boundary_score"),
2044            "missing boundary_score"
2045        );
2046    }
2047
2048    #[test]
2049    fn debug_log_voi_estimate_histogram() {
2050        let handle = with_captured_tracing(|| {
2051            let mut sampler = VoiSampler::new(VoiConfig::default());
2052            sampler.decide(Instant::now());
2053        });
2054
2055        let events = handle.events();
2056        let hist_events: Vec<_> = events
2057            .iter()
2058            .filter(|e| {
2059                e.level == tracing::Level::DEBUG
2060                    && e.target == "ftui.voi"
2061                    && e.fields.contains_key("voi_estimate_value")
2062            })
2063            .collect();
2064
2065        assert!(
2066            !hist_events.is_empty(),
2067            "expected voi_estimate_value histogram event"
2068        );
2069    }
2070
2071    // =========================================================================
2072    // TRACE log assertions (utility estimates)
2073    // =========================================================================
2074
2075    #[test]
2076    fn trace_log_utility_estimate_after_observation() {
2077        let handle = with_captured_tracing(|| {
2078            let mut sampler = VoiSampler::new(VoiConfig::default());
2079            let now = Instant::now();
2080            sampler.decide(now);
2081            sampler.observe_at(false, now);
2082        });
2083
2084        let events = handle.events();
2085        let trace_events: Vec<_> = events
2086            .iter()
2087            .filter(|e| {
2088                e.level == tracing::Level::TRACE
2089                    && e.target == "ftui.voi"
2090                    && e.fields.contains_key("voi_estimate_value")
2091            })
2092            .collect();
2093
2094        assert!(
2095            !trace_events.is_empty(),
2096            "expected TRACE utility estimate event after observe"
2097        );
2098
2099        let evt = &trace_events[0];
2100        assert!(evt.fields.contains_key("alpha"), "missing alpha");
2101        assert!(evt.fields.contains_key("beta"), "missing beta");
2102        assert!(
2103            evt.fields.contains_key("posterior_mean"),
2104            "missing posterior_mean"
2105        );
2106        assert!(evt.fields.contains_key("e_value"), "missing e_value");
2107    }
2108
2109    // =========================================================================
2110    // Counter verification
2111    // =========================================================================
2112
2113    #[test]
2114    fn counters_increment_on_sample_decision() {
2115        let handle = with_captured_tracing(|| {
2116            let mut sampler = VoiSampler::new(VoiConfig::default());
2117            let mut now = Instant::now();
2118            for _ in 0..5 {
2119                let d = sampler.decide(now);
2120                if d.should_sample {
2121                    sampler.observe_at(false, now);
2122                }
2123                now += Duration::from_millis(100);
2124            }
2125        });
2126
2127        // Verify the tracing events were emitted (indirect counter check).
2128        // We can't reliably check global atomic counters in parallel tests,
2129        // so we verify the DEBUG events contain voi_gain (one per decide call).
2130        let events = handle.events();
2131        let calc_events: Vec<_> = events
2132            .iter()
2133            .filter(|e| {
2134                e.level == tracing::Level::DEBUG
2135                    && e.target == "ftui.voi"
2136                    && e.fields.contains_key("voi_gain")
2137            })
2138            .collect();
2139
2140        assert_eq!(
2141            calc_events.len(),
2142            5,
2143            "expected 5 voi calculation events for 5 decide() calls"
2144        );
2145    }
2146
2147    #[test]
2148    fn counter_accessors_are_callable() {
2149        // Verify the counter accessor functions exist and return non-panicking values.
2150        let taken = voi_samples_taken_total();
2151        let skipped = voi_samples_skipped_total();
2152        // Verify the sum doesn't overflow (sanity check).
2153        let _ = taken.checked_add(skipped).expect("counter overflow");
2154    }
2155
2156    #[test]
2157    fn counters_increase_monotonically() {
2158        let before_taken = voi_samples_taken_total();
2159        let before_skipped = voi_samples_skipped_total();
2160
2161        let mut sampler = VoiSampler::new(VoiConfig::default());
2162        let mut now = Instant::now();
2163        for _ in 0..10 {
2164            let d = sampler.decide(now);
2165            if d.should_sample {
2166                sampler.observe_at(false, now);
2167            }
2168            now += Duration::from_millis(100);
2169        }
2170
2171        let after_taken = voi_samples_taken_total();
2172        let after_skipped = voi_samples_skipped_total();
2173
2174        // After 10 decisions, at least some should have been taken or skipped.
2175        assert!(
2176            (after_taken + after_skipped) >= (before_taken + before_skipped) + 10,
2177            "expected at least 10 counter increments total, \
2178             taken: {before_taken}→{after_taken}, skipped: {before_skipped}→{after_skipped}"
2179        );
2180    }
2181
2182    // =========================================================================
2183    // Deferred refinement scheduler tests (bd-2vr05.15.4.4)
2184    // =========================================================================
2185
2186    #[test]
2187    fn deferred_scheduler_respects_hard_budget() {
2188        let mut scheduler = DeferredRefinementScheduler::new(DeferredRefinementConfig {
2189            min_spare_budget_us: 200,
2190            max_refinements_per_frame: 3,
2191            voi_gain_cutoff: 0.01,
2192            fairness_boost_per_skip: 0.02,
2193            fairness_boost_cap: 0.5,
2194        });
2195
2196        let candidates = [
2197            RefinementCandidate {
2198                region_id: 1,
2199                estimated_cost_us: 600,
2200                voi_gain: 0.25,
2201            },
2202            RefinementCandidate {
2203                region_id: 2,
2204                estimated_cost_us: 500,
2205                voi_gain: 0.21,
2206            },
2207            RefinementCandidate {
2208                region_id: 3,
2209                estimated_cost_us: 300,
2210                voi_gain: 0.08,
2211            },
2212        ];
2213
2214        let plan = scheduler.plan_frame(3_000, 1_900, &candidates);
2215        assert!(plan.hard_budget_respected());
2216        assert!(plan.spent_optional_us <= plan.optional_budget_us);
2217        assert!(
2218            plan.mandatory_work_us
2219                .saturating_add(plan.reserved_spare_us)
2220                .saturating_add(plan.spent_optional_us)
2221                <= 3_000
2222        );
2223    }
2224
2225    #[test]
2226    fn deferred_scheduler_is_deterministic_for_identical_inputs() {
2227        let config = DeferredRefinementConfig {
2228            min_spare_budget_us: 100,
2229            max_refinements_per_frame: 2,
2230            voi_gain_cutoff: 0.01,
2231            fairness_boost_per_skip: 0.03,
2232            fairness_boost_cap: 0.6,
2233        };
2234        let mut a = DeferredRefinementScheduler::new(config.clone());
2235        let mut b = DeferredRefinementScheduler::new(config);
2236
2237        let candidates = [
2238            RefinementCandidate {
2239                region_id: 11,
2240                estimated_cost_us: 450,
2241                voi_gain: 0.13,
2242            },
2243            RefinementCandidate {
2244                region_id: 22,
2245                estimated_cost_us: 500,
2246                voi_gain: 0.11,
2247            },
2248            RefinementCandidate {
2249                region_id: 33,
2250                estimated_cost_us: 350,
2251                voi_gain: 0.07,
2252            },
2253        ];
2254
2255        for _ in 0..25 {
2256            let pa = a.plan_frame(2_800, 1_600, &candidates);
2257            let pb = b.plan_frame(2_800, 1_600, &candidates);
2258            assert_eq!(pa, pb);
2259        }
2260    }
2261
2262    #[test]
2263    fn deferred_scheduler_fairness_avoids_starvation() {
2264        let mut scheduler = DeferredRefinementScheduler::new(DeferredRefinementConfig {
2265            min_spare_budget_us: 400,
2266            max_refinements_per_frame: 1,
2267            voi_gain_cutoff: 0.01,
2268            fairness_boost_per_skip: 0.05,
2269            fairness_boost_cap: 2.0,
2270        });
2271
2272        let candidates = [
2273            RefinementCandidate {
2274                region_id: 100,
2275                estimated_cost_us: 700,
2276                voi_gain: 0.20,
2277            },
2278            RefinementCandidate {
2279                region_id: 200,
2280                estimated_cost_us: 700,
2281                voi_gain: 0.02,
2282            },
2283        ];
2284
2285        let mut low_region_selected = 0u32;
2286        for _ in 0..30 {
2287            let plan = scheduler.plan_frame(4_000, 2_700, &candidates);
2288            assert!(plan.hard_budget_respected());
2289            if plan.selected.iter().any(|s| s.region_id == 200) {
2290                low_region_selected = low_region_selected.saturating_add(1);
2291            }
2292        }
2293
2294        assert!(
2295            low_region_selected > 0,
2296            "fairness boosting should eventually schedule the lower-VOI region"
2297        );
2298    }
2299}