Skip to main content

codesynapse_core/
benchmark.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::Write;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::error::Result;
8use crate::security::{check_file_size, MAX_GRAPH_FILE_BYTES};
9
10const CHARS_PER_TOKEN: usize = 4;
11
12pub const SAMPLE_QUESTIONS: &[&str] = &[
13    "how does authentication work",
14    "what is the main entry point",
15    "how are errors handled",
16    "what connects the data layer to the api",
17    "what are the core abstractions",
18];
19
20// ---------------------------------------------------------------------------
21// Unicode safety
22// ---------------------------------------------------------------------------
23
24pub fn safe_char<'a>(unicode: &'a str, ascii: &'a str, supports_unicode: bool) -> &'a str {
25    if supports_unicode {
26        unicode
27    } else {
28        ascii
29    }
30}
31
32pub fn hr(width: usize, supports_unicode: bool) -> String {
33    safe_char("─", "-", supports_unicode).repeat(width)
34}
35
36// ---------------------------------------------------------------------------
37// Lightweight graph for benchmarking (loads node-link JSON)
38// ---------------------------------------------------------------------------
39
40#[derive(Debug, Default)]
41struct BenchGraph {
42    nodes: Vec<BenchNode>,
43    node_index: HashMap<String, usize>,
44    adj: HashMap<String, Vec<String>>,
45    edge_count: usize,
46}
47
48#[derive(Debug, Clone)]
49struct BenchNode {
50    id: String,
51    label: String,
52    source_file: String,
53    source_location: String,
54}
55
56fn value_to_id(v: &Value) -> Option<String> {
57    match v {
58        Value::String(s) => Some(s.clone()),
59        Value::Number(n) => Some(n.to_string()),
60        _ => None,
61    }
62}
63
64impl BenchGraph {
65    fn from_json(data: &Value) -> Option<Self> {
66        let mut g = BenchGraph::default();
67
68        let nodes = data.get("nodes")?.as_array()?;
69        for n in nodes {
70            let id = n.get("id").and_then(value_to_id).unwrap_or_default();
71            if id.is_empty() {
72                continue;
73            }
74            let label = n
75                .get("label")
76                .and_then(Value::as_str)
77                .unwrap_or("")
78                .to_string();
79            let source_file = n
80                .get("source_file")
81                .and_then(Value::as_str)
82                .unwrap_or("")
83                .to_string();
84            let source_location = n
85                .get("source_location")
86                .and_then(Value::as_str)
87                .unwrap_or("")
88                .to_string();
89            let idx = g.nodes.len();
90            g.node_index.insert(id.clone(), idx);
91            g.adj.entry(id.clone()).or_default();
92            g.nodes.push(BenchNode {
93                id,
94                label,
95                source_file,
96                source_location,
97            });
98        }
99
100        // Accept "links" or "edges" key
101        let links_key = if data.get("links").is_some() {
102            "links"
103        } else {
104            "edges"
105        };
106        if let Some(links) = data.get(links_key).and_then(Value::as_array) {
107            for e in links {
108                let src = e.get("source").and_then(value_to_id).unwrap_or_default();
109                let tgt = e.get("target").and_then(value_to_id).unwrap_or_default();
110                if src.is_empty() || tgt.is_empty() {
111                    continue;
112                }
113                g.adj.entry(src.clone()).or_default().push(tgt.clone());
114                g.adj.entry(tgt.clone()).or_default().push(src.clone());
115                g.edge_count += 1;
116            }
117        }
118
119        Some(g)
120    }
121
122    fn node_count(&self) -> usize {
123        self.nodes.len()
124    }
125    fn edge_count(&self) -> usize {
126        self.edge_count
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Token estimation
132// ---------------------------------------------------------------------------
133
134fn estimate_tokens(text: &str) -> u64 {
135    (text.len() / CHARS_PER_TOKEN).max(1) as u64
136}
137
138// ---------------------------------------------------------------------------
139// Query terms (port of Python _query_terms from serve.py)
140// ---------------------------------------------------------------------------
141
142fn has_non_ascii(s: &str) -> bool {
143    !s.is_ascii()
144}
145
146fn is_searchable(term: &str) -> bool {
147    if term
148        .chars()
149        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
150    {
151        term.len() > 2
152    } else {
153        true
154    }
155}
156
157fn bench_query_terms(question: &str) -> Vec<String> {
158    let mut terms = Vec::new();
159    for word in question.split_whitespace() {
160        if has_non_ascii(word) {
161            let s = word.to_string();
162            if is_searchable(&s) {
163                terms.push(s);
164            }
165        } else {
166            let lower = word.to_lowercase();
167            let mut start: Option<usize> = None;
168            let chars: Vec<(usize, char)> = lower.char_indices().collect();
169            for (i, c) in &chars {
170                if c.is_alphanumeric() || *c == '_' {
171                    if start.is_none() {
172                        start = Some(*i);
173                    }
174                } else if let Some(s) = start {
175                    let tok = &lower[s..*i];
176                    if is_searchable(tok) {
177                        terms.push(tok.to_string());
178                    }
179                    start = None;
180                }
181            }
182            if let Some(s) = start {
183                let tok = &lower[s..];
184                if is_searchable(tok) {
185                    terms.push(tok.to_string());
186                }
187            }
188        }
189    }
190    terms
191}
192
193// ---------------------------------------------------------------------------
194// Core: BFS subgraph token estimation
195// ---------------------------------------------------------------------------
196
197pub fn query_subgraph_tokens(g_data: &Value, question: &str, depth: u32) -> u64 {
198    let g = match BenchGraph::from_json(g_data) {
199        Some(g) => g,
200        None => return 0,
201    };
202    query_subgraph_tokens_inner(&g, question, depth)
203}
204
205fn query_subgraph_tokens_inner(g: &BenchGraph, question: &str, depth: u32) -> u64 {
206    let terms = bench_query_terms(question);
207    if terms.is_empty() {
208        return 0;
209    }
210
211    // Score nodes by term matches in label
212    let mut scored: Vec<(usize, &str)> = g
213        .nodes
214        .iter()
215        .map(|n| {
216            let label = n.label.to_lowercase();
217            let score = terms.iter().filter(|t| label.contains(t.as_str())).count();
218            (score, n.id.as_str())
219        })
220        .filter(|(s, _)| *s > 0)
221        .collect();
222    scored.sort_by_key(|k| std::cmp::Reverse(k.0));
223
224    let start_ids: Vec<&str> = scored.iter().take(3).map(|(_, id)| *id).collect();
225    if start_ids.is_empty() {
226        return 0;
227    }
228
229    // BFS
230    let mut visited: HashSet<&str> = start_ids.iter().copied().collect();
231    let mut frontier: VecDeque<&str> = start_ids.iter().copied().collect();
232    let mut edges_seen: Vec<(&str, &str)> = Vec::new();
233
234    for _ in 0..depth {
235        let current: Vec<&str> = frontier.drain(..).collect();
236        for n in &current {
237            if let Some(neighbors) = g.adj.get(*n) {
238                for nb in neighbors {
239                    let nb_s = nb.as_str();
240                    if !visited.contains(nb_s) {
241                        visited.insert(nb_s);
242                        frontier.push_back(nb_s);
243                        edges_seen.push((n, nb_s));
244                    }
245                }
246            }
247        }
248    }
249
250    // Build text like Python does
251    let mut lines = Vec::new();
252    for nid in &visited {
253        if let Some(&idx) = g.node_index.get(*nid) {
254            let n = &g.nodes[idx];
255            lines.push(format!(
256                "NODE {} src={} loc={}",
257                n.label, n.source_file, n.source_location
258            ));
259        }
260    }
261    for (u, v) in &edges_seen {
262        if visited.contains(u) && visited.contains(v) {
263            lines.push(format!("EDGE {} --> {}", u, v));
264        }
265    }
266
267    if lines.is_empty() {
268        return 0;
269    }
270    estimate_tokens(&lines.join("\n"))
271}
272
273// ---------------------------------------------------------------------------
274// Public result types
275// ---------------------------------------------------------------------------
276
277#[derive(Debug, Clone)]
278pub struct QuestionResult {
279    pub question: String,
280    pub query_tokens: u64,
281    pub reduction: f64,
282}
283
284#[derive(Debug, Clone, Default)]
285pub struct BenchmarkResult {
286    pub corpus_tokens: u64,
287    pub corpus_words: u64,
288    pub nodes: usize,
289    pub edges: usize,
290    pub avg_query_tokens: u64,
291    pub reduction_ratio: f64,
292    pub per_question: Vec<QuestionResult>,
293    pub error: Option<String>,
294}
295
296// ---------------------------------------------------------------------------
297// run_benchmark
298// ---------------------------------------------------------------------------
299
300pub fn run_benchmark(
301    graph_path: &Path,
302    corpus_words: Option<u64>,
303    questions: Option<&[&str]>,
304    max_bytes: Option<u64>,
305) -> Result<BenchmarkResult> {
306    let cap = max_bytes.unwrap_or(MAX_GRAPH_FILE_BYTES);
307    check_file_size(graph_path, cap)?;
308
309    let text = std::fs::read_to_string(graph_path)?;
310    let data: Value = serde_json::from_str(&text)?;
311
312    let g = match BenchGraph::from_json(&data) {
313        Some(g) => g,
314        None => {
315            return Ok(BenchmarkResult {
316                error: Some("Failed to parse graph JSON".to_string()),
317                ..Default::default()
318            })
319        }
320    };
321
322    let node_count = g.node_count();
323    let edge_count = g.edge_count();
324    let corpus_words = corpus_words.unwrap_or(node_count as u64 * 50);
325    let corpus_tokens = corpus_words * 100 / 75;
326
327    let qs = questions.unwrap_or(SAMPLE_QUESTIONS);
328    let mut per_question: Vec<QuestionResult> = Vec::new();
329
330    for q in qs {
331        let qt = query_subgraph_tokens_inner(&g, q, 3);
332        if qt > 0 {
333            let reduction = if qt > 0 {
334                (corpus_tokens as f64 / qt as f64 * 10.0).round() / 10.0
335            } else {
336                0.0
337            };
338            per_question.push(QuestionResult {
339                question: q.to_string(),
340                query_tokens: qt,
341                reduction,
342            });
343        }
344    }
345
346    if per_question.is_empty() {
347        return Ok(BenchmarkResult {
348            error: Some(
349                "No matching nodes found for sample questions. Build the graph first.".to_string(),
350            ),
351            nodes: node_count,
352            edges: edge_count,
353            corpus_tokens,
354            corpus_words,
355            ..Default::default()
356        });
357    }
358
359    let avg_query_tokens =
360        per_question.iter().map(|p| p.query_tokens).sum::<u64>() / per_question.len() as u64;
361    let reduction_ratio = if avg_query_tokens > 0 {
362        (corpus_tokens as f64 / avg_query_tokens as f64 * 10.0).round() / 10.0
363    } else {
364        0.0
365    };
366
367    Ok(BenchmarkResult {
368        corpus_tokens,
369        corpus_words,
370        nodes: node_count,
371        edges: edge_count,
372        avg_query_tokens,
373        reduction_ratio,
374        per_question,
375        error: None,
376    })
377}
378
379// ---------------------------------------------------------------------------
380// print_benchmark
381// ---------------------------------------------------------------------------
382
383pub fn print_benchmark(result: &BenchmarkResult) {
384    print_benchmark_to(result, &mut std::io::stdout(), true);
385}
386
387pub fn print_benchmark_to<W: Write>(result: &BenchmarkResult, out: &mut W, supports_unicode: bool) {
388    if let Some(ref err) = result.error {
389        let _ = writeln!(out, "Benchmark error: {err}");
390        return;
391    }
392
393    let arrow = safe_char("\u{2192}", "->", supports_unicode);
394    let _ = writeln!(out, "\ncodesynapse token reduction benchmark");
395    let _ = writeln!(out, "{}", hr(50, supports_unicode));
396    let _ = writeln!(
397        out,
398        "  Corpus:          {} words {} ~{} tokens (naive)",
399        result.corpus_words, arrow, result.corpus_tokens
400    );
401    let _ = writeln!(
402        out,
403        "  Graph:           {} nodes, {} edges",
404        result.nodes, result.edges
405    );
406    let _ = writeln!(
407        out,
408        "  Avg query cost:  ~{} tokens",
409        result.avg_query_tokens
410    );
411    let _ = writeln!(
412        out,
413        "  Reduction:       {}x fewer tokens per query",
414        result.reduction_ratio
415    );
416    let _ = writeln!(out, "\n  Per question:");
417    for p in &result.per_question {
418        let q = if p.question.len() > 55 {
419            &p.question[..55]
420        } else {
421            &p.question
422        };
423        let _ = writeln!(out, "    [{}x] {}", p.reduction, q);
424    }
425    let _ = writeln!(out);
426}
427
428// ---------------------------------------------------------------------------
429// Tests
430// ---------------------------------------------------------------------------
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use serde_json::json;
436    use std::io::Write;
437    use tempfile::NamedTempFile;
438
439    fn make_graph_value() -> Value {
440        json!({
441            "nodes": [
442                {"id": "n1", "label": "authentication", "source_file": "auth.py", "source_location": "L1", "community": 0},
443                {"id": "n2", "label": "api_handler",    "source_file": "api.py",  "source_location": "L5", "community": 0},
444                {"id": "n3", "label": "main_entry",     "source_file": "main.py", "source_location": "L1", "community": 1},
445                {"id": "n4", "label": "error_handler",  "source_file": "errors.py","source_location": "L1", "community": 1},
446                {"id": "n5", "label": "database_layer", "source_file": "db.py",   "source_location": "L1", "community": 2}
447            ],
448            "links": [
449                {"source": "n1", "target": "n2", "relation": "calls",   "confidence": "INFERRED"},
450                {"source": "n2", "target": "n3", "relation": "imports", "confidence": "EXTRACTED"},
451                {"source": "n3", "target": "n4", "relation": "uses",    "confidence": "EXTRACTED"},
452                {"source": "n5", "target": "n2", "relation": "provides","confidence": "EXTRACTED"}
453            ]
454        })
455    }
456
457    fn write_graph_file(data: &Value) -> NamedTempFile {
458        let mut f = NamedTempFile::new().unwrap();
459        write!(f, "{}", serde_json::to_string(data).unwrap()).unwrap();
460        f
461    }
462
463    // --- query_subgraph_tokens ---
464
465    #[test]
466    fn test_query_returns_positive_for_matching_question() {
467        let g = make_graph_value();
468        let tokens = query_subgraph_tokens(&g, "how does authentication work", 3);
469        assert!(tokens > 0, "expected >0 tokens, got {tokens}");
470    }
471
472    #[test]
473    fn test_query_returns_zero_for_no_match() {
474        let g = make_graph_value();
475        let tokens = query_subgraph_tokens(&g, "xyzzy plugh zorkmid", 3);
476        assert_eq!(tokens, 0);
477    }
478
479    #[test]
480    fn test_query_bfs_expands_neighbors() {
481        let g = make_graph_value();
482        let tokens_deep = query_subgraph_tokens(&g, "authentication", 3);
483        let tokens_shallow = query_subgraph_tokens(&g, "authentication", 1);
484        assert!(
485            tokens_deep >= tokens_shallow,
486            "deep={tokens_deep} shallow={tokens_shallow}"
487        );
488    }
489
490    #[test]
491    fn test_query_keeps_short_non_english_terms() {
492        let g = json!({
493            "nodes": [
494                {"id": "frontend", "label": "\u{524d}\u{7aef}", "source_file": "docs/frontend.md", "source_location": "L1", "community": 0}
495            ],
496            "links": []
497        });
498        let tokens = query_subgraph_tokens(&g, "\u{524d}\u{7aef}", 1);
499        assert!(tokens > 0, "expected >0 for Chinese query term");
500    }
501
502    // --- run_benchmark ---
503
504    #[test]
505    fn test_run_benchmark_returns_reduction() {
506        let data = make_graph_value();
507        let f = write_graph_file(&data);
508        let result = run_benchmark(f.path(), Some(10_000), None, None).unwrap();
509        assert!(result.error.is_none());
510        assert!(
511            result.reduction_ratio > 1.0,
512            "reduction_ratio={}",
513            result.reduction_ratio
514        );
515    }
516
517    #[test]
518    fn test_run_benchmark_corpus_tokens_proportional() {
519        let data = make_graph_value();
520        let f = write_graph_file(&data);
521        let r1 = run_benchmark(f.path(), Some(1_000), None, None).unwrap();
522        let r2 = run_benchmark(f.path(), Some(10_000), None, None).unwrap();
523        let diff = (r2.corpus_tokens as i64 - r1.corpus_tokens as i64 * 10).unsigned_abs();
524        assert!(
525            diff <= r1.corpus_tokens,
526            "corpus_tokens not proportional: r1={} r2={} diff={}",
527            r1.corpus_tokens,
528            r2.corpus_tokens,
529            diff
530        );
531    }
532
533    #[test]
534    fn test_run_benchmark_per_question_list() {
535        let data = make_graph_value();
536        let f = write_graph_file(&data);
537        let qs = &["how does authentication work", "what is the main entry"];
538        let result = run_benchmark(f.path(), Some(5_000), Some(qs), None).unwrap();
539        assert!(!result.per_question.is_empty());
540        for p in &result.per_question {
541            assert!(!p.question.is_empty());
542            assert!(p.query_tokens > 0);
543            assert!(p.reduction > 0.0);
544        }
545    }
546
547    #[test]
548    fn test_run_benchmark_estimates_corpus_if_no_words() {
549        let data = make_graph_value();
550        let f = write_graph_file(&data);
551        let result = run_benchmark(f.path(), None, None, None).unwrap();
552        assert!(result.corpus_words > 0, "corpus_words should be estimated");
553    }
554
555    #[test]
556    fn test_run_benchmark_error_on_empty_graph() {
557        let data = json!({"nodes": [], "links": []});
558        let f = write_graph_file(&data);
559        let result = run_benchmark(f.path(), Some(1_000), None, None).unwrap();
560        assert!(result.error.is_some(), "expected error for empty graph");
561    }
562
563    #[test]
564    fn test_run_benchmark_includes_node_edge_counts() {
565        let data = make_graph_value();
566        let f = write_graph_file(&data);
567        let result = run_benchmark(f.path(), Some(5_000), None, None).unwrap();
568        assert_eq!(result.nodes, 5, "nodes={}", result.nodes);
569        assert_eq!(result.edges, 4, "edges={}", result.edges);
570    }
571
572    // --- print_benchmark ---
573
574    #[test]
575    fn test_print_benchmark_no_crash() {
576        let data = make_graph_value();
577        let f = write_graph_file(&data);
578        let result = run_benchmark(f.path(), Some(5_000), None, None).unwrap();
579        let mut buf = Vec::new();
580        print_benchmark_to(&result, &mut buf, true);
581        let out = String::from_utf8(buf).unwrap();
582        assert!(out.to_lowercase().contains("reduction"), "out={out}");
583        assert!(out.contains('x'), "out={out}");
584    }
585
586    #[test]
587    fn test_print_benchmark_error_message() {
588        let result = BenchmarkResult {
589            error: Some("test error message".to_string()),
590            ..Default::default()
591        };
592        let mut buf = Vec::new();
593        print_benchmark_to(&result, &mut buf, true);
594        let out = String::from_utf8(buf).unwrap();
595        assert!(out.contains("test error message"), "out={out}");
596    }
597
598    // --- safe_char / hr ---
599
600    #[test]
601    fn test_safe_char_unicode_mode() {
602        assert_eq!(safe_char("\u{2192}", "->", true), "\u{2192}");
603        assert_eq!(hr(5, true), "\u{2500}".repeat(5));
604    }
605
606    #[test]
607    fn test_safe_char_ascii_mode() {
608        assert_eq!(safe_char("\u{2192}", "->", false), "->");
609        assert_eq!(hr(5, false), "-----");
610    }
611
612    #[test]
613    fn test_print_benchmark_ascii_only_output() {
614        let data = make_graph_value();
615        let f = write_graph_file(&data);
616        let result = run_benchmark(f.path(), Some(5_000), None, None).unwrap();
617        let mut buf = Vec::new();
618        print_benchmark_to(&result, &mut buf, false);
619        let out = String::from_utf8(buf).unwrap();
620        assert!(out.to_lowercase().contains("reduction"), "out={out}");
621        assert!(!out.contains('\u{2500}'), "should not contain ─");
622        assert!(!out.contains('\u{2192}'), "should not contain →");
623    }
624
625    #[test]
626    fn test_run_benchmark_rejects_oversized_graph() {
627        let data = make_graph_value();
628        let f = write_graph_file(&data);
629        let result = run_benchmark(f.path(), Some(5_000), None, Some(8));
630        assert!(result.is_err(), "expected Err for oversized graph");
631        let msg = result.unwrap_err().to_string();
632        assert!(msg.contains("exceeds"), "msg={msg}");
633    }
634}