Skip to main content

ftui_runtime/
diff_evidence.rs

1#![forbid(unsafe_code)]
2
3//! Diff strategy evidence ledger (bd-3jlw5.3).
4//!
5//! Records Bayesian diff strategy decisions in a fixed-capacity ring buffer
6//! for zero per-frame allocation on the hot path. Supports JSONL export
7//! via the [`EvidenceSink`] infrastructure.
8//!
9//! # Usage
10//!
11//! ```rust,ignore
12//! use ftui_runtime::diff_evidence::{DiffEvidenceLedger, DiffStrategyRecord, DiffRegime};
13//!
14//! let mut ledger = DiffEvidenceLedger::new(1000);
15//! ledger.record(DiffStrategyRecord {
16//!     frame_id: 42,
17//!     regime: DiffRegime::StableFrame,
18//!     // ...
19//! });
20//! assert_eq!(ledger.len(), 1);
21//! ```
22
23use std::fmt::Write as _;
24
25use ftui_render::diff_strategy::{DiffStrategy, StrategyEvidence};
26
27/// Regime classification for diff strategy decisions.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum DiffRegime {
30    /// Low change rate, stable content.
31    StableFrame,
32    /// Bursty changes (user typing, scrolling).
33    BurstyChange,
34    /// Terminal resize in progress.
35    ResizeRegime,
36    /// Terminal is degraded (slow output, high latency).
37    DegradedTerminal,
38}
39
40impl DiffRegime {
41    /// Regime name as a static string for JSONL output.
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::StableFrame => "stable_frame",
45            Self::BurstyChange => "bursty_change",
46            Self::ResizeRegime => "resize",
47            Self::DegradedTerminal => "degraded",
48        }
49    }
50}
51
52/// An observation that contributed to a diff strategy decision.
53#[derive(Debug, Clone)]
54pub struct Observation {
55    /// Metric name (e.g., "change_fraction", "frame_time_us").
56    pub metric_name: String,
57    /// Observed value.
58    pub value: f64,
59    /// How much this observation shifted the posterior (log-odds contribution).
60    pub prior_contribution: f64,
61}
62
63impl Observation {
64    /// Create a new observation.
65    pub fn new(metric_name: impl Into<String>, value: f64, prior_contribution: f64) -> Self {
66        Self {
67            metric_name: metric_name.into(),
68            value,
69            prior_contribution,
70        }
71    }
72}
73
74/// A complete record of a diff strategy decision.
75#[derive(Debug, Clone)]
76pub struct DiffStrategyRecord {
77    /// Frame number this decision was made for.
78    pub frame_id: u64,
79    /// Regime classification.
80    pub regime: DiffRegime,
81    /// Posterior probability per candidate strategy.
82    pub posterior: Vec<(DiffStrategy, f64)>,
83    /// Strategy chosen.
84    pub chosen_strategy: DiffStrategy,
85    /// Confidence (max posterior probability).
86    pub confidence: f64,
87    /// Full strategy evidence from the selector.
88    pub evidence: StrategyEvidence,
89    /// Whether a fallback was triggered.
90    pub fallback_triggered: bool,
91    /// Observations that fed into this decision.
92    pub observations: Vec<Observation>,
93}
94
95impl DiffStrategyRecord {
96    /// Format as a JSONL line (no trailing newline).
97    pub fn to_jsonl(&self) -> String {
98        let mut out = String::with_capacity(512);
99        out.push_str("{\"type\":\"diff_decision\"");
100        let _ = write!(out, ",\"frame\":{}", self.frame_id);
101        let _ = write!(out, ",\"regime\":\"{}\"", self.regime.as_str());
102        let _ = write!(out, ",\"strategy\":\"{:?}\"", self.chosen_strategy);
103        let _ = write!(out, ",\"confidence\":{:.6}", self.confidence);
104        let _ = write!(out, ",\"fallback\":{}", self.fallback_triggered);
105        let _ = write!(
106            out,
107            ",\"posterior_mean\":{:.6},\"posterior_var\":{:.6}",
108            self.evidence.posterior_mean, self.evidence.posterior_variance
109        );
110        let _ = write!(
111            out,
112            ",\"cost_full\":{:.4},\"cost_dirty\":{:.4},\"cost_redraw\":{:.4}",
113            self.evidence.cost_full, self.evidence.cost_dirty, self.evidence.cost_redraw
114        );
115        let _ = write!(
116            out,
117            ",\"alpha\":{:.4},\"beta\":{:.4}",
118            self.evidence.alpha, self.evidence.beta
119        );
120
121        // Observations
122        out.push_str(",\"obs\":[");
123        for (i, obs) in self.observations.iter().enumerate() {
124            if i > 0 {
125                out.push(',');
126            }
127            let _ = write!(
128                out,
129                "{{\"m\":\"{}\",\"v\":{:.6},\"c\":{:.6}}}",
130                obs.metric_name.replace('"', "\\\""),
131                obs.value,
132                obs.prior_contribution
133            );
134        }
135        out.push_str("]}");
136        out
137    }
138}
139
140/// A regime transition event.
141#[derive(Debug, Clone)]
142pub struct RegimeTransition {
143    /// Frame where the transition occurred.
144    pub frame_id: u64,
145    /// Previous regime.
146    pub from_regime: DiffRegime,
147    /// New regime.
148    pub to_regime: DiffRegime,
149    /// Human-readable trigger explanation.
150    pub trigger: String,
151    /// Confidence at the point of transition.
152    pub confidence: f64,
153}
154
155impl RegimeTransition {
156    /// Format as a JSONL line (no trailing newline).
157    pub fn to_jsonl(&self) -> String {
158        format!(
159            "{{\"type\":\"regime_transition\",\"frame\":{},\"from\":\"{}\",\"to\":\"{}\",\"trigger\":\"{}\",\"confidence\":{:.6}}}",
160            self.frame_id,
161            self.from_regime.as_str(),
162            self.to_regime.as_str(),
163            self.trigger.replace('"', "\\\""),
164            self.confidence,
165        )
166    }
167}
168
169/// Fixed-capacity ring buffer for diff strategy decisions.
170///
171/// Pre-allocates all storage up front so that `record()` never allocates
172/// on the hot path (the record itself is moved in, not cloned).
173pub struct DiffEvidenceLedger {
174    decisions: Vec<Option<DiffStrategyRecord>>,
175    transitions: Vec<Option<RegimeTransition>>,
176    decision_head: usize,
177    transition_head: usize,
178    decision_count: usize,
179    transition_count: usize,
180    decision_capacity: usize,
181    transition_capacity: usize,
182    current_regime: DiffRegime,
183}
184
185impl std::fmt::Debug for DiffEvidenceLedger {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("DiffEvidenceLedger")
188            .field("decisions", &self.decision_count)
189            .field("transitions", &self.transition_count)
190            .field("capacity", &self.decision_capacity)
191            .field("regime", &self.current_regime)
192            .finish()
193    }
194}
195
196impl DiffEvidenceLedger {
197    /// Create a new ledger with the given capacity for decisions.
198    ///
199    /// Transition capacity is set to 1/10 of decision capacity (regime
200    /// transitions are much rarer than per-frame decisions).
201    pub fn new(decision_capacity: usize) -> Self {
202        let decision_capacity = decision_capacity.max(1);
203        let transition_capacity = (decision_capacity / 10).max(16);
204        Self {
205            decisions: (0..decision_capacity).map(|_| None).collect(),
206            transitions: (0..transition_capacity).map(|_| None).collect(),
207            decision_head: 0,
208            transition_head: 0,
209            decision_count: 0,
210            transition_count: 0,
211            decision_capacity,
212            transition_capacity,
213            current_regime: DiffRegime::StableFrame,
214        }
215    }
216
217    /// Record a diff strategy decision. Overwrites oldest when full.
218    pub fn record(&mut self, record: DiffStrategyRecord) {
219        // Detect regime transition.
220        if record.regime != self.current_regime {
221            let transition = RegimeTransition {
222                frame_id: record.frame_id,
223                from_regime: self.current_regime,
224                to_regime: record.regime,
225                trigger: format!(
226                    "confidence={:.3} strategy={:?}",
227                    record.confidence, record.chosen_strategy
228                ),
229                confidence: record.confidence,
230            };
231            self.record_transition(transition);
232            self.current_regime = record.regime;
233        }
234
235        self.decisions[self.decision_head] = Some(record);
236        self.decision_head = (self.decision_head + 1) % self.decision_capacity;
237        if self.decision_count < self.decision_capacity {
238            self.decision_count += 1;
239        }
240    }
241
242    /// Record a regime transition explicitly.
243    pub fn record_transition(&mut self, transition: RegimeTransition) {
244        self.transitions[self.transition_head] = Some(transition);
245        self.transition_head = (self.transition_head + 1) % self.transition_capacity;
246        if self.transition_count < self.transition_capacity {
247            self.transition_count += 1;
248        }
249    }
250
251    /// Number of decisions stored.
252    pub fn len(&self) -> usize {
253        self.decision_count
254    }
255
256    /// Whether the ledger is empty.
257    pub fn is_empty(&self) -> bool {
258        self.decision_count == 0
259    }
260
261    /// Number of regime transitions stored.
262    pub fn transition_count(&self) -> usize {
263        self.transition_count
264    }
265
266    /// Current regime.
267    pub fn current_regime(&self) -> DiffRegime {
268        self.current_regime
269    }
270
271    /// Iterate over stored decisions in insertion order (oldest first).
272    pub fn decisions(&self) -> impl Iterator<Item = &DiffStrategyRecord> {
273        let cap = self.decision_capacity;
274        let count = self.decision_count;
275        let head = self.decision_head;
276        let start = if count < cap { 0 } else { head };
277
278        (0..count).filter_map(move |i| {
279            let idx = (start + i) % cap;
280            self.decisions[idx].as_ref()
281        })
282    }
283
284    /// Iterate over stored transitions in insertion order (oldest first).
285    pub fn transitions(&self) -> impl Iterator<Item = &RegimeTransition> {
286        let cap = self.transition_capacity;
287        let count = self.transition_count;
288        let head = self.transition_head;
289        let start = if count < cap { 0 } else { head };
290
291        (0..count).filter_map(move |i| {
292            let idx = (start + i) % cap;
293            self.transitions[idx].as_ref()
294        })
295    }
296
297    /// Get the most recent decision.
298    pub fn last_decision(&self) -> Option<&DiffStrategyRecord> {
299        if self.decision_count == 0 {
300            return None;
301        }
302        let idx = if self.decision_head == 0 {
303            self.decision_capacity - 1
304        } else {
305            self.decision_head - 1
306        };
307        self.decisions[idx].as_ref()
308    }
309
310    /// Export all decisions and transitions as JSONL lines.
311    pub fn export_jsonl(&self) -> String {
312        let mut out = String::new();
313        for d in self.decisions() {
314            out.push_str(&d.to_jsonl());
315            out.push('\n');
316        }
317        for t in self.transitions() {
318            out.push_str(&t.to_jsonl());
319            out.push('\n');
320        }
321        out
322    }
323
324    /// Flush decisions to an evidence sink.
325    pub fn flush_to_sink(&self, sink: &crate::evidence_sink::EvidenceSink) -> std::io::Result<()> {
326        for d in self.decisions() {
327            sink.write_jsonl(&d.to_jsonl())?;
328        }
329        for t in self.transitions() {
330            sink.write_jsonl(&t.to_jsonl())?;
331        }
332        Ok(())
333    }
334
335    /// Clear all stored decisions and transitions.
336    pub fn clear(&mut self) {
337        for slot in &mut self.decisions {
338            *slot = None;
339        }
340        for slot in &mut self.transitions {
341            *slot = None;
342        }
343        self.decision_head = 0;
344        self.transition_head = 0;
345        self.decision_count = 0;
346        self.transition_count = 0;
347        self.current_regime = DiffRegime::StableFrame;
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use ftui_render::diff_strategy::StrategyEvidence;
355
356    fn make_evidence() -> StrategyEvidence {
357        StrategyEvidence {
358            strategy: DiffStrategy::DirtyRows,
359            cost_full: 1.0,
360            cost_dirty: 0.5,
361            cost_redraw: 2.0,
362            posterior_mean: 0.05,
363            posterior_variance: 0.001,
364            alpha: 2.0,
365            beta: 38.0,
366            dirty_rows: 3,
367            total_rows: 24,
368            total_cells: 1920,
369            guard_reason: "none",
370            hysteresis_applied: false,
371            hysteresis_ratio: 0.05,
372        }
373    }
374
375    fn make_record(frame_id: u64, regime: DiffRegime) -> DiffStrategyRecord {
376        DiffStrategyRecord {
377            frame_id,
378            regime,
379            posterior: vec![
380                (DiffStrategy::Full, 0.3),
381                (DiffStrategy::DirtyRows, 0.6),
382                (DiffStrategy::FullRedraw, 0.1),
383            ],
384            chosen_strategy: DiffStrategy::DirtyRows,
385            confidence: 0.6,
386            evidence: make_evidence(),
387            fallback_triggered: false,
388            observations: vec![
389                Observation::new("change_fraction", 0.05, 0.3),
390                Observation::new("dirty_rows", 3.0, 0.2),
391            ],
392        }
393    }
394
395    #[test]
396    fn empty_ledger() {
397        let ledger = DiffEvidenceLedger::new(100);
398        assert!(ledger.is_empty());
399        assert_eq!(ledger.len(), 0);
400        assert_eq!(ledger.transition_count(), 0);
401        assert!(ledger.last_decision().is_none());
402        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
403    }
404
405    #[test]
406    fn record_single_decision() {
407        let mut ledger = DiffEvidenceLedger::new(100);
408        ledger.record(make_record(1, DiffRegime::StableFrame));
409        assert_eq!(ledger.len(), 1);
410        assert_eq!(ledger.last_decision().unwrap().frame_id, 1);
411    }
412
413    #[test]
414    fn ring_buffer_wraps() {
415        let mut ledger = DiffEvidenceLedger::new(5);
416        for i in 0..10 {
417            ledger.record(make_record(i, DiffRegime::StableFrame));
418        }
419        // Should have exactly 5 decisions (capacity)
420        assert_eq!(ledger.len(), 5);
421        // Oldest should be frame 5 (0-4 overwritten)
422        let frames: Vec<u64> = ledger.decisions().map(|d| d.frame_id).collect();
423        assert_eq!(frames, vec![5, 6, 7, 8, 9]);
424    }
425
426    #[test]
427    fn regime_transition_auto_detected() {
428        let mut ledger = DiffEvidenceLedger::new(100);
429        ledger.record(make_record(1, DiffRegime::StableFrame));
430        ledger.record(make_record(2, DiffRegime::BurstyChange));
431        assert_eq!(ledger.transition_count(), 1);
432        assert_eq!(ledger.current_regime(), DiffRegime::BurstyChange);
433        let t = ledger.transitions().next().unwrap();
434        assert_eq!(t.from_regime, DiffRegime::StableFrame);
435        assert_eq!(t.to_regime, DiffRegime::BurstyChange);
436        assert_eq!(t.frame_id, 2);
437    }
438
439    #[test]
440    fn no_transition_on_same_regime() {
441        let mut ledger = DiffEvidenceLedger::new(100);
442        ledger.record(make_record(1, DiffRegime::StableFrame));
443        ledger.record(make_record(2, DiffRegime::StableFrame));
444        assert_eq!(ledger.transition_count(), 0);
445    }
446
447    #[test]
448    fn multiple_transitions() {
449        let mut ledger = DiffEvidenceLedger::new(100);
450        ledger.record(make_record(1, DiffRegime::StableFrame));
451        ledger.record(make_record(2, DiffRegime::BurstyChange));
452        ledger.record(make_record(3, DiffRegime::ResizeRegime));
453        ledger.record(make_record(4, DiffRegime::StableFrame));
454        assert_eq!(ledger.transition_count(), 3);
455    }
456
457    #[test]
458    fn jsonl_round_trip_decision() {
459        let record = make_record(42, DiffRegime::StableFrame);
460        let jsonl = record.to_jsonl();
461        assert!(jsonl.contains("\"type\":\"diff_decision\""));
462        assert!(jsonl.contains("\"frame\":42"));
463        assert!(jsonl.contains("\"regime\":\"stable_frame\""));
464        assert!(jsonl.contains("\"strategy\":\"DirtyRows\""));
465        assert!(jsonl.contains("\"obs\":["));
466        assert!(jsonl.contains("\"m\":\"change_fraction\""));
467    }
468
469    #[test]
470    fn jsonl_round_trip_transition() {
471        let transition = RegimeTransition {
472            frame_id: 10,
473            from_regime: DiffRegime::StableFrame,
474            to_regime: DiffRegime::BurstyChange,
475            trigger: "burst detected".to_string(),
476            confidence: 0.85,
477        };
478        let jsonl = transition.to_jsonl();
479        assert!(jsonl.contains("\"type\":\"regime_transition\""));
480        assert!(jsonl.contains("\"frame\":10"));
481        assert!(jsonl.contains("\"from\":\"stable_frame\""));
482        assert!(jsonl.contains("\"to\":\"bursty_change\""));
483    }
484
485    #[test]
486    fn export_jsonl_output() {
487        let mut ledger = DiffEvidenceLedger::new(100);
488        ledger.record(make_record(1, DiffRegime::StableFrame));
489        ledger.record(make_record(2, DiffRegime::BurstyChange));
490        let output = ledger.export_jsonl();
491        let lines: Vec<&str> = output.lines().collect();
492        // 2 decisions + 1 transition
493        assert_eq!(lines.len(), 3);
494        assert!(lines[0].contains("\"frame\":1"));
495        assert!(lines[1].contains("\"frame\":2"));
496        assert!(lines[2].contains("regime_transition"));
497    }
498
499    #[test]
500    fn clear_resets_everything() {
501        let mut ledger = DiffEvidenceLedger::new(100);
502        ledger.record(make_record(1, DiffRegime::StableFrame));
503        ledger.record(make_record(2, DiffRegime::BurstyChange));
504        assert_eq!(ledger.current_regime(), DiffRegime::BurstyChange);
505        ledger.clear();
506        assert!(ledger.is_empty());
507        assert_eq!(ledger.transition_count(), 0);
508        assert!(ledger.last_decision().is_none());
509        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
510    }
511
512    #[test]
513    fn last_decision_returns_most_recent() {
514        let mut ledger = DiffEvidenceLedger::new(100);
515        ledger.record(make_record(1, DiffRegime::StableFrame));
516        ledger.record(make_record(2, DiffRegime::StableFrame));
517        ledger.record(make_record(3, DiffRegime::StableFrame));
518        assert_eq!(ledger.last_decision().unwrap().frame_id, 3);
519    }
520
521    #[test]
522    fn last_decision_after_wrap() {
523        let mut ledger = DiffEvidenceLedger::new(3);
524        for i in 0..10 {
525            ledger.record(make_record(i, DiffRegime::StableFrame));
526        }
527        assert_eq!(ledger.last_decision().unwrap().frame_id, 9);
528    }
529
530    #[test]
531    fn observation_fields() {
532        let obs = Observation::new("test_metric", 42.0, 1.5);
533        assert_eq!(obs.metric_name, "test_metric");
534        assert!((obs.value - 42.0).abs() < f64::EPSILON);
535        assert!((obs.prior_contribution - 1.5).abs() < f64::EPSILON);
536    }
537
538    #[test]
539    fn regime_as_str() {
540        assert_eq!(DiffRegime::StableFrame.as_str(), "stable_frame");
541        assert_eq!(DiffRegime::BurstyChange.as_str(), "bursty_change");
542        assert_eq!(DiffRegime::ResizeRegime.as_str(), "resize");
543        assert_eq!(DiffRegime::DegradedTerminal.as_str(), "degraded");
544    }
545
546    #[test]
547    fn transition_ring_buffer_wraps() {
548        let mut ledger = DiffEvidenceLedger::new(10); // transition_capacity = max(10/10, 16) = 16
549        // Force many transitions by alternating regimes
550        let regimes = [
551            DiffRegime::StableFrame,
552            DiffRegime::BurstyChange,
553            DiffRegime::ResizeRegime,
554            DiffRegime::DegradedTerminal,
555        ];
556        for i in 0..100 {
557            ledger.record(make_record(i, regimes[i as usize % regimes.len()]));
558        }
559        // Transitions should be capped at transition_capacity
560        assert!(ledger.transition_count() <= 16);
561    }
562
563    #[test]
564    fn decisions_order_before_wrap() {
565        let mut ledger = DiffEvidenceLedger::new(10);
566        for i in 0..5 {
567            ledger.record(make_record(i, DiffRegime::StableFrame));
568        }
569        let frames: Vec<u64> = ledger.decisions().map(|d| d.frame_id).collect();
570        assert_eq!(frames, vec![0, 1, 2, 3, 4]);
571    }
572
573    #[test]
574    fn flush_to_sink_writes_all() {
575        let mut ledger = DiffEvidenceLedger::new(100);
576        ledger.record(make_record(1, DiffRegime::StableFrame));
577        ledger.record(make_record(2, DiffRegime::BurstyChange));
578
579        let config = crate::evidence_sink::EvidenceSinkConfig::enabled_stdout();
580        if let Ok(Some(sink)) = crate::evidence_sink::EvidenceSink::from_config(&config) {
581            // This will write to stdout but shouldn't panic
582            let result = ledger.flush_to_sink(&sink);
583            assert!(result.is_ok());
584        }
585    }
586
587    #[test]
588    fn simulate_1000_frames() {
589        let mut ledger = DiffEvidenceLedger::new(10_000);
590        let regimes = [
591            DiffRegime::StableFrame,
592            DiffRegime::BurstyChange,
593            DiffRegime::ResizeRegime,
594            DiffRegime::StableFrame,
595            DiffRegime::DegradedTerminal,
596            DiffRegime::StableFrame,
597        ];
598
599        for i in 0..1000 {
600            // Switch regime every 100 frames
601            let regime = regimes[(i / 100) % regimes.len()];
602            ledger.record(make_record(i as u64, regime));
603        }
604
605        assert_eq!(ledger.len(), 1000);
606        // Should have transitions at boundaries
607        assert!(ledger.transition_count() > 0);
608
609        // Verify order
610        let mut prev_frame = 0u64;
611        for d in ledger.decisions() {
612            assert!(d.frame_id >= prev_frame);
613            prev_frame = d.frame_id;
614        }
615
616        // Verify JSONL export
617        let jsonl = ledger.export_jsonl();
618        let lines: Vec<&str> = jsonl.lines().collect();
619        assert_eq!(lines.len(), ledger.len() + ledger.transition_count());
620    }
621
622    #[test]
623    fn debug_format() {
624        let ledger = DiffEvidenceLedger::new(100);
625        let debug = format!("{ledger:?}");
626        assert!(debug.contains("DiffEvidenceLedger"));
627        assert!(debug.contains("decisions: 0"));
628    }
629
630    #[test]
631    fn minimum_capacity() {
632        let mut ledger = DiffEvidenceLedger::new(0); // clamped to 1
633        ledger.record(make_record(1, DiffRegime::StableFrame));
634        assert_eq!(ledger.len(), 1);
635        ledger.record(make_record(2, DiffRegime::StableFrame));
636        assert_eq!(ledger.len(), 1); // wrapped
637        assert_eq!(ledger.last_decision().unwrap().frame_id, 2);
638    }
639
640    // ── Decision Contract integration tests (bd-3jlw5.6) ───────
641
642    #[test]
643    fn contract_stable_to_bursty_transition() {
644        // StableFrame -> BurstyChange when change_fraction > 0.5
645        let mut ledger = DiffEvidenceLedger::new(100);
646
647        // 10 stable frames
648        for i in 0..10 {
649            ledger.record(make_record(i, DiffRegime::StableFrame));
650        }
651        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
652        assert_eq!(ledger.transition_count(), 0);
653
654        // Bursty change detected
655        ledger.record(make_record(10, DiffRegime::BurstyChange));
656        assert_eq!(ledger.current_regime(), DiffRegime::BurstyChange);
657        assert_eq!(ledger.transition_count(), 1);
658
659        let t = ledger.transitions().next().unwrap();
660        assert_eq!(t.from_regime, DiffRegime::StableFrame);
661        assert_eq!(t.to_regime, DiffRegime::BurstyChange);
662        assert_eq!(t.frame_id, 10);
663    }
664
665    #[test]
666    fn contract_bursty_recovery_to_stable() {
667        // BurstyChange -> StableFrame after consecutive low-change frames
668        let mut ledger = DiffEvidenceLedger::new(100);
669
670        // Enter bursty via stable first
671        ledger.record(make_record(0, DiffRegime::StableFrame));
672        ledger.record(make_record(1, DiffRegime::BurstyChange));
673        assert_eq!(ledger.transition_count(), 1); // Stable -> Bursty
674
675        // Recovery: 3 stable frames
676        for i in 2..5 {
677            ledger.record(make_record(i, DiffRegime::StableFrame));
678        }
679        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
680        assert_eq!(ledger.transition_count(), 2); // Stable->Bursty, Bursty->Stable
681    }
682
683    #[test]
684    fn contract_resize_returns_to_previous() {
685        // ResizeRegime lasts 1 frame, then returns to previous regime
686        let mut ledger = DiffEvidenceLedger::new(100);
687
688        // Start stable
689        ledger.record(make_record(0, DiffRegime::StableFrame));
690
691        // Resize event
692        ledger.record(make_record(1, DiffRegime::ResizeRegime));
693        assert_eq!(ledger.current_regime(), DiffRegime::ResizeRegime);
694
695        // Return to stable after 1 frame
696        ledger.record(make_record(2, DiffRegime::StableFrame));
697        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
698
699        // 2 transitions: Stable->Resize, Resize->Stable
700        assert_eq!(ledger.transition_count(), 2);
701    }
702
703    #[test]
704    fn contract_degraded_entry_and_recovery() {
705        // DegradedTerminal when latency > 10ms, recovery when < 5ms
706        let mut ledger = DiffEvidenceLedger::new(100);
707
708        ledger.record(make_record(0, DiffRegime::StableFrame));
709        ledger.record(make_record(1, DiffRegime::DegradedTerminal));
710        assert_eq!(ledger.current_regime(), DiffRegime::DegradedTerminal);
711
712        // Stay degraded for several frames
713        for i in 2..10 {
714            ledger.record(make_record(i, DiffRegime::DegradedTerminal));
715        }
716        assert_eq!(ledger.current_regime(), DiffRegime::DegradedTerminal);
717        assert_eq!(ledger.transition_count(), 1); // only the initial transition
718
719        // Recovery
720        ledger.record(make_record(10, DiffRegime::StableFrame));
721        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
722        assert_eq!(ledger.transition_count(), 2);
723    }
724
725    #[test]
726    fn contract_no_flapping() {
727        // Hysteresis: regime shouldn't flap back and forth rapidly
728        // Record transitions and verify each one is captured
729        let mut ledger = DiffEvidenceLedger::new(100);
730
731        let sequence = [
732            DiffRegime::StableFrame,
733            DiffRegime::BurstyChange,
734            DiffRegime::StableFrame,
735            DiffRegime::BurstyChange,
736            DiffRegime::StableFrame,
737        ];
738
739        for (i, &regime) in sequence.iter().enumerate() {
740            ledger.record(make_record(i as u64, regime));
741        }
742
743        // 4 transitions (each change recorded)
744        assert_eq!(ledger.transition_count(), 4);
745
746        // Verify transition order
747        let transitions: Vec<(DiffRegime, DiffRegime)> = ledger
748            .transitions()
749            .map(|t| (t.from_regime, t.to_regime))
750            .collect();
751        assert_eq!(
752            transitions,
753            vec![
754                (DiffRegime::StableFrame, DiffRegime::BurstyChange),
755                (DiffRegime::BurstyChange, DiffRegime::StableFrame),
756                (DiffRegime::StableFrame, DiffRegime::BurstyChange),
757                (DiffRegime::BurstyChange, DiffRegime::StableFrame),
758            ]
759        );
760    }
761
762    #[test]
763    fn contract_full_lifecycle() {
764        // Full lifecycle: stable -> bursty -> resize -> stable -> degraded -> stable
765        let mut ledger = DiffEvidenceLedger::new(100);
766
767        let lifecycle = [
768            (0, DiffRegime::StableFrame),
769            (1, DiffRegime::StableFrame),
770            (2, DiffRegime::BurstyChange),
771            (3, DiffRegime::BurstyChange),
772            (4, DiffRegime::ResizeRegime),
773            (5, DiffRegime::StableFrame),
774            (6, DiffRegime::StableFrame),
775            (7, DiffRegime::DegradedTerminal),
776            (8, DiffRegime::DegradedTerminal),
777            (9, DiffRegime::StableFrame),
778        ];
779
780        for &(frame, regime) in &lifecycle {
781            ledger.record(make_record(frame, regime));
782        }
783
784        assert_eq!(ledger.len(), 10);
785        // Transitions: Stable->Bursty, Bursty->Resize, Resize->Stable,
786        //              Stable->Degraded, Degraded->Stable
787        assert_eq!(ledger.transition_count(), 5);
788        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
789
790        // Verify all transitions have valid frame IDs
791        for t in ledger.transitions() {
792            assert!(t.frame_id <= 9);
793            assert_ne!(t.from_regime, t.to_regime);
794        }
795    }
796}