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