1#![forbid(unsafe_code)]
2
3use std::collections::VecDeque;
120use web_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; #[derive(Debug, Clone)]
132pub struct VoiConfig {
133 pub alpha: f64,
136
137 pub prior_alpha: f64,
139
140 pub prior_beta: f64,
142
143 pub mu_0: f64,
145
146 pub lambda: f64,
148
149 pub value_scale: f64,
151
152 pub boundary_weight: f64,
154
155 pub sample_cost: f64,
157
158 pub min_interval_ms: u64,
160
161 pub max_interval_ms: u64,
164
165 pub min_interval_events: u64,
167
168 pub max_interval_events: u64,
171
172 pub enable_logging: bool,
174
175 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#[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 #[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#[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 #[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#[derive(Debug, Clone)]
282pub enum VoiLogEntry {
283 Decision(VoiDecision),
284 Observation(VoiObservation),
285}
286
287impl VoiLogEntry {
288 #[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#[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#[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#[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 pub fn new(config: VoiConfig) -> Self {
353 Self::new_at(config, Instant::now())
354 }
355
356 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 #[must_use]
396 pub fn config(&self) -> &VoiConfig {
397 &self.config
398 }
399
400 #[must_use]
402 pub fn posterior_params(&self) -> (f64, f64) {
403 (self.alpha, self.beta)
404 }
405
406 #[must_use]
408 pub fn posterior_mean(&self) -> f64 {
409 beta_mean(self.alpha, self.beta)
410 }
411
412 #[must_use]
414 pub fn posterior_variance(&self) -> f64 {
415 beta_variance(self.alpha, self.beta)
416 }
417
418 #[must_use]
420 pub fn expected_variance_after(&self) -> f64 {
421 expected_variance_after(self.alpha, self.beta)
422 }
423
424 #[must_use]
426 pub fn last_decision(&self) -> Option<&VoiDecision> {
427 self.last_decision.as_ref()
428 }
429
430 #[must_use]
432 pub fn last_observation(&self) -> Option<&VoiObservation> {
433 self.last_observation.as_ref()
434 }
435
436 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 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 pub fn observe(&mut self, violated: bool) -> VoiObservation {
562 self.observe_at(violated, Instant::now())
563 }
564
565 #[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 #[must_use]
597 pub fn logs(&self) -> &VecDeque<VoiLogEntry> {
598 &self.logs
599 }
600
601 #[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 #[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 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#[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, ..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, ..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 #[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 #[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
987 #[test]
992 fn default_config_values() {
993 let cfg = VoiConfig::default();
994 assert!((cfg.alpha - 0.05).abs() < f64::EPSILON);
995 assert!((cfg.prior_alpha - 1.0).abs() < f64::EPSILON);
996 assert!((cfg.prior_beta - 1.0).abs() < f64::EPSILON);
997 assert!((cfg.mu_0 - 0.05).abs() < f64::EPSILON);
998 assert!((cfg.lambda - 0.5).abs() < f64::EPSILON);
999 assert_eq!(cfg.max_interval_ms, 250);
1000 assert_eq!(cfg.max_interval_events, 20);
1001 assert_eq!(cfg.min_interval_ms, 0);
1002 assert_eq!(cfg.min_interval_events, 0);
1003 assert!(!cfg.enable_logging);
1004 }
1005
1006 #[test]
1007 fn config_clamping_prior_alpha_beta() {
1008 let config = VoiConfig {
1009 prior_alpha: -1.0,
1010 prior_beta: 0.0,
1011 ..Default::default()
1012 };
1013 let sampler = VoiSampler::new(config);
1014 let (a, b) = sampler.posterior_params();
1015 assert!(a > 0.0, "alpha should be clamped above zero");
1016 assert!(b > 0.0, "beta should be clamped above zero");
1017 }
1018
1019 #[test]
1020 fn config_clamping_mu_0() {
1021 let config = VoiConfig {
1022 mu_0: -0.5,
1023 ..Default::default()
1024 };
1025 let sampler = VoiSampler::new(config);
1026 let mean = sampler.posterior_mean();
1027 assert!((0.0..=1.0).contains(&mean));
1028 }
1029
1030 #[test]
1031 fn config_clamping_sample_cost() {
1032 let config = VoiConfig {
1033 sample_cost: -1.0,
1034 ..Default::default()
1035 };
1036 let mut sampler = VoiSampler::new(config);
1037 let d = sampler.decide(Instant::now());
1038 assert!(d.cost > 0.0, "cost should be clamped above zero");
1039 }
1040
1041 #[test]
1042 fn accessor_config() {
1043 let config = VoiConfig {
1044 alpha: 0.1,
1045 ..Default::default()
1046 };
1047 let sampler = VoiSampler::new(config);
1048 assert!((sampler.config().alpha - 0.1).abs() < f64::EPSILON);
1049 }
1050
1051 #[test]
1052 fn accessor_posterior_params() {
1053 let config = VoiConfig {
1054 prior_alpha: 3.0,
1055 prior_beta: 7.0,
1056 ..Default::default()
1057 };
1058 let sampler = VoiSampler::new(config);
1059 let (a, b) = sampler.posterior_params();
1060 assert!((a - 3.0).abs() < f64::EPSILON);
1061 assert!((b - 7.0).abs() < f64::EPSILON);
1062 }
1063
1064 #[test]
1065 fn accessor_posterior_mean() {
1066 let config = VoiConfig {
1067 prior_alpha: 2.0,
1068 prior_beta: 8.0,
1069 ..Default::default()
1070 };
1071 let sampler = VoiSampler::new(config);
1072 assert!((sampler.posterior_mean() - 0.2).abs() < 1e-9);
1074 }
1075
1076 #[test]
1077 fn accessor_posterior_variance() {
1078 let sampler = VoiSampler::new(VoiConfig::default());
1079 let var = sampler.posterior_variance();
1080 assert!(var >= 0.0);
1081 assert!(var <= 0.25); }
1083
1084 #[test]
1085 fn accessor_expected_variance_after() {
1086 let sampler = VoiSampler::new(VoiConfig::default());
1087 let before = sampler.posterior_variance();
1088 let after = sampler.expected_variance_after();
1089 assert!(
1090 after <= before + 1e-12,
1091 "expected variance after should not exceed current"
1092 );
1093 }
1094
1095 #[test]
1096 fn last_decision_initially_none() {
1097 let sampler = VoiSampler::new(VoiConfig::default());
1098 assert!(sampler.last_decision().is_none());
1099 }
1100
1101 #[test]
1102 fn last_decision_after_decide() {
1103 let mut sampler = VoiSampler::new(VoiConfig::default());
1104 sampler.decide(Instant::now());
1105 assert!(sampler.last_decision().is_some());
1106 }
1107
1108 #[test]
1109 fn last_observation_initially_none() {
1110 let sampler = VoiSampler::new(VoiConfig::default());
1111 assert!(sampler.last_observation().is_none());
1112 }
1113
1114 #[test]
1115 fn last_observation_after_observe() {
1116 let mut sampler = VoiSampler::new(VoiConfig::default());
1117 sampler.decide(Instant::now());
1118 sampler.observe(false);
1119 assert!(sampler.last_observation().is_some());
1120 assert!(!sampler.last_observation().unwrap().violated);
1121 }
1122
1123 #[test]
1124 fn observe_violation_updates_alpha() {
1125 let mut sampler = VoiSampler::new(VoiConfig::default());
1126 let (a_before, _) = sampler.posterior_params();
1127 sampler.decide(Instant::now());
1128 sampler.observe(true);
1129 let (a_after, _) = sampler.posterior_params();
1130 assert!((a_after - a_before - 1.0).abs() < 1e-9);
1131 }
1132
1133 #[test]
1134 fn observe_no_violation_updates_beta() {
1135 let mut sampler = VoiSampler::new(VoiConfig::default());
1136 let (_, b_before) = sampler.posterior_params();
1137 sampler.decide(Instant::now());
1138 sampler.observe(false);
1139 let (_, b_after) = sampler.posterior_params();
1140 assert!((b_after - b_before - 1.0).abs() < 1e-9);
1141 }
1142
1143 #[test]
1144 fn e_value_positive_after_violations() {
1145 let mut sampler = VoiSampler::new(VoiConfig::default());
1146 let mut now = Instant::now();
1147 for _ in 0..10 {
1148 sampler.decide(now);
1149 sampler.observe_at(true, now);
1150 now += Duration::from_millis(1);
1151 }
1152 let summary = sampler.summary();
1153 assert!(summary.e_value > 0.0);
1154 }
1155
1156 #[test]
1157 fn summary_initial_state() {
1158 let sampler = VoiSampler::new(VoiConfig::default());
1159 let summary = sampler.summary();
1160 assert_eq!(summary.total_events, 0);
1161 assert_eq!(summary.total_samples, 0);
1162 assert_eq!(summary.forced_samples, 0);
1163 assert_eq!(summary.skipped_events, 0);
1164 assert!((summary.avg_events_between_samples).abs() < f64::EPSILON);
1165 }
1166
1167 #[test]
1168 fn summary_after_observations() {
1169 let mut sampler = VoiSampler::new(VoiConfig::default());
1170 let now = Instant::now();
1171 sampler.decide(now);
1172 sampler.observe_at(false, now);
1173 sampler.decide(now + Duration::from_millis(10));
1174 let summary = sampler.summary();
1175 assert_eq!(summary.total_events, 2);
1176 assert_eq!(summary.total_samples, 1);
1177 assert_eq!(summary.skipped_events, 1);
1178 }
1179
1180 #[test]
1181 fn mark_forced_sample_increments() {
1182 let mut sampler = VoiSampler::new(VoiConfig::default());
1183 assert_eq!(sampler.summary().forced_samples, 0);
1184 sampler.mark_forced_sample();
1185 sampler.mark_forced_sample();
1186 assert_eq!(sampler.summary().forced_samples, 2);
1187 }
1188
1189 #[test]
1190 fn snapshot_captures_state() {
1191 let mut sampler = VoiSampler::new(VoiConfig {
1192 enable_logging: true,
1193 ..Default::default()
1194 });
1195 let now = Instant::now();
1196 sampler.decide(now);
1197 sampler.observe_at(false, now);
1198
1199 let snap = sampler.snapshot(10, 42);
1200 assert_eq!(snap.captured_ms, 42);
1201 assert!(snap.alpha > 0.0);
1202 assert!(snap.beta > 0.0);
1203 assert!((0.0..=1.0).contains(&snap.posterior_mean));
1204 assert!(snap.last_decision.is_some());
1205 assert!(snap.last_observation.is_some());
1206 }
1207
1208 #[test]
1209 fn log_rotation_respects_max_entries() {
1210 let config = VoiConfig {
1211 enable_logging: true,
1212 max_log_entries: 3,
1213 ..Default::default()
1214 };
1215 let mut sampler = VoiSampler::new(config);
1216 let mut now = Instant::now();
1217
1218 for _ in 0..10 {
1219 let d = sampler.decide(now);
1220 if d.should_sample {
1221 sampler.observe_at(false, now);
1222 }
1223 now += Duration::from_millis(300);
1224 }
1225
1226 assert!(sampler.logs().len() <= 3);
1227 }
1228
1229 #[test]
1230 fn logs_empty_when_logging_disabled() {
1231 let config = VoiConfig {
1232 enable_logging: false,
1233 ..Default::default()
1234 };
1235 let mut sampler = VoiSampler::new(config);
1236 sampler.decide(Instant::now());
1237 assert!(sampler.logs().is_empty());
1238 }
1239
1240 #[test]
1241 fn decision_jsonl_format() {
1242 let mut sampler = VoiSampler::new(VoiConfig::default());
1243 let decision = sampler.decide(Instant::now());
1244 let jsonl = decision.to_jsonl();
1245 assert!(jsonl.starts_with('{'));
1246 assert!(jsonl.ends_with('}'));
1247 assert!(jsonl.contains("\"event\":\"voi_decision\""));
1248 assert!(jsonl.contains("\"should_sample\":"));
1249 assert!(jsonl.contains("\"reason\":"));
1250 }
1251
1252 #[test]
1253 fn observation_jsonl_format() {
1254 let mut sampler = VoiSampler::new(VoiConfig::default());
1255 sampler.decide(Instant::now());
1256 let obs = sampler.observe(false);
1257 let jsonl = obs.to_jsonl();
1258 assert!(jsonl.starts_with('{'));
1259 assert!(jsonl.ends_with('}'));
1260 assert!(jsonl.contains("\"event\":\"voi_observe\""));
1261 assert!(jsonl.contains("\"violated\":false"));
1262 }
1263
1264 #[test]
1265 fn log_entry_jsonl_decision_variant() {
1266 let mut sampler = VoiSampler::new(VoiConfig::default());
1267 let decision = sampler.decide(Instant::now());
1268 let entry = VoiLogEntry::Decision(decision);
1269 let jsonl = entry.to_jsonl();
1270 assert!(jsonl.contains("\"event\":\"voi_decision\""));
1271 }
1272
1273 #[test]
1274 fn log_entry_jsonl_observation_variant() {
1275 let mut sampler = VoiSampler::new(VoiConfig::default());
1276 sampler.decide(Instant::now());
1277 let obs = sampler.observe(true);
1278 let entry = VoiLogEntry::Observation(obs);
1279 let jsonl = entry.to_jsonl();
1280 assert!(jsonl.contains("\"event\":\"voi_observe\""));
1281 assert!(jsonl.contains("\"violated\":true"));
1282 }
1283
1284 #[test]
1285 fn time_based_max_interval_forces_sample() {
1286 let config = VoiConfig {
1287 max_interval_ms: 100,
1288 max_interval_events: 0, sample_cost: 100.0, ..Default::default()
1291 };
1292 let now = Instant::now();
1293 let mut sampler = VoiSampler::new_at(config, now);
1294
1295 let _d1 = sampler.decide(now + Duration::from_millis(1));
1297 sampler.observe_at(false, now + Duration::from_millis(1));
1298
1299 let d2 = sampler.decide(now + Duration::from_millis(10));
1301 assert!(!d2.forced_by_interval, "should not force within 100ms");
1302
1303 let d3 = sampler.decide(now + Duration::from_millis(110));
1305 assert!(d3.forced_by_interval, "should force after 100ms");
1306 assert!(d3.should_sample);
1307 }
1308
1309 #[test]
1310 fn time_based_min_interval_blocks() {
1311 let config = VoiConfig {
1312 min_interval_ms: 50,
1313 min_interval_events: 0,
1314 ..Default::default()
1315 };
1316 let now = Instant::now();
1317 let mut sampler = VoiSampler::new_at(config, now);
1318
1319 let d1 = sampler.decide(now);
1321 assert!(d1.should_sample);
1322 sampler.observe_at(false, now);
1323
1324 let d2 = sampler.decide(now + Duration::from_millis(10));
1326 assert!(d2.blocked_by_min_interval);
1327 assert!(!d2.should_sample);
1328
1329 let d3 = sampler.decide(now + Duration::from_millis(60));
1331 assert!(!d3.blocked_by_min_interval);
1332 }
1333
1334 #[test]
1335 fn decision_reason_strings() {
1336 let config = VoiConfig {
1338 max_interval_events: 1,
1339 ..Default::default()
1340 };
1341 let mut sampler = VoiSampler::new(config);
1342 let d = sampler.decide(Instant::now());
1343 assert_eq!(d.reason, "forced_interval");
1344 }
1345
1346 #[test]
1347 fn decision_reason_min_interval() {
1348 let config = VoiConfig {
1349 min_interval_events: 100,
1350 sample_cost: 0.0,
1351 ..Default::default()
1352 };
1353 let mut sampler = VoiSampler::new(config);
1354 sampler.decide(Instant::now());
1355 sampler.observe(false);
1356 let d = sampler.decide(Instant::now());
1357 assert_eq!(d.reason, "min_interval");
1358 }
1359
1360 #[test]
1361 fn beta_mean_basic() {
1362 assert!((beta_mean(1.0, 1.0) - 0.5).abs() < 1e-9);
1363 assert!((beta_mean(2.0, 8.0) - 0.2).abs() < 1e-9);
1364 assert!((beta_mean(5.0, 5.0) - 0.5).abs() < 1e-9);
1365 }
1366
1367 #[test]
1368 fn beta_variance_basic() {
1369 let var = beta_variance(1.0, 1.0);
1371 assert!((var - 1.0 / 12.0).abs() < 1e-9);
1372 }
1373
1374 #[test]
1375 fn beta_variance_degenerate() {
1376 assert!((beta_variance(0.0, 0.0)).abs() < f64::EPSILON);
1377 assert!((beta_variance(-1.0, -1.0)).abs() < f64::EPSILON);
1378 }
1379
1380 #[test]
1381 fn boundary_score_at_threshold() {
1382 let score = boundary_score(20.0, 20.0);
1384 assert!((score - 1.0).abs() < 1e-9);
1385 }
1386
1387 #[test]
1388 fn boundary_score_far_from_threshold() {
1389 let score = boundary_score(1.0, 1e6);
1391 assert!(score < 0.1);
1392 }
1393
1394 #[test]
1395 fn logs_to_jsonl_multiple_entries() {
1396 let config = VoiConfig {
1397 enable_logging: true,
1398 ..Default::default()
1399 };
1400 let mut sampler = VoiSampler::new(config);
1401 let mut now = Instant::now();
1402 for _ in 0..5 {
1403 let d = sampler.decide(now);
1404 if d.should_sample {
1405 sampler.observe_at(false, now);
1406 }
1407 now += Duration::from_millis(300);
1408 }
1409 let jsonl = sampler.logs_to_jsonl();
1410 let line_count = jsonl.lines().count();
1411 assert!(
1412 line_count >= 2,
1413 "should have at least 2 log lines, got {line_count}"
1414 );
1415 }
1416}