Skip to main content

renew_frame/
report.rs

1//! Frame reporting, split so the gated half cannot touch the timed
2//! half.
3//!
4//! One type tallying everything would absorb measured wall time into the
5//! determinism digest — and it would fail silently, because the gate would
6//! simply never go green and someone would "fix" it by loosening the
7//! comparison. [`FrameStats`] is the deterministic tally and is what gets
8//! gated; [`FrameTiming`] is measured and is only ever recorded. The
9//! boundary is a type distinction rather than a doc comment, and it
10//! is the same line the JSON output draws.
11
12use core::fmt;
13
14use crate::digest::StateHash;
15use crate::schedule::FramePlan;
16use crate::time::Nanos;
17
18/// The deterministic per-run tally: counts plus the schedule digest.
19///
20/// Everything here is a function of the plans absorbed, so two runs of the
21/// same schedule produce identical statistics on any machine.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct FrameStats {
24    frames: u64,
25    ticks: u64,
26    steps_dropped: u64,
27    hash: StateHash,
28}
29
30impl FrameStats {
31    #[must_use]
32    pub const fn new() -> Self {
33        Self {
34            frames: 0,
35            ticks: 0,
36            steps_dropped: 0,
37            hash: StateHash::new(),
38        }
39    }
40
41    /// Tally one frame and fold its plan into the schedule digest.
42    ///
43    /// Every counter saturates. That is not ceremony for the frame count:
44    /// a single frame on a saturated bank drops on the order of 1.1e12
45    /// steps, so the *dropped* tally is only about ten million such frames
46    /// from the ceiling, and an arithmetic overflow is a panic this crate
47    /// does not get to have.
48    pub fn absorb(&mut self, plan: &FramePlan) {
49        self.frames = self.frames.saturating_add(1);
50        self.ticks = self.ticks.saturating_add(u64::from(plan.step_count()));
51        self.steps_dropped = self.steps_dropped.saturating_add(plan.dropped());
52        self.hash = self.hash.absorb_plan(plan);
53    }
54
55    #[must_use]
56    pub const fn frames(&self) -> u64 {
57        self.frames
58    }
59
60    #[must_use]
61    pub const fn ticks(&self) -> u64 {
62        self.ticks
63    }
64
65    #[must_use]
66    pub const fn steps_dropped(&self) -> u64 {
67        self.steps_dropped
68    }
69
70    /// The fingerprint of every plan absorbed, in order.
71    #[must_use]
72    pub const fn schedule_hash(&self) -> u64 {
73        self.hash.finish()
74    }
75
76    #[must_use]
77    pub const fn json(&self) -> FrameStatsJson<'_> {
78        FrameStatsJson(self)
79    }
80}
81
82impl Default for FrameStats {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88/// [`FrameStats`] as one JSON object, for the machine-readable half of a
89/// tool's output.
90///
91/// The digest is a hexadecimal *string*: a `u64` exceeds the integer
92/// precision of every JSON reader that parses numbers as doubles, and a
93/// silently rounded fingerprint is worse than no fingerprint.
94#[derive(Clone, Copy, Debug)]
95pub struct FrameStatsJson<'a>(&'a FrameStats);
96
97impl fmt::Display for FrameStatsJson<'_> {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(
100            f,
101            "{{\"frames\":{},\"ticks\":{},\"steps_dropped\":{},\"schedule_hash\":\"{:#018x}\"}}",
102            self.0.frames,
103            self.0.ticks,
104            self.0.steps_dropped,
105            self.0.schedule_hash()
106        )
107    }
108}
109
110/// The measured per-run timing summary: never gated, only recorded.
111///
112/// Percentiles are deliberately absent — p50/p99 need a reservoir or a
113/// histogram, which is a real design. Count, minimum, maximum and sum are
114/// enough for a first baseline; the growth trigger is the first frame
115/// budget that needs negotiating.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct FrameTiming {
118    count: u64,
119    min: u64,
120    max: u64,
121    sum: u64,
122    drawn: u64,
123    skipped: u64,
124}
125
126impl FrameTiming {
127    #[must_use]
128    pub const fn new() -> Self {
129        Self {
130            count: 0,
131            // The ceiling, so the first sample wins the comparison; the
132            // JSON reports zero until a sample exists.
133            min: u64::MAX,
134            max: 0,
135            sum: 0,
136            drawn: 0,
137            skipped: 0,
138        }
139    }
140
141    /// Record one frame's measured CPU cost, and whether it presented.
142    ///
143    /// Presented-versus-skipped lives on the measured side deliberately:
144    /// its purpose is measurement integrity, not determinism. A dormant
145    /// window presenting nothing would otherwise "run at 40,000 fps" and
146    /// silently inflate a frame-time baseline.
147    pub fn record(&mut self, cpu_frame: Nanos, drawn: bool) {
148        let nanos = cpu_frame.get();
149        self.count = self.count.saturating_add(1);
150        self.min = self.min.min(nanos);
151        self.max = self.max.max(nanos);
152        self.sum = self.sum.saturating_add(nanos);
153        if drawn {
154            self.drawn = self.drawn.saturating_add(1);
155        } else {
156            self.skipped = self.skipped.saturating_add(1);
157        }
158    }
159
160    #[must_use]
161    pub const fn json(&self) -> FrameTimingJson<'_> {
162        FrameTimingJson(self)
163    }
164
165    /// The reported minimum: zero before the first sample, rather than the
166    /// sentinel the comparison starts from.
167    const fn reported_min(&self) -> u64 {
168        if self.count == 0 { 0 } else { self.min }
169    }
170}
171
172impl Default for FrameTiming {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178/// [`FrameTiming`] as one JSON object. Everything here varies between
179/// runs and machines, which is exactly why it is a separate document
180/// section from the digest.
181#[derive(Clone, Copy, Debug)]
182pub struct FrameTimingJson<'a>(&'a FrameTiming);
183
184impl fmt::Display for FrameTimingJson<'_> {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(
187            f,
188            "{{\"count\":{},\"min_ns\":{},\"max_ns\":{},\"sum_ns\":{},\"drawn\":{},\"skipped\":{}}}",
189            self.0.count,
190            self.0.reported_min(),
191            self.0.max,
192            self.0.sum,
193            self.0.drawn,
194            self.0.skipped
195        )
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{FrameStats, FrameTiming};
202    use crate::digest::StateHash;
203    use crate::schedule::FrameLoop;
204    use crate::time::{Nanos, StepBudget, Timestamp, Timestep};
205
206    fn stalling_loop() -> FrameLoop {
207        FrameLoop::new(
208            Timestep::HZ_60,
209            StepBudget::DEFAULT,
210            Timestamp::from_nanos(0),
211        )
212    }
213
214    #[test]
215    fn a_fresh_tally_is_empty_and_holds_the_untouched_digest() {
216        let stats = FrameStats::new();
217        assert_eq!(stats, FrameStats::default());
218        assert_eq!(stats.frames(), 0);
219        assert_eq!(stats.ticks(), 0);
220        assert_eq!(stats.steps_dropped(), 0);
221        assert_eq!(stats.schedule_hash(), StateHash::new().finish());
222        assert_eq!(
223            stats.json().to_string(),
224            "{\"frames\":0,\"ticks\":0,\"steps_dropped\":0,\"schedule_hash\":\"0xcbf29ce484222325\"}"
225        );
226    }
227
228    #[test]
229    fn absorbing_frames_tallies_steps_and_drops_and_moves_the_digest() {
230        let mut frame = stalling_loop();
231        let mut stats = FrameStats::new();
232        // Two ordinary frames, then a 200 ms stall the budget refuses.
233        for now in [16_666_667_u64, 33_333_334, 233_333_334] {
234            stats.absorb(&frame.begin_frame(Timestamp::from_nanos(now)));
235        }
236        assert_eq!(stats.frames(), 3);
237        assert_eq!(stats.ticks(), 7, "one, one, then the budgeted five");
238        assert!(stats.steps_dropped() > 0, "the stall was refused");
239        assert_ne!(stats.schedule_hash(), StateHash::new().finish());
240
241        // The digest is a quoted, zero-padded hex string, not a JSON
242        // number: a u64 exceeds the precision of a double-parsing reader.
243        let json = stats.json().to_string();
244        let prefix = "{\"frames\":3,\"ticks\":7,\"steps_dropped\":6,\"schedule_hash\":\"0x";
245        assert!(json.starts_with(prefix), "{json}");
246        assert!(json.ends_with("\"}"), "{json}");
247        assert_eq!(
248            json.len(),
249            prefix.len() + 16 + 2,
250            "sixteen hex digits: {json}"
251        );
252    }
253
254    /// The tally is a function of the plans absorbed and nothing else, so
255    /// two independently driven runs of one schedule agree exactly.
256    #[test]
257    fn two_runs_of_one_schedule_produce_identical_statistics() {
258        let run = || {
259            let mut frame = stalling_loop();
260            let mut stats = FrameStats::new();
261            for k in 1..=32u64 {
262                stats.absorb(&frame.begin_frame(Timestamp::from_nanos(k * 12_000_000)));
263            }
264            stats
265        };
266        assert_eq!(run(), run());
267    }
268
269    #[test]
270    fn a_fresh_timing_summary_reports_a_zero_minimum_rather_than_the_sentinel() {
271        let timing = FrameTiming::new();
272        assert_eq!(timing, FrameTiming::default());
273        assert_eq!(
274            timing.json().to_string(),
275            "{\"count\":0,\"min_ns\":0,\"max_ns\":0,\"sum_ns\":0,\"drawn\":0,\"skipped\":0}"
276        );
277    }
278
279    #[test]
280    fn recording_frames_tracks_the_extremes_the_total_and_the_presented_split() {
281        let mut timing = FrameTiming::new();
282        timing.record(Nanos::from_nanos(4_000_000), true);
283        timing.record(Nanos::from_nanos(1_000_000), true);
284        timing.record(Nanos::from_nanos(9_000_000), false);
285        assert_eq!(
286            timing.json().to_string(),
287            "{\"count\":3,\"min_ns\":1000000,\"max_ns\":9000000,\"sum_ns\":14000000,\
288             \"drawn\":2,\"skipped\":1}"
289        );
290    }
291
292    #[test]
293    fn the_measured_total_saturates_rather_than_wrapping() {
294        let mut timing = FrameTiming::new();
295        timing.record(Nanos::from_nanos(u64::MAX), true);
296        timing.record(Nanos::from_nanos(u64::MAX), true);
297        let json = timing.json().to_string();
298        assert!(json.contains(&format!("\"sum_ns\":{}", u64::MAX)), "{json}");
299        assert!(json.contains(&format!("\"min_ns\":{}", u64::MAX)), "{json}");
300    }
301}