Skip to main content

aprender_contracts_cli/commands/
verify_pipeline.rs

1//! `pv verify-pipeline` — compositional shape verification across contracts.
2//!
3//! Walks the dependency graph in topological order and verifies that every
4//! assumes/guarantees edge is satisfied. Produces a composition report
5//! showing the full proof chain or identifying break points.
6//!
7//! Spec: docs/specifications/sub/model-layout-provability.md (§36, P0-3)
8
9use std::collections::BTreeMap;
10use std::path::Path;
11
12use provable_contracts::graph::dependency_graph;
13use provable_contracts::schema::Contract;
14use serde_json::Value;
15
16use crate::contract_walk::collect_contracts;
17use crate::json_obj::obj;
18
19/// Run the verify-pipeline command.
20pub fn run(contract_dir: &Path, format: &str) {
21    // 1. Load all contracts
22    let mut contracts = Vec::new();
23    collect_contracts(contract_dir, &mut contracts);
24    contracts.sort_by(|a, b| a.0.cmp(&b.0));
25
26    if contracts.is_empty() {
27        eprintln!("No contracts found in {}", contract_dir.display());
28        return;
29    }
30
31    // 2. Build dependency graph + topological sort
32    let refs: Vec<(String, &Contract)> = contracts.iter().map(|(s, c)| (s.clone(), c)).collect();
33    let graph = dependency_graph(&refs);
34
35    if !graph.cycles.is_empty() {
36        eprintln!(
37            "ERROR: {} cycle(s) detected in dependency graph — cannot verify pipeline",
38            graph.cycles.len()
39        );
40        for cycle in &graph.cycles {
41            eprintln!("  cycle: {}", cycle.join(" → "));
42        }
43        std::process::exit(1);
44    }
45
46    // 3. Build stem→contract index and walk edges
47    let index: BTreeMap<&str, &Contract> = contracts.iter().map(|(s, c)| (s.as_str(), c)).collect();
48    let (chains, edges_total, edges_satisfied, edges_broken) =
49        walk_composition_edges(&graph.topo_order, &index);
50
51    // 4. Output
52    if format == "json" {
53        print_json(&chains, edges_total, edges_satisfied, &edges_broken, &graph);
54    } else {
55        print_text(&chains, edges_total, edges_satisfied, &edges_broken, &graph);
56    }
57
58    if !edges_broken.is_empty() {
59        std::process::exit(1);
60    }
61}
62
63fn walk_composition_edges(
64    topo_order: &[String],
65    index: &BTreeMap<&str, &Contract>,
66) -> (Vec<CompositionEdge>, usize, usize, Vec<CompositionEdge>) {
67    let mut edges_total = 0usize;
68    let mut edges_satisfied = 0usize;
69    let mut edges_broken = Vec::new();
70    let mut chains: Vec<CompositionEdge> = Vec::new();
71
72    for stem in topo_order {
73        let Some(contract) = index.get(stem.as_str()) else {
74            continue;
75        };
76        for (eq_name, equation) in &contract.equations {
77            let Some(assumes) = &equation.assumes else {
78                continue;
79            };
80            let Some(from_contract) = &assumes.from_contract else {
81                continue;
82            };
83
84            edges_total += 1;
85            let from_eq = assumes.from_equation.as_deref();
86
87            let edge = CompositionEdge {
88                downstream: format!("{stem}.{eq_name}"),
89                upstream: format!(
90                    "{from_contract}{}",
91                    from_eq.map_or(String::new(), |e| format!(".{e}"))
92                ),
93                assumed_shapes: assumes.shapes.keys().cloned().collect(),
94                status: EdgeStatus::Unknown,
95            };
96
97            // Resolve upstream
98            let Some(upstream_contract) = index.get(from_contract.as_str()) else {
99                let mut e = edge;
100                e.status = EdgeStatus::Broken("upstream contract not found".into());
101                edges_broken.push(e.clone());
102                chains.push(e);
103                continue;
104            };
105
106            if let Some(upstream_eq_name) = from_eq {
107                let Some(upstream_eq) = upstream_contract.equations.get(upstream_eq_name) else {
108                    let mut e = edge;
109                    e.status = EdgeStatus::Broken("upstream equation not found".into());
110                    edges_broken.push(e.clone());
111                    chains.push(e);
112                    continue;
113                };
114
115                // `let ... else` rather than an `is_none()` guard followed by
116                // `.unwrap()`: the binding carries the proof that guarantees
117                // exist, so there is no unwrap left to justify.
118                let Some(guarantees) = upstream_eq.guarantees.as_ref() else {
119                    let mut e = edge;
120                    e.status = EdgeStatus::Broken("upstream has no guarantees".into());
121                    edges_broken.push(e.clone());
122                    chains.push(e);
123                    continue;
124                };
125
126                // Edge satisfied: upstream has guarantees
127                let mut e = edge;
128                let guaranteed_shapes: Vec<String> = guarantees.shapes.keys().cloned().collect();
129                e.status = EdgeStatus::Satisfied(guaranteed_shapes);
130                edges_satisfied += 1;
131                chains.push(e);
132            } else {
133                // No specific equation -- check any equation has guarantees
134                let has_guarantees = upstream_contract
135                    .equations
136                    .values()
137                    .any(|eq| eq.guarantees.is_some());
138                let mut e = edge;
139                if has_guarantees {
140                    e.status = EdgeStatus::Satisfied(vec![]);
141                    edges_satisfied += 1;
142                } else {
143                    e.status = EdgeStatus::Broken("no equations with guarantees".into());
144                    edges_broken.push(e.clone());
145                }
146                chains.push(e);
147            }
148        }
149    }
150
151    (chains, edges_total, edges_satisfied, edges_broken)
152}
153
154#[derive(Debug, Clone)]
155struct CompositionEdge {
156    downstream: String,
157    upstream: String,
158    assumed_shapes: Vec<String>,
159    status: EdgeStatus,
160}
161
162#[derive(Debug, Clone)]
163enum EdgeStatus {
164    Unknown,
165    Satisfied(Vec<String>),
166    Broken(String),
167}
168
169fn print_text(
170    chains: &[CompositionEdge],
171    total: usize,
172    satisfied: usize,
173    broken: &[CompositionEdge],
174    graph: &provable_contracts::graph::DependencyGraph,
175) {
176    println!("pv verify-pipeline — Compositional Shape Verification");
177    println!("=====================================================");
178    println!();
179    println!(
180        "Contracts: {}  |  Topo depth: {}",
181        graph.nodes.len(),
182        graph.topo_order.len()
183    );
184    println!(
185        "Edges: {}  |  Satisfied: {}  |  Broken: {}",
186        total,
187        satisfied,
188        broken.len()
189    );
190    println!();
191
192    if !chains.is_empty() {
193        println!("Composition edges:");
194        for edge in chains {
195            let icon = match &edge.status {
196                EdgeStatus::Satisfied(_) => "✓",
197                EdgeStatus::Broken(_) => "✗",
198                EdgeStatus::Unknown => "?",
199            };
200            let detail = match &edge.status {
201                EdgeStatus::Satisfied(shapes) if !shapes.is_empty() => {
202                    format!(" (guarantees: {})", shapes.join(", "))
203                }
204                EdgeStatus::Broken(reason) => format!(" — {reason}"),
205                _ => String::new(),
206            };
207            println!("  {icon} {} ← {}{detail}", edge.downstream, edge.upstream);
208        }
209        println!();
210    }
211
212    if broken.is_empty() {
213        println!("Result: PASS — all composition edges satisfied");
214    } else {
215        println!("Result: FAIL — {} broken edge(s)", broken.len());
216        for edge in broken {
217            if let EdgeStatus::Broken(reason) = &edge.status {
218                println!("  ✗ {} ← {} — {reason}", edge.downstream, edge.upstream);
219            }
220        }
221    }
222}
223
224fn print_json(
225    chains: &[CompositionEdge],
226    total: usize,
227    satisfied: usize,
228    broken: &[CompositionEdge],
229    graph: &provable_contracts::graph::DependencyGraph,
230) {
231    let edges_json: Vec<Value> = chains
232        .iter()
233        .map(|e| {
234            let (status, detail) = match &e.status {
235                EdgeStatus::Satisfied(shapes) => (
236                    "satisfied",
237                    obj([("guaranteed_shapes", Value::from(shapes.clone()))]),
238                ),
239                EdgeStatus::Broken(reason) => {
240                    ("broken", obj([("reason", Value::from(reason.clone()))]))
241                }
242                EdgeStatus::Unknown => ("unknown", obj([])),
243            };
244            obj([
245                ("downstream", Value::from(e.downstream.clone())),
246                ("upstream", Value::from(e.upstream.clone())),
247                ("assumed_shapes", Value::from(e.assumed_shapes.clone())),
248                ("status", Value::from(status)),
249                ("detail", detail),
250            ])
251        })
252        .collect();
253
254    let report = obj([
255        ("contracts", Value::from(graph.nodes.len())),
256        ("topo_depth", Value::from(graph.topo_order.len())),
257        ("edges_total", Value::from(total)),
258        ("edges_satisfied", Value::from(satisfied)),
259        ("edges_broken", Value::from(broken.len())),
260        ("passed", Value::from(broken.is_empty())),
261        ("edges", Value::Array(edges_json)),
262    ]);
263
264    println!(
265        "{}",
266        serde_json::to_string_pretty(&report)
267            .expect("a serde_json::Value of objects/arrays/strings/numbers always serializes")
268    );
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn verify_pipeline_on_real_contracts() {
277        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
278        if !dir.exists() {
279            return; // skip in CI without contracts
280        }
281        // Should not panic
282        run(&dir, "text");
283    }
284
285    #[test]
286    fn verify_pipeline_json_on_real_contracts() {
287        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
288        if !dir.exists() {
289            return;
290        }
291        run(&dir, "json");
292    }
293
294    #[test]
295    fn verify_pipeline_empty_dir() {
296        let tmp = tempfile::tempdir().unwrap();
297        run(tmp.path(), "text");
298    }
299}