1mod scenarios;
10
11use std::collections::BTreeMap;
12use std::time::Instant;
13
14use serde::Serialize;
15
16use crate::core::{benchmark, bm25_index::BM25Index};
17
18#[derive(Debug, Clone, Serialize, PartialEq)]
20pub struct ScenarioScore {
21 pub name: String,
22 pub files: usize,
23 pub raw_tokens: usize,
24 pub best_mode: String,
25 pub best_savings_pct: f64,
26 pub savings_by_mode: BTreeMap<String, f64>,
27 pub queries: usize,
28 pub recall_at_5: f64,
29 pub recall_at_10: f64,
30 pub mrr: f64,
31 pub search_latency_us_p50: u64,
33}
34
35#[derive(Debug, Clone, Serialize, PartialEq)]
37pub struct Aggregate {
38 pub avg_savings_pct: f64,
39 pub avg_recall_at_5: f64,
40 pub avg_recall_at_10: f64,
41 pub avg_mrr: f64,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct Scorecard {
47 pub schema_version: u32,
48 pub tokenizer: String,
49 pub determinism_digest: String,
53 pub scenarios: Vec<ScenarioScore>,
54 pub aggregate: Aggregate,
55}
56
57impl Scorecard {
58 fn compute_digest(scenarios: &[ScenarioScore]) -> String {
61 let mut parts: Vec<String> = Vec::new();
62 for s in scenarios {
63 let modes: Vec<String> = s
64 .savings_by_mode
65 .iter()
66 .map(|(m, v)| format!("{m}={v:.2}"))
67 .collect();
68 parts.push(format!(
69 "{}|raw={}|best={}:{:.2}|r5={:.4}|r10={:.4}|mrr={:.4}|{}",
70 s.name,
71 s.raw_tokens,
72 s.best_mode,
73 s.best_savings_pct,
74 s.recall_at_5,
75 s.recall_at_10,
76 s.mrr,
77 modes.join(",")
78 ));
79 }
80 parts.join(";")
81 }
82
83 pub fn to_json(&self) -> String {
84 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
85 }
86
87 pub fn to_human(&self) -> String {
89 let mut out = String::new();
90 out.push_str("lean-ctx scorecard\n");
91 out.push_str(&format!("tokenizer: {}\n", self.tokenizer));
92 out.push_str(&format!("digest: {}\n\n", self.determinism_digest));
93 out.push_str(
94 "scenario files raw_tokens best_mode savings% R@5 R@10 MRR p50(us)\n",
95 );
96 out.push_str(
97 "--------------------------------------------------------------------------------\n",
98 );
99 for s in &self.scenarios {
100 out.push_str(&format!(
101 "{:<10} {:>5} {:>10} {:<13} {:>7.2} {:>4.2} {:>4.2} {:>4.2} {:>7}\n",
102 s.name,
103 s.files,
104 s.raw_tokens,
105 s.best_mode,
106 s.best_savings_pct,
107 s.recall_at_5,
108 s.recall_at_10,
109 s.mrr,
110 s.search_latency_us_p50,
111 ));
112 }
113 out.push_str(
114 "--------------------------------------------------------------------------------\n",
115 );
116 out.push_str(&format!(
117 "aggregate {:<13} {:>7.2} {:>4.2} {:>4.2} {:>4.2}\n",
118 "",
119 self.aggregate.avg_savings_pct,
120 self.aggregate.avg_recall_at_5,
121 self.aggregate.avg_recall_at_10,
122 self.aggregate.avg_mrr,
123 ));
124 out
125 }
126}
127
128pub fn run_scorecard() -> std::io::Result<Scorecard> {
130 let mut scenarios = Vec::with_capacity(scenarios::SCENARIOS.len());
131 for sc in scenarios::SCENARIOS {
132 scenarios.push(run_one(sc)?);
133 }
134 let aggregate = aggregate(&scenarios);
135 let determinism_digest = Scorecard::compute_digest(&scenarios);
136 Ok(Scorecard {
137 schema_version: 1,
138 tokenizer: crate::core::tokens::counting_family_label(),
139 determinism_digest,
140 scenarios,
141 aggregate,
142 })
143}
144
145fn run_one(sc: &scenarios::Scenario) -> std::io::Result<ScenarioScore> {
146 let dir = tempfile::TempDir::new()?;
147 let root = dir.path();
148 let queries = scenarios::materialize(sc, root)?;
149
150 let bench = benchmark::run_project_benchmark(&root.to_string_lossy());
152 let mut savings_by_mode = BTreeMap::new();
153 for m in &bench.mode_summaries {
154 savings_by_mode.insert(m.mode.clone(), round2(m.avg_savings_pct));
155 }
156 let (best_mode, best_savings_pct) = savings_by_mode
157 .iter()
158 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
159 .map_or_else(|| ("none".to_string(), 0.0), |(m, v)| (m.clone(), *v));
160
161 let index = BM25Index::build_from_directory(root);
163 let mut sum_r5 = 0.0;
164 let mut sum_r10 = 0.0;
165 let mut sum_mrr = 0.0;
166 let mut latencies: Vec<u64> = Vec::with_capacity(queries.len());
167 for q in &queries {
168 let start = Instant::now();
169 let results = index.search(&q.query, 10);
170 latencies.push(start.elapsed().as_micros() as u64);
171 let files = dedup_files(&results);
172 sum_r5 += recall_at_k(&files, &q.expected_file, 5);
173 sum_r10 += recall_at_k(&files, &q.expected_file, 10);
174 sum_mrr += reciprocal_rank(&files, &q.expected_file);
175 }
176 let n = queries.len().max(1) as f64;
177
178 Ok(ScenarioScore {
179 name: sc.name.to_string(),
180 files: sc.files,
181 raw_tokens: bench.total_raw_tokens,
182 best_mode,
183 best_savings_pct,
184 savings_by_mode,
185 queries: queries.len(),
186 recall_at_5: round2(sum_r5 / n),
187 recall_at_10: round2(sum_r10 / n),
188 mrr: round2(sum_mrr / n),
189 search_latency_us_p50: percentile_p50(&mut latencies),
190 })
191}
192
193fn aggregate(scenarios: &[ScenarioScore]) -> Aggregate {
194 if scenarios.is_empty() {
195 return Aggregate {
196 avg_savings_pct: 0.0,
197 avg_recall_at_5: 0.0,
198 avg_recall_at_10: 0.0,
199 avg_mrr: 0.0,
200 };
201 }
202 let n = scenarios.len() as f64;
203 Aggregate {
204 avg_savings_pct: round2(scenarios.iter().map(|s| s.best_savings_pct).sum::<f64>() / n),
205 avg_recall_at_5: round2(scenarios.iter().map(|s| s.recall_at_5).sum::<f64>() / n),
206 avg_recall_at_10: round2(scenarios.iter().map(|s| s.recall_at_10).sum::<f64>() / n),
207 avg_mrr: round2(scenarios.iter().map(|s| s.mrr).sum::<f64>() / n),
208 }
209}
210
211fn dedup_files(results: &[crate::core::bm25_index::SearchResult]) -> Vec<String> {
213 let mut seen = std::collections::HashSet::new();
214 let mut files = Vec::new();
215 for r in results {
216 if seen.insert(r.file_path.clone()) {
217 files.push(r.file_path.clone());
218 }
219 }
220 files
221}
222
223fn path_matches(retrieved: &str, expected: &str) -> bool {
225 let r = retrieved.replace('\\', "/");
226 let e = expected.replace('\\', "/");
227 r.ends_with(&e) || e.ends_with(&r)
228}
229
230fn recall_at_k(files: &[String], expected: &str, k: usize) -> f64 {
231 if files.iter().take(k).any(|f| path_matches(f, expected)) {
232 1.0
233 } else {
234 0.0
235 }
236}
237
238fn reciprocal_rank(files: &[String], expected: &str) -> f64 {
239 for (i, f) in files.iter().enumerate() {
240 if path_matches(f, expected) {
241 return 1.0 / (i as f64 + 1.0);
242 }
243 }
244 0.0
245}
246
247fn percentile_p50(latencies: &mut [u64]) -> u64 {
248 if latencies.is_empty() {
249 return 0;
250 }
251 latencies.sort_unstable();
252 latencies[latencies.len() / 2]
253}
254
255fn round2(v: f64) -> f64 {
256 (v * 100.0).round() / 100.0
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn path_matching_is_suffix_based() {
265 assert!(path_matches(
266 "/tmp/x/src/auth/file_001.rs",
267 "src/auth/file_001.rs"
268 ));
269 assert!(!path_matches(
270 "src/auth/file_002.rs",
271 "src/auth/file_001.rs"
272 ));
273 }
274
275 #[test]
276 fn recall_and_rr_basics() {
277 let files = vec![
278 "src/db/file_000.rs".to_string(),
279 "src/auth/file_001.rs".to_string(),
280 ];
281 assert_eq!(recall_at_k(&files, "src/auth/file_001.rs", 5), 1.0);
282 assert_eq!(recall_at_k(&files, "src/auth/file_001.rs", 1), 0.0);
283 assert_eq!(reciprocal_rank(&files, "src/auth/file_001.rs"), 0.5);
284 assert_eq!(reciprocal_rank(&files, "src/missing.rs"), 0.0);
285 }
286
287 #[test]
288 fn round2_rounds() {
289 assert_eq!(round2(12.3456), 12.35);
290 assert_eq!(round2(0.0), 0.0);
291 }
292}