Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/trueno/src/simulation/visual.rs
Line
Count
Source
1
//! Visual Regression Testing (Genchi Genbutsu: Go and See)
2
//!
3
//! Provides pixel-perfect validation of compute outputs through
4
//! heatmap rendering and golden baseline comparison.
5
6
use std::path::PathBuf;
7
8
/// RGB color for visualization
9
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
pub struct Rgb {
11
    /// Red component
12
    pub r: u8,
13
    /// Green component
14
    pub g: u8,
15
    /// Blue component
16
    pub b: u8,
17
}
18
19
impl Rgb {
20
    /// Create new RGB color
21
    #[must_use]
22
0
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
23
0
        Self { r, g, b }
24
0
    }
25
26
    /// Magenta for NaN values
27
    pub const NAN_COLOR: Self = Self::new(255, 0, 255);
28
    /// White for +Infinity
29
    pub const INF_COLOR: Self = Self::new(255, 255, 255);
30
    /// Black for -Infinity
31
    pub const NEG_INF_COLOR: Self = Self::new(0, 0, 0);
32
}
33
34
/// Color palette for heatmap rendering
35
#[derive(Debug, Clone)]
36
pub struct ColorPalette {
37
    pub(crate) colors: Vec<Rgb>,
38
}
39
40
impl Default for ColorPalette {
41
0
    fn default() -> Self {
42
0
        Self::viridis()
43
0
    }
44
}
45
46
impl ColorPalette {
47
    /// Viridis colorblind-friendly palette
48
    #[must_use]
49
0
    pub fn viridis() -> Self {
50
0
        Self {
51
0
            colors: vec![
52
0
                Rgb::new(68, 1, 84),
53
0
                Rgb::new(59, 82, 139),
54
0
                Rgb::new(33, 145, 140),
55
0
                Rgb::new(94, 201, 98),
56
0
                Rgb::new(253, 231, 37),
57
0
            ],
58
0
        }
59
0
    }
60
61
    /// Grayscale palette
62
    #[must_use]
63
0
    pub fn grayscale() -> Self {
64
0
        Self {
65
0
            colors: vec![
66
0
                Rgb::new(0, 0, 0),
67
0
                Rgb::new(128, 128, 128),
68
0
                Rgb::new(255, 255, 255),
69
0
            ],
70
0
        }
71
0
    }
72
73
    /// Interpolate color at position t (0.0 to 1.0)
74
    #[must_use]
75
    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
76
0
    pub fn interpolate(&self, t: f32) -> Rgb {
77
0
        let t = t.clamp(0.0, 1.0);
78
0
        let n = self.colors.len() - 1;
79
0
        let idx = (t * n as f32).floor() as usize;
80
0
        let idx = idx.min(n - 1);
81
0
        let local_t = t * n as f32 - idx as f32;
82
83
0
        let c1 = &self.colors[idx];
84
0
        let c2 = &self.colors[idx + 1];
85
86
0
        Rgb {
87
0
            r: (c1.r as f32 + (c2.r as f32 - c1.r as f32) * local_t) as u8,
88
0
            g: (c1.g as f32 + (c2.g as f32 - c1.g as f32) * local_t) as u8,
89
0
            b: (c1.b as f32 + (c2.b as f32 - c1.b as f32) * local_t) as u8,
90
0
        }
91
0
    }
92
}
93
94
/// Visual regression test configuration (Genchi Genbutsu)
95
#[derive(Debug, Clone)]
96
pub struct VisualRegressionConfig {
97
    /// Golden baseline directory
98
    pub golden_dir: PathBuf,
99
    /// Output directory for test results
100
    pub output_dir: PathBuf,
101
    /// Maximum allowed different pixels (percentage)
102
    pub max_diff_pct: f64,
103
    /// Color palette for visualization
104
    pub palette: ColorPalette,
105
}
106
107
impl Default for VisualRegressionConfig {
108
0
    fn default() -> Self {
109
0
        Self {
110
0
            golden_dir: PathBuf::from("golden"),
111
0
            output_dir: PathBuf::from("test_output"),
112
0
            max_diff_pct: 0.0, // Exact match by default
113
0
            palette: ColorPalette::default(),
114
0
        }
115
0
    }
116
}
117
118
impl VisualRegressionConfig {
119
    /// Create new config with custom golden directory
120
    #[must_use]
121
0
    pub fn new(golden_dir: impl Into<PathBuf>) -> Self {
122
0
        Self {
123
0
            golden_dir: golden_dir.into(),
124
0
            ..Default::default()
125
0
        }
126
0
    }
127
128
    /// Set output directory
129
    #[must_use]
130
0
    pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
131
0
        self.output_dir = dir.into();
132
0
        self
133
0
    }
134
135
    /// Set maximum diff percentage
136
    #[must_use]
137
0
    pub const fn with_max_diff_pct(mut self, pct: f64) -> Self {
138
0
        self.max_diff_pct = pct;
139
0
        self
140
0
    }
141
142
    /// Set color palette
143
    #[must_use]
144
0
    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
145
0
        self.palette = palette;
146
0
        self
147
0
    }
148
}
149
150
/// Pixel diff result for visual regression testing
151
#[derive(Debug, Clone)]
152
pub struct PixelDiffResult {
153
    /// Number of pixels that differ
154
    pub different_pixels: usize,
155
    /// Total number of pixels
156
    pub total_pixels: usize,
157
    /// Maximum color difference found
158
    pub max_diff: u32,
159
}
160
161
impl PixelDiffResult {
162
    /// Calculate percentage of different pixels
163
    #[must_use]
164
0
    pub fn diff_percentage(&self) -> f64 {
165
0
        if self.total_pixels == 0 {
166
0
            0.0
167
        } else {
168
0
            (self.different_pixels as f64 / self.total_pixels as f64) * 100.0
169
        }
170
0
    }
171
172
    /// Check if images match within threshold
173
    #[must_use]
174
0
    pub fn matches(&self, threshold_pct: f64) -> bool {
175
0
        self.diff_percentage() <= threshold_pct
176
0
    }
177
178
    /// Create a passing result (no differences)
179
    #[must_use]
180
0
    pub const fn pass(total_pixels: usize) -> Self {
181
0
        Self {
182
0
            different_pixels: 0,
183
0
            total_pixels,
184
0
            max_diff: 0,
185
0
        }
186
0
    }
187
}
188
189
/// Simple buffer renderer for SIMD output visualization
190
///
191
/// Converts f32 buffers to raw RGBA bytes for testing
192
#[derive(Debug, Clone)]
193
pub struct BufferRenderer {
194
    palette: ColorPalette,
195
    pub(crate) range: Option<(f32, f32)>,
196
}
197
198
impl Default for BufferRenderer {
199
0
    fn default() -> Self {
200
0
        Self::new()
201
0
    }
202
}
203
204
impl BufferRenderer {
205
    /// Create renderer with auto-normalization
206
    #[must_use]
207
0
    pub fn new() -> Self {
208
0
        Self {
209
0
            palette: ColorPalette::default(),
210
0
            range: None,
211
0
        }
212
0
    }
213
214
    /// Set fixed range for normalization
215
    #[must_use]
216
0
    pub const fn with_range(mut self, min: f32, max: f32) -> Self {
217
0
        self.range = Some((min, max));
218
0
        self
219
0
    }
220
221
    /// Set color palette
222
    #[must_use]
223
0
    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
224
0
        self.palette = palette;
225
0
        self
226
0
    }
227
228
    /// Render f32 buffer to raw RGBA bytes
229
    ///
230
    /// Returns Vec<u8> with RGBA pixels (4 bytes per pixel)
231
    #[must_use]
232
0
    pub fn render_to_rgba(&self, buffer: &[f32], width: u32, height: u32) -> Vec<u8> {
233
0
        assert_eq!(buffer.len(), (width * height) as usize);
234
235
0
        let (min_val, max_val) = self.range.unwrap_or_else(|| {
236
0
            let valid: Vec<f32> = buffer.iter().copied().filter(|v| v.is_finite()).collect();
237
0
            if valid.is_empty() {
238
0
                (0.0, 1.0)
239
            } else {
240
0
                let min = valid.iter().copied().fold(f32::INFINITY, f32::min);
241
0
                let max = valid.iter().copied().fold(f32::NEG_INFINITY, f32::max);
242
0
                (min, max.max(min + f32::EPSILON))
243
            }
244
0
        });
245
246
0
        let mut rgba = Vec::with_capacity(buffer.len() * 4);
247
248
0
        for &value in buffer {
249
0
            let color = if value.is_nan() {
250
0
                Rgb::NAN_COLOR
251
0
            } else if value.is_infinite() {
252
0
                if value > 0.0 {
253
0
                    Rgb::INF_COLOR
254
                } else {
255
0
                    Rgb::NEG_INF_COLOR
256
                }
257
            } else {
258
0
                let t = (value - min_val) / (max_val - min_val);
259
0
                self.palette.interpolate(t)
260
            };
261
262
0
            rgba.push(color.r);
263
0
            rgba.push(color.g);
264
0
            rgba.push(color.b);
265
0
            rgba.push(255); // Alpha
266
        }
267
268
0
        rgba
269
0
    }
270
271
    /// Compare two RGBA buffers and return diff result
272
    #[must_use]
273
0
    pub fn compare_rgba(&self, a: &[u8], b: &[u8], tolerance: u8) -> PixelDiffResult {
274
0
        if a == b {
275
0
            return PixelDiffResult::pass(a.len() / 4);
276
0
        }
277
278
0
        let min_len = a.len().min(b.len());
279
0
        let mut different = 0;
280
0
        let mut max_diff: u32 = 0;
281
282
        // Compare pixels (4 bytes each: RGBA)
283
0
        for i in (0..min_len).step_by(4) {
284
0
            let mut pixel_diff = false;
285
0
            for j in 0..4 {
286
0
                if i + j < min_len {
287
0
                    let diff = (a[i + j] as i32 - b[i + j] as i32).unsigned_abs();
288
0
                    if diff > tolerance as u32 {
289
0
                        pixel_diff = true;
290
0
                        max_diff = max_diff.max(diff);
291
0
                    }
292
0
                }
293
            }
294
0
            if pixel_diff {
295
0
                different += 1;
296
0
            }
297
        }
298
299
        // Count size difference as pixel differences
300
0
        if a.len() != b.len() {
301
0
            different += a.len().abs_diff(b.len()) / 4;
302
0
        }
303
304
0
        PixelDiffResult {
305
0
            different_pixels: different,
306
0
            total_pixels: min_len.max(a.len()).max(b.len()) / 4,
307
0
            max_diff,
308
0
        }
309
0
    }
310
}
311
312
/// Golden baseline manager for visual regression testing
313
#[derive(Debug, Clone)]
314
pub struct GoldenBaseline {
315
    config: VisualRegressionConfig,
316
}
317
318
impl GoldenBaseline {
319
    /// Create new golden baseline manager
320
    #[must_use]
321
0
    pub fn new(config: VisualRegressionConfig) -> Self {
322
0
        Self { config }
323
0
    }
324
325
    /// Get path for a golden baseline file
326
    #[must_use]
327
0
    pub fn golden_path(&self, name: &str) -> PathBuf {
328
0
        self.config.golden_dir.join(format!("{name}.golden"))
329
0
    }
330
331
    /// Get path for an output file
332
    #[must_use]
333
0
    pub fn output_path(&self, name: &str) -> PathBuf {
334
0
        self.config.output_dir.join(format!("{name}.output"))
335
0
    }
336
337
    /// Get the config
338
    #[must_use]
339
0
    pub const fn config(&self) -> &VisualRegressionConfig {
340
0
        &self.config
341
0
    }
342
}
343
344
#[cfg(test)]
345
mod tests {
346
    use super::*;
347
348
    #[test]
349
    fn test_rgb_color_creation() {
350
        let color = Rgb::new(255, 128, 64);
351
        assert_eq!(color.r, 255);
352
        assert_eq!(color.g, 128);
353
        assert_eq!(color.b, 64);
354
    }
355
356
    #[test]
357
    fn test_rgb_special_colors() {
358
        assert_eq!(Rgb::NAN_COLOR, Rgb::new(255, 0, 255));
359
        assert_eq!(Rgb::INF_COLOR, Rgb::new(255, 255, 255));
360
        assert_eq!(Rgb::NEG_INF_COLOR, Rgb::new(0, 0, 0));
361
    }
362
363
    #[test]
364
    fn test_color_palette_viridis() {
365
        let palette = ColorPalette::viridis();
366
        assert_eq!(palette.colors.len(), 5);
367
368
        // Test interpolation at boundaries
369
        let at_0 = palette.interpolate(0.0);
370
        let at_1 = palette.interpolate(1.0);
371
372
        // Viridis starts dark purple, ends yellow
373
        assert_eq!(at_0, Rgb::new(68, 1, 84));
374
        assert_eq!(at_1, Rgb::new(253, 231, 37));
375
    }
376
377
    #[test]
378
    fn test_color_palette_grayscale() {
379
        let palette = ColorPalette::grayscale();
380
        assert_eq!(palette.colors.len(), 3);
381
382
        let at_0 = palette.interpolate(0.0);
383
        let at_1 = palette.interpolate(1.0);
384
385
        assert_eq!(at_0, Rgb::new(0, 0, 0));
386
        assert_eq!(at_1, Rgb::new(255, 255, 255));
387
    }
388
389
    #[test]
390
    fn test_color_palette_interpolation_midpoint() {
391
        let palette = ColorPalette::grayscale();
392
        let at_mid = palette.interpolate(0.5);
393
394
        // Should be close to gray
395
        assert_eq!(at_mid, Rgb::new(128, 128, 128));
396
    }
397
398
    #[test]
399
    fn test_color_palette_clamping() {
400
        let palette = ColorPalette::viridis();
401
402
        // Values outside [0, 1] should be clamped
403
        let at_neg = palette.interpolate(-0.5);
404
        let at_over = palette.interpolate(1.5);
405
406
        assert_eq!(at_neg, palette.interpolate(0.0));
407
        assert_eq!(at_over, palette.interpolate(1.0));
408
    }
409
410
    #[test]
411
    fn test_visual_regression_config_default() {
412
        let config = VisualRegressionConfig::default();
413
414
        assert_eq!(config.golden_dir, PathBuf::from("golden"));
415
        assert_eq!(config.output_dir, PathBuf::from("test_output"));
416
        assert_eq!(config.max_diff_pct, 0.0);
417
    }
418
419
    #[test]
420
    fn test_visual_regression_config_builder() {
421
        let config = VisualRegressionConfig::new("my_golden")
422
            .with_output_dir("my_output")
423
            .with_max_diff_pct(1.5)
424
            .with_palette(ColorPalette::grayscale());
425
426
        assert_eq!(config.golden_dir, PathBuf::from("my_golden"));
427
        assert_eq!(config.output_dir, PathBuf::from("my_output"));
428
        assert_eq!(config.max_diff_pct, 1.5);
429
    }
430
431
    #[test]
432
    fn test_pixel_diff_result_percentage() {
433
        let result = PixelDiffResult {
434
            different_pixels: 10,
435
            total_pixels: 100,
436
            max_diff: 50,
437
        };
438
439
        assert_eq!(result.diff_percentage(), 10.0);
440
        assert!(!result.matches(5.0));
441
        assert!(result.matches(10.0));
442
        assert!(result.matches(15.0));
443
    }
444
445
    #[test]
446
    fn test_pixel_diff_result_zero_total() {
447
        let result = PixelDiffResult {
448
            different_pixels: 0,
449
            total_pixels: 0,
450
            max_diff: 0,
451
        };
452
453
        assert_eq!(result.diff_percentage(), 0.0);
454
    }
455
456
    #[test]
457
    fn test_pixel_diff_result_pass() {
458
        let result = PixelDiffResult::pass(100);
459
460
        assert_eq!(result.different_pixels, 0);
461
        assert_eq!(result.total_pixels, 100);
462
        assert_eq!(result.max_diff, 0);
463
        assert!(result.matches(0.0));
464
    }
465
466
    #[test]
467
    fn test_buffer_renderer_default() {
468
        let renderer = BufferRenderer::default();
469
        assert!(renderer.range.is_none());
470
    }
471
472
    #[test]
473
    fn test_buffer_renderer_with_range() {
474
        let renderer = BufferRenderer::new().with_range(0.0, 10.0);
475
        assert_eq!(renderer.range, Some((0.0, 10.0)));
476
    }
477
478
    #[test]
479
    fn test_buffer_renderer_with_palette() {
480
        let renderer = BufferRenderer::new().with_palette(ColorPalette::grayscale());
481
        assert_eq!(renderer.palette.colors.len(), 3);
482
    }
483
484
    #[test]
485
    fn test_buffer_renderer_rgba_output() {
486
        let renderer = BufferRenderer::new();
487
        let buffer: Vec<f32> = (0..4).map(|i| i as f32 / 3.0).collect();
488
        let rgba = renderer.render_to_rgba(&buffer, 2, 2);
489
490
        // 4 pixels * 4 bytes = 16 bytes
491
        assert_eq!(rgba.len(), 16);
492
493
        // Check alpha channel is always 255
494
        for i in (3..16).step_by(4) {
495
            assert_eq!(rgba[i], 255);
496
        }
497
    }
498
499
    #[test]
500
    fn test_buffer_renderer_nan_handling() {
501
        let renderer = BufferRenderer::new();
502
        let buffer = vec![0.0, f32::NAN, 1.0, 0.5];
503
        let rgba = renderer.render_to_rgba(&buffer, 2, 2);
504
505
        // Second pixel should be NAN_COLOR (magenta: 255, 0, 255)
506
        assert_eq!(rgba[4], 255); // R
507
        assert_eq!(rgba[5], 0); // G
508
        assert_eq!(rgba[6], 255); // B
509
        assert_eq!(rgba[7], 255); // A
510
    }
511
512
    #[test]
513
    fn test_buffer_renderer_inf_handling() {
514
        let renderer = BufferRenderer::new();
515
        let buffer = vec![f32::INFINITY, f32::NEG_INFINITY, 0.5, 0.5];
516
        let rgba = renderer.render_to_rgba(&buffer, 2, 2);
517
518
        // First pixel: +INF should be white
519
        assert_eq!(rgba[0], 255);
520
        assert_eq!(rgba[1], 255);
521
        assert_eq!(rgba[2], 255);
522
523
        // Second pixel: -INF should be black
524
        assert_eq!(rgba[4], 0);
525
        assert_eq!(rgba[5], 0);
526
        assert_eq!(rgba[6], 0);
527
    }
528
529
    #[test]
530
    fn test_buffer_renderer_compare_identical() {
531
        let renderer = BufferRenderer::new();
532
        let buffer: Vec<f32> = (0..16).map(|i| i as f32 / 15.0).collect();
533
        let rgba = renderer.render_to_rgba(&buffer, 4, 4);
534
535
        let result = renderer.compare_rgba(&rgba, &rgba, 0);
536
        assert_eq!(result.different_pixels, 0);
537
        assert!(result.matches(0.0));
538
    }
539
540
    #[test]
541
    fn test_buffer_renderer_compare_different() {
542
        let renderer = BufferRenderer::new();
543
        let buffer_a: Vec<f32> = (0..16).map(|i| i as f32 / 15.0).collect();
544
        let buffer_b: Vec<f32> = (0..16).map(|i| 1.0 - i as f32 / 15.0).collect();
545
546
        let rgba_a = renderer.render_to_rgba(&buffer_a, 4, 4);
547
        let rgba_b = renderer.render_to_rgba(&buffer_b, 4, 4);
548
549
        let result = renderer.compare_rgba(&rgba_a, &rgba_b, 0);
550
        assert!(result.different_pixels > 0);
551
    }
552
553
    #[test]
554
    fn test_buffer_renderer_compare_with_tolerance() {
555
        let renderer = BufferRenderer::new();
556
        let rgba_a = vec![100, 100, 100, 255];
557
        let rgba_b = vec![105, 102, 98, 255];
558
559
        // With tolerance 10, should match
560
        let result = renderer.compare_rgba(&rgba_a, &rgba_b, 10);
561
        assert_eq!(result.different_pixels, 0);
562
563
        // With tolerance 1, should differ
564
        let result_strict = renderer.compare_rgba(&rgba_a, &rgba_b, 1);
565
        assert!(result_strict.different_pixels > 0);
566
    }
567
568
    #[test]
569
    fn test_golden_baseline_paths() {
570
        let config = VisualRegressionConfig::new("/test/golden").with_output_dir("/test/output");
571
        let baseline = GoldenBaseline::new(config);
572
573
        assert_eq!(
574
            baseline.golden_path("relu_4x4"),
575
            PathBuf::from("/test/golden/relu_4x4.golden")
576
        );
577
        assert_eq!(
578
            baseline.output_path("relu_4x4"),
579
            PathBuf::from("/test/output/relu_4x4.output")
580
        );
581
    }
582
583
    #[test]
584
    fn test_golden_baseline_config_access() {
585
        let config = VisualRegressionConfig::new("/golden").with_max_diff_pct(2.5);
586
        let baseline = GoldenBaseline::new(config);
587
588
        assert_eq!(baseline.config().max_diff_pct, 2.5);
589
    }
590
}