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::schema::Contract;
17
18// ── Proof level hierarchy ─────────────────────────────────────────
19
20/// Hierarchical proof assurance level.
21///
22/// Each level subsumes the ones below it:
23/// - **L1** — Contract YAML exists with equations
24/// - **L2** — Property tested (falsification tests cover obligations)
25/// - **L3** — Kani bounded-model-checked
26/// - **L4** — Lean 4 theorem proved
27/// - **L5** — L4 + all bindings verified as implemented
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29pub enum ProofLevel {
30    /// Contract YAML exists with equations
31    L1,
32    /// Property tested via falsification tests
33    L2,
34    /// Kani bounded-model-checked
35    L3,
36    /// Lean 4 theorem proved
37    L4,
38    /// Lean proved and all bindings verified
39    L5,
40}
41
42impl fmt::Display for ProofLevel {
43    /// Format the proof level as its string label (L1 through L5)
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        let s = match self {
46            Self::L1 => "L1",
47            Self::L2 => "L2",
48            Self::L3 => "L3",
49            Self::L4 => "L4",
50            Self::L5 => "L5",
51        };
52        write!(f, "{s}")
53    }
54}
55
56// ── Per-contract status ───────────────────────────────────────────
57
58/// Proof status for a single contract.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ContractProofStatus {
61    /// Contract file stem (e.g. "softmax-kernel-v1")
62    pub stem: String,
63    /// Computed hierarchical proof level
64    pub proof_level: ProofLevel,
65    /// Number of proof obligations in the contract
66    pub obligations: u32,
67    /// Obligations declared `applies_to: not_applicable` (PMAT-3091). Counted
68    /// APART: never passed, discharged or proved, and never removed from
69    /// `obligations` for the level computation, so they cannot raise a level.
70    #[serde(default)]
71    pub not_applicable: u32,
72    /// Number of falsification tests defined
73    pub falsification_tests: u32,
74    /// Number of Kani bounded-model-checking harnesses
75    pub kani_harnesses: u32,
76    /// Number of obligations proved in Lean 4
77    pub lean_proved: u32,
78    /// Obligations grounded by a sorry-free in-tree Lean theorem the equation names (ONT-2a)
79    pub lean_grounded: u32,
80    /// The contract claims a Lean proof its tree does not ground: printed `self-declared`, excluded from L4
81    pub l4_self_declared: bool,
82    /// Number of bindings with `implemented` status
83    pub bindings_implemented: u32,
84    /// Total number of equation bindings
85    pub bindings_total: u32,
86}
87
88// ── Kernel class summary ──────────────────────────────────────────
89
90/// Summary of proof status for a kernel equivalence class.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct KernelClassSummary {
93    /// Kernel class identifier (A through E)
94    pub label: String,
95    /// Human-readable description of the kernel combination
96    pub description: String,
97    /// Contract stems belonging to this class
98    pub contract_stems: Vec<String>,
99    /// Lowest proof level among class members
100    pub min_proof_level: ProofLevel,
101    /// Whether all class members have full binding coverage
102    pub all_bound: bool,
103}
104
105// ── Full report ───────────────────────────────────────────────────
106
107/// Top-level proof status report, serializable to JSON.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ProofStatusReport {
110    /// Report schema version for forward compatibility
111    pub schema_version: String,
112    /// Unix epoch timestamp when the report was generated
113    pub timestamp: String,
114    /// Per-contract proof status entries
115    pub contracts: Vec<ContractProofStatus>,
116    /// Kernel equivalence class summaries
117    pub kernel_classes: Vec<KernelClassSummary>,
118    /// ONT-2a andon: a self-declared L4 is excluded from the L4 total in this build
119    pub l4_self_declared_excluded: bool,
120    /// Aggregate totals across all contracts
121    pub totals: ProofStatusTotals,
122}
123
124/// Aggregate totals across all contracts.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ProofStatusTotals {
127    /// Total number of contracts analyzed
128    pub contracts: u32,
129    /// Sum of proof obligations across all contracts
130    pub obligations: u32,
131    /// Sum of `applies_to: not_applicable` obligations (PMAT-3091), a subset of
132    /// `obligations` and never a subset of anything proved.
133    #[serde(default)]
134    pub not_applicable: u32,
135    /// Sum of falsification tests across all contracts
136    pub falsification_tests: u32,
137    /// Sum of Kani harnesses across all contracts
138    pub kani_harnesses: u32,
139    /// Sum of Lean-proved obligations across all contracts
140    pub lean_proved: u32,
141    /// Sum of GROUNDED Lean-proved obligations across all contracts (ONT-2a)
142    pub lean_grounded: u32,
143    /// Contracts whose L4 claim is self-declared, and therefore excluded from L4 (ONT-2a)
144    pub l4_self_declared: u32,
145    /// Sum of implemented bindings across all contracts
146    pub bindings_implemented: u32,
147    /// Sum of total bindings across all contracts
148    pub bindings_total: u32,
149}
150
151// ── Kernel class → contract stem mapping ──────────────────────────
152
153/// Static mapping from kernel equivalence class to contract stems.
154///
155/// Mirrors the `KernelOp` classification from `apr-model-qa-playbook`:
156/// - **A** — GQA + `RMSNorm` + `SiLU` + `SwiGLU` + `RoPE` (Llama/Mistral)
157/// - **B** — MHA + `LayerNorm` + GELU + `AbsPos` (GPT-2/BERT)
158/// - **C** — MHA + `LayerNorm` + GELU + `ALiBi` (BLOOM/MPT)
159/// - **D** — `LayerNorm` + GELU + `SiLU` + GQA (Gemma)
160/// - **E** — `RMSNorm` + `SwiGLU` + GQA (Qwen)
161fn kernel_class_map() -> Vec<(&'static str, &'static str, &'static [&'static str])> {
162    vec![
163        (
164            "A",
165            "GQA+RMSNorm+SiLU+SwiGLU+RoPE",
166            &[
167                "rmsnorm-kernel-v1",
168                "silu-kernel-v1",
169                "swiglu-kernel-v1",
170                "rope-kernel-v1",
171                "gqa-kernel-v1",
172                "softmax-kernel-v1",
173                "matmul-kernel-v1",
174            ],
175        ),
176        (
177            "B",
178            "MHA+LayerNorm+GELU+AbsPos",
179            &[
180                "layernorm-kernel-v1",
181                "gelu-kernel-v1",
182                "attention-kernel-v1",
183                "softmax-kernel-v1",
184                "matmul-kernel-v1",
185                "absolute-position-v1",
186            ],
187        ),
188        (
189            "C",
190            "MHA+LayerNorm+GELU+ALiBi",
191            &[
192                "layernorm-kernel-v1",
193                "gelu-kernel-v1",
194                "attention-kernel-v1",
195                "softmax-kernel-v1",
196                "alibi-kernel-v1",
197                "matmul-kernel-v1",
198            ],
199        ),
200        (
201            "D",
202            "LayerNorm+GELU+SiLU+GQA",
203            &[
204                "layernorm-kernel-v1",
205                "gelu-kernel-v1",
206                "silu-kernel-v1",
207                "gqa-kernel-v1",
208                "softmax-kernel-v1",
209                "matmul-kernel-v1",
210            ],
211        ),
212        (
213            "E",
214            "RMSNorm+SwiGLU+GQA",
215            &[
216                "rmsnorm-kernel-v1",
217                "swiglu-kernel-v1",
218                "gqa-kernel-v1",
219                "softmax-kernel-v1",
220                "matmul-kernel-v1",
221            ],
222        ),
223    ]
224}
225
226// ── Core computation ──────────────────────────────────────────────
227
228/// Returns `true` when EVERY proof obligation is discharged in Lean.
229///
230/// Strict per-obligation semantics — no fuzzy over-promotion. A contract is
231/// Lean-proved (L4) only when the number of Lean-proved obligations plus the
232/// explicitly not-applicable ones covers ALL proof obligations, and at least
233/// one obligation is genuinely proved. The obligation total is
234/// `proof_obligations.len()` — the SAME total used for L2/L3 — so a
235/// `verification_summary` cannot manufacture L4 by understating the total.
236///
237/// Proof counts come from the contract's `verification_summary` when it makes a
238/// positive claim (`l4_lean_proved > 0`); otherwise from a scan of the in-tree
239/// sorry-free `.lean` theorems. Because the scan can only credit obligations it
240/// resolves, partial coverage (the old "≥1 resolving ref → L4" over-promotion)
241/// now correctly reports L3 instead of a full L4. Contracts with legitimately
242/// N/A obligations (e.g. softmax-kernel-v1 = 5 proved + 4 N/A of 9) MUST declare
243/// that in `verification_summary` — the scan path grants no N/A credit.
244///
245/// The grounding count is passed IN rather than scanned here.
246///
247/// The scan reads the Lean tree from paths relative to the process CWD, so inside a unit test it resolves
248/// nothing and every fixture is ungrounded by construction — a suite in which the andon could withdraw
249/// ALL credit for ever and no test would notice. Taking the count as a parameter is what keeps both sides
250/// covered: `grounded == 0` is the andon, `grounded + not_applicable >= total` is the credit it still
251/// grants. Production callers use the wrapper and get the scan.
252#[must_use]
253pub fn is_lean_proved_with_grounding(contract: &Contract, grounded: u32) -> bool {
254    let total = contract.proof_obligations.len() as u32;
255    if total == 0 {
256        return false;
257    }
258    // ONT-001 ONT-2a (andon): a `verification_summary` is the contract talking about ITSELF, and until a
259    // discharge summary exists to check it against (PVL EV-8b) the only grounding in this tree is a
260    // sorry-free Lean theorem an equation names and `lean_theorem_names()` resolves. L4 is therefore
261    // granted on the GROUNDED count alone; a claim with nothing under it is reported `self-declared` by
262    // `is_l4_self_declared`, excluded from the L4 total, and never counted quietly. The `not_applicable`
263    // credit still comes from the summary: it is a claim about APPLICABILITY, not about a proof, and it
264    // is ONT-8's evidence block that will give it its own provenance.
265    let not_applicable = contract
266        .verification_summary
267        .as_ref()
268        .map_or(0, |vs| vs.l4_not_applicable);
269    grounded > 0 && grounded + not_applicable >= total
270}
271
272/// Returns `true` when the contract CLAIMS a Lean proof its own tree does not ground.
273///
274/// The claim is the summary's `l4_lean_proved` plus its `l4_not_applicable` covering every obligation —
275/// exactly the test that granted L4 before ONT-2a. The grounding is [`count_lean_theorems_for_contract`].
276/// A contract that would have been L4 by its own summary and is not L4 by grounding is SELF-DECLARED: the
277/// report prints the word beside it, the L4 total excludes it, and ONT-3b is where the credit is earned.
278#[must_use]
279pub fn is_l4_self_declared(contract: &Contract) -> bool {
280    is_l4_self_declared_with_grounding(contract, count_lean_theorems_for_contract(contract))
281}
282
283/// [`is_l4_self_declared`] with the grounding count passed in, for the same reason.
284#[must_use]
285pub fn is_l4_self_declared_with_grounding(contract: &Contract, grounded: u32) -> bool {
286    let total = contract.proof_obligations.len() as u32;
287    if total == 0 {
288        return false;
289    }
290    let Some(vs) = contract.verification_summary.as_ref() else {
291        return false;
292    };
293    let claim_covers = vs.l4_lean_proved > 0 && vs.l4_lean_proved + vs.l4_not_applicable >= total;
294    claim_covers && !is_lean_proved_with_grounding(contract, grounded)
295}
296
297/// Obligations declared `applies_to: not_applicable` (PMAT-3091).
298///
299/// This is a count for the report only. The level computation deliberately
300/// does NOT subtract it from the obligation total: tests, kani harnesses and
301/// Lean theorems are counted per contract, not linked per obligation, so there
302/// is no numerator to remove an N/A obligation from, and removing it from the
303/// denominator alone would let a declaration lower the bar for L2/L3/L4. An
304/// N/A obligation therefore leaves the level exactly where an undeclared one
305/// would. It is also not `verification_summary.l4_not_applicable`: it grants
306/// no Lean credit.
307#[must_use]
308#[allow(clippy::cast_possible_truncation)]
309pub fn count_not_applicable(contract: &Contract) -> u32 {
310    contract
311        .proof_obligations
312        .iter()
313        .filter(|ob| ob.is_not_applicable())
314        .count() as u32
315}
316
317/// Returns `true` when all bindings are implemented.
318fn is_fully_bound(binding_status: Option<(u32, u32)>) -> bool {
319    binding_status.is_some_and(|(implemented, total)| total > 0 && implemented == total)
320}
321
322/// Compute the proof level for a single contract.
323///
324/// Derivation rules (highest matching level wins):
325/// - **L5**: every obligation Lean-proved AND all bindings implemented
326/// - **L4**: every obligation Lean-proved — strict per-obligation coverage,
327///   `proved + not_applicable >= proof_obligations.len()` with `proved > 0`
328///   (partial coverage is NOT L4; see [`is_lean_proved`])
329/// - **L3**: has Kani harnesses AND falsification tests cover obligations
330/// - **L2**: falsification tests count >= obligations count
331/// - **L1**: contract exists with equations
332#[allow(clippy::cast_possible_truncation)]
333pub fn compute_proof_level(contract: &Contract, binding_status: Option<(u32, u32)>) -> ProofLevel {
334    compute_proof_level_with_grounding(
335        contract,
336        binding_status,
337        count_lean_theorems_for_contract(contract),
338    )
339}
340
341/// [`compute_proof_level`] with the grounding count passed in (ONT-2a).
342#[allow(clippy::cast_possible_truncation)]
343#[must_use]
344pub fn compute_proof_level_with_grounding(
345    contract: &Contract,
346    binding_status: Option<(u32, u32)>,
347    grounded: u32,
348) -> ProofLevel {
349    let total_obligations = contract.proof_obligations.len() as u32;
350    let ft_count = contract.falsification_tests.len() as u32;
351    let kani_count = contract.kani_harnesses.len() as u32;
352
353    // Check L4/L5: Lean proved
354    if is_lean_proved_with_grounding(contract, grounded) {
355        return if is_fully_bound(binding_status) {
356            ProofLevel::L5
357        } else {
358            ProofLevel::L4
359        };
360    }
361
362    // Check L3: Kani + falsification
363    let has_tests = ft_count >= total_obligations && total_obligations > 0;
364    if kani_count > 0 && has_tests {
365        return ProofLevel::L3;
366    }
367
368    // Check L2: falsification tests cover obligations
369    if has_tests {
370        return ProofLevel::L2;
371    }
372
373    // L1: contract exists with equations
374    ProofLevel::L1
375}
376
377/// Directories scanned (relative to CWD) for sorry-free Lean theorem files,
378/// in priority order. The IN-TREE staging tree is FIRST — it is the
379/// post-APR-MONO source of truth and a superset of the external sibling, so
380/// L4/L5 proof levels are reproducible on a fresh clone / CI without a
381/// co-located `../provable-contracts` checkout. The bare `lean` and the
382/// external sibling are kept as fallbacks for dev machines that still use them.
383pub(crate) const LEAN_THEOREM_BASES: &[&str] = &[
384    "crates/aprender-contracts-staging/lean",
385    "lean",
386    "../provable-contracts/lean",
387];
388
389/// Register the three naming forms a single label contributes: namespaced, bare, and lowercased.
390fn insert_name_forms(names: &mut std::collections::HashSet<String>, label: &str) {
391    names.insert(format!("Theorems.{label}"));
392    names.insert(label.to_string());
393    names.insert(label.to_lowercase());
394}
395
396/// `relu_nonneg` → `ReluNonneg`.
397pub(crate) fn camel_case(snake: &str) -> String {
398    snake
399        .split('_')
400        .map(|s| {
401            let mut c = s.chars();
402            match c.next() {
403                None => String::new(),
404                Some(f) => f.to_uppercase().chain(c).collect(),
405            }
406        })
407        .collect()
408}
409
410/// `ReluNonneg` → `Relu`: the leading word of a CamelCase name.
411pub(crate) fn first_camel_word(camel: &str) -> String {
412    camel
413        .chars()
414        .enumerate()
415        .take_while(|(i, c)| *i == 0 || !c.is_uppercase())
416        .map(|(_, c)| c)
417        .collect()
418}
419
420/// Register every `theorem <name>` a file declares, in the forms a contract may cite it by.
421fn insert_theorem_names_from_content(names: &mut std::collections::HashSet<String>, content: &str) {
422    for line in content.lines() {
423        let Some(pos) = line.find("theorem ") else {
424            continue;
425        };
426        let tname: String = line[pos + 8..]
427            .chars()
428            .take_while(|c| c.is_alphanumeric() || *c == '_')
429            .collect();
430        if tname.is_empty() {
431            continue;
432        }
433        let camel = camel_case(&tname);
434        names.insert(format!("Theorems.{camel}"));
435        names.insert(camel.clone());
436        let first_word = first_camel_word(&camel);
437        if first_word.len() >= 3 {
438            names.insert(format!("Theorems.{first_word}"));
439            names.insert(first_word);
440        }
441    }
442}
443
444/// Register the names contributed by one domain directory's sorry-free `.lean` files.
445///
446/// A file containing `sorry` contributes NOTHING: an admitted proof grounds no claim, which is the whole
447/// reason this scan is the grounding ONT-2a trusts over a contract's own summary.
448fn insert_domain_theorems(names: &mut std::collections::HashSet<String>, domain: &std::path::Path) {
449    let domain_name = domain
450        .file_name()
451        .unwrap_or_default()
452        .to_string_lossy()
453        .to_string();
454    let Ok(files) = std::fs::read_dir(domain) else {
455        return;
456    };
457    for file in files.flatten() {
458        let path = file.path();
459        if path.extension().is_none_or(|e| e != "lean") {
460            continue;
461        }
462        let Ok(content) = std::fs::read_to_string(&path) else {
463            continue;
464        };
465        if content.contains("sorry") {
466            continue;
467        }
468        let stem = path
469            .file_stem()
470            .unwrap_or_default()
471            .to_string_lossy()
472            .to_string();
473        insert_name_forms(names, &domain_name);
474        insert_name_forms(names, &stem);
475        insert_theorem_names_from_content(names, &content);
476    }
477}
478
479/// Every theorem name one base directory contributes; empty when the base is absent.
480fn scan_theorem_base(base: &str) -> std::collections::HashSet<String> {
481    let mut names = std::collections::HashSet::new();
482    let search_dir = std::path::Path::new(base).join("ProvableContracts/Theorems");
483    if !search_dir.exists() {
484        return names;
485    }
486    let Ok(domains) = std::fs::read_dir(&search_dir) else {
487        return names;
488    };
489    for domain_entry in domains.flatten() {
490        let path = domain_entry.path();
491        if path.is_dir() {
492            insert_domain_theorems(&mut names, &path);
493        }
494    }
495    names
496}
497
498/// Build a set of all sorry-free Lean theorem names from the Theorems/ directory.
499/// Scans once, caches the result in a thread-local for repeated calls.
500fn lean_theorem_names() -> &'static std::collections::HashSet<String> {
501    use std::sync::OnceLock;
502    static CACHE: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
503    CACHE.get_or_init(|| {
504        for base in LEAN_THEOREM_BASES {
505            let names = scan_theorem_base(base);
506            if !names.is_empty() {
507                return names;
508            }
509        }
510        std::collections::HashSet::new()
511    })
512}
513
514/// Count Lean theorems for a contract by matching `lean_theorem` refs against
515/// sorry-free `.lean` files in the Theorems/ directory.
516fn count_lean_theorems_for_contract(contract: &Contract) -> u32 {
517    let theorems = lean_theorem_names();
518    let mut count = 0u32;
519    for eq in contract.equations.values() {
520        if let Some(ref theorem_ref) = eq.lean_theorem {
521            let name = theorem_ref.trim().trim_matches('"');
522            // Try exact match, then without prefix, then lowercase
523            if theorems.contains(name)
524                || theorems.contains(name.strip_prefix("Theorems.").unwrap_or(name))
525                || theorems.contains(&name.to_lowercase())
526            {
527                count += 1;
528            }
529        }
530    }
531    count
532}
533
534/// Build a complete proof status report.
535///
536/// `contracts` is a list of `(stem, &Contract)` pairs.
537/// `binding` is an optional binding registry for binding coverage.
538/// `include_classes` controls whether kernel class summaries are generated.
539#[allow(clippy::cast_possible_truncation)]
540pub fn proof_status_report(
541    contracts: &[(String, &Contract)],
542    binding: Option<&BindingRegistry>,
543    include_classes: bool,
544) -> ProofStatusReport {
545    let mut statuses = Vec::new();
546    let mut totals = ProofStatusTotals {
547        contracts: contracts.len() as u32,
548        obligations: 0,
549        not_applicable: 0,
550        falsification_tests: 0,
551        kani_harnesses: 0,
552        lean_proved: 0,
553        lean_grounded: 0,
554        l4_self_declared: 0,
555        bindings_implemented: 0,
556        bindings_total: 0,
557    };
558
559    for (stem, contract) in contracts {
560        let contract_file = format!("{stem}.yaml");
561
562        let obligations = contract.proof_obligations.len() as u32;
563        let not_applicable = count_not_applicable(contract);
564        let ft_count = contract.falsification_tests.len() as u32;
565        let kani_count = contract.kani_harnesses.len() as u32;
566        // The CLAIM (what the contract says about itself) and the GROUNDING (what the tree shows) are two
567        // numbers since ONT-2a; before it, the first stood in for the second whenever it was non-zero.
568        let lean_proved = contract
569            .verification_summary
570            .as_ref()
571            .map_or(0, |vs| vs.l4_lean_proved);
572        let lean_grounded = count_lean_theorems_for_contract(contract);
573        let lean_proved = if lean_proved == 0 {
574            lean_grounded
575        } else {
576            lean_proved
577        };
578        let l4_self_declared = is_l4_self_declared_with_grounding(contract, lean_grounded);
579
580        // Count bindings for this contract
581        let (b_impl, b_total) = if let Some(reg) = binding {
582            count_bindings(&contract_file, contract, reg)
583        } else {
584            (0, contract.equations.len() as u32)
585        };
586
587        let binding_status = if binding.is_some() {
588            Some((b_impl, b_total))
589        } else {
590            None
591        };
592
593        let proof_level =
594            compute_proof_level_with_grounding(contract, binding_status, lean_grounded);
595
596        totals.obligations += obligations;
597        totals.not_applicable += not_applicable;
598        totals.falsification_tests += ft_count;
599        totals.kani_harnesses += kani_count;
600        totals.lean_proved += lean_proved;
601        totals.lean_grounded += lean_grounded;
602        totals.l4_self_declared += u32::from(l4_self_declared);
603        totals.bindings_implemented += b_impl;
604        totals.bindings_total += b_total;
605
606        statuses.push(ContractProofStatus {
607            stem: stem.clone(),
608            proof_level,
609            obligations,
610            not_applicable,
611            falsification_tests: ft_count,
612            kani_harnesses: kani_count,
613            lean_proved,
614            lean_grounded,
615            l4_self_declared,
616            bindings_implemented: b_impl,
617            bindings_total: b_total,
618        });
619    }
620
621    // Build kernel class summaries
622    let kernel_classes = if include_classes {
623        build_kernel_classes(&statuses)
624    } else {
625        Vec::new()
626    };
627
628    let timestamp = current_timestamp();
629
630    ProofStatusReport {
631        schema_version: "1.0.0".to_string(),
632        l4_self_declared_excluded: true,
633        timestamp,
634        contracts: statuses,
635        kernel_classes,
636        totals,
637    }
638}
639
640/// Format a proof status report as human-readable text.
641pub fn format_text(report: &ProofStatusReport) -> String {
642    let mut out = String::new();
643
644    out.push_str(&format!(
645        "Proof Status ({} contracts)\n\n",
646        report.totals.contracts
647    ));
648
649    out.push_str(&format!(
650        "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>9} {:>13}\n",
651        "Contract", "Level", "Obligs", "Tests", "Kani", "Lean", "Bindings", "L4 evidence"
652    ));
653    out.push_str(&format!("  {}\n", "─".repeat(86)));
654
655    for c in &report.contracts {
656        // ONT-2a: the andon is a COLUMN, not a footnote. A claim the tree does not ground says so on its
657        // own line, beside the level it no longer reaches.
658        let l4_evidence = if c.l4_self_declared {
659            "self-declared"
660        } else if c.lean_grounded > 0 {
661            "grounded"
662        } else {
663            "-"
664        };
665        out.push_str(&format!(
666            "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>4}/{:<4} {:>13}\n",
667            truncate(&c.stem, 35),
668            c.proof_level,
669            c.obligations,
670            c.falsification_tests,
671            c.kani_harnesses,
672            c.lean_proved,
673            c.bindings_implemented,
674            c.bindings_total,
675            l4_evidence,
676        ));
677    }
678
679    if !report.kernel_classes.is_empty() {
680        out.push_str("\nKernel Classes:\n");
681        for kc in &report.kernel_classes {
682            let bound_str = if kc.all_bound { "all bound" } else { "gaps" };
683            out.push_str(&format!(
684                "  {} ({}): min={}, {} contracts, {}\n",
685                kc.label,
686                kc.description,
687                kc.min_proof_level,
688                kc.contract_stems.len(),
689                bound_str,
690            ));
691        }
692    }
693
694    out.push_str(&format!(
695        "\nTotals: {} obligations ({} N/A, never counted as proved), {} tests, {} kani, {} lean claimed ({} grounded), {}/{} bound\n\
696         L4 evidence: {} contract(s) self-declared and excluded from L4 (ONT-2a andon); grounded means a \
697         sorry-free in-tree Lean theorem the equation names\n",
698        report.totals.obligations,
699        report.totals.not_applicable,
700        report.totals.falsification_tests,
701        report.totals.kani_harnesses,
702        report.totals.lean_proved,
703        report.totals.lean_grounded,
704        report.totals.bindings_implemented,
705        report.totals.bindings_total,
706        report.totals.l4_self_declared,
707    ));
708
709    out
710}
711
712// ── Internal helpers ──────────────────────────────────────────────
713
714/// Count implemented vs total bindings for a contract in the registry
715#[allow(clippy::cast_possible_truncation)]
716pub(crate) fn count_bindings(
717    contract_file: &str,
718    contract: &Contract,
719    binding: &BindingRegistry,
720) -> (u32, u32) {
721    let total = contract.equations.len() as u32;
722    let implemented = binding
723        .bindings_for(contract_file)
724        .iter()
725        .filter(|b| b.status == ImplStatus::Implemented)
726        .count() as u32;
727    (implemented, total)
728}
729
730/// Build kernel equivalence class summaries from per-contract statuses
731fn build_kernel_classes(statuses: &[ContractProofStatus]) -> Vec<KernelClassSummary> {
732    let status_map: BTreeMap<&str, &ContractProofStatus> =
733        statuses.iter().map(|s| (s.stem.as_str(), s)).collect();
734
735    kernel_class_map()
736        .into_iter()
737        .map(|(label, desc, stems)| {
738            let found_stems: Vec<String> = stems
739                .iter()
740                .filter(|s| status_map.contains_key(**s))
741                .map(|s| (*s).to_string())
742                .collect();
743
744            let min_level = found_stems
745                .iter()
746                .filter_map(|s| status_map.get(s.as_str()))
747                .map(|c| c.proof_level)
748                .min()
749                .unwrap_or(ProofLevel::L1);
750
751            let all_bound = !found_stems.is_empty()
752                && found_stems.iter().all(|s| {
753                    status_map.get(s.as_str()).is_some_and(|c| {
754                        c.bindings_total > 0 && c.bindings_implemented == c.bindings_total
755                    })
756                });
757
758            KernelClassSummary {
759                label: label.to_string(),
760                description: desc.to_string(),
761                contract_stems: found_stems,
762                min_proof_level: min_level,
763                all_bound,
764            }
765        })
766        .collect()
767}
768
769/// Truncate a string to at most `max` bytes for column alignment
770fn truncate(s: &str, max: usize) -> &str {
771    if s.len() > max {
772        &s[..max]
773    } else {
774        s
775    }
776}
777
778/// Generate an ISO-8601-style Unix epoch timestamp string
779fn current_timestamp() -> String {
780    // Use a simple ISO-8601 timestamp without external deps.
781    // In production this would use chrono or time crate.
782    // For now we use std::time for a Unix epoch string.
783    let duration = std::time::SystemTime::now()
784        .duration_since(std::time::UNIX_EPOCH)
785        .unwrap_or_default();
786    format!("{}Z", duration.as_secs())
787}
788
789#[cfg(test)]
790#[path = "proof_status_tests.rs"]
791mod tests;