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