1use batuta_common::display::WithDimensions;
33use serde::Serialize;
34
35use crate::monitor::inference::path::{
36 DecisionPath, ForestPath, KNNPath, LinearPath, NeuralPath, TreePath, TreeSplit,
37};
38use crate::monitor::inference::{HashChainCollector, RingCollector};
39
40use trueno_viz::color::Rgba;
41use trueno_viz::error::{Error, Result};
42use trueno_viz::framebuffer::Framebuffer;
43use trueno_viz::plots::{
44 ForceGraph, GraphEdge, GraphNode, Heatmap, HeatmapPalette, Histogram, LineChart, LineSeries,
45 ScatterPlot,
46};
47use trueno_viz::render::{draw_circle, draw_line, draw_rect};
48
49const POSITIVE_COLOR: Rgba = Rgba::new(76, 175, 80, 255);
55const NEGATIVE_COLOR: Rgba = Rgba::new(244, 67, 54, 255);
57const NEUTRAL_COLOR: Rgba = Rgba::new(158, 158, 158, 255);
59const HIGH_CONFIDENCE_COLOR: Rgba = Rgba::new(33, 150, 243, 255);
61const LOW_CONFIDENCE_COLOR: Rgba = Rgba::new(255, 152, 0, 255);
63const TREE_NODE_COLOR: Rgba = Rgba::new(103, 58, 183, 255);
65const LEAF_NODE_COLOR: Rgba = Rgba::new(0, 150, 136, 255);
67
68pub trait DecisionPathViz {
74 fn to_contribution_chart(&self, feature_names: &[&str]) -> Result<Framebuffer>;
78
79 fn to_contribution_chart_with(
81 &self,
82 feature_names: &[&str],
83 width: u32,
84 height: u32,
85 ) -> Result<Framebuffer>;
86
87 fn to_confidence_gauge(&self) -> Result<Framebuffer>;
91
92 fn to_confidence_gauge_with(&self, width: u32, height: u32) -> Result<Framebuffer>;
94}
95
96impl DecisionPathViz for LinearPath {
97 fn to_contribution_chart(&self, feature_names: &[&str]) -> Result<Framebuffer> {
98 self.to_contribution_chart_with(feature_names, 600, 400)
99 }
100
101 fn to_contribution_chart_with(
102 &self,
103 feature_names: &[&str],
104 width: u32,
105 height: u32,
106 ) -> Result<Framebuffer> {
107 contribution_bar_chart(&self.contributions, feature_names, width, height)
108 }
109
110 fn to_confidence_gauge(&self) -> Result<Framebuffer> {
111 self.to_confidence_gauge_with(200, 200)
112 }
113
114 fn to_confidence_gauge_with(&self, width: u32, height: u32) -> Result<Framebuffer> {
115 confidence_gauge(self.confidence(), width, height)
116 }
117}
118
119impl DecisionPathViz for NeuralPath {
120 fn to_contribution_chart(&self, feature_names: &[&str]) -> Result<Framebuffer> {
121 self.to_contribution_chart_with(feature_names, 600, 400)
122 }
123
124 fn to_contribution_chart_with(
125 &self,
126 feature_names: &[&str],
127 width: u32,
128 height: u32,
129 ) -> Result<Framebuffer> {
130 let contributions = self.feature_contributions();
131 contribution_bar_chart(contributions, feature_names, width, height)
132 }
133
134 fn to_confidence_gauge(&self) -> Result<Framebuffer> {
135 self.to_confidence_gauge_with(200, 200)
136 }
137
138 fn to_confidence_gauge_with(&self, width: u32, height: u32) -> Result<Framebuffer> {
139 confidence_gauge(self.confidence(), width, height)
140 }
141}
142
143impl DecisionPathViz for ForestPath {
144 fn to_contribution_chart(&self, feature_names: &[&str]) -> Result<Framebuffer> {
145 self.to_contribution_chart_with(feature_names, 600, 400)
146 }
147
148 fn to_contribution_chart_with(
149 &self,
150 feature_names: &[&str],
151 width: u32,
152 height: u32,
153 ) -> Result<Framebuffer> {
154 contribution_bar_chart(&self.feature_importance, feature_names, width, height)
155 }
156
157 fn to_confidence_gauge(&self) -> Result<Framebuffer> {
158 self.to_confidence_gauge_with(200, 200)
159 }
160
161 fn to_confidence_gauge_with(&self, width: u32, height: u32) -> Result<Framebuffer> {
162 confidence_gauge(self.confidence(), width, height)
163 }
164}
165
166pub trait TreePathViz {
172 fn to_tree_graph(&self) -> Result<Framebuffer>;
174
175 fn to_tree_graph_with(&self, width: u32, height: u32) -> Result<Framebuffer>;
177
178 fn to_waterfall_chart(&self, feature_names: &[&str]) -> Result<Framebuffer>;
180}
181
182impl TreePathViz for TreePath {
183 fn to_tree_graph(&self) -> Result<Framebuffer> {
184 self.to_tree_graph_with(600, 400)
185 }
186
187 fn to_tree_graph_with(&self, width: u32, height: u32) -> Result<Framebuffer> {
188 tree_path_to_graph(&self.splits, &self.leaf, width, height)
189 }
190
191 fn to_waterfall_chart(&self, _feature_names: &[&str]) -> Result<Framebuffer> {
192 let contributions = self.feature_contributions();
193 waterfall_chart(contributions, 600, 400)
194 }
195}
196
197pub trait ForestPathViz {
203 fn to_prediction_histogram(&self) -> Result<Framebuffer>;
205
206 fn to_tree_scatter(&self) -> Result<Framebuffer>;
208
209 fn to_agreement_chart(&self) -> Result<Framebuffer>;
211}
212
213impl ForestPathViz for ForestPath {
214 fn to_prediction_histogram(&self) -> Result<Framebuffer> {
215 if self.tree_predictions.is_empty() {
216 return Err(Error::EmptyData);
217 }
218
219 let plot = Histogram::new()
220 .data(&self.tree_predictions)
221 .color(TREE_NODE_COLOR)
222 .dimensions(600, 400)
223 .build()?;
224
225 plot.to_framebuffer()
226 }
227
228 fn to_tree_scatter(&self) -> Result<Framebuffer> {
229 if self.tree_predictions.is_empty() {
230 return Err(Error::EmptyData);
231 }
232
233 let x: Vec<f32> = (0..self.tree_predictions.len()).map(|i| i as f32).collect();
234
235 let plot = ScatterPlot::new()
236 .x(&x)
237 .y(&self.tree_predictions)
238 .color(TREE_NODE_COLOR)
239 .size(6.0)
240 .dimensions(600, 400)
241 .build()?;
242
243 plot.to_framebuffer()
244 }
245
246 fn to_agreement_chart(&self) -> Result<Framebuffer> {
247 let mut fb = Framebuffer::new(300, 100)?;
249 fb.clear(Rgba::WHITE);
250
251 let margin = 20;
252 let bar_height = 30;
253 let bar_width = 300 - 2 * margin;
254
255 draw_rect(&mut fb, margin as i32, 35, bar_width, bar_height, NEUTRAL_COLOR);
257
258 let filled_width = (bar_width as f32 * self.tree_agreement) as u32;
260 let color = if self.tree_agreement >= 0.8 {
261 HIGH_CONFIDENCE_COLOR
262 } else if self.tree_agreement >= 0.5 {
263 Rgba::new(255, 193, 7, 255) } else {
265 LOW_CONFIDENCE_COLOR
266 };
267
268 draw_rect(&mut fb, margin as i32, 35, filled_width, bar_height, color);
269
270 Ok(fb)
271 }
272}
273
274pub trait KNNPathViz {
280 fn to_neighbor_scatter(&self) -> Result<Framebuffer>;
282
283 fn to_vote_chart(&self) -> Result<Framebuffer>;
285}
286
287impl KNNPathViz for KNNPath {
288 fn to_neighbor_scatter(&self) -> Result<Framebuffer> {
289 if self.distances.is_empty() {
290 return Err(Error::EmptyData);
291 }
292
293 let x: Vec<f32> = (1..=self.distances.len()).map(|i| i as f32).collect();
295
296 let mut fb = Framebuffer::new(600, 400)?;
298 fb.clear(Rgba::WHITE);
299
300 let margin = 50;
302 let plot_width = 600 - 2 * margin;
303 let plot_height = 400 - 2 * margin;
304
305 let max_dist = self.distances.iter().copied().fold(0.0f32, f32::max).max(0.001);
306 let max_x = self.distances.len() as f32;
307
308 for (i, (&dist, &label)) in self.distances.iter().zip(&self.neighbor_labels).enumerate() {
309 let px = margin as f32 + (x[i] / max_x) * plot_width as f32;
310 let py = (400 - margin) as f32 - (dist / max_dist) * plot_height as f32;
311
312 let color = label_to_color(label);
314 draw_circle(&mut fb, px as i32, py as i32, 6, color);
315 }
316
317 Ok(fb)
318 }
319
320 fn to_vote_chart(&self) -> Result<Framebuffer> {
321 if self.votes.is_empty() {
322 return Err(Error::EmptyData);
323 }
324
325 let mut fb = Framebuffer::new(400, 300)?;
326 fb.clear(Rgba::WHITE);
327
328 let margin = 40;
329 let bar_width = 40;
330 let max_vote = self.votes.iter().map(|(_, c)| *c).max().unwrap_or(1);
331
332 let spacing = if self.votes.len() > 1 {
333 (400 - 2 * margin - bar_width as u32 * self.votes.len() as u32)
334 / (self.votes.len() as u32 - 1).max(1)
335 } else {
336 0
337 };
338
339 for (i, (class, count)) in self.votes.iter().enumerate() {
340 let x = margin + i as u32 * (bar_width as u32 + spacing);
341 let bar_height = (*count as f32 / max_vote as f32 * 200.0) as u32;
342 let y = 300 - margin - bar_height;
343
344 let color = label_to_color(*class);
345 draw_rect(&mut fb, x as i32, y as i32, bar_width as u32, bar_height, color);
346 }
347
348 Ok(fb)
349 }
350}
351
352pub trait HashChainViz<P: DecisionPath + Serialize> {
358 fn to_timeline(&self) -> Result<Framebuffer>;
360
361 fn to_confidence_trend(&self) -> Result<Framebuffer>;
363
364 fn to_chain_graph(&self) -> Result<Framebuffer>;
366}
367
368impl<P: DecisionPath + Serialize> HashChainViz<P> for HashChainCollector<P> {
369 fn to_timeline(&self) -> Result<Framebuffer> {
370 let entries = self.entries();
371 if entries.is_empty() {
372 return Err(Error::EmptyData);
373 }
374
375 let mut fb = Framebuffer::new(800, 200)?;
376 fb.clear(Rgba::WHITE);
377
378 let margin = 40;
379 let timeline_y = 100;
380 let n = entries.len();
381
382 draw_line(&mut fb, margin, timeline_y, 800 - margin, timeline_y, NEUTRAL_COLOR);
384
385 for (i, entry) in entries.iter().enumerate() {
387 let x = margin as f32 + (i as f32 / (n - 1).max(1) as f32) * (800 - 2 * margin) as f32;
388
389 let color = if entry.prev_hash == [0u8; 32] || i == 0 {
391 HIGH_CONFIDENCE_COLOR } else {
393 POSITIVE_COLOR };
395
396 draw_circle(&mut fb, x as i32, timeline_y, 8, color);
397
398 let hash_byte = entry.hash[0];
400 let indicator_height = (f32::from(hash_byte) / 255.0 * 40.0) as i32;
401 draw_line(
402 &mut fb,
403 x as i32,
404 timeline_y + 15,
405 x as i32,
406 timeline_y + 15 + indicator_height,
407 Rgba::new(hash_byte, 100, 200 - hash_byte, 180),
408 );
409 }
410
411 Ok(fb)
412 }
413
414 fn to_confidence_trend(&self) -> Result<Framebuffer> {
415 let entries = self.entries();
416 if entries.is_empty() {
417 return Err(Error::EmptyData);
418 }
419
420 let x: Vec<f32> = (0..entries.len()).map(|i| i as f32).collect();
421 let y: Vec<f32> = entries.iter().map(|e| e.trace.path.confidence()).collect();
422
423 let plot = LineChart::new()
424 .add_series(LineSeries::new("confidence").data(&x, &y).color(HIGH_CONFIDENCE_COLOR))
425 .dimensions(600, 300)
426 .build()?;
427
428 plot.to_framebuffer()
429 }
430
431 fn to_chain_graph(&self) -> Result<Framebuffer> {
432 let entries = self.entries();
433 if entries.is_empty() {
434 return Err(Error::EmptyData);
435 }
436
437 let max_nodes = 20;
439 let n = entries.len().min(max_nodes);
440
441 let mut graph = ForceGraph::new().dimensions(600, 400).iterations(80);
442
443 for i in 0..n {
445 let entry = &entries[entries.len() - n + i];
446 let confidence = entry.trace.path.confidence();
447
448 let color = confidence_to_color(confidence);
450
451 graph = graph.add_node(GraphNode::new(i).color(color).radius(8.0 + confidence * 4.0));
452 }
453
454 for i in 1..n {
456 graph = graph.add_edge(GraphEdge::new(i - 1, i).weight(2.0));
457 }
458
459 let built = graph.build()?;
460 built.to_framebuffer()
461 }
462}
463
464pub trait RingCollectorViz<P: DecisionPath, const N: usize> {
470 fn to_output_trend(&self) -> Result<Framebuffer>;
472
473 fn to_confidence_heatmap(&self) -> Result<Framebuffer>;
475}
476
477impl<P: DecisionPath, const N: usize> RingCollectorViz<P, N> for RingCollector<P, N> {
478 fn to_output_trend(&self) -> Result<Framebuffer> {
479 let traces = self.all();
480 if traces.is_empty() {
481 return Err(Error::EmptyData);
482 }
483
484 let x: Vec<f32> = (0..traces.len()).map(|i| i as f32).collect();
485 let y: Vec<f32> = traces.iter().map(|t| t.output).collect();
486
487 let plot = LineChart::new()
488 .add_series(LineSeries::new("output").data(&x, &y).color(TREE_NODE_COLOR))
489 .dimensions(600, 300)
490 .build()?;
491
492 plot.to_framebuffer()
493 }
494
495 fn to_confidence_heatmap(&self) -> Result<Framebuffer> {
496 let traces = self.all();
497 if traces.is_empty() {
498 return Err(Error::EmptyData);
499 }
500
501 let confidences: Vec<f32> = traces.iter().map(|t| t.path.confidence()).collect();
503 let n = confidences.len();
504
505 let plot = Heatmap::new()
506 .data(&confidences, 1, n)
507 .palette(HeatmapPalette::Viridis)
508 .dimensions(600, 100)
509 .build()?;
510
511 plot.to_framebuffer()
512 }
513}
514
515fn contribution_bar_chart(
521 contributions: &[f32],
522 _feature_names: &[&str],
523 width: u32,
524 height: u32,
525) -> Result<Framebuffer> {
526 if contributions.is_empty() {
527 return Err(Error::EmptyData);
528 }
529
530 let mut fb = Framebuffer::new(width, height)?;
531 fb.clear(Rgba::WHITE);
532
533 let n = contributions.len();
534 let margin = 60;
535 let bar_height = ((height - 2 * margin) / n as u32).min(30);
536 let spacing = 5;
537
538 let max_abs = contributions.iter().map(|c| c.abs()).fold(0.0f32, f32::max).max(0.001);
539
540 let center_x = width / 2;
541 let bar_max_width = (width / 2 - margin) as f32;
542
543 for (i, &contrib) in contributions.iter().enumerate() {
544 let y = margin + i as u32 * (bar_height + spacing);
545 let bar_width = (contrib.abs() / max_abs * bar_max_width) as u32;
546
547 let color = if contrib >= 0.0 { POSITIVE_COLOR } else { NEGATIVE_COLOR };
548
549 if contrib >= 0.0 {
550 draw_rect(&mut fb, center_x as i32, y as i32, bar_width, bar_height, color);
551 } else {
552 draw_rect(
553 &mut fb,
554 (center_x - bar_width) as i32,
555 y as i32,
556 bar_width,
557 bar_height,
558 color,
559 );
560 }
561
562 draw_line(
564 &mut fb,
565 center_x as i32,
566 margin as i32,
567 center_x as i32,
568 (height - margin) as i32,
569 NEUTRAL_COLOR,
570 );
571 }
572
573 Ok(fb)
574}
575
576fn waterfall_chart(contributions: &[f32], width: u32, height: u32) -> Result<Framebuffer> {
578 if contributions.is_empty() {
579 return Err(Error::EmptyData);
580 }
581
582 let mut fb = Framebuffer::new(width, height)?;
583 fb.clear(Rgba::WHITE);
584
585 let n = contributions.len();
586 let margin = 50;
587 let bar_width = ((width - 2 * margin) / (n + 1) as u32).min(40);
588 let spacing = 10;
589
590 let mut cumulative = vec![0.0f32; n + 1];
592 for (i, &c) in contributions.iter().enumerate() {
593 cumulative[i + 1] = cumulative[i] + c;
594 }
595
596 let min_val = cumulative.iter().copied().fold(f32::INFINITY, f32::min);
597 let max_val = cumulative.iter().copied().fold(f32::NEG_INFINITY, f32::max);
598 let range = (max_val - min_val).max(0.001);
599
600 let plot_height = (height - 2 * margin) as f32;
601 let baseline_y = height - margin;
602
603 for i in 0..n {
604 let x = margin + i as u32 * (bar_width + spacing);
605
606 let start_val = cumulative[i];
607 let end_val = cumulative[i + 1];
608
609 let start_y = baseline_y as f32 - ((start_val - min_val) / range * plot_height);
610 let end_y = baseline_y as f32 - ((end_val - min_val) / range * plot_height);
611
612 let (top_y, bar_h) =
613 if end_y < start_y { (end_y, start_y - end_y) } else { (start_y, end_y - start_y) };
614
615 let color = if contributions[i] >= 0.0 { POSITIVE_COLOR } else { NEGATIVE_COLOR };
616
617 draw_rect(&mut fb, x as i32, top_y as i32, bar_width, bar_h.max(1.0) as u32, color);
618
619 if i > 0 {
621 let prev_x = margin + (i - 1) as u32 * (bar_width + spacing) + bar_width;
622 let prev_y = baseline_y as f32 - ((cumulative[i] - min_val) / range * plot_height);
623 draw_line(
624 &mut fb,
625 prev_x as i32,
626 prev_y as i32,
627 x as i32,
628 start_y as i32,
629 NEUTRAL_COLOR,
630 );
631 }
632 }
633
634 Ok(fb)
635}
636
637fn confidence_gauge(confidence: f32, width: u32, height: u32) -> Result<Framebuffer> {
639 let mut fb = Framebuffer::new(width, height)?;
640 fb.clear(Rgba::WHITE);
641
642 let cx = (width / 2) as i32;
643 let cy = (height / 2) as i32;
644 let radius = (width.min(height) / 2 - 20) as i32;
645
646 draw_circle(&mut fb, cx, cy, radius, NEUTRAL_COLOR);
648 draw_circle(&mut fb, cx, cy, radius - 10, Rgba::WHITE);
649
650 let color = confidence_to_color(confidence);
652
653 let segments = (confidence * 32.0) as i32;
655 for i in 0..segments {
656 let angle = std::f32::consts::PI * (1.0 - i as f32 / 32.0);
657 let x = cx + (angle.cos() * (radius - 5) as f32) as i32;
658 let y = cy - (angle.sin() * (radius - 5) as f32) as i32;
659 draw_circle(&mut fb, x, y, 4, color);
660 }
661
662 draw_circle(&mut fb, cx, cy, 8, color);
664
665 Ok(fb)
666}
667
668fn tree_path_to_graph(
670 splits: &[TreeSplit],
671 leaf: &crate::monitor::inference::path::LeafInfo,
672 width: u32,
673 height: u32,
674) -> Result<Framebuffer> {
675 if splits.is_empty() {
676 let mut fb = Framebuffer::new(width, height)?;
678 fb.clear(Rgba::WHITE);
679 let cx = (width / 2) as i32;
680 let cy = (height / 2) as i32;
681 draw_circle(&mut fb, cx, cy, 20, LEAF_NODE_COLOR);
682 return Ok(fb);
683 }
684
685 let mut graph = ForceGraph::new().dimensions(width, height).iterations(60).attraction(0.03);
686
687 for (i, _split) in splits.iter().enumerate() {
689 graph = graph.add_node(GraphNode::new(i).color(TREE_NODE_COLOR).radius(12.0));
690 }
691
692 let leaf_idx = splits.len();
694 let leaf_radius = 10.0 + (leaf.n_samples as f32).log10() * 2.0;
695 graph = graph.add_node(GraphNode::new(leaf_idx).color(LEAF_NODE_COLOR).radius(leaf_radius));
696
697 for i in 0..splits.len() {
699 let target = if i == splits.len() - 1 { leaf_idx } else { i + 1 };
700 let edge_color = if splits[i].went_left { POSITIVE_COLOR } else { NEGATIVE_COLOR };
701 graph = graph.add_edge(GraphEdge::new(i, target).color(edge_color).weight(1.5));
702 }
703
704 let built = graph.build()?;
705 built.to_framebuffer()
706}
707
708fn confidence_to_color(confidence: f32) -> Rgba {
710 let c = confidence.clamp(0.0, 1.0);
711
712 if c >= 0.8 {
713 HIGH_CONFIDENCE_COLOR
714 } else if c >= 0.5 {
715 let t = (c - 0.5) / 0.3;
717 Rgba::new(
718 (255.0 * (1.0 - t) + 33.0 * t) as u8,
719 (193.0 * (1.0 - t) + 150.0 * t) as u8,
720 (7.0 * (1.0 - t) + 243.0 * t) as u8,
721 255,
722 )
723 } else {
724 let t = c / 0.5;
726 Rgba::new(
727 255,
728 (152.0 * (1.0 - t) + 193.0 * t) as u8,
729 (0.0 * (1.0 - t) + 7.0 * t) as u8,
730 255,
731 )
732 }
733}
734
735fn label_to_color(label: usize) -> Rgba {
737 const PALETTE: [Rgba; 8] = [
738 Rgba::new(66, 133, 244, 255), Rgba::new(234, 67, 53, 255), Rgba::new(251, 188, 4, 255), Rgba::new(52, 168, 83, 255), Rgba::new(103, 58, 183, 255), Rgba::new(0, 150, 136, 255), Rgba::new(255, 87, 34, 255), Rgba::new(121, 85, 72, 255), ];
747
748 PALETTE[label % PALETTE.len()]
749}
750
751pub fn feature_contributions<P: DecisionPath>(
757 path: &P,
758 feature_names: &[&str],
759) -> Result<Framebuffer> {
760 contribution_bar_chart(path.feature_contributions(), feature_names, 600, 400)
761}
762
763pub fn confidence_indicator<P: DecisionPath>(path: &P) -> Result<Framebuffer> {
765 confidence_gauge(path.confidence(), 200, 200)
766}
767
768#[cfg(test)]
773#[allow(clippy::unwrap_used)]
774mod tests {
775 use super::*;
776 use crate::monitor::inference::path::LeafInfo;
777
778 #[test]
779 fn test_linear_path_contribution_chart() {
780 let path = LinearPath::new(vec![0.3, -0.2, 0.5, -0.1], 0.1, 0.6, 0.75);
781 let fb = path
782 .to_contribution_chart(&["age", "income", "score", "tenure"])
783 .expect("operation should succeed");
784 assert_eq!(fb.width(), 600);
785 assert_eq!(fb.height(), 400);
786 }
787
788 #[test]
789 fn test_linear_path_confidence_gauge() {
790 let path = LinearPath::new(vec![0.3], 0.0, 0.5, 0.7).with_probability(0.85);
791 let fb = path.to_confidence_gauge().expect("operation should succeed");
792 assert_eq!(fb.width(), 200);
793 assert_eq!(fb.height(), 200);
794 }
795
796 #[test]
797 fn test_neural_path_contribution_chart() {
798 let path = NeuralPath::new(vec![0.1, -0.3, 0.2], 0.8, 0.9);
799 let fb = path.to_contribution_chart(&["x1", "x2", "x3"]).expect("operation should succeed");
800 assert!(fb.width() > 0);
801 }
802
803 #[test]
804 fn test_forest_path_prediction_histogram() {
805 let path = ForestPath::new(vec![], vec![0.5, 0.6, 0.55, 0.7, 0.45, 0.65]);
806 let fb = path.to_prediction_histogram().expect("operation should succeed");
807 assert!(fb.width() > 0);
808 }
809
810 #[test]
811 fn test_forest_path_tree_scatter() {
812 let path = ForestPath::new(vec![], vec![0.5, 0.6, 0.55, 0.7]);
813 let fb = path.to_tree_scatter().expect("operation should succeed");
814 assert!(fb.width() > 0);
815 }
816
817 #[test]
818 fn test_forest_path_agreement_chart() {
819 let path = ForestPath::new(vec![], vec![0.5, 0.5, 0.5]);
820 let fb = path.to_agreement_chart().expect("operation should succeed");
821 assert_eq!(fb.width(), 300);
822 }
823
824 #[test]
825 fn test_tree_path_graph() {
826 let splits = vec![
827 TreeSplit { feature_idx: 0, threshold: 35.0, went_left: true, n_samples: 100 },
828 TreeSplit { feature_idx: 1, threshold: 50000.0, went_left: false, n_samples: 60 },
829 ];
830 let leaf = LeafInfo { prediction: 0.8, n_samples: 30, class_distribution: None };
831
832 let path = TreePath::new(splits, leaf);
833 let fb = path.to_tree_graph().expect("operation should succeed");
834 assert!(fb.width() > 0);
835 }
836
837 #[test]
838 fn test_tree_path_empty_splits() {
839 let leaf = LeafInfo { prediction: 0.5, n_samples: 100, class_distribution: None };
840 let path = TreePath::new(vec![], leaf);
841 let fb = path.to_tree_graph().expect("operation should succeed");
842 assert!(fb.width() > 0);
843 }
844
845 #[test]
846 fn test_knn_neighbor_scatter() {
847 let path = KNNPath::new(
848 vec![0, 5, 10, 15, 20],
849 vec![0.1, 0.2, 0.3, 0.4, 0.5],
850 vec![0, 1, 0, 1, 1],
851 1.0,
852 );
853 let fb = path.to_neighbor_scatter().expect("operation should succeed");
854 assert!(fb.width() > 0);
855 }
856
857 #[test]
858 fn test_knn_vote_chart() {
859 let path = KNNPath::new(
860 vec![0, 1, 2, 3, 4],
861 vec![0.1, 0.2, 0.3, 0.4, 0.5],
862 vec![0, 0, 1, 1, 1],
863 1.0,
864 );
865 let fb = path.to_vote_chart().expect("operation should succeed");
866 assert!(fb.width() > 0);
867 }
868
869 #[test]
870 fn test_confidence_to_color_bounds() {
871 let low = confidence_to_color(0.0);
872 let mid = confidence_to_color(0.5);
873 let high = confidence_to_color(1.0);
874
875 assert_ne!(low, high);
877 assert_ne!(mid, high);
878 }
879
880 #[test]
881 fn test_label_to_color_cycling() {
882 let c0 = label_to_color(0);
883 let c1 = label_to_color(1);
884 let c8 = label_to_color(8);
885
886 assert_ne!(c0, c1);
887 assert_eq!(c0, c8); }
889
890 #[test]
891 fn test_empty_contributions_error() {
892 let result = contribution_bar_chart(&[], &[], 600, 400);
893 assert!(result.is_err());
894 }
895
896 #[test]
897 fn test_waterfall_chart() {
898 let contributions = vec![0.2, -0.1, 0.3, -0.05];
899 let fb = waterfall_chart(&contributions, 600, 400).expect("operation should succeed");
900 assert!(fb.width() > 0);
901 }
902
903 #[test]
904 fn test_feature_contributions_convenience() {
905 let path = LinearPath::new(vec![0.1, 0.2, 0.3], 0.0, 0.6, 0.6);
906 let fb = feature_contributions(&path, &["a", "b", "c"]).expect("operation should succeed");
907 assert!(fb.width() > 0);
908 }
909
910 #[test]
911 fn test_confidence_indicator_convenience() {
912 let path = LinearPath::new(vec![0.1], 0.0, 0.5, 0.5).with_probability(0.9);
913 let fb = confidence_indicator(&path).expect("operation should succeed");
914 assert_eq!(fb.width(), 200);
915 }
916}
917
918#[cfg(test)]
919mod proptests {
920 use super::*;
921 use proptest::prelude::*;
922
923 proptest! {
924 #![proptest_config(ProptestConfig::with_cases(100))]
925
926 #[test]
927 fn prop_contribution_chart_any_values(
928 contributions in prop::collection::vec(-100.0f32..100.0, 1..20)
929 ) {
930 let names: Vec<&str> = (0..contributions.len()).map(|_| "x").collect();
931 let result = contribution_bar_chart(&contributions, &names, 600, 400);
932 prop_assert!(result.is_ok());
933 }
934
935 #[test]
936 fn prop_confidence_gauge_bounded(confidence in 0.0f32..1.0) {
937 let result = confidence_gauge(confidence, 200, 200);
938 prop_assert!(result.is_ok());
939 }
940
941 #[test]
942 fn prop_confidence_color_always_valid(confidence in -1.0f32..2.0) {
943 let _color = confidence_to_color(confidence);
945 }
946
947 #[test]
948 fn prop_label_color_never_panics(label in 0usize..1000) {
949 let _color = label_to_color(label);
950 }
951
952 #[test]
953 fn prop_linear_path_viz_works(
954 contributions in prop::collection::vec(-10.0f32..10.0, 1..10),
955 intercept in -1.0f32..1.0,
956 logit in -5.0f32..5.0
957 ) {
958 let prediction = 1.0 / (1.0 + (-logit).exp());
959 let path = LinearPath::new(contributions.clone(), intercept, logit, prediction);
960
961 let names: Vec<&str> = (0..contributions.len()).map(|_| "f").collect();
962 let chart = path.to_contribution_chart(&names);
963 prop_assert!(chart.is_ok());
964
965 let gauge = path.to_confidence_gauge();
966 prop_assert!(gauge.is_ok());
967 }
968
969 #[test]
970 fn prop_waterfall_chart_any_contributions(
971 contributions in prop::collection::vec(-50.0f32..50.0, 1..15)
972 ) {
973 let result = waterfall_chart(&contributions, 600, 400);
974 prop_assert!(result.is_ok());
975 }
976 }
977}