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