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    /// Number of falsification tests defined
68    pub falsification_tests: u32,
69    /// Number of Kani bounded-model-checking harnesses
70    pub kani_harnesses: u32,
71    /// Number of obligations proved in Lean 4
72    pub lean_proved: u32,
73    /// Number of bindings with `implemented` status
74    pub bindings_implemented: u32,
75    /// Total number of equation bindings
76    pub bindings_total: u32,
77}
78
79// ── Kernel class summary ──────────────────────────────────────────
80
81/// Summary of proof status for a kernel equivalence class.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct KernelClassSummary {
84    /// Kernel class identifier (A through E)
85    pub label: String,
86    /// Human-readable description of the kernel combination
87    pub description: String,
88    /// Contract stems belonging to this class
89    pub contract_stems: Vec<String>,
90    /// Lowest proof level among class members
91    pub min_proof_level: ProofLevel,
92    /// Whether all class members have full binding coverage
93    pub all_bound: bool,
94}
95
96// ── Full report ───────────────────────────────────────────────────
97
98/// Top-level proof status report, serializable to JSON.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProofStatusReport {
101    /// Report schema version for forward compatibility
102    pub schema_version: String,
103    /// Unix epoch timestamp when the report was generated
104    pub timestamp: String,
105    /// Per-contract proof status entries
106    pub contracts: Vec<ContractProofStatus>,
107    /// Kernel equivalence class summaries
108    pub kernel_classes: Vec<KernelClassSummary>,
109    /// Aggregate totals across all contracts
110    pub totals: ProofStatusTotals,
111}
112
113/// Aggregate totals across all contracts.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ProofStatusTotals {
116    /// Total number of contracts analyzed
117    pub contracts: u32,
118    /// Sum of proof obligations across all contracts
119    pub obligations: u32,
120    /// Sum of falsification tests across all contracts
121    pub falsification_tests: u32,
122    /// Sum of Kani harnesses across all contracts
123    pub kani_harnesses: u32,
124    /// Sum of Lean-proved obligations across all contracts
125    pub lean_proved: u32,
126    /// Sum of implemented bindings across all contracts
127    pub bindings_implemented: u32,
128    /// Sum of total bindings across all contracts
129    pub bindings_total: u32,
130}
131
132// ── Kernel class → contract stem mapping ──────────────────────────
133
134/// Static mapping from kernel equivalence class to contract stems.
135///
136/// Mirrors the `KernelOp` classification from `apr-model-qa-playbook`:
137/// - **A** — GQA + `RMSNorm` + `SiLU` + `SwiGLU` + `RoPE` (Llama/Mistral)
138/// - **B** — MHA + `LayerNorm` + GELU + `AbsPos` (GPT-2/BERT)
139/// - **C** — MHA + `LayerNorm` + GELU + `ALiBi` (BLOOM/MPT)
140/// - **D** — `LayerNorm` + GELU + `SiLU` + GQA (Gemma)
141/// - **E** — `RMSNorm` + `SwiGLU` + GQA (Qwen)
142fn kernel_class_map() -> Vec<(&'static str, &'static str, &'static [&'static str])> {
143    vec![
144        (
145            "A",
146            "GQA+RMSNorm+SiLU+SwiGLU+RoPE",
147            &[
148                "rmsnorm-kernel-v1",
149                "silu-kernel-v1",
150                "swiglu-kernel-v1",
151                "rope-kernel-v1",
152                "gqa-kernel-v1",
153                "softmax-kernel-v1",
154                "matmul-kernel-v1",
155            ],
156        ),
157        (
158            "B",
159            "MHA+LayerNorm+GELU+AbsPos",
160            &[
161                "layernorm-kernel-v1",
162                "gelu-kernel-v1",
163                "attention-kernel-v1",
164                "softmax-kernel-v1",
165                "matmul-kernel-v1",
166                "absolute-position-v1",
167            ],
168        ),
169        (
170            "C",
171            "MHA+LayerNorm+GELU+ALiBi",
172            &[
173                "layernorm-kernel-v1",
174                "gelu-kernel-v1",
175                "attention-kernel-v1",
176                "softmax-kernel-v1",
177                "alibi-kernel-v1",
178                "matmul-kernel-v1",
179            ],
180        ),
181        (
182            "D",
183            "LayerNorm+GELU+SiLU+GQA",
184            &[
185                "layernorm-kernel-v1",
186                "gelu-kernel-v1",
187                "silu-kernel-v1",
188                "gqa-kernel-v1",
189                "softmax-kernel-v1",
190                "matmul-kernel-v1",
191            ],
192        ),
193        (
194            "E",
195            "RMSNorm+SwiGLU+GQA",
196            &[
197                "rmsnorm-kernel-v1",
198                "swiglu-kernel-v1",
199                "gqa-kernel-v1",
200                "softmax-kernel-v1",
201                "matmul-kernel-v1",
202            ],
203        ),
204    ]
205}
206
207// ── Core computation ──────────────────────────────────────────────
208
209/// Returns `true` when EVERY proof obligation is discharged in Lean.
210///
211/// Strict per-obligation semantics — no fuzzy over-promotion. A contract is
212/// Lean-proved (L4) only when the number of Lean-proved obligations plus the
213/// explicitly not-applicable ones covers ALL proof obligations, and at least
214/// one obligation is genuinely proved. The obligation total is
215/// `proof_obligations.len()` — the SAME total used for L2/L3 — so a
216/// `verification_summary` cannot manufacture L4 by understating the total.
217///
218/// Proof counts come from the contract's `verification_summary` when it makes a
219/// positive claim (`l4_lean_proved > 0`); otherwise from a scan of the in-tree
220/// sorry-free `.lean` theorems. Because the scan can only credit obligations it
221/// resolves, partial coverage (the old "≥1 resolving ref → L4" over-promotion)
222/// now correctly reports L3 instead of a full L4. Contracts with legitimately
223/// N/A obligations (e.g. softmax-kernel-v1 = 5 proved + 4 N/A of 9) MUST declare
224/// that in `verification_summary` — the scan path grants no N/A credit.
225fn is_lean_proved(contract: &Contract) -> bool {
226    let total = contract.proof_obligations.len() as u32;
227    if total == 0 {
228        return false;
229    }
230    // A present verification_summary that makes a positive Lean claim is
231    // authoritative (a stale/zero summary falls through to the scan). Otherwise
232    // count resolvable in-tree Lean theorems; the scan grants no N/A credit.
233    let (proved, not_applicable) = match contract.verification_summary.as_ref() {
234        Some(vs) if vs.l4_lean_proved > 0 => (vs.l4_lean_proved, vs.l4_not_applicable),
235        _ => (count_lean_theorems_for_contract(contract), 0),
236    };
237    proved > 0 && proved + not_applicable >= total
238}
239
240/// Returns `true` when all bindings are implemented.
241fn is_fully_bound(binding_status: Option<(u32, u32)>) -> bool {
242    binding_status.is_some_and(|(implemented, total)| total > 0 && implemented == total)
243}
244
245/// Compute the proof level for a single contract.
246///
247/// Derivation rules (highest matching level wins):
248/// - **L5**: every obligation Lean-proved AND all bindings implemented
249/// - **L4**: every obligation Lean-proved — strict per-obligation coverage,
250///   `proved + not_applicable >= proof_obligations.len()` with `proved > 0`
251///   (partial coverage is NOT L4; see [`is_lean_proved`])
252/// - **L3**: has Kani harnesses AND falsification tests cover obligations
253/// - **L2**: falsification tests count >= obligations count
254/// - **L1**: contract exists with equations
255#[allow(clippy::cast_possible_truncation)]
256pub fn compute_proof_level(contract: &Contract, binding_status: Option<(u32, u32)>) -> ProofLevel {
257    let total_obligations = contract.proof_obligations.len() as u32;
258    let ft_count = contract.falsification_tests.len() as u32;
259    let kani_count = contract.kani_harnesses.len() as u32;
260
261    // Check L4/L5: Lean proved
262    if is_lean_proved(contract) {
263        return if is_fully_bound(binding_status) {
264            ProofLevel::L5
265        } else {
266            ProofLevel::L4
267        };
268    }
269
270    // Check L3: Kani + falsification
271    let has_tests = ft_count >= total_obligations && total_obligations > 0;
272    if kani_count > 0 && has_tests {
273        return ProofLevel::L3;
274    }
275
276    // Check L2: falsification tests cover obligations
277    if has_tests {
278        return ProofLevel::L2;
279    }
280
281    // L1: contract exists with equations
282    ProofLevel::L1
283}
284
285/// Directories scanned (relative to CWD) for sorry-free Lean theorem files,
286/// in priority order. The IN-TREE staging tree is FIRST — it is the
287/// post-APR-MONO source of truth and a superset of the external sibling, so
288/// L4/L5 proof levels are reproducible on a fresh clone / CI without a
289/// co-located `../provable-contracts` checkout. The bare `lean` and the
290/// external sibling are kept as fallbacks for dev machines that still use them.
291pub(crate) const LEAN_THEOREM_BASES: &[&str] = &[
292    "crates/aprender-contracts-staging/lean",
293    "lean",
294    "../provable-contracts/lean",
295];
296
297/// Build a set of all sorry-free Lean theorem names from the Theorems/ directory.
298/// Scans once, caches the result in a thread-local for repeated calls.
299fn lean_theorem_names() -> &'static std::collections::HashSet<String> {
300    use std::sync::OnceLock;
301    static CACHE: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
302    CACHE.get_or_init(|| {
303        let mut names = std::collections::HashSet::new();
304        for base in LEAN_THEOREM_BASES {
305            let search_dir = std::path::Path::new(base).join("ProvableContracts/Theorems");
306            if !search_dir.exists() {
307                continue;
308            }
309            // Walk all domain dirs and collect theorem names
310            if let Ok(domains) = std::fs::read_dir(&search_dir) {
311                for domain_entry in domains.flatten() {
312                    if !domain_entry.path().is_dir() {
313                        continue;
314                    }
315                    let domain_name = domain_entry.file_name().to_string_lossy().to_string();
316                    if let Ok(files) = std::fs::read_dir(domain_entry.path()) {
317                        for file in files.flatten() {
318                            let path = file.path();
319                            if path.extension().is_some_and(|e| e == "lean") {
320                                if let Ok(content) = std::fs::read_to_string(&path) {
321                                    if !content.contains("sorry") {
322                                        let stem = path
323                                            .file_stem()
324                                            .unwrap_or_default()
325                                            .to_string_lossy()
326                                            .to_string();
327                                        // Register domain, stem, and namespace forms
328                                        names.insert(format!("Theorems.{domain_name}"));
329                                        names.insert(domain_name.clone());
330                                        names.insert(domain_name.to_lowercase());
331                                        names.insert(format!("Theorems.{stem}"));
332                                        names.insert(stem.clone());
333                                        names.insert(stem.to_lowercase());
334                                        // Extract theorem names from content
335                                        // e.g., "theorem relu_nonneg" → "Relu"
336                                        for line in content.lines() {
337                                            if let Some(pos) = line.find("theorem ") {
338                                                let rest = &line[pos + 8..];
339                                                let tname: String = rest
340                                                    .chars()
341                                                    .take_while(|c| {
342                                                        c.is_alphanumeric() || *c == '_'
343                                                    })
344                                                    .collect();
345                                                if !tname.is_empty() {
346                                                    // CamelCase the theorem name for matching
347                                                    let camel: String = tname
348                                                        .split('_')
349                                                        .map(|s| {
350                                                            let mut c = s.chars();
351                                                            match c.next() {
352                                                                None => String::new(),
353                                                                Some(f) => f
354                                                                    .to_uppercase()
355                                                                    .chain(c)
356                                                                    .collect(),
357                                                            }
358                                                        })
359                                                        .collect();
360                                                    names.insert(format!("Theorems.{camel}"));
361                                                    names.insert(camel.clone());
362                                                    // Also register first CamelCase word
363                                                    // e.g., "ReluNonneg" → "Relu"
364                                                    let first_word: String = camel
365                                                        .chars()
366                                                        .enumerate()
367                                                        .take_while(|(i, c)| {
368                                                            *i == 0 || !c.is_uppercase()
369                                                        })
370                                                        .map(|(_, c)| c)
371                                                        .collect();
372                                                    if first_word.len() >= 3 {
373                                                        names.insert(format!(
374                                                            "Theorems.{first_word}"
375                                                        ));
376                                                        names.insert(first_word);
377                                                    }
378                                                }
379                                            }
380                                        }
381                                    }
382                                }
383                            }
384                        }
385                    }
386                }
387            }
388            if !names.is_empty() {
389                break;
390            }
391        }
392        names
393    })
394}
395
396/// Count Lean theorems for a contract by matching `lean_theorem` refs against
397/// sorry-free `.lean` files in the Theorems/ directory.
398fn count_lean_theorems_for_contract(contract: &Contract) -> u32 {
399    let theorems = lean_theorem_names();
400    let mut count = 0u32;
401    for eq in contract.equations.values() {
402        if let Some(ref theorem_ref) = eq.lean_theorem {
403            let name = theorem_ref.trim().trim_matches('"');
404            // Try exact match, then without prefix, then lowercase
405            if theorems.contains(name)
406                || theorems.contains(name.strip_prefix("Theorems.").unwrap_or(name))
407                || theorems.contains(&name.to_lowercase())
408            {
409                count += 1;
410            }
411        }
412    }
413    count
414}
415
416/// Build a complete proof status report.
417///
418/// `contracts` is a list of `(stem, &Contract)` pairs.
419/// `binding` is an optional binding registry for binding coverage.
420/// `include_classes` controls whether kernel class summaries are generated.
421#[allow(clippy::cast_possible_truncation)]
422pub fn proof_status_report(
423    contracts: &[(String, &Contract)],
424    binding: Option<&BindingRegistry>,
425    include_classes: bool,
426) -> ProofStatusReport {
427    let mut statuses = Vec::new();
428    let mut totals = ProofStatusTotals {
429        contracts: contracts.len() as u32,
430        obligations: 0,
431        falsification_tests: 0,
432        kani_harnesses: 0,
433        lean_proved: 0,
434        bindings_implemented: 0,
435        bindings_total: 0,
436    };
437
438    for (stem, contract) in contracts {
439        let contract_file = format!("{stem}.yaml");
440
441        let obligations = contract.proof_obligations.len() as u32;
442        let ft_count = contract.falsification_tests.len() as u32;
443        let kani_count = contract.kani_harnesses.len() as u32;
444        // First try YAML self-reported count, then scan actual Lean files
445        let lean_proved = contract
446            .verification_summary
447            .as_ref()
448            .map_or(0, |vs| vs.l4_lean_proved);
449        let lean_proved = if lean_proved == 0 {
450            count_lean_theorems_for_contract(contract)
451        } else {
452            lean_proved
453        };
454
455        // Count bindings for this contract
456        let (b_impl, b_total) = if let Some(reg) = binding {
457            count_bindings(&contract_file, contract, reg)
458        } else {
459            (0, contract.equations.len() as u32)
460        };
461
462        let binding_status = if binding.is_some() {
463            Some((b_impl, b_total))
464        } else {
465            None
466        };
467
468        let proof_level = compute_proof_level(contract, binding_status);
469
470        totals.obligations += obligations;
471        totals.falsification_tests += ft_count;
472        totals.kani_harnesses += kani_count;
473        totals.lean_proved += lean_proved;
474        totals.bindings_implemented += b_impl;
475        totals.bindings_total += b_total;
476
477        statuses.push(ContractProofStatus {
478            stem: stem.clone(),
479            proof_level,
480            obligations,
481            falsification_tests: ft_count,
482            kani_harnesses: kani_count,
483            lean_proved,
484            bindings_implemented: b_impl,
485            bindings_total: b_total,
486        });
487    }
488
489    // Build kernel class summaries
490    let kernel_classes = if include_classes {
491        build_kernel_classes(&statuses)
492    } else {
493        Vec::new()
494    };
495
496    let timestamp = current_timestamp();
497
498    ProofStatusReport {
499        schema_version: "1.0.0".to_string(),
500        timestamp,
501        contracts: statuses,
502        kernel_classes,
503        totals,
504    }
505}
506
507/// Format a proof status report as human-readable text.
508pub fn format_text(report: &ProofStatusReport) -> String {
509    let mut out = String::new();
510
511    out.push_str(&format!(
512        "Proof Status ({} contracts)\n\n",
513        report.totals.contracts
514    ));
515
516    out.push_str(&format!(
517        "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>9}\n",
518        "Contract", "Level", "Obligs", "Tests", "Kani", "Lean", "Bindings"
519    ));
520    out.push_str(&format!("  {}\n", "─".repeat(72)));
521
522    for c in &report.contracts {
523        out.push_str(&format!(
524            "  {:<35} {:>5} {:>6} {:>5} {:>4} {:>4} {:>4}/{:<4}\n",
525            truncate(&c.stem, 35),
526            c.proof_level,
527            c.obligations,
528            c.falsification_tests,
529            c.kani_harnesses,
530            c.lean_proved,
531            c.bindings_implemented,
532            c.bindings_total,
533        ));
534    }
535
536    if !report.kernel_classes.is_empty() {
537        out.push_str("\nKernel Classes:\n");
538        for kc in &report.kernel_classes {
539            let bound_str = if kc.all_bound { "all bound" } else { "gaps" };
540            out.push_str(&format!(
541                "  {} ({}): min={}, {} contracts, {}\n",
542                kc.label,
543                kc.description,
544                kc.min_proof_level,
545                kc.contract_stems.len(),
546                bound_str,
547            ));
548        }
549    }
550
551    out.push_str(&format!(
552        "\nTotals: {} obligations, {} tests, {} kani, {} lean proved, {}/{} bound\n",
553        report.totals.obligations,
554        report.totals.falsification_tests,
555        report.totals.kani_harnesses,
556        report.totals.lean_proved,
557        report.totals.bindings_implemented,
558        report.totals.bindings_total,
559    ));
560
561    out
562}
563
564// ── Internal helpers ──────────────────────────────────────────────
565
566/// Count implemented vs total bindings for a contract in the registry
567#[allow(clippy::cast_possible_truncation)]
568pub(crate) fn count_bindings(
569    contract_file: &str,
570    contract: &Contract,
571    binding: &BindingRegistry,
572) -> (u32, u32) {
573    let total = contract.equations.len() as u32;
574    let implemented = binding
575        .bindings_for(contract_file)
576        .iter()
577        .filter(|b| b.status == ImplStatus::Implemented)
578        .count() as u32;
579    (implemented, total)
580}
581
582/// Build kernel equivalence class summaries from per-contract statuses
583fn build_kernel_classes(statuses: &[ContractProofStatus]) -> Vec<KernelClassSummary> {
584    let status_map: BTreeMap<&str, &ContractProofStatus> =
585        statuses.iter().map(|s| (s.stem.as_str(), s)).collect();
586
587    kernel_class_map()
588        .into_iter()
589        .map(|(label, desc, stems)| {
590            let found_stems: Vec<String> = stems
591                .iter()
592                .filter(|s| status_map.contains_key(**s))
593                .map(|s| (*s).to_string())
594                .collect();
595
596            let min_level = found_stems
597                .iter()
598                .filter_map(|s| status_map.get(s.as_str()))
599                .map(|c| c.proof_level)
600                .min()
601                .unwrap_or(ProofLevel::L1);
602
603            let all_bound = !found_stems.is_empty()
604                && found_stems.iter().all(|s| {
605                    status_map.get(s.as_str()).is_some_and(|c| {
606                        c.bindings_total > 0 && c.bindings_implemented == c.bindings_total
607                    })
608                });
609
610            KernelClassSummary {
611                label: label.to_string(),
612                description: desc.to_string(),
613                contract_stems: found_stems,
614                min_proof_level: min_level,
615                all_bound,
616            }
617        })
618        .collect()
619}
620
621/// Truncate a string to at most `max` bytes for column alignment
622fn truncate(s: &str, max: usize) -> &str {
623    if s.len() > max {
624        &s[..max]
625    } else {
626        s
627    }
628}
629
630/// Generate an ISO-8601-style Unix epoch timestamp string
631fn current_timestamp() -> String {
632    // Use a simple ISO-8601 timestamp without external deps.
633    // In production this would use chrono or time crate.
634    // For now we use std::time for a Unix epoch string.
635    let duration = std::time::SystemTime::now()
636        .duration_since(std::time::UNIX_EPOCH)
637        .unwrap_or_default();
638    format!("{}Z", duration.as_secs())
639}
640
641#[cfg(test)]
642#[path = "proof_status_tests.rs"]
643mod tests;