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 {
62 lean_status_with(contract, crate::proof_status::discharge_grounding())
63}
64
65pub fn lean_status_with(
67 contract: &Contract,
68 discharged: &crate::discharge::summary::Discharged,
69) -> LeanStatusReport {
70 let mut report = LeanStatusReport {
71 contract_description: contract.metadata.description.clone(),
72 #[allow(clippy::cast_possible_truncation)]
73 total_obligations: contract.proof_obligations.len() as u32,
74 with_lean: 0,
75 proved: 0,
76 sorry: 0,
77 wip: 0,
78 not_applicable: 0,
79 discharged: 0,
80 obligations: Vec::new(),
81 };
82
83 for ob in &contract.proof_obligations {
84 if let Some(ref lean) = ob.lean {
85 report.with_lean += 1;
86 match lean.status {
87 LeanStatus::Proved => report.proved += 1,
88 LeanStatus::Sorry => report.sorry += 1,
89 LeanStatus::Wip => report.wip += 1,
90 LeanStatus::NotApplicable => report.not_applicable += 1,
91 }
92 if lean.status != LeanStatus::NotApplicable && discharged.grants(&lean.theorem) {
93 report.discharged += 1;
94 }
95 report.obligations.push(ObligationStatus {
96 property: ob.property.clone(),
97 theorem: lean.theorem.clone(),
98 status: lean.status,
99 });
100 }
101 }
102
103 report
104}
105
106#[derive(Debug, Clone)]
108pub struct LeanStatusReport {
109 pub contract_description: String,
110 pub total_obligations: u32,
111 pub with_lean: u32,
112 pub proved: u32,
113 pub sorry: u32,
114 pub wip: u32,
115 pub not_applicable: u32,
116 pub discharged: u32,
118 pub obligations: Vec<ObligationStatus>,
119}
120
121#[derive(Debug, Clone)]
123pub struct ObligationStatus {
124 pub property: String,
125 pub theorem: String,
126 pub status: LeanStatus,
127}
128
129pub fn format_status_report(reports: &[LeanStatusReport]) -> String {
131 let mut out = String::new();
132
133 out.push_str(&format!(
134 "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
135 "Contract", "Oblgs", "self-declared", "discharged", "Sorry", "WIP", "N/A"
136 ));
137 out.push_str(&"─".repeat(76));
138 out.push('\n');
139
140 let mut total_ob = 0u32;
141 let mut total_proved = 0u32;
142 let mut total_discharged = 0u32;
143 let mut total_sorry = 0u32;
144 let mut total_wip = 0u32;
145 let mut total_na = 0u32;
146
147 for r in reports {
148 let name = if r.contract_description.len() > 30 {
149 let end = r
151 .contract_description
152 .char_indices()
153 .take_while(|(i, _)| *i < 30)
154 .last()
155 .map_or(0, |(i, c)| i + c.len_utf8());
156 &r.contract_description[..end]
157 } else {
158 &r.contract_description
159 };
160 out.push_str(&format!(
161 "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
162 name, r.with_lean, r.proved, r.discharged, r.sorry, r.wip, r.not_applicable
163 ));
164 total_ob += r.with_lean;
165 total_proved += r.proved;
166 total_discharged += r.discharged;
167 total_sorry += r.sorry;
168 total_wip += r.wip;
169 total_na += r.not_applicable;
170 }
171
172 out.push_str(&"─".repeat(76));
173 out.push('\n');
174 out.push_str(&format!(
175 "{:<30} {:>5} {:>13} {:>10} {:>5} {:>3} {:>3}\n",
176 "Total", total_ob, total_proved, total_discharged, total_sorry, total_wip, total_na
177 ));
178
179 if let Some(pct) = (total_discharged * 100).checked_div(total_ob) {
181 out.push_str(&format!(
182 "L4 Coverage: {pct}% ({total_discharged}/{total_ob} discharged; {total_proved} self-declared) Sorry Debt: {total_sorry}\n"
183 ));
184 }
185
186 out
187}
188
189fn derive_module_name(description: &str) -> String {
192 let base = description
193 .split_whitespace()
194 .next()
195 .unwrap_or("Unknown")
196 .to_string();
197 let mut chars = base.chars();
199 match chars.next() {
200 None => "Unknown".to_string(),
201 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
202 }
203}
204
205fn generate_defs_file(contract: &Contract, module_name: &str) -> LeanFile {
206 let mut content = String::new();
207
208 content.push_str(&format!("-- {}\n", contract.metadata.description));
209 content.push_str(&format!(
210 "-- Generated from contract v{}\n",
211 contract.metadata.version
212 ));
213 content.push_str("-- DO NOT EDIT — regenerate with `pv lean`\n\n");
214
215 let mut imports: Vec<&str> = Vec::new();
217 for ob in &contract.proof_obligations {
218 if let Some(ref lean) = ob.lean {
219 for imp in &lean.mathlib_imports {
220 if !imports.contains(&imp.as_str()) {
221 imports.push(imp);
222 }
223 }
224 }
225 }
226
227 content.push_str("import Mathlib.Data.Real.Basic\n");
228 content.push_str("import Mathlib.Data.Finset.Basic\n");
229 for imp in &imports {
230 content.push_str(&format!("import {imp}\n"));
231 }
232 content.push('\n');
233
234 content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
235
236 for (name, eq) in &contract.equations {
238 content.push_str(&format!("-- Equation: {name}\n"));
239 content.push_str(&format!("-- Formula: {}\n", eq.formula));
240 if let Some(ref domain) = eq.domain {
241 content.push_str(&format!("-- Domain: {domain}\n"));
242 }
243 content.push_str(&format!("noncomputable def {name} : sorry := sorry\n\n"));
244 }
245
246 content.push_str(&format!("end ProvableContracts.{module_name}\n"));
247
248 LeanFile {
249 path: format!("ProvableContracts/Defs/{module_name}.lean"),
250 content,
251 }
252}
253
254fn generate_theorem_file(
255 ob: &ProofObligation,
256 lean: &crate::schema::LeanProof,
257 module_name: &str,
258) -> LeanFile {
259 let mut content = String::new();
260
261 content.push_str(&format!("-- Theorem: {}\n", lean.theorem));
262 content.push_str(&format!("-- Property: {}\n", ob.property));
263 content.push_str(&format!("-- Obligation type: {}\n", ob.obligation_type));
264 content.push_str("-- Generated with `pv lean`\n\n");
265
266 content.push_str(&format!("import ProvableContracts.Defs.{module_name}\n"));
268 for imp in &lean.mathlib_imports {
269 content.push_str(&format!("import {imp}\n"));
270 }
271 content.push('\n');
272
273 if !lean.depends_on.is_empty() {
275 content.push_str("-- Dependencies:\n");
276 for dep in &lean.depends_on {
277 content.push_str(&format!("-- {dep}\n"));
278 }
279 content.push('\n');
280 }
281
282 content.push_str(&format!("namespace ProvableContracts.{module_name}\n\n"));
283
284 if let Some(ref formal) = ob.formal {
286 content.push_str(&format!("-- Formal: {formal}\n"));
287 }
288
289 let status_comment = match lean.status {
291 LeanStatus::Proved => "-- Status: proved",
292 LeanStatus::Sorry => "-- Status: sorry (proof pending)",
293 LeanStatus::Wip => "-- Status: work in progress",
294 LeanStatus::NotApplicable => "-- Status: not applicable",
295 };
296 content.push_str(&format!("{status_comment}\n"));
297 content.push_str(&format!("theorem {} : sorry := by\n", lean.theorem));
298 content.push_str(" sorry\n");
299
300 if let Some(ref notes) = lean.notes {
301 content.push_str(&format!("\n-- Note: {notes}\n"));
302 }
303
304 content.push_str(&format!("\nend ProvableContracts.{module_name}\n"));
305
306 let theorem_file = lean.theorem.split('.').next_back().unwrap_or(&lean.theorem);
308 LeanFile {
309 path: format!("ProvableContracts/Theorems/{module_name}/{theorem_file}.lean"),
310 content,
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::schema::parse_contract_str;
318
319 #[test]
320 fn no_lean_obligations_produces_empty() {
321 let yaml = r#"
322metadata:
323 version: "1.0.0"
324 description: "Softmax kernel"
325 references: ["Paper"]
326equations:
327 softmax:
328 formula: "f(x) = exp(x_i) / sum(exp(x_j))"
329proof_obligations:
330 - type: invariant
331 property: "Output sums to 1"
332falsification_tests: []
333"#;
334 let contract = parse_contract_str(yaml).unwrap();
335 let files = generate_lean_files(&contract);
336 assert!(files.is_empty());
337 }
338
339 #[test]
340 fn generates_defs_and_theorem_files() {
341 let yaml = r#"
342metadata:
343 version: "1.0.0"
344 description: "Softmax kernel"
345 references: ["Paper"]
346equations:
347 softmax:
348 formula: "f(x) = exp(x_i) / sum(exp(x_j))"
349 domain: "R^n"
350proof_obligations:
351 - type: invariant
352 property: "Output sums to 1"
353 formal: "|sum(f(x)) - 1| < eps"
354 lean:
355 theorem: Softmax.partition_of_unity
356 module: ProvableContracts.Softmax
357 status: sorry
358 depends_on:
359 - Real.exp_pos
360 mathlib_imports:
361 - Mathlib.Analysis.SpecialFunctions.ExpDeriv
362 notes: "Proof over reals"
363falsification_tests: []
364"#;
365 let contract = parse_contract_str(yaml).unwrap();
366 let files = generate_lean_files(&contract);
367 assert_eq!(files.len(), 2); let defs = &files[0];
371 assert!(defs.path.contains("Defs/Softmax"));
372 assert!(defs.content.contains("noncomputable def softmax"));
373 assert!(defs.content.contains("namespace ProvableContracts.Softmax"));
374 assert!(defs
375 .content
376 .contains("Mathlib.Analysis.SpecialFunctions.ExpDeriv"));
377
378 let thm = &files[1];
380 assert!(thm.path.contains("Theorems/Softmax/partition_of_unity"));
381 assert!(thm.content.contains("theorem Softmax.partition_of_unity"));
382 assert!(thm.content.contains("sorry"));
383 assert!(thm.content.contains("Real.exp_pos"));
384 assert!(thm.content.contains("Proof over reals"));
385 }
386
387 #[test]
388 fn lean_status_counts_correctly() {
389 let yaml = r#"
390metadata:
391 version: "1.0.0"
392 description: "Test kernel"
393 references: ["Paper"]
394equations:
395 f:
396 formula: "f(x) = x"
397proof_obligations:
398 - type: invariant
399 property: "P1"
400 lean:
401 theorem: T1
402 status: proved
403 - type: bound
404 property: "P2"
405 lean:
406 theorem: T2
407 status: sorry
408 - type: monotonicity
409 property: "P3"
410 lean:
411 theorem: T3
412 status: wip
413 - type: equivalence
414 property: "P4 no lean"
415falsification_tests: []
416"#;
417 let contract = parse_contract_str(yaml).unwrap();
418 let report = lean_status(&contract);
419 assert_eq!(report.total_obligations, 4);
420 assert_eq!(report.with_lean, 3);
421 assert_eq!(report.proved, 1);
422 assert_eq!(report.sorry, 1);
423 assert_eq!(report.wip, 1);
424 assert_eq!(report.not_applicable, 0);
425 }
426
427 #[test]
428 fn format_status_report_renders_table() {
429 let reports = vec![LeanStatusReport {
430 contract_description: "Softmax kernel".to_string(),
431 total_obligations: 5,
432 with_lean: 3,
433 proved: 1,
434 sorry: 1,
435 wip: 1,
436 not_applicable: 0,
437 discharged: 1,
438 obligations: vec![],
439 }];
440 let table = format_status_report(&reports);
441 assert!(table.contains("Softmax kernel"));
442 assert!(table.contains("L4 Coverage: 33%"));
443 assert!(table.contains("Sorry Debt: 1"));
444 }
445
446 #[test]
448 fn coverage_counts_discharged_not_self_declared() {
449 let reports = vec![LeanStatusReport {
450 contract_description: "Softmax kernel".to_string(),
451 total_obligations: 2,
452 with_lean: 2,
453 proved: 2,
454 sorry: 0,
455 wip: 0,
456 not_applicable: 0,
457 discharged: 0,
458 obligations: vec![],
459 }];
460 let table = format_status_report(&reports);
461 assert!(table.contains("self-declared") && table.contains("discharged"));
462 assert!(
463 table.contains("L4 Coverage: 0% (0/2 discharged; 2 self-declared)"),
464 "{table}"
465 );
466 }
467
468 #[test]
469 fn lean_status_with_counts_only_granted_theorems() {
470 let yaml = r#"
471metadata:
472 version: "1.0.0"
473 description: "Test"
474 references: []
475equations:
476 f:
477 formula: "f(x) = x"
478proof_obligations:
479 - type: invariant
480 property: "P1"
481 lean:
482 theorem: Test.t1
483 status: proved
484 - type: invariant
485 property: "P2"
486 lean:
487 theorem: Test.t2
488 status: proved
489falsification_tests: []
490"#;
491 let contract = parse_contract_str(yaml).unwrap();
492 let d = crate::discharge::summary::Discharged {
493 theorems: ["ProvableContracts.Test.t1".to_string()]
494 .into_iter()
495 .collect(),
496 withheld: None,
497 ..Default::default()
498 };
499 let r = lean_status_with(&contract, &d);
500 assert_eq!((r.proved, r.discharged), (2, 1));
501 }
502
503 #[test]
504 fn derive_module_name_capitalizes() {
505 assert_eq!(derive_module_name("softmax kernel"), "Softmax");
506 assert_eq!(derive_module_name("RMSNorm"), "RMSNorm");
507 assert_eq!(derive_module_name(""), "Unknown");
508 }
509
510 #[test]
511 fn proved_theorem_has_proved_comment() {
512 let yaml = r#"
513metadata:
514 version: "1.0.0"
515 description: "Test"
516 references: ["P"]
517equations:
518 f:
519 formula: "f(x) = x"
520proof_obligations:
521 - type: invariant
522 property: "Always true"
523 lean:
524 theorem: F.always_true
525 status: proved
526falsification_tests: []
527"#;
528 let contract = parse_contract_str(yaml).unwrap();
529 let files = generate_lean_files(&contract);
530 let thm = &files[1];
531 assert!(thm.content.contains("Status: proved"));
532 }
533}