Skip to main content

ftui_layout/
pane_monitors.rs

1//! Latency-envelope and assumption monitors for advanced pane strategies
2//! (`bd-1pvzq.2`).
3//!
4//! The checkpointed, persistent, and policy-selected pane execution strategies
5//! each rest on operating assumptions that a single scalar perf budget cannot
6//! protect:
7//!
8//! * **Checkpointed replay** assumes `replay()` walks at most one checkpoint
9//!   interval from the nearest checkpoint — if `replay_depth` blows past the
10//!   interval, undo/redo silently degrades to a long replay.
11//! * **Retention** assumes the bounded budget can hold the working set — if the
12//!   policy hits its floor it is keeping over-budget state (or dropping history
13//!   a user expects).
14//! * **The execution-policy selector** assumes hysteresis keeps the chosen
15//!   strategy stable — if it thrashes, every switch pays a substrate-rebuild
16//!   cost, and if it keeps falling back to the conservative path the advanced
17//!   strategies are not actually being used.
18//! * **Every strategy** has a per-op **latency envelope** that, once exceeded,
19//!   shows up to the user as a sluggish drag/undo.
20//!
21//! These monitors turn each assumption into an explicit, structured, and
22//! *operator-readable* verdict so a CI gate or a local triage session can say
23//! exactly which assumption failed, on which scenario, under which strategy —
24//! rather than leaving a regression as a mysterious slowdown.
25//!
26//! Everything here is pure and deterministic: the same telemetry always yields
27//! the same report, byte-for-byte, so the verdicts are safe to attach to CI
28//! artifacts (see `docs/perf/pane_assumption_monitors.md`).
29
30use 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/// Health of one monitored assumption.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PaneMonitorStatus {
41    /// The assumption holds with comfortable headroom.
42    Healthy,
43    /// The assumption still holds but headroom is thin — worth watching.
44    Degraded,
45    /// The assumption is violated — responsiveness or correctness of the
46    /// optimization is at risk.
47    Violated,
48}
49
50impl PaneMonitorStatus {
51    /// Whether this status is a hard violation (the CI fail condition).
52    #[must_use]
53    pub const fn is_violation(self) -> bool {
54        matches!(self, Self::Violated)
55    }
56
57    /// Whether this status is at least degraded (warn-or-worse).
58    #[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    /// The worse (higher-severity) of two statuses.
72    #[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/// Which operating assumption a verdict concerns.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum PaneAssumption {
94    /// Checkpointed replay walks at most one checkpoint interval.
95    ReplayDepthBound,
96    /// Retained history fits within the configured budget.
97    RetentionPressure,
98    /// The execution-policy selector does not thrash between strategies.
99    SelectorChurn,
100    /// The workload is not constantly forced onto the conservative path.
101    FallbackFrequency,
102    /// Per-operation latency stays within the strategy's envelope.
103    LatencyEnvelope,
104}
105
106impl PaneAssumption {
107    /// Stable identifier for logs and dashboards.
108    #[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/// Tunable thresholds. Defaults are chosen to flag real regressions (≈2×
121/// blowups, near-budget pressure, sustained thrash) without firing on the
122/// normal jitter of a healthy workload.
123#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
124pub struct PaneMonitorThresholds {
125    /// `replay_depth / interval` above this is degraded.
126    pub replay_depth_degraded_ratio: f64,
127    /// `replay_depth / interval` above this is a violation.
128    pub replay_depth_violated_ratio: f64,
129    /// Retained-bytes utilization (% of budget) above this is degraded.
130    pub retention_degraded_util_pct: f64,
131    /// Strategy-switch rate (% of transitions) above this is degraded.
132    pub selector_churn_degraded_pct: f64,
133    /// Strategy-switch rate above this is a violation.
134    pub selector_churn_violated_pct: f64,
135    /// Conservative-fallback rate (% of decisions) above this is degraded.
136    pub fallback_degraded_pct: f64,
137    /// Conservative-fallback rate above this is a violation.
138    pub fallback_violated_pct: f64,
139    /// `observed / envelope` above this is degraded.
140    pub latency_degraded_ratio: f64,
141    /// `observed / envelope` above this is a violation.
142    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/// One monitor verdict: machine-readable metrics plus an operator-readable
162/// explanation of what it means for responsiveness.
163#[derive(Debug, Clone, PartialEq, Serialize)]
164pub struct PaneMonitorVerdict {
165    /// The assumption being judged.
166    pub assumption: PaneAssumption,
167    /// The strategy the verdict applies to.
168    pub strategy: PaneMemoryStrategy,
169    /// Overall health.
170    pub status: PaneMonitorStatus,
171    /// Observed metric value (units depend on the assumption: a ratio, a
172    /// percentage, or nanoseconds).
173    pub observed: f64,
174    /// The value at which the verdict would become a violation (the budget).
175    pub budget: f64,
176    /// Remaining headroom as a percentage of the budget; negative once over.
177    pub headroom_pct: f64,
178    /// Plain-language explanation aimed at an operator, not just a developer.
179    pub explanation: String,
180}
181
182/// Aggregate report over one scenario.
183#[derive(Debug, Clone, PartialEq, Serialize)]
184pub struct PaneMonitorReport {
185    /// Scenario label (e.g. the profiling scenario name).
186    pub scenario: String,
187    /// Verdicts in evaluation order.
188    pub verdicts: Vec<PaneMonitorVerdict>,
189}
190
191impl PaneMonitorReport {
192    /// Start an empty report for `scenario`.
193    #[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    /// Append a verdict (builder style).
202    pub fn with(mut self, verdict: PaneMonitorVerdict) -> Self {
203        self.verdicts.push(verdict);
204        self
205    }
206
207    /// Append a verdict in place.
208    pub fn push(&mut self, verdict: PaneMonitorVerdict) {
209        self.verdicts.push(verdict);
210    }
211
212    /// The worst status across all verdicts (`Healthy` if empty).
213    #[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    /// Whether any assumption is violated — the CI fail condition.
221    #[must_use]
222    pub fn has_violations(&self) -> bool {
223        self.verdicts.iter().any(|v| v.status.is_violation())
224    }
225
226    /// Iterate the violated verdicts.
227    pub fn violations(&self) -> impl Iterator<Item = &PaneMonitorVerdict> {
228        self.verdicts.iter().filter(|v| v.status.is_violation())
229    }
230
231    /// Deterministic structured JSON log (one object). Infallible: falls back to
232    /// `"{}"` on the serialization error that cannot occur for these fields.
233    #[must_use]
234    pub fn to_json(&self) -> String {
235        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
236    }
237
238    /// Operator-facing multi-line summary: one line per verdict.
239    #[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
258// ===========================================================================
259// Individual monitors
260// ===========================================================================
261
262fn 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/// Monitor the checkpointed-replay depth assumption: `replay()` should walk at
281/// most one checkpoint interval from the nearest checkpoint.
282#[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/// Monitor retention pressure from a bounded-retention decision.
325#[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    // Utilization as a percentage of budget (0 budget = unbounded = no pressure).
333    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/// Monitor selector churn: how often the policy switches strategy across a
390/// decision sequence (e.g. successive `reselect` calls over a session).
391#[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/// Monitor how often the policy is forced onto the conservative path. A
440/// workload that constantly falls back is not benefiting from the advanced
441/// strategies.
442#[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/// Monitor a strategy's per-operation latency against its envelope (both in
492/// nanoseconds/op).
493#[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        // Operator summary names each assumption.
790        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}