Skip to main content

batuta/stack/diagnostics/
engine.rs

1//! Stack diagnostics engine
2//!
3//! This module contains the main `StackDiagnostics` struct which serves as the
4//! primary analysis engine for stack health monitoring. It includes graph
5//! algorithms like PageRank and Betweenness Centrality.
6
7use super::types::{
8    AndonStatus, Anomaly, ComponentNode, GraphMetrics, HealthStatus, HealthSummary,
9};
10use crate::stack::DependencyGraph;
11use anyhow::Result;
12use std::collections::HashMap;
13
14// ============================================================================
15// Stack Diagnostics Engine
16// ============================================================================
17
18/// Main diagnostics engine for stack analysis
19#[derive(Debug)]
20pub struct StackDiagnostics {
21    /// Component nodes
22    components: HashMap<String, ComponentNode>,
23    /// Dependency graph
24    graph: Option<DependencyGraph>,
25    /// Computed graph metrics
26    metrics: GraphMetrics,
27    /// Detected anomalies
28    anomalies: Vec<Anomaly>,
29}
30
31impl StackDiagnostics {
32    /// Create a new diagnostics engine
33    pub fn new() -> Self {
34        Self {
35            components: HashMap::new(),
36            graph: None,
37            metrics: GraphMetrics::default(),
38            anomalies: Vec::new(),
39        }
40    }
41
42    /// Add a component to the knowledge graph
43    pub fn add_component(&mut self, node: ComponentNode) {
44        self.components.insert(node.name.clone(), node);
45    }
46
47    /// Get a component by name
48    pub fn get_component(&self, name: &str) -> Option<&ComponentNode> {
49        self.components.get(name)
50    }
51
52    /// Get all components
53    pub fn components(&self) -> impl Iterator<Item = &ComponentNode> {
54        self.components.values()
55    }
56
57    /// Get component count
58    pub fn component_count(&self) -> usize {
59        self.components.len()
60    }
61
62    /// Set the dependency graph
63    pub fn set_graph(&mut self, graph: DependencyGraph) {
64        self.graph = Some(graph);
65    }
66
67    /// Get the dependency graph
68    pub fn graph(&self) -> Option<&DependencyGraph> {
69        self.graph.as_ref()
70    }
71
72    /// Compute graph metrics (PageRank, Betweenness, etc.)
73    pub fn compute_metrics(&mut self) -> Result<&GraphMetrics> {
74        let n = self.components.len();
75        if n == 0 {
76            return Ok(&self.metrics);
77        }
78
79        self.metrics.total_nodes = n;
80
81        // Build adjacency from dependency graph if available
82        let adjacency = self.build_adjacency();
83
84        // Compute PageRank
85        self.compute_pagerank(&adjacency, 0.85, 100);
86
87        // Compute Betweenness Centrality
88        self.compute_betweenness(&adjacency);
89
90        // Compute depth from roots
91        self.compute_depth(&adjacency);
92
93        // Compute graph-level metrics
94        self.metrics.total_edges = adjacency.values().map(|v| v.len()).sum();
95        let max_edges = n * (n.saturating_sub(1));
96        self.metrics.density =
97            if max_edges > 0 { self.metrics.total_edges as f64 / max_edges as f64 } else { 0.0 };
98        self.metrics.avg_degree =
99            if n > 0 { self.metrics.total_edges as f64 / n as f64 } else { 0.0 };
100        self.metrics.max_depth = self.metrics.depth_map.values().copied().max().unwrap_or(0);
101
102        Ok(&self.metrics)
103    }
104
105    /// Build adjacency list from dependency graph
106    fn build_adjacency(&self) -> HashMap<String, Vec<String>> {
107        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
108
109        // Initialize all nodes
110        for name in self.components.keys() {
111            adjacency.insert(name.clone(), Vec::new());
112        }
113
114        // Add edges from dependency graph
115        if let Some(graph) = &self.graph {
116            for crate_info in graph.all_crates() {
117                let from = &crate_info.name;
118                for dep in &crate_info.paiml_dependencies {
119                    if self.components.contains_key(&dep.name) {
120                        adjacency.entry(from.clone()).or_default().push(dep.name.clone());
121                    }
122                }
123            }
124        }
125
126        adjacency
127    }
128
129    /// Compute PageRank using power iteration
130    fn compute_pagerank(
131        &mut self,
132        adjacency: &HashMap<String, Vec<String>>,
133        damping: f64,
134        max_iter: usize,
135    ) {
136        let n = self.components.len();
137        if n == 0 {
138            return;
139        }
140
141        let initial = 1.0 / n as f64;
142        let mut scores: HashMap<String, f64> =
143            self.components.keys().map(|k| (k.clone(), initial)).collect();
144
145        // Find dangling nodes (nodes with no outgoing edges)
146        let dangling_nodes: Vec<_> = adjacency
147            .iter()
148            .filter(|(_, targets)| targets.is_empty())
149            .map(|(node, _)| node.clone())
150            .collect();
151
152        // Power iteration
153        for _ in 0..max_iter {
154            let mut new_scores: HashMap<String, f64> = HashMap::new();
155            let teleport = (1.0 - damping) / n as f64;
156
157            // Dangling nodes contribute their rank equally to all nodes
158            let dangling_sum: f64 =
159                dangling_nodes.iter().map(|node| scores.get(node).unwrap_or(&0.0)).sum();
160            let dangling_contrib = damping * dangling_sum / n as f64;
161
162            for node in self.components.keys() {
163                let mut incoming_score = 0.0;
164
165                // Find nodes that link to this node
166                for (source, targets) in adjacency {
167                    if targets.contains(node) {
168                        let out_degree = targets.len();
169                        if out_degree > 0 {
170                            incoming_score +=
171                                scores.get(source).unwrap_or(&0.0) / out_degree as f64;
172                        }
173                    }
174                }
175
176                new_scores
177                    .insert(node.clone(), teleport + damping * incoming_score + dangling_contrib);
178            }
179
180            // Check convergence
181            let diff: f64 =
182                new_scores.iter().map(|(k, v)| (v - scores.get(k).unwrap_or(&0.0)).abs()).sum();
183
184            scores = new_scores;
185
186            if diff < 1e-6 {
187                break;
188            }
189        }
190
191        self.metrics.pagerank = scores;
192    }
193
194    /// Compute Betweenness Centrality using Brandes algorithm (simplified)
195    fn compute_betweenness(&mut self, adjacency: &HashMap<String, Vec<String>>) {
196        let nodes: Vec<_> = self.components.keys().cloned().collect();
197        let n = nodes.len();
198
199        // Initialize betweenness
200        let mut betweenness: HashMap<String, f64> =
201            nodes.iter().map(|n| (n.clone(), 0.0)).collect();
202
203        // For each source, compute shortest paths and accumulate
204        for source in &nodes {
205            // BFS from source
206            let mut dist: HashMap<String, i32> = HashMap::new();
207            let mut sigma: HashMap<String, f64> = HashMap::new();
208            let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
209
210            for n in &nodes {
211                dist.insert(n.clone(), -1);
212                sigma.insert(n.clone(), 0.0);
213                predecessors.insert(n.clone(), Vec::new());
214            }
215
216            dist.insert(source.clone(), 0);
217            sigma.insert(source.clone(), 1.0);
218
219            let mut queue = vec![source.clone()];
220            let mut order = Vec::new();
221
222            while !queue.is_empty() {
223                let v = queue.remove(0);
224                order.push(v.clone());
225
226                if let Some(neighbors) = adjacency.get(&v) {
227                    for w in neighbors {
228                        let d_v = dist[&v];
229                        let d_w = dist.get(w).copied().unwrap_or(-1);
230
231                        if d_w < 0 {
232                            dist.insert(w.clone(), d_v + 1);
233                            queue.push(w.clone());
234                        }
235
236                        if dist.get(w).copied().unwrap_or(-1) == d_v + 1 {
237                            let sigma_v = sigma.get(&v).copied().unwrap_or(0.0);
238                            if let Some(s) = sigma.get_mut(w) {
239                                *s += sigma_v;
240                            }
241                            if let Some(p) = predecessors.get_mut(w) {
242                                p.push(v.clone());
243                            }
244                        }
245                    }
246                }
247            }
248
249            // Back-propagation
250            let mut delta: HashMap<String, f64> = nodes.iter().map(|n| (n.clone(), 0.0)).collect();
251
252            for w in order.iter().rev() {
253                for v in predecessors.get(w).cloned().unwrap_or_default() {
254                    let sigma_v = sigma.get(&v).copied().unwrap_or(1.0);
255                    let sigma_w = sigma.get(w).copied().unwrap_or(1.0);
256                    let delta_w = delta.get(w).copied().unwrap_or(0.0);
257
258                    if sigma_w > 0.0 {
259                        if let Some(d) = delta.get_mut(&v) {
260                            *d += (sigma_v / sigma_w) * (1.0 + delta_w);
261                        }
262                    }
263                }
264
265                if w != source {
266                    if let Some(b) = betweenness.get_mut(w) {
267                        *b += delta.get(w).copied().unwrap_or(0.0);
268                    }
269                }
270            }
271        }
272
273        // Normalize
274        let norm = if n > 2 { (n - 1) * (n - 2) } else { 1 };
275        for v in betweenness.values_mut() {
276            *v /= norm as f64;
277        }
278
279        self.metrics.betweenness = betweenness;
280    }
281
282    /// Compute depth from root nodes (nodes with no incoming edges)
283    fn compute_depth(&mut self, adjacency: &HashMap<String, Vec<String>>) {
284        let mut depth: HashMap<String, u32> = HashMap::new();
285        let nodes: Vec<_> = self.components.keys().cloned().collect();
286
287        // Find incoming edges for each node
288        let mut has_incoming: HashMap<String, bool> =
289            nodes.iter().map(|n| (n.clone(), false)).collect();
290        for targets in adjacency.values() {
291            for t in targets {
292                has_incoming.insert(t.clone(), true);
293            }
294        }
295
296        // Roots are nodes with no incoming edges
297        let roots: Vec<_> =
298            nodes.iter().filter(|n| !has_incoming.get(*n).unwrap_or(&false)).cloned().collect();
299
300        // BFS from roots
301        let mut queue: Vec<(String, u32)> = roots.into_iter().map(|r| (r, 0)).collect();
302
303        while let Some((node, d)) = queue.pop() {
304            if let std::collections::hash_map::Entry::Vacant(e) = depth.entry(node.clone()) {
305                e.insert(d);
306                if let Some(neighbors) = adjacency.get(&node) {
307                    for neighbor in neighbors {
308                        if !depth.contains_key(neighbor) {
309                            queue.push((neighbor.clone(), d + 1));
310                        }
311                    }
312                }
313            }
314        }
315
316        // Assign depth 0 to any unreachable nodes
317        for node in &nodes {
318            depth.entry(node.clone()).or_insert(0);
319        }
320
321        self.metrics.depth_map = depth;
322    }
323
324    /// Get computed metrics
325    pub fn metrics(&self) -> &GraphMetrics {
326        &self.metrics
327    }
328
329    /// Get detected anomalies
330    pub fn anomalies(&self) -> &[Anomaly] {
331        &self.anomalies
332    }
333
334    /// Add an anomaly
335    pub fn add_anomaly(&mut self, anomaly: Anomaly) {
336        self.anomalies.push(anomaly);
337    }
338
339    /// Compute stack health summary
340    pub fn health_summary(&self) -> HealthSummary {
341        let total = self.components.len();
342        let green = self.components.values().filter(|c| c.health == HealthStatus::Green).count();
343        let yellow = self.components.values().filter(|c| c.health == HealthStatus::Yellow).count();
344        let red = self.components.values().filter(|c| c.health == HealthStatus::Red).count();
345
346        let avg_score = if total > 0 {
347            self.components.values().map(|c| c.metrics.demo_score).sum::<f64>() / total as f64
348        } else {
349            0.0
350        };
351
352        HealthSummary {
353            total_components: total,
354            green_count: green,
355            yellow_count: yellow,
356            red_count: red,
357            unknown_count: total.saturating_sub(green + yellow + red),
358            avg_demo_score: avg_score,
359            avg_coverage: self.avg_metric(|c| c.metrics.coverage),
360            andon_status: self.compute_andon_status(green, yellow, red, total),
361        }
362    }
363
364    fn avg_metric<F>(&self, f: F) -> f64
365    where
366        F: Fn(&ComponentNode) -> f64,
367    {
368        let total = self.components.len();
369        if total == 0 {
370            return 0.0;
371        }
372        self.components.values().map(f).sum::<f64>() / total as f64
373    }
374
375    fn compute_andon_status(
376        &self,
377        green: usize,
378        yellow: usize,
379        red: usize,
380        total: usize,
381    ) -> AndonStatus {
382        if red > 0 {
383            AndonStatus::Red
384        } else if yellow > 0 {
385            AndonStatus::Yellow
386        } else if green == total && total > 0 {
387            AndonStatus::Green
388        } else {
389            AndonStatus::Unknown
390        }
391    }
392}
393
394impl Default for StackDiagnostics {
395    fn default() -> Self {
396        Self::new()
397    }
398}