provable_contracts/lean_gen/
mod.rs1use crate::schema::{Contract, LeanStatus, ProofObligation};
13
14#[derive(Debug, Clone)]
16pub struct LeanFile {
17 pub path: String,
19 pub content: String,
21}
22
23pub 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 files.push(generate_defs_file(contract, &module_name));
46
47 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
57pub 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#[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#[derive(Debug, Clone)]
108pub struct ObligationStatus {
109 pub property: String,
110 pub theorem: String,
111 pub status: LeanStatus,
112}
113
114pub 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 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
172fn derive_module_name(description: &str) -> String {
175 let base = description
176 .split_whitespace()
177 .next()
178 .unwrap_or("Unknown")
179 .to_string();
180 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 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 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 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 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 if let Some(ref formal) = ob.formal {
269 content.push_str(&format!("-- Formal: {formal}\n"));
270 }
271
272 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 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); 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 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}