## Coverage Report

#### Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)

    /home/noah/src/realizar/src/viz.rs

    Line
    Count
    Source
    1
    //! Benchmark visualization using trueno-viz.
    2
    //!
    3
    //! Provides terminal-based visualizations for benchmark results including
    4
    //! histograms, sparklines, and performance comparisons.
    5
    6
    // Statistical calculations require numeric casts that may lose precision
    7
    // on 64-bit systems, but this is acceptable for visualization purposes
    8
    #![allow(clippy::cast_precision_loss)]
    9
    #![allow(clippy::cast_possible_truncation)]
    10
    #![allow(clippy::cast_sign_loss)]
    11
    12
    #[cfg(feature = "visualization")]
    13
    use trueno_viz::{
    14
        output::{TerminalEncoder, TerminalMode},
    15
        plots::{BinStrategy, Histogram},
    16
        prelude::Rgba,
    17
    };
    18
    19
    #[cfg(feature = "visualization")]
    20
    use crate::error::{RealizarError, Result};
    21
    22
    /// Benchmark result data for visualization.
    23
    #[derive(Debug, Clone)]
    24
    pub struct BenchmarkData {
    25
        /// Name of the benchmark
    26
        pub name: String,
    27
        /// Latency samples in microseconds
    28
        pub latencies_us: Vec<f64>,
    29
        /// Throughput samples (ops/sec)
    30
        pub throughput: Option<Vec<f64>>,
    31
    }
    32
    33
    impl BenchmarkData {
    34
        /// Create new benchmark data.
    35
        #[must_use]
    36
    16
        pub fn new(name: impl Into<String>, latencies_us: Vec<f64>) -> Self {
    37
    16
            Self {
    38
    16
                name: name.into(),
    39
    16
                latencies_us,
    40
    16
                throughput: None,
    41
    16
            }
    42
    16
        }
    43
    44
        /// Add throughput data.
    45
        #[must_use]
    46
    1
        pub fn with_throughput(mut self, throughput: Vec<f64>) -> Self {
    47
    1
            self.throughput = Some(throughput);
    48
    1
            self
    49
    1
        }
    50
    51
        /// Calculate statistics.
    52
        #[must_use]
    53
    14
        pub fn stats(&self) -> BenchmarkStats {
    54
    14
            let n = self.latencies_us.len();
    55
    14
            if n == 0 {
    56
    2
                return BenchmarkStats::default();
    57
    12
            }
    58
    59
    12
            let mut sorted = self.latencies_us.clone();
    60
    2.36k
            sorted12.sort_by12(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    61
    62
    12
            let sum: f64 = sorted.iter().sum();
    63
    12
            let mean = sum / n as f64;
    64
    65
    356
            let variance12 = sorted.iter()12.map12(|x| (x - mean).powi(2)).sum12::<f64>() / n as f6412;
    66
    12
            let std_dev = variance.sqrt();
    67
    68
    12
            let p50 = percentile(&sorted, 50.0);
    69
    12
            let p95 = percentile(&sorted, 95.0);
    70
    12
            let p99 = percentile(&sorted, 99.0);
    71
    72
    12
            BenchmarkStats {
    73
    12
                count: n,
    74
    12
                mean,
    75
    12
                std_dev,
    76
    12
                min: sorted.first().copied().unwrap_or(0.0),
    77
    12
                max: sorted.last().copied().unwrap_or(0.0),
    78
    12
                p50,
    79
    12
                p95,
    80
    12
                p99,
    81
    12
            }
    82
    14
        }
    83
    }
    84
    85
    /// Calculate percentile from sorted data.
    86
    40
    fn percentile(sorted: &[f64], p: f64) -> f64 {
    87
    40
        if sorted.is_empty() {
    88
    1
            return 0.0;
    89
    39
        }
    90
    39
        let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
    91
    39
        sorted[idx.min(sorted.len() - 1)]
    92
    40
    }
    93
    94
    /// Statistics for benchmark results.
    95
    #[derive(Debug, Clone, Default)]
    96
    pub struct BenchmarkStats {
    97
        /// Number of samples
    98
        pub count: usize,
    99
        /// Mean latency (us)
    100
        pub mean: f64,
    101
        /// Standard deviation (us)
    102
        pub std_dev: f64,
    103
        /// Minimum latency (us)
    104
        pub min: f64,
    105
        /// Maximum latency (us)
    106
        pub max: f64,
    107
        /// 50th percentile (median)
    108
        pub p50: f64,
    109
        /// 95th percentile
    110
        pub p95: f64,
    111
        /// 99th percentile
    112
        pub p99: f64,
    113
    }
    114
    115
    impl std::fmt::Display for BenchmarkStats {
    116
    12
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    117
    12
            writeln!(f, "  samples: {}", self.count)?0;
    118
    12
            writeln!(f, "  mean:    {:.2} us", self.mean)?0;
    119
    12
            writeln!(f, "  std_dev: {:.2} us", self.std_dev)?0;
    120
    12
            writeln!(f, "  min:     {:.2} us", self.min)?0;
    121
    12
            writeln!(f, "  p50:     {:.2} us", self.p50)?0;
    122
    12
            writeln!(f, "  p95:     {:.2} us", self.p95)?0;
    123
    12
            writeln!(f, "  p99:     {:.2} us", self.p99)?0;
    124
    12
            write!(f, "  max:     {:.2} us", self.max)
    125
    12
        }
    126
    }
    127
    128
    /// Render a latency histogram to terminal.
    129
    ///
    130
    /// # Errors
    131
    ///
    132
    /// Returns error if visualization fails or data is empty.
    133
    #[cfg(feature = "visualization")]
    134
    pub fn render_histogram_terminal(data: &BenchmarkData, width: u32) -> Result<String> {
    135
        if data.latencies_us.is_empty() {
    136
            return Err(RealizarError::InvalidShape {
    137
                reason: "No latency data to visualize".to_string(),
    138
            });
    139
        }
    140
    141
        // Convert to f32 for trueno-viz
    142
        let latencies: Vec<f32> = data.latencies_us.iter().map(|&x| x as f32).collect();
    143
    144
        let hist = Histogram::new()
    145
            .data(&latencies)
    146
            .bins(BinStrategy::Sturges)
    147
            .color(Rgba::rgb(70, 130, 180)) // Steel blue
    148
            .dimensions(width * 8, 200) // Scale up for better resolution
    149
            .build()
    150
            .map_err(|e| RealizarError::InvalidShape {
    151
                reason: format!("Failed to build histogram: {e}"),
    152
            })?;
    153
    154
        let fb = hist
    155
            .to_framebuffer()
    156
            .map_err(|e| RealizarError::InvalidShape {
    157
                reason: format!("Failed to render histogram: {e}"),
    158
            })?;
    159
    160
        let encoder = TerminalEncoder::new()
    161
            .mode(TerminalMode::Ascii)
    162
            .width(width);
    163
    164
        Ok(encoder.render(&fb))
    165
    }
    166
    167
    /// Render a latency histogram with ANSI colors.
    168
    ///
    169
    /// # Errors
    170
    ///
    171
    /// Returns error if visualization fails or data is empty.
    172
    #[cfg(feature = "visualization")]
    173
    pub fn render_histogram_ansi(data: &BenchmarkData, width: u32) -> Result<String> {
    174
        if data.latencies_us.is_empty() {
    175
            return Err(RealizarError::InvalidShape {
    176
                reason: "No latency data to visualize".to_string(),
    177
            });
    178
        }
    179
    180
        let latencies: Vec<f32> = data.latencies_us.iter().map(|&x| x as f32).collect();
    181
    182
        let hist = Histogram::new()
    183
            .data(&latencies)
    184
            .bins(BinStrategy::Sturges)
    185
            .color(Rgba::rgb(70, 130, 180))
    186
            .dimensions(width * 8, 200)
    187
            .build()
    188
            .map_err(|e| RealizarError::InvalidShape {
    189
                reason: format!("Failed to build histogram: {e}"),
    190
            })?;
    191
    192
        let fb = hist
    193
            .to_framebuffer()
    194
            .map_err(|e| RealizarError::InvalidShape {
    195
                reason: format!("Failed to render histogram: {e}"),
    196
            })?;
    197
    198
        let encoder = TerminalEncoder::new()
    199
            .mode(TerminalMode::UnicodeHalfBlock)
    200
            .width(width);
    201
    202
        Ok(encoder.render(&fb))
    203
    }
    204
    205
    /// Sparkline bar characters (8 levels).
    206
    const SPARKLINE_BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
    207
    208
    /// Render an ASCII sparkline for quick visualization.
    209
    ///
    210
    /// This is a lightweight alternative that doesn't require trueno-viz.
    211
    #[must_use]
    212
    80
    pub fn render_sparkline(values: &[f64], width: usize) -> String {
    213
    80
        if values.is_empty() {
    214
    3
            return String::new();
    215
    77
        }
    216
    217
    77
        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
    218
    77
        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
    219
    77
        let range = max - min;
    220
    221
        // Sample values to fit width
    222
    77
        let step = values.len().max(1) / width.max(1);
    223
    77
        let step = step.max(1);
    224
    225
    77
        let mut result = String::with_capacity(width);
    226
    227
    1.56k
        for i in 0..width77 {
    228
    1.56k
            let idx = (i * step).min(values.len() - 1);
    229
    1.56k
            let value = values[idx];
    230
    231
    1.56k
            let normalized = if range > 0.0 {
    232
    1.45k
                (value - min) / range
    233
            } else {
    234
    110
                0.5
    235
            };
    236
    237
    1.56k
            let bar_idx = (normalized * (SPARKLINE_BARS.len() - 1) as f64).round() as usize;
    238
    1.56k
            result.push(SPARKLINE_BARS[bar_idx.min(SPARKLINE_BARS.len() - 1)]);
    239
        }
    240
    241
    77
        result
    242
    80
    }
    243
    244
    /// Render a simple ASCII histogram (no dependencies).
    245
    ///
    246
    /// This provides basic visualization without trueno-viz.
    247
    #[must_use]
    248
    24
    pub fn render_ascii_histogram(values: &[f64], bins: usize, width: usize) -> String {
    249
        use std::fmt::Write;
    250
    251
    24
        if values.is_empty() {
    252
    3
            return String::new();
    253
    21
        }
    254
    255
    21
        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
    256
    21
        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
    257
    21
        let range = max - min;
    258
    21
        let bin_width = range / bins as f64;
    259
    260
        // Count values in each bin
    261
    21
        let mut counts = vec![0usize; bins];
    262
    817
        for &v796 in values {
    263
    796
            let bin = if bin_width > 0.0 {
    264
    794
                ((v - min) / bin_width).floor() as usize
    265
            } else {
    266
    2
                0
    267
            };
    268
    796
            let bin = bin.min(bins - 1);
    269
    796
            counts[bin] += 1;
    270
        }
    271
    272
    21
        let max_count = *counts.iter().max().unwrap_or(&1);
    273
    21
        let scale = width as f64 / max_count as f64;
    274
    275
    21
        let mut result = String::new();
    276
    277
    230
        for (i, &count) in counts.iter()21.enumerate21() {
    278
    230
            let bar_len = (count as f64 * scale).round() as usize;
    279
    230
            let bin_start = min + i as f64 * bin_width;
    280
    230
            let bin_end = bin_start + bin_width;
    281
    230
    282
    230
            let _ = writeln!(
    283
    230
                result,
    284
    230
                "{:>8.1}-{:<8.1} |{}",
    285
    230
                bin_start,
    286
    230
                bin_end,
    287
    230
                "█".repeat(bar_len)
    288
    230
            );
    289
    230
        }
    290
    291
    21
        result
    292
    24
    }
    293
    294
    /// Print benchmark results with optional visualization.
    295
    11
    pub fn print_benchmark_results(data: &BenchmarkData, use_ansi: bool) {
    296
    11
        let stats = data.stats();
    297
    298
    11
        println!("Benchmark: {}", data.name);
    299
    11
        println!("{stats}");
    300
    11
        println!();
    301
    302
        // Sparkline (always available)
    303
    11
        println!("  trend: {}", render_sparkline(&data.latencies_us, 40));
    304
    11
        println!();
    305
    306
        // ASCII histogram (always available)
    307
    11
        println!("  distribution:");
    308
    11
        let hist = render_ascii_histogram(&data.latencies_us, 10, 40);
    309
    100
        for line in hist11.lines11() {
    310
    100
            println!("    {line}");
    311
    100
        }
    312
    313
        // Full visualization if available
    314
        #[cfg(feature = "visualization")]
    315
        {
    316
            println!();
    317
            println!("  visual:");
    318
            let rendered = if use_ansi {
    319
                render_histogram_ansi(data, 60)
    320
            } else {
    321
                render_histogram_terminal(data, 60)
    322
            };
    323
    324
            if let Ok(viz) = rendered {
    325
                for line in viz.lines() {
    326
                    println!("    {line}");
    327
                }
    328
            }
    329
        }
    330
    331
    11
        let _ = use_ansi; // Suppress unused warning when feature disabled
    332
    11
    }
    333
    334
    #[cfg(test)]
    335
    mod tests {
    336
        use super::*;
    337
    338
        #[test]
    339
    1
        fn test_benchmark_data_creation() {
    340
    1
            let data = BenchmarkData::new("test", vec![1.0, 2.0, 3.0, 4.0, 5.0]);
    341
    1
            assert_eq!(data.name, "test");
    342
    1
            assert_eq!(data.latencies_us.len(), 5);
    343
    1
        }
    344
    345
        #[test]
    346
    1
        fn test_benchmark_stats() {
    347
    1
            let data = BenchmarkData::new("test", vec![1.0, 2.0, 3.0, 4.0, 5.0]);
    348
    1
            let stats = data.stats();
    349
    350
    1
            assert_eq!(stats.count, 5);
    351
    1
            assert!((stats.mean - 3.0).abs() < 0.01);
    352
    1
            assert!((stats.min - 1.0).abs() < 0.01);
    353
    1
            assert!((stats.max - 5.0).abs() < 0.01);
    354
    1
        }
    355
    356
        #[test]
    357
    1
        fn test_empty_stats() {
    358
    1
            let data = BenchmarkData::new("empty", vec![]);
    359
    1
            let stats = data.stats();
    360
    1
            assert_eq!(stats.count, 0);
    361
    1
        }
    362
    363
        #[test]
    364
    1
        fn test_sparkline() {
    365
    1
            let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0];
    366
    1
            let sparkline = render_sparkline(&values, 9);
    367
    1
            assert_eq!(sparkline.chars().count(), 9);
    368
    1
            assert!(sparkline.contains('▁')); // Min
    369
    1
            assert!(sparkline.contains('█')); // Max
    370
    1
        }
    371
    372
        #[test]
    373
    1
        fn test_sparkline_empty() {
    374
    1
            let sparkline = render_sparkline(&[], 10);
    375
    1
            assert!(sparkline.is_empty());
    376
    1
        }
    377
    378
        #[test]
    379
    1
        fn test_sparkline_constant() {
    380
    1
            let values = vec![5.0; 10];
    381
    1
            let sparkline = render_sparkline(&values, 10);
    382
            // All same value should produce uniform bars
    383
    1
            let unique: std::collections::HashSet<char> = sparkline.chars().collect();
    384
    1
            assert_eq!(unique.len(), 1);
    385
    1
        }
    386
    387
        #[test]
    388
    1
        fn test_ascii_histogram() {
    389
    100
            let values1: Vec<f64>1 = (0..100)1.map1(|i| i as f64).collect1();
    390
    1
            let hist = render_ascii_histogram(&values, 10, 40);
    391
    392
    1
            assert!(!hist.is_empty());
    393
    1
            assert!(hist.contains('█'));
    394
    1
            assert_eq!(hist.lines().count(), 10);
    395
    1
        }
    396
    397
        #[test]
    398
    1
        fn test_ascii_histogram_empty() {
    399
    1
            let hist = render_ascii_histogram(&[], 10, 40);
    400
    1
            assert!(hist.is_empty());
    401
    1
        }
    402
    403
        #[test]
    404
    1
        fn test_percentile() {
    405
    1
            let sorted = vec![1.0, 2.0, 3.0, 4.0, 5.0];
    406
    1
            assert!((percentile(&sorted, 0.0) - 1.0).abs() < 0.01);
    407
    1
            assert!((percentile(&sorted, 50.0) - 3.0).abs() < 0.01);
    408
    1
            assert!((percentile(&sorted, 100.0) - 5.0).abs() < 0.01);
    409
    1
        }
    410
    411
        #[test]
    412
    1
        fn test_percentile_empty() {
    413
    1
            assert!((percentile(&[], 50.0) - 0.0).abs() < 0.01);
    414
    1
        }
    415
    416
        #[test]
    417
    1
        fn test_stats_display() {
    418
    1
            let data = BenchmarkData::new("test", vec![1.0, 2.0, 3.0]);
    419
    1
            let stats = data.stats();
    420
    1
            let display = format!("{stats}");
    421
    1
            assert!(display.contains("mean"));
    422
    1
            assert!(display.contains("p50"));
    423
    1
            assert!(display.contains("p99"));
    424
    1
        }
    425
    426
        #[test]
    427
    1
        fn test_with_throughput() {
    428
    1
            let data = BenchmarkData::new("test", vec![1.0, 2.0]).with_throughput(vec![1000.0, 2000.0]);
    429
    1
            assert!(data.throughput.is_some());
    430
    1
            assert_eq!(data.throughput.expect("test").len(), 2);
    431
    1
        }
    432
    433
        #[cfg(feature = "visualization")]
    434
        #[test]
    435
        fn test_histogram_terminal() {
    436
            let data = BenchmarkData::new("test", vec![1.0, 2.0, 3.0, 4.0, 5.0]);
    437
            let result = render_histogram_terminal(&data, 40);
    438
            assert!(result.is_ok());
    439
            assert!(!result.expect("test").is_empty());
    440
        }
    441
    442
        #[cfg(feature = "visualization")]
    443
        #[test]
    444
        fn test_histogram_empty_error() {
    445
            let data = BenchmarkData::new("empty", vec![]);
    446
            let result = render_histogram_terminal(&data, 40);
    447
            assert!(result.is_err());
    448
        }
    449
    }