1#![forbid(unsafe_code)]
2
3use 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; static VOI_SAMPLES_TAKEN_TOTAL: AtomicU64 = AtomicU64::new(0);
136static VOI_SAMPLES_SKIPPED_TOTAL: AtomicU64 = AtomicU64::new(0);
137
138#[must_use]
140pub fn voi_samples_taken_total() -> u64 {
141 VOI_SAMPLES_TAKEN_TOTAL.load(Ordering::Relaxed)
142}
143
144#[must_use]
146pub fn voi_samples_skipped_total() -> u64 {
147 VOI_SAMPLES_SKIPPED_TOTAL.load(Ordering::Relaxed)
148}
149
150#[derive(Debug, Clone)]
152pub struct VoiConfig {
153 pub alpha: f64,
156
157 pub prior_alpha: f64,
159
160 pub prior_beta: f64,
162
163 pub mu_0: f64,
165
166 pub lambda: f64,
168
169 pub value_scale: f64,
171
172 pub boundary_weight: f64,
174
175 pub sample_cost: f64,
177
178 pub min_interval_ms: u64,
180
181 pub max_interval_ms: u64,
184
185 pub min_interval_events: u64,
187
188 pub max_interval_events: u64,
191
192 pub enable_logging: bool,
194
195 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#[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 #[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#[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 #[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#[derive(Debug, Clone)]
302pub enum VoiLogEntry {
303 Decision(VoiDecision),
304 Observation(VoiObservation),
305}
306
307impl VoiLogEntry {
308 #[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#[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#[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#[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 pub fn new(config: VoiConfig) -> Self {
373 Self::new_at(config, Instant::now())
374 }
375
376 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 #[must_use]
444 pub fn config(&self) -> &VoiConfig {
445 &self.config
446 }
447
448 #[must_use]
450 pub fn posterior_params(&self) -> (f64, f64) {
451 (self.alpha, self.beta)
452 }
453
454 #[must_use]
456 pub fn posterior_mean(&self) -> f64 {
457 beta_mean(self.alpha, self.beta)
458 }
459
460 #[must_use]
462 pub fn posterior_variance(&self) -> f64 {
463 beta_variance(self.alpha, self.beta)
464 }
465
466 #[must_use]
468 pub fn expected_variance_after(&self) -> f64 {
469 expected_variance_after(self.alpha, self.beta)
470 }
471
472 #[must_use]
474 pub fn last_decision(&self) -> Option<&VoiDecision> {
475 self.last_decision.as_ref()
476 }
477
478 #[must_use]
480 pub fn last_observation(&self) -> Option<&VoiObservation> {
481 self.last_observation.as_ref()
482 }
483
484 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 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 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 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 pub fn observe(&mut self, violated: bool) -> VoiObservation {
666 self.observe_at(violated, Instant::now())
667 }
668
669 #[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 #[must_use]
701 pub fn logs(&self) -> &VecDeque<VoiLogEntry> {
702 &self.logs
703 }
704
705 #[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 #[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 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#[derive(Debug, Clone, PartialEq)]
805pub struct DeferredRefinementConfig {
806 pub min_spare_budget_us: u64,
808 pub max_refinements_per_frame: usize,
810 pub voi_gain_cutoff: f64,
812 pub fairness_boost_per_skip: f64,
814 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#[derive(Debug, Clone, Copy, PartialEq)]
832pub struct RefinementCandidate {
833 pub region_id: u64,
835 pub estimated_cost_us: u64,
837 pub voi_gain: f64,
839}
840
841#[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#[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 #[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#[derive(Debug, Clone)]
876pub struct DeferredRefinementScheduler {
877 config: DeferredRefinementConfig,
878 skipped_frames: BTreeMap<u64, u32>,
879}
880
881impl DeferredRefinementScheduler {
882 #[must_use]
884 pub fn new(config: DeferredRefinementConfig) -> Self {
885 Self {
886 config,
887 skipped_frames: BTreeMap::new(),
888 }
889 }
890
891 #[must_use]
893 pub fn config(&self) -> &DeferredRefinementConfig {
894 &self.config
895 }
896
897 #[must_use]
899 pub fn skipped_frames_for(&self, region_id: u64) -> u32 {
900 self.skipped_frames.get(®ion_id).copied().unwrap_or(0)
901 }
902
903 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 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 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#[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, ..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, ..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 #[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 #[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 #[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 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); }
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, sample_cost: 100.0, ..Default::default()
1609 };
1610 let now = Instant::now();
1611 let mut sampler = VoiSampler::new_at(config, now);
1612
1613 let _d1 = sampler.decide(now + Duration::from_millis(1));
1615 sampler.observe_at(false, now + Duration::from_millis(1));
1616
1617 let d2 = sampler.decide(now + Duration::from_millis(10));
1619 assert!(!d2.forced_by_interval, "should not force within 100ms");
1620
1621 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 let d1 = sampler.decide(now);
1639 assert!(d1.should_sample);
1640 sampler.observe_at(false, now);
1641
1642 let d2 = sampler.decide(now + Duration::from_millis(10));
1644 assert!(d2.blocked_by_min_interval);
1645 assert!(!d2.should_sample);
1646
1647 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 let taken = voi_samples_taken_total();
2151 let skipped = voi_samples_skipped_total();
2152 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 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 #[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}