Skip to main content

bones_triage/graph/
diagnostics.rs

1//! Planning and health diagnostics over dependency graphs.
2//!
3//! Exposes convenience helpers used by CLI diagnostics commands:
4//! - [`topological_layers`] for parallel execution planning
5//! - [`health_metrics`] for dashboard summaries
6//! - [`find_sccs`] for cycle reporting
7
8use std::collections::{BTreeSet, HashMap};
9
10use petgraph::{
11    Direction,
12    algo::condensation,
13    graph::{DiGraph as PetDiGraph, NodeIndex},
14    visit::{EdgeRef, IntoNodeIdentifiers},
15};
16
17use crate::graph::{
18    build::RawGraph, compute_critical_path, cycles::find_all_cycles, normalize::NormalizedGraph,
19    stats::GraphStats,
20};
21
22/// Directed dependency graph type used by diagnostics helpers.
23///
24/// Node weights are item IDs. Edge direction is `blocker -> blocked`.
25pub type DiGraph = PetDiGraph<String, ()>;
26
27/// Project-level health metrics derived from a dependency graph.
28#[derive(Debug, Clone, PartialEq)]
29pub struct HealthMetrics {
30    /// Graph density in `[0.0, 1.0]`.
31    pub density: f64,
32    /// Number of strongly connected components.
33    pub scc_count: usize,
34    /// Length of the longest dependency chain.
35    pub critical_path_length: usize,
36    /// Number of items currently blocking at least one other item.
37    pub blocker_count: usize,
38}
39
40/// Compute topological layers for parallel execution planning.
41///
42/// Each returned layer contains items that can be worked in parallel given the
43/// edge direction `blocker -> blocked`.
44///
45/// When `scope` is provided, only nodes equal to `scope` or prefixed by
46/// `"{scope}."` are included. This supports subtree-style IDs.
47#[must_use]
48pub fn topological_layers(graph: &DiGraph, scope: Option<&str>) -> Vec<Vec<String>> {
49    let scoped_ids = scoped_node_ids(graph, scope);
50    if scoped_ids.is_empty() {
51        return Vec::new();
52    }
53
54    let scoped_graph = build_scoped_graph(graph, &scoped_ids);
55
56    // Condense SCCs first so we can still produce deterministic layers when
57    // the raw graph contains cycles.
58    let condensed: PetDiGraph<Vec<String>, ()> = condensation(scoped_graph, true);
59
60    let mut indegree: HashMap<NodeIndex, usize> = condensed
61        .node_identifiers()
62        .map(|idx| {
63            (
64                idx,
65                condensed
66                    .neighbors_directed(idx, Direction::Incoming)
67                    .count(),
68            )
69        })
70        .collect();
71
72    let mut ready: Vec<NodeIndex> = indegree
73        .iter()
74        .filter_map(|(idx, deg)| (*deg == 0).then_some(*idx))
75        .collect();
76    ready.sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
77
78    let mut layers: Vec<Vec<String>> = Vec::new();
79
80    while !ready.is_empty() {
81        let current = std::mem::take(&mut ready);
82        let mut next_ready: Vec<NodeIndex> = Vec::new();
83        let mut layer: Vec<String> = Vec::new();
84
85        for idx in current {
86            let mut members = condensed.node_weight(idx).cloned().unwrap_or_default();
87            members.sort_unstable();
88            layer.extend(members);
89
90            for edge in condensed.edges_directed(idx, Direction::Outgoing) {
91                let target = edge.target();
92                if let Some(entry) = indegree.get_mut(&target)
93                    && *entry > 0
94                {
95                    *entry -= 1;
96                    if *entry == 0 {
97                        next_ready.push(target);
98                    }
99                }
100            }
101
102            indegree.remove(&idx);
103        }
104
105        layer.sort_unstable();
106        if !layer.is_empty() {
107            layers.push(layer);
108        }
109
110        next_ready
111            .sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
112        next_ready.dedup();
113        ready = next_ready;
114    }
115
116    // Defensive fallback: condensation should be acyclic, but if any nodes
117    // remain due to unexpected graph corruption, emit them deterministically.
118    if !indegree.is_empty() {
119        let mut leftovers: Vec<NodeIndex> = indegree.keys().copied().collect();
120        leftovers
121            .sort_by(|a, b| representative(&condensed, *a).cmp(representative(&condensed, *b)));
122
123        for idx in leftovers {
124            let mut layer = condensed.node_weight(idx).cloned().unwrap_or_default();
125            layer.sort_unstable();
126            if !layer.is_empty() {
127                layers.push(layer);
128            }
129        }
130    }
131
132    layers
133}
134
135/// Compute project health metrics from a dependency graph.
136#[must_use]
137pub fn health_metrics(graph: &DiGraph) -> HealthMetrics {
138    let node_map: HashMap<String, NodeIndex> = graph
139        .node_identifiers()
140        .filter_map(|idx| graph.node_weight(idx).map(|id| (id.clone(), idx)))
141        .collect();
142
143    let raw = RawGraph {
144        graph: graph.clone(),
145        node_map,
146        content_hash: graph_content_hash(graph),
147    };
148
149    let normalized = NormalizedGraph::from_raw(raw);
150    let stats = GraphStats::from_normalized(&normalized);
151    let cp = compute_critical_path(&normalized);
152
153    let blocker_count = graph
154        .node_identifiers()
155        .filter(|&idx| {
156            graph
157                .neighbors_directed(idx, Direction::Outgoing)
158                .next()
159                .is_some()
160        })
161        .count();
162
163    HealthMetrics {
164        density: stats.density,
165        scc_count: stats.scc_count,
166        critical_path_length: cp.total_length,
167        blocker_count,
168    }
169}
170
171/// Find dependency cycles (strongly connected components).
172///
173/// Returns only SCCs that represent actual cycles:
174/// - components with 2+ members
175/// - self-loop singleton components
176#[must_use]
177pub fn find_sccs(graph: &DiGraph) -> Vec<Vec<String>> {
178    find_all_cycles(graph)
179}
180
181fn scoped_node_ids(graph: &DiGraph, scope: Option<&str>) -> BTreeSet<String> {
182    scope.map_or_else(
183        || graph.node_weights().cloned().collect(),
184        |scope_id| {
185            let child_prefix = format!("{scope_id}.");
186            graph
187                .node_weights()
188                .filter(|id| id.as_str() == scope_id || id.starts_with(&child_prefix))
189                .cloned()
190                .collect()
191        },
192    )
193}
194
195fn build_scoped_graph(graph: &DiGraph, scoped_ids: &BTreeSet<String>) -> DiGraph {
196    let mut scoped = DiGraph::new();
197
198    let raw_nodes: HashMap<&str, NodeIndex> = graph
199        .node_identifiers()
200        .filter_map(|idx| graph.node_weight(idx).map(|id| (id.as_str(), idx)))
201        .collect();
202
203    let mut scoped_nodes: HashMap<&str, NodeIndex> = HashMap::with_capacity(scoped_ids.len());
204
205    for item_id in scoped_ids {
206        let idx = scoped.add_node(item_id.clone());
207        scoped_nodes.insert(item_id.as_str(), idx);
208    }
209
210    for from_id in scoped_ids {
211        let Some(&from_raw_idx) = raw_nodes.get(from_id.as_str()) else {
212            continue;
213        };
214        let Some(&from_scoped_idx) = scoped_nodes.get(from_id.as_str()) else {
215            continue;
216        };
217
218        for to_raw_idx in graph.neighbors_directed(from_raw_idx, Direction::Outgoing) {
219            let Some(to_id) = graph.node_weight(to_raw_idx).map(String::as_str) else {
220                continue;
221            };
222            let Some(&to_scoped_idx) = scoped_nodes.get(to_id) else {
223                continue;
224            };
225
226            if !scoped.contains_edge(from_scoped_idx, to_scoped_idx) {
227                scoped.add_edge(from_scoped_idx, to_scoped_idx, ());
228            }
229        }
230    }
231
232    scoped
233}
234
235fn representative(graph: &PetDiGraph<Vec<String>, ()>, idx: NodeIndex) -> &str {
236    graph
237        .node_weight(idx)
238        .and_then(|members| members.iter().min())
239        .map_or("", String::as_str)
240}
241
242fn graph_content_hash(graph: &DiGraph) -> String {
243    let mut edges: Vec<(String, String)> = graph
244        .edge_references()
245        .filter_map(|edge| {
246            let source = graph.node_weight(edge.source())?;
247            let target = graph.node_weight(edge.target())?;
248            Some((source.clone(), target.clone()))
249        })
250        .collect();
251    edges.sort_unstable();
252
253    let mut hasher = blake3::Hasher::new();
254    for (source, target) in edges {
255        hasher.update(source.as_bytes());
256        hasher.update(b"\x00");
257        hasher.update(target.as_bytes());
258        hasher.update(b"\x00");
259    }
260
261    format!("blake3:{}", hasher.finalize())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn build_graph(nodes: &[&str], edges: &[(&str, &str)]) -> DiGraph {
269        let mut graph = DiGraph::new();
270        let mut node_map: HashMap<&str, NodeIndex> = HashMap::new();
271
272        for id in nodes {
273            let idx = graph.add_node((*id).to_string());
274            node_map.insert(*id, idx);
275        }
276
277        for (from, to) in edges {
278            let from_idx = node_map[from];
279            let to_idx = node_map[to];
280            graph.add_edge(from_idx, to_idx, ());
281        }
282
283        graph
284    }
285
286    #[test]
287    fn topological_layers_linear_chain() {
288        let graph = build_graph(
289            &["bn-a", "bn-b", "bn-c"],
290            &[("bn-a", "bn-b"), ("bn-b", "bn-c")],
291        );
292
293        let layers = topological_layers(&graph, None);
294
295        assert_eq!(
296            layers,
297            vec![
298                vec!["bn-a".to_string()],
299                vec!["bn-b".to_string()],
300                vec!["bn-c".to_string()],
301            ]
302        );
303    }
304
305    #[test]
306    fn topological_layers_parallel_work() {
307        let graph = build_graph(
308            &["bn-a", "bn-b", "bn-c", "bn-d"],
309            &[("bn-a", "bn-c"), ("bn-b", "bn-c"), ("bn-c", "bn-d")],
310        );
311
312        let layers = topological_layers(&graph, None);
313
314        assert_eq!(
315            layers,
316            vec![
317                vec!["bn-a".to_string(), "bn-b".to_string()],
318                vec!["bn-c".to_string()],
319                vec!["bn-d".to_string()],
320            ]
321        );
322    }
323
324    #[test]
325    fn topological_layers_condenses_cycles() {
326        let graph = build_graph(
327            &["bn-a", "bn-b", "bn-c"],
328            &[("bn-a", "bn-b"), ("bn-b", "bn-a"), ("bn-b", "bn-c")],
329        );
330
331        let layers = topological_layers(&graph, None);
332
333        assert_eq!(
334            layers,
335            vec![
336                vec!["bn-a".to_string(), "bn-b".to_string()],
337                vec!["bn-c".to_string()],
338            ]
339        );
340    }
341
342    #[test]
343    fn find_sccs_detects_cycles_and_self_loops() {
344        let graph = build_graph(
345            &["bn-a", "bn-b", "bn-c", "bn-d"],
346            &[("bn-a", "bn-b"), ("bn-b", "bn-a"), ("bn-c", "bn-c")],
347        );
348
349        let sccs = find_sccs(&graph);
350
351        assert_eq!(
352            sccs,
353            vec![
354                vec!["bn-a".to_string(), "bn-b".to_string()],
355                vec!["bn-c".to_string()],
356            ]
357        );
358    }
359
360    #[test]
361    fn health_metrics_reports_expected_values() {
362        let graph = build_graph(
363            &["bn-a", "bn-b", "bn-c"],
364            &[("bn-a", "bn-b"), ("bn-b", "bn-c"), ("bn-a", "bn-c")],
365        );
366
367        let metrics = health_metrics(&graph);
368
369        assert!((metrics.density - 0.5).abs() < f64::EPSILON);
370        assert_eq!(metrics.scc_count, 3);
371        assert_eq!(metrics.critical_path_length, 3);
372        assert_eq!(metrics.blocker_count, 2);
373    }
374}