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.checked_duration_since(earlier)
83 .unwrap_or(Duration::ZERO)
84}
85
86fn default_resize_run_id() -> String {
87 format!("resize-{}", std::process::id())
88}
89
90fn screen_mode_str(mode: ScreenMode) -> &'static str {
91 match mode {
92 ScreenMode::Inline { .. } => "inline",
93 ScreenMode::InlineAuto { .. } => "inline_auto",
94 ScreenMode::AltScreen => "altscreen",
95 }
96}
97
98#[inline]
99fn json_escape(value: &str) -> String {
100 let mut out = String::with_capacity(value.len());
101 for ch in value.chars() {
102 match ch {
103 '"' => out.push_str("\\\""),
104 '\\' => out.push_str("\\\\"),
105 '\n' => out.push_str("\\n"),
106 '\r' => out.push_str("\\r"),
107 '\t' => out.push_str("\\t"),
108 c if c.is_control() => {
109 use std::fmt::Write as _;
110 let _ = write!(out, "\\u{:04X}", c as u32);
111 }
112 _ => out.push(ch),
113 }
114 }
115 out
116}
117
118fn evidence_prefix(
119 run_id: &str,
120 screen_mode: ScreenMode,
121 cols: u16,
122 rows: u16,
123 event_idx: u64,
124) -> String {
125 format!(
126 r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
127 EVIDENCE_SCHEMA_VERSION,
128 json_escape(run_id),
129 event_idx,
130 screen_mode_str(screen_mode),
131 cols,
132 rows,
133 )
134}
135
136#[derive(Debug, Clone)]
138pub struct CoalescerConfig {
139 pub steady_delay_ms: u64,
142
143 pub burst_delay_ms: u64,
146
147 pub hard_deadline_ms: u64,
150
151 pub burst_enter_rate: f64,
153
154 pub burst_exit_rate: f64,
157
158 pub cooldown_frames: u32,
160
161 pub rate_window_size: usize,
163
164 pub enable_logging: bool,
166
167 pub enable_bocpd: bool,
178
179 pub bocpd_config: Option<BocpdConfig>,
181}
182
183impl Default for CoalescerConfig {
184 fn default() -> Self {
185 Self {
186 steady_delay_ms: 16, burst_delay_ms: 40, hard_deadline_ms: 100,
189 burst_enter_rate: 10.0, burst_exit_rate: 5.0, cooldown_frames: 3,
192 rate_window_size: 8,
193 enable_logging: false,
194 enable_bocpd: false,
195 bocpd_config: None,
196 }
197 }
198}
199
200impl CoalescerConfig {
201 #[must_use]
203 pub fn with_logging(mut self, enabled: bool) -> Self {
204 self.enable_logging = enabled;
205 self
206 }
207
208 #[must_use]
210 pub fn with_bocpd(mut self) -> Self {
211 self.enable_bocpd = true;
212 self.bocpd_config = Some(BocpdConfig::default());
213 self
214 }
215
216 #[must_use]
218 pub fn with_bocpd_config(mut self, config: BocpdConfig) -> Self {
219 self.enable_bocpd = true;
220 self.bocpd_config = Some(config);
221 self
222 }
223
224 #[must_use]
226 pub fn to_jsonl(
227 &self,
228 run_id: &str,
229 screen_mode: ScreenMode,
230 cols: u16,
231 rows: u16,
232 event_idx: u64,
233 ) -> String {
234 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
235 format!(
236 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":{}}}"#,
237 self.steady_delay_ms,
238 self.burst_delay_ms,
239 self.hard_deadline_ms,
240 self.burst_enter_rate,
241 self.burst_exit_rate,
242 self.cooldown_frames,
243 self.rate_window_size,
244 self.enable_logging
245 )
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CoalesceAction {
252 None,
254
255 ShowPlaceholder,
257
258 ApplyResize {
260 width: u16,
261 height: u16,
262 coalesce_time: Duration,
264 forced_by_deadline: bool,
266 },
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum Regime {
272 #[default]
274 Steady,
275 Burst,
277}
278
279impl Regime {
280 #[must_use]
282 pub const fn as_str(self) -> &'static str {
283 match self {
284 Self::Steady => "steady",
285 Self::Burst => "burst",
286 }
287 }
288}
289
290#[derive(Debug, Clone)]
294pub struct ResizeAppliedEvent {
295 pub new_size: (u16, u16),
297 pub old_size: (u16, u16),
299 pub elapsed: Duration,
301 pub forced: bool,
303}
304
305#[derive(Debug, Clone)]
309pub struct RegimeChangeEvent {
310 pub from: Regime,
312 pub to: Regime,
314 pub event_idx: u64,
316}
317
318#[derive(Debug, Clone)]
346pub struct DecisionEvidence {
347 pub log_bayes_factor: f64,
349
350 pub regime_contribution: f64,
352
353 pub timing_contribution: f64,
355
356 pub rate_contribution: f64,
358
359 pub explanation: String,
361}
362
363impl DecisionEvidence {
364 #[must_use]
366 pub fn favor_apply(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
367 let regime_contrib = if regime == Regime::Steady { 1.0 } else { -0.5 };
368 let timing_contrib = (dt_ms / 50.0).min(2.0); let rate_contrib = if event_rate < 5.0 { 0.5 } else { -0.3 };
370
371 let lbf = regime_contrib + timing_contrib + rate_contrib;
372
373 Self {
374 log_bayes_factor: lbf,
375 regime_contribution: regime_contrib,
376 timing_contribution: timing_contrib,
377 rate_contribution: rate_contrib,
378 explanation: format!(
379 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
380 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
381 ),
382 }
383 }
384
385 #[must_use]
387 pub fn favor_coalesce(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
388 let regime_contrib = if regime == Regime::Burst { 1.0 } else { -0.5 };
389 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 };
391
392 let lbf = -(regime_contrib + timing_contrib + rate_contrib);
393
394 Self {
395 log_bayes_factor: lbf,
396 regime_contribution: regime_contrib,
397 timing_contribution: timing_contrib,
398 rate_contribution: rate_contrib,
399 explanation: format!(
400 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
401 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
402 ),
403 }
404 }
405
406 #[must_use]
408 pub fn forced_deadline(deadline_ms: f64) -> Self {
409 Self {
410 log_bayes_factor: f64::INFINITY,
411 regime_contribution: 0.0,
412 timing_contribution: deadline_ms,
413 rate_contribution: 0.0,
414 explanation: format!("Forced by hard deadline ({:.1}ms)", deadline_ms),
415 }
416 }
417
418 #[must_use]
420 pub fn to_jsonl(
421 &self,
422 run_id: &str,
423 screen_mode: ScreenMode,
424 cols: u16,
425 rows: u16,
426 event_idx: u64,
427 ) -> String {
428 let lbf_str = if self.log_bayes_factor.is_infinite() {
429 "\"inf\"".to_string()
430 } else {
431 format!("{:.3}", self.log_bayes_factor)
432 };
433 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
434 format!(
435 r#"{{{prefix},"event":"decision_evidence","log_bayes_factor":{},"regime_contribution":{:.3},"timing_contribution":{:.3},"rate_contribution":{:.3},"explanation":"{}"}}"#,
436 lbf_str,
437 self.regime_contribution,
438 self.timing_contribution,
439 self.rate_contribution,
440 json_escape(&self.explanation)
441 )
442 }
443
444 #[must_use]
446 pub fn is_strong(&self) -> bool {
447 self.log_bayes_factor.abs() > 1.0
448 }
449
450 #[must_use]
452 pub fn is_decisive(&self) -> bool {
453 self.log_bayes_factor.abs() > 2.0 || self.log_bayes_factor.is_infinite()
454 }
455}
456
457#[derive(Debug, Clone)]
459pub struct DecisionLog {
460 pub timestamp: Instant,
462 pub elapsed_ms: f64,
464 pub event_idx: u64,
466 pub dt_ms: f64,
468 pub event_rate: f64,
470 pub regime: Regime,
472 pub action: &'static str,
474 pub pending_size: Option<(u16, u16)>,
476 pub applied_size: Option<(u16, u16)>,
478 pub time_since_render_ms: f64,
480 pub coalesce_ms: Option<f64>,
482 pub forced: bool,
484}
485
486impl DecisionLog {
487 #[must_use]
489 pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
490 let (pending_w, pending_h) = match self.pending_size {
491 Some((w, h)) => (w.to_string(), h.to_string()),
492 None => ("null".to_string(), "null".to_string()),
493 };
494 let (applied_w, applied_h) = match self.applied_size {
495 Some((w, h)) => (w.to_string(), h.to_string()),
496 None => ("null".to_string(), "null".to_string()),
497 };
498 let coalesce_ms = match self.coalesce_ms {
499 Some(ms) => format!("{:.3}", ms),
500 None => "null".to_string(),
501 };
502 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
503
504 format!(
505 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":{}}}"#,
506 self.event_idx,
507 self.elapsed_ms,
508 self.dt_ms,
509 self.event_rate,
510 self.regime.as_str(),
511 self.action,
512 pending_w,
513 pending_h,
514 applied_w,
515 applied_h,
516 self.time_since_render_ms,
517 coalesce_ms,
518 self.forced
519 )
520 }
521}
522
523#[derive(Debug)]
527pub struct ResizeCoalescer {
528 config: CoalescerConfig,
529
530 pending_size: Option<(u16, u16)>,
532
533 last_applied: (u16, u16),
535
536 window_start: Option<Instant>,
538
539 last_event: Option<Instant>,
541
542 last_render: Instant,
544
545 regime: Regime,
547
548 cooldown_remaining: u32,
550
551 event_times: VecDeque<Instant>,
553
554 event_count: u64,
556
557 log_start: Option<Instant>,
559
560 logs: Vec<DecisionLog>,
562 evidence_sink: Option<EvidenceSink>,
564 config_logged: bool,
566 evidence_run_id: String,
568 evidence_screen_mode: ScreenMode,
570
571 telemetry_hooks: Option<TelemetryHooks>,
574
575 regime_transitions: u64,
577
578 events_in_window: u64,
580
581 cycle_times: Vec<f64>,
583
584 bocpd: Option<BocpdDetector>,
586}
587
588#[derive(Debug, Clone, Copy)]
590pub struct CycleTimePercentiles {
591 pub p50_ms: f64,
593 pub p95_ms: f64,
595 pub p99_ms: f64,
597 pub count: usize,
599 pub mean_ms: f64,
601}
602
603impl CycleTimePercentiles {
604 #[must_use]
606 pub fn to_jsonl(&self) -> String {
607 format!(
608 r#"{{"event":"cycle_time_percentiles","p50_ms":{:.3},"p95_ms":{:.3},"p99_ms":{:.3},"mean_ms":{:.3},"count":{}}}"#,
609 self.p50_ms, self.p95_ms, self.p99_ms, self.mean_ms, self.count
610 )
611 }
612}
613
614impl ResizeCoalescer {
615 pub fn new(config: CoalescerConfig, initial_size: (u16, u16)) -> Self {
617 let bocpd = if config.enable_bocpd {
618 let mut bocpd_cfg = config.bocpd_config.clone().unwrap_or_default();
619 if config.enable_logging {
620 bocpd_cfg.enable_logging = true;
621 }
622 Some(BocpdDetector::new(bocpd_cfg))
623 } else {
624 None
625 };
626
627 Self {
628 config,
629 pending_size: None,
630 last_applied: initial_size,
631 window_start: None,
632 last_event: None,
633 last_render: Instant::now(),
634 regime: Regime::Steady,
635 cooldown_remaining: 0,
636 event_times: VecDeque::new(),
637 event_count: 0,
638 log_start: None,
639 logs: Vec::new(),
640 evidence_sink: None,
641 config_logged: false,
642 evidence_run_id: default_resize_run_id(),
643 evidence_screen_mode: ScreenMode::AltScreen,
644 telemetry_hooks: None,
645 regime_transitions: 0,
646 events_in_window: 0,
647 cycle_times: Vec::new(),
648 bocpd,
649 }
650 }
651
652 #[must_use]
654 pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
655 self.telemetry_hooks = Some(hooks);
656 self
657 }
658
659 #[must_use]
661 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
662 self.evidence_sink = Some(sink);
663 self.config_logged = false;
664 self
665 }
666
667 #[must_use]
669 pub fn with_evidence_run_id(mut self, run_id: impl Into<String>) -> Self {
670 self.evidence_run_id = run_id.into();
671 self
672 }
673
674 #[must_use]
676 pub fn with_screen_mode(mut self, screen_mode: ScreenMode) -> Self {
677 self.evidence_screen_mode = screen_mode;
678 self
679 }
680
681 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
683 self.evidence_sink = sink;
684 self.config_logged = false;
685 }
686
687 #[must_use]
689 pub fn with_last_render(mut self, time: Instant) -> Self {
690 self.last_render = time;
691 self
692 }
693
694 pub fn record_external_apply(&mut self, width: u16, height: u16, now: Instant) {
696 self.event_count += 1;
697 self.event_times.push_back(now);
698 while self.event_times.len() > self.config.rate_window_size {
699 self.event_times.pop_front();
700 }
701 self.update_regime(now);
702
703 self.pending_size = None;
704 self.window_start = None;
705 self.last_event = Some(now);
706 self.last_applied = (width, height);
707 self.last_render = now;
708 self.events_in_window = 0;
709 self.cooldown_remaining = 0;
710
711 self.log_decision(now, "apply_immediate", false, Some(0.0), Some(0.0));
712
713 if let Some(ref hooks) = self.telemetry_hooks
714 && let Some(entry) = self.logs.last()
715 {
716 hooks.fire_resize_applied(entry);
717 }
718 }
719
720 #[must_use]
722 pub fn regime_transition_count(&self) -> u64 {
723 self.regime_transitions
724 }
725
726 #[must_use]
729 pub fn cycle_time_percentiles(&self) -> Option<CycleTimePercentiles> {
730 if self.cycle_times.is_empty() {
731 return None;
732 }
733
734 let mut sorted = self.cycle_times.clone();
735 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
736
737 let len = sorted.len();
738 let p50_idx = len / 2;
739 let p95_idx = (len * 95) / 100;
740 let p99_idx = (len * 99) / 100;
741
742 Some(CycleTimePercentiles {
743 p50_ms: sorted[p50_idx],
744 p95_ms: sorted[p95_idx.min(len - 1)],
745 p99_ms: sorted[p99_idx.min(len - 1)],
746 count: len,
747 mean_ms: sorted.iter().sum::<f64>() / len as f64,
748 })
749 }
750
751 pub fn handle_resize(&mut self, width: u16, height: u16) -> CoalesceAction {
755 self.handle_resize_at(width, height, Instant::now())
756 }
757
758 pub fn handle_resize_at(&mut self, width: u16, height: u16, now: Instant) -> CoalesceAction {
760 self.event_count += 1;
761
762 self.event_times.push_back(now);
764 while self.event_times.len() > self.config.rate_window_size {
765 self.event_times.pop_front();
766 }
767
768 self.update_regime(now);
770
771 let dt = self.last_event.map(|t| duration_since_or_zero(now, t));
773 let dt_ms = dt.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0);
774 self.last_event = Some(now);
775
776 if self.pending_size.is_none() && (width, height) == self.last_applied {
778 self.log_decision(now, "skip_same_size", false, Some(dt_ms), None);
779 return CoalesceAction::None;
780 }
781
782 self.pending_size = Some((width, height));
784
785 self.events_in_window += 1;
787
788 if self.window_start.is_none() {
790 self.window_start = Some(now);
791 }
792
793 let time_since_render = duration_since_or_zero(now, self.last_render);
795 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
796 return self.apply_pending_at(now, true);
797 }
798
799 if let Some(dt) = dt
802 && dt >= Duration::from_millis(self.current_delay_ms())
803 && (self.bocpd.is_some() || self.regime == Regime::Steady)
804 {
805 return self.apply_pending_at(now, false);
806 }
807
808 self.log_decision(now, "coalesce", false, Some(dt_ms), None);
809
810 if let Some(ref hooks) = self.telemetry_hooks
812 && let Some(entry) = self.logs.last()
813 {
814 hooks.fire_decision(entry);
815 }
816
817 CoalesceAction::ShowPlaceholder
818 }
819
820 pub fn tick(&mut self) -> CoalesceAction {
824 self.tick_at(Instant::now())
825 }
826
827 pub fn tick_at(&mut self, now: Instant) -> CoalesceAction {
829 if self.pending_size.is_none() {
830 return CoalesceAction::None;
831 }
832
833 if self.window_start.is_none() {
834 return CoalesceAction::None;
835 }
836
837 let time_since_render = duration_since_or_zero(now, self.last_render);
839 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
840 return self.apply_pending_at(now, true);
841 }
842
843 let delay_ms = self.current_delay_ms();
844
845 if let Some(last_event) = self.last_event {
847 let since_last_event = duration_since_or_zero(now, last_event);
848 if since_last_event >= Duration::from_millis(delay_ms) {
849 return self.apply_pending_at(now, false);
850 }
851 }
852
853 if self.cooldown_remaining > 0 {
855 self.cooldown_remaining -= 1;
856 if self.cooldown_remaining == 0 && self.regime == Regime::Burst {
857 let rate = self.calculate_event_rate(now);
858 if rate < self.config.burst_exit_rate {
859 self.regime = Regime::Steady;
860 }
861 }
862 }
863
864 CoalesceAction::None
865 }
866
867 pub fn time_until_apply(&self, now: Instant) -> Option<Duration> {
869 let _pending = self.pending_size?;
870 let last_event = self.last_event?;
871
872 let delay_ms = self.current_delay_ms();
873
874 let elapsed = duration_since_or_zero(now, last_event);
875 let target = Duration::from_millis(delay_ms);
876
877 if elapsed >= target {
878 Some(Duration::ZERO)
879 } else {
880 Some(target - elapsed)
881 }
882 }
883
884 #[inline]
886 pub fn has_pending(&self) -> bool {
887 self.pending_size.is_some()
888 }
889
890 #[inline]
892 pub fn regime(&self) -> Regime {
893 self.regime
894 }
895
896 #[inline]
898 pub fn bocpd_enabled(&self) -> bool {
899 self.bocpd.is_some()
900 }
901
902 #[inline]
906 pub fn bocpd(&self) -> Option<&BocpdDetector> {
907 self.bocpd.as_ref()
908 }
909
910 #[inline]
915 pub fn bocpd_p_burst(&self) -> Option<f64> {
916 self.bocpd.as_ref().map(|b| b.p_burst())
917 }
918
919 #[inline]
924 pub fn bocpd_recommended_delay(&self) -> Option<u64> {
925 self.bocpd
926 .as_ref()
927 .map(|b| b.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms))
928 }
929
930 pub fn event_rate(&self) -> f64 {
932 self.calculate_event_rate(Instant::now())
933 }
934
935 #[inline]
937 pub fn last_applied(&self) -> (u16, u16) {
938 self.last_applied
939 }
940
941 pub fn logs(&self) -> &[DecisionLog] {
943 &self.logs
944 }
945
946 pub fn clear_logs(&mut self) {
948 self.logs.clear();
949 self.log_start = None;
950 self.config_logged = false;
951 }
952
953 pub fn stats(&self) -> CoalescerStats {
955 CoalescerStats {
956 event_count: self.event_count,
957 regime: self.regime,
958 event_rate: self.event_rate(),
959 has_pending: self.pending_size.is_some(),
960 last_applied: self.last_applied,
961 }
962 }
963
964 #[must_use]
966 pub fn decision_logs_jsonl(&self) -> String {
967 let (cols, rows) = self.last_applied;
968 let run_id = self.evidence_run_id.as_str();
969 let screen_mode = self.evidence_screen_mode;
970 self.logs
971 .iter()
972 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows))
973 .collect::<Vec<_>>()
974 .join("\n")
975 }
976
977 #[must_use]
979 pub fn decision_checksum(&self) -> u64 {
980 let mut hash = FNV_OFFSET_BASIS;
981 for entry in &self.logs {
982 fnv_hash_bytes(&mut hash, &entry.event_idx.to_le_bytes());
983 fnv_hash_bytes(&mut hash, &entry.elapsed_ms.to_bits().to_le_bytes());
984 fnv_hash_bytes(&mut hash, &entry.dt_ms.to_bits().to_le_bytes());
985 fnv_hash_bytes(&mut hash, &entry.event_rate.to_bits().to_le_bytes());
986 fnv_hash_bytes(
987 &mut hash,
988 &[match entry.regime {
989 Regime::Steady => 0u8,
990 Regime::Burst => 1u8,
991 }],
992 );
993 fnv_hash_bytes(&mut hash, entry.action.as_bytes());
994 fnv_hash_bytes(&mut hash, &[0u8]); fnv_hash_bytes(&mut hash, &[entry.pending_size.is_some() as u8]);
997 if let Some((w, h)) = entry.pending_size {
998 fnv_hash_bytes(&mut hash, &w.to_le_bytes());
999 fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1000 }
1001
1002 fnv_hash_bytes(&mut hash, &[entry.applied_size.is_some() as u8]);
1003 if let Some((w, h)) = entry.applied_size {
1004 fnv_hash_bytes(&mut hash, &w.to_le_bytes());
1005 fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1006 }
1007
1008 fnv_hash_bytes(
1009 &mut hash,
1010 &entry.time_since_render_ms.to_bits().to_le_bytes(),
1011 );
1012 fnv_hash_bytes(&mut hash, &[entry.coalesce_ms.is_some() as u8]);
1013 if let Some(ms) = entry.coalesce_ms {
1014 fnv_hash_bytes(&mut hash, &ms.to_bits().to_le_bytes());
1015 }
1016 fnv_hash_bytes(&mut hash, &[entry.forced as u8]);
1017 }
1018 hash
1019 }
1020
1021 #[must_use]
1023 pub fn decision_checksum_hex(&self) -> String {
1024 format!("{:016x}", self.decision_checksum())
1025 }
1026
1027 #[must_use]
1029 #[allow(clippy::field_reassign_with_default)]
1030 pub fn decision_summary(&self) -> DecisionSummary {
1031 let mut summary = DecisionSummary::default();
1032 summary.decision_count = self.logs.len();
1033 summary.last_applied = self.last_applied;
1034 summary.regime = self.regime;
1035
1036 for entry in &self.logs {
1037 match entry.action {
1038 "apply" | "apply_forced" | "apply_immediate" => {
1039 summary.apply_count += 1;
1040 if entry.forced {
1041 summary.forced_apply_count += 1;
1042 }
1043 }
1044 "coalesce" => summary.coalesce_count += 1,
1045 "skip_same_size" => summary.skip_count += 1,
1046 _ => {}
1047 }
1048 }
1049
1050 summary.checksum = self.decision_checksum();
1051 summary
1052 }
1053
1054 #[must_use]
1056 pub fn evidence_to_jsonl(&self) -> String {
1057 let mut lines = Vec::with_capacity(self.logs.len() + 2);
1058 let (cols, rows) = self.last_applied;
1059 let run_id = self.evidence_run_id.as_str();
1060 let screen_mode = self.evidence_screen_mode;
1061 let summary_event_idx = self.logs.last().map(|entry| entry.event_idx).unwrap_or(0);
1062 lines.push(self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1063 lines.extend(
1064 self.logs
1065 .iter()
1066 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1067 );
1068 lines.push(self.decision_summary().to_jsonl(
1069 run_id,
1070 screen_mode,
1071 cols,
1072 rows,
1073 summary_event_idx,
1074 ));
1075 lines.join("\n")
1076 }
1077
1078 fn apply_pending_at(&mut self, now: Instant, forced: bool) -> CoalesceAction {
1081 let Some((width, height)) = self.pending_size.take() else {
1082 return CoalesceAction::None;
1083 };
1084
1085 let coalesce_time = self
1086 .window_start
1087 .map(|s| duration_since_or_zero(now, s))
1088 .unwrap_or(Duration::ZERO);
1089 let coalesce_ms = coalesce_time.as_secs_f64() * 1000.0;
1090
1091 self.cycle_times.push(coalesce_ms);
1093
1094 self.window_start = None;
1095 self.last_applied = (width, height);
1096 self.last_render = now;
1097
1098 self.events_in_window = 0;
1100
1101 self.log_decision(
1102 now,
1103 if forced { "apply_forced" } else { "apply" },
1104 forced,
1105 None,
1106 Some(coalesce_ms),
1107 );
1108
1109 if let Some(ref hooks) = self.telemetry_hooks
1111 && let Some(entry) = self.logs.last()
1112 {
1113 hooks.fire_resize_applied(entry);
1114 }
1115
1116 CoalesceAction::ApplyResize {
1117 width,
1118 height,
1119 coalesce_time,
1120 forced_by_deadline: forced,
1121 }
1122 }
1123
1124 #[inline]
1125 fn current_delay_ms(&self) -> u64 {
1126 if let Some(ref bocpd) = self.bocpd {
1127 bocpd.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms)
1128 } else {
1129 match self.regime {
1130 Regime::Steady => self.config.steady_delay_ms,
1131 Regime::Burst => self.config.burst_delay_ms,
1132 }
1133 }
1134 }
1135
1136 fn update_regime(&mut self, now: Instant) {
1137 let old_regime = self.regime;
1138
1139 if let Some(ref mut bocpd) = self.bocpd {
1141 bocpd.observe_event(now);
1143
1144 self.regime = match bocpd.regime() {
1146 BocpdRegime::Steady => Regime::Steady,
1147 BocpdRegime::Burst => Regime::Burst,
1148 BocpdRegime::Transitional => {
1149 self.regime
1151 }
1152 };
1153 } else {
1154 let rate = self.calculate_event_rate(now);
1156
1157 match self.regime {
1158 Regime::Steady => {
1159 if rate >= self.config.burst_enter_rate {
1160 self.regime = Regime::Burst;
1161 self.cooldown_remaining = self.config.cooldown_frames;
1162 }
1163 }
1164 Regime::Burst => {
1165 if rate < self.config.burst_exit_rate {
1166 if self.cooldown_remaining == 0 {
1168 self.cooldown_remaining = self.config.cooldown_frames;
1169 }
1170 } else {
1171 self.cooldown_remaining = self.config.cooldown_frames;
1173 }
1174 }
1175 }
1176 }
1177
1178 if old_regime != self.regime {
1180 self.regime_transitions += 1;
1181 if let Some(ref hooks) = self.telemetry_hooks {
1182 hooks.fire_regime_change(old_regime, self.regime);
1183 }
1184 }
1185 }
1186
1187 fn calculate_event_rate(&self, now: Instant) -> f64 {
1188 if self.event_times.len() < 2 {
1189 return 0.0;
1190 }
1191
1192 let first = *self
1193 .event_times
1194 .front()
1195 .expect("event_times has >=2 elements per length guard");
1196 let window_duration = match now.checked_duration_since(first) {
1197 Some(duration) => duration,
1198 None => return 0.0,
1199 };
1200
1201 let duration_secs = window_duration.as_secs_f64().max(0.001);
1204
1205 (self.event_times.len() as f64) / duration_secs
1206 }
1207
1208 fn log_decision(
1209 &mut self,
1210 now: Instant,
1211 action: &'static str,
1212 forced: bool,
1213 dt_ms_override: Option<f64>,
1214 coalesce_ms: Option<f64>,
1215 ) {
1216 if !self.config.enable_logging {
1217 return;
1218 }
1219
1220 if self.log_start.is_none() {
1221 self.log_start = Some(now);
1222 }
1223
1224 let elapsed_ms = self
1225 .log_start
1226 .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1227 .unwrap_or(0.0);
1228
1229 let dt_ms = dt_ms_override
1230 .or_else(|| {
1231 self.last_event
1232 .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1233 })
1234 .unwrap_or(0.0);
1235
1236 let time_since_render_ms =
1237 duration_since_or_zero(now, self.last_render).as_secs_f64() * 1000.0;
1238
1239 let applied_size =
1240 if action == "apply" || action == "apply_forced" || action == "apply_immediate" {
1241 Some(self.last_applied)
1242 } else {
1243 None
1244 };
1245
1246 self.logs.push(DecisionLog {
1247 timestamp: now,
1248 elapsed_ms,
1249 event_idx: self.event_count,
1250 dt_ms,
1251 event_rate: self.calculate_event_rate(now),
1252 regime: self.regime,
1253 action,
1254 pending_size: self.pending_size,
1255 applied_size,
1256 time_since_render_ms,
1257 coalesce_ms,
1258 forced,
1259 });
1260
1261 if let Some(ref sink) = self.evidence_sink {
1262 let (cols, rows) = self.last_applied;
1263 let run_id = self.evidence_run_id.as_str();
1264 let screen_mode = self.evidence_screen_mode;
1265 if !self.config_logged {
1266 let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1267 self.config_logged = true;
1268 }
1269 if let Some(entry) = self.logs.last() {
1270 let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1271 }
1272 if let Some(ref bocpd) = self.bocpd
1273 && let Some(jsonl) = bocpd.decision_log_jsonl(
1274 self.config.steady_delay_ms,
1275 self.config.burst_delay_ms,
1276 forced,
1277 )
1278 {
1279 let _ = sink.write_jsonl(&jsonl);
1280 }
1281 }
1282 }
1283}
1284
1285#[derive(Debug, Clone)]
1287pub struct CoalescerStats {
1288 pub event_count: u64,
1290 pub regime: Regime,
1292 pub event_rate: f64,
1294 pub has_pending: bool,
1296 pub last_applied: (u16, u16),
1298}
1299
1300#[derive(Debug, Clone, Default)]
1302pub struct DecisionSummary {
1303 pub decision_count: usize,
1305 pub apply_count: usize,
1307 pub forced_apply_count: usize,
1309 pub coalesce_count: usize,
1311 pub skip_count: usize,
1313 pub regime: Regime,
1315 pub last_applied: (u16, u16),
1317 pub checksum: u64,
1319}
1320
1321impl DecisionSummary {
1322 #[must_use]
1324 pub fn checksum_hex(&self) -> String {
1325 format!("{:016x}", self.checksum)
1326 }
1327
1328 #[must_use]
1330 pub fn to_jsonl(
1331 &self,
1332 run_id: &str,
1333 screen_mode: ScreenMode,
1334 cols: u16,
1335 rows: u16,
1336 event_idx: u64,
1337 ) -> String {
1338 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
1339 format!(
1340 r#"{{{prefix},"event":"summary","decisions":{},"applies":{},"forced_applies":{},"coalesces":{},"skips":{},"regime":"{}","last_w":{},"last_h":{},"checksum":"{}"}}"#,
1341 self.decision_count,
1342 self.apply_count,
1343 self.forced_apply_count,
1344 self.coalesce_count,
1345 self.skip_count,
1346 self.regime.as_str(),
1347 self.last_applied.0,
1348 self.last_applied.1,
1349 self.checksum_hex()
1350 )
1351 }
1352}
1353
1354pub type OnResizeApplied = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1360pub type OnRegimeChange = Box<dyn Fn(Regime, Regime) + Send + Sync>;
1362pub type OnCoalesceDecision = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1364
1365pub struct TelemetryHooks {
1380 on_resize_applied: Option<OnResizeApplied>,
1382 on_regime_change: Option<OnRegimeChange>,
1384 on_decision: Option<OnCoalesceDecision>,
1386 emit_tracing: bool,
1388}
1389
1390impl Default for TelemetryHooks {
1391 fn default() -> Self {
1392 Self::new()
1393 }
1394}
1395
1396impl std::fmt::Debug for TelemetryHooks {
1397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1398 f.debug_struct("TelemetryHooks")
1399 .field("on_resize_applied", &self.on_resize_applied.is_some())
1400 .field("on_regime_change", &self.on_regime_change.is_some())
1401 .field("on_decision", &self.on_decision.is_some())
1402 .field("emit_tracing", &self.emit_tracing)
1403 .finish()
1404 }
1405}
1406
1407impl TelemetryHooks {
1408 #[must_use]
1410 pub fn new() -> Self {
1411 Self {
1412 on_resize_applied: None,
1413 on_regime_change: None,
1414 on_decision: None,
1415 emit_tracing: false,
1416 }
1417 }
1418
1419 #[must_use]
1421 pub fn on_resize_applied<F>(mut self, callback: F) -> Self
1422 where
1423 F: Fn(&DecisionLog) + Send + Sync + 'static,
1424 {
1425 self.on_resize_applied = Some(Box::new(callback));
1426 self
1427 }
1428
1429 #[must_use]
1431 pub fn on_regime_change<F>(mut self, callback: F) -> Self
1432 where
1433 F: Fn(Regime, Regime) + Send + Sync + 'static,
1434 {
1435 self.on_regime_change = Some(Box::new(callback));
1436 self
1437 }
1438
1439 #[must_use]
1441 pub fn on_decision<F>(mut self, callback: F) -> Self
1442 where
1443 F: Fn(&DecisionLog) + Send + Sync + 'static,
1444 {
1445 self.on_decision = Some(Box::new(callback));
1446 self
1447 }
1448
1449 #[must_use]
1454 pub fn with_tracing(mut self, enabled: bool) -> Self {
1455 self.emit_tracing = enabled;
1456 self
1457 }
1458
1459 pub fn has_resize_applied(&self) -> bool {
1461 self.on_resize_applied.is_some()
1462 }
1463
1464 pub fn has_regime_change(&self) -> bool {
1466 self.on_regime_change.is_some()
1467 }
1468
1469 pub fn has_decision(&self) -> bool {
1471 self.on_decision.is_some()
1472 }
1473
1474 fn fire_resize_applied(&self, entry: &DecisionLog) {
1476 if let Some(ref cb) = self.on_resize_applied {
1477 cb(entry);
1478 }
1479 if self.emit_tracing {
1480 Self::emit_resize_tracing(entry);
1481 }
1482 }
1483
1484 fn fire_regime_change(&self, from: Regime, to: Regime) {
1486 if let Some(ref cb) = self.on_regime_change {
1487 cb(from, to);
1488 }
1489 if self.emit_tracing {
1490 tracing::debug!(
1491 target: "ftui.decision.resize",
1492 from_regime = %from.as_str(),
1493 to_regime = %to.as_str(),
1494 "regime_change"
1495 );
1496 }
1497 }
1498
1499 fn fire_decision(&self, entry: &DecisionLog) {
1501 if let Some(ref cb) = self.on_decision {
1502 cb(entry);
1503 }
1504 }
1505
1506 fn emit_resize_tracing(entry: &DecisionLog) {
1508 let (pending_w, pending_h) = entry.pending_size.unwrap_or((0, 0));
1509 let (applied_w, applied_h) = entry.applied_size.unwrap_or((0, 0));
1510 let coalesce_ms = entry.coalesce_ms.unwrap_or(0.0);
1511
1512 tracing::info!(
1513 target: "ftui.decision.resize",
1514 event_idx = entry.event_idx,
1515 elapsed_ms = entry.elapsed_ms,
1516 dt_ms = entry.dt_ms,
1517 event_rate = entry.event_rate,
1518 regime = %entry.regime.as_str(),
1519 action = entry.action,
1520 pending_w = pending_w,
1521 pending_h = pending_h,
1522 applied_w = applied_w,
1523 applied_h = applied_h,
1524 time_since_render_ms = entry.time_since_render_ms,
1525 coalesce_ms = coalesce_ms,
1526 forced = entry.forced,
1527 "resize_decision"
1528 );
1529 }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 fn test_config() -> CoalescerConfig {
1537 CoalescerConfig {
1538 steady_delay_ms: 16,
1539 burst_delay_ms: 40,
1540 hard_deadline_ms: 100,
1541 burst_enter_rate: 10.0,
1542 burst_exit_rate: 5.0,
1543 cooldown_frames: 3,
1544 rate_window_size: 8,
1545 enable_logging: true,
1546 enable_bocpd: false,
1547 bocpd_config: None,
1548 }
1549 }
1550
1551 #[derive(Debug, Clone, Copy)]
1552 struct SimulationMetrics {
1553 event_count: u64,
1554 apply_count: u64,
1555 forced_count: u64,
1556 mean_coalesce_ms: f64,
1557 max_coalesce_ms: f64,
1558 decision_checksum: u64,
1559 final_regime: Regime,
1560 }
1561
1562 impl SimulationMetrics {
1563 fn to_jsonl(self, pattern: &str, mode: &str) -> String {
1564 let pattern = json_escape(pattern);
1565 let mode = json_escape(mode);
1566 let apply_ratio = if self.event_count == 0 {
1567 0.0
1568 } else {
1569 self.apply_count as f64 / self.event_count as f64
1570 };
1571
1572 format!(
1573 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}"}}"#,
1574 self.event_count,
1575 self.apply_count,
1576 self.forced_count,
1577 apply_ratio,
1578 self.mean_coalesce_ms,
1579 self.max_coalesce_ms,
1580 self.final_regime.as_str(),
1581 self.decision_checksum
1582 )
1583 }
1584 }
1585
1586 #[derive(Debug, Clone, Copy)]
1587 struct SimulationComparison {
1588 apply_delta: i64,
1589 mean_coalesce_delta_ms: f64,
1590 }
1591
1592 impl SimulationComparison {
1593 fn from_metrics(heuristic: SimulationMetrics, bocpd: SimulationMetrics) -> Self {
1594 let heuristic_apply = i64::try_from(heuristic.apply_count).unwrap_or(i64::MAX);
1595 let bocpd_apply = i64::try_from(bocpd.apply_count).unwrap_or(i64::MAX);
1596 let apply_delta = heuristic_apply.saturating_sub(bocpd_apply);
1597 let mean_coalesce_delta_ms = heuristic.mean_coalesce_ms - bocpd.mean_coalesce_ms;
1598 Self {
1599 apply_delta,
1600 mean_coalesce_delta_ms,
1601 }
1602 }
1603
1604 fn to_jsonl(self, pattern: &str) -> String {
1605 let pattern = json_escape(pattern);
1606 format!(
1607 r#"{{"event":"simulation_compare","pattern":"{pattern}","apply_delta":{},"mean_coalesce_delta_ms":{:.3}}}"#,
1608 self.apply_delta, self.mean_coalesce_delta_ms
1609 )
1610 }
1611 }
1612
1613 fn as_u64(value: usize) -> u64 {
1614 u64::try_from(value).unwrap_or(u64::MAX)
1615 }
1616
1617 fn build_schedule(base: Instant, events: &[(u16, u16, u64)]) -> Vec<(Instant, u16, u16)> {
1618 let mut schedule = Vec::with_capacity(events.len());
1619 let mut elapsed_ms = 0u64;
1620 for (w, h, delay_ms) in events {
1621 elapsed_ms = elapsed_ms.saturating_add(*delay_ms);
1622 schedule.push((base + Duration::from_millis(elapsed_ms), *w, *h));
1623 }
1624 schedule
1625 }
1626
1627 fn run_simulation(
1628 events: &[(u16, u16, u64)],
1629 config: CoalescerConfig,
1630 tick_ms: u64,
1631 ) -> SimulationMetrics {
1632 let mut c = ResizeCoalescer::new(config, (80, 24));
1633 let base = Instant::now();
1634 let schedule = build_schedule(base, events);
1635 let last_event_ms = schedule
1636 .last()
1637 .map(|(time, _, _)| {
1638 u64::try_from(duration_since_or_zero(*time, base).as_millis()).unwrap_or(u64::MAX)
1639 })
1640 .unwrap_or(0);
1641 let end_ms = last_event_ms
1642 .saturating_add(c.config.hard_deadline_ms)
1643 .saturating_add(tick_ms);
1644
1645 let mut next_idx = 0usize;
1646 let mut now_ms = 0u64;
1647 while now_ms <= end_ms {
1648 let now = base + Duration::from_millis(now_ms);
1649
1650 while next_idx < schedule.len() && schedule[next_idx].0 <= now {
1651 let (event_time, w, h) = schedule[next_idx];
1652 let _ = c.handle_resize_at(w, h, event_time);
1653 next_idx += 1;
1654 }
1655
1656 let _ = c.tick_at(now);
1657 now_ms = now_ms.saturating_add(tick_ms);
1658 }
1659
1660 let mut coalesce_values = Vec::new();
1661 let mut apply_count = 0usize;
1662 let mut forced_count = 0usize;
1663 for entry in c.logs() {
1664 if matches!(entry.action, "apply" | "apply_forced" | "apply_immediate") {
1665 apply_count += 1;
1666 if entry.forced {
1667 forced_count += 1;
1668 }
1669 if let Some(ms) = entry.coalesce_ms {
1670 coalesce_values.push(ms);
1671 }
1672 }
1673 }
1674
1675 let max_coalesce_ms = coalesce_values
1676 .iter()
1677 .copied()
1678 .fold(0.0_f64, |acc, value| acc.max(value));
1679 let mean_coalesce_ms = if coalesce_values.is_empty() {
1680 0.0
1681 } else {
1682 let sum = coalesce_values.iter().sum::<f64>();
1683 sum / as_u64(coalesce_values.len()) as f64
1684 };
1685
1686 SimulationMetrics {
1687 event_count: as_u64(events.len()),
1688 apply_count: as_u64(apply_count),
1689 forced_count: as_u64(forced_count),
1690 mean_coalesce_ms,
1691 max_coalesce_ms,
1692 decision_checksum: c.decision_checksum(),
1693 final_regime: c.regime(),
1694 }
1695 }
1696
1697 fn steady_pattern() -> Vec<(u16, u16, u64)> {
1698 let mut events = Vec::new();
1699 for i in 0..8u16 {
1700 let width = 90 + i;
1701 let height = 30 + (i % 3);
1702 events.push((width, height, 300));
1703 }
1704 events
1705 }
1706
1707 fn burst_pattern() -> Vec<(u16, u16, u64)> {
1708 let mut events = Vec::new();
1709 for i in 0..30u16 {
1710 let width = 100 + i;
1711 let height = 25 + (i % 5);
1712 events.push((width, height, 10));
1713 }
1714 events
1715 }
1716
1717 fn oscillatory_pattern() -> Vec<(u16, u16, u64)> {
1718 let mut events = Vec::new();
1719 let sizes = [(120, 40), (140, 28), (130, 36), (150, 32)];
1720 let delays = [40u64, 200u64, 60u64, 180u64];
1721 for i in 0..16usize {
1722 let (w, h) = sizes[i % sizes.len()];
1723 let delay = delays[i % delays.len()];
1724 events.push((w + (i as u16 % 3), h, delay));
1725 }
1726 events
1727 }
1728
1729 #[test]
1730 fn new_coalescer_starts_in_steady() {
1731 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
1732 assert_eq!(c.regime(), Regime::Steady);
1733 assert!(!c.has_pending());
1734 }
1735
1736 #[test]
1737 fn same_size_returns_none() {
1738 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1739 let action = c.handle_resize(80, 24);
1740 assert_eq!(action, CoalesceAction::None);
1741 }
1742
1743 #[test]
1744 fn different_size_shows_placeholder() {
1745 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1746 let action = c.handle_resize(100, 40);
1747 assert_eq!(action, CoalesceAction::ShowPlaceholder);
1748 assert!(c.has_pending());
1749 }
1750
1751 #[test]
1752 fn latest_wins_semantics() {
1753 let config = test_config();
1754 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1755
1756 let base = Instant::now();
1757
1758 c.handle_resize_at(90, 30, base);
1760 c.handle_resize_at(100, 40, base + Duration::from_millis(5));
1761 c.handle_resize_at(110, 50, base + Duration::from_millis(10));
1762
1763 let action = c.tick_at(base + Duration::from_millis(60));
1765
1766 let (width, height) = if let CoalesceAction::ApplyResize { width, height, .. } = action {
1767 (width, height)
1768 } else {
1769 assert!(
1770 matches!(action, CoalesceAction::ApplyResize { .. }),
1771 "Expected ApplyResize, got {action:?}"
1772 );
1773 return;
1774 };
1775 assert_eq!((width, height), (110, 50), "Should apply latest size");
1776 }
1777
1778 #[test]
1779 fn hard_deadline_forces_apply() {
1780 let config = test_config();
1781 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1782
1783 let base = Instant::now();
1784
1785 c.handle_resize_at(100, 40, base);
1787
1788 let action = c.tick_at(base + Duration::from_millis(150));
1790
1791 let forced_by_deadline = if let CoalesceAction::ApplyResize {
1792 forced_by_deadline, ..
1793 } = action
1794 {
1795 forced_by_deadline
1796 } else {
1797 assert!(
1798 matches!(action, CoalesceAction::ApplyResize { .. }),
1799 "Expected ApplyResize, got {action:?}"
1800 );
1801 return;
1802 };
1803 assert!(forced_by_deadline, "Should be forced by deadline");
1804 }
1805
1806 #[test]
1807 fn burst_mode_detection() {
1808 let config = test_config();
1809 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1810
1811 let base = Instant::now();
1812
1813 for i in 0..15 {
1815 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
1816 }
1817
1818 assert_eq!(c.regime(), Regime::Burst);
1819 }
1820
1821 #[test]
1822 fn steady_mode_fast_response() {
1823 let config = test_config();
1824 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1825
1826 let base = Instant::now();
1827
1828 c.handle_resize_at(100, 40, base);
1830
1831 let action = c.tick_at(base + Duration::from_millis(20));
1833
1834 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1835 }
1836
1837 #[test]
1838 fn record_external_apply_updates_state_and_logs() {
1839 let config = test_config();
1840 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1841
1842 let base = Instant::now();
1843 c.handle_resize_at(100, 40, base);
1844 c.record_external_apply(120, 50, base + Duration::from_millis(5));
1845
1846 assert!(!c.has_pending());
1847 assert_eq!(c.last_applied(), (120, 50));
1848
1849 let summary = c.decision_summary();
1850 assert_eq!(summary.apply_count, 1);
1851 assert_eq!(summary.last_applied, (120, 50));
1852 assert!(
1853 c.logs()
1854 .iter()
1855 .any(|entry| entry.action == "apply_immediate"),
1856 "record_external_apply should emit apply_immediate decision"
1857 );
1858 }
1859
1860 #[test]
1861 fn coalesce_time_tracked() {
1862 let config = test_config();
1863 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1864
1865 let base = Instant::now();
1866
1867 c.handle_resize_at(100, 40, base);
1868 let action = c.tick_at(base + Duration::from_millis(50));
1869
1870 let coalesce_time = if let CoalesceAction::ApplyResize { coalesce_time, .. } = action {
1871 coalesce_time
1872 } else {
1873 assert!(
1874 matches!(action, CoalesceAction::ApplyResize { .. }),
1875 "Expected ApplyResize"
1876 );
1877 return;
1878 };
1879 assert!(coalesce_time >= Duration::from_millis(40));
1880 assert!(coalesce_time <= Duration::from_millis(60));
1881 }
1882
1883 #[test]
1884 fn event_rate_calculation() {
1885 let config = test_config();
1886 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1887
1888 let base = Instant::now();
1889
1890 for i in 0..10 {
1892 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 100));
1893 }
1894
1895 let rate = c.calculate_event_rate(base + Duration::from_millis(1000));
1896 assert!(rate > 8.0 && rate < 12.0, "Rate should be ~10 events/sec");
1897 }
1898
1899 #[test]
1900 fn rapid_burst_triggers_high_rate() {
1901 let config = test_config();
1902 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1903 let base = Instant::now();
1904
1905 for _ in 0..8 {
1907 c.handle_resize_at(80, 24, base);
1908 }
1909
1910 let rate = c.calculate_event_rate(base);
1911 assert!(
1913 rate >= 1000.0,
1914 "Rate should be high for instantaneous burst, got {}",
1915 rate
1916 );
1917 }
1918
1919 #[test]
1920 fn cooldown_prevents_immediate_exit() {
1921 let config = test_config();
1922 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1923
1924 let base = Instant::now();
1925
1926 for i in 0..15 {
1928 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
1929 }
1930 assert_eq!(c.regime(), Regime::Burst);
1931
1932 c.tick_at(base + Duration::from_millis(500));
1934 c.tick_at(base + Duration::from_millis(600));
1935
1936 c.tick_at(base + Duration::from_millis(700));
1938 c.tick_at(base + Duration::from_millis(800));
1939 c.tick_at(base + Duration::from_millis(900));
1940
1941 }
1944
1945 #[test]
1946 fn logging_captures_decisions() {
1947 let mut config = test_config();
1948 config.enable_logging = true;
1949 let mut c = ResizeCoalescer::new(config, (80, 24));
1950
1951 c.handle_resize(100, 40);
1952
1953 assert!(!c.logs().is_empty());
1954 assert_eq!(c.logs()[0].action, "coalesce");
1955 }
1956
1957 #[test]
1958 fn logging_jsonl_format() {
1959 let mut config = test_config();
1960 config.enable_logging = true;
1961 let mut c = ResizeCoalescer::new(config, (80, 24));
1962
1963 c.handle_resize(100, 40);
1964 let (cols, rows) = c.last_applied();
1965 let jsonl = c.logs()[0].to_jsonl("resize-test", ScreenMode::AltScreen, cols, rows);
1966
1967 assert!(jsonl.contains("\"event\":\"decision\""));
1968 assert!(jsonl.contains("\"action\":\"coalesce\""));
1969 assert!(jsonl.contains("\"regime\":\"steady\""));
1970 assert!(jsonl.contains("\"pending_w\":100"));
1971 assert!(jsonl.contains("\"pending_h\":40"));
1972 }
1973
1974 #[test]
1975 fn apply_logs_coalesce_ms() {
1976 let mut config = test_config();
1977 config.enable_logging = true;
1978 let mut c = ResizeCoalescer::new(config, (80, 24));
1979
1980 let base = Instant::now();
1981 c.handle_resize_at(100, 40, base);
1982 let action = c.tick_at(base + Duration::from_millis(50));
1983 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1984
1985 let last = c.logs().last().expect("Expected a decision log entry");
1986 assert!(last.coalesce_ms.is_some());
1987 assert!(last.coalesce_ms.unwrap() >= 0.0);
1988 }
1989
1990 #[test]
1991 fn decision_checksum_is_stable() {
1992 let mut config = test_config();
1993 config.enable_logging = true;
1994
1995 let base = Instant::now();
1996 let mut c1 = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
1997 let mut c2 = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
1998
1999 for c in [&mut c1, &mut c2] {
2000 c.handle_resize_at(90, 30, base);
2001 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2002 let _ = c.tick_at(base + Duration::from_millis(80));
2003 }
2004
2005 assert_eq!(c1.decision_checksum(), c2.decision_checksum());
2006 }
2007
2008 #[test]
2009 fn evidence_jsonl_includes_summary() {
2010 let mut config = test_config();
2011 config.enable_logging = true;
2012 let mut c = ResizeCoalescer::new(config, (80, 24));
2013
2014 c.handle_resize(100, 40);
2015 let jsonl = c.evidence_to_jsonl();
2016
2017 assert!(jsonl.contains("\"event\":\"config\""));
2018 assert!(jsonl.contains("\"event\":\"summary\""));
2019 }
2020
2021 #[test]
2022 fn evidence_jsonl_parses_and_has_required_fields() {
2023 use serde_json::Value;
2024
2025 let mut config = test_config();
2026 config.enable_logging = true;
2027 let base = Instant::now();
2028 let mut c = ResizeCoalescer::new(config, (80, 24))
2029 .with_last_render(base)
2030 .with_evidence_run_id("resize-test")
2031 .with_screen_mode(ScreenMode::AltScreen);
2032
2033 c.handle_resize_at(90, 30, base);
2034 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2035 let _ = c.tick_at(base + Duration::from_millis(120));
2036
2037 let jsonl = c.evidence_to_jsonl();
2038 let mut saw_config = false;
2039 let mut saw_decision = false;
2040 let mut saw_summary = false;
2041
2042 for line in jsonl.lines() {
2043 let value: Value = serde_json::from_str(line).expect("valid JSONL evidence");
2044 let event = value
2045 .get("event")
2046 .and_then(Value::as_str)
2047 .expect("event field");
2048 assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
2049 assert_eq!(value["run_id"], "resize-test");
2050 assert!(
2051 value["event_idx"].is_number(),
2052 "event_idx should be numeric"
2053 );
2054 assert_eq!(value["screen_mode"], "altscreen");
2055 assert!(value["cols"].is_number(), "cols should be numeric");
2056 assert!(value["rows"].is_number(), "rows should be numeric");
2057 match event {
2058 "config" => {
2059 for key in [
2060 "steady_delay_ms",
2061 "burst_delay_ms",
2062 "hard_deadline_ms",
2063 "burst_enter_rate",
2064 "burst_exit_rate",
2065 "cooldown_frames",
2066 "rate_window_size",
2067 "logging_enabled",
2068 ] {
2069 assert!(value.get(key).is_some(), "missing config field {key}");
2070 }
2071 saw_config = true;
2072 }
2073 "decision" => {
2074 for key in [
2075 "idx",
2076 "elapsed_ms",
2077 "dt_ms",
2078 "event_rate",
2079 "regime",
2080 "action",
2081 "pending_w",
2082 "pending_h",
2083 "applied_w",
2084 "applied_h",
2085 "time_since_render_ms",
2086 "coalesce_ms",
2087 "forced",
2088 ] {
2089 assert!(value.get(key).is_some(), "missing decision field {key}");
2090 }
2091 saw_decision = true;
2092 }
2093 "summary" => {
2094 for key in [
2095 "decisions",
2096 "applies",
2097 "forced_applies",
2098 "coalesces",
2099 "skips",
2100 "regime",
2101 "last_w",
2102 "last_h",
2103 "checksum",
2104 ] {
2105 assert!(value.get(key).is_some(), "missing summary field {key}");
2106 }
2107 saw_summary = true;
2108 }
2109 _ => {}
2110 }
2111 }
2112
2113 assert!(saw_config, "config evidence missing");
2114 assert!(saw_decision, "decision evidence missing");
2115 assert!(saw_summary, "summary evidence missing");
2116 }
2117
2118 #[test]
2119 fn evidence_jsonl_is_deterministic_for_fixed_schedule() {
2120 let mut config = test_config();
2121 config.enable_logging = true;
2122 let base = Instant::now();
2123
2124 let run = || {
2125 let mut c = ResizeCoalescer::new(config.clone(), (80, 24))
2126 .with_last_render(base)
2127 .with_evidence_run_id("resize-test")
2128 .with_screen_mode(ScreenMode::AltScreen);
2129 c.handle_resize_at(90, 30, base);
2130 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2131 let _ = c.tick_at(base + Duration::from_millis(120));
2132 c.evidence_to_jsonl()
2133 };
2134
2135 let first = run();
2136 let second = run();
2137 assert_eq!(first, second);
2138 }
2139
2140 #[test]
2141 fn bocpd_logging_inherits_coalescer_logging() {
2142 let mut config = test_config();
2143 config.enable_bocpd = true;
2144 config.bocpd_config = Some(BocpdConfig::default());
2145
2146 let c = ResizeCoalescer::new(config, (80, 24));
2147 let bocpd = c.bocpd().expect("BOCPD should be enabled");
2148 assert!(bocpd.config().enable_logging);
2149 }
2150
2151 #[test]
2152 fn stats_reflect_state() {
2153 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2154
2155 c.handle_resize(100, 40);
2156
2157 let stats = c.stats();
2158 assert_eq!(stats.event_count, 1);
2159 assert!(stats.has_pending);
2160 assert_eq!(stats.last_applied, (80, 24));
2161 }
2162
2163 #[test]
2164 fn time_until_apply_calculation() {
2165 let config = test_config();
2166 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2167
2168 let base = Instant::now();
2169 c.handle_resize_at(100, 40, base);
2170
2171 let time_left = c.time_until_apply(base + Duration::from_millis(5));
2172 assert!(time_left.is_some());
2173 let time_left = time_left.unwrap();
2174 assert!(time_left.as_millis() > 0);
2175 assert!(time_left.as_millis() < config.steady_delay_ms as u128);
2176 }
2177
2178 #[test]
2179 fn deterministic_behavior() {
2180 let config = test_config();
2181
2182 let results: Vec<_> = (0..2)
2184 .map(|_| {
2185 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2186 let base = Instant::now();
2187
2188 for i in 0..5 {
2189 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 20));
2190 }
2191
2192 c.tick_at(base + Duration::from_millis(200))
2193 })
2194 .collect();
2195
2196 assert_eq!(results[0], results[1], "Results must be deterministic");
2197 }
2198
2199 #[test]
2200 fn simulation_bocpd_vs_heuristic_metrics() {
2201 let tick_ms = 5;
2202 let mut heuristic_config = test_config();
2205 heuristic_config.burst_enter_rate = 60.0;
2206 heuristic_config.burst_exit_rate = 30.0;
2207 let mut bocpd_cfg = BocpdConfig::responsive();
2208 bocpd_cfg.burst_prior = 0.35;
2209 bocpd_cfg.steady_threshold = 0.2;
2210 bocpd_cfg.burst_threshold = 0.6;
2211 let bocpd_config = heuristic_config.clone().with_bocpd_config(bocpd_cfg);
2212 let patterns = vec![
2213 ("steady", steady_pattern()),
2214 ("burst", burst_pattern()),
2215 ("oscillatory", oscillatory_pattern()),
2216 ];
2217
2218 for (pattern, events) in patterns {
2219 let heuristic = run_simulation(&events, heuristic_config.clone(), tick_ms);
2220 let bocpd = run_simulation(&events, bocpd_config.clone(), tick_ms);
2221
2222 let heuristic_jsonl = heuristic.to_jsonl(pattern, "heuristic");
2223 let bocpd_jsonl = bocpd.to_jsonl(pattern, "bocpd");
2224 let comparison = SimulationComparison::from_metrics(heuristic, bocpd);
2225 let comparison_jsonl = comparison.to_jsonl(pattern);
2226
2227 eprintln!("{heuristic_jsonl}");
2228 eprintln!("{bocpd_jsonl}");
2229 eprintln!("{comparison_jsonl}");
2230
2231 assert!(heuristic_jsonl.contains("\"event\":\"simulation_summary\""));
2232 assert!(bocpd_jsonl.contains("\"event\":\"simulation_summary\""));
2233 assert!(comparison_jsonl.contains("\"event\":\"simulation_compare\""));
2234
2235 #[allow(clippy::cast_precision_loss)]
2236 let max_allowed = test_config().hard_deadline_ms as f64 + 1.0;
2237 assert!(
2238 heuristic.max_coalesce_ms <= max_allowed,
2239 "heuristic latency bounded for {pattern}"
2240 );
2241 assert!(
2242 bocpd.max_coalesce_ms <= max_allowed,
2243 "bocpd latency bounded for {pattern}"
2244 );
2245
2246 if pattern == "burst" {
2247 let event_count = as_u64(events.len());
2248 assert!(
2249 heuristic.apply_count < event_count,
2250 "heuristic should coalesce under burst pattern"
2251 );
2252 assert!(
2253 bocpd.apply_count < event_count,
2254 "bocpd should coalesce under burst pattern"
2255 );
2256 assert!(
2257 comparison.apply_delta >= 0,
2258 "BOCPD should not increase renders in burst (apply_delta={})",
2259 comparison.apply_delta
2260 );
2261 }
2262 }
2263 }
2264
2265 #[test]
2266 fn never_drops_final_size() {
2267 let config = test_config();
2268 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2269
2270 let base = Instant::now();
2271
2272 let mut intermediate_applies = Vec::new();
2274 for i in 0..100 {
2275 let action = c.handle_resize_at(
2276 80 + (i % 50),
2277 24 + (i % 30),
2278 base + Duration::from_millis(i as u64 * 5),
2279 );
2280 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2281 intermediate_applies.push((width, height));
2282 }
2283 }
2284
2285 let final_action = c.handle_resize_at(200, 100, base + Duration::from_millis(600));
2287
2288 let applied_size = if let CoalesceAction::ApplyResize { width, height, .. } = final_action {
2289 Some((width, height))
2290 } else {
2291 let mut result = None;
2293 for tick in 0..100 {
2294 let action = c.tick_at(base + Duration::from_millis(700 + tick * 20));
2295 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2296 result = Some((width, height));
2297 break;
2298 }
2299 }
2300 result
2301 };
2302
2303 assert_eq!(
2304 applied_size,
2305 Some((200, 100)),
2306 "Must apply final size 200x100"
2307 );
2308 }
2309
2310 #[test]
2311 fn bounded_latency_invariant() {
2312 let config = test_config();
2313 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2314
2315 let base = Instant::now();
2316 c.handle_resize_at(100, 40, base);
2317
2318 let mut applied_at = None;
2320 for ms in 0..200 {
2321 let now = base + Duration::from_millis(ms);
2322 let action = c.tick_at(now);
2323 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2324 applied_at = Some(ms);
2325 break;
2326 }
2327 }
2328
2329 assert!(applied_at.is_some(), "Must apply within reasonable time");
2330 assert!(
2331 applied_at.unwrap() <= config.hard_deadline_ms,
2332 "Must apply within hard deadline"
2333 );
2334 }
2335
2336 mod property {
2341 use super::*;
2342 use proptest::prelude::*;
2343
2344 fn dimension() -> impl Strategy<Value = u16> {
2346 1u16..500
2347 }
2348
2349 fn resize_sequence(max_len: usize) -> impl Strategy<Value = Vec<(u16, u16, u64)>> {
2351 proptest::collection::vec((dimension(), dimension(), 0u64..200), 0..max_len)
2352 }
2353
2354 proptest! {
2355 #[test]
2359 fn determinism_across_sequences(
2360 events in resize_sequence(50),
2361 tick_offset in 100u64..500
2362 ) {
2363 let config = CoalescerConfig::default();
2364
2365 let results: Vec<_> = (0..2)
2366 .map(|_| {
2367 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2368 let base = Instant::now();
2369
2370 for (i, (w, h, delay)) in events.iter().enumerate() {
2371 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2372 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2373 }
2374
2375 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2377 c.tick_at(base + Duration::from_millis(total_time))
2378 })
2379 .collect();
2380
2381 prop_assert_eq!(results[0], results[1], "Results must be deterministic");
2382 }
2383
2384 #[test]
2389 fn latest_wins_never_drops(
2390 events in resize_sequence(20),
2391 final_w in dimension(),
2392 final_h in dimension()
2393 ) {
2394 if events.is_empty() {
2395 return Ok(());
2397 }
2398
2399 let config = CoalescerConfig::default();
2400 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2401 let base = Instant::now();
2402
2403 let mut offset = 0u64;
2405 for (w, h, delay) in &events {
2406 offset += delay;
2407 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2408 }
2409
2410 offset += 50;
2412 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2413
2414 let mut final_applied = None;
2416 for tick in 0..200 {
2417 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2418 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2419 final_applied = Some((width, height));
2420 }
2421 if !c.has_pending() && final_applied.is_some() {
2422 break;
2423 }
2424 }
2425
2426 if let Some((applied_w, applied_h)) = final_applied {
2428 prop_assert_eq!(
2429 (applied_w, applied_h),
2430 (final_w, final_h),
2431 "Must apply the final size {} x {}",
2432 final_w,
2433 final_h
2434 );
2435 }
2436 }
2437
2438 #[test]
2442 fn bounded_latency_maintained(
2443 w in dimension(),
2444 h in dimension()
2445 ) {
2446 let config = CoalescerConfig::default();
2447 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2449 let base = Instant::now();
2450
2451 c.handle_resize_at(w, h, base);
2452
2453 let mut applied_at = None;
2455 for ms in 0..=config.hard_deadline_ms + 50 {
2456 let action = c.tick_at(base + Duration::from_millis(ms));
2457 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2458 applied_at = Some(ms);
2459 break;
2460 }
2461 }
2462
2463 prop_assert!(applied_at.is_some(), "Resize must be applied");
2464 prop_assert!(
2465 applied_at.unwrap() <= config.hard_deadline_ms,
2466 "Must apply within hard deadline ({}ms), took {}ms",
2467 config.hard_deadline_ms,
2468 applied_at.unwrap()
2469 );
2470 }
2471
2472 #[test]
2477 fn no_size_corruption(
2478 w in dimension(),
2479 h in dimension()
2480 ) {
2481 let config = CoalescerConfig::default();
2482 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2484 let base = Instant::now();
2485
2486 c.handle_resize_at(w, h, base);
2487
2488 let mut result = None;
2490 for ms in 0..200 {
2491 let action = c.tick_at(base + Duration::from_millis(ms));
2492 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2493 result = Some((width, height));
2494 break;
2495 }
2496 }
2497
2498 prop_assert!(result.is_some());
2499 let (applied_w, applied_h) = result.unwrap();
2500 prop_assert_eq!(applied_w, w, "Width must not be corrupted");
2501 prop_assert_eq!(applied_h, h, "Height must not be corrupted");
2502 }
2503
2504 #[test]
2508 fn regime_follows_event_rate(
2509 event_count in 1usize..30
2510 ) {
2511 let config = CoalescerConfig {
2512 burst_enter_rate: 10.0,
2513 burst_exit_rate: 5.0,
2514 ..CoalescerConfig::default()
2515 };
2516 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2517 let base = Instant::now();
2518
2519 for i in 0..event_count {
2521 c.handle_resize_at(
2522 80 + i as u16,
2523 24,
2524 base + Duration::from_millis(i as u64 * 50), );
2526 }
2527
2528 if event_count >= 10 {
2530 prop_assert_eq!(
2531 c.regime(),
2532 Regime::Burst,
2533 "Many rapid events should trigger burst mode"
2534 );
2535 }
2536 }
2537
2538 #[test]
2543 fn event_count_invariant(
2544 events in resize_sequence(100)
2545 ) {
2546 let config = CoalescerConfig::default();
2547 let mut c = ResizeCoalescer::new(config, (80, 24));
2548 let base = Instant::now();
2549
2550 for (w, h, delay) in &events {
2551 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2552 }
2553
2554 let stats = c.stats();
2555 prop_assert_eq!(
2557 stats.event_count,
2558 events.len() as u64,
2559 "Event count should match total incoming events"
2560 );
2561 }
2562
2563 #[test]
2569 fn bocpd_determinism_across_sequences(
2570 events in resize_sequence(30),
2571 tick_offset in 100u64..400
2572 ) {
2573 let config = CoalescerConfig::default().with_bocpd();
2574
2575 let results: Vec<_> = (0..2)
2576 .map(|_| {
2577 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2578 let base = Instant::now();
2579
2580 for (i, (w, h, delay)) in events.iter().enumerate() {
2581 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2582 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2583 }
2584
2585 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2586 let action = c.tick_at(base + Duration::from_millis(total_time));
2587 (action, c.regime(), c.bocpd_p_burst())
2588 })
2589 .collect();
2590
2591 prop_assert_eq!(results[0], results[1], "BOCPD results must be deterministic");
2592 }
2593
2594 #[test]
2596 fn bocpd_latest_wins_never_drops(
2597 events in resize_sequence(15),
2598 final_w in dimension(),
2599 final_h in dimension()
2600 ) {
2601 if events.is_empty() {
2602 return Ok(());
2603 }
2604
2605 let config = CoalescerConfig::default().with_bocpd();
2606 let mut c = ResizeCoalescer::new(config, (80, 24));
2607 let base = Instant::now();
2608
2609 let mut offset = 0u64;
2610 for (w, h, delay) in &events {
2611 offset += delay;
2612 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2613 }
2614
2615 offset += 50;
2616 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2617
2618 let mut final_applied = None;
2619 for tick in 0..200 {
2620 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2621 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2622 final_applied = Some((width, height));
2623 }
2624 if !c.has_pending() && final_applied.is_some() {
2625 break;
2626 }
2627 }
2628
2629 if let Some((applied_w, applied_h)) = final_applied {
2630 prop_assert_eq!(
2631 (applied_w, applied_h),
2632 (final_w, final_h),
2633 "BOCPD must apply the final size"
2634 );
2635 }
2636 }
2637
2638 #[test]
2640 fn bocpd_bounded_latency_maintained(
2641 w in dimension(),
2642 h in dimension()
2643 ) {
2644 let config = CoalescerConfig::default().with_bocpd();
2645 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2646 let base = Instant::now();
2647
2648 c.handle_resize_at(w, h, base);
2649
2650 let mut applied_at = None;
2651 for ms in 0..=config.hard_deadline_ms + 50 {
2652 let action = c.tick_at(base + Duration::from_millis(ms));
2653 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2654 applied_at = Some(ms);
2655 break;
2656 }
2657 }
2658
2659 prop_assert!(applied_at.is_some(), "BOCPD resize must be applied");
2660 prop_assert!(
2661 applied_at.unwrap() <= config.hard_deadline_ms,
2662 "BOCPD must apply within hard deadline ({}ms), took {}ms",
2663 config.hard_deadline_ms,
2664 applied_at.unwrap()
2665 );
2666 }
2667
2668 #[test]
2670 fn bocpd_posterior_always_valid(
2671 events in resize_sequence(50)
2672 ) {
2673 if events.is_empty() {
2674 return Ok(());
2675 }
2676
2677 let config = CoalescerConfig::default().with_bocpd();
2678 let mut c = ResizeCoalescer::new(config, (80, 24));
2679 let base = Instant::now();
2680
2681 for (w, h, delay) in &events {
2682 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2683
2684 if let Some(bocpd) = c.bocpd() {
2686 let sum: f64 = bocpd.run_length_posterior().iter().sum();
2687 prop_assert!(
2688 (sum - 1.0).abs() < 1e-8,
2689 "Posterior must sum to 1, got {}",
2690 sum
2691 );
2692 }
2693
2694 let p_burst = c.bocpd_p_burst().unwrap();
2695 prop_assert!(
2696 (0.0..=1.0).contains(&p_burst),
2697 "P(burst) must be in [0,1], got {}",
2698 p_burst
2699 );
2700 }
2701 }
2702 }
2703 }
2704
2705 #[test]
2710 fn telemetry_hooks_fire_on_resize_applied() {
2711 use std::sync::Arc;
2712 use std::sync::atomic::{AtomicU32, Ordering};
2713
2714 let applied_count = Arc::new(AtomicU32::new(0));
2715 let applied_count_clone = applied_count.clone();
2716
2717 let hooks = TelemetryHooks::new().on_resize_applied(move |_entry| {
2718 applied_count_clone.fetch_add(1, Ordering::SeqCst);
2719 });
2720
2721 let mut config = test_config();
2722 config.enable_logging = true;
2723 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2724
2725 let base = Instant::now();
2726 c.handle_resize_at(100, 40, base);
2727 c.tick_at(base + Duration::from_millis(50));
2728
2729 assert_eq!(applied_count.load(Ordering::SeqCst), 1);
2730 }
2731
2732 #[test]
2733 fn telemetry_hooks_fire_on_regime_change() {
2734 use std::sync::Arc;
2735 use std::sync::atomic::{AtomicU32, Ordering};
2736
2737 let regime_changes = Arc::new(AtomicU32::new(0));
2738 let regime_changes_clone = regime_changes.clone();
2739
2740 let hooks = TelemetryHooks::new().on_regime_change(move |_from, _to| {
2741 regime_changes_clone.fetch_add(1, Ordering::SeqCst);
2742 });
2743
2744 let config = test_config();
2745 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2746
2747 let base = Instant::now();
2748
2749 for i in 0..15 {
2751 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2752 }
2753
2754 assert!(regime_changes.load(Ordering::SeqCst) >= 1);
2756 }
2757
2758 #[test]
2759 fn regime_transition_count_tracks_changes() {
2760 let config = test_config();
2761 let mut c = ResizeCoalescer::new(config, (80, 24));
2762
2763 assert_eq!(c.regime_transition_count(), 0);
2764
2765 let base = Instant::now();
2766
2767 for i in 0..15 {
2769 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2770 }
2771
2772 assert!(c.regime_transition_count() >= 1);
2774 }
2775
2776 #[test]
2777 fn cycle_time_percentiles_calculated() {
2778 let mut config = test_config();
2779 config.enable_logging = true;
2780 let mut c = ResizeCoalescer::new(config, (80, 24));
2781
2782 assert!(c.cycle_time_percentiles().is_none());
2784
2785 let base = Instant::now();
2786
2787 for i in 0..5 {
2789 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 100));
2790 c.tick_at(base + Duration::from_millis(i as u64 * 100 + 50));
2791 }
2792
2793 let percentiles = c.cycle_time_percentiles();
2795 assert!(percentiles.is_some());
2796
2797 let p = percentiles.unwrap();
2798 assert!(p.count >= 1);
2799 assert!(p.mean_ms >= 0.0);
2800 assert!(p.p50_ms >= 0.0);
2801 assert!(p.p95_ms >= p.p50_ms);
2802 assert!(p.p99_ms >= p.p95_ms);
2803 }
2804
2805 #[test]
2806 fn cycle_time_percentiles_jsonl_format() {
2807 let percentiles = CycleTimePercentiles {
2808 p50_ms: 10.5,
2809 p95_ms: 25.3,
2810 p99_ms: 42.1,
2811 count: 100,
2812 mean_ms: 15.2,
2813 };
2814
2815 let jsonl = percentiles.to_jsonl();
2816 assert!(jsonl.contains("\"event\":\"cycle_time_percentiles\""));
2817 assert!(jsonl.contains("\"p50_ms\":10.500"));
2818 assert!(jsonl.contains("\"p95_ms\":25.300"));
2819 assert!(jsonl.contains("\"p99_ms\":42.100"));
2820 assert!(jsonl.contains("\"mean_ms\":15.200"));
2821 assert!(jsonl.contains("\"count\":100"));
2822 }
2823
2824 #[test]
2829 fn bocpd_disabled_by_default() {
2830 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
2831 assert!(!c.bocpd_enabled());
2832 assert!(c.bocpd().is_none());
2833 assert!(c.bocpd_p_burst().is_none());
2834 }
2835
2836 #[test]
2837 fn bocpd_enabled_with_config() {
2838 let config = CoalescerConfig::default().with_bocpd();
2839 let c = ResizeCoalescer::new(config, (80, 24));
2840 assert!(c.bocpd_enabled());
2841 assert!(c.bocpd().is_some());
2842 }
2843
2844 #[test]
2845 fn bocpd_posterior_normalized() {
2846 let config = CoalescerConfig::default().with_bocpd();
2847 let mut c = ResizeCoalescer::new(config, (80, 24));
2848
2849 let base = Instant::now();
2850
2851 for i in 0..20 {
2853 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 50));
2854 }
2855
2856 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2858 assert!(
2859 (0.0..=1.0).contains(&p_burst),
2860 "P(burst) must be in [0,1], got {}",
2861 p_burst
2862 );
2863
2864 if let Some(bocpd) = c.bocpd() {
2866 let sum: f64 = bocpd.run_length_posterior().iter().sum();
2867 assert!(
2868 (sum - 1.0).abs() < 1e-9,
2869 "Posterior must sum to 1, got {}",
2870 sum
2871 );
2872 }
2873 }
2874
2875 #[test]
2876 fn bocpd_detects_burst_from_rapid_events() {
2877 use crate::bocpd::BocpdConfig;
2878
2879 let bocpd_config = BocpdConfig {
2881 mu_steady_ms: 200.0,
2882 mu_burst_ms: 20.0,
2883 burst_threshold: 0.6,
2884 steady_threshold: 0.4,
2885 ..BocpdConfig::default()
2886 };
2887
2888 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2889 let mut c = ResizeCoalescer::new(config, (80, 24));
2890
2891 let base = Instant::now();
2892
2893 for i in 0..30 {
2895 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2896 }
2897
2898 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2900 assert!(
2901 p_burst > 0.5,
2902 "Rapid events should yield high P(burst), got {}",
2903 p_burst
2904 );
2905 assert_eq!(
2906 c.regime(),
2907 Regime::Burst,
2908 "Regime should be Burst with rapid events"
2909 );
2910 }
2911
2912 #[test]
2913 fn bocpd_detects_steady_from_slow_events() {
2914 use crate::bocpd::BocpdConfig;
2915
2916 let bocpd_config = BocpdConfig {
2918 mu_steady_ms: 200.0,
2919 mu_burst_ms: 20.0,
2920 burst_threshold: 0.7,
2921 steady_threshold: 0.3,
2922 ..BocpdConfig::default()
2923 };
2924
2925 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2926 let mut c = ResizeCoalescer::new(config, (80, 24));
2927
2928 let base = Instant::now();
2929
2930 for i in 0..10 {
2932 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 300));
2933 }
2934
2935 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2937 assert!(
2938 p_burst < 0.5,
2939 "Slow events should yield low P(burst), got {}",
2940 p_burst
2941 );
2942 assert_eq!(
2943 c.regime(),
2944 Regime::Steady,
2945 "Regime should be Steady with slow events"
2946 );
2947 }
2948
2949 #[test]
2950 fn bocpd_recommended_delay_varies_with_regime() {
2951 let config = CoalescerConfig::default().with_bocpd();
2952 let mut c = ResizeCoalescer::new(config, (80, 24));
2953
2954 let base = Instant::now();
2955
2956 c.handle_resize_at(85, 30, base);
2958 let delay_initial = c.bocpd_recommended_delay().expect("BOCPD enabled");
2959
2960 for i in 1..30 {
2962 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2963 }
2964 let delay_burst = c.bocpd_recommended_delay().expect("BOCPD enabled");
2965
2966 assert!(delay_initial > 0, "Initial delay should be positive");
2968 assert!(delay_burst > 0, "Burst delay should be positive");
2969 }
2970
2971 #[test]
2972 fn bocpd_update_is_deterministic() {
2973 let config = CoalescerConfig::default().with_bocpd();
2974
2975 let base = Instant::now();
2976
2977 let results: Vec<_> = (0..2)
2979 .map(|_| {
2980 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2981 for i in 0..20 {
2982 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 25));
2983 }
2984 (c.regime(), c.bocpd_p_burst())
2985 })
2986 .collect();
2987
2988 assert_eq!(
2989 results[0], results[1],
2990 "BOCPD results must be deterministic"
2991 );
2992 }
2993
2994 #[test]
2995 fn bocpd_memory_bounded() {
2996 use crate::bocpd::BocpdConfig;
2997
2998 let bocpd_config = BocpdConfig {
3000 max_run_length: 50,
3001 ..BocpdConfig::default()
3002 };
3003
3004 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
3005 let mut c = ResizeCoalescer::new(config, (80, 24));
3006
3007 let base = Instant::now();
3008
3009 for i in 0u64..200 {
3011 c.handle_resize_at(
3012 80 + (i as u16 % 100),
3013 24 + (i as u16 % 50),
3014 base + Duration::from_millis(i * 20),
3015 );
3016 }
3017
3018 if let Some(bocpd) = c.bocpd() {
3020 let posterior_len = bocpd.run_length_posterior().len();
3021 assert!(
3022 posterior_len <= 51, "Posterior length should be bounded, got {}",
3024 posterior_len
3025 );
3026 }
3027 }
3028
3029 #[test]
3030 fn bocpd_stable_under_mixed_traffic() {
3031 let config = CoalescerConfig::default().with_bocpd();
3032 let mut c = ResizeCoalescer::new(config, (80, 24));
3033
3034 let base = Instant::now();
3035 let mut offset = 0u64;
3036
3037 for i in 0..5 {
3039 offset += 200;
3040 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(offset));
3041 }
3042
3043 for i in 0..15 {
3045 offset += 15;
3046 c.handle_resize_at(90 + i, 30 + i, base + Duration::from_millis(offset));
3047 }
3048
3049 for i in 0..5 {
3051 offset += 250;
3052 c.handle_resize_at(100 + i, 40 + i, base + Duration::from_millis(offset));
3053 }
3054
3055 let p_burst = c.bocpd_p_burst().expect("BOCPD enabled");
3057 assert!(
3058 (0.0..=1.0).contains(&p_burst),
3059 "P(burst) must remain valid after mixed traffic"
3060 );
3061
3062 if let Some(bocpd) = c.bocpd() {
3063 let sum: f64 = bocpd.run_length_posterior().iter().sum();
3064 assert!((sum - 1.0).abs() < 1e-9, "Posterior must remain normalized");
3065 }
3066 }
3067
3068 #[test]
3073 fn evidence_decision_jsonl_contains_all_required_fields() {
3074 let log = DecisionLog {
3075 timestamp: Instant::now(),
3076 elapsed_ms: 16.5,
3077 event_idx: 1,
3078 dt_ms: 16.0,
3079 event_rate: 62.5,
3080 regime: Regime::Steady,
3081 action: "apply",
3082 pending_size: Some((100, 40)),
3083 applied_size: Some((100, 40)),
3084 time_since_render_ms: 16.2,
3085 coalesce_ms: Some(16.0),
3086 forced: false,
3087 };
3088
3089 let jsonl = log.to_jsonl("test-run-1", ScreenMode::AltScreen, 100, 40);
3090 let parsed: serde_json::Value =
3091 serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3092
3093 assert_eq!(
3095 parsed["schema_version"].as_str().unwrap(),
3096 EVIDENCE_SCHEMA_VERSION
3097 );
3098 assert_eq!(parsed["run_id"].as_str().unwrap(), "test-run-1");
3099 assert_eq!(parsed["event_idx"].as_u64().unwrap(), 1);
3100 assert_eq!(parsed["screen_mode"].as_str().unwrap(), "altscreen");
3101 assert_eq!(parsed["cols"].as_u64().unwrap(), 100);
3102 assert_eq!(parsed["rows"].as_u64().unwrap(), 40);
3103
3104 assert_eq!(parsed["event"].as_str().unwrap(), "decision");
3106 assert!(parsed["elapsed_ms"].as_f64().is_some());
3107 assert!(parsed["dt_ms"].as_f64().is_some());
3108 assert!(parsed["event_rate"].as_f64().is_some());
3109 assert_eq!(parsed["regime"].as_str().unwrap(), "steady");
3110 assert_eq!(parsed["action"].as_str().unwrap(), "apply");
3111 assert_eq!(parsed["pending_w"].as_u64().unwrap(), 100);
3112 assert_eq!(parsed["pending_h"].as_u64().unwrap(), 40);
3113 assert_eq!(parsed["applied_w"].as_u64().unwrap(), 100);
3114 assert_eq!(parsed["applied_h"].as_u64().unwrap(), 40);
3115 assert!(parsed["time_since_render_ms"].as_f64().is_some());
3116 assert!(parsed["coalesce_ms"].as_f64().is_some());
3117 assert!(!parsed["forced"].as_bool().unwrap());
3118 }
3119
3120 #[test]
3121 fn evidence_decision_jsonl_null_fields_when_no_pending() {
3122 let log = DecisionLog {
3123 timestamp: Instant::now(),
3124 elapsed_ms: 0.0,
3125 event_idx: 0,
3126 dt_ms: 0.0,
3127 event_rate: 0.0,
3128 regime: Regime::Steady,
3129 action: "skip_same_size",
3130 pending_size: None,
3131 applied_size: None,
3132 time_since_render_ms: 0.0,
3133 coalesce_ms: None,
3134 forced: false,
3135 };
3136
3137 let jsonl = log.to_jsonl("test-run-2", ScreenMode::AltScreen, 80, 24);
3138 let parsed: serde_json::Value =
3139 serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3140
3141 assert!(parsed["pending_w"].is_null());
3142 assert!(parsed["pending_h"].is_null());
3143 assert!(parsed["applied_w"].is_null());
3144 assert!(parsed["applied_h"].is_null());
3145 assert!(parsed["coalesce_ms"].is_null());
3146 }
3147
3148 #[test]
3149 fn evidence_config_jsonl_contains_all_fields() {
3150 let config = test_config();
3151 let jsonl = config.to_jsonl("cfg-run", ScreenMode::AltScreen, 80, 24, 0);
3152 let parsed: serde_json::Value =
3153 serde_json::from_str(&jsonl).expect("Config JSONL must be valid JSON");
3154
3155 assert_eq!(parsed["event"].as_str().unwrap(), "config");
3156 assert_eq!(
3157 parsed["schema_version"].as_str().unwrap(),
3158 EVIDENCE_SCHEMA_VERSION
3159 );
3160 assert_eq!(parsed["steady_delay_ms"].as_u64().unwrap(), 16);
3161 assert_eq!(parsed["burst_delay_ms"].as_u64().unwrap(), 40);
3162 assert_eq!(parsed["hard_deadline_ms"].as_u64().unwrap(), 100);
3163 assert!(parsed["burst_enter_rate"].as_f64().is_some());
3164 assert!(parsed["burst_exit_rate"].as_f64().is_some());
3165 assert_eq!(parsed["cooldown_frames"].as_u64().unwrap(), 3);
3166 assert_eq!(parsed["rate_window_size"].as_u64().unwrap(), 8);
3167 }
3168
3169 #[test]
3170 fn evidence_inline_screen_mode_string() {
3171 let log = DecisionLog {
3172 timestamp: Instant::now(),
3173 elapsed_ms: 0.0,
3174 event_idx: 0,
3175 dt_ms: 0.0,
3176 event_rate: 0.0,
3177 regime: Regime::Burst,
3178 action: "coalesce",
3179 pending_size: Some((120, 40)),
3180 applied_size: None,
3181 time_since_render_ms: 5.0,
3182 coalesce_ms: None,
3183 forced: false,
3184 };
3185
3186 let jsonl = log.to_jsonl("inline-run", ScreenMode::Inline { ui_height: 12 }, 120, 40);
3187 let parsed: serde_json::Value =
3188 serde_json::from_str(&jsonl).expect("JSONL must be valid JSON");
3189
3190 assert_eq!(parsed["screen_mode"].as_str().unwrap(), "inline");
3191 assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
3192 }
3193
3194 #[test]
3195 fn resize_scheduling_steady_applies_within_steady_delay() {
3196 let config = CoalescerConfig {
3197 steady_delay_ms: 20,
3198 burst_delay_ms: 50,
3199 hard_deadline_ms: 200,
3200 enable_logging: true,
3201 ..test_config()
3202 };
3203 let base = Instant::now();
3204 let mut c = ResizeCoalescer::new(config, (80, 24));
3205
3206 let action = c.handle_resize_at(100, 40, base);
3208 match action {
3210 CoalesceAction::ApplyResize { width, height, .. } => {
3211 assert_eq!(width, 100);
3212 assert_eq!(height, 40);
3213 }
3214 CoalesceAction::None | CoalesceAction::ShowPlaceholder => {
3215 let later = base + Duration::from_millis(25);
3217 let action = c.tick_at(later);
3218 if let CoalesceAction::ApplyResize { width, height, .. } = action {
3219 assert_eq!(width, 100);
3220 assert_eq!(height, 40);
3221 }
3222 }
3223 }
3224
3225 assert_eq!(c.last_applied(), (100, 40));
3227 }
3228
3229 #[test]
3230 fn resize_scheduling_burst_regime_coalesces_rapid_events() {
3231 let config = CoalescerConfig {
3232 steady_delay_ms: 16,
3233 burst_delay_ms: 40,
3234 hard_deadline_ms: 100,
3235 burst_enter_rate: 10.0,
3236 enable_logging: true,
3237 ..test_config()
3238 };
3239 let base = Instant::now();
3240 let mut c = ResizeCoalescer::new(config, (80, 24));
3241 let mut apply_count = 0u32;
3242
3243 for i in 0..20 {
3245 let t = base + Duration::from_millis(i * 50);
3246 let action = c.handle_resize_at(80 + (i as u16), 24, t);
3247 if matches!(action, CoalesceAction::ApplyResize { .. }) {
3248 apply_count += 1;
3249 }
3250 let tick_t = t + Duration::from_millis(10);
3252 let tick_action = c.tick_at(tick_t);
3253 if matches!(tick_action, CoalesceAction::ApplyResize { .. }) {
3254 apply_count += 1;
3255 }
3256 }
3257
3258 assert!(
3260 apply_count < 20,
3261 "Expected coalescing: {apply_count} applies for 20 events"
3262 );
3263 assert!(apply_count > 0, "Should have at least one apply");
3265 }
3266
3267 #[test]
3268 fn evidence_summary_jsonl_includes_checksum() {
3269 let config = CoalescerConfig {
3270 enable_logging: true,
3271 ..test_config()
3272 };
3273 let base = Instant::now();
3274 let mut c = ResizeCoalescer::new(config, (80, 24));
3275
3276 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
3278 c.tick_at(base + Duration::from_millis(30));
3279
3280 let all_lines = c.evidence_to_jsonl();
3281 let summary_line = all_lines.lines().last().expect("Should have summary line");
3282 let parsed: serde_json::Value =
3283 serde_json::from_str(summary_line).expect("Summary JSONL line must be valid JSON");
3284
3285 assert_eq!(parsed["event"].as_str().unwrap(), "summary");
3286 assert!(parsed["decisions"].as_u64().is_some());
3287 assert!(parsed["applies"].as_u64().is_some());
3288 assert!(parsed["forced_applies"].as_u64().is_some());
3289 assert!(parsed["coalesces"].as_u64().is_some());
3290 assert!(parsed["skips"].as_u64().is_some());
3291 assert!(parsed["regime"].as_str().is_some());
3292 assert!(parsed["checksum"].as_str().is_some());
3293 }
3294
3295 #[test]
3300 fn decision_evidence_favor_apply_steady() {
3301 let ev = DecisionEvidence::favor_apply(Regime::Steady, 80.0, 2.0);
3302 assert!(ev.log_bayes_factor > 0.0, "Should favor apply");
3304 assert_eq!(ev.regime_contribution, 1.0);
3305 assert!((ev.timing_contribution - 1.6).abs() < 0.01);
3306 assert_eq!(ev.rate_contribution, 0.5);
3307 assert!(ev.is_strong());
3308 assert!(ev.is_decisive());
3309 }
3310
3311 #[test]
3312 fn decision_evidence_favor_apply_burst_regime() {
3313 let ev = DecisionEvidence::favor_apply(Regime::Burst, 10.0, 20.0);
3314 assert_eq!(ev.regime_contribution, -0.5);
3316 assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3317 assert_eq!(ev.rate_contribution, -0.3);
3318 assert!(!ev.is_strong());
3320 }
3321
3322 #[test]
3323 fn decision_evidence_favor_coalesce_burst() {
3324 let ev = DecisionEvidence::favor_coalesce(Regime::Burst, 5.0, 15.0);
3325 assert!(ev.log_bayes_factor < 0.0, "Should favor coalesce");
3328 assert_eq!(ev.regime_contribution, 1.0);
3329 assert!((ev.timing_contribution - 2.0).abs() < 0.01);
3330 assert_eq!(ev.rate_contribution, 0.5);
3331 assert!(ev.is_strong());
3332 assert!(ev.is_decisive());
3333 }
3334
3335 #[test]
3336 fn decision_evidence_favor_coalesce_steady_regime() {
3337 let ev = DecisionEvidence::favor_coalesce(Regime::Steady, 100.0, 3.0);
3338 assert_eq!(ev.regime_contribution, -0.5);
3340 assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3341 assert_eq!(ev.rate_contribution, -0.3);
3342 }
3343
3344 #[test]
3345 fn decision_evidence_forced_deadline() {
3346 let ev = DecisionEvidence::forced_deadline(100.0);
3347 assert!(ev.log_bayes_factor.is_infinite());
3348 assert_eq!(ev.regime_contribution, 0.0);
3349 assert!((ev.timing_contribution - 100.0).abs() < 0.01);
3350 assert_eq!(ev.rate_contribution, 0.0);
3351 assert!(ev.is_strong());
3352 assert!(ev.is_decisive());
3353 assert!(ev.explanation.contains("100.0ms"));
3354 }
3355
3356 #[test]
3357 fn decision_evidence_is_strong_boundary() {
3358 let ev = DecisionEvidence {
3360 log_bayes_factor: 1.0,
3361 regime_contribution: 0.0,
3362 timing_contribution: 0.0,
3363 rate_contribution: 0.0,
3364 explanation: String::new(),
3365 };
3366 assert!(!ev.is_strong());
3367
3368 let ev2 = DecisionEvidence {
3369 log_bayes_factor: 1.001,
3370 ..ev.clone()
3371 };
3372 assert!(ev2.is_strong());
3373
3374 let ev3 = DecisionEvidence {
3376 log_bayes_factor: -1.5,
3377 ..ev
3378 };
3379 assert!(ev3.is_strong());
3380 }
3381
3382 #[test]
3383 fn decision_evidence_is_decisive_boundary() {
3384 let ev = DecisionEvidence {
3385 log_bayes_factor: 2.0,
3386 regime_contribution: 0.0,
3387 timing_contribution: 0.0,
3388 rate_contribution: 0.0,
3389 explanation: String::new(),
3390 };
3391 assert!(!ev.is_decisive()); let ev2 = DecisionEvidence {
3394 log_bayes_factor: 2.001,
3395 ..ev
3396 };
3397 assert!(ev2.is_decisive());
3398 }
3399
3400 #[test]
3401 fn decision_evidence_to_jsonl_valid() {
3402 let ev = DecisionEvidence::favor_apply(Regime::Steady, 50.0, 3.0);
3403 let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 5);
3404 let parsed: serde_json::Value =
3405 serde_json::from_str(&jsonl).expect("DecisionEvidence JSONL must be valid JSON");
3406
3407 assert_eq!(parsed["event"].as_str().unwrap(), "decision_evidence");
3408 assert!(parsed["log_bayes_factor"].as_f64().is_some());
3409 assert!(parsed["regime_contribution"].as_f64().is_some());
3410 assert!(parsed["timing_contribution"].as_f64().is_some());
3411 assert!(parsed["rate_contribution"].as_f64().is_some());
3412 assert!(parsed["explanation"].as_str().is_some());
3413 }
3414
3415 #[test]
3416 fn decision_evidence_to_jsonl_infinity() {
3417 let ev = DecisionEvidence::forced_deadline(100.0);
3418 let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 0);
3419 assert!(jsonl.contains("\"inf\""));
3421 }
3422
3423 #[test]
3428 fn hard_deadline_zero_applies_immediately() {
3429 let config = CoalescerConfig {
3430 hard_deadline_ms: 0,
3431 enable_logging: true,
3432 ..test_config()
3433 };
3434 let mut c = ResizeCoalescer::new(config, (80, 24));
3435 let base = Instant::now();
3436
3437 let action = c.handle_resize_at(100, 40, base);
3439 assert!(
3440 matches!(action, CoalesceAction::ApplyResize { .. }),
3441 "hard_deadline_ms=0 should force immediate apply, got {action:?}"
3442 );
3443 }
3444
3445 #[test]
3446 fn rate_window_size_zero_returns_zero_rate() {
3447 let config = CoalescerConfig {
3448 rate_window_size: 0,
3449 enable_logging: true,
3450 ..test_config()
3451 };
3452 let mut c = ResizeCoalescer::new(config, (80, 24));
3453 let base = Instant::now();
3454
3455 for i in 0..5 {
3457 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
3458 }
3459 let rate = c.calculate_event_rate(base + Duration::from_millis(50));
3460 assert_eq!(rate, 0.0, "rate_window_size=0 should yield 0 rate");
3461 }
3462
3463 #[test]
3464 fn rate_window_size_one_returns_zero_rate() {
3465 let config = CoalescerConfig {
3466 rate_window_size: 1,
3467 enable_logging: true,
3468 ..test_config()
3469 };
3470 let mut c = ResizeCoalescer::new(config, (80, 24));
3471 let base = Instant::now();
3472
3473 for i in 0..5 {
3474 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
3475 }
3476 let rate = c.calculate_event_rate(base + Duration::from_millis(50));
3478 assert_eq!(rate, 0.0, "rate_window_size=1 should yield 0 rate");
3479 }
3480
3481 #[test]
3482 fn tick_no_pending_returns_none() {
3483 let config = test_config();
3484 let mut c = ResizeCoalescer::new(config, (80, 24));
3485 let base = Instant::now();
3486
3487 let action = c.tick_at(base);
3489 assert_eq!(action, CoalesceAction::None);
3490 let action = c.tick_at(base + Duration::from_millis(500));
3491 assert_eq!(action, CoalesceAction::None);
3492 }
3493
3494 #[test]
3495 fn time_until_apply_none_when_no_pending() {
3496 let c = ResizeCoalescer::new(test_config(), (80, 24));
3497 assert!(c.time_until_apply(Instant::now()).is_none());
3498 }
3499
3500 #[test]
3501 fn time_until_apply_zero_when_past_delay() {
3502 let config = test_config();
3503 let mut c = ResizeCoalescer::new(config, (80, 24));
3504 let base = Instant::now();
3505
3506 c.handle_resize_at(100, 40, base);
3507 let result = c.time_until_apply(base + Duration::from_millis(500));
3509 assert_eq!(result, Some(Duration::ZERO));
3510 }
3511
3512 #[test]
3517 fn json_escape_special_characters() {
3518 assert_eq!(json_escape("hello"), "hello");
3519 assert_eq!(json_escape("a\"b"), "a\\\"b");
3520 assert_eq!(json_escape("a\\b"), "a\\\\b");
3521 assert_eq!(json_escape("a\nb"), "a\\nb");
3522 assert_eq!(json_escape("a\rb"), "a\\rb");
3523 assert_eq!(json_escape("a\tb"), "a\\tb");
3524 }
3525
3526 #[test]
3527 fn json_escape_control_characters() {
3528 let input = "a\x01b";
3530 let escaped = json_escape(input);
3531 assert_eq!(escaped, "a\\u0001b");
3532 }
3533
3534 #[test]
3535 fn json_escape_empty_string() {
3536 assert_eq!(json_escape(""), "");
3537 }
3538
3539 #[test]
3544 fn clear_logs_resets_state() {
3545 let mut config = test_config();
3546 config.enable_logging = true;
3547 let mut c = ResizeCoalescer::new(config, (80, 24));
3548
3549 c.handle_resize(100, 40);
3550 assert!(!c.logs().is_empty());
3551
3552 c.clear_logs();
3553 assert!(c.logs().is_empty());
3554
3555 c.handle_resize(120, 50);
3557 assert!(!c.logs().is_empty());
3558 }
3559
3560 #[test]
3561 fn decision_logs_jsonl_each_line_valid() {
3562 let mut config = test_config();
3563 config.enable_logging = true;
3564 let base = Instant::now();
3565 let mut c = ResizeCoalescer::new(config, (80, 24))
3566 .with_evidence_run_id("jsonl-test")
3567 .with_screen_mode(ScreenMode::AltScreen);
3568
3569 c.handle_resize_at(100, 40, base);
3570 c.handle_resize_at(110, 50, base + Duration::from_millis(5));
3571 c.tick_at(base + Duration::from_millis(50));
3572
3573 let jsonl = c.decision_logs_jsonl();
3574 assert!(!jsonl.is_empty());
3575 for line in jsonl.lines() {
3576 let _: serde_json::Value =
3577 serde_json::from_str(line).expect("Each JSONL line must be valid JSON");
3578 }
3579 }
3580
3581 #[test]
3586 fn telemetry_hooks_has_methods() {
3587 let hooks = TelemetryHooks::new();
3588 assert!(!hooks.has_resize_applied());
3589 assert!(!hooks.has_regime_change());
3590 assert!(!hooks.has_decision());
3591
3592 let hooks = hooks.on_resize_applied(|_| {});
3593 assert!(hooks.has_resize_applied());
3594 assert!(!hooks.has_regime_change());
3595 assert!(!hooks.has_decision());
3596 }
3597
3598 #[test]
3599 fn telemetry_hooks_with_tracing() {
3600 let hooks = TelemetryHooks::new().with_tracing(true);
3601 let debug_str = format!("{:?}", hooks);
3602 assert!(debug_str.contains("emit_tracing: true"));
3603 }
3604
3605 #[test]
3606 fn telemetry_hooks_default_equals_new() {
3607 let h1 = TelemetryHooks::default();
3608 let h2 = TelemetryHooks::new();
3609 assert!(!h1.has_resize_applied());
3610 assert!(!h2.has_resize_applied());
3611 }
3612
3613 #[test]
3614 fn telemetry_hooks_on_decision_fires() {
3615 use std::sync::Arc;
3616 use std::sync::atomic::{AtomicU32, Ordering};
3617
3618 let count = Arc::new(AtomicU32::new(0));
3619 let count_clone = count.clone();
3620
3621 let hooks = TelemetryHooks::new().on_decision(move |_entry| {
3622 count_clone.fetch_add(1, Ordering::SeqCst);
3623 });
3624
3625 let mut config = test_config();
3626 config.enable_logging = true;
3627 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
3628
3629 let base = Instant::now();
3630 c.handle_resize_at(100, 40, base);
3631
3632 assert!(count.load(Ordering::SeqCst) >= 1);
3634 }
3635
3636 #[test]
3641 fn regime_as_str_values() {
3642 assert_eq!(Regime::Steady.as_str(), "steady");
3643 assert_eq!(Regime::Burst.as_str(), "burst");
3644 }
3645
3646 #[test]
3647 fn regime_default_is_steady() {
3648 assert_eq!(Regime::default(), Regime::Steady);
3649 }
3650
3651 #[test]
3656 fn decision_summary_checksum_hex_format() {
3657 let summary = DecisionSummary {
3658 checksum: 0x0123456789ABCDEF,
3659 ..DecisionSummary::default()
3660 };
3661 assert_eq!(summary.checksum_hex(), "0123456789abcdef");
3662 }
3663
3664 #[test]
3665 fn decision_summary_default_values() {
3666 let summary = DecisionSummary::default();
3667 assert_eq!(summary.decision_count, 0);
3668 assert_eq!(summary.apply_count, 0);
3669 assert_eq!(summary.forced_apply_count, 0);
3670 assert_eq!(summary.coalesce_count, 0);
3671 assert_eq!(summary.skip_count, 0);
3672 assert_eq!(summary.regime, Regime::Steady);
3673 assert_eq!(summary.last_applied, (0, 0));
3674 assert_eq!(summary.checksum, 0);
3675 }
3676
3677 #[test]
3678 fn decision_summary_to_jsonl_valid() {
3679 let summary = DecisionSummary {
3680 decision_count: 5,
3681 apply_count: 2,
3682 forced_apply_count: 1,
3683 coalesce_count: 2,
3684 skip_count: 1,
3685 regime: Regime::Burst,
3686 last_applied: (120, 40),
3687 checksum: 0xDEADBEEF,
3688 };
3689 let jsonl = summary.to_jsonl("run-1", ScreenMode::AltScreen, 120, 40, 5);
3690 let parsed: serde_json::Value =
3691 serde_json::from_str(&jsonl).expect("Summary JSONL must be valid JSON");
3692
3693 assert_eq!(parsed["event"].as_str().unwrap(), "summary");
3694 assert_eq!(parsed["decisions"].as_u64().unwrap(), 5);
3695 assert_eq!(parsed["applies"].as_u64().unwrap(), 2);
3696 assert_eq!(parsed["forced_applies"].as_u64().unwrap(), 1);
3697 assert_eq!(parsed["coalesces"].as_u64().unwrap(), 2);
3698 assert_eq!(parsed["skips"].as_u64().unwrap(), 1);
3699 assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
3700 assert_eq!(parsed["last_w"].as_u64().unwrap(), 120);
3701 assert_eq!(parsed["last_h"].as_u64().unwrap(), 40);
3702 assert!(parsed["checksum"].as_str().unwrap().contains("deadbeef"));
3703 }
3704
3705 #[test]
3710 fn screen_mode_str_all_variants() {
3711 assert_eq!(screen_mode_str(ScreenMode::AltScreen), "altscreen");
3712 assert_eq!(
3713 screen_mode_str(ScreenMode::Inline { ui_height: 10 }),
3714 "inline"
3715 );
3716 assert_eq!(
3717 screen_mode_str(ScreenMode::InlineAuto {
3718 min_height: 5,
3719 max_height: 20,
3720 }),
3721 "inline_auto"
3722 );
3723 }
3724
3725 #[test]
3730 fn config_with_logging_chaining() {
3731 let config = CoalescerConfig::default().with_logging(true);
3732 assert!(config.enable_logging);
3733
3734 let config2 = config.with_logging(false);
3735 assert!(!config2.enable_logging);
3736 }
3737
3738 #[test]
3739 fn config_with_bocpd_chaining() {
3740 let config = CoalescerConfig::default().with_bocpd();
3741 assert!(config.enable_bocpd);
3742 assert!(config.bocpd_config.is_some());
3743 }
3744
3745 #[test]
3746 fn config_with_bocpd_config_chaining() {
3747 let bocpd_cfg = BocpdConfig {
3748 max_run_length: 42,
3749 ..BocpdConfig::default()
3750 };
3751 let config = CoalescerConfig::default().with_bocpd_config(bocpd_cfg);
3752 assert!(config.enable_bocpd);
3753 assert_eq!(config.bocpd_config.as_ref().unwrap().max_run_length, 42);
3754 }
3755
3756 #[test]
3761 fn coalescer_with_evidence_run_id() {
3762 let c = ResizeCoalescer::new(test_config(), (80, 24)).with_evidence_run_id("custom-run-id");
3763 let jsonl = c.evidence_to_jsonl();
3765 assert!(jsonl.contains("custom-run-id"));
3766 }
3767
3768 #[test]
3769 fn coalescer_with_screen_mode() {
3770 let mut config = test_config();
3771 config.enable_logging = true;
3772 let mut c = ResizeCoalescer::new(config, (80, 24))
3773 .with_screen_mode(ScreenMode::Inline { ui_height: 8 });
3774
3775 c.handle_resize(100, 40);
3776 let jsonl = c.evidence_to_jsonl();
3777 assert!(jsonl.contains("\"screen_mode\":\"inline\""));
3778 }
3779
3780 #[test]
3781 fn coalescer_set_evidence_sink_clears_config_logged() {
3782 let mut config = test_config();
3783 config.enable_logging = true;
3784 let mut c = ResizeCoalescer::new(config, (80, 24));
3785
3786 c.handle_resize(100, 40);
3788
3789 c.set_evidence_sink(None);
3791
3792 let jsonl = c.evidence_to_jsonl();
3794 assert!(jsonl.contains("\"event\":\"config\""));
3795 }
3796
3797 #[test]
3802 fn duration_since_or_zero_normal() {
3803 let earlier = Instant::now();
3804 std::thread::sleep(Duration::from_millis(1));
3805 let now = Instant::now();
3806 let result = duration_since_or_zero(now, earlier);
3807 assert!(result >= Duration::from_millis(1));
3808 }
3809
3810 #[test]
3811 fn duration_since_or_zero_same_instant() {
3812 let now = Instant::now();
3813 let result = duration_since_or_zero(now, now);
3814 assert_eq!(result, Duration::ZERO);
3815 }
3816
3817 #[test]
3822 fn resize_applied_event_fields() {
3823 let event = ResizeAppliedEvent {
3824 new_size: (100, 40),
3825 old_size: (80, 24),
3826 elapsed: Duration::from_millis(42),
3827 forced: true,
3828 };
3829 assert_eq!(event.new_size, (100, 40));
3830 assert_eq!(event.old_size, (80, 24));
3831 assert_eq!(event.elapsed, Duration::from_millis(42));
3832 assert!(event.forced);
3833 }
3834
3835 #[test]
3836 fn regime_change_event_fields() {
3837 let event = RegimeChangeEvent {
3838 from: Regime::Steady,
3839 to: Regime::Burst,
3840 event_idx: 42,
3841 };
3842 assert_eq!(event.from, Regime::Steady);
3843 assert_eq!(event.to, Regime::Burst);
3844 assert_eq!(event.event_idx, 42);
3845 }
3846
3847 #[test]
3852 fn coalesce_action_show_placeholder_eq() {
3853 assert_eq!(
3854 CoalesceAction::ShowPlaceholder,
3855 CoalesceAction::ShowPlaceholder
3856 );
3857 assert_ne!(CoalesceAction::ShowPlaceholder, CoalesceAction::None);
3858 }
3859
3860 #[test]
3861 fn coalesce_action_apply_resize_eq() {
3862 let a = CoalesceAction::ApplyResize {
3863 width: 100,
3864 height: 40,
3865 coalesce_time: Duration::from_millis(16),
3866 forced_by_deadline: false,
3867 };
3868 let b = CoalesceAction::ApplyResize {
3869 width: 100,
3870 height: 40,
3871 coalesce_time: Duration::from_millis(16),
3872 forced_by_deadline: false,
3873 };
3874 assert_eq!(a, b);
3875
3876 let c = CoalesceAction::ApplyResize {
3877 width: 100,
3878 height: 40,
3879 coalesce_time: Duration::from_millis(16),
3880 forced_by_deadline: true,
3881 };
3882 assert_ne!(a, c);
3883 }
3884
3885 #[test]
3890 fn fnv_hash_deterministic() {
3891 let mut h1 = FNV_OFFSET_BASIS;
3892 fnv_hash_bytes(&mut h1, b"hello world");
3893
3894 let mut h2 = FNV_OFFSET_BASIS;
3895 fnv_hash_bytes(&mut h2, b"hello world");
3896
3897 assert_eq!(h1, h2);
3898 }
3899
3900 #[test]
3901 fn fnv_hash_different_inputs_different_hashes() {
3902 let mut h1 = FNV_OFFSET_BASIS;
3903 fnv_hash_bytes(&mut h1, b"hello");
3904
3905 let mut h2 = FNV_OFFSET_BASIS;
3906 fnv_hash_bytes(&mut h2, b"world");
3907
3908 assert_ne!(h1, h2);
3909 }
3910
3911 #[test]
3912 fn fnv_hash_empty_input_returns_basis() {
3913 let mut hash = FNV_OFFSET_BASIS;
3914 fnv_hash_bytes(&mut hash, b"");
3915 assert_eq!(hash, FNV_OFFSET_BASIS);
3916 }
3917}