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