organizational_intelligence_plugin/
viz.rs

1//! Visualization module for OIP defect pattern analysis.
2//!
3//! Requires the `viz` feature flag: `cargo build --features viz`
4
5#[cfg(feature = "viz")]
6use trueno_viz::output::{TerminalEncoder, TerminalMode};
7#[cfg(feature = "viz")]
8use trueno_viz::plots::{BinStrategy, Heatmap, Histogram};
9
10#[cfg(test)]
11use crate::citl::TrainingSource;
12use crate::training::TrainingExample;
13
14/// Defect distribution data for visualization
15pub struct DefectDistribution {
16    pub categories: Vec<String>,
17    pub counts: Vec<u32>,
18    pub percentages: Vec<f32>,
19}
20
21impl DefectDistribution {
22    /// Create from training examples
23    pub fn from_examples(examples: &[TrainingExample]) -> Self {
24        let mut category_counts: std::collections::HashMap<String, u32> =
25            std::collections::HashMap::new();
26
27        for example in examples {
28            let label_str = format!("{:?}", example.label);
29            *category_counts.entry(label_str).or_insert(0) += 1;
30        }
31
32        let total = examples.len() as f32;
33        let mut items: Vec<_> = category_counts.into_iter().collect();
34        items.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count descending
35
36        let categories: Vec<String> = items.iter().map(|(k, _)| k.clone()).collect();
37        let counts: Vec<u32> = items.iter().map(|(_, v)| *v).collect();
38        let percentages: Vec<f32> = counts.iter().map(|c| (*c as f32 / total) * 100.0).collect();
39
40        Self {
41            categories,
42            counts,
43            percentages,
44        }
45    }
46
47    /// Render as ASCII bar chart (no trueno-viz dependency)
48    pub fn to_ascii(&self, width: usize) -> String {
49        let max_count = self.counts.iter().max().copied().unwrap_or(1) as f32;
50        let max_label_len = self.categories.iter().map(|s| s.len()).max().unwrap_or(15);
51
52        let mut output = String::new();
53        for (i, category) in self.categories.iter().enumerate() {
54            let count = self.counts[i];
55            let pct = self.percentages[i];
56            let bar_len =
57                ((count as f32 / max_count) * (width - max_label_len - 15) as f32) as usize;
58            let bar: String = "█".repeat(bar_len.max(1));
59
60            output.push_str(&format!(
61                "{:width$} {:>5} {:>5.1}%\n",
62                category,
63                bar,
64                pct,
65                width = max_label_len
66            ));
67        }
68        output
69    }
70}
71
72/// Confidence distribution for histogram
73pub struct ConfidenceDistribution {
74    pub values: Vec<f32>,
75    pub min: f32,
76    pub max: f32,
77    pub mean: f32,
78}
79
80impl ConfidenceDistribution {
81    /// Create from training examples
82    pub fn from_examples(examples: &[TrainingExample]) -> Self {
83        let values: Vec<f32> = examples.iter().map(|e| e.confidence).collect();
84        let min = values.iter().cloned().fold(f32::INFINITY, f32::min);
85        let max = values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
86        let mean = values.iter().sum::<f32>() / values.len() as f32;
87
88        Self {
89            values,
90            min,
91            max,
92            mean,
93        }
94    }
95
96    /// Render as ASCII histogram (no trueno-viz dependency)
97    pub fn to_ascii(&self, bins: usize, height: usize) -> String {
98        let range = self.max - self.min;
99        let bin_width = range / bins as f32;
100
101        // Count values in each bin
102        let mut bin_counts = vec![0u32; bins];
103        for &v in &self.values {
104            let bin_idx = ((v - self.min) / bin_width).floor() as usize;
105            let bin_idx = bin_idx.min(bins - 1);
106            bin_counts[bin_idx] += 1;
107        }
108
109        let max_count = *bin_counts.iter().max().unwrap_or(&1) as f32;
110
111        // Build histogram
112        let mut output = String::new();
113        for row in (0..height).rev() {
114            let threshold = (row as f32 / height as f32) * max_count;
115            output.push('│');
116            for &count in &bin_counts {
117                if count as f32 >= threshold {
118                    output.push_str("██");
119                } else {
120                    output.push_str("  ");
121                }
122            }
123            output.push('\n');
124        }
125
126        // X-axis
127        output.push('└');
128        output.push_str(&"──".repeat(bins));
129        output.push('\n');
130        output.push_str(&format!(
131            " {:.2}{}  {:.2}\n",
132            self.min,
133            " ".repeat(bins * 2 - 10),
134            self.max
135        ));
136        output.push_str(&format!(" Mean: {:.2}\n", self.mean));
137
138        output
139    }
140}
141
142/// Cross-repository defect matrix for heatmap
143#[derive(Default)]
144pub struct CrossRepoMatrix {
145    pub repos: Vec<String>,
146    pub categories: Vec<String>,
147    pub matrix: Vec<Vec<f32>>, // [category][repo] = percentage
148}
149
150impl CrossRepoMatrix {
151    /// Create from multiple repo analyses
152    #[must_use]
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Add a repository's defect distribution
158    pub fn add_repo(&mut self, repo_name: &str, dist: &DefectDistribution) {
159        self.repos.push(repo_name.to_string());
160
161        // Ensure all categories exist
162        for cat in &dist.categories {
163            if !self.categories.contains(cat) {
164                self.categories.push(cat.clone());
165                self.matrix.push(vec![0.0; self.repos.len() - 1]);
166            }
167        }
168
169        // Add column for new repo
170        for row in &mut self.matrix {
171            row.push(0.0);
172        }
173
174        // Fill in percentages
175        let repo_idx = self.repos.len() - 1;
176        for (i, cat) in dist.categories.iter().enumerate() {
177            if let Some(cat_idx) = self.categories.iter().position(|c| c == cat) {
178                self.matrix[cat_idx][repo_idx] = dist.percentages[i];
179            }
180        }
181    }
182
183    /// Render as ASCII heatmap
184    pub fn to_ascii(&self) -> String {
185        let max_cat_len = self.categories.iter().map(|s| s.len()).max().unwrap_or(15);
186        let col_width = 8;
187
188        let mut output = String::new();
189
190        // Header
191        output.push_str(&" ".repeat(max_cat_len + 1));
192        for repo in &self.repos {
193            output.push_str(&format!(
194                "{:>width$}",
195                &repo[..repo.len().min(col_width)],
196                width = col_width
197            ));
198        }
199        output.push('\n');
200
201        // Rows
202        for (cat_idx, category) in self.categories.iter().enumerate() {
203            output.push_str(&format!("{:width$} ", category, width = max_cat_len));
204            for &pct in &self.matrix[cat_idx] {
205                let block = match pct {
206                    p if p >= 40.0 => "███████",
207                    p if p >= 20.0 => "█████░░",
208                    p if p >= 10.0 => "███░░░░",
209                    p if p >= 5.0 => "██░░░░░",
210                    p if p > 0.0 => "█░░░░░░",
211                    _ => "░░░░░░░",
212                };
213                output.push_str(&format!(" {}", block));
214            }
215            output.push('\n');
216        }
217
218        output
219    }
220}
221
222#[cfg(feature = "viz")]
223/// Render defect distribution using trueno-viz
224pub fn render_distribution_heatmap(matrix: &CrossRepoMatrix) -> Result<(), anyhow::Error> {
225    // Flatten matrix data for heatmap
226    let rows = matrix.categories.len();
227    let cols = matrix.repos.len();
228    let data: Vec<f32> = matrix.matrix.iter().flatten().copied().collect();
229
230    let heatmap = Heatmap::new().data(&data, rows, cols).build()?;
231
232    let fb = heatmap.to_framebuffer()?;
233    TerminalEncoder::new()
234        .mode(TerminalMode::UnicodeHalfBlock)
235        .width(80)
236        .print(&fb);
237
238    Ok(())
239}
240
241#[cfg(feature = "viz")]
242/// Render confidence histogram using trueno-viz
243pub fn render_confidence_histogram(dist: &ConfidenceDistribution) -> Result<(), anyhow::Error> {
244    let histogram = Histogram::new()
245        .data(&dist.values)
246        .bins(BinStrategy::Fixed(20))
247        .build()?;
248
249    let fb = histogram.to_framebuffer()?;
250    TerminalEncoder::new()
251        .mode(TerminalMode::UnicodeHalfBlock)
252        .width(80)
253        .print(&fb);
254
255    Ok(())
256}
257
258/// Print defect summary report (ASCII, no dependencies)
259pub fn print_summary_report(
260    repo_name: &str,
261    dist: &DefectDistribution,
262    confidence: &ConfidenceDistribution,
263) {
264    println!();
265    println!("Organizational Intelligence Report: {}", repo_name);
266    println!("{}", "═".repeat(50));
267    println!();
268    println!(
269        "Defect Distribution ({} commits analyzed)",
270        dist.counts.iter().sum::<u32>()
271    );
272    println!("{}", "─".repeat(40));
273    println!("{}", dist.to_ascii(50));
274    println!();
275    println!("Confidence Distribution");
276    println!("{}", "─".repeat(40));
277    println!("{}", confidence.to_ascii(20, 8));
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::classifier::DefectCategory;
284
285    fn make_example(label: DefectCategory, confidence: f32) -> TrainingExample {
286        TrainingExample {
287            message: "test fix".to_string(),
288            label,
289            confidence,
290            commit_hash: "abc123".to_string(),
291            author: "test".to_string(),
292            timestamp: 0,
293            lines_added: 10,
294            lines_removed: 5,
295            files_changed: 1,
296            error_code: None,
297            clippy_lint: None,
298            has_suggestion: false,
299            suggestion_applicability: None,
300            source: TrainingSource::CommitMessage,
301        }
302    }
303
304    #[test]
305    fn test_defect_distribution() {
306        let examples = vec![
307            make_example(DefectCategory::ASTTransform, 0.9),
308            make_example(DefectCategory::ASTTransform, 0.85),
309            make_example(DefectCategory::OwnershipBorrow, 0.8),
310        ];
311
312        let dist = DefectDistribution::from_examples(&examples);
313        assert_eq!(dist.categories.len(), 2);
314        assert_eq!(dist.counts[0], 2); // ASTTransform has 2
315
316        let ascii = dist.to_ascii(40);
317        assert!(ascii.contains("ASTTransform"));
318    }
319
320    #[test]
321    fn test_confidence_distribution() {
322        let examples = vec![
323            make_example(DefectCategory::ASTTransform, 0.7),
324            make_example(DefectCategory::ASTTransform, 0.9),
325        ];
326
327        let dist = ConfidenceDistribution::from_examples(&examples);
328        assert!((dist.mean - 0.8).abs() < 0.01);
329        assert!((dist.min - 0.7).abs() < 0.01);
330        assert!((dist.max - 0.9).abs() < 0.01);
331    }
332
333    #[test]
334    fn test_cross_repo_matrix() {
335        let mut matrix = CrossRepoMatrix::new();
336
337        let dist1 = DefectDistribution {
338            categories: vec!["ASTTransform".to_string(), "Security".to_string()],
339            counts: vec![50, 10],
340            percentages: vec![50.0, 10.0],
341        };
342
343        let dist2 = DefectDistribution {
344            categories: vec!["ASTTransform".to_string(), "Memory".to_string()],
345            counts: vec![40, 20],
346            percentages: vec![40.0, 20.0],
347        };
348
349        matrix.add_repo("depyler", &dist1);
350        matrix.add_repo("bashrs", &dist2);
351
352        assert_eq!(matrix.repos.len(), 2);
353        assert_eq!(matrix.categories.len(), 3); // ASTTransform, Security, Memory
354
355        let ascii = matrix.to_ascii();
356        assert!(ascii.contains("depyler"));
357        assert!(ascii.contains("bashrs"));
358    }
359}