Skip to main content

provable_contracts/
coverage.rs

1//! Cross-contract obligation coverage report.
2//!
3//! Analyzes a set of contracts (optionally with a binding registry)
4//! to compute how many proof obligations are backed by falsification
5//! tests, Kani harnesses, and implementation bindings.
6
7use std::collections::BTreeMap;
8
9use crate::binding::{BindingRegistry, ImplStatus};
10use crate::schema::Contract;
11
12/// Coverage report across multiple contracts.
13#[derive(Debug, Clone)]
14pub struct CoverageReport {
15    /// Per-contract coverage details.
16    pub contracts: Vec<ContractCoverage>,
17    /// Aggregate totals.
18    pub totals: CoverageTotals,
19}
20
21/// Coverage details for a single contract.
22#[derive(Debug, Clone)]
23pub struct ContractCoverage {
24    /// Contract stem (filename without `.yaml`).
25    pub stem: String,
26    /// Total equations.
27    pub equations: usize,
28    /// Total proof obligations.
29    pub obligations: usize,
30    /// Obligations covered by at least one falsification test.
31    pub falsification_covered: usize,
32    /// Obligations covered by at least one Kani harness.
33    pub kani_covered: usize,
34    /// Number of equations with an implemented binding.
35    pub binding_implemented: usize,
36    /// Number of equations with partial binding.
37    pub binding_partial: usize,
38    /// Number of equations with no binding.
39    pub binding_missing: usize,
40    /// Per-obligation-type breakdown.
41    pub by_type: BTreeMap<String, usize>,
42}
43
44/// Aggregate totals across all contracts.
45#[derive(Debug, Clone)]
46pub struct CoverageTotals {
47    /// Number of contracts analyzed.
48    pub contracts: usize,
49    /// Total equations across all contracts.
50    pub equations: usize,
51    /// Total proof obligations across all contracts.
52    pub obligations: usize,
53    /// Total falsification tests across all contracts.
54    pub falsification_tests: usize,
55    /// Total Kani harnesses across all contracts.
56    pub kani_harnesses: usize,
57    /// Equations with implemented bindings.
58    pub binding_implemented: usize,
59    /// Equations with partial bindings.
60    pub binding_partial: usize,
61    /// Equations with no binding entry.
62    pub binding_missing: usize,
63}
64
65/// Compute a coverage report for a set of contracts.
66///
67/// If a `binding` registry is provided, binding coverage is
68/// included. Otherwise binding fields are zero.
69pub fn coverage_report(
70    contracts: &[(String, &Contract)],
71    binding: Option<&BindingRegistry>,
72) -> CoverageReport {
73    let mut results = Vec::new();
74    let mut totals = CoverageTotals {
75        contracts: contracts.len(),
76        equations: 0,
77        obligations: 0,
78        falsification_tests: 0,
79        kani_harnesses: 0,
80        binding_implemented: 0,
81        binding_partial: 0,
82        binding_missing: 0,
83    };
84
85    for (stem, contract) in contracts {
86        let contract_file = format!("{stem}.yaml");
87
88        let equations = contract.equations.len();
89        let obligations = contract.proof_obligations.len();
90        let ft_count = contract.falsification_tests.len();
91        let kh_count = contract.kani_harnesses.len();
92
93        // Count obligation types
94        let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
95        for ob in &contract.proof_obligations {
96            *by_type.entry(ob.obligation_type.to_string()).or_default() += 1;
97        }
98
99        // Falsification coverage: count obligations that have at
100        // least one falsification test (heuristic: if any tests
101        // exist, all obligations get some coverage).
102        let falsification_covered = if ft_count > 0 { obligations } else { 0 };
103
104        // Kani coverage: each harness covers one obligation
105        let kani_covered = kh_count.min(obligations);
106
107        // Binding coverage
108        let (binding_implemented, binding_partial, binding_missing) = if let Some(reg) = binding {
109            count_binding_coverage(&contract_file, contract, reg)
110        } else {
111            (0, 0, equations)
112        };
113
114        totals.equations += equations;
115        totals.obligations += obligations;
116        totals.falsification_tests += ft_count;
117        totals.kani_harnesses += kh_count;
118        totals.binding_implemented += binding_implemented;
119        totals.binding_partial += binding_partial;
120        totals.binding_missing += binding_missing;
121
122        results.push(ContractCoverage {
123            stem: stem.clone(),
124            equations,
125            obligations,
126            falsification_covered,
127            kani_covered,
128            binding_implemented,
129            binding_partial,
130            binding_missing,
131            by_type,
132        });
133    }
134
135    CoverageReport {
136        contracts: results,
137        totals,
138    }
139}
140
141/// Count implemented, partial, and missing bindings for a single contract
142fn count_binding_coverage(
143    contract_file: &str,
144    contract: &Contract,
145    binding: &BindingRegistry,
146) -> (usize, usize, usize) {
147    let mut implemented = 0usize;
148    let mut partial = 0usize;
149    let mut missing = 0usize;
150
151    for eq_name in contract.equations.keys() {
152        let status = binding
153            .find_binding(contract_file, eq_name)
154            .map(|b| b.status);
155
156        match status {
157            Some(ImplStatus::Implemented) => implemented += 1,
158            Some(ImplStatus::Partial) => partial += 1,
159            Some(ImplStatus::NotImplemented) | None => missing += 1,
160            Some(ImplStatus::Pending) => {} // Pending: excluded from denominator
161        }
162    }
163
164    (implemented, partial, missing)
165}
166
167/// Compute overall obligation coverage percentage.
168///
169/// An obligation is "covered" if it has both a falsification test
170/// and a Kani harness (or at least one of them).
171pub fn overall_percentage(report: &CoverageReport) -> f64 {
172    if report.totals.obligations == 0 {
173        return 100.0;
174    }
175    let covered: usize = report
176        .contracts
177        .iter()
178        .map(|c| c.falsification_covered.max(c.kani_covered))
179        .sum();
180    #[allow(clippy::cast_precision_loss)]
181    let pct = (covered as f64 / report.totals.obligations as f64) * 100.0;
182    pct
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::schema::parse_contract_str;
189
190    fn contract_with_obs(n_obligations: usize, n_ft: usize, n_kani: usize) -> Contract {
191        let mut yaml = String::from(
192            r#"
193metadata:
194  version: "1.0.0"
195  description: "Test"
196  references: ["Paper"]
197equations:
198  f:
199    formula: "f(x) = x"
200proof_obligations:
201"#,
202        );
203        for i in 0..n_obligations {
204            yaml.push_str(&format!(
205                "  - type: invariant\n    property: \"prop {i}\"\n"
206            ));
207        }
208        yaml.push_str("falsification_tests:\n");
209        for i in 0..n_ft {
210            yaml.push_str(&format!(
211                "  - id: FT-{i:03}\n    rule: \"r\"\n    \
212                 prediction: \"p\"\n    if_fails: \"f\"\n"
213            ));
214        }
215        yaml.push_str("kani_harnesses:\n");
216        for i in 0..n_kani {
217            yaml.push_str(&format!(
218                "  - id: KH-{i:03}\n    obligation: OBL-{i:03}\n    \
219                 bound: 16\n"
220            ));
221        }
222        parse_contract_str(&yaml).unwrap()
223    }
224
225    #[test]
226    fn empty_contracts() {
227        let report = coverage_report(&[], None);
228        assert_eq!(report.totals.contracts, 0);
229        assert!((overall_percentage(&report) - 100.0).abs() < f64::EPSILON);
230    }
231
232    #[test]
233    fn single_contract_full_coverage() {
234        let c = contract_with_obs(3, 3, 3);
235        let report = coverage_report(&[("test".to_string(), &c)], None);
236        assert_eq!(report.totals.obligations, 3);
237        assert_eq!(report.totals.falsification_tests, 3);
238        assert_eq!(report.totals.kani_harnesses, 3);
239        assert_eq!(report.contracts[0].falsification_covered, 3);
240        assert_eq!(report.contracts[0].kani_covered, 3);
241        assert!((overall_percentage(&report) - 100.0).abs() < 0.01);
242    }
243
244    #[test]
245    fn no_tests_zero_coverage() {
246        let c = contract_with_obs(5, 0, 0);
247        let report = coverage_report(&[("test".to_string(), &c)], None);
248        assert_eq!(report.contracts[0].falsification_covered, 0);
249        assert_eq!(report.contracts[0].kani_covered, 0);
250        assert!((overall_percentage(&report) - 0.0).abs() < 0.01);
251    }
252
253    #[test]
254    fn multiple_contracts() {
255        let c1 = contract_with_obs(2, 2, 1);
256        let c2 = contract_with_obs(3, 0, 3);
257        let report = coverage_report(&[("a".to_string(), &c1), ("b".to_string(), &c2)], None);
258        assert_eq!(report.totals.contracts, 2);
259        assert_eq!(report.totals.obligations, 5);
260        assert_eq!(report.totals.equations, 2);
261    }
262
263    #[test]
264    fn binding_coverage_implemented() {
265        let c = contract_with_obs(2, 2, 2);
266        let binding = crate::binding::parse_binding_str(
267            r#"
268version: "1.0.0"
269target_crate: test
270bindings:
271  - contract: test.yaml
272    equation: f
273    module_path: "test::f"
274    function: f
275    status: implemented
276"#,
277        )
278        .unwrap();
279        let report = coverage_report(&[("test".to_string(), &c)], Some(&binding));
280        assert_eq!(report.contracts[0].binding_implemented, 1);
281        assert_eq!(report.contracts[0].binding_missing, 0);
282        assert_eq!(report.totals.binding_implemented, 1);
283    }
284
285    #[test]
286    fn binding_coverage_partial() {
287        let c = contract_with_obs(1, 1, 1);
288        let binding = crate::binding::parse_binding_str(
289            r#"
290version: "1.0.0"
291target_crate: test
292bindings:
293  - contract: test.yaml
294    equation: f
295    module_path: "test::f"
296    function: f
297    status: partial
298"#,
299        )
300        .unwrap();
301        let report = coverage_report(&[("test".to_string(), &c)], Some(&binding));
302        assert_eq!(report.contracts[0].binding_partial, 1);
303        assert_eq!(report.totals.binding_partial, 1);
304    }
305
306    #[test]
307    fn binding_missing_when_no_entry() {
308        let c = contract_with_obs(1, 1, 1);
309        let binding = crate::binding::parse_binding_str(
310            r#"
311version: "1.0.0"
312target_crate: test
313bindings: []
314"#,
315        )
316        .unwrap();
317        let report = coverage_report(&[("test".to_string(), &c)], Some(&binding));
318        assert_eq!(report.contracts[0].binding_missing, 1);
319    }
320
321    #[test]
322    fn obligation_type_breakdown() {
323        let yaml = r#"
324metadata:
325  version: "1.0.0"
326  description: "Test"
327  references: ["Paper"]
328equations:
329  f:
330    formula: "f(x) = x"
331proof_obligations:
332  - type: invariant
333    property: "p1"
334  - type: bound
335    property: "p2"
336  - type: invariant
337    property: "p3"
338falsification_tests: []
339"#;
340        let c = parse_contract_str(yaml).unwrap();
341        let report = coverage_report(&[("test".to_string(), &c)], None);
342        let by_type = &report.contracts[0].by_type;
343        assert_eq!(by_type.get("invariant"), Some(&2));
344        assert_eq!(by_type.get("bound"), Some(&1));
345    }
346
347    #[test]
348    fn kani_covered_capped_at_obligations() {
349        // More harnesses than obligations
350        let c = contract_with_obs(2, 0, 10);
351        let report = coverage_report(&[("test".to_string(), &c)], None);
352        assert_eq!(report.contracts[0].kani_covered, 2);
353    }
354
355    #[test]
356    fn no_binding_defaults_to_missing() {
357        let c = contract_with_obs(1, 1, 1);
358        let report = coverage_report(&[("test".to_string(), &c)], None);
359        assert_eq!(report.contracts[0].binding_missing, 1);
360    }
361}