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.
60pub fn lean_status(contract: &Contract) -> LeanStatusReport {
61    let mut report = LeanStatusReport {
62        contract_description: contract.metadata.description.clone(),
63        #[allow(clippy::cast_possible_truncation)]
64        total_obligations: contract.proof_obligations.len() as u32,
65        with_lean: 0,
66        proved: 0,
67        sorry: 0,
68        wip: 0,
69        not_applicable: 0,
70        obligations: Vec::new(),
71    };
72
73    for ob in &contract.proof_obligations {
74        if let Some(ref lean) = ob.lean {
75            report.with_lean += 1;
76            match lean.status {
77                LeanStatus::Proved => report.proved += 1,
78                LeanStatus::Sorry => report.sorry += 1,
79                LeanStatus::Wip => report.wip += 1,
80                LeanStatus::NotApplicable => report.not_applicable += 1,
81            }
82            report.obligations.push(ObligationStatus {
83                property: ob.property.clone(),
84                theorem: lean.theorem.clone(),
85                status: lean.status,
86            });
87        }
88    }
89
90    report
91}
92
93/// Status report for Lean proofs in a single contract.
94#[derive(Debug, Clone)]
95pub struct LeanStatusReport {
96    pub contract_description: String,
97    pub total_obligations: u32,
98    pub with_lean: u32,
99    pub proved: u32,
100    pub sorry: u32,
101    pub wip: u32,
102    pub not_applicable: u32,
103    pub obligations: Vec<ObligationStatus>,
104}
105
106/// Status of a single obligation's Lean proof.
107#[derive(Debug, Clone)]
108pub struct ObligationStatus {
109    pub property: String,
110    pub theorem: String,
111    pub status: LeanStatus,
112}
113
114/// Format a `LeanStatusReport` as a human-readable table.
115pub fn format_status_report(reports: &[LeanStatusReport]) -> String {
116    let mut out = String::new();
117
118    out.push_str(&format!(
119        "{:<30} {:>5} {:>6} {:>5} {:>3} {:>3}\n",
120        "Contract", "Oblgs", "Proved", "Sorry", "WIP", "N/A"
121    ));
122    out.push_str(&"─".repeat(60));
123    out.push('\n');
124
125    let mut total_ob = 0u32;
126    let mut total_proved = 0u32;
127    let mut total_sorry = 0u32;
128    let mut total_wip = 0u32;
129    let mut total_na = 0u32;
130
131    for r in reports {
132        let name = if r.contract_description.len() > 30 {
133            &r.contract_description[..30]
134        } else {
135            &r.contract_description
136        };
137        out.push_str(&format!(
138            "{:<30} {:>5} {:>6} {:>5} {:>3} {:>3}\n",
139            name, r.with_lean, r.proved, r.sorry, r.wip, r.not_applicable
140        ));
141        total_ob += r.with_lean;
142        total_proved += r.proved;
143        total_sorry += r.sorry;
144        total_wip += r.wip;
145        total_na += r.not_applicable;
146    }
147
148    out.push_str(&"─".repeat(60));
149    out.push('\n');
150    out.push_str(&format!(
151        "{:<30} {:>5} {:>6} {:>5} {:>3} {:>3}\n",
152        "Total", total_ob, total_proved, total_sorry, total_wip, total_na
153    ));
154
155    if total_ob > 0 {
156        let pct = total_proved * 100 / total_ob;
157        out.push_str(&format!(
158            "L4 Coverage: {pct}% ({total_proved}/{total_ob})   Sorry Debt: {total_sorry}\n"
159        ));
160    }
161
162    out
163}
164
165// ── Internal helpers ──────────────────────────────────────────────
166
167fn derive_module_name(description: &str) -> String {
168    let base = description
169        .split_whitespace()
170        .next()
171        .unwrap_or("Unknown")
172        .to_string();
173    // Capitalize first letter for Lean module convention
174    let mut chars = base.chars();
175    match chars.next() {
176        None => "Unknown".to_string(),
177        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
178    }
179}
180
181fn generate_defs_file(contract: &Contract, module_name: &str) -> LeanFile {
182    let mut content = String::new();
183
184    content.push_str(&format!("-- {}\n", contract.metadata.description));
185    content.push_str(&format!(
186        "-- Generated from contract v{}\n",
187        contract.metadata.version
188    ));
189    content.push_str("-- DO NOT EDIT — regenerate with `pv lean`\n\n");
190
191    // Collect all mathlib imports from obligations
192    let mut imports: Vec<&str> = Vec::new();
193    for ob in &contract.proof_obligations {
194        if let Some(ref lean) = ob.lean {
195            for imp in &lean.mathlib_imports {
196                if !imports.contains(&imp.as_str()) {
197                    imports.push(imp);
198                }
199            }
200        }
201    }
202
203    content.push_str("import Mathlib.Data.Real.Basic\n");
204    content.push_str("import Mathlib.Data.Finset.Basic\n");
205    for imp in &imports {
206        content.push_str(&format!("import {imp}\n"));
207    }
208    content.push('\n');
209
210    content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
211
212    // Generate noncomputable defs from equations
213    for (name, eq) in &contract.equations {
214        content.push_str(&format!("-- Equation: {name}\n"));
215        content.push_str(&format!("-- Formula: {}\n", eq.formula));
216        if let Some(ref domain) = eq.domain {
217            content.push_str(&format!("-- Domain: {domain}\n"));
218        }
219        content.push_str(&format!("noncomputable def {name} : sorry := sorry\n\n"));
220    }
221
222    content.push_str(&format!("end ProvableContracts.{module_name}\n"));
223
224    LeanFile {
225        path: format!("ProvableContracts/Defs/{module_name}.lean"),
226        content,
227    }
228}
229
230fn generate_theorem_file(
231    ob: &ProofObligation,
232    lean: &crate::schema::LeanProof,
233    module_name: &str,
234) -> LeanFile {
235    let mut content = String::new();
236
237    content.push_str(&format!("-- Theorem: {}\n", lean.theorem));
238    content.push_str(&format!("-- Property: {}\n", ob.property));
239    content.push_str(&format!("-- Obligation type: {}\n", ob.obligation_type));
240    content.push_str("-- Generated with `pv lean`\n\n");
241
242    // Imports
243    content.push_str(&format!("import ProvableContracts.Defs.{module_name}\n"));
244    for imp in &lean.mathlib_imports {
245        content.push_str(&format!("import {imp}\n"));
246    }
247    content.push('\n');
248
249    // Lean-level dependencies
250    if !lean.depends_on.is_empty() {
251        content.push_str("-- Dependencies:\n");
252        for dep in &lean.depends_on {
253            content.push_str(&format!("--   {dep}\n"));
254        }
255        content.push('\n');
256    }
257
258    content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
259
260    // Formal statement if present
261    if let Some(ref formal) = ob.formal {
262        content.push_str(&format!("-- Formal: {formal}\n"));
263    }
264
265    // Theorem stub
266    let status_comment = match lean.status {
267        LeanStatus::Proved => "-- Status: proved",
268        LeanStatus::Sorry => "-- Status: sorry (proof pending)",
269        LeanStatus::Wip => "-- Status: work in progress",
270        LeanStatus::NotApplicable => "-- Status: not applicable",
271    };
272    content.push_str(&format!("{status_comment}\n"));
273    content.push_str(&format!("theorem {} : sorry := by\n", lean.theorem));
274    content.push_str("  sorry\n");
275
276    if let Some(ref notes) = lean.notes {
277        content.push_str(&format!("\n-- Note: {notes}\n"));
278    }
279
280    content.push_str(&format!("\nend ProvableContracts.{module_name}\n"));
281
282    // Derive file path from theorem name
283    let theorem_file = lean.theorem.split('.').next_back().unwrap_or(&lean.theorem);
284    LeanFile {
285        path: format!("ProvableContracts/Theorems/{module_name}/{theorem_file}.lean"),
286        content,
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::schema::parse_contract_str;
294
295    #[test]
296    fn no_lean_obligations_produces_empty() {
297        let yaml = r#"
298metadata:
299  version: "1.0.0"
300  description: "Softmax kernel"
301  references: ["Paper"]
302equations:
303  softmax:
304    formula: "f(x) = exp(x_i) / sum(exp(x_j))"
305proof_obligations:
306  - type: invariant
307    property: "Output sums to 1"
308falsification_tests: []
309"#;
310        let contract = parse_contract_str(yaml).unwrap();
311        let files = generate_lean_files(&contract);
312        assert!(files.is_empty());
313    }
314
315    #[test]
316    fn generates_defs_and_theorem_files() {
317        let yaml = r#"
318metadata:
319  version: "1.0.0"
320  description: "Softmax kernel"
321  references: ["Paper"]
322equations:
323  softmax:
324    formula: "f(x) = exp(x_i) / sum(exp(x_j))"
325    domain: "R^n"
326proof_obligations:
327  - type: invariant
328    property: "Output sums to 1"
329    formal: "|sum(f(x)) - 1| < eps"
330    lean:
331      theorem: Softmax.partition_of_unity
332      module: ProvableContracts.Softmax
333      status: sorry
334      depends_on:
335        - Real.exp_pos
336      mathlib_imports:
337        - Mathlib.Analysis.SpecialFunctions.ExpDeriv
338      notes: "Proof over reals"
339falsification_tests: []
340"#;
341        let contract = parse_contract_str(yaml).unwrap();
342        let files = generate_lean_files(&contract);
343        assert_eq!(files.len(), 2); // defs + 1 theorem
344
345        // Check defs file
346        let defs = &files[0];
347        assert!(defs.path.contains("Defs/Softmax"));
348        assert!(defs.content.contains("noncomputable def softmax"));
349        assert!(defs.content.contains("namespace ProvableContracts.Softmax"));
350        assert!(
351            defs.content
352                .contains("Mathlib.Analysis.SpecialFunctions.ExpDeriv")
353        );
354
355        // Check theorem file
356        let thm = &files[1];
357        assert!(thm.path.contains("Theorems/Softmax/partition_of_unity"));
358        assert!(thm.content.contains("theorem Softmax.partition_of_unity"));
359        assert!(thm.content.contains("sorry"));
360        assert!(thm.content.contains("Real.exp_pos"));
361        assert!(thm.content.contains("Proof over reals"));
362    }
363
364    #[test]
365    fn lean_status_counts_correctly() {
366        let yaml = r#"
367metadata:
368  version: "1.0.0"
369  description: "Test kernel"
370  references: ["Paper"]
371equations:
372  f:
373    formula: "f(x) = x"
374proof_obligations:
375  - type: invariant
376    property: "P1"
377    lean:
378      theorem: T1
379      status: proved
380  - type: bound
381    property: "P2"
382    lean:
383      theorem: T2
384      status: sorry
385  - type: monotonicity
386    property: "P3"
387    lean:
388      theorem: T3
389      status: wip
390  - type: equivalence
391    property: "P4 no lean"
392falsification_tests: []
393"#;
394        let contract = parse_contract_str(yaml).unwrap();
395        let report = lean_status(&contract);
396        assert_eq!(report.total_obligations, 4);
397        assert_eq!(report.with_lean, 3);
398        assert_eq!(report.proved, 1);
399        assert_eq!(report.sorry, 1);
400        assert_eq!(report.wip, 1);
401        assert_eq!(report.not_applicable, 0);
402    }
403
404    #[test]
405    fn format_status_report_renders_table() {
406        let reports = vec![LeanStatusReport {
407            contract_description: "Softmax kernel".to_string(),
408            total_obligations: 5,
409            with_lean: 3,
410            proved: 1,
411            sorry: 1,
412            wip: 1,
413            not_applicable: 0,
414            obligations: vec![],
415        }];
416        let table = format_status_report(&reports);
417        assert!(table.contains("Softmax kernel"));
418        assert!(table.contains("L4 Coverage: 33%"));
419        assert!(table.contains("Sorry Debt: 1"));
420    }
421
422    #[test]
423    fn derive_module_name_capitalizes() {
424        assert_eq!(derive_module_name("softmax kernel"), "Softmax");
425        assert_eq!(derive_module_name("RMSNorm"), "RMSNorm");
426        assert_eq!(derive_module_name(""), "Unknown");
427    }
428
429    #[test]
430    fn proved_theorem_has_proved_comment() {
431        let yaml = r#"
432metadata:
433  version: "1.0.0"
434  description: "Test"
435  references: ["P"]
436equations:
437  f:
438    formula: "f(x) = x"
439proof_obligations:
440  - type: invariant
441    property: "Always true"
442    lean:
443      theorem: F.always_true
444      status: proved
445falsification_tests: []
446"#;
447        let contract = parse_contract_str(yaml).unwrap();
448        let files = generate_lean_files(&contract);
449        let thm = &files[1];
450        assert!(thm.content.contains("Status: proved"));
451    }
452}