Skip to main content

entrenar/viz/
inference_path.rs

1//! Entrenar inference monitoring visualization.
2//!
3//! Provides visualization extensions for decision paths and audit trails.
4//!
5//! # Provenance (APR-MONO §S, #1978)
6//!
7//! This module was re-homed from `aprender-viz` (`trueno_viz::interop::entrenar`).
8//! In the self-contained monorepo DAG, `aprender-train` (`entrenar`) depends on
9//! `aprender-viz` (`trueno_viz`) for training plots — the correct direction. The
10//! reverse edge (viz → entrenar) closed a `train ↔ viz` cycle and was dropped in
11//! #1975. Because these visualizations consume `entrenar::monitor::inference`
12//! types, their natural home is *inside* `aprender-train`, which already depends
13//! on `trueno_viz`. No cycle: `train → viz`.
14//!
15//! # Features
16//!
17//! - **Feature Contribution Charts**: Bar charts showing feature importance
18//! - **Decision Tree Visualization**: Tree path rendering as graphs
19//! - **Hash Chain Provenance**: Timeline visualization of audit entries
20//! - **Confidence Gauges**: Visual confidence indicators
21//!
22//! # Examples
23//!
24//! ```rust,ignore
25//! use entrenar::monitor::inference::path::LinearPath;
26//! use entrenar::viz::inference_path::DecisionPathViz;
27//!
28//! let path = LinearPath::new(vec![0.3, -0.2, 0.5], 0.1, 0.6, 0.75);
29//! let fb = path.to_contribution_chart(&["age", "income", "score"])?;
30//! ```
31
32use 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
49// =============================================================================
50// Color Constants for Explainability
51// =============================================================================
52
53/// Positive contribution color (green)
54const POSITIVE_COLOR: Rgba = Rgba::new(76, 175, 80, 255);
55/// Negative contribution color (red)
56const NEGATIVE_COLOR: Rgba = Rgba::new(244, 67, 54, 255);
57/// Neutral color (gray)
58const NEUTRAL_COLOR: Rgba = Rgba::new(158, 158, 158, 255);
59/// High confidence color (blue)
60const HIGH_CONFIDENCE_COLOR: Rgba = Rgba::new(33, 150, 243, 255);
61/// Low confidence color (orange)
62const LOW_CONFIDENCE_COLOR: Rgba = Rgba::new(255, 152, 0, 255);
63/// Tree node color
64const TREE_NODE_COLOR: Rgba = Rgba::new(103, 58, 183, 255);
65/// Leaf node color
66const LEAF_NODE_COLOR: Rgba = Rgba::new(0, 150, 136, 255);
67
68// =============================================================================
69// DecisionPath Visualization Trait
70// =============================================================================
71
72/// Visualization extensions for decision paths.
73pub trait DecisionPathViz {
74    /// Create a horizontal bar chart of feature contributions.
75    ///
76    /// Positive contributions are shown in green, negative in red.
77    fn to_contribution_chart(&self, feature_names: &[&str]) -> Result<Framebuffer>;
78
79    /// Create a contribution chart with custom dimensions.
80    fn to_contribution_chart_with(
81        &self,
82        feature_names: &[&str],
83        width: u32,
84        height: u32,
85    ) -> Result<Framebuffer>;
86
87    /// Create a confidence gauge visualization.
88    ///
89    /// Shows confidence as a filled arc from 0% to 100%.
90    fn to_confidence_gauge(&self) -> Result<Framebuffer>;
91
92    /// Create a confidence gauge with custom dimensions.
93    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
166// =============================================================================
167// Tree Path Visualization
168// =============================================================================
169
170/// Visualization extensions for tree-based decision paths.
171pub trait TreePathViz {
172    /// Render the decision path as a tree graph.
173    fn to_tree_graph(&self) -> Result<Framebuffer>;
174
175    /// Render with custom dimensions.
176    fn to_tree_graph_with(&self, width: u32, height: u32) -> Result<Framebuffer>;
177
178    /// Create a waterfall chart showing cumulative decision impact.
179    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
197// =============================================================================
198// Forest Path Visualization
199// =============================================================================
200
201/// Visualization extensions for ensemble decision paths.
202pub trait ForestPathViz {
203    /// Create a histogram of tree predictions.
204    fn to_prediction_histogram(&self) -> Result<Framebuffer>;
205
206    /// Create a scatter plot of tree predictions vs tree index.
207    fn to_tree_scatter(&self) -> Result<Framebuffer>;
208
209    /// Visualize tree agreement as a bar chart.
210    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        // Create a simple bar showing agreement level
248        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        // Background bar
256        draw_rect(&mut fb, margin as i32, 35, bar_width, bar_height, NEUTRAL_COLOR);
257
258        // Filled portion based on agreement
259        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) // Yellow
264        } 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
274// =============================================================================
275// KNN Path Visualization
276// =============================================================================
277
278/// Visualization extensions for KNN decision paths.
279pub trait KNNPathViz {
280    /// Create a distance-based scatter showing neighbors.
281    fn to_neighbor_scatter(&self) -> Result<Framebuffer>;
282
283    /// Create a vote distribution bar chart.
284    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        // X-axis: neighbor rank (1, 2, 3, ...)
294        let x: Vec<f32> = (1..=self.distances.len()).map(|i| i as f32).collect();
295
296        // Color by label
297        let mut fb = Framebuffer::new(600, 400)?;
298        fb.clear(Rgba::WHITE);
299
300        // Draw points with colors based on labels
301        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            // Color based on label (cycle through palette)
313            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
352// =============================================================================
353// Hash Chain Audit Trail Visualization
354// =============================================================================
355
356/// Visualization for hash chain audit trails.
357pub trait HashChainViz<P: DecisionPath + Serialize> {
358    /// Create a timeline visualization of audit entries.
359    fn to_timeline(&self) -> Result<Framebuffer>;
360
361    /// Create a confidence trend line over entries.
362    fn to_confidence_trend(&self) -> Result<Framebuffer>;
363
364    /// Create a provenance chain graph.
365    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 timeline line
383        draw_line(&mut fb, margin, timeline_y, 800 - margin, timeline_y, NEUTRAL_COLOR);
384
385        // Draw entry points
386        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            // Color based on verification
390            let color = if entry.prev_hash == [0u8; 32] || i == 0 {
391                HIGH_CONFIDENCE_COLOR // Genesis or first entry
392            } else {
393                POSITIVE_COLOR // Valid chain link
394            };
395
396            draw_circle(&mut fb, x as i32, timeline_y, 8, color);
397
398            // Draw hash prefix indicator
399            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        // Limit to reasonable number for visualization
438        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        // Add nodes
444        for i in 0..n {
445            let entry = &entries[entries.len() - n + i];
446            let confidence = entry.trace.path.confidence();
447
448            // Color based on confidence
449            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        // Add edges (chain links)
455        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
464// =============================================================================
465// Ring Collector Visualization
466// =============================================================================
467
468/// Visualization for ring buffer collectors.
469pub trait RingCollectorViz<P: DecisionPath, const N: usize> {
470    /// Create an output trend line.
471    fn to_output_trend(&self) -> Result<Framebuffer>;
472
473    /// Create a confidence heatmap over recent predictions.
474    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        // Create a 1xN heatmap of confidences
502        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
515// =============================================================================
516// Helper Functions
517// =============================================================================
518
519/// Create a horizontal bar chart for feature contributions.
520fn 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 center line
563        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
576/// Create a waterfall chart showing cumulative impact.
577fn 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    // Calculate cumulative values
591    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        // Connect bars
620        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
637/// Create a confidence gauge visualization.
638fn 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 background arc (gray)
647    draw_circle(&mut fb, cx, cy, radius, NEUTRAL_COLOR);
648    draw_circle(&mut fb, cx, cy, radius - 10, Rgba::WHITE);
649
650    // Draw filled portion based on confidence
651    let color = confidence_to_color(confidence);
652
653    // Approximate arc by drawing segments
654    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 center value indicator
663    draw_circle(&mut fb, cx, cy, 8, color);
664
665    Ok(fb)
666}
667
668/// Render tree path splits as a graph.
669fn 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        // Just show leaf node
677        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    // Add split nodes
688    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    // Add leaf node
693    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    // Add edges
698    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
708/// Map confidence to color gradient.
709fn 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        // Interpolate yellow to blue
716        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        // Interpolate orange to yellow
725        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
735/// Map label to color (cycling through palette).
736fn label_to_color(label: usize) -> Rgba {
737    const PALETTE: [Rgba; 8] = [
738        Rgba::new(66, 133, 244, 255), // Blue
739        Rgba::new(234, 67, 53, 255),  // Red
740        Rgba::new(251, 188, 4, 255),  // Yellow
741        Rgba::new(52, 168, 83, 255),  // Green
742        Rgba::new(103, 58, 183, 255), // Purple
743        Rgba::new(0, 150, 136, 255),  // Teal
744        Rgba::new(255, 87, 34, 255),  // Deep Orange
745        Rgba::new(121, 85, 72, 255),  // Brown
746    ];
747
748    PALETTE[label % PALETTE.len()]
749}
750
751// =============================================================================
752// Convenience Functions
753// =============================================================================
754
755/// Create a feature contribution chart from a decision path.
756pub 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
763/// Create a confidence gauge from a decision path.
764pub fn confidence_indicator<P: DecisionPath>(path: &P) -> Result<Framebuffer> {
765    confidence_gauge(path.confidence(), 200, 200)
766}
767
768// =============================================================================
769// Tests
770// =============================================================================
771
772#[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        // Just verify they're different colors
876        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); // Should cycle
888    }
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            // Just verify it doesn't panic - u8 values are always valid
944            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}