## 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
    }