Skip to main content

entrenar/monitor/wasm/
dashboard.rs

1//! WASM dashboard for canvas rendering.
2
3#[cfg(target_arch = "wasm32")]
4use wasm_bindgen::prelude::*;
5
6use super::collector::WasmMetricsCollector;
7use super::options::WasmDashboardOptions;
8use super::utils::{generate_sparkline, normalize_values};
9
10/// WASM dashboard for canvas rendering.
11///
12/// Renders training metrics to a canvas element in the browser.
13#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
14#[derive(Debug)]
15pub struct WasmDashboard {
16    options: WasmDashboardOptions,
17    loss_history: Vec<f64>,
18    accuracy_history: Vec<f64>,
19    max_history: usize,
20}
21
22#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
23impl WasmDashboard {
24    /// Create a new dashboard with default options.
25    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
26    pub fn new() -> Self {
27        Self {
28            options: WasmDashboardOptions::new(),
29            loss_history: Vec::new(),
30            accuracy_history: Vec::new(),
31            max_history: 100,
32        }
33    }
34
35    /// Create a dashboard with custom options.
36    pub fn with_options(options: WasmDashboardOptions) -> Self {
37        Self { options, loss_history: Vec::new(), accuracy_history: Vec::new(), max_history: 100 }
38    }
39
40    /// Set maximum history length.
41    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
42    pub fn max_history(mut self, max: usize) -> Self {
43        self.max_history = max;
44        self
45    }
46
47    /// Update dashboard with new metrics from collector.
48    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
49    pub fn update(&mut self, collector: &WasmMetricsCollector) {
50        // Get latest values
51        let loss = collector.loss_mean();
52        let accuracy = collector.accuracy_mean();
53
54        // Add to history if valid
55        if !loss.is_nan() {
56            self.loss_history.push(loss);
57            if self.loss_history.len() > self.max_history {
58                self.loss_history.remove(0);
59            }
60        }
61
62        if !accuracy.is_nan() {
63            self.accuracy_history.push(accuracy);
64            if self.accuracy_history.len() > self.max_history {
65                self.accuracy_history.remove(0);
66            }
67        }
68    }
69
70    /// Add a loss value directly to history.
71    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
72    pub fn add_loss(&mut self, value: f64) {
73        if !value.is_nan() && !value.is_infinite() {
74            self.loss_history.push(value);
75            if self.loss_history.len() > self.max_history {
76                self.loss_history.remove(0);
77            }
78        }
79    }
80
81    /// Add an accuracy value directly to history.
82    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
83    pub fn add_accuracy(&mut self, value: f64) {
84        if !value.is_nan() && !value.is_infinite() {
85            self.accuracy_history.push(value);
86            if self.accuracy_history.len() > self.max_history {
87                self.accuracy_history.remove(0);
88            }
89        }
90    }
91
92    /// Get loss history length.
93    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
94    pub fn loss_history_len(&self) -> usize {
95        self.loss_history.len()
96    }
97
98    /// Get accuracy history length.
99    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
100    pub fn accuracy_history_len(&self) -> usize {
101        self.accuracy_history.len()
102    }
103
104    /// Clear all history.
105    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
106    pub fn clear(&mut self) {
107        self.loss_history.clear();
108        self.accuracy_history.clear();
109    }
110
111    /// Get canvas width.
112    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
113    pub fn width(&self) -> u32 {
114        self.options.width
115    }
116
117    /// Get canvas height.
118    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
119    pub fn height(&self) -> u32 {
120        self.options.height
121    }
122
123    /// Get loss history as normalized Y coordinates (0.0-1.0).
124    /// Returns empty vec if no history.
125    pub fn loss_normalized(&self) -> Vec<f64> {
126        normalize_values(&self.loss_history)
127    }
128
129    /// Get accuracy history as normalized Y coordinates (0.0-1.0).
130    /// Accuracy is already 0-1 typically, but this handles edge cases.
131    pub fn accuracy_normalized(&self) -> Vec<f64> {
132        normalize_values(&self.accuracy_history)
133    }
134
135    /// Get X coordinates for plotting (normalized 0.0-1.0).
136    pub fn x_coordinates(&self, len: usize) -> Vec<f64> {
137        if len <= 1 {
138            return vec![0.5];
139        }
140        (0..len).map(|i| i as f64 / (len - 1) as f64).collect()
141    }
142
143    /// Generate sparkline characters for terminal display.
144    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
145    pub fn loss_sparkline(&self) -> String {
146        generate_sparkline(&self.loss_history, 20)
147    }
148
149    /// Generate sparkline characters for accuracy.
150    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
151    pub fn accuracy_sparkline(&self) -> String {
152        generate_sparkline(&self.accuracy_history, 20)
153    }
154
155    /// Get dashboard state as JSON.
156    #[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
157    pub fn state_json(&self) -> String {
158        let state = DashboardState {
159            width: self.options.width,
160            height: self.options.height,
161            loss_history: self.loss_history.clone(),
162            accuracy_history: self.accuracy_history.clone(),
163            loss_color: self.options.loss_color.clone(),
164            accuracy_color: self.options.accuracy_color.clone(),
165            background_color: self.options.background_color.clone(),
166        };
167        serde_json::to_string(&state).unwrap_or_else(|_err| "{}".to_string())
168    }
169}
170
171impl Default for WasmDashboard {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177/// Dashboard state for JSON serialization.
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
179pub(crate) struct DashboardState {
180    width: u32,
181    height: u32,
182    loss_history: Vec<f64>,
183    accuracy_history: Vec<f64>,
184    loss_color: String,
185    accuracy_color: String,
186    background_color: String,
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_wasm_dashboard_new() {
195        let dashboard = WasmDashboard::new();
196        assert_eq!(dashboard.loss_history_len(), 0);
197        assert_eq!(dashboard.accuracy_history_len(), 0);
198        assert_eq!(dashboard.width(), 800);
199        assert_eq!(dashboard.height(), 400);
200    }
201
202    #[test]
203    fn test_wasm_dashboard_add_loss() {
204        let mut dashboard = WasmDashboard::new();
205        dashboard.add_loss(0.5);
206        dashboard.add_loss(0.3);
207        assert_eq!(dashboard.loss_history_len(), 2);
208    }
209
210    #[test]
211    fn test_wasm_dashboard_add_accuracy() {
212        let mut dashboard = WasmDashboard::new();
213        dashboard.add_accuracy(0.8);
214        dashboard.add_accuracy(0.9);
215        assert_eq!(dashboard.accuracy_history_len(), 2);
216    }
217
218    #[test]
219    fn test_wasm_dashboard_ignores_nan() {
220        let mut dashboard = WasmDashboard::new();
221        dashboard.add_loss(0.5);
222        dashboard.add_loss(f64::NAN);
223        dashboard.add_loss(0.3);
224        assert_eq!(dashboard.loss_history_len(), 2);
225    }
226
227    #[test]
228    fn test_wasm_dashboard_ignores_inf() {
229        let mut dashboard = WasmDashboard::new();
230        dashboard.add_accuracy(0.8);
231        dashboard.add_accuracy(f64::INFINITY);
232        assert_eq!(dashboard.accuracy_history_len(), 1);
233    }
234
235    #[test]
236    fn test_wasm_dashboard_max_history() {
237        let mut dashboard = WasmDashboard::new().max_history(5);
238        for i in 0..10 {
239            dashboard.add_loss(f64::from(i));
240        }
241        assert_eq!(dashboard.loss_history_len(), 5);
242    }
243
244    #[test]
245    fn test_wasm_dashboard_clear() {
246        let mut dashboard = WasmDashboard::new();
247        dashboard.add_loss(0.5);
248        dashboard.add_accuracy(0.8);
249        dashboard.clear();
250        assert_eq!(dashboard.loss_history_len(), 0);
251        assert_eq!(dashboard.accuracy_history_len(), 0);
252    }
253
254    #[test]
255    fn test_wasm_dashboard_update() {
256        let mut collector = WasmMetricsCollector::new();
257        collector.record_loss(0.5);
258        collector.record_accuracy(0.8);
259
260        let mut dashboard = WasmDashboard::new();
261        dashboard.update(&collector);
262
263        assert_eq!(dashboard.loss_history_len(), 1);
264        assert_eq!(dashboard.accuracy_history_len(), 1);
265    }
266
267    #[test]
268    fn test_wasm_dashboard_sparkline() {
269        let mut dashboard = WasmDashboard::new();
270        for i in 0..10 {
271            dashboard.add_loss(f64::from(i) / 10.0);
272        }
273
274        let sparkline = dashboard.loss_sparkline();
275        assert!(!sparkline.is_empty());
276        assert!(sparkline.chars().count() <= 20);
277    }
278
279    #[test]
280    fn test_wasm_dashboard_state_json() {
281        let mut dashboard = WasmDashboard::new();
282        dashboard.add_loss(0.5);
283        dashboard.add_accuracy(0.8);
284
285        let json = dashboard.state_json();
286        assert!(json.contains("width"));
287        assert!(json.contains("loss_history"));
288        assert!(json.contains("accuracy_history"));
289    }
290
291    #[test]
292    fn test_wasm_dashboard_x_coordinates() {
293        let dashboard = WasmDashboard::new();
294
295        let coords = dashboard.x_coordinates(5);
296        assert_eq!(coords.len(), 5);
297        assert!((coords[0] - 0.0).abs() < 1e-6);
298        assert!((coords[4] - 1.0).abs() < 1e-6);
299    }
300
301    #[test]
302    fn test_wasm_dashboard_x_coordinates_single() {
303        let dashboard = WasmDashboard::new();
304        let coords = dashboard.x_coordinates(1);
305        assert_eq!(coords.len(), 1);
306        assert!((coords[0] - 0.5).abs() < 1e-6);
307    }
308
309    #[test]
310    fn test_wasm_dashboard_loss_normalized() {
311        let mut dashboard = WasmDashboard::new();
312        dashboard.add_loss(0.0);
313        dashboard.add_loss(5.0);
314        dashboard.add_loss(10.0);
315
316        let normalized = dashboard.loss_normalized();
317        assert_eq!(normalized.len(), 3);
318        assert!((normalized[0] - 0.0).abs() < 1e-6);
319        assert!((normalized[1] - 0.5).abs() < 1e-6);
320        assert!((normalized[2] - 1.0).abs() < 1e-6);
321    }
322
323    #[test]
324    fn test_wasm_dashboard_with_options() {
325        let opts = WasmDashboardOptions::new().width(1024).height(768);
326        let dashboard = WasmDashboard::with_options(opts);
327        assert_eq!(dashboard.width(), 1024);
328        assert_eq!(dashboard.height(), 768);
329    }
330
331    #[test]
332    fn test_wasm_dashboard_default() {
333        let dashboard = WasmDashboard::default();
334        assert_eq!(dashboard.width(), 800);
335    }
336}
337
338#[cfg(test)]
339mod proptests {
340    use super::*;
341    use proptest::prelude::*;
342
343    proptest! {
344        /// Property: Dashboard respects max_history
345        #[test]
346        fn prop_dashboard_max_history(
347            max in 5usize..50,
348            count in 10usize..200
349        ) {
350            let mut dashboard = WasmDashboard::new().max_history(max);
351
352            for i in 0..count {
353                dashboard.add_loss(i as f64);
354            }
355
356            prop_assert!(dashboard.loss_history_len() <= max);
357        }
358
359        /// Property: X coordinates span [0, 1]
360        #[test]
361        fn prop_x_coords_span(len in 2usize..100) {
362            let dashboard = WasmDashboard::new();
363            let coords = dashboard.x_coordinates(len);
364
365            prop_assert_eq!(coords.len(), len);
366            prop_assert!((coords[0] - 0.0).abs() < 1e-10);
367            prop_assert!((coords[len - 1] - 1.0).abs() < 1e-10);
368        }
369
370        /// Property: Dashboard ignores NaN values
371        #[test]
372        fn prop_dashboard_ignores_nan(
373            valid_count in 1usize..20,
374            nan_count in 1usize..10
375        ) {
376            let mut dashboard = WasmDashboard::new();
377
378            for i in 0..valid_count {
379                dashboard.add_loss(i as f64);
380            }
381            for _ in 0..nan_count {
382                dashboard.add_loss(f64::NAN);
383            }
384
385            prop_assert_eq!(dashboard.loss_history_len(), valid_count);
386        }
387
388        /// Property: JSON state is valid
389        #[test]
390        fn prop_json_state_valid(values in prop::collection::vec(0.0f64..100.0, 0..50)) {
391            let mut dashboard = WasmDashboard::new();
392            for v in &values {
393                dashboard.add_loss(*v);
394            }
395
396            let json = dashboard.state_json();
397            let parsed: Result<serde_json::Value, _> = serde_json::from_str(&json);
398            prop_assert!(parsed.is_ok());
399        }
400    }
401}