Skip to main content

vasari_core/
eval.rs

1//! Attribution accuracy evaluation.
2//!
3//! Scores `vasari why` against a labeled corpus and reports the v0.1 go/no-go
4//! gate: does `why` return the correct top-level Intent often enough to be the
5//! hero verb? See `tests/corpus/attribution/` for the corpus + protocol.
6//!
7//! What this measures, honestly: **file→intent attribution accuracy**. The current
8//! attributor is whole-file (every line of a file resolves to the same intent), so
9//! the `:line` in `why <file>:<line>` is decorative under v0.1. The evaluator
10//! reports single-intent-file vs multi-intent-file accuracy separately so a blended
11//! number can't hide a multi-intent collapse, and reports recall (`why_all` contains
12//! the right intent) alongside precision (`why`'s single top pick is right).
13
14use serde::Deserialize;
15
16use std::collections::HashSet;
17
18use crate::error::VasariError;
19use crate::resolve::why_all;
20use crate::store::ObjectStore;
21
22/// z for a 95% confidence interval.
23pub const Z_95: f64 = 1.96;
24/// Default token-overlap threshold for matching a returned intent to a label.
25pub const MATCH_THRESHOLD: f64 = 0.6;
26/// Minimum corpus size before the gate is eligible to PASS/FAIL.
27/// Below this the result is reported but the gate is not evaluated — a 10-line
28/// pilot cannot clear the Wilson floor (8/10 ≈ 0.49), so gating on it would
29/// wedge `why` off permanently.
30pub const N_MIN: u64 = 100;
31/// Accuracy bar (design doc): ≥80% correct top-level Intent.
32pub const ACCURACY_BAR: f64 = 0.80;
33/// Wilson 95% lower-bound bar (eng-review T4): ≥70%.
34pub const WILSON_BAR: f64 = 0.70;
35
36/// Wilson score interval lower bound for a binomial proportion.
37///
38/// Plain Wilson form (no continuity correction). Chosen over the normal
39/// approximation because the corpus is small-n, where the normal approximation
40/// is badly miscalibrated.
41pub fn wilson_lower_bound(successes: u64, n: u64, z: f64) -> f64 {
42    if n == 0 {
43        return 0.0;
44    }
45    let n_f = n as f64;
46    let phat = successes as f64 / n_f;
47    let z2 = z * z;
48    let denom = 1.0 + z2 / n_f;
49    let center = phat + z2 / (2.0 * n_f);
50    let margin = z * ((phat * (1.0 - phat) / n_f) + z2 / (4.0 * n_f * n_f)).sqrt();
51    ((center - margin) / denom).max(0.0)
52}
53
54/// Tokenize for matching: lowercase, split on non-alphanumerics, drop empties.
55fn normalize_tokens(s: &str) -> Vec<String> {
56    s.to_lowercase()
57        .split(|c: char| !c.is_alphanumeric())
58        .filter(|t| !t.is_empty())
59        .map(|t| t.to_string())
60        .collect()
61}
62
63/// True if `candidate` covers at least `threshold` of `expected`'s tokens.
64///
65/// Token-overlap, not substring-subset: a short label like "auth" must not match
66/// a long unrelated prompt and inflate accuracy.
67pub fn intent_matches(expected: &str, candidate: &str, threshold: f64) -> bool {
68    let exp = normalize_tokens(expected);
69    if exp.is_empty() {
70        return false;
71    }
72    let cand: std::collections::HashSet<String> = normalize_tokens(candidate).into_iter().collect();
73    let hits = exp.iter().filter(|t| cand.contains(*t)).count();
74    (hits as f64 / exp.len() as f64) >= threshold
75}
76
77/// One ground-truth label. `expected_intent` is a list so multi-intent files can
78/// name every contributor; an empty list means "should return nothing."
79#[derive(Debug, Clone, Deserialize)]
80pub struct Label {
81    pub file: String,
82    pub line: u32,
83    #[serde(default)]
84    pub expected_intent: Vec<String>,
85    #[serde(default = "default_confidence_threshold")]
86    pub confidence_threshold: f32,
87    #[serde(default)]
88    pub notes: Option<String>,
89}
90
91fn default_confidence_threshold() -> f32 {
92    0.5
93}
94
95/// Parse a labels JSONL document (one [`Label`] per non-empty line).
96pub fn parse_labels(jsonl: &str) -> Result<Vec<Label>, serde_json::Error> {
97    jsonl
98        .lines()
99        .filter(|l| !l.trim().is_empty())
100        .map(serde_json::from_str)
101        .collect()
102}
103
104/// Per-label scoring outcome.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Outcome {
107    /// Top pick matched an expected intent (also a recall hit).
108    PrecisionHit,
109    /// Top pick wrong, but `why_all` contained a match (recall hit, precision miss).
110    RecallOnly,
111    /// Answered, nothing matched.
112    Wrong,
113    /// Expected nothing and got nothing.
114    NoAnswerCorrect,
115    /// Expected something, got nothing.
116    NoAnswerMiss,
117}
118
119impl Outcome {
120    fn precision_ok(self) -> bool {
121        matches!(self, Outcome::PrecisionHit | Outcome::NoAnswerCorrect)
122    }
123    fn recall_ok(self) -> bool {
124        matches!(
125            self,
126            Outcome::PrecisionHit | Outcome::RecallOnly | Outcome::NoAnswerCorrect
127        )
128    }
129    fn symbol(self) -> &'static str {
130        match self {
131            Outcome::PrecisionHit | Outcome::NoAnswerCorrect => "✓",
132            Outcome::RecallOnly => "~",
133            Outcome::Wrong | Outcome::NoAnswerMiss => "✗",
134        }
135    }
136}
137
138/// One scored row in the report.
139#[derive(Debug, Clone)]
140pub struct Row {
141    pub file: String,
142    pub line: u32,
143    pub expected: Vec<String>,
144    pub got: Option<String>,
145    pub multi_intent: bool,
146    pub outcome: Outcome,
147}
148
149/// Whether the gate passed, failed, or could not be evaluated.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum GateStatus {
152    Pass,
153    Fail,
154    /// `n < N_MIN` — reported but not gate-eligible.
155    InsufficientCorpus,
156}
157
158/// Full evaluation result over a corpus.
159#[derive(Debug, Clone)]
160pub struct EvalReport {
161    pub rows: Vec<Row>,
162    pub n: u64,
163    pub precision_correct: u64,
164    pub recall_correct: u64,
165    pub single_total: u64,
166    pub single_correct: u64,
167    pub multi_total: u64,
168    pub multi_correct: u64,
169    pub match_threshold: f64,
170}
171
172impl EvalReport {
173    pub fn accuracy(&self) -> f64 {
174        if self.n == 0 {
175            0.0
176        } else {
177            self.precision_correct as f64 / self.n as f64
178        }
179    }
180    pub fn recall(&self) -> f64 {
181        if self.n == 0 {
182            0.0
183        } else {
184            self.recall_correct as f64 / self.n as f64
185        }
186    }
187    pub fn wilson_lower(&self) -> f64 {
188        wilson_lower_bound(self.precision_correct, self.n, Z_95)
189    }
190    pub fn single_accuracy(&self) -> f64 {
191        ratio(self.single_correct, self.single_total)
192    }
193    pub fn multi_accuracy(&self) -> f64 {
194        ratio(self.multi_correct, self.multi_total)
195    }
196
197    pub fn status(&self) -> GateStatus {
198        if self.n < N_MIN {
199            GateStatus::InsufficientCorpus
200        } else if self.accuracy() >= ACCURACY_BAR && self.wilson_lower() >= WILSON_BAR {
201            GateStatus::Pass
202        } else {
203            GateStatus::Fail
204        }
205    }
206
207    /// Human-readable report: per-line table + split summary + status.
208    pub fn render(&self) -> String {
209        let mut out = String::new();
210        out.push_str("Attribution accuracy report\n");
211        out.push_str("===========================\n\n");
212        out.push_str(&format!(
213            "{:<32} {:>5}  {:<5} expected -> got\n",
214            "file", "line", "ok"
215        ));
216        for r in &self.rows {
217            let exp = if r.expected.is_empty() {
218                "(none)".to_string()
219            } else {
220                r.expected.join(" | ")
221            };
222            let got = r.got.as_deref().unwrap_or("(no answer)");
223            out.push_str(&format!(
224                "{:<32} {:>5}  {:<5} {}{} -> {}\n",
225                truncate(&r.file, 32),
226                r.line,
227                r.outcome.symbol(),
228                if r.multi_intent { "[multi] " } else { "" },
229                truncate(&exp, 48),
230                truncate(got, 48),
231            ));
232        }
233        out.push('\n');
234        out.push_str(&format!(
235            "n={}  precision(accuracy)={:.1}%  recall={:.1}%  Wilson95-lower={:.1}%  (match_threshold={:.2})\n",
236            self.n,
237            self.accuracy() * 100.0,
238            self.recall() * 100.0,
239            self.wilson_lower() * 100.0,
240            self.match_threshold,
241        ));
242        out.push_str(&format!(
243            "single-intent files: {}/{} = {:.1}%   multi-intent files: {}/{} = {:.1}%\n",
244            self.single_correct,
245            self.single_total,
246            self.single_accuracy() * 100.0,
247            self.multi_correct,
248            self.multi_total,
249            self.multi_accuracy() * 100.0,
250        ));
251        let status = match self.status() {
252            GateStatus::Pass => format!(
253                "PASS (accuracy ≥ {:.0}% and Wilson ≥ {:.0}%)",
254                ACCURACY_BAR * 100.0,
255                WILSON_BAR * 100.0
256            ),
257            GateStatus::Fail => format!(
258                "FAIL (need accuracy ≥ {:.0}% and Wilson ≥ {:.0}%)",
259                ACCURACY_BAR * 100.0,
260                WILSON_BAR * 100.0
261            ),
262            GateStatus::InsufficientCorpus => format!(
263                "INSUFFICIENT_CORPUS (n={} < {} — gate not evaluated; grow the corpus)",
264                self.n, N_MIN
265            ),
266        };
267        out.push_str(&format!("gate: {status}\n"));
268        out
269    }
270
271    /// One-line machine-readable summary for a panic/assert message.
272    pub fn summary_line(&self) -> String {
273        format!(
274            "{}/{} correct ({:.1}%), Wilson95-lower={:.1}% (need ≥{:.0}%), \
275             single={:.1}% multi={:.1}%",
276            self.precision_correct,
277            self.n,
278            self.accuracy() * 100.0,
279            self.wilson_lower() * 100.0,
280            WILSON_BAR * 100.0,
281            self.single_accuracy() * 100.0,
282            self.multi_accuracy() * 100.0,
283        )
284    }
285}
286
287fn ratio(num: u64, den: u64) -> f64 {
288    if den == 0 {
289        0.0
290    } else {
291        num as f64 / den as f64
292    }
293}
294
295fn truncate(s: &str, max: usize) -> String {
296    if s.chars().count() <= max {
297        s.to_string()
298    } else {
299        let mut t: String = s.chars().take(max.saturating_sub(1)).collect();
300        t.push('…');
301        t
302    }
303}
304
305/// Evaluate `vasari why` over a labeled corpus already ingested into `store`.
306pub fn evaluate(
307    store: &ObjectStore,
308    labels: &[Label],
309    match_threshold: f64,
310) -> Result<EvalReport, VasariError> {
311    // Dedup on (file, line) so a corpus can't pad `n` past the gate threshold
312    // with duplicate labels (e.g. two label files covering the same line).
313    let mut seen = HashSet::new();
314    let labels: Vec<&Label> = labels
315        .iter()
316        .filter(|l| seen.insert((l.file.as_str(), l.line)))
317        .collect();
318
319    let mut rows = Vec::with_capacity(labels.len());
320    let (mut precision_correct, mut recall_correct) = (0u64, 0u64);
321    let (mut single_total, mut single_correct) = (0u64, 0u64);
322    let (mut multi_total, mut multi_correct) = (0u64, 0u64);
323
324    for label in &labels {
325        let multi = label.expected_intent.len() > 1;
326
327        // One lookup per label. Sort by confidence descending (stable) to match
328        // `resolve::why`'s tie-break, so the first above-threshold chain is the
329        // same "top pick" `why` would return; the rest feed recall.
330        let mut chains = why_all(store, &label.file, label.line)?;
331        chains.sort_by(|a, b| {
332            b.confidence()
333                .partial_cmp(&a.confidence())
334                .unwrap_or(std::cmp::Ordering::Less)
335        });
336        let above: Vec<_> = chains
337            .iter()
338            .filter(|c| c.confidence() >= label.confidence_threshold)
339            .collect();
340
341        // Top pick (precision) and all above-threshold intents (recall).
342        let top = above
343            .first()
344            .and_then(|c| c.primary_intent().map(|i| i.text.clone()));
345        let all_texts: Vec<String> = above
346            .iter()
347            .filter_map(|c| c.primary_intent().map(|i| i.text.clone()))
348            .collect();
349
350        let matches_any = |cand: &str| -> bool {
351            label
352                .expected_intent
353                .iter()
354                .any(|e| intent_matches(e, cand, match_threshold))
355        };
356
357        let outcome = if label.expected_intent.is_empty() {
358            match top {
359                None => Outcome::NoAnswerCorrect,
360                Some(_) => Outcome::Wrong,
361            }
362        } else if let Some(t) = &top {
363            if matches_any(t) {
364                Outcome::PrecisionHit
365            } else if all_texts.iter().any(|c| matches_any(c)) {
366                Outcome::RecallOnly
367            } else {
368                Outcome::Wrong
369            }
370        } else {
371            Outcome::NoAnswerMiss
372        };
373
374        if outcome.precision_ok() {
375            precision_correct += 1;
376        }
377        if outcome.recall_ok() {
378            recall_correct += 1;
379        }
380        if multi {
381            multi_total += 1;
382            if outcome.precision_ok() {
383                multi_correct += 1;
384            }
385        } else {
386            single_total += 1;
387            if outcome.precision_ok() {
388                single_correct += 1;
389            }
390        }
391
392        rows.push(Row {
393            file: label.file.clone(),
394            line: label.line,
395            expected: label.expected_intent.clone(),
396            got: top,
397            multi_intent: multi,
398            outcome,
399        });
400    }
401
402    Ok(EvalReport {
403        rows,
404        n: labels.len() as u64,
405        precision_correct,
406        recall_correct,
407        single_total,
408        single_correct,
409        multi_total,
410        multi_correct,
411        match_threshold,
412    })
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    fn approx(a: f64, b: f64) {
420        assert!((a - b).abs() < 0.005, "expected {b}, got {a}");
421    }
422
423    #[test]
424    fn wilson_matches_known_vectors() {
425        // Plan test vectors.
426        approx(wilson_lower_bound(8, 10, Z_95), 0.490);
427        approx(wilson_lower_bound(80, 100, Z_95), 0.711);
428        // A perfect pilot still can't clear the 0.70 floor until n grows.
429        assert!(wilson_lower_bound(10, 10, Z_95) < 0.75);
430        // n=0 is defined as 0.0, not NaN.
431        approx(wilson_lower_bound(0, 0, Z_95), 0.0);
432    }
433
434    #[test]
435    fn token_overlap_not_substring() {
436        // Covered tokens above threshold → match.
437        assert!(intent_matches(
438            "add auth",
439            "Add authentication: auth guard",
440            0.5
441        ));
442        // A single short token that isn't present → no match (no substring inflation).
443        assert!(!intent_matches(
444            "auth",
445            "refactor the logging pipeline",
446            0.6
447        ));
448        // Exact-ish match.
449        assert!(intent_matches(
450            "add rate limit middleware",
451            "Add rate limit middleware to /api",
452            0.8
453        ));
454        // Empty expected never matches.
455        assert!(!intent_matches("", "anything", 0.0));
456    }
457
458    #[test]
459    fn insufficient_corpus_below_n_min() {
460        let report = EvalReport {
461            rows: vec![],
462            n: 10,
463            precision_correct: 10,
464            recall_correct: 10,
465            single_total: 10,
466            single_correct: 10,
467            multi_total: 0,
468            multi_correct: 0,
469            match_threshold: MATCH_THRESHOLD,
470        };
471        assert_eq!(report.status(), GateStatus::InsufficientCorpus);
472    }
473
474    #[test]
475    fn gate_pass_and_fail_at_scale() {
476        let pass = EvalReport {
477            rows: vec![],
478            n: 100,
479            precision_correct: 85,
480            recall_correct: 90,
481            single_total: 100,
482            single_correct: 85,
483            multi_total: 0,
484            multi_correct: 0,
485            match_threshold: MATCH_THRESHOLD,
486        };
487        assert_eq!(pass.status(), GateStatus::Pass);
488
489        let fail = EvalReport {
490            precision_correct: 60,
491            ..pass.clone()
492        };
493        assert_eq!(fail.status(), GateStatus::Fail);
494    }
495}