Skip to main content

aprender_contracts_cli/commands/
score.rs

1//! `pv score` — Quantitative contract and codebase scoring.
2
3use std::path::Path;
4
5use std::collections::HashSet;
6
7use provable_contracts::binding::BindingRegistry;
8use provable_contracts::query::ContractIndex;
9use provable_contracts::schema::parse_contract;
10use provable_contracts::scoring;
11use provable_contracts::scoring::drift;
12use provable_contracts::scoring::pvscore_10dim;
13use provable_contracts::scoring::{CodebaseScore, ContractScore, Grade, ScoringWeights};
14use serde_json::Value;
15
16use crate::json_obj::obj;
17
18#[allow(clippy::too_many_arguments)]
19pub fn run(
20    path: &Path,
21    binding: Option<&Path>,
22    format: &str,
23    min_score: Option<f64>,
24    summary: bool,
25    top_gaps: usize,
26    weights_json: Option<&str>,
27    pvscore: bool,
28) -> Result<(), Box<dyn std::error::Error>> {
29    let binding_registry = binding
30        .map(|p| {
31            let content = std::fs::read_to_string(p)?;
32            let reg: BindingRegistry = serde_yaml::from_str(&content)?;
33            Ok::<_, Box<dyn std::error::Error>>(reg)
34        })
35        .transpose()?;
36
37    let weights = match weights_json {
38        Some(json) => serde_json::from_str::<ScoringWeights>(json)?,
39        None => ScoringWeights::default(),
40    };
41
42    if path.is_dir() {
43        run_directory(
44            path,
45            binding_registry.as_ref(),
46            binding,
47            format,
48            min_score,
49            summary,
50            top_gaps,
51            &weights,
52            pvscore,
53        )
54    } else {
55        run_single(path, binding_registry.as_ref(), format, min_score, &weights)
56    }
57}
58
59fn run_single(
60    path: &Path,
61    binding: Option<&BindingRegistry>,
62    format: &str,
63    min_score: Option<f64>,
64    weights: &ScoringWeights,
65) -> Result<(), Box<dyn std::error::Error>> {
66    let contract = parse_contract(path)?;
67    let stem = path
68        .file_stem()
69        .and_then(|s| s.to_str())
70        .unwrap_or("unknown");
71    let score = scoring::score_contract_weighted(&contract, binding, stem, weights);
72
73    match format {
74        "json" => println!("{}", serde_json::to_string_pretty(&score)?),
75        "markdown" => print!("{}", score_to_markdown(&score)),
76        _ => {
77            print!("{score}");
78            print_probes(&score);
79        }
80    }
81
82    if let Some(threshold) = min_score {
83        if score.composite < threshold {
84            return Err(format!(
85                "Score {:.2} below threshold {threshold:.2}",
86                score.composite,
87            )
88            .into());
89        }
90    }
91
92    Ok(())
93}
94
95#[allow(
96    clippy::too_many_arguments,
97    clippy::too_many_lines,
98    clippy::cast_precision_loss
99)]
100fn run_directory(
101    dir: &Path,
102    binding: Option<&BindingRegistry>,
103    binding_path: Option<&Path>,
104    format: &str,
105    min_score: Option<f64>,
106    summary: bool,
107    top_gaps: usize,
108    weights: &ScoringWeights,
109    pvscore: bool,
110) -> Result<(), Box<dyn std::error::Error>> {
111    let mut yaml_paths: Vec<std::path::PathBuf> = Vec::new();
112    collect_yaml_files(dir, &mut yaml_paths);
113    yaml_paths.sort();
114
115    let mut scores = Vec::new();
116    for path in &yaml_paths {
117        let Ok(contract) = parse_contract(path) else {
118            continue;
119        };
120        let stem = path
121            .file_stem()
122            .and_then(|s| s.to_str())
123            .unwrap_or("unknown");
124        scores.push(scoring::score_contract_weighted(
125            &contract, binding, stem, weights,
126        ));
127    }
128
129    let mean: f64 = if scores.is_empty() {
130        0.0
131    } else {
132        scores.iter().map(|s| s.composite).sum::<f64>() / scores.len() as f64
133    };
134
135    if summary {
136        print_summary_only(&scores, mean, format)?;
137    } else {
138        print_directory_scores(&scores, mean, format)?;
139    }
140
141    // Show top gaps by lowest score
142    if top_gaps > 0 && !scores.is_empty() {
143        print_top_gaps(&scores, top_gaps, format);
144    }
145
146    // Codebase scoring if binding is provided
147    if let Some(binding) = binding {
148        let mut parsed = Vec::new();
149        for path in &yaml_paths {
150            let Ok(contract) = parse_contract(path) else {
151                continue;
152            };
153            let stem = path
154                .file_name()
155                .and_then(|s| s.to_str())
156                .unwrap_or("unknown")
157                .to_string();
158            parsed.push((stem, contract));
159        }
160        let refs: Vec<_> = parsed.iter().map(|(s, c)| (s.clone(), c)).collect();
161
162        // Build pagerank from contract index for impact-weighted gap analysis
163        let pagerank = ContractIndex::from_directory(dir).ok().map(|idx| {
164            idx.entries
165                .iter()
166                .filter_map(|e| idx.cached_pagerank(&e.stem).map(|s| (e.stem.clone(), s)))
167                .collect::<std::collections::HashMap<String, f64>>()
168        });
169
170        // CD5: Detect stale contracts via git timestamps
171        let drift_score = binding_path.map(|bp| {
172            let bound_stems: HashSet<&str> = binding
173                .bindings
174                .iter()
175                .map(|b| b.contract.as_str())
176                .collect();
177            let stale = drift::detect_stale_contracts(dir, bp, &bound_stems);
178            drift::compute_drift(stale.len(), bound_stems.len())
179        });
180
181        let codebase = scoring::score_codebase_full(&refs, binding, pagerank.as_ref(), drift_score);
182
183        match format {
184            "json" => {
185                let output = obj([("codebase", serde_json::to_value(&codebase)?)]);
186                println!("{}", serde_json::to_string_pretty(&output)?);
187            }
188            "markdown" => {
189                println!("\n## Codebase Score\n");
190                println!("| Dimension | Value |");
191                println!("|-----------|-------|");
192                println!("| Coverage | {:.0}% |", codebase.contract_coverage * 100.0);
193                println!(
194                    "| Binding | {:.0}% |",
195                    codebase.binding_completeness * 100.0
196                );
197                println!("| Mean Score | {:.2} |", codebase.mean_contract_score);
198                println!("| Proof Depth | {:.2} |", codebase.proof_depth_dist);
199                println!("| Drift | {:.2} |", codebase.drift);
200                println!(
201                    "\n**Composite:** {:.2} (Grade {})",
202                    codebase.composite, codebase.grade
203                );
204            }
205            _ => println!("\n{codebase}"),
206        }
207
208        if pvscore {
209            print_pvscore(&codebase);
210        }
211    }
212
213    if let Some(threshold) = min_score {
214        if mean < threshold {
215            return Err(format!("Mean score {mean:.2} below threshold {threshold:.2}",).into());
216        }
217    }
218
219    Ok(())
220}
221
222#[allow(clippy::cast_precision_loss)]
223fn print_directory_scores(
224    scores: &[ContractScore],
225    mean: f64,
226    format: &str,
227) -> Result<(), Box<dyn std::error::Error>> {
228    match format {
229        "json" => {
230            let output = obj([
231                ("contracts", Value::from(scores.len())),
232                ("mean_score", Value::from(mean)),
233                (
234                    "mean_grade",
235                    Value::from(scoring::Grade::from_score(mean).to_string()),
236                ),
237                ("scores", serde_json::to_value(scores)?),
238            ]);
239            println!("{}", serde_json::to_string_pretty(&output)?);
240        }
241        "markdown" => {
242            println!("## Contract Scores\n");
243            println!("| Contract | Score | Grade | Spec | Falsify | Kani | Lean | Bind |");
244            println!("|----------|-------|-------|------|---------|------|------|------|");
245            for s in scores {
246                println!(
247                    "| {} | {:.2} | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
248                    s.stem,
249                    s.composite,
250                    s.grade,
251                    s.spec_depth,
252                    s.falsification_coverage,
253                    s.kani_coverage,
254                    s.lean_coverage,
255                    s.binding_coverage
256                );
257            }
258            println!(
259                "\n**{} contracts** — Mean: {mean:.2} (Grade {})",
260                scores.len(),
261                scoring::Grade::from_score(mean)
262            );
263        }
264        _ => {
265            for s in scores {
266                print!("{s}");
267            }
268            println!(
269                "\n{} contracts — Mean: {mean:.2} (Grade {})",
270                scores.len(),
271                scoring::Grade::from_score(mean)
272            );
273        }
274    }
275    Ok(())
276}
277
278#[allow(clippy::cast_precision_loss)]
279fn print_summary_only(
280    scores: &[ContractScore],
281    mean: f64,
282    format: &str,
283) -> Result<(), Box<dyn std::error::Error>> {
284    let grade = scoring::Grade::from_score(mean);
285    match format {
286        "json" => {
287            let output = obj([
288                ("contracts", Value::from(scores.len())),
289                ("mean_score", Value::from(mean)),
290                ("mean_grade", Value::from(grade.to_string())),
291            ]);
292            println!("{}", serde_json::to_string_pretty(&output)?);
293        }
294        "markdown" => {
295            println!(
296                "**{} contracts** — Mean: {mean:.2} (Grade {grade})",
297                scores.len()
298            );
299        }
300        _ => {
301            println!(
302                "{} contracts — Mean: {mean:.2} (Grade {grade})",
303                scores.len()
304            );
305        }
306    }
307    Ok(())
308}
309
310fn print_top_gaps(scores: &[ContractScore], n: usize, format: &str) {
311    let mut sorted: Vec<_> = scores.iter().collect();
312    sorted.sort_by(|a, b| {
313        a.composite
314            .partial_cmp(&b.composite)
315            .unwrap_or(std::cmp::Ordering::Equal)
316    });
317    let top: Vec<_> = sorted.into_iter().take(n).collect();
318
319    match format {
320        "json" => {} // Already in JSON output
321        "markdown" => {
322            println!("\n### Top {} Gaps\n", top.len());
323            for s in &top {
324                println!("- **{}** — {:.2} ({})", s.stem, s.composite, s.grade);
325            }
326        }
327        _ => {
328            println!("\nTop {} gaps:", top.len());
329            for s in &top {
330                println!("  {} — {:.2} ({})", s.stem, s.composite, s.grade);
331            }
332        }
333    }
334}
335
336fn print_pvscore(codebase: &CodebaseScore) {
337    let pv = pvscore_10dim(codebase);
338    let grade = Grade::from_score(pv / 100.0);
339    println!("\nPVScore: {pv:.1} (Grade: {grade})");
340    println!(
341        "  D1  Spec Depth:        {:.1}",
342        codebase.contract_coverage * 100.0
343    );
344    println!(
345        "  D2  Falsification:     {:.1}",
346        codebase.binding_completeness * 100.0
347    );
348    println!(
349        "  D3  Mean Score:        {:.1}",
350        codebase.mean_contract_score * 100.0
351    );
352    println!(
353        "  D4  Proof Depth:       {:.1}",
354        codebase.proof_depth_dist * 100.0
355    );
356    println!("  D5  Drift:             {:.1}", codebase.drift * 100.0);
357    println!(
358        "  D6  Reverse Coverage:  {:.1}{}",
359        codebase.reverse_coverage * 100.0,
360        default_suffix(codebase.reverse_coverage)
361    );
362    println!(
363        "  D7  Mutation Testing:  {:.1}{}",
364        codebase.mutation_testing * 100.0,
365        default_suffix(codebase.mutation_testing)
366    );
367    println!(
368        "  D8  CI Pipeline:       {:.1}{}",
369        codebase.ci_pipeline_depth * 100.0,
370        default_suffix(codebase.ci_pipeline_depth)
371    );
372    println!(
373        "  D9  Proof Freshness:   {:.1}{}",
374        codebase.proof_freshness * 100.0,
375        default_suffix(codebase.proof_freshness)
376    );
377    println!(
378        "  D10 Defect Patterns:   {:.1}{}",
379        codebase.defect_patterns * 100.0,
380        default_suffix(codebase.defect_patterns)
381    );
382}
383
384fn default_suffix(value: f64) -> &'static str {
385    if value == 0.0 {
386        " (default)"
387    } else {
388        ""
389    }
390}
391
392fn score_to_markdown(score: &ContractScore) -> String {
393    format!(
394        "### {}\n\n- **Score:** {:.2} (Grade {})\n- Spec: {:.2} | Falsify: {:.2} | Kani: {:.2} | Lean: {:.2} | Bind: {:.2}\n",
395        score.stem,
396        score.composite,
397        score.grade,
398        score.spec_depth,
399        score.falsification_coverage,
400        score.kani_coverage,
401        score.lean_coverage,
402        score.binding_coverage
403    )
404}
405
406/// Print probe-level score decomposition for a single contract.
407///
408/// Groups probes by dimension and prints each with a pass/fail indicator,
409/// showing what contributed to each dimension's score.
410fn print_probes(score: &ContractScore) {
411    if score.probes.is_empty() {
412        return;
413    }
414
415    // Dimension display order and labels
416    let dimensions = [
417        ("spec_depth", "D1 Spec Depth", score.spec_depth),
418        (
419            "falsification",
420            "D2 Falsification",
421            score.falsification_coverage,
422        ),
423        ("kani", "D3 Kani", score.kani_coverage),
424        ("lean", "D4 Lean", score.lean_coverage),
425        ("binding", "D5 Binding", score.binding_coverage),
426    ];
427
428    println!("  Probes:");
429    for (dim_key, dim_label, dim_score) in &dimensions {
430        let dim_probes: Vec<_> = score
431            .probes
432            .iter()
433            .filter(|p| p.dimension == *dim_key)
434            .collect();
435        if dim_probes.is_empty() {
436            continue;
437        }
438        println!("  {dim_label:20} {dim_score:.2}");
439        for p in &dim_probes {
440            let icon = if p.outcome { "+" } else { "-" };
441            println!("    {icon} {}: {}", p.probe, p.detail);
442        }
443    }
444}
445
446/// Recursively collect all `.yaml` contract files from a directory.
447///
448/// Skips:
449/// - `binding.yaml` (binding registries, not contracts)
450/// - `kaizen/` directories (work items, not contracts)
451/// - `legacy/` directories (deprecated contracts)
452/// - `pipelines/` directories (pipeline contracts, different schema)
453fn collect_yaml_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
454    let Ok(entries) = std::fs::read_dir(dir) else {
455        return;
456    };
457    for entry in entries.flatten() {
458        let path = entry.path();
459        if path.is_dir() {
460            let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
461            // Skip non-contract directories
462            if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
463                continue;
464            }
465            collect_yaml_files(&path, out);
466        } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
467            && path.file_name().and_then(|n| n.to_str()) != Some("binding.yaml")
468        {
469            out.push(path);
470        }
471    }
472}