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    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use ftui_render::diff_strategy::StrategyEvidence;
354
355    fn make_evidence() -> StrategyEvidence {
356        StrategyEvidence {
357            strategy: DiffStrategy::DirtyRows,
358            cost_full: 1.0,
359            cost_dirty: 0.5,
360            cost_redraw: 2.0,
361            posterior_mean: 0.05,
362            posterior_variance: 0.001,
363            alpha: 2.0,
364            beta: 38.0,
365            dirty_rows: 3,
366            total_rows: 24,
367            total_cells: 1920,
368            guard_reason: "none",
369            hysteresis_applied: false,
370            hysteresis_ratio: 0.05,
371        }
372    }
373
374    fn make_record(frame_id: u64, regime: DiffRegime) -> DiffStrategyRecord {
375        DiffStrategyRecord {
376            frame_id,
377            regime,
378            posterior: vec![
379                (DiffStrategy::Full, 0.3),
380                (DiffStrategy::DirtyRows, 0.6),
381                (DiffStrategy::FullRedraw, 0.1),
382            ],
383            chosen_strategy: DiffStrategy::DirtyRows,
384            confidence: 0.6,
385            evidence: make_evidence(),
386            fallback_triggered: false,
387            observations: vec![
388                Observation::new("change_fraction", 0.05, 0.3),
389                Observation::new("dirty_rows", 3.0, 0.2),
390            ],
391        }
392    }
393
394    #[test]
395    fn empty_ledger() {
396        let ledger = DiffEvidenceLedger::new(100);
397        assert!(ledger.is_empty());
398        assert_eq!(ledger.len(), 0);
399        assert_eq!(ledger.transition_count(), 0);
400        assert!(ledger.last_decision().is_none());
401        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
402    }
403
404    #[test]
405    fn record_single_decision() {
406        let mut ledger = DiffEvidenceLedger::new(100);
407        ledger.record(make_record(1, DiffRegime::StableFrame));
408        assert_eq!(ledger.len(), 1);
409        assert_eq!(ledger.last_decision().unwrap().frame_id, 1);
410    }
411
412    #[test]
413    fn ring_buffer_wraps() {
414        let mut ledger = DiffEvidenceLedger::new(5);
415        for i in 0..10 {
416            ledger.record(make_record(i, DiffRegime::StableFrame));
417        }
418        // Should have exactly 5 decisions (capacity)
419        assert_eq!(ledger.len(), 5);
420        // Oldest should be frame 5 (0-4 overwritten)
421        let frames: Vec<u64> = ledger.decisions().map(|d| d.frame_id).collect();
422        assert_eq!(frames, vec![5, 6, 7, 8, 9]);
423    }
424
425    #[test]
426    fn regime_transition_auto_detected() {
427        let mut ledger = DiffEvidenceLedger::new(100);
428        ledger.record(make_record(1, DiffRegime::StableFrame));
429        ledger.record(make_record(2, DiffRegime::BurstyChange));
430        assert_eq!(ledger.transition_count(), 1);
431        assert_eq!(ledger.current_regime(), DiffRegime::BurstyChange);
432        let t = ledger.transitions().next().unwrap();
433        assert_eq!(t.from_regime, DiffRegime::StableFrame);
434        assert_eq!(t.to_regime, DiffRegime::BurstyChange);
435        assert_eq!(t.frame_id, 2);
436    }
437
438    #[test]
439    fn no_transition_on_same_regime() {
440        let mut ledger = DiffEvidenceLedger::new(100);
441        ledger.record(make_record(1, DiffRegime::StableFrame));
442        ledger.record(make_record(2, DiffRegime::StableFrame));
443        assert_eq!(ledger.transition_count(), 0);
444    }
445
446    #[test]
447    fn multiple_transitions() {
448        let mut ledger = DiffEvidenceLedger::new(100);
449        ledger.record(make_record(1, DiffRegime::StableFrame));
450        ledger.record(make_record(2, DiffRegime::BurstyChange));
451        ledger.record(make_record(3, DiffRegime::ResizeRegime));
452        ledger.record(make_record(4, DiffRegime::StableFrame));
453        assert_eq!(ledger.transition_count(), 3);
454    }
455
456    #[test]
457    fn jsonl_round_trip_decision() {
458        let record = make_record(42, DiffRegime::StableFrame);
459        let jsonl = record.to_jsonl();
460        assert!(jsonl.contains("\"type\":\"diff_decision\""));
461        assert!(jsonl.contains("\"frame\":42"));
462        assert!(jsonl.contains("\"regime\":\"stable_frame\""));
463        assert!(jsonl.contains("\"strategy\":\"DirtyRows\""));
464        assert!(jsonl.contains("\"obs\":["));
465        assert!(jsonl.contains("\"m\":\"change_fraction\""));
466    }
467
468    #[test]
469    fn jsonl_round_trip_transition() {
470        let transition = RegimeTransition {
471            frame_id: 10,
472            from_regime: DiffRegime::StableFrame,
473            to_regime: DiffRegime::BurstyChange,
474            trigger: "burst detected".to_string(),
475            confidence: 0.85,
476        };
477        let jsonl = transition.to_jsonl();
478        assert!(jsonl.contains("\"type\":\"regime_transition\""));
479        assert!(jsonl.contains("\"frame\":10"));
480        assert!(jsonl.contains("\"from\":\"stable_frame\""));
481        assert!(jsonl.contains("\"to\":\"bursty_change\""));
482    }
483
484    #[test]
485    fn export_jsonl_output() {
486        let mut ledger = DiffEvidenceLedger::new(100);
487        ledger.record(make_record(1, DiffRegime::StableFrame));
488        ledger.record(make_record(2, DiffRegime::BurstyChange));
489        let output = ledger.export_jsonl();
490        let lines: Vec<&str> = output.lines().collect();
491        // 2 decisions + 1 transition
492        assert_eq!(lines.len(), 3);
493        assert!(lines[0].contains("\"frame\":1"));
494        assert!(lines[1].contains("\"frame\":2"));
495        assert!(lines[2].contains("regime_transition"));
496    }
497
498    #[test]
499    fn clear_resets_everything() {
500        let mut ledger = DiffEvidenceLedger::new(100);
501        ledger.record(make_record(1, DiffRegime::StableFrame));
502        ledger.record(make_record(2, DiffRegime::BurstyChange));
503        ledger.clear();
504        assert!(ledger.is_empty());
505        assert_eq!(ledger.transition_count(), 0);
506        assert!(ledger.last_decision().is_none());
507    }
508
509    #[test]
510    fn last_decision_returns_most_recent() {
511        let mut ledger = DiffEvidenceLedger::new(100);
512        ledger.record(make_record(1, DiffRegime::StableFrame));
513        ledger.record(make_record(2, DiffRegime::StableFrame));
514        ledger.record(make_record(3, DiffRegime::StableFrame));
515        assert_eq!(ledger.last_decision().unwrap().frame_id, 3);
516    }
517
518    #[test]
519    fn last_decision_after_wrap() {
520        let mut ledger = DiffEvidenceLedger::new(3);
521        for i in 0..10 {
522            ledger.record(make_record(i, DiffRegime::StableFrame));
523        }
524        assert_eq!(ledger.last_decision().unwrap().frame_id, 9);
525    }
526
527    #[test]
528    fn observation_fields() {
529        let obs = Observation::new("test_metric", 42.0, 1.5);
530        assert_eq!(obs.metric_name, "test_metric");
531        assert!((obs.value - 42.0).abs() < f64::EPSILON);
532        assert!((obs.prior_contribution - 1.5).abs() < f64::EPSILON);
533    }
534
535    #[test]
536    fn regime_as_str() {
537        assert_eq!(DiffRegime::StableFrame.as_str(), "stable_frame");
538        assert_eq!(DiffRegime::BurstyChange.as_str(), "bursty_change");
539        assert_eq!(DiffRegime::ResizeRegime.as_str(), "resize");
540        assert_eq!(DiffRegime::DegradedTerminal.as_str(), "degraded");
541    }
542
543    #[test]
544    fn transition_ring_buffer_wraps() {
545        let mut ledger = DiffEvidenceLedger::new(10); // transition_capacity = max(10/10, 16) = 16
546        // Force many transitions by alternating regimes
547        let regimes = [
548            DiffRegime::StableFrame,
549            DiffRegime::BurstyChange,
550            DiffRegime::ResizeRegime,
551            DiffRegime::DegradedTerminal,
552        ];
553        for i in 0..100 {
554            ledger.record(make_record(i, regimes[i as usize % regimes.len()]));
555        }
556        // Transitions should be capped at transition_capacity
557        assert!(ledger.transition_count() <= 16);
558    }
559
560    #[test]
561    fn decisions_order_before_wrap() {
562        let mut ledger = DiffEvidenceLedger::new(10);
563        for i in 0..5 {
564            ledger.record(make_record(i, DiffRegime::StableFrame));
565        }
566        let frames: Vec<u64> = ledger.decisions().map(|d| d.frame_id).collect();
567        assert_eq!(frames, vec![0, 1, 2, 3, 4]);
568    }
569
570    #[test]
571    fn flush_to_sink_writes_all() {
572        let mut ledger = DiffEvidenceLedger::new(100);
573        ledger.record(make_record(1, DiffRegime::StableFrame));
574        ledger.record(make_record(2, DiffRegime::BurstyChange));
575
576        let config = crate::evidence_sink::EvidenceSinkConfig::enabled_stdout();
577        if let Ok(Some(sink)) = crate::evidence_sink::EvidenceSink::from_config(&config) {
578            // This will write to stdout but shouldn't panic
579            let result = ledger.flush_to_sink(&sink);
580            assert!(result.is_ok());
581        }
582    }
583
584    #[test]
585    fn simulate_1000_frames() {
586        let mut ledger = DiffEvidenceLedger::new(10_000);
587        let regimes = [
588            DiffRegime::StableFrame,
589            DiffRegime::BurstyChange,
590            DiffRegime::ResizeRegime,
591            DiffRegime::StableFrame,
592            DiffRegime::DegradedTerminal,
593            DiffRegime::StableFrame,
594        ];
595
596        for i in 0..1000 {
597            // Switch regime every 100 frames
598            let regime = regimes[(i / 100) % regimes.len()];
599            ledger.record(make_record(i as u64, regime));
600        }
601
602        assert_eq!(ledger.len(), 1000);
603        // Should have transitions at boundaries
604        assert!(ledger.transition_count() > 0);
605
606        // Verify order
607        let mut prev_frame = 0u64;
608        for d in ledger.decisions() {
609            assert!(d.frame_id >= prev_frame);
610            prev_frame = d.frame_id;
611        }
612
613        // Verify JSONL export
614        let jsonl = ledger.export_jsonl();
615        let lines: Vec<&str> = jsonl.lines().collect();
616        assert_eq!(lines.len(), ledger.len() + ledger.transition_count());
617    }
618
619    #[test]
620    fn debug_format() {
621        let ledger = DiffEvidenceLedger::new(100);
622        let debug = format!("{ledger:?}");
623        assert!(debug.contains("DiffEvidenceLedger"));
624        assert!(debug.contains("decisions: 0"));
625    }
626
627    #[test]
628    fn minimum_capacity() {
629        let mut ledger = DiffEvidenceLedger::new(0); // clamped to 1
630        ledger.record(make_record(1, DiffRegime::StableFrame));
631        assert_eq!(ledger.len(), 1);
632        ledger.record(make_record(2, DiffRegime::StableFrame));
633        assert_eq!(ledger.len(), 1); // wrapped
634        assert_eq!(ledger.last_decision().unwrap().frame_id, 2);
635    }
636
637    // ── Decision Contract integration tests (bd-3jlw5.6) ───────
638
639    #[test]
640    fn contract_stable_to_bursty_transition() {
641        // StableFrame -> BurstyChange when change_fraction > 0.5
642        let mut ledger = DiffEvidenceLedger::new(100);
643
644        // 10 stable frames
645        for i in 0..10 {
646            ledger.record(make_record(i, DiffRegime::StableFrame));
647        }
648        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
649        assert_eq!(ledger.transition_count(), 0);
650
651        // Bursty change detected
652        ledger.record(make_record(10, DiffRegime::BurstyChange));
653        assert_eq!(ledger.current_regime(), DiffRegime::BurstyChange);
654        assert_eq!(ledger.transition_count(), 1);
655
656        let t = ledger.transitions().next().unwrap();
657        assert_eq!(t.from_regime, DiffRegime::StableFrame);
658        assert_eq!(t.to_regime, DiffRegime::BurstyChange);
659        assert_eq!(t.frame_id, 10);
660    }
661
662    #[test]
663    fn contract_bursty_recovery_to_stable() {
664        // BurstyChange -> StableFrame after consecutive low-change frames
665        let mut ledger = DiffEvidenceLedger::new(100);
666
667        // Enter bursty via stable first
668        ledger.record(make_record(0, DiffRegime::StableFrame));
669        ledger.record(make_record(1, DiffRegime::BurstyChange));
670        assert_eq!(ledger.transition_count(), 1); // Stable -> Bursty
671
672        // Recovery: 3 stable frames
673        for i in 2..5 {
674            ledger.record(make_record(i, DiffRegime::StableFrame));
675        }
676        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
677        assert_eq!(ledger.transition_count(), 2); // Stable->Bursty, Bursty->Stable
678    }
679
680    #[test]
681    fn contract_resize_returns_to_previous() {
682        // ResizeRegime lasts 1 frame, then returns to previous regime
683        let mut ledger = DiffEvidenceLedger::new(100);
684
685        // Start stable
686        ledger.record(make_record(0, DiffRegime::StableFrame));
687
688        // Resize event
689        ledger.record(make_record(1, DiffRegime::ResizeRegime));
690        assert_eq!(ledger.current_regime(), DiffRegime::ResizeRegime);
691
692        // Return to stable after 1 frame
693        ledger.record(make_record(2, DiffRegime::StableFrame));
694        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
695
696        // 2 transitions: Stable->Resize, Resize->Stable
697        assert_eq!(ledger.transition_count(), 2);
698    }
699
700    #[test]
701    fn contract_degraded_entry_and_recovery() {
702        // DegradedTerminal when latency > 10ms, recovery when < 5ms
703        let mut ledger = DiffEvidenceLedger::new(100);
704
705        ledger.record(make_record(0, DiffRegime::StableFrame));
706        ledger.record(make_record(1, DiffRegime::DegradedTerminal));
707        assert_eq!(ledger.current_regime(), DiffRegime::DegradedTerminal);
708
709        // Stay degraded for several frames
710        for i in 2..10 {
711            ledger.record(make_record(i, DiffRegime::DegradedTerminal));
712        }
713        assert_eq!(ledger.current_regime(), DiffRegime::DegradedTerminal);
714        assert_eq!(ledger.transition_count(), 1); // only the initial transition
715
716        // Recovery
717        ledger.record(make_record(10, DiffRegime::StableFrame));
718        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
719        assert_eq!(ledger.transition_count(), 2);
720    }
721
722    #[test]
723    fn contract_no_flapping() {
724        // Hysteresis: regime shouldn't flap back and forth rapidly
725        // Record transitions and verify each one is captured
726        let mut ledger = DiffEvidenceLedger::new(100);
727
728        let sequence = [
729            DiffRegime::StableFrame,
730            DiffRegime::BurstyChange,
731            DiffRegime::StableFrame,
732            DiffRegime::BurstyChange,
733            DiffRegime::StableFrame,
734        ];
735
736        for (i, &regime) in sequence.iter().enumerate() {
737            ledger.record(make_record(i as u64, regime));
738        }
739
740        // 4 transitions (each change recorded)
741        assert_eq!(ledger.transition_count(), 4);
742
743        // Verify transition order
744        let transitions: Vec<(DiffRegime, DiffRegime)> = ledger
745            .transitions()
746            .map(|t| (t.from_regime, t.to_regime))
747            .collect();
748        assert_eq!(
749            transitions,
750            vec![
751                (DiffRegime::StableFrame, DiffRegime::BurstyChange),
752                (DiffRegime::BurstyChange, DiffRegime::StableFrame),
753                (DiffRegime::StableFrame, DiffRegime::BurstyChange),
754                (DiffRegime::BurstyChange, DiffRegime::StableFrame),
755            ]
756        );
757    }
758
759    #[test]
760    fn contract_full_lifecycle() {
761        // Full lifecycle: stable -> bursty -> resize -> stable -> degraded -> stable
762        let mut ledger = DiffEvidenceLedger::new(100);
763
764        let lifecycle = [
765            (0, DiffRegime::StableFrame),
766            (1, DiffRegime::StableFrame),
767            (2, DiffRegime::BurstyChange),
768            (3, DiffRegime::BurstyChange),
769            (4, DiffRegime::ResizeRegime),
770            (5, DiffRegime::StableFrame),
771            (6, DiffRegime::StableFrame),
772            (7, DiffRegime::DegradedTerminal),
773            (8, DiffRegime::DegradedTerminal),
774            (9, DiffRegime::StableFrame),
775        ];
776
777        for &(frame, regime) in &lifecycle {
778            ledger.record(make_record(frame, regime));
779        }
780
781        assert_eq!(ledger.len(), 10);
782        // Transitions: Stable->Bursty, Bursty->Resize, Resize->Stable,
783        //              Stable->Degraded, Degraded->Stable
784        assert_eq!(ledger.transition_count(), 5);
785        assert_eq!(ledger.current_regime(), DiffRegime::StableFrame);
786
787        // Verify all transitions have valid frame IDs
788        for t in ledger.transitions() {
789            assert!(t.frame_id <= 9);
790            assert_ne!(t.from_regime, t.to_regime);
791        }
792    }
793}