1use serde::Serialize;
31
32use crate::pane::PaneInteractionTimelineReplayDiagnostics;
33use crate::pane_execution::{PaneExecutionDecision, PaneStrategyReason};
34use crate::pane_memory::PaneMemoryStrategy;
35use crate::pane_retention::{PaneRetentionDecision, PaneRetentionOutcome};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PaneMonitorStatus {
41 Healthy,
43 Degraded,
45 Violated,
48}
49
50impl PaneMonitorStatus {
51 #[must_use]
53 pub const fn is_violation(self) -> bool {
54 matches!(self, Self::Violated)
55 }
56
57 #[must_use]
59 pub const fn is_degraded_or_worse(self) -> bool {
60 matches!(self, Self::Degraded | Self::Violated)
61 }
62
63 const fn rank(self) -> u8 {
64 match self {
65 Self::Healthy => 0,
66 Self::Degraded => 1,
67 Self::Violated => 2,
68 }
69 }
70
71 #[must_use]
73 pub fn worst(self, other: Self) -> Self {
74 if other.rank() > self.rank() {
75 other
76 } else {
77 self
78 }
79 }
80
81 fn label(self) -> &'static str {
82 match self {
83 Self::Healthy => "healthy",
84 Self::Degraded => "degraded",
85 Self::Violated => "violated",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum PaneAssumption {
94 ReplayDepthBound,
96 RetentionPressure,
98 SelectorChurn,
100 FallbackFrequency,
102 LatencyEnvelope,
104}
105
106impl PaneAssumption {
107 #[must_use]
109 pub const fn as_str(self) -> &'static str {
110 match self {
111 Self::ReplayDepthBound => "replay_depth_bound",
112 Self::RetentionPressure => "retention_pressure",
113 Self::SelectorChurn => "selector_churn",
114 Self::FallbackFrequency => "fallback_frequency",
115 Self::LatencyEnvelope => "latency_envelope",
116 }
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
124pub struct PaneMonitorThresholds {
125 pub replay_depth_degraded_ratio: f64,
127 pub replay_depth_violated_ratio: f64,
129 pub retention_degraded_util_pct: f64,
131 pub selector_churn_degraded_pct: f64,
133 pub selector_churn_violated_pct: f64,
135 pub fallback_degraded_pct: f64,
137 pub fallback_violated_pct: f64,
139 pub latency_degraded_ratio: f64,
141 pub latency_violated_ratio: f64,
143}
144
145impl Default for PaneMonitorThresholds {
146 fn default() -> Self {
147 Self {
148 replay_depth_degraded_ratio: 1.0,
149 replay_depth_violated_ratio: 2.0,
150 retention_degraded_util_pct: 90.0,
151 selector_churn_degraded_pct: 25.0,
152 selector_churn_violated_pct: 50.0,
153 fallback_degraded_pct: 50.0,
154 fallback_violated_pct: 80.0,
155 latency_degraded_ratio: 0.8,
156 latency_violated_ratio: 1.0,
157 }
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize)]
164pub struct PaneMonitorVerdict {
165 pub assumption: PaneAssumption,
167 pub strategy: PaneMemoryStrategy,
169 pub status: PaneMonitorStatus,
171 pub observed: f64,
174 pub budget: f64,
176 pub headroom_pct: f64,
178 pub explanation: String,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize)]
184pub struct PaneMonitorReport {
185 pub scenario: String,
187 pub verdicts: Vec<PaneMonitorVerdict>,
189}
190
191impl PaneMonitorReport {
192 #[must_use]
194 pub fn new(scenario: impl Into<String>) -> Self {
195 Self {
196 scenario: scenario.into(),
197 verdicts: Vec::new(),
198 }
199 }
200
201 pub fn with(mut self, verdict: PaneMonitorVerdict) -> Self {
203 self.verdicts.push(verdict);
204 self
205 }
206
207 pub fn push(&mut self, verdict: PaneMonitorVerdict) {
209 self.verdicts.push(verdict);
210 }
211
212 #[must_use]
214 pub fn worst_status(&self) -> PaneMonitorStatus {
215 self.verdicts
216 .iter()
217 .fold(PaneMonitorStatus::Healthy, |acc, v| acc.worst(v.status))
218 }
219
220 #[must_use]
222 pub fn has_violations(&self) -> bool {
223 self.verdicts.iter().any(|v| v.status.is_violation())
224 }
225
226 pub fn violations(&self) -> impl Iterator<Item = &PaneMonitorVerdict> {
228 self.verdicts.iter().filter(|v| v.status.is_violation())
229 }
230
231 #[must_use]
234 pub fn to_json(&self) -> String {
235 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
236 }
237
238 #[must_use]
240 pub fn summary_log(&self) -> String {
241 let mut out = format!(
242 "pane monitors [{}]: {}\n",
243 self.scenario,
244 self.worst_status().label()
245 );
246 for v in &self.verdicts {
247 out.push_str(&format!(
248 " [{}] {}: {}\n",
249 v.status.label(),
250 v.assumption.as_str(),
251 v.explanation
252 ));
253 }
254 out
255 }
256}
257
258fn classify_ratio(ratio: f64, degraded_at: f64, violated_at: f64) -> PaneMonitorStatus {
263 if ratio > violated_at {
264 PaneMonitorStatus::Violated
265 } else if ratio > degraded_at {
266 PaneMonitorStatus::Degraded
267 } else {
268 PaneMonitorStatus::Healthy
269 }
270}
271
272fn headroom_pct(observed: f64, budget: f64) -> f64 {
273 if budget <= 0.0 {
274 100.0
275 } else {
276 (budget - observed) / budget * 100.0
277 }
278}
279
280#[must_use]
283pub fn monitor_replay_depth(
284 diag: &PaneInteractionTimelineReplayDiagnostics,
285 thresholds: &PaneMonitorThresholds,
286) -> PaneMonitorVerdict {
287 let interval = diag.checkpoint_interval.max(1) as f64;
288 let depth = diag.replay_depth as f64;
289 let ratio = depth / interval;
290 let status = classify_ratio(
291 ratio,
292 thresholds.replay_depth_degraded_ratio,
293 thresholds.replay_depth_violated_ratio,
294 );
295 let explanation = match status {
296 PaneMonitorStatus::Healthy => format!(
297 "Checkpointed replay walks {} step(s) from the nearest checkpoint, within the \
298{}-step interval — undo/redo stays snappy.",
299 diag.replay_depth, diag.checkpoint_interval
300 ),
301 PaneMonitorStatus::Degraded => format!(
302 "Checkpointed replay walks {} step(s), {:.1}x the {}-step checkpoint interval — \
303undo/redo is starting to lag; checkpoint spacing may be too coarse.",
304 diag.replay_depth, ratio, diag.checkpoint_interval
305 ),
306 PaneMonitorStatus::Violated => format!(
307 "Checkpointed replay walks {} step(s), {:.1}x the {}-step checkpoint interval \
308(checkpoint_hit={}) — undo/redo will feel sluggish; the checkpoint-spacing assumption is \
309violated.",
310 diag.replay_depth, ratio, diag.checkpoint_interval, diag.checkpoint_hit
311 ),
312 };
313 PaneMonitorVerdict {
314 assumption: PaneAssumption::ReplayDepthBound,
315 strategy: PaneMemoryStrategy::Checkpointed,
316 status,
317 observed: ratio,
318 budget: thresholds.replay_depth_violated_ratio,
319 headroom_pct: headroom_pct(ratio, thresholds.replay_depth_violated_ratio),
320 explanation,
321 }
322}
323
324#[must_use]
326pub fn monitor_retention_pressure(
327 decision: &PaneRetentionDecision,
328 thresholds: &PaneMonitorThresholds,
329) -> PaneMonitorVerdict {
330 let budget_bytes = decision.budget.max_retained_bytes;
331 let after = decision.bytes_after as f64;
332 let util_pct = if budget_bytes == 0 {
334 0.0
335 } else {
336 after / budget_bytes as f64 * 100.0
337 };
338
339 let status = match decision.outcome {
340 PaneRetentionOutcome::FloorReached => PaneMonitorStatus::Violated,
341 PaneRetentionOutcome::ConservativeHold => PaneMonitorStatus::Degraded,
342 PaneRetentionOutcome::WithinBudget | PaneRetentionOutcome::PrunedToFit => {
343 if budget_bytes != 0 && util_pct > thresholds.retention_degraded_util_pct {
344 PaneMonitorStatus::Degraded
345 } else {
346 PaneMonitorStatus::Healthy
347 }
348 }
349 };
350
351 let explanation = match decision.outcome {
352 PaneRetentionOutcome::FloorReached => format!(
353 "Retention hit its floor: {} byte(s) of state cannot fit the {}-byte budget even \
354after pruning to the minimum — history a user expects may be dropped, or the budget is too \
355small for this workspace.",
356 decision.bytes_after, budget_bytes
357 ),
358 PaneRetentionOutcome::ConservativeHold => format!(
359 "Retention is holding {} byte(s) over its {}-byte budget in conservative-debug mode \
360— memory pressure is unbounded; this should not run in production.",
361 decision.bytes_after, budget_bytes
362 ),
363 PaneRetentionOutcome::PrunedToFit => format!(
364 "Retention pruned {} unit(s) to fit the {}-byte budget ({:.0}% utilized) — undo \
365history beyond the kept window is gone.",
366 decision.units_pruned, budget_bytes, util_pct
367 ),
368 PaneRetentionOutcome::WithinBudget if budget_bytes == 0 => format!(
369 "Retention is unbounded ({} byte(s) retained) — full undo history kept.",
370 decision.bytes_after
371 ),
372 PaneRetentionOutcome::WithinBudget => format!(
373 "Retention is within budget ({:.0}% of {} bytes used) — full undo history retained.",
374 util_pct, budget_bytes
375 ),
376 };
377
378 PaneMonitorVerdict {
379 assumption: PaneAssumption::RetentionPressure,
380 strategy: decision.strategy,
381 status,
382 observed: util_pct,
383 budget: 100.0,
384 headroom_pct: (100.0 - util_pct).max(-100.0),
385 explanation,
386 }
387}
388
389#[must_use]
392pub fn monitor_selector_churn(
393 decisions: &[PaneExecutionDecision],
394 thresholds: &PaneMonitorThresholds,
395) -> PaneMonitorVerdict {
396 let transitions = decisions.len().saturating_sub(1);
397 let switches = decisions
398 .windows(2)
399 .filter(|w| w[0].strategy != w[1].strategy)
400 .count();
401 let churn_pct = if transitions == 0 {
402 0.0
403 } else {
404 switches as f64 / transitions as f64 * 100.0
405 };
406 let status = classify_ratio(
407 churn_pct,
408 thresholds.selector_churn_degraded_pct,
409 thresholds.selector_churn_violated_pct,
410 );
411 let strategy = decisions
412 .last()
413 .map_or(PaneMemoryStrategy::Checkpointed, |d| d.strategy);
414 let explanation = match status {
415 PaneMonitorStatus::Healthy => format!(
416 "Selector switched strategy {switches} time(s) across {transitions} transition(s) \
417({churn_pct:.0}%) — hysteresis is keeping the strategy stable."
418 ),
419 PaneMonitorStatus::Degraded => format!(
420 "Selector switched strategy {switches}/{transitions} times ({churn_pct:.0}%) — \
421hysteresis is fraying; each switch pays a substrate-rebuild cost the user may feel."
422 ),
423 PaneMonitorStatus::Violated => format!(
424 "Selector thrashed {switches}/{transitions} times ({churn_pct:.0}%) between \
425strategies — the hysteresis assumption is violated and rebuild cost is dominating."
426 ),
427 };
428 PaneMonitorVerdict {
429 assumption: PaneAssumption::SelectorChurn,
430 strategy,
431 status,
432 observed: churn_pct,
433 budget: thresholds.selector_churn_violated_pct,
434 headroom_pct: headroom_pct(churn_pct, thresholds.selector_churn_violated_pct),
435 explanation,
436 }
437}
438
439#[must_use]
443pub fn monitor_fallback_frequency(
444 decisions: &[PaneExecutionDecision],
445 thresholds: &PaneMonitorThresholds,
446) -> PaneMonitorVerdict {
447 let total = decisions.len();
448 let fallbacks = decisions
449 .iter()
450 .filter(|d| matches!(d.reason, PaneStrategyReason::ConservativeFallback))
451 .count();
452 let fallback_pct = if total == 0 {
453 0.0
454 } else {
455 fallbacks as f64 / total as f64 * 100.0
456 };
457 let status = classify_ratio(
458 fallback_pct,
459 thresholds.fallback_degraded_pct,
460 thresholds.fallback_violated_pct,
461 );
462 let strategy = decisions
463 .last()
464 .map_or(PaneMemoryStrategy::Checkpointed, |d| d.strategy);
465 let explanation = match status {
466 PaneMonitorStatus::Healthy => format!(
467 "Conservative fallback fired {fallbacks}/{total} decision(s) ({fallback_pct:.0}%) — \
468the advanced strategies are being used as intended."
469 ),
470 PaneMonitorStatus::Degraded => format!(
471 "Conservative fallback fired {fallbacks}/{total} decision(s) ({fallback_pct:.0}%) — \
472the workload is often forced onto the safe path; the optimization is only partly active."
473 ),
474 PaneMonitorStatus::Violated => format!(
475 "Conservative fallback fired {fallbacks}/{total} decision(s) ({fallback_pct:.0}%) — \
476the workload is almost always on the safe path, so the advanced strategies provide little \
477benefit here."
478 ),
479 };
480 PaneMonitorVerdict {
481 assumption: PaneAssumption::FallbackFrequency,
482 strategy,
483 status,
484 observed: fallback_pct,
485 budget: thresholds.fallback_violated_pct,
486 headroom_pct: headroom_pct(fallback_pct, thresholds.fallback_violated_pct),
487 explanation,
488 }
489}
490
491#[must_use]
494pub fn monitor_latency_envelope(
495 strategy: PaneMemoryStrategy,
496 observed_ns_per_op: f64,
497 envelope_ns_per_op: f64,
498 thresholds: &PaneMonitorThresholds,
499) -> PaneMonitorVerdict {
500 let envelope = envelope_ns_per_op.max(1.0);
501 let ratio = observed_ns_per_op / envelope;
502 let status = classify_ratio(
503 ratio,
504 thresholds.latency_degraded_ratio,
505 thresholds.latency_violated_ratio,
506 );
507 let explanation = match status {
508 PaneMonitorStatus::Healthy => format!(
509 "Observed {observed_ns_per_op:.0} ns/op is within the {envelope_ns_per_op:.0} ns/op \
510envelope ({ratio:.2}x) — interactions stay responsive."
511 ),
512 PaneMonitorStatus::Degraded => format!(
513 "Observed {observed_ns_per_op:.0} ns/op is {ratio:.2}x the {envelope_ns_per_op:.0} \
514ns/op envelope — approaching the latency budget; responsiveness margin is thin."
515 ),
516 PaneMonitorStatus::Violated => format!(
517 "Observed {observed_ns_per_op:.0} ns/op exceeds the {envelope_ns_per_op:.0} ns/op \
518envelope ({ratio:.2}x) — interactions will feel slow; the latency budget is blown."
519 ),
520 };
521 PaneMonitorVerdict {
522 assumption: PaneAssumption::LatencyEnvelope,
523 strategy,
524 status,
525 observed: observed_ns_per_op,
526 budget: envelope_ns_per_op,
527 headroom_pct: headroom_pct(observed_ns_per_op, envelope_ns_per_op),
528 explanation,
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::pane_retention::{PaneRetentionBudget, PaneRetentionPolicy};
536
537 fn replay_diag(
538 checkpoint_interval: usize,
539 replay_depth: usize,
540 checkpoint_hit: bool,
541 ) -> PaneInteractionTimelineReplayDiagnostics {
542 PaneInteractionTimelineReplayDiagnostics {
543 entry_count: 64,
544 cursor: 64,
545 checkpoint_count: 4,
546 checkpoint_interval,
547 checkpoint_hit,
548 replay_start_idx: 64usize.saturating_sub(replay_depth),
549 replay_depth,
550 }
551 }
552
553 fn retention_decision(
554 outcome: PaneRetentionOutcome,
555 budget_bytes: usize,
556 bytes_after: usize,
557 units_pruned: usize,
558 ) -> PaneRetentionDecision {
559 PaneRetentionDecision {
560 strategy: PaneMemoryStrategy::Persistent,
561 budget: PaneRetentionBudget {
562 max_retained_bytes: budget_bytes,
563 max_retained_units: 0,
564 },
565 conservative_debug: false,
566 units_before: 16,
567 units_after: 16usize.saturating_sub(units_pruned),
568 units_pruned,
569 bytes_before: bytes_after + units_pruned * 1024,
570 bytes_after,
571 current_state_hash: 0xABCD,
572 outcome,
573 log: String::new(),
574 }
575 }
576
577 fn decision(strategy: PaneMemoryStrategy, reason: PaneStrategyReason) -> PaneExecutionDecision {
578 use crate::pane_execution::PaneWorkloadProfile;
579 PaneExecutionDecision {
580 strategy,
581 reason,
582 forced: matches!(reason, PaneStrategyReason::ForcedOverride),
583 profile: PaneWorkloadProfile::observe(&[], 0, false),
584 retention: PaneRetentionPolicy::unbounded(),
585 log: String::new(),
586 }
587 }
588
589 #[test]
590 fn replay_depth_within_interval_is_healthy() {
591 let v = monitor_replay_depth(&replay_diag(16, 8, true), &PaneMonitorThresholds::default());
592 assert_eq!(v.status, PaneMonitorStatus::Healthy);
593 assert_eq!(v.assumption, PaneAssumption::ReplayDepthBound);
594 assert!(v.headroom_pct > 0.0);
595 }
596
597 #[test]
598 fn replay_depth_past_interval_degrades_then_violates() {
599 let t = PaneMonitorThresholds::default();
600 let degraded = monitor_replay_depth(&replay_diag(16, 20, true), &t);
601 assert_eq!(degraded.status, PaneMonitorStatus::Degraded);
602 let violated = monitor_replay_depth(&replay_diag(16, 40, false), &t);
603 assert_eq!(violated.status, PaneMonitorStatus::Violated);
604 assert!(violated.explanation.contains("violated"));
605 }
606
607 #[test]
608 fn retention_outcomes_map_to_statuses() {
609 let t = PaneMonitorThresholds::default();
610 assert_eq!(
611 monitor_retention_pressure(
612 &retention_decision(PaneRetentionOutcome::WithinBudget, 8192, 2048, 0),
613 &t
614 )
615 .status,
616 PaneMonitorStatus::Healthy
617 );
618 assert_eq!(
619 monitor_retention_pressure(
620 &retention_decision(PaneRetentionOutcome::PrunedToFit, 8192, 8000, 4),
621 &t
622 )
623 .status,
624 PaneMonitorStatus::Degraded
625 );
626 assert_eq!(
627 monitor_retention_pressure(
628 &retention_decision(PaneRetentionOutcome::ConservativeHold, 4096, 9000, 0),
629 &t
630 )
631 .status,
632 PaneMonitorStatus::Degraded
633 );
634 assert_eq!(
635 monitor_retention_pressure(
636 &retention_decision(PaneRetentionOutcome::FloorReached, 1024, 4096, 12),
637 &t
638 )
639 .status,
640 PaneMonitorStatus::Violated
641 );
642 }
643
644 #[test]
645 fn unbounded_retention_has_no_pressure() {
646 let v = monitor_retention_pressure(
647 &retention_decision(PaneRetentionOutcome::WithinBudget, 0, 1_000_000, 0),
648 &PaneMonitorThresholds::default(),
649 );
650 assert_eq!(v.status, PaneMonitorStatus::Healthy);
651 }
652
653 #[test]
654 fn selector_churn_detects_thrash() {
655 let t = PaneMonitorThresholds::default();
656 let stable = vec![
657 decision(
658 PaneMemoryStrategy::Checkpointed,
659 PaneStrategyReason::GeneralDefault,
660 ),
661 decision(
662 PaneMemoryStrategy::Checkpointed,
663 PaneStrategyReason::HysteresisHold,
664 ),
665 decision(
666 PaneMemoryStrategy::Checkpointed,
667 PaneStrategyReason::GeneralDefault,
668 ),
669 ];
670 assert_eq!(
671 monitor_selector_churn(&stable, &t).status,
672 PaneMonitorStatus::Healthy
673 );
674
675 let thrash = vec![
676 decision(
677 PaneMemoryStrategy::Checkpointed,
678 PaneStrategyReason::GeneralDefault,
679 ),
680 decision(
681 PaneMemoryStrategy::Persistent,
682 PaneStrategyReason::ResizeDominatedBurst,
683 ),
684 decision(
685 PaneMemoryStrategy::Checkpointed,
686 PaneStrategyReason::GeneralDefault,
687 ),
688 decision(
689 PaneMemoryStrategy::Persistent,
690 PaneStrategyReason::ResizeDominatedBurst,
691 ),
692 ];
693 assert_eq!(
694 monitor_selector_churn(&thrash, &t).status,
695 PaneMonitorStatus::Violated
696 );
697 }
698
699 #[test]
700 fn fallback_frequency_flags_constant_conservative() {
701 let t = PaneMonitorThresholds::default();
702 let healthy = vec![
703 decision(
704 PaneMemoryStrategy::Persistent,
705 PaneStrategyReason::ResizeDominatedBurst,
706 ),
707 decision(
708 PaneMemoryStrategy::Persistent,
709 PaneStrategyReason::ResizeDominatedBurst,
710 ),
711 ];
712 assert_eq!(
713 monitor_fallback_frequency(&healthy, &t).status,
714 PaneMonitorStatus::Healthy
715 );
716
717 let always_safe = vec![
718 decision(
719 PaneMemoryStrategy::Checkpointed,
720 PaneStrategyReason::ConservativeFallback,
721 ),
722 decision(
723 PaneMemoryStrategy::Checkpointed,
724 PaneStrategyReason::ConservativeFallback,
725 ),
726 decision(
727 PaneMemoryStrategy::Checkpointed,
728 PaneStrategyReason::ConservativeFallback,
729 ),
730 ];
731 assert_eq!(
732 monitor_fallback_frequency(&always_safe, &t).status,
733 PaneMonitorStatus::Violated
734 );
735 }
736
737 #[test]
738 fn latency_envelope_classifies_relative_to_budget() {
739 let t = PaneMonitorThresholds::default();
740 assert_eq!(
741 monitor_latency_envelope(PaneMemoryStrategy::Baseline, 400.0, 1000.0, &t).status,
742 PaneMonitorStatus::Healthy
743 );
744 assert_eq!(
745 monitor_latency_envelope(PaneMemoryStrategy::Baseline, 900.0, 1000.0, &t).status,
746 PaneMonitorStatus::Degraded
747 );
748 assert_eq!(
749 monitor_latency_envelope(PaneMemoryStrategy::Baseline, 1500.0, 1000.0, &t).status,
750 PaneMonitorStatus::Violated
751 );
752 }
753
754 #[test]
755 fn report_aggregates_worst_status_and_violations() {
756 let t = PaneMonitorThresholds::default();
757 let report = PaneMonitorReport::new("scenario-x")
758 .with(monitor_replay_depth(&replay_diag(16, 8, true), &t))
759 .with(monitor_latency_envelope(
760 PaneMemoryStrategy::Baseline,
761 1500.0,
762 1000.0,
763 &t,
764 ));
765 assert_eq!(report.worst_status(), PaneMonitorStatus::Violated);
766 assert!(report.has_violations());
767 assert_eq!(report.violations().count(), 1);
768 }
769
770 #[test]
771 fn report_is_deterministic_and_serializable() {
772 let t = PaneMonitorThresholds::default();
773 let build = || {
774 PaneMonitorReport::new("scenario-y")
775 .with(monitor_replay_depth(&replay_diag(16, 20, true), &t))
776 .with(monitor_retention_pressure(
777 &retention_decision(PaneRetentionOutcome::PrunedToFit, 8192, 8000, 4),
778 &t,
779 ))
780 };
781 let a = build().to_json();
782 let b = build().to_json();
783 assert_eq!(
784 a, b,
785 "monitor report must be byte-identical for identical inputs"
786 );
787 assert!(a.contains("\"assumption\":\"replay_depth_bound\""));
788 assert!(a.contains("\"status\":\"degraded\""));
789 let summary = build().summary_log();
791 assert!(summary.contains("replay_depth_bound"));
792 assert!(summary.contains("retention_pressure"));
793 }
794
795 #[test]
796 fn status_worst_combinator_is_monotone() {
797 use PaneMonitorStatus::{Degraded, Healthy, Violated};
798 assert_eq!(Healthy.worst(Degraded), Degraded);
799 assert_eq!(Degraded.worst(Healthy), Degraded);
800 assert_eq!(Degraded.worst(Violated), Violated);
801 assert_eq!(Violated.worst(Healthy), Violated);
802 assert!(Violated.is_violation());
803 assert!(!Degraded.is_violation());
804 assert!(Degraded.is_degraded_or_worse());
805 }
806}