Skip to main content

planarian_gym/
verification.rs

1//! `planarian-gym verification`: the verification ratio d:a (discharged to
2//! assumed obligations), the trust count |๐’ฏ| and the internal-unproven count
3//! (SPEC ยง17a), from docs/verification-ratio.toml, checked against the source
4//! and docs/assumptions.md, and held to the recorded baseline; and the bypass
5//! register, docs/bypasses.md (ยง17b): its format, and its open rows by
6//! severity. `--gate` also fails on an open High row (every gate does).
7//!
8//! The trust count is the number of platform and proof-validity ids in
9//! assumptions.md (P, G, V) plus every `external_body` and `assume` site in
10//! the verified crates. The internal-unproven count is the number of U ids:
11//! Planarian's own unproven code that the proven gates take input from. A
12//! discharged obligation is a Verus lemma or verified function, or a Kani
13//! harness, named in the file and present in the source.
14
15use std::collections::BTreeMap;
16use std::path::Path;
17use toml::Value as T;
18
19const RATIO: &str = "docs/verification-ratio.toml";
20const ASSUMPTIONS: &str = "docs/assumptions.md";
21const BYPASSES: &str = "docs/bypasses.md";
22const CATALOG: &str = crate::catalog::DIR;
23const VERIFIED: &[&str] = &["crates/policy/src", "crates/schema/src", "crates/txn/src"];
24
25fn rust_files(dir: &Path, out: &mut Vec<String>) {
26    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
27        let p = e.path();
28        if p.is_dir() {
29            rust_files(&p, out);
30        } else if p.extension().is_some_and(|x| x == "rs") {
31            out.push(std::fs::read_to_string(&p).unwrap_or_default());
32        }
33    }
34}
35
36fn source(krate: &str) -> Vec<String> {
37    let mut v = vec![];
38    rust_files(&Path::new("crates").join(krate).join("src"), &mut v);
39    v
40}
41
42/// What discharges an obligation (ยง17a, R14): a Verus `proof fn` or a
43/// function verified against its `ensures`, inside `verus!`; or a Kani
44/// harness (`#[kani::proof]`). A name that is only present (a plain
45/// function, a test, a comment) discharges nothing.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Discharge {
48    Verus,
49    Kani,
50}
51
52pub fn discharge_in(files: &[String], name: &str) -> Option<Discharge> {
53    let pat = format!("fn {name}");
54    for f in files {
55        for (i, _) in f.match_indices(&pat) {
56            let after = &f[i + pat.len()..];
57            if !after.chars().next().is_some_and(|c| c == '(' || c == '<') {
58                continue;
59            }
60            let line_start = f[..i].rfind('\n').map_or(0, |n| n + 1);
61            // The attributes directly above the function, and only those.
62            let attrs: Vec<&str> =
63                f[..line_start].lines().rev().map(str::trim_start).take_while(|l| l.starts_with("#[")).collect();
64            if attrs.iter().any(|l| l.starts_with("#[kani::proof")) {
65                return Some(Discharge::Kani);
66            }
67            if !f[..i].contains("verus!") {
68                continue;
69            }
70            let head = &after[..after.find('{').unwrap_or(after.len())];
71            if f[line_start..i].contains("proof ") || head.contains("ensures") {
72                return Some(Discharge::Verus);
73            }
74        }
75    }
76    None
77}
78
79fn discharge(krate: &str, name: &str) -> Option<Discharge> {
80    discharge_in(&source(krate), name)
81}
82
83/// R14's structural checks on the accounting, apart from the source: no
84/// invariant listed twice, no obligation listed twice under one invariant,
85/// and every invariant of the baseline still accounted.
86pub fn structure_errors(doc: &T) -> Vec<String> {
87    let mut errors = vec![];
88    let mut seen = std::collections::BTreeSet::new();
89    for inv in doc.get("invariant").and_then(T::as_array).into_iter().flatten() {
90        let id = inv.get("id").and_then(T::as_str).unwrap_or("?");
91        if !seen.insert(id.to_string()) {
92            errors.push(format!("{id} is accounted twice"));
93        }
94        let mut names = std::collections::BTreeSet::new();
95        for n in inv.get("discharged").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str) {
96            if !names.insert(n) {
97                errors.push(format!("{id}: {n} is listed twice"));
98            }
99        }
100    }
101    if let Some(bp) = doc.get("baseline").and_then(|b| b.get("per_invariant")).and_then(T::as_table) {
102        for id in bp.keys() {
103            if !seen.contains(id) {
104                errors.push(format!("{id} is in the baseline but no longer accounted (a removed invariant fails)"));
105            }
106        }
107    }
108    errors
109}
110
111/// `external_body` attributes and `assume(` calls in the verified crates
112/// (Kani's input bounds, `kani::assume`, are not trust).
113fn trust_sites() -> usize {
114    let mut files = vec![];
115    for d in VERIFIED {
116        rust_files(Path::new(d), &mut files);
117    }
118    files
119        .iter()
120        .map(|f| {
121            f.matches("#[verifier::external_body]").count()
122                + f.lines().filter(|l| l.contains("assume(") && !l.contains("kani::assume")).count()
123        })
124        .sum()
125}
126
127pub struct Report {
128    pub text: String,
129    pub ok: bool,
130}
131
132/// The bypass register's open rows by severity, and its format errors.
133fn bypasses() -> (BTreeMap<String, Vec<String>>, Vec<String>) {
134    match std::fs::read_to_string(BYPASSES) {
135        Ok(text) => parse_bypasses(&text),
136        Err(_) => (BTreeMap::new(), vec![format!("{BYPASSES} is missing")]),
137    }
138}
139
140pub fn parse_bypasses(text: &str) -> (BTreeMap<String, Vec<String>>, Vec<String>) {
141    let mut open: BTreeMap<String, Vec<String>> = BTreeMap::new();
142    let mut errors = vec![];
143    let mut seen = std::collections::BTreeSet::new();
144    // ยง17b (D13): three tables, Open, Closed and Accepted, each once and in
145    // that order; a row is held to the table it sits in.
146    const TABLES: [&str; 3] = ["Open", "Closed", "Accepted"];
147    let mut table: Option<&str> = None;
148    let mut tables: Vec<&str> = vec![];
149    for line in text.lines() {
150        if let Some(h) = line.strip_prefix("## ") {
151            let h = h.trim();
152            table = TABLES.iter().copied().find(|t| *t == h);
153            match table {
154                Some(t) => tables.push(t),
155                None => errors.push(format!("{BYPASSES}: section {h:?} is not one of Open, Closed, Accepted")),
156            }
157            continue;
158        }
159        let cells: Vec<&str> = line.trim().trim_matches('|').split('|').map(str::trim).collect();
160        let id = cells.first().copied().unwrap_or("");
161        let (kind, width) = match id.split_once('-') {
162            Some(("B", n)) if n.parse::<u32>().is_ok() => ('B', 8),
163            Some(("X", n)) if n.parse::<u32>().is_ok() => ('X', 6),
164            _ => continue,
165        };
166        let want = if kind == 'X' { "Accepted" } else { "Open or Closed" };
167        match (kind, table) {
168            (_, None) => errors.push(format!("{BYPASSES}: {id} is outside the Open, Closed and Accepted tables")),
169            ('X', Some("Accepted")) | ('B', Some("Open" | "Closed")) => {}
170            (_, Some(t)) => errors.push(format!("{BYPASSES}: {id} is in {t}; a {kind}- row belongs in {want}")),
171        }
172        if !seen.insert(id.to_string()) {
173            errors.push(format!("{BYPASSES}: {id} appears twice (ids are never reused)"));
174        }
175        if cells.len() != width {
176            errors.push(format!("{BYPASSES}: {id} has {} cells, want {width}", cells.len()));
177            continue;
178        }
179        // ยง17c: every row names the invariants it limits, or `all`, or `โ€”`.
180        let invariants = cells[width - 1];
181        let named = invariants == "all"
182            || invariants == "โ€”"
183            || invariants
184                .split(',')
185                .map(str::trim)
186                .all(|i| i.strip_prefix('I').is_some_and(|n| n.parse::<u32>().is_ok()));
187        if invariants.is_empty() || !named {
188            errors.push(format!("{BYPASSES}: {id}: invariants {invariants:?} is not a list of ids, `all` or `โ€”`"));
189        }
190        if kind == 'X' {
191            continue;
192        }
193        let (severity, status) = (cells[3], cells[4]);
194        if !matches!(severity, "High" | "Medium" | "Low") {
195            errors.push(format!("{BYPASSES}: {id}: severity {severity:?} is not High, Medium or Low"));
196        }
197        let closed = status.starts_with("Closed (") && status.ends_with(')');
198        if status.starts_with("Open") {
199            open.entry(severity.to_string()).or_default().push(id.to_string());
200        } else if !closed {
201            errors.push(format!("{BYPASSES}: {id}: status {status:?} is neither Openโ€ฆ nor Closed (<commit>)"));
202        }
203        match table {
204            Some("Open") if closed => errors.push(format!("{BYPASSES}: {id} is Closed but left in Open")),
205            Some("Closed") if status.starts_with("Open") => {
206                errors.push(format!("{BYPASSES}: {id} is Open but sits in Closed"))
207            }
208            _ => {}
209        }
210    }
211    if tables != TABLES {
212        errors.push(format!("{BYPASSES}: tables {tables:?}; want Open, Closed, Accepted, each once, in that order"));
213    }
214    (open, errors)
215}
216
217pub fn run(write_baseline: bool, gate: bool) -> Report {
218    let mut errors: Vec<String> = vec![];
219    let doc: T = match std::fs::read_to_string(RATIO)
220        .map_err(|e| e.to_string())
221        .and_then(|t| toml::from_str(&t).map_err(|e| e.to_string()))
222    {
223        Ok(d) => d,
224        Err(e) => return Report { text: format!("{RATIO}: {e}"), ok: false },
225    };
226    let assumptions = std::fs::read_to_string(ASSUMPTIONS).unwrap_or_default();
227    let ids: Vec<String> = ["V", "P", "G", "U"]
228        .iter()
229        .flat_map(|p| (1..=40).map(move |n| format!("{p}{n}")))
230        .filter(|id| assumptions.contains(&format!("**{id}**")))
231        .collect();
232    let internal = ids.iter().filter(|i| i.starts_with('U')).count();
233    let trust_ids = ids.len() - internal;
234    let trust_count = trust_ids + trust_sites();
235
236    errors.extend(structure_errors(&doc));
237    let mut used: BTreeMap<(String, Discharge), std::collections::BTreeSet<String>> = BTreeMap::new();
238    let mut lines = vec![];
239    let mut per: BTreeMap<String, (usize, usize)> = BTreeMap::new();
240    let (mut d_sum, mut a_sum) = (0usize, 0usize);
241    for inv in doc.get("invariant").and_then(T::as_array).into_iter().flatten() {
242        let id = inv.get("id").and_then(T::as_str).unwrap_or("?").to_string();
243        let names: Vec<&str> =
244            inv.get("discharged").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str).collect();
245        let assumes: Vec<&str> =
246            inv.get("assumes").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str).collect();
247        for n in &names {
248            match n.split_once("::").map(|(k, f)| (k, discharge(k, f))) {
249                Some((k, Some(d))) => {
250                    used.entry((k.to_string(), d)).or_default().insert(n.to_string());
251                }
252                _ => errors.push(format!(
253                    "{id}: {n} is not a proof fn, a verified fn with ensures, or a Kani harness in the source"
254                )),
255            }
256        }
257        for a in &assumes {
258            if !ids.iter().any(|x| x == a) {
259                errors.push(format!("{id}: assumption {a} is not in {ASSUMPTIONS}"));
260            }
261        }
262        let (d, a) = (names.len(), assumes.len());
263        d_sum += d;
264        a_sum += a;
265        per.insert(id.clone(), (d, a));
266        let phase = inv.get("phase").and_then(T::as_str).unwrap_or("");
267        lines.push(format!(
268            "  {id:<4} rests on {d} discharged and {a} assumed ({}){}",
269            if a == 0 { "-".into() } else { format!("{:.2}", d as f64 / a as f64) },
270            if d == 0 { format!("; {phase}: tested, not proved") } else { String::new() }
271        ));
272    }
273
274    // R14: tied to the verifier's output. `[verifier]` records what the
275    // Verus run reports per crate ("N verified"); the Verus flake check
276    // fails when its log disagrees. No crate can be credited with more
277    // distinct Verus obligations than its run verifies.
278    let verifier = doc.get("verifier").and_then(T::as_table);
279    if verifier.is_none() {
280        errors.push("no [verifier] counts: obligations are not tied to the verifier's output".into());
281    }
282    for ((k, d), names) in &used {
283        if *d != Discharge::Verus {
284            continue;
285        }
286        let verified = verifier.and_then(|v| v.get(k.as_str())).and_then(T::as_integer).unwrap_or(0) as usize;
287        if names.len() > verified {
288            errors.push(format!(
289                "{k}: {} distinct Verus obligations credited, but its Verus run verifies {verified}",
290                names.len()
291            ));
292        }
293    }
294
295    // ยง17a: every proved invariant has a catalog entry written from its
296    // English (tag `english`), so a wrong specification shows up as a failure.
297    let english: Vec<String> = crate::catalog::read_text(Path::new(CATALOG))
298        .ok()
299        .and_then(|t| toml::from_str::<T>(&t).ok())
300        .and_then(|d| d.get("fault").and_then(T::as_array).cloned())
301        .unwrap_or_default()
302        .iter()
303        .filter(|e| {
304            e.get("tags").and_then(T::as_array).is_some_and(|t| t.iter().any(|x| x.as_str() == Some("english")))
305        })
306        .flat_map(|e| e.get("spec").and_then(T::as_array).cloned().unwrap_or_default())
307        .filter_map(|x| x.as_str().map(String::from))
308        .collect();
309    let unwritten: Vec<&String> =
310        per.iter().filter(|(id, (d, _))| *d > 0 && !english.contains(id)).map(|(id, _)| id).collect();
311    if !unwritten.is_empty() {
312        errors.push(format!(
313            "proved without an entry written from the English (ยง17a): {}",
314            unwritten.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
315        ));
316    }
317
318    // The ratchet: neither the global nor any per-invariant ratio falls, and
319    // the trust count does not grow.
320    let base = doc.get("baseline");
321    let b = |k: &str| base.and_then(|b| b.get(k)).and_then(T::as_integer).unwrap_or(0) as usize;
322    if !write_baseline && base.is_some() {
323        if trust_count > b("trust_count") {
324            errors.push(format!("the trust count grew: {trust_count} > {}", b("trust_count")));
325        }
326        if internal > b("internal_unproven") {
327            errors.push(format!("the internal-unproven count grew: {internal} > {}", b("internal_unproven")));
328        }
329        let (bd, ba) = (b("discharged"), b("assumed"));
330        if ba > 0 && a_sum > 0 && (d_sum as f64 / a_sum as f64) < (bd as f64 / ba as f64) - 1e-9 {
331            errors.push(format!("the global ratio fell: {d_sum}:{a_sum} < {bd}:{ba}"));
332        }
333        if let Some(bp) = base.and_then(|b| b.get("per_invariant")).and_then(T::as_table) {
334            for (id, (d, a)) in &per {
335                let Some([pd, pa]) = bp.get(id).and_then(T::as_array).map(|v| {
336                    [v.first().and_then(T::as_integer).unwrap_or(0), v.get(1).and_then(T::as_integer).unwrap_or(0)]
337                }) else {
338                    continue;
339                };
340                let (pd, pa) = (pd as f64, pa as f64);
341                if pa > 0.0 && *a > 0 && (*d as f64 / *a as f64) < pd / pa - 1e-9 {
342                    errors.push(format!("{id}'s ratio fell: {d}:{a} < {pd}:{pa}"));
343                }
344            }
345        }
346    }
347
348    let (open, format_errors) = bypasses();
349    errors.extend(format_errors);
350    let high = open.get("High").cloned().unwrap_or_default();
351    if gate && !high.is_empty() {
352        errors.push(format!("open High bypasses block every gate (ยง17b): {}", high.join(", ")));
353    }
354    let mut text = format!(
355        "verification ratio (SPEC ยง17a)\n  trust count: {trust_count} ({} platform and proof-validity entries + {} external_body/assume sites)\n  internal-unproven count: {internal}\n  global d:a: {d_sum}:{a_sum} ({:.2})\n",
356        trust_ids,
357        trust_count - trust_ids,
358        if a_sum == 0 { 0.0 } else { d_sum as f64 / a_sum as f64 }
359    );
360    text.push_str(&lines.join("\n"));
361    text.push_str("\nbypass register (SPEC ยง17b)\n");
362    for sev in ["High", "Medium", "Low"] {
363        let ids = open.get(sev).cloned().unwrap_or_default();
364        text.push_str(&format!("  open {sev}: {} {}\n", ids.len(), ids.join(" ")));
365    }
366    if write_baseline {
367        let per_s: Vec<String> = per.iter().map(|(k, (d, a))| format!("{k} = [{d}, {a}]")).collect();
368        text.push_str(&format!(
369            "\n[baseline]\ntrust_count = {trust_count}\ninternal_unproven = {internal}\ndischarged = {d_sum}\nassumed = {a_sum}\n\n[baseline.per_invariant]\n{}\n",
370            per_s.join("\n")
371        ));
372    }
373    for e in &errors {
374        text.push_str(&format!("  ERROR: {e}\n"));
375    }
376    Report { text, ok: errors.is_empty() }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    /// R14's gym mutations: a removed invariant, a duplicated obligation and
384    /// a renamed proof function each fail; a plain function or a test of the
385    /// same name discharges nothing.
386    #[test]
387    fn the_accounting_checks_bite() {
388        let doc = |t: &str| toml::from_str::<T>(t).unwrap();
389        let ok = doc("[[invariant]]\nid = \"I1\"\ndischarged = [\"txn::a\"]\n[baseline.per_invariant]\nI1 = [1, 0]\n");
390        assert!(structure_errors(&ok).is_empty());
391        let removed = doc("[[invariant]]\nid = \"I2\"\n[baseline.per_invariant]\nI1 = [1, 0]\n");
392        assert!(!structure_errors(&removed).is_empty(), "a removed invariant");
393        let dup = doc("[[invariant]]\nid = \"I1\"\ndischarged = [\"txn::a\", \"txn::a\"]\n");
394        assert!(!structure_errors(&dup).is_empty(), "a duplicated obligation");
395        let twice = doc("[[invariant]]\nid = \"I1\"\n[[invariant]]\nid = \"I1\"\n");
396        assert!(!structure_errors(&twice).is_empty(), "an invariant twice");
397
398        let src = vec![
399            "verus! {\n    pub proof fn lemma_a(x: u8)\n        ensures x == x,\n    {}\n    pub fn decide(x: u8) -> (r: u8)\n        ensures r == x,\n    { x }\n    fn helper(x: u8) -> u8 { x }\n}\n#[kani::proof]\nfn harness_b() {}\n#[test]\nfn lemma_c() {}\n// fn lemma_d()\n".to_string(),
400        ];
401        assert_eq!(discharge_in(&src, "lemma_a"), Some(Discharge::Verus));
402        assert_eq!(discharge_in(&src, "decide"), Some(Discharge::Verus), "verified against its ensures");
403        assert_eq!(discharge_in(&src, "harness_b"), Some(Discharge::Kani));
404        assert_eq!(discharge_in(&src, "helper"), None, "a plain function");
405        assert_eq!(discharge_in(&src, "lemma_c"), None, "a test");
406        assert_eq!(discharge_in(&src, "lemma_d"), None, "a comment");
407        assert_eq!(discharge_in(&src, "lemma_renamed"), None, "a renamed proof function");
408    }
409
410    #[test]
411    fn the_register_parses_and_its_checks_bite() {
412        // The shipped register, where the source tree has it (the Nix
413        // package build copies only the crates; CI's cargo test has docs/).
414        if let Ok(shipped) = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/bypasses.md")) {
415            let (open, errors) = parse_bypasses(&shipped);
416            assert!(errors.is_empty(), "{errors:?}");
417            assert!(open.values().flatten().count() > 0);
418        }
419        let row = |id: &str, sev: &str, status: &str| format!("| {id} | r | b | {sev} | {status} | m | A | I1, I2 |\n");
420        let x = "| X-1 | r | b | n | E | all |\n";
421        // A register with the given rows in Open, Closed and Accepted.
422        let reg = |o: &str, c: &str, a: &str| format!("## Open\n{o}\n## Closed\n{c}\n## Accepted\n{a}");
423        let bad = [
424            reg(&(row("B-1", "High", "Open") + &row("B-1", "Low", "Open")), "", x),
425            reg(&row("B-2", "Severe", "Open"), "", x),
426            reg(&row("B-3", "Low", "Done"), "", x),
427            reg("| B-4 | r | b | Low | Open |\n", "", x),
428            reg("", "", "| X-1 | r | b | n |\n"),
429            reg("| B-7 | r | b | Low | Open | m | A | |\n", "", x),
430            reg("| B-8 | r | b | Low | Open | m | A | everything |\n", "", x),
431            // D13: a Closed row left in Open, an Open row in Closed, rows in
432            // the wrong kind of table or in none, and the tables themselves.
433            reg(&row("B-9", "Low", "Closed (abc123)"), "", x),
434            reg("", &row("B-9", "Low", "Open (v4.6)"), x),
435            reg(&row("B-9", "Low", "Open"), "", &(row("B-10", "Low", "Closed (abc123)") + x)),
436            reg(x, "", ""),
437            row("B-9", "Low", "Open") + &reg("", "", x),
438            format!("## Open\n## Accepted\n{x}"),
439            format!("## Closed\n## Open\n## Accepted\n{x}"),
440            reg("", "", x) + "## Open\n",
441            reg("", "", x) + "## Withdrawn\n",
442        ];
443        for text in bad {
444            assert!(!parse_bypasses(&text).1.is_empty(), "accepted: {text}");
445        }
446        let (open, errors) =
447            parse_bypasses(&reg(&row("B-5", "High", "Open (v4.5)"), &row("B-6", "Low", "Closed (abc123)"), x));
448        assert!(errors.is_empty(), "{errors:?}");
449        assert_eq!(open.get("High").map(Vec::len), Some(1));
450        assert_eq!(open.get("Low"), None);
451    }
452}