1#![forbid(unsafe_code)]
2
3use std::fmt;
127
128#[derive(Debug, Clone)]
134pub struct DiffStrategyConfig {
135 pub c_scan: f64,
138
139 pub c_emit: f64,
143
144 pub c_row: f64,
148
149 pub prior_alpha: f64,
152
153 pub prior_beta: f64,
156
157 pub decay: f64,
161
162 pub conservative: bool,
165
166 pub conservative_quantile: f64,
169
170 pub min_observation_cells: usize,
174
175 pub hysteresis_ratio: f64,
182
183 pub uncertainty_guard_variance: f64,
190}
191
192impl Default for DiffStrategyConfig {
193 fn default() -> Self {
194 Self {
195 c_scan: 1.0,
199 c_emit: 6.0,
200 c_row: 0.1,
201 prior_alpha: 1.0,
202 prior_beta: 19.0,
203 decay: 0.95,
204 conservative: false,
205 conservative_quantile: 0.95,
206 min_observation_cells: 1,
207 hysteresis_ratio: 0.05,
208 uncertainty_guard_variance: 0.002,
209 }
210 }
211}
212
213impl DiffStrategyConfig {
214 fn sanitized(&self) -> Self {
215 const EPS: f64 = 1e-6;
216 let mut config = self.clone();
217 config.c_scan = normalize_cost(config.c_scan, 1.0);
218 config.c_emit = normalize_cost(config.c_emit, 6.0);
219 config.c_row = normalize_cost(config.c_row, 0.1);
220 config.prior_alpha = normalize_positive(config.prior_alpha, 1.0);
221 config.prior_beta = normalize_positive(config.prior_beta, 19.0);
222 config.decay = normalize_decay(config.decay);
223 config.conservative_quantile = config.conservative_quantile.clamp(EPS, 1.0 - EPS);
224 config.hysteresis_ratio = normalize_ratio(config.hysteresis_ratio, 0.05);
225 config.uncertainty_guard_variance =
226 normalize_cost(config.uncertainty_guard_variance, 0.002);
227 config
228 }
229}
230
231fn normalize_positive(value: f64, fallback: f64) -> f64 {
232 if value.is_finite() && value > 0.0 {
233 value
234 } else {
235 fallback
236 }
237}
238
239fn normalize_cost(value: f64, fallback: f64) -> f64 {
240 if value.is_finite() && value >= 0.0 {
241 value
242 } else {
243 fallback
244 }
245}
246
247fn normalize_decay(value: f64) -> f64 {
248 if value.is_finite() && value > 0.0 {
249 value.min(1.0)
250 } else {
251 1.0
252 }
253}
254
255fn normalize_ratio(value: f64, fallback: f64) -> f64 {
256 if value.is_finite() {
257 value.clamp(0.0, 1.0)
258 } else {
259 fallback
260 }
261}
262
263#[derive(Debug, Clone)]
271pub struct ChangeRateEstimator {
272 prior_alpha: f64,
273 prior_beta: f64,
274 alpha: f64,
275 beta: f64,
276 decay: f64,
277 min_observation_cells: usize,
278}
279
280impl ChangeRateEstimator {
281 pub fn new(
283 prior_alpha: f64,
284 prior_beta: f64,
285 decay: f64,
286 min_observation_cells: usize,
287 ) -> Self {
288 Self {
289 prior_alpha,
290 prior_beta,
291 alpha: prior_alpha,
292 beta: prior_beta,
293 decay,
294 min_observation_cells,
295 }
296 }
297
298 pub fn reset(&mut self) {
300 self.alpha = self.prior_alpha;
301 self.beta = self.prior_beta;
302 }
303
304 pub fn posterior_params(&self) -> (f64, f64) {
306 (self.alpha, self.beta)
307 }
308
309 pub fn mean(&self) -> f64 {
311 self.alpha / (self.alpha + self.beta)
312 }
313
314 pub fn variance(&self) -> f64 {
316 let sum = self.alpha + self.beta;
317 (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
318 }
319
320 pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
322 if cells_scanned < self.min_observation_cells {
323 return;
324 }
325
326 let cells_changed = cells_changed.min(cells_scanned);
327 self.alpha *= self.decay;
328 self.beta *= self.decay;
329
330 self.alpha += cells_changed as f64;
331 self.beta += (cells_scanned.saturating_sub(cells_changed)) as f64;
332
333 const EPS: f64 = 1e-6;
334 const MAX: f64 = 1e6;
335 self.alpha = self.alpha.clamp(EPS, MAX);
336 self.beta = self.beta.clamp(EPS, MAX);
337 }
338
339 pub fn upper_quantile(&self, q: f64) -> f64 {
341 let q = q.clamp(1e-6, 1.0 - 1e-6);
342 let mean = self.mean();
343 let var = self.variance();
344 let std = var.sqrt();
345
346 let z = if q >= 0.5 {
348 let t = (-2.0 * (1.0 - q).ln()).sqrt();
349 t - (2.515517 + 0.802853 * t + 0.010328 * t * t)
350 / (1.0 + 1.432788 * t + 0.189269 * t * t + 0.001308 * t * t * t)
351 } else {
352 let t = (-2.0 * q.ln()).sqrt();
353 -(t - (2.515517 + 0.802853 * t + 0.010328 * t * t)
354 / (1.0 + 1.432788 * t + 0.189269 * t * t + 0.001308 * t * t * t))
355 };
356
357 (mean + z * std).clamp(0.0, 1.0)
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum DiffStrategy {
368 Full,
370 DirtyRows,
372 FullRedraw,
374}
375
376impl fmt::Display for DiffStrategy {
377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 match self {
379 Self::Full => write!(f, "Full"),
380 Self::DirtyRows => write!(f, "DirtyRows"),
381 Self::FullRedraw => write!(f, "FullRedraw"),
382 }
383 }
384}
385
386#[derive(Debug, Clone)]
395pub struct StrategyEvidence {
396 pub strategy: DiffStrategy,
398
399 pub cost_full: f64,
401
402 pub cost_dirty: f64,
404
405 pub cost_redraw: f64,
407
408 pub posterior_mean: f64,
410
411 pub posterior_variance: f64,
413
414 pub alpha: f64,
416
417 pub beta: f64,
419
420 pub dirty_rows: usize,
422
423 pub total_rows: usize,
425
426 pub total_cells: usize,
428
429 pub guard_reason: &'static str,
431
432 pub hysteresis_applied: bool,
434
435 pub hysteresis_ratio: f64,
437}
438
439impl fmt::Display for StrategyEvidence {
440 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441 writeln!(f, "Strategy: {}", self.strategy)?;
442 writeln!(
443 f,
444 "Costs: Full={:.2}, Dirty={:.2}, Redraw={:.2}",
445 self.cost_full, self.cost_dirty, self.cost_redraw
446 )?;
447 writeln!(
448 f,
449 "Posterior: p~Beta({:.2},{:.2}), E[p]={:.4}, Var[p]={:.6}",
450 self.alpha, self.beta, self.posterior_mean, self.posterior_variance
451 )?;
452 writeln!(
453 f,
454 "Dirty: {}/{} rows, {} total cells",
455 self.dirty_rows, self.total_rows, self.total_cells
456 )?;
457 writeln!(
458 f,
459 "Guard: {}, Hysteresis: {} (ratio {:.3})",
460 self.guard_reason, self.hysteresis_applied, self.hysteresis_ratio
461 )
462 }
463}
464
465#[derive(Debug, Clone)]
474pub struct DiffStrategySelector {
475 config: DiffStrategyConfig,
476 estimator: ChangeRateEstimator,
477
478 frame_count: u64,
480
481 last_evidence: Option<StrategyEvidence>,
483}
484
485impl DiffStrategySelector {
486 pub fn new(config: DiffStrategyConfig) -> Self {
488 let config = config.sanitized();
489 let estimator = ChangeRateEstimator::new(
490 config.prior_alpha,
491 config.prior_beta,
492 config.decay,
493 config.min_observation_cells,
494 );
495 Self {
496 config,
497 estimator,
498 frame_count: 0,
499 last_evidence: None,
500 }
501 }
502
503 pub fn with_defaults() -> Self {
505 Self::new(DiffStrategyConfig::default())
506 }
507
508 pub fn config(&self) -> &DiffStrategyConfig {
510 &self.config
511 }
512
513 pub fn posterior_params(&self) -> (f64, f64) {
515 self.estimator.posterior_params()
516 }
517
518 pub fn posterior_mean(&self) -> f64 {
520 self.estimator.mean()
521 }
522
523 pub fn posterior_variance(&self) -> f64 {
525 self.estimator.variance()
526 }
527
528 pub fn last_evidence(&self) -> Option<&StrategyEvidence> {
530 self.last_evidence.as_ref()
531 }
532
533 pub fn frame_count(&self) -> u64 {
535 self.frame_count
536 }
537
538 pub fn override_last_strategy(&mut self, strategy: DiffStrategy, reason: &'static str) {
543 if let Some(evidence) = self.last_evidence.as_mut() {
544 evidence.strategy = strategy;
545 evidence.guard_reason = reason;
546 evidence.hysteresis_applied = false;
547 }
548 }
549
550 pub fn select(&mut self, width: u16, height: u16, dirty_rows: usize) -> DiffStrategy {
562 let scan_cells = dirty_rows.saturating_mul(width as usize);
563 self.select_with_scan_estimate(width, height, dirty_rows, scan_cells)
564 }
565
566 pub fn select_with_scan_estimate(
572 &mut self,
573 width: u16,
574 height: u16,
575 dirty_rows: usize,
576 dirty_scan_cells: usize,
577 ) -> DiffStrategy {
578 self.frame_count += 1;
579
580 let w = width as f64;
581 let h = height as f64;
582 let d = dirty_rows as f64;
583 let n = w * h;
584 let scan_cells =
585 dirty_scan_cells.min((width as usize).saturating_mul(height as usize)) as f64;
586
587 let uncertainty_guard = self.config.uncertainty_guard_variance > 0.0
589 && self.posterior_variance() > self.config.uncertainty_guard_variance;
590 let mut guard_reason = if dirty_rows == 0 {
591 "zero_dirty_rows"
592 } else {
593 "none"
594 };
595 let mut p = if self.config.conservative || uncertainty_guard {
596 self.upper_quantile(self.config.conservative_quantile)
597 } else {
598 self.posterior_mean()
599 };
600 if dirty_rows == 0 {
601 p = 0.0;
602 }
603
604 let cost_full =
606 self.config.c_row * h + self.config.c_scan * d * w + self.config.c_emit * p * n;
607
608 let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * n;
609
610 let cost_redraw = self.config.c_emit * n;
611
612 let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
614 DiffStrategy::DirtyRows
615 } else if cost_full <= cost_redraw {
616 DiffStrategy::Full
617 } else {
618 DiffStrategy::FullRedraw
619 };
620
621 if uncertainty_guard {
622 if guard_reason == "none" {
623 guard_reason = "uncertainty_variance";
624 }
625 if strategy == DiffStrategy::FullRedraw {
626 strategy = if cost_dirty <= cost_full {
627 DiffStrategy::DirtyRows
628 } else {
629 DiffStrategy::Full
630 };
631 }
632 }
633
634 let mut hysteresis_applied = false;
635 if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
636 && prev != strategy
637 {
638 let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
639 let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
640 let ratio = self.config.hysteresis_ratio;
641 if ratio > 0.0
642 && prev_cost.is_finite()
643 && prev_cost > 0.0
644 && new_cost >= prev_cost * (1.0 - ratio)
645 && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
646 {
647 strategy = prev;
648 hysteresis_applied = true;
649 }
650 }
651
652 let (alpha, beta) = self.estimator.posterior_params();
654 self.last_evidence = Some(StrategyEvidence {
655 strategy,
656 cost_full,
657 cost_dirty,
658 cost_redraw,
659 posterior_mean: self.posterior_mean(),
660 posterior_variance: self.posterior_variance(),
661 alpha,
662 beta,
663 dirty_rows,
664 total_rows: height as usize,
665 total_cells: (width as usize) * (height as usize),
666 guard_reason,
667 hysteresis_applied,
668 hysteresis_ratio: self.config.hysteresis_ratio,
669 });
670
671 strategy
672 }
673
674 pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
681 self.estimator.observe(cells_scanned, cells_changed);
682 }
683
684 pub fn reset(&mut self) {
686 self.estimator.reset();
687 self.frame_count = 0;
688 self.last_evidence = None;
689 }
690
691 fn upper_quantile(&self, q: f64) -> f64 {
696 self.estimator.upper_quantile(q)
697 }
698}
699
700#[inline]
701fn cost_for_strategy(
702 strategy: DiffStrategy,
703 cost_full: f64,
704 cost_dirty: f64,
705 cost_redraw: f64,
706) -> f64 {
707 match strategy {
708 DiffStrategy::Full => cost_full,
709 DiffStrategy::DirtyRows => cost_dirty,
710 DiffStrategy::FullRedraw => cost_redraw,
711 }
712}
713
714impl Default for DiffStrategySelector {
715 fn default() -> Self {
716 Self::with_defaults()
717 }
718}
719
720#[cfg(test)]
725mod tests {
726 use super::*;
727
728 fn strategy_costs(
729 config: &DiffStrategyConfig,
730 width: u16,
731 height: u16,
732 dirty_rows: usize,
733 p_actual: f64,
734 ) -> (f64, f64, f64) {
735 let w = width as f64;
736 let h = height as f64;
737 let d = dirty_rows as f64;
738 let n = w * h;
739 let p = p_actual.clamp(0.0, 1.0);
740
741 let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
742 let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
743 let cost_redraw = config.c_emit * n;
744
745 (cost_full, cost_dirty, cost_redraw)
746 }
747
748 #[test]
749 fn test_default_config() {
750 let config = DiffStrategyConfig::default();
751 assert!((config.c_scan - 1.0).abs() < 1e-9);
752 assert!((config.c_emit - 6.0).abs() < 1e-9);
753 assert!((config.prior_alpha - 1.0).abs() < 1e-9);
754 assert!((config.prior_beta - 19.0).abs() < 1e-9);
755 assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
756 assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
757 assert_eq!(config.min_observation_cells, 1);
758 }
759
760 #[test]
761 fn test_decay_paused_on_empty_observation() {
762 let mut selector = DiffStrategySelector::with_defaults();
763 let initial_mean = selector.posterior_mean();
764
765 for _ in 0..100 {
767 selector.observe(0, 0);
768 }
769
770 assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
772 }
773
774 #[test]
775 fn estimator_initializes_from_priors() {
776 let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
777 let (alpha, beta) = estimator.posterior_params();
778 assert!((alpha - 2.0).abs() < 1e-9);
779 assert!((beta - 8.0).abs() < 1e-9);
780 assert!((estimator.mean() - 0.2).abs() < 1e-9);
781 }
782
783 #[test]
784 fn estimator_updates_with_decay() {
785 let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
786 estimator.observe(100, 10);
787 let (alpha, beta) = estimator.posterior_params();
788 assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
789 assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
790 }
791
792 #[test]
793 fn estimator_clamps_bounds() {
794 let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
795 for _ in 0..1000 {
796 estimator.observe(1_000_000, 1_000_000);
797 }
798 let (alpha, beta) = estimator.posterior_params();
799 assert!(alpha <= 1e6);
800 assert!(beta >= 1e-6);
801 }
802
803 #[test]
804 fn test_posterior_mean_initial() {
805 let selector = DiffStrategySelector::with_defaults();
806 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
808 }
809
810 #[test]
811 fn test_posterior_update() {
812 let mut selector = DiffStrategySelector::with_defaults();
813
814 selector.observe(100, 10);
816
817 let mean = selector.posterior_mean();
822 assert!(
823 mean > 0.05,
824 "Mean should increase after observing 10% change"
825 );
826 assert!(mean < 0.15, "Mean should not be too high");
827 }
828
829 #[test]
830 fn test_select_dirty_rows_when_few_dirty() {
831 let mut selector = DiffStrategySelector::with_defaults();
832
833 let strategy = selector.select(80, 24, 2); assert_eq!(strategy, DiffStrategy::DirtyRows);
837 }
838
839 #[test]
840 fn test_select_dirty_rows_when_no_dirty() {
841 let mut selector = DiffStrategySelector::with_defaults();
842
843 let strategy = selector.select(80, 24, 0);
844 assert_eq!(strategy, DiffStrategy::DirtyRows);
845
846 let evidence = selector.last_evidence().expect("evidence stored");
847 assert_eq!(evidence.guard_reason, "zero_dirty_rows");
848 }
849
850 #[test]
851 fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
852 let mut selector = DiffStrategySelector::with_defaults();
853
854 let strategy = selector.select(200, 60, 1);
856 assert_eq!(strategy, DiffStrategy::DirtyRows);
857 }
858
859 #[test]
860 fn test_select_full_redraw_when_high_change() {
861 let config = DiffStrategyConfig {
862 prior_alpha: 9.0, prior_beta: 1.0, ..Default::default()
865 };
866
867 let mut selector = DiffStrategySelector::new(config);
868 let strategy = selector.select(80, 24, 24); assert!(matches!(
874 strategy,
875 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
876 ));
877 }
878
879 #[test]
880 fn test_evidence_stored() {
881 let mut selector = DiffStrategySelector::with_defaults();
882 selector.select(80, 24, 5);
883
884 let evidence = selector.last_evidence().expect("Evidence should be stored");
885 assert_eq!(evidence.total_rows, 24);
886 assert_eq!(evidence.total_cells, 80 * 24);
887 assert_eq!(evidence.dirty_rows, 5);
888 }
889
890 #[test]
891 fn test_posterior_clamping() {
892 let mut selector = DiffStrategySelector::with_defaults();
893
894 for _ in 0..1000 {
896 selector.observe(1_000_000, 1_000_000);
897 }
898
899 let (alpha, beta) = selector.posterior_params();
900 assert!(alpha <= 1e6, "Alpha should be clamped");
901 assert!(beta >= 1e-6, "Beta should be clamped");
902 }
903
904 #[test]
905 fn conservative_quantile_extremes_are_safe() {
906 let config = DiffStrategyConfig {
907 conservative: true,
908 conservative_quantile: 1.0,
909 ..Default::default()
910 };
911 let mut selector = DiffStrategySelector::new(config);
912
913 let strategy = selector.select(80, 24, 0);
914 let evidence = selector.last_evidence().expect("evidence should exist");
915
916 assert_eq!(strategy, evidence.strategy);
917 assert!(evidence.cost_full.is_finite());
918 assert!(evidence.cost_dirty.is_finite());
919 assert!(evidence.cost_redraw.is_finite());
920 }
921
922 #[test]
923 fn sanitize_config_clamps_invalid_values() {
924 let config = DiffStrategyConfig {
925 c_scan: -1.0,
926 c_emit: f64::NAN,
927 c_row: f64::INFINITY,
928 prior_alpha: 0.0,
929 prior_beta: -3.0,
930 decay: -1.0,
931 conservative: true,
932 conservative_quantile: 2.0,
933 min_observation_cells: 0,
934 hysteresis_ratio: -1.0,
935 uncertainty_guard_variance: -1.0,
936 };
937 let selector = DiffStrategySelector::new(config);
938 let sanitized = selector.config();
939
940 assert!(sanitized.c_scan >= 0.0);
941 assert!(sanitized.c_emit.is_finite());
942 assert!(sanitized.c_row.is_finite());
943 assert!(sanitized.prior_alpha > 0.0);
944 assert!(sanitized.prior_beta > 0.0);
945 assert!((0.0..=1.0).contains(&sanitized.decay));
946 assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
947 assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
948 assert!(sanitized.uncertainty_guard_variance >= 0.0);
949 }
950
951 #[test]
952 fn hysteresis_can_freeze_strategy_switching() {
953 let config = DiffStrategyConfig {
954 hysteresis_ratio: 1.0,
955 uncertainty_guard_variance: 0.0,
956 ..Default::default()
957 };
958 let mut selector = DiffStrategySelector::new(config);
959
960 let first = selector.select(80, 24, 1);
961 let second = selector.select(80, 24, 24);
962
963 assert_eq!(
964 first, second,
965 "With hysteresis_ratio=1.0, selector should keep prior strategy"
966 );
967 }
968
969 #[test]
970 fn uncertainty_guard_avoids_full_redraw() {
971 let config = DiffStrategyConfig {
972 c_scan: 10.0,
973 c_emit: 1.0,
974 uncertainty_guard_variance: 1e-6,
975 ..Default::default()
976 };
977 let mut selector = DiffStrategySelector::new(config);
978
979 let strategy = selector.select(80, 24, 24);
980 assert_ne!(
981 strategy,
982 DiffStrategy::FullRedraw,
983 "Uncertainty guard should avoid FullRedraw under high variance"
984 );
985 }
986
987 #[test]
988 fn selector_regret_bounded_across_regimes() {
989 let mut selector = DiffStrategySelector::with_defaults();
990 let config = selector.config().clone();
991 let width = 200u16;
992 let height = 60u16;
993 let total_cells = width as usize * height as usize;
994
995 let regimes = [
996 (100usize, 2usize, 0.02f64),
997 (100usize, 12usize, 0.12f64),
998 (100usize, height as usize, 0.6f64),
999 ];
1000
1001 let mut selector_total = 0.0f64;
1002 let mut fixed_full_total = 0.0f64;
1003 let mut fixed_dirty_total = 0.0f64;
1004 let mut fixed_redraw_total = 0.0f64;
1005
1006 for (frames, dirty_rows, p_actual) in regimes {
1007 for _ in 0..frames {
1008 let strategy = selector.select(width, height, dirty_rows);
1009 let (cost_full, cost_dirty, cost_redraw) =
1010 strategy_costs(&config, width, height, dirty_rows, p_actual);
1011 fixed_full_total += cost_full;
1012 fixed_dirty_total += cost_dirty;
1013 fixed_redraw_total += cost_redraw;
1014
1015 let chosen_cost = match strategy {
1016 DiffStrategy::Full => cost_full,
1017 DiffStrategy::DirtyRows => cost_dirty,
1018 DiffStrategy::FullRedraw => cost_redraw,
1019 };
1020 selector_total += chosen_cost;
1021
1022 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1023 let scanned = match strategy {
1024 DiffStrategy::Full => total_cells,
1025 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1026 DiffStrategy::FullRedraw => 0,
1027 };
1028 if strategy != DiffStrategy::FullRedraw {
1029 selector.observe(scanned, changed);
1030 }
1031 }
1032 }
1033
1034 let best_fixed = fixed_full_total
1035 .min(fixed_dirty_total)
1036 .min(fixed_redraw_total);
1037 let regret = if best_fixed > 0.0 {
1038 (selector_total - best_fixed) / best_fixed
1039 } else {
1040 0.0
1041 };
1042 let evidence = selector
1043 .last_evidence()
1044 .map(ToString::to_string)
1045 .unwrap_or_else(|| "no evidence".to_string());
1046
1047 assert!(
1048 regret <= 0.05,
1049 "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1050 regret,
1051 selector_total,
1052 best_fixed,
1053 evidence
1054 );
1055 }
1056
1057 #[test]
1058 fn selector_switching_is_stable_under_constant_load() {
1059 let mut selector = DiffStrategySelector::with_defaults();
1060 let config = selector.config().clone();
1061 let width = 200u16;
1062 let height = 60u16;
1063 let dirty_rows = 2usize;
1064 let p_actual = 0.02f64;
1065 let total_cells = width as usize * height as usize;
1066
1067 let mut switches = 0usize;
1068 let mut last = None;
1069
1070 for _ in 0..200 {
1071 let strategy = selector.select(width, height, dirty_rows);
1072 if let Some(prev) = last
1073 && prev != strategy
1074 {
1075 switches = switches.saturating_add(1);
1076 }
1077 last = Some(strategy);
1078
1079 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1080 let scanned = match strategy {
1081 DiffStrategy::Full => total_cells,
1082 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1083 DiffStrategy::FullRedraw => 0,
1084 };
1085 if strategy != DiffStrategy::FullRedraw {
1086 selector.observe(scanned, changed);
1087 }
1088
1089 let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1090 }
1091
1092 let evidence = selector
1093 .last_evidence()
1094 .map(ToString::to_string)
1095 .unwrap_or_else(|| "no evidence".to_string());
1096 assert!(
1097 switches <= 40,
1098 "Selector switched too often under stable regime: {switches}\n{evidence}"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_reset() {
1104 let mut selector = DiffStrategySelector::with_defaults();
1105 selector.observe(100, 50);
1106 selector.select(80, 24, 10);
1107
1108 selector.reset();
1109
1110 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1111 assert_eq!(selector.frame_count(), 0);
1112 assert!(selector.last_evidence().is_none());
1113 }
1114
1115 #[test]
1116 fn test_deterministic() {
1117 let mut sel1 = DiffStrategySelector::with_defaults();
1118 let mut sel2 = DiffStrategySelector::with_defaults();
1119
1120 sel1.observe(100, 10);
1122 sel2.observe(100, 10);
1123
1124 let s1 = sel1.select(80, 24, 5);
1125 let s2 = sel2.select(80, 24, 5);
1126
1127 assert_eq!(s1, s2);
1128 assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1129 }
1130
1131 #[test]
1132 fn test_upper_quantile_reasonable() {
1133 let selector = DiffStrategySelector::with_defaults();
1134 let mean = selector.posterior_mean();
1135 let q95 = selector.upper_quantile(0.95);
1136
1137 assert!(q95 > mean, "95th percentile should be above mean");
1138 assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1139 }
1140
1141 #[test]
1143 fn prop_posterior_mean_bounded() {
1144 let mut selector = DiffStrategySelector::with_defaults();
1145
1146 for scanned in [1, 10, 100, 1000, 10000] {
1147 for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1148 selector.observe(scanned, changed);
1149 let mean = selector.posterior_mean();
1150 assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1151 }
1152 }
1153 }
1154
1155 #[test]
1157 fn prop_variance_non_negative() {
1158 let mut selector = DiffStrategySelector::with_defaults();
1159
1160 for _ in 0..100 {
1161 selector.observe(100, 5);
1162 assert!(selector.posterior_variance() >= 0.0);
1163 }
1164 }
1165}