1#![forbid(unsafe_code)]
60
61use std::collections::VecDeque;
62use web_time::{Duration, Instant};
63
64use crate::bocpd::{BocpdConfig, BocpdDetector, BocpdRegime};
65use crate::evidence_sink::{EVIDENCE_SCHEMA_VERSION, EvidenceSink};
66use crate::terminal_writer::ScreenMode;
67
68const MAX_CYCLE_TIME_SAMPLES: usize = 1024;
73
74const MAX_TRANSITION_LOGS: usize = 256;
77
78const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
79const FNV_PRIME: u64 = 0x100000001b3;
81
82fn fnv_hash_bytes(hash: &mut u64, bytes: &[u8]) {
83 for byte in bytes {
84 *hash ^= *byte as u64;
85 *hash = hash.wrapping_mul(FNV_PRIME);
86 }
87}
88
89#[inline]
90fn duration_since_or_zero(now: Instant, earlier: Instant) -> Duration {
91 now.saturating_duration_since(earlier)
92}
93
94fn default_resize_run_id() -> String {
95 format!("resize-{}", std::process::id())
96}
97
98fn screen_mode_str(mode: ScreenMode) -> &'static str {
99 match mode {
100 ScreenMode::Inline { .. } => "inline",
101 ScreenMode::InlineAuto { .. } => "inline_auto",
102 ScreenMode::AltScreen => "altscreen",
103 }
104}
105
106#[inline]
107fn json_escape(value: &str) -> String {
108 let mut out = String::with_capacity(value.len());
109 for ch in value.chars() {
110 match ch {
111 '"' => out.push_str("\\\""),
112 '\\' => out.push_str("\\\\"),
113 '\n' => out.push_str("\\n"),
114 '\r' => out.push_str("\\r"),
115 '\t' => out.push_str("\\t"),
116 c if c.is_control() => {
117 use std::fmt::Write as _;
118 let _ = write!(out, "\\u{:04X}", c as u32);
119 }
120 _ => out.push(ch),
121 }
122 }
123 out
124}
125
126fn evidence_prefix(
127 run_id: &str,
128 screen_mode: ScreenMode,
129 cols: u16,
130 rows: u16,
131 event_idx: u64,
132) -> String {
133 format!(
134 r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
135 EVIDENCE_SCHEMA_VERSION,
136 json_escape(run_id),
137 event_idx,
138 screen_mode_str(screen_mode),
139 cols,
140 rows,
141 )
142}
143
144#[derive(Debug, Clone)]
146pub struct CoalescerConfig {
147 pub steady_delay_ms: u64,
150
151 pub burst_delay_ms: u64,
154
155 pub hard_deadline_ms: u64,
158
159 pub burst_enter_rate: f64,
161
162 pub burst_exit_rate: f64,
165
166 pub cooldown_frames: u32,
168
169 pub rate_window_size: usize,
171
172 pub enable_logging: bool,
174
175 pub enable_bocpd: bool,
186
187 pub bocpd_config: Option<BocpdConfig>,
189}
190
191impl Default for CoalescerConfig {
192 fn default() -> Self {
193 Self {
194 steady_delay_ms: 16, burst_delay_ms: 40, hard_deadline_ms: 100,
197 burst_enter_rate: 10.0, burst_exit_rate: 5.0, cooldown_frames: 3,
200 rate_window_size: 8,
201 enable_logging: false,
202 enable_bocpd: false,
203 bocpd_config: None,
204 }
205 }
206}
207
208impl CoalescerConfig {
209 #[must_use]
211 pub fn with_logging(mut self, enabled: bool) -> Self {
212 self.enable_logging = enabled;
213 self
214 }
215
216 #[must_use]
218 pub fn with_bocpd(mut self) -> Self {
219 self.enable_bocpd = true;
220 self.bocpd_config = Some(BocpdConfig::default());
221 self
222 }
223
224 #[must_use]
226 pub fn with_bocpd_config(mut self, config: BocpdConfig) -> Self {
227 self.enable_bocpd = true;
228 self.bocpd_config = Some(config);
229 self
230 }
231
232 #[must_use]
234 pub fn to_jsonl(
235 &self,
236 run_id: &str,
237 screen_mode: ScreenMode,
238 cols: u16,
239 rows: u16,
240 event_idx: u64,
241 ) -> String {
242 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
243 format!(
244 r#"{{{prefix},"event":"config","steady_delay_ms":{},"burst_delay_ms":{},"hard_deadline_ms":{},"burst_enter_rate":{:.3},"burst_exit_rate":{:.3},"cooldown_frames":{},"rate_window_size":{},"logging_enabled":{}}}"#,
245 self.steady_delay_ms,
246 self.burst_delay_ms,
247 self.hard_deadline_ms,
248 self.burst_enter_rate,
249 self.burst_exit_rate,
250 self.cooldown_frames,
251 self.rate_window_size,
252 self.enable_logging
253 )
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum CoalesceAction {
260 None,
262
263 ShowPlaceholder,
265
266 ApplyResize {
268 width: u16,
269 height: u16,
270 coalesce_time: Duration,
272 forced_by_deadline: bool,
274 },
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
279pub enum Regime {
280 #[default]
282 Steady,
283 Burst,
285}
286
287impl Regime {
288 #[must_use]
290 pub const fn as_str(self) -> &'static str {
291 match self {
292 Self::Steady => "steady",
293 Self::Burst => "burst",
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum TransitionReasonCode {
301 HeuristicEnterBurstRate,
303 HeuristicExitBurstCooldown,
305 BocpdPosteriorBurst,
307 BocpdPosteriorSteady,
309}
310
311impl TransitionReasonCode {
312 #[must_use]
314 pub const fn as_str(self) -> &'static str {
315 match self {
316 Self::HeuristicEnterBurstRate => "heuristic_enter_burst_rate",
317 Self::HeuristicExitBurstCooldown => "heuristic_exit_burst_cooldown",
318 Self::BocpdPosteriorBurst => "bocpd_posterior_burst",
319 Self::BocpdPosteriorSteady => "bocpd_posterior_steady",
320 }
321 }
322}
323
324#[derive(Debug, Clone)]
328pub struct ResizeAppliedEvent {
329 pub new_size: (u16, u16),
331 pub old_size: (u16, u16),
333 pub elapsed: Duration,
335 pub forced: bool,
337}
338
339#[derive(Debug, Clone)]
343pub struct RegimeChangeEvent {
344 pub from: Regime,
346 pub to: Regime,
348 pub event_idx: u64,
350 pub reason_code: TransitionReasonCode,
352 pub confidence: f64,
354}
355
356#[derive(Debug, Clone)]
384pub struct DecisionEvidence {
385 pub log_bayes_factor: f64,
387
388 pub regime_contribution: f64,
390
391 pub timing_contribution: f64,
393
394 pub rate_contribution: f64,
396
397 pub explanation: String,
399}
400
401impl DecisionEvidence {
402 #[must_use]
404 pub fn favor_apply(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
405 let regime_contrib = if regime == Regime::Steady { 1.0 } else { -0.5 };
406 let timing_contrib = (dt_ms / 50.0).min(2.0); let rate_contrib = if event_rate < 5.0 { 0.5 } else { -0.3 };
408
409 let lbf = regime_contrib + timing_contrib + rate_contrib;
410
411 Self {
412 log_bayes_factor: lbf,
413 regime_contribution: regime_contrib,
414 timing_contribution: timing_contrib,
415 rate_contribution: rate_contrib,
416 explanation: format!(
417 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
418 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
419 ),
420 }
421 }
422
423 #[must_use]
425 pub fn favor_coalesce(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
426 let regime_contrib = if regime == Regime::Burst { 1.0 } else { -0.5 };
427 let timing_contrib = (20.0 / dt_ms.max(1.0)).min(2.0); let rate_contrib = if event_rate > 10.0 { 0.5 } else { -0.3 };
429
430 let lbf = -(regime_contrib + timing_contrib + rate_contrib);
431
432 Self {
433 log_bayes_factor: lbf,
434 regime_contribution: regime_contrib,
435 timing_contribution: timing_contrib,
436 rate_contribution: rate_contrib,
437 explanation: format!(
438 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
439 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
440 ),
441 }
442 }
443
444 #[must_use]
446 pub fn forced_deadline(deadline_ms: f64) -> Self {
447 Self {
448 log_bayes_factor: f64::INFINITY,
449 regime_contribution: 0.0,
450 timing_contribution: deadline_ms,
451 rate_contribution: 0.0,
452 explanation: format!("Forced by hard deadline ({:.1}ms)", deadline_ms),
453 }
454 }
455
456 #[must_use]
458 pub fn to_jsonl(
459 &self,
460 run_id: &str,
461 screen_mode: ScreenMode,
462 cols: u16,
463 rows: u16,
464 event_idx: u64,
465 ) -> String {
466 let lbf_str = if self.log_bayes_factor.is_infinite() {
467 "\"inf\"".to_string()
468 } else {
469 format!("{:.3}", self.log_bayes_factor)
470 };
471 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
472 format!(
473 r#"{{{prefix},"event":"decision_evidence","log_bayes_factor":{},"regime_contribution":{:.3},"timing_contribution":{:.3},"rate_contribution":{:.3},"explanation":"{}"}}"#,
474 lbf_str,
475 self.regime_contribution,
476 self.timing_contribution,
477 self.rate_contribution,
478 json_escape(&self.explanation)
479 )
480 }
481
482 #[must_use]
484 pub fn is_strong(&self) -> bool {
485 self.log_bayes_factor.abs() > 1.0
486 }
487
488 #[must_use]
490 pub fn is_decisive(&self) -> bool {
491 self.log_bayes_factor.abs() > 2.0 || self.log_bayes_factor.is_infinite()
492 }
493}
494
495#[derive(Debug, Clone)]
497pub struct DecisionLog {
498 pub timestamp: Instant,
500 pub elapsed_ms: f64,
502 pub event_idx: u64,
504 pub dt_ms: f64,
506 pub event_rate: f64,
508 pub regime: Regime,
510 pub action: &'static str,
512 pub pending_size: Option<(u16, u16)>,
514 pub applied_size: Option<(u16, u16)>,
516 pub time_since_render_ms: f64,
518 pub coalesce_ms: Option<f64>,
520 pub forced: bool,
522 pub transition_reason_code: Option<TransitionReasonCode>,
524 pub transition_confidence: Option<f64>,
526}
527
528impl DecisionLog {
529 #[must_use]
531 pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
532 let (pending_w, pending_h) = match self.pending_size {
533 Some((w, h)) => (w.to_string(), h.to_string()),
534 None => ("null".to_string(), "null".to_string()),
535 };
536 let (applied_w, applied_h) = match self.applied_size {
537 Some((w, h)) => (w.to_string(), h.to_string()),
538 None => ("null".to_string(), "null".to_string()),
539 };
540 let coalesce_ms = match self.coalesce_ms {
541 Some(ms) => format!("{:.3}", ms),
542 None => "null".to_string(),
543 };
544 let transition_reason_code = self
545 .transition_reason_code
546 .map(TransitionReasonCode::as_str)
547 .map(|code| format!(r#""{code}""#))
548 .unwrap_or_else(|| "null".to_string());
549 let transition_confidence = self
550 .transition_confidence
551 .map(|confidence| format!("{confidence:.6}"))
552 .unwrap_or_else(|| "null".to_string());
553 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
554
555 format!(
556 r#"{{{prefix},"event":"decision","idx":{},"elapsed_ms":{:.3},"dt_ms":{:.3},"event_rate":{:.3},"regime":"{}","action":"{}","pending_w":{},"pending_h":{},"applied_w":{},"applied_h":{},"time_since_render_ms":{:.3},"coalesce_ms":{},"forced":{},"transition_reason_code":{},"transition_confidence":{}}}"#,
557 self.event_idx,
558 self.elapsed_ms,
559 self.dt_ms,
560 self.event_rate,
561 self.regime.as_str(),
562 self.action,
563 pending_w,
564 pending_h,
565 applied_w,
566 applied_h,
567 self.time_since_render_ms,
568 coalesce_ms,
569 self.forced,
570 transition_reason_code,
571 transition_confidence
572 )
573 }
574}
575
576#[derive(Debug, Clone, Copy)]
577struct PendingTransitionEvidence {
578 reason_code: TransitionReasonCode,
579 confidence: f64,
580}
581
582#[derive(Debug, Clone)]
584pub struct RegimeTransitionLog {
585 pub timestamp: Instant,
587 pub event_idx: u64,
589 pub from_regime: Regime,
591 pub to_regime: Regime,
593 pub reason_code: TransitionReasonCode,
595 pub confidence: f64,
597 pub event_rate: f64,
599 pub p_burst: Option<f64>,
601 pub cooldown_remaining: u32,
603}
604
605impl RegimeTransitionLog {
606 #[must_use]
608 pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
609 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
610 let p_burst = self
611 .p_burst
612 .map(|value| format!("{value:.6}"))
613 .unwrap_or_else(|| "null".to_string());
614 format!(
615 r#"{{{prefix},"event":"regime_transition","from_regime":"{}","to_regime":"{}","reason_code":"{}","confidence":{:.6},"event_rate":{:.3},"p_burst":{},"cooldown_remaining":{}}}"#,
616 self.from_regime.as_str(),
617 self.to_regime.as_str(),
618 self.reason_code.as_str(),
619 self.confidence,
620 self.event_rate,
621 p_burst,
622 self.cooldown_remaining,
623 )
624 }
625}
626
627#[derive(Debug)]
631pub struct ResizeCoalescer {
632 config: CoalescerConfig,
633
634 pending_size: Option<(u16, u16)>,
636
637 last_applied: (u16, u16),
639
640 window_start: Option<Instant>,
642
643 last_event: Option<Instant>,
645
646 last_render: Instant,
648
649 regime: Regime,
651
652 cooldown_remaining: u32,
654
655 event_times: VecDeque<Instant>,
657
658 event_count: u64,
660
661 log_start: Option<Instant>,
663
664 logs: Vec<DecisionLog>,
666 transition_logs: Vec<RegimeTransitionLog>,
668 pending_transition_evidence: Option<PendingTransitionEvidence>,
670 evidence_sink: Option<EvidenceSink>,
672 config_logged: bool,
674 evidence_run_id: String,
676 evidence_screen_mode: ScreenMode,
678
679 telemetry_hooks: Option<TelemetryHooks>,
682
683 regime_transitions: u64,
685
686 events_in_window: u64,
688
689 cycle_times: Vec<f64>,
691
692 bocpd: Option<BocpdDetector>,
694}
695
696#[derive(Debug, Clone, Copy)]
698pub struct CycleTimePercentiles {
699 pub p50_ms: f64,
701 pub p95_ms: f64,
703 pub p99_ms: f64,
705 pub count: usize,
707 pub mean_ms: f64,
709}
710
711impl CycleTimePercentiles {
712 #[must_use]
714 pub fn to_jsonl(&self) -> String {
715 format!(
716 r#"{{"event":"cycle_time_percentiles","p50_ms":{:.3},"p95_ms":{:.3},"p99_ms":{:.3},"mean_ms":{:.3},"count":{}}}"#,
717 self.p50_ms, self.p95_ms, self.p99_ms, self.mean_ms, self.count
718 )
719 }
720}
721
722impl ResizeCoalescer {
723 pub fn new(config: CoalescerConfig, initial_size: (u16, u16)) -> Self {
725 let bocpd = if config.enable_bocpd {
726 let mut bocpd_cfg = config.bocpd_config.clone().unwrap_or_default();
727 if config.enable_logging {
728 bocpd_cfg.enable_logging = true;
729 }
730 Some(BocpdDetector::new(bocpd_cfg))
731 } else {
732 None
733 };
734
735 Self {
736 config,
737 pending_size: None,
738 last_applied: initial_size,
739 window_start: None,
740 last_event: None,
741 last_render: Instant::now(),
742 regime: Regime::Steady,
743 cooldown_remaining: 0,
744 event_times: VecDeque::new(),
745 event_count: 0,
746 log_start: None,
747 logs: Vec::new(),
748 transition_logs: Vec::new(),
749 pending_transition_evidence: None,
750 evidence_sink: None,
751 config_logged: false,
752 evidence_run_id: default_resize_run_id(),
753 evidence_screen_mode: ScreenMode::AltScreen,
754 telemetry_hooks: None,
755 regime_transitions: 0,
756 events_in_window: 0,
757 cycle_times: Vec::new(),
758 bocpd,
759 }
760 }
761
762 #[must_use]
764 pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
765 self.telemetry_hooks = Some(hooks);
766 self
767 }
768
769 #[must_use]
771 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
772 self.evidence_sink = Some(sink);
773 self.config_logged = false;
774 self
775 }
776
777 #[must_use]
779 pub fn with_evidence_run_id(mut self, run_id: impl Into<String>) -> Self {
780 self.evidence_run_id = run_id.into();
781 self
782 }
783
784 #[must_use]
786 pub fn with_screen_mode(mut self, screen_mode: ScreenMode) -> Self {
787 self.evidence_screen_mode = screen_mode;
788 self
789 }
790
791 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
793 self.evidence_sink = sink;
794 self.config_logged = false;
795 }
796
797 #[must_use]
799 pub fn with_last_render(mut self, time: Instant) -> Self {
800 self.last_render = time;
801 self
802 }
803
804 pub fn record_external_apply(&mut self, width: u16, height: u16, now: Instant) {
806 self.event_count += 1;
807 self.event_times.push_back(now);
808 while self.event_times.len() > self.config.rate_window_size {
809 self.event_times.pop_front();
810 }
811 self.update_regime(now);
812
813 self.pending_size = None;
814 self.window_start = None;
815 self.last_event = Some(now);
816 self.last_applied = (width, height);
817 self.last_render = now;
818 self.events_in_window = 0;
819 self.cooldown_remaining = 0;
820
821 self.log_decision(now, "apply_immediate", false, Some(0.0), Some(0.0));
822
823 if let Some(ref hooks) = self.telemetry_hooks
824 && let Some(entry) = self.logs.last()
825 {
826 hooks.fire_resize_applied(entry);
827 }
828 }
829
830 #[must_use]
832 pub fn regime_transition_count(&self) -> u64 {
833 self.regime_transitions
834 }
835
836 #[must_use]
839 pub fn cycle_time_percentiles(&self) -> Option<CycleTimePercentiles> {
840 if self.cycle_times.is_empty() {
841 return None;
842 }
843
844 let mut sorted = self.cycle_times.clone();
845 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
846
847 let len = sorted.len();
848 let p50_idx = len / 2;
849 let p95_idx = (len * 95) / 100;
850 let p99_idx = (len * 99) / 100;
851
852 Some(CycleTimePercentiles {
853 p50_ms: sorted[p50_idx],
854 p95_ms: sorted[p95_idx.min(len - 1)],
855 p99_ms: sorted[p99_idx.min(len - 1)],
856 count: len,
857 mean_ms: sorted.iter().sum::<f64>() / len as f64,
858 })
859 }
860
861 pub fn handle_resize(&mut self, width: u16, height: u16) -> CoalesceAction {
865 self.handle_resize_at(width, height, Instant::now())
866 }
867
868 pub fn handle_resize_at(&mut self, width: u16, height: u16, now: Instant) -> CoalesceAction {
870 self.event_count += 1;
871
872 let dt = self.last_event.map(|t| duration_since_or_zero(now, t));
874 let dt_ms = dt.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0);
875
876 if dt_ms > 1000.0 {
879 self.event_times.clear();
880 }
881
882 self.event_times.push_back(now);
883 while self.event_times.len() > self.config.rate_window_size {
884 self.event_times.pop_front();
885 }
886
887 self.update_regime(now);
889
890 self.last_event = Some(now);
891
892 if self.pending_size.is_none() && (width, height) == self.last_applied {
894 self.log_decision(now, "skip_same_size", false, Some(dt_ms), None);
895 return CoalesceAction::None;
896 }
897
898 let had_pending_before_event = self.pending_size.is_some();
902
903 self.pending_size = Some((width, height));
905
906 self.events_in_window += 1;
908
909 if self.window_start.is_none() {
911 self.window_start = Some(now);
912 }
913
914 let time_since_render = duration_since_or_zero(now, self.last_render);
919 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
920 return self.apply_pending_at(now, had_pending_before_event);
921 }
922
923 let time_ok = match dt {
926 Some(d) => d >= Duration::from_millis(self.current_delay_ms()),
927 None => false, };
929
930 if time_ok && (self.bocpd.is_some() || self.regime == Regime::Steady) {
931 return self.apply_pending_at(now, false);
932 }
933
934 self.log_decision(now, "coalesce", false, Some(dt_ms), None);
935
936 if let Some(ref hooks) = self.telemetry_hooks
938 && let Some(entry) = self.logs.last()
939 {
940 hooks.fire_decision(entry);
941 }
942
943 CoalesceAction::ShowPlaceholder
944 }
945
946 pub fn tick(&mut self) -> CoalesceAction {
950 self.tick_at(Instant::now())
951 }
952
953 pub fn tick_at(&mut self, now: Instant) -> CoalesceAction {
955 if self.regime == Regime::Burst && self.bocpd.is_none() {
962 let rate = self.calculate_event_rate(now);
963 if rate >= self.config.burst_exit_rate {
964 self.cooldown_remaining = self.config.cooldown_frames.max(1);
965 } else if self.cooldown_remaining > 0 {
966 self.cooldown_remaining -= 1;
967 if self.cooldown_remaining == 0 {
968 self.record_regime_transition(
969 now,
970 Regime::Steady,
971 TransitionReasonCode::HeuristicExitBurstCooldown,
972 (1.0 - (rate / self.config.burst_exit_rate)).clamp(0.0, 1.0),
973 rate,
974 None,
975 );
976 }
977 }
978 }
979
980 if self.pending_size.is_none() {
981 return CoalesceAction::None;
982 }
983
984 if self.window_start.is_none() {
985 return CoalesceAction::None;
986 }
987
988 let time_since_render = duration_since_or_zero(now, self.last_render);
990 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
991 return self.apply_pending_at(now, true);
992 }
993
994 let delay_ms = self.current_delay_ms();
995
996 if let Some(last_event) = self.last_event {
998 let since_last_event = duration_since_or_zero(now, last_event);
999 if since_last_event >= Duration::from_millis(delay_ms) {
1000 return self.apply_pending_at(now, false);
1001 }
1002 }
1003
1004 CoalesceAction::None
1005 }
1006
1007 pub fn time_until_apply(&self, now: Instant) -> Option<Duration> {
1009 let _pending = self.pending_size?;
1010
1011 let time_since_render = duration_since_or_zero(now, self.last_render);
1013 let hard_deadline = Duration::from_millis(self.config.hard_deadline_ms);
1014 let hard_deadline_remaining = hard_deadline.saturating_sub(time_since_render);
1015
1016 let delay_remaining = if let Some(last_event) = self.last_event {
1018 let since_last_event = duration_since_or_zero(now, last_event);
1019 let delay = Duration::from_millis(self.current_delay_ms());
1020 delay.saturating_sub(since_last_event)
1021 } else {
1022 Duration::ZERO
1023 };
1024
1025 Some(hard_deadline_remaining.min(delay_remaining))
1030 }
1031
1032 #[inline]
1034 pub fn has_pending(&self) -> bool {
1035 self.pending_size.is_some()
1036 }
1037
1038 #[inline]
1040 pub fn regime(&self) -> Regime {
1041 self.regime
1042 }
1043
1044 #[inline]
1046 pub fn bocpd_enabled(&self) -> bool {
1047 self.bocpd.is_some()
1048 }
1049
1050 #[inline]
1054 pub fn bocpd(&self) -> Option<&BocpdDetector> {
1055 self.bocpd.as_ref()
1056 }
1057
1058 #[inline]
1063 pub fn bocpd_p_burst(&self) -> Option<f64> {
1064 self.bocpd.as_ref().map(|b| b.p_burst())
1065 }
1066
1067 #[inline]
1072 pub fn bocpd_recommended_delay(&self) -> Option<u64> {
1073 self.bocpd
1074 .as_ref()
1075 .map(|b| b.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms))
1076 }
1077
1078 pub fn event_rate(&self) -> f64 {
1080 self.calculate_event_rate(Instant::now())
1081 }
1082
1083 #[inline]
1085 pub fn last_applied(&self) -> (u16, u16) {
1086 self.last_applied
1087 }
1088
1089 pub fn logs(&self) -> &[DecisionLog] {
1091 &self.logs
1092 }
1093
1094 pub fn transition_logs(&self) -> &[RegimeTransitionLog] {
1096 &self.transition_logs
1097 }
1098
1099 fn record_cycle_time(&mut self, coalesce_ms: f64) {
1105 self.cycle_times.push(coalesce_ms);
1106 let len = self.cycle_times.len();
1107 if len > MAX_CYCLE_TIME_SAMPLES {
1108 self.cycle_times.drain(..len - MAX_CYCLE_TIME_SAMPLES);
1109 }
1110 }
1111
1112 fn push_transition_log(&mut self, log: RegimeTransitionLog) {
1116 self.transition_logs.push(log);
1117 let len = self.transition_logs.len();
1118 if len > MAX_TRANSITION_LOGS {
1119 self.transition_logs.drain(..len - MAX_TRANSITION_LOGS);
1120 }
1121 }
1122
1123 pub fn clear_logs(&mut self) {
1125 self.logs.clear();
1126 self.transition_logs.clear();
1127 self.pending_transition_evidence = None;
1128 self.log_start = None;
1129 self.config_logged = false;
1130 }
1131
1132 pub fn stats(&self) -> CoalescerStats {
1134 CoalescerStats {
1135 event_count: self.event_count,
1136 regime: self.regime,
1137 event_rate: self.event_rate(),
1138 has_pending: self.pending_size.is_some(),
1139 last_applied: self.last_applied,
1140 }
1141 }
1142
1143 #[must_use]
1145 pub fn decision_logs_jsonl(&self) -> String {
1146 let (cols, rows) = self.last_applied;
1147 let run_id = self.evidence_run_id.as_str();
1148 let screen_mode = self.evidence_screen_mode;
1149 self.logs
1150 .iter()
1151 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows))
1152 .collect::<Vec<_>>()
1153 .join("\n")
1154 }
1155
1156 #[must_use]
1158 pub fn decision_checksum(&self) -> u64 {
1159 let mut hash = FNV_OFFSET_BASIS;
1160 for entry in &self.logs {
1161 fnv_hash_bytes(&mut hash, &entry.event_idx.to_le_bytes());
1162 fnv_hash_bytes(&mut hash, &entry.elapsed_ms.to_bits().to_le_bytes());
1163 fnv_hash_bytes(&mut hash, &entry.dt_ms.to_bits().to_le_bytes());
1164 fnv_hash_bytes(&mut hash, &entry.event_rate.to_bits().to_le_bytes());
1165 fnv_hash_bytes(
1166 &mut hash,
1167 &[match entry.regime {
1168 Regime::Steady => 0u8,
1169 Regime::Burst => 1u8,
1170 }],
1171 );
1172 fnv_hash_bytes(&mut hash, entry.action.as_bytes());
1173 fnv_hash_bytes(&mut hash, &[0u8]); fnv_hash_bytes(&mut hash, &[entry.pending_size.is_some() as u8]);
1176 if let Some((w, h)) = entry.pending_size {
1177 fnv_hash_bytes(&mut hash, &w.to_le_bytes());
1178 fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1179 }
1180
1181 fnv_hash_bytes(&mut hash, &[entry.applied_size.is_some() as u8]);
1182 if let Some((w, h)) = entry.applied_size {
1183 fnv_hash_bytes(&mut hash, &w.to_le_bytes());
1184 fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1185 }
1186
1187 fnv_hash_bytes(
1188 &mut hash,
1189 &entry.time_since_render_ms.to_bits().to_le_bytes(),
1190 );
1191 fnv_hash_bytes(&mut hash, &[entry.coalesce_ms.is_some() as u8]);
1192 if let Some(ms) = entry.coalesce_ms {
1193 fnv_hash_bytes(&mut hash, &ms.to_bits().to_le_bytes());
1194 }
1195 fnv_hash_bytes(&mut hash, &[entry.forced as u8]);
1196 fnv_hash_bytes(&mut hash, &[entry.transition_reason_code.is_some() as u8]);
1197 if let Some(reason_code) = entry.transition_reason_code {
1198 fnv_hash_bytes(&mut hash, reason_code.as_str().as_bytes());
1199 }
1200 fnv_hash_bytes(&mut hash, &[entry.transition_confidence.is_some() as u8]);
1201 if let Some(confidence) = entry.transition_confidence {
1202 fnv_hash_bytes(&mut hash, &confidence.to_bits().to_le_bytes());
1203 }
1204 }
1205 hash
1206 }
1207
1208 #[must_use]
1210 pub fn decision_checksum_hex(&self) -> String {
1211 format!("{:016x}", self.decision_checksum())
1212 }
1213
1214 #[must_use]
1216 #[allow(clippy::field_reassign_with_default)]
1217 pub fn decision_summary(&self) -> DecisionSummary {
1218 let mut summary = DecisionSummary::default();
1219 summary.decision_count = self.logs.len();
1220 summary.last_applied = self.last_applied;
1221 summary.regime = self.regime;
1222
1223 for entry in &self.logs {
1224 match entry.action {
1225 "apply" | "apply_forced" | "apply_immediate" => {
1226 summary.apply_count += 1;
1227 if entry.forced {
1228 summary.forced_apply_count += 1;
1229 }
1230 }
1231 "coalesce" => summary.coalesce_count += 1,
1232 "skip_same_size" => summary.skip_count += 1,
1233 _ => {}
1234 }
1235 }
1236
1237 summary.checksum = self.decision_checksum();
1238 summary
1239 }
1240
1241 #[must_use]
1243 pub fn evidence_to_jsonl(&self) -> String {
1244 let mut lines = Vec::with_capacity(self.logs.len() + self.transition_logs.len() + 2);
1245 let (cols, rows) = self.last_applied;
1246 let run_id = self.evidence_run_id.as_str();
1247 let screen_mode = self.evidence_screen_mode;
1248 let summary_event_idx = self
1249 .logs
1250 .last()
1251 .map(|entry| entry.event_idx)
1252 .or_else(|| self.transition_logs.last().map(|entry| entry.event_idx))
1253 .unwrap_or(0);
1254 lines.push(self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1255 lines.extend(
1256 self.logs
1257 .iter()
1258 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1259 );
1260 lines.extend(
1261 self.transition_logs
1262 .iter()
1263 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1264 );
1265 lines.push(self.decision_summary().to_jsonl(
1266 run_id,
1267 screen_mode,
1268 cols,
1269 rows,
1270 summary_event_idx,
1271 ));
1272 lines.join("\n")
1273 }
1274
1275 fn apply_pending_at(&mut self, now: Instant, forced: bool) -> CoalesceAction {
1278 let Some((width, height)) = self.pending_size.take() else {
1279 return CoalesceAction::None;
1280 };
1281
1282 let coalesce_time = self
1283 .window_start
1284 .map(|s| duration_since_or_zero(now, s))
1285 .unwrap_or(Duration::ZERO);
1286 let coalesce_ms = coalesce_time.as_secs_f64() * 1000.0;
1287
1288 self.record_cycle_time(coalesce_ms);
1290
1291 self.window_start = None;
1292 self.last_applied = (width, height);
1293 self.last_render = now;
1294
1295 self.events_in_window = 0;
1297
1298 self.log_decision(
1299 now,
1300 if forced { "apply_forced" } else { "apply" },
1301 forced,
1302 None,
1303 Some(coalesce_ms),
1304 );
1305
1306 if let Some(ref hooks) = self.telemetry_hooks
1308 && let Some(entry) = self.logs.last()
1309 {
1310 hooks.fire_resize_applied(entry);
1311 }
1312
1313 CoalesceAction::ApplyResize {
1314 width,
1315 height,
1316 coalesce_time,
1317 forced_by_deadline: forced,
1318 }
1319 }
1320
1321 #[inline]
1322 fn current_delay_ms(&self) -> u64 {
1323 if let Some(ref bocpd) = self.bocpd {
1324 bocpd.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms)
1325 } else {
1326 match self.regime {
1327 Regime::Steady => self.config.steady_delay_ms,
1328 Regime::Burst => self.config.burst_delay_ms,
1329 }
1330 }
1331 }
1332
1333 fn update_regime(&mut self, now: Instant) {
1334 if self.bocpd.is_some() {
1336 let transition = {
1337 let mut pending = None;
1338 if let Some(bocpd) = self.bocpd.as_mut() {
1339 bocpd.observe_event(now);
1341
1342 let p_burst = bocpd.p_burst();
1343 let proposed = match bocpd.regime() {
1345 BocpdRegime::Steady => Regime::Steady,
1346 BocpdRegime::Burst => Regime::Burst,
1347 BocpdRegime::Transitional => {
1348 self.regime
1350 }
1351 };
1352 if proposed != self.regime {
1353 let (reason_code, confidence) = if proposed == Regime::Burst {
1354 (
1355 TransitionReasonCode::BocpdPosteriorBurst,
1356 p_burst.clamp(0.0, 1.0),
1357 )
1358 } else {
1359 (
1360 TransitionReasonCode::BocpdPosteriorSteady,
1361 (1.0 - p_burst).clamp(0.0, 1.0),
1362 )
1363 };
1364 pending = Some((proposed, reason_code, confidence, p_burst));
1365 }
1366 }
1367 pending
1368 };
1369
1370 if let Some((proposed, reason_code, confidence, p_burst)) = transition {
1371 let rate = self.calculate_event_rate(now);
1372 self.record_regime_transition(
1373 now,
1374 proposed,
1375 reason_code,
1376 confidence,
1377 rate,
1378 Some(p_burst),
1379 );
1380 }
1381 } else {
1382 let rate = self.calculate_event_rate(now);
1384
1385 match self.regime {
1386 Regime::Steady => {
1387 if rate >= self.config.burst_enter_rate {
1388 self.cooldown_remaining = self.config.cooldown_frames.max(1);
1389 let confidence = (rate / self.config.burst_enter_rate).clamp(0.0, 1.0);
1390 self.record_regime_transition(
1391 now,
1392 Regime::Burst,
1393 TransitionReasonCode::HeuristicEnterBurstRate,
1394 confidence,
1395 rate,
1396 None,
1397 );
1398 }
1399 }
1400 Regime::Burst => {
1401 if rate >= self.config.burst_exit_rate {
1402 self.cooldown_remaining = self.config.cooldown_frames.max(1);
1403 }
1404 }
1405 }
1406 }
1407 }
1408
1409 fn record_regime_transition(
1410 &mut self,
1411 now: Instant,
1412 to_regime: Regime,
1413 reason_code: TransitionReasonCode,
1414 confidence: f64,
1415 event_rate: f64,
1416 p_burst: Option<f64>,
1417 ) {
1418 let from_regime = self.regime;
1419 if from_regime == to_regime {
1420 return;
1421 }
1422 self.regime = to_regime;
1423 self.regime_transitions += 1;
1424 self.pending_transition_evidence = Some(PendingTransitionEvidence {
1425 reason_code,
1426 confidence,
1427 });
1428 self.push_transition_log(RegimeTransitionLog {
1429 timestamp: now,
1430 event_idx: self.event_count,
1431 from_regime,
1432 to_regime,
1433 reason_code,
1434 confidence,
1435 event_rate,
1436 p_burst,
1437 cooldown_remaining: self.cooldown_remaining,
1438 });
1439 if let Some(ref hooks) = self.telemetry_hooks {
1440 hooks.fire_regime_change(from_regime, to_regime);
1441 }
1442
1443 if let Some(ref sink) = self.evidence_sink {
1444 let (cols, rows) = self.last_applied;
1445 let run_id = self.evidence_run_id.as_str();
1446 let screen_mode = self.evidence_screen_mode;
1447 if !self.config_logged {
1448 let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1449 self.config_logged = true;
1450 }
1451 if let Some(entry) = self.transition_logs.last() {
1452 let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1453 }
1454 }
1455 }
1456
1457 fn calculate_event_rate(&self, now: Instant) -> f64 {
1458 if self.event_times.len() < 2 {
1459 return 0.0;
1460 }
1461
1462 let first = *self
1463 .event_times
1464 .front()
1465 .expect("event_times has >=2 elements per length guard");
1466 let window_duration = match now.checked_duration_since(first) {
1467 Some(duration) => duration,
1468 None => return 0.0,
1469 };
1470
1471 let duration_secs = window_duration.as_secs_f64().max(0.001);
1474
1475 ((self.event_times.len() - 1) as f64) / duration_secs
1477 }
1478
1479 fn log_decision(
1480 &mut self,
1481 now: Instant,
1482 action: &'static str,
1483 forced: bool,
1484 dt_ms_override: Option<f64>,
1485 coalesce_ms: Option<f64>,
1486 ) {
1487 if !self.config.enable_logging {
1488 return;
1489 }
1490
1491 if self.log_start.is_none() {
1492 self.log_start = Some(now);
1493 }
1494
1495 let elapsed_ms = self
1496 .log_start
1497 .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1498 .unwrap_or(0.0);
1499
1500 let dt_ms = dt_ms_override
1501 .or_else(|| {
1502 self.last_event
1503 .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1504 })
1505 .unwrap_or(0.0);
1506
1507 let time_since_render_ms =
1508 duration_since_or_zero(now, self.last_render).as_secs_f64() * 1000.0;
1509
1510 let applied_size =
1511 if action == "apply" || action == "apply_forced" || action == "apply_immediate" {
1512 Some(self.last_applied)
1513 } else {
1514 None
1515 };
1516 let (transition_reason_code, transition_confidence) =
1517 match self.pending_transition_evidence.take() {
1518 Some(ev) => (Some(ev.reason_code), Some(ev.confidence)),
1519 None => (None, None),
1520 };
1521
1522 self.logs.push(DecisionLog {
1523 timestamp: now,
1524 elapsed_ms,
1525 event_idx: self.event_count,
1526 dt_ms,
1527 event_rate: self.calculate_event_rate(now),
1528 regime: self.regime,
1529 action,
1530 pending_size: self.pending_size,
1531 applied_size,
1532 time_since_render_ms,
1533 coalesce_ms,
1534 forced,
1535 transition_reason_code,
1536 transition_confidence,
1537 });
1538
1539 if let Some(ref sink) = self.evidence_sink {
1540 let (cols, rows) = self.last_applied;
1541 let run_id = self.evidence_run_id.as_str();
1542 let screen_mode = self.evidence_screen_mode;
1543 if !self.config_logged {
1544 let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1545 self.config_logged = true;
1546 }
1547 if let Some(entry) = self.logs.last() {
1548 let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1549 }
1550 if let Some(ref bocpd) = self.bocpd
1551 && let Some(jsonl) = bocpd.decision_log_jsonl(
1552 self.config.steady_delay_ms,
1553 self.config.burst_delay_ms,
1554 forced,
1555 )
1556 {
1557 let _ = sink.write_jsonl(&jsonl);
1558 }
1559 }
1560 }
1561}
1562
1563#[derive(Debug, Clone)]
1565pub struct CoalescerStats {
1566 pub event_count: u64,
1568 pub regime: Regime,
1570 pub event_rate: f64,
1572 pub has_pending: bool,
1574 pub last_applied: (u16, u16),
1576}
1577
1578#[derive(Debug, Clone, Default)]
1580pub struct DecisionSummary {
1581 pub decision_count: usize,
1583 pub apply_count: usize,
1585 pub forced_apply_count: usize,
1587 pub coalesce_count: usize,
1589 pub skip_count: usize,
1591 pub regime: Regime,
1593 pub last_applied: (u16, u16),
1595 pub checksum: u64,
1597}
1598
1599impl DecisionSummary {
1600 #[must_use]
1602 pub fn checksum_hex(&self) -> String {
1603 format!("{:016x}", self.checksum)
1604 }
1605
1606 #[must_use]
1608 pub fn to_jsonl(
1609 &self,
1610 run_id: &str,
1611 screen_mode: ScreenMode,
1612 cols: u16,
1613 rows: u16,
1614 event_idx: u64,
1615 ) -> String {
1616 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
1617 format!(
1618 r#"{{{prefix},"event":"summary","decisions":{},"applies":{},"forced_applies":{},"coalesces":{},"skips":{},"regime":"{}","last_w":{},"last_h":{},"checksum":"{}"}}"#,
1619 self.decision_count,
1620 self.apply_count,
1621 self.forced_apply_count,
1622 self.coalesce_count,
1623 self.skip_count,
1624 self.regime.as_str(),
1625 self.last_applied.0,
1626 self.last_applied.1,
1627 self.checksum_hex()
1628 )
1629 }
1630}
1631
1632pub type OnResizeApplied = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1638pub type OnRegimeChange = Box<dyn Fn(Regime, Regime) + Send + Sync>;
1640pub type OnCoalesceDecision = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1642
1643pub struct TelemetryHooks {
1658 on_resize_applied: Option<OnResizeApplied>,
1660 on_regime_change: Option<OnRegimeChange>,
1662 on_decision: Option<OnCoalesceDecision>,
1664 emit_tracing: bool,
1666}
1667
1668impl Default for TelemetryHooks {
1669 fn default() -> Self {
1670 Self::new()
1671 }
1672}
1673
1674impl std::fmt::Debug for TelemetryHooks {
1675 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1676 f.debug_struct("TelemetryHooks")
1677 .field("on_resize_applied", &self.on_resize_applied.is_some())
1678 .field("on_regime_change", &self.on_regime_change.is_some())
1679 .field("on_decision", &self.on_decision.is_some())
1680 .field("emit_tracing", &self.emit_tracing)
1681 .finish()
1682 }
1683}
1684
1685impl TelemetryHooks {
1686 #[must_use]
1688 pub fn new() -> Self {
1689 Self {
1690 on_resize_applied: None,
1691 on_regime_change: None,
1692 on_decision: None,
1693 emit_tracing: false,
1694 }
1695 }
1696
1697 #[must_use]
1699 pub fn on_resize_applied<F>(mut self, callback: F) -> Self
1700 where
1701 F: Fn(&DecisionLog) + Send + Sync + 'static,
1702 {
1703 self.on_resize_applied = Some(Box::new(callback));
1704 self
1705 }
1706
1707 #[must_use]
1709 pub fn on_regime_change<F>(mut self, callback: F) -> Self
1710 where
1711 F: Fn(Regime, Regime) + Send + Sync + 'static,
1712 {
1713 self.on_regime_change = Some(Box::new(callback));
1714 self
1715 }
1716
1717 #[must_use]
1719 pub fn on_decision<F>(mut self, callback: F) -> Self
1720 where
1721 F: Fn(&DecisionLog) + Send + Sync + 'static,
1722 {
1723 self.on_decision = Some(Box::new(callback));
1724 self
1725 }
1726
1727 #[must_use]
1732 pub fn with_tracing(mut self, enabled: bool) -> Self {
1733 self.emit_tracing = enabled;
1734 self
1735 }
1736
1737 pub fn has_resize_applied(&self) -> bool {
1739 self.on_resize_applied.is_some()
1740 }
1741
1742 pub fn has_regime_change(&self) -> bool {
1744 self.on_regime_change.is_some()
1745 }
1746
1747 pub fn has_decision(&self) -> bool {
1749 self.on_decision.is_some()
1750 }
1751
1752 fn fire_resize_applied(&self, entry: &DecisionLog) {
1754 if let Some(ref cb) = self.on_resize_applied {
1755 cb(entry);
1756 }
1757 if self.emit_tracing {
1758 Self::emit_resize_tracing(entry);
1759 }
1760 }
1761
1762 fn fire_regime_change(&self, from: Regime, to: Regime) {
1764 if let Some(ref cb) = self.on_regime_change {
1765 cb(from, to);
1766 }
1767 if self.emit_tracing {
1768 tracing::debug!(
1769 target: "ftui.decision.resize",
1770 from_regime = %from.as_str(),
1771 to_regime = %to.as_str(),
1772 "regime_change"
1773 );
1774 }
1775 }
1776
1777 fn fire_decision(&self, entry: &DecisionLog) {
1779 if let Some(ref cb) = self.on_decision {
1780 cb(entry);
1781 }
1782 }
1783
1784 fn emit_resize_tracing(entry: &DecisionLog) {
1786 let (pending_w, pending_h) = entry.pending_size.unwrap_or((0, 0));
1787 let (applied_w, applied_h) = entry.applied_size.unwrap_or((0, 0));
1788 let coalesce_ms = entry.coalesce_ms.unwrap_or(0.0);
1789
1790 tracing::info!(
1791 target: "ftui.decision.resize",
1792 event_idx = entry.event_idx,
1793 elapsed_ms = entry.elapsed_ms,
1794 dt_ms = entry.dt_ms,
1795 event_rate = entry.event_rate,
1796 regime = %entry.regime.as_str(),
1797 action = entry.action,
1798 pending_w = pending_w,
1799 pending_h = pending_h,
1800 applied_w = applied_w,
1801 applied_h = applied_h,
1802 time_since_render_ms = entry.time_since_render_ms,
1803 coalesce_ms = coalesce_ms,
1804 forced = entry.forced,
1805 "resize_decision"
1806 );
1807 }
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::*;
1813
1814 #[test]
1815 fn retained_diagnostics_are_bounded() {
1816 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1817 for i in 0..(MAX_CYCLE_TIME_SAMPLES + 500) {
1818 c.record_cycle_time(i as f64);
1819 }
1820 assert_eq!(c.cycle_times.len(), MAX_CYCLE_TIME_SAMPLES);
1821 assert_eq!(c.cycle_times[0], 500.0);
1823 assert!(c.cycle_time_percentiles().is_some());
1825
1826 let now = Instant::now();
1827 for i in 0..(MAX_TRANSITION_LOGS + 100) {
1828 c.push_transition_log(RegimeTransitionLog {
1829 timestamp: now,
1830 event_idx: i as u64,
1831 from_regime: Regime::Steady,
1832 to_regime: Regime::Burst,
1833 reason_code: TransitionReasonCode::HeuristicEnterBurstRate,
1834 confidence: 1.0,
1835 event_rate: 0.0,
1836 p_burst: None,
1837 cooldown_remaining: 0,
1838 });
1839 }
1840 assert_eq!(c.transition_logs.len(), MAX_TRANSITION_LOGS);
1841 assert_eq!(c.transition_logs[0].event_idx, 100);
1842 }
1843
1844 fn test_config() -> CoalescerConfig {
1845 CoalescerConfig {
1846 steady_delay_ms: 16,
1847 burst_delay_ms: 40,
1848 hard_deadline_ms: 100,
1849 burst_enter_rate: 10.0,
1850 burst_exit_rate: 5.0,
1851 cooldown_frames: 3,
1852 rate_window_size: 8,
1853 enable_logging: true,
1854 enable_bocpd: false,
1855 bocpd_config: None,
1856 }
1857 }
1858
1859 #[derive(Debug, Clone, Copy)]
1860 struct SimulationMetrics {
1861 event_count: u64,
1862 apply_count: u64,
1863 forced_count: u64,
1864 mean_coalesce_ms: f64,
1865 max_coalesce_ms: f64,
1866 decision_checksum: u64,
1867 final_regime: Regime,
1868 }
1869
1870 impl SimulationMetrics {
1871 fn to_jsonl(self, pattern: &str, mode: &str) -> String {
1872 let pattern = json_escape(pattern);
1873 let mode = json_escape(mode);
1874 let apply_ratio = if self.event_count == 0 {
1875 0.0
1876 } else {
1877 self.apply_count as f64 / self.event_count as f64
1878 };
1879
1880 format!(
1881 r#"{{"event":"simulation_summary","pattern":"{pattern}","mode":"{mode}","events":{},"applies":{},"forced":{},"apply_ratio":{:.4},"mean_coalesce_ms":{:.3},"max_coalesce_ms":{:.3},"final_regime":"{}","checksum":"{:016x}"}}"#,
1882 self.event_count,
1883 self.apply_count,
1884 self.forced_count,
1885 apply_ratio,
1886 self.mean_coalesce_ms,
1887 self.max_coalesce_ms,
1888 self.final_regime.as_str(),
1889 self.decision_checksum
1890 )
1891 }
1892 }
1893
1894 #[derive(Debug, Clone, Copy)]
1895 struct SimulationComparison {
1896 apply_delta: i64,
1897 mean_coalesce_delta_ms: f64,
1898 }
1899
1900 impl SimulationComparison {
1901 fn from_metrics(heuristic: SimulationMetrics, bocpd: SimulationMetrics) -> Self {
1902 let heuristic_apply = i64::try_from(heuristic.apply_count).unwrap_or(i64::MAX);
1903 let bocpd_apply = i64::try_from(bocpd.apply_count).unwrap_or(i64::MAX);
1904 let apply_delta = heuristic_apply.saturating_sub(bocpd_apply);
1905 let mean_coalesce_delta_ms = heuristic.mean_coalesce_ms - bocpd.mean_coalesce_ms;
1906 Self {
1907 apply_delta,
1908 mean_coalesce_delta_ms,
1909 }
1910 }
1911
1912 fn to_jsonl(self, pattern: &str) -> String {
1913 let pattern = json_escape(pattern);
1914 format!(
1915 r#"{{"event":"simulation_compare","pattern":"{pattern}","apply_delta":{},"mean_coalesce_delta_ms":{:.3}}}"#,
1916 self.apply_delta, self.mean_coalesce_delta_ms
1917 )
1918 }
1919 }
1920
1921 fn as_u64(value: usize) -> u64 {
1922 u64::try_from(value).unwrap_or(u64::MAX)
1923 }
1924
1925 fn build_schedule(base: Instant, events: &[(u16, u16, u64)]) -> Vec<(Instant, u16, u16)> {
1926 let mut schedule = Vec::with_capacity(events.len());
1927 let mut elapsed_ms = 0u64;
1928 for (w, h, delay_ms) in events {
1929 elapsed_ms = elapsed_ms.saturating_add(*delay_ms);
1930 schedule.push((base + Duration::from_millis(elapsed_ms), *w, *h));
1931 }
1932 schedule
1933 }
1934
1935 fn run_simulation(
1936 events: &[(u16, u16, u64)],
1937 config: CoalescerConfig,
1938 tick_ms: u64,
1939 ) -> SimulationMetrics {
1940 let mut c = ResizeCoalescer::new(config, (80, 24));
1941 let base = Instant::now();
1942 let schedule = build_schedule(base, events);
1943 let last_event_ms = schedule
1944 .last()
1945 .map(|(time, _, _)| {
1946 u64::try_from(duration_since_or_zero(*time, base).as_millis()).unwrap_or(u64::MAX)
1947 })
1948 .unwrap_or(0);
1949 let end_ms = last_event_ms
1950 .saturating_add(c.config.hard_deadline_ms)
1951 .saturating_add(tick_ms);
1952
1953 let mut next_idx = 0usize;
1954 let mut now_ms = 0u64;
1955 while now_ms <= end_ms {
1956 let now = base + Duration::from_millis(now_ms);
1957
1958 while next_idx < schedule.len() && schedule[next_idx].0 <= now {
1959 let (event_time, w, h) = schedule[next_idx];
1960 let _ = c.handle_resize_at(w, h, event_time);
1961 next_idx += 1;
1962 }
1963
1964 let _ = c.tick_at(now);
1965 now_ms = now_ms.saturating_add(tick_ms);
1966 }
1967
1968 let mut coalesce_values = Vec::new();
1969 let mut apply_count = 0usize;
1970 let mut forced_count = 0usize;
1971 for entry in c.logs() {
1972 if matches!(entry.action, "apply" | "apply_forced" | "apply_immediate") {
1973 apply_count += 1;
1974 if entry.forced {
1975 forced_count += 1;
1976 }
1977 if let Some(ms) = entry.coalesce_ms {
1978 coalesce_values.push(ms);
1979 }
1980 }
1981 }
1982
1983 let max_coalesce_ms = coalesce_values
1984 .iter()
1985 .copied()
1986 .fold(0.0_f64, |acc, value| acc.max(value));
1987 let mean_coalesce_ms = if coalesce_values.is_empty() {
1988 0.0
1989 } else {
1990 let sum = coalesce_values.iter().sum::<f64>();
1991 sum / as_u64(coalesce_values.len()) as f64
1992 };
1993
1994 SimulationMetrics {
1995 event_count: as_u64(events.len()),
1996 apply_count: as_u64(apply_count),
1997 forced_count: as_u64(forced_count),
1998 mean_coalesce_ms,
1999 max_coalesce_ms,
2000 decision_checksum: c.decision_checksum(),
2001 final_regime: c.regime(),
2002 }
2003 }
2004
2005 fn steady_pattern() -> Vec<(u16, u16, u64)> {
2006 let mut events = Vec::new();
2007 for i in 0..8u16 {
2008 let width = 90 + i;
2009 let height = 30 + (i % 3);
2010 events.push((width, height, 300));
2011 }
2012 events
2013 }
2014
2015 fn burst_pattern() -> Vec<(u16, u16, u64)> {
2016 let mut events = Vec::new();
2017 for i in 0..30u16 {
2018 let width = 100 + i;
2019 let height = 25 + (i % 5);
2020 events.push((width, height, 10));
2021 }
2022 events
2023 }
2024
2025 fn oscillatory_pattern() -> Vec<(u16, u16, u64)> {
2026 let mut events = Vec::new();
2027 let sizes = [(120, 40), (140, 28), (130, 36), (150, 32)];
2028 let delays = [40u64, 200u64, 60u64, 180u64];
2029 for i in 0..16usize {
2030 let (w, h) = sizes[i % sizes.len()];
2031 let delay = delays[i % delays.len()];
2032 events.push((w + (i as u16 % 3), h, delay));
2033 }
2034 events
2035 }
2036
2037 #[test]
2038 fn new_coalescer_starts_in_steady() {
2039 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
2040 assert_eq!(c.regime(), Regime::Steady);
2041 assert!(!c.has_pending());
2042 }
2043
2044 #[test]
2045 fn same_size_returns_none() {
2046 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2047 let action = c.handle_resize(80, 24);
2048 assert_eq!(action, CoalesceAction::None);
2049 }
2050
2051 #[test]
2052 fn different_size_shows_placeholder() {
2053 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2054 let action = c.handle_resize(100, 40);
2055 assert_eq!(action, CoalesceAction::ShowPlaceholder);
2056 assert!(c.has_pending());
2057 }
2058
2059 #[test]
2060 fn latest_wins_semantics() {
2061 let config = test_config();
2062 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2063
2064 let base = Instant::now();
2065
2066 c.handle_resize_at(90, 30, base);
2068 c.handle_resize_at(100, 40, base + Duration::from_millis(5));
2069 c.handle_resize_at(110, 50, base + Duration::from_millis(10));
2070
2071 let action = c.tick_at(base + Duration::from_millis(60));
2073
2074 let (width, height) = if let CoalesceAction::ApplyResize { width, height, .. } = action {
2075 (width, height)
2076 } else {
2077 assert!(
2078 matches!(action, CoalesceAction::ApplyResize { .. }),
2079 "Expected ApplyResize, got {action:?}"
2080 );
2081 return;
2082 };
2083 assert_eq!((width, height), (110, 50), "Should apply latest size");
2084 }
2085
2086 #[test]
2087 fn hard_deadline_forces_apply() {
2088 let config = test_config();
2089 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2090
2091 let base = Instant::now();
2092
2093 c.handle_resize_at(100, 40, base);
2095
2096 let action = c.tick_at(base + Duration::from_millis(150));
2098
2099 let forced_by_deadline = if let CoalesceAction::ApplyResize {
2100 forced_by_deadline, ..
2101 } = action
2102 {
2103 forced_by_deadline
2104 } else {
2105 assert!(
2106 matches!(action, CoalesceAction::ApplyResize { .. }),
2107 "Expected ApplyResize, got {action:?}"
2108 );
2109 return;
2110 };
2111 assert!(forced_by_deadline, "Should be forced by deadline");
2112 }
2113
2114 #[test]
2118 fn isolated_resize_after_quiet_gap_is_not_forced() {
2119 let config = test_config();
2120 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2121
2122 let base = Instant::now();
2123 let action = c.handle_resize_at(120, 40, base + Duration::from_millis(500));
2124
2125 match action {
2126 CoalesceAction::ApplyResize {
2127 forced_by_deadline, ..
2128 } => {
2129 assert!(
2130 !forced_by_deadline,
2131 "quiet-gap instant apply must not count as forced"
2132 );
2133 }
2134 other => panic!("Expected ApplyResize, got {other:?}"),
2135 }
2136 }
2137
2138 #[test]
2142 fn resize_arriving_while_pending_past_deadline_stays_forced() {
2143 let config = test_config();
2144 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2145
2146 let base = Instant::now();
2147 c.handle_resize_at(90, 30, base);
2149
2150 let action = c.handle_resize_at(120, 40, base + Duration::from_millis(500));
2153
2154 match action {
2155 CoalesceAction::ApplyResize {
2156 forced_by_deadline, ..
2157 } => {
2158 assert!(
2159 forced_by_deadline,
2160 "deadline breach of WAITING work must stay forced"
2161 );
2162 }
2163 other => panic!("Expected ApplyResize, got {other:?}"),
2164 }
2165 }
2166
2167 #[test]
2168 fn burst_mode_detection() {
2169 let config = test_config();
2170 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2171
2172 let base = Instant::now();
2173
2174 for i in 0..15 {
2176 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2177 }
2178
2179 assert_eq!(c.regime(), Regime::Burst);
2180 }
2181
2182 #[test]
2183 fn steady_mode_fast_response() {
2184 let config = test_config();
2185 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2186
2187 let base = Instant::now();
2188
2189 c.handle_resize_at(100, 40, base);
2191
2192 let action = c.tick_at(base + Duration::from_millis(20));
2194
2195 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
2196 }
2197
2198 #[test]
2199 fn record_external_apply_updates_state_and_logs() {
2200 let config = test_config();
2201 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2202
2203 let base = Instant::now();
2204 c.handle_resize_at(100, 40, base);
2205 c.record_external_apply(120, 50, base + Duration::from_millis(5));
2206
2207 assert!(!c.has_pending());
2208 assert_eq!(c.last_applied(), (120, 50));
2209
2210 let summary = c.decision_summary();
2211 assert_eq!(summary.apply_count, 1);
2212 assert_eq!(summary.last_applied, (120, 50));
2213 assert!(
2214 c.logs()
2215 .iter()
2216 .any(|entry| entry.action == "apply_immediate"),
2217 "record_external_apply should emit apply_immediate decision"
2218 );
2219 }
2220
2221 #[test]
2222 fn coalesce_time_tracked() {
2223 let config = test_config();
2224 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2225
2226 let base = Instant::now();
2227
2228 c.handle_resize_at(100, 40, base);
2229 let action = c.tick_at(base + Duration::from_millis(50));
2230
2231 let coalesce_time = if let CoalesceAction::ApplyResize { coalesce_time, .. } = action {
2232 coalesce_time
2233 } else {
2234 assert!(
2235 matches!(action, CoalesceAction::ApplyResize { .. }),
2236 "Expected ApplyResize"
2237 );
2238 return;
2239 };
2240 assert!(coalesce_time >= Duration::from_millis(40));
2241 assert!(coalesce_time <= Duration::from_millis(60));
2242 }
2243
2244 #[test]
2245 fn event_rate_calculation() {
2246 let config = test_config();
2247 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2248
2249 let base = Instant::now();
2250
2251 for i in 0..10 {
2253 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 100));
2254 }
2255
2256 let rate = c.calculate_event_rate(base + Duration::from_millis(1000));
2257 assert!(rate > 8.0 && rate < 12.0, "Rate should be ~10 events/sec");
2258 }
2259
2260 #[test]
2261 fn rapid_burst_triggers_high_rate() {
2262 let config = test_config();
2263 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2264 let base = Instant::now();
2265
2266 for _ in 0..8 {
2268 c.handle_resize_at(80, 24, base);
2269 }
2270
2271 let rate = c.calculate_event_rate(base);
2272 assert!(
2274 rate >= 1000.0,
2275 "Rate should be high for instantaneous burst, got {}",
2276 rate
2277 );
2278 }
2279
2280 #[test]
2281 fn cooldown_prevents_immediate_exit() {
2282 let config = test_config();
2283 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2284
2285 let base = Instant::now();
2286
2287 for i in 0..15 {
2289 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2290 }
2291 assert_eq!(c.regime(), Regime::Burst);
2292
2293 c.tick_at(base + Duration::from_millis(500));
2295 c.tick_at(base + Duration::from_millis(600));
2296
2297 assert_eq!(c.regime(), Regime::Burst);
2299
2300 for i in 0..6 {
2303 c.tick_at(base + Duration::from_millis(1300 + 100 * i));
2304 }
2305
2306 assert_eq!(c.stats().regime, Regime::Steady);
2310 }
2311
2312 #[test]
2313 fn cooldown_frames_zero_cannot_dead_end_burst() {
2314 let mut c = ResizeCoalescer::new(
2320 CoalescerConfig {
2321 cooldown_frames: 0,
2322 ..Default::default()
2323 },
2324 (80, 24),
2325 );
2326 let base = Instant::now();
2327 for i in 0..10 {
2329 let _ =
2330 c.handle_resize_at(100 + i, 40, base + Duration::from_millis(10 * u64::from(i)));
2331 }
2332 assert_eq!(c.stats().regime, Regime::Burst);
2333 for i in 1..=20 {
2335 c.tick_at(base + Duration::from_millis(100 + 200 * i));
2336 }
2337 assert_eq!(
2338 c.stats().regime,
2339 Regime::Steady,
2340 "Burst must always have an exit path"
2341 );
2342 }
2343
2344 #[test]
2345 fn logging_captures_decisions() {
2346 let mut config = test_config();
2347 config.enable_logging = true;
2348 let mut c = ResizeCoalescer::new(config, (80, 24));
2349
2350 let base = Instant::now();
2351 c.handle_resize_at(100, 40, base);
2352 c.tick_at(base + Duration::from_millis(50));
2353
2354 assert!(!c.logs().is_empty());
2355 assert_eq!(c.logs()[0].action, "coalesce");
2356 }
2357
2358 #[test]
2359 fn logging_jsonl_format() {
2360 let mut config = test_config();
2361 config.enable_logging = true;
2362 let mut c = ResizeCoalescer::new(config, (80, 24));
2363
2364 c.handle_resize_at(100, 40, Instant::now());
2365 c.tick_at(Instant::now() + Duration::from_millis(50));
2366
2367 let (cols, rows) = c.last_applied();
2368 let jsonl = c.logs()[0].to_jsonl("resize-test", ScreenMode::AltScreen, cols, rows);
2369
2370 assert!(jsonl.contains("\"event\":\"decision\""));
2371 assert!(jsonl.contains("\"action\":\"coalesce\""));
2372 assert!(jsonl.contains("\"regime\":\"steady\""));
2373 assert!(jsonl.contains("\"pending_w\":100"));
2374 assert!(jsonl.contains("\"pending_h\":40"));
2375 }
2376
2377 #[test]
2378 fn apply_logs_coalesce_ms() {
2379 let mut config = test_config();
2380 config.enable_logging = true;
2381 let mut c = ResizeCoalescer::new(config, (80, 24));
2382
2383 let base = Instant::now();
2384 c.handle_resize_at(100, 40, base);
2385 let action = c.tick_at(base + Duration::from_millis(50));
2386 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
2387
2388 let last = c.logs().last().expect("Expected a decision log entry");
2389 assert!(last.coalesce_ms.is_some());
2390 assert!(last.coalesce_ms.unwrap() >= 0.0);
2391 }
2392
2393 #[test]
2394 fn decision_checksum_is_stable() {
2395 let mut config = test_config();
2396 config.enable_logging = true;
2397
2398 let base = Instant::now();
2399 let mut c1 = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
2400 let mut c2 = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
2401
2402 for c in [&mut c1, &mut c2] {
2403 c.handle_resize_at(90, 30, base);
2404 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2405 let _ = c.tick_at(base + Duration::from_millis(80));
2406 }
2407
2408 assert_eq!(c1.decision_checksum(), c2.decision_checksum());
2409 }
2410
2411 #[test]
2412 fn evidence_jsonl_includes_summary() {
2413 let mut config = test_config();
2414 config.enable_logging = true;
2415 let mut c = ResizeCoalescer::new(config, (80, 24));
2416
2417 c.handle_resize_at(100, 40, Instant::now());
2418 c.tick_at(Instant::now() + Duration::from_millis(50));
2419
2420 let jsonl = c.evidence_to_jsonl();
2421
2422 assert!(jsonl.contains("\"event\":\"config\""));
2423 assert!(jsonl.contains("\"event\":\"summary\""));
2424 }
2425
2426 #[test]
2427 fn evidence_jsonl_parses_and_has_required_fields() {
2428 use serde_json::Value;
2429
2430 let mut config = test_config();
2431 config.enable_logging = true;
2432 let base = Instant::now();
2433 let mut c = ResizeCoalescer::new(config, (80, 24))
2434 .with_last_render(base)
2435 .with_evidence_run_id("resize-test")
2436 .with_screen_mode(ScreenMode::AltScreen);
2437
2438 c.handle_resize_at(90, 30, base);
2439 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2440 let _ = c.tick_at(base + Duration::from_millis(120));
2441
2442 let jsonl = c.evidence_to_jsonl();
2443 let mut saw_config = false;
2444 let mut saw_decision = false;
2445 let mut saw_summary = false;
2446
2447 for line in jsonl.lines() {
2448 let value: Value = serde_json::from_str(line).expect("valid JSONL evidence");
2449 let event = value
2450 .get("event")
2451 .and_then(Value::as_str)
2452 .expect("event field");
2453 assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
2454 assert_eq!(value["run_id"], "resize-test");
2455 assert!(
2456 value["event_idx"].is_number(),
2457 "event_idx should be numeric"
2458 );
2459 assert_eq!(value["screen_mode"], "altscreen");
2460 assert!(value["cols"].is_number(), "cols should be numeric");
2461 assert!(value["rows"].is_number(), "rows should be numeric");
2462 match event {
2463 "config" => {
2464 for key in [
2465 "steady_delay_ms",
2466 "burst_delay_ms",
2467 "hard_deadline_ms",
2468 "burst_enter_rate",
2469 "burst_exit_rate",
2470 "cooldown_frames",
2471 "rate_window_size",
2472 "logging_enabled",
2473 ] {
2474 assert!(value.get(key).is_some(), "missing config field {key}");
2475 }
2476 saw_config = true;
2477 }
2478 "decision" => {
2479 for key in [
2480 "idx",
2481 "elapsed_ms",
2482 "dt_ms",
2483 "event_rate",
2484 "regime",
2485 "action",
2486 "pending_w",
2487 "pending_h",
2488 "applied_w",
2489 "applied_h",
2490 "time_since_render_ms",
2491 "coalesce_ms",
2492 "forced",
2493 "transition_reason_code",
2494 "transition_confidence",
2495 ] {
2496 assert!(value.get(key).is_some(), "missing decision field {key}");
2497 }
2498 saw_decision = true;
2499 }
2500 "summary" => {
2501 for key in [
2502 "decisions",
2503 "applies",
2504 "forced_applies",
2505 "coalesces",
2506 "skips",
2507 "regime",
2508 "last_w",
2509 "last_h",
2510 "checksum",
2511 ] {
2512 assert!(value.get(key).is_some(), "missing summary field {key}");
2513 }
2514 saw_summary = true;
2515 }
2516 _ => {}
2517 }
2518 }
2519
2520 assert!(saw_config, "config evidence missing");
2521 assert!(saw_decision, "decision evidence missing");
2522 assert!(saw_summary, "summary evidence missing");
2523 }
2524
2525 #[test]
2526 fn evidence_jsonl_is_deterministic_for_fixed_schedule() {
2527 let mut config = test_config();
2528 config.enable_logging = true;
2529 let base = Instant::now();
2530
2531 let run = || {
2532 let mut c = ResizeCoalescer::new(config.clone(), (80, 24))
2533 .with_last_render(base)
2534 .with_evidence_run_id("resize-test")
2535 .with_screen_mode(ScreenMode::AltScreen);
2536 c.handle_resize_at(90, 30, base);
2537 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2538 let _ = c.tick_at(base + Duration::from_millis(120));
2539 c.evidence_to_jsonl()
2540 };
2541
2542 let first = run();
2543 let second = run();
2544 assert_eq!(first, second);
2545 }
2546
2547 #[test]
2548 fn bocpd_logging_inherits_coalescer_logging() {
2549 let mut config = test_config();
2550 config.enable_bocpd = true;
2551 config.bocpd_config = Some(BocpdConfig::default());
2552
2553 let c = ResizeCoalescer::new(config, (80, 24));
2554 let bocpd = c.bocpd().expect("BOCPD should be enabled");
2555 assert!(bocpd.config().enable_logging);
2556 }
2557
2558 #[test]
2559 fn stats_reflect_state() {
2560 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2561 let base = Instant::now();
2562
2563 c.handle_resize_at(100, 40, base);
2564 let action = c.tick_at(base + Duration::from_millis(5));
2565 assert_eq!(action, CoalesceAction::None);
2566
2567 let stats = c.stats();
2568 assert_eq!(stats.event_count, 1);
2569 assert!(stats.has_pending);
2570 assert_eq!(stats.last_applied, (80, 24));
2571
2572 let action = c.tick_at(base + Duration::from_millis(50));
2573 assert_eq!(
2574 action,
2575 CoalesceAction::ApplyResize {
2576 width: 100,
2577 height: 40,
2578 coalesce_time: Duration::from_millis(50),
2579 forced_by_deadline: false,
2580 }
2581 );
2582
2583 let stats = c.stats();
2584 assert!(!stats.has_pending);
2585 assert_eq!(stats.last_applied, (100, 40));
2586 }
2587
2588 #[test]
2589 fn time_until_apply_calculation() {
2590 let config = test_config();
2591 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2592
2593 let base = Instant::now();
2594 c.handle_resize_at(100, 40, base);
2595
2596 let time_left = c.time_until_apply(base + Duration::from_millis(5));
2597 assert!(time_left.is_some());
2598 let time_left = time_left.unwrap();
2599 assert!(time_left.as_millis() > 0);
2600 assert!(time_left.as_millis() < config.steady_delay_ms as u128);
2601 }
2602
2603 #[test]
2604 fn deterministic_behavior() {
2605 let config = test_config();
2606
2607 let results: Vec<_> = (0..2)
2609 .map(|_| {
2610 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2611 let base = Instant::now();
2612
2613 for i in 0..5 {
2614 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 20));
2615 }
2616
2617 c.tick_at(base + Duration::from_millis(200))
2618 })
2619 .collect();
2620
2621 assert_eq!(results[0], results[1], "Results must be deterministic");
2622 }
2623
2624 #[test]
2625 fn transition_reason_codes_and_evidence_fields_are_logged() {
2626 let mut config = test_config();
2627 config.enable_logging = true;
2628 config.hard_deadline_ms = 5_000;
2629 config.burst_delay_ms = 50;
2630 let base = Instant::now();
2631 let mut c = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
2632
2633 for i in 0..12 {
2634 c.handle_resize_at(90 + i, 30, base + Duration::from_millis(i as u64 * 10));
2635 }
2636
2637 let transition = c
2638 .transition_logs()
2639 .first()
2640 .expect("rapid events should trigger a transition");
2641 assert_eq!(transition.from_regime, Regime::Steady);
2642 assert_eq!(transition.to_regime, Regime::Burst);
2643 assert_eq!(
2644 transition.reason_code,
2645 TransitionReasonCode::HeuristicEnterBurstRate
2646 );
2647 assert!(
2648 (0.0..=1.0).contains(&transition.confidence),
2649 "transition confidence should be normalized"
2650 );
2651 assert!(
2652 transition.event_rate >= 0.0,
2653 "event-rate evidence should be included"
2654 );
2655
2656 let decision_with_transition = c
2657 .logs()
2658 .iter()
2659 .find(|entry| entry.transition_reason_code.is_some())
2660 .expect("transition decisions should include reason code/evidence");
2661 assert_eq!(
2662 decision_with_transition.transition_reason_code,
2663 Some(TransitionReasonCode::HeuristicEnterBurstRate)
2664 );
2665 assert!(decision_with_transition.transition_confidence.is_some());
2666
2667 let jsonl = c.evidence_to_jsonl();
2668 assert!(jsonl.contains("\"event\":\"regime_transition\""));
2669 assert!(jsonl.contains("\"reason_code\":\"heuristic_enter_burst_rate\""));
2670 assert!(jsonl.contains("\"transition_reason_code\":"));
2671 }
2672
2673 #[test]
2674 fn regime_transition_sequence_is_deterministic_for_fixed_schedule() {
2675 let config = CoalescerConfig {
2676 burst_enter_rate: 5.0,
2677 burst_exit_rate: 2.0,
2678 cooldown_frames: 3,
2679 rate_window_size: 4,
2680 steady_delay_ms: 10,
2681 burst_delay_ms: 50,
2682 hard_deadline_ms: 5_000,
2683 enable_logging: true,
2684 enable_bocpd: false,
2685 bocpd_config: None,
2686 };
2687 let base = Instant::now();
2688
2689 let run = || {
2690 let mut c = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
2691
2692 for i in 0..8u64 {
2694 let t = base + Duration::from_millis(30 * i);
2695 c.handle_resize_at(80 + i as u16, 24 + i as u16, t);
2696 }
2697
2698 let mut t = base + Duration::from_millis(280);
2700 let _ = c.tick_at(t);
2701 for i in 0..5u64 {
2702 t += Duration::from_secs(1);
2703 c.handle_resize_at(100 + i as u16, 30 + i as u16, t);
2704 let _ = c.tick_at(t + Duration::from_millis(60));
2705 }
2706
2707 t += Duration::from_millis(70);
2709 c.handle_resize_at(120, 35, t);
2710 for step in 1..=config.cooldown_frames {
2711 let _ = c.tick_at(t + Duration::from_millis(step as u64 * 5));
2712 }
2713
2714 c.transition_logs()
2715 .iter()
2716 .map(|entry| {
2717 (
2718 entry.from_regime,
2719 entry.to_regime,
2720 entry.reason_code,
2721 entry.event_idx,
2722 entry.cooldown_remaining,
2723 )
2724 })
2725 .collect::<Vec<_>>()
2726 };
2727
2728 let first = run();
2729 let second = run();
2730 assert_eq!(first, second);
2731 assert!(
2732 first.iter().any(|(_, to, reason, _, _)| {
2733 *to == Regime::Burst && *reason == TransitionReasonCode::HeuristicEnterBurstRate
2734 }),
2735 "expected steady->burst transition with heuristic reason"
2736 );
2737 assert!(
2738 first.iter().any(|(_, to, reason, _, _)| {
2739 *to == Regime::Steady && *reason == TransitionReasonCode::HeuristicExitBurstCooldown
2740 }),
2741 "expected burst->steady transition with cooldown reason"
2742 );
2743 }
2744
2745 #[test]
2746 fn bounded_oscillation_and_converges_to_steady() {
2747 let config = CoalescerConfig {
2748 burst_enter_rate: 5.0,
2749 burst_exit_rate: 2.0,
2750 cooldown_frames: 3,
2751 rate_window_size: 4,
2752 steady_delay_ms: 10,
2753 burst_delay_ms: 50,
2754 hard_deadline_ms: 5_000,
2755 enable_logging: true,
2756 enable_bocpd: false,
2757 bocpd_config: None,
2758 };
2759 let base = Instant::now();
2760 let mut c = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
2761 let mut t = base;
2762
2763 for cycle in 0..30u64 {
2765 for pulse in 0..6u64 {
2766 t += Duration::from_millis(30);
2767 c.handle_resize_at(80 + ((cycle + pulse) % 40) as u16, 24 + pulse as u16, t);
2768 }
2769 t += Duration::from_millis(70);
2770 let _ = c.tick_at(t);
2771
2772 t += Duration::from_secs(1);
2773 c.handle_resize_at(120 + (cycle % 20) as u16, 30 + (cycle % 5) as u16, t);
2774 let _ = c.tick_at(t + Duration::from_millis(60));
2775 }
2776
2777 let transitions_before_convergence = c.regime_transition_count();
2778 assert!(
2779 transitions_before_convergence <= 4,
2780 "oscillation should stay bounded, transitions={}",
2781 transitions_before_convergence
2782 );
2783
2784 t += Duration::from_secs(1);
2786 c.handle_resize_at(160, 40, t);
2787 for step in 1..=config.cooldown_frames {
2788 let _ = c.tick_at(t + Duration::from_millis(step as u64 * 5));
2789 }
2790 assert_eq!(c.regime(), Regime::Steady);
2791 let last_transition = c
2792 .transition_logs()
2793 .last()
2794 .expect("expected at least one transition");
2795 assert_eq!(last_transition.to_regime, Regime::Steady);
2796 assert_eq!(
2797 last_transition.reason_code,
2798 TransitionReasonCode::HeuristicExitBurstCooldown
2799 );
2800 }
2801
2802 #[test]
2803 fn simulation_bocpd_vs_heuristic_metrics() {
2804 let tick_ms = 5;
2805 let mut heuristic_config = test_config();
2808 heuristic_config.burst_enter_rate = 60.0;
2809 heuristic_config.burst_exit_rate = 30.0;
2810 let mut bocpd_cfg = BocpdConfig::responsive();
2811 bocpd_cfg.burst_prior = 0.35;
2812 bocpd_cfg.steady_threshold = 0.2;
2813 bocpd_cfg.burst_threshold = 0.6;
2814 let bocpd_config = heuristic_config.clone().with_bocpd_config(bocpd_cfg);
2815 let patterns = vec![
2816 ("steady", steady_pattern()),
2817 ("burst", burst_pattern()),
2818 ("oscillatory", oscillatory_pattern()),
2819 ];
2820
2821 for (pattern, events) in patterns {
2822 let heuristic = run_simulation(&events, heuristic_config.clone(), tick_ms);
2823 let bocpd = run_simulation(&events, bocpd_config.clone(), tick_ms);
2824
2825 let heuristic_jsonl = heuristic.to_jsonl(pattern, "heuristic");
2826 let bocpd_jsonl = bocpd.to_jsonl(pattern, "bocpd");
2827 let comparison = SimulationComparison::from_metrics(heuristic, bocpd);
2828 let comparison_jsonl = comparison.to_jsonl(pattern);
2829
2830 eprintln!("{heuristic_jsonl}");
2831 eprintln!("{bocpd_jsonl}");
2832 eprintln!("{comparison_jsonl}");
2833
2834 assert!(heuristic_jsonl.contains("\"event\":\"simulation_summary\""));
2835 assert!(bocpd_jsonl.contains("\"event\":\"simulation_summary\""));
2836 assert!(comparison_jsonl.contains("\"event\":\"simulation_compare\""));
2837
2838 #[allow(clippy::cast_precision_loss)]
2839 let max_allowed = test_config().hard_deadline_ms as f64 + 1.0;
2840 assert!(
2841 heuristic.max_coalesce_ms <= max_allowed,
2842 "heuristic latency bounded for {pattern}"
2843 );
2844 assert!(
2845 bocpd.max_coalesce_ms <= max_allowed,
2846 "bocpd latency bounded for {pattern}"
2847 );
2848
2849 if pattern == "burst" {
2850 let event_count = as_u64(events.len());
2851 assert!(
2852 heuristic.apply_count < event_count,
2853 "heuristic should coalesce under burst pattern"
2854 );
2855 assert!(
2856 bocpd.apply_count < event_count,
2857 "bocpd should coalesce under burst pattern"
2858 );
2859 assert!(
2860 comparison.apply_delta >= 0,
2861 "BOCPD should not increase renders in burst (apply_delta={})",
2862 comparison.apply_delta
2863 );
2864 }
2865 }
2866 }
2867
2868 #[test]
2869 fn never_drops_final_size() {
2870 let config = test_config();
2871 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2872
2873 let base = Instant::now();
2874
2875 let mut intermediate_applies = Vec::new();
2877 for i in 0..100 {
2878 let action = c.handle_resize_at(
2879 80 + (i % 50),
2880 24 + (i % 30),
2881 base + Duration::from_millis(i as u64 * 5),
2882 );
2883 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2884 intermediate_applies.push((width, height));
2885 }
2886 }
2887
2888 let final_action = c.handle_resize_at(200, 100, base + Duration::from_millis(600));
2890
2891 let applied_size = if let CoalesceAction::ApplyResize { width, height, .. } = final_action {
2892 Some((width, height))
2893 } else {
2894 let mut result = None;
2896 for tick in 0..100 {
2897 let action = c.tick_at(base + Duration::from_millis(700 + tick * 20));
2898 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2899 result = Some((width, height));
2900 break;
2901 }
2902 }
2903 result
2904 };
2905
2906 assert_eq!(
2907 applied_size,
2908 Some((200, 100)),
2909 "Must apply final size 200x100"
2910 );
2911 }
2912
2913 #[test]
2914 fn bounded_latency_invariant() {
2915 let config = test_config();
2916 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2917
2918 let base = Instant::now();
2919 c.handle_resize_at(100, 40, base);
2920
2921 let mut applied_at = None;
2923 for ms in 0..200 {
2924 let now = base + Duration::from_millis(ms);
2925 let action = c.tick_at(now);
2926 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2927 applied_at = Some(ms);
2928 break;
2929 }
2930 }
2931
2932 assert!(applied_at.is_some(), "Must apply within reasonable time");
2933 assert!(
2934 applied_at.unwrap() <= config.hard_deadline_ms,
2935 "Must apply within hard deadline"
2936 );
2937 }
2938
2939 mod property {
2944 use super::*;
2945 use proptest::prelude::*;
2946
2947 fn dimension() -> impl Strategy<Value = u16> {
2949 1u16..500
2950 }
2951
2952 fn resize_sequence(max_len: usize) -> impl Strategy<Value = Vec<(u16, u16, u64)>> {
2954 proptest::collection::vec((dimension(), dimension(), 0u64..200), 0..max_len)
2955 }
2956
2957 proptest! {
2958 #[test]
2962 fn determinism_across_sequences(
2963 events in resize_sequence(50),
2964 tick_offset in 100u64..500
2965 ) {
2966 let config = CoalescerConfig::default();
2967
2968 let results: Vec<_> = (0..2)
2969 .map(|_| {
2970 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2971 let base = Instant::now();
2972
2973 for (i, (w, h, delay)) in events.iter().enumerate() {
2974 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2975 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2976 }
2977
2978 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2980 c.tick_at(base + Duration::from_millis(total_time))
2981 })
2982 .collect();
2983
2984 prop_assert_eq!(results[0], results[1], "Results must be deterministic");
2985 }
2986
2987 #[test]
2992 fn latest_wins_never_drops(
2993 events in resize_sequence(20),
2994 final_w in dimension(),
2995 final_h in dimension()
2996 ) {
2997 if events.is_empty() {
2998 return Ok(());
3000 }
3001
3002 let config = CoalescerConfig::default();
3003 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
3004 let base = Instant::now();
3005
3006 let mut offset = 0u64;
3008 for (w, h, delay) in &events {
3009 offset += delay;
3010 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
3011 }
3012
3013 offset += 50;
3015 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
3016
3017 let mut result = None;
3019 for tick in 0..200 {
3020 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
3021 if let CoalesceAction::ApplyResize { width, height, .. } = action {
3022 result = Some((width, height));
3023 break;
3024 }
3025 }
3026
3027 if let Some((applied_w, applied_h)) = result {
3029 prop_assert_eq!(
3030 (applied_w, applied_h),
3031 (final_w, final_h),
3032 "Must apply the final size {} x {}",
3033 final_w,
3034 final_h
3035 );
3036 }
3037 }
3038
3039 #[test]
3043 fn bounded_latency_maintained(
3044 w in dimension(),
3045 h in dimension()
3046 ) {
3047 let config = CoalescerConfig::default();
3048 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
3050 let base = Instant::now();
3051
3052 c.handle_resize_at(w, h, base);
3053
3054 let mut applied_at = None;
3056 for ms in 0..=config.hard_deadline_ms + 50 {
3057 let action = c.tick_at(base + Duration::from_millis(ms));
3058 if matches!(action, CoalesceAction::ApplyResize { .. }) {
3059 applied_at = Some(ms);
3060 break;
3061 }
3062 }
3063
3064 prop_assert!(applied_at.is_some(), "Resize must be applied");
3065 prop_assert!(
3066 applied_at.unwrap() <= config.hard_deadline_ms,
3067 "Must apply within hard deadline ({}ms), took {}ms",
3068 config.hard_deadline_ms,
3069 applied_at.unwrap()
3070 );
3071 }
3072
3073 #[test]
3078 fn no_size_corruption(
3079 w in dimension(),
3080 h in dimension()
3081 ) {
3082 let config = CoalescerConfig::default();
3083 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
3085 let base = Instant::now();
3086
3087 c.handle_resize_at(w, h, base);
3088
3089 let mut result = None;
3091 for ms in 0..200 {
3092 let action = c.tick_at(base + Duration::from_millis(ms));
3093 if let CoalesceAction::ApplyResize { width, height, .. } = action {
3094 result = Some((width, height));
3095 break;
3096 }
3097 }
3098
3099 prop_assert!(result.is_some());
3100 let (applied_w, applied_h) = result.unwrap();
3101 prop_assert_eq!(applied_w, w, "Width must not be corrupted");
3102 prop_assert_eq!(applied_h, h, "Height must not be corrupted");
3103 }
3104
3105 #[test]
3109 fn regime_follows_event_rate(
3110 event_count in 1usize..30
3111 ) {
3112 let config = CoalescerConfig {
3113 burst_enter_rate: 10.0,
3114 burst_exit_rate: 5.0,
3115 ..CoalescerConfig::default()
3116 };
3117 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
3118 let base = Instant::now();
3119
3120 for i in 0..event_count {
3122 c.handle_resize_at(
3123 80 + i as u16,
3124 24,
3125 base + Duration::from_millis(i as u64 * 50), );
3127 }
3128
3129 if event_count >= 10 {
3131 prop_assert_eq!(
3132 c.regime(),
3133 Regime::Burst,
3134 "Many rapid events should trigger burst mode"
3135 );
3136 }
3137 }
3138
3139 #[test]
3144 fn event_count_invariant(
3145 events in resize_sequence(100)
3146 ) {
3147 let config = CoalescerConfig::default();
3148 let mut c = ResizeCoalescer::new(config, (80, 24));
3149 let base = Instant::now();
3150
3151 for (w, h, delay) in &events {
3152 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
3153 }
3154
3155 let stats = c.stats();
3156 prop_assert_eq!(
3158 stats.event_count,
3159 events.len() as u64,
3160 "Event count should match total incoming events"
3161 );
3162 }
3163
3164 #[test]
3170 fn bocpd_determinism_across_sequences(
3171 events in resize_sequence(30),
3172 tick_offset in 100u64..400
3173 ) {
3174 let config = CoalescerConfig::default().with_bocpd();
3175
3176 let results: Vec<_> = (0..2)
3177 .map(|_| {
3178 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
3179 let base = Instant::now();
3180
3181 for (i, (w, h, delay)) in events.iter().enumerate() {
3182 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
3183 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
3184 }
3185
3186 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
3187 let action = c.tick_at(base + Duration::from_millis(total_time));
3188 (action, c.regime(), c.bocpd_p_burst())
3189 })
3190 .collect();
3191
3192 prop_assert_eq!(results[0], results[1], "BOCPD results must be deterministic");
3193 }
3194
3195 #[test]
3197 fn bocpd_latest_wins_never_drops(
3198 events in resize_sequence(15),
3199 final_w in dimension(),
3200 final_h in dimension()
3201 ) {
3202 if events.is_empty() {
3203 return Ok(());
3204 }
3205
3206 let config = CoalescerConfig::default().with_bocpd();
3207 let mut c = ResizeCoalescer::new(config, (80, 24));
3208 let base = Instant::now();
3209
3210 let mut offset = 0u64;
3211 for (w, h, delay) in &events {
3212 offset += delay;
3213 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
3214 }
3215
3216 offset += 50;
3217 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
3218
3219 let mut final_applied = None;
3220 for tick in 0..200 {
3221 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
3222 if let CoalesceAction::ApplyResize { width, height, .. } = action {
3223 final_applied = Some((width, height));
3224 }
3225 if !c.has_pending() && final_applied.is_some() {
3226 break;
3227 }
3228 }
3229
3230 if let Some((applied_w, applied_h)) = final_applied {
3231 prop_assert_eq!(
3232 (applied_w, applied_h),
3233 (final_w, final_h),
3234 "BOCPD must apply the final size"
3235 );
3236 }
3237 }
3238
3239 #[test]
3241 fn bocpd_bounded_latency_maintained(
3242 w in dimension(),
3243 h in dimension()
3244 ) {
3245 let config = CoalescerConfig::default().with_bocpd();
3246 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
3247 let base = Instant::now();
3248
3249 c.handle_resize_at(w, h, base);
3250
3251 let mut applied_at = None;
3252 for ms in 0..=config.hard_deadline_ms + 50 {
3253 let action = c.tick_at(base + Duration::from_millis(ms));
3254 if matches!(action, CoalesceAction::ApplyResize { .. }) {
3255 applied_at = Some(ms);
3256 break;
3257 }
3258 }
3259
3260 prop_assert!(applied_at.is_some(), "BOCPD resize must be applied");
3261 prop_assert!(
3262 applied_at.unwrap() <= config.hard_deadline_ms,
3263 "BOCPD must apply within hard deadline ({}ms), took {}ms",
3264 config.hard_deadline_ms,
3265 applied_at.unwrap()
3266 );
3267 }
3268
3269 #[test]
3271 fn bocpd_posterior_always_valid(
3272 events in resize_sequence(50)
3273 ) {
3274 if events.is_empty() {
3275 return Ok(());
3276 }
3277
3278 let config = CoalescerConfig::default().with_bocpd();
3279 let mut c = ResizeCoalescer::new(config, (80, 24));
3280 let base = Instant::now();
3281
3282 for (w, h, delay) in &events {
3283 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
3284
3285 if let Some(bocpd) = c.bocpd() {
3287 let sum: f64 = bocpd.run_length_posterior().iter().sum();
3288 prop_assert!(
3289 (sum - 1.0).abs() < 1e-8,
3290 "Posterior must sum to 1, got {}",
3291 sum
3292 );
3293 }
3294
3295 let p_burst = c.bocpd_p_burst().unwrap();
3296 prop_assert!(
3297 (0.0..=1.0).contains(&p_burst),
3298 "P(burst) must be in [0,1], got {}",
3299 p_burst
3300 );
3301 }
3302 }
3303 }
3304 }
3305
3306 #[test]
3311 fn telemetry_hooks_fire_on_resize_applied() {
3312 use std::sync::Arc;
3313 use std::sync::atomic::{AtomicU32, Ordering};
3314
3315 let applied_count = Arc::new(AtomicU32::new(0));
3316 let applied_count_clone = applied_count.clone();
3317
3318 let hooks = TelemetryHooks::new().on_resize_applied(move |_entry| {
3319 applied_count_clone.fetch_add(1, Ordering::SeqCst);
3320 });
3321
3322 let mut config = test_config();
3323 config.enable_logging = true;
3324 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
3325
3326 let base = Instant::now();
3327 c.handle_resize_at(100, 40, base);
3328 c.tick_at(base + Duration::from_millis(50));
3329
3330 assert_eq!(applied_count.load(Ordering::SeqCst), 1);
3331 }
3332
3333 #[test]
3334 fn telemetry_hooks_fire_on_regime_change() {
3335 use std::sync::Arc;
3336 use std::sync::atomic::{AtomicU32, Ordering};
3337
3338 let regime_changes = Arc::new(AtomicU32::new(0));
3339 let regime_changes_clone = regime_changes.clone();
3340
3341 let hooks = TelemetryHooks::new().on_regime_change(move |_from, _to| {
3342 regime_changes_clone.fetch_add(1, Ordering::SeqCst);
3343 });
3344
3345 let config = test_config();
3346 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
3347
3348 let base = Instant::now();
3349
3350 for i in 0..15 {
3352 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
3353 }
3354
3355 assert!(regime_changes.load(Ordering::SeqCst) >= 1);
3357 }
3358
3359 #[test]
3360 fn regime_transition_count_tracks_changes() {
3361 let config = test_config();
3362 let mut c = ResizeCoalescer::new(config, (80, 24));
3363
3364 assert_eq!(c.regime_transition_count(), 0);
3365
3366 let base = Instant::now();
3367
3368 for i in 0..15 {
3370 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
3371 }
3372
3373 assert!(c.regime_transition_count() >= 1);
3375 }
3376
3377 #[test]
3378 fn cycle_time_percentiles_calculated() {
3379 let mut config = test_config();
3380 config.enable_logging = true;
3381 let mut c = ResizeCoalescer::new(config, (80, 24));
3382
3383 assert!(c.cycle_time_percentiles().is_none());
3385
3386 let base = Instant::now();
3387
3388 for i in 0..5 {
3390 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 100));
3391 c.tick_at(base + Duration::from_millis(i as u64 * 100 + 50));
3392 }
3393
3394 let percentiles = c.cycle_time_percentiles();
3396 assert!(percentiles.is_some());
3397
3398 let p = percentiles.unwrap();
3399 assert!(p.count >= 1);
3400 assert!(p.mean_ms >= 0.0);
3401 assert!(p.p50_ms >= 0.0);
3402 assert!(p.p95_ms >= p.p50_ms);
3403 assert!(p.p99_ms >= p.p95_ms);
3404 }
3405
3406 #[test]
3407 fn cycle_time_percentiles_jsonl_format() {
3408 let percentiles = CycleTimePercentiles {
3409 p50_ms: 10.5,
3410 p95_ms: 25.3,
3411 p99_ms: 42.1,
3412 count: 100,
3413 mean_ms: 15.2,
3414 };
3415
3416 let jsonl = percentiles.to_jsonl();
3417 assert!(jsonl.contains("\"event\":\"cycle_time_percentiles\""));
3418 assert!(jsonl.contains("\"p50_ms\":10.500"));
3419 assert!(jsonl.contains("\"p95_ms\":25.300"));
3420 assert!(jsonl.contains("\"p99_ms\":42.100"));
3421 assert!(jsonl.contains("\"mean_ms\":15.200"));
3422 assert!(jsonl.contains("\"count\":100"));
3423 }
3424
3425 #[test]
3430 fn bocpd_disabled_by_default() {
3431 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
3432 assert!(!c.bocpd_enabled());
3433 assert!(c.bocpd().is_none());
3434 assert!(c.bocpd_p_burst().is_none());
3435 }
3436
3437 #[test]
3438 fn bocpd_enabled_with_config() {
3439 let config = CoalescerConfig::default().with_bocpd();
3440 let c = ResizeCoalescer::new(config, (80, 24));
3441 assert!(c.bocpd_enabled());
3442 assert!(c.bocpd().is_some());
3443 }
3444
3445 #[test]
3446 fn bocpd_posterior_normalized() {
3447 let config = CoalescerConfig::default().with_bocpd();
3448 let mut c = ResizeCoalescer::new(config, (80, 24));
3449
3450 let base = Instant::now();
3451
3452 for i in 0..20 {
3454 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 50));
3455 }
3456
3457 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
3459 assert!(
3460 (0.0..=1.0).contains(&p_burst),
3461 "P(burst) must be in [0,1], got {}",
3462 p_burst
3463 );
3464
3465 if let Some(bocpd) = c.bocpd() {
3467 let sum: f64 = bocpd.run_length_posterior().iter().sum();
3468 assert!(
3469 (sum - 1.0).abs() < 1e-9,
3470 "Posterior must sum to 1, got {}",
3471 sum
3472 );
3473 }
3474 }
3475
3476 #[test]
3477 fn bocpd_detects_burst_from_rapid_events() {
3478 use crate::bocpd::BocpdConfig;
3479
3480 let bocpd_config = BocpdConfig {
3482 mu_steady_ms: 200.0,
3483 mu_burst_ms: 20.0,
3484 burst_threshold: 0.6,
3485 steady_threshold: 0.4,
3486 ..BocpdConfig::default()
3487 };
3488
3489 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
3490 let mut c = ResizeCoalescer::new(config, (80, 24));
3491
3492 let base = Instant::now();
3493
3494 for i in 0..30 {
3496 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
3497 }
3498
3499 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
3501 assert!(
3502 p_burst > 0.5,
3503 "Rapid events should yield high P(burst), got {}",
3504 p_burst
3505 );
3506 assert_eq!(
3507 c.regime(),
3508 Regime::Burst,
3509 "Regime should be Burst with rapid events"
3510 );
3511 }
3512
3513 #[test]
3514 fn bocpd_detects_steady_from_slow_events() {
3515 use crate::bocpd::BocpdConfig;
3516
3517 let bocpd_config = BocpdConfig {
3519 mu_steady_ms: 200.0,
3520 mu_burst_ms: 20.0,
3521 burst_threshold: 0.7,
3522 steady_threshold: 0.3,
3523 ..BocpdConfig::default()
3524 };
3525
3526 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
3527 let mut c = ResizeCoalescer::new(config, (80, 24));
3528
3529 let base = Instant::now();
3530
3531 for i in 0..10 {
3533 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 300));
3534 }
3535
3536 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
3538 assert!(
3539 p_burst < 0.5,
3540 "Slow events should yield low P(burst), got {}",
3541 p_burst
3542 );
3543 assert_eq!(
3544 c.regime(),
3545 Regime::Steady,
3546 "Regime should be Steady with slow events"
3547 );
3548 }
3549
3550 #[test]
3551 fn bocpd_recommended_delay_varies_with_regime() {
3552 let config = CoalescerConfig::default().with_bocpd();
3553 let mut c = ResizeCoalescer::new(config, (80, 24));
3554
3555 let base = Instant::now();
3556
3557 c.handle_resize_at(85, 30, base);
3559 let delay_initial = c.bocpd_recommended_delay().expect("BOCPD enabled");
3560
3561 for i in 1..30 {
3563 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
3564 }
3565 let delay_burst = c.bocpd_recommended_delay().expect("BOCPD enabled");
3566
3567 assert!(delay_initial > 0, "Initial delay should be positive");
3569 assert!(delay_burst > 0, "Burst delay should be positive");
3570 }
3571
3572 #[test]
3573 fn bocpd_update_is_deterministic() {
3574 let config = CoalescerConfig::default().with_bocpd();
3575
3576 let base = Instant::now();
3577
3578 let results: Vec<_> = (0..2)
3580 .map(|_| {
3581 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
3582 for i in 0..20 {
3583 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 25));
3584 }
3585 (c.regime(), c.bocpd_p_burst())
3586 })
3587 .collect();
3588
3589 assert_eq!(
3590 results[0], results[1],
3591 "BOCPD results must be deterministic"
3592 );
3593 }
3594
3595 #[test]
3596 fn bocpd_memory_bounded() {
3597 use crate::bocpd::BocpdConfig;
3598
3599 let bocpd_config = BocpdConfig {
3601 max_run_length: 50,
3602 ..BocpdConfig::default()
3603 };
3604
3605 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
3606 let mut c = ResizeCoalescer::new(config, (80, 24));
3607
3608 let base = Instant::now();
3609
3610 for i in 0u64..200 {
3612 c.handle_resize_at(
3613 80 + (i as u16 % 100),
3614 24 + (i as u16 % 50),
3615 base + Duration::from_millis(i * 20),
3616 );
3617 }
3618
3619 if let Some(bocpd) = c.bocpd() {
3621 let posterior_len = bocpd.run_length_posterior().len();
3622 assert!(
3623 posterior_len <= 51, "Posterior length should be bounded, got {}",
3625 posterior_len
3626 );
3627 }
3628 }
3629
3630 #[test]
3631 fn bocpd_stable_under_mixed_traffic() {
3632 let config = CoalescerConfig::default().with_bocpd();
3633 let mut c = ResizeCoalescer::new(config, (80, 24));
3634
3635 let base = Instant::now();
3636 let mut offset = 0u64;
3637
3638 for i in 0..5 {
3640 offset += 200;
3641 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(offset));
3642 }
3643
3644 for i in 0..15 {
3646 offset += 15;
3647 c.handle_resize_at(90 + i, 30 + i, base + Duration::from_millis(offset));
3648 }
3649
3650 for i in 0..5 {
3652 offset += 250;
3653 c.handle_resize_at(100 + i, 40 + i, base + Duration::from_millis(offset));
3654 }
3655
3656 let p_burst = c.bocpd_p_burst().expect("BOCPD enabled");
3658 assert!(
3659 (0.0..=1.0).contains(&p_burst),
3660 "P(burst) must remain valid after mixed traffic"
3661 );
3662
3663 if let Some(bocpd) = c.bocpd() {
3664 let sum: f64 = bocpd.run_length_posterior().iter().sum();
3665 assert!((sum - 1.0).abs() < 1e-9, "Posterior must remain normalized");
3666 }
3667 }
3668
3669 #[test]
3674 fn evidence_decision_jsonl_contains_all_required_fields() {
3675 let log = DecisionLog {
3676 timestamp: Instant::now(),
3677 elapsed_ms: 16.5,
3678 event_idx: 1,
3679 dt_ms: 16.0,
3680 event_rate: 62.5,
3681 regime: Regime::Steady,
3682 action: "apply",
3683 pending_size: Some((100, 40)),
3684 applied_size: Some((100, 40)),
3685 time_since_render_ms: 16.2,
3686 coalesce_ms: Some(16.0),
3687 forced: false,
3688 transition_reason_code: None,
3689 transition_confidence: None,
3690 };
3691
3692 let jsonl = log.to_jsonl("test-run-1", ScreenMode::AltScreen, 100, 40);
3693 let parsed: serde_json::Value =
3694 serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3695
3696 assert_eq!(
3698 parsed["schema_version"].as_str().unwrap(),
3699 EVIDENCE_SCHEMA_VERSION
3700 );
3701 assert_eq!(parsed["run_id"].as_str().unwrap(), "test-run-1");
3702 assert_eq!(parsed["event_idx"].as_u64().unwrap(), 1);
3703 assert_eq!(parsed["screen_mode"].as_str().unwrap(), "altscreen");
3704 assert_eq!(parsed["cols"].as_u64().unwrap(), 100);
3705 assert_eq!(parsed["rows"].as_u64().unwrap(), 40);
3706
3707 assert_eq!(parsed["event"].as_str().unwrap(), "decision");
3709 assert!(parsed["elapsed_ms"].as_f64().is_some());
3710 assert!(parsed["dt_ms"].as_f64().is_some());
3711 assert!(parsed["event_rate"].as_f64().is_some());
3712 assert_eq!(parsed["regime"].as_str().unwrap(), "steady");
3713 assert_eq!(parsed["action"].as_str().unwrap(), "apply");
3714 assert_eq!(parsed["pending_w"].as_u64().unwrap(), 100);
3715 assert_eq!(parsed["pending_h"].as_u64().unwrap(), 40);
3716 assert_eq!(parsed["applied_w"].as_u64().unwrap(), 100);
3717 assert_eq!(parsed["applied_h"].as_u64().unwrap(), 40);
3718 assert!(parsed["time_since_render_ms"].as_f64().is_some());
3719 assert!(parsed["coalesce_ms"].as_f64().is_some());
3720 assert!(!parsed["forced"].as_bool().unwrap());
3721 assert!(parsed["transition_reason_code"].is_null());
3722 assert!(parsed["transition_confidence"].is_null());
3723 }
3724
3725 #[test]
3726 fn evidence_decision_jsonl_null_fields_when_no_pending() {
3727 let log = DecisionLog {
3728 timestamp: Instant::now(),
3729 elapsed_ms: 0.0,
3730 event_idx: 0,
3731 dt_ms: 0.0,
3732 event_rate: 0.0,
3733 regime: Regime::Steady,
3734 action: "skip_same_size",
3735 pending_size: None,
3736 applied_size: None,
3737 time_since_render_ms: 0.0,
3738 coalesce_ms: None,
3739 forced: false,
3740 transition_reason_code: None,
3741 transition_confidence: None,
3742 };
3743
3744 let jsonl = log.to_jsonl("test-run-2", ScreenMode::AltScreen, 80, 24);
3745 let parsed: serde_json::Value =
3746 serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3747
3748 assert!(parsed["pending_w"].is_null());
3749 assert!(parsed["pending_h"].is_null());
3750 assert!(parsed["applied_w"].is_null());
3751 assert!(parsed["applied_h"].is_null());
3752 assert!(parsed["coalesce_ms"].is_null());
3753 assert!(parsed["transition_reason_code"].is_null());
3754 assert!(parsed["transition_confidence"].is_null());
3755 }
3756
3757 #[test]
3758 fn evidence_config_jsonl_contains_all_fields() {
3759 let config = test_config();
3760 let jsonl = config.to_jsonl("cfg-run", ScreenMode::AltScreen, 80, 24, 0);
3761 let parsed: serde_json::Value =
3762 serde_json::from_str(&jsonl).expect("Config JSONL must be valid JSON");
3763
3764 assert_eq!(parsed["event"].as_str().unwrap(), "config");
3765 assert_eq!(
3766 parsed["schema_version"].as_str().unwrap(),
3767 EVIDENCE_SCHEMA_VERSION
3768 );
3769 assert_eq!(parsed["steady_delay_ms"].as_u64().unwrap(), 16);
3770 assert_eq!(parsed["burst_delay_ms"].as_u64().unwrap(), 40);
3771 assert_eq!(parsed["hard_deadline_ms"].as_u64().unwrap(), 100);
3772 assert!(parsed["burst_enter_rate"].as_f64().is_some());
3773 assert!(parsed["burst_exit_rate"].as_f64().is_some());
3774 assert_eq!(parsed["cooldown_frames"].as_u64().unwrap(), 3);
3775 assert_eq!(parsed["rate_window_size"].as_u64().unwrap(), 8);
3776 }
3777
3778 #[test]
3779 fn evidence_inline_screen_mode_string() {
3780 let log = DecisionLog {
3781 timestamp: Instant::now(),
3782 elapsed_ms: 0.0,
3783 event_idx: 0,
3784 dt_ms: 0.0,
3785 event_rate: 0.0,
3786 regime: Regime::Burst,
3787 action: "coalesce",
3788 pending_size: Some((120, 40)),
3789 applied_size: None,
3790 time_since_render_ms: 5.0,
3791 coalesce_ms: None,
3792 forced: false,
3793 transition_reason_code: None,
3794 transition_confidence: None,
3795 };
3796
3797 let jsonl = log.to_jsonl("inline-run", ScreenMode::Inline { ui_height: 12 }, 120, 40);
3798 let parsed: serde_json::Value =
3799 serde_json::from_str(&jsonl).expect("JSONL must be valid JSON");
3800
3801 assert_eq!(parsed["screen_mode"].as_str().unwrap(), "inline");
3802 assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
3803 }
3804
3805 #[test]
3806 fn resize_scheduling_steady_applies_within_steady_delay() {
3807 let config = CoalescerConfig {
3808 steady_delay_ms: 20,
3809 burst_delay_ms: 50,
3810 hard_deadline_ms: 200,
3811 enable_logging: true,
3812 ..test_config()
3813 };
3814 let base = Instant::now();
3815 let mut c = ResizeCoalescer::new(config, (80, 24));
3816
3817 let action = c.handle_resize_at(100, 40, base);
3819 match action {
3821 CoalesceAction::ApplyResize { width, height, .. } => {
3822 assert_eq!(width, 100);
3823 assert_eq!(height, 40);
3824 }
3825 CoalesceAction::None | CoalesceAction::ShowPlaceholder => {
3826 let later = base + Duration::from_millis(25);
3828 let action = c.tick_at(later);
3829 if let CoalesceAction::ApplyResize { width, height, .. } = action {
3830 assert_eq!(width, 100);
3831 assert_eq!(height, 40);
3832 }
3833 }
3834 }
3835
3836 assert_eq!(c.last_applied(), (100, 40));
3838 }
3839
3840 #[test]
3841 fn resize_scheduling_burst_regime_coalesces_rapid_events() {
3842 let config = CoalescerConfig {
3843 steady_delay_ms: 16,
3844 burst_delay_ms: 40,
3845 hard_deadline_ms: 100,
3846 burst_enter_rate: 10.0,
3847 enable_logging: true,
3848 ..test_config()
3849 };
3850 let base = Instant::now();
3851 let mut c = ResizeCoalescer::new(config, (80, 24));
3852 let mut apply_count = 0u32;
3853
3854 for i in 0..20 {
3856 let t = base + Duration::from_millis(i * 50);
3857 let action = c.handle_resize_at(80 + (i as u16), 24, t);
3858 if matches!(action, CoalesceAction::ApplyResize { .. }) {
3859 apply_count += 1;
3860 }
3861 let tick_t = t + Duration::from_millis(10);
3863 let tick_action = c.tick_at(tick_t);
3864 if matches!(tick_action, CoalesceAction::ApplyResize { .. }) {
3865 apply_count += 1;
3866 }
3867 }
3868
3869 assert!(
3871 apply_count < 20,
3872 "Expected coalescing: {apply_count} applies for 20 events"
3873 );
3874 assert!(apply_count > 0, "Should have at least one apply");
3876 }
3877
3878 #[test]
3879 fn evidence_summary_jsonl_includes_checksum() {
3880 let config = CoalescerConfig {
3881 enable_logging: true,
3882 ..test_config()
3883 };
3884 let base = Instant::now();
3885 let mut c = ResizeCoalescer::new(config, (80, 24));
3886
3887 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
3889 c.tick_at(base + Duration::from_millis(30));
3890
3891 let all_lines = c.evidence_to_jsonl();
3892 let summary_line = all_lines.lines().last().expect("Should have summary line");
3893 let parsed: serde_json::Value =
3894 serde_json::from_str(summary_line).expect("Summary JSONL line must be valid JSON");
3895
3896 assert_eq!(parsed["event"].as_str().unwrap(), "summary");
3897 assert!(parsed["decisions"].as_u64().is_some());
3898 assert!(parsed["applies"].as_u64().is_some());
3899 assert!(parsed["forced_applies"].as_u64().is_some());
3900 assert!(parsed["coalesces"].as_u64().is_some());
3901 assert!(parsed["skips"].as_u64().is_some());
3902 assert!(parsed["regime"].as_str().is_some());
3903 assert!(parsed["checksum"].as_str().is_some());
3904 }
3905
3906 #[test]
3911 fn decision_evidence_favor_apply_steady() {
3912 let ev = DecisionEvidence::favor_apply(Regime::Steady, 80.0, 2.0);
3913 assert!(ev.log_bayes_factor > 0.0, "Should favor apply");
3915 assert_eq!(ev.regime_contribution, 1.0);
3916 assert!((ev.timing_contribution - 1.6).abs() < 0.01);
3917 assert_eq!(ev.rate_contribution, 0.5);
3918 assert!(ev.is_strong());
3919 assert!(ev.is_decisive());
3920 }
3921
3922 #[test]
3923 fn decision_evidence_favor_apply_burst_regime() {
3924 let ev = DecisionEvidence::favor_apply(Regime::Burst, 10.0, 20.0);
3925 assert_eq!(ev.regime_contribution, -0.5);
3927 assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3928 assert_eq!(ev.rate_contribution, -0.3);
3929 assert!(!ev.is_strong());
3931 }
3932
3933 #[test]
3934 fn decision_evidence_favor_coalesce_burst() {
3935 let ev = DecisionEvidence::favor_coalesce(Regime::Burst, 5.0, 15.0);
3936 assert!(ev.log_bayes_factor < 0.0, "Should favor coalesce");
3939 assert_eq!(ev.regime_contribution, 1.0);
3940 assert!((ev.timing_contribution - 2.0).abs() < 0.01);
3941 assert_eq!(ev.rate_contribution, 0.5);
3942 assert!(ev.is_strong());
3943 assert!(ev.is_decisive());
3944 }
3945
3946 #[test]
3947 fn decision_evidence_favor_coalesce_steady_regime() {
3948 let ev = DecisionEvidence::favor_coalesce(Regime::Steady, 100.0, 3.0);
3949 assert_eq!(ev.regime_contribution, -0.5);
3951 assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3952 assert_eq!(ev.rate_contribution, -0.3);
3953 }
3954
3955 #[test]
3956 fn decision_evidence_forced_deadline() {
3957 let ev = DecisionEvidence::forced_deadline(100.0);
3958 assert!(ev.log_bayes_factor.is_infinite());
3959 assert_eq!(ev.regime_contribution, 0.0);
3960 assert!((ev.timing_contribution - 100.0).abs() < 0.01);
3961 assert_eq!(ev.rate_contribution, 0.0);
3962 assert!(ev.is_strong());
3963 assert!(ev.is_decisive());
3964 assert!(ev.explanation.contains("100.0ms"));
3965 }
3966
3967 #[test]
3968 fn decision_evidence_is_strong_boundary() {
3969 let ev = DecisionEvidence {
3971 log_bayes_factor: 1.0,
3972 regime_contribution: 0.0,
3973 timing_contribution: 0.0,
3974 rate_contribution: 0.0,
3975 explanation: String::new(),
3976 };
3977 assert!(!ev.is_strong());
3978
3979 let ev2 = DecisionEvidence {
3980 log_bayes_factor: 1.001,
3981 ..ev.clone()
3982 };
3983 assert!(ev2.is_strong());
3984
3985 let ev3 = DecisionEvidence {
3987 log_bayes_factor: -1.5,
3988 ..ev
3989 };
3990 assert!(ev3.is_strong());
3991 }
3992
3993 #[test]
3994 fn decision_evidence_is_decisive_boundary() {
3995 let ev = DecisionEvidence {
3996 log_bayes_factor: 2.0,
3997 regime_contribution: 0.0,
3998 timing_contribution: 0.0,
3999 rate_contribution: 0.0,
4000 explanation: String::new(),
4001 };
4002 assert!(!ev.is_decisive()); let ev2 = DecisionEvidence {
4005 log_bayes_factor: 2.001,
4006 ..ev
4007 };
4008 assert!(ev2.is_decisive());
4009 }
4010
4011 #[test]
4012 fn decision_evidence_to_jsonl_valid() {
4013 let ev = DecisionEvidence::favor_apply(Regime::Steady, 50.0, 3.0);
4014 let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 5);
4015 let parsed: serde_json::Value =
4016 serde_json::from_str(&jsonl).expect("DecisionEvidence JSONL must be valid JSON");
4017
4018 assert_eq!(parsed["event"].as_str().unwrap(), "decision_evidence");
4019 assert!(parsed["log_bayes_factor"].as_f64().is_some());
4020 assert!(parsed["regime_contribution"].as_f64().is_some());
4021 assert!(parsed["timing_contribution"].as_f64().is_some());
4022 assert!(parsed["rate_contribution"].as_f64().is_some());
4023 assert!(parsed["explanation"].as_str().is_some());
4024 }
4025
4026 #[test]
4027 fn decision_evidence_to_jsonl_infinity() {
4028 let ev = DecisionEvidence::forced_deadline(100.0);
4029 let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 0);
4030 assert!(jsonl.contains("\"inf\""));
4032 }
4033
4034 #[test]
4039 fn hard_deadline_zero_applies_immediately() {
4040 let config = CoalescerConfig {
4041 hard_deadline_ms: 0,
4042 enable_logging: true,
4043 ..test_config()
4044 };
4045 let mut c = ResizeCoalescer::new(config, (80, 24));
4046 let base = Instant::now();
4047
4048 let action = c.handle_resize_at(100, 40, base);
4050 assert!(
4051 matches!(action, CoalesceAction::ApplyResize { .. }),
4052 "hard_deadline_ms=0 should force immediate apply, got {action:?}"
4053 );
4054 }
4055
4056 #[test]
4057 fn rate_window_size_zero_returns_zero_rate() {
4058 let config = CoalescerConfig {
4059 rate_window_size: 0,
4060 enable_logging: true,
4061 ..test_config()
4062 };
4063 let mut c = ResizeCoalescer::new(config, (80, 24));
4064 let base = Instant::now();
4065
4066 for i in 0..5 {
4067 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
4068 }
4069 let rate = c.calculate_event_rate(base + Duration::from_millis(50));
4071 assert_eq!(rate, 0.0, "rate_window_size=0 should yield 0 rate");
4072 }
4073
4074 #[test]
4075 fn tick_no_pending_returns_none() {
4076 let config = test_config();
4077 let mut c = ResizeCoalescer::new(config, (80, 24));
4078 let base = Instant::now();
4079
4080 let action = c.tick_at(base);
4082 assert_eq!(action, CoalesceAction::None);
4083 let action = c.tick_at(base + Duration::from_millis(500));
4084 assert_eq!(action, CoalesceAction::None);
4085 }
4086
4087 #[test]
4088 fn time_until_apply_none_when_no_pending() {
4089 let c = ResizeCoalescer::new(test_config(), (80, 24));
4090 assert!(c.time_until_apply(Instant::now()).is_none());
4091 }
4092
4093 #[test]
4094 fn time_until_apply_zero_when_past_delay() {
4095 let config = test_config();
4096 let mut c = ResizeCoalescer::new(config, (80, 24));
4097 let base = Instant::now();
4098
4099 c.handle_resize_at(100, 40, base);
4100 let result = c.time_until_apply(base + Duration::from_millis(500));
4102 assert_eq!(result, Some(Duration::ZERO));
4103 }
4104
4105 #[test]
4110 fn json_escape_special_characters() {
4111 assert_eq!(json_escape("hello"), "hello");
4112 assert_eq!(json_escape("a\"b"), "a\\\"b");
4113 assert_eq!(json_escape("a\\b"), "a\\\\b");
4114 assert_eq!(json_escape("a\nb"), "a\\nb");
4115 assert_eq!(json_escape("a\rb"), "a\\rb");
4116 assert_eq!(json_escape("a\tb"), "a\\tb");
4117 }
4118
4119 #[test]
4120 fn json_escape_control_characters() {
4121 let input = "a\x01b";
4123 let escaped = json_escape(input);
4124 assert_eq!(escaped, "a\\u0001b");
4125 }
4126
4127 #[test]
4128 fn json_escape_empty_string() {
4129 assert_eq!(json_escape(""), "");
4130 }
4131
4132 #[test]
4137 fn clear_logs_resets_state() {
4138 let mut config = test_config();
4139 config.enable_logging = true;
4140 let mut c = ResizeCoalescer::new(config, (80, 24));
4141
4142 c.handle_resize_at(100, 40, Instant::now());
4143 c.tick_at(Instant::now() + Duration::from_millis(50));
4144
4145 assert!(!c.logs().is_empty());
4146
4147 c.clear_logs();
4148 assert!(c.logs().is_empty());
4149
4150 c.handle_resize_at(120, 50, Instant::now());
4152 c.tick_at(Instant::now() + Duration::from_millis(50));
4153 assert!(!c.logs().is_empty());
4154 }
4155
4156 #[test]
4157 fn decision_logs_jsonl_each_line_valid() {
4158 let mut config = test_config();
4159 config.enable_logging = true;
4160 let base = Instant::now();
4161 let mut c = ResizeCoalescer::new(config, (80, 24))
4162 .with_evidence_run_id("jsonl-test")
4163 .with_screen_mode(ScreenMode::AltScreen);
4164
4165 c.handle_resize_at(100, 40, base);
4166 c.handle_resize_at(110, 50, base + Duration::from_millis(5));
4167 c.tick_at(base + Duration::from_millis(50));
4168
4169 let jsonl = c.decision_logs_jsonl();
4170 assert!(!jsonl.is_empty());
4171 for line in jsonl.lines() {
4172 let _: serde_json::Value =
4173 serde_json::from_str(line).expect("Each JSONL line must be valid JSON");
4174 }
4175 }
4176
4177 #[test]
4182 fn telemetry_hooks_has_methods() {
4183 let hooks = TelemetryHooks::new();
4184 assert!(!hooks.has_resize_applied());
4185 assert!(!hooks.has_regime_change());
4186 assert!(!hooks.has_decision());
4187
4188 let hooks = hooks.on_resize_applied(|_| {});
4189 assert!(hooks.has_resize_applied());
4190 assert!(!hooks.has_regime_change());
4191 assert!(!hooks.has_decision());
4192 }
4193
4194 #[test]
4195 fn telemetry_hooks_with_tracing() {
4196 let hooks = TelemetryHooks::new().with_tracing(true);
4197 let debug_str = format!("{:?}", hooks);
4198 assert!(debug_str.contains("emit_tracing: true"));
4199 }
4200
4201 #[test]
4202 fn telemetry_hooks_default_equals_new() {
4203 let h1 = TelemetryHooks::default();
4204 let h2 = TelemetryHooks::new();
4205 assert!(!h1.has_resize_applied());
4206 assert!(!h2.has_resize_applied());
4207 }
4208
4209 #[test]
4210 fn telemetry_hooks_on_decision_fires() {
4211 use std::sync::Arc;
4212 use std::sync::atomic::{AtomicU32, Ordering};
4213
4214 let count = Arc::new(AtomicU32::new(0));
4215 let count_clone = count.clone();
4216
4217 let hooks = TelemetryHooks::new().on_decision(move |_entry| {
4218 count_clone.fetch_add(1, Ordering::SeqCst);
4219 });
4220
4221 let mut config = test_config();
4222 config.enable_logging = true;
4223 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
4224
4225 let base = Instant::now();
4226 c.handle_resize_at(100, 40, base);
4227
4228 assert!(count.load(Ordering::SeqCst) >= 1);
4230 }
4231
4232 #[test]
4237 fn regime_as_str_values() {
4238 assert_eq!(Regime::Steady.as_str(), "steady");
4239 assert_eq!(Regime::Burst.as_str(), "burst");
4240 }
4241
4242 #[test]
4243 fn regime_default_is_steady() {
4244 assert_eq!(Regime::default(), Regime::Steady);
4245 }
4246
4247 #[test]
4252 fn decision_summary_checksum_hex_format() {
4253 let summary = DecisionSummary {
4254 checksum: 0x0123456789ABCDEF,
4255 ..DecisionSummary::default()
4256 };
4257 assert_eq!(summary.checksum_hex(), "0123456789abcdef");
4258 }
4259
4260 #[test]
4261 fn decision_summary_default_values() {
4262 let summary = DecisionSummary::default();
4263 assert_eq!(summary.decision_count, 0);
4264 assert_eq!(summary.apply_count, 0);
4265 assert_eq!(summary.forced_apply_count, 0);
4266 assert_eq!(summary.coalesce_count, 0);
4267 assert_eq!(summary.skip_count, 0);
4268 assert_eq!(summary.regime, Regime::Steady);
4269 assert_eq!(summary.last_applied, (0, 0));
4270 assert_eq!(summary.checksum, 0);
4271 }
4272
4273 #[test]
4274 fn decision_summary_to_jsonl_valid() {
4275 let summary = DecisionSummary {
4276 decision_count: 5,
4277 apply_count: 2,
4278 forced_apply_count: 1,
4279 coalesce_count: 2,
4280 skip_count: 1,
4281 regime: Regime::Burst,
4282 last_applied: (120, 40),
4283 checksum: 0xDEADBEEF,
4284 };
4285 let jsonl = summary.to_jsonl("run-1", ScreenMode::AltScreen, 120, 40, 5);
4286 let parsed: serde_json::Value =
4287 serde_json::from_str(&jsonl).expect("Summary JSONL must be valid JSON");
4288
4289 assert_eq!(parsed["event"].as_str().unwrap(), "summary");
4290 assert_eq!(parsed["decisions"].as_u64().unwrap(), 5);
4291 assert_eq!(parsed["applies"].as_u64().unwrap(), 2);
4292 assert_eq!(parsed["forced_applies"].as_u64().unwrap(), 1);
4293 assert_eq!(parsed["coalesces"].as_u64().unwrap(), 2);
4294 assert_eq!(parsed["skips"].as_u64().unwrap(), 1);
4295 assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
4296 assert_eq!(parsed["last_w"].as_u64().unwrap(), 120);
4297 assert_eq!(parsed["last_h"].as_u64().unwrap(), 40);
4298 assert!(parsed["checksum"].as_str().unwrap().contains("deadbeef"));
4299 }
4300
4301 #[test]
4306 fn screen_mode_str_all_variants() {
4307 assert_eq!(screen_mode_str(ScreenMode::AltScreen), "altscreen");
4308 assert_eq!(
4309 screen_mode_str(ScreenMode::Inline { ui_height: 10 }),
4310 "inline"
4311 );
4312 assert_eq!(
4313 screen_mode_str(ScreenMode::InlineAuto {
4314 min_height: 5,
4315 max_height: 20,
4316 }),
4317 "inline_auto"
4318 );
4319 }
4320
4321 #[test]
4326 fn config_with_logging_chaining() {
4327 let config = CoalescerConfig::default().with_logging(true);
4328 assert!(config.enable_logging);
4329
4330 let config2 = config.with_logging(false);
4331 assert!(!config2.enable_logging);
4332 }
4333
4334 #[test]
4335 fn config_with_bocpd_chaining() {
4336 let config = CoalescerConfig::default().with_bocpd();
4337 assert!(config.enable_bocpd);
4338 assert!(config.bocpd_config.is_some());
4339 }
4340
4341 #[test]
4342 fn config_with_bocpd_config_chaining() {
4343 let bocpd_cfg = BocpdConfig {
4344 max_run_length: 42,
4345 ..BocpdConfig::default()
4346 };
4347 let config = CoalescerConfig::default().with_bocpd_config(bocpd_cfg);
4348 assert!(config.enable_bocpd);
4349 assert_eq!(config.bocpd_config.as_ref().unwrap().max_run_length, 42);
4350 }
4351
4352 #[test]
4357 fn coalescer_with_evidence_run_id() {
4358 let c = ResizeCoalescer::new(test_config(), (80, 24)).with_evidence_run_id("custom-run-id");
4359 let jsonl = c.evidence_to_jsonl();
4361 assert!(jsonl.contains("custom-run-id"));
4362 }
4363
4364 #[test]
4365 fn coalescer_with_screen_mode() {
4366 let mut config = test_config();
4367 config.enable_logging = true;
4368 let mut c = ResizeCoalescer::new(config, (80, 24))
4369 .with_screen_mode(ScreenMode::Inline { ui_height: 8 });
4370
4371 c.handle_resize(100, 40);
4372 let jsonl = c.evidence_to_jsonl();
4373 assert!(jsonl.contains("\"screen_mode\":\"inline\""));
4374 }
4375
4376 #[test]
4377 fn coalescer_set_evidence_sink_clears_config_logged() {
4378 let mut config = test_config();
4379 config.enable_logging = true;
4380 let mut c = ResizeCoalescer::new(config, (80, 24));
4381
4382 c.handle_resize(100, 40);
4384
4385 c.set_evidence_sink(None);
4387
4388 let jsonl = c.evidence_to_jsonl();
4390 assert!(jsonl.contains("\"event\":\"config\""));
4391 }
4392
4393 #[test]
4398 fn duration_since_or_zero_normal() {
4399 let earlier = Instant::now();
4400 std::thread::sleep(Duration::from_millis(1));
4401 let now = Instant::now();
4402 let result = duration_since_or_zero(now, earlier);
4403 assert!(result >= Duration::from_millis(1));
4404 }
4405
4406 #[test]
4407 fn duration_since_or_zero_same_instant() {
4408 let now = Instant::now();
4409 let result = duration_since_or_zero(now, now);
4410 assert_eq!(result, Duration::ZERO);
4411 }
4412
4413 #[test]
4418 fn resize_applied_event_fields() {
4419 let event = ResizeAppliedEvent {
4420 new_size: (100, 40),
4421 old_size: (80, 24),
4422 elapsed: Duration::from_millis(42),
4423 forced: true,
4424 };
4425 assert_eq!(event.new_size, (100, 40));
4426 assert_eq!(event.old_size, (80, 24));
4427 assert_eq!(event.elapsed, Duration::from_millis(42));
4428 assert!(event.forced);
4429 }
4430
4431 #[test]
4432 fn regime_change_event_fields() {
4433 let event = RegimeChangeEvent {
4434 from: Regime::Steady,
4435 to: Regime::Burst,
4436 event_idx: 42,
4437 reason_code: TransitionReasonCode::HeuristicEnterBurstRate,
4438 confidence: 0.91,
4439 };
4440 assert_eq!(event.from, Regime::Steady);
4441 assert_eq!(event.to, Regime::Burst);
4442 assert_eq!(event.event_idx, 42);
4443 assert_eq!(
4444 event.reason_code,
4445 TransitionReasonCode::HeuristicEnterBurstRate
4446 );
4447 assert!((event.confidence - 0.91).abs() < f64::EPSILON);
4448 }
4449
4450 #[test]
4455 fn coalesce_action_show_placeholder_eq() {
4456 assert_eq!(
4457 CoalesceAction::ShowPlaceholder,
4458 CoalesceAction::ShowPlaceholder
4459 );
4460 assert_ne!(CoalesceAction::ShowPlaceholder, CoalesceAction::None);
4461 }
4462
4463 #[test]
4464 fn coalesce_action_apply_resize_eq() {
4465 let a = CoalesceAction::ApplyResize {
4466 width: 100,
4467 height: 40,
4468 coalesce_time: Duration::from_millis(16),
4469 forced_by_deadline: false,
4470 };
4471 let b = CoalesceAction::ApplyResize {
4472 width: 100,
4473 height: 40,
4474 coalesce_time: Duration::from_millis(16),
4475 forced_by_deadline: false,
4476 };
4477 assert_eq!(a, b);
4478
4479 let c = CoalesceAction::ApplyResize {
4480 width: 100,
4481 height: 40,
4482 coalesce_time: Duration::from_millis(16),
4483 forced_by_deadline: true,
4484 };
4485 assert_ne!(a, c);
4486 }
4487
4488 #[test]
4493 fn fnv_hash_deterministic() {
4494 let mut h1 = FNV_OFFSET_BASIS;
4495 fnv_hash_bytes(&mut h1, b"hello world");
4496
4497 let mut h2 = FNV_OFFSET_BASIS;
4498 fnv_hash_bytes(&mut h2, b"hello world");
4499
4500 assert_eq!(h1, h2);
4501 }
4502
4503 #[test]
4504 fn fnv_hash_different_inputs_different_hashes() {
4505 let mut h1 = FNV_OFFSET_BASIS;
4506 fnv_hash_bytes(&mut h1, b"hello");
4507
4508 let mut h2 = FNV_OFFSET_BASIS;
4509 fnv_hash_bytes(&mut h2, b"world");
4510
4511 assert_ne!(h1, h2);
4512 }
4513
4514 #[test]
4515 fn fnv_hash_empty_input_returns_basis() {
4516 let mut hash = FNV_OFFSET_BASIS;
4517 fnv_hash_bytes(&mut hash, b"");
4518 assert_eq!(hash, FNV_OFFSET_BASIS);
4519 }
4520}