Skip to main content

presentar_terminal/widgets/
loss_curve.rs

1//! Loss curve widget for ML training visualization.
2//!
3//! Implements P204 from SPEC-024 Section 15.2.
4//! Supports EMA smoothing for noisy training curves.
5
6use crate::widgets::symbols::BRAILLE_UP;
7use presentar_core::{
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
9    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
10};
11use std::any::Any;
12use std::time::Duration;
13
14/// EMA (Exponential Moving Average) configuration.
15#[derive(Debug, Clone, Copy)]
16pub struct EmaConfig {
17    /// Smoothing factor (0.0 = no smoothing, 0.99 = heavy smoothing).
18    pub alpha: f64,
19}
20
21impl Default for EmaConfig {
22    fn default() -> Self {
23        Self { alpha: 0.6 }
24    }
25}
26
27/// A single training series (e.g., train loss, val loss).
28#[derive(Debug, Clone)]
29pub struct LossSeries {
30    /// Series name.
31    pub name: String,
32    /// Raw loss values per epoch/step.
33    pub values: Vec<f64>,
34    /// Series color.
35    pub color: Color,
36    /// Show smoothed line.
37    pub smoothed: bool,
38}
39
40/// Loss curve widget for visualizing ML training progress.
41#[derive(Debug, Clone)]
42pub struct LossCurve {
43    series: Vec<LossSeries>,
44    ema_config: EmaConfig,
45    show_raw: bool,
46    x_label: Option<String>,
47    y_label: Option<String>,
48    y_log_scale: bool,
49    bounds: Rect,
50    // Cached smoothed values (computed once, reused)
51    smoothed_cache: Vec<Vec<f64>>,
52}
53
54impl LossCurve {
55    /// Create a new loss curve widget.
56    #[must_use]
57    pub fn new() -> Self {
58        Self {
59            series: Vec::new(),
60            ema_config: EmaConfig::default(),
61            show_raw: true,
62            x_label: Some("Epoch".to_string()),
63            y_label: Some("Loss".to_string()),
64            y_log_scale: false,
65            bounds: Rect::default(),
66            smoothed_cache: Vec::new(),
67        }
68    }
69
70    /// Add a loss series.
71    #[must_use]
72    pub fn add_series(mut self, name: &str, values: Vec<f64>, color: Color) -> Self {
73        self.series.push(LossSeries {
74            name: name.to_string(),
75            values,
76            color,
77            smoothed: true,
78        });
79        self.invalidate_cache();
80        self
81    }
82
83    /// Set EMA smoothing configuration.
84    #[must_use]
85    pub fn with_ema(mut self, config: EmaConfig) -> Self {
86        self.ema_config = config;
87        self.invalidate_cache();
88        self
89    }
90
91    /// Toggle raw line visibility.
92    #[must_use]
93    pub fn with_raw_visible(mut self, show: bool) -> Self {
94        self.show_raw = show;
95        self
96    }
97
98    /// Set log scale for Y axis.
99    #[must_use]
100    pub fn with_log_scale(mut self, log: bool) -> Self {
101        self.y_log_scale = log;
102        self
103    }
104
105    /// Set X axis label.
106    #[must_use]
107    pub fn with_x_label(mut self, label: &str) -> Self {
108        self.x_label = Some(label.to_string());
109        self
110    }
111
112    /// Set Y axis label.
113    #[must_use]
114    pub fn with_y_label(mut self, label: &str) -> Self {
115        self.y_label = Some(label.to_string());
116        self
117    }
118
119    /// Update series data.
120    pub fn update_series(&mut self, index: usize, values: Vec<f64>) {
121        if let Some(series) = self.series.get_mut(index) {
122            series.values = values;
123            self.invalidate_cache();
124        }
125    }
126
127    /// Invalidate smoothed value cache.
128    fn invalidate_cache(&mut self) {
129        self.smoothed_cache.clear();
130    }
131
132    /// Compute EMA smoothing for a series.
133    /// Uses SIMD-friendly batch operations for large datasets.
134    fn compute_ema(&self, values: &[f64]) -> Vec<f64> {
135        if values.is_empty() {
136            return Vec::new();
137        }
138
139        let alpha = self.ema_config.alpha;
140        let mut smoothed = Vec::with_capacity(values.len());
141
142        // EMA: S_t = α * X_t + (1-α) * S_{t-1}
143        // Sequential processing due to recurrence relation.
144
145        let mut prev = values[0];
146        smoothed.push(prev);
147
148        for &val in values.iter().skip(1) {
149            if val.is_finite() {
150                prev = alpha * val + (1.0 - alpha) * prev;
151            }
152            // Keep previous value for NaN/Inf
153            smoothed.push(prev);
154        }
155
156        smoothed
157    }
158
159    /// Ensure smoothed cache is populated.
160    fn ensure_cache(&mut self) {
161        if self.smoothed_cache.len() != self.series.len() {
162            self.smoothed_cache = self
163                .series
164                .iter()
165                .map(|s| {
166                    if s.smoothed {
167                        self.compute_ema(&s.values)
168                    } else {
169                        s.values.clone()
170                    }
171                })
172                .collect();
173        }
174    }
175
176    /// Get Y range across all series.
177    fn y_range(&self) -> (f64, f64) {
178        let mut y_min = f64::INFINITY;
179        let mut y_max = f64::NEG_INFINITY;
180
181        for series in &self.series {
182            for &v in &series.values {
183                if v.is_finite() && v > 0.0 {
184                    // Filter non-positive for log scale
185                    y_min = y_min.min(v);
186                    y_max = y_max.max(v);
187                }
188            }
189        }
190
191        if y_min == f64::INFINITY {
192            (0.001, 1.0)
193        } else {
194            // Add padding
195            let padding = (y_max - y_min) * 0.1;
196            (
197                (y_min - padding).max(if self.y_log_scale { 1e-10 } else { 0.0 }),
198                y_max + padding,
199            )
200        }
201    }
202
203    /// Get X range (number of epochs/steps).
204    fn x_range(&self) -> (f64, f64) {
205        let max_len = self
206            .series
207            .iter()
208            .map(|s| s.values.len())
209            .max()
210            .unwrap_or(0);
211        (0.0, max_len.saturating_sub(1) as f64)
212    }
213
214    /// Transform Y value (linear or log scale).
215    fn transform_y(&self, y: f64, y_min: f64, y_max: f64) -> f64 {
216        if self.y_log_scale {
217            let log_min = y_min.max(1e-10).ln();
218            let log_max = y_max.ln();
219            let log_y = y.max(1e-10).ln();
220            (log_y - log_min) / (log_max - log_min)
221        } else {
222            (y - y_min) / (y_max - y_min)
223        }
224    }
225}
226
227impl Default for LossCurve {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233impl Widget for LossCurve {
234    fn type_id(&self) -> TypeId {
235        TypeId::of::<Self>()
236    }
237
238    fn measure(&self, constraints: Constraints) -> Size {
239        Size::new(
240            constraints.max_width.min(80.0),
241            constraints.max_height.min(20.0),
242        )
243    }
244
245    fn layout(&mut self, bounds: Rect) -> LayoutResult {
246        self.bounds = bounds;
247        self.ensure_cache();
248        LayoutResult {
249            size: Size::new(bounds.width, bounds.height),
250        }
251    }
252
253    #[allow(clippy::too_many_lines)]
254    fn paint(&self, canvas: &mut dyn Canvas) {
255        if self.bounds.width < 15.0 || self.bounds.height < 5.0 {
256            return;
257        }
258
259        let margin_left = 8.0;
260        let margin_bottom = 2.0;
261        let margin_right = 2.0;
262
263        let plot_x = self.bounds.x + margin_left;
264        let plot_y = self.bounds.y;
265        let plot_width = self.bounds.width - margin_left - margin_right;
266        let plot_height = self.bounds.height - margin_bottom;
267
268        if plot_width <= 0.0 || plot_height <= 0.0 {
269            return;
270        }
271
272        let (y_min, y_max) = self.y_range();
273        let (x_min, x_max) = self.x_range();
274
275        let label_style = TextStyle {
276            color: Color::new(0.6, 0.6, 0.6, 1.0),
277            ..Default::default()
278        };
279
280        // Draw Y axis labels
281        for i in 0..=4 {
282            let t = i as f64 / 4.0;
283            let y_val = if self.y_log_scale {
284                let log_min = y_min.max(1e-10).ln();
285                let log_max = y_max.ln();
286                (log_min + (log_max - log_min) * (1.0 - t)).exp()
287            } else {
288                y_min + (y_max - y_min) * (1.0 - t)
289            };
290            let y_pos = plot_y + plot_height * t as f32;
291
292            if y_pos >= plot_y && y_pos < plot_y + plot_height {
293                let label = if y_val < 0.01 {
294                    format!("{y_val:.1e}")
295                } else if y_val < 1.0 {
296                    format!("{y_val:.3}")
297                } else {
298                    format!("{y_val:.2}")
299                };
300                canvas.draw_text(
301                    &format!("{label:>7}"),
302                    Point::new(self.bounds.x, y_pos),
303                    &label_style,
304                );
305            }
306        }
307
308        // Draw X axis labels
309        let x_ticks = 5.min(x_max as usize);
310        for i in 0..=x_ticks {
311            let t = i as f64 / x_ticks as f64;
312            let x_val = x_min + (x_max - x_min) * t;
313            let x_pos = plot_x + plot_width * t as f32;
314
315            if x_pos >= plot_x && x_pos < plot_x + plot_width - 3.0 {
316                let label = format!("{x_val:.0}");
317                canvas.draw_text(
318                    &label,
319                    Point::new(x_pos, plot_y + plot_height),
320                    &label_style,
321                );
322            }
323        }
324
325        // Draw each series
326        for (series_idx, series) in self.series.iter().enumerate() {
327            if series.values.is_empty() {
328                continue;
329            }
330
331            // Get smoothed values from cache
332            let smoothed = if series_idx < self.smoothed_cache.len() && series.smoothed {
333                &self.smoothed_cache[series_idx]
334            } else {
335                &series.values
336            };
337
338            // Draw raw line (dimmed) if enabled
339            if self.show_raw && series.smoothed {
340                let raw_style = TextStyle {
341                    color: Color::new(
342                        series.color.r * 0.4,
343                        series.color.g * 0.4,
344                        series.color.b * 0.4,
345                        0.5,
346                    ),
347                    ..Default::default()
348                };
349
350                self.draw_line_braille(
351                    canvas,
352                    &series.values,
353                    &raw_style,
354                    plot_x,
355                    plot_y,
356                    plot_width,
357                    plot_height,
358                    y_min,
359                    y_max,
360                    x_max,
361                );
362            }
363
364            // Draw smoothed line
365            let style = TextStyle {
366                color: series.color,
367                ..Default::default()
368            };
369
370            self.draw_line_braille(
371                canvas,
372                smoothed,
373                &style,
374                plot_x,
375                plot_y,
376                plot_width,
377                plot_height,
378                y_min,
379                y_max,
380                x_max,
381            );
382        }
383
384        // Draw legend
385        if !self.series.is_empty() {
386            for (i, series) in self.series.iter().enumerate() {
387                let style = TextStyle {
388                    color: series.color,
389                    ..Default::default()
390                };
391                let legend_y = plot_y + i as f32;
392                canvas.draw_text(
393                    &format!("─ {}", series.name),
394                    Point::new(plot_x + plot_width - 15.0, legend_y),
395                    &style,
396                );
397            }
398        }
399    }
400
401    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
402        None
403    }
404
405    fn children(&self) -> &[Box<dyn Widget>] {
406        &[]
407    }
408
409    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
410        &mut []
411    }
412}
413
414impl LossCurve {
415    /// Draw a line using braille characters.
416    #[allow(clippy::too_many_arguments)]
417    fn draw_line_braille(
418        &self,
419        canvas: &mut dyn Canvas,
420        values: &[f64],
421        style: &TextStyle,
422        plot_x: f32,
423        plot_y: f32,
424        plot_width: f32,
425        plot_height: f32,
426        y_min: f64,
427        y_max: f64,
428        x_max: f64,
429    ) {
430        if values.is_empty() || x_max <= 0.0 {
431            return;
432        }
433
434        let cols = plot_width as usize;
435        let rows = (plot_height * 4.0) as usize; // 4 braille dots per row
436
437        if cols == 0 || rows == 0 {
438            return;
439        }
440
441        let mut grid = vec![vec![false; rows]; cols];
442
443        // Plot points onto grid
444        for (i, &y) in values.iter().enumerate() {
445            if !y.is_finite() {
446                continue;
447            }
448
449            let x_norm = i as f64 / x_max;
450            let y_norm = self.transform_y(y, y_min, y_max);
451
452            if !(0.0..=1.0).contains(&x_norm) || !(0.0..=1.0).contains(&y_norm) {
453                continue;
454            }
455
456            let gx = ((x_norm * (cols - 1) as f64).round() as usize).min(cols.saturating_sub(1));
457            let gy =
458                (((1.0 - y_norm) * (rows - 1) as f64).round() as usize).min(rows.saturating_sub(1));
459
460            grid[gx][gy] = true;
461
462            // Connect to previous point
463            if i > 0 {
464                let prev_y = values[i - 1];
465                if prev_y.is_finite() {
466                    let prev_x_norm = (i - 1) as f64 / x_max;
467                    let prev_y_norm = self.transform_y(prev_y, y_min, y_max);
468
469                    if (0.0..=1.0).contains(&prev_x_norm) && (0.0..=1.0).contains(&prev_y_norm) {
470                        let prev_gx = ((prev_x_norm * (cols - 1) as f64).round() as usize)
471                            .min(cols.saturating_sub(1));
472                        let prev_gy = (((1.0 - prev_y_norm) * (rows - 1) as f64).round() as usize)
473                            .min(rows.saturating_sub(1));
474
475                        Self::draw_line_bresenham(&mut grid, prev_gx, prev_gy, gx, gy);
476                    }
477                }
478            }
479        }
480
481        // Render grid as braille
482        let char_rows = plot_height as usize;
483        for cy in 0..char_rows {
484            for (cx, column) in grid.iter().enumerate() {
485                let mut dots = 0u8;
486                for dy in 0..4 {
487                    let gy = cy * 4 + dy;
488                    if gy < rows && column[gy] {
489                        dots |= 1 << dy;
490                    }
491                }
492
493                if dots > 0 {
494                    let braille_idx = dots as usize;
495                    let ch = if braille_idx < BRAILLE_UP.len() {
496                        BRAILLE_UP[braille_idx]
497                    } else {
498                        '⣿'
499                    };
500                    canvas.draw_text(
501                        &ch.to_string(),
502                        Point::new(plot_x + cx as f32, plot_y + cy as f32),
503                        style,
504                    );
505                }
506            }
507        }
508    }
509
510    /// Draw a line between two points using Bresenham's algorithm.
511    #[allow(clippy::cast_possible_wrap)]
512    fn draw_line_bresenham(grid: &mut [Vec<bool>], x0: usize, y0: usize, x1: usize, y1: usize) {
513        let dx = (x1 as isize - x0 as isize).abs();
514        let dy = -(y1 as isize - y0 as isize).abs();
515        let sx: isize = if x0 < x1 { 1 } else { -1 };
516        let sy: isize = if y0 < y1 { 1 } else { -1 };
517        let mut err = dx + dy;
518
519        let mut x = x0 as isize;
520        let mut y = y0 as isize;
521
522        let cols = grid.len() as isize;
523        let rows = if cols > 0 { grid[0].len() as isize } else { 0 };
524
525        loop {
526            if x >= 0 && x < cols && y >= 0 && y < rows {
527                grid[x as usize][y as usize] = true;
528            }
529
530            if x == x1 as isize && y == y1 as isize {
531                break;
532            }
533
534            let e2 = 2 * err;
535            if e2 >= dy {
536                err += dy;
537                x += sx;
538            }
539            if e2 <= dx {
540                err += dx;
541                y += sy;
542            }
543        }
544    }
545}
546
547impl Brick for LossCurve {
548    fn brick_name(&self) -> &'static str {
549        "LossCurve"
550    }
551
552    fn assertions(&self) -> &[BrickAssertion] {
553        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
554        ASSERTIONS
555    }
556
557    fn budget(&self) -> BrickBudget {
558        BrickBudget::uniform(16)
559    }
560
561    fn verify(&self) -> BrickVerification {
562        let mut passed = Vec::new();
563        let mut failed = Vec::new();
564
565        if self.bounds.width >= 15.0 && self.bounds.height >= 5.0 {
566            passed.push(BrickAssertion::max_latency_ms(16));
567        } else {
568            failed.push((
569                BrickAssertion::max_latency_ms(16),
570                "Size too small".to_string(),
571            ));
572        }
573
574        BrickVerification {
575            passed,
576            failed,
577            verification_time: Duration::from_micros(5),
578        }
579    }
580
581    fn to_html(&self) -> String {
582        String::new()
583    }
584
585    fn to_css(&self) -> String {
586        String::new()
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::{CellBuffer, DirectTerminalCanvas};
594
595    #[test]
596    fn test_loss_curve_creation() {
597        let curve =
598            LossCurve::new().add_series("train", vec![1.0, 0.8, 0.6, 0.4, 0.3, 0.25], Color::BLUE);
599        assert_eq!(curve.series.len(), 1);
600    }
601
602    #[test]
603    fn test_ema_smoothing() {
604        let curve = LossCurve::new().with_ema(EmaConfig { alpha: 0.5 });
605        let values = vec![1.0, 0.0, 1.0, 0.0, 1.0];
606        let smoothed = curve.compute_ema(&values);
607
608        assert_eq!(smoothed.len(), values.len());
609        // First value should be unchanged
610        assert!((smoothed[0] - 1.0).abs() < 0.001);
611        // Subsequent values should be smoothed
612        assert!((smoothed[1] - 0.5).abs() < 0.001);
613    }
614
615    #[test]
616    fn test_ema_empty_values() {
617        let curve = LossCurve::new();
618        let smoothed = curve.compute_ema(&[]);
619        assert!(smoothed.is_empty());
620    }
621
622    #[test]
623    fn test_ema_with_nan_values() {
624        let curve = LossCurve::new().with_ema(EmaConfig { alpha: 0.5 });
625        let values = vec![1.0, f64::NAN, 0.5];
626        let smoothed = curve.compute_ema(&values);
627        assert_eq!(smoothed.len(), 3);
628        assert!((smoothed[0] - 1.0).abs() < 0.001);
629        // NaN should keep previous value
630        assert!((smoothed[1] - 1.0).abs() < 0.001);
631    }
632
633    #[test]
634    fn test_ema_config_default() {
635        let config = EmaConfig::default();
636        assert!((config.alpha - 0.6).abs() < 0.001);
637    }
638
639    #[test]
640    fn test_log_scale() {
641        let curve = LossCurve::new().with_log_scale(true);
642        let y_norm = curve.transform_y(1.0, 0.1, 10.0);
643        assert!(y_norm > 0.0 && y_norm < 1.0);
644    }
645
646    #[test]
647    fn test_linear_scale() {
648        let curve = LossCurve::new().with_log_scale(false);
649        let y_norm = curve.transform_y(5.0, 0.0, 10.0);
650        assert!((y_norm - 0.5).abs() < 0.001);
651    }
652
653    #[test]
654    fn test_multi_series() {
655        let curve = LossCurve::new()
656            .add_series("train", vec![1.0, 0.5, 0.3], Color::BLUE)
657            .add_series("val", vec![1.1, 0.6, 0.4], Color::RED);
658        assert_eq!(curve.series.len(), 2);
659    }
660
661    #[test]
662    fn test_with_x_label() {
663        let curve = LossCurve::new().with_x_label("Steps");
664        assert_eq!(curve.x_label, Some("Steps".to_string()));
665    }
666
667    #[test]
668    fn test_with_y_label() {
669        let curve = LossCurve::new().with_y_label("MSE");
670        assert_eq!(curve.y_label, Some("MSE".to_string()));
671    }
672
673    #[test]
674    fn test_with_raw_visible() {
675        let curve = LossCurve::new().with_raw_visible(false);
676        assert!(!curve.show_raw);
677
678        let curve2 = LossCurve::new().with_raw_visible(true);
679        assert!(curve2.show_raw);
680    }
681
682    #[test]
683    fn test_update_series() {
684        let mut curve = LossCurve::new().add_series("train", vec![1.0, 0.8], Color::BLUE);
685        curve.update_series(0, vec![0.5, 0.3, 0.1]);
686        assert_eq!(curve.series[0].values.len(), 3);
687    }
688
689    #[test]
690    fn test_update_series_invalid_index() {
691        let mut curve = LossCurve::new().add_series("train", vec![1.0], Color::BLUE);
692        curve.update_series(5, vec![0.5]); // Invalid index, should be ignored
693        assert_eq!(curve.series[0].values.len(), 1);
694    }
695
696    #[test]
697    fn test_y_range_empty() {
698        let curve = LossCurve::new();
699        let (y_min, y_max) = curve.y_range();
700        assert!(y_min < y_max);
701    }
702
703    #[test]
704    fn test_y_range_with_data() {
705        let curve = LossCurve::new().add_series("train", vec![1.0, 2.0, 3.0], Color::BLUE);
706        let (y_min, y_max) = curve.y_range();
707        assert!(y_min <= 1.0);
708        assert!(y_max >= 3.0);
709    }
710
711    #[test]
712    fn test_x_range() {
713        let curve =
714            LossCurve::new().add_series("train", vec![1.0, 2.0, 3.0, 4.0, 5.0], Color::BLUE);
715        let (x_min, x_max) = curve.x_range();
716        assert!((x_min - 0.0).abs() < 0.001);
717        assert!((x_max - 4.0).abs() < 0.001);
718    }
719
720    #[test]
721    fn test_x_range_empty() {
722        let curve = LossCurve::new();
723        let (x_min, x_max) = curve.x_range();
724        assert!((x_min - 0.0).abs() < 0.001);
725        assert!((x_max - 0.0).abs() < 0.001);
726    }
727
728    #[test]
729    fn test_loss_curve_measure() {
730        let curve = LossCurve::new();
731        let constraints = Constraints::new(0.0, 100.0, 0.0, 50.0);
732        let size = curve.measure(constraints);
733        assert_eq!(size.width, 80.0);
734        assert_eq!(size.height, 20.0);
735    }
736
737    #[test]
738    fn test_loss_curve_layout_and_paint() {
739        let mut curve = LossCurve::new()
740            .add_series("train", vec![2.0, 1.5, 1.0, 0.8, 0.6], Color::BLUE)
741            .add_series("val", vec![2.2, 1.8, 1.2, 1.0, 0.9], Color::RED);
742
743        let mut buffer = CellBuffer::new(60, 20);
744        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
745
746        let result = curve.layout(Rect::new(0.0, 0.0, 60.0, 20.0));
747        assert_eq!(result.size.width, 60.0);
748        assert_eq!(result.size.height, 20.0);
749
750        curve.paint(&mut canvas);
751
752        // Verify something was rendered
753        let cells = buffer.cells();
754        let non_empty = cells.iter().filter(|c| !c.symbol.is_empty()).count();
755        assert!(non_empty > 0, "Loss curve should render some content");
756    }
757
758    #[test]
759    fn test_loss_curve_paint_with_log_scale() {
760        let mut curve = LossCurve::new().with_log_scale(true).add_series(
761            "train",
762            vec![1.0, 0.1, 0.01, 0.001],
763            Color::BLUE,
764        );
765
766        let mut buffer = CellBuffer::new(60, 20);
767        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
768
769        curve.layout(Rect::new(0.0, 0.0, 60.0, 20.0));
770        curve.paint(&mut canvas);
771    }
772
773    #[test]
774    fn test_loss_curve_paint_small_bounds() {
775        let mut curve = LossCurve::new().add_series("train", vec![1.0, 0.5], Color::BLUE);
776
777        let mut buffer = CellBuffer::new(10, 3);
778        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
779
780        curve.layout(Rect::new(0.0, 0.0, 10.0, 3.0));
781        curve.paint(&mut canvas);
782        // Should not crash with small bounds
783    }
784
785    #[test]
786    fn test_loss_curve_paint_with_raw_hidden() {
787        let mut curve = LossCurve::new().with_raw_visible(false).add_series(
788            "train",
789            vec![1.0, 0.8, 0.6, 0.4],
790            Color::BLUE,
791        );
792
793        let mut buffer = CellBuffer::new(60, 20);
794        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
795
796        curve.layout(Rect::new(0.0, 0.0, 60.0, 20.0));
797        curve.paint(&mut canvas);
798    }
799
800    #[test]
801    fn test_loss_curve_paint_noisy_data() {
802        let values: Vec<f64> = (0..100)
803            .map(|i| 2.0 * (-i as f64 / 30.0).exp() + 0.1 * (i as f64 * 0.5).sin())
804            .collect();
805
806        let mut curve = LossCurve::new()
807            .with_ema(EmaConfig { alpha: 0.1 })
808            .add_series("train", values, Color::BLUE);
809
810        let mut buffer = CellBuffer::new(80, 25);
811        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
812
813        curve.layout(Rect::new(0.0, 0.0, 80.0, 25.0));
814        curve.paint(&mut canvas);
815    }
816
817    #[test]
818    fn test_loss_curve_paint_with_nan() {
819        let values = vec![1.0, f64::NAN, 0.5, f64::INFINITY, 0.3];
820        let mut curve = LossCurve::new().add_series("train", values, Color::BLUE);
821
822        let mut buffer = CellBuffer::new(60, 20);
823        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
824
825        curve.layout(Rect::new(0.0, 0.0, 60.0, 20.0));
826        curve.paint(&mut canvas);
827    }
828
829    #[test]
830    fn test_loss_curve_ensure_cache() {
831        let mut curve = LossCurve::new().add_series("train", vec![1.0, 0.5, 0.3], Color::BLUE);
832        curve.ensure_cache();
833        assert_eq!(curve.smoothed_cache.len(), 1);
834        assert_eq!(curve.smoothed_cache[0].len(), 3);
835    }
836
837    #[test]
838    fn test_loss_curve_assertions() {
839        let curve = LossCurve::default();
840        assert!(!curve.assertions().is_empty());
841    }
842
843    #[test]
844    fn test_loss_curve_verify_valid() {
845        let mut curve = LossCurve::default();
846        curve.bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
847        assert!(curve.verify().is_valid());
848    }
849
850    #[test]
851    fn test_loss_curve_verify_invalid() {
852        let mut curve = LossCurve::default();
853        curve.bounds = Rect::new(0.0, 0.0, 10.0, 3.0);
854        assert!(!curve.verify().is_valid());
855    }
856
857    #[test]
858    fn test_loss_curve_children() {
859        let curve = LossCurve::default();
860        assert!(curve.children().is_empty());
861    }
862
863    #[test]
864    fn test_loss_curve_children_mut() {
865        let mut curve = LossCurve::default();
866        assert!(curve.children_mut().is_empty());
867    }
868
869    #[test]
870    fn test_loss_curve_brick_name() {
871        let curve = LossCurve::new();
872        assert_eq!(curve.brick_name(), "LossCurve");
873    }
874
875    #[test]
876    fn test_loss_curve_budget() {
877        let curve = LossCurve::new();
878        let budget = curve.budget();
879        assert!(budget.layout_ms > 0);
880    }
881
882    #[test]
883    fn test_loss_curve_to_html() {
884        let curve = LossCurve::new();
885        assert!(curve.to_html().is_empty());
886    }
887
888    #[test]
889    fn test_loss_curve_to_css() {
890        let curve = LossCurve::new();
891        assert!(curve.to_css().is_empty());
892    }
893
894    #[test]
895    fn test_loss_curve_type_id() {
896        let curve = LossCurve::new();
897        let type_id = Widget::type_id(&curve);
898        assert_eq!(type_id, TypeId::of::<LossCurve>());
899    }
900
901    #[test]
902    fn test_loss_curve_event() {
903        let mut curve = LossCurve::new();
904        let event = Event::Resize {
905            width: 80.0,
906            height: 24.0,
907        };
908        assert!(curve.event(&event).is_none());
909    }
910
911    #[test]
912    fn test_empty_series() {
913        let curve = LossCurve::new().add_series("empty", vec![], Color::BLUE);
914        let (y_min, y_max) = curve.y_range();
915        assert!(y_min < y_max);
916    }
917
918    #[test]
919    fn test_bresenham_line() {
920        let mut grid = vec![vec![false; 10]; 10];
921        LossCurve::draw_line_bresenham(&mut grid, 0, 0, 9, 9);
922        // Check diagonal has points
923        assert!(grid[0][0]);
924        assert!(grid[9][9]);
925    }
926
927    #[test]
928    fn test_bresenham_line_reverse() {
929        let mut grid = vec![vec![false; 10]; 10];
930        LossCurve::draw_line_bresenham(&mut grid, 9, 9, 0, 0);
931        assert!(grid[0][0]);
932        assert!(grid[9][9]);
933    }
934
935    #[test]
936    fn test_bresenham_horizontal() {
937        let mut grid = vec![vec![false; 10]; 10];
938        LossCurve::draw_line_bresenham(&mut grid, 0, 5, 9, 5);
939        for x in 0..10 {
940            assert!(grid[x][5]);
941        }
942    }
943
944    #[test]
945    fn test_bresenham_vertical() {
946        let mut grid = vec![vec![false; 10]; 10];
947        LossCurve::draw_line_bresenham(&mut grid, 5, 0, 5, 9);
948        for y in 0..10 {
949            assert!(grid[5][y]);
950        }
951    }
952}