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 =
639 self.config.c_row * h + self.config.c_scan * d * w + self.config.c_emit * p * n;
640
641 let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * n;
642
643 let cost_redraw = self.config.c_emit * n;
644
645 let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
647 DiffStrategy::DirtyRows
648 } else if cost_full <= cost_redraw {
649 DiffStrategy::Full
650 } else {
651 DiffStrategy::FullRedraw
652 };
653
654 if uncertainty_guard {
655 if guard_reason == "none" {
656 guard_reason = "uncertainty_variance";
657 }
658 if strategy == DiffStrategy::FullRedraw {
659 strategy = if cost_dirty <= cost_full {
660 DiffStrategy::DirtyRows
661 } else {
662 DiffStrategy::Full
663 };
664 }
665 }
666
667 let mut hysteresis_applied = false;
668 if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
669 && prev != strategy
670 {
671 let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
672 let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
673 let ratio = self.config.hysteresis_ratio;
674 if ratio > 0.0
675 && prev_cost.is_finite()
676 && prev_cost > 0.0
677 && new_cost >= prev_cost * (1.0 - ratio)
678 && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
679 {
680 strategy = prev;
681 hysteresis_applied = true;
682 }
683 }
684
685 let (alpha, beta) = self.estimator.posterior_params();
687 self.last_evidence = Some(StrategyEvidence {
688 strategy,
689 cost_full,
690 cost_dirty,
691 cost_redraw,
692 posterior_mean: self.posterior_mean(),
693 posterior_variance: self.posterior_variance(),
694 alpha,
695 beta,
696 dirty_rows,
697 total_rows: height as usize,
698 total_cells: (width as usize) * (height as usize),
699 guard_reason,
700 hysteresis_applied,
701 hysteresis_ratio: self.config.hysteresis_ratio,
702 });
703
704 strategy
705 }
706
707 pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
714 self.estimator.observe(cells_scanned, cells_changed);
715 }
716
717 pub fn reset(&mut self) {
719 self.estimator.reset();
720 self.frame_count = 0;
721 self.last_evidence = None;
722 }
723
724 fn upper_quantile(&self, q: f64) -> f64 {
729 self.estimator.upper_quantile(q)
730 }
731}
732
733#[inline]
734fn cost_for_strategy(
735 strategy: DiffStrategy,
736 cost_full: f64,
737 cost_dirty: f64,
738 cost_redraw: f64,
739) -> f64 {
740 match strategy {
741 DiffStrategy::Full => cost_full,
742 DiffStrategy::DirtyRows => cost_dirty,
743 DiffStrategy::FullRedraw => cost_redraw,
744 }
745}
746
747impl Default for DiffStrategySelector {
748 fn default() -> Self {
749 Self::with_defaults()
750 }
751}
752
753#[cfg(test)]
758mod tests {
759 use super::*;
760
761 fn strategy_costs(
762 config: &DiffStrategyConfig,
763 width: u16,
764 height: u16,
765 dirty_rows: usize,
766 p_actual: f64,
767 ) -> (f64, f64, f64) {
768 let w = width as f64;
769 let h = height as f64;
770 let d = dirty_rows as f64;
771 let n = w * h;
772 let p = p_actual.clamp(0.0, 1.0);
773
774 let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
775 let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
776 let cost_redraw = config.c_emit * n;
777
778 (cost_full, cost_dirty, cost_redraw)
779 }
780
781 #[test]
782 fn test_default_config() {
783 let config = DiffStrategyConfig::default();
784 assert!((config.c_scan - 1.0).abs() < 1e-9);
785 assert!((config.c_emit - 6.0).abs() < 1e-9);
786 assert!((config.prior_alpha - 1.0).abs() < 1e-9);
787 assert!((config.prior_beta - 19.0).abs() < 1e-9);
788 assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
789 assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
790 assert_eq!(config.min_observation_cells, 1);
791 }
792
793 #[test]
794 fn test_decay_paused_on_empty_observation() {
795 let mut selector = DiffStrategySelector::with_defaults();
796 let initial_mean = selector.posterior_mean();
797
798 for _ in 0..100 {
800 selector.observe(0, 0);
801 }
802
803 assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
805 }
806
807 #[test]
808 fn estimator_initializes_from_priors() {
809 let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
810 let (alpha, beta) = estimator.posterior_params();
811 assert!((alpha - 2.0).abs() < 1e-9);
812 assert!((beta - 8.0).abs() < 1e-9);
813 assert!((estimator.mean() - 0.2).abs() < 1e-9);
814 }
815
816 #[test]
817 fn estimator_updates_with_decay() {
818 let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
819 estimator.observe(100, 10);
820 let (alpha, beta) = estimator.posterior_params();
821 assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
822 assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
823 }
824
825 #[test]
826 fn estimator_clamps_bounds() {
827 let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
828 for _ in 0..1000 {
829 estimator.observe(1_000_000, 1_000_000);
830 }
831 let (alpha, beta) = estimator.posterior_params();
832 assert!(alpha <= 1e6);
833 assert!(beta >= 1e-6);
834 }
835
836 #[test]
837 fn test_posterior_mean_initial() {
838 let selector = DiffStrategySelector::with_defaults();
839 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
841 }
842
843 #[test]
844 fn test_posterior_update() {
845 let mut selector = DiffStrategySelector::with_defaults();
846
847 selector.observe(100, 10);
849
850 let mean = selector.posterior_mean();
855 assert!(
856 mean > 0.05,
857 "Mean should increase after observing 10% change"
858 );
859 assert!(mean < 0.15, "Mean should not be too high");
860 }
861
862 #[test]
863 fn test_select_dirty_rows_when_few_dirty() {
864 let mut selector = DiffStrategySelector::with_defaults();
865
866 let strategy = selector.select(80, 24, 2); assert_eq!(strategy, DiffStrategy::DirtyRows);
870 }
871
872 #[test]
873 fn test_select_dirty_rows_when_no_dirty() {
874 let mut selector = DiffStrategySelector::with_defaults();
875
876 let strategy = selector.select(80, 24, 0);
877 assert_eq!(strategy, DiffStrategy::DirtyRows);
878
879 let evidence = selector.last_evidence().expect("evidence stored");
880 assert_eq!(evidence.guard_reason, "zero_dirty_rows");
881 }
882
883 #[test]
884 fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
885 let mut selector = DiffStrategySelector::with_defaults();
886
887 let strategy = selector.select(200, 60, 1);
889 assert_eq!(strategy, DiffStrategy::DirtyRows);
890 }
891
892 #[test]
893 fn test_select_full_redraw_when_high_change() {
894 let config = DiffStrategyConfig {
895 prior_alpha: 9.0, prior_beta: 1.0, ..Default::default()
898 };
899
900 let mut selector = DiffStrategySelector::new(config);
901 let strategy = selector.select(80, 24, 24); assert!(matches!(
907 strategy,
908 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
909 ));
910 }
911
912 #[test]
913 fn test_evidence_stored() {
914 let mut selector = DiffStrategySelector::with_defaults();
915 selector.select(80, 24, 5);
916
917 let evidence = selector.last_evidence().expect("Evidence should be stored");
918 assert_eq!(evidence.total_rows, 24);
919 assert_eq!(evidence.total_cells, 80 * 24);
920 assert_eq!(evidence.dirty_rows, 5);
921 }
922
923 #[test]
924 fn test_posterior_clamping() {
925 let mut selector = DiffStrategySelector::with_defaults();
926
927 for _ in 0..1000 {
929 selector.observe(1_000_000, 1_000_000);
930 }
931
932 let (alpha, beta) = selector.posterior_params();
933 assert!(alpha <= 1e6, "Alpha should be clamped");
934 assert!(beta >= 1e-6, "Beta should be clamped");
935 }
936
937 #[test]
938 fn conservative_quantile_extremes_are_safe() {
939 let config = DiffStrategyConfig {
940 conservative: true,
941 conservative_quantile: 1.0,
942 ..Default::default()
943 };
944 let mut selector = DiffStrategySelector::new(config);
945
946 let strategy = selector.select(80, 24, 0);
947 let evidence = selector.last_evidence().expect("evidence should exist");
948
949 assert_eq!(strategy, evidence.strategy);
950 assert!(evidence.cost_full.is_finite());
951 assert!(evidence.cost_dirty.is_finite());
952 assert!(evidence.cost_redraw.is_finite());
953 }
954
955 #[test]
956 fn sanitize_config_clamps_invalid_values() {
957 let config = DiffStrategyConfig {
958 c_scan: -1.0,
959 c_emit: f64::NAN,
960 c_row: f64::INFINITY,
961 prior_alpha: 0.0,
962 prior_beta: -3.0,
963 decay: -1.0,
964 conservative: true,
965 conservative_quantile: 2.0,
966 min_observation_cells: 0,
967 hysteresis_ratio: -1.0,
968 uncertainty_guard_variance: -1.0,
969 };
970 let selector = DiffStrategySelector::new(config);
971 let sanitized = selector.config();
972
973 assert!(sanitized.c_scan >= 0.0);
974 assert!(sanitized.c_emit.is_finite());
975 assert!(sanitized.c_row.is_finite());
976 assert!(sanitized.prior_alpha > 0.0);
977 assert!(sanitized.prior_beta > 0.0);
978 assert!((0.0..=1.0).contains(&sanitized.decay));
979 assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
980 assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
981 assert!(sanitized.uncertainty_guard_variance >= 0.0);
982 }
983
984 #[test]
985 fn hysteresis_can_freeze_strategy_switching() {
986 let config = DiffStrategyConfig {
987 hysteresis_ratio: 1.0,
988 uncertainty_guard_variance: 0.0,
989 ..Default::default()
990 };
991 let mut selector = DiffStrategySelector::new(config);
992
993 let first = selector.select(80, 24, 1);
994 let second = selector.select(80, 24, 24);
995
996 assert_eq!(
997 first, second,
998 "With hysteresis_ratio=1.0, selector should keep prior strategy"
999 );
1000 }
1001
1002 #[test]
1003 fn uncertainty_guard_avoids_full_redraw() {
1004 let config = DiffStrategyConfig {
1005 c_scan: 10.0,
1006 c_emit: 1.0,
1007 uncertainty_guard_variance: 1e-6,
1008 ..Default::default()
1009 };
1010 let mut selector = DiffStrategySelector::new(config);
1011
1012 let strategy = selector.select(80, 24, 24);
1013 assert_ne!(
1014 strategy,
1015 DiffStrategy::FullRedraw,
1016 "Uncertainty guard should avoid FullRedraw under high variance"
1017 );
1018 }
1019
1020 #[test]
1021 fn selector_regret_bounded_across_regimes() {
1022 let mut selector = DiffStrategySelector::with_defaults();
1023 let config = selector.config().clone();
1024 let width = 200u16;
1025 let height = 60u16;
1026 let total_cells = width as usize * height as usize;
1027
1028 let regimes = [
1029 (100usize, 2usize, 0.02f64),
1030 (100usize, 12usize, 0.12f64),
1031 (100usize, height as usize, 0.6f64),
1032 ];
1033
1034 let mut selector_total = 0.0f64;
1035 let mut fixed_full_total = 0.0f64;
1036 let mut fixed_dirty_total = 0.0f64;
1037 let mut fixed_redraw_total = 0.0f64;
1038
1039 for (frames, dirty_rows, p_actual) in regimes {
1040 for _ in 0..frames {
1041 let strategy = selector.select(width, height, dirty_rows);
1042 let (cost_full, cost_dirty, cost_redraw) =
1043 strategy_costs(&config, width, height, dirty_rows, p_actual);
1044 fixed_full_total += cost_full;
1045 fixed_dirty_total += cost_dirty;
1046 fixed_redraw_total += cost_redraw;
1047
1048 let chosen_cost = match strategy {
1049 DiffStrategy::Full => cost_full,
1050 DiffStrategy::DirtyRows => cost_dirty,
1051 DiffStrategy::FullRedraw => cost_redraw,
1052 };
1053 selector_total += chosen_cost;
1054
1055 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1056 let scanned = match strategy {
1057 DiffStrategy::Full => total_cells,
1058 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1059 DiffStrategy::FullRedraw => 0,
1060 };
1061 if strategy != DiffStrategy::FullRedraw {
1062 selector.observe(scanned, changed);
1063 }
1064 }
1065 }
1066
1067 let best_fixed = fixed_full_total
1068 .min(fixed_dirty_total)
1069 .min(fixed_redraw_total);
1070 let regret = if best_fixed > 0.0 {
1071 (selector_total - best_fixed) / best_fixed
1072 } else {
1073 0.0
1074 };
1075 let evidence = selector
1076 .last_evidence()
1077 .map(ToString::to_string)
1078 .unwrap_or_else(|| "no evidence".to_string());
1079
1080 assert!(
1081 regret <= 0.05,
1082 "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1083 regret,
1084 selector_total,
1085 best_fixed,
1086 evidence
1087 );
1088 }
1089
1090 #[test]
1091 fn selector_switching_is_stable_under_constant_load() {
1092 let mut selector = DiffStrategySelector::with_defaults();
1093 let config = selector.config().clone();
1094 let width = 200u16;
1095 let height = 60u16;
1096 let dirty_rows = 2usize;
1097 let p_actual = 0.02f64;
1098 let total_cells = width as usize * height as usize;
1099
1100 let mut switches = 0usize;
1101 let mut last = None;
1102
1103 for _ in 0..200 {
1104 let strategy = selector.select(width, height, dirty_rows);
1105 if let Some(prev) = last
1106 && prev != strategy
1107 {
1108 switches = switches.saturating_add(1);
1109 }
1110 last = Some(strategy);
1111
1112 let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1113 let scanned = match strategy {
1114 DiffStrategy::Full => total_cells,
1115 DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1116 DiffStrategy::FullRedraw => 0,
1117 };
1118 if strategy != DiffStrategy::FullRedraw {
1119 selector.observe(scanned, changed);
1120 }
1121
1122 let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1123 }
1124
1125 let evidence = selector
1126 .last_evidence()
1127 .map(ToString::to_string)
1128 .unwrap_or_else(|| "no evidence".to_string());
1129 assert!(
1130 switches <= 40,
1131 "Selector switched too often under stable regime: {switches}\n{evidence}"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_reset() {
1137 let mut selector = DiffStrategySelector::with_defaults();
1138 selector.observe(100, 50);
1139 selector.select(80, 24, 10);
1140
1141 selector.reset();
1142
1143 assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1144 assert_eq!(selector.frame_count(), 0);
1145 assert!(selector.last_evidence().is_none());
1146 }
1147
1148 #[test]
1149 fn test_deterministic() {
1150 let mut sel1 = DiffStrategySelector::with_defaults();
1151 let mut sel2 = DiffStrategySelector::with_defaults();
1152
1153 sel1.observe(100, 10);
1155 sel2.observe(100, 10);
1156
1157 let s1 = sel1.select(80, 24, 5);
1158 let s2 = sel2.select(80, 24, 5);
1159
1160 assert_eq!(s1, s2);
1161 assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1162 }
1163
1164 #[test]
1165 fn test_upper_quantile_reasonable() {
1166 let selector = DiffStrategySelector::with_defaults();
1167 let mean = selector.posterior_mean();
1168 let q95 = selector.upper_quantile(0.95);
1169
1170 assert!(q95 > mean, "95th percentile should be above mean");
1171 assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1172 }
1173
1174 #[test]
1176 fn prop_posterior_mean_bounded() {
1177 let mut selector = DiffStrategySelector::with_defaults();
1178
1179 for scanned in [1, 10, 100, 1000, 10000] {
1180 for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1181 selector.observe(scanned, changed);
1182 let mean = selector.posterior_mean();
1183 assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1184 }
1185 }
1186 }
1187
1188 #[test]
1190 fn prop_variance_non_negative() {
1191 let mut selector = DiffStrategySelector::with_defaults();
1192
1193 for _ in 0..100 {
1194 selector.observe(100, 5);
1195 assert!(selector.posterior_variance() >= 0.0);
1196 }
1197 }
1198
1199 #[test]
1202 fn diff_strategy_display() {
1203 assert_eq!(format!("{}", DiffStrategy::Full), "Full");
1204 assert_eq!(format!("{}", DiffStrategy::DirtyRows), "DirtyRows");
1205 assert_eq!(format!("{}", DiffStrategy::FullRedraw), "FullRedraw");
1206 }
1207
1208 #[test]
1209 fn diff_strategy_debug() {
1210 let dbg = format!("{:?}", DiffStrategy::Full);
1211 assert!(dbg.contains("Full"));
1212 }
1213
1214 #[test]
1215 fn diff_strategy_clone_and_eq() {
1216 let a = DiffStrategy::DirtyRows;
1217 let b = a;
1218 assert_eq!(a, b);
1219 assert_ne!(a, DiffStrategy::Full);
1220 }
1221
1222 #[test]
1225 fn strategy_evidence_display_contains_all_sections() {
1226 let mut selector = DiffStrategySelector::with_defaults();
1227 selector.select(80, 24, 5);
1228 let ev = selector.last_evidence().unwrap();
1229 let display = format!("{ev}");
1230 assert!(display.contains("Strategy:"));
1231 assert!(display.contains("Costs:"));
1232 assert!(display.contains("Posterior:"));
1233 assert!(display.contains("Dirty:"));
1234 assert!(display.contains("Guard:"));
1235 assert!(display.contains("Hysteresis:"));
1236 }
1237
1238 #[test]
1239 fn strategy_evidence_clone() {
1240 let mut selector = DiffStrategySelector::with_defaults();
1241 selector.select(80, 24, 3);
1242 let ev = selector.last_evidence().unwrap().clone();
1243 assert_eq!(ev.dirty_rows, 3);
1244 assert_eq!(ev.total_rows, 24);
1245 assert_eq!(ev.total_cells, 80 * 24);
1246 }
1247
1248 #[test]
1249 fn strategy_evidence_debug() {
1250 let mut selector = DiffStrategySelector::with_defaults();
1251 selector.select(80, 24, 2);
1252 let ev = selector.last_evidence().unwrap();
1253 let dbg = format!("{ev:?}");
1254 assert!(dbg.contains("StrategyEvidence"));
1255 assert!(dbg.contains("cost_full"));
1256 }
1257
1258 #[test]
1261 fn config_default_all_fields() {
1262 let c = DiffStrategyConfig::default();
1263 assert!((c.c_row - 0.1).abs() < 1e-9);
1264 assert!((c.decay - 0.95).abs() < 1e-9);
1265 assert!(!c.conservative);
1266 assert!((c.conservative_quantile - 0.95).abs() < 1e-9);
1267 }
1268
1269 #[test]
1270 fn config_clone_and_debug() {
1271 let c = DiffStrategyConfig::default();
1272 let c2 = c.clone();
1273 assert!((c2.c_scan - c.c_scan).abs() < 1e-9);
1274 let dbg = format!("{c:?}");
1275 assert!(dbg.contains("DiffStrategyConfig"));
1276 assert!(dbg.contains("c_scan"));
1277 }
1278
1279 #[test]
1282 fn selector_default_equals_with_defaults() {
1283 let s1 = DiffStrategySelector::default();
1284 let s2 = DiffStrategySelector::with_defaults();
1285 assert!((s1.posterior_mean() - s2.posterior_mean()).abs() < 1e-12);
1286 assert_eq!(s1.frame_count(), s2.frame_count());
1287 }
1288
1289 #[test]
1290 fn selector_config_accessor() {
1291 let config = DiffStrategyConfig {
1292 c_scan: 2.0,
1293 ..Default::default()
1294 };
1295 let selector = DiffStrategySelector::new(config);
1296 assert!((selector.config().c_scan - 2.0).abs() < 1e-9);
1297 }
1298
1299 #[test]
1302 fn frame_count_increments_per_select() {
1303 let mut selector = DiffStrategySelector::with_defaults();
1304 assert_eq!(selector.frame_count(), 0);
1305 selector.select(80, 24, 1);
1306 assert_eq!(selector.frame_count(), 1);
1307 selector.select(80, 24, 1);
1308 assert_eq!(selector.frame_count(), 2);
1309 for _ in 0..10 {
1310 selector.select(80, 24, 1);
1311 }
1312 assert_eq!(selector.frame_count(), 12);
1313 }
1314
1315 #[test]
1316 fn frame_count_not_affected_by_observe() {
1317 let mut selector = DiffStrategySelector::with_defaults();
1318 selector.observe(100, 10);
1319 assert_eq!(selector.frame_count(), 0);
1320 }
1321
1322 #[test]
1325 fn override_last_strategy_changes_evidence() {
1326 let mut selector = DiffStrategySelector::with_defaults();
1327 selector.select(80, 24, 2);
1328 let original = selector.last_evidence().unwrap().strategy;
1329
1330 let override_to = if original == DiffStrategy::Full {
1331 DiffStrategy::FullRedraw
1332 } else {
1333 DiffStrategy::Full
1334 };
1335 selector.override_last_strategy(override_to, "test_override");
1336
1337 let ev = selector.last_evidence().unwrap();
1338 assert_eq!(ev.strategy, override_to);
1339 assert_eq!(ev.guard_reason, "test_override");
1340 assert!(!ev.hysteresis_applied);
1341 }
1342
1343 #[test]
1344 fn override_last_strategy_noop_when_no_evidence() {
1345 let mut selector = DiffStrategySelector::with_defaults();
1346 selector.override_last_strategy(DiffStrategy::Full, "noop");
1348 assert!(selector.last_evidence().is_none());
1349 }
1350
1351 #[test]
1354 fn select_with_scan_estimate_custom_cells() {
1355 let mut selector = DiffStrategySelector::with_defaults();
1356 let strategy = selector.select_with_scan_estimate(80, 24, 10, 10);
1358 assert_eq!(strategy, DiffStrategy::DirtyRows);
1359 }
1360
1361 #[test]
1362 fn select_with_scan_estimate_clamped_to_total() {
1363 let mut selector = DiffStrategySelector::with_defaults();
1364 let strategy = selector.select_with_scan_estimate(80, 24, 5, 1_000_000);
1366 assert!(matches!(
1368 strategy,
1369 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1370 ));
1371 }
1372
1373 #[test]
1376 fn estimator_reset_restores_priors() {
1377 let mut est = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
1378 est.observe(100, 50);
1379 assert!((est.mean() - 0.2).abs() > 0.01, "Mean should have changed");
1380
1381 est.reset();
1382 let (alpha, beta) = est.posterior_params();
1383 assert!((alpha - 2.0).abs() < 1e-9);
1384 assert!((beta - 8.0).abs() < 1e-9);
1385 assert!((est.mean() - 0.2).abs() < 1e-9);
1386 }
1387
1388 #[test]
1389 fn estimator_clone() {
1390 let est1 = ChangeRateEstimator::new(1.0, 9.0, 0.95, 0);
1391 let est2 = est1.clone();
1392 assert!((est2.mean() - est1.mean()).abs() < 1e-12);
1393 }
1394
1395 #[test]
1396 fn estimator_debug() {
1397 let est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1398 let dbg = format!("{est:?}");
1399 assert!(dbg.contains("ChangeRateEstimator"));
1400 }
1401
1402 #[test]
1403 fn estimator_min_observation_cells_filters() {
1404 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 50);
1405 let initial_mean = est.mean();
1406 est.observe(49, 25);
1408 assert!(
1409 (est.mean() - initial_mean).abs() < 1e-12,
1410 "Observation below min should be ignored"
1411 );
1412 est.observe(50, 25);
1414 assert!(
1415 (est.mean() - initial_mean).abs() > 0.01,
1416 "Observation at min should be accepted"
1417 );
1418 }
1419
1420 #[test]
1421 fn estimator_changed_exceeds_scanned_is_clamped() {
1422 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1423 est.observe(10, 100);
1425 let mean = est.mean();
1426 assert!(mean > 0.3, "Mean should be high when all cells changed");
1428 }
1429
1430 #[test]
1431 fn estimator_variance_decreases_with_data() {
1432 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1433 let v0 = est.variance();
1434 for _ in 0..50 {
1435 est.observe(100, 5);
1436 }
1437 let v1 = est.variance();
1438 assert!(
1439 v1 < v0,
1440 "Variance should decrease with more data: before={v0:.6}, after={v1:.6}"
1441 );
1442 }
1443
1444 #[test]
1445 fn estimator_upper_quantile_at_50_pct_near_mean() {
1446 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1447 let mean = est.mean();
1448 let q50 = est.upper_quantile(0.5);
1449 assert!(
1450 (q50 - mean).abs() < 0.05,
1451 "50th percentile should be near mean: q50={q50:.4}, mean={mean:.4}"
1452 );
1453 }
1454
1455 #[test]
1456 fn estimator_upper_quantile_monotonic() {
1457 let est = ChangeRateEstimator::new(5.0, 15.0, 1.0, 0);
1458 let q25 = est.upper_quantile(0.25);
1459 let q50 = est.upper_quantile(0.5);
1460 let q75 = est.upper_quantile(0.75);
1461 let q95 = est.upper_quantile(0.95);
1462 assert!(q25 <= q50, "q25={q25:.4} should <= q50={q50:.4}");
1463 assert!(q50 <= q75, "q50={q50:.4} should <= q75={q75:.4}");
1464 assert!(q75 <= q95, "q75={q75:.4} should <= q95={q95:.4}");
1465 }
1466
1467 #[test]
1470 fn normalize_positive_rejects_zero_and_negative() {
1471 assert!((normalize_positive(0.0, 5.0) - 5.0).abs() < 1e-9);
1472 assert!((normalize_positive(-1.0, 5.0) - 5.0).abs() < 1e-9);
1473 assert!((normalize_positive(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1474 assert!((normalize_positive(3.0, 5.0) - 3.0).abs() < 1e-9);
1475 }
1476
1477 #[test]
1478 fn normalize_cost_accepts_zero() {
1479 assert!((normalize_cost(0.0, 5.0) - 0.0).abs() < 1e-9);
1480 assert!((normalize_cost(-1.0, 5.0) - 5.0).abs() < 1e-9);
1481 assert!((normalize_cost(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1482 }
1483
1484 #[test]
1485 fn normalize_decay_clamps_to_one() {
1486 assert!((normalize_decay(1.5) - 1.0).abs() < 1e-9);
1487 assert!((normalize_decay(0.5) - 0.5).abs() < 1e-9);
1488 assert!((normalize_decay(-1.0) - 1.0).abs() < 1e-9);
1489 assert!((normalize_decay(0.0) - 1.0).abs() < 1e-9);
1490 assert!((normalize_decay(f64::NAN) - 1.0).abs() < 1e-9);
1491 }
1492
1493 #[test]
1494 fn normalize_ratio_clamps_to_unit() {
1495 assert!((normalize_ratio(0.5, 0.1) - 0.5).abs() < 1e-9);
1496 assert!((normalize_ratio(-1.0, 0.1) - 0.0).abs() < 1e-9);
1497 assert!((normalize_ratio(2.0, 0.1) - 1.0).abs() < 1e-9);
1498 assert!((normalize_ratio(f64::NAN, 0.1) - 0.1).abs() < 1e-9);
1499 }
1500
1501 #[test]
1504 fn cost_for_strategy_returns_correct_values() {
1505 assert!((cost_for_strategy(DiffStrategy::Full, 1.0, 2.0, 3.0) - 1.0).abs() < 1e-9);
1506 assert!((cost_for_strategy(DiffStrategy::DirtyRows, 1.0, 2.0, 3.0) - 2.0).abs() < 1e-9);
1507 assert!((cost_for_strategy(DiffStrategy::FullRedraw, 1.0, 2.0, 3.0) - 3.0).abs() < 1e-9);
1508 }
1509
1510 #[test]
1513 fn select_1x1_buffer() {
1514 let mut selector = DiffStrategySelector::with_defaults();
1515 let strategy = selector.select(1, 1, 1);
1516 assert!(matches!(
1517 strategy,
1518 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1519 ));
1520 }
1521
1522 #[test]
1523 fn select_zero_width() {
1524 let mut selector = DiffStrategySelector::with_defaults();
1525 let strategy = selector.select(0, 24, 0);
1526 assert!(matches!(
1528 strategy,
1529 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1530 ));
1531 }
1532
1533 #[test]
1534 fn select_zero_height() {
1535 let mut selector = DiffStrategySelector::with_defaults();
1536 let strategy = selector.select(80, 0, 0);
1537 assert!(matches!(
1538 strategy,
1539 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1540 ));
1541 }
1542
1543 #[test]
1546 fn all_dirty_vs_no_dirty_different_evidence() {
1547 let mut sel1 = DiffStrategySelector::with_defaults();
1548 let mut sel2 = DiffStrategySelector::with_defaults();
1549
1550 sel1.select(80, 24, 0);
1551 sel2.select(80, 24, 24);
1552
1553 let ev1 = sel1.last_evidence().unwrap();
1554 let ev2 = sel2.last_evidence().unwrap();
1555
1556 assert_eq!(ev1.dirty_rows, 0);
1557 assert_eq!(ev2.dirty_rows, 24);
1558 assert!(
1560 ev1.cost_dirty <= ev1.cost_full,
1561 "DirtyRows should be cheap with no dirty rows"
1562 );
1563 }
1564
1565 #[test]
1568 fn no_decay_accumulates_all_evidence() {
1569 let config = DiffStrategyConfig {
1570 decay: 1.0,
1571 ..Default::default()
1572 };
1573 let mut selector = DiffStrategySelector::new(config);
1574
1575 for _ in 0..100 {
1577 selector.observe(100, 100);
1578 }
1579 let mean = selector.posterior_mean();
1580 assert!(
1582 mean > 0.9,
1583 "No-decay all-changed mean should be near 1.0: {mean:.4}"
1584 );
1585 }
1586
1587 #[test]
1590 fn evidence_costs_always_finite() {
1591 let mut selector = DiffStrategySelector::with_defaults();
1592 for dirty in [0, 1, 12, 24] {
1593 selector.select(80, 24, dirty);
1594 let ev = selector.last_evidence().unwrap();
1595 assert!(ev.cost_full.is_finite(), "cost_full should be finite");
1596 assert!(ev.cost_dirty.is_finite(), "cost_dirty should be finite");
1597 assert!(ev.cost_redraw.is_finite(), "cost_redraw should be finite");
1598 }
1599 }
1600
1601 #[test]
1604 fn evidence_posterior_matches_selector() {
1605 let mut selector = DiffStrategySelector::with_defaults();
1606 selector.observe(100, 10);
1607 selector.select(80, 24, 5);
1608 let ev = selector.last_evidence().unwrap();
1609 assert!((ev.posterior_mean - selector.posterior_mean()).abs() < 1e-12);
1610 assert!((ev.posterior_variance - selector.posterior_variance()).abs() < 1e-12);
1611 let (alpha, beta) = selector.posterior_params();
1612 assert!((ev.alpha - alpha).abs() < 1e-12);
1613 assert!((ev.beta - beta).abs() < 1e-12);
1614 }
1615
1616 #[test]
1619 fn selector_clone() {
1620 let mut selector = DiffStrategySelector::with_defaults();
1621 selector.observe(100, 10);
1622 selector.select(80, 24, 5);
1623 let clone = selector.clone();
1624 assert!((clone.posterior_mean() - selector.posterior_mean()).abs() < 1e-12);
1625 assert_eq!(clone.frame_count(), selector.frame_count());
1626 }
1627
1628 #[test]
1631 fn selector_debug() {
1632 let selector = DiffStrategySelector::with_defaults();
1633 let dbg = format!("{selector:?}");
1634 assert!(dbg.contains("DiffStrategySelector"));
1635 assert!(dbg.contains("frame_count"));
1636 }
1637
1638 #[test]
1641 fn hysteresis_not_applied_on_first_select() {
1642 let config = DiffStrategyConfig {
1643 hysteresis_ratio: 1.0,
1644 ..Default::default()
1645 };
1646 let mut selector = DiffStrategySelector::new(config);
1647 selector.select(80, 24, 5);
1648 let ev = selector.last_evidence().unwrap();
1649 assert!(
1650 !ev.hysteresis_applied,
1651 "First select should not apply hysteresis"
1652 );
1653 }
1654
1655 #[test]
1658 fn conservative_mode_higher_p_estimate() {
1659 let mut conservative = DiffStrategySelector::new(DiffStrategyConfig {
1660 conservative: true,
1661 ..Default::default()
1662 });
1663 let mut normal = DiffStrategySelector::with_defaults();
1664
1665 for _ in 0..20 {
1667 conservative.observe(100, 5);
1668 normal.observe(100, 5);
1669 }
1670
1671 conservative.select(80, 24, 12);
1672 normal.select(80, 24, 12);
1673
1674 let ev_cons = conservative.last_evidence().unwrap();
1675 let ev_norm = normal.last_evidence().unwrap();
1676
1677 assert!(
1680 ev_cons.cost_dirty >= ev_norm.cost_dirty - 1e-6,
1681 "Conservative costs should be >= normal costs"
1682 );
1683 }
1684
1685 mod edge_case_tests {
1690 use super::super::*;
1691 use super::strategy_costs;
1692
1693 #[test]
1696 fn estimator_observe_zero_scanned_with_min_one() {
1697 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 1);
1699 let initial = est.mean();
1700 est.observe(0, 0);
1701 assert!(
1702 (est.mean() - initial).abs() < 1e-12,
1703 "Zero scanned should be filtered: mean changed"
1704 );
1705 }
1706
1707 #[test]
1708 fn estimator_observe_all_unchanged() {
1709 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1710 for _ in 0..100 {
1711 est.observe(1000, 0);
1712 }
1713 assert!(
1715 est.mean() < 0.01,
1716 "All-unchanged observations should drive mean near zero: {}",
1717 est.mean()
1718 );
1719 }
1720
1721 #[test]
1722 fn estimator_observe_all_changed() {
1723 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1724 for _ in 0..100 {
1725 est.observe(1000, 1000);
1726 }
1727 assert!(
1729 est.mean() > 0.99,
1730 "All-changed observations should drive mean near 1.0: {}",
1731 est.mean()
1732 );
1733 }
1734
1735 #[test]
1736 fn estimator_rapid_decay_forgets_quickly() {
1737 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.1, 0);
1738 for _ in 0..50 {
1740 est.observe(100, 90);
1741 }
1742 let high_mean = est.mean();
1743
1744 for _ in 0..10 {
1746 est.observe(100, 1);
1747 }
1748 let low_mean = est.mean();
1749
1750 assert!(
1751 low_mean < high_mean * 0.5,
1752 "Rapid decay should forget quickly: high={high_mean:.4}, low={low_mean:.4}"
1753 );
1754 }
1755
1756 #[test]
1757 fn estimator_alternating_observations() {
1758 let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1759 for i in 0..100 {
1760 if i % 2 == 0 {
1761 est.observe(100, 100); } else {
1763 est.observe(100, 0); }
1765 }
1766 let mean = est.mean();
1768 assert!(
1769 mean > 0.3 && mean < 0.7,
1770 "Alternating observations should settle near 0.5: {mean:.4}"
1771 );
1772 }
1773
1774 #[test]
1775 fn estimator_upper_quantile_at_extreme_low() {
1776 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1777 let q01 = est.upper_quantile(0.01);
1778 assert!(
1779 q01 >= 0.0,
1780 "Lower quantile should be non-negative: {q01:.4}"
1781 );
1782 assert!(
1783 q01 < est.mean(),
1784 "1st percentile should be below mean: q01={q01:.4}, mean={:.4}",
1785 est.mean()
1786 );
1787 }
1788
1789 #[test]
1790 fn estimator_upper_quantile_at_extreme_high() {
1791 let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1792 let q99 = est.upper_quantile(0.99);
1793 assert!(q99 <= 1.0, "Upper quantile should be <= 1.0: {q99:.4}");
1794 assert!(
1795 q99 > est.mean(),
1796 "99th percentile should be above mean: q99={q99:.4}, mean={:.4}",
1797 est.mean()
1798 );
1799 }
1800
1801 #[test]
1802 fn estimator_upper_quantile_tight_posterior() {
1803 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1805 for _ in 0..10000 {
1806 est.observe(100, 5);
1807 }
1808 let mean = est.mean();
1809 let q95 = est.upper_quantile(0.95);
1810 assert!(
1811 (q95 - mean).abs() < 0.01,
1812 "Tight posterior should have quantile near mean: q95={q95:.4}, mean={mean:.4}"
1813 );
1814 }
1815
1816 #[test]
1817 fn estimator_upper_quantile_clamped_output() {
1818 let est = ChangeRateEstimator::new(1e-6, 1e-6, 1.0, 0);
1820 for q in [0.01, 0.1, 0.5, 0.9, 0.99] {
1821 let val = est.upper_quantile(q);
1822 assert!(
1823 (0.0..=1.0).contains(&val),
1824 "Quantile({q}) = {val} should be in [0,1]"
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn estimator_variance_formula_correct() {
1831 let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1832 let (a, b) = est.posterior_params();
1833 let expected_var = (a * b) / ((a + b).powi(2) * (a + b + 1.0));
1834 assert!(
1835 (est.variance() - expected_var).abs() < 1e-12,
1836 "Variance formula: got {}, expected {}",
1837 est.variance(),
1838 expected_var
1839 );
1840 }
1841
1842 #[test]
1843 fn estimator_mean_formula_correct() {
1844 let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1845 let (a, b) = est.posterior_params();
1846 let expected_mean = a / (a + b);
1847 assert!(
1848 (est.mean() - expected_mean).abs() < 1e-12,
1849 "Mean formula: got {}, expected {}",
1850 est.mean(),
1851 expected_mean
1852 );
1853 }
1854
1855 #[test]
1858 fn select_dirty_rows_exceeds_height() {
1859 let mut selector = DiffStrategySelector::with_defaults();
1860 let strategy = selector.select(80, 24, 100);
1862 assert!(matches!(
1863 strategy,
1864 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1865 ));
1866 }
1867
1868 #[test]
1869 fn select_large_dimensions() {
1870 let mut selector = DiffStrategySelector::with_defaults();
1871 let strategy = selector.select(u16::MAX, u16::MAX, 1);
1873 assert!(matches!(
1874 strategy,
1875 DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1876 ));
1877 let ev = selector.last_evidence().unwrap();
1878 assert!(ev.cost_full.is_finite());
1879 assert!(ev.cost_dirty.is_finite());
1880 assert!(ev.cost_redraw.is_finite());
1881 }
1882
1883 #[test]
1884 fn multiple_selects_without_observe() {
1885 let mut selector = DiffStrategySelector::with_defaults();
1886 let initial_mean = selector.posterior_mean();
1887
1888 for _ in 0..50 {
1889 selector.select(80, 24, 5);
1890 }
1891
1892 assert!(
1894 (selector.posterior_mean() - initial_mean).abs() < 1e-12,
1895 "Mean should not change without observations"
1896 );
1897 assert_eq!(selector.frame_count(), 50);
1898 }
1899
1900 #[test]
1901 fn conservative_with_zero_dirty_rows() {
1902 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1903 conservative: true,
1904 ..Default::default()
1905 });
1906 let strategy = selector.select(80, 24, 0);
1907 assert_eq!(strategy, DiffStrategy::DirtyRows);
1908 let ev = selector.last_evidence().unwrap();
1909 assert_eq!(ev.guard_reason, "zero_dirty_rows");
1910 }
1911
1912 #[test]
1913 fn uncertainty_guard_with_fullredraw_hysteresis() {
1914 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1917 c_scan: 10.0,
1918 c_emit: 1.0,
1919 uncertainty_guard_variance: 1e-6,
1920 hysteresis_ratio: 0.99, ..Default::default()
1922 });
1923
1924 selector.select(80, 24, 24);
1926
1927 let strategy = selector.select(80, 24, 24);
1929 assert_ne!(
1931 strategy,
1932 DiffStrategy::FullRedraw,
1933 "Uncertainty guard should override hysteresis for FullRedraw"
1934 );
1935 }
1936
1937 #[test]
1938 fn select_with_scan_estimate_zero_cells() {
1939 let mut selector = DiffStrategySelector::with_defaults();
1940 let strategy = selector.select_with_scan_estimate(80, 24, 5, 0);
1942 assert_eq!(strategy, DiffStrategy::DirtyRows);
1943 }
1944
1945 #[test]
1946 fn hysteresis_prevents_switch_near_boundary() {
1947 let config = DiffStrategyConfig {
1948 hysteresis_ratio: 0.5, uncertainty_guard_variance: 0.0,
1950 ..Default::default()
1951 };
1952 let mut selector = DiffStrategySelector::new(config);
1953
1954 let first = selector.select(80, 24, 5);
1956
1957 let second = selector.select(80, 24, 6);
1959 assert_eq!(
1960 first, second,
1961 "High hysteresis should prevent switching on small changes"
1962 );
1963 }
1964
1965 #[test]
1966 fn reset_clears_frame_count_and_evidence() {
1967 let mut selector = DiffStrategySelector::with_defaults();
1968 selector.observe(100, 10);
1969 selector.select(80, 24, 5);
1970 selector.select(80, 24, 5);
1971
1972 assert_eq!(selector.frame_count(), 2);
1973 assert!(selector.last_evidence().is_some());
1974
1975 selector.reset();
1976
1977 assert_eq!(selector.frame_count(), 0);
1978 assert!(selector.last_evidence().is_none());
1979 assert!(
1980 (selector.posterior_mean() - 0.05).abs() < 1e-9,
1981 "Reset should restore prior mean"
1982 );
1983 }
1984
1985 #[test]
1986 fn posterior_variance_after_reset() {
1987 let mut selector = DiffStrategySelector::with_defaults();
1988 let initial_var = selector.posterior_variance();
1989
1990 selector.observe(100, 10);
1991 assert!(selector.posterior_variance() != initial_var);
1992
1993 selector.reset();
1994 assert!(
1995 (selector.posterior_variance() - initial_var).abs() < 1e-12,
1996 "Reset should restore prior variance"
1997 );
1998 }
1999
2000 #[test]
2003 fn normalize_positive_rejects_infinity() {
2004 assert!(
2005 (normalize_positive(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2006 "Infinity should be rejected"
2007 );
2008 assert!(
2009 (normalize_positive(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2010 "Negative infinity should be rejected"
2011 );
2012 }
2013
2014 #[test]
2015 fn normalize_cost_rejects_neg_infinity() {
2016 assert!(
2017 (normalize_cost(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2018 "Negative infinity should be rejected"
2019 );
2020 }
2021
2022 #[test]
2023 fn normalize_cost_accepts_positive_infinity() {
2024 assert!(
2026 (normalize_cost(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2027 "Positive infinity should be rejected"
2028 );
2029 }
2030
2031 #[test]
2032 fn normalize_ratio_rejects_infinity() {
2033 assert!(
2034 (normalize_ratio(f64::INFINITY, 0.1) - 0.1).abs() < 1e-9,
2035 "Infinity should use fallback"
2036 );
2037 assert!(
2038 (normalize_ratio(f64::NEG_INFINITY, 0.1) - 0.1).abs() < 1e-9,
2039 "Negative infinity should use fallback"
2040 );
2041 }
2042
2043 #[test]
2044 fn normalize_decay_rejects_neg_infinity() {
2045 assert!(
2046 (normalize_decay(f64::NEG_INFINITY) - 1.0).abs() < 1e-9,
2047 "Negative infinity should use fallback"
2048 );
2049 }
2050
2051 #[test]
2054 fn cost_redraw_independent_of_dirty_rows() {
2055 let mut sel1 = DiffStrategySelector::with_defaults();
2056 let mut sel2 = DiffStrategySelector::with_defaults();
2057
2058 sel1.select(80, 24, 0);
2059 sel2.select(80, 24, 24);
2060
2061 let ev1 = sel1.last_evidence().unwrap();
2062 let ev2 = sel2.last_evidence().unwrap();
2063
2064 assert!(
2066 (ev1.cost_redraw - ev2.cost_redraw).abs() < 1e-6,
2067 "FullRedraw cost should not depend on dirty rows"
2068 );
2069 }
2070
2071 #[test]
2072 fn cost_full_increases_with_dirty_rows() {
2073 let config = DiffStrategyConfig::default();
2074 let (cost_full_2, _, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2076 let (cost_full_20, _, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2077 assert!(
2078 cost_full_20 > cost_full_2,
2079 "More dirty rows should increase Full cost: 2={cost_full_2:.2}, 20={cost_full_20:.2}"
2080 );
2081 }
2082
2083 #[test]
2084 fn cost_dirty_increases_with_dirty_rows() {
2085 let config = DiffStrategyConfig::default();
2086 let (_, cost_dirty_2, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2087 let (_, cost_dirty_20, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2088 assert!(
2089 cost_dirty_20 > cost_dirty_2,
2090 "More dirty rows should increase DirtyRows cost"
2091 );
2092 }
2093
2094 #[test]
2097 fn evidence_all_fields_populated() {
2098 let mut selector = DiffStrategySelector::with_defaults();
2099 selector.observe(100, 10);
2100 selector.select(200, 60, 15);
2101
2102 let ev = selector.last_evidence().unwrap();
2103 assert_eq!(ev.total_rows, 60);
2104 assert_eq!(ev.total_cells, 200 * 60);
2105 assert_eq!(ev.dirty_rows, 15);
2106 assert!(ev.cost_full >= 0.0);
2107 assert!(ev.cost_dirty >= 0.0);
2108 assert!(ev.cost_redraw >= 0.0);
2109 assert!((0.0..=1.0).contains(&ev.posterior_mean));
2110 assert!(ev.posterior_variance >= 0.0);
2111 assert!(ev.alpha > 0.0);
2112 assert!(ev.beta > 0.0);
2113 assert!(!ev.guard_reason.is_empty());
2114 assert!(ev.hysteresis_ratio >= 0.0);
2115 }
2116
2117 #[test]
2118 fn evidence_display_format() {
2119 let mut selector = DiffStrategySelector::with_defaults();
2120 selector.select(80, 24, 5);
2121 let ev = selector.last_evidence().unwrap();
2122 let display = format!("{ev}");
2123
2124 assert!(display.contains("Strategy:"));
2126 assert!(display.contains("Costs:"));
2127 assert!(display.contains("Posterior:"));
2128 assert!(display.contains("Dirty:"));
2129 assert!(display.contains("Guard:"));
2130 }
2131
2132 #[test]
2135 fn diff_strategy_all_variants_distinct() {
2136 let variants = [
2137 DiffStrategy::Full,
2138 DiffStrategy::DirtyRows,
2139 DiffStrategy::FullRedraw,
2140 ];
2141 for (i, a) in variants.iter().enumerate() {
2142 for (j, b) in variants.iter().enumerate() {
2143 if i == j {
2144 assert_eq!(a, b);
2145 } else {
2146 assert_ne!(a, b);
2147 }
2148 }
2149 }
2150 }
2151
2152 #[test]
2153 fn diff_strategy_copy() {
2154 let a = DiffStrategy::DirtyRows;
2155 let b = a; let _c = a; assert_eq!(a, b);
2158 }
2159
2160 #[test]
2163 fn custom_prior_high_alpha_favors_dirty_rows_less() {
2164 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2166 prior_alpha: 50.0,
2167 prior_beta: 1.0, ..Default::default()
2169 });
2170 selector.select(80, 24, 24);
2171 let ev = selector.last_evidence().unwrap();
2172 assert!(
2174 ev.cost_redraw <= ev.cost_full * 1.5,
2175 "High change rate should make redraw competitive"
2176 );
2177 }
2178
2179 #[test]
2180 fn custom_prior_high_beta_favors_dirty_rows() {
2181 let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2183 prior_alpha: 1.0,
2184 prior_beta: 1000.0, ..Default::default()
2186 });
2187 let strategy = selector.select(80, 24, 5);
2188 assert_eq!(
2189 strategy,
2190 DiffStrategy::DirtyRows,
2191 "Very low expected change rate should favor DirtyRows"
2192 );
2193 }
2194
2195 #[test]
2198 fn decay_zero_sanitizes_to_one() {
2199 let config = DiffStrategyConfig {
2201 decay: 0.0,
2202 ..Default::default()
2203 };
2204 let selector = DiffStrategySelector::new(config);
2205 assert!(
2207 (selector.config().decay - 1.0).abs() < 1e-9,
2208 "Decay=0.0 should be sanitized to 1.0"
2209 );
2210 }
2211
2212 #[test]
2213 fn decay_one_no_forgetting() {
2214 let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
2215 est.observe(100, 10);
2216 let (a1, b1) = est.posterior_params();
2217 assert!(
2219 (a1 - 11.0).abs() < 1e-9,
2220 "No-decay alpha: expected 11.0, got {a1}"
2221 );
2222 assert!(
2223 (b1 - 109.0).abs() < 1e-9,
2224 "No-decay beta: expected 109.0, got {b1}"
2225 );
2226 }
2227
2228 #[test]
2231 fn determinism_across_long_trace() {
2232 let trace: Vec<(u16, u16, usize, usize, usize)> = (0..200)
2233 .map(|i| {
2234 let dirty = (i * 3 % 24) + 1;
2235 let scanned = 80 * dirty;
2236 let changed = (i * 7 % scanned.max(1)).max(1);
2237 (80u16, 24u16, dirty, scanned, changed)
2238 })
2239 .collect();
2240
2241 let mut sel1 = DiffStrategySelector::with_defaults();
2242 let mut sel2 = DiffStrategySelector::with_defaults();
2243
2244 for (w, h, dirty, scanned, changed) in &trace {
2245 let s1 = sel1.select(*w, *h, *dirty);
2246 let s2 = sel2.select(*w, *h, *dirty);
2247 assert_eq!(s1, s2, "Determinism violated");
2248
2249 sel1.observe(*scanned, *changed);
2250 sel2.observe(*scanned, *changed);
2251
2252 assert!(
2253 (sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12,
2254 "Posterior diverged"
2255 );
2256 }
2257 }
2258
2259 #[test]
2262 fn override_changes_strategy_and_clears_hysteresis() {
2263 let mut selector = DiffStrategySelector::with_defaults();
2264 selector.select(80, 24, 5);
2265
2266 let original = selector.last_evidence().unwrap().strategy;
2267 let target = if original == DiffStrategy::FullRedraw {
2268 DiffStrategy::Full
2269 } else {
2270 DiffStrategy::FullRedraw
2271 };
2272
2273 selector.override_last_strategy(target, "forced_override");
2274 let ev = selector.last_evidence().unwrap();
2275
2276 assert_eq!(ev.strategy, target, "Override should change strategy");
2277 assert_eq!(ev.guard_reason, "forced_override");
2278 assert!(!ev.hysteresis_applied, "Override should clear hysteresis");
2279 }
2280
2281 #[test]
2284 fn sanitize_preserves_valid_config() {
2285 let config = DiffStrategyConfig {
2286 c_scan: 2.0,
2287 c_emit: 8.0,
2288 c_row: 0.5,
2289 prior_alpha: 3.0,
2290 prior_beta: 17.0,
2291 decay: 0.9,
2292 conservative: true,
2293 conservative_quantile: 0.9,
2294 min_observation_cells: 5,
2295 hysteresis_ratio: 0.1,
2296 uncertainty_guard_variance: 0.005,
2297 };
2298 let selector = DiffStrategySelector::new(config);
2299 let c = selector.config();
2300 assert!((c.c_scan - 2.0).abs() < 1e-9);
2301 assert!((c.c_emit - 8.0).abs() < 1e-9);
2302 assert!((c.c_row - 0.5).abs() < 1e-9);
2303 assert!((c.prior_alpha - 3.0).abs() < 1e-9);
2304 assert!((c.prior_beta - 17.0).abs() < 1e-9);
2305 assert!((c.decay - 0.9).abs() < 1e-9);
2306 assert!(c.conservative);
2307 assert!((c.conservative_quantile - 0.9).abs() < 1e-9);
2308 assert_eq!(c.min_observation_cells, 5);
2309 assert!((c.hysteresis_ratio - 0.1).abs() < 1e-9);
2310 }
2311
2312 #[test]
2313 fn sanitize_all_nan_uses_defaults() {
2314 let config = DiffStrategyConfig {
2315 c_scan: f64::NAN,
2316 c_emit: f64::NAN,
2317 c_row: f64::NAN,
2318 prior_alpha: f64::NAN,
2319 prior_beta: f64::NAN,
2320 decay: f64::NAN,
2321 conservative: false,
2322 conservative_quantile: f64::NAN,
2323 min_observation_cells: 0,
2324 hysteresis_ratio: f64::NAN,
2325 uncertainty_guard_variance: f64::NAN,
2326 };
2327 let selector = DiffStrategySelector::new(config);
2328 let c = selector.config();
2329 assert!((c.c_scan - 1.0).abs() < 1e-9);
2331 assert!((c.c_emit - 6.0).abs() < 1e-9);
2332 assert!((c.c_row - 0.1).abs() < 1e-9);
2333 assert!((c.prior_alpha - 1.0).abs() < 1e-9);
2334 assert!((c.prior_beta - 19.0).abs() < 1e-9);
2335 assert!((c.decay - 1.0).abs() < 1e-9);
2336 assert!((c.hysteresis_ratio - 0.05).abs() < 1e-9);
2337 assert!((c.uncertainty_guard_variance - 0.002).abs() < 1e-9);
2338 }
2339
2340 #[test]
2343 fn zero_change_rate_costs() {
2344 let config = DiffStrategyConfig::default();
2345 let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 5, 0.0);
2346 let expected_full = config.c_row * 24.0 + config.c_scan * 5.0 * 80.0;
2348 let expected_dirty = config.c_scan * 5.0 * 80.0;
2349 let expected_redraw = config.c_emit * 80.0 * 24.0;
2350
2351 assert!((cost_full - expected_full).abs() < 1e-6);
2352 assert!((cost_dirty - expected_dirty).abs() < 1e-6);
2353 assert!((cost_redraw - expected_redraw).abs() < 1e-6);
2354 }
2355
2356 #[test]
2357 fn full_change_rate_costs() {
2358 let config = DiffStrategyConfig::default();
2359 let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 24, 1.0);
2360 assert!(
2363 cost_redraw <= cost_full,
2364 "At p=1.0, redraw should be <= full"
2365 );
2366 assert!(
2367 cost_redraw <= cost_dirty,
2368 "At p=1.0, redraw should be <= dirty"
2369 );
2370 }
2371 }
2372}