Skip to main content

provable_contracts/discharge/
mod.rs

1//! PVL-001 EV-6a (#4139): `pv discharge` — what the Lean tree's proofs actually rest on.
2//!
3//! - **Escapes.** A token-level scan (comments and strings excluded, [`lex`]) of `ProvableContracts/**` and the
4//!   root for `sorry admit axiom native_decide implemented_by extern unsafe partial`. Each one must be listed in
5//!   `escape-allowlist.yaml` as `{file, decl, kind, reason, ticket, confirmed_by}`; `confirmed_by: pending` is
6//!   PENDING, and RED only under `--strict` — the scanner and its exemptions are never confirmed by the same run.
7//! - **Roots.** A theorem is contract-bound when a contract's `lean_theorem:` names it: an EXACT name
8//!   (`ProvableContracts.<…>.<decl>`) must name a declaration or it is MISSING-ROOT; a LABEL is matched the way
9//!   ONT-4b2 matches it ([`crate::ontology::extract::lean`]), and an unresolved label is held by a non-increasing
10//!   ratchet keyed on the SET in `unresolved-labels.json`: only a label NOT in it fails (by name). The gate never
11//!   writes that file; `make label-ratchet` only shrinks it (PVL-001 infra#992; cop ruling on #4139).
12//! - **Axioms.lean**, generated: every bound theorem in the root's import cone gets a SUBSET pin over
13//!   `Lean.collectAxioms` (a proof needing fewer axioms stays green); `capstones:` in `formalization.yaml` get an
14//!   exact `#guard_msgs in #print axioms`. Bound theorems outside the cone are ORPHANED-ROOT: `lake env lean`
15//!   cannot see a module `lake build` never built (EV-5a's orphans; EV-5c drains them).
16
17pub mod challenge;
18pub mod comparator;
19pub mod formalization;
20pub mod lex;
21pub mod refinement;
22pub mod summary;
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26
27use crate::ontology::extract::lean::{reference_matches, references_of, Statement};
28
29/// The escape kinds the scan reports.
30pub const ESCAPE_KINDS: &[&str] = &[
31    "sorry",
32    "admit",
33    "axiom",
34    "native_decide",
35    "implemented_by",
36    "extern",
37    "unsafe",
38    "partial",
39];
40/// `status.axioms` when `formalization.yaml` does not say otherwise, in the order `#print axioms` prints them.
41pub const DEFAULT_AXIOMS: &[&str] = &["propext", "Classical.choice", "Quot.sound"];
42pub const ALLOWLIST: &str = "escape-allowlist.yaml";
43pub const LABELS: &str = "unresolved-labels.json";
44pub const AXIOMS_FILE: &str = "Axioms.lean";
45const ROOT_MODULE: &str = "ProvableContracts";
46
47/// One parsed `.lean` file of the tree.
48#[derive(Debug, Clone)]
49pub struct LeanFile {
50    /// Relative to the lean dir, `/`-separated.
51    pub rel: String,
52    pub module: String,
53    pub toks: Vec<lex::Token>,
54    pub decls: Vec<lex::Decl>,
55    pub imports: Vec<String>,
56}
57
58/// The Lean tree under a lean dir: the root `ProvableContracts.lean` and every file under `ProvableContracts/`.
59#[derive(Debug, Clone)]
60pub struct Tree {
61    pub dir: PathBuf,
62    pub files: Vec<LeanFile>,
63}
64
65impl Tree {
66    /// `Err` (a decline) when the dir has no `ProvableContracts.lean`.
67    pub fn load(dir: &Path) -> Result<Self, String> {
68        let root = dir.join(format!("{ROOT_MODULE}.lean"));
69        if !root.is_file() {
70            return Err(format!("no {ROOT_MODULE}.lean under {}", dir.display()));
71        }
72        let mut paths = vec![root];
73        collect_lean(&dir.join(ROOT_MODULE), &mut paths);
74        paths.sort();
75        let files = paths.iter().filter_map(|p| parse_file(dir, p)).collect();
76        Ok(Self {
77            dir: dir.to_path_buf(),
78            files,
79        })
80    }
81
82    /// The modules the root reaches through imports, transitively (itself included).
83    #[must_use]
84    pub fn cone(&self) -> BTreeSet<String> {
85        let by_mod: BTreeMap<&str, &LeanFile> =
86            self.files.iter().map(|f| (f.module.as_str(), f)).collect();
87        let mut seen = BTreeSet::new();
88        let mut todo = vec![ROOT_MODULE.to_string()];
89        while let Some(m) = todo.pop() {
90            let Some(f) = by_mod.get(m.as_str()) else {
91                continue;
92            };
93            if seen.insert(m.clone()) {
94                todo.extend(f.imports.iter().cloned());
95            }
96        }
97        seen
98    }
99}
100
101fn collect_lean(dir: &Path, out: &mut Vec<PathBuf>) {
102    let Ok(rd) = std::fs::read_dir(dir) else {
103        return;
104    };
105    for e in rd.flatten() {
106        let p = e.path();
107        if p.is_dir() {
108            collect_lean(&p, out);
109        } else if p.extension().is_some_and(|x| x == "lean") {
110            out.push(p);
111        }
112    }
113}
114
115fn parse_file(dir: &Path, p: &Path) -> Option<LeanFile> {
116    let src = std::fs::read_to_string(p).ok()?;
117    let rel = p
118        .strip_prefix(dir)
119        .ok()?
120        .to_string_lossy()
121        .replace('\\', "/");
122    let blanked = lex::blank(&src);
123    let toks = lex::tokens(&blanked);
124    Some(LeanFile {
125        module: rel.trim_end_matches(".lean").replace('/', "."),
126        decls: lex::decls(&toks),
127        imports: lex::imports(&blanked),
128        toks,
129        rel,
130    })
131}
132
133/// One escape the scan found.
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
135pub struct Escape {
136    pub file: String,
137    pub line: usize,
138    pub kind: String,
139    /// The declaration it belongs to: the one it sits in (`sorry admit native_decide`), the one it declares
140    /// (`axiom`), or the one it modifies (`implemented_by extern unsafe partial` precede their declaration).
141    pub decl: String,
142}
143
144/// Every escape in the tree, in file then line order.
145#[must_use]
146pub fn escapes(tree: &Tree) -> Vec<Escape> {
147    let mut out = Vec::new();
148    for f in &tree.files {
149        for (i, t) in f.toks.iter().enumerate() {
150            if ESCAPE_KINDS.contains(&t.text.as_str()) {
151                out.push(Escape {
152                    file: f.rel.clone(),
153                    line: t.line,
154                    kind: t.text.clone(),
155                    decl: owner(&f.decls, i, &t.text),
156                });
157            }
158        }
159    }
160    out
161}
162
163fn owner(decls: &[lex::Decl], i: usize, kind: &str) -> String {
164    // `k` splits the declarations into those whose keyword is at or before token `i` and those after it. An
165    // `axiom` token IS its declaration's keyword (at == i), so it lands on the left: the left side's last entry
166    // is the one a body escape sits in or an `axiom` declares, and the right side's first is the one a modifier
167    // precedes.
168    let k = decls.partition_point(|d| d.at <= i);
169    let pick = match kind {
170        "implemented_by" | "extern" | "unsafe" | "partial" => decls.get(k),
171        _ => k.checked_sub(1).and_then(|p| decls.get(p)),
172    };
173    pick.map_or_else(|| "<none>".to_string(), |d| d.fqn.clone())
174}
175
176/// One `escape-allowlist.yaml` entry.
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct Allowed {
179    pub file: String,
180    pub decl: String,
181    pub kind: String,
182    pub reason: String,
183    pub ticket: String,
184    pub confirmed_by: String,
185}
186
187impl Allowed {
188    fn key(&self) -> (String, String, String) {
189        (self.file.clone(), self.decl.clone(), self.kind.clone())
190    }
191
192    /// The fields this entry lacks (an entry without its reason or its ticket exempts nothing).
193    fn missing(&self) -> Vec<&'static str> {
194        [
195            ("file", &self.file),
196            ("decl", &self.decl),
197            ("kind", &self.kind),
198            ("reason", &self.reason),
199            ("ticket", &self.ticket),
200            ("confirmed_by", &self.confirmed_by),
201        ]
202        .into_iter()
203        .filter(|(_, v)| v.trim().is_empty())
204        .map(|(k, _)| k)
205        .collect()
206    }
207}
208
209/// The allowlist; a missing file is an empty list (every escape is then unlisted), an unreadable one an error.
210pub fn load_allowlist(dir: &Path) -> Result<Vec<Allowed>, String> {
211    let p = dir.join(ALLOWLIST);
212    if !p.exists() {
213        return Ok(Vec::new());
214    }
215    let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
216    let doc: serde_yaml::Value =
217        serde_yaml::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?;
218    let entries = match &doc {
219        serde_yaml::Value::Null => return Ok(Vec::new()),
220        serde_yaml::Value::Sequence(s) => s,
221        _ => return Err(format!("{}: not a list of entries", p.display())),
222    };
223    let field = |e: &serde_yaml::Value, k: &str| {
224        e.get(k).map(|v| match v {
225            serde_yaml::Value::String(s) => s.clone(),
226            other => serde_yaml::to_string(other)
227                .unwrap_or_default()
228                .trim()
229                .to_string(),
230        })
231    };
232    Ok(entries
233        .iter()
234        .map(|e| Allowed {
235            file: field(e, "file").unwrap_or_default(),
236            decl: field(e, "decl").unwrap_or_default(),
237            kind: field(e, "kind").unwrap_or_default(),
238            reason: field(e, "reason").unwrap_or_default(),
239            ticket: field(e, "ticket").unwrap_or_default(),
240            confirmed_by: field(e, "confirmed_by").unwrap_or_default(),
241        })
242        .collect())
243}
244
245/// `formalization.yaml`'s `status.axioms` and `capstones`, or the defaults when it is absent. `--validate-formalization`
246/// ([`formalization`], EV-8b) is the arm that requires it.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct Formalization {
249    pub axioms: Vec<String>,
250    pub capstones: Vec<String>,
251}
252
253pub fn load_formalization(dir: &Path) -> Result<Formalization, String> {
254    let p = dir.join("formalization.yaml");
255    let strings = |v: Option<&serde_yaml::Value>| -> Vec<String> {
256        v.and_then(serde_yaml::Value::as_sequence)
257            .map(|s| {
258                s.iter()
259                    .filter_map(|x| x.as_str().map(str::to_string))
260                    .collect()
261            })
262            .unwrap_or_default()
263    };
264    let doc: serde_yaml::Value = if p.exists() {
265        let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
266        serde_yaml::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?
267    } else {
268        serde_yaml::Value::Null
269    };
270    let mut axioms = strings(doc.get("status").and_then(|s| s.get("axioms")));
271    if axioms.is_empty() {
272        axioms = DEFAULT_AXIOMS.iter().map(|s| (*s).to_string()).collect();
273    }
274    Ok(Formalization {
275        axioms,
276        capstones: strings(doc.get("capstones")),
277    })
278}
279
280/// A contract-bound theorem.
281#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
282pub struct Root {
283    pub fqn: String,
284    pub module: String,
285}
286
287/// What the contracts' `lean_theorem:` references bind.
288#[derive(Debug, Clone, Default)]
289pub struct Binding {
290    pub roots: BTreeSet<Root>,
291    /// The same roots, per contract stem: what `pv challenge` pins, one `Challenge/<stem>.lean` each (EV-7a).
292    pub by_contract: BTreeMap<String, BTreeSet<Root>>,
293    /// `(contract stem, exact-name reference)` naming no declaration.
294    pub missing: BTreeSet<(String, String)>,
295    /// `(contract stem, label reference)` matching no theorem, file or domain.
296    pub unresolved_labels: BTreeSet<(String, String)>,
297}
298
299/// An exact-name reference: the fully qualified form, `ProvableContracts.<…>.<decl>`.
300#[must_use]
301pub fn is_exact_name(reference: &str) -> bool {
302    reference.starts_with("ProvableContracts.") && !reference.contains(char::is_whitespace)
303}
304
305/// The theorems of `Theorems/<Domain>/*.lean` (ONT-4b2's scope), non-private, as ONT statements plus their roots.
306fn statements(tree: &Tree) -> Vec<(Statement, Root)> {
307    let mut out = Vec::new();
308    for f in &tree.files {
309        let parts: Vec<&str> = f.rel.split('/').collect();
310        let [_, "Theorems", domain, file] = parts.as_slice() else {
311            continue;
312        };
313        for d in f.decls.iter().filter(|d| is_theorem(d)) {
314            let s = Statement {
315                domain: (*domain).to_string(),
316                stem: file.trim_end_matches(".lean").to_string(),
317                name: d.name.clone(),
318                file: f.rel.clone(),
319                sorry_free: true,
320            };
321            out.push((
322                s,
323                Root {
324                    fqn: d.fqn.clone(),
325                    module: f.module.clone(),
326                },
327            ));
328        }
329    }
330    out
331}
332
333fn theorem_fqns(tree: &Tree) -> BTreeSet<&str> {
334    tree.files
335        .iter()
336        .flat_map(|f| {
337            f.decls
338                .iter()
339                .filter(|d| is_theorem(d))
340                .map(|d| d.fqn.as_str())
341        })
342        .collect()
343}
344
345fn is_theorem(d: &lex::Decl) -> bool {
346    matches!(d.keyword.as_str(), "theorem" | "lemma") && !d.private
347}
348
349/// Join every contract under `contract_dir` to the tree.
350#[must_use]
351pub fn bind(tree: &Tree, contract_dir: &Path) -> Binding {
352    let stmts = statements(tree);
353    let accepted: Vec<BTreeSet<String>> = stmts.iter().map(|(s, _)| s.accepted_names()).collect();
354    let theorems: BTreeMap<String, Root> = tree
355        .files
356        .iter()
357        .flat_map(|f| {
358            f.decls
359                .iter()
360                .filter(|d| is_theorem(d))
361                .map(move |d| (d.fqn.clone(), f.module.clone()))
362        })
363        .map(|(fqn, module)| (fqn.clone(), Root { fqn, module }))
364        .collect();
365    let mut b = Binding::default();
366    for (stem, _rel, doc) in crate::ontology::extract::pv_contract::documents(contract_dir) {
367        for r in references_of(&doc) {
368            bind_one(&mut b, &stem, &r, &theorems, &stmts, &accepted);
369        }
370    }
371    b
372}
373
374fn bind_one(
375    b: &mut Binding,
376    stem: &str,
377    r: &str,
378    theorems: &BTreeMap<String, Root>,
379    stmts: &[(Statement, Root)],
380    accepted: &[BTreeSet<String>],
381) {
382    if is_exact_name(r) {
383        match theorems.get(r) {
384            Some(root) => {
385                b.roots.insert(root.clone());
386                b.by_contract
387                    .entry(stem.to_string())
388                    .or_default()
389                    .insert(root.clone());
390            }
391            None => {
392                b.missing.insert((stem.to_string(), r.to_string()));
393            }
394        }
395        return;
396    }
397    let hits: Vec<&Root> = stmts
398        .iter()
399        .zip(accepted)
400        .filter(|(_, names)| reference_matches(r, names))
401        .map(|((_, root), _)| root)
402        .collect();
403    if hits.is_empty() {
404        b.unresolved_labels
405            .insert((stem.to_string(), r.to_string()));
406    }
407    if !hits.is_empty() {
408        b.by_contract
409            .entry(stem.to_string())
410            .or_default()
411            .extend(hits.iter().map(|r| (*r).clone()));
412    }
413    b.roots.extend(hits.into_iter().cloned());
414}
415
416/// `unresolved-labels.json` `{command, labels: [{contract, label}]}`: the SET the label ratchet is keyed on.
417/// `None` when absent (every unresolved label is then new). The gate never writes it (PVL-001 infra#992).
418pub fn load_labels(dir: &Path) -> Result<Option<BTreeSet<(String, String)>>, String> {
419    let p = dir.join(LABELS);
420    if !p.exists() {
421        return Ok(None);
422    }
423    let bad = |e: &dyn std::fmt::Display| format!("{}: {e}", p.display());
424    let text = std::fs::read_to_string(&p).map_err(|e| bad(&e))?;
425    let doc: serde_json::Value = serde_json::from_str(&text).map_err(|e| bad(&e))?;
426    let labels = doc
427        .get("labels")
428        .and_then(serde_json::Value::as_array)
429        .ok_or_else(|| bad(&"no `labels` array"))?;
430    labels
431        .iter()
432        .map(|l| {
433            let f = |k: &str| {
434                l.get(k)
435                    .and_then(serde_json::Value::as_str)
436                    .map(str::to_string)
437            };
438            f("contract")
439                .zip(f("label"))
440                .ok_or_else(|| bad(&format!("entry without contract/label: {l}")))
441        })
442        .collect::<Result<BTreeSet<_>, _>>()
443        .map(Some)
444}
445
446/// The bytes `make label-ratchet` writes.
447#[must_use]
448pub fn render_labels(set: &BTreeSet<(String, String)>) -> String {
449    use serde_json::{Map, Value};
450    let obj = |pairs: [(&str, Value); 2]| {
451        Value::Object(
452            pairs
453                .into_iter()
454                .map(|(k, v)| (k.to_string(), v))
455                .collect::<Map<_, _>>(),
456        )
457    };
458    let labels = set
459        .iter()
460        .map(|(c, l)| {
461            obj([
462                ("contract", Value::from(c.as_str())),
463                ("label", Value::from(l.as_str())),
464            ])
465        })
466        .collect();
467    let doc = obj([
468        ("command", Value::from("make label-ratchet")),
469        ("labels", Value::Array(labels)),
470    ]);
471    format!(
472        "{}\n",
473        serde_json::to_string_pretty(&doc).unwrap_or_default()
474    )
475}
476
477/// A Lean name literal: `` `A.b ``, with `«»` around any component that is not a plain identifier.
478fn name_lit(fqn: &str) -> String {
479    let parts: Vec<String> = fqn
480        .split('.')
481        .map(|p| {
482            let plain = p
483                .chars()
484                .next()
485                .is_some_and(|c| c.is_alphabetic() || c == '_')
486                && p.chars()
487                    .all(|c| c.is_alphanumeric() || c == '_' || c == '\'');
488            if plain {
489                p.to_string()
490            } else {
491                format!("«{p}»")
492            }
493        })
494        .collect();
495    format!("`{}", parts.join("."))
496}
497
498/// The pinned set: `status.axioms` plus every axiom `escape-allowlist.yaml` exempts (the allowlist IS the
499/// exemption; an axiom removed from it is RED twice — in the scan and in its dependents' pins).
500#[must_use]
501pub fn pinned_axioms(form: &Formalization, allow: &[Allowed]) -> Vec<String> {
502    let mut out = form.axioms.clone();
503    for a in allow
504        .iter()
505        .filter(|a| a.kind == "axiom" && !a.decl.is_empty())
506    {
507        if !out.contains(&a.decl) {
508            out.push(a.decl.clone());
509        }
510    }
511    out
512}
513
514/// `Axioms.lean`: the roots in `cone` get subset pins, capstones an exact pin; the rest are counted, not pinned.
515#[must_use]
516pub fn render_axioms(
517    roots: &BTreeSet<Root>,
518    cone: &BTreeSet<String>,
519    pinned: &[String],
520    form: &Formalization,
521) -> String {
522    let in_cone: Vec<&Root> = roots.iter().filter(|r| cone.contains(&r.module)).collect();
523    let orphaned = roots.len() - in_cone.len();
524    let mut s = format!(
525        "-- GENERATED by `pv discharge gen-axioms` (PVL-001 EV-6a, #4139). Do not edit: regenerate.\n\
526         -- Every contract-bound theorem in the root's import cone may use only the pinned axioms. It is a SUBSET pin,\n\
527         -- so a proof that needs fewer stays green; `sorryAx`, or any axiom outside the set, fails elaboration.\n\
528         -- {} pinned; {orphaned} bound outside the root's import cone (ORPHANED-ROOT, pinned once EV-5c imports them).\n\
529         import {ROOT_MODULE}\n\n\
530         open Lean Elab Command in\n\
531         /-- `n` exists and every axiom it depends on is in `pinned`. -/\n\
532         def pvlAxiomsSubset (n : Name) (pinned : List Name) : CommandElabM Unit := do\n\
533         \x20 unless (← getEnv).contains n do\n\
534         \x20   throwError \"MISSING-ROOT {{n}}\"\n\
535         \x20 let axs ← collectAxioms n\n\
536         \x20 let extra := axs.toList.filter (fun a => !pinned.contains a)\n\
537         \x20 unless extra.isEmpty do\n\
538         \x20   throwError \"AXIOMS {{n}}: {{extra}} outside the pinned set\"\n\n\
539         open Lean in\n\
540         /-- formalization.yaml `status.axioms`, plus every axiom escape-allowlist.yaml exempts. -/\n\
541         def pvlPinned : List Name := [{}]\n\n",
542        in_cone.len(),
543        pinned.iter().map(|a| name_lit(a)).collect::<Vec<_>>().join(", "),
544    );
545    for r in &in_cone {
546        s.push_str(&format!(
547            "run_cmd pvlAxiomsSubset {} pvlPinned\n",
548            name_lit(&r.fqn)
549        ));
550    }
551    for c in &form.capstones {
552        s.push_str(&format!(
553            "\n/-- info: '{c}' depends on axioms: [{}] -/\n#guard_msgs in #print axioms {c}\n",
554            form.axioms.join(", ")
555        ));
556    }
557    s
558}
559
560/// Everything one generation read from disk, and the `Axioms.lean` it renders.
561#[derive(Debug, Clone)]
562pub struct Generated {
563    pub text: String,
564    pub tree: Tree,
565    pub binding: Binding,
566    pub allow: Vec<Allowed>,
567    pub form: Formalization,
568}
569
570/// The whole generation, from disk. `Err` (a decline: nothing can be judged) when the root file is missing or
571/// `escape-allowlist.yaml` / `formalization.yaml` cannot be read.
572pub fn generate(lean_dir: &Path, contract_dir: &Path) -> Result<Generated, String> {
573    let tree = Tree::load(lean_dir)?;
574    let binding = bind(&tree, contract_dir);
575    let allow = load_allowlist(lean_dir)?;
576    let form = load_formalization(lean_dir)?;
577    let text = render_axioms(
578        &binding.roots,
579        &tree.cone(),
580        &pinned_axioms(&form, &allow),
581        &form,
582    );
583    Ok(Generated {
584        text,
585        tree,
586        binding,
587        allow,
588        form,
589    })
590}
591
592/// A `pv discharge` verdict: printed lines, and whether it rejects (rc 1) or declines (rc 2).
593#[derive(Debug, Clone, Default, PartialEq, Eq)]
594pub struct Report {
595    pub lines: Vec<String>,
596    pub reject: bool,
597    pub decline: Option<String>,
598    /// Raw exit of `lake env lean Axioms.lean` (a signal is 128+n); `None` when it never ran.
599    pub lake_exit: Option<i32>,
600    /// Raw exit of the `--leanchecker` recheck (124/137 on a timeout); `None` when it never ran.
601    pub leanchecker_exit: Option<i32>,
602    /// What `--comparator` closed (EV-7b); `None` when it never judged a row set.
603    pub challenges: Option<comparator::Closure>,
604    /// The escape scan added no failure (EV-8a's `escapes_ok`); `None` when `check` never scanned.
605    pub escapes_ok: Option<bool>,
606    /// `Axioms.lean` is its regeneration (half of EV-8a's `axioms_ok`); `None` when `check` never compared it.
607    pub axioms_fresh: Option<bool>,
608}
609
610impl Report {
611    fn fail(&mut self, line: String) {
612        self.lines.push(format!("FAIL  {line}"));
613        self.reject = true;
614    }
615}
616
617/// `check`'s switches.
618#[derive(Debug, Clone, Copy, Default)]
619pub struct CheckOpts {
620    pub strict: bool,
621    /// Also judge `formalization.yaml` against the summary and the tree (EV-8b, [`formalization`]).
622    pub validate_formalization: bool,
623}
624
625/// Escapes against the allowlist: unlisted, malformed and stale are RED; pending is RED only under `strict`.
626pub fn judge_escapes(found: &[Escape], allow: &[Allowed], strict: bool, r: &mut Report) {
627    let listed: BTreeSet<_> = allow.iter().map(Allowed::key).collect();
628    for e in found
629        .iter()
630        .filter(|e| !listed.contains(&(e.file.clone(), e.decl.clone(), e.kind.clone())))
631    {
632        r.fail(format!(
633            "ESCAPE {}:{} `{}` in {} -- not in {ALLOWLIST}",
634            e.file, e.line, e.kind, e.decl
635        ));
636    }
637    let present: BTreeSet<_> = found
638        .iter()
639        .map(|e| (e.file.clone(), e.decl.clone(), e.kind.clone()))
640        .collect();
641    for a in allow {
642        let miss = a.missing();
643        if !miss.is_empty() {
644            r.fail(format!(
645                "{ALLOWLIST} entry {}/{}/{} has no {}",
646                a.file,
647                a.decl,
648                a.kind,
649                miss.join("/")
650            ));
651        } else if !present.contains(&a.key()) {
652            r.fail(format!(
653                "STALE {ALLOWLIST} entry {}/{}/{} -- no such escape: remove it",
654                a.file, a.decl, a.kind
655            ));
656        }
657    }
658    let pending: Vec<&Allowed> = allow
659        .iter()
660        .filter(|a| a.confirmed_by.trim() == "pending")
661        .collect();
662    if !pending.is_empty() {
663        r.lines.push(format!("PENDING ({})", pending.len()));
664        for a in &pending {
665            r.lines.push(format!(
666                "  {} {} in {} -- {}",
667                a.kind, a.decl, a.file, a.ticket
668            ));
669        }
670        if strict {
671            r.fail(format!(
672                "--strict: {} allowlist entr(ies) still confirmed_by: pending",
673                pending.len()
674            ));
675        }
676    }
677}
678
679/// The label ratchet: an unresolved label NOT in the set fails BY NAME; a listed one that resolves now is reported
680/// (`make label-ratchet` removes it) and is not a failure.
681pub fn judge_labels(
682    current: &BTreeSet<(String, String)>,
683    base: Option<&BTreeSet<(String, String)>>,
684    r: &mut Report,
685) {
686    let empty = BTreeSet::new();
687    let base = base.unwrap_or(&empty);
688    for (s, l) in current.difference(base) {
689        r.fail(format!(
690            "NEW-UNRESOLVED-LABEL {s}: {l} -- names no theorem, file or domain in the tree"
691        ));
692    }
693    let resolved = base.difference(current).count();
694    if resolved > 0 {
695        r.lines.push(format!(
696            "RESOLVED-LABEL ({resolved}) still listed in {LABELS} -- `make label-ratchet` removes them"
697        ));
698    }
699    r.lines.push(format!(
700        "UNRESOLVED-LABEL ({}) (listed {})",
701        current.len(),
702        base.len()
703    ));
704}
705
706/// `pv discharge check` without the Lean elaboration (the caller runs `lake env lean Axioms.lean` unless
707/// `--no-lake`). Failures are judged before the vacuity decline: a failure never hides behind "not a verdict".
708pub fn check(lean_dir: &Path, contract_dir: &Path, opts: CheckOpts) -> Report {
709    let mut r = Report::default();
710    let g = match generate(lean_dir, contract_dir) {
711        Ok(g) => g,
712        Err(e) => {
713            r.decline = Some(e);
714            return r;
715        }
716    };
717    let fails = |r: &Report| r.lines.iter().filter(|l| l.starts_with("FAIL")).count();
718    let before = fails(&r);
719    judge_escapes(&escapes(&g.tree), &g.allow, opts.strict, &mut r);
720    r.escapes_ok = Some(fails(&r) == before);
721    judge_roots(&g, &mut r);
722    judge_ratchet(lean_dir, &g.binding, &mut r);
723    let before = fails(&r);
724    judge_axioms_file(lean_dir, &g.text, &mut r);
725    r.axioms_fresh = Some(fails(&r) == before);
726    if opts.validate_formalization {
727        formalization::judge(lean_dir, &g.tree, &mut r);
728    }
729    let cone = g.tree.cone();
730    let roots = &g.binding.roots;
731    let pinned = roots.iter().filter(|x| cone.contains(&x.module)).count();
732    r.lines.push(format!(
733        "ROOTS {pinned} pinned, {} ORPHANED-ROOT",
734        roots.len() - pinned
735    ));
736    // A bound theorem outside the cone is never built, so no pin and no `--leanchecker` re-check reaches it.
737    // It is counted here; it is not RED here. Not-worse is `pv lint --gate refinement` (PV-ONT-034, HEAD vs
738    // BASE). The absolute rule (EV-6c, #4244) moved to 0.70.1 with the 27 modules that drain it.
739    if pinned == 0 && !r.reject {
740        r.decline = Some(format!(
741            "0 contract-bound theorems in the root's import cone under {}",
742            lean_dir.display()
743        ));
744    }
745    r
746}
747
748/// MISSING-ROOT: an exact-name reference naming no theorem (saying what it names instead, if anything), and a
749/// capstone naming no theorem.
750fn judge_roots(g: &Generated, r: &mut Report) {
751    let kinds: BTreeMap<&str, &str> = g
752        .tree
753        .files
754        .iter()
755        .flat_map(|f| f.decls.iter().map(|d| (d.fqn.as_str(), d.keyword.as_str())))
756        .collect();
757    for (s, x) in &g.binding.missing {
758        let what = kinds.get(x.as_str()).map_or_else(
759            || "no such theorem in the tree".to_string(),
760            |k| format!("it names an `{k}`, not a proved theorem"),
761        );
762        r.fail(format!("MISSING-ROOT contract {s}: {x} -- {what}"));
763    }
764    let theorems = theorem_fqns(&g.tree);
765    for c in g
766        .form
767        .capstones
768        .iter()
769        .filter(|c| !theorems.contains(c.as_str()))
770    {
771        r.fail(format!(
772            "MISSING-ROOT capstone {c} -- no such theorem in the tree"
773        ));
774    }
775}
776
777fn judge_ratchet(lean_dir: &Path, b: &Binding, r: &mut Report) {
778    match load_labels(lean_dir) {
779        Ok(base) => judge_labels(&b.unresolved_labels, base.as_ref(), r),
780        Err(e) => r.fail(format!("{LABELS} unreadable: {e}")),
781    }
782}
783
784/// `make label-ratchet` (`pv discharge label-ratchet`): the set keeps only labels still unresolved; it never gains
785/// one (a new label is reported and left OUT). A missing file is seeded from what is measured.
786pub fn ratchet_labels(lean_dir: &Path, contract_dir: &Path) -> Report {
787    let mut r = Report::default();
788    let tree = match Tree::load(lean_dir) {
789        Ok(t) => t,
790        Err(e) => {
791            r.decline = Some(e);
792            return r;
793        }
794    };
795    let current = bind(&tree, contract_dir).unresolved_labels;
796    let next: BTreeSet<_> = match load_labels(lean_dir) {
797        Ok(Some(base)) => {
798            judge_labels(&current, Some(&base), &mut r);
799            base.intersection(&current).cloned().collect()
800        }
801        Ok(None) => current,
802        Err(e) => {
803            r.fail(format!("{LABELS} unreadable: {e}"));
804            return r;
805        }
806    };
807    let p = lean_dir.join(LABELS);
808    match std::fs::write(&p, render_labels(&next)) {
809        Ok(()) => r
810            .lines
811            .push(format!("wrote {} ({} label(s))", p.display(), next.len())),
812        Err(e) => r.fail(format!("cannot write {}: {e}", p.display())),
813    }
814    r
815}
816
817fn judge_axioms_file(lean_dir: &Path, text: &str, r: &mut Report) {
818    match std::fs::read_to_string(lean_dir.join(AXIOMS_FILE)) {
819        Ok(on_disk) if on_disk == text => {}
820        Ok(_) => r.fail(format!(
821            "STALE {AXIOMS_FILE} -- regeneration differs: run `pv discharge gen-axioms`"
822        )),
823        Err(_) => r.fail(format!("no {AXIOMS_FILE} -- run `pv discharge gen-axioms`")),
824    }
825}
826
827#[cfg(test)]
828mod tests;