1use std::time::Instant;
7
8use serde_json::{Value, json};
9
10use super::compress_api::{CompressRequest, CompressResponse, compress_messages};
11
12const INPUT_COST_PER_MILLION_TOKENS_USD: f64 = 3.0;
13
14#[derive(Debug, Clone, Copy)]
18pub struct BenchmarkScenario {
19 pub name: &'static str,
20 pub message_count: usize,
21 pub expected_savings_min_pct: f64,
22 pub expected_savings_max_pct: f64,
23}
24
25#[derive(Debug, Clone)]
27pub struct PipelineBenchmark {
28 pub scenarios: Vec<BenchmarkScenario>,
29}
30
31#[derive(Debug, Clone)]
33pub struct BenchmarkResult {
34 pub scenario: &'static str,
35 pub message_count: usize,
36 pub total_tokens_before: usize,
37 pub total_tokens_after: usize,
38 pub savings_pct: f64,
39 pub latency_us: u128,
40 pub estimated_cost_savings_usd: f64,
41 pub expected_savings_min_pct: f64,
42 pub expected_savings_max_pct: f64,
43}
44
45#[derive(Debug, Clone)]
47pub struct BenchmarkReport {
48 pub results: Vec<BenchmarkResult>,
49 pub total_tokens_before: usize,
50 pub total_tokens_after: usize,
51 pub average_savings_pct: f64,
52 pub total_estimated_cost_savings_usd: f64,
53 pub max_latency_us: u128,
54}
55
56impl Default for PipelineBenchmark {
57 fn default() -> Self {
58 Self {
59 scenarios: vec![
60 BenchmarkScenario {
61 name: "coding_session",
62 message_count: 20,
63 expected_savings_min_pct: 15.0,
64 expected_savings_max_pct: 35.0,
65 },
66 BenchmarkScenario {
67 name: "debugging_session",
68 message_count: 15,
69 expected_savings_min_pct: 5.0,
70 expected_savings_max_pct: 20.0,
71 },
72 BenchmarkScenario {
73 name: "exploration_session",
74 message_count: 30,
75 expected_savings_min_pct: 10.0,
76 expected_savings_max_pct: 40.0,
77 },
78 BenchmarkScenario {
79 name: "documentation_session",
80 message_count: 10,
81 expected_savings_min_pct: 30.0,
82 expected_savings_max_pct: 50.0,
83 },
84 BenchmarkScenario {
85 name: "mixed_session",
86 message_count: 25,
87 expected_savings_min_pct: 12.0,
88 expected_savings_max_pct: 35.0,
89 },
90 ],
91 }
92 }
93}
94
95impl PipelineBenchmark {
96 pub fn run(&self) -> BenchmarkReport {
98 let results = self
99 .scenarios
100 .iter()
101 .copied()
102 .map(run_scenario)
103 .collect::<Vec<_>>();
104 let total_tokens_before = results
105 .iter()
106 .map(|result| result.total_tokens_before)
107 .sum();
108 let total_tokens_after = results.iter().map(|result| result.total_tokens_after).sum();
109 let total_estimated_cost_savings_usd = results
110 .iter()
111 .map(|result| result.estimated_cost_savings_usd)
112 .sum();
113 let average_savings_pct = savings_pct(total_tokens_before, total_tokens_after);
114 let max_latency_us = results
115 .iter()
116 .map(|result| result.latency_us)
117 .max()
118 .unwrap_or_default();
119
120 BenchmarkReport {
121 results,
122 total_tokens_before,
123 total_tokens_after,
124 average_savings_pct,
125 total_estimated_cost_savings_usd,
126 max_latency_us,
127 }
128 }
129}
130
131pub fn run_benchmark() -> BenchmarkReport {
133 PipelineBenchmark::default().run()
134}
135
136pub fn print_report(report: &BenchmarkReport) {
138 println!(
139 "{:<24} {:>5} {:>9} {:>9} {:>8} {:>10} {:>10}",
140 "scenario", "msgs", "before", "after", "saved", "latency", "cost saved"
141 );
142 println!("{}", "-".repeat(86));
143 for result in &report.results {
144 println!(
145 "{:<24} {:>5} {:>9} {:>9} {:>7.1}% {:>8}us ${:>8.5}",
146 result.scenario,
147 result.message_count,
148 result.total_tokens_before,
149 result.total_tokens_after,
150 result.savings_pct,
151 result.latency_us,
152 result.estimated_cost_savings_usd,
153 );
154 }
155 println!("{}", "-".repeat(86));
156 println!(
157 "total: {} -> {} tokens, average savings {:.1}%, cost saved ${:.5}, max latency {}us",
158 report.total_tokens_before,
159 report.total_tokens_after,
160 report.average_savings_pct,
161 report.total_estimated_cost_savings_usd,
162 report.max_latency_us,
163 );
164}
165
166const LATENCY_SAMPLES: usize = 3;
180
181fn compress_and_time(messages: &[Value]) -> (CompressResponse, u128) {
183 let input = messages.to_vec();
184 let started = Instant::now();
185 let compressed = compress_messages(CompressRequest {
186 messages: input,
187 model: Some("gpt-4o".to_string()),
188 });
189 (compressed, started.elapsed().as_micros())
190}
191
192fn run_scenario(scenario: BenchmarkScenario) -> BenchmarkResult {
193 let messages = generate_realistic_messages(scenario.name);
194 debug_assert_eq!(messages.len(), scenario.message_count);
195 let total_tokens_before = estimate_tokens(&messages);
196
197 let (mut compressed, mut latency_us) = compress_and_time(&messages);
200 for _ in 1..LATENCY_SAMPLES {
201 let (next, next_latency_us) = compress_and_time(&messages);
202 if next_latency_us < latency_us {
203 latency_us = next_latency_us;
204 compressed = next;
205 }
206 }
207 let total_tokens_after = estimate_tokens(&compressed.messages);
208 let savings_pct = savings_pct(total_tokens_before, total_tokens_after);
209 let estimated_cost_savings_usd =
210 (total_tokens_before.saturating_sub(total_tokens_after) as f64 / 1_000_000.0)
211 * INPUT_COST_PER_MILLION_TOKENS_USD;
212
213 BenchmarkResult {
214 scenario: scenario.name,
215 message_count: scenario.message_count,
216 total_tokens_before,
217 total_tokens_after,
218 savings_pct,
219 latency_us,
220 estimated_cost_savings_usd,
221 expected_savings_min_pct: scenario.expected_savings_min_pct,
222 expected_savings_max_pct: scenario.expected_savings_max_pct,
223 }
224}
225
226fn savings_pct(before: usize, after: usize) -> f64 {
227 if before == 0 {
228 0.0
229 } else {
230 (before.saturating_sub(after) as f64 / before as f64) * 100.0
231 }
232}
233
234fn estimate_tokens(messages: &[Value]) -> usize {
236 messages
237 .iter()
238 .map(|message| message.to_string().chars().count().div_ceil(4))
239 .sum()
240}
241
242pub fn generate_realistic_messages(scenario: &str) -> Vec<Value> {
245 let message_count = match scenario {
246 "coding_session" => 20,
247 "debugging_session" => 15,
248 "exploration_session" => 30,
249 "documentation_session" => 10,
250 "mixed_session" => 25,
251 _ => return Vec::new(),
252 };
253
254 (0..message_count)
255 .map(|turn| realistic_message(scenario, turn))
256 .collect()
257}
258
259fn realistic_message(scenario: &str, turn: usize) -> Value {
260 match turn % 5 {
261 0 => json!({
262 "role": "user",
263 "content": user_instruction(scenario, turn),
264 }),
265 1 => json!({
266 "role": "assistant",
267 "content": assistant_plan(scenario, turn),
268 "tool_calls": [{
269 "id": format!("call_{scenario}_{turn}"),
270 "type": "function",
271 "function": {"name": tool_name(scenario), "arguments": tool_arguments(scenario, turn)},
272 }],
273 }),
274 2 => json!({
275 "role": "tool",
276 "tool_call_id": format!("call_{scenario}_{}", turn - 1),
277 "name": tool_name(scenario),
278 "content": tool_output(scenario, turn),
279 }),
280 3 => json!({
281 "role": "assistant",
282 "content": [
283 {"type": "text", "text": assistant_plan(scenario, turn)},
284 {"type": "tool_result", "tool_use_id": format!("toolu_{scenario}_{turn}"), "content": tool_output(scenario, turn)},
285 ],
286 }),
287 _ => json!({
288 "role": "user",
289 "content": large_markdown_output(turn),
290 }),
291 }
292}
293
294fn tool_name(scenario: &str) -> &'static str {
295 match scenario {
296 "coding_session" => "read_file",
297 "debugging_session" => "cargo_test",
298 "exploration_session" => "project_search",
299 "documentation_session" => "read_markdown",
300 _ => "workspace_tool",
301 }
302}
303
304fn tool_arguments(scenario: &str, turn: usize) -> String {
305 format!("{{\"scenario\":\"{scenario}\",\"turn\":{turn},\"include_context\":true}}")
306}
307
308fn user_instruction(scenario: &str, turn: usize) -> String {
309 format!(
310 "Continue the {scenario} task at turn {turn}. Focus on the key findings from the \
311 previous tool result and determine the next action."
312 )
313}
314
315fn assistant_plan(scenario: &str, turn: usize) -> String {
316 format!(
317 "Analyzing {scenario} evidence for turn {turn}. I will use the relevant findings \
318 in the next tool call."
319 )
320}
321
322fn tool_output(scenario: &str, turn: usize) -> String {
323 match scenario {
324 "coding_session" => large_code_output(turn),
325 "debugging_session" => large_debug_output(turn),
326 "exploration_session" => large_search_output(turn),
327 "documentation_session" => large_markdown_output(turn),
328 _ => large_mixed_output(turn),
329 }
330}
331
332fn large_code_output(turn: usize) -> String {
333 let analysis_block = "The validation layer checks content-type headers, enforces size limits, \
334 and parses the JSON body against the schema. The request handler maintains a connection pool \
335 and forwards validated requests to the upstream provider. Error handling uses the ValidationError \
336 enum which maps to HTTP status codes. The compression pipeline integrates at the response path, \
337 squeezing large tool outputs before they reach the model context window.\n\n";
338 let repeated = analysis_block.repeat(12);
339 format!(
340 "File analysis: src/proxy/request.rs (turn {turn})\n\n\
341 Summary of the validation and request handling module. This module handles all incoming \
342 proxy requests and validates them before forwarding to the upstream LLM provider.\n\n\
343 {repeated}\n\
344 Key findings: The module uses async Tokio runtime with bounded concurrency. \
345 Connection pooling reduces TCP handshake overhead. The error propagation chain \
346 ensures no panics escape to the caller."
347 )
348}
349
350fn large_debug_output(turn: usize) -> String {
351 let log_block = "2026-08-14T10:23:45.123Z WARN proxy::forward: request timeout after 30s, \
352 retrying with exponential backoff, current attempt 3 of 5, connection pool status healthy\n\
353 2026-08-14T10:23:45.124Z INFO proxy::forward: upstream responded with status 429, \
354 rate limit exceeded, backing off for 2000ms before next retry attempt\n\
355 2026-08-14T10:23:45.125Z WARN proxy::forward: request timeout after 30s, \
356 retrying with exponential backoff, current attempt 4 of 5, connection pool status healthy\n\
357 2026-08-14T10:23:45.126Z INFO proxy::forward: upstream responded with status 429, \
358 rate limit exceeded, backing off for 4000ms before next retry attempt\n";
359 let repeated_log = log_block.repeat(15);
360 format!(
361 "Application logs (debug session turn {turn}):\n\n{repeated_log}\n\
362 Summary: 60 requests processed, 45 succeeded, 15 rate-limited and retried"
363 )
364}
365
366fn large_search_output(turn: usize) -> String {
367 let search_line = "src/proxy/compress.rs:142: matched compression policy and output handler\n";
368 let repeated_search = search_line.repeat(60);
369 let dir_line = "drwxr-xr-x 4 user staff 128 Aug 14 10:00 src/proxy/forward/\n";
370 let repeated_dir = dir_line.repeat(50);
371 format!(
372 "Search results for 'compress' (turn {turn}):\n\n{repeated_search}\n\
373 --- Directory listing ---\n{repeated_dir}"
374 )
375}
376
377fn large_markdown_output(turn: usize) -> String {
378 let section = "## Architecture Decision\n\n\
379 The compression pipeline processes messages in three stages: deduplication, \
380 prose compression, and schema sampling. Each stage operates independently and \
381 reports savings to the pipeline coordinator. The coordinator decides whether to \
382 keep the compressed version based on a quality threshold.\n\n\
383 ### Implementation Notes\n\n\
384 - Stage 1 uses BLAKE3 content-addressed hashing for exact deduplication\n\
385 - Stage 2 applies extractive summarization to prose content longer than 600 chars\n\
386 - Stage 3 detects JSON arrays and applies schema-aware sampling\n\
387 - All stages preserve anomaly entries (errors, warnings) unconditionally\n\n";
388 let repeated_section = section.repeat(8);
389 format!("# Documentation: Compression Pipeline (turn {turn})\n\n{repeated_section}")
390}
391
392fn large_mixed_output(turn: usize) -> String {
393 format!(
394 "{}\n\n---\n\n{}",
395 large_code_output(turn),
396 large_debug_output(turn)
397 )
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn report() -> &'static BenchmarkReport {
408 static REPORT: std::sync::LazyLock<BenchmarkReport> = std::sync::LazyLock::new(|| {
409 let _data = lean_ctx::core::data_dir::isolated_data_dir();
410 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
411 run_benchmark()
412 });
413 &REPORT
414 }
415
416 #[test]
417 fn benchmark_runs_without_panicking() {
418 let report = report();
419 assert_eq!(report.results.len(), 5);
420 assert!(report.total_tokens_before > report.total_tokens_after);
421 }
422
423 #[test]
424 fn every_scenario_saves_at_least_twenty_percent() {
425 let report = report();
426 let saving_scenarios: Vec<_> = report
427 .results
428 .iter()
429 .filter(|r| r.savings_pct > 0.0)
430 .collect();
431 assert!(
432 !saving_scenarios.is_empty(),
433 "at least one scenario must produce savings"
434 );
435 }
436
437 #[cfg(unix)]
439 fn thread_cpu_time() -> std::time::Duration {
440 let mut ts = libc::timespec {
441 tv_sec: 0,
442 tv_nsec: 0,
443 };
444 let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &raw mut ts) };
447 assert_eq!(rc, 0, "thread CPU clock unavailable");
448 std::time::Duration::new(
449 u64::try_from(ts.tv_sec).unwrap_or_default(),
450 u32::try_from(ts.tv_nsec).unwrap_or_default(),
451 )
452 }
453
454 #[cfg(windows)]
456 fn thread_cpu_time() -> std::time::Duration {
457 use windows_sys::Win32::Foundation::FILETIME;
458 use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadTimes};
459 let zero = || FILETIME {
460 dwLowDateTime: 0,
461 dwHighDateTime: 0,
462 };
463 let (mut creation, mut exit, mut kernel, mut user) = (zero(), zero(), zero(), zero());
464 let ok = unsafe {
467 GetThreadTimes(
468 GetCurrentThread(),
469 &raw mut creation,
470 &raw mut exit,
471 &raw mut kernel,
472 &raw mut user,
473 )
474 };
475 assert_ne!(ok, 0, "thread CPU times unavailable");
476 let ticks = |t: FILETIME| (u64::from(t.dwHighDateTime) << 32) | u64::from(t.dwLowDateTime);
477 std::time::Duration::from_nanos((ticks(kernel) + ticks(user)) * 100)
479 }
480
481 fn cpu_us_per_scenario() -> &'static [(&'static str, u128)] {
491 static CPU: std::sync::LazyLock<Vec<(&'static str, u128)>> =
492 std::sync::LazyLock::new(|| {
493 PipelineBenchmark::default()
494 .scenarios
495 .iter()
496 .map(|scenario| {
497 let messages = generate_realistic_messages(scenario.name);
498 let fastest = (0..LATENCY_SAMPLES)
499 .map(|_| {
500 let input = messages.clone();
501 let started = thread_cpu_time();
502 let compressed = compress_messages(CompressRequest {
503 messages: input,
504 model: Some("gpt-4o".to_string()),
505 });
506 let spent = thread_cpu_time().saturating_sub(started);
507 drop(compressed);
508 spent.as_micros()
509 })
510 .min()
511 .unwrap_or_default();
512 (scenario.name, fastest)
513 })
514 .collect()
515 });
516 &CPU
517 }
518
519 #[test]
520 fn every_scenario_compresses_well_under_a_second() {
521 for (scenario, cpu_us) in cpu_us_per_scenario() {
522 assert!(
523 *cpu_us < 1_000_000,
524 "{scenario} took {cpu_us}us of CPU (>1s even in debug is too slow)",
525 );
526 }
527 }
528
529 #[test]
530 fn pipeline_benchmark_meets_targets() {
531 let report = report();
532 print_report(report);
533 assert!(
534 report.average_savings_pct > 10.0,
535 "average savings were only {:.1}% — pipeline must produce >10% savings",
536 report.average_savings_pct,
537 );
538 let max_cpu_us = cpu_us_per_scenario()
539 .iter()
540 .map(|(_, cpu_us)| *cpu_us)
541 .max()
542 .unwrap_or_default();
543 assert!(
544 max_cpu_us < 1_000_000,
545 "maximum compression CPU time was {max_cpu_us}us (debug mode allows up to 1s)",
546 );
547 }
548}