//! Criterion benchmarks for scrollback search. //! //! - `scan` measures the raw regex scan over a large corpus — the work the //! daemon runs off the input thread, sparing the UI a per-keystroke scan. //! - `query_steady` / `query_cold` measure the UI-thread cost of `update_query`: //! a steady keystroke only compiles the matcher and enqueues the query (the //! scan is off-thread), while the cold path also rebuilds and ships the corpus //! on a content change. use std::hint::black_box; use std::time::Duration; use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use kigi_tui::scrollback::{ RenderBlock, ScrollbackSearchIndex, ScrollbackSearchState, ScrollbackState, }; use kigi_tui::search::{QueryKind, TextMatcher}; /// Roughly the entry count of a long working session. const CORPUS_ENTRIES: usize = 30_000; /// Each measured iteration scans (or rebuilds) the whole corpus, so cap the /// sample count — criterion's default 100 would run for minutes. const SAMPLE_SIZE: usize = 10; /// One paragraph of body text per entry, so the whole corpus is on the order of /// a long session's searchable text (tens of MB; the exact size is logged). fn entry_body(i: usize) -> String { let lorem = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \ eiusmod tempor incididunt ut labore et dolore magna aliqua ut \ enim ad minim veniam quis nostrud exercitation ullamco laboris "; format!( "Entry {i}: the quick brown fox jumps over the lazy dog. {lorem}{lorem}{lorem} \ function foo_{i} calls bar_{i} and returns baz_{i} after work." ) } /// Build a large scrollback approximating a long session. fn build_large_scrollback(entries: usize) -> ScrollbackState { let mut state = ScrollbackState::new(); let mut total_bytes = 0usize; for i in 0..entries { let body = entry_body(i); total_bytes += body.len(); state.push_block(RenderBlock::user_prompt(body)); } eprintln!( "scrollback search corpus: {entries} entries, ~{:.1} MB searchable text", total_bytes as f64 / (1024.0 * 1024.0) ); state } /// The regex scan itself — the corpus work the daemon runs off the input /// thread. `fox` appears in every entry, the worst case for match collection. fn bench_scan(c: &mut Criterion) { let state = build_large_scrollback(CORPUS_ENTRIES); let mut index = ScrollbackSearchIndex::new(); index.sync(&state); let matcher = TextMatcher::new("fox", QueryKind::Regex); let mut g = c.benchmark_group("search"); g.sample_size(SAMPLE_SIZE) .warm_up_time(Duration::from_secs(1)); g.bench_function("scan", |b| { b.iter(|| black_box(index.find(black_box(&matcher)))); }); g.finish(); } /// Steady keystroke: the corpus is already shipped, so `update_query` just /// compiles the matcher and enqueues the query, and `poll` picks up the async /// result — no scan on the UI thread. fn bench_query_steady(c: &mut Criterion) { let state = build_large_scrollback(CORPUS_ENTRIES); let mut search = ScrollbackSearchState::open(); // Ship the corpus once so the measured calls only enqueue (content unchanged). search.update_query("warmup", &state); let queries = ["fox", "baz", "lorem", "function"]; let mut g = c.benchmark_group("search"); g.sample_size(SAMPLE_SIZE) .warm_up_time(Duration::from_secs(1)); g.bench_function("query_steady", |b| { let mut n = 0usize; b.iter(|| { let q = queries[n % queries.len()]; n += 1; search.update_query(q, &state); search.poll(); }); }); g.finish(); } /// Cold path: a fresh session, so `update_query` also rebuilds and ships the /// corpus (the per-entry searchable-text cache) before enqueueing the query. fn bench_query_cold(c: &mut Criterion) { let state = build_large_scrollback(CORPUS_ENTRIES); let mut g = c.benchmark_group("search"); g.sample_size(SAMPLE_SIZE) .warm_up_time(Duration::from_secs(1)); g.bench_function("query_cold", |b| { b.iter_batched( ScrollbackSearchState::open, |mut search| { search.update_query("fox", &state); }, BatchSize::SmallInput, ); }); g.finish(); } criterion_group!(benches, bench_scan, bench_query_steady, bench_query_cold); criterion_main!(benches);