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
}