Skip to main content

batuta/stack/
diagnostics.rs

1#![allow(dead_code)]
2//! Stack Visualization, Diagnostics, and Reporting
3//!
4//! ML-driven system for visualizing, diagnosing, and reporting on the health
5//! of the Sovereign AI Stack. Implements Toyota Way principles for observability.
6//!
7//! ## Toyota Way Principles
8//!
9//! - **Mieruka (Visual Control)**: Rich ASCII dashboards make health visible
10//! - **Jidoka**: ML anomaly detection surfaces issues automatically
11//! - **Genchi Genbutsu**: Evidence-based diagnosis from actual dependency data
12//! - **Andon**: Red/Yellow/Green status with stop-the-line alerts
13//! - **Yokoten**: Cross-component insight sharing via knowledge graph
14
15use crate::stack::quality::{QualityGrade, StackLayer};
16use crate::stack::DependencyGraph;
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21// Re-export ML components from diagnostics_ml module
22pub use super::diagnostics_ml::{ErrorForecaster, ForecastMetrics, IsolationForest};
23
24// ============================================================================
25// Health Status (Andon System)
26// ============================================================================
27
28/// Health status for components (Andon-style visual control)
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub enum HealthStatus {
31    /// All systems healthy - normal operation
32    Green,
33    /// Attention needed - warnings present
34    Yellow,
35    /// Critical issues - stop-the-line
36    Red,
37    /// Not yet analyzed
38    Unknown,
39}
40
41impl HealthStatus {
42    /// Create from quality grade
43    pub fn from_grade(grade: QualityGrade) -> Self {
44        match grade {
45            QualityGrade::APlus | QualityGrade::A => Self::Green,
46            QualityGrade::AMinus | QualityGrade::BPlus => Self::Yellow,
47            _ => Self::Red,
48        }
49    }
50
51    /// Get display icon for status
52    pub fn icon(&self) -> &'static str {
53        match self {
54            Self::Green => "🟢",
55            Self::Yellow => "🟡",
56            Self::Red => "🔴",
57            Self::Unknown => "⚪",
58        }
59    }
60
61    /// Get ASCII symbol for terminal without emoji support
62    pub fn symbol(&self) -> &'static str {
63        match self {
64            Self::Green => "●",
65            Self::Yellow => "◐",
66            Self::Red => "○",
67            Self::Unknown => "◌",
68        }
69    }
70}
71
72impl std::fmt::Display for HealthStatus {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.icon())
75    }
76}
77
78// ============================================================================
79// Component Node (Stack Knowledge Graph)
80// ============================================================================
81
82/// A component in the stack knowledge graph
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ComponentNode {
85    /// Component name (e.g., "trueno", "aprender")
86    pub name: String,
87    /// Semantic version
88    pub version: String,
89    /// Stack layer classification
90    pub layer: StackLayer,
91    /// Current health status
92    pub health: HealthStatus,
93    /// Quality metrics
94    pub metrics: ComponentMetrics,
95}
96
97impl ComponentNode {
98    /// Create a new component node
99    pub fn new(name: impl Into<String>, version: impl Into<String>, layer: StackLayer) -> Self {
100        Self {
101            name: name.into(),
102            version: version.into(),
103            layer,
104            health: HealthStatus::Unknown,
105            metrics: ComponentMetrics::default(),
106        }
107    }
108
109    /// Update health status from metrics
110    pub fn update_health(&mut self) {
111        self.health = HealthStatus::from_grade(self.metrics.grade);
112    }
113}
114
115// ============================================================================
116// Component Metrics
117// ============================================================================
118
119/// Quality and performance metrics for a component
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ComponentMetrics {
122    /// Demo score (0-100 normalized)
123    pub demo_score: f64,
124    /// Test coverage percentage
125    pub coverage: f64,
126    /// Mutation score percentage
127    pub mutation_score: f64,
128    /// Average cyclomatic complexity
129    pub complexity_avg: f64,
130    /// SATD (Self-Admitted Technical Debt) count
131    pub satd_count: u32,
132    /// Dead code percentage
133    pub dead_code_pct: f64,
134    /// Overall quality grade
135    pub grade: QualityGrade,
136}
137
138impl Default for ComponentMetrics {
139    fn default() -> Self {
140        Self {
141            demo_score: 0.0,
142            coverage: 0.0,
143            mutation_score: 0.0,
144            complexity_avg: 0.0,
145            satd_count: 0,
146            dead_code_pct: 0.0,
147            grade: QualityGrade::F, // Lowest grade as default
148        }
149    }
150}
151
152impl ComponentMetrics {
153    /// Create metrics with demo score
154    pub fn with_demo_score(demo_score: f64) -> Self {
155        let grade = QualityGrade::from_sqi(demo_score);
156        Self {
157            demo_score,
158            grade,
159            ..Default::default()
160        }
161    }
162
163    /// Check if metrics meet A- threshold
164    pub fn meets_threshold(&self) -> bool {
165        self.demo_score >= 85.0
166    }
167}
168
169// ============================================================================
170// Graph Metrics
171// ============================================================================
172
173/// Computed graph-level metrics
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct GraphMetrics {
176    /// PageRank scores by node
177    pub pagerank: HashMap<String, f64>,
178    /// Betweenness centrality by node
179    pub betweenness: HashMap<String, f64>,
180    /// Clustering coefficient by node
181    pub clustering: HashMap<String, f64>,
182    /// Community assignments (node -> community_id)
183    pub communities: HashMap<String, usize>,
184    /// Depth from root nodes
185    pub depth_map: HashMap<String, u32>,
186    /// Total nodes in graph
187    pub total_nodes: usize,
188    /// Total edges in graph
189    pub total_edges: usize,
190    /// Graph density (edges / possible edges)
191    pub density: f64,
192    /// Average degree
193    pub avg_degree: f64,
194    /// Maximum depth
195    pub max_depth: u32,
196}
197
198impl GraphMetrics {
199    /// Get the most critical components by PageRank
200    pub fn top_by_pagerank(&self, n: usize) -> Vec<(&String, f64)> {
201        let mut scores: Vec<_> = self.pagerank.iter().map(|(k, v)| (k, *v)).collect();
202        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
203        scores.into_iter().take(n).collect()
204    }
205
206    /// Get bottleneck components (high betweenness)
207    pub fn bottlenecks(&self, threshold: f64) -> Vec<&String> {
208        self.betweenness
209            .iter()
210            .filter(|(_, &v)| v > threshold)
211            .map(|(k, _)| k)
212            .collect()
213    }
214}
215
216// ============================================================================
217// Stack Diagnostics Engine
218// ============================================================================
219
220/// Main diagnostics engine for stack analysis
221#[derive(Debug)]
222pub struct StackDiagnostics {
223    /// Component nodes
224    components: HashMap<String, ComponentNode>,
225    /// Dependency graph
226    graph: Option<DependencyGraph>,
227    /// Computed graph metrics
228    metrics: GraphMetrics,
229    /// Detected anomalies
230    anomalies: Vec<Anomaly>,
231}
232
233impl StackDiagnostics {
234    /// Create a new diagnostics engine
235    pub fn new() -> Self {
236        Self {
237            components: HashMap::new(),
238            graph: None,
239            metrics: GraphMetrics::default(),
240            anomalies: Vec::new(),
241        }
242    }
243
244    /// Add a component to the knowledge graph
245    pub fn add_component(&mut self, node: ComponentNode) {
246        self.components.insert(node.name.clone(), node);
247    }
248
249    /// Get a component by name
250    pub fn get_component(&self, name: &str) -> Option<&ComponentNode> {
251        self.components.get(name)
252    }
253
254    /// Get all components
255    pub fn components(&self) -> impl Iterator<Item = &ComponentNode> {
256        self.components.values()
257    }
258
259    /// Get component count
260    pub fn component_count(&self) -> usize {
261        self.components.len()
262    }
263
264    /// Set the dependency graph
265    pub fn set_graph(&mut self, graph: DependencyGraph) {
266        self.graph = Some(graph);
267    }
268
269    /// Get the dependency graph
270    pub fn graph(&self) -> Option<&DependencyGraph> {
271        self.graph.as_ref()
272    }
273
274    /// Compute graph metrics (PageRank, Betweenness, etc.)
275    pub fn compute_metrics(&mut self) -> Result<&GraphMetrics> {
276        let n = self.components.len();
277        if n == 0 {
278            return Ok(&self.metrics);
279        }
280
281        self.metrics.total_nodes = n;
282
283        // Build adjacency from dependency graph if available
284        let adjacency = self.build_adjacency();
285
286        // Compute PageRank
287        self.compute_pagerank(&adjacency, 0.85, 100);
288
289        // Compute Betweenness Centrality
290        self.compute_betweenness(&adjacency);
291
292        // Compute depth from roots
293        self.compute_depth(&adjacency);
294
295        // Compute graph-level metrics
296        self.metrics.total_edges = adjacency.values().map(|v| v.len()).sum();
297        let max_edges = n * (n.saturating_sub(1));
298        self.metrics.density = if max_edges > 0 {
299            self.metrics.total_edges as f64 / max_edges as f64
300        } else {
301            0.0
302        };
303        self.metrics.avg_degree = if n > 0 {
304            self.metrics.total_edges as f64 / n as f64
305        } else {
306            0.0
307        };
308        self.metrics.max_depth = self.metrics.depth_map.values().copied().max().unwrap_or(0);
309
310        Ok(&self.metrics)
311    }
312
313    /// Build adjacency list from dependency graph
314    fn build_adjacency(&self) -> HashMap<String, Vec<String>> {
315        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
316
317        // Initialize all nodes
318        for name in self.components.keys() {
319            adjacency.insert(name.clone(), Vec::new());
320        }
321
322        // Add edges from dependency graph
323        if let Some(graph) = &self.graph {
324            for crate_info in graph.all_crates() {
325                let from = &crate_info.name;
326                for dep in &crate_info.paiml_dependencies {
327                    if self.components.contains_key(&dep.name) {
328                        adjacency
329                            .entry(from.clone())
330                            .or_default()
331                            .push(dep.name.clone());
332                    }
333                }
334            }
335        }
336
337        adjacency
338    }
339
340    /// Compute PageRank using power iteration
341    fn compute_pagerank(
342        &mut self,
343        adjacency: &HashMap<String, Vec<String>>,
344        damping: f64,
345        max_iter: usize,
346    ) {
347        let n = self.components.len();
348        if n == 0 {
349            return;
350        }
351
352        let initial = 1.0 / n as f64;
353        let mut scores: HashMap<String, f64> = self
354            .components
355            .keys()
356            .map(|k| (k.clone(), initial))
357            .collect();
358
359        // Find dangling nodes (nodes with no outgoing edges)
360        let dangling_nodes: Vec<_> = adjacency
361            .iter()
362            .filter(|(_, targets)| targets.is_empty())
363            .map(|(node, _)| node.clone())
364            .collect();
365
366        // Power iteration
367        for _ in 0..max_iter {
368            let mut new_scores: HashMap<String, f64> = HashMap::new();
369            let teleport = (1.0 - damping) / n as f64;
370
371            // Dangling nodes contribute their rank equally to all nodes
372            let dangling_sum: f64 = dangling_nodes
373                .iter()
374                .map(|node| scores.get(node).unwrap_or(&0.0))
375                .sum();
376            let dangling_contrib = damping * dangling_sum / n as f64;
377
378            for node in self.components.keys() {
379                let mut incoming_score = 0.0;
380
381                // Find nodes that link to this node
382                for (source, targets) in adjacency {
383                    if targets.contains(node) {
384                        let out_degree = targets.len();
385                        if out_degree > 0 {
386                            incoming_score +=
387                                scores.get(source).unwrap_or(&0.0) / out_degree as f64;
388                        }
389                    }
390                }
391
392                new_scores.insert(
393                    node.clone(),
394                    teleport + damping * incoming_score + dangling_contrib,
395                );
396            }
397
398            // Check convergence
399            let diff: f64 = new_scores
400                .iter()
401                .map(|(k, v)| (v - scores.get(k).unwrap_or(&0.0)).abs())
402                .sum();
403
404            scores = new_scores;
405
406            if diff < 1e-6 {
407                break;
408            }
409        }
410
411        self.metrics.pagerank = scores;
412    }
413
414    /// Compute Betweenness Centrality using Brandes algorithm (simplified)
415    fn compute_betweenness(&mut self, adjacency: &HashMap<String, Vec<String>>) {
416        let nodes: Vec<_> = self.components.keys().cloned().collect();
417        let n = nodes.len();
418
419        // Initialize betweenness
420        let mut betweenness: HashMap<String, f64> =
421            nodes.iter().map(|n| (n.clone(), 0.0)).collect();
422
423        // For each source, compute shortest paths and accumulate
424        for source in &nodes {
425            // BFS from source
426            let mut dist: HashMap<String, i32> = HashMap::new();
427            let mut sigma: HashMap<String, f64> = HashMap::new();
428            let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
429
430            for n in &nodes {
431                dist.insert(n.clone(), -1);
432                sigma.insert(n.clone(), 0.0);
433                predecessors.insert(n.clone(), Vec::new());
434            }
435
436            dist.insert(source.clone(), 0);
437            sigma.insert(source.clone(), 1.0);
438
439            let mut queue = vec![source.clone()];
440            let mut order = Vec::new();
441
442            while !queue.is_empty() {
443                let v = queue.remove(0);
444                order.push(v.clone());
445
446                if let Some(neighbors) = adjacency.get(&v) {
447                    for w in neighbors {
448                        let d_v = dist[&v];
449                        let d_w = dist.get(w).copied().unwrap_or(-1);
450
451                        if d_w < 0 {
452                            dist.insert(w.clone(), d_v + 1);
453                            queue.push(w.clone());
454                        }
455
456                        if dist.get(w).copied().unwrap_or(-1) == d_v + 1 {
457                            let sigma_v = sigma.get(&v).copied().unwrap_or(0.0);
458                            if let Some(s) = sigma.get_mut(w) {
459                                *s += sigma_v;
460                            }
461                            if let Some(p) = predecessors.get_mut(w) {
462                                p.push(v.clone());
463                            }
464                        }
465                    }
466                }
467            }
468
469            // Back-propagation
470            let mut delta: HashMap<String, f64> = nodes.iter().map(|n| (n.clone(), 0.0)).collect();
471
472            for w in order.iter().rev() {
473                for v in predecessors.get(w).cloned().unwrap_or_default() {
474                    let sigma_v = sigma.get(&v).copied().unwrap_or(1.0);
475                    let sigma_w = sigma.get(w).copied().unwrap_or(1.0);
476                    let delta_w = delta.get(w).copied().unwrap_or(0.0);
477
478                    if sigma_w > 0.0 {
479                        if let Some(d) = delta.get_mut(&v) {
480                            *d += (sigma_v / sigma_w) * (1.0 + delta_w);
481                        }
482                    }
483                }
484
485                if w != source {
486                    if let Some(b) = betweenness.get_mut(w) {
487                        *b += delta.get(w).copied().unwrap_or(0.0);
488                    }
489                }
490            }
491        }
492
493        // Normalize
494        let norm = if n > 2 { (n - 1) * (n - 2) } else { 1 };
495        for v in betweenness.values_mut() {
496            *v /= norm as f64;
497        }
498
499        self.metrics.betweenness = betweenness;
500    }
501
502    /// Compute depth from root nodes (nodes with no incoming edges)
503    fn compute_depth(&mut self, adjacency: &HashMap<String, Vec<String>>) {
504        let mut depth: HashMap<String, u32> = HashMap::new();
505        let nodes: Vec<_> = self.components.keys().cloned().collect();
506
507        // Find incoming edges for each node
508        let mut has_incoming: HashMap<String, bool> =
509            nodes.iter().map(|n| (n.clone(), false)).collect();
510        for targets in adjacency.values() {
511            for t in targets {
512                has_incoming.insert(t.clone(), true);
513            }
514        }
515
516        // Roots are nodes with no incoming edges
517        let roots: Vec<_> = nodes
518            .iter()
519            .filter(|n| !has_incoming.get(*n).unwrap_or(&false))
520            .cloned()
521            .collect();
522
523        // BFS from roots
524        let mut queue: Vec<(String, u32)> = roots.into_iter().map(|r| (r, 0)).collect();
525
526        while let Some((node, d)) = queue.pop() {
527            if let std::collections::hash_map::Entry::Vacant(e) = depth.entry(node.clone()) {
528                e.insert(d);
529                if let Some(neighbors) = adjacency.get(&node) {
530                    for neighbor in neighbors {
531                        if !depth.contains_key(neighbor) {
532                            queue.push((neighbor.clone(), d + 1));
533                        }
534                    }
535                }
536            }
537        }
538
539        // Assign depth 0 to any unreachable nodes
540        for node in &nodes {
541            depth.entry(node.clone()).or_insert(0);
542        }
543
544        self.metrics.depth_map = depth;
545    }
546
547    /// Get computed metrics
548    pub fn metrics(&self) -> &GraphMetrics {
549        &self.metrics
550    }
551
552    /// Get detected anomalies
553    pub fn anomalies(&self) -> &[Anomaly] {
554        &self.anomalies
555    }
556
557    /// Add an anomaly
558    pub fn add_anomaly(&mut self, anomaly: Anomaly) {
559        self.anomalies.push(anomaly);
560    }
561
562    /// Compute stack health summary
563    pub fn health_summary(&self) -> HealthSummary {
564        let total = self.components.len();
565        let green = self
566            .components
567            .values()
568            .filter(|c| c.health == HealthStatus::Green)
569            .count();
570        let yellow = self
571            .components
572            .values()
573            .filter(|c| c.health == HealthStatus::Yellow)
574            .count();
575        let red = self
576            .components
577            .values()
578            .filter(|c| c.health == HealthStatus::Red)
579            .count();
580
581        let avg_score = if total > 0 {
582            self.components
583                .values()
584                .map(|c| c.metrics.demo_score)
585                .sum::<f64>()
586                / total as f64
587        } else {
588            0.0
589        };
590
591        HealthSummary {
592            total_components: total,
593            green_count: green,
594            yellow_count: yellow,
595            red_count: red,
596            unknown_count: total.saturating_sub(green + yellow + red),
597            avg_demo_score: avg_score,
598            avg_coverage: self.avg_metric(|c| c.metrics.coverage),
599            andon_status: self.compute_andon_status(green, yellow, red, total),
600        }
601    }
602
603    fn avg_metric<F>(&self, f: F) -> f64
604    where
605        F: Fn(&ComponentNode) -> f64,
606    {
607        let total = self.components.len();
608        if total == 0 {
609            return 0.0;
610        }
611        self.components.values().map(f).sum::<f64>() / total as f64
612    }
613
614    fn compute_andon_status(
615        &self,
616        green: usize,
617        yellow: usize,
618        red: usize,
619        total: usize,
620    ) -> AndonStatus {
621        if red > 0 {
622            AndonStatus::Red
623        } else if yellow > 0 {
624            AndonStatus::Yellow
625        } else if green == total && total > 0 {
626            AndonStatus::Green
627        } else {
628            AndonStatus::Unknown
629        }
630    }
631}
632
633impl Default for StackDiagnostics {
634    fn default() -> Self {
635        Self::new()
636    }
637}
638
639// ============================================================================
640// Health Summary
641// ============================================================================
642
643/// Summary of stack health for dashboard
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct HealthSummary {
646    /// Total components in stack
647    pub total_components: usize,
648    /// Components with green status
649    pub green_count: usize,
650    /// Components with yellow status
651    pub yellow_count: usize,
652    /// Components with red status
653    pub red_count: usize,
654    /// Components with unknown status
655    pub unknown_count: usize,
656    /// Average demo score
657    pub avg_demo_score: f64,
658    /// Average test coverage
659    pub avg_coverage: f64,
660    /// Overall Andon status
661    pub andon_status: AndonStatus,
662}
663
664impl HealthSummary {
665    /// Check if all components are healthy
666    pub fn all_healthy(&self) -> bool {
667        self.red_count == 0 && self.yellow_count == 0 && self.green_count == self.total_components
668    }
669
670    /// Get percentage of healthy components
671    pub fn health_percentage(&self) -> f64 {
672        if self.total_components == 0 {
673            return 0.0;
674        }
675        (self.green_count as f64 / self.total_components as f64) * 100.0
676    }
677}
678
679// ============================================================================
680// Andon Status (Overall Stack)
681// ============================================================================
682
683/// Andon board status for the entire stack
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
685pub enum AndonStatus {
686    /// All systems green - normal operation
687    Green,
688    /// Warnings present - attention needed
689    Yellow,
690    /// Critical issues - stop-the-line
691    Red,
692    /// Not yet analyzed
693    Unknown,
694}
695
696impl AndonStatus {
697    /// Get display message
698    pub fn message(&self) -> &'static str {
699        match self {
700            Self::Green => "All systems healthy",
701            Self::Yellow => "Attention needed",
702            Self::Red => "Stop-the-line",
703            Self::Unknown => "Analysis pending",
704        }
705    }
706}
707
708impl std::fmt::Display for AndonStatus {
709    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710        let icon = match self {
711            Self::Green => "🟢",
712            Self::Yellow => "🟡",
713            Self::Red => "🔴",
714            Self::Unknown => "⚪",
715        };
716        write!(f, "{} {}", icon, self.message())
717    }
718}
719
720// ============================================================================
721// Anomaly Detection
722// ============================================================================
723
724/// Detected anomaly in the stack
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct Anomaly {
727    /// Component where anomaly was detected
728    pub component: String,
729    /// Anomaly score (0-1, higher = more anomalous)
730    pub score: f64,
731    /// Category of anomaly
732    pub category: AnomalyCategory,
733    /// Human-readable description
734    pub description: String,
735    /// Evidence supporting the anomaly
736    pub evidence: Vec<String>,
737    /// Suggested remediation
738    pub recommendation: Option<String>,
739}
740
741impl Anomaly {
742    /// Create a new anomaly
743    pub fn new(
744        component: impl Into<String>,
745        score: f64,
746        category: AnomalyCategory,
747        description: impl Into<String>,
748    ) -> Self {
749        Self {
750            component: component.into(),
751            score,
752            category,
753            description: description.into(),
754            evidence: Vec::new(),
755            recommendation: None,
756        }
757    }
758
759    /// Add evidence
760    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
761        self.evidence.push(evidence.into());
762        self
763    }
764
765    /// Add recommendation
766    pub fn with_recommendation(mut self, rec: impl Into<String>) -> Self {
767        self.recommendation = Some(rec.into());
768        self
769    }
770
771    /// Check if anomaly is critical (score > 0.8)
772    pub fn is_critical(&self) -> bool {
773        self.score > 0.8
774    }
775}
776
777/// Categories of anomalies
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
779pub enum AnomalyCategory {
780    /// Quality score regression
781    QualityRegression,
782    /// Coverage drop
783    CoverageDrop,
784    /// Build time spike
785    BuildTimeSpike,
786    /// Dependency risk
787    DependencyRisk,
788    /// Complexity increase
789    ComplexityIncrease,
790    /// Other anomaly
791    Other,
792}
793
794impl std::fmt::Display for AnomalyCategory {
795    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796        match self {
797            Self::QualityRegression => write!(f, "Quality Regression"),
798            Self::CoverageDrop => write!(f, "Coverage Drop"),
799            Self::BuildTimeSpike => write!(f, "Build Time Spike"),
800            Self::DependencyRisk => write!(f, "Dependency Risk"),
801            Self::ComplexityIncrease => write!(f, "Complexity Increase"),
802            Self::Other => write!(f, "Other"),
803        }
804    }
805}
806
807// ============================================================================
808// Dashboard Renderer
809// ============================================================================
810
811/// Render diagnostics as ASCII dashboard
812pub fn render_dashboard(diagnostics: &StackDiagnostics) -> String {
813    let mut output = String::new();
814    let summary = diagnostics.health_summary();
815
816    // Header
817    output
818        .push_str("┌─────────────────────────────────────────────────────────────────────────┐\n");
819    output
820        .push_str("│                  SOVEREIGN AI STACK HEALTH DASHBOARD                    │\n");
821    output.push_str(&format!(
822        "│                  Timestamp: {:40} │\n",
823        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
824    ));
825    output
826        .push_str("├─────────────────────────────────────────────────────────────────────────┤\n");
827
828    // Andon Status
829    output
830        .push_str("│                                                                         │\n");
831    output.push_str(&format!(
832        "│  ANDON STATUS: {} {:55}│\n",
833        summary.andon_status, ""
834    ));
835    output
836        .push_str("│                                                                         │\n");
837
838    // Stack Summary
839    output.push_str("│  ═══════════════════════════════════════════════════════════════════   │\n");
840    output
841        .push_str("│  STACK SUMMARY                                                          │\n");
842    output.push_str("│  ═══════════════════════════════════════════════════════════════════   │\n");
843    output
844        .push_str("│                                                                         │\n");
845    output.push_str(&format!(
846        "│  Total Components:    {:3}                                              │\n",
847        summary.total_components
848    ));
849    output.push_str(&format!(
850        "│  Healthy:             {:3} ({:.0}%)                                         │\n",
851        summary.green_count,
852        summary.health_percentage()
853    ));
854    output.push_str(&format!(
855        "│  Warnings:            {:3} ({:.0}%)                                         │\n",
856        summary.yellow_count,
857        if summary.total_components > 0 {
858            (summary.yellow_count as f64 / summary.total_components as f64) * 100.0
859        } else {
860            0.0
861        }
862    ));
863    output.push_str(&format!(
864        "│  Critical:            {:3} ({:.0}%)                                         │\n",
865        summary.red_count,
866        if summary.total_components > 0 {
867            (summary.red_count as f64 / summary.total_components as f64) * 100.0
868        } else {
869            0.0
870        }
871    ));
872    output.push_str(&format!(
873        "│  Average Demo Score:  {:.1}/100                                          │\n",
874        summary.avg_demo_score
875    ));
876    output.push_str(&format!(
877        "│  Average Coverage:    {:.1}%                                             │\n",
878        summary.avg_coverage
879    ));
880    output
881        .push_str("│                                                                         │\n");
882
883    // Anomalies
884    let anomalies = diagnostics.anomalies();
885    if !anomalies.is_empty() {
886        output.push_str(
887            "│  ═══════════════════════════════════════════════════════════════════   │\n",
888        );
889        output.push_str(
890            "│  ANOMALIES DETECTED                                                     │\n",
891        );
892        output.push_str(
893            "│  ═══════════════════════════════════════════════════════════════════   │\n",
894        );
895        output.push_str(
896            "│                                                                         │\n",
897        );
898
899        for anomaly in anomalies.iter().take(5) {
900            let icon = if anomaly.is_critical() {
901                "🔴"
902            } else {
903                "⚠️"
904            };
905            output.push_str(&format!(
906                "│  {}  {}: {}                               │\n",
907                icon, anomaly.component, anomaly.description
908            ));
909        }
910        output.push_str(
911            "│                                                                         │\n",
912        );
913    }
914
915    output
916        .push_str("└─────────────────────────────────────────────────────────────────────────┘\n");
917
918    output
919}
920
921// ============================================================================
922// Tests (EXTREME TDD - RED PHASE)
923// ============================================================================
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    // ========================================================================
930    // HealthStatus Tests
931    // ========================================================================
932
933    #[test]
934    fn test_health_status_from_grade_green() {
935        assert_eq!(
936            HealthStatus::from_grade(QualityGrade::APlus),
937            HealthStatus::Green
938        );
939        assert_eq!(
940            HealthStatus::from_grade(QualityGrade::A),
941            HealthStatus::Green
942        );
943    }
944
945    #[test]
946    fn test_health_status_from_grade_yellow() {
947        assert_eq!(
948            HealthStatus::from_grade(QualityGrade::AMinus),
949            HealthStatus::Yellow
950        );
951        assert_eq!(
952            HealthStatus::from_grade(QualityGrade::BPlus),
953            HealthStatus::Yellow
954        );
955    }
956
957    #[test]
958    fn test_health_status_from_grade_red() {
959        assert_eq!(HealthStatus::from_grade(QualityGrade::B), HealthStatus::Red);
960        assert_eq!(HealthStatus::from_grade(QualityGrade::C), HealthStatus::Red);
961        assert_eq!(HealthStatus::from_grade(QualityGrade::F), HealthStatus::Red);
962    }
963
964    #[test]
965    fn test_health_status_icons() {
966        assert_eq!(HealthStatus::Green.icon(), "🟢");
967        assert_eq!(HealthStatus::Yellow.icon(), "🟡");
968        assert_eq!(HealthStatus::Red.icon(), "🔴");
969        assert_eq!(HealthStatus::Unknown.icon(), "⚪");
970    }
971
972    #[test]
973    fn test_health_status_symbols() {
974        assert_eq!(HealthStatus::Green.symbol(), "●");
975        assert_eq!(HealthStatus::Yellow.symbol(), "◐");
976        assert_eq!(HealthStatus::Red.symbol(), "○");
977        assert_eq!(HealthStatus::Unknown.symbol(), "◌");
978    }
979
980    // ========================================================================
981    // ComponentNode Tests
982    // ========================================================================
983
984    #[test]
985    fn test_component_node_creation() {
986        let node = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
987        assert_eq!(node.name, "trueno");
988        assert_eq!(node.version, "0.7.4");
989        assert_eq!(node.layer, StackLayer::Compute);
990        assert_eq!(node.health, HealthStatus::Unknown);
991    }
992
993    #[test]
994    fn test_component_node_update_health() {
995        let mut node = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
996        node.metrics = ComponentMetrics::with_demo_score(95.0);
997        node.update_health();
998        assert_eq!(node.health, HealthStatus::Green);
999    }
1000
1001    #[test]
1002    fn test_component_node_update_health_yellow() {
1003        let mut node = ComponentNode::new("test", "1.0.0", StackLayer::Ml);
1004        node.metrics = ComponentMetrics::with_demo_score(85.0);
1005        node.update_health();
1006        assert_eq!(node.health, HealthStatus::Yellow);
1007    }
1008
1009    #[test]
1010    fn test_component_node_update_health_red() {
1011        let mut node = ComponentNode::new("test", "1.0.0", StackLayer::Ml);
1012        node.metrics = ComponentMetrics::with_demo_score(65.0);
1013        node.update_health();
1014        assert_eq!(node.health, HealthStatus::Red);
1015    }
1016
1017    // ========================================================================
1018    // ComponentMetrics Tests
1019    // ========================================================================
1020
1021    #[test]
1022    fn test_component_metrics_default() {
1023        let metrics = ComponentMetrics::default();
1024        assert_eq!(metrics.demo_score, 0.0);
1025        assert_eq!(metrics.coverage, 0.0);
1026        assert!(!metrics.meets_threshold());
1027    }
1028
1029    #[test]
1030    fn test_component_metrics_with_demo_score() {
1031        let metrics = ComponentMetrics::with_demo_score(90.0);
1032        assert_eq!(metrics.demo_score, 90.0);
1033        assert!(metrics.meets_threshold());
1034    }
1035
1036    #[test]
1037    fn test_component_metrics_threshold() {
1038        assert!(ComponentMetrics::with_demo_score(85.0).meets_threshold());
1039        assert!(ComponentMetrics::with_demo_score(100.0).meets_threshold());
1040        assert!(!ComponentMetrics::with_demo_score(84.9).meets_threshold());
1041    }
1042
1043    // ========================================================================
1044    // GraphMetrics Tests
1045    // ========================================================================
1046
1047    #[test]
1048    fn test_graph_metrics_top_by_pagerank() {
1049        let mut metrics = GraphMetrics::default();
1050        metrics.pagerank.insert("trueno".to_string(), 0.25);
1051        metrics.pagerank.insert("aprender".to_string(), 0.15);
1052        metrics.pagerank.insert("batuta".to_string(), 0.10);
1053
1054        let top = metrics.top_by_pagerank(2);
1055        assert_eq!(top.len(), 2);
1056        assert_eq!(top[0].0, "trueno");
1057        assert_eq!(top[1].0, "aprender");
1058    }
1059
1060    #[test]
1061    fn test_graph_metrics_bottlenecks() {
1062        let mut metrics = GraphMetrics::default();
1063        metrics.betweenness.insert("trueno".to_string(), 0.8);
1064        metrics.betweenness.insert("aprender".to_string(), 0.3);
1065        metrics.betweenness.insert("batuta".to_string(), 0.1);
1066
1067        let bottlenecks = metrics.bottlenecks(0.5);
1068        assert_eq!(bottlenecks.len(), 1);
1069        assert!(bottlenecks.contains(&&"trueno".to_string()));
1070    }
1071
1072    // ========================================================================
1073    // StackDiagnostics Tests
1074    // ========================================================================
1075
1076    #[test]
1077    fn test_stack_diagnostics_new() {
1078        let diag = StackDiagnostics::new();
1079        assert_eq!(diag.component_count(), 0);
1080        assert!(diag.graph().is_none());
1081        assert!(diag.anomalies().is_empty());
1082    }
1083
1084    #[test]
1085    fn test_stack_diagnostics_add_component() {
1086        let mut diag = StackDiagnostics::new();
1087        let node = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
1088        diag.add_component(node);
1089
1090        assert_eq!(diag.component_count(), 1);
1091        assert!(diag.get_component("trueno").is_some());
1092        assert!(diag.get_component("missing").is_none());
1093    }
1094
1095    #[test]
1096    fn test_stack_diagnostics_health_summary_empty() {
1097        let diag = StackDiagnostics::new();
1098        let summary = diag.health_summary();
1099
1100        assert_eq!(summary.total_components, 0);
1101        assert_eq!(summary.green_count, 0);
1102        assert_eq!(summary.andon_status, AndonStatus::Unknown);
1103    }
1104
1105    #[test]
1106    fn test_stack_diagnostics_health_summary_all_green() {
1107        let mut diag = StackDiagnostics::new();
1108
1109        let mut node1 = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
1110        node1.health = HealthStatus::Green;
1111        node1.metrics = ComponentMetrics::with_demo_score(95.0);
1112        diag.add_component(node1);
1113
1114        let mut node2 = ComponentNode::new("aprender", "0.9.0", StackLayer::Ml);
1115        node2.health = HealthStatus::Green;
1116        node2.metrics = ComponentMetrics::with_demo_score(92.0);
1117        diag.add_component(node2);
1118
1119        let summary = diag.health_summary();
1120
1121        assert_eq!(summary.total_components, 2);
1122        assert_eq!(summary.green_count, 2);
1123        assert_eq!(summary.yellow_count, 0);
1124        assert_eq!(summary.red_count, 0);
1125        assert!(summary.all_healthy());
1126        assert_eq!(summary.andon_status, AndonStatus::Green);
1127        assert!((summary.avg_demo_score - 93.5).abs() < 0.1);
1128    }
1129
1130    #[test]
1131    fn test_stack_diagnostics_health_summary_mixed() {
1132        let mut diag = StackDiagnostics::new();
1133
1134        let mut node1 = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
1135        node1.health = HealthStatus::Green;
1136        diag.add_component(node1);
1137
1138        let mut node2 = ComponentNode::new("weak", "1.0.0", StackLayer::Ml);
1139        node2.health = HealthStatus::Red;
1140        diag.add_component(node2);
1141
1142        let summary = diag.health_summary();
1143
1144        assert_eq!(summary.green_count, 1);
1145        assert_eq!(summary.red_count, 1);
1146        assert!(!summary.all_healthy());
1147        assert_eq!(summary.andon_status, AndonStatus::Red);
1148    }
1149
1150    #[test]
1151    fn test_stack_diagnostics_add_anomaly() {
1152        let mut diag = StackDiagnostics::new();
1153
1154        let anomaly = Anomaly::new(
1155            "trueno-graph",
1156            0.75,
1157            AnomalyCategory::CoverageDrop,
1158            "Coverage dropped 5.2%",
1159        )
1160        .with_evidence("lcov.info shows missing tests")
1161        .with_recommendation("Add tests for GPU BFS");
1162
1163        diag.add_anomaly(anomaly);
1164
1165        assert_eq!(diag.anomalies().len(), 1);
1166        assert_eq!(diag.anomalies()[0].component, "trueno-graph");
1167        assert!(!diag.anomalies()[0].is_critical());
1168    }
1169
1170    // ========================================================================
1171    // HealthSummary Tests
1172    // ========================================================================
1173
1174    #[test]
1175    fn test_health_summary_percentage() {
1176        let summary = HealthSummary {
1177            total_components: 20,
1178            green_count: 17,
1179            yellow_count: 3,
1180            red_count: 0,
1181            unknown_count: 0,
1182            avg_demo_score: 85.0,
1183            avg_coverage: 90.0,
1184            andon_status: AndonStatus::Yellow,
1185        };
1186
1187        assert_eq!(summary.health_percentage(), 85.0);
1188        assert!(!summary.all_healthy());
1189    }
1190
1191    #[test]
1192    fn test_health_summary_percentage_empty() {
1193        let summary = HealthSummary {
1194            total_components: 0,
1195            green_count: 0,
1196            yellow_count: 0,
1197            red_count: 0,
1198            unknown_count: 0,
1199            avg_demo_score: 0.0,
1200            avg_coverage: 0.0,
1201            andon_status: AndonStatus::Unknown,
1202        };
1203
1204        assert_eq!(summary.health_percentage(), 0.0);
1205    }
1206
1207    // ========================================================================
1208    // Anomaly Tests
1209    // ========================================================================
1210
1211    #[test]
1212    fn test_anomaly_creation() {
1213        let anomaly = Anomaly::new(
1214            "test",
1215            0.65,
1216            AnomalyCategory::QualityRegression,
1217            "Score dropped",
1218        );
1219
1220        assert_eq!(anomaly.component, "test");
1221        assert_eq!(anomaly.score, 0.65);
1222        assert!(!anomaly.is_critical());
1223        assert!(anomaly.evidence.is_empty());
1224        assert!(anomaly.recommendation.is_none());
1225    }
1226
1227    #[test]
1228    fn test_anomaly_critical() {
1229        let critical = Anomaly::new("test", 0.85, AnomalyCategory::DependencyRisk, "High risk");
1230        assert!(critical.is_critical());
1231
1232        let non_critical = Anomaly::new("test", 0.79, AnomalyCategory::Other, "Low risk");
1233        assert!(!non_critical.is_critical());
1234    }
1235
1236    #[test]
1237    fn test_anomaly_with_details() {
1238        let anomaly = Anomaly::new("test", 0.7, AnomalyCategory::BuildTimeSpike, "Build slow")
1239            .with_evidence("Time increased 40%")
1240            .with_evidence("New macro expansion")
1241            .with_recommendation("Enable incremental compilation");
1242
1243        assert_eq!(anomaly.evidence.len(), 2);
1244        assert!(anomaly.recommendation.is_some());
1245    }
1246
1247    #[test]
1248    fn test_anomaly_category_display() {
1249        assert_eq!(
1250            format!("{}", AnomalyCategory::QualityRegression),
1251            "Quality Regression"
1252        );
1253        assert_eq!(
1254            format!("{}", AnomalyCategory::CoverageDrop),
1255            "Coverage Drop"
1256        );
1257        assert_eq!(
1258            format!("{}", AnomalyCategory::BuildTimeSpike),
1259            "Build Time Spike"
1260        );
1261    }
1262
1263    // ========================================================================
1264    // AndonStatus Tests
1265    // ========================================================================
1266
1267    #[test]
1268    fn test_andon_status_messages() {
1269        assert_eq!(AndonStatus::Green.message(), "All systems healthy");
1270        assert_eq!(AndonStatus::Yellow.message(), "Attention needed");
1271        assert_eq!(AndonStatus::Red.message(), "Stop-the-line");
1272        assert_eq!(AndonStatus::Unknown.message(), "Analysis pending");
1273    }
1274
1275    #[test]
1276    fn test_andon_status_display() {
1277        let green = format!("{}", AndonStatus::Green);
1278        assert!(green.contains("🟢"));
1279        assert!(green.contains("healthy"));
1280    }
1281
1282    // ========================================================================
1283    // Dashboard Renderer Tests
1284    // ========================================================================
1285
1286    #[test]
1287    fn test_render_dashboard_empty() {
1288        let diag = StackDiagnostics::new();
1289        let output = render_dashboard(&diag);
1290
1291        assert!(output.contains("SOVEREIGN AI STACK"));
1292        assert!(output.contains("ANDON STATUS"));
1293        assert!(output.contains("Total Components"));
1294    }
1295
1296    #[test]
1297    fn test_render_dashboard_with_components() {
1298        let mut diag = StackDiagnostics::new();
1299
1300        let mut node = ComponentNode::new("trueno", "0.7.4", StackLayer::Compute);
1301        node.health = HealthStatus::Green;
1302        node.metrics = ComponentMetrics::with_demo_score(92.0);
1303        diag.add_component(node);
1304
1305        let output = render_dashboard(&diag);
1306
1307        assert!(output.contains("Total Components:      1"));
1308        assert!(output.contains("Healthy:               1"));
1309    }
1310
1311    #[test]
1312    fn test_render_dashboard_with_anomalies() {
1313        let mut diag = StackDiagnostics::new();
1314
1315        diag.add_anomaly(Anomaly::new(
1316            "trueno-graph",
1317            0.75,
1318            AnomalyCategory::CoverageDrop,
1319            "Coverage dropped",
1320        ));
1321
1322        let output = render_dashboard(&diag);
1323        assert!(output.contains("ANOMALIES DETECTED"));
1324        assert!(output.contains("trueno-graph"));
1325    }
1326
1327    // ========================================================================
1328    // Phase 2: Graph Analytics Tests
1329    // ========================================================================
1330
1331    #[test]
1332    fn test_compute_metrics_empty() {
1333        let mut diag = StackDiagnostics::new();
1334        let metrics = diag.compute_metrics().unwrap();
1335
1336        assert_eq!(metrics.total_nodes, 0);
1337        assert_eq!(metrics.total_edges, 0);
1338        assert_eq!(metrics.density, 0.0);
1339    }
1340
1341    #[test]
1342    fn test_compute_metrics_single_node() {
1343        let mut diag = StackDiagnostics::new();
1344        diag.add_component(ComponentNode::new("trueno", "0.7.4", StackLayer::Compute));
1345
1346        let metrics = diag.compute_metrics().unwrap();
1347
1348        assert_eq!(metrics.total_nodes, 1);
1349        assert_eq!(metrics.total_edges, 0);
1350        assert_eq!(metrics.density, 0.0);
1351        assert_eq!(metrics.avg_degree, 0.0);
1352
1353        // PageRank should be 1.0 for single node
1354        let pagerank = metrics.pagerank.get("trueno").copied().unwrap_or(0.0);
1355        assert!(
1356            (pagerank - 1.0).abs() < 0.01,
1357            "Single node PageRank should be ~1.0"
1358        );
1359
1360        // Depth should be 0 for root
1361        assert_eq!(metrics.depth_map.get("trueno").copied(), Some(0));
1362    }
1363
1364    #[test]
1365    fn test_compute_metrics_pagerank_chain() {
1366        let mut diag = StackDiagnostics::new();
1367
1368        // Create chain: A -> B -> C (where A is root, C has highest PageRank)
1369        diag.add_component(ComponentNode::new("A", "1.0", StackLayer::Orchestration));
1370        diag.add_component(ComponentNode::new("B", "1.0", StackLayer::Ml));
1371        diag.add_component(ComponentNode::new("C", "1.0", StackLayer::Compute));
1372
1373        let metrics = diag.compute_metrics().unwrap();
1374
1375        // All nodes have PageRank
1376        assert!(metrics.pagerank.contains_key("A"));
1377        assert!(metrics.pagerank.contains_key("B"));
1378        assert!(metrics.pagerank.contains_key("C"));
1379
1380        // Sum of PageRanks should be ~1.0
1381        let sum: f64 = metrics.pagerank.values().sum();
1382        assert!((sum - 1.0).abs() < 0.01, "PageRank sum should be ~1.0");
1383    }
1384
1385    #[test]
1386    fn test_compute_metrics_betweenness() {
1387        let mut diag = StackDiagnostics::new();
1388
1389        // Hub-spoke topology: A is hub, B,C,D are leaves
1390        diag.add_component(ComponentNode::new("hub", "1.0", StackLayer::Compute));
1391        diag.add_component(ComponentNode::new("leaf1", "1.0", StackLayer::Ml));
1392        diag.add_component(ComponentNode::new("leaf2", "1.0", StackLayer::DataMlops));
1393        diag.add_component(ComponentNode::new(
1394            "leaf3",
1395            "1.0",
1396            StackLayer::Orchestration,
1397        ));
1398
1399        let metrics = diag.compute_metrics().unwrap();
1400
1401        // All nodes have betweenness
1402        assert!(metrics.betweenness.contains_key("hub"));
1403        assert!(metrics.betweenness.contains_key("leaf1"));
1404        assert!(metrics.betweenness.contains_key("leaf2"));
1405        assert!(metrics.betweenness.contains_key("leaf3"));
1406
1407        // Without edges, all betweenness should be 0
1408        for &v in metrics.betweenness.values() {
1409            assert_eq!(v, 0.0);
1410        }
1411    }
1412
1413    #[test]
1414    fn test_compute_metrics_depth() {
1415        let mut diag = StackDiagnostics::new();
1416
1417        // Simple graph without dependencies - all are roots
1418        diag.add_component(ComponentNode::new("root1", "1.0", StackLayer::Compute));
1419        diag.add_component(ComponentNode::new("root2", "1.0", StackLayer::Ml));
1420        diag.add_component(ComponentNode::new("root3", "1.0", StackLayer::DataMlops));
1421
1422        let metrics = diag.compute_metrics().unwrap();
1423
1424        // All nodes are roots, so depth = 0
1425        assert_eq!(metrics.depth_map.get("root1").copied(), Some(0));
1426        assert_eq!(metrics.depth_map.get("root2").copied(), Some(0));
1427        assert_eq!(metrics.depth_map.get("root3").copied(), Some(0));
1428        assert_eq!(metrics.max_depth, 0);
1429    }
1430
1431    #[test]
1432    fn test_compute_metrics_graph_density() {
1433        let mut diag = StackDiagnostics::new();
1434
1435        // Add 3 nodes
1436        diag.add_component(ComponentNode::new("A", "1.0", StackLayer::Compute));
1437        diag.add_component(ComponentNode::new("B", "1.0", StackLayer::Ml));
1438        diag.add_component(ComponentNode::new("C", "1.0", StackLayer::DataMlops));
1439
1440        let metrics = diag.compute_metrics().unwrap();
1441
1442        // No edges, so density = 0
1443        assert_eq!(metrics.total_nodes, 3);
1444        assert_eq!(metrics.total_edges, 0);
1445        assert_eq!(metrics.density, 0.0);
1446
1447        // max_edges for 3 nodes = 3 * 2 = 6
1448        // density = edges / max_edges = 0 / 6 = 0
1449    }
1450
1451    #[test]
1452    fn test_compute_metrics_avg_degree() {
1453        let mut diag = StackDiagnostics::new();
1454
1455        diag.add_component(ComponentNode::new("node1", "1.0", StackLayer::Compute));
1456        diag.add_component(ComponentNode::new("node2", "1.0", StackLayer::Ml));
1457
1458        let metrics = diag.compute_metrics().unwrap();
1459
1460        assert_eq!(metrics.total_nodes, 2);
1461        assert_eq!(metrics.avg_degree, 0.0);
1462    }
1463
1464    #[test]
1465    fn test_build_adjacency_no_graph() {
1466        let mut diag = StackDiagnostics::new();
1467        diag.add_component(ComponentNode::new("A", "1.0", StackLayer::Compute));
1468        diag.add_component(ComponentNode::new("B", "1.0", StackLayer::Ml));
1469
1470        // compute_metrics internally calls build_adjacency
1471        let metrics = diag.compute_metrics().unwrap();
1472
1473        // Without a graph, edges should be 0
1474        assert_eq!(metrics.total_edges, 0);
1475    }
1476
1477    #[test]
1478    fn test_graph_metrics_top_by_pagerank_empty() {
1479        let metrics = GraphMetrics::default();
1480        let top = metrics.top_by_pagerank(5);
1481        assert!(top.is_empty());
1482    }
1483
1484    #[test]
1485    fn test_graph_metrics_bottlenecks_empty() {
1486        let metrics = GraphMetrics::default();
1487        let bottlenecks = metrics.bottlenecks(0.5);
1488        assert!(bottlenecks.is_empty());
1489    }
1490
1491    #[test]
1492    fn test_compute_metrics_pagerank_convergence() {
1493        let mut diag = StackDiagnostics::new();
1494
1495        // Larger graph to test convergence
1496        for i in 0..10 {
1497            diag.add_component(ComponentNode::new(
1498                format!("node{}", i),
1499                "1.0",
1500                StackLayer::Compute,
1501            ));
1502        }
1503
1504        let metrics = diag.compute_metrics().unwrap();
1505
1506        // All nodes should have PageRank assigned
1507        assert_eq!(metrics.pagerank.len(), 10);
1508
1509        // Sum should be ~1.0 (normalized)
1510        let sum: f64 = metrics.pagerank.values().sum();
1511        assert!(
1512            (sum - 1.0).abs() < 0.01,
1513            "PageRank sum={} should be ~1.0",
1514            sum
1515        );
1516    }
1517
1518    #[test]
1519    fn test_compute_metrics_multiple_calls() {
1520        let mut diag = StackDiagnostics::new();
1521        diag.add_component(ComponentNode::new("X", "1.0", StackLayer::Compute));
1522
1523        // Call compute_metrics multiple times
1524        let _ = diag.compute_metrics().unwrap();
1525        let metrics = diag.compute_metrics().unwrap();
1526
1527        // Should still work correctly
1528        assert_eq!(metrics.total_nodes, 1);
1529        assert!(metrics.pagerank.contains_key("X"));
1530    }
1531
1532}