1#![forbid(unsafe_code)]
60
61use std::collections::VecDeque;
62use std::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
80fn default_resize_run_id() -> String {
81 format!("resize-{}", std::process::id())
82}
83
84fn screen_mode_str(mode: ScreenMode) -> &'static str {
85 match mode {
86 ScreenMode::Inline { .. } => "inline",
87 ScreenMode::InlineAuto { .. } => "inline_auto",
88 ScreenMode::AltScreen => "altscreen",
89 }
90}
91
92#[inline]
93fn json_escape(value: &str) -> String {
94 let mut out = String::with_capacity(value.len());
95 for ch in value.chars() {
96 match ch {
97 '"' => out.push_str("\\\""),
98 '\\' => out.push_str("\\\\"),
99 '\n' => out.push_str("\\n"),
100 '\r' => out.push_str("\\r"),
101 '\t' => out.push_str("\\t"),
102 c if c.is_control() => {
103 use std::fmt::Write as _;
104 let _ = write!(out, "\\u{:04X}", c as u32);
105 }
106 _ => out.push(ch),
107 }
108 }
109 out
110}
111
112fn evidence_prefix(
113 run_id: &str,
114 screen_mode: ScreenMode,
115 cols: u16,
116 rows: u16,
117 event_idx: u64,
118) -> String {
119 format!(
120 r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
121 EVIDENCE_SCHEMA_VERSION,
122 json_escape(run_id),
123 event_idx,
124 screen_mode_str(screen_mode),
125 cols,
126 rows,
127 )
128}
129
130#[derive(Debug, Clone)]
132pub struct CoalescerConfig {
133 pub steady_delay_ms: u64,
136
137 pub burst_delay_ms: u64,
140
141 pub hard_deadline_ms: u64,
144
145 pub burst_enter_rate: f64,
147
148 pub burst_exit_rate: f64,
151
152 pub cooldown_frames: u32,
154
155 pub rate_window_size: usize,
157
158 pub enable_logging: bool,
160
161 pub enable_bocpd: bool,
172
173 pub bocpd_config: Option<BocpdConfig>,
175}
176
177impl Default for CoalescerConfig {
178 fn default() -> Self {
179 Self {
180 steady_delay_ms: 16, burst_delay_ms: 40, hard_deadline_ms: 100,
183 burst_enter_rate: 10.0, burst_exit_rate: 5.0, cooldown_frames: 3,
186 rate_window_size: 8,
187 enable_logging: false,
188 enable_bocpd: false,
189 bocpd_config: None,
190 }
191 }
192}
193
194impl CoalescerConfig {
195 #[must_use]
197 pub fn with_logging(mut self, enabled: bool) -> Self {
198 self.enable_logging = enabled;
199 self
200 }
201
202 #[must_use]
204 pub fn with_bocpd(mut self) -> Self {
205 self.enable_bocpd = true;
206 self.bocpd_config = Some(BocpdConfig::default());
207 self
208 }
209
210 #[must_use]
212 pub fn with_bocpd_config(mut self, config: BocpdConfig) -> Self {
213 self.enable_bocpd = true;
214 self.bocpd_config = Some(config);
215 self
216 }
217
218 #[must_use]
220 pub fn to_jsonl(
221 &self,
222 run_id: &str,
223 screen_mode: ScreenMode,
224 cols: u16,
225 rows: u16,
226 event_idx: u64,
227 ) -> String {
228 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
229 format!(
230 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":{}}}"#,
231 self.steady_delay_ms,
232 self.burst_delay_ms,
233 self.hard_deadline_ms,
234 self.burst_enter_rate,
235 self.burst_exit_rate,
236 self.cooldown_frames,
237 self.rate_window_size,
238 self.enable_logging
239 )
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum CoalesceAction {
246 None,
248
249 ShowPlaceholder,
251
252 ApplyResize {
254 width: u16,
255 height: u16,
256 coalesce_time: Duration,
258 forced_by_deadline: bool,
260 },
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
265pub enum Regime {
266 #[default]
268 Steady,
269 Burst,
271}
272
273impl Regime {
274 #[must_use]
276 pub const fn as_str(self) -> &'static str {
277 match self {
278 Self::Steady => "steady",
279 Self::Burst => "burst",
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
288pub struct ResizeAppliedEvent {
289 pub new_size: (u16, u16),
291 pub old_size: (u16, u16),
293 pub elapsed: Duration,
295 pub forced: bool,
297}
298
299#[derive(Debug, Clone)]
303pub struct RegimeChangeEvent {
304 pub from: Regime,
306 pub to: Regime,
308 pub event_idx: u64,
310}
311
312#[derive(Debug, Clone)]
340pub struct DecisionEvidence {
341 pub log_bayes_factor: f64,
343
344 pub regime_contribution: f64,
346
347 pub timing_contribution: f64,
349
350 pub rate_contribution: f64,
352
353 pub explanation: String,
355}
356
357impl DecisionEvidence {
358 #[must_use]
360 pub fn favor_apply(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
361 let regime_contrib = if regime == Regime::Steady { 1.0 } else { -0.5 };
362 let timing_contrib = (dt_ms / 50.0).min(2.0); let rate_contrib = if event_rate < 5.0 { 0.5 } else { -0.3 };
364
365 let lbf = regime_contrib + timing_contrib + rate_contrib;
366
367 Self {
368 log_bayes_factor: lbf,
369 regime_contribution: regime_contrib,
370 timing_contribution: timing_contrib,
371 rate_contribution: rate_contrib,
372 explanation: format!(
373 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
374 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
375 ),
376 }
377 }
378
379 #[must_use]
381 pub fn favor_coalesce(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
382 let regime_contrib = if regime == Regime::Burst { 1.0 } else { -0.5 };
383 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 };
385
386 let lbf = -(regime_contrib + timing_contrib + rate_contrib);
387
388 Self {
389 log_bayes_factor: lbf,
390 regime_contribution: regime_contrib,
391 timing_contribution: timing_contrib,
392 rate_contribution: rate_contrib,
393 explanation: format!(
394 "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
395 regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
396 ),
397 }
398 }
399
400 #[must_use]
402 pub fn forced_deadline(deadline_ms: f64) -> Self {
403 Self {
404 log_bayes_factor: f64::INFINITY,
405 regime_contribution: 0.0,
406 timing_contribution: deadline_ms,
407 rate_contribution: 0.0,
408 explanation: format!("Forced by hard deadline ({:.1}ms)", deadline_ms),
409 }
410 }
411
412 #[must_use]
414 pub fn to_jsonl(
415 &self,
416 run_id: &str,
417 screen_mode: ScreenMode,
418 cols: u16,
419 rows: u16,
420 event_idx: u64,
421 ) -> String {
422 let lbf_str = if self.log_bayes_factor.is_infinite() {
423 "\"inf\"".to_string()
424 } else {
425 format!("{:.3}", self.log_bayes_factor)
426 };
427 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
428 format!(
429 r#"{{{prefix},"event":"decision_evidence","log_bayes_factor":{},"regime_contribution":{:.3},"timing_contribution":{:.3},"rate_contribution":{:.3},"explanation":"{}"}}"#,
430 lbf_str,
431 self.regime_contribution,
432 self.timing_contribution,
433 self.rate_contribution,
434 json_escape(&self.explanation)
435 )
436 }
437
438 #[must_use]
440 pub fn is_strong(&self) -> bool {
441 self.log_bayes_factor.abs() > 1.0
442 }
443
444 #[must_use]
446 pub fn is_decisive(&self) -> bool {
447 self.log_bayes_factor.abs() > 2.0 || self.log_bayes_factor.is_infinite()
448 }
449}
450
451#[derive(Debug, Clone)]
453pub struct DecisionLog {
454 pub timestamp: Instant,
456 pub elapsed_ms: f64,
458 pub event_idx: u64,
460 pub dt_ms: f64,
462 pub event_rate: f64,
464 pub regime: Regime,
466 pub action: &'static str,
468 pub pending_size: Option<(u16, u16)>,
470 pub applied_size: Option<(u16, u16)>,
472 pub time_since_render_ms: f64,
474 pub coalesce_ms: Option<f64>,
476 pub forced: bool,
478}
479
480impl DecisionLog {
481 #[must_use]
483 pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
484 let (pending_w, pending_h) = match self.pending_size {
485 Some((w, h)) => (w.to_string(), h.to_string()),
486 None => ("null".to_string(), "null".to_string()),
487 };
488 let (applied_w, applied_h) = match self.applied_size {
489 Some((w, h)) => (w.to_string(), h.to_string()),
490 None => ("null".to_string(), "null".to_string()),
491 };
492 let coalesce_ms = match self.coalesce_ms {
493 Some(ms) => format!("{:.3}", ms),
494 None => "null".to_string(),
495 };
496 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
497
498 format!(
499 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":{}}}"#,
500 self.event_idx,
501 self.elapsed_ms,
502 self.dt_ms,
503 self.event_rate,
504 self.regime.as_str(),
505 self.action,
506 pending_w,
507 pending_h,
508 applied_w,
509 applied_h,
510 self.time_since_render_ms,
511 coalesce_ms,
512 self.forced
513 )
514 }
515}
516
517#[derive(Debug)]
521pub struct ResizeCoalescer {
522 config: CoalescerConfig,
523
524 pending_size: Option<(u16, u16)>,
526
527 last_applied: (u16, u16),
529
530 window_start: Option<Instant>,
532
533 last_event: Option<Instant>,
535
536 last_render: Instant,
538
539 regime: Regime,
541
542 cooldown_remaining: u32,
544
545 event_times: VecDeque<Instant>,
547
548 event_count: u64,
550
551 log_start: Option<Instant>,
553
554 logs: Vec<DecisionLog>,
556 evidence_sink: Option<EvidenceSink>,
558 config_logged: bool,
560 evidence_run_id: String,
562 evidence_screen_mode: ScreenMode,
564
565 telemetry_hooks: Option<TelemetryHooks>,
568
569 regime_transitions: u64,
571
572 events_in_window: u64,
574
575 cycle_times: Vec<f64>,
577
578 bocpd: Option<BocpdDetector>,
580}
581
582#[derive(Debug, Clone, Copy)]
584pub struct CycleTimePercentiles {
585 pub p50_ms: f64,
587 pub p95_ms: f64,
589 pub p99_ms: f64,
591 pub count: usize,
593 pub mean_ms: f64,
595}
596
597impl CycleTimePercentiles {
598 #[must_use]
600 pub fn to_jsonl(&self) -> String {
601 format!(
602 r#"{{"event":"cycle_time_percentiles","p50_ms":{:.3},"p95_ms":{:.3},"p99_ms":{:.3},"mean_ms":{:.3},"count":{}}}"#,
603 self.p50_ms, self.p95_ms, self.p99_ms, self.mean_ms, self.count
604 )
605 }
606}
607
608impl ResizeCoalescer {
609 pub fn new(config: CoalescerConfig, initial_size: (u16, u16)) -> Self {
611 let bocpd = if config.enable_bocpd {
612 let mut bocpd_cfg = config.bocpd_config.clone().unwrap_or_default();
613 if config.enable_logging {
614 bocpd_cfg.enable_logging = true;
615 }
616 Some(BocpdDetector::new(bocpd_cfg))
617 } else {
618 None
619 };
620
621 Self {
622 config,
623 pending_size: None,
624 last_applied: initial_size,
625 window_start: None,
626 last_event: None,
627 last_render: Instant::now(),
628 regime: Regime::Steady,
629 cooldown_remaining: 0,
630 event_times: VecDeque::new(),
631 event_count: 0,
632 log_start: None,
633 logs: Vec::new(),
634 evidence_sink: None,
635 config_logged: false,
636 evidence_run_id: default_resize_run_id(),
637 evidence_screen_mode: ScreenMode::AltScreen,
638 telemetry_hooks: None,
639 regime_transitions: 0,
640 events_in_window: 0,
641 cycle_times: Vec::new(),
642 bocpd,
643 }
644 }
645
646 #[must_use]
648 pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
649 self.telemetry_hooks = Some(hooks);
650 self
651 }
652
653 #[must_use]
655 pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
656 self.evidence_sink = Some(sink);
657 self.config_logged = false;
658 self
659 }
660
661 #[must_use]
663 pub fn with_evidence_run_id(mut self, run_id: impl Into<String>) -> Self {
664 self.evidence_run_id = run_id.into();
665 self
666 }
667
668 #[must_use]
670 pub fn with_screen_mode(mut self, screen_mode: ScreenMode) -> Self {
671 self.evidence_screen_mode = screen_mode;
672 self
673 }
674
675 pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
677 self.evidence_sink = sink;
678 self.config_logged = false;
679 }
680
681 #[must_use]
683 pub fn with_last_render(mut self, time: Instant) -> Self {
684 self.last_render = time;
685 self
686 }
687
688 pub fn record_external_apply(&mut self, width: u16, height: u16, now: Instant) {
690 self.event_count += 1;
691 self.event_times.push_back(now);
692 while self.event_times.len() > self.config.rate_window_size {
693 self.event_times.pop_front();
694 }
695 self.update_regime(now);
696
697 self.pending_size = None;
698 self.window_start = None;
699 self.last_event = Some(now);
700 self.last_applied = (width, height);
701 self.last_render = now;
702 self.events_in_window = 0;
703 self.cooldown_remaining = 0;
704
705 self.log_decision(now, "apply_immediate", false, Some(0.0), Some(0.0));
706
707 if let Some(ref hooks) = self.telemetry_hooks
708 && let Some(entry) = self.logs.last()
709 {
710 hooks.fire_resize_applied(entry);
711 }
712 }
713
714 #[must_use]
716 pub fn regime_transition_count(&self) -> u64 {
717 self.regime_transitions
718 }
719
720 #[must_use]
723 pub fn cycle_time_percentiles(&self) -> Option<CycleTimePercentiles> {
724 if self.cycle_times.is_empty() {
725 return None;
726 }
727
728 let mut sorted = self.cycle_times.clone();
729 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
730
731 let len = sorted.len();
732 let p50_idx = len / 2;
733 let p95_idx = (len * 95) / 100;
734 let p99_idx = (len * 99) / 100;
735
736 Some(CycleTimePercentiles {
737 p50_ms: sorted[p50_idx],
738 p95_ms: sorted[p95_idx.min(len - 1)],
739 p99_ms: sorted[p99_idx.min(len - 1)],
740 count: len,
741 mean_ms: sorted.iter().sum::<f64>() / len as f64,
742 })
743 }
744
745 pub fn handle_resize(&mut self, width: u16, height: u16) -> CoalesceAction {
749 self.handle_resize_at(width, height, Instant::now())
750 }
751
752 pub fn handle_resize_at(&mut self, width: u16, height: u16, now: Instant) -> CoalesceAction {
754 self.event_count += 1;
755
756 self.event_times.push_back(now);
758 while self.event_times.len() > self.config.rate_window_size {
759 self.event_times.pop_front();
760 }
761
762 self.update_regime(now);
764
765 let dt = self.last_event.map(|t| now.duration_since(t));
767 let dt_ms = dt.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0);
768 self.last_event = Some(now);
769
770 if self.pending_size.is_none() && (width, height) == self.last_applied {
772 self.log_decision(now, "skip_same_size", false, Some(dt_ms), None);
773 return CoalesceAction::None;
774 }
775
776 self.pending_size = Some((width, height));
778
779 self.events_in_window += 1;
781
782 if self.window_start.is_none() {
784 self.window_start = Some(now);
785 }
786
787 let time_since_render = now.duration_since(self.last_render);
789 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
790 return self.apply_pending_at(now, true);
791 }
792
793 if let Some(dt) = dt
796 && dt >= Duration::from_millis(self.current_delay_ms())
797 && (self.bocpd.is_some() || self.regime == Regime::Steady)
798 {
799 return self.apply_pending_at(now, false);
800 }
801
802 self.log_decision(now, "coalesce", false, Some(dt_ms), None);
803
804 if let Some(ref hooks) = self.telemetry_hooks
806 && let Some(entry) = self.logs.last()
807 {
808 hooks.fire_decision(entry);
809 }
810
811 CoalesceAction::ShowPlaceholder
812 }
813
814 pub fn tick(&mut self) -> CoalesceAction {
818 self.tick_at(Instant::now())
819 }
820
821 pub fn tick_at(&mut self, now: Instant) -> CoalesceAction {
823 if self.pending_size.is_none() {
824 return CoalesceAction::None;
825 }
826
827 if self.window_start.is_none() {
828 return CoalesceAction::None;
829 }
830
831 let time_since_render = now.duration_since(self.last_render);
833 if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
834 return self.apply_pending_at(now, true);
835 }
836
837 let delay_ms = self.current_delay_ms();
838
839 if let Some(last_event) = self.last_event {
841 let since_last_event = now.duration_since(last_event);
842 if since_last_event >= Duration::from_millis(delay_ms) {
843 return self.apply_pending_at(now, false);
844 }
845 }
846
847 if self.cooldown_remaining > 0 {
849 self.cooldown_remaining -= 1;
850 if self.cooldown_remaining == 0 && self.regime == Regime::Burst {
851 let rate = self.calculate_event_rate(now);
852 if rate < self.config.burst_exit_rate {
853 self.regime = Regime::Steady;
854 }
855 }
856 }
857
858 CoalesceAction::None
859 }
860
861 pub fn time_until_apply(&self, now: Instant) -> Option<Duration> {
863 let _pending = self.pending_size?;
864 let last_event = self.last_event?;
865
866 let delay_ms = self.current_delay_ms();
867
868 let elapsed = now.duration_since(last_event);
869 let target = Duration::from_millis(delay_ms);
870
871 if elapsed >= target {
872 Some(Duration::ZERO)
873 } else {
874 Some(target - elapsed)
875 }
876 }
877
878 #[inline]
880 pub fn has_pending(&self) -> bool {
881 self.pending_size.is_some()
882 }
883
884 #[inline]
886 pub fn regime(&self) -> Regime {
887 self.regime
888 }
889
890 #[inline]
892 pub fn bocpd_enabled(&self) -> bool {
893 self.bocpd.is_some()
894 }
895
896 #[inline]
900 pub fn bocpd(&self) -> Option<&BocpdDetector> {
901 self.bocpd.as_ref()
902 }
903
904 #[inline]
909 pub fn bocpd_p_burst(&self) -> Option<f64> {
910 self.bocpd.as_ref().map(|b| b.p_burst())
911 }
912
913 #[inline]
918 pub fn bocpd_recommended_delay(&self) -> Option<u64> {
919 self.bocpd
920 .as_ref()
921 .map(|b| b.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms))
922 }
923
924 pub fn event_rate(&self) -> f64 {
926 self.calculate_event_rate(Instant::now())
927 }
928
929 #[inline]
931 pub fn last_applied(&self) -> (u16, u16) {
932 self.last_applied
933 }
934
935 pub fn logs(&self) -> &[DecisionLog] {
937 &self.logs
938 }
939
940 pub fn clear_logs(&mut self) {
942 self.logs.clear();
943 self.log_start = None;
944 self.config_logged = false;
945 }
946
947 pub fn stats(&self) -> CoalescerStats {
949 CoalescerStats {
950 event_count: self.event_count,
951 regime: self.regime,
952 event_rate: self.event_rate(),
953 has_pending: self.pending_size.is_some(),
954 last_applied: self.last_applied,
955 }
956 }
957
958 #[must_use]
960 pub fn decision_logs_jsonl(&self) -> String {
961 let (cols, rows) = self.last_applied;
962 let run_id = self.evidence_run_id.as_str();
963 let screen_mode = self.evidence_screen_mode;
964 self.logs
965 .iter()
966 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows))
967 .collect::<Vec<_>>()
968 .join("\n")
969 }
970
971 #[must_use]
973 pub fn decision_checksum(&self) -> u64 {
974 let mut hash = FNV_OFFSET_BASIS;
975 for entry in &self.logs {
976 fnv_hash_bytes(&mut hash, &entry.event_idx.to_le_bytes());
977 fnv_hash_bytes(&mut hash, &entry.elapsed_ms.to_bits().to_le_bytes());
978 fnv_hash_bytes(&mut hash, &entry.dt_ms.to_bits().to_le_bytes());
979 fnv_hash_bytes(&mut hash, &entry.event_rate.to_bits().to_le_bytes());
980 fnv_hash_bytes(
981 &mut hash,
982 &[match entry.regime {
983 Regime::Steady => 0u8,
984 Regime::Burst => 1u8,
985 }],
986 );
987 fnv_hash_bytes(&mut hash, entry.action.as_bytes());
988 fnv_hash_bytes(&mut hash, &[0u8]); fnv_hash_bytes(&mut hash, &[entry.pending_size.is_some() as u8]);
991 if let Some((w, h)) = entry.pending_size {
992 fnv_hash_bytes(&mut hash, &w.to_le_bytes());
993 fnv_hash_bytes(&mut hash, &h.to_le_bytes());
994 }
995
996 fnv_hash_bytes(&mut hash, &[entry.applied_size.is_some() as u8]);
997 if let Some((w, h)) = entry.applied_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(
1003 &mut hash,
1004 &entry.time_since_render_ms.to_bits().to_le_bytes(),
1005 );
1006 fnv_hash_bytes(&mut hash, &[entry.coalesce_ms.is_some() as u8]);
1007 if let Some(ms) = entry.coalesce_ms {
1008 fnv_hash_bytes(&mut hash, &ms.to_bits().to_le_bytes());
1009 }
1010 fnv_hash_bytes(&mut hash, &[entry.forced as u8]);
1011 }
1012 hash
1013 }
1014
1015 #[must_use]
1017 pub fn decision_checksum_hex(&self) -> String {
1018 format!("{:016x}", self.decision_checksum())
1019 }
1020
1021 #[must_use]
1023 #[allow(clippy::field_reassign_with_default)]
1024 pub fn decision_summary(&self) -> DecisionSummary {
1025 let mut summary = DecisionSummary::default();
1026 summary.decision_count = self.logs.len();
1027 summary.last_applied = self.last_applied;
1028 summary.regime = self.regime;
1029
1030 for entry in &self.logs {
1031 match entry.action {
1032 "apply" | "apply_forced" | "apply_immediate" => {
1033 summary.apply_count += 1;
1034 if entry.forced {
1035 summary.forced_apply_count += 1;
1036 }
1037 }
1038 "coalesce" => summary.coalesce_count += 1,
1039 "skip_same_size" => summary.skip_count += 1,
1040 _ => {}
1041 }
1042 }
1043
1044 summary.checksum = self.decision_checksum();
1045 summary
1046 }
1047
1048 #[must_use]
1050 pub fn evidence_to_jsonl(&self) -> String {
1051 let mut lines = Vec::with_capacity(self.logs.len() + 2);
1052 let (cols, rows) = self.last_applied;
1053 let run_id = self.evidence_run_id.as_str();
1054 let screen_mode = self.evidence_screen_mode;
1055 let summary_event_idx = self.logs.last().map(|entry| entry.event_idx).unwrap_or(0);
1056 lines.push(self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1057 lines.extend(
1058 self.logs
1059 .iter()
1060 .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1061 );
1062 lines.push(self.decision_summary().to_jsonl(
1063 run_id,
1064 screen_mode,
1065 cols,
1066 rows,
1067 summary_event_idx,
1068 ));
1069 lines.join("\n")
1070 }
1071
1072 fn apply_pending_at(&mut self, now: Instant, forced: bool) -> CoalesceAction {
1075 let Some((width, height)) = self.pending_size.take() else {
1076 return CoalesceAction::None;
1077 };
1078
1079 let coalesce_time = self
1080 .window_start
1081 .map(|s| now.duration_since(s))
1082 .unwrap_or(Duration::ZERO);
1083 let coalesce_ms = coalesce_time.as_secs_f64() * 1000.0;
1084
1085 self.cycle_times.push(coalesce_ms);
1087
1088 self.window_start = None;
1089 self.last_applied = (width, height);
1090 self.last_render = now;
1091
1092 self.events_in_window = 0;
1094
1095 self.log_decision(
1096 now,
1097 if forced { "apply_forced" } else { "apply" },
1098 forced,
1099 None,
1100 Some(coalesce_ms),
1101 );
1102
1103 if let Some(ref hooks) = self.telemetry_hooks
1105 && let Some(entry) = self.logs.last()
1106 {
1107 hooks.fire_resize_applied(entry);
1108 }
1109
1110 CoalesceAction::ApplyResize {
1111 width,
1112 height,
1113 coalesce_time,
1114 forced_by_deadline: forced,
1115 }
1116 }
1117
1118 #[inline]
1119 fn current_delay_ms(&self) -> u64 {
1120 if let Some(ref bocpd) = self.bocpd {
1121 bocpd.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms)
1122 } else {
1123 match self.regime {
1124 Regime::Steady => self.config.steady_delay_ms,
1125 Regime::Burst => self.config.burst_delay_ms,
1126 }
1127 }
1128 }
1129
1130 fn update_regime(&mut self, now: Instant) {
1131 let old_regime = self.regime;
1132
1133 if let Some(ref mut bocpd) = self.bocpd {
1135 bocpd.observe_event(now);
1137
1138 self.regime = match bocpd.regime() {
1140 BocpdRegime::Steady => Regime::Steady,
1141 BocpdRegime::Burst => Regime::Burst,
1142 BocpdRegime::Transitional => {
1143 self.regime
1145 }
1146 };
1147 } else {
1148 let rate = self.calculate_event_rate(now);
1150
1151 match self.regime {
1152 Regime::Steady => {
1153 if rate >= self.config.burst_enter_rate {
1154 self.regime = Regime::Burst;
1155 self.cooldown_remaining = self.config.cooldown_frames;
1156 }
1157 }
1158 Regime::Burst => {
1159 if rate < self.config.burst_exit_rate {
1160 if self.cooldown_remaining == 0 {
1162 self.cooldown_remaining = self.config.cooldown_frames;
1163 }
1164 } else {
1165 self.cooldown_remaining = self.config.cooldown_frames;
1167 }
1168 }
1169 }
1170 }
1171
1172 if old_regime != self.regime {
1174 self.regime_transitions += 1;
1175 if let Some(ref hooks) = self.telemetry_hooks {
1176 hooks.fire_regime_change(old_regime, self.regime);
1177 }
1178 }
1179 }
1180
1181 fn calculate_event_rate(&self, now: Instant) -> f64 {
1182 if self.event_times.len() < 2 {
1183 return 0.0;
1184 }
1185
1186 let first = *self.event_times.front().unwrap();
1187 let window_duration = now.duration_since(first);
1188
1189 let duration_secs = window_duration.as_secs_f64().max(0.001);
1192
1193 (self.event_times.len() as f64) / duration_secs
1194 }
1195
1196 fn log_decision(
1197 &mut self,
1198 now: Instant,
1199 action: &'static str,
1200 forced: bool,
1201 dt_ms_override: Option<f64>,
1202 coalesce_ms: Option<f64>,
1203 ) {
1204 if !self.config.enable_logging {
1205 return;
1206 }
1207
1208 if self.log_start.is_none() {
1209 self.log_start = Some(now);
1210 }
1211
1212 let elapsed_ms = self
1213 .log_start
1214 .map(|t| now.duration_since(t).as_secs_f64() * 1000.0)
1215 .unwrap_or(0.0);
1216
1217 let dt_ms = dt_ms_override
1218 .or_else(|| {
1219 self.last_event
1220 .map(|t| now.duration_since(t).as_secs_f64() * 1000.0)
1221 })
1222 .unwrap_or(0.0);
1223
1224 let time_since_render_ms = now.duration_since(self.last_render).as_secs_f64() * 1000.0;
1225
1226 let applied_size =
1227 if action == "apply" || action == "apply_forced" || action == "apply_immediate" {
1228 Some(self.last_applied)
1229 } else {
1230 None
1231 };
1232
1233 self.logs.push(DecisionLog {
1234 timestamp: now,
1235 elapsed_ms,
1236 event_idx: self.event_count,
1237 dt_ms,
1238 event_rate: self.calculate_event_rate(now),
1239 regime: self.regime,
1240 action,
1241 pending_size: self.pending_size,
1242 applied_size,
1243 time_since_render_ms,
1244 coalesce_ms,
1245 forced,
1246 });
1247
1248 if let Some(ref sink) = self.evidence_sink {
1249 let (cols, rows) = self.last_applied;
1250 let run_id = self.evidence_run_id.as_str();
1251 let screen_mode = self.evidence_screen_mode;
1252 if !self.config_logged {
1253 let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1254 self.config_logged = true;
1255 }
1256 if let Some(entry) = self.logs.last() {
1257 let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1258 }
1259 if let Some(ref bocpd) = self.bocpd
1260 && let Some(jsonl) = bocpd.decision_log_jsonl(
1261 self.config.steady_delay_ms,
1262 self.config.burst_delay_ms,
1263 forced,
1264 )
1265 {
1266 let _ = sink.write_jsonl(&jsonl);
1267 }
1268 }
1269 }
1270}
1271
1272#[derive(Debug, Clone)]
1274pub struct CoalescerStats {
1275 pub event_count: u64,
1277 pub regime: Regime,
1279 pub event_rate: f64,
1281 pub has_pending: bool,
1283 pub last_applied: (u16, u16),
1285}
1286
1287#[derive(Debug, Clone, Default)]
1289pub struct DecisionSummary {
1290 pub decision_count: usize,
1292 pub apply_count: usize,
1294 pub forced_apply_count: usize,
1296 pub coalesce_count: usize,
1298 pub skip_count: usize,
1300 pub regime: Regime,
1302 pub last_applied: (u16, u16),
1304 pub checksum: u64,
1306}
1307
1308impl DecisionSummary {
1309 #[must_use]
1311 pub fn checksum_hex(&self) -> String {
1312 format!("{:016x}", self.checksum)
1313 }
1314
1315 #[must_use]
1317 pub fn to_jsonl(
1318 &self,
1319 run_id: &str,
1320 screen_mode: ScreenMode,
1321 cols: u16,
1322 rows: u16,
1323 event_idx: u64,
1324 ) -> String {
1325 let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
1326 format!(
1327 r#"{{{prefix},"event":"summary","decisions":{},"applies":{},"forced_applies":{},"coalesces":{},"skips":{},"regime":"{}","last_w":{},"last_h":{},"checksum":"{}"}}"#,
1328 self.decision_count,
1329 self.apply_count,
1330 self.forced_apply_count,
1331 self.coalesce_count,
1332 self.skip_count,
1333 self.regime.as_str(),
1334 self.last_applied.0,
1335 self.last_applied.1,
1336 self.checksum_hex()
1337 )
1338 }
1339}
1340
1341pub type OnResizeApplied = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1347pub type OnRegimeChange = Box<dyn Fn(Regime, Regime) + Send + Sync>;
1349pub type OnCoalesceDecision = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1351
1352pub struct TelemetryHooks {
1367 on_resize_applied: Option<OnResizeApplied>,
1369 on_regime_change: Option<OnRegimeChange>,
1371 on_decision: Option<OnCoalesceDecision>,
1373 emit_tracing: bool,
1375}
1376
1377impl Default for TelemetryHooks {
1378 fn default() -> Self {
1379 Self::new()
1380 }
1381}
1382
1383impl std::fmt::Debug for TelemetryHooks {
1384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1385 f.debug_struct("TelemetryHooks")
1386 .field("on_resize_applied", &self.on_resize_applied.is_some())
1387 .field("on_regime_change", &self.on_regime_change.is_some())
1388 .field("on_decision", &self.on_decision.is_some())
1389 .field("emit_tracing", &self.emit_tracing)
1390 .finish()
1391 }
1392}
1393
1394impl TelemetryHooks {
1395 #[must_use]
1397 pub fn new() -> Self {
1398 Self {
1399 on_resize_applied: None,
1400 on_regime_change: None,
1401 on_decision: None,
1402 emit_tracing: false,
1403 }
1404 }
1405
1406 #[must_use]
1408 pub fn on_resize_applied<F>(mut self, callback: F) -> Self
1409 where
1410 F: Fn(&DecisionLog) + Send + Sync + 'static,
1411 {
1412 self.on_resize_applied = Some(Box::new(callback));
1413 self
1414 }
1415
1416 #[must_use]
1418 pub fn on_regime_change<F>(mut self, callback: F) -> Self
1419 where
1420 F: Fn(Regime, Regime) + Send + Sync + 'static,
1421 {
1422 self.on_regime_change = Some(Box::new(callback));
1423 self
1424 }
1425
1426 #[must_use]
1428 pub fn on_decision<F>(mut self, callback: F) -> Self
1429 where
1430 F: Fn(&DecisionLog) + Send + Sync + 'static,
1431 {
1432 self.on_decision = Some(Box::new(callback));
1433 self
1434 }
1435
1436 #[must_use]
1441 pub fn with_tracing(mut self, enabled: bool) -> Self {
1442 self.emit_tracing = enabled;
1443 self
1444 }
1445
1446 pub fn has_resize_applied(&self) -> bool {
1448 self.on_resize_applied.is_some()
1449 }
1450
1451 pub fn has_regime_change(&self) -> bool {
1453 self.on_regime_change.is_some()
1454 }
1455
1456 pub fn has_decision(&self) -> bool {
1458 self.on_decision.is_some()
1459 }
1460
1461 fn fire_resize_applied(&self, entry: &DecisionLog) {
1463 if let Some(ref cb) = self.on_resize_applied {
1464 cb(entry);
1465 }
1466 if self.emit_tracing {
1467 Self::emit_resize_tracing(entry);
1468 }
1469 }
1470
1471 fn fire_regime_change(&self, from: Regime, to: Regime) {
1473 if let Some(ref cb) = self.on_regime_change {
1474 cb(from, to);
1475 }
1476 if self.emit_tracing {
1477 tracing::debug!(
1478 target: "ftui.decision.resize",
1479 from_regime = %from.as_str(),
1480 to_regime = %to.as_str(),
1481 "regime_change"
1482 );
1483 }
1484 }
1485
1486 fn fire_decision(&self, entry: &DecisionLog) {
1488 if let Some(ref cb) = self.on_decision {
1489 cb(entry);
1490 }
1491 }
1492
1493 fn emit_resize_tracing(entry: &DecisionLog) {
1495 let (pending_w, pending_h) = entry.pending_size.unwrap_or((0, 0));
1496 let (applied_w, applied_h) = entry.applied_size.unwrap_or((0, 0));
1497 let coalesce_ms = entry.coalesce_ms.unwrap_or(0.0);
1498
1499 tracing::info!(
1500 target: "ftui.decision.resize",
1501 event_idx = entry.event_idx,
1502 elapsed_ms = entry.elapsed_ms,
1503 dt_ms = entry.dt_ms,
1504 event_rate = entry.event_rate,
1505 regime = %entry.regime.as_str(),
1506 action = entry.action,
1507 pending_w = pending_w,
1508 pending_h = pending_h,
1509 applied_w = applied_w,
1510 applied_h = applied_h,
1511 time_since_render_ms = entry.time_since_render_ms,
1512 coalesce_ms = coalesce_ms,
1513 forced = entry.forced,
1514 "resize_decision"
1515 );
1516 }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522
1523 fn test_config() -> CoalescerConfig {
1524 CoalescerConfig {
1525 steady_delay_ms: 16,
1526 burst_delay_ms: 40,
1527 hard_deadline_ms: 100,
1528 burst_enter_rate: 10.0,
1529 burst_exit_rate: 5.0,
1530 cooldown_frames: 3,
1531 rate_window_size: 8,
1532 enable_logging: true,
1533 enable_bocpd: false,
1534 bocpd_config: None,
1535 }
1536 }
1537
1538 #[derive(Debug, Clone, Copy)]
1539 struct SimulationMetrics {
1540 event_count: u64,
1541 apply_count: u64,
1542 forced_count: u64,
1543 mean_coalesce_ms: f64,
1544 max_coalesce_ms: f64,
1545 decision_checksum: u64,
1546 final_regime: Regime,
1547 }
1548
1549 impl SimulationMetrics {
1550 fn to_jsonl(self, pattern: &str, mode: &str) -> String {
1551 let pattern = json_escape(pattern);
1552 let mode = json_escape(mode);
1553 let apply_ratio = if self.event_count == 0 {
1554 0.0
1555 } else {
1556 self.apply_count as f64 / self.event_count as f64
1557 };
1558
1559 format!(
1560 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}"}}"#,
1561 self.event_count,
1562 self.apply_count,
1563 self.forced_count,
1564 apply_ratio,
1565 self.mean_coalesce_ms,
1566 self.max_coalesce_ms,
1567 self.final_regime.as_str(),
1568 self.decision_checksum
1569 )
1570 }
1571 }
1572
1573 #[derive(Debug, Clone, Copy)]
1574 struct SimulationComparison {
1575 apply_delta: i64,
1576 mean_coalesce_delta_ms: f64,
1577 }
1578
1579 impl SimulationComparison {
1580 fn from_metrics(heuristic: SimulationMetrics, bocpd: SimulationMetrics) -> Self {
1581 let heuristic_apply = i64::try_from(heuristic.apply_count).unwrap_or(i64::MAX);
1582 let bocpd_apply = i64::try_from(bocpd.apply_count).unwrap_or(i64::MAX);
1583 let apply_delta = heuristic_apply.saturating_sub(bocpd_apply);
1584 let mean_coalesce_delta_ms = heuristic.mean_coalesce_ms - bocpd.mean_coalesce_ms;
1585 Self {
1586 apply_delta,
1587 mean_coalesce_delta_ms,
1588 }
1589 }
1590
1591 fn to_jsonl(self, pattern: &str) -> String {
1592 let pattern = json_escape(pattern);
1593 format!(
1594 r#"{{"event":"simulation_compare","pattern":"{pattern}","apply_delta":{},"mean_coalesce_delta_ms":{:.3}}}"#,
1595 self.apply_delta, self.mean_coalesce_delta_ms
1596 )
1597 }
1598 }
1599
1600 fn as_u64(value: usize) -> u64 {
1601 u64::try_from(value).unwrap_or(u64::MAX)
1602 }
1603
1604 fn build_schedule(base: Instant, events: &[(u16, u16, u64)]) -> Vec<(Instant, u16, u16)> {
1605 let mut schedule = Vec::with_capacity(events.len());
1606 let mut elapsed_ms = 0u64;
1607 for (w, h, delay_ms) in events {
1608 elapsed_ms = elapsed_ms.saturating_add(*delay_ms);
1609 schedule.push((base + Duration::from_millis(elapsed_ms), *w, *h));
1610 }
1611 schedule
1612 }
1613
1614 fn run_simulation(
1615 events: &[(u16, u16, u64)],
1616 config: CoalescerConfig,
1617 tick_ms: u64,
1618 ) -> SimulationMetrics {
1619 let mut c = ResizeCoalescer::new(config, (80, 24));
1620 let base = Instant::now();
1621 let schedule = build_schedule(base, events);
1622 let last_event_ms = schedule
1623 .last()
1624 .map(|(time, _, _)| {
1625 u64::try_from(time.duration_since(base).as_millis()).unwrap_or(u64::MAX)
1626 })
1627 .unwrap_or(0);
1628 let end_ms = last_event_ms
1629 .saturating_add(c.config.hard_deadline_ms)
1630 .saturating_add(tick_ms);
1631
1632 let mut next_idx = 0usize;
1633 let mut now_ms = 0u64;
1634 while now_ms <= end_ms {
1635 let now = base + Duration::from_millis(now_ms);
1636
1637 while next_idx < schedule.len() && schedule[next_idx].0 <= now {
1638 let (event_time, w, h) = schedule[next_idx];
1639 let _ = c.handle_resize_at(w, h, event_time);
1640 next_idx += 1;
1641 }
1642
1643 let _ = c.tick_at(now);
1644 now_ms = now_ms.saturating_add(tick_ms);
1645 }
1646
1647 let mut coalesce_values = Vec::new();
1648 let mut apply_count = 0usize;
1649 let mut forced_count = 0usize;
1650 for entry in c.logs() {
1651 if matches!(entry.action, "apply" | "apply_forced" | "apply_immediate") {
1652 apply_count += 1;
1653 if entry.forced {
1654 forced_count += 1;
1655 }
1656 if let Some(ms) = entry.coalesce_ms {
1657 coalesce_values.push(ms);
1658 }
1659 }
1660 }
1661
1662 let max_coalesce_ms = coalesce_values
1663 .iter()
1664 .copied()
1665 .fold(0.0_f64, |acc, value| acc.max(value));
1666 let mean_coalesce_ms = if coalesce_values.is_empty() {
1667 0.0
1668 } else {
1669 let sum = coalesce_values.iter().sum::<f64>();
1670 sum / as_u64(coalesce_values.len()) as f64
1671 };
1672
1673 SimulationMetrics {
1674 event_count: as_u64(events.len()),
1675 apply_count: as_u64(apply_count),
1676 forced_count: as_u64(forced_count),
1677 mean_coalesce_ms,
1678 max_coalesce_ms,
1679 decision_checksum: c.decision_checksum(),
1680 final_regime: c.regime(),
1681 }
1682 }
1683
1684 fn steady_pattern() -> Vec<(u16, u16, u64)> {
1685 let mut events = Vec::new();
1686 for i in 0..8u16 {
1687 let width = 90 + i;
1688 let height = 30 + (i % 3);
1689 events.push((width, height, 300));
1690 }
1691 events
1692 }
1693
1694 fn burst_pattern() -> Vec<(u16, u16, u64)> {
1695 let mut events = Vec::new();
1696 for i in 0..30u16 {
1697 let width = 100 + i;
1698 let height = 25 + (i % 5);
1699 events.push((width, height, 10));
1700 }
1701 events
1702 }
1703
1704 fn oscillatory_pattern() -> Vec<(u16, u16, u64)> {
1705 let mut events = Vec::new();
1706 let sizes = [(120, 40), (140, 28), (130, 36), (150, 32)];
1707 let delays = [40u64, 200u64, 60u64, 180u64];
1708 for i in 0..16usize {
1709 let (w, h) = sizes[i % sizes.len()];
1710 let delay = delays[i % delays.len()];
1711 events.push((w + (i as u16 % 3), h, delay));
1712 }
1713 events
1714 }
1715
1716 #[test]
1717 fn new_coalescer_starts_in_steady() {
1718 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
1719 assert_eq!(c.regime(), Regime::Steady);
1720 assert!(!c.has_pending());
1721 }
1722
1723 #[test]
1724 fn same_size_returns_none() {
1725 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1726 let action = c.handle_resize(80, 24);
1727 assert_eq!(action, CoalesceAction::None);
1728 }
1729
1730 #[test]
1731 fn different_size_shows_placeholder() {
1732 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1733 let action = c.handle_resize(100, 40);
1734 assert_eq!(action, CoalesceAction::ShowPlaceholder);
1735 assert!(c.has_pending());
1736 }
1737
1738 #[test]
1739 fn latest_wins_semantics() {
1740 let config = test_config();
1741 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1742
1743 let base = Instant::now();
1744
1745 c.handle_resize_at(90, 30, base);
1747 c.handle_resize_at(100, 40, base + Duration::from_millis(5));
1748 c.handle_resize_at(110, 50, base + Duration::from_millis(10));
1749
1750 let action = c.tick_at(base + Duration::from_millis(60));
1752
1753 let (width, height) = if let CoalesceAction::ApplyResize { width, height, .. } = action {
1754 (width, height)
1755 } else {
1756 assert!(
1757 matches!(action, CoalesceAction::ApplyResize { .. }),
1758 "Expected ApplyResize, got {action:?}"
1759 );
1760 return;
1761 };
1762 assert_eq!((width, height), (110, 50), "Should apply latest size");
1763 }
1764
1765 #[test]
1766 fn hard_deadline_forces_apply() {
1767 let config = test_config();
1768 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1769
1770 let base = Instant::now();
1771
1772 c.handle_resize_at(100, 40, base);
1774
1775 let action = c.tick_at(base + Duration::from_millis(150));
1777
1778 let forced_by_deadline = if let CoalesceAction::ApplyResize {
1779 forced_by_deadline, ..
1780 } = action
1781 {
1782 forced_by_deadline
1783 } else {
1784 assert!(
1785 matches!(action, CoalesceAction::ApplyResize { .. }),
1786 "Expected ApplyResize, got {action:?}"
1787 );
1788 return;
1789 };
1790 assert!(forced_by_deadline, "Should be forced by deadline");
1791 }
1792
1793 #[test]
1794 fn burst_mode_detection() {
1795 let config = test_config();
1796 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1797
1798 let base = Instant::now();
1799
1800 for i in 0..15 {
1802 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
1803 }
1804
1805 assert_eq!(c.regime(), Regime::Burst);
1806 }
1807
1808 #[test]
1809 fn steady_mode_fast_response() {
1810 let config = test_config();
1811 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1812
1813 let base = Instant::now();
1814
1815 c.handle_resize_at(100, 40, base);
1817
1818 let action = c.tick_at(base + Duration::from_millis(20));
1820
1821 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1822 }
1823
1824 #[test]
1825 fn record_external_apply_updates_state_and_logs() {
1826 let config = test_config();
1827 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1828
1829 let base = Instant::now();
1830 c.handle_resize_at(100, 40, base);
1831 c.record_external_apply(120, 50, base + Duration::from_millis(5));
1832
1833 assert!(!c.has_pending());
1834 assert_eq!(c.last_applied(), (120, 50));
1835
1836 let summary = c.decision_summary();
1837 assert_eq!(summary.apply_count, 1);
1838 assert_eq!(summary.last_applied, (120, 50));
1839 assert!(
1840 c.logs()
1841 .iter()
1842 .any(|entry| entry.action == "apply_immediate"),
1843 "record_external_apply should emit apply_immediate decision"
1844 );
1845 }
1846
1847 #[test]
1848 fn coalesce_time_tracked() {
1849 let config = test_config();
1850 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1851
1852 let base = Instant::now();
1853
1854 c.handle_resize_at(100, 40, base);
1855 let action = c.tick_at(base + Duration::from_millis(50));
1856
1857 let coalesce_time = if let CoalesceAction::ApplyResize { coalesce_time, .. } = action {
1858 coalesce_time
1859 } else {
1860 assert!(
1861 matches!(action, CoalesceAction::ApplyResize { .. }),
1862 "Expected ApplyResize"
1863 );
1864 return;
1865 };
1866 assert!(coalesce_time >= Duration::from_millis(40));
1867 assert!(coalesce_time <= Duration::from_millis(60));
1868 }
1869
1870 #[test]
1871 fn event_rate_calculation() {
1872 let config = test_config();
1873 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1874
1875 let base = Instant::now();
1876
1877 for i in 0..10 {
1879 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 100));
1880 }
1881
1882 let rate = c.calculate_event_rate(base + Duration::from_millis(1000));
1883 assert!(rate > 8.0 && rate < 12.0, "Rate should be ~10 events/sec");
1884 }
1885
1886 #[test]
1887 fn rapid_burst_triggers_high_rate() {
1888 let config = test_config();
1889 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1890 let base = Instant::now();
1891
1892 for _ in 0..8 {
1894 c.handle_resize_at(80, 24, base);
1895 }
1896
1897 let rate = c.calculate_event_rate(base);
1898 assert!(
1900 rate >= 1000.0,
1901 "Rate should be high for instantaneous burst, got {}",
1902 rate
1903 );
1904 }
1905
1906 #[test]
1907 fn cooldown_prevents_immediate_exit() {
1908 let config = test_config();
1909 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1910
1911 let base = Instant::now();
1912
1913 for i in 0..15 {
1915 c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
1916 }
1917 assert_eq!(c.regime(), Regime::Burst);
1918
1919 c.tick_at(base + Duration::from_millis(500));
1921 c.tick_at(base + Duration::from_millis(600));
1922
1923 c.tick_at(base + Duration::from_millis(700));
1925 c.tick_at(base + Duration::from_millis(800));
1926 c.tick_at(base + Duration::from_millis(900));
1927
1928 }
1931
1932 #[test]
1933 fn logging_captures_decisions() {
1934 let mut config = test_config();
1935 config.enable_logging = true;
1936 let mut c = ResizeCoalescer::new(config, (80, 24));
1937
1938 c.handle_resize(100, 40);
1939
1940 assert!(!c.logs().is_empty());
1941 assert_eq!(c.logs()[0].action, "coalesce");
1942 }
1943
1944 #[test]
1945 fn logging_jsonl_format() {
1946 let mut config = test_config();
1947 config.enable_logging = true;
1948 let mut c = ResizeCoalescer::new(config, (80, 24));
1949
1950 c.handle_resize(100, 40);
1951 let (cols, rows) = c.last_applied();
1952 let jsonl = c.logs()[0].to_jsonl("resize-test", ScreenMode::AltScreen, cols, rows);
1953
1954 assert!(jsonl.contains("\"event\":\"decision\""));
1955 assert!(jsonl.contains("\"action\":\"coalesce\""));
1956 assert!(jsonl.contains("\"regime\":\"steady\""));
1957 assert!(jsonl.contains("\"pending_w\":100"));
1958 assert!(jsonl.contains("\"pending_h\":40"));
1959 }
1960
1961 #[test]
1962 fn apply_logs_coalesce_ms() {
1963 let mut config = test_config();
1964 config.enable_logging = true;
1965 let mut c = ResizeCoalescer::new(config, (80, 24));
1966
1967 let base = Instant::now();
1968 c.handle_resize_at(100, 40, base);
1969 let action = c.tick_at(base + Duration::from_millis(50));
1970 assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1971
1972 let last = c.logs().last().expect("Expected a decision log entry");
1973 assert!(last.coalesce_ms.is_some());
1974 assert!(last.coalesce_ms.unwrap() >= 0.0);
1975 }
1976
1977 #[test]
1978 fn decision_checksum_is_stable() {
1979 let mut config = test_config();
1980 config.enable_logging = true;
1981
1982 let base = Instant::now();
1983 let mut c1 = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
1984 let mut c2 = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
1985
1986 for c in [&mut c1, &mut c2] {
1987 c.handle_resize_at(90, 30, base);
1988 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
1989 let _ = c.tick_at(base + Duration::from_millis(80));
1990 }
1991
1992 assert_eq!(c1.decision_checksum(), c2.decision_checksum());
1993 }
1994
1995 #[test]
1996 fn evidence_jsonl_includes_summary() {
1997 let mut config = test_config();
1998 config.enable_logging = true;
1999 let mut c = ResizeCoalescer::new(config, (80, 24));
2000
2001 c.handle_resize(100, 40);
2002 let jsonl = c.evidence_to_jsonl();
2003
2004 assert!(jsonl.contains("\"event\":\"config\""));
2005 assert!(jsonl.contains("\"event\":\"summary\""));
2006 }
2007
2008 #[test]
2009 fn evidence_jsonl_parses_and_has_required_fields() {
2010 use serde_json::Value;
2011
2012 let mut config = test_config();
2013 config.enable_logging = true;
2014 let base = Instant::now();
2015 let mut c = ResizeCoalescer::new(config, (80, 24))
2016 .with_last_render(base)
2017 .with_evidence_run_id("resize-test")
2018 .with_screen_mode(ScreenMode::AltScreen);
2019
2020 c.handle_resize_at(90, 30, base);
2021 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2022 let _ = c.tick_at(base + Duration::from_millis(120));
2023
2024 let jsonl = c.evidence_to_jsonl();
2025 let mut saw_config = false;
2026 let mut saw_decision = false;
2027 let mut saw_summary = false;
2028
2029 for line in jsonl.lines() {
2030 let value: Value = serde_json::from_str(line).expect("valid JSONL evidence");
2031 let event = value
2032 .get("event")
2033 .and_then(Value::as_str)
2034 .expect("event field");
2035 assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
2036 assert_eq!(value["run_id"], "resize-test");
2037 assert!(
2038 value["event_idx"].is_number(),
2039 "event_idx should be numeric"
2040 );
2041 assert_eq!(value["screen_mode"], "altscreen");
2042 assert!(value["cols"].is_number(), "cols should be numeric");
2043 assert!(value["rows"].is_number(), "rows should be numeric");
2044 match event {
2045 "config" => {
2046 for key in [
2047 "steady_delay_ms",
2048 "burst_delay_ms",
2049 "hard_deadline_ms",
2050 "burst_enter_rate",
2051 "burst_exit_rate",
2052 "cooldown_frames",
2053 "rate_window_size",
2054 "logging_enabled",
2055 ] {
2056 assert!(value.get(key).is_some(), "missing config field {key}");
2057 }
2058 saw_config = true;
2059 }
2060 "decision" => {
2061 for key in [
2062 "idx",
2063 "elapsed_ms",
2064 "dt_ms",
2065 "event_rate",
2066 "regime",
2067 "action",
2068 "pending_w",
2069 "pending_h",
2070 "applied_w",
2071 "applied_h",
2072 "time_since_render_ms",
2073 "coalesce_ms",
2074 "forced",
2075 ] {
2076 assert!(value.get(key).is_some(), "missing decision field {key}");
2077 }
2078 saw_decision = true;
2079 }
2080 "summary" => {
2081 for key in [
2082 "decisions",
2083 "applies",
2084 "forced_applies",
2085 "coalesces",
2086 "skips",
2087 "regime",
2088 "last_w",
2089 "last_h",
2090 "checksum",
2091 ] {
2092 assert!(value.get(key).is_some(), "missing summary field {key}");
2093 }
2094 saw_summary = true;
2095 }
2096 _ => {}
2097 }
2098 }
2099
2100 assert!(saw_config, "config evidence missing");
2101 assert!(saw_decision, "decision evidence missing");
2102 assert!(saw_summary, "summary evidence missing");
2103 }
2104
2105 #[test]
2106 fn evidence_jsonl_is_deterministic_for_fixed_schedule() {
2107 let mut config = test_config();
2108 config.enable_logging = true;
2109 let base = Instant::now();
2110
2111 let run = || {
2112 let mut c = ResizeCoalescer::new(config.clone(), (80, 24))
2113 .with_last_render(base)
2114 .with_evidence_run_id("resize-test")
2115 .with_screen_mode(ScreenMode::AltScreen);
2116 c.handle_resize_at(90, 30, base);
2117 c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2118 let _ = c.tick_at(base + Duration::from_millis(120));
2119 c.evidence_to_jsonl()
2120 };
2121
2122 let first = run();
2123 let second = run();
2124 assert_eq!(first, second);
2125 }
2126
2127 #[test]
2128 fn bocpd_logging_inherits_coalescer_logging() {
2129 let mut config = test_config();
2130 config.enable_bocpd = true;
2131 config.bocpd_config = Some(BocpdConfig::default());
2132
2133 let c = ResizeCoalescer::new(config, (80, 24));
2134 let bocpd = c.bocpd().expect("BOCPD should be enabled");
2135 assert!(bocpd.config().enable_logging);
2136 }
2137
2138 #[test]
2139 fn stats_reflect_state() {
2140 let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2141
2142 c.handle_resize(100, 40);
2143
2144 let stats = c.stats();
2145 assert_eq!(stats.event_count, 1);
2146 assert!(stats.has_pending);
2147 assert_eq!(stats.last_applied, (80, 24));
2148 }
2149
2150 #[test]
2151 fn time_until_apply_calculation() {
2152 let config = test_config();
2153 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2154
2155 let base = Instant::now();
2156 c.handle_resize_at(100, 40, base);
2157
2158 let time_left = c.time_until_apply(base + Duration::from_millis(5));
2159 assert!(time_left.is_some());
2160 let time_left = time_left.unwrap();
2161 assert!(time_left.as_millis() > 0);
2162 assert!(time_left.as_millis() < config.steady_delay_ms as u128);
2163 }
2164
2165 #[test]
2166 fn deterministic_behavior() {
2167 let config = test_config();
2168
2169 let results: Vec<_> = (0..2)
2171 .map(|_| {
2172 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2173 let base = Instant::now();
2174
2175 for i in 0..5 {
2176 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 20));
2177 }
2178
2179 c.tick_at(base + Duration::from_millis(200))
2180 })
2181 .collect();
2182
2183 assert_eq!(results[0], results[1], "Results must be deterministic");
2184 }
2185
2186 #[test]
2187 fn simulation_bocpd_vs_heuristic_metrics() {
2188 let tick_ms = 5;
2189 let mut heuristic_config = test_config();
2192 heuristic_config.burst_enter_rate = 60.0;
2193 heuristic_config.burst_exit_rate = 30.0;
2194 let mut bocpd_cfg = BocpdConfig::responsive();
2195 bocpd_cfg.burst_prior = 0.35;
2196 bocpd_cfg.steady_threshold = 0.2;
2197 bocpd_cfg.burst_threshold = 0.6;
2198 let bocpd_config = heuristic_config.clone().with_bocpd_config(bocpd_cfg);
2199 let patterns = vec![
2200 ("steady", steady_pattern()),
2201 ("burst", burst_pattern()),
2202 ("oscillatory", oscillatory_pattern()),
2203 ];
2204
2205 for (pattern, events) in patterns {
2206 let heuristic = run_simulation(&events, heuristic_config.clone(), tick_ms);
2207 let bocpd = run_simulation(&events, bocpd_config.clone(), tick_ms);
2208
2209 let heuristic_jsonl = heuristic.to_jsonl(pattern, "heuristic");
2210 let bocpd_jsonl = bocpd.to_jsonl(pattern, "bocpd");
2211 let comparison = SimulationComparison::from_metrics(heuristic, bocpd);
2212 let comparison_jsonl = comparison.to_jsonl(pattern);
2213
2214 eprintln!("{heuristic_jsonl}");
2215 eprintln!("{bocpd_jsonl}");
2216 eprintln!("{comparison_jsonl}");
2217
2218 assert!(heuristic_jsonl.contains("\"event\":\"simulation_summary\""));
2219 assert!(bocpd_jsonl.contains("\"event\":\"simulation_summary\""));
2220 assert!(comparison_jsonl.contains("\"event\":\"simulation_compare\""));
2221
2222 #[allow(clippy::cast_precision_loss)]
2223 let max_allowed = test_config().hard_deadline_ms as f64 + 1.0;
2224 assert!(
2225 heuristic.max_coalesce_ms <= max_allowed,
2226 "heuristic latency bounded for {pattern}"
2227 );
2228 assert!(
2229 bocpd.max_coalesce_ms <= max_allowed,
2230 "bocpd latency bounded for {pattern}"
2231 );
2232
2233 if pattern == "burst" {
2234 let event_count = as_u64(events.len());
2235 assert!(
2236 heuristic.apply_count < event_count,
2237 "heuristic should coalesce under burst pattern"
2238 );
2239 assert!(
2240 bocpd.apply_count < event_count,
2241 "bocpd should coalesce under burst pattern"
2242 );
2243 assert!(
2244 comparison.apply_delta >= 0,
2245 "BOCPD should not increase renders in burst (apply_delta={})",
2246 comparison.apply_delta
2247 );
2248 }
2249 }
2250 }
2251
2252 #[test]
2253 fn never_drops_final_size() {
2254 let config = test_config();
2255 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2256
2257 let base = Instant::now();
2258
2259 let mut intermediate_applies = Vec::new();
2261 for i in 0..100 {
2262 let action = c.handle_resize_at(
2263 80 + (i % 50),
2264 24 + (i % 30),
2265 base + Duration::from_millis(i as u64 * 5),
2266 );
2267 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2268 intermediate_applies.push((width, height));
2269 }
2270 }
2271
2272 let final_action = c.handle_resize_at(200, 100, base + Duration::from_millis(600));
2274
2275 let applied_size = if let CoalesceAction::ApplyResize { width, height, .. } = final_action {
2276 Some((width, height))
2277 } else {
2278 let mut result = None;
2280 for tick in 0..100 {
2281 let action = c.tick_at(base + Duration::from_millis(700 + tick * 20));
2282 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2283 result = Some((width, height));
2284 break;
2285 }
2286 }
2287 result
2288 };
2289
2290 assert_eq!(
2291 applied_size,
2292 Some((200, 100)),
2293 "Must apply final size 200x100"
2294 );
2295 }
2296
2297 #[test]
2298 fn bounded_latency_invariant() {
2299 let config = test_config();
2300 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2301
2302 let base = Instant::now();
2303 c.handle_resize_at(100, 40, base);
2304
2305 let mut applied_at = None;
2307 for ms in 0..200 {
2308 let now = base + Duration::from_millis(ms);
2309 let action = c.tick_at(now);
2310 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2311 applied_at = Some(ms);
2312 break;
2313 }
2314 }
2315
2316 assert!(applied_at.is_some(), "Must apply within reasonable time");
2317 assert!(
2318 applied_at.unwrap() <= config.hard_deadline_ms,
2319 "Must apply within hard deadline"
2320 );
2321 }
2322
2323 mod property {
2328 use super::*;
2329 use proptest::prelude::*;
2330
2331 fn dimension() -> impl Strategy<Value = u16> {
2333 1u16..500
2334 }
2335
2336 fn resize_sequence(max_len: usize) -> impl Strategy<Value = Vec<(u16, u16, u64)>> {
2338 proptest::collection::vec((dimension(), dimension(), 0u64..200), 0..max_len)
2339 }
2340
2341 proptest! {
2342 #[test]
2346 fn determinism_across_sequences(
2347 events in resize_sequence(50),
2348 tick_offset in 100u64..500
2349 ) {
2350 let config = CoalescerConfig::default();
2351
2352 let results: Vec<_> = (0..2)
2353 .map(|_| {
2354 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2355 let base = Instant::now();
2356
2357 for (i, (w, h, delay)) in events.iter().enumerate() {
2358 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2359 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2360 }
2361
2362 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2364 c.tick_at(base + Duration::from_millis(total_time))
2365 })
2366 .collect();
2367
2368 prop_assert_eq!(results[0], results[1], "Results must be deterministic");
2369 }
2370
2371 #[test]
2376 fn latest_wins_never_drops(
2377 events in resize_sequence(20),
2378 final_w in dimension(),
2379 final_h in dimension()
2380 ) {
2381 if events.is_empty() {
2382 return Ok(());
2384 }
2385
2386 let config = CoalescerConfig::default();
2387 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2388 let base = Instant::now();
2389
2390 let mut offset = 0u64;
2392 for (w, h, delay) in &events {
2393 offset += delay;
2394 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2395 }
2396
2397 offset += 50;
2399 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2400
2401 let mut final_applied = None;
2403 for tick in 0..200 {
2404 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2405 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2406 final_applied = Some((width, height));
2407 }
2408 if !c.has_pending() && final_applied.is_some() {
2409 break;
2410 }
2411 }
2412
2413 if let Some((applied_w, applied_h)) = final_applied {
2415 prop_assert_eq!(
2416 (applied_w, applied_h),
2417 (final_w, final_h),
2418 "Must apply the final size {} x {}",
2419 final_w,
2420 final_h
2421 );
2422 }
2423 }
2424
2425 #[test]
2429 fn bounded_latency_maintained(
2430 w in dimension(),
2431 h in dimension()
2432 ) {
2433 let config = CoalescerConfig::default();
2434 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2436 let base = Instant::now();
2437
2438 c.handle_resize_at(w, h, base);
2439
2440 let mut applied_at = None;
2442 for ms in 0..=config.hard_deadline_ms + 50 {
2443 let action = c.tick_at(base + Duration::from_millis(ms));
2444 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2445 applied_at = Some(ms);
2446 break;
2447 }
2448 }
2449
2450 prop_assert!(applied_at.is_some(), "Resize must be applied");
2451 prop_assert!(
2452 applied_at.unwrap() <= config.hard_deadline_ms,
2453 "Must apply within hard deadline ({}ms), took {}ms",
2454 config.hard_deadline_ms,
2455 applied_at.unwrap()
2456 );
2457 }
2458
2459 #[test]
2464 fn no_size_corruption(
2465 w in dimension(),
2466 h in dimension()
2467 ) {
2468 let config = CoalescerConfig::default();
2469 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2471 let base = Instant::now();
2472
2473 c.handle_resize_at(w, h, base);
2474
2475 let mut result = None;
2477 for ms in 0..200 {
2478 let action = c.tick_at(base + Duration::from_millis(ms));
2479 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2480 result = Some((width, height));
2481 break;
2482 }
2483 }
2484
2485 prop_assert!(result.is_some());
2486 let (applied_w, applied_h) = result.unwrap();
2487 prop_assert_eq!(applied_w, w, "Width must not be corrupted");
2488 prop_assert_eq!(applied_h, h, "Height must not be corrupted");
2489 }
2490
2491 #[test]
2495 fn regime_follows_event_rate(
2496 event_count in 1usize..30
2497 ) {
2498 let config = CoalescerConfig {
2499 burst_enter_rate: 10.0,
2500 burst_exit_rate: 5.0,
2501 ..CoalescerConfig::default()
2502 };
2503 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2504 let base = Instant::now();
2505
2506 for i in 0..event_count {
2508 c.handle_resize_at(
2509 80 + i as u16,
2510 24,
2511 base + Duration::from_millis(i as u64 * 50), );
2513 }
2514
2515 if event_count >= 10 {
2517 prop_assert_eq!(
2518 c.regime(),
2519 Regime::Burst,
2520 "Many rapid events should trigger burst mode"
2521 );
2522 }
2523 }
2524
2525 #[test]
2530 fn event_count_invariant(
2531 events in resize_sequence(100)
2532 ) {
2533 let config = CoalescerConfig::default();
2534 let mut c = ResizeCoalescer::new(config, (80, 24));
2535 let base = Instant::now();
2536
2537 for (w, h, delay) in &events {
2538 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2539 }
2540
2541 let stats = c.stats();
2542 prop_assert_eq!(
2544 stats.event_count,
2545 events.len() as u64,
2546 "Event count should match total incoming events"
2547 );
2548 }
2549
2550 #[test]
2556 fn bocpd_determinism_across_sequences(
2557 events in resize_sequence(30),
2558 tick_offset in 100u64..400
2559 ) {
2560 let config = CoalescerConfig::default().with_bocpd();
2561
2562 let results: Vec<_> = (0..2)
2563 .map(|_| {
2564 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2565 let base = Instant::now();
2566
2567 for (i, (w, h, delay)) in events.iter().enumerate() {
2568 let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2569 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2570 }
2571
2572 let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2573 let action = c.tick_at(base + Duration::from_millis(total_time));
2574 (action, c.regime(), c.bocpd_p_burst())
2575 })
2576 .collect();
2577
2578 prop_assert_eq!(results[0], results[1], "BOCPD results must be deterministic");
2579 }
2580
2581 #[test]
2583 fn bocpd_latest_wins_never_drops(
2584 events in resize_sequence(15),
2585 final_w in dimension(),
2586 final_h in dimension()
2587 ) {
2588 if events.is_empty() {
2589 return Ok(());
2590 }
2591
2592 let config = CoalescerConfig::default().with_bocpd();
2593 let mut c = ResizeCoalescer::new(config, (80, 24));
2594 let base = Instant::now();
2595
2596 let mut offset = 0u64;
2597 for (w, h, delay) in &events {
2598 offset += delay;
2599 c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2600 }
2601
2602 offset += 50;
2603 c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2604
2605 let mut final_applied = None;
2606 for tick in 0..200 {
2607 let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2608 if let CoalesceAction::ApplyResize { width, height, .. } = action {
2609 final_applied = Some((width, height));
2610 }
2611 if !c.has_pending() && final_applied.is_some() {
2612 break;
2613 }
2614 }
2615
2616 if let Some((applied_w, applied_h)) = final_applied {
2617 prop_assert_eq!(
2618 (applied_w, applied_h),
2619 (final_w, final_h),
2620 "BOCPD must apply the final size"
2621 );
2622 }
2623 }
2624
2625 #[test]
2627 fn bocpd_bounded_latency_maintained(
2628 w in dimension(),
2629 h in dimension()
2630 ) {
2631 let config = CoalescerConfig::default().with_bocpd();
2632 let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2633 let base = Instant::now();
2634
2635 c.handle_resize_at(w, h, base);
2636
2637 let mut applied_at = None;
2638 for ms in 0..=config.hard_deadline_ms + 50 {
2639 let action = c.tick_at(base + Duration::from_millis(ms));
2640 if matches!(action, CoalesceAction::ApplyResize { .. }) {
2641 applied_at = Some(ms);
2642 break;
2643 }
2644 }
2645
2646 prop_assert!(applied_at.is_some(), "BOCPD resize must be applied");
2647 prop_assert!(
2648 applied_at.unwrap() <= config.hard_deadline_ms,
2649 "BOCPD must apply within hard deadline ({}ms), took {}ms",
2650 config.hard_deadline_ms,
2651 applied_at.unwrap()
2652 );
2653 }
2654
2655 #[test]
2657 fn bocpd_posterior_always_valid(
2658 events in resize_sequence(50)
2659 ) {
2660 if events.is_empty() {
2661 return Ok(());
2662 }
2663
2664 let config = CoalescerConfig::default().with_bocpd();
2665 let mut c = ResizeCoalescer::new(config, (80, 24));
2666 let base = Instant::now();
2667
2668 for (w, h, delay) in &events {
2669 c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2670
2671 if let Some(bocpd) = c.bocpd() {
2673 let sum: f64 = bocpd.run_length_posterior().iter().sum();
2674 prop_assert!(
2675 (sum - 1.0).abs() < 1e-8,
2676 "Posterior must sum to 1, got {}",
2677 sum
2678 );
2679 }
2680
2681 let p_burst = c.bocpd_p_burst().unwrap();
2682 prop_assert!(
2683 (0.0..=1.0).contains(&p_burst),
2684 "P(burst) must be in [0,1], got {}",
2685 p_burst
2686 );
2687 }
2688 }
2689 }
2690 }
2691
2692 #[test]
2697 fn telemetry_hooks_fire_on_resize_applied() {
2698 use std::sync::Arc;
2699 use std::sync::atomic::{AtomicU32, Ordering};
2700
2701 let applied_count = Arc::new(AtomicU32::new(0));
2702 let applied_count_clone = applied_count.clone();
2703
2704 let hooks = TelemetryHooks::new().on_resize_applied(move |_entry| {
2705 applied_count_clone.fetch_add(1, Ordering::SeqCst);
2706 });
2707
2708 let mut config = test_config();
2709 config.enable_logging = true;
2710 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2711
2712 let base = Instant::now();
2713 c.handle_resize_at(100, 40, base);
2714 c.tick_at(base + Duration::from_millis(50));
2715
2716 assert_eq!(applied_count.load(Ordering::SeqCst), 1);
2717 }
2718
2719 #[test]
2720 fn telemetry_hooks_fire_on_regime_change() {
2721 use std::sync::Arc;
2722 use std::sync::atomic::{AtomicU32, Ordering};
2723
2724 let regime_changes = Arc::new(AtomicU32::new(0));
2725 let regime_changes_clone = regime_changes.clone();
2726
2727 let hooks = TelemetryHooks::new().on_regime_change(move |_from, _to| {
2728 regime_changes_clone.fetch_add(1, Ordering::SeqCst);
2729 });
2730
2731 let config = test_config();
2732 let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2733
2734 let base = Instant::now();
2735
2736 for i in 0..15 {
2738 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2739 }
2740
2741 assert!(regime_changes.load(Ordering::SeqCst) >= 1);
2743 }
2744
2745 #[test]
2746 fn regime_transition_count_tracks_changes() {
2747 let config = test_config();
2748 let mut c = ResizeCoalescer::new(config, (80, 24));
2749
2750 assert_eq!(c.regime_transition_count(), 0);
2751
2752 let base = Instant::now();
2753
2754 for i in 0..15 {
2756 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2757 }
2758
2759 assert!(c.regime_transition_count() >= 1);
2761 }
2762
2763 #[test]
2764 fn cycle_time_percentiles_calculated() {
2765 let mut config = test_config();
2766 config.enable_logging = true;
2767 let mut c = ResizeCoalescer::new(config, (80, 24));
2768
2769 assert!(c.cycle_time_percentiles().is_none());
2771
2772 let base = Instant::now();
2773
2774 for i in 0..5 {
2776 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 100));
2777 c.tick_at(base + Duration::from_millis(i as u64 * 100 + 50));
2778 }
2779
2780 let percentiles = c.cycle_time_percentiles();
2782 assert!(percentiles.is_some());
2783
2784 let p = percentiles.unwrap();
2785 assert!(p.count >= 1);
2786 assert!(p.mean_ms >= 0.0);
2787 assert!(p.p50_ms >= 0.0);
2788 assert!(p.p95_ms >= p.p50_ms);
2789 assert!(p.p99_ms >= p.p95_ms);
2790 }
2791
2792 #[test]
2793 fn cycle_time_percentiles_jsonl_format() {
2794 let percentiles = CycleTimePercentiles {
2795 p50_ms: 10.5,
2796 p95_ms: 25.3,
2797 p99_ms: 42.1,
2798 count: 100,
2799 mean_ms: 15.2,
2800 };
2801
2802 let jsonl = percentiles.to_jsonl();
2803 assert!(jsonl.contains("\"event\":\"cycle_time_percentiles\""));
2804 assert!(jsonl.contains("\"p50_ms\":10.500"));
2805 assert!(jsonl.contains("\"p95_ms\":25.300"));
2806 assert!(jsonl.contains("\"p99_ms\":42.100"));
2807 assert!(jsonl.contains("\"mean_ms\":15.200"));
2808 assert!(jsonl.contains("\"count\":100"));
2809 }
2810
2811 #[test]
2816 fn bocpd_disabled_by_default() {
2817 let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
2818 assert!(!c.bocpd_enabled());
2819 assert!(c.bocpd().is_none());
2820 assert!(c.bocpd_p_burst().is_none());
2821 }
2822
2823 #[test]
2824 fn bocpd_enabled_with_config() {
2825 let config = CoalescerConfig::default().with_bocpd();
2826 let c = ResizeCoalescer::new(config, (80, 24));
2827 assert!(c.bocpd_enabled());
2828 assert!(c.bocpd().is_some());
2829 }
2830
2831 #[test]
2832 fn bocpd_posterior_normalized() {
2833 let config = CoalescerConfig::default().with_bocpd();
2834 let mut c = ResizeCoalescer::new(config, (80, 24));
2835
2836 let base = Instant::now();
2837
2838 for i in 0..20 {
2840 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 50));
2841 }
2842
2843 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2845 assert!(
2846 (0.0..=1.0).contains(&p_burst),
2847 "P(burst) must be in [0,1], got {}",
2848 p_burst
2849 );
2850
2851 if let Some(bocpd) = c.bocpd() {
2853 let sum: f64 = bocpd.run_length_posterior().iter().sum();
2854 assert!(
2855 (sum - 1.0).abs() < 1e-9,
2856 "Posterior must sum to 1, got {}",
2857 sum
2858 );
2859 }
2860 }
2861
2862 #[test]
2863 fn bocpd_detects_burst_from_rapid_events() {
2864 use crate::bocpd::BocpdConfig;
2865
2866 let bocpd_config = BocpdConfig {
2868 mu_steady_ms: 200.0,
2869 mu_burst_ms: 20.0,
2870 burst_threshold: 0.6,
2871 steady_threshold: 0.4,
2872 ..BocpdConfig::default()
2873 };
2874
2875 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2876 let mut c = ResizeCoalescer::new(config, (80, 24));
2877
2878 let base = Instant::now();
2879
2880 for i in 0..30 {
2882 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2883 }
2884
2885 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2887 assert!(
2888 p_burst > 0.5,
2889 "Rapid events should yield high P(burst), got {}",
2890 p_burst
2891 );
2892 assert_eq!(
2893 c.regime(),
2894 Regime::Burst,
2895 "Regime should be Burst with rapid events"
2896 );
2897 }
2898
2899 #[test]
2900 fn bocpd_detects_steady_from_slow_events() {
2901 use crate::bocpd::BocpdConfig;
2902
2903 let bocpd_config = BocpdConfig {
2905 mu_steady_ms: 200.0,
2906 mu_burst_ms: 20.0,
2907 burst_threshold: 0.7,
2908 steady_threshold: 0.3,
2909 ..BocpdConfig::default()
2910 };
2911
2912 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2913 let mut c = ResizeCoalescer::new(config, (80, 24));
2914
2915 let base = Instant::now();
2916
2917 for i in 0..10 {
2919 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 300));
2920 }
2921
2922 let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2924 assert!(
2925 p_burst < 0.5,
2926 "Slow events should yield low P(burst), got {}",
2927 p_burst
2928 );
2929 assert_eq!(
2930 c.regime(),
2931 Regime::Steady,
2932 "Regime should be Steady with slow events"
2933 );
2934 }
2935
2936 #[test]
2937 fn bocpd_recommended_delay_varies_with_regime() {
2938 let config = CoalescerConfig::default().with_bocpd();
2939 let mut c = ResizeCoalescer::new(config, (80, 24));
2940
2941 let base = Instant::now();
2942
2943 c.handle_resize_at(85, 30, base);
2945 let delay_initial = c.bocpd_recommended_delay().expect("BOCPD enabled");
2946
2947 for i in 1..30 {
2949 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2950 }
2951 let delay_burst = c.bocpd_recommended_delay().expect("BOCPD enabled");
2952
2953 assert!(delay_initial > 0, "Initial delay should be positive");
2955 assert!(delay_burst > 0, "Burst delay should be positive");
2956 }
2957
2958 #[test]
2959 fn bocpd_update_is_deterministic() {
2960 let config = CoalescerConfig::default().with_bocpd();
2961
2962 let base = Instant::now();
2963
2964 let results: Vec<_> = (0..2)
2966 .map(|_| {
2967 let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2968 for i in 0..20 {
2969 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 25));
2970 }
2971 (c.regime(), c.bocpd_p_burst())
2972 })
2973 .collect();
2974
2975 assert_eq!(
2976 results[0], results[1],
2977 "BOCPD results must be deterministic"
2978 );
2979 }
2980
2981 #[test]
2982 fn bocpd_memory_bounded() {
2983 use crate::bocpd::BocpdConfig;
2984
2985 let bocpd_config = BocpdConfig {
2987 max_run_length: 50,
2988 ..BocpdConfig::default()
2989 };
2990
2991 let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2992 let mut c = ResizeCoalescer::new(config, (80, 24));
2993
2994 let base = Instant::now();
2995
2996 for i in 0u64..200 {
2998 c.handle_resize_at(
2999 80 + (i as u16 % 100),
3000 24 + (i as u16 % 50),
3001 base + Duration::from_millis(i * 20),
3002 );
3003 }
3004
3005 if let Some(bocpd) = c.bocpd() {
3007 let posterior_len = bocpd.run_length_posterior().len();
3008 assert!(
3009 posterior_len <= 51, "Posterior length should be bounded, got {}",
3011 posterior_len
3012 );
3013 }
3014 }
3015
3016 #[test]
3017 fn bocpd_stable_under_mixed_traffic() {
3018 let config = CoalescerConfig::default().with_bocpd();
3019 let mut c = ResizeCoalescer::new(config, (80, 24));
3020
3021 let base = Instant::now();
3022 let mut offset = 0u64;
3023
3024 for i in 0..5 {
3026 offset += 200;
3027 c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(offset));
3028 }
3029
3030 for i in 0..15 {
3032 offset += 15;
3033 c.handle_resize_at(90 + i, 30 + i, base + Duration::from_millis(offset));
3034 }
3035
3036 for i in 0..5 {
3038 offset += 250;
3039 c.handle_resize_at(100 + i, 40 + i, base + Duration::from_millis(offset));
3040 }
3041
3042 let p_burst = c.bocpd_p_burst().expect("BOCPD enabled");
3044 assert!(
3045 (0.0..=1.0).contains(&p_burst),
3046 "P(burst) must remain valid after mixed traffic"
3047 );
3048
3049 if let Some(bocpd) = c.bocpd() {
3050 let sum: f64 = bocpd.run_length_posterior().iter().sum();
3051 assert!((sum - 1.0).abs() < 1e-9, "Posterior must remain normalized");
3052 }
3053 }
3054}