Skip to main content

provable_contracts/scoring/
codebase.rs

1//! Codebase scoring — how well a consumer project uses contracts.
2
3use std::collections::{BTreeSet, HashMap};
4
5use crate::binding::{BindingRegistry, ImplStatus, normalize_contract_id};
6use crate::schema::{Contract, LeanStatus};
7
8use super::score_contract;
9use super::types::{CodebaseScore, Grade, ScoringGap};
10
11/// Score a codebase that consumes contracts via a binding registry.
12///
13/// Five dimensions (weights from spec):
14/// - CD1: Contract coverage (30%) — fraction of available contracts that are bound
15/// - CD2: Binding completeness (20%) — implemented / total bindings
16/// - CD3: Mean contract score (20%) — avg composite of bound contracts
17/// - CD4: Proof depth distribution (15%) — weighted L1-L5 distribution
18/// - CD5: Drift detection (15%) — via git timestamp comparison
19///
20/// Optional `pagerank` scores weight gap analysis by dependency importance.
21#[allow(clippy::cast_precision_loss)]
22pub fn score_codebase(
23    contracts: &[(String, &Contract)],
24    binding: &BindingRegistry,
25) -> CodebaseScore {
26    score_codebase_with_pagerank(contracts, binding, None)
27}
28
29/// Score a codebase with pagerank-weighted gap analysis.
30///
31/// `drift_override` provides a pre-computed CD5 drift score (0.0-1.0).
32/// Use [`super::drift::compute_drift`] + [`super::drift::detect_stale_contracts`]
33/// to compute it from git timestamps. Pass `None` to default to 1.0 (no drift).
34#[allow(clippy::cast_precision_loss, clippy::implicit_hasher)]
35pub fn score_codebase_with_pagerank(
36    contracts: &[(String, &Contract)],
37    binding: &BindingRegistry,
38    pagerank: Option<&HashMap<String, f64>>,
39) -> CodebaseScore {
40    score_codebase_full(contracts, binding, pagerank, None)
41}
42
43/// Score a codebase with all optional enrichment: pagerank + drift.
44#[allow(clippy::cast_precision_loss, clippy::implicit_hasher)]
45pub fn score_codebase_full(
46    contracts: &[(String, &Contract)],
47    binding: &BindingRegistry,
48    pagerank: Option<&HashMap<String, f64>>,
49    drift_override: Option<f64>,
50) -> CodebaseScore {
51    let bound_stems: BTreeSet<_> = binding
52        .bindings
53        .iter()
54        .map(|b| b.contract.as_str())
55        .collect();
56
57    // CD1: Contract coverage (Option C — fraction of DECLARED contracts covered)
58    // Only counts contracts the binding.yaml references, not all 427 equations.
59    // A repo that declares 49 bindings and implements all 49 gets 100%.
60    let unique_declared: BTreeSet<_> = binding
61        .bindings
62        .iter()
63        .map(|b| normalize_contract_id(&b.contract))
64        .collect();
65    let declared_count = unique_declared.len();
66
67    let contract_coverage = if declared_count == 0 {
68        0.0
69    } else {
70        // How many declared contracts actually exist in the contract directory?
71        let resolved = unique_declared
72            .iter()
73            .filter(|stem| {
74                contracts
75                    .iter()
76                    .any(|(s, _)| normalize_contract_id(s) == **stem)
77            })
78            .count();
79        resolved as f64 / declared_count as f64
80    };
81
82    // CD2: Binding completeness (implementation status of declared bindings)
83    let total_bindings = binding.bindings.len();
84    let implemented_bindings: f64 = binding
85        .bindings
86        .iter()
87        .map(|b| match b.status {
88            ImplStatus::Implemented => 1.0,
89            ImplStatus::Partial => 0.5,
90            ImplStatus::NotImplemented | ImplStatus::Pending => 0.0,
91        })
92        .sum();
93    let binding_completeness = if total_bindings == 0 {
94        0.0
95    } else {
96        implemented_bindings / total_bindings as f64
97    };
98
99    // CD2b: Critical path completeness (Section 28 v4)
100    // Developer declares critical functions in binding.yaml critical_path.
101    // Score = entries with matching bindings / total declared.
102    let critical_path_coverage = if binding.critical_path.is_empty() {
103        binding_completeness // fallback: no declaration = use binding completeness
104    } else {
105        let covered = binding
106            .critical_path
107            .iter()
108            .filter(|cp| {
109                binding.bindings.iter().any(|b| {
110                    b.function
111                        .as_deref()
112                        .is_some_and(|f| f.contains(cp.as_str()))
113                })
114            })
115            .count();
116        #[allow(clippy::cast_precision_loss)]
117        let ratio = covered as f64 / binding.critical_path.len() as f64;
118        ratio
119    };
120
121    // CD3: Mean contract score of bound contracts
122    let bound_scores: Vec<f64> = contracts
123        .iter()
124        .filter(|(stem, _)| bound_stems.contains(stem.as_str()))
125        .map(|(stem, c)| score_contract(c, Some(binding), stem).composite)
126        .collect();
127    let mean_contract_score = if bound_scores.is_empty() {
128        0.0
129    } else {
130        bound_scores.iter().sum::<f64>() / bound_scores.len() as f64
131    };
132
133    // CD4: Proof depth distribution (weighted L1-L5)
134    let proof_depth_dist = compute_proof_depth(contracts, &bound_stems);
135
136    // CD5: Drift detection
137    let drift = drift_override.unwrap_or(1.0);
138
139    let composite = contract_coverage * 0.25
140        + critical_path_coverage * 0.20
141        + mean_contract_score * 0.20
142        + proof_depth_dist * 0.15
143        + drift * 0.20;
144
145    let top_gaps = compute_gaps(contracts, binding, &bound_stems, pagerank);
146
147    CodebaseScore {
148        path: "codebase".to_string(),
149        contract_coverage,
150        binding_completeness: critical_path_coverage,
151        mean_contract_score,
152        proof_depth_dist,
153        drift,
154        // TODO: reverse_coverage is hardcoded to 0.0 — needs wiring
155        // reverse_coverage_report into the scoring pipeline to compute actual value.
156        reverse_coverage: 0.0,
157        mutation_testing: 1.0,
158        ci_pipeline_depth: 1.0,
159        proof_freshness: 1.0,
160        defect_patterns: 1.0,
161        composite,
162        grade: Grade::from_score(composite),
163        top_gaps,
164    }
165}
166
167#[allow(clippy::cast_precision_loss)]
168fn compute_proof_depth(contracts: &[(String, &Contract)], bound_stems: &BTreeSet<&str>) -> f64 {
169    let mut total_obligations = 0usize;
170    let mut weighted_sum = 0.0;
171
172    for (stem, contract) in contracts {
173        if !bound_stems.contains(stem.as_str()) {
174            continue;
175        }
176        for ob in &contract.proof_obligations {
177            total_obligations += 1;
178            weighted_sum += 0.1; // L1 (type system)
179            if !contract.falsification_tests.is_empty() {
180                weighted_sum += 0.3; // L3 (probar)
181            }
182            if !contract.kani_harnesses.is_empty() {
183                weighted_sum += 0.4; // L4 (Kani)
184            }
185            if ob
186                .lean
187                .as_ref()
188                .is_some_and(|l| l.status == LeanStatus::Proved)
189            {
190                weighted_sum += 0.2; // L5 (Lean)
191            }
192        }
193    }
194
195    if total_obligations == 0 {
196        return 0.0;
197    }
198    (weighted_sum / total_obligations as f64).min(1.0)
199}
200
201/// Compute gaps with impact-weighted scoring per spec Section 4:
202/// `impact = (1.0 - obligation_coverage) * dependency_fanout * tier_weight`
203///
204/// `dependency_fanout` uses pagerank when available, otherwise falls back
205/// to reverse dependency count + 1.
206#[allow(clippy::cast_precision_loss)]
207fn compute_gaps(
208    contracts: &[(String, &Contract)],
209    binding: &BindingRegistry,
210    bound_stems: &BTreeSet<&str>,
211    pagerank: Option<&HashMap<String, f64>>,
212) -> Vec<ScoringGap> {
213    let mut gaps = Vec::new();
214
215    // Pre-compute reverse dependency counts for fallback
216    let rev_dep_counts = compute_reverse_dep_counts(contracts);
217
218    for (stem, contract) in contracts {
219        if !bound_stems.contains(stem.as_str()) {
220            continue;
221        }
222        let ob_count = contract.proof_obligations.len();
223        let kani_count = contract.kani_harnesses.len();
224        let ft_count = contract.falsification_tests.len();
225        let fanout = dependency_fanout(stem, pagerank, &rev_dep_counts);
226
227        if ob_count > 0 && kani_count < ob_count {
228            let coverage = kani_count as f64 / ob_count as f64;
229            gaps.push(ScoringGap {
230                contract: stem.clone(),
231                dimension: "kani_coverage".into(),
232                current: coverage,
233                target: 1.0,
234                impact: (1.0 - coverage) * fanout,
235                action: "Write #[kani::proof] harnesses".into(),
236            });
237        }
238
239        if ob_count > 0 && ft_count < ob_count {
240            let coverage = ft_count as f64 / ob_count as f64;
241            gaps.push(ScoringGap {
242                contract: stem.clone(),
243                dimension: "falsification_coverage".into(),
244                current: coverage,
245                target: 1.0,
246                impact: (1.0 - coverage) * fanout,
247                action: "Write probar property tests".into(),
248            });
249        }
250
251        let partial_count = binding
252            .bindings_for(stem)
253            .iter()
254            .filter(|b| b.status == ImplStatus::Partial)
255            .count();
256        if partial_count > 0 {
257            gaps.push(ScoringGap {
258                contract: stem.clone(),
259                dimension: "binding_partial".into(),
260                current: 0.5,
261                target: 1.0,
262                impact: 0.5 * fanout,
263                action: "Complete partial implementations".into(),
264            });
265        }
266
267        let unimpl_count = binding
268            .bindings_for(stem)
269            .iter()
270            .filter(|b| b.status == ImplStatus::NotImplemented)
271            .count();
272        if unimpl_count > 0 {
273            gaps.push(ScoringGap {
274                contract: stem.clone(),
275                dimension: "binding_coverage".into(),
276                current: 0.0,
277                target: 1.0,
278                impact: 1.0 * fanout,
279                action: "Implement bound equations".into(),
280            });
281        }
282    }
283
284    gaps.sort_by(|a, b| {
285        b.impact
286            .partial_cmp(&a.impact)
287            .unwrap_or(std::cmp::Ordering::Equal)
288    });
289    gaps.truncate(10);
290    gaps
291}
292
293/// Compute dependency fanout for a contract stem.
294///
295/// Uses pagerank score when available (normalized to 1.0-10.0 range),
296/// otherwise falls back to reverse dependency count + 1.
297#[allow(clippy::cast_precision_loss)]
298fn dependency_fanout(
299    stem: &str,
300    pagerank: Option<&HashMap<String, f64>>,
301    rev_dep_counts: &HashMap<&str, usize>,
302) -> f64 {
303    if let Some(pr) = pagerank {
304        if let Some(&score) = pr.get(stem) {
305            let max_pr = pr.values().copied().fold(f64::NEG_INFINITY, f64::max);
306            let min_pr = pr.values().copied().fold(f64::INFINITY, f64::min);
307            let range = max_pr - min_pr;
308            if range > 1e-12 {
309                // Normalize to 1.0-10.0 range for readable impact scores
310                return 1.0 + 9.0 * (score - min_pr) / range;
311            }
312        }
313    }
314    // Fallback: reverse dep count + 1 (so isolated contracts still have impact 1.0)
315    (rev_dep_counts.get(stem).copied().unwrap_or(0) + 1) as f64
316}
317
318/// Count how many contracts depend on each stem.
319fn compute_reverse_dep_counts<'a>(contracts: &'a [(String, &Contract)]) -> HashMap<&'a str, usize> {
320    let mut counts: HashMap<&str, usize> = HashMap::new();
321    for (_, contract) in contracts {
322        for dep in &contract.metadata.depends_on {
323            // Find the matching stem in our contracts
324            for (stem, _) in contracts {
325                if stem == dep || stem.strip_suffix(".yaml").is_some_and(|s| s == dep) {
326                    *counts.entry(stem.as_str()).or_default() += 1;
327                }
328            }
329        }
330    }
331    counts
332}
333
334#[cfg(test)]
335#[path = "codebase_tests.rs"]
336mod tests;