Skip to main content

ftui_render/
render_certificate.rs

1//! Production render certificates (bd-6b9nr): explicit, named skip
2//! decisions for the diff stage — never opaque heuristics.
3//!
4//! The runtime's `TerminalWriter` builds a [`RenderCertificateInputs`] from
5//! facts it can prove locally each frame and asks
6//! [`evaluate_render_certificate`] for a decision. The certificate maps to a
7//! [`DiffSkipHint`](crate::diff::DiffSkipHint) consumed by
8//! [`BufferDiff::compute_certified_into`](crate::diff::BufferDiff::compute_certified_into):
9//!
10//! - `FullRequired` — no previous frame, viewport change, or a due
11//!   full-redraw probe: a full-fidelity pass is mandatory;
12//! - `SkipAll` — zero dirty rows: the frame provably introduces no cell
13//!   changes relative to the tracked baseline, so the diff scan is skipped
14//!   entirely;
15//! - `NarrowToDirty` — the diff scan is narrowed to exactly the dirty rows
16//!   (soundness inherited from the buffer's dirty-tracking invariant:
17//!   dirty rows ⊇ changed rows, which in turn requires that the buffer's
18//!   dirty state was cleared while its content was identical to the diff's
19//!   `old` baseline — see `BufferDiff::compute_dirty`'s precondition; the
20//!   production writer guarantees this by diffing consecutive frames of one
21//!   buffer lineage).
22//!
23//! The decision tree is deliberately conservative and fail-open: any
24//! condition that cannot be proven falls back to `FullRequired`. Every
25//! certificate names its causes so evidence logs explain *why* work was
26//! skipped or performed (the mirror of this model used by the offline
27//! gauntlet lives in `ftui-harness::render_certificate`; this module is the
28//! production-side evaluator, kept in `ftui-render` because the harness is
29//! downstream of the runtime).
30
31use crate::diff::DiffSkipHint;
32
33/// Facts the writer can prove about the frame before diffing.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct RenderCertificateInputs {
36    /// A previous frame exists to diff against.
37    pub prev_available: bool,
38    /// The viewport dimensions changed since the previous frame.
39    pub dims_changed: bool,
40    /// A periodic full-redraw probe is due.
41    pub full_redraw_due: bool,
42    /// Rows marked dirty by the buffer's tracking invariant.
43    pub dirty_row_count: usize,
44    /// Total rows in the frame.
45    pub total_rows: u16,
46}
47
48/// The certified level of work elimination.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum RenderCertificateLevel {
51    /// Full-fidelity work is required; nothing may be skipped.
52    FullRequired,
53    /// The diff scan is skipped entirely (no dirty rows).
54    SkipAll,
55    /// The diff scan is narrowed to the dirty rows only.
56    NarrowToDirty,
57}
58
59impl RenderCertificateLevel {
60    /// Stable lowercase tag for evidence logs.
61    #[must_use]
62    pub const fn label(&self) -> &'static str {
63        match self {
64            Self::FullRequired => "full-required",
65            Self::SkipAll => "skip-all",
66            Self::NarrowToDirty => "narrow-to-dirty",
67        }
68    }
69}
70
71/// An explicit, explainable skip decision for one frame.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct RenderCertificate {
74    /// The certified level.
75    pub level: RenderCertificateLevel,
76    /// Named causes behind the decision (never empty).
77    pub causes: Vec<&'static str>,
78    /// Rows the certificate narrows to (empty unless `NarrowToDirty`).
79    pub dirty_rows: Vec<u16>,
80    /// Whether the evaluator fell back to full work out of caution.
81    pub fell_back: bool,
82}
83
84impl RenderCertificate {
85    /// Translate the certificate into the diff-stage hint.
86    #[must_use]
87    pub fn to_hint(&self) -> DiffSkipHint {
88        match self.level {
89            RenderCertificateLevel::FullRequired => DiffSkipHint::FullDiff,
90            RenderCertificateLevel::SkipAll => DiffSkipHint::SkipDiff,
91            RenderCertificateLevel::NarrowToDirty => {
92                DiffSkipHint::NarrowToRows(self.dirty_rows.clone())
93            }
94        }
95    }
96
97    /// Compact JSON fragment for evidence lines (stable field order).
98    #[must_use]
99    pub fn to_evidence_json(&self) -> String {
100        format!(
101            r#"{{"level":"{}","causes":[{}],"narrowed_rows":{},"fell_back":{}}}"#,
102            self.level.label(),
103            self.causes
104                .iter()
105                .map(|c| format!("\"{c}\""))
106                .collect::<Vec<_>>()
107                .join(","),
108            self.dirty_rows.len(),
109            self.fell_back
110        )
111    }
112}
113
114/// Evaluate the conservative production decision tree.
115///
116/// `dirty_rows` must be the buffer's dirty row indices in ascending order;
117/// it is only consulted when the decision narrows.
118#[must_use]
119pub fn evaluate_render_certificate(
120    inputs: &RenderCertificateInputs,
121    dirty_rows: Vec<u16>,
122) -> RenderCertificate {
123    if !inputs.prev_available {
124        return RenderCertificate {
125            level: RenderCertificateLevel::FullRequired,
126            causes: vec!["no-previous-frame"],
127            dirty_rows: Vec::new(),
128            fell_back: true,
129        };
130    }
131    if inputs.dims_changed {
132        return RenderCertificate {
133            level: RenderCertificateLevel::FullRequired,
134            causes: vec!["viewport-changed"],
135            dirty_rows: Vec::new(),
136            fell_back: true,
137        };
138    }
139    if inputs.full_redraw_due {
140        return RenderCertificate {
141            level: RenderCertificateLevel::FullRequired,
142            causes: vec!["full-redraw-probe-due"],
143            dirty_rows: Vec::new(),
144            fell_back: true,
145        };
146    }
147    if inputs.dirty_row_count == 0 {
148        return RenderCertificate {
149            level: RenderCertificateLevel::SkipAll,
150            causes: vec!["zero-dirty-rows"],
151            dirty_rows: Vec::new(),
152            fell_back: false,
153        };
154    }
155    // The narrow certificate is only honest when the provided row list is
156    // consistent with the count; any mismatch falls back to full work.
157    if dirty_rows.len() != inputs.dirty_row_count
158        || dirty_rows.iter().any(|&row| row >= inputs.total_rows)
159    {
160        return RenderCertificate {
161            level: RenderCertificateLevel::FullRequired,
162            causes: vec!["dirty-row-witness-inconsistent"],
163            dirty_rows: Vec::new(),
164            fell_back: true,
165        };
166    }
167    RenderCertificate {
168        level: RenderCertificateLevel::NarrowToDirty,
169        causes: vec!["dirty-rows-witnessed"],
170        dirty_rows,
171        fell_back: false,
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::buffer::Buffer;
179    use crate::cell::Cell;
180    use crate::diff::BufferDiff;
181
182    fn inputs(dirty: usize, rows: u16) -> RenderCertificateInputs {
183        RenderCertificateInputs {
184            prev_available: true,
185            dims_changed: false,
186            full_redraw_due: false,
187            dirty_row_count: dirty,
188            total_rows: rows,
189        }
190    }
191
192    #[test]
193    fn unprovable_conditions_force_full_work() {
194        let mut no_prev = inputs(3, 10);
195        no_prev.prev_available = false;
196        let cert = evaluate_render_certificate(&no_prev, vec![1, 2, 3]);
197        assert_eq!(cert.level, RenderCertificateLevel::FullRequired);
198        assert!(cert.fell_back);
199        assert_eq!(cert.causes, vec!["no-previous-frame"]);
200
201        let mut resized = inputs(3, 10);
202        resized.dims_changed = true;
203        let cert = evaluate_render_certificate(&resized, vec![1, 2, 3]);
204        assert_eq!(cert.causes, vec!["viewport-changed"]);
205
206        let mut probe = inputs(3, 10);
207        probe.full_redraw_due = true;
208        let cert = evaluate_render_certificate(&probe, vec![1, 2, 3]);
209        assert_eq!(cert.causes, vec!["full-redraw-probe-due"]);
210    }
211
212    #[test]
213    fn zero_dirty_rows_certifies_a_skip() {
214        let cert = evaluate_render_certificate(&inputs(0, 10), Vec::new());
215        assert_eq!(cert.level, RenderCertificateLevel::SkipAll);
216        assert!(!cert.fell_back);
217        assert!(matches!(cert.to_hint(), DiffSkipHint::SkipDiff));
218    }
219
220    #[test]
221    fn dirty_rows_certify_a_narrow_scan() {
222        let cert = evaluate_render_certificate(&inputs(2, 10), vec![3, 7]);
223        assert_eq!(cert.level, RenderCertificateLevel::NarrowToDirty);
224        match cert.to_hint() {
225            DiffSkipHint::NarrowToRows(rows) => assert_eq!(rows, vec![3, 7]),
226            other => panic!("expected narrow hint, got {other:?}"),
227        }
228    }
229
230    #[test]
231    fn inconsistent_witness_falls_back_to_full() {
232        // Count mismatch.
233        let cert = evaluate_render_certificate(&inputs(2, 10), vec![3]);
234        assert_eq!(cert.level, RenderCertificateLevel::FullRequired);
235        assert!(cert.fell_back);
236        // Out-of-bounds row.
237        let cert = evaluate_render_certificate(&inputs(1, 10), vec![10]);
238        assert_eq!(cert.level, RenderCertificateLevel::FullRequired);
239        assert_eq!(cert.causes, vec!["dirty-row-witness-inconsistent"]);
240    }
241
242    #[test]
243    fn evidence_json_is_stable_and_named() {
244        let cert = evaluate_render_certificate(&inputs(2, 10), vec![3, 7]);
245        let json = cert.to_evidence_json();
246        assert_eq!(
247            json,
248            r#"{"level":"narrow-to-dirty","causes":["dirty-rows-witnessed"],"narrowed_rows":2,"fell_back":false}"#
249        );
250    }
251
252    /// The certified path must produce exactly the change set of the
253    /// uncertified dirty path across generated frames (behavior
254    /// preservation, deterministic xorshift corpus).
255    #[test]
256    fn certified_changes_equal_dirty_changes_across_generated_frames() {
257        let (w, h) = (24u16, 8u16);
258        let mut seed = 0x9E37_79B9_7F4A_7C15u64;
259        let mut next = move || {
260            seed ^= seed << 13;
261            seed ^= seed >> 7;
262            seed ^= seed << 17;
263            seed
264        };
265
266        for round in 0..50 {
267            let old = Buffer::new(w, h);
268            let mut new = Buffer::new(w, h);
269            new.clear_dirty();
270
271            let mutations = (next() % 20) as usize;
272            for _ in 0..mutations {
273                let x = (next() % u64::from(w)) as u16;
274                let y = (next() % u64::from(h)) as u16;
275                let ch = char::from(b'A' + (next() % 26) as u8);
276                new.set(x, y, Cell::from_char(ch));
277            }
278
279            let dirty_count = new.dirty_row_count();
280            let dirty_rows = new.dirty_row_indices();
281            let cert = evaluate_render_certificate(&inputs(dirty_count, h), dirty_rows);
282            assert!(!cert.fell_back, "round {round}: unexpected fallback");
283
284            let mut certified = BufferDiff::new();
285            certified.compute_certified_into(&old, &new, cert.to_hint());
286            let mut truth = BufferDiff::new();
287            truth.compute_dirty_into(&old, &new);
288            assert_eq!(
289                certified.changes(),
290                truth.changes(),
291                "round {round}: certified path diverged ({} mutations)",
292                mutations
293            );
294        }
295    }
296}