Skip to main content

cadvm_core/
verify.rs

1//! Geometric verification: assert that a diff matches expectations.
2//!
3//! Turns the structured geometric diff into a flat map of named metrics, then
4//! evaluates simple assertions (`metric op value`) against them. This is the
5//! "judge" an AI agent or CI gate needs: declare what the edit *should* do, get
6//! a pass/fail.
7//!
8//! Metric keys come from the diff:
9//! - **STEP/STP**: `volume_a`, `volume_b`, `volume_delta`, `added_volume`,
10//!   `removed_volume`, `common_volume`, `area_a`, `area_b`, `faces_a`, `faces_b`,
11//!   `faces_added`, `faces_removed`, `faces_common`.
12//! - **STL/OBJ**: `added_tris`, `removed_tris`, `unchanged_tris`,
13//!   `bbox_dx`, `bbox_dy`, `bbox_dz`.
14
15use std::collections::BTreeMap;
16
17use serde::Serialize;
18
19use crate::geom::{GeomDiff, MeshDiff};
20
21/// Comparison operator in an assertion.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23pub enum Op {
24    Lt,
25    Le,
26    Gt,
27    Ge,
28    Eq,
29    Ne,
30}
31
32impl Op {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Op::Lt => "<",
36            Op::Le => "<=",
37            Op::Gt => ">",
38            Op::Ge => ">=",
39            Op::Eq => "==",
40            Op::Ne => "!=",
41        }
42    }
43    fn apply(self, lhs: f64, rhs: f64) -> bool {
44        match self {
45            Op::Lt => lhs < rhs,
46            Op::Le => lhs <= rhs,
47            Op::Gt => lhs > rhs,
48            Op::Ge => lhs >= rhs,
49            Op::Eq => lhs == rhs,
50            Op::Ne => lhs != rhs,
51        }
52    }
53}
54
55/// A single parsed assertion, e.g. `added_volume > 100`.
56#[derive(Debug, Clone, PartialEq, Serialize)]
57pub struct Check {
58    pub metric: String,
59    pub op: Op,
60    pub value: f64,
61}
62
63/// Parse an assertion of the form `<metric><op><value>` (whitespace optional),
64/// e.g. `added_volume>100`, `faces_added == 3`, `removed_volume <= 0.5`.
65pub fn parse_check(s: &str) -> Result<Check, String> {
66    // Two-char operators must be tried before single-char ones.
67    for (token, op) in [
68        ("<=", Op::Le),
69        (">=", Op::Ge),
70        ("==", Op::Eq),
71        ("!=", Op::Ne),
72        ("<", Op::Lt),
73        (">", Op::Gt),
74    ] {
75        if let Some(pos) = s.find(token) {
76            let metric = s[..pos].trim().to_string();
77            let value_str = s[pos + token.len()..].trim();
78            if metric.is_empty() {
79                return Err(format!("missing metric in `{s}`"));
80            }
81            let value: f64 = value_str
82                .parse()
83                .map_err(|_| format!("invalid number `{value_str}` in `{s}`"))?;
84            return Ok(Check { metric, op, value });
85        }
86    }
87    Err(format!(
88        "no comparison operator in `{s}` (use < <= > >= == !=)"
89    ))
90}
91
92/// The result of one check.
93#[derive(Debug, Clone, PartialEq, Serialize)]
94pub struct CheckResult {
95    pub metric: String,
96    pub op: Op,
97    pub expected: f64,
98    /// `None` if the metric is not available for this file/format.
99    pub actual: Option<f64>,
100    pub pass: bool,
101}
102
103/// The full verification outcome.
104#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct VerifyReport {
106    pub pass: bool,
107    pub metrics: BTreeMap<String, f64>,
108    pub checks: Vec<CheckResult>,
109}
110
111/// Evaluate `checks` against `metrics`. With no checks, `pass` is `true` (a
112/// pure "describe the metrics" call).
113pub fn evaluate(metrics: BTreeMap<String, f64>, checks: &[Check]) -> VerifyReport {
114    let mut results = Vec::with_capacity(checks.len());
115    let mut all = true;
116    for c in checks {
117        let actual = metrics.get(&c.metric).copied();
118        let pass = actual.map(|a| c.op.apply(a, c.value)).unwrap_or(false);
119        all &= pass;
120        results.push(CheckResult {
121            metric: c.metric.clone(),
122            op: c.op,
123            expected: c.value,
124            actual,
125            pass,
126        });
127    }
128    VerifyReport {
129        pass: all,
130        metrics,
131        checks: results,
132    }
133}
134
135/// Flatten a B-Rep (STEP) geometric diff into named metrics.
136pub fn metrics_from_geom(g: &GeomDiff) -> BTreeMap<String, f64> {
137    let mut m = BTreeMap::new();
138    if let Some(a) = &g.a {
139        m.insert("volume_a".into(), a.volume);
140        m.insert("area_a".into(), a.area);
141        m.insert("faces_a".into(), a.faces as f64);
142    }
143    if let Some(b) = &g.b {
144        m.insert("volume_b".into(), b.volume);
145        m.insert("area_b".into(), b.area);
146        m.insert("faces_b".into(), b.faces as f64);
147    }
148    if let (Some(a), Some(b)) = (&g.a, &g.b) {
149        m.insert("volume_delta".into(), b.volume - a.volume);
150    }
151    if let Some(p) = &g.added {
152        m.insert("added_volume".into(), p.volume);
153    }
154    if let Some(p) = &g.removed {
155        m.insert("removed_volume".into(), p.volume);
156    }
157    if let Some(p) = &g.common {
158        m.insert("common_volume".into(), p.volume);
159    }
160    if let Some(ft) = &g.faces_topo {
161        m.insert("faces_added".into(), ft.added as f64);
162        m.insert("faces_removed".into(), ft.removed as f64);
163        m.insert("faces_common".into(), ft.common as f64);
164    }
165    m
166}
167
168/// Flatten a mesh (STL/OBJ) geometric diff into named metrics.
169pub fn metrics_from_mesh(d: &MeshDiff) -> BTreeMap<String, f64> {
170    let mut m = BTreeMap::new();
171    if let Some(l) = &d.layers {
172        m.insert("unchanged_tris".into(), l.unchanged.triangle_count() as f64);
173        m.insert("added_tris".into(), l.added.triangle_count() as f64);
174        m.insert("removed_tris".into(), l.removed.triangle_count() as f64);
175    }
176    if let Some(b) = &d.bbox {
177        let s = b.size();
178        m.insert("bbox_dx".into(), s[0]);
179        m.insert("bbox_dy".into(), s[1]);
180        m.insert("bbox_dz".into(), s[2]);
181    }
182    m
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn parses_operators() {
191        assert_eq!(
192            parse_check("added_volume>100").unwrap(),
193            Check {
194                metric: "added_volume".into(),
195                op: Op::Gt,
196                value: 100.0
197            }
198        );
199        assert_eq!(parse_check(" faces_added == 3 ").unwrap().op, Op::Eq);
200        assert_eq!(parse_check("removed_volume<=0.5").unwrap().op, Op::Le);
201        assert!(parse_check("nonsense").is_err());
202        assert!(parse_check(">5").is_err());
203    }
204
205    #[test]
206    fn evaluates_pass_and_fail() {
207        let mut metrics = BTreeMap::new();
208        metrics.insert("added_volume".to_string(), 173.0);
209        metrics.insert("removed_volume".to_string(), 110.0);
210        let checks = vec![
211            parse_check("added_volume>100").unwrap(),
212            parse_check("removed_volume<1").unwrap(),
213        ];
214        let r = evaluate(metrics, &checks);
215        assert!(!r.pass);
216        assert!(r.checks[0].pass);
217        assert!(!r.checks[1].pass);
218    }
219
220    #[test]
221    fn missing_metric_fails_check() {
222        let r = evaluate(BTreeMap::new(), &[parse_check("added_volume>1").unwrap()]);
223        assert!(!r.pass);
224        assert_eq!(r.checks[0].actual, None);
225    }
226
227    #[test]
228    fn no_checks_passes() {
229        let r = evaluate(BTreeMap::new(), &[]);
230        assert!(r.pass);
231    }
232}