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
20pub 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#[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 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
130fn estimate_tokens(text: &str) -> u64 {
135 (text.len() / CHARS_PER_TOKEN).max(1) as u64
136}
137
138fn 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
193pub 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 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 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 ¤t {
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 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#[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
296pub 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
379pub 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#[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 #[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 #[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 #[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 #[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}