batuta/stack/diagnostics/
engine.rs1use super::types::{
8 AndonStatus, Anomaly, ComponentNode, GraphMetrics, HealthStatus, HealthSummary,
9};
10use crate::stack::DependencyGraph;
11use anyhow::Result;
12use std::collections::HashMap;
13
14#[derive(Debug)]
20pub struct StackDiagnostics {
21 components: HashMap<String, ComponentNode>,
23 graph: Option<DependencyGraph>,
25 metrics: GraphMetrics,
27 anomalies: Vec<Anomaly>,
29}
30
31impl StackDiagnostics {
32 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 pub fn add_component(&mut self, node: ComponentNode) {
44 self.components.insert(node.name.clone(), node);
45 }
46
47 pub fn get_component(&self, name: &str) -> Option<&ComponentNode> {
49 self.components.get(name)
50 }
51
52 pub fn components(&self) -> impl Iterator<Item = &ComponentNode> {
54 self.components.values()
55 }
56
57 pub fn component_count(&self) -> usize {
59 self.components.len()
60 }
61
62 pub fn set_graph(&mut self, graph: DependencyGraph) {
64 self.graph = Some(graph);
65 }
66
67 pub fn graph(&self) -> Option<&DependencyGraph> {
69 self.graph.as_ref()
70 }
71
72 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 let adjacency = self.build_adjacency();
83
84 self.compute_pagerank(&adjacency, 0.85, 100);
86
87 self.compute_betweenness(&adjacency);
89
90 self.compute_depth(&adjacency);
92
93 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 fn build_adjacency(&self) -> HashMap<String, Vec<String>> {
107 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
108
109 for name in self.components.keys() {
111 adjacency.insert(name.clone(), Vec::new());
112 }
113
114 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 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 let dangling_nodes: Vec<_> = adjacency
147 .iter()
148 .filter(|(_, targets)| targets.is_empty())
149 .map(|(node, _)| node.clone())
150 .collect();
151
152 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 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 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 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 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 let mut betweenness: HashMap<String, f64> =
201 nodes.iter().map(|n| (n.clone(), 0.0)).collect();
202
203 for source in &nodes {
205 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 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 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 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 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 let roots: Vec<_> =
298 nodes.iter().filter(|n| !has_incoming.get(*n).unwrap_or(&false)).cloned().collect();
299
300 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 for node in &nodes {
318 depth.entry(node.clone()).or_insert(0);
319 }
320
321 self.metrics.depth_map = depth;
322 }
323
324 pub fn metrics(&self) -> &GraphMetrics {
326 &self.metrics
327 }
328
329 pub fn anomalies(&self) -> &[Anomaly] {
331 &self.anomalies
332 }
333
334 pub fn add_anomaly(&mut self, anomaly: Anomaly) {
336 self.anomalies.push(anomaly);
337 }
338
339 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}