返回 CodeWhale
perf_gate.rs
根目录 / crates / tui / src / tui / streaming / tests / perf_gate.rs
1 //! Runtime performance gate for the streaming reveal path (#6193, first
2 //! slice).
3 //!
4 //! This module removes the "Nothing measures this" limitation from the
5 //! streaming module doc for the reveal path: fixed synthetic inputs built
6 //! from reviewed constants drive the REAL [`StreamDisplayClock`] and the
7 //! real block buffers, and the verdicts are deterministic integer gates —
8 //! no wall clock anywhere except the single macOS reference-lane test at
9 //! the bottom.
10 //!
11 //! Inputs are never recorded sessions, never ambient repositories, never
12 //! the network: every byte is generated from `'a'..='z'` cycles and the
13 //! production constants in the parent module. Budgets below are reviewed
14 //! source constants — they are deliberately NOT overridable by environment
15 //! variables, because a budget an env var can raise is not a budget.
16 //!
17 //! How each verdict is reached:
18 //!
19 //! - `max_bytes_per_beat` — absolute bound: every beat's committed byte
20 //! count must be `<= reveal_budget(interval, backlog_at_beat_start)`
21 //! computed with the production constants, across a backlog sweep at two
22 //! clock intervals. Pure ASCII filler keeps one grapheme == one byte, so
23 //! the bound is exact with no boundary rounding.
24 //! - `beats_to_first_visible` — absolute count: a delta pushed at t0 must
25 //! become visible on the first due beat (never zero beats = invisible,
26 //! never two = manufactured lag).
27 //! - `beats_to_drain` — exact integer ceiling: beats until a backlog is
28 //! fully revealed must equal `backlog.div_ceil(per_beat_budget)` with
29 //! `per_beat_budget = reveal_budget(interval, usize::MAX)`.
30 //! - cell-kind digest — fixed expected sequence: the (kind, bytes)
31 //! sequence committed per beat for a mixed thinking/text stream must
32 //! match the digest literal below; a beat committing the wrong cell,
33 //! duplicating a cell, or dropping bytes changes the digest.
34 //! - `flush_now` after a long pause — absolute count: the whole backlog
35 //! lands in exactly one forced step, as the module doc promises.
36
37 use std::time::Duration;
38 use std::time::Instant;
39
40 use super::DEFAULT_STREAM_COMMIT_INTERVAL;
41 use super::REVEAL_PER_SECOND;
42 use super::StreamDisplayClock;
43 use super::StreamingState;
44
45 /// Slow-clock variant for the sweep gates: 100 ms beats. At
46 /// [`REVEAL_PER_SECOND`] this admits 240 bytes per beat, so the same
47 /// budgets must hold on a cadence four times slower than production.
48 const SLOW_CLOCK_INTERVAL: Duration = Duration::from_millis(100);
49
50 /// Backlog sweep for the per-beat and drain gates (bytes):
51 ///
52 /// 1. `1` — the smallest possible receipt;
53 /// 2. one beat's budget at the production interval (38 bytes:
54 /// `2_400 B/s * 16 ms`, truncated);
55 /// 3. `4 KiB` / `64 KiB` / `256 KiB` — burst shapes a fast model emits
56 /// when it front-loads a long answer.
57 ///
58 /// Reviewed 2026-09-15; not configurable.
59 const SWEEP_BACKLOG_BYTES: [usize; 4] = [1, 38, 4 * 1024, 64 * 1024];
60
61 /// Largest burst in the sweep, kept as a named constant so the
62 /// flush-after-pause gate and the sweep cannot drift apart.
63 const LARGEST_BURST_BYTES: usize = 256 * 1024;
64
65 /// Beat bookkeeping cycles measured by the macOS reference lane.
66 /// 10_000 note_delta + take_due + budgeted take cycles is ~160 s of
67 /// virtual stream time — a full long-form answer's worth of beats.
68 /// Reviewed 2026-09-15; not configurable.
69 #[cfg(target_os = "macos")]
70 const WALL_CLOCK_CYCLES: usize = 10_000;
71
72 /// Wall-clock budget for [`WALL_CLOCK_CYCLES`] beat cycles on the macOS
73 /// reference lane. Observed 0.9–2.5 ms across repeated runs on Apple
74 /// silicon (2026-09-15, this repository's dev machine); 50 ms is
75 /// deliberately generous so a busy developer machine still passes. Other
76 /// platforms never assert wall clock — see the ignored companion test
77 /// below.
78 #[cfg(target_os = "macos")]
79 const WALL_CLOCK_BUDGET: Duration = Duration::from_millis(50);
80
81 /// Deterministic ASCII filler: one grapheme per byte, so byte budgets and
82 /// grapheme boundaries coincide and the integer gates are exact.
83 fn filler(len: usize) -> String {
84 (0..len)
85 .map(|i| char::from(b'a' + (i % 26) as u8))
86 .collect()
87 }
88
89 /// Drive one paced reveal over `state`'s block `index` on a synthetic
90 /// timeline, mirroring the production call shape in `tui/ui/frame.rs`
91 /// (`reveal_budget(interval, pending_len(index))` per beat). Like the
92 /// production caller, the clock is only kept ticking while the block still
93 /// holds pending text (`has_pending_stream_text`). Returns the bytes
94 /// committed per beat.
95 fn run_paced_beats(
96 clock: &mut StreamDisplayClock,
97 state: &mut StreamingState,
98 index: usize,
99 interval: Duration,
100 max_beats: usize,
101 ) -> Vec<usize> {
102 let t0 = Instant::now();
103 let mut per_beat = Vec::new();
104 for beat in 0..max_beats {
105 if !state.has_pending_stream_text(index) {
106 break;
107 }
108 let now = t0 + interval * (beat as u32);
109 clock.note_delta(now);
110 if clock.take_due(now) {
111 let backlog = state.pending_len(index);
112 let taken = state.commit_text(index, super::reveal_budget(interval, backlog));
113 per_beat.push(taken.len());
114 }
115 }
116 per_beat
117 }
118
119 #[test]
120 fn max_bytes_per_beat_no_beat_reveals_more_than_the_budget() {
121 for interval in [DEFAULT_STREAM_COMMIT_INTERVAL, SLOW_CLOCK_INTERVAL] {
122 for backlog in SWEEP_BACKLOG_BYTES
123 .iter()
124 .copied()
125 .chain([LARGEST_BURST_BYTES])
126 {
127 let mut clock = StreamDisplayClock::new(interval);
128 let mut state = StreamingState::default();
129 state.start_text(0);
130 let burst = filler(backlog);
131 state.push_content(0, &burst);
132
133 let per_beat = run_paced_beats(&mut clock, &mut state, 0, interval, 10_000);
134 assert!(
135 !per_beat.is_empty(),
136 "interval {interval:?}, backlog {backlog}: no beat ever committed"
137 );
138 for (beat, taken) in per_beat.iter().enumerate() {
139 // Recompute the backlog that beat started from; `taken` is
140 // the only mutation, so this stays pure integer arithmetic.
141 let revealed_before: usize = per_beat[..beat].iter().sum();
142 assert!(
143 revealed_before <= backlog,
144 "interval {interval:?}, backlog {backlog}: beats revealed {revealed_before} \
145 bytes before beat {beat}"
146 );
147 let backlog_at_beat_start = backlog - revealed_before;
148 let budget = super::reveal_budget(interval, backlog_at_beat_start);
149 assert!(
150 taken <= &budget,
151 "interval {interval:?}, backlog {backlog}: beat {beat} revealed \
152 {taken} bytes, budget was {budget}"
153 );
154 }
155 let total: usize = per_beat.iter().sum();
156 assert_eq!(total, backlog, "bytes must neither duplicate nor drop");
157 assert_eq!(state.pending_len(0), 0, "drain must complete");
158 }
159 }
160 }
161
162 #[test]
163 fn beats_to_first_visible_is_exactly_one_due_beat() {
164 for interval in [DEFAULT_STREAM_COMMIT_INTERVAL, SLOW_CLOCK_INTERVAL] {
165 let mut clock = StreamDisplayClock::new(interval);
166 let mut state = StreamingState::default();
167 state.start_text(0);
168 let delta = "visible on the first due beat";
169 state.push_content(0, delta);
170 assert!(delta.len() <= super::reveal_budget(interval, usize::MAX));
171
172 let t0 = Instant::now();
173 clock.note_delta(t0);
174
175 // Probe at half-beat granularity so an off-by-one that pushes the
176 // reveal to a second beat is visible, not hidden by the step size.
177 let mut beats_until_visible = 0usize;
178 let mut visible_len = 0usize;
179 for half in 0..4u32 {
180 let now = t0 + interval / 2 * half;
181 if clock.take_due(now) {
182 beats_until_visible += 1;
183 if visible_len == 0 {
184 let taken =
185 state.commit_text(0, super::reveal_budget(interval, state.pending_len(0)));
186 visible_len = taken.len();
187 }
188 }
189 }
190 assert_eq!(
191 beats_until_visible, 1,
192 "interval {interval:?}: a t0 delta must show on the FIRST due beat"
193 );
194 assert_eq!(
195 visible_len,
196 delta.len(),
197 "interval {interval:?}: the first beat must reveal the whole small delta"
198 );
199 assert_eq!(state.pending_len(0), 0);
200 }
201 }
202
203 #[test]
204 fn beats_to_drain_matches_ceiling_of_backlog_over_per_beat_budget() {
205 for interval in [DEFAULT_STREAM_COMMIT_INTERVAL, SLOW_CLOCK_INTERVAL] {
206 let per_beat = super::reveal_budget(interval, usize::MAX);
207 assert!(per_beat >= 1, "a beat must always admit progress");
208 // Pin the integer arithmetic the ceiling below is derived from.
209 let expected_per_beat = (REVEAL_PER_SECOND * interval.as_millis() as usize) / 1000;
210 assert_eq!(per_beat, expected_per_beat.max(1));
211
212 for backlog in SWEEP_BACKLOG_BYTES
213 .iter()
214 .copied()
215 .chain([LARGEST_BURST_BYTES])
216 {
217 let expected_beats = backlog.div_ceil(per_beat);
218 let mut clock = StreamDisplayClock::new(interval);
219 let mut state = StreamingState::default();
220 state.start_text(0);
221 state.push_content(0, &filler(backlog));
222
223 let per_beat_sizes = run_paced_beats(&mut clock, &mut state, 0, interval, 10_000);
224 assert_eq!(
225 per_beat_sizes.len(),
226 expected_beats,
227 "interval {interval:?}, backlog {backlog}: expected \
228 ceil({backlog}/{per_beat}) = {expected_beats} beats, got {}",
229 per_beat_sizes.len()
230 );
231 assert_eq!(state.pending_len(0), 0);
232 }
233 }
234 }
235
236 #[test]
237 fn cell_kind_digest_matches_the_fixed_expected_sequence() {
238 // Mixed thinking/text stream: block 0 is thinking, block 1 is text.
239 // Distinct filler letters so a byte committed to the wrong cell, a
240 // duplicated commit, or a dropped byte all change the reconstruction.
241 let interval = DEFAULT_STREAM_COMMIT_INTERVAL;
242 let per_beat = super::reveal_budget(interval, usize::MAX);
243 let thinking_len = 2 * per_beat + 24; // ragged tail differs from text's
244 let text_len = 2 * per_beat + 14;
245 let thinking_src: String = "K".repeat(thinking_len);
246 let text_src: String = "A".repeat(text_len);
247
248 let mut clock = StreamDisplayClock::new(interval);
249 let mut state = StreamingState::default();
250 state.start_thinking(0);
251 state.push_content(0, &thinking_src);
252 state.start_text(1);
253 state.push_content(1, &text_src);
254
255 let t0 = Instant::now();
256 let mut digest_beats: Vec<String> = Vec::new();
257 let mut thinking_out = String::new();
258 let mut text_out = String::new();
259 for beat in 0..3u32 {
260 let now = t0 + interval * beat;
261 clock.note_delta(now);
262 assert!(clock.take_due(now), "beat {beat} must be due");
263 let mut tokens: Vec<String> = Vec::new();
264 for (index, kind, out) in [(0usize, 'T', &mut thinking_out), (1, 'A', &mut text_out)] {
265 let backlog = state.pending_len(index);
266 let taken = state.commit_text(index, super::reveal_budget(interval, backlog));
267 if !taken.is_empty() {
268 tokens.push(format!("{kind}{}", taken.len()));
269 out.push_str(&taken);
270 }
271 }
272 digest_beats.push(tokens.join(","));
273 }
274
275 // Fixed expected digest for per_beat == 38, thinking 100 bytes, text
276 // 90 bytes. Any wrong-cell commit, duplicate, or omission shows here.
277 assert_eq!(
278 per_beat, 38,
279 "per-beat budget changed; recompute the fixed digest below"
280 );
281 assert_eq!(digest_beats.join("|"), "T38,A38|T38,A38|T24,A14");
282
283 assert_eq!(
284 thinking_out, thinking_src,
285 "thinking bytes must arrive whole"
286 );
287 assert_eq!(text_out, text_src, "text bytes must arrive whole");
288 assert_eq!(state.accumulated_thinking, thinking_src);
289 assert_eq!(state.accumulated_text, text_src);
290 assert_eq!(state.pending_len(0), 0);
291 assert_eq!(state.pending_len(1), 0);
292 }
293
294 #[test]
295 fn flush_now_after_long_pause_lands_the_whole_backlog_in_one_step() {
296 // Pins the module-doc promise: a pause longer than the beat interval
297 // leaves the next beat due immediately, but pacing still spreads a
298 // paced beat; only the forced `flush_now` finalization beat lands a
299 // very large receipt in one step.
300 let interval = DEFAULT_STREAM_COMMIT_INTERVAL;
301 let mut clock = StreamDisplayClock::new(interval);
302 let mut state = StreamingState::default();
303 state.start_text(0);
304 state.push_content(0, &filler(LARGEST_BURST_BYTES));
305
306 let t0 = Instant::now();
307 clock.note_delta(t0);
308 assert!(clock.take_due(t0), "first beat is due immediately");
309 let first = state.commit_text(0, super::reveal_budget(interval, state.pending_len(0)));
310 assert!(first.len() <= super::reveal_budget(interval, LARGEST_BURST_BYTES));
311
312 // Idle past the documented pause threshold (interval), then a large
313 // receipt lands: the next beat is due immediately...
314 let after_pause = t0 + 10 * interval;
315 clock.note_delta(after_pause);
316 assert_eq!(
317 clock.due_in(after_pause),
318 Some(Duration::ZERO),
319 "a pause longer than the interval must leave the next beat due now"
320 );
321 // ...yet that paced beat still takes only one budget's worth.
322 assert!(clock.take_due(after_pause));
323 let paced = state.commit_text(0, super::reveal_budget(interval, state.pending_len(0)));
324 assert!(paced.len() <= super::reveal_budget(interval, usize::MAX));
325
326 // Finalization: one forced step drains everything remaining. The
327 // caller keeps the clock ticking while backlog remains (see
328 // `has_pending_stream_text`), so the finalization beat is noted first.
329 let remaining_before = state.pending_len(0);
330 assert!(remaining_before > super::reveal_budget(interval, usize::MAX));
331 let finalize_at = after_pause + interval / 2;
332 clock.note_delta(finalize_at);
333 assert!(clock.flush_now(finalize_at));
334 let landed = state.finalize_block_text(0);
335 assert_eq!(
336 landed.len(),
337 remaining_before,
338 "the whole backlog must land in ONE step"
339 );
340 assert_eq!(state.pending_len(0), 0);
341 assert!(!state.has_pending_stream_text(0));
342 // No second step exists to double-commit: a follow-up beat finds nothing.
343 assert!(!clock.take_due(after_pause + interval));
344 }
345
346 // The wall-clock lane below is the ONLY place this file reads a real
347 // clock, and only on the macOS reference machine. The deterministic gates
348 // above carry the performance contract everywhere else.
349 #[cfg(target_os = "macos")]
350 #[test]
351 fn macos_reference_lane_beat_bookkeeping_stays_under_budget() {
352 let interval = DEFAULT_STREAM_COMMIT_INTERVAL;
353 let chunk = filler(38); // one beat's budget per cycle: steady state
354 let mut clock = StreamDisplayClock::new(interval);
355 let mut buffer = super::StreamBuffer::new();
356 let t0 = Instant::now(); // synthetic timeline; Instant used as pure arithmetic
357 let start = Instant::now(); // the only real wall-clock read
358 for i in 0..WALL_CLOCK_CYCLES {
359 let now = t0 + interval * (i as u32);
360 clock.note_delta(now);
361 buffer.push_delta(&chunk);
362 if clock.take_due(now) {
363 let budget = super::reveal_budget(interval, buffer.pending_len());
364 let _ = buffer.take_up_to(budget);
365 }
366 }
367 let elapsed = start.elapsed();
368 assert!(
369 elapsed < WALL_CLOCK_BUDGET,
370 "{WALL_CLOCK_CYCLES} beat cycles of clock + buffer bookkeeping took \
371 {elapsed:?}, budget is {WALL_CLOCK_BUDGET:?}"
372 );
373 }
374
375 // Deliberate skip marker for non-macOS platforms: no wall-clock assertion
376 // runs there, by design. CI on linux must not inherit this machine's time
377 // scale (see #6193: "separate expectations by machine").
378 #[cfg(not(target_os = "macos"))]
379 #[test]
380 #[ignore = "wall-clock reference lane is macOS-only; deterministic gates carry the contract here"]
381 fn macos_wall_clock_lane_is_not_asserted_off_macos() {}
382
382 lines RUST