1#[derive(Debug, Clone)]
27pub struct AsciiLossPlotter {
28 pub width: usize,
29 pub height: usize,
30 pub title: String,
31}
32
33impl AsciiLossPlotter {
34 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 pub fn with_title(mut self, title: impl Into<String>) -> Self {
45 self.title = title.into();
46 self
47 }
48
49 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 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 fn render_curves(&self, curves: &[&[(u64, f32)]], labels: &[&str]) -> Vec<String> {
72 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 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 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 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 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 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 out.push(format!(
136 "{:>width$}+{}",
137 "",
138 separator,
139 width = y_label_width
140 ));
141 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 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#[derive(Debug, Clone)]
195pub struct GradientHistogram {
196 pub buckets: Vec<f32>,
198 pub counts: Vec<usize>,
200 pub total_values: usize,
202 running_mean: f64,
204 running_m2: f64,
205}
206
207impl GradientHistogram {
208 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 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 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 pub fn add_values(&mut self, vals: &[f32]) {
252 for &v in vals {
253 self.add_value(v);
254 }
255 }
256
257 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 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 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 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 *self.buckets.last().unwrap_or(&self.buckets[0])
300 }
301
302 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 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#[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 pub zero_fraction: f32,
346 pub saturation_fraction: f32,
348}
349
350impl ActivationLayerStats {
351 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 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 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 pub fn is_dead(&self, threshold: f32) -> bool {
414 self.zero_fraction > threshold.clamp(0.0, 1.0)
415 }
416
417 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#[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 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 pub fn entropy(attn_row: &[f32]) -> f32 {
497 if attn_row.is_empty() {
498 return 0.0;
499 }
500 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 pub fn all_entropies(attn_matrix: &[Vec<f32>]) -> Vec<f32> {
518 attn_matrix.iter().map(|row| Self::entropy(row)).collect()
519 }
520
521 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#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[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 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 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 let legend = lines.last().unwrap();
601 assert!(legend.contains("train") || legend.contains('*'));
602 assert!(legend.contains("val") || legend.contains('+'));
603 }
604
605 #[test]
608 fn test_histogram_bucket_assignment() {
609 let mut h = GradientHistogram::new(4, 0.0, 4.0);
610 h.add_value(0.5); h.add_value(1.5); h.add_value(2.5); h.add_value(3.5); 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); 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); 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 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 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 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 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 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 assert!(!bars.is_empty());
693 }
694
695 #[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 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 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 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 let vals = vec![1.0_f32; 5];
748 let stats = ActivationLayerStats::compute("saturated", &vals);
749 assert!(
751 stats.saturation_fraction >= 0.99,
752 "saturation={}",
753 stats.saturation_fraction
754 );
755 }
756
757 #[test]
760 fn test_attention_entropy_uniform() {
761 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 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], vec![0.0, 0.0, 1.0, 0.0], ];
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 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]; let lines = AttentionVisualizer::render_ascii(&matrix);
818 assert_eq!(lines.len(), 5);
820 for line in &lines[1..] {
822 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}