Skip to main content

provable_contracts/
graph.rs

1//! Contract dependency graph — composition via `depends_on`.
2//!
3//! Builds a directed acyclic graph (DAG) of contract dependencies
4//! and provides cycle detection and topological ordering.
5
6use std::collections::{BTreeMap, BTreeSet, VecDeque};
7
8/// A dependency graph of contracts.
9#[derive(Debug, Clone)]
10pub struct DependencyGraph {
11    /// Map from contract stem to its direct dependencies.
12    pub edges: BTreeMap<String, Vec<String>>,
13    /// All unique contract stems in the graph.
14    pub nodes: BTreeSet<String>,
15    /// Topological ordering (empty if cycle detected).
16    pub topo_order: Vec<String>,
17    /// Detected cycles (empty if DAG is valid).
18    pub cycles: Vec<Vec<String>>,
19}
20
21/// A single node in the graph with metadata.
22#[derive(Debug, Clone)]
23pub struct GraphNode {
24    /// Contract stem (e.g. "softmax-kernel-v1").
25    pub stem: String,
26    /// Number of contracts that depend on this one.
27    pub dependents: usize,
28    /// Number of contracts this one depends on.
29    pub dependencies: usize,
30}
31
32/// Build a dependency graph from a set of contracts.
33///
34/// Each contract is identified by its stem (filename without `.yaml`).
35/// Dependencies come from `metadata.depends_on`.
36pub fn dependency_graph(contracts: &[(String, &crate::schema::Contract)]) -> DependencyGraph {
37    let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
38    let mut nodes = BTreeSet::new();
39
40    for (stem, contract) in contracts {
41        nodes.insert(stem.clone());
42        let deps = &contract.metadata.depends_on;
43        edges.insert(stem.clone(), deps.clone());
44        for dep in deps {
45            nodes.insert(dep.clone());
46        }
47    }
48
49    // Fill in missing nodes (dependencies that aren't in the input set)
50    for node in &nodes {
51        edges.entry(node.clone()).or_default();
52    }
53
54    let cycles = detect_cycles(&edges);
55    let topo_order = if cycles.is_empty() {
56        topological_sort(&edges, &nodes)
57    } else {
58        Vec::new()
59    };
60
61    DependencyGraph {
62        edges,
63        nodes,
64        topo_order,
65        cycles,
66    }
67}
68
69/// Get metadata about each node in the graph.
70pub fn graph_nodes(graph: &DependencyGraph) -> Vec<GraphNode> {
71    let mut dependents_count: BTreeMap<&str, usize> = BTreeMap::new();
72    for deps in graph.edges.values() {
73        for dep in deps {
74            *dependents_count.entry(dep.as_str()).or_default() += 1;
75        }
76    }
77
78    graph
79        .nodes
80        .iter()
81        .map(|stem| GraphNode {
82            stem: stem.clone(),
83            dependents: dependents_count.get(stem.as_str()).copied().unwrap_or(0),
84            dependencies: graph.edges.get(stem).map_or(0, Vec::len),
85        })
86        .collect()
87}
88
89/// Kahn's algorithm for topological sort (build order: dependencies first).
90fn topological_sort(
91    edges: &BTreeMap<String, Vec<String>>,
92    nodes: &BTreeSet<String>,
93) -> Vec<String> {
94    // Build reverse adjacency: for each dependency, track who depends on it
95    let mut reverse_edges: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
96    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
97    for node in nodes {
98        in_degree.insert(node.as_str(), 0);
99        reverse_edges.entry(node.as_str()).or_default();
100    }
101    // in_degree[node] = number of things node depends on
102    for (node, deps) in edges {
103        *in_degree.entry(node.as_str()).or_default() = deps.len();
104        for dep in deps {
105            reverse_edges
106                .entry(dep.as_str())
107                .or_default()
108                .push(node.as_str());
109        }
110    }
111
112    // Start with nodes that depend on nothing (foundations)
113    let mut queue: VecDeque<String> = nodes
114        .iter()
115        .filter(|n| in_degree.get(n.as_str()) == Some(&0))
116        .cloned()
117        .collect();
118
119    let mut result = Vec::new();
120    while let Some(node) = queue.pop_front() {
121        result.push(node.clone());
122        // For each node that depends on this one, decrement its in-degree
123        if let Some(dependents) = reverse_edges.get(node.as_str()) {
124            for &dependent in dependents {
125                if let Some(deg) = in_degree.get_mut(dependent) {
126                    *deg = deg.saturating_sub(1);
127                    if *deg == 0 {
128                        queue.push_back(dependent.to_string());
129                    }
130                }
131            }
132        }
133    }
134
135    result
136}
137
138#[derive(Clone, Copy, PartialEq)]
139enum DfsColor {
140    White,
141    Gray,
142    Black,
143}
144
145fn dfs_visit<'a>(
146    node: &'a str,
147    edges: &'a BTreeMap<String, Vec<String>>,
148    color: &mut BTreeMap<&'a str, DfsColor>,
149    path: &mut Vec<String>,
150    cycles: &mut Vec<Vec<String>>,
151) {
152    color.insert(node, DfsColor::Gray);
153    path.push(node.to_string());
154
155    if let Some(deps) = edges.get(node) {
156        for dep in deps {
157            match color.get(dep.as_str()) {
158                Some(DfsColor::Gray) => {
159                    if let Some(pos) = path.iter().position(|n| n == dep) {
160                        let cycle: Vec<String> = path[pos..].to_vec();
161                        cycles.push(cycle);
162                    }
163                }
164                Some(DfsColor::White) | None => {
165                    dfs_visit(dep.as_str(), edges, color, path, cycles);
166                }
167                Some(DfsColor::Black) => {}
168            }
169        }
170    }
171
172    path.pop();
173    color.insert(node, DfsColor::Black);
174}
175
176/// Detect cycles using DFS coloring.
177fn detect_cycles(edges: &BTreeMap<String, Vec<String>>) -> Vec<Vec<String>> {
178    let mut color: BTreeMap<&str, DfsColor> = BTreeMap::new();
179    for key in edges.keys() {
180        color.insert(key.as_str(), DfsColor::White);
181    }
182
183    let mut cycles = Vec::new();
184    let keys: Vec<String> = edges.keys().cloned().collect();
185    let mut path = Vec::new();
186    for node in &keys {
187        if color.get(node.as_str()) == Some(&DfsColor::White) {
188            dfs_visit(node.as_str(), edges, &mut color, &mut path, &mut cycles);
189        }
190    }
191
192    cycles
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::schema::parse_contract_str;
199
200    fn contract_with_deps(deps: &[&str]) -> crate::schema::Contract {
201        let deps_yaml = if deps.is_empty() {
202            String::new()
203        } else {
204            let items: Vec<String> = deps.iter().map(|d| format!("    - \"{d}\"")).collect();
205            format!("  depends_on:\n{}", items.join("\n"))
206        };
207        parse_contract_str(&format!(
208            r#"
209metadata:
210  version: "1.0.0"
211  description: "Test"
212  references: ["Paper"]
213{deps_yaml}
214equations:
215  f:
216    formula: "f(x) = x"
217falsification_tests: []
218"#
219        ))
220        .unwrap()
221    }
222
223    #[test]
224    fn empty_graph() {
225        let graph = dependency_graph(&[]);
226        assert!(graph.nodes.is_empty());
227        assert!(graph.edges.is_empty());
228        assert!(graph.cycles.is_empty());
229    }
230
231    #[test]
232    fn single_node_no_deps() {
233        let c = contract_with_deps(&[]);
234        let graph = dependency_graph(&[("softmax".to_string(), &c)]);
235        assert_eq!(graph.nodes.len(), 1);
236        assert!(graph.cycles.is_empty());
237        assert_eq!(graph.topo_order.len(), 1);
238    }
239
240    #[test]
241    fn linear_chain() {
242        let a = contract_with_deps(&["b"]);
243        let b = contract_with_deps(&["c"]);
244        let c = contract_with_deps(&[]);
245        let graph = dependency_graph(&[
246            ("a".to_string(), &a),
247            ("b".to_string(), &b),
248            ("c".to_string(), &c),
249        ]);
250        assert_eq!(graph.nodes.len(), 3);
251        assert!(graph.cycles.is_empty());
252        assert_eq!(graph.topo_order.len(), 3);
253    }
254
255    #[test]
256    fn cycle_detected() {
257        let a = contract_with_deps(&["b"]);
258        let b = contract_with_deps(&["a"]);
259        let graph = dependency_graph(&[("a".to_string(), &a), ("b".to_string(), &b)]);
260        assert!(!graph.cycles.is_empty());
261        assert!(graph.topo_order.is_empty());
262    }
263
264    #[test]
265    fn diamond_dependency() {
266        let a = contract_with_deps(&["b", "c"]);
267        let b = contract_with_deps(&["d"]);
268        let c = contract_with_deps(&["d"]);
269        let d = contract_with_deps(&[]);
270        let graph = dependency_graph(&[
271            ("a".to_string(), &a),
272            ("b".to_string(), &b),
273            ("c".to_string(), &c),
274            ("d".to_string(), &d),
275        ]);
276        assert_eq!(graph.nodes.len(), 4);
277        assert!(graph.cycles.is_empty());
278        assert_eq!(graph.topo_order.len(), 4);
279    }
280
281    #[test]
282    fn graph_nodes_metadata() {
283        let a = contract_with_deps(&["b"]);
284        let b = contract_with_deps(&[]);
285        let graph = dependency_graph(&[("a".to_string(), &a), ("b".to_string(), &b)]);
286        let nodes = graph_nodes(&graph);
287        assert_eq!(nodes.len(), 2);
288
289        let a_node = nodes.iter().find(|n| n.stem == "a").unwrap();
290        assert_eq!(a_node.dependencies, 1);
291        assert_eq!(a_node.dependents, 0);
292
293        let b_node = nodes.iter().find(|n| n.stem == "b").unwrap();
294        assert_eq!(b_node.dependencies, 0);
295        assert_eq!(b_node.dependents, 1);
296    }
297
298    #[test]
299    fn external_dependency_added_to_nodes() {
300        let a = contract_with_deps(&["external-crate"]);
301        let graph = dependency_graph(&[("a".to_string(), &a)]);
302        assert!(graph.nodes.contains("external-crate"));
303        assert_eq!(graph.nodes.len(), 2);
304    }
305
306    #[test]
307    fn topo_order_respects_deps() {
308        let a = contract_with_deps(&["b"]);
309        let b = contract_with_deps(&[]);
310        let graph = dependency_graph(&[("a".to_string(), &a), ("b".to_string(), &b)]);
311        let a_pos = graph.topo_order.iter().position(|n| n == "a").unwrap();
312        let b_pos = graph.topo_order.iter().position(|n| n == "b").unwrap();
313        // Build order: dependencies come before dependents.
314        // "a" depends on "b", so "b" (foundation) should come before "a".
315        assert!(b_pos < a_pos);
316    }
317}