Skip to main content

karpal_verify/
report.rs

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