Skip to main content

tessera_codegraph/
bench.rs

1//! Benchmark harness. Indexes a path (real or synthetic) and reports the
2//! numbers that live in the README's perf chart. We deliberately keep this
3//! self-contained and reproducible — `tessera bench` is meant to be runnable
4//! by anyone evaluating Tessera.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::Instant;
9
10use anyhow::Result;
11use tempfile::TempDir;
12
13use crate::db;
14use crate::indexer::{self, IndexOptions};
15use crate::query;
16use crate::types::{BenchQuery, BenchResult, BenchSavings};
17
18pub struct BenchOptions {
19    pub path: Option<PathBuf>,
20    pub probe_symbol: Option<String>,
21    /// Size of the synthetic repo when `path` is None. Defaults to 50.
22    pub scale: Option<usize>,
23}
24
25pub fn run(options: BenchOptions) -> Result<BenchResult> {
26    let (path, _synthetic_dir) = match options.path {
27        Some(p) => (p, None),
28        None => {
29            let dir = TempDir::new()?;
30            generate_synthetic_repo(dir.path(), options.scale.unwrap_or(50))?;
31            let path = dir.path().to_path_buf();
32            (path, Some(dir))
33        }
34    };
35
36    let db_dir = tempfile::tempdir()?;
37    let db_path = db_dir.path().join("bench.db");
38
39    // Cold index.
40    let full = indexer::index_path_with(
41        &path,
42        &db_path,
43        IndexOptions {
44            full: true,
45            build_snapshot: true,
46        },
47    )?;
48
49    // Incremental rerun (no file changes — exercises the sha-skip path).
50    let incremental = indexer::index_path_with(
51        &path,
52        &db_path,
53        IndexOptions {
54            full: false,
55            build_snapshot: false,
56        },
57    )?;
58
59    let conn = db::open(&db_path)?;
60    // Pick a symbol that actually has callers so the "who calls X?" comparison
61    // produces meaningful numbers, not an empty result.
62    let probe = options
63        .probe_symbol
64        .unwrap_or_else(|| pick_called_symbol(&conn).unwrap_or_else(|_| "main".to_string()));
65
66    let queries = vec![
67        bench_query(&conn, "find_definition", || {
68            let r = query::find_definition_conn(&conn, &probe)?;
69            Ok(estimate(&r))
70        })?,
71        bench_query(&conn, "find_references", || {
72            let r = query::find_references_conn(&conn, &probe)?;
73            Ok(estimate(&r))
74        })?,
75        bench_query(&conn, "get_outline", || {
76            let r = query::get_outline_conn(&conn, Path::new("."))?;
77            Ok(estimate(&r))
78        })?,
79        bench_query(&conn, "impact", || {
80            let r = query::impact_conn(&conn, &probe, 4)?;
81            Ok(estimate(&r))
82        })?,
83        bench_query(&conn, "validate", || {
84            let r = query::validate_conn(&conn, &probe)?;
85            Ok(estimate(&r))
86        })?,
87    ];
88
89    // Headline comparison: "who calls X?" via raw grep+read vs `tessera impact`.
90    // The raw baseline is the total token cost of files that actually contain
91    // the symbol name — that's the work an agent would do with grep + read.
92    let raw_callers_tokens = estimate_raw_grep_tokens(&path, &probe)?;
93    let impact_tokens = queries
94        .iter()
95        .find(|q| q.name == "impact")
96        .map(|q| q.tokens)
97        .unwrap_or(1);
98
99    // Secondary comparison: "where is X defined?" — agent reads at least one
100    // file to confirm a definition; Tessera returns a single symbol record.
101    let raw_definition_tokens = estimate_mean_file_tokens(&path)?;
102    let find_def_tokens = queries
103        .iter()
104        .find(|q| q.name == "find_definition")
105        .map(|q| q.tokens)
106        .unwrap_or(1);
107
108    let savings = vec![
109        BenchSavings {
110            label: format!("\"who calls {}?\"", probe),
111            raw_tokens: raw_callers_tokens,
112            tessera_tokens: impact_tokens,
113            ratio: ratio(raw_callers_tokens, impact_tokens),
114        },
115        BenchSavings {
116            label: format!("\"where is {} defined?\"", probe),
117            raw_tokens: raw_definition_tokens,
118            tessera_tokens: find_def_tokens,
119            ratio: ratio(raw_definition_tokens, find_def_tokens),
120        },
121    ];
122
123    let chart = render_chart(
124        &path,
125        full.files_indexed + full.files_reused,
126        full.symbols_indexed,
127        full.references_indexed,
128        full.elapsed_ms,
129        incremental.elapsed_ms,
130        &savings,
131        &queries,
132    );
133
134    Ok(BenchResult {
135        path: path.to_string_lossy().to_string(),
136        files: full.files_indexed + full.files_reused,
137        symbols: full.symbols_indexed,
138        references: full.references_indexed,
139        index_full_ms: full.elapsed_ms,
140        index_incremental_ms: incremental.elapsed_ms,
141        queries,
142        savings,
143        chart,
144    })
145}
146
147fn bench_query<F>(_conn: &rusqlite::Connection, name: &str, mut run_one: F) -> Result<BenchQuery>
148where
149    F: FnMut() -> Result<usize>,
150{
151    let iterations: u128 = 3;
152    let started = Instant::now();
153    let mut tokens = 0;
154    for _ in 0..iterations {
155        tokens = run_one()?;
156    }
157    let elapsed_ms = started.elapsed().as_millis() / iterations;
158    Ok(BenchQuery {
159        name: name.to_string(),
160        ms: elapsed_ms,
161        tokens,
162    })
163}
164
165fn estimate<T: serde::Serialize>(value: &T) -> usize {
166    serde_json::to_vec(value)
167        .map(|bytes| (bytes.len() / 4).max(1))
168        .unwrap_or(1)
169}
170
171fn estimate_raw_grep_tokens(root: &Path, symbol: &str) -> Result<usize> {
172    // Approximate the agent's "grep + read every match" workflow: any source
173    // file that mentions `symbol` would be read in full. We sum those file
174    // sizes and divide by 4 (the same heuristic the per-query estimator uses).
175    let mut total = 0usize;
176    for entry in walkdir::WalkDir::new(root)
177        .into_iter()
178        .filter_map(Result::ok)
179        .filter(|e| e.file_type().is_file())
180    {
181        let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) else {
182            continue;
183        };
184        if crate::types::Language::from_extension(ext).is_none() {
185            continue;
186        }
187        let Ok(content) = fs::read_to_string(entry.path()) else {
188            continue;
189        };
190        if content.contains(symbol) {
191            total += content.len() / 4;
192        }
193    }
194    Ok(total.max(1))
195}
196
197fn estimate_mean_file_tokens(root: &Path) -> Result<usize> {
198    // Agents that ask "where is X defined?" typically read one file —
199    // approximate that cost with the mean source-file token count.
200    let mut total = 0usize;
201    let mut count = 0usize;
202    for entry in walkdir::WalkDir::new(root)
203        .into_iter()
204        .filter_map(Result::ok)
205        .filter(|e| e.file_type().is_file())
206    {
207        let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) else {
208            continue;
209        };
210        if crate::types::Language::from_extension(ext).is_none() {
211            continue;
212        }
213        if let Ok(meta) = entry.metadata() {
214            total += (meta.len() as usize) / 4;
215            count += 1;
216        }
217    }
218    Ok(total.checked_div(count).unwrap_or(1))
219}
220
221fn ratio(raw: usize, tessera: usize) -> f32 {
222    if tessera == 0 {
223        return f32::INFINITY;
224    }
225    raw as f32 / tessera as f32
226}
227
228fn pick_called_symbol(conn: &rusqlite::Connection) -> Result<String> {
229    // Prefer a symbol that has at least one inbound edge (someone calls it).
230    // Falls back to the most-defined function/method name if no edges exist.
231    if let Ok(name) = conn.query_row::<String, _, _>(
232        "
233        SELECT s.name
234        FROM symbols s
235        JOIN edges e ON e.to_symbol_name = s.name OR e.to_symbol_name = s.qualified_name
236        WHERE s.kind IN ('function', 'method')
237        GROUP BY s.name
238        ORDER BY COUNT(*) DESC, s.name
239        LIMIT 1
240        ",
241        [],
242        |row| row.get(0),
243    ) {
244        return Ok(name);
245    }
246    let name: String = conn.query_row(
247        "
248        SELECT name FROM symbols
249        WHERE kind IN ('function', 'method')
250        GROUP BY name
251        ORDER BY COUNT(*) DESC
252        LIMIT 1
253        ",
254        [],
255        |row| row.get(0),
256    )?;
257    Ok(name)
258}
259
260#[allow(clippy::too_many_arguments)]
261fn render_chart(
262    path: &Path,
263    files: usize,
264    symbols: usize,
265    refs: usize,
266    full_ms: u128,
267    inc_ms: u128,
268    savings: &[BenchSavings],
269    queries: &[BenchQuery],
270) -> String {
271    const BAR_W: usize = 32;
272    let mut out = String::new();
273
274    out.push_str(&format!("Tessera v{} bench\n", env!("CARGO_PKG_VERSION")));
275    out.push_str("─────────────────────\n");
276    out.push_str(&format!("Repo: {}\n", short_path(path)));
277    out.push_str(&format!(
278        "  {} files · {} symbols · {} references\n\n",
279        thousands(files),
280        thousands(symbols),
281        thousands(refs)
282    ));
283
284    out.push_str("Index time\n");
285    let max_ms = full_ms.max(inc_ms).max(1) as f32;
286    out.push_str(&format!(
287        "  full         {}  {:>6} ms\n",
288        bar(full_ms as f32 / max_ms, BAR_W),
289        full_ms
290    ));
291    out.push_str(&format!(
292        "  incremental  {}  {:>6} ms",
293        bar(inc_ms as f32 / max_ms, BAR_W),
294        inc_ms
295    ));
296    if inc_ms > 0 && full_ms > inc_ms {
297        out.push_str(&format!(
298            "   ·  {:.0}× faster",
299            full_ms as f32 / inc_ms.max(1) as f32
300        ));
301    }
302    out.push_str("\n\n");
303
304    for s in savings {
305        out.push_str(&format!("{}\n", s.label));
306        let max_tokens = s.raw_tokens.max(s.tessera_tokens).max(1) as f32;
307        out.push_str(&format!(
308            "  raw grep + read   {}  {:>8} tokens\n",
309            bar(s.raw_tokens as f32 / max_tokens, BAR_W),
310            thousands(s.raw_tokens)
311        ));
312        out.push_str(&format!(
313            "  tessera           {}  {:>8} tokens   ·  {:.0}× cheaper\n\n",
314            bar(s.tessera_tokens as f32 / max_tokens, BAR_W),
315            thousands(s.tessera_tokens),
316            s.ratio
317        ));
318    }
319
320    if !queries.is_empty() {
321        out.push_str("Per-query latency  ·  median of 3 runs\n");
322        for q in queries {
323            out.push_str(&format!(
324                "  {:<18} {:>3} ms     ~{:>5} tokens\n",
325                q.name,
326                q.ms,
327                thousands(q.tokens)
328            ));
329        }
330    }
331
332    out
333}
334
335fn bar(fraction: f32, width: usize) -> String {
336    let fraction = fraction.clamp(0.0, 1.0);
337    let filled =
338        ((fraction * width as f32).round() as usize).max(if fraction > 0.0 { 1 } else { 0 });
339    let mut s = String::with_capacity(width);
340    for _ in 0..filled {
341        s.push('█');
342    }
343    for _ in filled..width {
344        s.push(' ');
345    }
346    s
347}
348
349fn thousands(n: usize) -> String {
350    let s = n.to_string();
351    let bytes = s.as_bytes();
352    let mut out = String::with_capacity(s.len() + s.len() / 3);
353    let len = bytes.len();
354    for (i, b) in bytes.iter().enumerate() {
355        if i > 0 && (len - i) % 3 == 0 {
356            out.push(',');
357        }
358        out.push(*b as char);
359    }
360    out
361}
362
363fn short_path(path: &Path) -> String {
364    let s = path.to_string_lossy().to_string();
365    // Tempdirs are noisy; collapse them.
366    if s.contains("/tmp/") || s.contains("/T/") {
367        return "synthetic repo".to_string();
368    }
369    s
370}
371
372fn generate_synthetic_repo(root: &Path, file_count: usize) -> Result<()> {
373    // Models a realistic "popular utility" topology:
374    //   util.ts exports sharedHelper / formatRow / parseInput
375    //   every module_i calls sharedHelper + neighbor bridges
376    // sharedHelper ends up with `file_count` callers, which is what makes the
377    // "who calls sharedHelper?" comparison interesting.
378    fs::create_dir_all(root)?;
379    fs::write(root.join("util.ts"), synthetic_util())?;
380    for i in 0..file_count {
381        let path = root.join(format!("module_{i:03}.ts"));
382        let content = synthetic_module(i, file_count);
383        fs::write(path, content)?;
384    }
385    Ok(())
386}
387
388fn synthetic_util() -> String {
389    "// Shared utility module — imported across the synthetic repo.
390// In real codebases, files like this are the heaviest blast-radius targets
391// for any refactor, which is why Tessera ranks their callers by PageRank.
392
393export function sharedHelper(x: number): number {
394    // Inline computation; mirrors the kind of common helper found in real
395    // codebases that ends up called from dozens or hundreds of places.
396    return (x * 13 + 7) ^ 0;
397}
398
399export function formatRow(row: { id: number; name: string }): string {
400    return `${row.id}\\t${row.name}`;
401}
402
403export function parseInput(raw: string): number {
404    return Number.parseInt(raw, 10);
405}
406"
407    .to_string()
408}
409
410fn synthetic_module(index: usize, total: usize) -> String {
411    let prev = (index + total - 1) % total;
412    let next = (index + 1) % total;
413    format!(
414        "// Module {index} of {total} — autogenerated for `tessera bench`.
415// Each module imports the shared utility module and forwards through a
416// couple of bridge functions, modelling the call topology of real services
417// where a small set of helpers is called from many places.
418
419import {{ sharedHelper, formatRow, parseInput }} from \"./util\";
420
421export interface Module{index}Input {{
422    raw: string;
423    label: string;
424}}
425
426export function moduleEntry{index}(input: Module{index}Input): string {{
427    const parsed = parseInput(input.raw);
428    const value = helper{index}(parsed);
429    return formatRow({{ id: value, name: input.label }});
430}}
431
432function helper{index}(x: number): number {{
433    // Cross-cuts: every helper depends on the shared utility plus its two
434    // neighbours, so the call graph has both a popular target and a chain.
435    return sharedHelper(x) + bridge{prev}(x) + bridge{next}(x);
436}}
437
438export function bridge{index}(x: number): number {{
439    return sharedHelper(x + {index});
440}}
441"
442    )
443}