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