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 = if config.conservative_quantile.is_nan() {
224 EPS
225 } else {
226 config.conservative_quantile.clamp(EPS, 1.0 - EPS)
227 };
228 config.hysteresis_ratio = normalize_ratio(config.hysteresis_ratio, 0.05);
229 config.uncertainty_guard_variance =
230 normalize_cost(config.uncertainty_guard_variance, 0.002);
231 config
232 }
233}
234
235fn normalize_positive(value: f64, fallback: f64) -> f64 {
236 if value.is_finite() && value > 0.0 {
237 value
238 } else {
239 fallback
240 }
241}
242
243fn normalize_cost(value: f64, fallback: f64) -> f64 {
244 if value.is_finite() && value >= 0.0 {
245 value
246 } else {
247 fallback
248 }
249}
250
251fn normalize_decay(value: f64) -> f64 {
252 if value.is_finite() && value > 0.0 {
253 value.min(1.0)
254 } else {
255 1.0
256 }
257}
258
259fn normalize_ratio(value: f64, fallback: f64) -> f64 {
260 if value.is_finite() {
261 value.clamp(0.0, 1.0)
262 } else {
263 fallback
264 }
265}
266
267#[derive(Debug, Clone)]
275pub struct ChangeRateEstimator {
276 prior_alpha: f64,
277 prior_beta: f64,
278 alpha: f64,
279 beta: f64,
280 decay: f64,
281 min_observation_cells: usize,
282}
283
284impl ChangeRateEstimator {
285 pub fn new(
287 prior_alpha: f64,
288 prior_beta: f64,
289 decay: f64,
290 min_observation_cells: usize,
291 ) -> Self {
292 Self {
293 prior_alpha,
294 prior_beta,
295 alpha: prior_alpha,
296 beta: prior_beta,
297 decay,
298 min_observation_cells,
299 }
300 }
301
302 pub fn reset(&mut self) {
304 self.alpha = self.prior_alpha;
305 self.beta = self.prior_beta;
306 }
307
308 pub fn posterior_params(&self) -> (f64, f64) {
310 (self.alpha, self.beta)
311 }
312
313 pub fn mean(&self) -> f64 {
315 self.alpha / (self.alpha + self.beta)
316 }
317
318 pub fn variance(&self) -> f64 {
320 let sum = self.alpha + self.beta;
321 (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
322 }
323
324 pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
326 if cells_scanned < self.min_observation_cells {
327 return;
328 }
329
330 let cells_changed = cells_changed.min(cells_scanned);
331 self.alpha *= self.decay;
332 self.beta *= self.decay;
333
334 self.alpha += cells_changed as f64;
335 self.beta += (cells_scanned.saturating_sub(cells_changed)) as f64;
336
337 const EPS: f64 = 1e-6;
338 const MAX: f64 = 1e6;
339 self.alpha = self.alpha.clamp(EPS, MAX);
340 self.beta = self.beta.clamp(EPS, MAX);
341 }
342
343 pub fn upper_quantile(&self, q: f64) -> f64 {
345 let q = q.clamp(1e-6, 1.0 - 1e-6);
346 let mean = self.mean();
347 let var = self.variance();
348 let std = var.sqrt();
349
350 let z = if q >= 0.5 {
352 let t = (-2.0 * (1.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 } else {
356 let t = (-2.0 * q.ln()).sqrt();
357 -(t - (2.515517 + 0.802853 * t + 0.010328 * t * t)
358 / (1.0 + 1.432788 * t + 0.189269 * t * t + 0.001308 * t * t * t))
359 };
360
361 (mean + z * std).clamp(0.0, 1.0)
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum DiffStrategy {
372 Full,
374 DirtyRows,
376 FullRedraw,
378}
379
380impl fmt::Display for DiffStrategy {
381 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382 match self {
383 Self::Full => write!(f, "Full"),
384 Self::DirtyRows => write!(f, "DirtyRows"),
385 Self::FullRedraw => write!(f, "FullRedraw"),
386 }
387 }
388}
389
390#[derive(Debug, Clone)]
399pub struct StrategyEvidence {
400 pub strategy: DiffStrategy,
402
403 pub cost_full: f64,
405
406 pub cost_dirty: f64,
408
409 pub cost_redraw: f64,
411
412 pub posterior_mean: f64,
414
415 pub posterior_variance: f64,
417
418 pub alpha: f64,
420
421 pub beta: f64,
423
424 pub dirty_rows: usize,
426
427 pub total_rows: usize,
429
430 pub total_cells: usize,
432
433 pub guard_reason: &'static str,
435
436 pub hysteresis_applied: bool,
438
439 pub hysteresis_ratio: f64,
441}
442
443impl StrategyEvidence {
444 #[must_use]
446 pub fn to_jsonl(&self) -> String {
447 format!(
448 r#"{{"schema":"diff-strategy-v1","strategy":"{}","cost_full":{:.2},"cost_dirty":{:.2},"cost_redraw":{:.2},"posterior_mean":{:.6},"posterior_var":{:.8},"alpha":{:.4},"beta":{:.4},"dirty_rows":{},"total_rows":{},"total_cells":{},"guard":"{}","hysteresis":{},"hysteresis_ratio":{:.4}}}"#,
449 self.strategy,
450 self.cost_full,
451 self.cost_dirty,
452 self.cost_redraw,
453 self.posterior_mean,
454 self.posterior_variance,
455 self.alpha,
456 self.beta,
457 self.dirty_rows,
458 self.total_rows,
459 self.total_cells,
460 self.guard_reason,
461 self.hysteresis_applied,
462 self.hysteresis_ratio,
463 )
464 }
465}
466
467impl fmt::Display for StrategyEvidence {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 writeln!(f, "Strategy: {}", self.strategy)?;
470 writeln!(
471 f,
472 "Costs: Full={:.2}, Dirty={:.2}, Redraw={:.2}",
473 self.cost_full, self.cost_dirty, self.cost_redraw
474 )?;
475 writeln!(
476 f,
477 "Posterior: p~Beta({:.2},{:.2}), E[p]={:.4}, Var[p]={:.6}",
478 self.alpha, self.beta, self.posterior_mean, self.posterior_variance
479 )?;
480 writeln!(
481 f,
482 "Dirty: {}/{} rows, {} total cells",
483 self.dirty_rows, self.total_rows, self.total_cells
484 )?;
485 writeln!(
486 f,
487 "Guard: {}, Hysteresis: {} (ratio {:.3})",
488 self.guard_reason, self.hysteresis_applied, self.hysteresis_ratio
489 )
490 }
491}
492
493#[derive(Debug, Clone)]
502pub struct DiffStrategySelector {
503 config: DiffStrategyConfig,
504 estimator: ChangeRateEstimator,
505
506 frame_count: u64,
508
509 last_evidence: Option<StrategyEvidence>,
511}
512
513impl DiffStrategySelector {
514 pub fn new(config: DiffStrategyConfig) -> Self {
516 let config = config.sanitized();
517 let estimator = ChangeRateEstimator::new(
518 config.prior_alpha,
519 config.prior_beta,
520 config.decay,
521 config.min_observation_cells,
522 );
523 Self {
524 config,
525 estimator,
526 frame_count: 0,
527 last_evidence: None,
528 }
529 }
530
531 pub fn with_defaults() -> Self {
533 Self::new(DiffStrategyConfig::default())
534 }
535
536 #[must_use]
538 pub fn config(&self) -> &DiffStrategyConfig {
539 &self.config
540 }
541
542 #[must_use]
544 pub fn posterior_params(&self) -> (f64, f64) {
545 self.estimator.posterior_params()
546 }
547
548 #[must_use]
550 pub fn posterior_mean(&self) -> f64 {
551 self.estimator.mean()
552 }
553
554 #[must_use]
556 pub fn posterior_variance(&self) -> f64 {
557 self.estimator.variance()
558 }
559
560 #[must_use]
562 pub fn last_evidence(&self) -> Option<&StrategyEvidence> {
563 self.last_evidence.as_ref()
564 }
565
566 pub fn frame_count(&self) -> u64 {
568 self.frame_count
569 }
570
571 pub fn override_last_strategy(&mut self, strategy: DiffStrategy, reason: &'static str) {
576 if let Some(evidence) = self.last_evidence.as_mut() {
577 evidence.strategy = strategy;
578 evidence.guard_reason = reason;
579 evidence.hysteresis_applied = false;
580 }
581 }
582
583 pub fn select(&mut self, width: u16, height: u16, dirty_rows: usize) -> DiffStrategy {
595 let scan_cells = dirty_rows.saturating_mul(width as usize);
596 self.select_with_scan_estimate(width, height, dirty_rows, scan_cells)
597 }
598
599 pub fn select_with_scan_estimate(
605 &mut self,
606 width: u16,
607 height: u16,
608 dirty_rows: usize,
609 dirty_scan_cells: usize,
610 ) -> DiffStrategy {
611 self.frame_count += 1;
612
613 let w = width as f64;
614 let h = height as f64;
615 let d = dirty_rows as f64;
616 let n = w * h;
617 let scan_cells =
618 dirty_scan_cells.min((width as usize).saturating_mul(height as usize)) as f64;
619
620 let uncertainty_guard = self.config.uncertainty_guard_variance > 0.0
622 && self.posterior_variance() > self.config.uncertainty_guard_variance;
623 let mut guard_reason = if dirty_rows == 0 {
624 "zero_dirty_rows"
625 } else {
626 "none"
627 };
628 let mut p = if self.config.conservative || uncertainty_guard {
629 self.upper_quantile(self.config.conservative_quantile)
630 } else {
631 self.posterior_mean()
632 };
633 if dirty_rows == 0 {
634 p = 0.0;
635 }
636
637 let cost_full = self.config.c_row * h
652 + self.config.c_scan * d * w
653 + self.config.c_emit * p * (d * w).min(n);
654
655 let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * scan_cells;
656
657 let cost_redraw = self.config.c_emit * n;
658
659 let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
661 DiffStrategy::DirtyRows
662 } else if cost_full <= cost_redraw {
663 DiffStrategy::Full
664 } else {
665 DiffStrategy::FullRedraw
666 };
667
668 if uncertainty_guard {
669 if guard_reason == "none" {
670 guard_reason = "uncertainty_variance";
671 }
672 if strategy == DiffStrategy::FullRedraw {
673 strategy = if cost_dirty <= cost_full {
674 DiffStrategy::DirtyRows
675 } else {
676 DiffStrategy::Full
677 };
678 }
679 }
680
681 let mut hysteresis_applied = false;
682 if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
683 && prev != strategy
684 {
685 let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
686 let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
687 let ratio = self.config.hysteresis_ratio;
688 if ratio > 0.0
689 && prev_cost.is_finite()
690 && prev_cost > 0.0
691 && new_cost >= prev_cost * (1.0 - ratio)
692 && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
693 {
694 strategy = prev;
695 hysteresis_applied = true;
696 }
697 }
698
699 let (alpha, beta) = self.estimator.posterior_params();
701 self.last_evidence = Some(StrategyEvidence {
702 strategy,
703 cost_full,
704 cost_dirty,
705 cost_redraw,
706 posterior_mean: self.posterior_mean(),
707 posterior_variance: self.posterior_variance(),
708 alpha,
709 beta,
710 dirty_rows,
711 total_rows: height as usize,
712 total_cells: (width as usize) * (height as usize),
713 guard_reason,
714 hysteresis_applied,
715 hysteresis_ratio: self.config.hysteresis_ratio,
716 });
717
718 strategy
719 }
720
721 pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
728 self.estimator.observe(cells_scanned, cells_changed);
729 }
730
731 pub fn reset(&mut self) {
733 self.estimator.reset();
734 self.frame_count = 0;
735 self.last_evidence = None;
736 }
737
738 fn upper_quantile(&self, q: f64) -> f64 {
743 self.estimator.upper_quantile(q)
744 }
745}
746
747#[inline]
748fn cost_for_strategy(
749 strategy: DiffStrategy,
750 cost_full: f64,
751 cost_dirty: f64,
752 cost_redraw: f64,
753) -> f64 {
754 match strategy {
755 DiffStrategy::Full => cost_full,
756 DiffStrategy::DirtyRows => cost_dirty,
757 DiffStrategy::FullRedraw => cost_redraw,
758 }
759}
760
761impl Default for DiffStrategySelector {
762 fn default() -> Self {
763 Self::with_defaults()
764 }
765}
766
767#[cfg(test)]
772mod tests {
773 use super::*;
774
775 fn strategy_costs(
776 config: &DiffStrategyConfig,
777 width: u16,
778 height: u16,
779 dirty_rows: usize,
780 p_actual: f64,
781 ) -> (f64, f64, f64) {
782 let w = width as f64;
783 let h = height as f64;
784 let d = dirty_rows as f64;
785 let n = w * h;
786 let p = p_actual.clamp(0.0, 1.0);
787
788 let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
789 let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
790 let cost_redraw = config.c_emit * n;
791
792 (cost_full, cost_dirty, cost_redraw)
793 }
794
795 #[test]
796 fn test_default_config() {
797 let config = DiffStrategyConfig::default();
798 assert!((config.c_scan - 1.0).abs() < 1e-9);
799 assert!((config.c_emit - 6.0).abs() < 1e-9);
800 assert!((config.prior_alpha - 1.0).abs() < 1e-9);
801 assert!((config.prior_beta - 19.0).abs() < 1e-9);
802 assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
803 assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
804 assert_eq!(config.min_observation_cells, 1);
805 }
806
807 #[test]
808 fn test_decay_paused_on_empty_observation() {
809 let mut selector = DiffStrategySelector::with_defaults();
810 let initial_mean = selector.posterior_mean();
811
812 for _ in 0..100 {
814 selector.observe(0, 0);
815 }
816
817 assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
819 }
820
821 #[test]
822 fn estimator_initializes_from_priors() {
823 let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
824 let (alpha, beta) = estimator.posterior_params();
825 assert!((alpha - 2.0).abs() < 1e-9);
826 assert!((beta - 8.0).abs() < 1e-9);
827 assert!((estimator.mean() - 0.2).abs() < 1e-9);
828 }
829
830 #[test]
831 fn estimator_updates_with_decay() {
832 let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
833 estimator.observe(100, 10);
834 let (alpha, beta) = estimator.posterior_params();
835 assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
836 assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
837 }
838
839 #[test]
840 fn estimator_clamps_bounds() {
841 let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
842 for _ in 0..1000 {
843 estimator.observe(1_000_000, 1_000_000);
844 }
845 let (alpha, beta) = estimator.posterior_params();
846 assert!(alpha <= 1e6);
847 assert!(beta >= 1e-6);
848 }
849
850 #[test]
851 fn test_posterior_mean_initial() {
852 let selector = DiffStrategySelector::with_defaults();
853 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
855 }
856
857 #[test]
858 fn test_posterior_update() {
859 let mut selector = DiffStrategySelector::with_defaults();
860
861 selector.observe(100, 10);
863
864 let mean = selector.posterior_mean();
869 assert!(
870 mean > 0.05,
871 "Mean should increase after observing 10% change"
872 );
873 assert!(mean < 0.15, "Mean should not be too high");
874 }
875
876 #[test]
877 fn test_select_dirty_rows_when_few_dirty() {
878 let mut selector = DiffStrategySelector::with_defaults();
879
880 let strategy = selector.select(80, 24, 2); assert_eq!(strategy, DiffStrategy::DirtyRows);
884 }
885
886 #[test]
887 fn test_select_dirty_rows_when_no_dirty() {
888 let mut selector = DiffStrategySelector::with_defaults();
889
890 let strategy = selector.select(80, 24, 0);
891 assert_eq!(strategy, DiffStrategy::DirtyRows);
892
893 let evidence = selector.last_evidence().expect("evidence stored");
894 assert_eq!(evidence.guard_reason, "zero_dirty_rows");
895 }
896
897 #[test]
898 fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
899 let mut selector = DiffStrategySelector::with_defaults();
900
901 let strategy = selector.select(200, 60, 1);
903 assert_eq!(strategy, DiffStrategy::DirtyRows);
904 }
905
906 #[test]
907 fn test_select_full_redraw_when_high_change() {
908 let config = DiffStrategyConfig {
909 prior_alpha: 9.0, prior_beta: 1.0, ..Default::default()
912 };
913
914 let mut selector = DiffStrategySelector::new(config);
915 let strategy = selector.select(80, 24, 24); assert!(matches!(
921 strategy,
922 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
923 ));
924 }
925
926 #[test]
927 fn test_evidence_stored() {
928 let mut selector = DiffStrategySelector::with_defaults();
929 selector.select(80, 24, 5);
930
931 let evidence = selector.last_evidence().expect("Evidence should be stored");
932 assert_eq!(evidence.total_rows, 24);
933 assert_eq!(evidence.total_cells, 80 * 24);
934 assert_eq!(evidence.dirty_rows, 5);
935 }
936
937 #[test]
938 fn test_posterior_clamping() {
939 let mut selector = DiffStrategySelector::with_defaults();
940
941 for _ in 0..1000 {
943 selector.observe(1_000_000, 1_000_000);
944 }
945
946 let (alpha, beta) = selector.posterior_params();
947 assert!(alpha <= 1e6, "Alpha should be clamped");
948 assert!(beta >= 1e-6, "Beta should be clamped");
949 }
950
951 #[test]
952 fn conservative_quantile_extremes_are_safe() {
953 let config = DiffStrategyConfig {
954 conservative: true,
955 conservative_quantile: 1.0,
956 ..Default::default()
957 };
958 let mut selector = DiffStrategySelector::new(config);
959
960 let strategy = selector.select(80, 24, 0);
961 let evidence = selector.last_evidence().expect("evidence should exist");
962
963 assert_eq!(strategy, evidence.strategy);
964 assert!(evidence.cost_full.is_finite());
965 assert!(evidence.cost_dirty.is_finite());
966 assert!(evidence.cost_redraw.is_finite());
967 }
968
969 #[test]
970 fn sanitize_config_clamps_invalid_values() {
971 let config = DiffStrategyConfig {
972 c_scan: -1.0,
973 c_emit: f64::NAN,
974 c_row: f64::INFINITY,
975 prior_alpha: 0.0,
976 prior_beta: -3.0,
977 decay: -1.0,
978 conservative: true,
979 conservative_quantile: 2.0,
980 min_observation_cells: 0,
981 hysteresis_ratio: -1.0,
982 uncertainty_guard_variance: -1.0,
983 };
984 let selector = DiffStrategySelector::new(config);
985 let sanitized = selector.config();
986
987 assert!(sanitized.c_scan >= 0.0);
988 assert!(sanitized.c_emit.is_finite());
989 assert!(sanitized.c_row.is_finite());
990 assert!(sanitized.prior_alpha > 0.0);
991 assert!(sanitized.prior_beta > 0.0);
992 assert!((0.0..=1.0).contains(&sanitized.decay));
993 assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
994 assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
995 assert!(sanitized.uncertainty_guard_variance >= 0.0);
996 }
997
998 #[test]
999 fn hysteresis_can_freeze_strategy_switching() {
1000 let config = DiffStrategyConfig {
1001 hysteresis_ratio: 1.0,
1002 uncertainty_guard_variance: 0.0,
1003 ..Default::default()
1004 };
1005 let mut selector = DiffStrategySelector::new(config);
1006
1007 let first = selector.select(80, 24, 1);
1008 let second = selector.select(80, 24, 24);
1009
1010 assert_eq!(
1011 first, second,
1012 "With hysteresis_ratio=1.0, selector should keep prior strategy"
1013 );
1014 }
1015
1016 #[test]
1017 fn uncertainty_guard_avoids_full_redraw() {
1018 let config = DiffStrategyConfig {
1019 c_scan: 10.0,
1020 c_emit: 1.0,
1021 uncertainty_guard_variance: 1e-6,
1022 ..Default::default()
1023 };
1024 let mut selector = DiffStrategySelector::new(config);
1025
1026 let strategy = selector.select(80, 24, 24);
1027 assert_ne!(
1028 strategy,
1029 DiffStrategy::FullRedraw,
1030 "Uncertainty guard should avoid FullRedraw under high variance"
1031 );
1032 }
1033
1034 #[test]
1035 fn concentrated_half_screen_workload_stays_dirty_rows() {
1036 let mut sel = DiffStrategySelector::new(DiffStrategyConfig::default());
1043
1044 for _ in 0..10 {
1046 let strategy = sel.select_with_scan_estimate(200, 60, 30, 6000);
1047 assert_ne!(
1048 strategy,
1049 DiffStrategy::FullRedraw,
1050 "half-screen concentrated workload must not full-redraw (6000 scanned + 6000 emitted < 12000 emitted)"
1051 );
1052 sel.observe(6000, 6000);
1053 }
1054 }
1055
1056 #[test]
1057 fn selector_regret_bounded_across_regimes() {
1058 let mut selector = DiffStrategySelector::with_defaults();
1059 let config = selector.config().clone();
1060 let width = 200u16;
1061 let height = 60u16;
1062 let total_cells = width as usize * height as usize;
1063
1064 let regimes = [
1065 (100usize, 2usize, 0.02f64),
1066 (100usize, 12usize, 0.12f64),
1067 (100usize, height as usize, 0.6f64),
1068 ];
1069
1070 let mut selector_total = 0.0f64;
1071 let mut fixed_full_total = 0.0f64;
1072 let mut fixed_dirty_total = 0.0f64;
1073 let mut fixed_redraw_total = 0.0f64;
1074
1075 for (frames, dirty_rows, p_actual) in regimes {
1076 for _ in 0..frames {
1077 let strategy = selector.select(width, height, dirty_rows);
1078 let (cost_full, cost_dirty, cost_redraw) =
1079 strategy_costs(&config, width, height, dirty_rows, p_actual);
1080 fixed_full_total += cost_full;
1081 fixed_dirty_total += cost_dirty;
1082 fixed_redraw_total += cost_redraw;
1083
1084 let chosen_cost = match strategy {
1085 DiffStrategy::Full => cost_full,
1086 DiffStrategy::DirtyRows => cost_dirty,
1087 DiffStrategy::FullRedraw => cost_redraw,
1088 };
1089 selector_total += chosen_cost;
1090
1091 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1092 let scanned = match strategy {
1093 DiffStrategy::Full => total_cells,
1094 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1095 DiffStrategy::FullRedraw => 0,
1096 };
1097 if strategy != DiffStrategy::FullRedraw {
1098 selector.observe(scanned, changed);
1099 }
1100 }
1101 }
1102
1103 let best_fixed = fixed_full_total
1104 .min(fixed_dirty_total)
1105 .min(fixed_redraw_total);
1106 let regret = if best_fixed > 0.0 {
1107 (selector_total - best_fixed) / best_fixed
1108 } else {
1109 0.0
1110 };
1111 let evidence = selector
1112 .last_evidence()
1113 .map(ToString::to_string)
1114 .unwrap_or_else(|| "no evidence".to_string());
1115
1116 assert!(
1117 regret <= 0.05,
1118 "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1119 regret,
1120 selector_total,
1121 best_fixed,
1122 evidence
1123 );
1124 }
1125
1126 #[test]
1127 fn selector_switching_is_stable_under_constant_load() {
1128 let mut selector = DiffStrategySelector::with_defaults();
1129 let config = selector.config().clone();
1130 let width = 200u16;
1131 let height = 60u16;
1132 let dirty_rows = 2usize;
1133 let p_actual = 0.02f64;
1134 let total_cells = width as usize * height as usize;
1135
1136 let mut switches = 0usize;
1137 let mut last = None;
1138
1139 for _ in 0..200 {
1140 let strategy = selector.select(width, height, dirty_rows);
1141 if let Some(prev) = last
1142 && prev != strategy
1143 {
1144 switches = switches.saturating_add(1);
1145 }
1146 last = Some(strategy);
1147
1148 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1149 let scanned = match strategy {
1150 DiffStrategy::Full => total_cells,
1151 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1152 DiffStrategy::FullRedraw => 0,
1153 };
1154 if strategy != DiffStrategy::FullRedraw {
1155 selector.observe(scanned, changed);
1156 }
1157
1158 let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1159 }
1160
1161 let evidence = selector
1162 .last_evidence()
1163 .map(ToString::to_string)
1164 .unwrap_or_else(|| "no evidence".to_string());
1165 assert!(
1166 switches <= 40,
1167 "Selector switched too often under stable regime: {switches}\n{evidence}"
1168 );
1169 }
1170
1171 #[test]
1172 fn test_reset() {
1173 let mut selector = DiffStrategySelector::with_defaults();
1174 selector.observe(100, 50);
1175 selector.select(80, 24, 10);
1176
1177 selector.reset();
1178
1179 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1180 assert_eq!(selector.frame_count(), 0);
1181 assert!(selector.last_evidence().is_none());
1182 }
1183
1184 #[test]
1185 fn test_deterministic() {
1186 let mut sel1 = DiffStrategySelector::with_defaults();
1187 let mut sel2 = DiffStrategySelector::with_defaults();
1188
1189 sel1.observe(100, 10);
1191 sel2.observe(100, 10);
1192
1193 let s1 = sel1.select(80, 24, 5);
1194 let s2 = sel2.select(80, 24, 5);
1195
1196 assert_eq!(s1, s2);
1197 assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1198 }
1199
1200 #[test]
1201 fn test_upper_quantile_reasonable() {
1202 let selector = DiffStrategySelector::with_defaults();
1203 let mean = selector.posterior_mean();
1204 let q95 = selector.upper_quantile(0.95);
1205
1206 assert!(q95 > mean, "95th percentile should be above mean");
1207 assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1208 }
1209
1210 #[test]
1212 fn prop_posterior_mean_bounded() {
1213 let mut selector = DiffStrategySelector::with_defaults();
1214
1215 for scanned in [1, 10, 100, 1000, 10000] {
1216 for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1217 selector.observe(scanned, changed);
1218 let mean = selector.posterior_mean();
1219 assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1220 }
1221 }
1222 }
1223
1224 #[test]
1226 fn prop_variance_non_negative() {
1227 let mut selector = DiffStrategySelector::with_defaults();
1228
1229 for _ in 0..100 {
1230 selector.observe(100, 5);
1231 assert!(selector.posterior_variance() >= 0.0);
1232 }
1233 }
1234
1235 #[test]
1238 fn diff_strategy_display() {
1239 assert_eq!(format!("{}", DiffStrategy::Full), "Full");
1240 assert_eq!(format!("{}", DiffStrategy::DirtyRows), "DirtyRows");
1241 assert_eq!(format!("{}", DiffStrategy::FullRedraw), "FullRedraw");
1242 }
1243
1244 #[test]
1245 fn diff_strategy_debug() {
1246 let dbg = format!("{:?}", DiffStrategy::Full);
1247 assert!(dbg.contains("Full"));
1248 }
1249
1250 #[test]
1251 fn diff_strategy_clone_and_eq() {
1252 let a = DiffStrategy::DirtyRows;
1253 let b = a;
1254 assert_eq!(a, b);
1255 assert_ne!(a, DiffStrategy::Full);
1256 }
1257
1258 #[test]
1261 fn strategy_evidence_display_contains_all_sections() {
1262 let mut selector = DiffStrategySelector::with_defaults();
1263 selector.select(80, 24, 5);
1264 let ev = selector.last_evidence().unwrap();
1265 let display = format!("{ev}");
1266 assert!(display.contains("Strategy:"));
1267 assert!(display.contains("Costs:"));
1268 assert!(display.contains("Posterior:"));
1269 assert!(display.contains("Dirty:"));
1270 assert!(display.contains("Guard:"));
1271 assert!(display.contains("Hysteresis:"));
1272 }
1273
1274 #[test]
1275 fn strategy_evidence_clone() {
1276 let mut selector = DiffStrategySelector::with_defaults();
1277 selector.select(80, 24, 3);
1278 let ev = selector.last_evidence().unwrap().clone();
1279 assert_eq!(ev.dirty_rows, 3);
1280 assert_eq!(ev.total_rows, 24);
1281 assert_eq!(ev.total_cells, 80 * 24);
1282 }
1283
1284 #[test]
1285 fn strategy_evidence_debug() {
1286 let mut selector = DiffStrategySelector::with_defaults();
1287 selector.select(80, 24, 2);
1288 let ev = selector.last_evidence().unwrap();
1289 let dbg = format!("{ev:?}");
1290 assert!(dbg.contains("StrategyEvidence"));
1291 assert!(dbg.contains("cost_full"));
1292 }
1293
1294 #[test]
1297 fn config_default_all_fields() {
1298 let c = DiffStrategyConfig::default();
1299 assert!((c.c_row - 0.1).abs() < 1e-9);
1300 assert!((c.decay - 0.95).abs() < 1e-9);
1301 assert!(!c.conservative);
1302 assert!((c.conservative_quantile - 0.95).abs() < 1e-9);
1303 }
1304
1305 #[test]
1306 fn config_clone_and_debug() {
1307 let c = DiffStrategyConfig::default();
1308 let c2 = c.clone();
1309 assert!((c2.c_scan - c.c_scan).abs() < 1e-9);
1310 let dbg = format!("{c:?}");
1311 assert!(dbg.contains("DiffStrategyConfig"));
1312 assert!(dbg.contains("c_scan"));
1313 }
1314
1315 #[test]
1318 fn selector_default_equals_with_defaults() {
1319 let s1 = DiffStrategySelector::default();
1320 let s2 = DiffStrategySelector::with_defaults();
1321 assert!((s1.posterior_mean() - s2.posterior_mean()).abs() < 1e-12);
1322 assert_eq!(s1.frame_count(), s2.frame_count());
1323 }
1324
1325 #[test]
1326 fn selector_config_accessor() {
1327 let config = DiffStrategyConfig {
1328 c_scan: 2.0,
1329 ..Default::default()
1330 };
1331 let selector = DiffStrategySelector::new(config);
1332 assert!((selector.config().c_scan - 2.0).abs() < 1e-9);
1333 }
1334
1335 #[test]
1338 fn frame_count_increments_per_select() {
1339 let mut selector = DiffStrategySelector::with_defaults();
1340 assert_eq!(selector.frame_count(), 0);
1341 selector.select(80, 24, 1);
1342 assert_eq!(selector.frame_count(), 1);
1343 selector.select(80, 24, 1);
1344 assert_eq!(selector.frame_count(), 2);
1345 for _ in 0..10 {
1346 selector.select(80, 24, 1);
1347 }
1348 assert_eq!(selector.frame_count(), 12);
1349 }
1350
1351 #[test]
1352 fn frame_count_not_affected_by_observe() {
1353 let mut selector = DiffStrategySelector::with_defaults();
1354 selector.observe(100, 10);
1355 assert_eq!(selector.frame_count(), 0);
1356 }
1357
1358 #[test]
1361 fn override_last_strategy_changes_evidence() {
1362 let mut selector = DiffStrategySelector::with_defaults();
1363 selector.select(80, 24, 2);
1364 let original = selector.last_evidence().unwrap().strategy;
1365
1366 let override_to = if original == DiffStrategy::Full {
1367 DiffStrategy::FullRedraw
1368 } else {
1369 DiffStrategy::Full
1370 };
1371 selector.override_last_strategy(override_to, "test_override");
1372
1373 let ev = selector.last_evidence().unwrap();
1374 assert_eq!(ev.strategy, override_to);
1375 assert_eq!(ev.guard_reason, "test_override");
1376 assert!(!ev.hysteresis_applied);
1377 }
1378
1379 #[test]
1380 fn override_last_strategy_noop_when_no_evidence() {
1381 let mut selector = DiffStrategySelector::with_defaults();
1382 selector.override_last_strategy(DiffStrategy::Full, "noop");
1384 assert!(selector.last_evidence().is_none());
1385 }
1386
1387 #[test]
1390 fn select_with_scan_estimate_custom_cells() {
1391 let mut selector = DiffStrategySelector::with_defaults();
1392 let strategy = selector.select_with_scan_estimate(80, 24, 10, 10);
1394 assert_eq!(strategy, DiffStrategy::DirtyRows);
1395 }
1396
1397 #[test]
1398 fn select_with_scan_estimate_clamped_to_total() {
1399 let mut selector = DiffStrategySelector::with_defaults();
1400 let strategy = selector.select_with_scan_estimate(80, 24, 5, 1_000_000);
1402 assert!(matches!(
1404 strategy,
1405 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1406 ));
1407 }
1408
1409 #[test]
1412 fn estimator_reset_restores_priors() {
1413 let mut est = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
1414 est.observe(100, 50);
1415 assert!((est.mean() - 0.2).abs() > 0.01, "Mean should have changed");
1416
1417 est.reset();
1418 let (alpha, beta) = est.posterior_params();
1419 assert!((alpha - 2.0).abs() < 1e-9);
1420 assert!((beta - 8.0).abs() < 1e-9);
1421 assert!((est.mean() - 0.2).abs() < 1e-9);
1422 }
1423
1424 #[test]
1425 fn estimator_clone() {
1426 let est1 = ChangeRateEstimator::new(1.0, 9.0, 0.95, 0);
1427 let est2 = est1.clone();
1428 assert!((est2.mean() - est1.mean()).abs() < 1e-12);
1429 }
1430
1431 #[test]
1432 fn estimator_debug() {
1433 let est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1434 let dbg = format!("{est:?}");
1435 assert!(dbg.contains("ChangeRateEstimator"));
1436 }
1437
1438 #[test]
1439 fn estimator_min_observation_cells_filters() {
1440 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 50);
1441 let initial_mean = est.mean();
1442 est.observe(49, 25);
1444 assert!(
1445 (est.mean() - initial_mean).abs() < 1e-12,
1446 "Observation below min should be ignored"
1447 );
1448 est.observe(50, 25);
1450 assert!(
1451 (est.mean() - initial_mean).abs() > 0.01,
1452 "Observation at min should be accepted"
1453 );
1454 }
1455
1456 #[test]
1457 fn estimator_changed_exceeds_scanned_is_clamped() {
1458 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1459 est.observe(10, 100);
1461 let mean = est.mean();
1462 assert!(mean > 0.3, "Mean should be high when all cells changed");
1464 }
1465
1466 #[test]
1467 fn estimator_variance_decreases_with_data() {
1468 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1469 let v0 = est.variance();
1470 for _ in 0..50 {
1471 est.observe(100, 5);
1472 }
1473 let v1 = est.variance();
1474 assert!(
1475 v1 < v0,
1476 "Variance should decrease with more data: before={v0:.6}, after={v1:.6}"
1477 );
1478 }
1479
1480 #[test]
1481 fn estimator_upper_quantile_at_50_pct_near_mean() {
1482 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1483 let mean = est.mean();
1484 let q50 = est.upper_quantile(0.5);
1485 assert!(
1486 (q50 - mean).abs() < 0.05,
1487 "50th percentile should be near mean: q50={q50:.4}, mean={mean:.4}"
1488 );
1489 }
1490
1491 #[test]
1492 fn estimator_upper_quantile_monotonic() {
1493 let est = ChangeRateEstimator::new(5.0, 15.0, 1.0, 0);
1494 let q25 = est.upper_quantile(0.25);
1495 let q50 = est.upper_quantile(0.5);
1496 let q75 = est.upper_quantile(0.75);
1497 let q95 = est.upper_quantile(0.95);
1498 assert!(q25 <= q50, "q25={q25:.4} should <= q50={q50:.4}");
1499 assert!(q50 <= q75, "q50={q50:.4} should <= q75={q75:.4}");
1500 assert!(q75 <= q95, "q75={q75:.4} should <= q95={q95:.4}");
1501 }
1502
1503 #[test]
1506 fn normalize_positive_rejects_zero_and_negative() {
1507 assert!((normalize_positive(0.0, 5.0) - 5.0).abs() < 1e-9);
1508 assert!((normalize_positive(-1.0, 5.0) - 5.0).abs() < 1e-9);
1509 assert!((normalize_positive(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1510 assert!((normalize_positive(3.0, 5.0) - 3.0).abs() < 1e-9);
1511 }
1512
1513 #[test]
1514 fn normalize_cost_accepts_zero() {
1515 assert!((normalize_cost(0.0, 5.0) - 0.0).abs() < 1e-9);
1516 assert!((normalize_cost(-1.0, 5.0) - 5.0).abs() < 1e-9);
1517 assert!((normalize_cost(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1518 }
1519
1520 #[test]
1521 fn normalize_decay_clamps_to_one() {
1522 assert!((normalize_decay(1.5) - 1.0).abs() < 1e-9);
1523 assert!((normalize_decay(0.5) - 0.5).abs() < 1e-9);
1524 assert!((normalize_decay(-1.0) - 1.0).abs() < 1e-9);
1525 assert!((normalize_decay(0.0) - 1.0).abs() < 1e-9);
1526 assert!((normalize_decay(f64::NAN) - 1.0).abs() < 1e-9);
1527 }
1528
1529 #[test]
1530 fn normalize_ratio_clamps_to_unit() {
1531 assert!((normalize_ratio(0.5, 0.1) - 0.5).abs() < 1e-9);
1532 assert!((normalize_ratio(-1.0, 0.1) - 0.0).abs() < 1e-9);
1533 assert!((normalize_ratio(2.0, 0.1) - 1.0).abs() < 1e-9);
1534 assert!((normalize_ratio(f64::NAN, 0.1) - 0.1).abs() < 1e-9);
1535 }
1536
1537 #[test]
1540 fn cost_for_strategy_returns_correct_values() {
1541 assert!((cost_for_strategy(DiffStrategy::Full, 1.0, 2.0, 3.0) - 1.0).abs() < 1e-9);
1542 assert!((cost_for_strategy(DiffStrategy::DirtyRows, 1.0, 2.0, 3.0) - 2.0).abs() < 1e-9);
1543 assert!((cost_for_strategy(DiffStrategy::FullRedraw, 1.0, 2.0, 3.0) - 3.0).abs() < 1e-9);
1544 }
1545
1546 #[test]
1549 fn select_1x1_buffer() {
1550 let mut selector = DiffStrategySelector::with_defaults();
1551 let strategy = selector.select(1, 1, 1);
1552 assert!(matches!(
1553 strategy,
1554 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1555 ));
1556 }
1557
1558 #[test]
1559 fn select_zero_width() {
1560 let mut selector = DiffStrategySelector::with_defaults();
1561 let strategy = selector.select(0, 24, 0);
1562 assert!(matches!(
1564 strategy,
1565 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1566 ));
1567 }
1568
1569 #[test]
1570 fn select_zero_height() {
1571 let mut selector = DiffStrategySelector::with_defaults();
1572 let strategy = selector.select(80, 0, 0);
1573 assert!(matches!(
1574 strategy,
1575 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1576 ));
1577 }
1578
1579 #[test]
1582 fn all_dirty_vs_no_dirty_different_evidence() {
1583 let mut sel1 = DiffStrategySelector::with_defaults();
1584 let mut sel2 = DiffStrategySelector::with_defaults();
1585
1586 sel1.select(80, 24, 0);
1587 sel2.select(80, 24, 24);
1588
1589 let ev1 = sel1.last_evidence().unwrap();
1590 let ev2 = sel2.last_evidence().unwrap();
1591
1592 assert_eq!(ev1.dirty_rows, 0);
1593 assert_eq!(ev2.dirty_rows, 24);
1594 assert!(
1596 ev1.cost_dirty <= ev1.cost_full,
1597 "DirtyRows should be cheap with no dirty rows"
1598 );
1599 }
1600
1601 #[test]
1604 fn no_decay_accumulates_all_evidence() {
1605 let config = DiffStrategyConfig {
1606 decay: 1.0,
1607 ..Default::default()
1608 };
1609 let mut selector = DiffStrategySelector::new(config);
1610
1611 for _ in 0..100 {
1613 selector.observe(100, 100);
1614 }
1615 let mean = selector.posterior_mean();
1616 assert!(
1618 mean > 0.9,
1619 "No-decay all-changed mean should be near 1.0: {mean:.4}"
1620 );
1621 }
1622
1623 #[test]
1626 fn evidence_costs_always_finite() {
1627 let mut selector = DiffStrategySelector::with_defaults();
1628 for dirty in [0, 1, 12, 24] {
1629 selector.select(80, 24, dirty);
1630 let ev = selector.last_evidence().unwrap();
1631 assert!(ev.cost_full.is_finite(), "cost_full should be finite");
1632 assert!(ev.cost_dirty.is_finite(), "cost_dirty should be finite");
1633 assert!(ev.cost_redraw.is_finite(), "cost_redraw should be finite");
1634 }
1635 }
1636
1637 #[test]
1640 fn evidence_posterior_matches_selector() {
1641 let mut selector = DiffStrategySelector::with_defaults();
1642 selector.observe(100, 10);
1643 selector.select(80, 24, 5);
1644 let ev = selector.last_evidence().unwrap();
1645 assert!((ev.posterior_mean - selector.posterior_mean()).abs() < 1e-12);
1646 assert!((ev.posterior_variance - selector.posterior_variance()).abs() < 1e-12);
1647 let (alpha, beta) = selector.posterior_params();
1648 assert!((ev.alpha - alpha).abs() < 1e-12);
1649 assert!((ev.beta - beta).abs() < 1e-12);
1650 }
1651
1652 #[test]
1655 fn selector_clone() {
1656 let mut selector = DiffStrategySelector::with_defaults();
1657 selector.observe(100, 10);
1658 selector.select(80, 24, 5);
1659 let clone = selector.clone();
1660 assert!((clone.posterior_mean() - selector.posterior_mean()).abs() < 1e-12);
1661 assert_eq!(clone.frame_count(), selector.frame_count());
1662 }
1663
1664 #[test]
1667 fn selector_debug() {
1668 let selector = DiffStrategySelector::with_defaults();
1669 let dbg = format!("{selector:?}");
1670 assert!(dbg.contains("DiffStrategySelector"));
1671 assert!(dbg.contains("frame_count"));
1672 }
1673
1674 #[test]
1677 fn hysteresis_not_applied_on_first_select() {
1678 let config = DiffStrategyConfig {
1679 hysteresis_ratio: 1.0,
1680 ..Default::default()
1681 };
1682 let mut selector = DiffStrategySelector::new(config);
1683 selector.select(80, 24, 5);
1684 let ev = selector.last_evidence().unwrap();
1685 assert!(
1686 !ev.hysteresis_applied,
1687 "First select should not apply hysteresis"
1688 );
1689 }
1690
1691 #[test]
1694 fn conservative_mode_higher_p_estimate() {
1695 let mut conservative = DiffStrategySelector::new(DiffStrategyConfig {
1696 conservative: true,
1697 ..Default::default()
1698 });
1699 let mut normal = DiffStrategySelector::with_defaults();
1700
1701 for _ in 0..20 {
1703 conservative.observe(100, 5);
1704 normal.observe(100, 5);
1705 }
1706
1707 conservative.select(80, 24, 12);
1708 normal.select(80, 24, 12);
1709
1710 let ev_cons = conservative.last_evidence().unwrap();
1711 let ev_norm = normal.last_evidence().unwrap();
1712
1713 assert!(
1716 ev_cons.cost_dirty >= ev_norm.cost_dirty - 1e-6,
1717 "Conservative costs should be >= normal costs"
1718 );
1719 }
1720
1721 mod edge_case_tests {
1726 use super::super::*;
1727 use super::strategy_costs;
1728
1729 #[test]
1732 fn estimator_observe_zero_scanned_with_min_one() {
1733 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 1);
1735 let initial = est.mean();
1736 est.observe(0, 0);
1737 assert!(
1738 (est.mean() - initial).abs() < 1e-12,
1739 "Zero scanned should be filtered: mean changed"
1740 );
1741 }
1742
1743 #[test]
1744 fn estimator_observe_all_unchanged() {
1745 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1746 for _ in 0..100 {
1747 est.observe(1000, 0);
1748 }
1749 assert!(
1751 est.mean() < 0.01,
1752 "All-unchanged observations should drive mean near zero: {}",
1753 est.mean()
1754 );
1755 }
1756
1757 #[test]
1758 fn estimator_observe_all_changed() {
1759 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1760 for _ in 0..100 {
1761 est.observe(1000, 1000);
1762 }
1763 assert!(
1765 est.mean() > 0.99,
1766 "All-changed observations should drive mean near 1.0: {}",
1767 est.mean()
1768 );
1769 }
1770
1771 #[test]
1772 fn estimator_rapid_decay_forgets_quickly() {
1773 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.1, 0);
1774 for _ in 0..50 {
1776 est.observe(100, 90);
1777 }
1778 let high_mean = est.mean();
1779
1780 for _ in 0..10 {
1782 est.observe(100, 1);
1783 }
1784 let low_mean = est.mean();
1785
1786 assert!(
1787 low_mean < high_mean * 0.5,
1788 "Rapid decay should forget quickly: high={high_mean:.4}, low={low_mean:.4}"
1789 );
1790 }
1791
1792 #[test]
1793 fn estimator_alternating_observations() {
1794 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1795 for i in 0..100 {
1796 if i % 2 == 0 {
1797 est.observe(100, 100); } else {
1799 est.observe(100, 0); }
1801 }
1802 let mean = est.mean();
1804 assert!(
1805 mean > 0.3 && mean < 0.7,
1806 "Alternating observations should settle near 0.5: {mean:.4}"
1807 );
1808 }
1809
1810 #[test]
1811 fn estimator_upper_quantile_at_extreme_low() {
1812 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1813 let q01 = est.upper_quantile(0.01);
1814 assert!(
1815 q01 >= 0.0,
1816 "Lower quantile should be non-negative: {q01:.4}"
1817 );
1818 assert!(
1819 q01 < est.mean(),
1820 "1st percentile should be below mean: q01={q01:.4}, mean={:.4}",
1821 est.mean()
1822 );
1823 }
1824
1825 #[test]
1826 fn estimator_upper_quantile_at_extreme_high() {
1827 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1828 let q99 = est.upper_quantile(0.99);
1829 assert!(q99 <= 1.0, "Upper quantile should be <= 1.0: {q99:.4}");
1830 assert!(
1831 q99 > est.mean(),
1832 "99th percentile should be above mean: q99={q99:.4}, mean={:.4}",
1833 est.mean()
1834 );
1835 }
1836
1837 #[test]
1838 fn estimator_upper_quantile_tight_posterior() {
1839 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1841 for _ in 0..10000 {
1842 est.observe(100, 5);
1843 }
1844 let mean = est.mean();
1845 let q95 = est.upper_quantile(0.95);
1846 assert!(
1847 (q95 - mean).abs() < 0.01,
1848 "Tight posterior should have quantile near mean: q95={q95:.4}, mean={mean:.4}"
1849 );
1850 }
1851
1852 #[test]
1853 fn estimator_upper_quantile_clamped_output() {
1854 let est = ChangeRateEstimator::new(1e-6, 1e-6, 1.0, 0);
1856 for q in [0.01, 0.1, 0.5, 0.9, 0.99] {
1857 let val = est.upper_quantile(q);
1858 assert!(
1859 (0.0..=1.0).contains(&val),
1860 "Quantile({q}) = {val} should be in [0,1]"
1861 );
1862 }
1863 }
1864
1865 #[test]
1866 fn estimator_variance_formula_correct() {
1867 let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1868 let (a, b) = est.posterior_params();
1869 let expected_var = (a * b) / ((a + b).powi(2) * (a + b + 1.0));
1870 assert!(
1871 (est.variance() - expected_var).abs() < 1e-12,
1872 "Variance formula: got {}, expected {}",
1873 est.variance(),
1874 expected_var
1875 );
1876 }
1877
1878 #[test]
1879 fn estimator_mean_formula_correct() {
1880 let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1881 let (a, b) = est.posterior_params();
1882 let expected_mean = a / (a + b);
1883 assert!(
1884 (est.mean() - expected_mean).abs() < 1e-12,
1885 "Mean formula: got {}, expected {}",
1886 est.mean(),
1887 expected_mean
1888 );
1889 }
1890
1891 #[test]
1894 fn select_dirty_rows_exceeds_height() {
1895 let mut selector = DiffStrategySelector::with_defaults();
1896 let strategy = selector.select(80, 24, 100);
1898 assert!(matches!(
1899 strategy,
1900 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1901 ));
1902 }
1903
1904 #[test]
1905 fn select_large_dimensions() {
1906 let mut selector = DiffStrategySelector::with_defaults();
1907 let strategy = selector.select(u16::MAX, u16::MAX, 1);
1909 assert!(matches!(
1910 strategy,
1911 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1912 ));
1913 let ev = selector.last_evidence().unwrap();
1914 assert!(ev.cost_full.is_finite());
1915 assert!(ev.cost_dirty.is_finite());
1916 assert!(ev.cost_redraw.is_finite());
1917 }
1918
1919 #[test]
1920 fn multiple_selects_without_observe() {
1921 let mut selector = DiffStrategySelector::with_defaults();
1922 let initial_mean = selector.posterior_mean();
1923
1924 for _ in 0..50 {
1925 selector.select(80, 24, 5);
1926 }
1927
1928 assert!(
1930 (selector.posterior_mean() - initial_mean).abs() < 1e-12,
1931 "Mean should not change without observations"
1932 );
1933 assert_eq!(selector.frame_count(), 50);
1934 }
1935
1936 #[test]
1937 fn conservative_with_zero_dirty_rows() {
1938 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1939 conservative: true,
1940 ..Default::default()
1941 });
1942 let strategy = selector.select(80, 24, 0);
1943 assert_eq!(strategy, DiffStrategy::DirtyRows);
1944 let ev = selector.last_evidence().unwrap();
1945 assert_eq!(ev.guard_reason, "zero_dirty_rows");
1946 }
1947
1948 #[test]
1949 fn uncertainty_guard_with_fullredraw_hysteresis() {
1950 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1953 c_scan: 10.0,
1954 c_emit: 1.0,
1955 uncertainty_guard_variance: 1e-6,
1956 hysteresis_ratio: 0.99, ..Default::default()
1958 });
1959
1960 selector.select(80, 24, 24);
1962
1963 let strategy = selector.select(80, 24, 24);
1965 assert_ne!(
1967 strategy,
1968 DiffStrategy::FullRedraw,
1969 "Uncertainty guard should override hysteresis for FullRedraw"
1970 );
1971 }
1972
1973 #[test]
1974 fn select_with_scan_estimate_zero_cells() {
1975 let mut selector = DiffStrategySelector::with_defaults();
1976 let strategy = selector.select_with_scan_estimate(80, 24, 5, 0);
1978 assert_eq!(strategy, DiffStrategy::DirtyRows);
1979 }
1980
1981 #[test]
1982 fn hysteresis_prevents_switch_near_boundary() {
1983 let config = DiffStrategyConfig {
1984 hysteresis_ratio: 0.5, uncertainty_guard_variance: 0.0,
1986 ..Default::default()
1987 };
1988 let mut selector = DiffStrategySelector::new(config);
1989
1990 let first = selector.select(80, 24, 5);
1992
1993 let second = selector.select(80, 24, 6);
1995 assert_eq!(
1996 first, second,
1997 "High hysteresis should prevent switching on small changes"
1998 );
1999 }
2000
2001 #[test]
2002 fn reset_clears_frame_count_and_evidence() {
2003 let mut selector = DiffStrategySelector::with_defaults();
2004 selector.observe(100, 10);
2005 selector.select(80, 24, 5);
2006 selector.select(80, 24, 5);
2007
2008 assert_eq!(selector.frame_count(), 2);
2009 assert!(selector.last_evidence().is_some());
2010
2011 selector.reset();
2012
2013 assert_eq!(selector.frame_count(), 0);
2014 assert!(selector.last_evidence().is_none());
2015 assert!(
2016 (selector.posterior_mean() - 0.05).abs() < 1e-9,
2017 "Reset should restore prior mean"
2018 );
2019 }
2020
2021 #[test]
2022 fn posterior_variance_after_reset() {
2023 let mut selector = DiffStrategySelector::with_defaults();
2024 let initial_var = selector.posterior_variance();
2025
2026 selector.observe(100, 10);
2027 assert!(selector.posterior_variance() != initial_var);
2028
2029 selector.reset();
2030 assert!(
2031 (selector.posterior_variance() - initial_var).abs() < 1e-12,
2032 "Reset should restore prior variance"
2033 );
2034 }
2035
2036 #[test]
2039 fn normalize_positive_rejects_infinity() {
2040 assert!(
2041 (normalize_positive(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2042 "Infinity should be rejected"
2043 );
2044 assert!(
2045 (normalize_positive(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2046 "Negative infinity should be rejected"
2047 );
2048 }
2049
2050 #[test]
2051 fn normalize_cost_rejects_neg_infinity() {
2052 assert!(
2053 (normalize_cost(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2054 "Negative infinity should be rejected"
2055 );
2056 }
2057
2058 #[test]
2059 fn normalize_cost_accepts_positive_infinity() {
2060 assert!(
2062 (normalize_cost(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2063 "Positive infinity should be rejected"
2064 );
2065 }
2066
2067 #[test]
2068 fn normalize_ratio_rejects_infinity() {
2069 assert!(
2070 (normalize_ratio(f64::INFINITY, 0.1) - 0.1).abs() < 1e-9,
2071 "Infinity should use fallback"
2072 );
2073 assert!(
2074 (normalize_ratio(f64::NEG_INFINITY, 0.1) - 0.1).abs() < 1e-9,
2075 "Negative infinity should use fallback"
2076 );
2077 }
2078
2079 #[test]
2080 fn normalize_decay_rejects_neg_infinity() {
2081 assert!(
2082 (normalize_decay(f64::NEG_INFINITY) - 1.0).abs() < 1e-9,
2083 "Negative infinity should use fallback"
2084 );
2085 }
2086
2087 #[test]
2090 fn cost_redraw_independent_of_dirty_rows() {
2091 let mut sel1 = DiffStrategySelector::with_defaults();
2092 let mut sel2 = DiffStrategySelector::with_defaults();
2093
2094 sel1.select(80, 24, 0);
2095 sel2.select(80, 24, 24);
2096
2097 let ev1 = sel1.last_evidence().unwrap();
2098 let ev2 = sel2.last_evidence().unwrap();
2099
2100 assert!(
2102 (ev1.cost_redraw - ev2.cost_redraw).abs() < 1e-6,
2103 "FullRedraw cost should not depend on dirty rows"
2104 );
2105 }
2106
2107 #[test]
2108 fn cost_full_increases_with_dirty_rows() {
2109 let config = DiffStrategyConfig::default();
2110 let (cost_full_2, _, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2112 let (cost_full_20, _, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2113 assert!(
2114 cost_full_20 > cost_full_2,
2115 "More dirty rows should increase Full cost: 2={cost_full_2:.2}, 20={cost_full_20:.2}"
2116 );
2117 }
2118
2119 #[test]
2120 fn cost_dirty_increases_with_dirty_rows() {
2121 let config = DiffStrategyConfig::default();
2122 let (_, cost_dirty_2, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2123 let (_, cost_dirty_20, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2124 assert!(
2125 cost_dirty_20 > cost_dirty_2,
2126 "More dirty rows should increase DirtyRows cost"
2127 );
2128 }
2129
2130 #[test]
2133 fn evidence_all_fields_populated() {
2134 let mut selector = DiffStrategySelector::with_defaults();
2135 selector.observe(100, 10);
2136 selector.select(200, 60, 15);
2137
2138 let ev = selector.last_evidence().unwrap();
2139 assert_eq!(ev.total_rows, 60);
2140 assert_eq!(ev.total_cells, 200 * 60);
2141 assert_eq!(ev.dirty_rows, 15);
2142 assert!(ev.cost_full >= 0.0);
2143 assert!(ev.cost_dirty >= 0.0);
2144 assert!(ev.cost_redraw >= 0.0);
2145 assert!((0.0..=1.0).contains(&ev.posterior_mean));
2146 assert!(ev.posterior_variance >= 0.0);
2147 assert!(ev.alpha > 0.0);
2148 assert!(ev.beta > 0.0);
2149 assert!(!ev.guard_reason.is_empty());
2150 assert!(ev.hysteresis_ratio >= 0.0);
2151 }
2152
2153 #[test]
2154 fn evidence_display_format() {
2155 let mut selector = DiffStrategySelector::with_defaults();
2156 selector.select(80, 24, 5);
2157 let ev = selector.last_evidence().unwrap();
2158 let display = format!("{ev}");
2159
2160 assert!(display.contains("Strategy:"));
2162 assert!(display.contains("Costs:"));
2163 assert!(display.contains("Posterior:"));
2164 assert!(display.contains("Dirty:"));
2165 assert!(display.contains("Guard:"));
2166 }
2167
2168 #[test]
2171 fn diff_strategy_all_variants_distinct() {
2172 let variants = [
2173 DiffStrategy::Full,
2174 DiffStrategy::DirtyRows,
2175 DiffStrategy::FullRedraw,
2176 ];
2177 for (i, a) in variants.iter().enumerate() {
2178 for (j, b) in variants.iter().enumerate() {
2179 if i == j {
2180 assert_eq!(a, b);
2181 } else {
2182 assert_ne!(a, b);
2183 }
2184 }
2185 }
2186 }
2187
2188 #[test]
2189 fn diff_strategy_copy() {
2190 let a = DiffStrategy::DirtyRows;
2191 let b = a; let _c = a; assert_eq!(a, b);
2194 }
2195
2196 #[test]
2199 fn custom_prior_high_alpha_favors_dirty_rows_less() {
2200 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2202 prior_alpha: 50.0,
2203 prior_beta: 1.0, ..Default::default()
2205 });
2206 selector.select(80, 24, 24);
2207 let ev = selector.last_evidence().unwrap();
2208 assert!(
2210 ev.cost_redraw <= ev.cost_full * 1.5,
2211 "High change rate should make redraw competitive"
2212 );
2213 }
2214
2215 #[test]
2216 fn custom_prior_high_beta_favors_dirty_rows() {
2217 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2219 prior_alpha: 1.0,
2220 prior_beta: 1000.0, ..Default::default()
2222 });
2223 let strategy = selector.select(80, 24, 5);
2224 assert_eq!(
2225 strategy,
2226 DiffStrategy::DirtyRows,
2227 "Very low expected change rate should favor DirtyRows"
2228 );
2229 }
2230
2231 #[test]
2234 fn decay_zero_sanitizes_to_one() {
2235 let config = DiffStrategyConfig {
2237 decay: 0.0,
2238 ..Default::default()
2239 };
2240 let selector = DiffStrategySelector::new(config);
2241 assert!(
2243 (selector.config().decay - 1.0).abs() < 1e-9,
2244 "Decay=0.0 should be sanitized to 1.0"
2245 );
2246 }
2247
2248 #[test]
2249 fn decay_one_no_forgetting() {
2250 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
2251 est.observe(100, 10);
2252 let (a1, b1) = est.posterior_params();
2253 assert!(
2255 (a1 - 11.0).abs() < 1e-9,
2256 "No-decay alpha: expected 11.0, got {a1}"
2257 );
2258 assert!(
2259 (b1 - 109.0).abs() < 1e-9,
2260 "No-decay beta: expected 109.0, got {b1}"
2261 );
2262 }
2263
2264 #[test]
2267 fn determinism_across_long_trace() {
2268 let trace: Vec<(u16, u16, usize, usize, usize)> = (0..200)
2269 .map(|i| {
2270 let dirty = (i * 3 % 24) + 1;
2271 let scanned = 80 * dirty;
2272 let changed = (i * 7 % scanned.max(1)).max(1);
2273 (80u16, 24u16, dirty, scanned, changed)
2274 })
2275 .collect();
2276
2277 let mut sel1 = DiffStrategySelector::with_defaults();
2278 let mut sel2 = DiffStrategySelector::with_defaults();
2279
2280 for (w, h, dirty, scanned, changed) in &trace {
2281 let s1 = sel1.select(*w, *h, *dirty);
2282 let s2 = sel2.select(*w, *h, *dirty);
2283 assert_eq!(s1, s2, "Determinism violated");
2284
2285 sel1.observe(*scanned, *changed);
2286 sel2.observe(*scanned, *changed);
2287
2288 assert!(
2289 (sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12,
2290 "Posterior diverged"
2291 );
2292 }
2293 }
2294
2295 #[test]
2298 fn override_changes_strategy_and_clears_hysteresis() {
2299 let mut selector = DiffStrategySelector::with_defaults();
2300 selector.select(80, 24, 5);
2301
2302 let original = selector.last_evidence().unwrap().strategy;
2303 let target = if original == DiffStrategy::FullRedraw {
2304 DiffStrategy::Full
2305 } else {
2306 DiffStrategy::FullRedraw
2307 };
2308
2309 selector.override_last_strategy(target, "forced_override");
2310 let ev = selector.last_evidence().unwrap();
2311
2312 assert_eq!(ev.strategy, target, "Override should change strategy");
2313 assert_eq!(ev.guard_reason, "forced_override");
2314 assert!(!ev.hysteresis_applied, "Override should clear hysteresis");
2315 }
2316
2317 #[test]
2320 fn sanitize_preserves_valid_config() {
2321 let config = DiffStrategyConfig {
2322 c_scan: 2.0,
2323 c_emit: 8.0,
2324 c_row: 0.5,
2325 prior_alpha: 3.0,
2326 prior_beta: 17.0,
2327 decay: 0.9,
2328 conservative: true,
2329 conservative_quantile: 0.9,
2330 min_observation_cells: 5,
2331 hysteresis_ratio: 0.1,
2332 uncertainty_guard_variance: 0.005,
2333 };
2334 let selector = DiffStrategySelector::new(config);
2335 let c = selector.config();
2336 assert!((c.c_scan - 2.0).abs() < 1e-9);
2337 assert!((c.c_emit - 8.0).abs() < 1e-9);
2338 assert!((c.c_row - 0.5).abs() < 1e-9);
2339 assert!((c.prior_alpha - 3.0).abs() < 1e-9);
2340 assert!((c.prior_beta - 17.0).abs() < 1e-9);
2341 assert!((c.decay - 0.9).abs() < 1e-9);
2342 assert!(c.conservative);
2343 assert!((c.conservative_quantile - 0.9).abs() < 1e-9);
2344 assert_eq!(c.min_observation_cells, 5);
2345 assert!((c.hysteresis_ratio - 0.1).abs() < 1e-9);
2346 }
2347
2348 #[test]
2349 fn sanitize_all_nan_uses_defaults() {
2350 let config = DiffStrategyConfig {
2351 c_scan: f64::NAN,
2352 c_emit: f64::NAN,
2353 c_row: f64::NAN,
2354 prior_alpha: f64::NAN,
2355 prior_beta: f64::NAN,
2356 decay: f64::NAN,
2357 conservative: false,
2358 conservative_quantile: f64::NAN,
2359 min_observation_cells: 0,
2360 hysteresis_ratio: f64::NAN,
2361 uncertainty_guard_variance: f64::NAN,
2362 };
2363 let selector = DiffStrategySelector::new(config);
2364 let c = selector.config();
2365 assert!((c.c_scan - 1.0).abs() < 1e-9);
2367 assert!((c.c_emit - 6.0).abs() < 1e-9);
2368 assert!((c.c_row - 0.1).abs() < 1e-9);
2369 assert!((c.prior_alpha - 1.0).abs() < 1e-9);
2370 assert!((c.prior_beta - 19.0).abs() < 1e-9);
2371 assert!((c.decay - 1.0).abs() < 1e-9);
2372 assert!((c.hysteresis_ratio - 0.05).abs() < 1e-9);
2373 assert!((c.uncertainty_guard_variance - 0.002).abs() < 1e-9);
2374 }
2375
2376 #[test]
2379 fn zero_change_rate_costs() {
2380 let config = DiffStrategyConfig::default();
2381 let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 5, 0.0);
2382 let expected_full = config.c_row * 24.0 + config.c_scan * 5.0 * 80.0;
2384 let expected_dirty = config.c_scan * 5.0 * 80.0;
2385 let expected_redraw = config.c_emit * 80.0 * 24.0;
2386
2387 assert!((cost_full - expected_full).abs() < 1e-6);
2388 assert!((cost_dirty - expected_dirty).abs() < 1e-6);
2389 assert!((cost_redraw - expected_redraw).abs() < 1e-6);
2390 }
2391
2392 #[test]
2393 fn full_change_rate_costs() {
2394 let config = DiffStrategyConfig::default();
2395 let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 24, 1.0);
2396 assert!(
2399 cost_redraw <= cost_full,
2400 "At p=1.0, redraw should be <= full"
2401 );
2402 assert!(
2403 cost_redraw <= cost_dirty,
2404 "At p=1.0, redraw should be <= dirty"
2405 );
2406 }
2407 }
2408}