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        match h.strategy.as_ref() {
315            Some(KaniStrategy::Exhaustive) => 1.0,
316            Some(KaniStrategy::BoundedInt) => 0.9,
317            Some(KaniStrategy::StubFloat) => 0.8,
318            Some(KaniStrategy::Compositional) => 0.7,
319            None => 0.5,
320        }
321    };
322
323    let weighted_sum: f64 = contract.kani_harnesses.iter().map(strategy_weight).sum();
324    (weighted_sum / total as f64).min(1.0)
325}
326
327#[allow(clippy::cast_precision_loss)]
328fn compute_lean_coverage(contract: &Contract, probes: &mut Vec<ScoreProbe>) -> f64 {
329    // Emit a probe per obligation showing lean status
330    for ob in &contract.proof_obligations {
331        match &ob.lean {
332            Some(lean) if lean.status != LeanStatus::NotApplicable => {
333                let is_proved = lean.status == LeanStatus::Proved;
334                probes.push(ScoreProbe {
335                    dimension: "lean".into(),
336                    probe: ob.property.clone(),
337                    outcome: is_proved,
338                    detail: if is_proved {
339                        format!("{} (proved)", lean.theorem)
340                    } else {
341                        format!("{} ({})", lean.theorem, lean.status)
342                    },
343                });
344            }
345            Some(lean) => {
346                // NotApplicable — still informative as a probe
347                probes.push(ScoreProbe {
348                    dimension: "lean".into(),
349                    probe: ob.property.clone(),
350                    outcome: false,
351                    detail: format!("{} (not-applicable)", lean.theorem),
352                });
353            }
354            None => {
355                probes.push(ScoreProbe {
356                    dimension: "lean".into(),
357                    probe: ob.property.clone(),
358                    outcome: false,
359                    detail: "(no lean_theorem)".into(),
360                });
361            }
362        }
363    }
364
365    let applicable: Vec<_> = contract
366        .proof_obligations
367        .iter()
368        .filter(|ob| {
369            ob.lean
370                .as_ref()
371                .is_some_and(|l| l.status != LeanStatus::NotApplicable)
372        })
373        .collect();
374
375    if applicable.is_empty() {
376        return 0.0;
377    }
378
379    let proved = applicable
380        .iter()
381        .filter(|ob| {
382            ob.lean
383                .as_ref()
384                .is_some_and(|l| l.status == LeanStatus::Proved)
385        })
386        .count();
387
388    proved as f64 / applicable.len() as f64
389}
390
391#[allow(clippy::cast_precision_loss)]
392fn compute_binding_coverage(
393    _contract: &Contract,
394    binding: Option<&BindingRegistry>,
395    stem: &str,
396    probes: &mut Vec<ScoreProbe>,
397) -> f64 {
398    let Some(binding) = binding else {
399        probes.push(ScoreProbe {
400            dimension: "binding".into(),
401            probe: "(all)".into(),
402            outcome: false,
403            detail: "(no binding registry)".into(),
404        });
405        return 0.0;
406    };
407
408    let relevant = binding.bindings_for(stem);
409
410    if relevant.is_empty() {
411        probes.push(ScoreProbe {
412            dimension: "binding".into(),
413            probe: stem.into(),
414            outcome: false,
415            detail: "(no bindings for this contract)".into(),
416        });
417        return 0.0;
418    }
419
420    // Emit a probe per binding entry
421    for b in &relevant {
422        let status_str = match b.status {
423            ImplStatus::Implemented => "implemented",
424            ImplStatus::Partial => "partial",
425            ImplStatus::NotImplemented => "not_implemented",
426            ImplStatus::Pending => "pending",
427        };
428        let is_implemented = b.status == ImplStatus::Implemented;
429        let fn_name = b
430            .function
431            .as_deref()
432            .or(b.module_path.as_deref())
433            .unwrap_or("?");
434        probes.push(ScoreProbe {
435            dimension: "binding".into(),
436            probe: b.equation.clone(),
437            outcome: is_implemented,
438            detail: format!("{fn_name} ({status_str})"),
439        });
440    }
441
442    let implemented: f64 = relevant
443        .iter()
444        .map(|b| match b.status {
445            ImplStatus::Implemented => 1.0,
446            ImplStatus::Partial => 0.5,
447            ImplStatus::NotImplemented | ImplStatus::Pending => 0.0,
448        })
449        .sum();
450
451    implemented / relevant.len() as f64
452}
453
454#[cfg(test)]
455mod tests {
456    include!("scoring_tests.rs");
457}