Skip to main content

cognis_graph/
analysis.rs

1//! Static graph analysis — cycle detection, longest path, reachability.
2//!
3//! Operates on the **static** edges declared via [`crate::builder::Graph::edge`].
4//! Dynamic routing (a node returning `Goto::Send` to a peer that wasn't
5//! declared statically) is invisible here — by design, since this is a
6//! diagnostics tool for the graph topology you wrote, not its runtime
7//! behavior.
8//!
9//! ```ignore
10//! let analysis = compiled.analyze();
11//! if analysis.has_cycle {
12//!     eprintln!("cycle detected");
13//! }
14//! println!("longest path: {} hops", analysis.depth);
15//! ```
16//!
17//! `depth` is the longest *acyclic* path from the start node to any
18//! reachable node, measured in edge hops. For graphs with cycles, depth
19//! is `0` (it's only meaningful for DAGs).
20
21use std::collections::{HashMap, HashSet, VecDeque};
22
23use crate::compiled::CompiledGraph;
24use crate::state::GraphState;
25use crate::viz::extract_edges;
26
27/// Output of [`CompiledGraph::analyze`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct GraphAnalysis {
30    /// Number of registered nodes (excludes the synthetic `__END__`).
31    pub node_count: usize,
32    /// Number of static edges declared on the graph.
33    pub edge_count: usize,
34    /// `true` if the static edge graph contains a cycle reachable from
35    /// the start node.
36    pub has_cycle: bool,
37    /// Longest path from the start node to any reachable node, measured
38    /// in edges. `0` if the graph contains a cycle (depth is undefined
39    /// for non-DAGs) or has no start node.
40    pub depth: usize,
41    /// Nodes registered on the graph but not reachable from the start
42    /// via static edges. Sorted alphabetically.
43    pub unreachable: Vec<String>,
44}
45
46impl<S: GraphState> CompiledGraph<S> {
47    /// Compute static-graph diagnostics. See [`GraphAnalysis`].
48    pub fn analyze(&self) -> GraphAnalysis {
49        let nodes: Vec<&String> = self.graph.nodes.keys().collect();
50        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
51        for node in &nodes {
52            adj.insert(node.as_str(), Vec::new());
53        }
54        let edges = extract_edges(self);
55        for e in &edges {
56            adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
57        }
58
59        let start = self.graph.start.as_deref();
60
61        // Reachability via BFS from start. Skips the synthetic __END__
62        // sink — we care about whether registered nodes are reached.
63        let mut reachable: HashSet<&str> = HashSet::new();
64        if let Some(s) = start {
65            let mut q: VecDeque<&str> = VecDeque::from([s]);
66            reachable.insert(s);
67            while let Some(n) = q.pop_front() {
68                if let Some(nexts) = adj.get(n) {
69                    for &m in nexts {
70                        if m == "__END__" {
71                            continue;
72                        }
73                        if reachable.insert(m) {
74                            q.push_back(m);
75                        }
76                    }
77                }
78            }
79        }
80        let mut unreachable: Vec<String> = nodes
81            .iter()
82            .filter(|n| !reachable.contains(n.as_str()))
83            .map(|n| (*n).clone())
84            .collect();
85        unreachable.sort();
86
87        // Cycle detection over the reachable subgraph via DFS three-color.
88        let has_cycle = match start {
89            Some(s) => detect_cycle(s, &adj, &reachable),
90            None => false,
91        };
92
93        // Longest path: only meaningful for DAGs reachable from start.
94        let depth = match start {
95            Some(s) if !has_cycle => longest_path(s, &adj, &reachable),
96            _ => 0,
97        };
98
99        GraphAnalysis {
100            node_count: self.graph.nodes.len(),
101            edge_count: edges.len(),
102            has_cycle,
103            depth,
104            unreachable,
105        }
106    }
107}
108
109fn detect_cycle<'a>(
110    start: &'a str,
111    adj: &HashMap<&'a str, Vec<&'a str>>,
112    reachable: &HashSet<&'a str>,
113) -> bool {
114    enum Color {
115        White,
116        Gray,
117        Black,
118    }
119    let mut color: HashMap<&str, Color> = HashMap::new();
120    for &n in reachable {
121        color.insert(n, Color::White);
122    }
123    fn visit<'a>(
124        node: &'a str,
125        adj: &HashMap<&'a str, Vec<&'a str>>,
126        reachable: &HashSet<&'a str>,
127        color: &mut HashMap<&'a str, Color>,
128    ) -> bool {
129        color.insert(node, Color::Gray);
130        if let Some(nexts) = adj.get(node) {
131            for &m in nexts {
132                if m == "__END__" || !reachable.contains(m) {
133                    continue;
134                }
135                let recurse = match color.get(m) {
136                    Some(Color::Gray) => return true, // back-edge → cycle
137                    Some(Color::White) => true,
138                    _ => false,
139                };
140                if recurse && visit(m, adj, reachable, color) {
141                    return true;
142                }
143            }
144        }
145        color.insert(node, Color::Black);
146        false
147    }
148    visit(start, adj, reachable, &mut color)
149}
150
151fn longest_path<'a>(
152    start: &'a str,
153    adj: &HashMap<&'a str, Vec<&'a str>>,
154    reachable: &HashSet<&'a str>,
155) -> usize {
156    // Memoized DFS over the DAG. Caller must ensure no cycles.
157    fn dfs<'a>(
158        node: &'a str,
159        adj: &HashMap<&'a str, Vec<&'a str>>,
160        reachable: &HashSet<&'a str>,
161        memo: &mut HashMap<&'a str, usize>,
162    ) -> usize {
163        if let Some(&d) = memo.get(node) {
164            return d;
165        }
166        let mut best = 0;
167        if let Some(nexts) = adj.get(node) {
168            for &m in nexts {
169                if m == "__END__" || !reachable.contains(m) {
170                    continue;
171                }
172                let d = 1 + dfs(m, adj, reachable, memo);
173                if d > best {
174                    best = d;
175                }
176            }
177        }
178        memo.insert(node, best);
179        best
180    }
181    let mut memo: HashMap<&str, usize> = HashMap::new();
182    dfs(start, adj, reachable, &mut memo)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::builder::Graph;
189    use crate::goto::Goto;
190    use crate::node::{node_fn, NodeOut};
191
192    #[derive(Default, Clone)]
193    struct S;
194    #[derive(Default)]
195    struct SU;
196    impl GraphState for S {
197        type Update = SU;
198        fn apply(&mut self, _: Self::Update) {}
199    }
200
201    fn nf(name: &'static str) -> impl crate::Node<S> + 'static {
202        node_fn::<S, _, _>(name, |_, _| async move {
203            Ok(NodeOut {
204                update: SU,
205                goto: Goto::end(),
206            })
207        })
208    }
209
210    #[test]
211    fn linear_three_node_chain_depth_2() {
212        // a → b → c
213        let g = Graph::<S>::new()
214            .node("a", nf("a"))
215            .node("b", nf("b"))
216            .node("c", nf("c"))
217            .edge("a", "b")
218            .edge("b", "c")
219            .start_at("a")
220            .compile()
221            .unwrap();
222        let r = g.analyze();
223        assert_eq!(r.node_count, 3);
224        assert_eq!(r.edge_count, 2);
225        assert!(!r.has_cycle);
226        assert_eq!(r.depth, 2);
227        assert!(r.unreachable.is_empty());
228    }
229
230    #[test]
231    fn diamond_depth_is_2() {
232        // a → b → d
233        // a → c → d
234        let g = Graph::<S>::new()
235            .node("a", nf("a"))
236            .node("b", nf("b"))
237            .node("c", nf("c"))
238            .node("d", nf("d"))
239            .edge("a", "b")
240            .edge("a", "c")
241            .edge("b", "d")
242            .edge("c", "d")
243            .start_at("a")
244            .compile()
245            .unwrap();
246        let r = g.analyze();
247        assert!(!r.has_cycle);
248        assert_eq!(r.depth, 2);
249    }
250
251    #[test]
252    fn self_loop_is_a_cycle() {
253        // a → a (cycle)
254        let g = Graph::<S>::new()
255            .node("a", nf("a"))
256            .edge("a", "a")
257            .start_at("a")
258            .compile()
259            .unwrap();
260        let r = g.analyze();
261        assert!(r.has_cycle);
262        assert_eq!(r.depth, 0, "depth undefined for graphs with cycles");
263    }
264
265    #[test]
266    fn detects_back_edge_cycle() {
267        // a → b → c → a
268        let g = Graph::<S>::new()
269            .node("a", nf("a"))
270            .node("b", nf("b"))
271            .node("c", nf("c"))
272            .edge("a", "b")
273            .edge("b", "c")
274            .edge("c", "a")
275            .start_at("a")
276            .compile()
277            .unwrap();
278        let r = g.analyze();
279        assert!(r.has_cycle);
280    }
281
282    #[test]
283    fn unreachable_nodes_listed() {
284        // a → b ;  c is registered but no edge to/from start.
285        let g = Graph::<S>::new()
286            .node("a", nf("a"))
287            .node("b", nf("b"))
288            .node("c", nf("c"))
289            .edge("a", "b")
290            .start_at("a")
291            .compile()
292            .unwrap();
293        let r = g.analyze();
294        assert_eq!(r.unreachable, vec!["c".to_string()]);
295        assert!(!r.has_cycle);
296        assert_eq!(r.depth, 1);
297    }
298}