Skip to main content

karpal_verify/
report.rs

1use crate::{
2    ArtifactBatch, Certificate, DryRunner, ExecutionResult, ExecutionStatus, Obligation,
3    ObligationBundle, VerifierRunner,
4};
5
6#[cfg(feature = "std")]
7use std::fmt::Write as _;
8
9#[cfg(not(feature = "std"))]
10use alloc::{string::String, vec::Vec};
11#[cfg(feature = "std")]
12use std::{string::String, vec::Vec};
13
14/// Schema version for serialized verification report JSON.
15pub const VERIFICATION_REPORT_SCHEMA_VERSION: &str = "1";
16
17/// Per-obligation verification report.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ObligationReport {
20    pub obligation_name: String,
21    pub summary: String,
22    pub artifact_path: Option<String>,
23    pub lean_theorem_ref: Option<String>,
24    pub lean_diagnostics: Vec<String>,
25    pub result: Option<ExecutionResult>,
26    pub certificate: Option<Certificate>,
27    pub kani_result: Option<ExecutionResult>,
28    pub kani_certificate: Option<Certificate>,
29    pub lean_certificate: Option<Certificate>,
30}
31
32impl ObligationReport {
33    pub fn status(&self) -> Option<ExecutionStatus> {
34        self.result.as_ref().map(|result| result.status)
35    }
36
37    pub fn succeeded(&self) -> bool {
38        self.result
39            .as_ref()
40            .map(|result| result.is_success())
41            .unwrap_or(false)
42    }
43}
44
45/// Report for the generated Lean module.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ModuleReport {
48    pub module_name: String,
49    pub artifact_path: Option<String>,
50    pub theorem_refs: Vec<String>,
51    pub prelude_imports: Vec<String>,
52    pub prelude_aliases: Vec<String>,
53    pub diagnostics: Vec<String>,
54    pub theorem_failures: Vec<String>,
55    pub result: Option<ExecutionResult>,
56    pub certificate: Option<Certificate>,
57}
58
59impl ModuleReport {
60    pub fn status(&self) -> Option<ExecutionStatus> {
61        self.result.as_ref().map(|result| result.status)
62    }
63}
64
65/// Aggregate verification report for a bundle.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct VerificationReport {
68    pub bundle_name: String,
69    pub root: String,
70    pub obligations: Vec<ObligationReport>,
71    pub lean_module: Option<ModuleReport>,
72}
73
74impl VerificationReport {
75    pub fn obligation_count(&self) -> usize {
76        self.obligations.len()
77    }
78
79    pub fn success_count(&self) -> usize {
80        self.obligations
81            .iter()
82            .filter(|report| report.succeeded())
83            .count()
84    }
85
86    pub fn failure_count(&self) -> usize {
87        self.obligation_count().saturating_sub(self.success_count())
88    }
89
90    pub fn is_success(&self) -> bool {
91        self.failure_count() == 0
92    }
93
94    #[cfg(feature = "std")]
95    pub fn to_json(&self) -> String {
96        fn esc(s: &str) -> String {
97            s.replace('\\', "\\\\")
98                .replace('"', "\\\"")
99                .replace('\n', "\\n")
100        }
101
102        fn render_certificate_json(certificate: &Certificate) -> String {
103            format!(
104                "{{\"backend\":\"{}\",\"witness_ref\":\"{}\"}}",
105                esc(certificate.backend),
106                esc(&certificate.witness_ref)
107            )
108        }
109
110        let mut out = String::new();
111        let _ = write!(
112            out,
113            "{{\"schema_version\":\"{}\",\"bundle_name\":\"{}\",\"root\":\"{}\",\"success_count\":{},\"failure_count\":{},\"obligations\":[",
114            VERIFICATION_REPORT_SCHEMA_VERSION,
115            esc(&self.bundle_name),
116            esc(&self.root),
117            self.success_count(),
118            self.failure_count()
119        );
120
121        for (idx, obligation) in self.obligations.iter().enumerate() {
122            if idx > 0 {
123                out.push(',');
124            }
125            let _ = write!(
126                out,
127                "{{\"name\":\"{}\",\"summary\":\"{}\",\"status\":\"{}\",\"artifact_path\":{},\"lean_theorem_ref\":{},\"lean_diagnostic_count\":{},\"certificate\":{},\"kani_certificate\":{},\"lean_certificate\":{}}}",
128                esc(&obligation.obligation_name),
129                esc(&obligation.summary),
130                obligation
131                    .status()
132                    .map(|s| format!("{s:?}"))
133                    .unwrap_or_else(|| "None".into()),
134                obligation
135                    .artifact_path
136                    .as_ref()
137                    .map(|p| format!("\"{}\"", esc(p)))
138                    .unwrap_or_else(|| "null".into()),
139                obligation
140                    .lean_theorem_ref
141                    .as_ref()
142                    .map(|r| format!("\"{}\"", esc(r)))
143                    .unwrap_or_else(|| "null".into()),
144                obligation.lean_diagnostics.len(),
145                obligation
146                    .certificate
147                    .as_ref()
148                    .map(render_certificate_json)
149                    .unwrap_or_else(|| "null".into()),
150                obligation
151                    .kani_certificate
152                    .as_ref()
153                    .map(render_certificate_json)
154                    .unwrap_or_else(|| "null".into()),
155                obligation
156                    .lean_certificate
157                    .as_ref()
158                    .map(render_certificate_json)
159                    .unwrap_or_else(|| "null".into())
160            );
161        }
162        out.push(']');
163        if let Some(module) = &self.lean_module {
164            let _ = write!(
165                out,
166                ",\"lean_module\":{{\"module_name\":\"{}\",\"status\":\"{}\",\"theorem_count\":{},\"import_count\":{},\"alias_count\":{},\"diagnostic_count\":{},\"theorem_failure_count\":{},\"certificate\":{}}}",
167                esc(&module.module_name),
168                module
169                    .status()
170                    .map(|s| format!("{s:?}"))
171                    .unwrap_or_else(|| "None".into()),
172                module.theorem_refs.len(),
173                module.prelude_imports.len(),
174                module.prelude_aliases.len(),
175                module.diagnostics.len(),
176                module.theorem_failures.len(),
177                module
178                    .certificate
179                    .as_ref()
180                    .map(render_certificate_json)
181                    .unwrap_or_else(|| "null".into())
182            );
183        }
184        out.push('}');
185        out
186    }
187
188    #[cfg(feature = "std")]
189    pub fn to_markdown(&self) -> String {
190        let mut out = String::new();
191        let _ = writeln!(out, "# Verification Report");
192        let _ = writeln!(out);
193        let _ = writeln!(
194            out,
195            "- Schema version: `{}`",
196            VERIFICATION_REPORT_SCHEMA_VERSION
197        );
198        let _ = writeln!(out, "- Bundle: `{}`", self.bundle_name);
199        let _ = writeln!(out, "- Root: `{}`", self.root);
200        let _ = writeln!(out, "- Successes: {}", self.success_count());
201        let _ = writeln!(out, "- Failures: {}", self.failure_count());
202        let _ = writeln!(out);
203        let _ = writeln!(
204            out,
205            "| Obligation | Status | Artifact | Lean theorem | Lean diagnostics | SMT certificate | Kani certificate | Lean certificate |"
206        );
207        let _ = writeln!(out, "|---|---|---|---|---|---|---|---|");
208        for obligation in &self.obligations {
209            let _ = writeln!(
210                out,
211                "| `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` | `{}` |",
212                obligation.obligation_name,
213                obligation
214                    .status()
215                    .map(|s| format!("{s:?}"))
216                    .unwrap_or_else(|| "None".into()),
217                obligation.artifact_path.as_deref().unwrap_or("-"),
218                obligation.lean_theorem_ref.as_deref().unwrap_or("-"),
219                if obligation.lean_diagnostics.is_empty() {
220                    "-".into()
221                } else {
222                    obligation.lean_diagnostics.join("; ")
223                },
224                obligation
225                    .certificate
226                    .as_ref()
227                    .map(|c| c.backend)
228                    .unwrap_or("-"),
229                obligation
230                    .kani_certificate
231                    .as_ref()
232                    .map(|c| c.backend)
233                    .unwrap_or("-"),
234                obligation
235                    .lean_certificate
236                    .as_ref()
237                    .map(|c| c.backend)
238                    .unwrap_or("-")
239            );
240        }
241        if let Some(module) = &self.lean_module {
242            let _ = writeln!(out);
243            let _ = writeln!(out, "Lean module: `{}`", module.module_name);
244            let _ = writeln!(out, "Lean theorems: {}", module.theorem_refs.len());
245            let _ = writeln!(out, "Lean imports: {}", module.prelude_imports.len());
246            let _ = writeln!(out, "Lean aliases: {}", module.prelude_aliases.len());
247            let _ = writeln!(out, "Lean diagnostics: {}", module.diagnostics.len());
248            let _ = writeln!(
249                out,
250                "Lean theorem failures: {}",
251                module.theorem_failures.len()
252            );
253            let _ = writeln!(
254                out,
255                "Lean certificate: `{}`",
256                module
257                    .certificate
258                    .as_ref()
259                    .map(|c| c.witness_ref.as_str())
260                    .unwrap_or("-")
261            );
262        }
263        out
264    }
265}
266
267/// Build a dry-run report without spawning external tools.
268pub fn dry_run_report(bundle: &ObligationBundle, artifacts: &ArtifactBatch) -> VerificationReport {
269    execute_report(bundle, artifacts, &DryRunner)
270}
271
272/// Execute all plans in an artifact batch and attach results back to obligations.
273pub fn execute_report(
274    bundle: &ObligationBundle,
275    artifacts: &ArtifactBatch,
276    runner: &impl VerifierRunner,
277) -> VerificationReport {
278    let lean_record = artifacts
279        .records
280        .iter()
281        .find(|record| record.path.ends_with(".lean"));
282    let lean_result = lean_record.and_then(|record| {
283        artifacts
284            .plans
285            .iter()
286            .find(|plan| {
287                plan.kind == crate::CommandKind::Lean
288                    && plan.input_files.iter().any(|f| f == &record.path)
289            })
290            .map(|plan| runner.run(plan))
291    });
292
293    let mut obligations = Vec::new();
294
295    for obligation in bundle.obligations() {
296        let artifact_path = artifacts
297            .records
298            .iter()
299            .find(|record| record.name == obligation.name)
300            .map(|record| record.path.clone());
301        let result = artifacts
302            .plans
303            .iter()
304            .find(|plan| {
305                plan.kind == crate::CommandKind::Smt
306                    && plan
307                        .input_files
308                        .iter()
309                        .any(|f| artifact_path.as_ref() == Some(f))
310            })
311            .map(|plan| runner.run(plan));
312        let certificate = result.as_ref().and_then(|result| {
313            certificate_for_obligation(result, obligation, artifact_path.clone())
314        });
315        let kani_artifact_path = artifacts
316            .records
317            .iter()
318            .find(|record| record.name == format!("{}_kani", obligation.name))
319            .map(|record| record.path.clone());
320        let kani_result = artifacts
321            .plans
322            .iter()
323            .find(|plan| {
324                plan.kind == crate::CommandKind::Kani
325                    && plan
326                        .input_files
327                        .iter()
328                        .any(|f| kani_artifact_path.as_ref() == Some(f))
329            })
330            .map(|plan| runner.run(plan));
331        let kani_certificate = kani_result.as_ref().and_then(|result| {
332            certificate_for_obligation(result, obligation, kani_artifact_path.clone())
333        });
334        let lean_theorem = artifacts
335            .lean_export
336            .as_ref()
337            .and_then(|export| export.theorem_for_obligation(&obligation.name).cloned());
338        let lean_theorem_ref = artifacts.lean_export.as_ref().and_then(|export| {
339            lean_theorem
340                .as_ref()
341                .map(|theorem| theorem.witness_ref(&export.module_name))
342        });
343        let lean_diagnostics = lean_result
344            .as_ref()
345            .and_then(|result| result.lean_output.as_ref())
346            .and_then(|output| {
347                lean_theorem.as_ref().map(|theorem| {
348                    output
349                        .theorem_diagnostics(theorem)
350                        .into_iter()
351                        .map(|diagnostic| diagnostic.message.clone())
352                        .collect::<Vec<_>>()
353                })
354            })
355            .unwrap_or_default();
356        let lean_certificate = lean_result.as_ref().and_then(|result| {
357            lean_theorem_ref.as_ref().and_then(|witness_ref| {
358                certificate_for_witness::<crate::LeanCertificate>(
359                    result,
360                    obligation,
361                    witness_ref.clone(),
362                    lean_record.map(|record| record.path.clone()),
363                )
364            })
365        });
366
367        obligations.push(ObligationReport {
368            obligation_name: obligation.name.clone(),
369            summary: obligation.summary(),
370            artifact_path,
371            lean_theorem_ref,
372            lean_diagnostics,
373            result,
374            certificate,
375            kani_result,
376            kani_certificate,
377            lean_certificate,
378        });
379    }
380
381    let lean_module = lean_record.map(|record| {
382        let theorem_refs = artifacts
383            .lean_export
384            .as_ref()
385            .map(|export| {
386                export
387                    .theorems
388                    .iter()
389                    .map(|theorem| theorem.witness_ref(&export.module_name))
390                    .collect::<Vec<_>>()
391            })
392            .unwrap_or_default();
393        let prelude_imports = artifacts
394            .lean_export
395            .as_ref()
396            .map(|export| {
397                export
398                    .prelude
399                    .imports
400                    .iter()
401                    .map(|import| import.module.clone())
402                    .collect::<Vec<_>>()
403            })
404            .unwrap_or_default();
405        let prelude_aliases = artifacts
406            .lean_export
407            .as_ref()
408            .map(|export| {
409                export
410                    .prelude
411                    .aliases
412                    .iter()
413                    .map(|alias| format!("{} := {}", alias.alias, alias.target))
414                    .collect::<Vec<_>>()
415            })
416            .unwrap_or_default();
417        let diagnostics = lean_result
418            .as_ref()
419            .and_then(|result| result.lean_output.as_ref())
420            .map(|output| {
421                output
422                    .diagnostics
423                    .iter()
424                    .map(|diagnostic| diagnostic.message.clone())
425                    .collect::<Vec<_>>()
426            })
427            .unwrap_or_default();
428        let theorem_failures = match (
429            artifacts.lean_export.as_ref(),
430            lean_result
431                .as_ref()
432                .and_then(|result| result.lean_output.as_ref()),
433        ) {
434            (Some(export), Some(output)) => export
435                .theorems
436                .iter()
437                .filter(|theorem| output.has_theorem_failure(theorem))
438                .map(|theorem| theorem.witness_ref(&export.module_name))
439                .collect::<Vec<_>>(),
440            _ => Vec::new(),
441        };
442        let certificate = lean_result.as_ref().and_then(|result| {
443            certificate_for_module(
444                result,
445                &record.name,
446                Some(record.path.clone()),
447                theorem_refs.len(),
448            )
449        });
450
451        ModuleReport {
452            module_name: record.name.clone(),
453            artifact_path: Some(record.path.clone()),
454            theorem_refs,
455            prelude_imports,
456            prelude_aliases,
457            diagnostics,
458            theorem_failures,
459            result: lean_result.clone(),
460            certificate,
461        }
462    });
463
464    VerificationReport {
465        bundle_name: bundle.name.clone(),
466        root: artifacts.root.clone(),
467        obligations,
468        lean_module,
469    }
470}
471
472fn certificate_for_obligation(
473    result: &ExecutionResult,
474    obligation: &Obligation,
475    artifact_path: Option<String>,
476) -> Option<Certificate> {
477    let policy = result.verification_policy();
478    let witness_ref = format!("{}:{}", result.plan.executable, policy.witness_suffix);
479    match result.plan.kind {
480        crate::CommandKind::Smt => certificate_for_witness::<crate::SmtCertificate>(
481            result,
482            obligation,
483            witness_ref,
484            artifact_path,
485        ),
486        crate::CommandKind::Lean => certificate_for_witness::<crate::LeanCertificate>(
487            result,
488            obligation,
489            witness_ref,
490            artifact_path,
491        ),
492        crate::CommandKind::Kani => certificate_for_witness::<crate::KaniCertificate>(
493            result,
494            obligation,
495            witness_ref,
496            artifact_path,
497        ),
498    }
499}
500
501fn certificate_for_witness<B: crate::VerificationBackend>(
502    result: &ExecutionResult,
503    obligation: &Obligation,
504    witness_ref: String,
505    artifact_path: Option<String>,
506) -> Option<Certificate> {
507    if !result.is_success() {
508        return None;
509    }
510
511    let mut cert = Certificate::from_obligation::<B>(obligation, witness_ref);
512
513    if let Some(version) = &result.backend_version {
514        cert = cert.with_backend_version(version.clone());
515    }
516
517    if let Some(path) = artifact_path {
518        cert = cert.with_artifact_path(path);
519    }
520
521    Some(cert)
522}
523
524fn certificate_for_module(
525    result: &ExecutionResult,
526    module_name: &str,
527    artifact_path: Option<String>,
528    theorem_count: usize,
529) -> Option<Certificate> {
530    if !result.is_success() {
531        return None;
532    }
533
534    let mut cert = Certificate::new(
535        <crate::LeanCertificate as crate::VerificationBackend>::NAME,
536        format!("Lean module {module_name}"),
537        crate::LeanCertificate::module_ref(module_name),
538    )
539    .with_notes(format!(
540        "verified module containing {theorem_count} theorem(s)"
541    ));
542
543    if let Some(version) = &result.backend_version {
544        cert = cert.with_backend_version(version.clone());
545    }
546
547    if let Some(path) = artifact_path {
548        cert = cert.with_artifact_path(path);
549    }
550
551    Some(cert)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::{
558        AlgebraicSignature, ArtifactLayout, LeanConfig, Origin, SmtConfig, Sort,
559        dry_run_bundle_artifacts,
560    };
561
562    #[test]
563    fn dry_run_report_attaches_results_to_all_obligations() {
564        let sig = AlgebraicSignature::monoid(Sort::Int, "combine", "e");
565        let bundle = ObligationBundle::monoid(
566            "sum_monoid",
567            Origin::new("karpal-core", "Monoid for Sum<i32>"),
568            &sig,
569        );
570        let layout = ArtifactLayout::new("target/karpal-verify-report-test");
571        let artifacts = dry_run_bundle_artifacts(
572            &bundle,
573            &layout,
574            "KarpalVerify",
575            &SmtConfig::default(),
576            &LeanConfig::default(),
577        );
578
579        let report = dry_run_report(&bundle, &artifacts);
580        assert_eq!(report.obligation_count(), 3);
581        assert!(
582            report
583                .obligations
584                .iter()
585                .all(|entry| entry.status() == Some(ExecutionStatus::DryRun))
586        );
587        assert_eq!(
588            report.lean_module.as_ref().unwrap().status(),
589            Some(ExecutionStatus::DryRun)
590        );
591        assert_eq!(report.lean_module.as_ref().unwrap().theorem_refs.len(), 3);
592    }
593
594    #[test]
595    fn successful_execution_yields_certificates() {
596        let sig = AlgebraicSignature::semigroup(Sort::Int, "combine");
597        let bundle = ObligationBundle::semigroup(
598            "sum_semigroup",
599            Origin::new("karpal-core", "Semigroup for Sum<i32>"),
600            &sig,
601        );
602        let layout = ArtifactLayout::new("target/karpal-verify-report-test-2");
603        let artifacts = dry_run_bundle_artifacts(
604            &bundle,
605            &layout,
606            "KarpalVerify",
607            &SmtConfig::default(),
608            &LeanConfig::default(),
609        );
610
611        struct SuccessRunner;
612        impl VerifierRunner for SuccessRunner {
613            fn run(&self, plan: &crate::InvocationPlan) -> ExecutionResult {
614                ExecutionResult {
615                    plan: plan.clone(),
616                    status: match plan.kind {
617                        crate::CommandKind::Smt => ExecutionStatus::Unsat,
618                        crate::CommandKind::Lean | crate::CommandKind::Kani => {
619                            ExecutionStatus::Success
620                        }
621                    },
622                    stdout: String::new(),
623                    stderr: String::new(),
624                    exit_code: Some(0),
625                    backend_version: Some("tool 1.0".into()),
626                    smt_output: Some(crate::SmtOutput {
627                        status: Some(ExecutionStatus::Unsat),
628                        model: None,
629                        reason_unknown: None,
630                    }),
631                    lean_output: (plan.kind == crate::CommandKind::Lean).then(|| {
632                        crate::LeanOutput {
633                            diagnostics: vec![crate::LeanDiagnostic {
634                                file: Some("lean/KarpalVerify.lean".into()),
635                                line: Some(7),
636                                column: Some(2),
637                                severity: "error".into(),
638                                message: "unsolved goals in theorem associativity".into(),
639                                theorem_hits: vec!["associativity".into()],
640                            }],
641                            theorem_hits: vec!["associativity".into()],
642                        }
643                    }),
644                }
645            }
646        }
647
648        let report = execute_report(&bundle, &artifacts, &SuccessRunner);
649        assert_eq!(report.success_count(), 1);
650        assert!(report.obligations[0].certificate.is_some());
651        assert_eq!(
652            report.obligations[0]
653                .certificate
654                .as_ref()
655                .and_then(|c| c.backend_version.as_deref()),
656            Some("tool 1.0")
657        );
658        assert_eq!(
659            report
660                .lean_module
661                .as_ref()
662                .map(|module| module.theorem_refs.len()),
663            Some(1)
664        );
665        assert_eq!(
666            report.obligations[0].lean_theorem_ref.as_deref(),
667            Some("KarpalVerify.associativity")
668        );
669        assert_eq!(
670            report.obligations[0].lean_diagnostics,
671            vec!["unsolved goals in theorem associativity".to_string()]
672        );
673        assert_eq!(
674            report.obligations[0]
675                .lean_certificate
676                .as_ref()
677                .map(|certificate| certificate.backend),
678            Some("lean4")
679        );
680        assert_eq!(
681            report
682                .lean_module
683                .as_ref()
684                .and_then(|module| module.certificate.as_ref())
685                .map(|certificate| certificate.witness_ref.as_str()),
686            Some("KarpalVerify")
687        );
688        assert_eq!(
689            report
690                .lean_module
691                .as_ref()
692                .map(|module| module.theorem_failures.clone()),
693            Some(vec!["KarpalVerify.associativity".to_string()])
694        );
695    }
696
697    #[test]
698    fn report_serialization_includes_summary_data() {
699        let report = VerificationReport {
700            bundle_name: "demo".into(),
701            root: "target/demo".into(),
702            obligations: vec![ObligationReport {
703                obligation_name: "assoc".into(),
704                summary: "demo::assoc [associativity]".into(),
705                artifact_path: Some("target/demo/smt/assoc.smt2".into()),
706                lean_theorem_ref: None,
707                lean_diagnostics: Vec::new(),
708                result: None,
709                certificate: None,
710                kani_result: None,
711                kani_certificate: None,
712                lean_certificate: None,
713            }],
714            lean_module: None,
715        };
716
717        let json = report.to_json();
718        let markdown = report.to_markdown();
719        assert!(json.contains("\"schema_version\":\"1\""));
720        assert!(json.contains("\"bundle_name\":\"demo\""));
721        assert!(markdown.contains("# Verification Report"));
722        assert!(markdown.contains("Schema version: `1`"));
723        assert!(markdown.contains("`assoc`"));
724    }
725
726    #[test]
727    fn theorem_mapping_uses_exported_theorem_identity_not_obligation_name() {
728        let sig = AlgebraicSignature::semiring(Sort::Int, "add", "mul", "zero", "one");
729        let bundle = ObligationBundle::semiring(
730            "sum_semiring",
731            Origin::new("karpal-core", "Semiring for Sum<i32>"),
732            &sig,
733        );
734        let layout = ArtifactLayout::new("target/karpal-verify-report-test-3");
735        let artifacts = dry_run_bundle_artifacts(
736            &bundle,
737            &layout,
738            "KarpalVerify",
739            &SmtConfig::default(),
740            &LeanConfig::default(),
741        );
742
743        struct TheoremNameRunner;
744        impl VerifierRunner for TheoremNameRunner {
745            fn run(&self, plan: &crate::InvocationPlan) -> ExecutionResult {
746                ExecutionResult {
747                    plan: plan.clone(),
748                    status: match plan.kind {
749                        crate::CommandKind::Smt => ExecutionStatus::Unsat,
750                        crate::CommandKind::Lean => ExecutionStatus::Failure,
751                        crate::CommandKind::Kani => ExecutionStatus::Success,
752                    },
753                    stdout: String::new(),
754                    stderr: String::new(),
755                    exit_code: Some(1),
756                    backend_version: Some("tool 1.0".into()),
757                    smt_output: Some(crate::SmtOutput {
758                        status: Some(ExecutionStatus::Unsat),
759                        model: None,
760                        reason_unknown: None,
761                    }),
762                    lean_output: (plan.kind == crate::CommandKind::Lean).then(|| {
763                        crate::LeanOutput {
764                            diagnostics: vec![crate::LeanDiagnostic {
765                                file: Some("lean/KarpalVerify.lean".into()),
766                                line: Some(9),
767                                column: Some(2),
768                                severity: "error".into(),
769                                message: "unsolved goals in theorem left_distributivity".into(),
770                                theorem_hits: vec!["left_distributivity".into()],
771                            }],
772                            theorem_hits: vec!["left_distributivity".into()],
773                        }
774                    }),
775                }
776            }
777        }
778
779        let report = execute_report(&bundle, &artifacts, &TheoremNameRunner);
780        let left_distributivity = report
781            .obligations
782            .iter()
783            .find(|entry| entry.obligation_name == "left_distributivity")
784            .expect("left distributivity obligation should be present");
785        assert_eq!(
786            left_distributivity.lean_diagnostics,
787            vec!["unsolved goals in theorem left_distributivity".to_string()]
788        );
789        assert_eq!(
790            report
791                .lean_module
792                .as_ref()
793                .map(|module| module.theorem_failures.clone()),
794            Some(vec!["KarpalVerify.left_distributivity".to_string()])
795        );
796    }
797
798    #[test]
799    fn theorem_mapping_can_fall_back_to_exported_line_spans() {
800        let sig = AlgebraicSignature::semiring(Sort::Int, "add", "mul", "zero", "one");
801        let bundle = ObligationBundle::semiring(
802            "sum_semiring",
803            Origin::new("karpal-core", "Semiring for Sum<i32>"),
804            &sig,
805        );
806        let layout = ArtifactLayout::new("target/karpal-verify-report-test-4");
807        let artifacts = dry_run_bundle_artifacts(
808            &bundle,
809            &layout,
810            "KarpalVerify",
811            &SmtConfig::default(),
812            &LeanConfig::default(),
813        );
814
815        let failure_line = artifacts
816            .lean_export
817            .as_ref()
818            .and_then(|export| export.theorem_for_obligation("left_distributivity"))
819            .map(|theorem| theorem.declaration_start_line)
820            .expect("left distributivity theorem span should be present");
821
822        struct LocationRunner {
823            failure_line: usize,
824        }
825        impl VerifierRunner for LocationRunner {
826            fn run(&self, plan: &crate::InvocationPlan) -> ExecutionResult {
827                ExecutionResult {
828                    plan: plan.clone(),
829                    status: match plan.kind {
830                        crate::CommandKind::Smt => ExecutionStatus::Unsat,
831                        crate::CommandKind::Lean => ExecutionStatus::Failure,
832                        crate::CommandKind::Kani => ExecutionStatus::Success,
833                    },
834                    stdout: String::new(),
835                    stderr: String::new(),
836                    exit_code: Some(1),
837                    backend_version: Some("tool 1.0".into()),
838                    smt_output: Some(crate::SmtOutput {
839                        status: Some(ExecutionStatus::Unsat),
840                        model: None,
841                        reason_unknown: None,
842                    }),
843                    lean_output: (plan.kind == crate::CommandKind::Lean).then(|| {
844                        crate::LeanOutput {
845                            diagnostics: vec![crate::LeanDiagnostic {
846                                file: Some("lean/KarpalVerify.lean".into()),
847                                line: Some(self.failure_line),
848                                column: Some(2),
849                                severity: "error".into(),
850                                message: "type mismatch".into(),
851                                theorem_hits: Vec::new(),
852                            }],
853                            theorem_hits: Vec::new(),
854                        }
855                    }),
856                }
857            }
858        }
859
860        let report = execute_report(&bundle, &artifacts, &LocationRunner { failure_line });
861        let left_distributivity = report
862            .obligations
863            .iter()
864            .find(|entry| entry.obligation_name == "left_distributivity")
865            .expect("left distributivity obligation should be present");
866        assert_eq!(
867            left_distributivity.lean_diagnostics,
868            vec!["type mismatch".to_string()]
869        );
870        assert_eq!(
871            report
872                .lean_module
873                .as_ref()
874                .map(|module| module.theorem_failures.clone()),
875            Some(vec!["KarpalVerify.left_distributivity".to_string()])
876        );
877    }
878}