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::VecDeque;
120use std::time::{Duration, Instant};
121
122const EPS: f64 = 1e-12;
123const MU_0_MIN: f64 = 1e-6;
124const MU_0_MAX: f64 = 1.0 - 1e-6;
125const LAMBDA_EPS: f64 = 1e-9;
126const E_MIN: f64 = 1e-12;
127const E_MAX: f64 = 1e12;
128const VAR_MAX: f64 = 0.25; // Max Beta variance as α,β → 0
129
130/// Configuration for the VOI sampling policy.
131#[derive(Debug, Clone)]
132pub struct VoiConfig {
133    /// Significance level α for the e-process threshold (W* = 1/α).
134    /// Default: 0.05.
135    pub alpha: f64,
136
137    /// Beta prior α for violation probability. Default: 1.0.
138    pub prior_alpha: f64,
139
140    /// Beta prior β for violation probability. Default: 1.0.
141    pub prior_beta: f64,
142
143    /// Baseline violation rate μ₀ under H₀. Default: 0.05.
144    pub mu_0: f64,
145
146    /// E-process betting fraction λ. Default: 0.5 (clamped).
147    pub lambda: f64,
148
149    /// Value scaling factor for VOI. Default: 1.0.
150    pub value_scale: f64,
151
152    /// Weight for boundary proximity. Default: 1.0.
153    pub boundary_weight: f64,
154
155    /// Sampling cost (in normalized units). Default: 0.01.
156    pub sample_cost: f64,
157
158    /// Minimum interval between samples (ms). Default: 0.
159    pub min_interval_ms: u64,
160
161    /// Maximum interval between samples (ms). 0 disables time forcing.
162    /// Default: 250.
163    pub max_interval_ms: u64,
164
165    /// Minimum events between samples. Default: 0.
166    pub min_interval_events: u64,
167
168    /// Maximum events between samples. 0 disables event forcing.
169    /// Default: 20.
170    pub max_interval_events: u64,
171
172    /// Enable JSONL-compatible logging.
173    pub enable_logging: bool,
174
175    /// Maximum log entries to retain.
176    pub max_log_entries: usize,
177}
178
179impl Default for VoiConfig {
180    fn default() -> Self {
181        Self {
182            alpha: 0.05,
183            prior_alpha: 1.0,
184            prior_beta: 1.0,
185            mu_0: 0.05,
186            lambda: 0.5,
187            value_scale: 1.0,
188            boundary_weight: 1.0,
189            sample_cost: 0.01,
190            min_interval_ms: 0,
191            max_interval_ms: 250,
192            min_interval_events: 0,
193            max_interval_events: 20,
194            enable_logging: false,
195            max_log_entries: 2048,
196        }
197    }
198}
199
200/// Sampling decision with full evidence.
201#[derive(Debug, Clone)]
202pub struct VoiDecision {
203    pub event_idx: u64,
204    pub should_sample: bool,
205    pub forced_by_interval: bool,
206    pub blocked_by_min_interval: bool,
207    pub voi_gain: f64,
208    pub score: f64,
209    pub cost: f64,
210    pub log_bayes_factor: f64,
211    pub posterior_mean: f64,
212    pub posterior_variance: f64,
213    pub e_value: f64,
214    pub e_threshold: f64,
215    pub boundary_score: f64,
216    pub events_since_sample: u64,
217    pub time_since_sample_ms: f64,
218    pub reason: &'static str,
219}
220
221impl VoiDecision {
222    /// Serialize decision to JSONL.
223    #[must_use]
224    pub fn to_jsonl(&self) -> String {
225        format!(
226            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":"{}"}}"#,
227            self.event_idx,
228            self.should_sample,
229            self.forced_by_interval,
230            self.blocked_by_min_interval,
231            self.voi_gain,
232            self.score,
233            self.cost,
234            self.log_bayes_factor,
235            self.posterior_mean,
236            self.posterior_variance,
237            self.e_value,
238            self.e_threshold,
239            self.boundary_score,
240            self.events_since_sample,
241            self.time_since_sample_ms,
242            self.reason
243        )
244    }
245}
246
247/// Observation result after a sample is taken.
248#[derive(Debug, Clone)]
249pub struct VoiObservation {
250    pub event_idx: u64,
251    pub sample_idx: u64,
252    pub violated: bool,
253    pub posterior_mean: f64,
254    pub posterior_variance: f64,
255    pub alpha: f64,
256    pub beta: f64,
257    pub e_value: f64,
258    pub e_threshold: f64,
259}
260
261impl VoiObservation {
262    /// Serialize observation to JSONL.
263    #[must_use]
264    pub fn to_jsonl(&self) -> String {
265        format!(
266            r#"{{"event":"voi_observe","idx":{},"sample_idx":{},"violated":{},"posterior_mean":{:.6},"posterior_variance":{:.6},"alpha":{:.3},"beta":{:.3},"e_value":{:.6},"e_threshold":{:.6}}}"#,
267            self.event_idx,
268            self.sample_idx,
269            self.violated,
270            self.posterior_mean,
271            self.posterior_variance,
272            self.alpha,
273            self.beta,
274            self.e_value,
275            self.e_threshold
276        )
277    }
278}
279
280/// Log entry for VOI sampling.
281#[derive(Debug, Clone)]
282pub enum VoiLogEntry {
283    Decision(VoiDecision),
284    Observation(VoiObservation),
285}
286
287impl VoiLogEntry {
288    /// Serialize log entry to JSONL.
289    #[must_use]
290    pub fn to_jsonl(&self) -> String {
291        match self {
292            Self::Decision(decision) => decision.to_jsonl(),
293            Self::Observation(obs) => obs.to_jsonl(),
294        }
295    }
296}
297
298/// Summary statistics for VOI sampling.
299#[derive(Debug, Clone)]
300pub struct VoiSummary {
301    pub total_events: u64,
302    pub total_samples: u64,
303    pub forced_samples: u64,
304    pub skipped_events: u64,
305    pub current_mean: f64,
306    pub current_variance: f64,
307    pub e_value: f64,
308    pub e_threshold: f64,
309    pub avg_events_between_samples: f64,
310    pub avg_ms_between_samples: f64,
311}
312
313/// Snapshot of VOI sampler state for debug overlays.
314#[derive(Debug, Clone)]
315pub struct VoiSamplerSnapshot {
316    pub captured_ms: u64,
317    pub alpha: f64,
318    pub beta: f64,
319    pub posterior_mean: f64,
320    pub posterior_variance: f64,
321    pub expected_variance_after: f64,
322    pub voi_gain: f64,
323    pub last_decision: Option<VoiDecision>,
324    pub last_observation: Option<VoiObservation>,
325    pub recent_logs: Vec<VoiLogEntry>,
326}
327
328/// VOI-driven sampler with Beta-Bernoulli posterior and e-process control.
329#[derive(Debug, Clone)]
330pub struct VoiSampler {
331    config: VoiConfig,
332    alpha: f64,
333    beta: f64,
334    mu_0: f64,
335    lambda: f64,
336    e_value: f64,
337    e_threshold: f64,
338    event_idx: u64,
339    sample_idx: u64,
340    forced_samples: u64,
341    last_sample_event: u64,
342    last_sample_time: Instant,
343    start_time: Instant,
344    last_decision_forced: bool,
345    logs: VecDeque<VoiLogEntry>,
346    last_decision: Option<VoiDecision>,
347    last_observation: Option<VoiObservation>,
348}
349
350impl VoiSampler {
351    /// Create a new VOI sampler with given config.
352    pub fn new(config: VoiConfig) -> Self {
353        Self::new_at(config, Instant::now())
354    }
355
356    /// Create a new VOI sampler at a specific time (for deterministic tests).
357    pub fn new_at(config: VoiConfig, now: Instant) -> Self {
358        let mut cfg = config;
359
360        let prior_alpha = cfg.prior_alpha.max(EPS);
361        let prior_beta = cfg.prior_beta.max(EPS);
362        let mu_0 = cfg.mu_0.clamp(MU_0_MIN, MU_0_MAX);
363        let lambda_max = (1.0 / (1.0 - mu_0)) - LAMBDA_EPS;
364        let lambda = cfg.lambda.clamp(LAMBDA_EPS, lambda_max);
365
366        cfg.value_scale = cfg.value_scale.max(EPS);
367        cfg.boundary_weight = cfg.boundary_weight.max(0.0);
368        cfg.sample_cost = cfg.sample_cost.max(EPS);
369        cfg.max_log_entries = cfg.max_log_entries.max(1);
370
371        let e_threshold = 1.0 / cfg.alpha.max(EPS);
372
373        Self {
374            config: cfg,
375            alpha: prior_alpha,
376            beta: prior_beta,
377            mu_0,
378            lambda,
379            e_value: 1.0,
380            e_threshold,
381            event_idx: 0,
382            sample_idx: 0,
383            forced_samples: 0,
384            last_sample_event: 0,
385            last_sample_time: now,
386            start_time: now,
387            last_decision_forced: false,
388            logs: VecDeque::new(),
389            last_decision: None,
390            last_observation: None,
391        }
392    }
393
394    /// Access the sampler configuration.
395    #[must_use]
396    pub fn config(&self) -> &VoiConfig {
397        &self.config
398    }
399
400    /// Current posterior parameters (alpha, beta).
401    #[must_use]
402    pub fn posterior_params(&self) -> (f64, f64) {
403        (self.alpha, self.beta)
404    }
405
406    /// Current posterior mean.
407    #[must_use]
408    pub fn posterior_mean(&self) -> f64 {
409        beta_mean(self.alpha, self.beta)
410    }
411
412    /// Current posterior variance.
413    #[must_use]
414    pub fn posterior_variance(&self) -> f64 {
415        beta_variance(self.alpha, self.beta)
416    }
417
418    /// Expected posterior variance after one additional sample.
419    #[must_use]
420    pub fn expected_variance_after(&self) -> f64 {
421        expected_variance_after(self.alpha, self.beta)
422    }
423
424    /// Most recent decision, if any.
425    #[must_use]
426    pub fn last_decision(&self) -> Option<&VoiDecision> {
427        self.last_decision.as_ref()
428    }
429
430    /// Most recent observation, if any.
431    #[must_use]
432    pub fn last_observation(&self) -> Option<&VoiObservation> {
433        self.last_observation.as_ref()
434    }
435
436    /// Decide whether to sample at time `now`.
437    pub fn decide(&mut self, now: Instant) -> VoiDecision {
438        self.event_idx += 1;
439
440        let events_since_sample = if self.sample_idx == 0 {
441            self.event_idx
442        } else {
443            self.event_idx.saturating_sub(self.last_sample_event)
444        };
445        let time_since_sample = if now >= self.last_sample_time {
446            now.duration_since(self.last_sample_time)
447        } else {
448            Duration::ZERO
449        };
450
451        let forced_by_events = self.config.max_interval_events > 0
452            && events_since_sample >= self.config.max_interval_events;
453        let forced_by_time = self.config.max_interval_ms > 0
454            && time_since_sample >= Duration::from_millis(self.config.max_interval_ms);
455        let forced = forced_by_events || forced_by_time;
456
457        let blocked_by_events = self.sample_idx > 0
458            && self.config.min_interval_events > 0
459            && events_since_sample < self.config.min_interval_events;
460        let blocked_by_time = self.sample_idx > 0
461            && self.config.min_interval_ms > 0
462            && time_since_sample < Duration::from_millis(self.config.min_interval_ms);
463        let blocked = blocked_by_events || blocked_by_time;
464
465        let variance = beta_variance(self.alpha, self.beta);
466        let expected_after = expected_variance_after(self.alpha, self.beta);
467        let voi_gain = (variance - expected_after).max(0.0);
468
469        let boundary_score = boundary_score(self.e_value, self.e_threshold);
470        let score = voi_gain
471            * self.config.value_scale
472            * (1.0 + self.config.boundary_weight * boundary_score);
473        let cost = self.config.sample_cost;
474        let log_bayes_factor = log10_ratio(score, cost);
475
476        let should_sample = if forced {
477            true
478        } else if blocked {
479            false
480        } else {
481            score >= cost
482        };
483
484        let reason = if forced {
485            "forced_interval"
486        } else if blocked {
487            "min_interval"
488        } else if should_sample {
489            "voi_ge_cost"
490        } else {
491            "voi_lt_cost"
492        };
493
494        let decision = VoiDecision {
495            event_idx: self.event_idx,
496            should_sample,
497            forced_by_interval: forced,
498            blocked_by_min_interval: blocked,
499            voi_gain,
500            score,
501            cost,
502            log_bayes_factor,
503            posterior_mean: beta_mean(self.alpha, self.beta),
504            posterior_variance: variance,
505            e_value: self.e_value,
506            e_threshold: self.e_threshold,
507            boundary_score,
508            events_since_sample,
509            time_since_sample_ms: time_since_sample.as_secs_f64() * 1000.0,
510            reason,
511        };
512
513        self.last_decision = Some(decision.clone());
514        self.last_decision_forced = forced;
515
516        if self.config.enable_logging {
517            self.push_log(VoiLogEntry::Decision(decision.clone()));
518        }
519
520        decision
521    }
522
523    /// Record a sampled observation at time `now`.
524    pub fn observe_at(&mut self, violated: bool, now: Instant) -> VoiObservation {
525        self.sample_idx += 1;
526        self.last_sample_event = self.event_idx;
527        self.last_sample_time = now;
528        if self.last_decision_forced {
529            self.forced_samples += 1;
530        }
531
532        if violated {
533            self.alpha += 1.0;
534        } else {
535            self.beta += 1.0;
536        }
537
538        self.update_eprocess(violated);
539
540        let observation = VoiObservation {
541            event_idx: self.event_idx,
542            sample_idx: self.sample_idx,
543            violated,
544            posterior_mean: beta_mean(self.alpha, self.beta),
545            posterior_variance: beta_variance(self.alpha, self.beta),
546            alpha: self.alpha,
547            beta: self.beta,
548            e_value: self.e_value,
549            e_threshold: self.e_threshold,
550        };
551
552        self.last_observation = Some(observation.clone());
553        if self.config.enable_logging {
554            self.push_log(VoiLogEntry::Observation(observation.clone()));
555        }
556
557        observation
558    }
559
560    /// Record a sampled observation using `Instant::now()`.
561    pub fn observe(&mut self, violated: bool) -> VoiObservation {
562        self.observe_at(violated, Instant::now())
563    }
564
565    /// Current summary statistics.
566    #[must_use]
567    pub fn summary(&self) -> VoiSummary {
568        let skipped_events = self.event_idx.saturating_sub(self.sample_idx);
569        let avg_events_between_samples = if self.sample_idx > 0 {
570            self.event_idx as f64 / self.sample_idx as f64
571        } else {
572            0.0
573        };
574        let elapsed_ms = self.start_time.elapsed().as_secs_f64() * 1000.0;
575        let avg_ms_between_samples = if self.sample_idx > 0 {
576            elapsed_ms / self.sample_idx as f64
577        } else {
578            0.0
579        };
580
581        VoiSummary {
582            total_events: self.event_idx,
583            total_samples: self.sample_idx,
584            forced_samples: self.forced_samples,
585            skipped_events,
586            current_mean: beta_mean(self.alpha, self.beta),
587            current_variance: beta_variance(self.alpha, self.beta),
588            e_value: self.e_value,
589            e_threshold: self.e_threshold,
590            avg_events_between_samples,
591            avg_ms_between_samples,
592        }
593    }
594
595    /// Access current logs.
596    #[must_use]
597    pub fn logs(&self) -> &VecDeque<VoiLogEntry> {
598        &self.logs
599    }
600
601    /// Render logs as JSONL.
602    #[must_use]
603    pub fn logs_to_jsonl(&self) -> String {
604        self.logs
605            .iter()
606            .map(VoiLogEntry::to_jsonl)
607            .collect::<Vec<_>>()
608            .join("\n")
609    }
610
611    /// Create a snapshot of the sampler state for debug overlays.
612    #[must_use]
613    pub fn snapshot(&self, max_logs: usize, captured_ms: u64) -> VoiSamplerSnapshot {
614        let expected_after = expected_variance_after(self.alpha, self.beta);
615        let variance = beta_variance(self.alpha, self.beta);
616        let voi_gain = (variance - expected_after).max(0.0);
617        let mut recent_logs: Vec<VoiLogEntry> = self
618            .logs
619            .iter()
620            .rev()
621            .take(max_logs.max(1))
622            .cloned()
623            .collect();
624        recent_logs.reverse();
625
626        VoiSamplerSnapshot {
627            captured_ms,
628            alpha: self.alpha,
629            beta: self.beta,
630            posterior_mean: beta_mean(self.alpha, self.beta),
631            posterior_variance: variance,
632            expected_variance_after: expected_after,
633            voi_gain,
634            last_decision: self.last_decision.clone(),
635            last_observation: self.last_observation.clone(),
636            recent_logs,
637        }
638    }
639
640    fn push_log(&mut self, entry: VoiLogEntry) {
641        if self.logs.len() >= self.config.max_log_entries {
642            self.logs.pop_front();
643        }
644        self.logs.push_back(entry);
645    }
646
647    fn update_eprocess(&mut self, violated: bool) {
648        let x = if violated { 1.0 } else { 0.0 };
649        let factor = 1.0 + self.lambda * (x - self.mu_0);
650        let next = self.e_value * factor.max(EPS);
651        self.e_value = next.clamp(E_MIN, E_MAX);
652    }
653
654    /// Increment forced sample counter (for testing/integration).
655    pub fn mark_forced_sample(&mut self) {
656        self.forced_samples += 1;
657    }
658}
659
660fn beta_mean(alpha: f64, beta: f64) -> f64 {
661    alpha / (alpha + beta)
662}
663
664fn beta_variance(alpha: f64, beta: f64) -> f64 {
665    let sum = alpha + beta;
666    if sum <= 0.0 {
667        return 0.0;
668    }
669    let var = (alpha * beta) / (sum * sum * (sum + 1.0));
670    var.min(VAR_MAX)
671}
672
673fn expected_variance_after(alpha: f64, beta: f64) -> f64 {
674    let p = beta_mean(alpha, beta);
675    let var_success = beta_variance(alpha + 1.0, beta);
676    let var_failure = beta_variance(alpha, beta + 1.0);
677    p * var_success + (1.0 - p) * var_failure
678}
679
680fn boundary_score(e_value: f64, threshold: f64) -> f64 {
681    let e = e_value.max(EPS);
682    let t = threshold.max(EPS);
683    let gap = (e.ln() - t.ln()).abs();
684    1.0 / (1.0 + gap)
685}
686
687fn log10_ratio(score: f64, cost: f64) -> f64 {
688    let ratio = (score + EPS) / (cost + EPS);
689    ratio.ln() / std::f64::consts::LN_10
690}
691
692// =============================================================================
693// Tests
694// =============================================================================
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use proptest::prelude::*;
700
701    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
702    const FNV_PRIME: u64 = 0x100000001b3;
703
704    fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
705        for byte in bytes {
706            *hash ^= *byte as u64;
707            *hash = hash.wrapping_mul(FNV_PRIME);
708        }
709    }
710
711    fn hash_u64(hash: &mut u64, value: u64) {
712        hash_bytes(hash, &value.to_le_bytes());
713    }
714
715    fn hash_f64(hash: &mut u64, value: f64) {
716        hash_u64(hash, value.to_bits());
717    }
718
719    fn decision_checksum(decisions: &[VoiDecision]) -> u64 {
720        let mut hash = FNV_OFFSET_BASIS;
721        for decision in decisions {
722            hash_u64(&mut hash, decision.event_idx);
723            hash_u64(&mut hash, decision.should_sample as u64);
724            hash_u64(&mut hash, decision.forced_by_interval as u64);
725            hash_u64(&mut hash, decision.blocked_by_min_interval as u64);
726            hash_f64(&mut hash, decision.voi_gain);
727            hash_f64(&mut hash, decision.score);
728            hash_f64(&mut hash, decision.cost);
729            hash_f64(&mut hash, decision.log_bayes_factor);
730            hash_f64(&mut hash, decision.posterior_mean);
731            hash_f64(&mut hash, decision.posterior_variance);
732            hash_f64(&mut hash, decision.e_value);
733            hash_f64(&mut hash, decision.e_threshold);
734            hash_f64(&mut hash, decision.boundary_score);
735            hash_u64(&mut hash, decision.events_since_sample);
736            hash_f64(&mut hash, decision.time_since_sample_ms);
737        }
738        hash
739    }
740
741    #[test]
742    fn voi_gain_non_negative() {
743        let mut sampler = VoiSampler::new(VoiConfig::default());
744        let decision = sampler.decide(Instant::now());
745        assert!(decision.voi_gain >= 0.0);
746    }
747
748    #[test]
749    fn forced_by_max_interval() {
750        let config = VoiConfig {
751            max_interval_events: 2,
752            sample_cost: 1.0, // discourage sampling unless forced
753            ..Default::default()
754        };
755        let mut sampler = VoiSampler::new(config);
756        let now = Instant::now();
757
758        let d1 = sampler.decide(now);
759        assert!(!d1.forced_by_interval);
760
761        let d2 = sampler.decide(now + Duration::from_millis(1));
762        assert!(d2.forced_by_interval);
763        assert!(d2.should_sample);
764    }
765
766    #[test]
767    fn min_interval_blocks_sampling_after_first() {
768        let config = VoiConfig {
769            min_interval_events: 5,
770            sample_cost: 0.0, // otherwise would sample
771            ..Default::default()
772        };
773        let mut sampler = VoiSampler::new(config);
774
775        let first = sampler.decide(Instant::now());
776        assert!(first.should_sample);
777        sampler.observe(false);
778
779        let second = sampler.decide(Instant::now());
780        assert!(second.blocked_by_min_interval);
781        assert!(!second.should_sample);
782    }
783
784    #[test]
785    fn variance_shrinks_with_samples() {
786        let mut sampler = VoiSampler::new(VoiConfig::default());
787        let mut now = Instant::now();
788        let mut variances = Vec::new();
789        for _ in 0..5 {
790            let decision = sampler.decide(now);
791            if decision.should_sample {
792                sampler.observe_at(false, now);
793            }
794            variances.push(beta_variance(sampler.alpha, sampler.beta));
795            now += Duration::from_millis(1);
796        }
797        for window in variances.windows(2) {
798            assert!(window[1] <= window[0] + 1e-9);
799        }
800    }
801
802    #[test]
803    fn decision_checksum_is_stable() {
804        let config = VoiConfig {
805            sample_cost: 0.01,
806            ..Default::default()
807        };
808        let mut now = Instant::now();
809        let mut sampler = VoiSampler::new_at(config, now);
810
811        let mut state: u64 = 42;
812        let mut decisions = Vec::new();
813
814        for _ in 0..32 {
815            let decision = sampler.decide(now);
816            let violated = lcg_next(&mut state).is_multiple_of(10);
817            if decision.should_sample {
818                sampler.observe_at(violated, now);
819            }
820            decisions.push(decision);
821            now += Duration::from_millis(5 + (lcg_next(&mut state) % 7));
822        }
823
824        let checksum = decision_checksum(&decisions);
825        assert_eq!(checksum, 0x0b51_d8b6_47a7_b00c);
826    }
827
828    #[test]
829    fn logs_render_jsonl() {
830        let config = VoiConfig {
831            enable_logging: true,
832            ..Default::default()
833        };
834        let mut sampler = VoiSampler::new(config);
835        let decision = sampler.decide(Instant::now());
836        if decision.should_sample {
837            sampler.observe(false);
838        }
839        let jsonl = sampler.logs_to_jsonl();
840        assert!(jsonl.contains("\"event\":\"voi_decision\""));
841    }
842
843    proptest! {
844        #[test]
845        fn prop_voi_gain_non_negative(alpha in 0.01f64..10.0, beta in 0.01f64..10.0) {
846            let var = beta_variance(alpha, beta);
847            let expected_after = expected_variance_after(alpha, beta);
848            prop_assert!(var + 1e-12 >= expected_after);
849        }
850
851        #[test]
852        fn prop_e_value_stays_positive(seq in proptest::collection::vec(any::<bool>(), 1..50)) {
853            let mut sampler = VoiSampler::new(VoiConfig::default());
854            let mut now = Instant::now();
855            for violated in seq {
856                let decision = sampler.decide(now);
857                if decision.should_sample {
858                    sampler.observe_at(violated, now);
859                }
860                now += Duration::from_millis(1);
861                prop_assert!(sampler.e_value >= E_MIN - 1e-12);
862            }
863        }
864    }
865
866    // =========================================================================
867    // Perf microbench (JSONL + budget gate)
868    // =========================================================================
869
870    #[test]
871    fn perf_voi_sampling_budget() {
872        use std::io::Write as _;
873
874        const RUNS: usize = 60;
875        let mut sampler = VoiSampler::new(VoiConfig::default());
876        let mut now = Instant::now();
877        let mut samples = Vec::with_capacity(RUNS);
878        let mut jsonl = Vec::new();
879
880        for i in 0..RUNS {
881            let start = Instant::now();
882            let decision = sampler.decide(now);
883            let violated = i % 11 == 0;
884            if decision.should_sample {
885                sampler.observe_at(violated, now);
886            }
887            let elapsed_ns = start.elapsed().as_nanos() as u64;
888            samples.push(elapsed_ns);
889
890            writeln!(
891                &mut jsonl,
892                "{{\"test\":\"voi_sampling\",\"case\":\"decision\",\"idx\":{},\
893\"elapsed_ns\":{},\"sample\":{},\"violated\":{},\"e_value\":{:.6}}}",
894                i, elapsed_ns, decision.should_sample, violated, sampler.e_value
895            )
896            .expect("jsonl write failed");
897
898            now += Duration::from_millis(1);
899        }
900
901        fn percentile(samples: &mut [u64], p: f64) -> u64 {
902            samples.sort_unstable();
903            let idx = ((samples.len() as f64 - 1.0) * p).round() as usize;
904            samples[idx]
905        }
906
907        let mut samples_sorted = samples.clone();
908        let _p50 = percentile(&mut samples_sorted, 0.50);
909        let p95 = percentile(&mut samples_sorted, 0.95);
910        let p99 = percentile(&mut samples_sorted, 0.99);
911
912        let (budget_p95, budget_p99) = if cfg!(debug_assertions) {
913            (200_000, 400_000)
914        } else {
915            (20_000, 40_000)
916        };
917
918        assert!(p95 <= budget_p95, "p95 {p95}ns exceeds {budget_p95}ns");
919        assert!(p99 <= budget_p99, "p99 {p99}ns exceeds {budget_p99}ns");
920
921        let text = String::from_utf8(jsonl).expect("jsonl utf8");
922        print!("{text}");
923        assert_eq!(text.lines().count(), RUNS);
924    }
925
926    // =========================================================================
927    // Deterministic JSONL output for E2E harness
928    // =========================================================================
929
930    #[test]
931    fn e2e_deterministic_jsonl() {
932        use std::io::Write as _;
933
934        let seed = std::env::var("VOI_SEED")
935            .ok()
936            .and_then(|s| s.parse::<u64>().ok())
937            .unwrap_or(0);
938
939        let config = VoiConfig {
940            enable_logging: false,
941            ..Default::default()
942        };
943        let mut now = Instant::now();
944        let mut sampler = VoiSampler::new_at(config, now);
945        let mut state = seed;
946        let mut decisions = Vec::new();
947        let mut jsonl = Vec::new();
948
949        for idx in 0..40u64 {
950            let decision = sampler.decide(now);
951            let violated = lcg_next(&mut state).is_multiple_of(7);
952            if decision.should_sample {
953                sampler.observe_at(violated, now);
954            }
955            decisions.push(decision.clone());
956
957            writeln!(
958                &mut jsonl,
959                "{{\"event\":\"voi_decision\",\"seed\":{},\"idx\":{},\
960\"sample\":{},\"violated\":{},\"voi_gain\":{:.6}}}",
961                seed, idx, decision.should_sample, violated, decision.voi_gain
962            )
963            .expect("jsonl write failed");
964
965            now += Duration::from_millis(3 + (lcg_next(&mut state) % 5));
966        }
967
968        let checksum = decision_checksum(&decisions);
969        writeln!(
970            &mut jsonl,
971            "{{\"event\":\"voi_checksum\",\"seed\":{},\"checksum\":\"{checksum:016x}\",\"decisions\":{}}}",
972            seed,
973            decisions.len()
974        )
975        .expect("jsonl write failed");
976
977        let text = String::from_utf8(jsonl).expect("jsonl utf8");
978        print!("{text}");
979        assert!(text.contains("\"event\":\"voi_checksum\""));
980    }
981
982    fn lcg_next(state: &mut u64) -> u64 {
983        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
984        *state
985    }
986}