Skip to main content

trustformers_debug/visualization/
ascii_tools.rs

1//! ASCII-based visualization tools for loss curves, gradient histograms,
2//! layer activation statistics, and attention pattern inspection.
3//!
4//! All types in this module are completely pure-Rust with no external plot
5//! dependencies; they are usable in no-std-adjacent environments and in CI
6//! pipelines that lack a display server.
7
8// ============================================================================
9// AsciiLossPlotter
10// ============================================================================
11
12/// ASCII-based loss curve renderer.
13///
14/// Produces a grid of `width × height` characters where the y-axis spans
15/// `[min_value - pad, max_value + pad]` and the x-axis spans the step range
16/// of the supplied data.
17///
18/// # Example
19/// ```
20/// use trustformers_debug::visualization::ascii_tools::AsciiLossPlotter;
21/// let plotter = AsciiLossPlotter::new(60, 15);
22/// let data: Vec<(u64, f32)> = (0..20).map(|i| (i as u64, 2.0 - i as f32 * 0.1)).collect();
23/// let lines = plotter.render(&data);
24/// assert!(!lines.is_empty());
25/// ```
26#[derive(Debug, Clone)]
27pub struct AsciiLossPlotter {
28    pub width: usize,
29    pub height: usize,
30    pub title: String,
31}
32
33impl AsciiLossPlotter {
34    /// Create a new plotter with the given canvas dimensions.
35    pub fn new(width: usize, height: usize) -> Self {
36        Self {
37            width: width.max(10),
38            height: height.max(4),
39            title: String::new(),
40        }
41    }
42
43    /// Attach a title that will appear above the plot.
44    pub fn with_title(mut self, title: impl Into<String>) -> Self {
45        self.title = title.into();
46        self
47    }
48
49    /// Render `values` as an ASCII plot.
50    ///
51    /// Returns an empty `Vec` when fewer than 2 points are supplied.
52    pub fn render(&self, values: &[(u64, f32)]) -> Vec<String> {
53        if values.len() < 2 {
54            return Vec::new();
55        }
56        self.render_curves(&[values], &["loss"])
57    }
58
59    /// Render two overlapping curves (train + val).
60    ///
61    /// Returns an empty `Vec` when either slice has fewer than 2 points.
62    pub fn render_two(&self, train: &[(u64, f32)], val: &[(u64, f32)]) -> Vec<String> {
63        if train.len() < 2 || val.len() < 2 {
64            return Vec::new();
65        }
66        self.render_curves(&[train, val], &["train", "val"])
67    }
68
69    // ── internal ──────────────────────────────────────────────────────────────
70
71    fn render_curves(&self, curves: &[&[(u64, f32)]], labels: &[&str]) -> Vec<String> {
72        // Determine global y range across all curves.
73        let all_vals: Vec<f32> = curves.iter().flat_map(|c| c.iter().map(|&(_, v)| v)).collect();
74        let y_min = all_vals.iter().cloned().fold(f32::INFINITY, f32::min);
75        let y_max = all_vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
76        let y_pad = ((y_max - y_min) * 0.05).max(1e-6_f32);
77        let y_lo = y_min - y_pad;
78        let y_hi = y_max + y_pad;
79        let y_range = y_hi - y_lo;
80
81        // Determine x range.
82        let x_min = curves.iter().flat_map(|c| c.iter().map(|&(s, _)| s)).min().unwrap_or(0);
83        let x_max = curves.iter().flat_map(|c| c.iter().map(|&(s, _)| s)).max().unwrap_or(1);
84
85        // Map each step to a column index.
86        let map_x = |step: u64| -> usize {
87            if x_max == x_min {
88                return self.width / 2;
89            }
90            let frac = (step - x_min) as f64 / (x_max - x_min) as f64;
91            ((frac * (self.width - 1) as f64).round() as usize).min(self.width - 1)
92        };
93
94        // Map each value to a row index (row 0 = top = y_hi).
95        let map_y = |v: f32| -> usize {
96            let frac = (v - y_lo) / y_range;
97            let row = (self.height as f32 - 1.0) * (1.0 - frac.clamp(0.0, 1.0));
98            (row.round() as usize).min(self.height - 1)
99        };
100
101        // Build the grid: each cell is a char.
102        let glyphs: &[char] = &['*', '+', 'o', 'x', '#'];
103        let mut grid = vec![vec![' '; self.width]; self.height];
104
105        for (ci, curve) in curves.iter().enumerate() {
106            let glyph = glyphs[ci % glyphs.len()];
107            for &(step, v) in *curve {
108                let col = map_x(step);
109                let row = map_y(v);
110                grid[row][col] = glyph;
111            }
112        }
113
114        // Convert grid to strings, add y-axis labels.
115        let y_label_width = 9_usize;
116        let separator = "─".repeat(self.width + 1);
117        let mut out = Vec::<String>::new();
118
119        if !self.title.is_empty() {
120            out.push(format!(
121                "{:^width$}",
122                self.title,
123                width = y_label_width + 1 + self.width
124            ));
125        }
126
127        for row in 0..self.height {
128            let y_val = y_hi - (row as f32 / (self.height - 1) as f32) * y_range;
129            let y_label = format!("{:>8.4}", y_val);
130            let line: String = grid[row].iter().collect();
131            out.push(format!("{}|{}", y_label, line));
132        }
133
134        // x-axis
135        out.push(format!(
136            "{:>width$}+{}",
137            "",
138            separator,
139            width = y_label_width
140        ));
141        // x labels
142        let step_range = x_max - x_min;
143        let left = format!("{}", x_min);
144        let right = format!("{}", x_max);
145        let mid_step = x_min + step_range / 2;
146        let mid = format!("{}", mid_step);
147        let col_pad = y_label_width + 1;
148        let total = col_pad + self.width;
149        let mid_pos = col_pad + self.width / 2;
150        let mut x_axis_line = vec![' '; total];
151        for (i, ch) in left.chars().enumerate() {
152            if col_pad + i < total {
153                x_axis_line[col_pad + i] = ch;
154            }
155        }
156        let mid_start = mid_pos.saturating_sub(mid.len() / 2);
157        for (i, ch) in mid.chars().enumerate() {
158            if mid_start + i < total {
159                x_axis_line[mid_start + i] = ch;
160            }
161        }
162        let right_start = total.saturating_sub(right.len());
163        for (i, ch) in right.chars().enumerate() {
164            if right_start + i < total {
165                x_axis_line[right_start + i] = ch;
166            }
167        }
168        out.push(x_axis_line.into_iter().collect());
169
170        // Legend
171        if labels.len() > 1 || (labels.len() == 1 && !labels[0].is_empty()) {
172            let legend: String = labels
173                .iter()
174                .zip(glyphs.iter())
175                .map(|(l, g)| format!("{}={}", g, l))
176                .collect::<Vec<_>>()
177                .join("  ");
178            out.push(format!("{:>width$} {}", "", legend, width = y_label_width));
179        }
180
181        out
182    }
183}
184
185// ============================================================================
186// GradientHistogram
187// ============================================================================
188
189/// Bucket-based gradient histogram builder.
190///
191/// The bucket edges are linearly spaced between `[min, max]`
192/// (inclusive endpoints stored as the first and last element of `buckets`).
193/// Values outside `[min, max]` are clamped into the first/last bucket.
194#[derive(Debug, Clone)]
195pub struct GradientHistogram {
196    /// Bucket edges (length = `num_buckets + 1`).
197    pub buckets: Vec<f32>,
198    /// Count per bucket (length = `num_buckets`).
199    pub counts: Vec<usize>,
200    /// Total number of values added.
201    pub total_values: usize,
202    // Running totals for mean / variance (Welford online algorithm).
203    running_mean: f64,
204    running_m2: f64,
205}
206
207impl GradientHistogram {
208    /// Create a new histogram with linearly spaced edges in `[min, max]`.
209    ///
210    /// Panics if `num_buckets == 0` or `min >= max`.
211    pub fn new(num_buckets: usize, min: f32, max: f32) -> Self {
212        assert!(num_buckets > 0, "num_buckets must be >= 1");
213        assert!(min < max, "min must be less than max");
214        let mut buckets = Vec::with_capacity(num_buckets + 1);
215        for i in 0..=num_buckets {
216            buckets.push(min + (max - min) * (i as f32 / num_buckets as f32));
217        }
218        Self {
219            buckets,
220            counts: vec![0; num_buckets],
221            total_values: 0,
222            running_mean: 0.0,
223            running_m2: 0.0,
224        }
225    }
226
227    /// Add a single value to the histogram.
228    pub fn add_value(&mut self, val: f32) {
229        let n_buckets = self.counts.len();
230        let min = self.buckets[0];
231        let max = *self.buckets.last().unwrap_or(&min);
232        let range = max - min;
233        let bucket_idx = if range <= 0.0 {
234            0
235        } else {
236            let frac = (val - min) / range;
237            let idx = (frac * n_buckets as f32).floor() as isize;
238            idx.clamp(0, (n_buckets as isize) - 1) as usize
239        };
240        self.counts[bucket_idx] += 1;
241
242        // Welford online update.
243        self.total_values += 1;
244        let delta = val as f64 - self.running_mean;
245        self.running_mean += delta / self.total_values as f64;
246        let delta2 = val as f64 - self.running_mean;
247        self.running_m2 += delta * delta2;
248    }
249
250    /// Add multiple values.
251    pub fn add_values(&mut self, vals: &[f32]) {
252        for &v in vals {
253            self.add_value(v);
254        }
255    }
256
257    /// Compute the mean of all inserted values.
258    ///
259    /// Returns 0.0 if no values have been added.
260    pub fn mean(&self) -> f32 {
261        if self.total_values == 0 {
262            return 0.0;
263        }
264        self.running_mean as f32
265    }
266
267    /// Compute the sample standard deviation of all inserted values.
268    ///
269    /// Returns 0.0 if fewer than 2 values have been added.
270    pub fn std_dev(&self) -> f32 {
271        if self.total_values < 2 {
272            return 0.0;
273        }
274        (self.running_m2 / (self.total_values - 1) as f64).sqrt() as f32
275    }
276
277    /// Approximate the `p`-th percentile (0.0–100.0) via bucket interpolation.
278    ///
279    /// Returns the lower bucket edge when counts are insufficient for
280    /// interpolation.
281    pub fn percentile(&self, p: f32) -> f32 {
282        if self.total_values == 0 {
283            return self.buckets[0];
284        }
285        let target = (p.clamp(0.0, 100.0) / 100.0) * self.total_values as f32;
286        let mut cum = 0.0_f32;
287        for (i, &count) in self.counts.iter().enumerate() {
288            let next = cum + count as f32;
289            if next >= target {
290                // Linear interpolation within bucket i.
291                let lo = self.buckets[i];
292                let hi = self.buckets[i + 1];
293                let bucket_frac = if count == 0 { 0.0 } else { (target - cum) / count as f32 };
294                return lo + bucket_frac * (hi - lo);
295            }
296            cum = next;
297        }
298        // Clamp to max if percentile is 100.
299        *self.buckets.last().unwrap_or(&self.buckets[0])
300    }
301
302    /// Render a compact horizontal bar chart (one line per bucket).
303    pub fn to_ascii_bars(&self) -> String {
304        if self.counts.is_empty() {
305            return "(empty histogram)\n".to_string();
306        }
307        let max_count = self.counts.iter().copied().max().unwrap_or(0);
308        let bar_width = 40_usize;
309        let mut out = String::new();
310        let n_buckets = self.counts.len();
311        for i in 0..n_buckets {
312            let lo = self.buckets[i];
313            let hi = self.buckets[i + 1];
314            let cnt = self.counts[i];
315            // `checked_div` covers the empty-histogram case (max_count == 0)
316            // in one step; the bar is then zero-length.
317            let bar_len = (cnt * bar_width).checked_div(max_count).unwrap_or(0);
318            let bar = "█".repeat(bar_len);
319            out.push_str(&format!(
320                "[{:>8.3e},{:>8.3e}) {:>6} |{}\n",
321                lo, hi, cnt, bar
322            ));
323        }
324        out
325    }
326}
327
328// ============================================================================
329// ActivationLayerStats
330// ============================================================================
331
332/// Per-layer activation statistics computed in a single pass over the data.
333///
334/// Named `ActivationLayerStats` to distinguish it from
335/// `model_diagnostics::LayerActivationStats` which uses `f64` fields with
336/// different names.
337#[derive(Debug, Clone)]
338pub struct ActivationLayerStats {
339    pub layer_name: String,
340    pub mean: f32,
341    pub std: f32,
342    pub min: f32,
343    pub max: f32,
344    /// Fraction of values that are exactly zero.
345    pub zero_fraction: f32,
346    /// Fraction of values whose absolute magnitude exceeds `0.99 * |max_abs|`.
347    pub saturation_fraction: f32,
348}
349
350impl ActivationLayerStats {
351    /// Compute statistics from raw activation values.
352    pub fn compute(layer_name: &str, activations: &[f32]) -> Self {
353        if activations.is_empty() {
354            return Self {
355                layer_name: layer_name.to_string(),
356                mean: 0.0,
357                std: 0.0,
358                min: 0.0,
359                max: 0.0,
360                zero_fraction: 1.0,
361                saturation_fraction: 0.0,
362            };
363        }
364
365        let n = activations.len() as f64;
366
367        // Single-pass mean & M2 (Welford).
368        let mut running_mean = 0.0_f64;
369        let mut running_m2 = 0.0_f64;
370        let mut min_val = f32::INFINITY;
371        let mut max_val = f32::NEG_INFINITY;
372        let mut zero_count = 0usize;
373
374        for (idx, &v) in activations.iter().enumerate() {
375            if v < min_val {
376                min_val = v;
377            }
378            if v > max_val {
379                max_val = v;
380            }
381            if v == 0.0 {
382                zero_count += 1;
383            }
384            let delta = v as f64 - running_mean;
385            running_mean += delta / (idx + 1) as f64;
386            let delta2 = v as f64 - running_mean;
387            running_m2 += delta * delta2;
388        }
389
390        let std_val =
391            if activations.len() > 1 { (running_m2 / (n - 1.0)).sqrt() as f32 } else { 0.0 };
392
393        // Saturation: |v| > 0.99 * max_abs.
394        let max_abs = min_val.abs().max(max_val.abs());
395        let sat_threshold = 0.99 * max_abs;
396        let sat_count = activations.iter().filter(|&&v| v.abs() > sat_threshold).count();
397
398        Self {
399            layer_name: layer_name.to_string(),
400            mean: running_mean as f32,
401            std: std_val,
402            min: min_val,
403            max: max_val,
404            zero_fraction: zero_count as f32 / activations.len() as f32,
405            saturation_fraction: sat_count as f32 / activations.len() as f32,
406        }
407    }
408
409    /// Returns `true` when the zero fraction exceeds the given threshold.
410    ///
411    /// A layer is considered "dead" when most of its activations are zero
412    /// (common symptom of a collapsed ReLU).
413    pub fn is_dead(&self, threshold: f32) -> bool {
414        self.zero_fraction > threshold.clamp(0.0, 1.0)
415    }
416
417    /// Return a compact one-line summary suitable for logging.
418    pub fn to_summary_line(&self) -> String {
419        format!(
420            "[{}] mean={:.4e}  std={:.4e}  min={:.4e}  max={:.4e}  zero={:.1}%  sat={:.1}%",
421            self.layer_name,
422            self.mean,
423            self.std,
424            self.min,
425            self.max,
426            self.zero_fraction * 100.0,
427            self.saturation_fraction * 100.0,
428        )
429    }
430}
431
432// ============================================================================
433// AttentionVisualizer
434// ============================================================================
435
436/// Attention pattern visualizer producing ASCII heatmaps and entropy metrics.
437#[derive(Debug, Clone)]
438pub struct AttentionVisualizer {
439    pub head_idx: usize,
440    pub layer_idx: usize,
441}
442
443impl AttentionVisualizer {
444    pub fn new(head_idx: usize, layer_idx: usize) -> Self {
445        Self {
446            head_idx,
447            layer_idx,
448        }
449    }
450
451    /// Render an attention matrix as a compact ASCII heatmap.
452    ///
453    /// Rows = query positions, columns = key positions.
454    /// Each cell is encoded with one of `' ', '░', '▒', '▓', '█'`
455    /// according to the value's position in the global `[min, max]` range.
456    pub fn render_ascii(attn_matrix: &[Vec<f32>]) -> Vec<String> {
457        if attn_matrix.is_empty() {
458            return Vec::new();
459        }
460        let blocks = [' ', '░', '▒', '▓', '█'];
461        let n_blocks = blocks.len() as f32;
462
463        let (global_min, global_max) = attn_matrix
464            .iter()
465            .flat_map(|row| row.iter().copied())
466            .fold((f32::INFINITY, f32::NEG_INFINITY), |(mn, mx), v| {
467                (mn.min(v), mx.max(v))
468            });
469        let range = (global_max - global_min).max(1e-9_f32);
470
471        let mut lines = Vec::with_capacity(attn_matrix.len() + 1);
472        lines.push(format!(
473            "Attn heatmap  min={:.3}  max={:.3}",
474            global_min, global_max
475        ));
476
477        for row in attn_matrix {
478            let encoded: String = row
479                .iter()
480                .map(|&v| {
481                    let idx = ((v - global_min) / range * (n_blocks - 1.0))
482                        .round()
483                        .clamp(0.0, n_blocks - 1.0) as usize;
484                    blocks[idx]
485                })
486                .collect();
487            lines.push(encoded);
488        }
489        lines
490    }
491
492    /// Compute the (Shannon) entropy of a single attention row.
493    ///
494    /// `attn_row` should sum to approximately 1.0 (softmax output).
495    /// Returns 0.0 for empty rows.
496    pub fn entropy(attn_row: &[f32]) -> f32 {
497        if attn_row.is_empty() {
498            return 0.0;
499        }
500        // Normalise defensively to handle near-softmax vectors.
501        let total: f32 = attn_row.iter().sum();
502        let scale = if total > 0.0 { total } else { 1.0 };
503        -attn_row
504            .iter()
505            .filter_map(|&p| {
506                let pn = p / scale;
507                if pn > 0.0 {
508                    Some(pn * pn.ln())
509                } else {
510                    None
511                }
512            })
513            .sum::<f32>()
514    }
515
516    /// Compute per-row entropy for the full attention matrix.
517    pub fn all_entropies(attn_matrix: &[Vec<f32>]) -> Vec<f32> {
518        attn_matrix.iter().map(|row| Self::entropy(row)).collect()
519    }
520
521    /// Returns `true` when the range of the row is smaller than `eps`,
522    /// indicating a near-uniform (or constant) attention distribution.
523    pub fn is_uniform(attn_row: &[f32], eps: f32) -> bool {
524        if attn_row.is_empty() {
525            return true;
526        }
527        let max_v = attn_row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
528        let min_v = attn_row.iter().cloned().fold(f32::INFINITY, f32::min);
529        (max_v - min_v) < eps
530    }
531}
532
533// ============================================================================
534// Tests
535// ============================================================================
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    // ── AsciiLossPlotter ────────────────────────────────────────────────────
542
543    #[test]
544    fn test_plotter_returns_empty_for_single_point() {
545        let plotter = AsciiLossPlotter::new(60, 15);
546        let data = vec![(0u64, 1.5_f32)];
547        assert!(plotter.render(&data).is_empty());
548    }
549
550    #[test]
551    fn test_plotter_returns_empty_for_zero_points() {
552        let plotter = AsciiLossPlotter::new(60, 15);
553        assert!(plotter.render(&[]).is_empty());
554    }
555
556    #[test]
557    fn test_plotter_two_point_render() {
558        let plotter = AsciiLossPlotter::new(40, 10);
559        let data = vec![(0u64, 2.0_f32), (10u64, 1.0_f32)];
560        let lines = plotter.render(&data);
561        assert!(!lines.is_empty(), "should produce output for 2 points");
562        // The plot should contain at least the axis separator.
563        assert!(lines.iter().any(|l| l.contains('+')));
564    }
565
566    #[test]
567    fn test_plotter_many_points_produce_canvas_height() {
568        let height = 12;
569        let plotter = AsciiLossPlotter::new(60, height);
570        let data: Vec<(u64, f32)> = (0..30).map(|i| (i as u64, 3.0 - i as f32 * 0.1)).collect();
571        let lines = plotter.render(&data);
572        // height body rows + separator + x-label + optional legend ≥ height + 2
573        assert!(lines.len() >= height + 2);
574    }
575
576    #[test]
577    fn test_plotter_with_title() {
578        let plotter = AsciiLossPlotter::new(50, 10).with_title("Training Loss");
579        let data: Vec<(u64, f32)> = (0..5).map(|i| (i as u64, 1.0)).collect();
580        let lines = plotter.render(&data);
581        assert!(lines[0].contains("Training Loss"));
582    }
583
584    #[test]
585    fn test_plotter_render_two_returns_empty_if_not_enough_points() {
586        let plotter = AsciiLossPlotter::new(40, 10);
587        let one_point = vec![(0u64, 1.0_f32)];
588        let many = vec![(0u64, 1.0_f32), (1u64, 0.9_f32)];
589        assert!(plotter.render_two(&one_point, &many).is_empty());
590        assert!(plotter.render_two(&many, &one_point).is_empty());
591    }
592
593    #[test]
594    fn test_plotter_render_two_produces_legend_markers() {
595        let plotter = AsciiLossPlotter::new(50, 10);
596        let train: Vec<(u64, f32)> = (0..5).map(|i| (i as u64, 2.0 - i as f32 * 0.3)).collect();
597        let val: Vec<(u64, f32)> = (0..5).map(|i| (i as u64, 2.1 - i as f32 * 0.25)).collect();
598        let lines = plotter.render_two(&train, &val);
599        // Legend line should contain both labels.
600        let legend = lines.last().unwrap();
601        assert!(legend.contains("train") || legend.contains('*'));
602        assert!(legend.contains("val") || legend.contains('+'));
603    }
604
605    // ── GradientHistogram ───────────────────────────────────────────────────
606
607    #[test]
608    fn test_histogram_bucket_assignment() {
609        let mut h = GradientHistogram::new(4, 0.0, 4.0);
610        // Each bucket covers 1.0 unit: [0,1), [1,2), [2,3), [3,4]
611        h.add_value(0.5); // bucket 0
612        h.add_value(1.5); // bucket 1
613        h.add_value(2.5); // bucket 2
614        h.add_value(3.5); // bucket 3
615        assert_eq!(h.counts, vec![1, 1, 1, 1]);
616    }
617
618    #[test]
619    fn test_histogram_clamping_below_min() {
620        let mut h = GradientHistogram::new(4, 0.0, 4.0);
621        h.add_value(-100.0); // should land in bucket 0
622        assert_eq!(h.counts[0], 1);
623    }
624
625    #[test]
626    fn test_histogram_clamping_above_max() {
627        let mut h = GradientHistogram::new(4, 0.0, 4.0);
628        h.add_value(999.0); // should land in bucket 3
629        assert_eq!(h.counts[3], 1);
630    }
631
632    #[test]
633    fn test_histogram_mean() {
634        let mut h = GradientHistogram::new(10, 0.0, 10.0);
635        // Add 1, 2, 3 → mean should be 2.
636        h.add_values(&[1.0, 2.0, 3.0]);
637        let mean = h.mean();
638        assert!((mean - 2.0).abs() < 1e-4, "expected mean≈2.0, got {}", mean);
639    }
640
641    #[test]
642    fn test_histogram_std_dev() {
643        let mut h = GradientHistogram::new(10, -10.0, 10.0);
644        // Population: 0,0,0,0 → std should be 0.
645        h.add_values(&[0.0, 0.0, 0.0, 0.0]);
646        assert!(h.std_dev() < 1e-4);
647    }
648
649    #[test]
650    fn test_histogram_std_dev_known_value() {
651        let mut h = GradientHistogram::new(20, 0.0, 10.0);
652        // Values 2 and 8 → sample std dev = 3√2 ≈ 4.243.
653        h.add_values(&[2.0, 8.0]);
654        let std = h.std_dev();
655        assert!((std - (18.0_f32).sqrt()).abs() < 0.1, "std={}", std);
656    }
657
658    #[test]
659    fn test_histogram_percentile_median() {
660        let mut h = GradientHistogram::new(100, 0.0, 100.0);
661        // Uniform distribution 0..100 → median ≈ 50.
662        let vals: Vec<f32> = (0..100).map(|i| i as f32).collect();
663        h.add_values(&vals);
664        let p50 = h.percentile(50.0);
665        assert!((p50 - 50.0).abs() < 2.0, "p50={}", p50);
666    }
667
668    #[test]
669    fn test_histogram_percentile_0_and_100() {
670        let mut h = GradientHistogram::new(10, 0.0, 10.0);
671        h.add_values(&[1.0, 2.0, 3.0, 4.0, 5.0]);
672        let p0 = h.percentile(0.0);
673        let p100 = h.percentile(100.0);
674        assert!(p0 <= p100, "p0={}, p100={}", p0, p100);
675    }
676
677    #[test]
678    fn test_histogram_ascii_bars_non_empty() {
679        let mut h = GradientHistogram::new(5, 0.0, 5.0);
680        h.add_values(&[0.5, 1.5, 2.5, 3.5, 4.5]);
681        let bars = h.to_ascii_bars();
682        assert!(!bars.is_empty());
683        // Each bucket line contains '|'.
684        assert!(bars.lines().all(|l| l.contains('|') || l.is_empty()));
685    }
686
687    #[test]
688    fn test_histogram_empty_to_ascii_bars() {
689        let h = GradientHistogram::new(4, 0.0, 4.0);
690        let bars = h.to_ascii_bars();
691        // With zero counts the histogram still has bucket lines.
692        assert!(!bars.is_empty());
693    }
694
695    // ── ActivationLayerStats ────────────────────────────────────────────────
696
697    #[test]
698    fn test_activation_stats_empty() {
699        let stats = ActivationLayerStats::compute("empty_layer", &[]);
700        assert_eq!(stats.zero_fraction, 1.0);
701        assert_eq!(stats.mean, 0.0);
702    }
703
704    #[test]
705    fn test_activation_stats_mean() {
706        let vals = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
707        let stats = ActivationLayerStats::compute("fc1", &vals);
708        assert!((stats.mean - 3.0).abs() < 1e-4, "mean={}", stats.mean);
709    }
710
711    #[test]
712    fn test_activation_stats_std() {
713        // All identical → std = 0.
714        let vals = vec![5.0_f32; 10];
715        let stats = ActivationLayerStats::compute("relu", &vals);
716        assert!(stats.std < 1e-4, "std={}", stats.std);
717    }
718
719    #[test]
720    fn test_activation_stats_zeros() {
721        let vals = vec![0.0_f32, 0.0, 1.0, 2.0];
722        let stats = ActivationLayerStats::compute("dead_relu", &vals);
723        // 2 out of 4 are zero → 0.5.
724        assert!((stats.zero_fraction - 0.5).abs() < 1e-4);
725    }
726
727    #[test]
728    fn test_activation_stats_is_dead() {
729        let vals: Vec<f32> = (0..10).map(|i| if i < 9 { 0.0 } else { 1.0 }).collect();
730        let stats = ActivationLayerStats::compute("mostly_dead", &vals);
731        // 9/10 = 0.9 zeros.
732        assert!(stats.is_dead(0.8));
733        assert!(!stats.is_dead(0.95));
734    }
735
736    #[test]
737    fn test_activation_stats_summary_line_contains_name() {
738        let vals = vec![0.1_f32, 0.2, 0.3];
739        let stats = ActivationLayerStats::compute("encoder_0", &vals);
740        let summary = stats.to_summary_line();
741        assert!(summary.contains("encoder_0"));
742    }
743
744    #[test]
745    fn test_activation_stats_saturation_fraction() {
746        // All values at max → saturation should be 1.0.
747        let vals = vec![1.0_f32; 5];
748        let stats = ActivationLayerStats::compute("saturated", &vals);
749        // max_abs=1.0, all vals have abs=1.0 > 0.99*1.0 → all saturated.
750        assert!(
751            stats.saturation_fraction >= 0.99,
752            "saturation={}",
753            stats.saturation_fraction
754        );
755    }
756
757    // ── AttentionVisualizer ─────────────────────────────────────────────────
758
759    #[test]
760    fn test_attention_entropy_uniform() {
761        // Uniform attention over 4 tokens: entropy = ln(4) ≈ 1.386.
762        let row = vec![0.25_f32; 4];
763        let h = AttentionVisualizer::entropy(&row);
764        assert!((h - (4.0_f32).ln()).abs() < 0.01, "entropy={}", h);
765    }
766
767    #[test]
768    fn test_attention_entropy_concentrated() {
769        // All mass on one token → entropy = 0.
770        let row = vec![0.0_f32, 0.0, 1.0, 0.0];
771        let h = AttentionVisualizer::entropy(&row);
772        assert!(
773            h < 1e-4,
774            "entropy for concentrated distribution should be ~0, got {}",
775            h
776        );
777    }
778
779    #[test]
780    fn test_attention_entropy_empty_row() {
781        assert_eq!(AttentionVisualizer::entropy(&[]), 0.0);
782    }
783
784    #[test]
785    fn test_attention_all_entropies_per_row() {
786        let matrix = vec![
787            vec![0.25_f32; 4],        // uniform
788            vec![0.0, 0.0, 1.0, 0.0], // concentrated
789        ];
790        let entropies = AttentionVisualizer::all_entropies(&matrix);
791        assert_eq!(entropies.len(), 2);
792        assert!(entropies[0] > entropies[1], "uniform > concentrated");
793    }
794
795    #[test]
796    fn test_attention_is_uniform_true() {
797        // All weights equal → uniform.
798        let row = vec![0.25_f32; 4];
799        assert!(AttentionVisualizer::is_uniform(&row, 0.01));
800    }
801
802    #[test]
803    fn test_attention_is_uniform_false() {
804        let row = vec![0.9_f32, 0.05, 0.03, 0.02];
805        assert!(!AttentionVisualizer::is_uniform(&row, 0.01));
806    }
807
808    #[test]
809    fn test_attention_render_ascii_empty() {
810        let result = AttentionVisualizer::render_ascii(&[]);
811        assert!(result.is_empty());
812    }
813
814    #[test]
815    fn test_attention_render_ascii_shape() {
816        let matrix = vec![vec![0.1_f32; 5]; 4]; // 4 rows × 5 cols
817        let lines = AttentionVisualizer::render_ascii(&matrix);
818        // 1 header + 4 rows = 5 lines.
819        assert_eq!(lines.len(), 5);
820        // Each data row is exactly 5 characters wide.
821        for line in &lines[1..] {
822            // Unicode chars; count chars not bytes.
823            let char_count: usize = line.chars().count();
824            assert_eq!(char_count, 5, "expected 5 chars, got {}", char_count);
825        }
826    }
827
828    #[test]
829    fn test_attention_visualizer_new() {
830        let av = AttentionVisualizer::new(3, 1);
831        assert_eq!(av.head_idx, 3);
832        assert_eq!(av.layer_idx, 1);
833    }
834}