Skip to main content

provable_contracts/
proof_status.rs

1//! Proof status report — cross-contract proof level assessment.
2//!
3//! Computes a hierarchical proof level (L1–L5) for each contract and
4//! aggregates them into kernel equivalence classes that mirror the
5//! `KernelOp` classification from apr-model-qa-playbook.
6//!
7//! Output is consumed by `pv proof-status` (text/JSON) and by the
8//! playbook's `ProofBonus` MQS integration.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14
15use crate::binding::{BindingRegistry, ImplStatus};
16use crate::discharge::summary::{self, Discharged, L4Source};
17use crate::schema::Contract;
18
19// ── Proof level hierarchy ─────────────────────────────────────────
20
21/// Hierarchical proof assurance level.
22///
23/// Each level subsumes the ones below it:
24/// - **L1** — Contract YAML exists with equations
25/// - **L2** — Property tested (falsification tests cover obligations)
26/// - **L3** — Kani bounded-model-checked
27/// - **L4** — Lean 4 theorem proved
28/// - **L5** — L4 + all bindings verified as implemented
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30pub enum ProofLevel {
31    /// Contract YAML exists with equations
32    L1,
33    /// Property tested via falsification tests
34    L2,
35    /// Kani bounded-model-checked
36    L3,
37    /// Lean 4 theorem proved
38    L4,
39    /// Lean proved and all bindings verified
40    L5,
41}
42
43impl fmt::Display for ProofLevel {
44    /// Format the proof level as its string label (L1 through L5)
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        let s = match self {
47            Self::L1 => "L1",
48            Self::L2 => "L2",
49            Self::L3 => "L3",
50            Self::L4 => "L4",
51            Self::L5 => "L5",
52        };
53        write!(f, "{s}")
54    }
55}
56
57// ── Per-contract status ───────────────────────────────────────────
58
59/// Proof status for a single contract.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ContractProofStatus {
62    /// Contract file stem (e.g. "softmax-kernel-v1")
63    pub stem: String,
64    /// Computed hierarchical proof level
65    pub proof_level: ProofLevel,
66    /// Number of proof obligations in the contract
67    pub obligations: u32,
68    /// Obligations declared `applies_to: not_applicable` (PMAT-3091). Counted
69    /// APART: never passed, discharged or proved, and never removed from
70    /// `obligations` for the level computation, so they cannot raise a level.
71    #[serde(default)]
72    pub not_applicable: u32,
73    /// Number of falsification tests defined
74    pub falsification_tests: u32,
75    /// Number of Kani bounded-model-checking harnesses
76    pub kani_harnesses: u32,
77    /// Number of obligations proved in Lean 4
78    pub lean_proved: u32,
79    /// Obligations grounded by a sorry-free in-tree Lean theorem the equation names (ONT-2a)
80    pub lean_grounded: u32,
81    /// The contract claims a Lean proof its tree does not ground: printed `self-declared`, excluded from L4
82    pub l4_self_declared: bool,
83    /// Number of bindings with `implemented` status
84    pub bindings_implemented: u32,
85    /// Total number of equation bindings
86    pub bindings_total: u32,
87}
88
89// ── Kernel class summary ──────────────────────────────────────────
90
91/// Summary of proof status for a kernel equivalence class.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct KernelClassSummary {
94    /// Kernel class identifier (A through E)
95    pub label: String,
96    /// Human-readable description of the kernel combination
97    pub description: String,
98    /// Contract stems belonging to this class
99    pub contract_stems: Vec<String>,
100    /// Lowest proof level among class members
101    pub min_proof_level: ProofLevel,
102    /// Whether all class members have full binding coverage
103    pub all_bound: bool,
104}
105
106// ── Full report ───────────────────────────────────────────────────
107
108/// Top-level proof status report, serializable to JSON.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ProofStatusReport {
111    /// Report schema version for forward compatibility
112    pub schema_version: String,
113    /// Unix epoch timestamp when the report was generated
114    pub timestamp: String,
115    /// Per-contract proof status entries
116    pub contracts: Vec<ContractProofStatus>,
117    /// Kernel equivalence class summaries
118    pub kernel_classes: Vec<KernelClassSummary>,
119    /// ONT-2a andon: a self-declared L4 is excluded from the L4 total in this build
120    pub l4_self_declared_excluded: bool,
121    /// EV-8b: `discharge` when L4 credit came from `discharge-summary.json`, `self-declared` when no summary was found
122    /// and the tree's own `.lean` text was scanned instead
123    #[serde(default)]
124    pub l4_source: L4Source,
125    /// Why the summary granted nothing (`stale discharge: …`, `red discharge: …`); `None` when it granted or was absent
126    #[serde(default)]
127    pub l4_withheld: Option<String>,
128    /// Aggregate totals across all contracts
129    pub totals: ProofStatusTotals,
130}
131
132/// Aggregate totals across all contracts.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ProofStatusTotals {
135    /// Total number of contracts analyzed
136    pub contracts: u32,
137    /// Sum of proof obligations across all contracts
138    pub obligations: u32,
139    /// Sum of `applies_to: not_applicable` obligations (PMAT-3091), a subset of
140    /// `obligations` and never a subset of anything proved.
141    #[serde(default)]
142    pub not_applicable: u32,
143    /// Sum of falsification tests across all contracts
144    pub falsification_tests: u32,
145    /// Sum of Kani harnesses across all contracts
146    pub kani_harnesses: u32,
147    /// Sum of Lean-proved obligations across all contracts
148    pub lean_proved: u32,
149    /// Sum of GROUNDED Lean-proved obligations across all contracts (ONT-2a)
150    pub lean_grounded: u32,
151    /// Contracts whose L4 claim is self-declared, and therefore excluded from L4 (ONT-2a)
152    pub l4_self_declared: u32,
153    /// Sum of implemented bindings across all contracts
154    pub bindings_implemented: u32,
155    /// Sum of total bindings across all contracts
156    pub bindings_total: u32,
157}
158
159// ── Kernel class → contract stem mapping ──────────────────────────
160
161/// Static mapping from kernel equivalence class to contract stems.
162///
163/// Mirrors the `KernelOp` classification from `apr-model-qa-playbook`:
164/// - **A** — GQA + `RMSNorm` + `SiLU` + `SwiGLU` + `RoPE` (Llama/Mistral)
165/// - **B** — MHA + `LayerNorm` + GELU + `AbsPos` (GPT-2/BERT)
166/// - **C** — MHA + `LayerNorm` + GELU + `ALiBi` (BLOOM/MPT)
167/// - **D** — `LayerNorm` + GELU + `SiLU` + GQA (Gemma)
168/// - **E** — `RMSNorm` + `SwiGLU` + GQA (Qwen)
169fn kernel_class_map() -> Vec<(&'static str, &'static str, &'static [&'static str])> {
170    vec![
171        (
172            "A",
173            "GQA+RMSNorm+SiLU+SwiGLU+RoPE",
174            &[
175                "rmsnorm-kernel-v1",
176                "silu-kernel-v1",
177                "swiglu-kernel-v1",
178                "rope-kernel-v1",
179                "gqa-kernel-v1",
180                "softmax-kernel-v1",
181                "matmul-kernel-v1",
182            ],
183        ),
184        (
185            "B",
186            "MHA+LayerNorm+GELU+AbsPos",
187            &[
188                "layernorm-kernel-v1",
189                "gelu-kernel-v1",
190                "attention-kernel-v1",
191                "softmax-kernel-v1",
192                "matmul-kernel-v1",
193                "absolute-position-v1",
194            ],
195        ),
196        (
197            "C",
198            "MHA+LayerNorm+GELU+ALiBi",
199            &[
200                "layernorm-kernel-v1",
201                "gelu-kernel-v1",
202                "attention-kernel-v1",
203                "softmax-kernel-v1",
204                "alibi-kernel-v1",
205                "matmul-kernel-v1",
206            ],
207        ),
208        (
209            "D",
210            "LayerNorm+GELU+SiLU+GQA",
211            &[
212                "layernorm-kernel-v1",
213                "gelu-kernel-v1",
214                "silu-kernel-v1",
215                "gqa-kernel-v1",
216                "softmax-kernel-v1",
217                "matmul-kernel-v1",
218            ],
219        ),
220        (
221            "E",
222            "RMSNorm+SwiGLU+GQA",
223            &[
224                "rmsnorm-kernel-v1",
225                "swiglu-kernel-v1",
226                "gqa-kernel-v1",
227                "softmax-kernel-v1",
228                "matmul-kernel-v1",
229            ],
230        ),
231    ]
232}
233
234// ── Core computation ──────────────────────────────────────────────
235
236/// Returns `true` when EVERY proof obligation is discharged in Lean.
237///
238/// Strict per-obligation semantics — no fuzzy over-promotion. A contract is
239/// Lean-proved (L4) only when the number of Lean-proved obligations plus the
240/// explicitly not-applicable ones covers ALL proof obligations, and at least
241/// one obligation is genuinely proved. The obligation total is
242/// `proof_obligations.len()` — the SAME total used for L2/L3 — so a
243/// `verification_summary` cannot manufacture L4 by understating the total.
244///
245/// Proof counts come from the contract's `verification_summary` when it makes a
246/// positive claim (`l4_lean_proved > 0`); otherwise from a scan of the in-tree
247/// sorry-free `.lean` theorems. Because the scan can only credit obligations it
248/// resolves, partial coverage (the old "≥1 resolving ref → L4" over-promotion)
249/// now correctly reports L3 instead of a full L4. Contracts with legitimately
250/// N/A obligations (e.g. softmax-kernel-v1 = 5 proved + 4 N/A of 9) MUST declare
251/// that in `verification_summary` — the scan path grants no N/A credit.
252///
253/// The grounding count is passed IN rather than scanned here.
254///
255/// The scan reads the Lean tree from paths relative to the process CWD, so inside a unit test it resolves
256/// nothing and every fixture is ungrounded by construction — a suite in which the andon could withdraw
257/// ALL credit for ever and no test would notice. Taking the count as a parameter is what keeps both sides
258/// covered: `grounded == 0` is the andon, `grounded + not_applicable >= total` is the credit it still
259/// grants. Production callers use the wrapper and get the scan.
260#[must_use]
261pub fn is_lean_proved_with_grounding(contract: &Contract, grounded: u32) -> bool {
262    let total = contract.proof_obligations.len() as u32;
263    if total == 0 {
264        return false;
265    }
266    // ONT-001 ONT-2a (andon): a `verification_summary` is the contract talking about ITSELF. PVL EV-8b: the
267    // grounding is a theorem an equation names that a green, fresh, challenge-closed `discharge-summary.json`
268    // lists; with no summary beside the Lean base, a sorry-free theorem `lean_theorem_names()` resolves. L4 is therefore
269    // granted on the GROUNDED count alone; a claim with nothing under it is reported `self-declared` by
270    // `is_l4_self_declared`, excluded from the L4 total, and never counted quietly. The `not_applicable`
271    // credit still comes from the summary: it is a claim about APPLICABILITY, not about a proof, and it
272    // is ONT-8's evidence block that will give it its own provenance.
273    let not_applicable = contract
274        .verification_summary
275        .as_ref()
276        .map_or(0, |vs| vs.l4_not_applicable);
277    grounded > 0 && grounded + not_applicable >= total
278}
279
280/// Returns `true` when the contract CLAIMS a Lean proof its own tree does not ground.
281///
282/// The claim is the summary's `l4_lean_proved` plus its `l4_not_applicable` covering every obligation —
283/// exactly the test that granted L4 before ONT-2a. The grounding is [`count_lean_theorems_for_contract`].
284/// A contract that would have been L4 by its own summary and is not L4 by grounding is SELF-DECLARED: the
285/// report prints the word beside it, the L4 total excludes it, and ONT-3b is where the credit is earned.
286#[must_use]
287pub fn is_l4_self_declared(contract: &Contract) -> bool {
288    is_l4_self_declared_with_grounding(contract, count_lean_theorems_for_contract(contract))
289}
290
291/// [`is_l4_self_declared`] with the grounding count passed in, for the same reason.
292#[must_use]
293pub fn is_l4_self_declared_with_grounding(contract: &Contract, grounded: u32) -> bool {
294    let total = contract.proof_obligations.len() as u32;
295    if total == 0 {
296        return false;
297    }
298    let Some(vs) = contract.verification_summary.as_ref() else {
299        return false;
300    };
301    let claim_covers = vs.l4_lean_proved > 0 && vs.l4_lean_proved + vs.l4_not_applicable >= total;
302    claim_covers && !is_lean_proved_with_grounding(contract, grounded)
303}
304
305/// Obligations declared `applies_to: not_applicable` (PMAT-3091).
306///
307/// This is a count for the report only. The level computation deliberately
308/// does NOT subtract it from the obligation total: tests, kani harnesses and
309/// Lean theorems are counted per contract, not linked per obligation, so there
310/// is no numerator to remove an N/A obligation from, and removing it from the
311/// denominator alone would let a declaration lower the bar for L2/L3/L4. An
312/// N/A obligation therefore leaves the level exactly where an undeclared one
313/// would. It is also not `verification_summary.l4_not_applicable`: it grants
314/// no Lean credit.
315#[must_use]
316#[allow(clippy::cast_possible_truncation)]
317pub fn count_not_applicable(contract: &Contract) -> u32 {
318    contract
319        .proof_obligations
320        .iter()
321        .filter(|ob| ob.is_not_applicable())
322        .count() as u32
323}
324
325/// Returns `true` when all bindings are implemented.
326fn is_fully_bound(binding_status: Option<(u32, u32)>) -> bool {
327    binding_status.is_some_and(|(implemented, total)| total > 0 && implemented == total)
328}
329
330/// Compute the proof level for a single contract.
331///
332/// Derivation rules (highest matching level wins):
333/// - **L5**: every obligation Lean-proved AND all bindings implemented
334/// - **L4**: every obligation Lean-proved — strict per-obligation coverage,
335///   `proved + not_applicable >= proof_obligations.len()` with `proved > 0`
336///   (partial coverage is NOT L4; see [`is_lean_proved`])
337/// - **L3**: has Kani harnesses AND falsification tests cover obligations
338/// - **L2**: falsification tests count >= obligations count
339/// - **L1**: contract exists with equations
340#[allow(clippy::cast_possible_truncation)]
341pub fn compute_proof_level(contract: &Contract, binding_status: Option<(u32, u32)>) -> ProofLevel {
342    compute_proof_level_with_grounding(
343        contract,
344        binding_status,
345        count_lean_theorems_for_contract(contract),
346    )
347}
348
349/// [`compute_proof_level`] with the grounding count passed in (ONT-2a).
350#[allow(clippy::cast_possible_truncation)]
351#[must_use]
352pub fn compute_proof_level_with_grounding(
353    contract: &Contract,
354    binding_status: Option<(u32, u32)>,
355    grounded: u32,
356) -> ProofLevel {
357    let total_obligations = contract.proof_obligations.len() as u32;
358    let ft_count = contract.falsification_tests.len() as u32;
359    let kani_count = contract.kani_harnesses.len() as u32;
360
361    // Check L4/L5: Lean proved
362    if is_lean_proved_with_grounding(contract, grounded) {
363        return if is_fully_bound(binding_status) {
364            ProofLevel::L5
365        } else {
366            ProofLevel::L4
367        };
368    }
369
370    // Check L3: Kani + falsification
371    let has_tests = ft_count >= total_obligations && total_obligations > 0;
372    if kani_count > 0 && has_tests {
373        return ProofLevel::L3;
374    }
375
376    // Check L2: falsification tests cover obligations
377    if has_tests {
378        return ProofLevel::L2;
379    }
380
381    // L1: contract exists with equations
382    ProofLevel::L1
383}
384
385/// Directories scanned (relative to CWD) for sorry-free Lean theorem files,
386/// in priority order. The IN-TREE staging tree is FIRST — it is the
387/// post-APR-MONO source of truth and a superset of the external sibling, so
388/// L4/L5 proof levels are reproducible on a fresh clone / CI without a
389/// co-located `../provable-contracts` checkout. The bare `lean` and the
390/// external sibling are kept as fallbacks for dev machines that still use them.
391pub(crate) const LEAN_THEOREM_BASES: &[&str] = &[
392    "crates/aprender-contracts-staging/lean",
393    "lean",
394    "../provable-contracts/lean",
395];
396
397/// Register the three naming forms a single label contributes: namespaced, bare, and lowercased.
398fn insert_name_forms(names: &mut std::collections::HashSet<String>, label: &str) {
399    names.insert(format!("Theorems.{label}"));
400    names.insert(label.to_string());
401    names.insert(label.to_lowercase());
402}
403
404/// `relu_nonneg` → `ReluNonneg`.
405pub(crate) fn camel_case(snake: &str) -> String {
406    snake
407        .split('_')
408        .map(|s| {
409            let mut c = s.chars();
410            match c.next() {
411                None => String::new(),
412                Some(f) => f.to_uppercase().chain(c).collect(),
413            }
414        })
415        .collect()
416}
417
418/// `ReluNonneg` → `Relu`: the leading word of a CamelCase name.
419pub(crate) fn first_camel_word(camel: &str) -> String {
420    camel
421        .chars()
422        .enumerate()
423        .take_while(|(i, c)| *i == 0 || !c.is_uppercase())
424        .map(|(_, c)| c)
425        .collect()
426}
427
428/// Register every `theorem <name>` a file declares, in the forms a contract may cite it by.
429fn insert_theorem_names_from_content(names: &mut std::collections::HashSet<String>, content: &str) {
430    for line in content.lines() {
431        let Some(pos) = line.find("theorem ") else {
432            continue;
433        };
434        let tname: String = line[pos + 8..]
435            .chars()
436            .take_while(|c| c.is_alphanumeric() || *c == '_')
437            .collect();
438        if tname.is_empty() {
439            continue;
440        }
441        let camel = camel_case(&tname);
442        names.insert(format!("Theorems.{camel}"));
443        names.insert(camel.clone());
444        let first_word = first_camel_word(&camel);
445        if first_word.len() >= 3 {
446            names.insert(format!("Theorems.{first_word}"));
447            names.insert(first_word);
448        }
449    }
450}
451
452/// Lean identifier continuation: `sorry_free` and `x.sorry'` are not the `sorry` token.
453fn is_lean_ident_byte(c: u8) -> bool {
454    c.is_ascii_alphanumeric() || c == b'_' || c == b'\''
455}
456
457/// Whether Lean source admits a proof hole: the token `sorry` outside `--` line comments and
458/// (nested) `/- -/` block comments (#4351).
459///
460/// A doc comment that SAYS "compiles sorry-free" admits nothing, and a byte-level `contains("sorry")`
461/// denied such files every theorem they prove. The scan still fails CLOSED everywhere else:
462/// - a `sorry` inside a string literal counts — a string is code, not commentary;
463/// - string and char literals are skipped only so a `"--"` or `'"'` cannot open a fake comment
464///   that would hide a real `sorry` after it;
465/// - a file that ends inside a comment or string counts — it does not compile, so it proves nothing.
466pub(crate) fn lean_has_sorry(content: &str) -> bool {
467    let b = content.as_bytes();
468    let mut i = 0;
469    let mut depth = 0usize;
470    while i < b.len() {
471        if depth > 0 {
472            (i, depth) = step_block_comment(b, i, depth);
473            continue;
474        }
475        match scan_code_at(b, i) {
476            LeanScan::Next(j) => i = j,
477            LeanScan::OpenBlock(j) => {
478                depth = 1;
479                i = j;
480            }
481            LeanScan::Sorry => return true,
482            LeanScan::EndsInLineComment => return false,
483        }
484    }
485    depth > 0
486}
487
488/// One step of [`lean_has_sorry`] outside a block comment.
489enum LeanScan {
490    /// Resume scanning at this byte.
491    Next(usize),
492    /// A `/-` opened a block comment; resume at this byte, inside it.
493    OpenBlock(usize),
494    /// A `sorry` token was found (or a literal ran off the end of the file).
495    Sorry,
496    /// A `--` comment runs to EOF: nothing after it can admit a hole.
497    EndsInLineComment,
498}
499
500/// `sorry` as a whole Lean token at byte `i`.
501fn sorry_token_at(b: &[u8], i: usize) -> bool {
502    b[i..].starts_with(b"sorry")
503        && (i == 0 || !is_lean_ident_byte(b[i - 1]))
504        && b.get(i + 5).is_none_or(|&c| !is_lean_ident_byte(c))
505}
506
507/// Inside `depth` nested `/- -/` comments: returns the next byte and the new depth.
508fn step_block_comment(b: &[u8], i: usize, depth: usize) -> (usize, usize) {
509    if b[i..].starts_with(b"/-") {
510        (i + 2, depth + 1)
511    } else if b[i..].starts_with(b"-/") {
512        (i + 2, depth - 1)
513    } else {
514        (i + 1, depth)
515    }
516}
517
518/// Classify the lexeme starting at byte `i` of code (not inside any comment).
519fn scan_code_at(b: &[u8], i: usize) -> LeanScan {
520    let prev_ident = i > 0 && is_lean_ident_byte(b[i - 1]);
521    if b[i..].starts_with(b"--") {
522        return match b[i..].iter().position(|&c| c == b'\n') {
523            Some(p) => LeanScan::Next(i + p + 1),
524            None => LeanScan::EndsInLineComment,
525        };
526    }
527    if b[i..].starts_with(b"/-") {
528        return LeanScan::OpenBlock(i + 2);
529    }
530    if b[i] == b'r' && !prev_ident && matches!(b.get(i + 1), Some(b'"' | b'#')) {
531        return scan_raw_string(b, i);
532    }
533    if b[i] == b'"' {
534        return scan_string(b, i);
535    }
536    if b[i] == b'\'' && !prev_ident {
537        return LeanScan::Next(skip_char_literal(b, i));
538    }
539    if sorry_token_at(b, i) {
540        LeanScan::Sorry
541    } else {
542        LeanScan::Next(i + 1)
543    }
544}
545
546/// A literal's body `[from, to)` counts a `sorry` in it; else resume at `resume`.
547fn scan_literal_body(b: &[u8], from: usize, to: usize, resume: usize) -> LeanScan {
548    if (from..to).any(|k| sorry_token_at(b, k)) {
549        LeanScan::Sorry
550    } else {
551        LeanScan::Next(resume)
552    }
553}
554
555/// r"…" / r#"…"#: no escapes, closed by `"` plus the same number of `#`.
556fn scan_raw_string(b: &[u8], i: usize) -> LeanScan {
557    let hashes = b[i + 1..].iter().take_while(|&&c| c == b'#').count();
558    let open = i + 1 + hashes;
559    if b.get(open) != Some(&b'"') {
560        return LeanScan::Next(i + 1);
561    }
562    let mut close = vec![b'"'];
563    close.extend(std::iter::repeat_n(b'#', hashes));
564    let Some(p) = b[open + 1..]
565        .windows(close.len())
566        .position(|w| w == close.as_slice())
567    else {
568        return LeanScan::Sorry;
569    };
570    let end = open + 1 + p;
571    scan_literal_body(b, open + 1, end, end + close.len())
572}
573
574/// "…" with `\` escapes; an unterminated string does not compile, so it counts.
575fn scan_string(b: &[u8], i: usize) -> LeanScan {
576    let mut j = i + 1;
577    loop {
578        match b.get(j) {
579            None => return LeanScan::Sorry,
580            Some(b'\\') => j += 2,
581            Some(b'"') => break,
582            Some(_) => j += 1,
583        }
584    }
585    scan_literal_body(b, i + 1, j, j + 1)
586}
587
588/// A char literal is at most an escape plus one UTF-8 scalar; anything else is not one.
589fn skip_char_literal(b: &[u8], i: usize) -> usize {
590    let from = if b.get(i + 1) == Some(&b'\\') {
591        i + 3
592    } else {
593        i + 2
594    };
595    match b
596        .get(from..b.len().min(i + 7))
597        .and_then(|s| s.iter().position(|&c| c == b'\''))
598    {
599        Some(p) => from + p + 1,
600        None => i + 1,
601    }
602}
603
604/// Register the names contributed by one domain directory's sorry-free `.lean` files.
605///
606/// A file that admits a `sorry` ([`lean_has_sorry`]) contributes NOTHING: an admitted proof grounds no
607/// claim, which is the whole reason this scan is the grounding ONT-2a trusts over a contract's own
608/// summary. Each file also registers `Theorems.<Domain>.<Stem>`, the `domain.file` form a contract's
609/// `lean_theorem:` cites (e.g. `Theorems.GgufExportSymmetry.Roundtrip`, #4351).
610fn insert_domain_theorems(names: &mut std::collections::HashSet<String>, domain: &std::path::Path) {
611    let domain_name = domain
612        .file_name()
613        .unwrap_or_default()
614        .to_string_lossy()
615        .to_string();
616    let Ok(files) = std::fs::read_dir(domain) else {
617        return;
618    };
619    for file in files.flatten() {
620        let path = file.path();
621        if path.extension().is_none_or(|e| e != "lean") {
622            continue;
623        }
624        let Ok(content) = std::fs::read_to_string(&path) else {
625            continue;
626        };
627        if lean_has_sorry(&content) {
628            continue;
629        }
630        let stem = path
631            .file_stem()
632            .unwrap_or_default()
633            .to_string_lossy()
634            .to_string();
635        names.insert(format!("Theorems.{domain_name}.{stem}"));
636        insert_name_forms(names, &domain_name);
637        insert_name_forms(names, &stem);
638        insert_theorem_names_from_content(names, &content);
639    }
640}
641
642/// Every theorem name one base directory contributes; empty when the base is absent.
643fn scan_theorem_base(base: &str) -> std::collections::HashSet<String> {
644    let mut names = std::collections::HashSet::new();
645    let search_dir = std::path::Path::new(base).join("ProvableContracts/Theorems");
646    if !search_dir.exists() {
647        return names;
648    }
649    let Ok(domains) = std::fs::read_dir(&search_dir) else {
650        return names;
651    };
652    for domain_entry in domains.flatten() {
653        let path = domain_entry.path();
654        if path.is_dir() {
655            insert_domain_theorems(&mut names, &path);
656        }
657    }
658    names
659}
660
661/// Build a set of all sorry-free Lean theorem names from the Theorems/ directory.
662/// Scans once, caches the result in a thread-local for repeated calls.
663fn lean_theorem_names() -> &'static std::collections::HashSet<String> {
664    use std::sync::OnceLock;
665    static CACHE: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
666    CACHE.get_or_init(|| {
667        for base in LEAN_THEOREM_BASES {
668            let names = scan_theorem_base(base);
669            if !names.is_empty() {
670                return names;
671            }
672        }
673        std::collections::HashSet::new()
674    })
675}
676
677/// The L4 grounding this process reads once: the discharge summary beside the first Lean base that has one, or the
678/// ONT-2a scan when none does.
679struct Grounding {
680    source: L4Source,
681    discharged: Discharged,
682}
683
684fn grounding() -> &'static Grounding {
685    use std::sync::OnceLock;
686    static CACHE: OnceLock<Grounding> = OnceLock::new();
687    CACHE.get_or_init(|| {
688        let bases: Vec<&std::path::Path> = LEAN_THEOREM_BASES
689            .iter()
690            .map(std::path::Path::new)
691            .collect();
692        grounding_from(&bases, summary::current_tree_sha)
693    })
694}
695
696/// `grounding()` without the cache, over explicit bases and a tree-sha source, so a test can drive the whole
697/// discharge → refinement path. The first base with a summary wins.
698fn grounding_from(
699    bases: &[&std::path::Path],
700    tree_sha: impl Fn(&std::path::Path) -> Option<String>,
701) -> Grounding {
702    for &dir in bases {
703        let path = summary::summary_path(dir);
704        if !dir.is_dir() || !path.is_file() {
705            continue;
706        }
707        let mut discharged = match summary::load(&path) {
708            Ok(s) => summary::discharged(&s, tree_sha(dir).as_deref()),
709            Err(why) => Discharged {
710                withheld: Some(why),
711                ..Discharged::default()
712            },
713        };
714        refine(dir, &mut discharged);
715        return Grounding {
716            source: L4Source::Discharge,
717            discharged,
718        };
719    }
720    Grounding {
721        source: L4Source::SelfDeclared,
722        discharged: Discharged::default(),
723    }
724}
725
726/// ONT-3b (#4073): a discharged theorem stays L4 only when an `extraction`/`simulation` model whose `model_of`
727/// resolves covers its module. The workspace walk runs only when the discharge grants something.
728fn refine(lean_dir: &std::path::Path, discharged: &mut Discharged) {
729    use crate::discharge::refinement;
730    if discharged.theorems.is_empty() {
731        return;
732    }
733    let l4 = match refinement::load(lean_dir) {
734        Ok(rec) => {
735            let modules = discharged.module_of.values().cloned().collect();
736            let root = workspace_root(lean_dir);
737            refinement::l4_modules(&refinement::judge(
738                &rec.models,
739                &modules,
740                refinement::workspace_resolver(&root),
741            ))
742        }
743        Err(_) => std::collections::BTreeSet::new(),
744    };
745    discharged.require_refinement(&l4);
746}
747
748/// The nearest ancestor of `dir` whose Cargo.toml has a `[workspace]` table, else `.`.
749fn workspace_root(dir: &std::path::Path) -> std::path::PathBuf {
750    let abs = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
751    abs.ancestors()
752        .find(|a| {
753            std::fs::read_to_string(a.join("Cargo.toml"))
754                .is_ok_and(|t| t.lines().any(|l| l.trim() == "[workspace]"))
755        })
756        .map_or_else(
757            || std::path::PathBuf::from("."),
758            std::path::Path::to_path_buf,
759        )
760}
761
762/// The discharge summary's grant as this process read it: empty when there is no summary, or it is withheld.
763#[must_use]
764pub fn discharge_grounding() -> &'static Discharged {
765    &grounding().discharged
766}
767
768/// EV-8b: the equations whose `lean_theorem` the discharge summary grants. A theorem the YAML names and the summary
769/// does not list earns nothing, however the YAML describes it.
770#[must_use]
771pub fn count_discharged_for_contract(contract: &Contract, discharged: &Discharged) -> u32 {
772    let n = contract
773        .equations
774        .values()
775        .filter_map(|eq| eq.lean_theorem.as_deref())
776        .filter(|t| discharged.grants(t))
777        .count();
778    u32::try_from(n).unwrap_or(u32::MAX)
779}
780
781/// The grounding count for a contract: from the discharge summary when one exists, else the ONT-2a scan.
782fn count_lean_theorems_for_contract(contract: &Contract) -> u32 {
783    let g = grounding();
784    match g.source {
785        L4Source::Discharge => count_discharged_for_contract(contract, &g.discharged),
786        L4Source::SelfDeclared => count_scanned_theorems_for_contract(contract),
787    }
788}
789
790/// Count Lean theorems for a contract by matching `lean_theorem` refs against
791/// sorry-free `.lean` files in the Theorems/ directory.
792fn count_scanned_theorems_for_contract(contract: &Contract) -> u32 {
793    let theorems = lean_theorem_names();
794    let mut count = 0u32;
795    for eq in contract.equations.values() {
796        if let Some(ref theorem_ref) = eq.lean_theorem {
797            let name = theorem_ref.trim().trim_matches('"');
798            // Try exact match, then without prefix, then lowercase
799            if theorems.contains(name)
800                || theorems.contains(name.strip_prefix("Theorems.").unwrap_or(name))
801                || theorems.contains(&name.to_lowercase())
802            {
803                count += 1;
804            }
805        }
806    }
807    count
808}
809
810/// Build a complete proof status report.
811///
812/// `contracts` is a list of `(stem, &Contract)` pairs.
813/// `binding` is an optional binding registry for binding coverage.
814/// `include_classes` controls whether kernel class summaries are generated.
815#[allow(clippy::cast_possible_truncation)]
816pub fn proof_status_report(
817    contracts: &[(String, &Contract)],
818    binding: Option<&BindingRegistry>,
819    include_classes: bool,
820) -> ProofStatusReport {
821    let mut statuses = Vec::new();
822    let mut totals = ProofStatusTotals {
823        contracts: contracts.len() as u32,
824        obligations: 0,
825        not_applicable: 0,
826        falsification_tests: 0,
827        kani_harnesses: 0,
828        lean_proved: 0,
829        lean_grounded: 0,
830        l4_self_declared: 0,
831        bindings_implemented: 0,
832        bindings_total: 0,
833    };
834
835    for (stem, contract) in contracts {
836        let contract_file = format!("{stem}.yaml");
837
838        let obligations = contract.proof_obligations.len() as u32;
839        let not_applicable = count_not_applicable(contract);
840        let ft_count = contract.falsification_tests.len() as u32;
841        let kani_count = contract.kani_harnesses.len() as u32;
842        // The CLAIM (what the contract says about itself) and the GROUNDING (what the tree shows) are two
843        // numbers since ONT-2a; before it, the first stood in for the second whenever it was non-zero.
844        let lean_proved = contract
845            .verification_summary
846            .as_ref()
847            .map_or(0, |vs| vs.l4_lean_proved);
848        let lean_grounded = count_lean_theorems_for_contract(contract);
849        let lean_proved = if lean_proved == 0 {
850            lean_grounded
851        } else {
852            lean_proved
853        };
854        let l4_self_declared = is_l4_self_declared_with_grounding(contract, lean_grounded);
855
856        // Count bindings for this contract
857        let (b_impl, b_total) = if let Some(reg) = binding {
858            count_bindings(&contract_file, contract, reg)
859        } else {
860            (0, contract.equations.len() as u32)
861        };
862
863        let binding_status = if binding.is_some() {
864            Some((b_impl, b_total))
865        } else {
866            None
867        };
868
869        let proof_level =
870            compute_proof_level_with_grounding(contract, binding_status, lean_grounded);
871
872        totals.obligations += obligations;
873        totals.not_applicable += not_applicable;
874        totals.falsification_tests += ft_count;
875        totals.kani_harnesses += kani_count;
876        totals.lean_proved += lean_proved;
877        totals.lean_grounded += lean_grounded;
878        totals.l4_self_declared += u32::from(l4_self_declared);
879        totals.bindings_implemented += b_impl;
880        totals.bindings_total += b_total;
881
882        statuses.push(ContractProofStatus {
883            stem: stem.clone(),
884            proof_level,
885            obligations,
886            not_applicable,
887            falsification_tests: ft_count,
888            kani_harnesses: kani_count,
889            lean_proved,
890            lean_grounded,
891            l4_self_declared,
892            bindings_implemented: b_impl,
893            bindings_total: b_total,
894        });
895    }
896
897    // Build kernel class summaries
898    let kernel_classes = if include_classes {
899        build_kernel_classes(&statuses)
900    } else {
901        Vec::new()
902    };
903
904    let timestamp = current_timestamp();
905
906    ProofStatusReport {
907        schema_version: "1.0.0".to_string(),
908        l4_self_declared_excluded: true,
909        l4_source: grounding().source,
910        l4_withheld: grounding().discharged.withheld.clone(),
911        timestamp,
912        contracts: statuses,
913        kernel_classes,
914        totals,
915    }
916}
917
918/// Format a proof status report as human-readable text.
919pub fn format_text(report: &ProofStatusReport) -> String {
920    let mut out = String::new();
921
922    out.push_str(&format!(
923        "Proof Status ({} contracts)\n\n",
924        report.totals.contracts
925    ));
926
927    out.push_str(&format!(
928        "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>9} {:>13}\n",
929        "Contract", "Level", "Obligs", "Tests", "Kani", "Lean", "Bindings", "L4 evidence"
930    ));
931    out.push_str(&format!("  {}\n", "─".repeat(86)));
932
933    for c in &report.contracts {
934        // ONT-2a: the andon is a COLUMN, not a footnote. A claim the tree does not ground says so on its
935        // own line, beside the level it no longer reaches.
936        let l4_evidence = if c.l4_self_declared {
937            "self-declared"
938        } else if c.lean_grounded > 0 {
939            "grounded"
940        } else {
941            "-"
942        };
943        out.push_str(&format!(
944            "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>4}/{:<4} {:>13}\n",
945            truncate(&c.stem, 35),
946            c.proof_level,
947            c.obligations,
948            c.falsification_tests,
949            c.kani_harnesses,
950            c.lean_proved,
951            c.bindings_implemented,
952            c.bindings_total,
953            l4_evidence,
954        ));
955    }
956
957    if !report.kernel_classes.is_empty() {
958        out.push_str("\nKernel Classes:\n");
959        for kc in &report.kernel_classes {
960            let bound_str = if kc.all_bound { "all bound" } else { "gaps" };
961            out.push_str(&format!(
962                "  {} ({}): min={}, {} contracts, {}\n",
963                kc.label,
964                kc.description,
965                kc.min_proof_level,
966                kc.contract_stems.len(),
967                bound_str,
968            ));
969        }
970    }
971
972    out.push_str(&format!(
973        "\nTotals: {} obligations ({} N/A, never counted as proved), {} tests, {} kani, {} lean claimed ({} grounded), {}/{} bound\n\
974         L4 evidence: {} contract(s) self-declared and excluded from L4 (ONT-2a andon); grounded means a \
975         theorem the equation names that a green, fresh discharge-summary.json lists (EV-8b), or with no summary \
976         a sorry-free in-tree Lean theorem\n",
977        report.totals.obligations,
978        report.totals.not_applicable,
979        report.totals.falsification_tests,
980        report.totals.kani_harnesses,
981        report.totals.lean_proved,
982        report.totals.lean_grounded,
983        report.totals.bindings_implemented,
984        report.totals.bindings_total,
985        report.totals.l4_self_declared,
986    ));
987    let source = match report.l4_source {
988        L4Source::Discharge => "discharge (discharge-summary.json)",
989        L4Source::SelfDeclared => "self-declared (no discharge-summary.json; in-tree .lean scan)",
990    };
991    out.push_str(&format!("L4 source: {source}\n"));
992    if let Some(why) = &report.l4_withheld {
993        out.push_str(&format!("L4 withheld: {why}\n"));
994    }
995
996    out
997}
998
999// ── Internal helpers ──────────────────────────────────────────────
1000
1001/// Count implemented vs total bindings for a contract in the registry
1002#[allow(clippy::cast_possible_truncation)]
1003pub(crate) fn count_bindings(
1004    contract_file: &str,
1005    contract: &Contract,
1006    binding: &BindingRegistry,
1007) -> (u32, u32) {
1008    let total = contract.equations.len() as u32;
1009    let implemented = binding
1010        .bindings_for(contract_file)
1011        .iter()
1012        .filter(|b| b.status == ImplStatus::Implemented)
1013        .count() as u32;
1014    (implemented, total)
1015}
1016
1017/// Build kernel equivalence class summaries from per-contract statuses
1018fn build_kernel_classes(statuses: &[ContractProofStatus]) -> Vec<KernelClassSummary> {
1019    let status_map: BTreeMap<&str, &ContractProofStatus> =
1020        statuses.iter().map(|s| (s.stem.as_str(), s)).collect();
1021
1022    kernel_class_map()
1023        .into_iter()
1024        .map(|(label, desc, stems)| {
1025            let found_stems: Vec<String> = stems
1026                .iter()
1027                .filter(|s| status_map.contains_key(**s))
1028                .map(|s| (*s).to_string())
1029                .collect();
1030
1031            let min_level = found_stems
1032                .iter()
1033                .filter_map(|s| status_map.get(s.as_str()))
1034                .map(|c| c.proof_level)
1035                .min()
1036                .unwrap_or(ProofLevel::L1);
1037
1038            let all_bound = !found_stems.is_empty()
1039                && found_stems.iter().all(|s| {
1040                    status_map.get(s.as_str()).is_some_and(|c| {
1041                        c.bindings_total > 0 && c.bindings_implemented == c.bindings_total
1042                    })
1043                });
1044
1045            KernelClassSummary {
1046                label: label.to_string(),
1047                description: desc.to_string(),
1048                contract_stems: found_stems,
1049                min_proof_level: min_level,
1050                all_bound,
1051            }
1052        })
1053        .collect()
1054}
1055
1056/// Truncate a string to at most `max` bytes for column alignment
1057fn truncate(s: &str, max: usize) -> &str {
1058    if s.len() > max {
1059        &s[..max]
1060    } else {
1061        s
1062    }
1063}
1064
1065/// Generate an ISO-8601-style Unix epoch timestamp string
1066fn current_timestamp() -> String {
1067    // Use a simple ISO-8601 timestamp without external deps.
1068    // In production this would use chrono or time crate.
1069    // For now we use std::time for a Unix epoch string.
1070    let duration = std::time::SystemTime::now()
1071        .duration_since(std::time::UNIX_EPOCH)
1072        .unwrap_or_default();
1073    format!("{}Z", duration.as_secs())
1074}
1075
1076#[cfg(test)]
1077#[path = "proof_status_tests.rs"]
1078mod tests;