//! M6 perf gates (T M6.6, T M6.7). //! //! # Contract //! //! Per `pmacs-tasks.tex` T M6.6 / T M6.7 and `pmacs-spec.tex` //! §sec:repl-perf, M6.6 imposes three perf gates against a //! fast-spewing producer ingested through the REPL package: //! //! 1. **Sustained ingest rate ≥ 100 MB/s** over a 10 s window after //! warmup. Computed as bytes appended to the rope per second. //! 2. **Buffer memory ≤ 200 MB RSS delta** for the duration of the //! run, sampled at 100 Hz; a single excursion above is a fail. //! 3. **Cancel response latency p99 ≤ 100 ms** over 100 trials, //! each cancelling a fresh producer at a random point in the //! first 5 seconds. //! //! M6.7 adds two more gates against a populated 10000-line scrollback //! buffer: //! //! 4. **Navigation latency p99 ≤ 16 ms** for cursor motion across the //! scrollback, 1000 trials cycling through `move_down`, `move_up`, //! `move_page_down`, `move_page_up`. //! 5. **Search latency p99 ≤ 100 ms** for buffer scans across the //! scrollback, 1000 trials with needles distributed across early, //! mid, and late position bands. //! //! # Methodology //! //! Locked here so future "is this regression real or measurement //! noise?" debates have a single source of truth. //! //! - **What the producer is.** The spec names `find /` as the //! workload exemplar. The gate's metric is *pmacs's rope-append //! rate*, not the producer's read rate. `find /` is impractical //! in CI: non-deterministic across runners, requires elevated //! permissions for some paths, and easily produces over 200 MB on //! developer machines (which would trip the memory ceiling for //! reasons unrelated to pmacs's behavior). We use synthetic //! producers (`yes ""` for the infinite cases) that exhibit //! the same high-rate spew profile and are reproducible across //! environments. //! //! - **Tick cadence during measurement.** Tight loop; no artificial //! 60 Hz cap. The gate measures *upper-bound capacity*: what //! pmacs is capable of when given the CPU. A future "frame-rate- //! capped throughput" gate (M6.7+) could measure user-perceived //! throughput at 60 Hz. //! //! - **What "bytes appended to the rope" means.** Every byte that //! reaches `Handle:append_output` and lands in history (i.e., //! passes the ANSI parser as a `Text` event and gets inserted via //! `_emit_history`). Bytes consumed by escape sequences (SGR, //! alt-screen markers, OSC titles) do not count toward ingest //! because they don't grow the rope; this matches the spec's //! "appended to the rope" wording exactly. //! //! - **Warmup vs measurement window.** 1 s warmup absorbs first-tick //! costs (`LuaJIT` trace compilation, supervisor reader-thread //! startup, kernel pipe buffer fills). The 10 s measurement //! window is then the spec-specified gate. //! CI may override `PMACS_M6_INGEST_MIN_BYTES_PER_SEC` for the //! hosted-runner profile; omitting it keeps the full 100 MB/s gate. //! //! - **RSS sampling source.** Linux-only via `/proc/self/status`'s //! `VmRSS:` field (kilobytes; multiply by 1024 for bytes). We //! prefer `status` over `statm` because the latter reports pages //! and the page-size lookup needs `sysconf(_SC_PAGESIZE)`, which //! requires an `unsafe` block — incompatible with the project's //! `forbid(unsafe_code)` posture. macOS would need a different //! API; the M5 perf-gates workflow already pins CI to //! ubuntu-latest, and we follow that precedent. //! //! - **Sampler thread cadence.** 100 Hz (10 ms inter-sample sleep). //! `std::thread::sleep` is best-effort; expect ~95–100 samples per //! second under typical CI load. The gate is "no excursion above //! 200 MB", which holds regardless of sampling jitter. The CI //! fixture retains ~100 MiB of synthetic output so allocator and //! hosted-runner variance have room below that hard ceiling. //! //! - **Cancel-latency definition.** From `pmacs.process.signal(id, //! "INT")` returning to the moment `_on_exit` has run, observed //! via the handle's `_exited` boolean. That flag is set as the //! first thing in `_on_exit`, immediately before the exit marker //! is written; sampling the flag is O(1) regardless of history //! size, whereas grepping the rope for the marker text is O(N) //! and would conflate cancel-response time with rope-scan cost //! on trials with multi-hundred-MB history (the worst case under //! `yes` at the M6.6 ingest rate). Both signals fire in the same //! tick, so the choice is purely a measurement-cost decision. //! //! - **Why we cancel `yes` directly, not a shell.** PTY-mode //! `pmacs.process.signal(_proc_id, "INT")` targets the foreground //! process group. For a shell, that group can include the shell's //! current foreground job rather than only the shell process. For //! the M6.6 gate, the cleanest measurement is a single-process //! target (`yes`), so the foreground group contains the producer //! being measured and no shell/job-control policy enters the timing. //! //! - **Percentile computation.** Sort the latency samples; p99 is //! `samples[(len * 99) / 100]`, matching M5.9c's exact-integer //! formula. For len=100 this is index 99 (the 100th smallest of //! 100 — i.e., the max). Conservative: a single bad trial fails //! the gate at len=100. M6.7 gates use len=1000 to stabilize p99 //! (index 990 = 11th-from-max), since 100-sample p99 has too much //! variance for a 16 ms threshold to be a regression signal rather //! than measurement noise. //! //! - **Random-delay generator.** Hand-rolled xorshift seeded by //! trial index. No `rand` dev-dep; reproducible across runs; //! varied enough that we don't always hit the same supervisor //! tick boundary. Methodology: random in `[10 ms, 5000 ms]` per //! spec ("first 5 seconds"). CI may override the trial count and //! delay ceiling with `PMACS_M6_CANCEL_TRIALS` and //! `PMACS_M6_CANCEL_MAX_DELAY_MS` so the hosted perf job fits //! under the runner's effective wall-clock ceiling; omitting those //! env vars runs the full spec profile. //! //! - **M6.7 buffer-direct populate.** The 10000-line scrollback is //! built via the public buffer API (`pmacs.buffer.create` + //! `Buffer:insert`), not through a real REPL with a producer //! process. The gate measures cursor-motion and search latency, //! which are buffer-level operations; injecting REPL machinery //! (PTY scheduling, supervisor ticks, parser overhead) measures //! the wrong thing. The user-felt experience is "cursor moved //! within one frame," and the buffer-direct path is what dispatch //! ultimately runs. //! //! - **M6.7 navigation cycle.** Each trial calls one of //! `move_down`, `move_up`, `move_page_down`, `move_page_up` (round- //! robin by trial index). All four are O(log N) over the rope, so //! the gate is dominated by view-update / cursor-position math, //! not by traversal. We cycle rather than fix on one operation so //! p99 reflects the realistic mixed workload an interactive user //! produces. //! //! - **M6.7 search position bands.** Needles are placed at trial- //! varying line numbers spanning early (lines 1..3333), mid //! (3334..6666), and late (6667..10000) bands. Without this //! variation the gate would measure only the early-termination //! case (fast) or only the full-scan case (slow), neither of //! which reflects realistic isearch usage. Each needle is unique //! (`PMACS_M67_PAD_`) so trial N's search doesn't false-hit //! on trial N-1's marker. //! //! # Why `#[ignore]` //! //! Perf measurement under debug-mode `cargo test` is meaningless. //! CI runs these tests release-mode via the `m6-perf-gates` workflow //! job (`cargo test --release --test m6_perf_acceptance -- --ignored //! --nocapture`). Local dev runs (`cargo test`) skip them. #![cfg(target_os = "linux")] use pmacs::editor::EditorState; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; // --------------------------------------------------------------------------- // Shared harness // --------------------------------------------------------------------------- /// Spawn the given argv as a REPL via the M6.5 surface. Returns the /// proc-id raw integer for caller-side bookkeeping; the handle and /// after-tick wiring live entirely Lua-side. fn spawn_repl(editor: &mut EditorState, argv: &[&str]) -> i64 { use std::fmt::Write as _; let mut argv_lua = String::new(); for (i, a) in argv.iter().enumerate() { if i > 0 { argv_lua.push_str(", "); } write!(&mut argv_lua, "\"{a}\"").unwrap(); } let chunk = format!( r" _G.h = pmacs.repl.spawn {{ argv = {{ {argv_lua} }} }} return _G.h._proc_id:raw() ", ); editor .lua_host .lua() .load(&chunk) .eval() .expect("spawn_repl chunk runs and returns raw id") } /// Read history-region byte length via the handle's `:history_end()` /// query. Matches the spec wording exactly: "bytes appended to the /// rope per second." fn history_bytes(editor: &mut EditorState) -> i64 { editor .lua_host .lua() .load("return _G.h:history_end()") .eval() .expect("history_end query") } /// Pump the supervisor + after-tick hook until `predicate` returns /// true or the deadline elapses. Returns true on predicate-met, /// false on timeout. fn pump_until( editor: &mut EditorState, predicate: impl Fn(&mut EditorState) -> bool, timeout: Duration, ) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { editor.tick_processes(); if predicate(editor) { return true; } std::thread::sleep(Duration::from_millis(2)); } false } /// Wait for the spawned process to reach the `running` state. Without /// this, the test's first ticks measure pre-exec state and skew the /// warmup. 5 s budget is generous; the supervisor typically reaches /// running in under 100 ms. fn wait_until_running(editor: &mut EditorState) { let ok = pump_until( editor, |e| { let kind: String = e .lua_host .lua() .load( r#" local s = pmacs.process.status(_G.h._proc_id) return (s and s.kind) or "nil" "#, ) .eval() .expect("status query"); kind == "running" }, Duration::from_secs(5), ); assert!(ok, "spawned producer never reached running state"); } fn env_usize(name: &str, default: usize) -> usize { std::env::var(name) .ok() .and_then(|raw| raw.parse::().ok()) .filter(|value| *value > 0) .unwrap_or(default) } fn env_u64(name: &str, default: u64) -> u64 { std::env::var(name) .ok() .and_then(|raw| raw.parse::().ok()) .unwrap_or(default) } // --------------------------------------------------------------------------- // T M6.6 acceptance bullet 1: sustained ingest rate // --------------------------------------------------------------------------- /// 100 MB/s sustained ingest gate. Producer is `yes` repeating a /// 100-character line, which produces ~200 MB/s on modern hardware /// — fast enough that pmacs is the bottleneck, which is what the /// gate measures. #[test] #[ignore = "perf gate; requires release build"] fn m6_6_sustained_ingest_rate_meets_100mbps_gate() { const WARMUP: Duration = Duration::from_secs(1); const WINDOW: Duration = Duration::from_secs(10); const DEFAULT_RATE_THRESHOLD_BYTES_PER_SEC: u64 = 100 * 1024 * 1024; let rate_threshold_bytes_per_sec = env_u64( "PMACS_M6_INGEST_MIN_BYTES_PER_SEC", DEFAULT_RATE_THRESHOLD_BYTES_PER_SEC, ) .max(1); let mut editor = EditorState::new(); // 100 chars + newline per line. The exact value is unimportant; // what matters is that `yes` blasts at a higher rate than pmacs's // ingest path, so pmacs is the bottleneck under measurement. let line = "a".repeat(100); let _proc_raw = spawn_repl(&mut editor, &["yes", &line]); wait_until_running(&mut editor); // Warmup: drain ticks for 1s without measuring. let warmup_deadline = Instant::now() + WARMUP; while Instant::now() < warmup_deadline { editor.tick_processes(); } let bytes_at_window_start = history_bytes(&mut editor); let window_start = Instant::now(); let window_deadline = window_start + WINDOW; while Instant::now() < window_deadline { editor.tick_processes(); } let elapsed = window_start.elapsed(); let bytes_at_window_end = history_bytes(&mut editor); // Tear down the producer so it doesn't keep spewing into the // supervisor's bound channel during teardown / next test. let _ = editor .lua_host .lua() .load( r#" pcall(pmacs.process.signal, _G.h._proc_id, "INT") _G.h:close() "#, ) .exec(); let ingested = (bytes_at_window_end - bytes_at_window_start) as u64; let rate = (ingested as f64 / elapsed.as_secs_f64()) as u64; println!("M6.6 sustained ingest rate gate:"); println!(" window: {elapsed:?}"); println!( " bytes ingested: {} ({:.1} MiB)", ingested, ingested as f64 / (1024.0 * 1024.0) ); println!( " rate: {} B/s ({:.1} MiB/s)", rate, rate as f64 / (1024.0 * 1024.0) ); println!( " threshold: {} B/s ({:.1} MiB/s)", rate_threshold_bytes_per_sec, rate_threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) ); assert!( rate >= rate_threshold_bytes_per_sec, "ingest rate {rate} B/s below {rate_threshold_bytes_per_sec} B/s gate" ); } // --------------------------------------------------------------------------- // T M6.6 acceptance bullet 2: buffer memory ceiling // --------------------------------------------------------------------------- /// Read `VmRSS` in bytes from `/proc/self/status`. The kernel reports /// `VmRSS:` in kilobytes; we multiply by 1024. Using `/proc/self/status` /// (rather than `/proc/self/statm`'s page-count form) avoids a /// `sysconf(_SC_PAGESIZE)` call, which would require an `unsafe` /// block — incompatible with the project's `forbid(unsafe_code)` /// posture. fn read_rss_bytes() -> u64 { let s = std::fs::read_to_string("/proc/self/status").expect("read /proc/self/status"); for line in s.lines() { if let Some(rest) = line.strip_prefix("VmRSS:") { // "VmRSS: 12345 kB" → split_whitespace handles leading // whitespace; first token is the kB value. let kb: u64 = rest .split_whitespace() .next() .expect("VmRSS value") .parse() .expect("parse VmRSS kB"); return kb * 1024; } } panic!("VmRSS not found in /proc/self/status"); } /// 200 MB RSS-delta ceiling for a bounded ~100 MiB run. We bound by /// byte count rather than by producer-exit so the producer can be /// `yes` (no shell-quoting concerns). When history reaches the /// target byte count the test sends SIGINT to terminate the producer /// cleanly. The sampler thread polls at 100 Hz; the test fails on /// any single excursion above the ceiling. #[test] #[ignore = "perf gate; requires release build"] fn m6_6_buffer_memory_stays_under_200mb_during_run() { const TARGET_HISTORY_BYTES: i64 = 100 * 1024 * 1024; const RSS_CEILING_BYTES: u64 = 200 * 1024 * 1024; const SAMPLE_INTERVAL: Duration = Duration::from_millis(10); let baseline_rss = read_rss_bytes(); println!( "M6.6 memory ceiling gate: baseline RSS {} B ({:.1} MiB)", baseline_rss, baseline_rss as f64 / (1024.0 * 1024.0) ); let mut editor = EditorState::new(); // 100-char line; the line content is unimportant for the ceiling // gate. Total target = 150 MB of bytes-into-history. let line = "a".repeat(100); let _proc_raw = spawn_repl(&mut editor, &["yes", &line]); wait_until_running(&mut editor); // Sampler thread. Records the max RSS observed during the run. let stop_flag = Arc::new(AtomicBool::new(false)); let max_rss = Arc::new(AtomicU64::new(baseline_rss)); { let stop_flag = stop_flag.clone(); let max_rss = max_rss.clone(); std::thread::spawn(move || { while !stop_flag.load(Ordering::Relaxed) { let rss = read_rss_bytes(); let prev = max_rss.load(Ordering::Relaxed); if rss > prev { max_rss.store(rss, Ordering::Relaxed); } std::thread::sleep(SAMPLE_INTERVAL); } }); } // Pump until history reaches the target size. With the gate of // 100 MB/s, 150 MB takes ~1.5 s on a fast machine and well under // 60 s budget on any sane runner. let reached_target = pump_until( &mut editor, |e| history_bytes(e) >= TARGET_HISTORY_BYTES, Duration::from_mins(1), ); assert!( reached_target, "history did not reach {TARGET_HISTORY_BYTES} bytes within 60 s budget" ); // Cancel the producer and drain the exit so the sampler still // catches any post-cancel allocator churn before we stop sampling. let _ = editor .lua_host .lua() .load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#) .exec(); let _ = pump_until( &mut editor, |e| { let exited: bool = e .lua_host .lua() .load("return _G.h._exited == true") .eval() .expect("exited query"); exited }, Duration::from_secs(2), ); stop_flag.store(true, Ordering::Relaxed); let final_rss = max_rss.load(Ordering::Relaxed); let delta = final_rss.saturating_sub(baseline_rss); let history_len = history_bytes(&mut editor); println!("M6.6 memory ceiling gate:"); println!( " history bytes: {history_len} ({:.1} MiB)", history_len as f64 / (1024.0 * 1024.0) ); println!( " max RSS: {} B ({:.1} MiB)", final_rss, final_rss as f64 / (1024.0 * 1024.0) ); println!( " RSS delta: {} B ({:.1} MiB)", delta, delta as f64 / (1024.0 * 1024.0) ); println!( " ceiling: {} B ({:.1} MiB)", RSS_CEILING_BYTES, RSS_CEILING_BYTES as f64 / (1024.0 * 1024.0) ); let _ = editor.lua_host.lua().load("_G.h:close()").exec(); assert!( delta < RSS_CEILING_BYTES, "RSS delta {delta} exceeded {RSS_CEILING_BYTES} ceiling" ); } // --------------------------------------------------------------------------- // T M6.6 acceptance bullet 3: cancel response latency // --------------------------------------------------------------------------- /// Hand-rolled xorshift64 — no `rand` dev-dep needed. Seeded by the /// trial index for reproducibility. Sufficient variance to spread /// trials across supervisor tick boundaries. fn xorshift64(state: &mut u64) -> u64 { let mut x = *state; x ^= x << 13; x ^= x >> 7; x ^= x << 17; *state = x; x } /// 100-trial cancel-latency gate. Each trial: spawn `yes`, wait a /// random delay in [10 ms, 5000 ms] per spec, send SIGINT, measure /// the time until the exit marker appears in history. p99 of those /// 100 latencies must be ≤ 100 ms. #[test] #[ignore = "perf gate; requires release build"] fn m6_6_cancel_response_p99_under_100ms() { const DEFAULT_TRIALS: usize = 100; const P99_THRESHOLD: Duration = Duration::from_millis(100); const MIN_DELAY_MS: u64 = 10; const DEFAULT_MAX_DELAY_MS: u64 = 5000; const PER_CANCEL_TIMEOUT: Duration = Duration::from_secs(2); let trials = env_usize("PMACS_M6_CANCEL_TRIALS", DEFAULT_TRIALS); let max_delay_ms = env_u64("PMACS_M6_CANCEL_MAX_DELAY_MS", DEFAULT_MAX_DELAY_MS).max(MIN_DELAY_MS); let mut latencies: Vec = Vec::with_capacity(trials); let mut prng_state: u64 = 0xa5a5_5a5a_dead_beef; for trial in 0..trials { // Vary the seed per trial so consecutive trials don't sample // the same delay; xor with trial index keeps it deterministic. prng_state ^= trial as u64; let r = xorshift64(&mut prng_state); let delay_ms = MIN_DELAY_MS + (r % (max_delay_ms - MIN_DELAY_MS + 1)); let delay = Duration::from_millis(delay_ms); let mut editor = EditorState::new(); let _ = spawn_repl(&mut editor, &["yes"]); wait_until_running(&mut editor); // Pump the supervisor for the random delay to let `yes` spew // into history. Captures the realistic case of cancelling // mid-flow rather than the trivial just-spawned case. let delay_deadline = Instant::now() + delay; while Instant::now() < delay_deadline { editor.tick_processes(); std::thread::sleep(Duration::from_millis(2)); } // Send SIGINT and start the latency clock. let t0 = Instant::now(); let _ = editor .lua_host .lua() .load(r#"pmacs.process.signal(_G.h._proc_id, "INT")"#) .exec(); // Pump until `_on_exit` has run (the cheap signal). `_exited` // is set as the first thing in `_on_exit`, immediately before // the marker is written. Querying a boolean flag is O(1) // regardless of history size; querying the marker via // `buf:slice(0, history_end):find(...)` is O(N) and dominates // the latency for trials with multi-MB history (the worst case // — 5s of `yes` at >100 MB/s — produces 500+ MB of history, // and string scan over that swamps the cancel-response measurement // we are actually trying to gate). let saw_exit = pump_until( &mut editor, |e| { e.lua_host .lua() .load("return _G.h._exited == true") .eval::() .expect("exited query") }, PER_CANCEL_TIMEOUT, ); let elapsed = t0.elapsed(); assert!( saw_exit, "trial {trial}: cancel did not transition to exited within {PER_CANCEL_TIMEOUT:?}" ); latencies.push(elapsed); let _ = editor.lua_host.lua().load("_G.h:close()").exec(); if (trial + 1) % 10 == 0 || trial + 1 == trials { println!(" cancel trials completed: {}/{}", trial + 1, trials); } } let mut sorted = latencies.clone(); sorted.sort(); let percentile = |p: usize| -> Duration { let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1); sorted[idx] }; let p50 = percentile(50); let p90 = percentile(90); let p99 = percentile(99); let max = sorted[sorted.len() - 1]; println!("M6.6 cancel-response latency gate ({trials} trials, max delay {max_delay_ms}ms):"); println!(" p50: {p50:?}"); println!(" p90: {p90:?}"); println!(" p99: {p99:?}"); println!(" max: {max:?}"); println!(" threshold: {P99_THRESHOLD:?}"); assert!( p99 <= P99_THRESHOLD, "cancel p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \ p50={p50:?}, p90={p90:?}, max={max:?}" ); } // --------------------------------------------------------------------------- // T M6.7 acceptance bullet 1: navigation latency // --------------------------------------------------------------------------- /// Populate `_G.scroll_buf` with `lines` lines of synthetic content. /// Each line is "line-N: \n" with N spelled out so search /// trials can target a known string at a known line. The buffer is /// then made the active buffer so motion / search dispatch lands on /// it. The trailing `move_to_start` rewinds the cursor to position 0 /// for predictable starting state. fn populate_scrollback(editor: &mut EditorState, lines: usize) { let chunk = format!( r#" local buf = pmacs.buffer.create("*scrollback*") -- Build the content in Lua-side first to avoid {lines} -- separate insert-edit notifications. One big insert is -- {lines}x faster and the gate is about post-populate -- cursor behavior, not populate cost. local parts = {{}} for i = 1, {lines} do parts[i] = string.format( "line-%d: PMACS_M67_PAD_%d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n", i, i) end local body = table.concat(parts) buf:insert(0, body) pmacs.window.switch_buffer(buf) _G.scroll_buf = buf _G.scroll_buf_len = buf:len() "#, ); editor .lua_host .eval(Some("@populate"), &chunk) .expect("populate"); } /// Move the cursor halfway through the buffer. Without this, every /// trial starts at position 0 and `move_up` / `move_page_up` no-op, /// skewing the latency distribution low. With the cursor at midpoint, /// the four motion operations all have somewhere to go. fn seek_cursor_to_middle(editor: &mut EditorState, lines: usize) { let down_count = lines / 2; let chunk = format!( r" for _ = 1, {down_count} do pmacs.editor.move_down() end ", ); editor.lua_host.eval(Some("@seek"), &chunk).expect("seek"); } /// 1000-trial navigation-latency gate. Each trial dispatches one /// motion command via `pmacs.command.invoke` (the keymap-equivalent /// path) and times it. The four commands rotate by trial index so /// p99 reflects mixed motion rather than one extreme. p99 ≤ 16 ms /// is the spec gate (single-frame budget at 60 Hz). #[test] #[ignore = "perf gate; requires release build"] fn m6_7_scrollback_navigation_p99_under_16ms() { const LINES: usize = 10_000; const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(16); let mut editor = EditorState::new(); populate_scrollback(&mut editor, LINES); seek_cursor_to_middle(&mut editor, LINES); // Warmup: 100 dispatches that don't get measured. Absorbs LuaJIT // trace compilation and any first-call costs in command dispatch. { let chunk = r#" for i = 1, 100 do local op = ({"cursor.down", "cursor.up", "cursor.page-down", "cursor.page-up"})[(i % 4) + 1] pmacs.command.invoke(op) end "#; editor .lua_host .eval(Some("@warmup"), chunk) .expect("warmup"); } let mut latencies: Vec = Vec::with_capacity(TRIALS); let ops = [ "cursor.down", "cursor.up", "cursor.page-down", "cursor.page-up", ]; for trial in 0..TRIALS { let op = ops[trial % 4]; let chunk = format!(r#"pmacs.command.invoke("{op}")"#); let t0 = Instant::now(); editor .lua_host .eval(Some("@nav-trial"), &chunk) .expect("nav"); latencies.push(t0.elapsed()); } let mut sorted = latencies.clone(); sorted.sort(); let percentile = |p: usize| -> Duration { let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1); sorted[idx] }; let p50 = percentile(50); let p90 = percentile(90); let p99 = percentile(99); let max = sorted[sorted.len() - 1]; println!("M6.7 navigation latency gate ({TRIALS} trials, {LINES} lines):"); println!(" p50: {p50:?}"); println!(" p90: {p90:?}"); println!(" p99: {p99:?}"); println!(" max: {max:?}"); println!(" threshold: {P99_THRESHOLD:?}"); assert!( p99 <= P99_THRESHOLD, "navigation p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \ p50={p50:?}, p90={p90:?}, max={max:?}" ); } // --------------------------------------------------------------------------- // T M6.7 acceptance bullet 2: search latency // --------------------------------------------------------------------------- /// 1000-trial search-latency gate. Each trial searches the populated /// buffer for a unique needle whose target line varies across early/ /// mid/late position bands. Search uses `Buffer:slice` + Lua /// `string.find`; this is the path isearch will dispatch to when /// implemented. p99 ≤ 100 ms. #[test] #[ignore = "perf gate; requires release build"] fn m6_7_scrollback_search_p99_under_100ms() { const LINES: usize = 10_000; const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(100); let mut editor = EditorState::new(); populate_scrollback(&mut editor, LINES); // Warmup: 100 searches at varied positions. Same trace-compilation // / hot-path-warmup rationale as the navigation gate. { let chunk = r#" for i = 1, 100 do local target = ((i * 31) % 10000) + 1 local needle = "PMACS_M67_PAD_" .. target .. "_" local _ = _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true) end "#; editor .lua_host .eval(Some("@warmup"), chunk) .expect("warmup"); } let mut latencies: Vec = Vec::with_capacity(TRIALS); for trial in 0..TRIALS { // Spread targets across early/mid/late bands. (trial * 31) mod // LINES gives a stride pattern that hits every line band // without clustering; 31 is coprime to 10000. let target = ((trial * 31) % LINES) + 1; let chunk = format!( r#" local needle = "PMACS_M67_PAD_{target}_" return _G.scroll_buf:slice(0, _G.scroll_buf_len):find(needle, 1, true) "#, ); let t0 = Instant::now(); let pos: Option = editor.lua_host.lua().load(&chunk).eval().expect("search"); latencies.push(t0.elapsed()); // Sanity: every needle is present in the buffer; if find // returns nil we've broken the populate / lookup contract. assert!( pos.is_some(), "trial {trial}: needle for line {target} not found" ); } let mut sorted = latencies.clone(); sorted.sort(); let percentile = |p: usize| -> Duration { let idx = ((sorted.len() * p) / 100).min(sorted.len() - 1); sorted[idx] }; let p50 = percentile(50); let p90 = percentile(90); let p99 = percentile(99); let max = sorted[sorted.len() - 1]; println!("M6.7 search latency gate ({TRIALS} trials, {LINES} lines):"); println!(" p50: {p50:?}"); println!(" p90: {p90:?}"); println!(" p99: {p99:?}"); println!(" max: {max:?}"); println!(" threshold: {P99_THRESHOLD:?}"); assert!( p99 <= P99_THRESHOLD, "search p99 {p99:?} exceeds {P99_THRESHOLD:?} gate; \ p50={p50:?}, p90={p90:?}, max={max:?}" ); }