Skip to main content

lean_ctx/core/scorecard/
mod.rs

1//! Reproducible scorecard (#211): one command → compression savings + retrieval
2//! recall/MRR + latency over a fixed, deterministic scenario matrix.
3//!
4//! The quality metrics (savings, recall, MRR) are reproducible run-to-run and
5//! machine-to-machine because the corpus is generated deterministically and the
6//! retrieval path is pure BM25. Latency is measured wall-clock and therefore
7//! reported but not part of the determinism contract (see `determinism_digest`).
8
9mod scenarios;
10
11use std::collections::BTreeMap;
12use std::time::Instant;
13
14use serde::Serialize;
15
16use crate::core::{benchmark, bm25_index::BM25Index};
17
18/// Per-scenario result.
19#[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    /// Wall-clock; informational only (not part of the determinism contract).
32    pub search_latency_us_p50: u64,
33}
34
35/// Cross-scenario averages.
36#[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/// The full scorecard.
45#[derive(Debug, Clone, Serialize)]
46pub struct Scorecard {
47    pub schema_version: u32,
48    pub tokenizer: String,
49    /// Stable fingerprint of the reproducible (latency-free) metrics. Serialized
50    /// so the JSON artifact is self-verifying: two runs on the same code (any
51    /// machine) yield the same digest.
52    pub determinism_digest: String,
53    pub scenarios: Vec<ScenarioScore>,
54    pub aggregate: Aggregate,
55}
56
57impl Scorecard {
58    /// Compute the stable fingerprint from scenario scores. Latency is excluded
59    /// by construction, so two runs on the same code must produce the same value.
60    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    /// Human-readable scorecard table.
88    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
128/// Run the full scenario matrix and assemble the scorecard.
129pub 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    // --- Compression savings (existing, tested benchmark path) ---
151    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    // --- Retrieval quality (pure BM25 → deterministic, feature-independent) ---
162    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
211/// Unique file paths in rank order (the first chunk of each file defines rank).
212fn 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
223/// Platform-independent suffix match (mirrors `eval_harness` convention).
224fn 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}