Skip to main content

trueno_graph/algorithms/
topo.rs

1//! Topological algorithms: cycle detection and topological sort
2//!
3//! Provides graph ordering algorithms needed for dependency analysis:
4//! - `is_cyclic`: Detect if a directed graph contains cycles
5//! - `toposort`: Topological ordering of a DAG (Directed Acyclic Graph)
6//!
7//! # Example
8//!
9//! ```
10//! use trueno_graph::{CsrGraph, NodeId, is_cyclic, toposort};
11//!
12//! // Build a DAG: 0 → 1 → 2
13//! let edges = vec![
14//!     (NodeId(0), NodeId(1), 1.0),
15//!     (NodeId(1), NodeId(2), 1.0),
16//! ];
17//! let graph = CsrGraph::from_edge_list(&edges).unwrap();
18//!
19//! assert!(!is_cyclic(&graph));
20//!
21//! let order = toposort(&graph).unwrap();
22//! assert_eq!(order, vec![NodeId(0), NodeId(1), NodeId(2)]);
23//! ```
24
25use crate::storage::CsrGraph;
26use crate::NodeId;
27use anyhow::{anyhow, Result};
28
29/// Node state during DFS traversal
30#[derive(Clone, Copy, PartialEq, Eq)]
31enum NodeState {
32    /// Not yet visited
33    Unvisited,
34    /// Currently in DFS stack (part of current path)
35    InStack,
36    /// Fully processed (all descendants visited)
37    Finished,
38}
39
40/// Check if the graph contains any cycles
41///
42/// Uses depth-first search with three-color marking:
43/// - Unvisited: not yet seen
44/// - `InStack`: currently being explored (back edge = cycle)
45/// - Finished: fully explored
46///
47/// # Arguments
48///
49/// * `graph` - The CSR graph to check
50///
51/// # Returns
52///
53/// `true` if the graph contains at least one cycle, `false` otherwise
54///
55/// # Example
56///
57/// ```
58/// use trueno_graph::{CsrGraph, NodeId, is_cyclic};
59///
60/// // Acyclic: 0 → 1 → 2
61/// let dag = CsrGraph::from_edge_list(&[
62///     (NodeId(0), NodeId(1), 1.0),
63///     (NodeId(1), NodeId(2), 1.0),
64/// ]).unwrap();
65/// assert!(!is_cyclic(&dag));
66///
67/// // Cyclic: 0 → 1 → 2 → 0
68/// let cyclic = CsrGraph::from_edge_list(&[
69///     (NodeId(0), NodeId(1), 1.0),
70///     (NodeId(1), NodeId(2), 1.0),
71///     (NodeId(2), NodeId(0), 1.0),
72/// ]).unwrap();
73/// assert!(is_cyclic(&cyclic));
74/// ```
75#[must_use]
76pub fn is_cyclic(graph: &CsrGraph) -> bool {
77    let n = graph.num_nodes();
78    if n == 0 {
79        return false;
80    }
81
82    let mut state = vec![NodeState::Unvisited; n];
83
84    // Check all nodes (handles disconnected components)
85    for start in 0..n {
86        if state[start] == NodeState::Unvisited && has_cycle_from(graph, start, &mut state) {
87            return true;
88        }
89    }
90
91    false
92}
93
94/// DFS helper to detect cycle starting from a node
95fn has_cycle_from(graph: &CsrGraph, node: usize, state: &mut [NodeState]) -> bool {
96    state[node] = NodeState::InStack;
97
98    // Check all outgoing neighbors
99    #[allow(clippy::cast_possible_truncation)]
100    let neighbors = graph.outgoing_neighbors(NodeId(node as u32));
101
102    if let Ok(neighbors) = neighbors {
103        for &neighbor in neighbors {
104            let neighbor_idx = neighbor as usize;
105
106            match state[neighbor_idx] {
107                NodeState::InStack => {
108                    // Back edge found - cycle detected!
109                    return true;
110                }
111                NodeState::Unvisited => {
112                    if has_cycle_from(graph, neighbor_idx, state) {
113                        return true;
114                    }
115                }
116                NodeState::Finished => {
117                    // Already fully explored, skip
118                }
119            }
120        }
121    }
122
123    state[node] = NodeState::Finished;
124    false
125}
126
127/// Compute topological ordering of a directed acyclic graph (DAG)
128///
129/// Returns nodes in an order where for every edge u → v, u appears before v.
130/// This is the order needed for dependency resolution (dependencies first).
131///
132/// # Arguments
133///
134/// * `graph` - The CSR graph to sort
135///
136/// # Returns
137///
138/// * `Ok(Vec<NodeId>)` - Nodes in topological order
139/// * `Err` - If the graph contains a cycle
140///
141/// # Errors
142///
143/// Returns an error if the graph contains a cycle, making topological ordering impossible.
144///
145/// # Example
146///
147/// ```
148/// use trueno_graph::{CsrGraph, NodeId, toposort};
149///
150/// // DAG: 0 → 1 → 2, 0 → 2
151/// let graph = CsrGraph::from_edge_list(&[
152///     (NodeId(0), NodeId(1), 1.0),
153///     (NodeId(1), NodeId(2), 1.0),
154///     (NodeId(0), NodeId(2), 1.0),
155/// ]).unwrap();
156///
157/// let order = toposort(&graph).unwrap();
158///
159/// // Node 0 must come before 1 and 2
160/// let pos_0 = order.iter().position(|&n| n == NodeId(0)).unwrap();
161/// let pos_1 = order.iter().position(|&n| n == NodeId(1)).unwrap();
162/// let pos_2 = order.iter().position(|&n| n == NodeId(2)).unwrap();
163///
164/// assert!(pos_0 < pos_1);
165/// assert!(pos_0 < pos_2);
166/// assert!(pos_1 < pos_2);
167/// ```
168pub fn toposort(graph: &CsrGraph) -> Result<Vec<NodeId>> {
169    let n = graph.num_nodes();
170    if n == 0 {
171        return Ok(Vec::new());
172    }
173
174    let mut state = vec![NodeState::Unvisited; n];
175    let mut result = Vec::with_capacity(n);
176
177    // Visit all nodes (handles disconnected components)
178    for start in 0..n {
179        if state[start] == NodeState::Unvisited {
180            toposort_dfs(graph, start, &mut state, &mut result)?;
181        }
182    }
183
184    // Reverse to get correct order (DFS gives reverse topological order)
185    result.reverse();
186    Ok(result)
187}
188
189/// DFS helper for topological sort
190fn toposort_dfs(
191    graph: &CsrGraph,
192    node: usize,
193    state: &mut [NodeState],
194    result: &mut Vec<NodeId>,
195) -> Result<()> {
196    state[node] = NodeState::InStack;
197
198    // Visit all outgoing neighbors
199    #[allow(clippy::cast_possible_truncation)]
200    let neighbors = graph.outgoing_neighbors(NodeId(node as u32))?;
201
202    for &neighbor in neighbors {
203        let neighbor_idx = neighbor as usize;
204
205        match state[neighbor_idx] {
206            NodeState::InStack => {
207                // Back edge found - cycle detected!
208                return Err(anyhow!("Cycle detected: cannot compute topological order"));
209            }
210            NodeState::Unvisited => {
211                toposort_dfs(graph, neighbor_idx, state, result)?;
212            }
213            NodeState::Finished => {
214                // Already processed, skip
215            }
216        }
217    }
218
219    state[node] = NodeState::Finished;
220
221    #[allow(clippy::cast_possible_truncation)]
222    result.push(NodeId(node as u32));
223
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_empty_graph_not_cyclic() {
233        let graph = CsrGraph::new();
234        assert!(!is_cyclic(&graph));
235    }
236
237    #[test]
238    fn test_empty_graph_toposort() {
239        let graph = CsrGraph::new();
240        let order = toposort(&graph).unwrap();
241        assert!(order.is_empty());
242    }
243
244    #[test]
245    fn test_single_node_not_cyclic() {
246        let mut graph = CsrGraph::new();
247        graph.add_edge(NodeId(0), NodeId(1), 1.0).unwrap();
248        // Node 0 exists with edge to node 1
249        assert!(!is_cyclic(&graph));
250    }
251
252    #[test]
253    fn test_self_loop_is_cyclic() {
254        let edges = vec![(NodeId(0), NodeId(0), 1.0)]; // Self-loop
255        let graph = CsrGraph::from_edge_list(&edges).unwrap();
256        assert!(is_cyclic(&graph));
257    }
258
259    #[test]
260    fn test_simple_dag_not_cyclic() {
261        // 0 → 1 → 2
262        let edges = vec![(NodeId(0), NodeId(1), 1.0), (NodeId(1), NodeId(2), 1.0)];
263        let graph = CsrGraph::from_edge_list(&edges).unwrap();
264        assert!(!is_cyclic(&graph));
265    }
266
267    #[test]
268    fn test_simple_cycle() {
269        // 0 → 1 → 2 → 0 (cycle)
270        let edges = vec![
271            (NodeId(0), NodeId(1), 1.0),
272            (NodeId(1), NodeId(2), 1.0),
273            (NodeId(2), NodeId(0), 1.0),
274        ];
275        let graph = CsrGraph::from_edge_list(&edges).unwrap();
276        assert!(is_cyclic(&graph));
277    }
278
279    #[test]
280    fn test_diamond_dag_not_cyclic() {
281        // Diamond: 0 → 1 → 3, 0 → 2 → 3
282        let edges = vec![
283            (NodeId(0), NodeId(1), 1.0),
284            (NodeId(0), NodeId(2), 1.0),
285            (NodeId(1), NodeId(3), 1.0),
286            (NodeId(2), NodeId(3), 1.0),
287        ];
288        let graph = CsrGraph::from_edge_list(&edges).unwrap();
289        assert!(!is_cyclic(&graph));
290    }
291
292    #[test]
293    fn test_toposort_simple_chain() {
294        // 0 → 1 → 2
295        let edges = vec![(NodeId(0), NodeId(1), 1.0), (NodeId(1), NodeId(2), 1.0)];
296        let graph = CsrGraph::from_edge_list(&edges).unwrap();
297
298        let order = toposort(&graph).unwrap();
299        assert_eq!(order, vec![NodeId(0), NodeId(1), NodeId(2)]);
300    }
301
302    #[test]
303    fn test_toposort_diamond() {
304        // Diamond: 0 → 1 → 3, 0 → 2 → 3
305        let edges = vec![
306            (NodeId(0), NodeId(1), 1.0),
307            (NodeId(0), NodeId(2), 1.0),
308            (NodeId(1), NodeId(3), 1.0),
309            (NodeId(2), NodeId(3), 1.0),
310        ];
311        let graph = CsrGraph::from_edge_list(&edges).unwrap();
312
313        let order = toposort(&graph).unwrap();
314
315        // Verify constraints
316        let pos = |n: u32| order.iter().position(|&x| x == NodeId(n)).unwrap();
317
318        assert!(pos(0) < pos(1), "0 must come before 1");
319        assert!(pos(0) < pos(2), "0 must come before 2");
320        assert!(pos(1) < pos(3), "1 must come before 3");
321        assert!(pos(2) < pos(3), "2 must come before 3");
322    }
323
324    #[test]
325    fn test_toposort_fails_on_cycle() {
326        // 0 → 1 → 2 → 0 (cycle)
327        let edges = vec![
328            (NodeId(0), NodeId(1), 1.0),
329            (NodeId(1), NodeId(2), 1.0),
330            (NodeId(2), NodeId(0), 1.0),
331        ];
332        let graph = CsrGraph::from_edge_list(&edges).unwrap();
333
334        let result = toposort(&graph);
335        assert!(result.is_err());
336        assert!(result.unwrap_err().to_string().contains("Cycle"));
337    }
338
339    #[test]
340    fn test_disconnected_components() {
341        // Two disconnected chains: 0 → 1, 2 → 3
342        let edges = vec![(NodeId(0), NodeId(1), 1.0), (NodeId(2), NodeId(3), 1.0)];
343        let graph = CsrGraph::from_edge_list(&edges).unwrap();
344
345        assert!(!is_cyclic(&graph));
346
347        let order = toposort(&graph).unwrap();
348        assert_eq!(order.len(), 4);
349
350        // Verify ordering constraints
351        let pos = |n: u32| order.iter().position(|&x| x == NodeId(n)).unwrap();
352        assert!(pos(0) < pos(1));
353        assert!(pos(2) < pos(3));
354    }
355
356    #[test]
357    fn test_complex_dag() {
358        // Complex DAG with multiple paths
359        //     0
360        //    /|\
361        //   1 2 3
362        //    \|/
363        //     4
364        let edges = vec![
365            (NodeId(0), NodeId(1), 1.0),
366            (NodeId(0), NodeId(2), 1.0),
367            (NodeId(0), NodeId(3), 1.0),
368            (NodeId(1), NodeId(4), 1.0),
369            (NodeId(2), NodeId(4), 1.0),
370            (NodeId(3), NodeId(4), 1.0),
371        ];
372        let graph = CsrGraph::from_edge_list(&edges).unwrap();
373
374        assert!(!is_cyclic(&graph));
375
376        let order = toposort(&graph).unwrap();
377
378        // 0 must be first, 4 must be last
379        assert_eq!(order[0], NodeId(0));
380        assert_eq!(order[4], NodeId(4));
381    }
382
383    #[test]
384    fn test_two_node_cycle() {
385        // Simple 2-node cycle: 0 ↔ 1
386        let edges = vec![(NodeId(0), NodeId(1), 1.0), (NodeId(1), NodeId(0), 1.0)];
387        let graph = CsrGraph::from_edge_list(&edges).unwrap();
388        assert!(is_cyclic(&graph));
389    }
390
391    #[test]
392    fn test_cycle_in_subgraph() {
393        // DAG with cycle in subgraph: 0 → 1 → 2 → 1 (cycle), 3 → 4
394        let edges = vec![
395            (NodeId(0), NodeId(1), 1.0),
396            (NodeId(1), NodeId(2), 1.0),
397            (NodeId(2), NodeId(1), 1.0), // Creates cycle
398            (NodeId(3), NodeId(4), 1.0),
399        ];
400        let graph = CsrGraph::from_edge_list(&edges).unwrap();
401        assert!(is_cyclic(&graph));
402    }
403}