Skip to main content

entrenar/monitor/
dashboard.rs

1//! Dashboard Module (ENT-043)
2//!
3//! Terminal visualization using trueno-viz patterns.
4//! Displays training metrics in real-time ASCII format.
5
6use super::MetricsSummary;
7
8/// Dashboard configuration
9#[derive(Debug, Clone)]
10pub struct DashboardConfig {
11    /// Width in characters
12    pub width: usize,
13    /// Height in characters
14    pub height: usize,
15    /// Refresh interval in milliseconds
16    pub refresh_ms: u64,
17    /// Show ASCII mode (for SSH)
18    pub ascii_mode: bool,
19}
20
21impl Default for DashboardConfig {
22    fn default() -> Self {
23        Self { width: 80, height: 24, refresh_ms: 1000, ascii_mode: true }
24    }
25}
26
27/// Training dashboard for real-time visualization
28pub struct Dashboard {
29    config: DashboardConfig,
30    history: Vec<MetricsSummary>,
31    max_history: usize,
32}
33
34impl Dashboard {
35    /// Create a new dashboard with default config
36    pub fn new() -> Self {
37        Self::with_config(DashboardConfig::default())
38    }
39
40    /// Create with custom config
41    pub fn with_config(config: DashboardConfig) -> Self {
42        Self { config, history: Vec::new(), max_history: 100 }
43    }
44
45    /// Update with new metrics
46    pub fn update(&mut self, summary: MetricsSummary) {
47        self.history.push(summary);
48        if self.history.len() > self.max_history {
49            self.history.remove(0);
50        }
51    }
52
53    /// Render to ASCII string
54    pub fn render_ascii(&self) -> String {
55        let mut output = String::new();
56        output.push_str(&"═".repeat(self.config.width));
57        output.push('\n');
58        output.push_str("  TRAINING MONITOR\n");
59        output.push_str(&"─".repeat(self.config.width));
60        output.push('\n');
61
62        if let Some(latest) = self.history.last() {
63            for (metric, stats) in latest {
64                output.push_str(&format!(
65                    "  {:<15} mean={:.4} std={:.4} min={:.4} max={:.4}\n",
66                    metric.as_str(),
67                    stats.mean,
68                    stats.std,
69                    stats.min,
70                    stats.max
71                ));
72            }
73        } else {
74            output.push_str("  No metrics recorded yet\n");
75        }
76
77        output.push_str(&"═".repeat(self.config.width));
78        output.push('\n');
79        output
80    }
81
82    /// Render simple sparkline for a metric
83    pub fn sparkline(&self, metric: &super::Metric) -> String {
84        let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
85        let values: Vec<f64> =
86            self.history.iter().filter_map(|s| s.get(metric).map(|st| st.mean)).collect();
87
88        if values.is_empty() {
89            return String::new();
90        }
91
92        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
93        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
94        let range = max - min;
95
96        if range == 0.0 {
97            return chars[4].to_string().repeat(values.len());
98        }
99
100        values
101            .iter()
102            .map(|v| {
103                let idx = (((v - min) / range) * 7.0).round() as usize;
104                chars[idx.min(7)]
105            })
106            .collect()
107    }
108}
109
110impl Default for Dashboard {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::monitor::{Metric, MetricStats};
120    use std::collections::HashMap;
121
122    #[test]
123    fn test_dashboard_new() {
124        let dashboard = Dashboard::new();
125        assert_eq!(dashboard.history.len(), 0);
126    }
127
128    #[test]
129    fn test_dashboard_update() {
130        let mut dashboard = Dashboard::new();
131        let mut summary = HashMap::new();
132        summary.insert(
133            Metric::Loss,
134            MetricStats {
135                count: 1,
136                mean: 0.5,
137                std: 0.0,
138                min: 0.5,
139                max: 0.5,
140                sum: 0.5,
141                has_nan: false,
142                has_inf: false,
143            },
144        );
145        dashboard.update(summary);
146        assert_eq!(dashboard.history.len(), 1);
147    }
148
149    #[test]
150    fn test_render_ascii_empty() {
151        let dashboard = Dashboard::new();
152        let output = dashboard.render_ascii();
153        assert!(output.contains("No metrics"));
154    }
155
156    #[test]
157    fn test_render_ascii_with_data() {
158        let mut dashboard = Dashboard::new();
159        let mut summary = HashMap::new();
160        summary.insert(
161            Metric::Loss,
162            MetricStats {
163                count: 10,
164                mean: 0.25,
165                std: 0.1,
166                min: 0.1,
167                max: 0.5,
168                sum: 2.5,
169                has_nan: false,
170                has_inf: false,
171            },
172        );
173        dashboard.update(summary);
174        let output = dashboard.render_ascii();
175        assert!(output.contains("loss"));
176        assert!(output.contains("0.25"));
177    }
178
179    #[test]
180    fn test_sparkline() {
181        let mut dashboard = Dashboard::new();
182
183        // Add decreasing loss values
184        for i in 0..10 {
185            let mut summary = HashMap::new();
186            summary.insert(
187                Metric::Loss,
188                MetricStats {
189                    count: 1,
190                    mean: 1.0 - (f64::from(i) * 0.1),
191                    std: 0.0,
192                    min: 0.0,
193                    max: 1.0,
194                    sum: 0.0,
195                    has_nan: false,
196                    has_inf: false,
197                },
198            );
199            dashboard.update(summary);
200        }
201
202        let spark = dashboard.sparkline(&Metric::Loss);
203        assert_eq!(spark.chars().count(), 10);
204    }
205
206    #[test]
207    fn test_dashboard_config_default() {
208        let config = DashboardConfig::default();
209        assert_eq!(config.width, 80);
210        assert_eq!(config.height, 24);
211        assert_eq!(config.refresh_ms, 1000);
212        assert!(config.ascii_mode);
213    }
214
215    #[test]
216    fn test_dashboard_with_config() {
217        let config = DashboardConfig { width: 100, height: 30, refresh_ms: 500, ascii_mode: false };
218        let dashboard = Dashboard::with_config(config.clone());
219        assert_eq!(dashboard.config.width, 100);
220    }
221
222    #[test]
223    fn test_sparkline_empty() {
224        let dashboard = Dashboard::new();
225        let spark = dashboard.sparkline(&Metric::Loss);
226        assert!(spark.is_empty());
227    }
228
229    #[test]
230    fn test_dashboard_default() {
231        let dashboard = Dashboard::default();
232        assert!(dashboard.history.is_empty());
233    }
234}