Skip to main content

aprender_contracts_cli/commands/
check_parity.rs

1//! `pv check-parity` — SEMANTIC gate for parity-matrix contracts.
2//!
3//! `pv validate` is the SCHEMA gate — it checks the YAML parses and carries
4//! the fields required by the `aprender-contracts` schema. A parity-matrix
5//! contract (e.g. `contracts/apr-code-parity-v1.yaml`, `kind: pattern`)
6//! additionally encodes a per-row `cross_check_command` whose output is the
7//! mechanical verification of `status`. This command runs each row's
8//! cross-check and compares the hit count against the declared
9//! `expected_min_hits` / `expected_max_hits` bounds.
10//!
11//! Closes the SEMANTIC half of PMAT-CONTRACTS-PARITY-001.
12
13use std::path::Path;
14use std::process::Command;
15
16use serde_yaml::Value;
17
18#[derive(Debug)]
19pub struct RowResult {
20    pub id: String,
21    pub status: String,
22    pub verdict: Verdict,
23}
24
25#[derive(Debug)]
26pub enum Verdict {
27    Pass { hits: u64 },
28    Fail { reason: String },
29    Skipped { reason: String },
30}
31
32pub fn run(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
33    let text = std::fs::read_to_string(path)?;
34    let doc: Value = serde_yaml::from_str(&text)?;
35
36    let rows = doc
37        .get("categories")
38        .and_then(Value::as_sequence)
39        .ok_or("contract has no `categories:` sequence — is this a parity matrix?")?;
40
41    let mut results: Vec<RowResult> = Vec::with_capacity(rows.len());
42    for row in rows {
43        results.push(check_row(row));
44    }
45
46    print_report(&results);
47
48    let headline_errors = check_headline(&doc, rows);
49    for e in &headline_errors {
50        println!("  FAIL  [HEADLINE] {e}");
51    }
52
53    let failures = results
54        .iter()
55        .filter(|r| matches!(r.verdict, Verdict::Fail { .. }))
56        .count();
57    let total_failures = failures + headline_errors.len();
58    if total_failures == 0 {
59        Ok(())
60    } else {
61        Err(format!("{total_failures} parity check(s) failed").into())
62    }
63}
64
65/// FALSIFY-CODE-PARITY-002: aggregate invariant — the `headline.counts`
66/// block must match the actual status distribution across `categories[]`.
67fn check_headline(doc: &Value, rows: &[Value]) -> Vec<String> {
68    let mut errors = Vec::new();
69
70    let (mut shipped, mut partial, mut missing) = (0u64, 0u64, 0u64);
71    for row in rows {
72        match row.get("status").and_then(Value::as_str) {
73            Some("SHIPPED") => shipped += 1,
74            Some("PARTIAL") => partial += 1,
75            Some("NONE" | "MISSING") => missing += 1,
76            _ => {}
77        }
78    }
79    let actual_total = rows.len() as u64;
80
81    let Some(headline) = doc.get("headline") else {
82        return errors;
83    };
84
85    if let Some(declared) = headline.get("total_rows").and_then(Value::as_u64) {
86        if declared != actual_total {
87            errors.push(format!(
88                "headline.total_rows {declared} ≠ actual {actual_total}"
89            ));
90        }
91    }
92
93    let Some(counts) = headline.get("counts") else {
94        return errors;
95    };
96    for (name, actual) in &[
97        ("shipped", shipped),
98        ("partial", partial),
99        ("missing", missing),
100    ] {
101        if let Some(declared) = counts.get(*name).and_then(Value::as_u64) {
102            if declared != *actual {
103                errors.push(format!(
104                    "headline.counts.{name} {declared} ≠ actual {actual}"
105                ));
106            }
107        }
108    }
109
110    errors
111}
112
113fn check_row(row: &Value) -> RowResult {
114    let id = row
115        .get("id")
116        .and_then(Value::as_str)
117        .unwrap_or("<unnamed>")
118        .to_string();
119    let status = row
120        .get("status")
121        .and_then(Value::as_str)
122        .unwrap_or("UNKNOWN")
123        .to_string();
124
125    let Some(cmd) = row.get("cross_check_command").and_then(Value::as_str) else {
126        return RowResult {
127            id,
128            status,
129            verdict: Verdict::Skipped {
130                reason: "no cross_check_command".to_string(),
131            },
132        };
133    };
134
135    let output = match Command::new("sh").arg("-c").arg(cmd.trim()).output() {
136        Ok(o) => o,
137        Err(e) => {
138            return RowResult {
139                id,
140                status,
141                verdict: Verdict::Skipped {
142                    reason: format!("exec failed: {e}"),
143                },
144            };
145        }
146    };
147
148    let stdout = String::from_utf8_lossy(&output.stdout);
149    let stdout_trimmed = stdout.trim();
150    let Ok(hits) = stdout_trimmed.parse::<u64>() else {
151        return RowResult {
152            id,
153            status,
154            verdict: Verdict::Skipped {
155                reason: format!("non-numeric output: {stdout_trimmed:?}"),
156            },
157        };
158    };
159
160    let min = row
161        .get("expected_min_hits")
162        .and_then(Value::as_u64)
163        .or_else(|| {
164            row.get("expected_variant_count_min")
165                .and_then(Value::as_u64)
166        });
167    let max = row
168        .get("expected_max_hits")
169        .and_then(Value::as_u64)
170        .or_else(|| {
171            row.get("expected_variant_count_max")
172                .and_then(Value::as_u64)
173        });
174
175    if let Some(m) = min {
176        if hits < m {
177            return RowResult {
178                id,
179                status,
180                verdict: Verdict::Fail {
181                    reason: format!("hits {hits} < expected_min {m}"),
182                },
183            };
184        }
185    }
186    if let Some(m) = max {
187        if hits > m {
188            return RowResult {
189                id,
190                status,
191                verdict: Verdict::Fail {
192                    reason: format!("hits {hits} > expected_max {m}"),
193                },
194            };
195        }
196    }
197
198    RowResult {
199        id,
200        status,
201        verdict: Verdict::Pass { hits },
202    }
203}
204
205fn print_report(results: &[RowResult]) {
206    let mut pass = 0usize;
207    let mut fail = 0usize;
208    let mut skip = 0usize;
209    for r in results {
210        match &r.verdict {
211            Verdict::Pass { hits } => {
212                pass += 1;
213                println!("  PASS  [{:<8}] {}  (hits={hits})", r.status, r.id);
214            }
215            Verdict::Fail { reason } => {
216                fail += 1;
217                println!("  FAIL  [{:<8}] {}  ({reason})", r.status, r.id);
218            }
219            Verdict::Skipped { reason } => {
220                skip += 1;
221                println!("  SKIP  [{:<8}] {}  ({reason})", r.status, r.id);
222            }
223        }
224    }
225    println!();
226    println!(
227        "{} row(s) checked: {pass} pass, {fail} fail, {skip} skip",
228        results.len()
229    );
230}