ascii_dag/layout/generic/
metrics.rs

1//! Graph metrics and statistics.
2//!
3//! This module provides lightweight statistical analysis of dependency graphs.
4//!
5//! # Examples
6//!
7//! ```
8//! use ascii_dag::layout::generic::metrics::GraphMetrics;
9//!
10//! let get_deps = |task: &&str| match *task {
11//!     "deploy" => vec!["build", "test"],
12//!     "build" => vec!["compile"],
13//!     "test" => vec!["compile"],
14//!     "compile" => vec![],
15//!     _ => vec![],
16//! };
17//!
18//! let tasks = ["deploy", "build", "test", "compile"];
19//! let metrics = GraphMetrics::compute(&tasks, get_deps);
20//!
21//! assert_eq!(metrics.node_count(), 4);
22//! assert_eq!(metrics.edge_count(), 4);
23//! // Max depth varies based on path (deploy has ancestors [build, compile] or [test, compile])
24//! assert!(metrics.max_depth() >= 2);
25//! ```
26
27use alloc::vec::Vec;
28use core::hash::Hash;
29
30use super::impact::compute_ancestors_fn;
31use super::impact::compute_descendants_fn;
32use crate::cycles::generic::roots::find_roots_fn;
33
34/// Statistical metrics for a dependency graph.
35///
36/// # Examples
37///
38/// ```
39/// use ascii_dag::layout::generic::metrics::GraphMetrics;
40///
41/// let get_deps = |&id: &usize| match id {
42///     1 => vec![],
43///     2 => vec![1],
44///     3 => vec![1, 2],
45///     _ => vec![],
46/// };
47///
48/// let items = [1, 2, 3];
49/// let metrics = GraphMetrics::compute(&items, get_deps);
50///
51/// println!("Nodes: {}", metrics.node_count());
52/// println!("Max depth: {}", metrics.max_depth());
53/// println!("Avg dependencies: {:.2}", metrics.avg_dependencies());
54/// ```
55#[derive(Debug, Clone)]
56pub struct GraphMetrics {
57    node_count: usize,
58    edge_count: usize,
59    root_count: usize,
60    leaf_count: usize,
61    max_depth: usize,
62    max_descendants: usize,
63    total_dependencies: usize,
64}
65
66impl GraphMetrics {
67    /// Compute metrics for a graph.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use ascii_dag::layout::generic::metrics::GraphMetrics;
73    ///
74    /// let get_deps = |&id: &usize| match id {
75    ///     1 => vec![],
76    ///     2 => vec![1],
77    ///     3 => vec![2],
78    ///     _ => vec![],
79    /// };
80    ///
81    /// let items = [1, 2, 3];
82    /// let metrics = GraphMetrics::compute(&items, get_deps);
83    /// assert_eq!(metrics.node_count(), 3);
84    /// ```
85    pub fn compute<Id, F>(items: &[Id], get_dependencies: F) -> Self
86    where
87        Id: Clone + Eq + Hash,
88        F: Fn(&Id) -> Vec<Id> + Clone,
89    {
90        let node_count = items.len();
91
92        // Count edges and total dependencies
93        let mut edge_count = 0;
94        let mut total_dependencies = 0;
95        for item in items {
96            let deps = get_dependencies(item);
97            let dep_count = deps.len();
98            edge_count += dep_count;
99            total_dependencies += dep_count;
100        }
101
102        // Find roots and leaves
103        let roots = find_roots_fn(items, get_dependencies.clone());
104        let root_count = roots.len();
105
106        let mut leaf_count = 0;
107        for candidate in items {
108            let mut is_leaf = true;
109            for item in items {
110                if get_dependencies(item).contains(candidate) {
111                    is_leaf = false;
112                    break;
113                }
114            }
115            if is_leaf {
116                leaf_count += 1;
117            }
118        }
119
120        // Calculate max depth (longest path from any root)
121        let mut max_depth = 0;
122        for item in items {
123            let ancestors = compute_ancestors_fn(items, item, get_dependencies.clone());
124            max_depth = max_depth.max(ancestors.len());
125        }
126
127        // Calculate max descendants (most impactful node)
128        let mut max_descendants = 0;
129        for item in items {
130            let descendants = compute_descendants_fn(items, item, get_dependencies.clone());
131            max_descendants = max_descendants.max(descendants.len());
132        }
133
134        Self {
135            node_count,
136            edge_count,
137            root_count,
138            leaf_count,
139            max_depth,
140            max_descendants,
141            total_dependencies,
142        }
143    }
144
145    /// Total number of nodes in the graph.
146    pub fn node_count(&self) -> usize {
147        self.node_count
148    }
149
150    /// Total number of edges (dependencies) in the graph.
151    pub fn edge_count(&self) -> usize {
152        self.edge_count
153    }
154
155    /// Number of root nodes (nodes with no dependencies).
156    pub fn root_count(&self) -> usize {
157        self.root_count
158    }
159
160    /// Number of leaf nodes (nodes that nothing depends on).
161    pub fn leaf_count(&self) -> usize {
162        self.leaf_count
163    }
164
165    /// Maximum depth (longest dependency chain from a root).
166    pub fn max_depth(&self) -> usize {
167        self.max_depth
168    }
169
170    /// Maximum number of descendants any single node has.
171    ///
172    /// This represents the "blast radius" of the most impactful node.
173    pub fn max_descendants(&self) -> usize {
174        self.max_descendants
175    }
176
177    /// Average number of dependencies per node.
178    pub fn avg_dependencies(&self) -> f64 {
179        if self.node_count == 0 {
180            0.0
181        } else {
182            self.total_dependencies as f64 / self.node_count as f64
183        }
184    }
185
186    /// Graph density (ratio of actual edges to possible edges).
187    ///
188    /// Returns a value between 0.0 (no edges) and 1.0 (complete graph).
189    pub fn density(&self) -> f64 {
190        if self.node_count <= 1 {
191            0.0
192        } else {
193            let max_possible_edges = self.node_count * (self.node_count - 1);
194            self.edge_count as f64 / max_possible_edges as f64
195        }
196    }
197
198    /// Check if this is a tree (single root, no multiple paths to same node).
199    pub fn is_tree(&self) -> bool {
200        self.root_count == 1 && self.edge_count == self.node_count.saturating_sub(1)
201    }
202
203    /// Check if this is a forest (multiple trees, no cycles).
204    pub fn is_forest(&self) -> bool {
205        self.edge_count == self.node_count.saturating_sub(self.root_count)
206    }
207
208    /// Check if the graph is sparse (few edges relative to nodes).
209    pub fn is_sparse(&self) -> bool {
210        self.density() < 0.1
211    }
212
213    /// Check if the graph is dense (many edges relative to nodes).
214    pub fn is_dense(&self) -> bool {
215        self.density() > 0.5
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_simple_chain() {
225        let get_deps = |&id: &usize| match id {
226            1 => vec![],
227            2 => vec![1],
228            3 => vec![2],
229            _ => vec![],
230        };
231
232        let items = [1, 2, 3];
233        let metrics = GraphMetrics::compute(&items, get_deps);
234
235        assert_eq!(metrics.node_count(), 3);
236        assert_eq!(metrics.edge_count(), 2);
237        assert_eq!(metrics.root_count(), 1);
238        assert_eq!(metrics.leaf_count(), 1);
239        assert_eq!(metrics.max_depth(), 2);
240        assert!(metrics.is_tree());
241    }
242
243    #[test]
244    fn test_diamond() {
245        let get_deps = |&id: &usize| match id {
246            1 => vec![],
247            2 => vec![1],
248            3 => vec![1],
249            4 => vec![2, 3],
250            _ => vec![],
251        };
252
253        let items = [1, 2, 3, 4];
254        let metrics = GraphMetrics::compute(&items, get_deps);
255
256        assert_eq!(metrics.node_count(), 4);
257        assert_eq!(metrics.edge_count(), 4);
258        assert_eq!(metrics.root_count(), 1);
259        assert_eq!(metrics.leaf_count(), 1);
260        // Max depth is 2: node 4 has ancestors [2, 3, 1] = 3 total ancestors
261        // But depth is number of levels, not number of ancestors
262        // 1 is at depth 0, 2/3 at depth 1, 4 at depth 2
263        assert!(metrics.max_depth() >= 2);
264        assert_eq!(metrics.max_descendants(), 3);
265        assert!(!metrics.is_tree()); // Diamond has 4 edges, tree would have 3
266    }
267
268    #[test]
269    fn test_multiple_roots() {
270        let get_deps = |&id: &usize| match id {
271            1 => vec![],
272            2 => vec![],
273            3 => vec![1, 2],
274            _ => vec![],
275        };
276
277        let items = [1, 2, 3];
278        let metrics = GraphMetrics::compute(&items, get_deps);
279
280        assert_eq!(metrics.root_count(), 2);
281        assert_eq!(metrics.leaf_count(), 1);
282        assert!(!metrics.is_tree()); // Multiple roots
283        // Not a forest either because 3 has 2 deps but only 2 roots
284        assert_eq!(metrics.edge_count(), 2);
285    }
286
287    #[test]
288    fn test_empty_graph() {
289        let get_deps = |_: &usize| vec![];
290        let items: [usize; 0] = [];
291        let metrics = GraphMetrics::compute(&items, get_deps);
292
293        assert_eq!(metrics.node_count(), 0);
294        assert_eq!(metrics.edge_count(), 0);
295        assert_eq!(metrics.avg_dependencies(), 0.0);
296    }
297
298    #[test]
299    fn test_isolated_nodes() {
300        let get_deps = |_: &usize| vec![];
301        let items = [1, 2, 3, 4, 5];
302        let metrics = GraphMetrics::compute(&items, get_deps);
303
304        assert_eq!(metrics.node_count(), 5);
305        assert_eq!(metrics.edge_count(), 0);
306        assert_eq!(metrics.root_count(), 5);
307        assert_eq!(metrics.leaf_count(), 5);
308        assert_eq!(metrics.density(), 0.0);
309        assert!(metrics.is_sparse());
310    }
311
312    #[test]
313    fn test_avg_dependencies() {
314        let get_deps = |&id: &usize| match id {
315            1 => vec![],
316            2 => vec![1],
317            3 => vec![1, 2],
318            _ => vec![],
319        };
320
321        let items = [1, 2, 3];
322        let metrics = GraphMetrics::compute(&items, get_deps);
323
324        // Total deps: 0 + 1 + 2 = 3, avg = 3/3 = 1.0
325        assert_eq!(metrics.avg_dependencies(), 1.0);
326    }
327}