1use std::collections::HashMap;
17use std::path::Path;
18
19use serde_json::{Map, Value};
20
21use crate::pycompat::{py_repr_str, py_round};
22use crate::pyjson::py_float;
23use crate::relationships::{corpus_items, relationships_from_corpus, Relationship};
24use crate::resolve::{
25 build_index, index_from_items, resolve_in_index, search_index, IndexEntry, OUTCOME_RESOLVED,
26};
27use crate::sha256::Sha256;
28use crate::spec::{snake, RELATIONSHIP_SECTIONS};
29use crate::walk::find_markdown_files;
30
31pub const K_VALUES: [usize; 3] = [1, 3, 5];
33pub const NEGATIVE_K: usize = 5;
35const PRECISION: i32 = 6;
37
38pub const DEFAULT_CORPUS: &str = "rust/fixtures/eval/corpus";
39pub const DEFAULT_QUERIES: &str = "rust/fixtures/eval/queries.json";
40pub const DEFAULT_BASELINE: &str = "rust/fixtures/eval/baseline.json";
41pub const DEFAULT_CONFIG: &str = "rust/fixtures/eval/eval-config.json";
42
43const TOOL_SEARCH: &str = "search_artifacts";
44const TOOL_GET_RELATED: &str = "get_related";
45
46pub struct EvalUsageError(pub String);
48
49type EvalResult<T> = Result<T, EvalUsageError>;
50
51fn usage<T>(message: String) -> EvalResult<T> {
52 Err(EvalUsageError(message))
53}
54
55pub struct QueryCase {
57 pub id: String,
58 pub tool: String,
59 pub query: String,
60 pub category: String,
61 pub relevant: Vec<String>,
62 pub must_not_return: Vec<String>,
63 pub artifact_type: Option<String>,
65}
66
67struct CaseResult {
69 case: QueryCase,
70 returned: Vec<String>,
71 precision: [f64; 3],
73 recall: [f64; 3],
74 violations: Vec<String>,
75}
76
77impl CaseResult {
78 fn to_value(&self) -> Value {
79 let mut m = Map::new();
80 m.insert("id".into(), Value::String(self.case.id.clone()));
81 m.insert("tool".into(), Value::String(self.case.tool.clone()));
82 m.insert("category".into(), Value::String(self.case.category.clone()));
83 m.insert("returned".into(), str_list(&self.returned));
84 m.insert("relevant".into(), str_list(&self.case.relevant));
85 if !self.case.must_not_return.is_empty() {
86 m.insert("must_not_return".into(), str_list(&self.case.must_not_return));
87 }
88 for (i, k) in K_VALUES.iter().enumerate() {
89 m.insert(format!("p_at_{k}"), py_float(round6(self.precision[i])));
90 }
91 for (i, k) in K_VALUES.iter().enumerate() {
92 m.insert(format!("r_at_{k}"), py_float(round6(self.recall[i])));
93 }
94 m.insert("violations".into(), str_list(&self.violations));
95 Value::Object(m)
96 }
97}
98
99fn str_list(items: &[String]) -> Value {
100 Value::Array(items.iter().map(|s| Value::String(s.clone())).collect())
101}
102
103pub struct Scorecard {
105 pub metrics: Value,
106 pub metadata: Value,
107 pub per_query: Vec<Value>,
108}
109
110impl Scorecard {
111 fn to_value(&self) -> Value {
112 let mut m = Map::new();
113 m.insert("metrics".into(), self.metrics.clone());
114 m.insert("metadata".into(), self.metadata.clone());
115 m.insert("per_query".into(), Value::Array(self.per_query.clone()));
116 Value::Object(m)
117 }
118}
119
120fn round6(value: f64) -> f64 {
121 py_round(value, PRECISION)
122}
123
124fn load_json(path: &str, what: &str) -> EvalResult<Value> {
127 if !Path::new(path).is_file() {
128 return usage(format!("{what} not found: {path}"));
129 }
130 let bytes = match std::fs::read(path) {
131 Ok(b) => b,
132 Err(e) => return usage(format!("cannot read {what}: {path}: {e}")),
133 };
134 let text = match String::from_utf8(bytes) {
135 Ok(t) => t,
136 Err(e) => return usage(format!("cannot read {what}: {path}: {e}")),
137 };
138 match serde_json::from_str::<Value>(&text) {
139 Ok(v) => Ok(v),
140 Err(e) => usage(format!("malformed {what}: {path}: {e}")),
143 }
144}
145
146fn py_str(value: &Value) -> String {
148 match value {
149 Value::String(s) => s.clone(),
150 Value::Bool(true) => "True".to_string(),
151 Value::Bool(false) => "False".to_string(),
152 Value::Null => "None".to_string(),
153 Value::Number(n) => {
154 if let Some(i) = n.as_i64() {
155 i.to_string()
156 } else {
157 crate::pycompat::py_float_repr(n.as_f64().unwrap_or(0.0))
158 }
159 }
160 other => other.to_string(),
161 }
162}
163
164pub fn load_query_set(path: &str) -> EvalResult<Vec<QueryCase>> {
166 let data = load_json(path, "query set")?;
167 let cases_raw = match &data {
169 Value::Object(map) => map.get("cases").cloned().unwrap_or(Value::Null),
170 other => other.clone(),
171 };
172 let Value::Array(cases_raw) = cases_raw else {
173 return usage(format!(
174 "malformed query set: {path}: expected a non-empty 'cases' list"
175 ));
176 };
177 if cases_raw.is_empty() {
178 return usage(format!(
179 "malformed query set: {path}: expected a non-empty 'cases' list"
180 ));
181 }
182 let mut cases: Vec<QueryCase> = Vec::with_capacity(cases_raw.len());
183 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
184 for (i, raw) in cases_raw.iter().enumerate() {
185 let case = parse_case(raw, path, i)?;
186 if !seen.insert(case.id.clone()) {
187 return usage(format!(
188 "malformed query set: {path}: duplicate case id {}",
189 py_repr_str(&case.id)
190 ));
191 }
192 cases.push(case);
193 }
194 Ok(cases)
195}
196
197fn parse_case(raw: &Value, path: &str, index: usize) -> EvalResult<QueryCase> {
198 let Value::Object(map) = raw else {
199 return usage(format!("malformed query set: {path}: case {index} is not an object"));
200 };
201 let require = |field: &str| -> EvalResult<&Value> {
202 map.get(field).ok_or_else(|| {
203 EvalUsageError(format!(
204 "malformed query set: {path}: case {index} missing {}",
205 py_repr_str(field)
206 ))
207 })
208 };
209 let case_id = require("id")?.clone();
210 let tool = require("tool")?.clone();
211 let query = require("query")?.clone();
212 let category = require("category")?.clone();
213 let relevant = require("relevant")?.clone();
214 let id_repr = py_repr_str(&py_str(&case_id));
215 if py_str(&tool) != TOOL_SEARCH && py_str(&tool) != TOOL_GET_RELATED {
216 return usage(format!(
217 "malformed query set: {path}: case {id_repr} tool must be one of ('{TOOL_SEARCH}', '{TOOL_GET_RELATED}')"
218 ));
219 }
220 let Value::Array(relevant) = relevant else {
221 return usage(format!(
222 "malformed query set: {path}: case {id_repr} 'relevant' must be a non-empty list"
223 ));
224 };
225 if relevant.is_empty() {
226 return usage(format!(
227 "malformed query set: {path}: case {id_repr} 'relevant' must be a non-empty list"
228 ));
229 }
230 let must_not = map.get("must_not_return").cloned().unwrap_or(Value::Array(Vec::new()));
231 let Value::Array(must_not) = must_not else {
232 return usage(format!(
233 "malformed query set: {path}: case {id_repr} 'must_not_return' must be a list"
234 ));
235 };
236 let artifact_type = match map.get("type") {
237 None | Some(Value::Null) => None,
238 Some(Value::String(s)) => Some(s.clone()),
239 Some(_) => {
240 return usage(format!(
241 "malformed query set: {path}: case {id_repr} 'type' must be a string"
242 ))
243 }
244 };
245 Ok(QueryCase {
246 id: py_str(&case_id),
247 tool: py_str(&tool),
248 query: py_str(&query),
249 category: py_str(&category),
250 relevant: relevant.iter().map(py_str).collect(),
251 must_not_return: must_not.iter().map(py_str).collect(),
252 artifact_type,
253 })
254}
255
256pub fn load_baseline(path: &str) -> EvalResult<Value> {
258 let data = load_json(path, "baseline")?;
259 match &data {
260 Value::Object(map) if map.contains_key("overall") => Ok(data),
261 _ => usage(format!("malformed baseline: {path}: expected a metrics object")),
262 }
263}
264
265pub fn load_config(path: &str) -> EvalResult<Value> {
267 let data = load_json(path, "config")?;
268 match &data {
269 Value::Object(map) if map.contains_key("floors") && map.contains_key("tolerance") => {
270 Ok(data)
271 }
272 _ => usage(format!("malformed config: {path}: expected 'floors' and 'tolerance'")),
273 }
274}
275
276fn relationship_order(section: &str) -> usize {
282 for (i, (name, _)) in RELATIONSHIP_SECTIONS.iter().enumerate() {
283 if snake(name) == section {
284 return i;
285 }
286 }
287 RELATIONSHIP_SECTIONS.len()
288}
289
290fn incoming_ids(
294 relationships: &[Relationship],
295 identity_by_path: &HashMap<&str, &str>,
296 target_path: &str,
297) -> Vec<String> {
298 const MAX_RELATED_EDGES: usize = 1000;
299 let mut incoming: Vec<(usize, String, String)> = Vec::new(); for rel in relationships {
301 if rel.resolved_path.as_deref() != Some(target_path) {
302 continue;
303 }
304 if rel.source_path == target_path {
305 continue; }
307 let Some(&id) = identity_by_path.get(rel.source_path.as_str()) else {
308 continue;
309 };
310 if incoming.len() < MAX_RELATED_EDGES {
311 incoming.push((
312 relationship_order(&rel.relationship),
313 id.to_string(),
314 rel.source_path.clone(),
315 ));
316 }
317 }
318 incoming.sort_by(|a, b| (a.0, &a.1, &a.2).cmp(&(b.0, &b.1, &b.2)));
319 incoming.into_iter().map(|(_, id, _)| id).collect()
320}
321
322fn search_returned(entries: &[IndexEntry], case: &QueryCase) -> Vec<String> {
324 let result = search_index(entries, &case.query, case.artifact_type.as_deref(), &[]);
325 result.matches.into_iter().map(|m| m.id).collect()
326}
327
328fn related_returned(root: &str, case: &QueryCase) -> EvalResult<Vec<String>> {
331 let corpus = corpus_items(root, true);
332 let index = index_from_items(&corpus);
333 let resolution = resolve_in_index(&index, &case.query);
334 let Some(artifact) = resolution
335 .artifact
336 .as_ref()
337 .filter(|_| resolution.outcome == OUTCOME_RESOLVED)
338 else {
339 return usage(format!(
340 "get_related case {}: query {} did not resolve to an artifact in {}",
341 py_repr_str(&case.id),
342 py_repr_str(&case.query),
343 py_repr_str(root)
344 ));
345 };
346 let relationships = relationships_from_corpus(&corpus);
347 let identity_by_path: HashMap<&str, &str> =
348 index.iter().map(|e| (e.path.as_str(), e.id.as_str())).collect();
349 Ok(incoming_ids(&relationships, &identity_by_path, &artifact.path))
350}
351
352fn returned_ids(root: &str, entries: &[IndexEntry], case: &QueryCase) -> EvalResult<Vec<String>> {
353 if case.tool == TOOL_SEARCH {
354 Ok(search_returned(entries, case))
355 } else {
356 related_returned(root, case)
357 }
358}
359
360fn score_case(returned: Vec<String>, case: QueryCase) -> CaseResult {
364 let relevant: std::collections::HashSet<&str> =
365 case.relevant.iter().map(String::as_str).collect();
366 let mut precision = [0.0f64; 3];
367 let mut recall = [0.0f64; 3];
368 for (i, &k) in K_VALUES.iter().enumerate() {
369 let top_k = &returned[..k.min(returned.len())];
370 let hits = top_k.iter().filter(|rid| relevant.contains(rid.as_str())).count();
371 precision[i] = hits as f64 / k as f64;
372 recall[i] = hits as f64 / case.relevant.len() as f64;
373 }
374 let negatives: std::collections::HashSet<&str> =
375 case.must_not_return.iter().map(String::as_str).collect();
376 let mut violations: Vec<String> = returned
377 .iter()
378 .take(NEGATIVE_K)
379 .filter(|rid| negatives.contains(rid.as_str()))
380 .cloned()
381 .collect();
382 violations.sort();
383 CaseResult {
384 case,
385 returned,
386 precision,
387 recall,
388 violations,
389 }
390}
391
392fn mean(values: &[f64]) -> f64 {
395 if values.is_empty() {
396 0.0
397 } else {
398 values.iter().sum::<f64>() / values.len() as f64
399 }
400}
401
402fn overall_metrics(results: &[CaseResult]) -> Value {
403 let mut m = Map::new();
404 for (i, k) in K_VALUES.iter().enumerate() {
405 let values: Vec<f64> = results.iter().map(|r| r.precision[i]).collect();
406 m.insert(format!("p_at_{k}"), py_float(round6(mean(&values))));
407 }
408 for (i, k) in K_VALUES.iter().enumerate() {
409 let values: Vec<f64> = results.iter().map(|r| r.recall[i]).collect();
410 m.insert(format!("r_at_{k}"), py_float(round6(mean(&values))));
411 }
412 let negatives: i64 = results.iter().map(|r| r.violations.len() as i64).sum();
413 m.insert("negative_violations".into(), Value::from(negatives));
414 Value::Object(m)
415}
416
417fn grouped_metrics(results: &[CaseResult], key: impl Fn(&CaseResult) -> &str) -> Value {
419 let mut groups: Vec<(&str, Vec<&CaseResult>)> = Vec::new();
420 for result in results {
421 let name = key(result);
422 match groups.iter_mut().find(|(n, _)| *n == name) {
423 Some((_, members)) => members.push(result),
424 None => groups.push((name, vec![result])),
425 }
426 }
427 groups.sort_by(|a, b| a.0.cmp(b.0));
428 let mut out = Map::new();
429 for (name, members) in groups {
430 let p1: Vec<f64> = members.iter().map(|r| r.precision[0]).collect();
431 let r5: Vec<f64> = members.iter().map(|r| r.recall[2]).collect();
432 let mut cell = Map::new();
433 cell.insert("p_at_1".into(), py_float(round6(mean(&p1))));
434 cell.insert("r_at_5".into(), py_float(round6(mean(&r5))));
435 out.insert(name.to_string(), Value::Object(cell));
436 }
437 Value::Object(out)
438}
439
440pub fn corpus_hash(root: &str) -> String {
445 let mut digest = Sha256::new();
446 for entry in find_markdown_files(root, true) {
447 digest.update(entry.rel().as_bytes());
448 digest.update(b"\0");
449 digest.update(&std::fs::read(&entry.abs).unwrap_or_default());
450 digest.update(b"\0");
451 }
452 format!("sha256:{}", digest.hexdigest())
453}
454
455pub fn query_set_hash(path: &str) -> String {
457 format!(
458 "sha256:{}",
459 crate::sha256::hexdigest(&std::fs::read(path).unwrap_or_default())
460 )
461}
462
463pub fn run_eval(root: &str, queries_path: &str) -> EvalResult<Scorecard> {
467 if !Path::new(root).is_dir() {
468 return usage(format!("corpus not found or not a directory: {root}"));
469 }
470 let cases = load_query_set(queries_path)?;
471 let entries = build_index(root, true);
472
473 let mut results: Vec<CaseResult> = Vec::with_capacity(cases.len());
474 for case in cases {
475 let returned = returned_ids(root, &entries, &case)?;
476 results.push(score_case(returned, case));
477 }
478 let n_queries = results.len() as i64;
479 results.sort_by(|a, b| a.case.id.cmp(&b.case.id));
480
481 let mut metrics = Map::new();
482 metrics.insert("overall".into(), overall_metrics(&results));
483 metrics.insert(
484 "by_category".into(),
485 grouped_metrics(&results, |r| r.case.category.as_str()),
486 );
487 metrics.insert("by_tool".into(), grouped_metrics(&results, |r| r.case.tool.as_str()));
488
489 let mut metadata = Map::new();
490 metadata.insert(
491 "lore_version".into(),
492 Value::String(crate::output::rac_version()),
493 );
494 metadata.insert("corpus_hash".into(), Value::String(corpus_hash(root)));
495 metadata.insert(
496 "query_set_hash".into(),
497 Value::String(query_set_hash(queries_path)),
498 );
499 metadata.insert("n_queries".into(), Value::from(n_queries));
500 metadata.insert("generated_at".into(), Value::String(now_iso()));
501
502 let per_query: Vec<Value> = results.iter().map(CaseResult::to_value).collect();
503 Ok(Scorecard {
504 metrics: Value::Object(metrics),
505 metadata: Value::Object(metadata),
506 per_query,
507 })
508}
509
510fn now_iso() -> String {
512 let (secs, micros) = crate::consent::now_epoch();
513 crate::consent::utc_isoformat_micros(secs, micros)
514}
515
516const RULE_NEGATIVE: &str = "negative_violations";
519const RULE_FLOOR: &str = "floor";
520const RULE_REGRESSION: &str = "regression";
521
522pub struct GateFailure {
524 rule: &'static str,
525 metric: String,
526 threshold: f64,
527 current: f64,
528}
529
530impl GateFailure {
531 pub fn render(&self) -> String {
533 use crate::pycompat::py_format_fixed;
534 if self.rule == RULE_NEGATIVE {
535 return format!(
536 "FAIL [negative_violations] {}: limit {}, current {}",
537 self.metric,
538 py_format_fixed(self.threshold, 0),
539 py_format_fixed(self.current, 0)
540 );
541 }
542 let label = if self.rule == RULE_FLOOR { "floor" } else { "baseline" };
543 format!(
544 "FAIL [{}] {}: {} {}, current {}",
545 self.rule,
546 self.metric,
547 label,
548 py_format_fixed(self.threshold, 6),
549 py_format_fixed(self.current, 6)
550 )
551 }
552}
553
554fn as_float(value: &Value) -> Option<f64> {
556 value.as_f64()
557}
558
559fn gated_pairs(config: &Value) -> Vec<(String, String, String)> {
563 let mut pairs = Vec::new();
564 let floors = &config["floors"];
565 for metric in ["p_at_1", "r_at_5"] {
566 if floors
567 .get("overall")
568 .and_then(|o| o.get(metric))
569 .is_some()
570 {
571 pairs.push(("overall".to_string(), String::new(), metric.to_string()));
572 }
573 }
574 if let Some(Value::Object(by_category)) = floors.get("by_category") {
575 let mut categories: Vec<&String> = by_category.keys().collect();
576 categories.sort();
577 for category in categories {
578 for metric in ["p_at_1", "r_at_5"] {
579 if by_category[category].get(metric).is_some() {
580 pairs.push((
581 "by_category".to_string(),
582 category.clone(),
583 metric.to_string(),
584 ));
585 }
586 }
587 }
588 }
589 pairs
590}
591
592fn metric_value(metrics: &Value, scope: &str, name: &str, metric: &str) -> Option<f64> {
593 let block = metrics.get(scope)?;
594 let value = if scope == "overall" {
595 block.get(metric)
596 } else {
597 block.get(name)?.get(metric)
598 };
599 value.and_then(as_float)
600}
601
602fn floor_value(floors: &Value, scope: &str, name: &str, metric: &str) -> Option<f64> {
603 let value = if scope == "overall" {
604 floors.get("overall")?.get(metric)
605 } else {
606 floors.get(scope)?.get(name)?.get(metric)
607 };
608 value.and_then(as_float)
609}
610
611pub fn evaluate_gate(current: &Value, baseline: &Value, config: &Value) -> Vec<GateFailure> {
615 let mut failures: Vec<GateFailure> = Vec::new();
616 let tolerance = config
617 .get("tolerance")
618 .and_then(as_float)
619 .unwrap_or(0.0);
620 let floors = &config["floors"];
621
622 let as_int = |v: &Value| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64));
625 let negatives = current
626 .get("overall")
627 .and_then(|o| o.get("negative_violations"))
628 .and_then(as_int)
629 .unwrap_or(0);
630 let negatives_max = floors
631 .get("negative_violations")
632 .and_then(as_int)
633 .unwrap_or(0);
634 if negatives > negatives_max {
635 failures.push(GateFailure {
636 rule: RULE_NEGATIVE,
637 metric: "overall.negative_violations".to_string(),
638 threshold: negatives_max as f64,
639 current: negatives as f64,
640 });
641 }
642
643 for (scope, name, metric) in gated_pairs(config) {
644 let dotted = if name.is_empty() {
645 format!("{scope}.{metric}")
646 } else {
647 format!("{scope}.{name}.{metric}")
648 };
649 let value = metric_value(current, &scope, &name, &metric);
650 let floor = floor_value(floors, &scope, &name, &metric);
651 let Some(value) = value else {
652 failures.push(GateFailure {
654 rule: RULE_FLOOR,
655 metric: dotted,
656 threshold: floor.unwrap_or(0.0),
657 current: 0.0,
658 });
659 continue;
660 };
661 if let Some(floor) = floor {
662 if value < floor {
663 failures.push(GateFailure {
664 rule: RULE_FLOOR,
665 metric: dotted.clone(),
666 threshold: floor,
667 current: value,
668 });
669 }
670 }
671 if let Some(base) = metric_value(baseline, &scope, &name, &metric) {
672 if value < base - tolerance {
673 failures.push(GateFailure {
674 rule: RULE_REGRESSION,
675 metric: dotted,
676 threshold: base,
677 current: value,
678 });
679 }
680 }
681 }
682 failures
683}
684
685#[cfg(test)]
686#[allow(clippy::items_after_test_module)]
687mod tests {
688 use super::*;
689 use serde_json::json;
690
691 #[test]
692 fn gate_only_enforces_p1_and_r5_where_floored() {
693 let config = json!({
694 "tolerance": 0.02,
695 "floors": {"overall": {"p_at_1": 0.9, "p_at_5": 0.99}}
696 });
697 assert_eq!(
699 gated_pairs(&config),
700 vec![("overall".to_string(), String::new(), "p_at_1".to_string())]
701 );
702 }
703
704 #[test]
705 fn gate_failure_render_shapes() {
706 let neg = GateFailure {
707 rule: RULE_NEGATIVE,
708 metric: "overall.negative_violations".into(),
709 threshold: -1.0,
710 current: 0.0,
711 };
712 assert_eq!(
713 neg.render(),
714 "FAIL [negative_violations] overall.negative_violations: limit -1, current 0"
715 );
716 let floor = GateFailure {
717 rule: RULE_FLOOR,
718 metric: "overall.p_at_1".into(),
719 threshold: 1.5,
720 current: 1.0,
721 };
722 assert_eq!(
723 floor.render(),
724 "FAIL [floor] overall.p_at_1: floor 1.500000, current 1.000000"
725 );
726 let reg = GateFailure {
727 rule: RULE_REGRESSION,
728 metric: "overall.p_at_1".into(),
729 threshold: 1.5,
730 current: 1.0,
731 };
732 assert_eq!(
733 reg.render(),
734 "FAIL [regression] overall.p_at_1: baseline 1.500000, current 1.000000"
735 );
736 }
737
738 #[test]
739 fn score_case_windows() {
740 let case = QueryCase {
741 id: "T1".into(),
742 tool: "search_artifacts".into(),
743 query: "q".into(),
744 category: "c".into(),
745 relevant: vec!["A".into(), "B".into()],
746 must_not_return: vec!["X".into()],
747 artifact_type: None,
748 };
749 let result = score_case(
750 vec!["A".into(), "X".into(), "B".into()],
751 case,
752 );
753 assert_eq!(result.precision, [1.0, 2.0 / 3.0, 2.0 / 5.0]);
754 assert_eq!(result.recall, [0.5, 1.0, 1.0]);
755 assert_eq!(result.violations, vec!["X".to_string()]);
756 }
757}
758
759pub fn render_scorecard_json(scorecard: &Scorecard) -> String {
763 crate::pyjson::dumps_indent2_no_ascii(&scorecard.to_value())
764}
765
766pub fn render_metrics_json(metrics: &Value) -> String {
768 crate::pyjson::dumps_indent2_no_ascii(metrics)
769}
770
771pub fn render_scorecard_human(scorecard: &Scorecard) -> String {
774 use crate::pycompat::py_format_fixed;
775 let rjust = |s: &str, w: usize| -> String {
776 let n = s.chars().count();
777 if n >= w {
778 s.to_string()
779 } else {
780 format!("{}{}", " ".repeat(w - n), s)
781 }
782 };
783 let f = |v: f64, w: usize, nd: usize| rjust(&py_format_fixed(v, nd), w);
784 let get = |obj: &Value, key: &str| obj.get(key).and_then(Value::as_f64).unwrap_or(0.0);
785
786 let metrics = &scorecard.metrics;
787 let mut lines: Vec<String> = Vec::new();
788
789 let overall = &metrics["overall"];
790 lines.push("Overall".to_string());
791 let mut header = " ".to_string();
792 for k in K_VALUES {
793 header.push_str(&rjust(&format!("P@{k}"), 8));
794 header.push_str(&rjust(&format!("R@{k}"), 8));
795 }
796 lines.push(header);
797 let mut row = " ".to_string();
798 for k in K_VALUES {
799 row.push_str(&f(get(overall, &format!("p_at_{k}")), 8, 3));
800 row.push_str(&f(get(overall, &format!("r_at_{k}")), 8, 3));
801 }
802 lines.push(row);
803 lines.push(format!(
804 " negative_violations: {}",
805 overall
806 .get("negative_violations")
807 .and_then(Value::as_i64)
808 .unwrap_or(0)
809 ));
810 lines.push(String::new());
811
812 lines.push("By category".to_string());
813 render_group(&metrics["by_category"], &mut lines);
814 lines.push(String::new());
815
816 lines.push("By tool".to_string());
817 render_group(&metrics["by_tool"], &mut lines);
818 lines.push(String::new());
819
820 lines.push("Violations".to_string());
821 let offenders: Vec<&Value> = scorecard
822 .per_query
823 .iter()
824 .filter(|entry| {
825 entry
826 .get("violations")
827 .and_then(Value::as_array)
828 .is_some_and(|v| !v.is_empty())
829 })
830 .collect();
831 if offenders.is_empty() {
832 lines.push(" none".to_string());
833 } else {
834 for offender in offenders {
835 let id = offender.get("id").and_then(Value::as_str).unwrap_or("");
836 let tool = offender.get("tool").and_then(Value::as_str).unwrap_or("");
837 let violations = py_repr_str_list(offender.get("violations"));
838 let returned = py_repr_str_list(offender.get("returned"));
839 lines.push(format!(
840 " {id} ({tool}): returned {violations} in top-{NEGATIVE_K} [returned={returned}]"
841 ));
842 }
843 }
844 lines.join("\n")
845}
846
847fn py_repr_str_list(value: Option<&Value>) -> String {
849 let items: Vec<String> = value
850 .and_then(Value::as_array)
851 .map(|arr| {
852 arr.iter()
853 .map(|v| py_repr_str(v.as_str().unwrap_or("")))
854 .collect()
855 })
856 .unwrap_or_default();
857 format!("[{}]", items.join(", "))
858}
859
860fn render_group(group: &Value, lines: &mut Vec<String>) {
861 use crate::pycompat::py_format_fixed;
862 let Some(map) = group.as_object() else {
863 lines.push(" (none)".to_string());
864 return;
865 };
866 if map.is_empty() {
867 lines.push(" (none)".to_string());
868 return;
869 }
870 let width = map.keys().map(|name| name.chars().count()).max().unwrap_or(0);
871 lines.push(format!(" {} P@1 R@5", " ".repeat(width)));
872 for (name, cell) in map {
873 let pad = width.saturating_sub(name.chars().count());
874 let p1 = cell.get("p_at_1").and_then(Value::as_f64).unwrap_or(0.0);
875 let r5 = cell.get("r_at_5").and_then(Value::as_f64).unwrap_or(0.0);
876 let p1s = py_format_fixed(p1, 3);
877 let r5s = py_format_fixed(r5, 3);
878 lines.push(format!(
879 " {}{} {:>6} {:>6}",
880 name,
881 " ".repeat(pad),
882 p1s,
883 r5s
884 ));
885 }
886}