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 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
171fn derive_module_name(description: &str) -> String {
174 let base = description
175 .split_whitespace()
176 .next()
177 .unwrap_or("Unknown")
178 .to_string();
179 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 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 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 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 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 if let Some(ref formal) = ob.formal {
268 content.push_str(&format!("-- Formal: {formal}\n"));
269 }
270
271 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 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); 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 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}