1use serde::Deserialize;
15
16use std::collections::HashSet;
17
18use crate::error::VasariError;
19use crate::resolve::why_all;
20use crate::store::ObjectStore;
21
22pub const Z_95: f64 = 1.96;
24pub const MATCH_THRESHOLD: f64 = 0.6;
26pub const N_MIN: u64 = 100;
31pub const ACCURACY_BAR: f64 = 0.80;
33pub const WILSON_BAR: f64 = 0.70;
35
36pub 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
54fn 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
63pub 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#[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
95pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Outcome {
107 PrecisionHit,
109 RecallOnly,
111 Wrong,
113 NoAnswerCorrect,
115 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum GateStatus {
152 Pass,
153 Fail,
154 InsufficientCorpus,
156}
157
158#[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 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 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
305pub fn evaluate(
307 store: &ObjectStore,
308 labels: &[Label],
309 match_threshold: f64,
310) -> Result<EvalReport, VasariError> {
311 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 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 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 approx(wilson_lower_bound(8, 10, Z_95), 0.490);
427 approx(wilson_lower_bound(80, 100, Z_95), 0.711);
428 assert!(wilson_lower_bound(10, 10, Z_95) < 0.75);
430 approx(wilson_lower_bound(0, 0, Z_95), 0.0);
432 }
433
434 #[test]
435 fn token_overlap_not_substring() {
436 assert!(intent_matches(
438 "add auth",
439 "Add authentication: auth guard",
440 0.5
441 ));
442 assert!(!intent_matches(
444 "auth",
445 "refactor the logging pipeline",
446 0.6
447 ));
448 assert!(intent_matches(
450 "add rate limit middleware",
451 "Add rate limit middleware to /api",
452 0.8
453 ));
454 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}