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;
17const CACHE_HIT_TOKENS: usize = 13;
18
19// ── Types ───────────────────────────────────────────────────
20
21#[derive(Debug, Clone)]
22pub struct ModeMeasurement {
23    pub mode: String,
24    pub tokens: usize,
25    pub savings_pct: f64,
26    pub latency_us: u64,
27    pub preservation_score: f64,
28}
29
30#[derive(Debug, Clone)]
31pub struct FileMeasurement {
32    #[allow(dead_code)]
33    pub path: String,
34    pub ext: String,
35    pub raw_tokens: usize,
36    pub modes: Vec<ModeMeasurement>,
37}
38
39#[derive(Debug, Clone)]
40pub struct LanguageStats {
41    pub ext: String,
42    pub count: usize,
43    pub total_tokens: usize,
44}
45
46#[derive(Debug, Clone)]
47pub struct ModeSummary {
48    pub mode: String,
49    pub total_compressed_tokens: usize,
50    pub avg_savings_pct: f64,
51    pub avg_latency_us: u64,
52    pub avg_preservation: f64,
53}
54
55#[derive(Debug, Clone)]
56pub struct SessionSimResult {
57    pub raw_tokens: usize,
58    pub lean_tokens: usize,
59    pub lean_ccp_tokens: usize,
60    pub raw_cost: f64,
61    pub lean_cost: f64,
62    pub ccp_cost: f64,
63}
64
65#[derive(Debug, Clone)]
66pub struct ProjectBenchmark {
67    pub root: String,
68    pub files_scanned: usize,
69    pub files_measured: usize,
70    pub total_raw_tokens: usize,
71    pub languages: Vec<LanguageStats>,
72    pub mode_summaries: Vec<ModeSummary>,
73    pub session_sim: SessionSimResult,
74    #[allow(dead_code)]
75    pub file_results: Vec<FileMeasurement>,
76}
77
78// ── Scanner ─────────────────────────────────────────────────
79
80fn is_skipped_dir(name: &str) -> bool {
81    matches!(
82        name,
83        "node_modules"
84            | ".git"
85            | "target"
86            | "dist"
87            | "build"
88            | ".next"
89            | ".nuxt"
90            | "__pycache__"
91            | ".cache"
92            | "coverage"
93            | "vendor"
94            | ".svn"
95            | ".hg"
96    )
97}
98
99fn is_text_ext(ext: &str) -> bool {
100    matches!(
101        ext,
102        "rs" | "ts"
103            | "tsx"
104            | "js"
105            | "jsx"
106            | "py"
107            | "go"
108            | "java"
109            | "c"
110            | "cpp"
111            | "h"
112            | "hpp"
113            | "cs"
114            | "kt"
115            | "swift"
116            | "rb"
117            | "php"
118            | "vue"
119            | "svelte"
120            | "html"
121            | "css"
122            | "scss"
123            | "less"
124            | "json"
125            | "yaml"
126            | "yml"
127            | "toml"
128            | "xml"
129            | "md"
130            | "txt"
131            | "sh"
132            | "bash"
133            | "zsh"
134            | "fish"
135            | "sql"
136            | "graphql"
137            | "proto"
138            | "ex"
139            | "exs"
140            | "zig"
141            | "lua"
142            | "r"
143            | "R"
144            | "dart"
145            | "scala"
146    )
147}
148
149fn scan_project(root: &str) -> Vec<PathBuf> {
150    let mut files: Vec<(PathBuf, u64)> = Vec::new();
151
152    for entry in WalkDir::new(root)
153        .max_depth(8)
154        .into_iter()
155        .filter_entry(|e| {
156            let name = e.file_name().to_string_lossy();
157            if e.file_type().is_dir() {
158                if e.depth() > 0 && name.starts_with('.') {
159                    return false;
160                }
161                return !is_skipped_dir(&name);
162            }
163            true
164        })
165    {
166        let Ok(entry) = entry else { continue };
167
168        if entry.file_type().is_dir() {
169            continue;
170        }
171
172        let path = entry.path().to_path_buf();
173        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
174
175        if !is_text_ext(ext) {
176            continue;
177        }
178
179        let size = entry.metadata().map_or(0, |m| m.len());
180        if size == 0 || size > MAX_FILE_SIZE {
181            continue;
182        }
183
184        files.push((path, size));
185    }
186
187    files.sort_by_key(|x| std::cmp::Reverse(x.1));
188
189    let mut selected = Vec::new();
190    let mut ext_counts: HashMap<String, usize> = HashMap::new();
191
192    for (path, _size) in &files {
193        if selected.len() >= MAX_FILES {
194            break;
195        }
196        let ext = path
197            .extension()
198            .and_then(|e| e.to_str())
199            .unwrap_or("")
200            .to_string();
201        let count = ext_counts.entry(ext.clone()).or_insert(0);
202        if *count < 10 {
203            *count += 1;
204            selected.push(path.clone());
205        }
206    }
207
208    selected
209}
210
211// ── Measurement ─────────────────────────────────────────────
212
213fn measure_mode(content: &str, ext: &str, mode: &str, raw_tokens: usize) -> ModeMeasurement {
214    let start = Instant::now();
215
216    let compressed = match mode {
217        "map" => {
218            let sigs = signatures::extract_signatures(content, ext);
219            let dep_info = deps::extract_deps(content, ext);
220            let mut parts = Vec::new();
221            if !dep_info.imports.is_empty() {
222                parts.push(format!("deps: {}", dep_info.imports.join(", ")));
223            }
224            if !dep_info.exports.is_empty() {
225                parts.push(format!("exports: {}", dep_info.exports.join(", ")));
226            }
227            let key_sigs: Vec<String> = sigs
228                .iter()
229                .filter(|s| s.is_exported || s.indent == 0)
230                .map(super::signatures::Signature::to_compact)
231                .collect();
232            if !key_sigs.is_empty() {
233                parts.push(key_sigs.join("\n"));
234            }
235            parts.join("\n")
236        }
237        "signatures" => {
238            let sigs = signatures::extract_signatures(content, ext);
239            sigs.iter()
240                .map(super::signatures::Signature::to_compact)
241                .collect::<Vec<_>>()
242                .join("\n")
243        }
244        "aggressive" => compressor::aggressive_compress(content, Some(ext)),
245        "entropy" => entropy::entropy_compress(content).output,
246        "cache_hit" => "cached re-read ~13tok".to_string(),
247        _ => content.to_string(),
248    };
249
250    let latency = start.elapsed();
251    let tokens = if mode == "cache_hit" {
252        CACHE_HIT_TOKENS
253    } else {
254        count_tokens(&compressed)
255    };
256
257    let savings_pct = if raw_tokens > 0 {
258        (1.0 - tokens as f64 / raw_tokens as f64) * 100.0
259    } else {
260        0.0
261    };
262
263    let preservation_score = if mode == "cache_hit" {
264        -1.0
265    } else {
266        preservation::measure(content, &compressed, ext).overall()
267    };
268
269    ModeMeasurement {
270        mode: mode.to_string(),
271        tokens,
272        savings_pct,
273        latency_us: latency.as_micros() as u64,
274        preservation_score,
275    }
276}
277
278fn measure_file(path: &Path, root: &str) -> Option<FileMeasurement> {
279    let content = std::fs::read_to_string(path).ok()?;
280    if content.is_empty() {
281        return None;
282    }
283
284    let ext = path
285        .extension()
286        .and_then(|e| e.to_str())
287        .unwrap_or("")
288        .to_string();
289
290    let raw_tokens = count_tokens(&content);
291    if raw_tokens == 0 {
292        return None;
293    }
294
295    let modes = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
296    let measurements: Vec<ModeMeasurement> = modes
297        .iter()
298        .map(|m| measure_mode(&content, &ext, m, raw_tokens))
299        .collect();
300
301    let display_path = path
302        .strip_prefix(root)
303        .unwrap_or(path)
304        .to_string_lossy()
305        .to_string();
306
307    Some(FileMeasurement {
308        path: display_path,
309        ext,
310        raw_tokens,
311        modes: measurements,
312    })
313}
314
315// ── Aggregation ─────────────────────────────────────────────
316
317fn aggregate_languages(files: &[FileMeasurement]) -> Vec<LanguageStats> {
318    let mut map: HashMap<String, (usize, usize)> = HashMap::new();
319    for f in files {
320        let entry = map.entry(f.ext.clone()).or_insert((0, 0));
321        entry.0 += 1;
322        entry.1 += f.raw_tokens;
323    }
324    let mut stats: Vec<LanguageStats> = map
325        .into_iter()
326        .map(|(ext, (count, total_tokens))| LanguageStats {
327            ext,
328            count,
329            total_tokens,
330        })
331        .collect();
332    stats.sort_by_key(|x| std::cmp::Reverse(x.total_tokens));
333    stats
334}
335
336fn aggregate_modes(files: &[FileMeasurement]) -> Vec<ModeSummary> {
337    let mode_names = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
338    let mut summaries = Vec::new();
339
340    for mode_name in &mode_names {
341        let mut total_tokens = 0usize;
342        let mut total_savings = 0.0f64;
343        let mut total_latency = 0u64;
344        let mut total_preservation = 0.0f64;
345        let mut preservation_count = 0usize;
346        let mut count = 0usize;
347
348        for f in files {
349            if let Some(m) = f.modes.iter().find(|m| m.mode == *mode_name) {
350                total_tokens += m.tokens;
351                total_savings += m.savings_pct;
352                total_latency += m.latency_us;
353                if m.preservation_score >= 0.0 {
354                    total_preservation += m.preservation_score;
355                    preservation_count += 1;
356                }
357                count += 1;
358            }
359        }
360
361        if count == 0 {
362            continue;
363        }
364
365        summaries.push(ModeSummary {
366            mode: mode_name.to_string(),
367            total_compressed_tokens: total_tokens,
368            avg_savings_pct: total_savings / count as f64,
369            avg_latency_us: total_latency / count as u64,
370            avg_preservation: if preservation_count > 0 {
371                total_preservation / preservation_count as f64
372            } else {
373                -1.0
374            },
375        });
376    }
377
378    summaries
379}
380
381// ── Session Simulation ──────────────────────────────────────
382
383fn simulate_session(files: &[FileMeasurement]) -> SessionSimResult {
384    if files.is_empty() {
385        return SessionSimResult {
386            raw_tokens: 0,
387            lean_tokens: 0,
388            lean_ccp_tokens: 0,
389            raw_cost: 0.0,
390            lean_cost: 0.0,
391            ccp_cost: 0.0,
392        };
393    }
394
395    let file_count = files.len().min(15);
396    let selected = &files[..file_count];
397
398    let first_read_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
399
400    let first_read_lean: usize = selected
401        .iter()
402        .enumerate()
403        .map(|(i, f)| {
404            let mode = if i % 3 == 0 { "aggressive" } else { "map" };
405            f.modes
406                .iter()
407                .find(|m| m.mode == mode)
408                .map_or(f.raw_tokens, |m| m.tokens)
409        })
410        .sum();
411
412    let cache_reread_count = 10usize.min(file_count);
413    let cache_raw: usize = selected[..cache_reread_count]
414        .iter()
415        .map(|f| f.raw_tokens)
416        .sum();
417    let cache_lean: usize = cache_reread_count * CACHE_HIT_TOKENS;
418
419    let shell_count = 8usize;
420    let shell_raw = shell_count * 500;
421    let shell_lean = shell_count * 200;
422
423    let resume_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
424    let resume_lean: usize = selected
425        .iter()
426        .map(|f| {
427            f.modes
428                .iter()
429                .find(|m| m.mode == "map")
430                .map_or(f.raw_tokens, |m| m.tokens)
431        })
432        .sum();
433    let resume_ccp = 400usize;
434
435    let raw_total = first_read_raw + cache_raw + shell_raw + resume_raw;
436    let lean_total = first_read_lean + cache_lean + shell_lean + resume_lean;
437    let ccp_total = first_read_lean + cache_lean + shell_lean + resume_ccp;
438
439    SessionSimResult {
440        raw_tokens: raw_total,
441        lean_tokens: lean_total,
442        lean_ccp_tokens: ccp_total,
443        raw_cost: raw_total as f64 * COST_PER_TOKEN,
444        lean_cost: lean_total as f64 * COST_PER_TOKEN,
445        ccp_cost: ccp_total as f64 * COST_PER_TOKEN,
446    }
447}
448
449// ── Public API ──────────────────────────────────────────────
450
451pub fn run_project_benchmark(path: &str) -> ProjectBenchmark {
452    let root = if path.is_empty() { "." } else { path };
453    let scanned = scan_project(root);
454    let files_scanned = scanned.len();
455
456    let file_results: Vec<FileMeasurement> = scanned
457        .iter()
458        .filter_map(|p| measure_file(p, root))
459        .collect();
460
461    let total_raw_tokens: usize = file_results.iter().map(|f| f.raw_tokens).sum();
462    let languages = aggregate_languages(&file_results);
463    let mode_summaries = aggregate_modes(&file_results);
464    let session_sim = simulate_session(&file_results);
465
466    ProjectBenchmark {
467        root: root.to_string(),
468        files_scanned,
469        files_measured: file_results.len(),
470        total_raw_tokens,
471        languages,
472        mode_summaries,
473        session_sim,
474        file_results,
475    }
476}
477
478// ── Report: Terminal ────────────────────────────────────────
479
480pub fn format_terminal(b: &ProjectBenchmark) -> String {
481    let mut out = Vec::new();
482    let sep = "\u{2550}".repeat(66);
483
484    out.push(sep.clone());
485    out.push(format!("  lean-ctx Benchmark — {}", b.root));
486    out.push(sep.clone());
487
488    let lang_summary: Vec<String> = b
489        .languages
490        .iter()
491        .take(5)
492        .map(|l| format!("{} {}", l.count, l.ext))
493        .collect();
494    out.push(format!(
495        "  Scanned: {} files ({})",
496        b.files_measured,
497        lang_summary.join(", ")
498    ));
499    out.push(format!(
500        "  Total raw tokens: {}",
501        format_num(b.total_raw_tokens)
502    ));
503    out.push(String::new());
504
505    out.push("  Mode Performance:".to_string());
506    out.push(format!(
507        "  {:<14} {:>10} {:>10} {:>10} {:>10}",
508        "Mode", "Tokens", "Savings", "Latency", "Quality"
509    ));
510    out.push(format!("  {}", "\u{2500}".repeat(58)));
511
512    for m in &b.mode_summaries {
513        let qual = if m.avg_preservation < 0.0 {
514            "N/A".to_string()
515        } else {
516            format!("{:.1}%", m.avg_preservation * 100.0)
517        };
518        let latency = if m.avg_latency_us > 1000 {
519            format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
520        } else {
521            format!("{}μs", m.avg_latency_us)
522        };
523        out.push(format!(
524            "  {:<14} {:>10} {:>9.1}% {:>10} {:>10}",
525            m.mode,
526            format_num(m.total_compressed_tokens),
527            m.avg_savings_pct,
528            latency,
529            qual,
530        ));
531    }
532
533    out.push(String::new());
534    out.push("  Session Simulation (30-min coding):".to_string());
535    out.push(format!(
536        "  {:<24} {:>10} {:>10} {:>10}",
537        "Approach", "Tokens", "Cost", "Savings"
538    ));
539    out.push(format!("  {}", "\u{2500}".repeat(58)));
540
541    let s = &b.session_sim;
542    out.push(format!(
543        "  {:<24} {:>10} {:>10} {:>10}",
544        "Raw (no compression)",
545        format_num(s.raw_tokens),
546        format!("${:.3}", s.raw_cost),
547        "\u{2014}",
548    ));
549
550    let lean_pct = if s.raw_tokens > 0 {
551        (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
552    } else {
553        0.0
554    };
555    out.push(format!(
556        "  {:<24} {:>10} {:>10} {:>9.1}%",
557        "lean-ctx (no CCP)",
558        format_num(s.lean_tokens),
559        format!("${:.3}", s.lean_cost),
560        lean_pct,
561    ));
562
563    let ccp_pct = if s.raw_tokens > 0 {
564        (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
565    } else {
566        0.0
567    };
568    out.push(format!(
569        "  {:<24} {:>10} {:>10} {:>9.1}%",
570        "lean-ctx + CCP",
571        format_num(s.lean_ccp_tokens),
572        format!("${:.3}", s.ccp_cost),
573        ccp_pct,
574    ));
575
576    out.push(sep.clone());
577    out.join("\n")
578}
579
580// ── Report: Markdown ────────────────────────────────────────
581
582pub fn format_markdown(b: &ProjectBenchmark) -> String {
583    let mut out = Vec::new();
584
585    out.push("# lean-ctx Benchmark Report".to_string());
586    out.push(String::new());
587    out.push(format!("**Project:** `{}`", b.root));
588    out.push(format!("**Files measured:** {}", b.files_measured));
589    out.push(format!(
590        "**Total raw tokens:** {}",
591        format_num(b.total_raw_tokens)
592    ));
593    out.push(String::new());
594
595    out.push("## Languages".to_string());
596    out.push(String::new());
597    out.push("| Extension | Files | Tokens |".to_string());
598    out.push("|-----------|------:|-------:|".to_string());
599    for l in &b.languages {
600        out.push(format!(
601            "| {} | {} | {} |",
602            l.ext,
603            l.count,
604            format_num(l.total_tokens)
605        ));
606    }
607    out.push(String::new());
608
609    out.push("## Mode Performance".to_string());
610    out.push(String::new());
611    out.push("| Mode | Tokens | Savings | Latency | Quality |".to_string());
612    out.push("|------|-------:|--------:|--------:|--------:|".to_string());
613    for m in &b.mode_summaries {
614        let qual = if m.avg_preservation < 0.0 {
615            "N/A".to_string()
616        } else {
617            format!("{:.1}%", m.avg_preservation * 100.0)
618        };
619        let latency = if m.avg_latency_us > 1000 {
620            format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
621        } else {
622            format!("{}μs", m.avg_latency_us)
623        };
624        out.push(format!(
625            "| {} | {} | {:.1}% | {} | {} |",
626            m.mode,
627            format_num(m.total_compressed_tokens),
628            m.avg_savings_pct,
629            latency,
630            qual
631        ));
632    }
633    out.push(String::new());
634
635    out.push("## Session Simulation (30-min coding)".to_string());
636    out.push(String::new());
637    out.push("| Approach | Tokens | Cost | Savings |".to_string());
638    out.push("|----------|-------:|-----:|--------:|".to_string());
639
640    let s = &b.session_sim;
641    out.push(format!(
642        "| Raw (no compression) | {} | ${:.3} | — |",
643        format_num(s.raw_tokens),
644        s.raw_cost
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        "| lean-ctx (no CCP) | {} | ${:.3} | {:.1}% |",
654        format_num(s.lean_tokens),
655        s.lean_cost,
656        lean_pct
657    ));
658
659    let ccp_pct = if s.raw_tokens > 0 {
660        (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
661    } else {
662        0.0
663    };
664    out.push(format!(
665        "| lean-ctx + CCP | {} | ${:.3} | {:.1}% |",
666        format_num(s.lean_ccp_tokens),
667        s.ccp_cost,
668        ccp_pct
669    ));
670
671    out.push(String::new());
672    out.push(format!(
673        "*Generated by lean-ctx benchmark v{} — https://leanctx.com*",
674        env!("CARGO_PKG_VERSION")
675    ));
676
677    out.join("\n")
678}
679
680// ── Report: JSON ────────────────────────────────────────────
681
682pub fn format_json(b: &ProjectBenchmark) -> String {
683    let modes: Vec<serde_json::Value> = b.mode_summaries.iter().map(|m| {
684        serde_json::json!({
685            "mode": m.mode,
686            "total_compressed_tokens": m.total_compressed_tokens,
687            "avg_savings_pct": round2(m.avg_savings_pct),
688            "avg_latency_us": m.avg_latency_us,
689            "avg_preservation": if m.avg_preservation < 0.0 { serde_json::Value::Null } else { serde_json::json!(round2(m.avg_preservation * 100.0)) },
690        })
691    }).collect();
692
693    let languages: Vec<serde_json::Value> = b
694        .languages
695        .iter()
696        .map(|l| {
697            serde_json::json!({
698                "ext": l.ext,
699                "count": l.count,
700                "total_tokens": l.total_tokens,
701            })
702        })
703        .collect();
704
705    let s = &b.session_sim;
706    let report = serde_json::json!({
707        "version": env!("CARGO_PKG_VERSION"),
708        "root": b.root,
709        "files_scanned": b.files_scanned,
710        "files_measured": b.files_measured,
711        "total_raw_tokens": b.total_raw_tokens,
712        "languages": languages,
713        "mode_summaries": modes,
714        "session_simulation": {
715            "raw_tokens": s.raw_tokens,
716            "lean_tokens": s.lean_tokens,
717            "lean_ccp_tokens": s.lean_ccp_tokens,
718            "raw_cost_usd": round2(s.raw_cost),
719            "lean_cost_usd": round2(s.lean_cost),
720            "ccp_cost_usd": round2(s.ccp_cost),
721        },
722    });
723
724    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
725}
726
727// ── Helpers ─────────────────────────────────────────────────
728
729fn format_num(n: usize) -> String {
730    if n >= 1_000_000 {
731        format!("{:.1}M", n as f64 / 1_000_000.0)
732    } else if n >= 1_000 {
733        format!("{:.1}K", n as f64 / 1_000.0)
734    } else {
735        format!("{n}")
736    }
737}
738
739fn round2(v: f64) -> f64 {
740    (v * 100.0).round() / 100.0
741}