Skip to main content

lean_ctx/core/
benchmark.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use walkdir::WalkDir;
6
7use crate::core::compressor;
8use crate::core::deps;
9use crate::core::entropy;
10use crate::core::preservation;
11use crate::core::signatures;
12use crate::core::tokens::count_tokens;
13
14const COST_PER_TOKEN: f64 = crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
15const MAX_FILE_SIZE: u64 = 100 * 1024;
16const MAX_FILES: usize = 50;
17fn cache_hit_tokens() -> usize {
18    let stub = "F1=src/example.rs [unchanged, 500L, use cached context]";
19    count_tokens(stub)
20}
21
22// ── Types ───────────────────────────────────────────────────
23
24#[derive(Debug, Clone)]
25pub struct ModeMeasurement {
26    pub mode: String,
27    pub tokens: usize,
28    pub savings_pct: f64,
29    pub latency_us: u64,
30    pub preservation_score: f64,
31}
32
33#[derive(Debug, Clone)]
34pub struct FileMeasurement {
35    #[allow(dead_code)]
36    pub path: String,
37    pub ext: String,
38    pub raw_tokens: usize,
39    pub modes: Vec<ModeMeasurement>,
40}
41
42#[derive(Debug, Clone)]
43pub struct LanguageStats {
44    pub ext: String,
45    pub count: usize,
46    pub total_tokens: usize,
47    pub best_mode: String,
48    pub best_mode_tokens: usize,
49    pub best_savings_pct: f64,
50}
51
52#[derive(Debug, Clone)]
53pub struct ModeSummary {
54    pub mode: String,
55    pub total_compressed_tokens: usize,
56    pub avg_savings_pct: f64,
57    pub avg_latency_us: u64,
58    pub avg_preservation: f64,
59}
60
61#[derive(Debug, Clone)]
62pub struct SessionSimResult {
63    pub raw_tokens: usize,
64    pub lean_tokens: usize,
65    pub lean_ccp_tokens: usize,
66    pub raw_cost: f64,
67    pub lean_cost: f64,
68    pub ccp_cost: f64,
69}
70
71#[derive(Debug, Clone)]
72pub struct ProjectBenchmark {
73    pub root: String,
74    pub files_scanned: usize,
75    pub files_measured: usize,
76    pub total_raw_tokens: usize,
77    pub languages: Vec<LanguageStats>,
78    pub mode_summaries: Vec<ModeSummary>,
79    pub session_sim: SessionSimResult,
80    #[allow(dead_code)]
81    pub file_results: Vec<FileMeasurement>,
82}
83
84// ── Scanner ─────────────────────────────────────────────────
85
86fn is_skipped_dir(name: &str) -> bool {
87    matches!(
88        name,
89        "node_modules"
90            | ".git"
91            | "target"
92            | "dist"
93            | "build"
94            | ".next"
95            | ".nuxt"
96            | "__pycache__"
97            | ".cache"
98            | "coverage"
99            | "vendor"
100            | ".svn"
101            | ".hg"
102    )
103}
104
105fn is_text_ext(ext: &str) -> bool {
106    matches!(
107        ext,
108        "rs" | "ts"
109            | "tsx"
110            | "js"
111            | "jsx"
112            | "py"
113            | "go"
114            | "java"
115            | "c"
116            | "cpp"
117            | "h"
118            | "hpp"
119            | "cs"
120            | "kt"
121            | "swift"
122            | "rb"
123            | "php"
124            | "vue"
125            | "svelte"
126            | "html"
127            | "css"
128            | "scss"
129            | "less"
130            | "json"
131            | "yaml"
132            | "yml"
133            | "toml"
134            | "xml"
135            | "md"
136            | "txt"
137            | "sh"
138            | "bash"
139            | "zsh"
140            | "fish"
141            | "sql"
142            | "graphql"
143            | "proto"
144            | "ex"
145            | "exs"
146            | "zig"
147            | "lua"
148            | "r"
149            | "R"
150            | "dart"
151            | "scala"
152    )
153}
154
155fn scan_project(root: &str) -> Vec<PathBuf> {
156    let mut files: Vec<(PathBuf, u64)> = Vec::new();
157
158    for entry in WalkDir::new(root)
159        .max_depth(8)
160        .into_iter()
161        .filter_entry(|e| {
162            let name = e.file_name().to_string_lossy();
163            if e.file_type().is_dir() {
164                if e.depth() > 0 && name.starts_with('.') {
165                    return false;
166                }
167                return !is_skipped_dir(&name);
168            }
169            true
170        })
171    {
172        let Ok(entry) = entry else { continue };
173
174        if entry.file_type().is_dir() {
175            continue;
176        }
177
178        let path = entry.path().to_path_buf();
179        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
180
181        if !is_text_ext(ext) {
182            continue;
183        }
184
185        let size = entry.metadata().map_or(0, |m| m.len());
186        if size == 0 || size > MAX_FILE_SIZE {
187            continue;
188        }
189
190        files.push((path, size));
191    }
192
193    files.sort_by_key(|x| std::cmp::Reverse(x.1));
194
195    let mut selected = Vec::new();
196    let mut ext_counts: HashMap<String, usize> = HashMap::new();
197
198    for (path, _size) in &files {
199        if selected.len() >= MAX_FILES {
200            break;
201        }
202        let ext = path
203            .extension()
204            .and_then(|e| e.to_str())
205            .unwrap_or("")
206            .to_string();
207        let count = ext_counts.entry(ext.clone()).or_insert(0);
208        if *count < 10 {
209            *count += 1;
210            selected.push(path.clone());
211        }
212    }
213
214    selected
215}
216
217// ── Measurement ─────────────────────────────────────────────
218
219fn measure_mode(content: &str, ext: &str, mode: &str, raw_tokens: usize) -> ModeMeasurement {
220    let start = Instant::now();
221
222    let compressed = match mode {
223        "map" => {
224            let sigs = signatures::extract_signatures(content, ext);
225            let dep_info = deps::extract_deps(content, ext);
226            let mut parts = Vec::new();
227            if !dep_info.imports.is_empty() {
228                parts.push(format!("deps: {}", dep_info.imports.join(", ")));
229            }
230            let key_refs: Vec<&signatures::Signature> = sigs
231                .iter()
232                .filter(|s| s.is_exported || s.indent == 0)
233                .collect();
234            // Drop exports the API already lists so the benchmark measures the
235            // same deduped map the renderers emit (mirrors ctx_read map, #361).
236            let extra_exports = signatures::exports_not_in_signatures(&dep_info.exports, &key_refs);
237            if !extra_exports.is_empty() {
238                parts.push(format!("exports: {}", extra_exports.join(", ")));
239            }
240            if !key_refs.is_empty() {
241                let key_sigs: Vec<String> = key_refs.iter().map(|s| s.to_compact()).collect();
242                parts.push(key_sigs.join("\n"));
243            }
244            parts.join("\n")
245        }
246        "signatures" => {
247            let sigs = signatures::extract_signatures(content, ext);
248            sigs.iter()
249                .map(super::signatures::Signature::to_compact)
250                .collect::<Vec<_>>()
251                .join("\n")
252        }
253        "aggressive" => compressor::aggressive_compress(content, Some(ext)),
254        // Deterministic variant: benchmark numbers feed the scorecard's
255        // reproducibility digest (#211) and must not depend on whether the
256        // shared embedding engine happens to be loaded in this process.
257        "entropy" => entropy::entropy_compress_deterministic(content).output,
258        "cache_hit" => format!(
259            "F1=src/file.{ext} [unchanged, {}L, use cached context]",
260            content.lines().count()
261        ),
262        _ => content.to_string(),
263    };
264
265    let latency = start.elapsed();
266    let tokens = count_tokens(&compressed);
267
268    let savings_pct = if raw_tokens > 0 {
269        (1.0 - tokens as f64 / raw_tokens as f64) * 100.0
270    } else {
271        0.0
272    };
273
274    let preservation_score = if mode == "cache_hit" {
275        -1.0
276    } else {
277        preservation::measure(content, &compressed, ext).overall()
278    };
279
280    ModeMeasurement {
281        mode: mode.to_string(),
282        tokens,
283        savings_pct,
284        latency_us: latency.as_micros() as u64,
285        preservation_score,
286    }
287}
288
289fn measure_file(path: &Path, root: &str) -> Option<FileMeasurement> {
290    let content = std::fs::read_to_string(path).ok()?;
291    if content.is_empty() {
292        return None;
293    }
294
295    let ext = path
296        .extension()
297        .and_then(|e| e.to_str())
298        .unwrap_or("")
299        .to_string();
300
301    let raw_tokens = count_tokens(&content);
302    if raw_tokens == 0 {
303        return None;
304    }
305
306    let modes = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
307    let measurements: Vec<ModeMeasurement> = modes
308        .iter()
309        .map(|m| measure_mode(&content, &ext, m, raw_tokens))
310        .collect();
311
312    let display_path = path
313        .strip_prefix(root)
314        .unwrap_or(path)
315        .to_string_lossy()
316        .to_string();
317
318    Some(FileMeasurement {
319        path: display_path,
320        ext,
321        raw_tokens,
322        modes: measurements,
323    })
324}
325
326// ── Aggregation ─────────────────────────────────────────────
327
328/// Structural modes (map, signatures) only produce meaningful output for
329/// programming languages with parseable code structures. For data/markup
330/// files, these modes return near-empty output that would be misleading
331/// as "compression". Only `aggressive` and `entropy` are honest for these.
332fn is_mode_applicable_for_ext(mode: &str, ext: &str, tokens: usize) -> bool {
333    if tokens == 0 {
334        return false;
335    }
336    let is_structural_mode = matches!(mode, "map" | "signatures");
337    if !is_structural_mode {
338        return true;
339    }
340    // Extensions with actual code structures (functions, classes, types)
341    let code_exts = [
342        "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "kt", "c", "cpp", "h", "hpp", "cs",
343        "rb", "swift", "scala", "zig", "lua", "php", "dart", "ex", "exs", "elm", "hs", "ml",
344        "svelte", "vue", "sh", "bash", "zsh",
345    ];
346    code_exts.contains(&ext)
347}
348
349fn aggregate_languages(files: &[FileMeasurement]) -> Vec<LanguageStats> {
350    struct LangAccum {
351        count: usize,
352        total_tokens: usize,
353        mode_tokens: HashMap<String, usize>,
354    }
355
356    let mut map: HashMap<String, LangAccum> = HashMap::new();
357    for f in files {
358        let entry = map.entry(f.ext.clone()).or_insert_with(|| LangAccum {
359            count: 0,
360            total_tokens: 0,
361            mode_tokens: HashMap::new(),
362        });
363        entry.count += 1;
364        entry.total_tokens += f.raw_tokens;
365        for m in &f.modes {
366            *entry.mode_tokens.entry(m.mode.clone()).or_insert(0) += m.tokens;
367        }
368    }
369
370    let mut stats: Vec<LanguageStats> = map
371        .into_iter()
372        .map(|(ext, acc)| {
373            let (best_mode, best_tokens) = acc
374                .mode_tokens
375                .iter()
376                .filter(|(m, _)| m.as_str() != "cache_hit")
377                .filter(|(m, t)| is_mode_applicable_for_ext(m, &ext, **t))
378                .min_by_key(|(_, t)| **t)
379                .map_or_else(
380                    || ("full".to_string(), acc.total_tokens),
381                    |(m, t)| (m.clone(), *t),
382                );
383
384            let savings = if acc.total_tokens > 0 {
385                (1.0 - best_tokens as f64 / acc.total_tokens as f64) * 100.0
386            } else {
387                0.0
388            };
389
390            LanguageStats {
391                ext,
392                count: acc.count,
393                total_tokens: acc.total_tokens,
394                best_mode,
395                best_mode_tokens: best_tokens,
396                best_savings_pct: savings,
397            }
398        })
399        .collect();
400    stats.sort_by_key(|x| std::cmp::Reverse(x.total_tokens));
401    stats
402}
403
404fn aggregate_modes(files: &[FileMeasurement]) -> Vec<ModeSummary> {
405    let mode_names = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
406    let mut summaries = Vec::new();
407
408    for mode_name in &mode_names {
409        let mut total_tokens = 0usize;
410        let mut total_savings = 0.0f64;
411        let mut total_latency = 0u64;
412        let mut total_preservation = 0.0f64;
413        let mut preservation_count = 0usize;
414        let mut count = 0usize;
415
416        for f in files {
417            if let Some(m) = f.modes.iter().find(|m| m.mode == *mode_name) {
418                total_tokens += m.tokens;
419                total_savings += m.savings_pct;
420                total_latency += m.latency_us;
421                if m.preservation_score >= 0.0 {
422                    total_preservation += m.preservation_score;
423                    preservation_count += 1;
424                }
425                count += 1;
426            }
427        }
428
429        if count == 0 {
430            continue;
431        }
432
433        summaries.push(ModeSummary {
434            mode: mode_name.to_string(),
435            total_compressed_tokens: total_tokens,
436            avg_savings_pct: total_savings / count as f64,
437            avg_latency_us: total_latency / count as u64,
438            avg_preservation: if preservation_count > 0 {
439                total_preservation / preservation_count as f64
440            } else {
441                -1.0
442            },
443        });
444    }
445
446    summaries
447}
448
449// ── Session Simulation ──────────────────────────────────────
450
451/// Honest CCP resume size (GL #573): measure what `ctx_session load` actually
452/// emits for the latest real session instead of asserting a constant. The
453/// model value is the documented fallback for machines without session history.
454fn measured_ccp_resume_tokens() -> usize {
455    const RESUME_CCP_MODEL_TOKENS: usize = 400;
456    crate::core::session::SessionState::load_latest().map_or(RESUME_CCP_MODEL_TOKENS, |s| {
457        crate::core::tokens::count_tokens(&format!("Session loaded.\n{}", s.format_compact()))
458    })
459}
460
461fn simulate_session(files: &[FileMeasurement]) -> SessionSimResult {
462    if files.is_empty() {
463        return SessionSimResult {
464            raw_tokens: 0,
465            lean_tokens: 0,
466            lean_ccp_tokens: 0,
467            raw_cost: 0.0,
468            lean_cost: 0.0,
469            ccp_cost: 0.0,
470        };
471    }
472
473    let file_count = files.len().min(15);
474    let selected = &files[..file_count];
475
476    let first_read_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
477
478    let first_read_lean: usize = selected
479        .iter()
480        .enumerate()
481        .map(|(i, f)| {
482            let mode = if i % 3 == 0 { "aggressive" } else { "map" };
483            f.modes
484                .iter()
485                .find(|m| m.mode == mode)
486                .map_or(f.raw_tokens, |m| m.tokens)
487        })
488        .sum();
489
490    let cache_reread_count = 10usize.min(file_count);
491    let cache_raw: usize = selected[..cache_reread_count]
492        .iter()
493        .map(|f| f.raw_tokens)
494        .sum();
495    let cache_lean: usize = cache_reread_count * cache_hit_tokens();
496
497    let shell_count = 8usize;
498    let shell_raw = shell_count * 500;
499    let shell_lean = shell_count * 200;
500
501    let resume_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
502    let resume_lean: usize = selected
503        .iter()
504        .map(|f| {
505            f.modes
506                .iter()
507                .find(|m| m.mode == "map")
508                .map_or(f.raw_tokens, |m| m.tokens)
509        })
510        .sum();
511    let resume_ccp = measured_ccp_resume_tokens();
512
513    let raw_total = first_read_raw + cache_raw + shell_raw + resume_raw;
514    let lean_total = first_read_lean + cache_lean + shell_lean + resume_lean;
515    let ccp_total = first_read_lean + cache_lean + shell_lean + resume_ccp;
516
517    SessionSimResult {
518        raw_tokens: raw_total,
519        lean_tokens: lean_total,
520        lean_ccp_tokens: ccp_total,
521        raw_cost: raw_total as f64 * COST_PER_TOKEN,
522        lean_cost: lean_total as f64 * COST_PER_TOKEN,
523        ccp_cost: ccp_total as f64 * COST_PER_TOKEN,
524    }
525}
526
527// ── Public API ──────────────────────────────────────────────
528
529pub fn run_project_benchmark(path: &str) -> ProjectBenchmark {
530    let root = if path.is_empty() { "." } else { path };
531    let scanned = scan_project(root);
532    let files_scanned = scanned.len();
533
534    let file_results: Vec<FileMeasurement> = scanned
535        .iter()
536        .filter_map(|p| measure_file(p, root))
537        .collect();
538
539    let total_raw_tokens: usize = file_results.iter().map(|f| f.raw_tokens).sum();
540    let languages = aggregate_languages(&file_results);
541    let mode_summaries = aggregate_modes(&file_results);
542    let session_sim = simulate_session(&file_results);
543
544    ProjectBenchmark {
545        root: root.to_string(),
546        files_scanned,
547        files_measured: file_results.len(),
548        total_raw_tokens,
549        languages,
550        mode_summaries,
551        session_sim,
552        file_results,
553    }
554}
555
556// ── Report: Terminal ────────────────────────────────────────
557
558pub fn format_terminal(b: &ProjectBenchmark) -> String {
559    let mut out = Vec::new();
560    let sep = "\u{2550}".repeat(66);
561
562    out.push(sep.clone());
563    out.push(format!("  lean-ctx Benchmark — {}", b.root));
564    out.push(sep.clone());
565
566    let lang_summary: Vec<String> = b
567        .languages
568        .iter()
569        .take(5)
570        .map(|l| format!("{} {}", l.count, l.ext))
571        .collect();
572    out.push(format!(
573        "  Scanned: {} files ({})",
574        b.files_measured,
575        lang_summary.join(", ")
576    ));
577    out.push(format!(
578        "  Total raw tokens: {}",
579        format_num(b.total_raw_tokens)
580    ));
581    out.push(String::new());
582
583    out.push("  Compression by Language:".to_string());
584    out.push(format!(
585        "  {:<10} {:>6} {:>10} {:>10} {:>10} {:>10}",
586        "Lang", "Files", "Raw Tok", "Best Mode", "Compressed", "Savings"
587    ));
588    out.push(format!("  {}", "\u{2500}".repeat(62)));
589    for l in &b.languages {
590        out.push(format!(
591            "  {:<10} {:>6} {:>10} {:>10} {:>10} {:>9.1}%",
592            l.ext,
593            l.count,
594            format_num(l.total_tokens),
595            l.best_mode,
596            format_num(l.best_mode_tokens),
597            l.best_savings_pct,
598        ));
599    }
600    out.push(String::new());
601
602    out.push("  Mode Performance:".to_string());
603    out.push(format!(
604        "  {:<14} {:>10} {:>10} {:>10} {:>10}",
605        "Mode", "Tokens", "Savings", "Latency", "Quality"
606    ));
607    out.push(format!("  {}", "\u{2500}".repeat(58)));
608
609    for m in &b.mode_summaries {
610        let qual = if m.avg_preservation < 0.0 {
611            "N/A".to_string()
612        } else {
613            format!("{:.1}%", m.avg_preservation * 100.0)
614        };
615        let latency = if m.avg_latency_us > 1000 {
616            format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
617        } else {
618            format!("{}μs", m.avg_latency_us)
619        };
620        out.push(format!(
621            "  {:<14} {:>10} {:>9.1}% {:>10} {:>10}",
622            m.mode,
623            format_num(m.total_compressed_tokens),
624            m.avg_savings_pct,
625            latency,
626            qual,
627        ));
628    }
629
630    out.push(String::new());
631    out.push("  Session Simulation (30-min coding):".to_string());
632    out.push(format!(
633        "  {:<24} {:>10} {:>10} {:>10}",
634        "Approach", "Tokens", "Cost", "Savings"
635    ));
636    out.push(format!("  {}", "\u{2500}".repeat(58)));
637
638    let s = &b.session_sim;
639    out.push(format!(
640        "  {:<24} {:>10} {:>10} {:>10}",
641        "Raw (no compression)",
642        format_num(s.raw_tokens),
643        format!("${:.3}", s.raw_cost),
644        "\u{2014}",
645    ));
646
647    let lean_pct = if s.raw_tokens > 0 {
648        (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
649    } else {
650        0.0
651    };
652    out.push(format!(
653        "  {:<24} {:>10} {:>10} {:>9.1}%",
654        "lean-ctx (no CCP)",
655        format_num(s.lean_tokens),
656        format!("${:.3}", s.lean_cost),
657        lean_pct,
658    ));
659
660    let ccp_pct = if s.raw_tokens > 0 {
661        (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
662    } else {
663        0.0
664    };
665    out.push(format!(
666        "  {:<24} {:>10} {:>10} {:>9.1}%",
667        "lean-ctx + CCP",
668        format_num(s.lean_ccp_tokens),
669        format!("${:.3}", s.ccp_cost),
670        ccp_pct,
671    ));
672
673    out.push(sep.clone());
674    out.join("\n")
675}
676
677// ── Report: Markdown ────────────────────────────────────────
678
679pub fn format_markdown(b: &ProjectBenchmark) -> String {
680    let mut out = Vec::new();
681
682    out.push("# lean-ctx Benchmark Report".to_string());
683    out.push(String::new());
684    out.push(format!("**Project:** `{}`", b.root));
685    out.push(format!("**Files measured:** {}", b.files_measured));
686    out.push(format!(
687        "**Total raw tokens:** {}",
688        format_num(b.total_raw_tokens)
689    ));
690    out.push(String::new());
691
692    out.push("## Compression by Language".to_string());
693    out.push(String::new());
694    out.push("| Language | Files | Raw Tokens | Best Mode | Compressed | Savings |".to_string());
695    out.push("|----------|------:|-----------:|-----------|----------:|--------:|".to_string());
696    for l in &b.languages {
697        out.push(format!(
698            "| {} | {} | {} | {} | {} | {:.1}% |",
699            l.ext,
700            l.count,
701            format_num(l.total_tokens),
702            l.best_mode,
703            format_num(l.best_mode_tokens),
704            l.best_savings_pct,
705        ));
706    }
707    out.push(String::new());
708
709    out.push("## Mode Performance".to_string());
710    out.push(String::new());
711    out.push("| Mode | Tokens | Savings | Latency | Quality |".to_string());
712    out.push("|------|-------:|--------:|--------:|--------:|".to_string());
713    for m in &b.mode_summaries {
714        let qual = if m.avg_preservation < 0.0 {
715            "N/A".to_string()
716        } else {
717            format!("{:.1}%", m.avg_preservation * 100.0)
718        };
719        let latency = if m.avg_latency_us > 1000 {
720            format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
721        } else {
722            format!("{}μs", m.avg_latency_us)
723        };
724        out.push(format!(
725            "| {} | {} | {:.1}% | {} | {} |",
726            m.mode,
727            format_num(m.total_compressed_tokens),
728            m.avg_savings_pct,
729            latency,
730            qual
731        ));
732    }
733    out.push(String::new());
734
735    out.push("## Session Simulation (30-min coding)".to_string());
736    out.push(String::new());
737    out.push("| Approach | Tokens | Cost | Savings |".to_string());
738    out.push("|----------|-------:|-----:|--------:|".to_string());
739
740    let s = &b.session_sim;
741    out.push(format!(
742        "| Raw (no compression) | {} | ${:.3} | — |",
743        format_num(s.raw_tokens),
744        s.raw_cost
745    ));
746
747    let lean_pct = if s.raw_tokens > 0 {
748        (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
749    } else {
750        0.0
751    };
752    out.push(format!(
753        "| lean-ctx (no CCP) | {} | ${:.3} | {:.1}% |",
754        format_num(s.lean_tokens),
755        s.lean_cost,
756        lean_pct
757    ));
758
759    let ccp_pct = if s.raw_tokens > 0 {
760        (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
761    } else {
762        0.0
763    };
764    out.push(format!(
765        "| lean-ctx + CCP | {} | ${:.3} | {:.1}% |",
766        format_num(s.lean_ccp_tokens),
767        s.ccp_cost,
768        ccp_pct
769    ));
770
771    out.push(String::new());
772    out.push(format!(
773        "*Generated by lean-ctx benchmark v{} — https://leanctx.com*",
774        env!("CARGO_PKG_VERSION")
775    ));
776
777    out.join("\n")
778}
779
780// ── Report: JSON ────────────────────────────────────────────
781
782pub fn format_json(b: &ProjectBenchmark) -> String {
783    let modes: Vec<serde_json::Value> = b.mode_summaries.iter().map(|m| {
784        serde_json::json!({
785            "mode": m.mode,
786            "total_compressed_tokens": m.total_compressed_tokens,
787            "avg_savings_pct": round2(m.avg_savings_pct),
788            "avg_latency_us": m.avg_latency_us,
789            "avg_preservation": if m.avg_preservation < 0.0 { serde_json::Value::Null } else { serde_json::json!(round2(m.avg_preservation * 100.0)) },
790        })
791    }).collect();
792
793    let languages: Vec<serde_json::Value> = b
794        .languages
795        .iter()
796        .map(|l| {
797            serde_json::json!({
798                "ext": l.ext,
799                "count": l.count,
800                "total_tokens": l.total_tokens,
801                "best_mode": l.best_mode,
802                "best_mode_tokens": l.best_mode_tokens,
803                "best_savings_pct": round2(l.best_savings_pct),
804            })
805        })
806        .collect();
807
808    let file_details: Vec<serde_json::Value> = b
809        .file_results
810        .iter()
811        .map(|f| {
812            let file_modes: Vec<serde_json::Value> = f
813                .modes
814                .iter()
815                .map(|m| {
816                    serde_json::json!({
817                        "mode": m.mode,
818                        "tokens": m.tokens,
819                        "savings_pct": round2(m.savings_pct),
820                        "latency_us": m.latency_us,
821                        "preservation": if m.preservation_score < 0.0 {
822                            serde_json::Value::Null
823                        } else {
824                            serde_json::json!(round2(m.preservation_score * 100.0))
825                        },
826                    })
827                })
828                .collect();
829            serde_json::json!({
830                "path": f.path,
831                "ext": f.ext,
832                "raw_tokens": f.raw_tokens,
833                "modes": file_modes,
834            })
835        })
836        .collect();
837
838    let s = &b.session_sim;
839    let report = serde_json::json!({
840        "version": env!("CARGO_PKG_VERSION"),
841        "root": b.root,
842        "files_scanned": b.files_scanned,
843        "files_measured": b.files_measured,
844        "total_raw_tokens": b.total_raw_tokens,
845        "languages": languages,
846        "mode_summaries": modes,
847        "files": file_details,
848        "session_simulation": {
849            "raw_tokens": s.raw_tokens,
850            "lean_tokens": s.lean_tokens,
851            "lean_ccp_tokens": s.lean_ccp_tokens,
852            "raw_cost_usd": round2(s.raw_cost),
853            "lean_cost_usd": round2(s.lean_cost),
854            "ccp_cost_usd": round2(s.ccp_cost),
855        },
856    });
857
858    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
859}
860
861// ── Helpers ─────────────────────────────────────────────────
862
863fn format_num(n: usize) -> String {
864    if n >= 1_000_000 {
865        format!("{:.1}M", n as f64 / 1_000_000.0)
866    } else if n >= 1_000 {
867        format!("{:.1}K", n as f64 / 1_000.0)
868    } else {
869        format!("{n}")
870    }
871}
872
873fn round2(v: f64) -> f64 {
874    (v * 100.0).round() / 100.0
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    fn mock_file(path: &str, ext: &str, raw: usize, modes: Vec<(&str, usize)>) -> FileMeasurement {
882        FileMeasurement {
883            path: path.to_string(),
884            ext: ext.to_string(),
885            raw_tokens: raw,
886            modes: modes
887                .into_iter()
888                .map(|(mode, tokens)| ModeMeasurement {
889                    mode: mode.to_string(),
890                    tokens,
891                    savings_pct: if raw > 0 {
892                        (1.0 - tokens as f64 / raw as f64) * 100.0
893                    } else {
894                        0.0
895                    },
896                    latency_us: 100,
897                    preservation_score: 0.85,
898                })
899                .collect(),
900        }
901    }
902
903    #[test]
904    fn aggregate_languages_computes_best_mode() {
905        let files = vec![
906            mock_file(
907                "a.rs",
908                "rs",
909                1000,
910                vec![("map", 400), ("signatures", 200), ("aggressive", 300)],
911            ),
912            mock_file(
913                "b.rs",
914                "rs",
915                800,
916                vec![("map", 300), ("signatures", 150), ("aggressive", 250)],
917            ),
918            mock_file(
919                "c.py",
920                "py",
921                600,
922                vec![("map", 100), ("signatures", 250), ("aggressive", 200)],
923            ),
924        ];
925
926        let langs = aggregate_languages(&files);
927        assert_eq!(langs.len(), 2);
928
929        let rs = langs.iter().find(|l| l.ext == "rs").unwrap();
930        assert_eq!(rs.count, 2);
931        assert_eq!(rs.total_tokens, 1800);
932        assert_eq!(rs.best_mode, "signatures");
933        assert_eq!(rs.best_mode_tokens, 350);
934        assert!(rs.best_savings_pct > 80.0);
935
936        let py = langs.iter().find(|l| l.ext == "py").unwrap();
937        assert_eq!(py.best_mode, "map");
938        assert_eq!(py.best_mode_tokens, 100);
939    }
940
941    #[test]
942    fn aggregate_modes_averages() {
943        let files = vec![
944            mock_file("a.rs", "rs", 1000, vec![("map", 400), ("aggressive", 300)]),
945            mock_file("b.rs", "rs", 500, vec![("map", 200), ("aggressive", 100)]),
946        ];
947
948        let modes = aggregate_modes(&files);
949        let map = modes.iter().find(|m| m.mode == "map").unwrap();
950        assert_eq!(map.total_compressed_tokens, 600);
951        assert!(map.avg_savings_pct > 50.0);
952    }
953
954    #[test]
955    fn session_sim_empty_files() {
956        let result = simulate_session(&[]);
957        assert_eq!(result.raw_tokens, 0);
958        assert_eq!(result.lean_tokens, 0);
959        assert!((result.raw_cost).abs() < f64::EPSILON);
960    }
961
962    #[test]
963    fn session_sim_basic() {
964        let files: Vec<FileMeasurement> = (0..5)
965            .map(|i| {
966                mock_file(
967                    &format!("file_{i}.rs"),
968                    "rs",
969                    2000,
970                    vec![
971                        ("map", 800),
972                        ("aggressive", 600),
973                        ("cache_hit", cache_hit_tokens()),
974                    ],
975                )
976            })
977            .collect();
978        let result = simulate_session(&files);
979        assert!(result.raw_tokens > 0);
980        assert!(result.lean_tokens < result.raw_tokens);
981        assert!(
982            result.lean_ccp_tokens < result.lean_tokens,
983            "CCP resume ({}) should beat map-based resume ({}) with enough files",
984            result.lean_ccp_tokens,
985            result.lean_tokens
986        );
987    }
988
989    #[test]
990    fn format_json_includes_files_and_language_savings() {
991        let files = vec![mock_file(
992            "src/main.rs",
993            "rs",
994            500,
995            vec![("map", 200), ("signatures", 100), ("cache_hit", 13)],
996        )];
997        let bench = ProjectBenchmark {
998            root: ".".to_string(),
999            files_scanned: 1,
1000            files_measured: 1,
1001            total_raw_tokens: 500,
1002            languages: aggregate_languages(&files),
1003            mode_summaries: aggregate_modes(&files),
1004            session_sim: simulate_session(&files),
1005            file_results: files,
1006        };
1007
1008        let json_str = format_json(&bench);
1009        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1010
1011        assert!(parsed["files"].is_array());
1012        assert_eq!(parsed["files"].as_array().unwrap().len(), 1);
1013        assert_eq!(parsed["files"][0]["path"], "src/main.rs");
1014        assert!(parsed["files"][0]["modes"].is_array());
1015
1016        assert!(parsed["languages"][0]["best_mode"].is_string());
1017        assert!(parsed["languages"][0]["best_savings_pct"].is_number());
1018    }
1019
1020    #[test]
1021    fn format_markdown_contains_language_savings() {
1022        let files = vec![mock_file(
1023            "lib.rs",
1024            "rs",
1025            1000,
1026            vec![("map", 300), ("signatures", 200)],
1027        )];
1028        let bench = ProjectBenchmark {
1029            root: ".".to_string(),
1030            files_scanned: 1,
1031            files_measured: 1,
1032            total_raw_tokens: 1000,
1033            languages: aggregate_languages(&files),
1034            mode_summaries: aggregate_modes(&files),
1035            session_sim: simulate_session(&files),
1036            file_results: files,
1037        };
1038
1039        let md = format_markdown(&bench);
1040        assert!(md.contains("Compression by Language"));
1041        assert!(md.contains("Best Mode"));
1042        assert!(md.contains("Savings"));
1043    }
1044
1045    #[test]
1046    fn format_terminal_contains_language_section() {
1047        let files = vec![mock_file(
1048            "app.py",
1049            "py",
1050            800,
1051            vec![("map", 200), ("aggressive", 300)],
1052        )];
1053        let bench = ProjectBenchmark {
1054            root: ".".to_string(),
1055            files_scanned: 1,
1056            files_measured: 1,
1057            total_raw_tokens: 800,
1058            languages: aggregate_languages(&files),
1059            mode_summaries: aggregate_modes(&files),
1060            session_sim: simulate_session(&files),
1061            file_results: files,
1062        };
1063
1064        let out = format_terminal(&bench);
1065        assert!(out.contains("Compression by Language"));
1066        assert!(out.contains("py"));
1067        assert!(out.contains("Best Mode"));
1068    }
1069
1070    #[test]
1071    fn run_project_benchmark_on_current_crate() {
1072        let bench = run_project_benchmark("src");
1073        assert!(bench.files_measured > 0);
1074        assert!(bench.total_raw_tokens > 0);
1075        assert!(!bench.languages.is_empty());
1076        assert!(!bench.mode_summaries.is_empty());
1077
1078        for lang in &bench.languages {
1079            assert!(!lang.best_mode.is_empty());
1080            assert!(lang.best_savings_pct >= 0.0);
1081        }
1082
1083        let json = format_json(&bench);
1084        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1085        assert!(!parsed["files"].as_array().unwrap().is_empty());
1086
1087        let md = format_markdown(&bench);
1088        assert!(md.contains("lean-ctx Benchmark Report"));
1089
1090        let term = format_terminal(&bench);
1091        assert!(term.contains("Session Simulation"));
1092    }
1093}