Skip to main content

mati_core/
eval.rs

1//! Eval / regression corpus runner (idea 4).
2//!
3//! Replays a labeled corpus through the REAL pure enforcement functions and
4//! scores a confusion matrix per layer:
5//!   - **detection** — `classify_command` + `extract_file_path`: which file a
6//!     bash command reads (the read gate's first stage);
7//!   - **decision** — `evaluate()`: what enforcement does given a file/gotcha
8//!     state (Allow / Advisory / Deny / …).
9//!
10//! Ground truth is independent of current behavior; cases the engine currently
11//! mishandles are tracked in `baseline.json`. The gate asserts each layer's
12//! failing set equals its baseline exactly — a new miss is a regression, a
13//! fixed gap forces a baseline update (ratcheting recall up). That makes the
14//! "how do I know it doesn't miss?" number a measured, regression-gated fact.
15//!
16//! The corpus + baseline are embedded at compile time so `mati eval` runs the
17//! identical corpus in a shipped binary. Pure — no store, daemon, or network;
18//! the eval path stays inside mati's zero-network invariant.
19
20use std::collections::{BTreeSet, HashMap};
21
22use serde::{Deserialize, Serialize};
23
24use crate::hooks::decide::{
25    classify_command, evaluate, extract_file_paths, CommandClass, Decision, EnforcementInput,
26};
27
28// ── Embedded corpus (relative to this file, src/eval.rs) ─────────────────────
29const DETECTION_KNOWN_GOOD: &str = include_str!("../tests/fixtures/eval/detection/known_good.json");
30const DETECTION_BENIGN: &str = include_str!("../tests/fixtures/eval/detection/benign.json");
31const DETECTION_ADVERSARIAL: &str =
32    include_str!("../tests/fixtures/eval/detection/adversarial.json");
33const DECISION_CASES: &str = include_str!("../tests/fixtures/eval/decision/cases.json");
34const BASELINE: &str = include_str!("../tests/fixtures/eval/baseline.json");
35
36// ── Corpus case types ────────────────────────────────────────────────────────
37
38#[derive(Debug, Deserialize)]
39struct DetectionCase {
40    id: String,
41    cmd: String,
42    /// "violation" = a real file-read enforcement must catch; "benign" = not a
43    /// file-read.
44    label: String,
45    /// Ground-truth class: "cat_like" | "grep_like" | "none".
46    expect_class: String,
47    /// Ground-truth set of files the gate must check (order-independent). One
48    /// entry for a single-file read, several for `cat a.rs b.rs`, empty when no
49    /// file is read (benign, or `grep PATTERN` with no file).
50    #[serde(default)]
51    expect_paths: Vec<String>,
52    #[serde(default)]
53    #[allow(dead_code)]
54    note: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct DecisionCase {
59    id: String,
60    /// "violation" = must Deny; "benign" = must NOT Deny.
61    label: String,
62    rel_path: String,
63    #[serde(default)]
64    file_record: Option<serde_json::Value>,
65    #[serde(default)]
66    gotcha_records: HashMap<String, serde_json::Value>,
67    #[serde(default)]
68    already_consulted: bool,
69    /// Caller's on-disk observation — see `EnforcementInput::file_exists`.
70    /// Absent in a case means `None`, preserving that case's prior behavior.
71    #[serde(default)]
72    file_exists: Option<bool>,
73    /// Ground-truth decision variant: "allow" | "advisory" | "deny" |
74    /// "already_consulted" | "liability" | "tombstone" | "no_record".
75    expect: String,
76    #[serde(default)]
77    #[allow(dead_code)]
78    note: Option<String>,
79}
80
81#[derive(Debug, Deserialize)]
82struct Baseline {
83    #[serde(default)]
84    detection: Vec<String>,
85    #[serde(default)]
86    decision: Vec<String>,
87}
88
89// ── Report types ─────────────────────────────────────────────────────────────
90
91/// Per-layer confusion matrix and baseline comparison.
92#[derive(Debug, Serialize)]
93pub struct LayerReport {
94    pub layer: &'static str,
95    pub cases: u32,
96    pub tp: u32,
97    #[serde(rename = "fn")]
98    pub fn_: u32,
99    pub tn: u32,
100    pub fp: u32,
101    pub recall: f64,
102    pub fp_rate: f64,
103    pub precision: f64,
104    /// Case ids whose current output != ground truth.
105    pub failing: Vec<String>,
106    /// Baseline-accepted gaps for this layer.
107    pub known_gaps: Vec<String>,
108    /// `failing` − `known_gaps`: new misses. Must be empty for a healthy gate.
109    pub regressions: Vec<String>,
110    /// `known_gaps` − `failing`: fixed cases that should leave the baseline.
111    pub newly_fixed: Vec<String>,
112}
113
114#[derive(Debug, Serialize)]
115pub struct EvalReport {
116    pub detection: LayerReport,
117    pub decision: LayerReport,
118}
119
120impl EvalReport {
121    /// Ok iff every layer's failing set equals its baseline (no regressions,
122    /// no stale baseline entries). The single source of truth for both the CI
123    /// gate and `mati eval`'s exit code.
124    pub fn gate(&self) -> Result<(), String> {
125        let mut errs = Vec::new();
126        for l in [&self.detection, &self.decision] {
127            if !l.regressions.is_empty() {
128                errs.push(format!(
129                    "[{}] REGRESSION — output now wrong for cases not in baseline: {}\n  \
130                     Fix the regression, or (if intended) add these ids to \
131                     tests/fixtures/eval/baseline.json.",
132                    l.layer,
133                    l.regressions.join(", ")
134                ));
135            }
136            if !l.newly_fixed.is_empty() {
137                errs.push(format!(
138                    "[{}] IMPROVEMENT — baseline gaps now PASS: {}\n  \
139                     Remove them from tests/fixtures/eval/baseline.json so the \
140                     baseline stays honest and recall ratchets up.",
141                    l.layer,
142                    l.newly_fixed.join(", ")
143                ));
144            }
145        }
146        if errs.is_empty() {
147            Ok(())
148        } else {
149            Err(errs.join("\n"))
150        }
151    }
152}
153
154// ── Scoring ──────────────────────────────────────────────────────────────────
155
156fn parse_class(s: &str) -> Option<CommandClass> {
157    match s {
158        "cat_like" => Some(CommandClass::CatLike),
159        "grep_like" => Some(CommandClass::GrepLike),
160        "db_client_like" => Some(CommandClass::DbClientLike),
161        "path_mutating" => Some(CommandClass::PathMutating),
162        "none" => None,
163        other => panic!("corpus: bad expect_class {other:?}"),
164    }
165}
166
167fn detection_pass(c: &DetectionCase) -> bool {
168    let got_class = classify_command(&c.cmd);
169    if got_class != parse_class(&c.expect_class) {
170        return false;
171    }
172    // The gate checks the SET of files a command reads, so compare
173    // order-independently. `cat a.rs b.rs` must yield {a.rs, b.rs}.
174    let mut got_paths = match got_class {
175        Some(cl) => extract_file_paths(&c.cmd, cl),
176        None => Vec::new(),
177    };
178    let mut want = c.expect_paths.clone();
179    got_paths.sort();
180    want.sort();
181    got_paths == want
182}
183
184/// Stable name for a `Decision` variant (ignores the inner context strings,
185/// which carry human-readable detail that is not part of the contract).
186fn decision_variant(d: &Decision) -> &'static str {
187    match d {
188        Decision::Allow => "allow",
189        Decision::Deny { .. } => "deny",
190        Decision::AlreadyConsulted { .. } => "already_consulted",
191        Decision::Advisory { .. } => "advisory",
192        Decision::Liability { .. } => "liability",
193        Decision::Tombstone => "tombstone",
194        Decision::NoRecord => "no_record",
195        Decision::NotFileRead => "not_file_read",
196    }
197}
198
199const DECISION_VARIANTS: &[&str] = &[
200    "allow",
201    "advisory",
202    "deny",
203    "already_consulted",
204    "liability",
205    "tombstone",
206    "no_record",
207    "not_file_read",
208];
209
210fn decision_pass(c: &DecisionCase) -> bool {
211    let input = EnforcementInput {
212        rel_path: c.rel_path.clone(),
213        file_record: c.file_record.clone(),
214        gotcha_records: c.gotcha_records.clone(),
215        already_consulted: c.already_consulted,
216        file_exists: c.file_exists,
217    };
218    decision_variant(&evaluate(&input).decision) == c.expect
219}
220
221/// Build a `LayerReport` from `(id, is_violation, passed)` rows.
222fn score(
223    layer: &'static str,
224    rows: &[(String, bool, bool)],
225    known_gaps: Vec<String>,
226) -> LayerReport {
227    let (mut tp, mut fn_, mut tn, mut fp) = (0u32, 0u32, 0u32, 0u32);
228    let mut failing: BTreeSet<String> = BTreeSet::new();
229    for (id, is_violation, pass) in rows {
230        match (is_violation, pass) {
231            (true, true) => tp += 1,
232            (true, false) => fn_ += 1,
233            (false, true) => tn += 1,
234            (false, false) => fp += 1,
235        }
236        if !pass {
237            failing.insert(id.clone());
238        }
239    }
240    let recall = if tp + fn_ == 0 {
241        1.0
242    } else {
243        tp as f64 / (tp + fn_) as f64
244    };
245    let fp_rate = if fp + tn == 0 {
246        0.0
247    } else {
248        fp as f64 / (fp + tn) as f64
249    };
250    let precision = if tp + fp == 0 {
251        1.0
252    } else {
253        tp as f64 / (tp + fp) as f64
254    };
255    let known: BTreeSet<String> = known_gaps.iter().cloned().collect();
256    let regressions = failing.difference(&known).cloned().collect();
257    let newly_fixed = known.difference(&failing).cloned().collect();
258    LayerReport {
259        layer,
260        cases: rows.len() as u32,
261        tp,
262        fn_,
263        tn,
264        fp,
265        recall,
266        fp_rate,
267        precision,
268        failing: failing.into_iter().collect(),
269        known_gaps,
270        regressions,
271        newly_fixed,
272    }
273}
274
275fn assert_unique_ids<'a>(layer: &str, ids: impl Iterator<Item = &'a str>) {
276    let mut seen = BTreeSet::new();
277    for id in ids {
278        assert!(seen.insert(id), "{layer} corpus: duplicate case id {id:?}");
279    }
280}
281
282/// Run the embedded corpus through the real enforcement functions and score it.
283///
284/// Panics only on a malformed corpus (bad label/expect/class, duplicate id,
285/// or label↔expect inconsistency) — these are compile-embedded fixtures, so a
286/// panic is a developer error caught immediately by the test or `mati eval`.
287pub fn run() -> EvalReport {
288    let baseline: Baseline = serde_json::from_str(BASELINE).expect("parse baseline.json");
289
290    // Detection layer.
291    let mut detection: Vec<DetectionCase> = Vec::new();
292    for raw in [
293        DETECTION_KNOWN_GOOD,
294        DETECTION_BENIGN,
295        DETECTION_ADVERSARIAL,
296    ] {
297        detection.extend(serde_json::from_str::<Vec<DetectionCase>>(raw).expect("parse detection"));
298    }
299    assert_unique_ids("detection", detection.iter().map(|c| c.id.as_str()));
300    let det_rows: Vec<(String, bool, bool)> = detection
301        .iter()
302        .map(|c| {
303            assert!(
304                c.label == "violation" || c.label == "benign",
305                "detection {}: bad label {:?}",
306                c.id,
307                c.label
308            );
309            (c.id.clone(), c.label == "violation", detection_pass(c))
310        })
311        .collect();
312    let detection = score("detection", &det_rows, baseline.detection);
313
314    // Decision layer.
315    let decision: Vec<DecisionCase> =
316        serde_json::from_str(DECISION_CASES).expect("parse decision corpus");
317    assert_unique_ids("decision", decision.iter().map(|c| c.id.as_str()));
318    let dec_rows: Vec<(String, bool, bool)> = decision
319        .iter()
320        .map(|c| {
321            assert!(
322                c.label == "violation" || c.label == "benign",
323                "decision {}: bad label {:?}",
324                c.id,
325                c.label
326            );
327            assert!(
328                DECISION_VARIANTS.contains(&c.expect.as_str()),
329                "decision {}: bad expect {:?}",
330                c.id,
331                c.expect
332            );
333            // The confusion-matrix axis is "must Deny": keep label and expect
334            // consistent so the matrix can't silently mislabel.
335            assert_eq!(
336                c.label == "violation",
337                c.expect == "deny",
338                "decision {}: label/expect mismatch (violation iff expect==deny)",
339                c.id
340            );
341            (c.id.clone(), c.label == "violation", decision_pass(c))
342        })
343        .collect();
344    let decision = score("decision", &dec_rows, baseline.decision);
345
346    EvalReport {
347        detection,
348        decision,
349    }
350}
351
352// ── Tests ────────────────────────────────────────────────────────────────────
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn detection_pass_can_fail() {
360        // Same discipline as the 1.2 grep validation: prove the scorer trips.
361        let mk = |expect_class: &str, expect_paths: &[&str]| DetectionCase {
362            id: "x".into(),
363            cmd: "cat src/main.rs".into(),
364            label: "violation".into(),
365            expect_class: expect_class.into(),
366            expect_paths: expect_paths.iter().map(|s| s.to_string()).collect(),
367            note: None,
368        };
369        assert!(detection_pass(&mk("cat_like", &["src/main.rs"])));
370        assert!(!detection_pass(&mk("cat_like", &["WRONG.rs"])));
371        assert!(!detection_pass(&mk("none", &[])));
372    }
373
374    #[test]
375    fn decision_pass_can_fail() {
376        let deny_input = DecisionCase {
377            id: "x".into(),
378            label: "violation".into(),
379            rel_path: "src/a.rs".into(),
380            file_record: Some(serde_json::json!({
381                "confidence": {"value": 0.9}, "quality": {"value": 0.8},
382                "staleness": {"value": 0.1, "tier": "fresh"},
383                "payload": {"gotcha_keys": ["g"]}
384            })),
385            gotcha_records: HashMap::from([(
386                "g".to_string(),
387                serde_json::json!({
388                    "value": "r", "confidence": {"value": 0.9}, "quality": {"value": 0.8},
389                    "payload": {"confirmed": true}
390                }),
391            )]),
392            already_consulted: false,
393            file_exists: None,
394            expect: "deny".into(),
395            note: None,
396        };
397        assert!(decision_pass(&deny_input), "real deny case must pass");
398
399        let mut wrong = deny_input;
400        wrong.expect = "allow".into();
401        assert!(
402            !decision_pass(&wrong),
403            "a deny scored against expect=allow must fail"
404        );
405    }
406
407    #[test]
408    fn corpus_is_well_formed_and_gates() {
409        // Loads + validates the embedded corpus (panics on malformed data) and
410        // confirms the committed baseline matches current behavior.
411        let report = run();
412        assert!(report.detection.cases > 0);
413        assert!(report.decision.cases > 0);
414        report
415            .gate()
416            .expect("embedded corpus must match its baseline");
417    }
418}