Skip to main content

provable_contracts/lean_gen/
mod.rs

1//! Lean 4 code generator — Phase 7 of the pipeline.
2//!
3//! Generates Lean 4 source files from YAML kernel contracts:
4//!
5//! - **Definition files** with kernel functions as Lean `def` over `ℝ`
6//! - **Theorem stubs** with `sorry` for each proof obligation that has
7//!   a `lean` block
8//! - **Import structure** based on Mathlib dependencies
9//!
10//! Also provides `lean_status` for reporting proof status across contracts.
11
12use crate::schema::{Contract, LeanStatus, ProofObligation};
13
14/// A generated Lean 4 file.
15#[derive(Debug, Clone)]
16pub struct LeanFile {
17    /// Relative path within the Lean project (e.g. `ProvableContracts/Defs/Softmax.lean`).
18    pub path: String,
19    /// Lean 4 source content.
20    pub content: String,
21}
22
23/// Generate Lean 4 source files from a contract.
24///
25/// Produces:
26/// 1. A definitions file with equations as Lean `noncomputable def`s
27/// 2. One theorem stub file per proof obligation that has a `lean` block
28///
29/// Returns an empty vec if the contract has no Lean metadata.
30pub fn generate_lean_files(contract: &Contract) -> Vec<LeanFile> {
31    let lean_obligations: Vec<&ProofObligation> = contract
32        .proof_obligations
33        .iter()
34        .filter(|ob| ob.lean.is_some())
35        .collect();
36
37    if lean_obligations.is_empty() {
38        return Vec::new();
39    }
40
41    let module_name = derive_module_name(&contract.metadata.description);
42    let mut files = Vec::new();
43
44    // 1. Definitions file
45    files.push(generate_defs_file(contract, &module_name));
46
47    // 2. Theorem stub files
48    for ob in &lean_obligations {
49        if let Some(ref lean) = ob.lean {
50            files.push(generate_theorem_file(ob, lean, &module_name));
51        }
52    }
53
54    files
55}
56
57/// Report Lean proof status for a contract.
58///
59/// Returns a `LeanStatusReport` with counts by status. `proved` is the YAML's own word (self-declared);
60/// `discharged` is what the repo's `discharge-summary.json` grants (EV-8b).
61pub fn lean_status(contract: &Contract) -> LeanStatusReport {
62    lean_status_with(contract, crate::proof_status::discharge_grounding())
63}
64
65/// [`lean_status`] against an explicit discharge grant.
66pub fn lean_status_with(
67    contract: &Contract,
68    discharged: &crate::discharge::summary::Discharged,
69) -> LeanStatusReport {
70    let mut report = LeanStatusReport {
71        contract_description: contract.metadata.description.clone(),
72        #[allow(clippy::cast_possible_truncation)]
73        total_obligations: contract.proof_obligations.len() as u32,
74        with_lean: 0,
75        proved: 0,
76        sorry: 0,
77        wip: 0,
78        not_applicable: 0,
79        discharged: 0,
80        obligations: Vec::new(),
81    };
82
83    for ob in &contract.proof_obligations {
84        if let Some(ref lean) = ob.lean {
85            report.with_lean += 1;
86            match lean.status {
87                LeanStatus::Proved => report.proved += 1,
88                LeanStatus::Sorry => report.sorry += 1,
89                LeanStatus::Wip => report.wip += 1,
90                LeanStatus::NotApplicable => report.not_applicable += 1,
91            }
92            if lean.status != LeanStatus::NotApplicable && discharged.grants(&lean.theorem) {
93                report.discharged += 1;
94            }
95            report.obligations.push(ObligationStatus {
96                property: ob.property.clone(),
97                theorem: lean.theorem.clone(),
98                status: lean.status,
99            });
100        }
101    }
102
103    report
104}
105
106/// Status report for Lean proofs in a single contract.
107#[derive(Debug, Clone)]
108pub struct LeanStatusReport {
109    pub contract_description: String,
110    pub total_obligations: u32,
111    pub with_lean: u32,
112    pub proved: u32,
113    pub sorry: u32,
114    pub wip: u32,
115    pub not_applicable: u32,
116    /// Obligations whose theorem the discharge summary grants (EV-8b): the only count L4 totals use.
117    pub discharged: u32,
118    pub obligations: Vec<ObligationStatus>,
119}
120
121/// Status of a single obligation's Lean proof.
122#[derive(Debug, Clone)]
123pub struct ObligationStatus {
124    pub property: String,
125    pub theorem: String,
126    pub status: LeanStatus,
127}
128
129/// Format a `LeanStatusReport` as a human-readable table.
130pub fn format_status_report(reports: &[LeanStatusReport]) -> String {
131    let mut out = String::new();
132
133    out.push_str(&format!(
134        "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
135        "Contract", "Oblgs", "self-declared", "discharged", "Sorry", "WIP", "N/A"
136    ));
137    out.push_str(&"─".repeat(76));
138    out.push('\n');
139
140    let mut total_ob = 0u32;
141    let mut total_proved = 0u32;
142    let mut total_discharged = 0u32;
143    let mut total_sorry = 0u32;
144    let mut total_wip = 0u32;
145    let mut total_na = 0u32;
146
147    for r in reports {
148        let name = if r.contract_description.len() > 30 {
149            // Truncate at char boundary to avoid UTF-8 panic
150            let end = r
151                .contract_description
152                .char_indices()
153                .take_while(|(i, _)| *i < 30)
154                .last()
155                .map_or(0, |(i, c)| i + c.len_utf8());
156            &r.contract_description[..end]
157        } else {
158            &r.contract_description
159        };
160        out.push_str(&format!(
161            "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
162            name, r.with_lean, r.proved, r.discharged, r.sorry, r.wip, r.not_applicable
163        ));
164        total_ob += r.with_lean;
165        total_proved += r.proved;
166        total_discharged += r.discharged;
167        total_sorry += r.sorry;
168        total_wip += r.wip;
169        total_na += r.not_applicable;
170    }
171
172    out.push_str(&"─".repeat(76));
173    out.push('\n');
174    out.push_str(&format!(
175        "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
176        "Total", total_ob, total_proved, total_discharged, total_sorry, total_wip, total_na
177    ));
178
179    // EV-8b: coverage counts only what the discharge summary grants; the YAML's `proved` is printed beside it.
180    if let Some(pct) = (total_discharged * 100).checked_div(total_ob) {
181        out.push_str(&format!(
182            "L4 Coverage: {pct}% ({total_discharged}/{total_ob} discharged; {total_proved} self-declared)   Sorry Debt: {total_sorry}\n"
183        ));
184    }
185
186    out
187}
188
189// ── Internal helpers ──────────────────────────────────────────────
190
191fn derive_module_name(description: &str) -> String {
192    let base = description
193        .split_whitespace()
194        .next()
195        .unwrap_or("Unknown")
196        .to_string();
197    // Capitalize first letter for Lean module convention
198    let mut chars = base.chars();
199    match chars.next() {
200        None => "Unknown".to_string(),
201        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
202    }
203}
204
205fn generate_defs_file(contract: &Contract, module_name: &str) -> LeanFile {
206    let mut content = String::new();
207
208    content.push_str(&format!("-- {}\n", contract.metadata.description));
209    content.push_str(&format!(
210        "-- Generated from contract v{}\n",
211        contract.metadata.version
212    ));
213    content.push_str("-- DO NOT EDIT — regenerate with `pv lean`\n\n");
214
215    // Collect all mathlib imports from obligations
216    let mut imports: Vec<&str> = Vec::new();
217    for ob in &contract.proof_obligations {
218        if let Some(ref lean) = ob.lean {
219            for imp in &lean.mathlib_imports {
220                if !imports.contains(&imp.as_str()) {
221                    imports.push(imp);
222                }
223            }
224        }
225    }
226
227    content.push_str("import Mathlib.Data.Real.Basic\n");
228    content.push_str("import Mathlib.Data.Finset.Basic\n");
229    for imp in &imports {
230        content.push_str(&format!("import {imp}\n"));
231    }
232    content.push('\n');
233
234    content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
235
236    // Generate noncomputable defs from equations
237    for (name, eq) in &contract.equations {
238        content.push_str(&format!("-- Equation: {name}\n"));
239        content.push_str(&format!("-- Formula: {}\n", eq.formula));
240        if let Some(ref domain) = eq.domain {
241            content.push_str(&format!("-- Domain: {domain}\n"));
242        }
243        content.push_str(&format!("noncomputable def {name} : sorry := sorry\n\n"));
244    }
245
246    content.push_str(&format!("end ProvableContracts.{module_name}\n"));
247
248    LeanFile {
249        path: format!("ProvableContracts/Defs/{module_name}.lean"),
250        content,
251    }
252}
253
254fn generate_theorem_file(
255    ob: &ProofObligation,
256    lean: &crate::schema::LeanProof,
257    module_name: &str,
258) -> LeanFile {
259    let mut content = String::new();
260
261    content.push_str(&format!("-- Theorem: {}\n", lean.theorem));
262    content.push_str(&format!("-- Property: {}\n", ob.property));
263    content.push_str(&format!("-- Obligation type: {}\n", ob.obligation_type));
264    content.push_str("-- Generated with `pv lean`\n\n");
265
266    // Imports
267    content.push_str(&format!("import ProvableContracts.Defs.{module_name}\n"));
268    for imp in &lean.mathlib_imports {
269        content.push_str(&format!("import {imp}\n"));
270    }
271    content.push('\n');
272
273    // Lean-level dependencies
274    if !lean.depends_on.is_empty() {
275        content.push_str("-- Dependencies:\n");
276        for dep in &lean.depends_on {
277            content.push_str(&format!("--   {dep}\n"));
278        }
279        content.push('\n');
280    }
281
282    content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
283
284    // Formal statement if present
285    if let Some(ref formal) = ob.formal {
286        content.push_str(&format!("-- Formal: {formal}\n"));
287    }
288
289    // Theorem stub
290    let status_comment = match lean.status {
291        LeanStatus::Proved => "-- Status: proved",
292        LeanStatus::Sorry => "-- Status: sorry (proof pending)",
293        LeanStatus::Wip => "-- Status: work in progress",
294        LeanStatus::NotApplicable => "-- Status: not applicable",
295    };
296    content.push_str(&format!("{status_comment}\n"));
297    content.push_str(&format!("theorem {} : sorry := by\n", lean.theorem));
298    content.push_str("  sorry\n");
299
300    if let Some(ref notes) = lean.notes {
301        content.push_str(&format!("\n-- Note: {notes}\n"));
302    }
303
304    content.push_str(&format!("\nend ProvableContracts.{module_name}\n"));
305
306    // Derive file path from theorem name
307    let theorem_file = lean.theorem.split('.').next_back().unwrap_or(&lean.theorem);
308    LeanFile {
309        path: format!("ProvableContracts/Theorems/{module_name}/{theorem_file}.lean"),
310        content,
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::schema::parse_contract_str;
318
319    #[test]
320    fn no_lean_obligations_produces_empty() {
321        let yaml = r#"
322metadata:
323  version: "1.0.0"
324  description: "Softmax kernel"
325  references: ["Paper"]
326equations:
327  softmax:
328    formula: "f(x) = exp(x_i) / sum(exp(x_j))"
329proof_obligations:
330  - type: invariant
331    property: "Output sums to 1"
332falsification_tests: []
333"#;
334        let contract = parse_contract_str(yaml).unwrap();
335        let files = generate_lean_files(&contract);
336        assert!(files.is_empty());
337    }
338
339    #[test]
340    fn generates_defs_and_theorem_files() {
341        let yaml = r#"
342metadata:
343  version: "1.0.0"
344  description: "Softmax kernel"
345  references: ["Paper"]
346equations:
347  softmax:
348    formula: "f(x) = exp(x_i) / sum(exp(x_j))"
349    domain: "R^n"
350proof_obligations:
351  - type: invariant
352    property: "Output sums to 1"
353    formal: "|sum(f(x)) - 1| < eps"
354    lean:
355      theorem: Softmax.partition_of_unity
356      module: ProvableContracts.Softmax
357      status: sorry
358      depends_on:
359        - Real.exp_pos
360      mathlib_imports:
361        - Mathlib.Analysis.SpecialFunctions.ExpDeriv
362      notes: "Proof over reals"
363falsification_tests: []
364"#;
365        let contract = parse_contract_str(yaml).unwrap();
366        let files = generate_lean_files(&contract);
367        assert_eq!(files.len(), 2); // defs + 1 theorem
368
369        // Check defs file
370        let defs = &files[0];
371        assert!(defs.path.contains("Defs/Softmax"));
372        assert!(defs.content.contains("noncomputable def softmax"));
373        assert!(defs.content.contains("namespace ProvableContracts.Softmax"));
374        assert!(defs
375            .content
376            .contains("Mathlib.Analysis.SpecialFunctions.ExpDeriv"));
377
378        // Check theorem file
379        let thm = &files[1];
380        assert!(thm.path.contains("Theorems/Softmax/partition_of_unity"));
381        assert!(thm.content.contains("theorem Softmax.partition_of_unity"));
382        assert!(thm.content.contains("sorry"));
383        assert!(thm.content.contains("Real.exp_pos"));
384        assert!(thm.content.contains("Proof over reals"));
385    }
386
387    #[test]
388    fn lean_status_counts_correctly() {
389        let yaml = r#"
390metadata:
391  version: "1.0.0"
392  description: "Test kernel"
393  references: ["Paper"]
394equations:
395  f:
396    formula: "f(x) = x"
397proof_obligations:
398  - type: invariant
399    property: "P1"
400    lean:
401      theorem: T1
402      status: proved
403  - type: bound
404    property: "P2"
405    lean:
406      theorem: T2
407      status: sorry
408  - type: monotonicity
409    property: "P3"
410    lean:
411      theorem: T3
412      status: wip
413  - type: equivalence
414    property: "P4 no lean"
415falsification_tests: []
416"#;
417        let contract = parse_contract_str(yaml).unwrap();
418        let report = lean_status(&contract);
419        assert_eq!(report.total_obligations, 4);
420        assert_eq!(report.with_lean, 3);
421        assert_eq!(report.proved, 1);
422        assert_eq!(report.sorry, 1);
423        assert_eq!(report.wip, 1);
424        assert_eq!(report.not_applicable, 0);
425    }
426
427    #[test]
428    fn format_status_report_renders_table() {
429        let reports = vec![LeanStatusReport {
430            contract_description: "Softmax kernel".to_string(),
431            total_obligations: 5,
432            with_lean: 3,
433            proved: 1,
434            sorry: 1,
435            wip: 1,
436            not_applicable: 0,
437            discharged: 1,
438            obligations: vec![],
439        }];
440        let table = format_status_report(&reports);
441        assert!(table.contains("Softmax kernel"));
442        assert!(table.contains("L4 Coverage: 33%"));
443        assert!(table.contains("Sorry Debt: 1"));
444    }
445
446    /// EV-8b: a YAML `status: proved` the summary does not grant is printed self-declared and earns no coverage.
447    #[test]
448    fn coverage_counts_discharged_not_self_declared() {
449        let reports = vec![LeanStatusReport {
450            contract_description: "Softmax kernel".to_string(),
451            total_obligations: 2,
452            with_lean: 2,
453            proved: 2,
454            sorry: 0,
455            wip: 0,
456            not_applicable: 0,
457            discharged: 0,
458            obligations: vec![],
459        }];
460        let table = format_status_report(&reports);
461        assert!(table.contains("self-declared") && table.contains("discharged"));
462        assert!(
463            table.contains("L4 Coverage: 0% (0/2 discharged; 2 self-declared)"),
464            "{table}"
465        );
466    }
467
468    #[test]
469    fn lean_status_with_counts_only_granted_theorems() {
470        let yaml = r#"
471metadata:
472  version: "1.0.0"
473  description: "Test"
474  references: []
475equations:
476  f:
477    formula: "f(x) = x"
478proof_obligations:
479  - type: invariant
480    property: "P1"
481    lean:
482      theorem: Test.t1
483      status: proved
484  - type: invariant
485    property: "P2"
486    lean:
487      theorem: Test.t2
488      status: proved
489falsification_tests: []
490"#;
491        let contract = parse_contract_str(yaml).unwrap();
492        let d = crate::discharge::summary::Discharged {
493            theorems: ["ProvableContracts.Test.t1".to_string()]
494                .into_iter()
495                .collect(),
496            withheld: None,
497            ..Default::default()
498        };
499        let r = lean_status_with(&contract, &d);
500        assert_eq!((r.proved, r.discharged), (2, 1));
501    }
502
503    #[test]
504    fn derive_module_name_capitalizes() {
505        assert_eq!(derive_module_name("softmax kernel"), "Softmax");
506        assert_eq!(derive_module_name("RMSNorm"), "RMSNorm");
507        assert_eq!(derive_module_name(""), "Unknown");
508    }
509
510    #[test]
511    fn proved_theorem_has_proved_comment() {
512        let yaml = r#"
513metadata:
514  version: "1.0.0"
515  description: "Test"
516  references: ["P"]
517equations:
518  f:
519    formula: "f(x) = x"
520proof_obligations:
521  - type: invariant
522    property: "Always true"
523    lean:
524      theorem: F.always_true
525      status: proved
526falsification_tests: []
527"#;
528        let contract = parse_contract_str(yaml).unwrap();
529        let files = generate_lean_files(&contract);
530        let thm = &files[1];
531        assert!(thm.content.contains("Status: proved"));
532    }
533}