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;