1pub mod ascii_tools;
17pub mod gradient_animation;
18pub mod modern_plotting;
19pub mod svg_render;
20pub mod types;
21
22pub use modern_plotting::*;
24pub use types::*;
25
26use anyhow::{anyhow, Result};
27use indexmap::IndexMap;
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum PlotSource {
36 Line(PlotData),
38 Histogram(HistogramData),
40 Heatmap(HeatmapData),
42}
43
44impl PlotSource {
45 pub fn title(&self) -> &str {
47 match self {
48 PlotSource::Line(d) => &d.title,
49 PlotSource::Histogram(d) => &d.title,
50 PlotSource::Heatmap(d) => &d.title,
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
58pub struct RenderedPlot {
59 pub name: String,
61 pub path: PathBuf,
63 pub document: String,
65 pub source: PlotSource,
67}
68
69#[derive(Debug)]
81pub struct DebugVisualizer {
82 config: VisualizationConfig,
83 plots: IndexMap<String, RenderedPlot>,
85}
86
87impl DebugVisualizer {
88 pub fn new(config: VisualizationConfig) -> Self {
89 Self {
90 config,
91 plots: IndexMap::new(),
92 }
93 }
94
95 pub fn with_default_config() -> Self {
96 Self::new(VisualizationConfig::default())
97 }
98
99 pub fn config(&self) -> &VisualizationConfig {
101 &self.config
102 }
103
104 fn extension_for(format: &ImageFormat) -> Result<&'static str> {
107 match format {
108 ImageFormat::SVG => Ok("svg"),
109 ImageFormat::HTML => Ok("html"),
110 ImageFormat::JSON => Ok("json"),
111 ImageFormat::PNG => Err(anyhow!(
112 "ImageFormat::PNG cannot be encoded: trustformers-debug ships no raster \
113 encoder in its default (Pure-Rust) feature set. Configure \
114 VisualizationConfig::image_format = ImageFormat::SVG, or use the \
115 `visual`/`image` cargo features for the plotters raster backends."
116 )),
117 ImageFormat::PDF => Err(anyhow!(
118 "ImageFormat::PDF cannot be encoded: no PDF writer is linked into \
119 trustformers-debug. Configure ImageFormat::SVG instead."
120 )),
121 ImageFormat::LaTeX => Err(anyhow!(
122 "ImageFormat::LaTeX is not implemented for plots: no TikZ/PGFPlots emitter \
123 exists in trustformers-debug. Configure ImageFormat::SVG instead."
124 )),
125 ImageFormat::MP4 | ImageFormat::WebM => Err(anyhow!(
126 "ImageFormat::{format:?} is a video container and there is no muxer or \
127 frame encoder in trustformers-debug (the `video` cargo feature was removed \
128 because nothing implemented it). Configure ImageFormat::SVG instead."
129 )),
130 ImageFormat::GIF => Err(anyhow!(
131 "ImageFormat::GIF cannot be encoded from a static plot: the animated-GIF \
132 path lives behind the optional `gif` cargo feature and takes a frame \
133 sequence, not a single plot. Configure ImageFormat::SVG instead."
134 )),
135 }
136 }
137
138 fn render_and_store(&mut self, source: PlotSource) -> Result<String> {
142 let ext = Self::extension_for(&self.config.image_format)?;
143
144 let svg = match &source {
145 PlotSource::Line(d) => svg_render::line_plot_svg(d, &self.config),
146 PlotSource::Histogram(d) => svg_render::histogram_svg(d, &self.config),
147 PlotSource::Heatmap(d) => svg_render::heatmap_svg(d, &self.config),
148 };
149
150 let title = source.title().to_string();
151 let document = match self.config.image_format {
152 ImageFormat::SVG => svg,
153 ImageFormat::HTML => format!(
154 "<!doctype html>\n<html><head><meta charset=\"utf-8\">\
155 <title>{}</title></head><body>\n{}</body></html>\n",
156 svg_render::escape_xml(&title),
157 svg
158 ),
159 ImageFormat::JSON => serde_json::to_string_pretty(&source)?,
162 _ => unreachable!("extension_for rejects every non-text format"),
165 };
166
167 let dir = Path::new(&self.config.output_directory);
168 std::fs::create_dir_all(dir)?;
169 let path = dir.join(format!("{}.{}", svg_render::slugify(&title), ext));
170 std::fs::write(&path, &document)?;
171
172 let rendered = RenderedPlot {
173 name: title.clone(),
174 path: path.clone(),
175 document,
176 source,
177 };
178 self.plots.insert(title, rendered);
179 Ok(path.to_string_lossy().to_string())
180 }
181
182 pub fn create_line_plot(&mut self, data: &PlotData) -> Result<String> {
184 self.render_and_store(PlotSource::Line(data.clone()))
185 }
186
187 pub fn create_heatmap(&mut self, data: &HeatmapData) -> Result<String> {
189 self.render_and_store(PlotSource::Heatmap(data.clone()))
190 }
191
192 pub fn create_histogram(&mut self, data: &HistogramData) -> Result<String> {
194 self.render_and_store(PlotSource::Histogram(data.clone()))
195 }
196
197 pub fn plot_tensor_distribution(
199 &mut self,
200 name: &str,
201 values: &[f64],
202 bins: usize,
203 ) -> Result<String> {
204 let data = HistogramData {
205 values: values.to_vec(),
206 bins,
207 title: format!("{} Distribution", name),
208 x_label: "Value".to_string(),
209 y_label: "Frequency".to_string(),
210 density: false,
211 };
212 self.create_histogram(&data)
213 }
214
215 pub fn plot_training_metrics(
217 &mut self,
218 steps: &[f64],
219 losses: &[f64],
220 accuracies: Option<&[f64]>,
221 ) -> Result<String> {
222 let mut plot_data = PlotData {
223 x_values: steps.to_vec(),
224 y_values: losses.to_vec(),
225 labels: vec!["Loss".to_string()],
226 title: "Training Metrics".to_string(),
227 x_label: "Steps".to_string(),
228 y_label: "Value".to_string(),
229 };
230
231 if let Some(acc) = accuracies {
232 plot_data.y_values.extend_from_slice(acc);
233 plot_data.labels.push("Accuracy".to_string());
234 }
235
236 self.create_line_plot(&plot_data)
237 }
238
239 pub fn plot_gradient_flow(
241 &mut self,
242 layer_name: &str,
243 steps: &[f64],
244 gradient_norms: &[f64],
245 ) -> Result<String> {
246 let data = PlotData {
247 x_values: steps.to_vec(),
248 y_values: gradient_norms.to_vec(),
249 labels: vec![format!("{} Gradient Flow", layer_name)],
250 title: format!("Gradient Flow - {}", layer_name),
251 x_label: "Steps".to_string(),
252 y_label: "Gradient Norm".to_string(),
253 };
254 self.create_line_plot(&data)
255 }
256
257 pub fn plot_tensor_heatmap(&mut self, name: &str, values: &[Vec<f64>]) -> Result<String> {
259 let data = HeatmapData {
260 values: values.to_vec(),
261 x_labels: (0..values.first().map_or(0, |row| row.len()))
262 .map(|i| i.to_string())
263 .collect(),
264 y_labels: (0..values.len()).map(|i| i.to_string()).collect(),
265 title: format!("{} Heatmap", name),
266 color_bar_label: "Value".to_string(),
267 };
268 self.create_heatmap(&data)
269 }
270
271 pub fn plot_activation_patterns(
273 &mut self,
274 layer_name: &str,
275 inputs: &[f64],
276 outputs: &[f64],
277 ) -> Result<String> {
278 let data = PlotData {
279 x_values: inputs.to_vec(),
280 y_values: outputs.to_vec(),
281 labels: vec![format!("{} Activation", layer_name)],
282 title: format!("Activation Pattern - {}", layer_name),
283 x_label: "Input".to_string(),
284 y_label: "Output".to_string(),
285 };
286 self.create_line_plot(&data)
287 }
288
289 pub fn get_plot_names(&self) -> Vec<String> {
295 self.plots.keys().cloned().collect()
296 }
297
298 pub fn get_plot(&self, name: &str) -> Option<&RenderedPlot> {
300 self.plots.get(name)
301 }
302
303 pub fn create_dashboard(&mut self, plot_names: &[String]) -> Result<String> {
310 let unknown: Vec<&str> = plot_names
311 .iter()
312 .map(String::as_str)
313 .filter(|n| !self.plots.contains_key(*n))
314 .collect();
315 if !unknown.is_empty() {
316 return Err(anyhow!(
317 "cannot build a dashboard for plots that were never rendered: {:?}. \
318 Rendered plots are: {:?}",
319 unknown,
320 self.get_plot_names()
321 ));
322 }
323
324 let dashboard_path = Path::new(&self.config.output_directory).join("dashboard.html");
325 std::fs::create_dir_all(&self.config.output_directory)?;
326
327 let mut html = String::from(
328 "<!doctype html>\n<html><head><meta charset=\"utf-8\">\
329 <title>Debug Dashboard</title></head><body>\n",
330 );
331 html.push_str("<h1>TrustformeRS Debug Dashboard</h1>\n");
332
333 for plot_name in plot_names {
334 let plot = self.plots.get(plot_name).ok_or_else(|| {
335 anyhow!("plot {plot_name:?} disappeared from the registry mid-render")
336 })?;
337 html.push_str(&format!(
338 "<section><h2>{}</h2>\n",
339 svg_render::escape_xml(plot_name)
340 ));
341 match self.config.image_format {
342 ImageFormat::SVG => html.push_str(&plot.document),
343 ImageFormat::HTML => {
344 match (plot.document.find("<svg"), plot.document.rfind("</svg>")) {
346 (Some(a), Some(b)) => html.push_str(&plot.document[a..b + 6]),
347 _ => html.push_str(&format!(
348 "<p><a href=\"{}\">{}</a></p>",
349 svg_render::escape_xml(&plot.path.to_string_lossy()),
350 svg_render::escape_xml(&plot.path.to_string_lossy())
351 )),
352 }
353 },
354 _ => html.push_str(&format!(
355 "<p><a href=\"{}\">{}</a></p>",
356 svg_render::escape_xml(&plot.path.to_string_lossy()),
357 svg_render::escape_xml(&plot.path.to_string_lossy())
358 )),
359 }
360 html.push_str("\n</section>\n");
361 }
362
363 html.push_str("</body></html>\n");
364 std::fs::write(&dashboard_path, html)?;
365
366 Ok(dashboard_path.to_string_lossy().to_string())
367 }
368
369 pub fn export_plot_data(&self, plot_name: &str, export_path: &Path) -> Result<()> {
375 let plot = self.plots.get(plot_name).ok_or_else(|| {
376 anyhow!(
377 "no plot named {plot_name:?} has been rendered; rendered plots are: {:?}",
378 self.get_plot_names()
379 )
380 })?;
381 if let Some(parent) = export_path.parent() {
382 if !parent.as_os_str().is_empty() {
383 std::fs::create_dir_all(parent)?;
384 }
385 }
386 std::fs::write(export_path, serde_json::to_string_pretty(&plot.source)?)?;
387 Ok(())
388 }
389
390 pub fn save_to_file(&self, filename: &str) -> Result<()> {
396 let (_, plot) = self.plots.last().ok_or_else(|| {
397 anyhow!(
398 "save_to_file({filename:?}): nothing has been rendered yet, so there is no \
399 visualization to save"
400 )
401 })?;
402 std::fs::create_dir_all(&self.config.output_directory)?;
403 let output_path = Path::new(&self.config.output_directory).join(filename);
404 std::fs::write(output_path, &plot.document)?;
405 Ok(())
406 }
407}
408
409pub struct TerminalVisualizer;
411
412impl TerminalVisualizer {
413 pub fn new() -> Self {
414 Self
415 }
416
417 pub fn display_histogram(&self, data: &HistogramData) -> Result<()> {
419 println!("Terminal Histogram: {}", data.title);
420 println!("Data points: {}", data.values.len());
421 if data.values.is_empty() {
422 return Ok(());
423 }
424 let bins = if data.bins == 0 { 10 } else { data.bins };
425 let rendered = self.ascii_histogram(&data.values, bins);
426 if !data.x_label.is_empty() || !data.y_label.is_empty() {
427 println!("{} vs {}", data.y_label, data.x_label);
428 }
429 print!("{}", rendered);
430 Ok(())
431 }
432
433 pub fn display_statistics(&self, label: &str, values: &[f64]) -> Result<()> {
435 if values.is_empty() {
436 println!("{}: No data", label);
437 return Ok(());
438 }
439
440 let mean = values.iter().sum::<f64>() / values.len() as f64;
441 let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
442 let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
443
444 println!(
445 "{}: mean={:.3}, min={:.3}, max={:.3}",
446 label, mean, min, max
447 );
448 Ok(())
449 }
450
451 pub fn ascii_histogram(&self, values: &[f64], bins: usize) -> String {
453 if values.is_empty() {
454 return "No data for histogram".to_string();
455 }
456
457 let min_val = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
458 let max_val = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
459
460 if (max_val - min_val).abs() < f64::EPSILON {
461 return format!("All values are {:.3}", min_val);
462 }
463
464 let mut histogram = vec![0; bins];
465 let bin_width = (max_val - min_val) / bins as f64;
466
467 for &value in values {
468 let bin_index = ((value - min_val) / bin_width).floor() as usize;
469 let bin_index = bin_index.min(bins - 1);
470 histogram[bin_index] += 1;
471 }
472
473 let max_count = histogram.iter().max().unwrap_or(&0);
474 let scale = if *max_count > 0 { 40.0 / *max_count as f64 } else { 1.0 };
475
476 let mut result = String::new();
477 for (i, &count) in histogram.iter().enumerate() {
478 let bin_start = min_val + i as f64 * bin_width;
479 let bin_end = bin_start + bin_width;
480 let bar_length = (count as f64 * scale) as usize;
481 let bar = "█".repeat(bar_length);
482 result.push_str(&format!(
483 "[{:.2}-{:.2}): {} ({})\n",
484 bin_start, bin_end, bar, count
485 ));
486 }
487
488 result
489 }
490
491 pub fn ascii_line_plot(&self, x_values: &[f64], y_values: &[f64], title: &str) -> String {
493 if x_values.is_empty() || y_values.is_empty() || x_values.len() != y_values.len() {
494 return "Invalid data for line plot".to_string();
495 }
496
497 let min_y = y_values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
498 let max_y = y_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
499
500 let mut result = format!("{}\n", title);
501 result.push_str("═".repeat(title.len()).as_str());
502 result.push('\n');
503
504 if (max_y - min_y).abs() < f64::EPSILON {
505 result.push_str(&format!("Constant value: {:.3}\n", min_y));
506 return result;
507 }
508
509 let height = 20;
510 let width = x_values.len().min(80);
511
512 let step = if x_values.len() > width { x_values.len() / width } else { 1 };
514
515 for row in (0..height).rev() {
516 let y_threshold = min_y + (max_y - min_y) * row as f64 / (height - 1) as f64;
517 let mut line = String::new();
518
519 for i in (0..x_values.len()).step_by(step).take(width) {
520 if y_values[i] >= y_threshold {
521 line.push('*');
522 } else {
523 line.push(' ');
524 }
525 }
526 result.push_str(&format!("{:8.2} |{}\n", y_threshold, line));
527 }
528
529 result.push_str(&format!("{:8} +{}\n", "", "─".repeat(width)));
530 result
531 }
532}
533
534impl Default for TerminalVisualizer {
535 fn default() -> Self {
536 Self::new()
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 fn scratch(tag: &str) -> String {
546 let dir = std::env::temp_dir().join(format!(
547 "tfdbg_viz_{}_{}_{}",
548 tag,
549 std::process::id(),
550 std::time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)
552 .map(|d| d.as_nanos())
553 .unwrap_or(0)
554 ));
555 dir.to_string_lossy().to_string()
556 }
557
558 fn viz(tag: &str) -> DebugVisualizer {
559 DebugVisualizer::new(VisualizationConfig {
560 output_directory: scratch(tag),
561 ..Default::default()
562 })
563 }
564
565 fn sample_line() -> PlotData {
566 PlotData {
567 x_values: vec![0.0, 1.0, 2.0, 3.0],
568 y_values: vec![1.0, 4.0, 9.0, 16.0],
569 labels: vec!["squares".to_string()],
570 title: "Squares".to_string(),
571 x_label: "n".to_string(),
572 y_label: "n^2".to_string(),
573 }
574 }
575
576 #[test]
577 fn create_line_plot_writes_a_real_svg_document() {
578 let mut v = viz("line");
579 let path = v.create_line_plot(&sample_line()).expect("render must succeed");
580 let written = std::fs::read_to_string(&path).expect("the returned path must exist");
581 assert!(!path.contains("created successfully"));
584 assert!(
585 written.starts_with("<svg"),
586 "must be a real SVG: {written:.80}"
587 );
588 assert!(
589 written.contains("<polyline"),
590 "must contain the real data polyline"
591 );
592 assert!(
593 written.contains(">squares<"),
594 "must carry the real series label"
595 );
596 let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
597 }
598
599 #[test]
600 fn get_plot_names_reports_only_what_was_really_rendered() {
601 let mut v = viz("names");
602 assert!(v.get_plot_names().is_empty(), "nothing rendered yet");
605 v.create_line_plot(&sample_line()).expect("render");
606 v.plot_tensor_distribution("weights", &[1.0, 2.0, 3.0, 4.0], 4).expect("render");
607 assert_eq!(
608 v.get_plot_names(),
609 vec!["Squares".to_string(), "weights Distribution".to_string()]
610 );
611 let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
612 }
613
614 #[test]
615 fn export_plot_data_exports_the_real_source_data() {
616 let mut v = viz("export");
617 v.create_line_plot(&sample_line()).expect("render");
618 let out = std::path::PathBuf::from(v.config().output_directory.clone()).join("d.json");
619 v.export_plot_data("Squares", &out).expect("export must succeed");
620 let text = std::fs::read_to_string(&out).expect("export file");
621 let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
622 assert_eq!(parsed["kind"], "line");
623 assert_eq!(
624 parsed["y_values"][3], 16.0,
625 "the real y values must round-trip"
626 );
627 let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
628 }
629
630 #[test]
631 fn export_plot_data_refuses_a_plot_that_was_never_rendered() {
632 let v = viz("export_missing");
633 let out = std::env::temp_dir().join("tfdbg_never_written.json");
634 let err = v.export_plot_data("nope", &out).expect_err("must not fabricate an export");
635 let msg = err.to_string();
636 assert!(
637 msg.contains("no plot named"),
638 "structured error names the problem: {msg}"
639 );
640 assert!(
641 !out.exists(),
642 "must not write a file for a plot that does not exist"
643 );
644 }
645
646 #[test]
647 fn save_to_file_refuses_before_anything_is_rendered() {
648 let v = viz("save_empty");
649 let err = v.save_to_file("x.svg").expect_err("must not write placeholder content");
650 assert!(err.to_string().contains("nothing has been rendered"));
651 }
652
653 #[test]
654 fn save_to_file_writes_the_real_rendered_document() {
655 let mut v = viz("save");
656 v.create_line_plot(&sample_line()).expect("render");
657 v.save_to_file("copy.svg").expect("save must succeed");
658 let text = std::fs::read_to_string(
659 std::path::PathBuf::from(v.config().output_directory.clone()).join("copy.svg"),
660 )
661 .expect("saved file");
662 assert!(
663 text.contains("<polyline"),
664 "the saved bytes are the real rendering"
665 );
666 assert!(!text.contains("placeholder visualization content"));
667 let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
668 }
669
670 #[test]
671 fn unencodable_formats_return_a_structured_error_not_mislabelled_bytes() {
672 for (format, needle) in [
673 (ImageFormat::PNG, "no raster encoder"),
674 (ImageFormat::PDF, "no PDF writer"),
675 (ImageFormat::MP4, "video container"),
676 (ImageFormat::GIF, "animated-GIF"),
677 (ImageFormat::LaTeX, "not implemented"),
678 ] {
679 let dir = scratch("fmt");
680 let mut v = DebugVisualizer::new(VisualizationConfig {
681 output_directory: dir.clone(),
682 image_format: format.clone(),
683 ..Default::default()
684 });
685 match v.create_line_plot(&sample_line()) {
686 Ok(p) => panic!("{format:?} must be refused, but it wrote {p}"),
687 Err(e) => assert!(
688 e.to_string().contains(needle),
689 "{format:?} error must name the missing encoder ({needle}): {e}"
690 ),
691 }
692 assert!(
693 !std::path::Path::new(&dir).exists(),
694 "{format:?}: nothing may be written for a refused format"
695 );
696 }
697 }
698
699 #[test]
700 fn png_is_refused_and_writes_nothing() {
701 let dir = scratch("png");
702 let mut v = DebugVisualizer::new(VisualizationConfig {
703 output_directory: dir.clone(),
704 image_format: ImageFormat::PNG,
705 ..Default::default()
706 });
707 let err = v.create_line_plot(&sample_line()).expect_err("PNG must be refused");
708 assert!(err.to_string().contains("no raster encoder"), "{err}");
709 assert!(
710 !std::path::Path::new(&dir).exists(),
711 "nothing may be written for a refused format"
712 );
713 }
714
715 #[test]
716 fn create_dashboard_embeds_real_plots_and_refuses_unknown_names() {
717 let mut v = viz("dash");
718 v.create_line_plot(&sample_line()).expect("render");
719 let err = v
720 .create_dashboard(&["Squares".to_string(), "ghost".to_string()])
721 .expect_err("unknown plot names must be refused");
722 assert!(err.to_string().contains("never rendered"), "{err}");
723
724 let path = v.create_dashboard(&v.get_plot_names()).expect("dashboard must build");
725 let html = std::fs::read_to_string(&path).expect("dashboard file");
726 assert!(
727 html.contains("<polyline"),
728 "the dashboard inlines the real SVG"
729 );
730 assert!(!html.contains("<p>Plot: "), "no fake plot cards");
731 let _ = std::fs::remove_dir_all(v.config().output_directory.clone());
732 }
733
734 #[test]
735 fn json_format_writes_the_real_source_data() {
736 let dir = scratch("json");
737 let mut v = DebugVisualizer::new(VisualizationConfig {
738 output_directory: dir.clone(),
739 image_format: ImageFormat::JSON,
740 ..Default::default()
741 });
742 let path = v
743 .create_histogram(&HistogramData {
744 values: vec![1.0, 2.0, 3.0],
745 bins: 3,
746 title: "H".to_string(),
747 x_label: String::new(),
748 y_label: String::new(),
749 density: false,
750 })
751 .expect("json render");
752 assert!(
753 path.ends_with(".json"),
754 "extension must match the real content: {path}"
755 );
756 let parsed: serde_json::Value =
757 serde_json::from_str(&std::fs::read_to_string(&path).expect("file")).expect("json");
758 assert_eq!(parsed["kind"], "histogram");
759 assert_eq!(parsed["values"][2], 3.0);
760 let _ = std::fs::remove_dir_all(dir);
761 }
762
763 #[test]
764 fn test_display_histogram_empty_returns_ok() {
765 let viz = TerminalVisualizer::new();
766 let data = HistogramData {
767 values: vec![],
768 bins: 10,
769 title: "empty".to_string(),
770 x_label: String::new(),
771 y_label: String::new(),
772 density: false,
773 };
774 assert!(viz.display_histogram(&data).is_ok());
775 }
776
777 #[test]
778 fn test_display_histogram_with_values_returns_ok() {
779 let viz = TerminalVisualizer::new();
780 let data = HistogramData {
781 values: (0..50).map(|i| i as f64).collect(),
782 bins: 5,
783 title: "ramp".to_string(),
784 x_label: "value".to_string(),
785 y_label: "count".to_string(),
786 density: false,
787 };
788 assert!(viz.display_histogram(&data).is_ok());
789 }
790
791 #[test]
792 fn test_display_histogram_zero_bins_falls_back_to_default() {
793 let viz = TerminalVisualizer::new();
794 let data = HistogramData {
795 values: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
796 bins: 0,
797 title: "fallback".to_string(),
798 x_label: String::new(),
799 y_label: String::new(),
800 density: false,
801 };
802 assert!(viz.display_histogram(&data).is_ok());
804 }
805}