| 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 |