Skip to main content

rac_engine/
eval.rs

1//! Grounding retrieval benchmark — `decided eval` (PORT-CONTRACT.d/15).
2//!
3//! Port of `src/asdecided/services/eval.py`. Deterministic by ADR-066: the scored
4//! path is a pure function of (corpus bytes, query set, retrieval code) — no
5//! network, no randomness, no clock. The only wall-clock/build values are
6//! `metadata.generated_at` and `metadata.lore_version`, both diagnostic and
7//! excluded from the gate (the parity harness masks them in `--json`).
8//!
9//! The benchmark guards the REAL retrieval surface: a `search_artifacts`
10//! case consumes `resolve::search_index` order verbatim, and a `get_related`
11//! case consumes the `incoming` neighborhood ordering that the MCP
12//! `get_related` tool serializes (mirrored here from `decided-mcp::graph::
13//! incoming_references` — decided-engine cannot depend on decided-mcp, and eval only
14//! needs the ordered id list).
15
16use 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
31/// The ranks the benchmark reports Precision@k / Recall@k at (REQ-003).
32pub const K_VALUES: [usize; 3] = [1, 3, 5];
33/// The hard-negative window: the widest k (REQ-003).
34pub const NEGATIVE_K: usize = 5;
35/// Metric rounding precision (`_PRECISION`).
36const 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
46/// `EvalUsageError` — the CLI maps this to exit 2 (`decided eval: <msg>`).
47pub 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
55/// One scored retrieval case (REQ-008).
56pub 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    /// Optional artifact-type filter, search cases only.
64    pub artifact_type: Option<String>,
65}
66
67/// The scored outcome of one case — a `per_query` row.
68struct CaseResult {
69    case: QueryCase,
70    returned: Vec<String>,
71    /// Indexed like K_VALUES.
72    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
103/// A full benchmark run: gated `metrics` plus diagnostic context.
104pub 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
124// --- Loading committed inputs (usage errors → EvalUsageError) ----------------
125
126fn 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        // The oracle embeds CPython's JSONDecodeError text here; serde's
141        // message differs (stderr-only surface, never byte-refereed).
142        Err(e) => usage(format!("malformed {what}: {path}: {e}")),
143    }
144}
145
146/// `str(x)` over the JSON scalars a query set can carry.
147fn 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
164/// `load_query_set(path)` — parse and shape-check the committed query set.
165pub fn load_query_set(path: &str) -> EvalResult<Vec<QueryCase>> {
166    let data = load_json(path, "query set")?;
167    // `data.get("cases") if isinstance(data, dict) else data`
168    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
256/// `load_baseline(path)` — the committed baseline `metrics` object.
257pub 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
265/// `load_config(path)` — floors and tolerance.
266pub 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
276// --- Retrieval seam: the real surface, never a parallel scorer (REQ-002) -----
277
278/// Rank of a snake_case relationship section in the canonical order
279/// (`_RELATIONSHIP_ORDER`); unknown sections rank last. Mirrors
280/// `decided-mcp::graph::relationship_order`.
281fn 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
290/// The ordered incoming-reference id list for `target_path` — exactly the
291/// `incoming` order the MCP `get_related` tool returns (mirrors
292/// `decided-mcp::graph::incoming_references`, `MAX_RELATED_EDGES` = 1000).
293fn 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(); // (rank, id, path)
300    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; // self-references are not incoming edges
306        }
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
322/// Returned ids for a `search_artifacts` case: `search_index` order verbatim.
323fn 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
328/// Returned ids for a `get_related` case — the tool's `incoming` order.
329/// A query that does not resolve is a malformed case (usage error).
330fn 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
360// --- Per-case scoring ---------------------------------------------------------
361
362/// `score_case(returned, case)` — P@k, R@k, hard-negative violations.
363fn 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
392// --- Aggregation ----------------------------------------------------------------
393
394fn 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
417/// `{group -> {p_at_1, r_at_5}}` macro-averaged within each group, sorted.
418fn 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
440// --- Hashing the inputs (diagnostic metadata, excluded from the gate) --------
441
442/// `corpus_hash(root)` — `sha256:` over rel-path + NUL + bytes + NUL per
443/// walked Markdown file, in the corpus walk's sorted order (REQ-005).
444pub 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
455/// `query_set_hash(path)` — `sha256:` over the raw file bytes.
456pub 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
463// --- Top-level run ------------------------------------------------------------
464
465/// `run_eval(root, queries_path)` (REQ-001..REQ-005).
466pub 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
510/// `datetime.now(UTC).isoformat()` — diagnostic metadata only.
511fn now_iso() -> String {
512    let (secs, micros) = crate::consent::now_epoch();
513    crate::consent::utc_isoformat_micros(secs, micros)
514}
515
516// --- The gate (`decided eval --check`) --------------------------------------------
517
518const RULE_NEGATIVE: &str = "negative_violations";
519const RULE_FLOOR: &str = "floor";
520const RULE_REGRESSION: &str = "regression";
521
522/// One fired gate rule.
523pub struct GateFailure {
524    rule: &'static str,
525    metric: String,
526    threshold: f64,
527    current: f64,
528}
529
530impl GateFailure {
531    /// `GateFailure.render()` — the byte-refereed stdout lines.
532    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
554/// `float(value)` over a gate-config JSON scalar.
555fn as_float(value: &Value) -> Option<f64> {
556    value.as_f64()
557}
558
559/// The `(scope, name, metric)` triples the gate enforces beyond negatives:
560/// ONLY `p_at_1` and `r_at_5`, and only where a floor is declared (a floor
561/// on any other metric is silently ignored — eval brief, landmine 2).
562fn 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
611/// `evaluate_gate(current, baseline, config)` (REQ-006) — one failure per
612/// fired rule, deterministic order: negatives, then per gated pair
613/// (missing-metric floor / floor / regression).
614pub 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    // (a) Hard-negative violations — always gated.
623    // `int(x)` truncates a float-typed JSON count.
624    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            // A gated metric absent from the current run is a regression.
653            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        // p_at_5 floor is silently ignored (landmine 2).
698        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
759// --- Rendering (module-local: the scorecard shapes live here) ------------------
760
761/// `render_scorecard_json(scorecard)` — pretty JSON, `ensure_ascii=False`.
762pub fn render_scorecard_json(scorecard: &Scorecard) -> String {
763    crate::pyjson::dumps_indent2_no_ascii(&scorecard.to_value())
764}
765
766/// `render_metrics_json(metrics)` — what `--update-baseline` writes.
767pub fn render_metrics_json(metrics: &Value) -> String {
768    crate::pyjson::dumps_indent2_no_ascii(metrics)
769}
770
771/// `render_scorecard_human(scorecard)` — overall / by-category / by-tool /
772/// Violations, Python format-spec faithful.
773pub 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
847/// `repr(list[str])` — `['a', 'b']`, elements via CPython `repr(str)`.
848fn 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}