Skip to main content

provable_contracts/scoring/
mod.rs

1//! Contract and codebase scoring.
2//!
3//! Provides quantitative quality assessment for individual contracts
4//! and codebases that consume them. Five dimensions per contract,
5//! ten dimensions per codebase (`PVScore`), grades A-F.
6//!
7//! Spec: `docs/specifications/sub/scoring.md`, `docs/specifications/sub/pvscore.md`
8
9mod codebase;
10pub mod drift;
11mod pvscore;
12mod types;
13
14pub use codebase::{score_codebase, score_codebase_full, score_codebase_with_pagerank};
15pub use pvscore::pvscore_10dim;
16pub use types::{CodebaseScore, ContractScore, Grade, ScoreProbe, ScoringGap, ScoringWeights};
17
18use crate::binding::{BindingRegistry, ImplStatus};
19use crate::schema::{Contract, KaniHarness, KaniStrategy, LeanStatus};
20
21/// Score a single contract against its completeness and verification depth.
22///
23/// Five dimensions (weights from spec):
24/// - D1: Specification depth (20%)
25/// - D2: Falsification coverage (25%)
26/// - D3: Kani proof coverage (25%)
27/// - D4: Lean proof coverage (10%)
28/// - D5: Binding coverage (20%)
29pub fn score_contract(
30    contract: &Contract,
31    binding: Option<&BindingRegistry>,
32    stem: &str,
33) -> ContractScore {
34    score_contract_weighted(contract, binding, stem, &ScoringWeights::default())
35}
36
37/// Score a contract with custom weights for each dimension.
38pub fn score_contract_weighted(
39    contract: &Contract,
40    binding: Option<&BindingRegistry>,
41    stem: &str,
42    weights: &ScoringWeights,
43) -> ContractScore {
44    let w = weights.normalized();
45    let mut probes = Vec::new();
46
47    let spec_depth = compute_spec_depth(contract, &mut probes);
48    let falsification = compute_falsification_coverage(contract, &mut probes);
49    let kani = compute_kani_coverage(contract, &mut probes);
50    let lean = compute_lean_coverage(contract, &mut probes);
51    let binding_cov = compute_binding_coverage(contract, binding, stem, &mut probes);
52
53    // Non-kernel contracts (registries, model-family schemas, patterns,
54    // reference documents) are data-only: they define lookup tables or
55    // metadata, not executable functions. Give them full credit for
56    // binding/lean so the composite reflects their declarative nature.
57    let (effective_binding, effective_lean) = if contract.requires_proofs() {
58        (binding_cov, lean)
59    } else {
60        (1.0, lean.max(0.5))
61    };
62
63    let composite = spec_depth * w.spec_depth
64        + falsification * w.falsification
65        + kani * w.kani
66        + effective_lean * w.lean
67        + effective_binding * w.binding;
68
69    ContractScore {
70        stem: stem.to_string(),
71        spec_depth,
72        falsification_coverage: falsification,
73        kani_coverage: kani,
74        lean_coverage: effective_lean,
75        binding_coverage: effective_binding,
76        composite,
77        grade: Grade::from_score(composite),
78        probes,
79    }
80}
81
82#[allow(clippy::too_many_lines)]
83fn compute_spec_depth(contract: &Contract, probes: &mut Vec<ScoreProbe>) -> f64 {
84    let mut score = 0.0;
85
86    // Has equations (0.30)
87    let has_equations = !contract.equations.is_empty();
88    probes.push(ScoreProbe {
89        dimension: "spec_depth".into(),
90        probe: "has_equations".into(),
91        outcome: has_equations,
92        detail: if has_equations {
93            format!("{} equation(s)", contract.equations.len())
94        } else {
95            "(no equations)".into()
96        },
97    });
98    if has_equations {
99        score += 0.30;
100    }
101
102    // Per-equation probes: domain and invariants
103    for (name, eq) in &contract.equations {
104        let has_domain = eq.domain.is_some();
105        probes.push(ScoreProbe {
106            dimension: "spec_depth".into(),
107            probe: format!("{name}: domain"),
108            outcome: has_domain,
109            detail: if has_domain {
110                eq.domain.as_deref().unwrap_or("").to_string()
111            } else {
112                "(no domain)".into()
113            },
114        });
115        let has_inv = !eq.invariants.is_empty();
116        probes.push(ScoreProbe {
117            dimension: "spec_depth".into(),
118            probe: format!("{name}: invariants"),
119            outcome: has_inv,
120            detail: if has_inv {
121                format!("{} invariant(s)", eq.invariants.len())
122            } else {
123                "(no invariants)".into()
124            },
125        });
126    }
127
128    // Has domains on equations (0.15)
129    let has_domains = contract.equations.values().any(|eq| eq.domain.is_some());
130    if has_domains {
131        score += 0.15;
132    }
133
134    // Has invariants on equations (0.15)
135    let has_invariants = contract
136        .equations
137        .values()
138        .any(|eq| !eq.invariants.is_empty());
139    if has_invariants {
140        score += 0.15;
141    }
142
143    // Has kernel structure (0.15)
144    let has_ks = contract.kernel_structure.is_some();
145    probes.push(ScoreProbe {
146        dimension: "spec_depth".into(),
147        probe: "kernel_structure".into(),
148        outcome: has_ks,
149        detail: if has_ks {
150            "present".into()
151        } else {
152            "(no kernel_structure)".into()
153        },
154    });
155    if has_ks {
156        score += 0.15;
157    }
158
159    // Has tolerances on obligations (0.10)
160    let has_tolerances = contract
161        .proof_obligations
162        .iter()
163        .any(|ob| ob.tolerance.is_some());
164    probes.push(ScoreProbe {
165        dimension: "spec_depth".into(),
166        probe: "has_tolerances".into(),
167        outcome: has_tolerances,
168        detail: if has_tolerances {
169            "at least one obligation has tolerance".into()
170        } else {
171            "(no tolerances)".into()
172        },
173    });
174    if has_tolerances {
175        score += 0.10;
176    }
177
178    // Has references (0.10)
179    let has_refs = !contract.metadata.references.is_empty();
180    probes.push(ScoreProbe {
181        dimension: "spec_depth".into(),
182        probe: "references".into(),
183        outcome: has_refs,
184        detail: if has_refs {
185            format!("{} reference(s)", contract.metadata.references.len())
186        } else {
187            "(no references)".into()
188        },
189    });
190    if has_refs {
191        score += 0.10;
192    }
193
194    // Has depends_on (0.05)
195    let has_deps = !contract.metadata.depends_on.is_empty();
196    probes.push(ScoreProbe {
197        dimension: "spec_depth".into(),
198        probe: "depends_on".into(),
199        outcome: has_deps,
200        detail: if has_deps {
201            format!("{} dep(s)", contract.metadata.depends_on.len())
202        } else {
203            "(no depends_on)".into()
204        },
205    });
206    if has_deps {
207        score += 0.05;
208    }
209
210    score
211}
212
213#[allow(clippy::cast_precision_loss)]
214fn compute_falsification_coverage(contract: &Contract, probes: &mut Vec<ScoreProbe>) -> f64 {
215    let total = contract.proof_obligations.len();
216
217    // Emit a probe per obligation
218    for ob in &contract.proof_obligations {
219        let matching_test = contract
220            .falsification_tests
221            .iter()
222            .find(|t| t.rule == ob.property);
223        let has_test = matching_test.is_some();
224        probes.push(ScoreProbe {
225            dimension: "falsification".into(),
226            probe: ob.property.clone(),
227            outcome: has_test,
228            detail: if let Some(t) = matching_test {
229                t.id.clone()
230            } else {
231                "(no test)".into()
232            },
233        });
234    }
235
236    // Also probe any tests that don't match an obligation
237    for t in &contract.falsification_tests {
238        let matches_obligation = contract
239            .proof_obligations
240            .iter()
241            .any(|ob| ob.property == t.rule);
242        if !matches_obligation {
243            probes.push(ScoreProbe {
244                dimension: "falsification".into(),
245                probe: t.rule.clone(),
246                outcome: true,
247                detail: format!("{} (unmatched)", t.id),
248            });
249        }
250    }
251
252    if total == 0 {
253        return if contract.falsification_tests.is_empty() {
254            0.0
255        } else {
256            1.0
257        };
258    }
259
260    // Preserve original behaviour: min(test_count, obligation_count) / obligation_count
261    let covered = contract.falsification_tests.len().min(total);
262    covered as f64 / total as f64
263}
264
265#[allow(clippy::cast_precision_loss)]
266fn compute_kani_coverage(contract: &Contract, probes: &mut Vec<ScoreProbe>) -> f64 {
267    let total = contract.proof_obligations.len();
268
269    // Emit a probe per obligation showing which harness covers it
270    for ob in &contract.proof_obligations {
271        let matching_harness = contract
272            .kani_harnesses
273            .iter()
274            .find(|h| h.obligation == ob.property);
275        let has_harness = matching_harness.is_some();
276        probes.push(ScoreProbe {
277            dimension: "kani".into(),
278            probe: ob.property.clone(),
279            outcome: has_harness,
280            detail: if let Some(h) = matching_harness {
281                let strategy_note = h.strategy.map(|s| format!(" [{s}]")).unwrap_or_default();
282                format!("{}{strategy_note}", h.id)
283            } else {
284                "(no harness)".into()
285            },
286        });
287    }
288
289    // Also probe harnesses that don't match any obligation
290    for h in &contract.kani_harnesses {
291        let matches_obligation = contract
292            .proof_obligations
293            .iter()
294            .any(|ob| ob.property == h.obligation);
295        if !matches_obligation {
296            probes.push(ScoreProbe {
297                dimension: "kani".into(),
298                probe: h.obligation.clone(),
299                outcome: true,
300                detail: format!("{} (unmatched)", h.id),
301            });
302        }
303    }
304
305    if total == 0 {
306        return if contract.kani_harnesses.is_empty() {
307            0.0
308        } else {
309            1.0
310        };
311    }
312
313    let strategy_weight = |h: &KaniHarness| -> f64 {
314        // GH-1595: If the harness has been verified by a green CI run
315        // (apr-cookbook kani-gate or equivalent), lift weight to 1.0 —
316        // runtime witness supplants static-readiness signal.
317        if h.actually_verified == Some(true) {
318            return 1.0;
319        }
320        match h.strategy.as_ref() {
321            Some(KaniStrategy::Exhaustive) => 1.0,
322            Some(KaniStrategy::BoundedInt) => 0.9,
323            Some(KaniStrategy::StubFloat) => 0.8,
324            Some(KaniStrategy::Compositional) => 0.7,
325            None => 0.5,
326        }
327    };
328
329    let weighted_sum: f64 = contract.kani_harnesses.iter().map(strategy_weight).sum();
330    (weighted_sum / total as f64).min(1.0)
331}
332
333#[allow(clippy::cast_precision_loss)]
334fn compute_lean_coverage(contract: &Contract, probes: &mut Vec<ScoreProbe>) -> f64 {
335    // Emit a probe per obligation showing lean status
336    for ob in &contract.proof_obligations {
337        match &ob.lean {
338            Some(lean) if lean.status != LeanStatus::NotApplicable => {
339                let is_proved = lean.status == LeanStatus::Proved;
340                probes.push(ScoreProbe {
341                    dimension: "lean".into(),
342                    probe: ob.property.clone(),
343                    outcome: is_proved,
344                    detail: if is_proved {
345                        format!("{} (proved)", lean.theorem)
346                    } else {
347                        format!("{} ({})", lean.theorem, lean.status)
348                    },
349                });
350            }
351            Some(lean) => {
352                // NotApplicable — still informative as a probe
353                probes.push(ScoreProbe {
354                    dimension: "lean".into(),
355                    probe: ob.property.clone(),
356                    outcome: false,
357                    detail: format!("{} (not-applicable)", lean.theorem),
358                });
359            }
360            None => {
361                probes.push(ScoreProbe {
362                    dimension: "lean".into(),
363                    probe: ob.property.clone(),
364                    outcome: false,
365                    detail: "(no lean_theorem)".into(),
366                });
367            }
368        }
369    }
370
371    let applicable: Vec<_> = contract
372        .proof_obligations
373        .iter()
374        .filter(|ob| {
375            ob.lean
376                .as_ref()
377                .is_some_and(|l| l.status != LeanStatus::NotApplicable)
378        })
379        .collect();
380
381    if applicable.is_empty() {
382        return 0.0;
383    }
384
385    let proved = applicable
386        .iter()
387        .filter(|ob| {
388            ob.lean
389                .as_ref()
390                .is_some_and(|l| l.status == LeanStatus::Proved)
391        })
392        .count();
393
394    proved as f64 / applicable.len() as f64
395}
396
397#[allow(clippy::cast_precision_loss)]
398fn compute_binding_coverage(
399    _contract: &Contract,
400    binding: Option<&BindingRegistry>,
401    stem: &str,
402    probes: &mut Vec<ScoreProbe>,
403) -> f64 {
404    let Some(binding) = binding else {
405        probes.push(ScoreProbe {
406            dimension: "binding".into(),
407            probe: "(all)".into(),
408            outcome: false,
409            detail: "(no binding registry)".into(),
410        });
411        return 0.0;
412    };
413
414    let relevant = binding.bindings_for(stem);
415
416    if relevant.is_empty() {
417        probes.push(ScoreProbe {
418            dimension: "binding".into(),
419            probe: stem.into(),
420            outcome: false,
421            detail: "(no bindings for this contract)".into(),
422        });
423        return 0.0;
424    }
425
426    // Emit a probe per binding entry
427    for b in &relevant {
428        let status_str = match b.status {
429            ImplStatus::Implemented => "implemented",
430            ImplStatus::Partial => "partial",
431            ImplStatus::NotImplemented => "not_implemented",
432            ImplStatus::Pending => "pending",
433        };
434        let is_implemented = b.status == ImplStatus::Implemented;
435        let fn_name = b
436            .function
437            .as_deref()
438            .or(b.module_path.as_deref())
439            .unwrap_or("?");
440        probes.push(ScoreProbe {
441            dimension: "binding".into(),
442            probe: b.equation.clone(),
443            outcome: is_implemented,
444            detail: format!("{fn_name} ({status_str})"),
445        });
446    }
447
448    let implemented: f64 = relevant
449        .iter()
450        .map(|b| match b.status {
451            ImplStatus::Implemented => 1.0,
452            ImplStatus::Partial => 0.5,
453            ImplStatus::NotImplemented | ImplStatus::Pending => 0.0,
454        })
455        .sum();
456
457    implemented / relevant.len() as f64
458}
459
460#[cfg(test)]
461mod tests {
462    include!("scoring_tests.rs");
463}