Skip to main content

ringkernel_accnet/gui/
dashboard.rs

1//! Analytics dashboard with live visualizations.
2//!
3//! Provides a comprehensive view of network health and detected patterns
4//! using charts, histograms, heatmaps, and real-time metrics.
5
6use eframe::egui::{self, Color32, RichText, Ui};
7use std::collections::{HashMap, VecDeque};
8
9use super::charts::{BarChart, DonutChart, Histogram, LiveTicker, MethodDistribution, Sparkline};
10use super::heatmaps::{ActivityHeatmap, CorrelationHeatmap, RiskHeatmap};
11use super::rankings::{AmountDistribution, PatternStatsPanel, TopAccountsPanel};
12use super::theme::AccNetTheme;
13use crate::models::{AccountType, AccountingNetwork, SolvingMethod};
14
15/// Analytics dashboard with live visualizations.
16pub struct AnalyticsDashboard {
17    /// Benford digit counts (digits 1-9).
18    benford_counts: [usize; 9],
19    /// Fraud pattern counts by type.
20    fraud_counts: [usize; 8],
21    /// GAAP violation counts by type.
22    gaap_counts: [usize; 6],
23    /// Account type counts.
24    account_type_counts: [usize; 5],
25    /// Account type balances (accumulated values).
26    account_type_balances: [f64; 5],
27    /// Method distribution.
28    method_counts: [usize; 5],
29    /// Transaction volume history.
30    volume_history: VecDeque<f32>,
31    /// Risk score history.
32    risk_history: VecDeque<f32>,
33    /// Flow rate history (flows per tick).
34    flow_rate_history: VecDeque<f32>,
35    /// Max history points.
36    max_history: usize,
37    /// Total flows processed.
38    total_flows: usize,
39    /// Total entries processed.
40    #[allow(dead_code)]
41    total_entries: usize,
42    /// Last update frame.
43    last_update_frame: u64,
44    /// Account metadata for display names.
45    account_metadata: HashMap<u16, String>,
46    /// Current view mode for dashboard sections.
47    expanded_sections: DashboardSections,
48}
49
50/// Tracks which dashboard sections are expanded/collapsed.
51#[derive(Default)]
52pub struct DashboardSections {
53    /// Show rankings panel.
54    pub show_rankings: bool,
55    /// Show heatmaps.
56    pub show_heatmaps: bool,
57    /// Show pattern detection.
58    pub show_patterns: bool,
59    /// Show amount distribution.
60    pub show_amounts: bool,
61}
62
63impl AnalyticsDashboard {
64    /// Create a new dashboard.
65    pub fn new() -> Self {
66        Self {
67            benford_counts: [0; 9],
68            fraud_counts: [0; 8],
69            gaap_counts: [0; 6],
70            account_type_counts: [0; 5],
71            account_type_balances: [0.0; 5],
72            method_counts: [0; 5],
73            volume_history: VecDeque::with_capacity(100),
74            risk_history: VecDeque::with_capacity(100),
75            flow_rate_history: VecDeque::with_capacity(100),
76            max_history: 100,
77            total_flows: 0,
78            total_entries: 0,
79            last_update_frame: 0,
80            account_metadata: HashMap::new(),
81            expanded_sections: DashboardSections {
82                show_rankings: true,
83                show_heatmaps: true,
84                show_patterns: true,
85                show_amounts: true,
86            },
87        }
88    }
89
90    /// Set account metadata for display.
91    pub fn set_account_metadata(&mut self, metadata: HashMap<u16, String>) {
92        self.account_metadata = metadata;
93    }
94
95    /// Update the dashboard from network state.
96    pub fn update(&mut self, network: &AccountingNetwork, frame: u64) {
97        // Avoid updating too frequently
98        if frame == self.last_update_frame {
99            return;
100        }
101        self.last_update_frame = frame;
102
103        // Reset counts
104        self.benford_counts = [0; 9];
105        self.fraud_counts = [0; 8];
106        self.gaap_counts = [0; 6];
107        self.account_type_counts = [0; 5];
108        self.account_type_balances = [0.0; 5];
109        self.method_counts = [0; 5];
110
111        // Count account types and sum balances
112        for account in &network.accounts {
113            let idx = match account.account_type {
114                AccountType::Asset => 0,
115                AccountType::Liability => 1,
116                AccountType::Equity => 2,
117                AccountType::Revenue => 3,
118                AccountType::Expense => 4,
119                AccountType::Contra => 0, // Count contra with assets for simplicity
120            };
121            self.account_type_counts[idx] += 1;
122            self.account_type_balances[idx] += account.closing_balance.to_f64().abs();
123        }
124
125        // Analyze flows for Benford's Law
126        for flow in &network.flows {
127            let amount = flow.amount.to_f64().abs();
128            if amount >= 1.0 {
129                // Get first digit
130                let first_digit = get_first_digit(amount);
131                if (1..=9).contains(&first_digit) {
132                    self.benford_counts[(first_digit - 1) as usize] += 1;
133                }
134            }
135
136            // Count solving methods
137            let method_idx = match flow.method_used {
138                SolvingMethod::MethodA => 0,
139                SolvingMethod::MethodB => 1,
140                SolvingMethod::MethodC => 2,
141                SolvingMethod::MethodD => 3,
142                SolvingMethod::MethodE => 4,
143                SolvingMethod::Pending => continue, // Skip pending flows in count
144            };
145            self.method_counts[method_idx] += 1;
146        }
147
148        // Count fraud patterns (from network statistics)
149        // We'll derive counts from network patterns if available
150        self.fraud_counts[0] = network.statistics.fraud_pattern_count / 3; // Rough estimate
151        self.fraud_counts[2] = self.calculate_benford_violations();
152
153        // Update totals
154        let new_flows = network.flows.len();
155        let flow_rate = (new_flows as i64 - self.total_flows as i64).max(0) as f32;
156        self.total_flows = new_flows;
157
158        // Update history
159        self.flow_rate_history.push_back(flow_rate);
160        if self.flow_rate_history.len() > self.max_history {
161            self.flow_rate_history.pop_front();
162        }
163
164        // Risk score from network
165        let risk = self.calculate_risk_score(network);
166        self.risk_history.push_back(risk);
167        if self.risk_history.len() > self.max_history {
168            self.risk_history.pop_front();
169        }
170
171        // Volume (cumulative flows)
172        let volume = network
173            .flows
174            .iter()
175            .map(|f| f.amount.to_f64().abs())
176            .sum::<f64>() as f32
177            / 1000.0; // In thousands
178        self.volume_history.push_back(volume);
179        if self.volume_history.len() > self.max_history {
180            self.volume_history.pop_front();
181        }
182    }
183
184    /// Calculate Benford violation count.
185    fn calculate_benford_violations(&self) -> usize {
186        let total: usize = self.benford_counts.iter().sum();
187        if total < 50 {
188            return 0;
189        }
190
191        // Chi-squared test
192        let expected = [
193            0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
194        ];
195        let mut chi_sq = 0.0;
196
197        for (i, &count) in self.benford_counts.iter().enumerate() {
198            let observed = count as f64 / total as f64;
199            let exp = expected[i];
200            chi_sq += (observed - exp).powi(2) / exp;
201        }
202
203        // Critical value at 0.05 significance with 8 df is 15.507
204        if chi_sq > 15.507 {
205            1
206        } else {
207            0
208        }
209    }
210
211    /// Calculate overall risk score.
212    /// Uses a bounded calculation that provides meaningful variation between 0-100%.
213    fn calculate_risk_score(&self, network: &AccountingNetwork) -> f32 {
214        let n = network.accounts.len().max(1) as f32;
215        let flows = network.flows.len().max(1) as f32;
216
217        // Suspense: proportion of accounts flagged as suspense (0-1 range)
218        // Cap at 20% suspense accounts = max risk from this factor
219        let suspense_ratio = (network.statistics.suspense_account_count as f32 / n).min(0.2) / 0.2;
220
221        // GAAP violations: per 100 flows, capped at 10%
222        // Having 10 violations per 100 flows = max risk from this factor
223        let violation_ratio =
224            (network.statistics.gaap_violation_count as f32 / flows * 100.0).min(10.0) / 10.0;
225
226        // Fraud patterns: per 100 flows, capped at 5%
227        // Having 5 fraud patterns per 100 flows = max risk from this factor
228        let fraud_ratio =
229            (network.statistics.fraud_pattern_count as f32 / flows * 100.0).min(5.0) / 5.0;
230
231        // Benford's Law anomaly (binary: 0 or 1)
232        let benford_risk = self.calculate_benford_violations() as f32;
233
234        // Confidence: lower average confidence = higher risk
235        let avg_confidence = network.statistics.avg_confidence as f32;
236        let confidence_risk = (1.0 - avg_confidence).clamp(0.0, 1.0);
237
238        // Weighted combination - each factor contributes proportionally
239        // Weights sum to 1.0
240        let risk = 0.20 * suspense_ratio
241            + 0.25 * violation_ratio
242            + 0.30 * fraud_ratio
243            + 0.10 * benford_risk
244            + 0.15 * confidence_risk;
245
246        risk.clamp(0.0, 1.0)
247    }
248
249    /// Render the full dashboard.
250    pub fn show(&mut self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
251        egui::ScrollArea::vertical().show(ui, |ui| {
252            // Live ticker at top
253            self.show_ticker(ui, network, theme);
254            ui.add_space(10.0);
255
256            // Benford's Law histogram
257            self.show_benford(ui, theme);
258            ui.add_space(15.0);
259
260            // Account type distribution
261            self.show_account_distribution(ui, theme);
262            ui.add_space(15.0);
263
264            // Method distribution
265            self.show_method_distribution(ui, theme);
266            ui.add_space(15.0);
267
268            // Fraud pattern breakdown
269            self.show_fraud_breakdown(ui, network, theme);
270            ui.add_space(15.0);
271
272            // Flow rate sparkline
273            self.show_flow_rate(ui, theme);
274            ui.add_space(10.0);
275
276            // Risk trend sparkline
277            self.show_risk_trend(ui, theme);
278            ui.add_space(15.0);
279
280            // === NEW ANALYTICS SECTIONS ===
281
282            // Section header with toggle
283            ui.separator();
284            ui.add_space(5.0);
285
286            // Pattern Detection (Collapsible)
287            let patterns_header = if self.expanded_sections.show_patterns {
288                "▼ Pattern Detection"
289            } else {
290                "▶ Pattern Detection"
291            };
292            if ui
293                .selectable_label(
294                    self.expanded_sections.show_patterns,
295                    RichText::new(patterns_header)
296                        .color(theme.text_primary)
297                        .size(12.0),
298                )
299                .clicked()
300            {
301                self.expanded_sections.show_patterns = !self.expanded_sections.show_patterns;
302            }
303            if self.expanded_sections.show_patterns {
304                ui.add_space(5.0);
305                self.show_pattern_detection(ui, network, theme);
306                ui.add_space(10.0);
307            }
308
309            // Top Accounts Rankings (Collapsible)
310            let rankings_header = if self.expanded_sections.show_rankings {
311                "▼ Top Accounts"
312            } else {
313                "▶ Top Accounts"
314            };
315            if ui
316                .selectable_label(
317                    self.expanded_sections.show_rankings,
318                    RichText::new(rankings_header)
319                        .color(theme.text_primary)
320                        .size(12.0),
321                )
322                .clicked()
323            {
324                self.expanded_sections.show_rankings = !self.expanded_sections.show_rankings;
325            }
326            if self.expanded_sections.show_rankings {
327                ui.add_space(5.0);
328                self.show_top_accounts(ui, network, theme);
329                ui.add_space(10.0);
330            }
331
332            // Amount Distribution (Collapsible)
333            let amounts_header = if self.expanded_sections.show_amounts {
334                "▼ Amount Distribution"
335            } else {
336                "▶ Amount Distribution"
337            };
338            if ui
339                .selectable_label(
340                    self.expanded_sections.show_amounts,
341                    RichText::new(amounts_header)
342                        .color(theme.text_primary)
343                        .size(12.0),
344                )
345                .clicked()
346            {
347                self.expanded_sections.show_amounts = !self.expanded_sections.show_amounts;
348            }
349            if self.expanded_sections.show_amounts {
350                ui.add_space(5.0);
351                self.show_amount_distribution(ui, network, theme);
352                ui.add_space(10.0);
353            }
354
355            // Heatmaps (Collapsible)
356            let heatmaps_header = if self.expanded_sections.show_heatmaps {
357                "▼ Heatmaps"
358            } else {
359                "▶ Heatmaps"
360            };
361            if ui
362                .selectable_label(
363                    self.expanded_sections.show_heatmaps,
364                    RichText::new(heatmaps_header)
365                        .color(theme.text_primary)
366                        .size(12.0),
367                )
368                .clicked()
369            {
370                self.expanded_sections.show_heatmaps = !self.expanded_sections.show_heatmaps;
371            }
372            if self.expanded_sections.show_heatmaps {
373                ui.add_space(5.0);
374                self.show_heatmaps(ui, network, theme);
375                ui.add_space(10.0);
376            }
377        });
378    }
379
380    /// Show pattern detection panel.
381    fn show_pattern_detection(
382        &self,
383        ui: &mut Ui,
384        network: &AccountingNetwork,
385        theme: &AccNetTheme,
386    ) {
387        let stats = PatternStatsPanel::from_network(network);
388        stats.show(ui, theme);
389    }
390
391    /// Show top accounts rankings.
392    fn show_top_accounts(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
393        // Build account name map from network metadata
394        let account_names: HashMap<u16, String> = network
395            .account_metadata
396            .iter()
397            .map(|(&idx, meta)| (idx, meta.name.clone()))
398            .collect();
399
400        // Show tabs for different ranking types
401        ui.horizontal(|ui| {
402            ui.label(RichText::new("By:").small().color(theme.text_secondary));
403        });
404
405        // PageRank ranking (most influential accounts)
406        let pagerank_panel = TopAccountsPanel::by_pagerank(network, &account_names);
407        pagerank_panel.show(ui, theme);
408
409        ui.add_space(10.0);
410
411        // Risk ranking
412        let risk_panel = TopAccountsPanel::by_risk(network, &account_names);
413        risk_panel.show(ui, theme);
414    }
415
416    /// Show amount distribution.
417    fn show_amount_distribution(
418        &self,
419        ui: &mut Ui,
420        network: &AccountingNetwork,
421        theme: &AccNetTheme,
422    ) {
423        let dist = AmountDistribution::from_network(network);
424        dist.show(ui, theme);
425    }
426
427    /// Show heatmaps.
428    fn show_heatmaps(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
429        // Build account name map from network metadata
430        let account_names: HashMap<u16, String> = network
431            .account_metadata
432            .iter()
433            .map(|(&idx, meta)| (idx, meta.name.clone()))
434            .collect();
435
436        // Activity heatmap by account type
437        let activity = ActivityHeatmap::from_network_by_type(network);
438        activity.show(ui, theme);
439
440        ui.add_space(10.0);
441
442        // Correlation heatmap (top accounts)
443        if network.accounts.len() >= 5 {
444            let correlation = CorrelationHeatmap::from_network(network, 8, &account_names);
445            correlation.show(ui, theme);
446
447            ui.add_space(10.0);
448
449            // Risk factor heatmap
450            let risk = RiskHeatmap::from_network(network, 6, &account_names);
451            risk.show(ui, theme);
452        }
453    }
454
455    /// Show the live ticker.
456    fn show_ticker(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
457        let ticker = LiveTicker::new()
458            .add(
459                "Accounts",
460                network.accounts.len().to_string(),
461                theme.asset_color,
462            )
463            .add("Flows", network.flows.len().to_string(), theme.flow_normal)
464            .add(
465                "Alerts",
466                network.statistics.gaap_violation_count.to_string(),
467                theme.alert_high,
468            )
469            .add(
470                "Fraud",
471                network.statistics.fraud_pattern_count.to_string(),
472                theme.alert_critical,
473            );
474
475        ticker.show(ui, theme);
476    }
477
478    /// Show Benford's Law histogram.
479    fn show_benford(&self, ui: &mut Ui, theme: &AccNetTheme) {
480        let histogram = Histogram::benford(self.benford_counts);
481        histogram.show(ui, theme);
482
483        // Violation indicator
484        let total: usize = self.benford_counts.iter().sum();
485        if total >= 50 {
486            let violations = self.calculate_benford_violations();
487            let (text, color) = if violations > 0 {
488                ("Distribution anomaly detected", theme.alert_high)
489            } else {
490                ("Distribution normal", theme.alert_low)
491            };
492            ui.horizontal(|ui| {
493                ui.add_space(5.0);
494                ui.label(RichText::new(text).small().color(color));
495            });
496        }
497    }
498
499    /// Show account type distribution.
500    fn show_account_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
501        let chart = DonutChart::new("Account Types")
502            .add(
503                "Asset",
504                self.account_type_counts[0] as f64,
505                theme.asset_color,
506            )
507            .add(
508                "Liability",
509                self.account_type_counts[1] as f64,
510                theme.liability_color,
511            )
512            .add(
513                "Equity",
514                self.account_type_counts[2] as f64,
515                theme.equity_color,
516            )
517            .add(
518                "Revenue",
519                self.account_type_counts[3] as f64,
520                theme.revenue_color,
521            )
522            .add(
523                "Expense",
524                self.account_type_counts[4] as f64,
525                theme.expense_color,
526            );
527
528        chart.show(ui, theme);
529    }
530
531    /// Show method distribution.
532    fn show_method_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
533        let dist = MethodDistribution::new(self.method_counts);
534        dist.show(ui, theme);
535    }
536
537    /// Show fraud pattern breakdown.
538    fn show_fraud_breakdown(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
539        let fraud_count = network.statistics.fraud_pattern_count;
540        let gaap_count = network.statistics.gaap_violation_count;
541        let suspense = network.statistics.suspense_account_count;
542
543        let chart = BarChart::new("Detected Issues")
544            .add("Suspense Accts", suspense as f64, theme.alert_medium)
545            .add("GAAP Violations", gaap_count as f64, theme.alert_high)
546            .add("Fraud Patterns", fraud_count as f64, theme.alert_critical)
547            .add(
548                "Benford Anomaly",
549                self.calculate_benford_violations() as f64,
550                Color32::from_rgb(200, 100, 200),
551            );
552
553        chart.show(ui, theme);
554    }
555
556    /// Show flow rate sparkline.
557    fn show_flow_rate(&self, ui: &mut Ui, theme: &AccNetTheme) {
558        let mut sparkline = Sparkline::new("Flow Rate (per tick)");
559        sparkline.color = theme.flow_normal;
560        for &val in &self.flow_rate_history {
561            sparkline.push(val);
562        }
563        sparkline.show(ui, theme);
564    }
565
566    /// Show risk trend sparkline.
567    fn show_risk_trend(&self, ui: &mut Ui, theme: &AccNetTheme) {
568        let mut sparkline = Sparkline::new("Risk Score Trend");
569        sparkline.color = theme.alert_high;
570        for &val in &self.risk_history {
571            sparkline.push(val * 100.0); // Convert to percentage
572        }
573        sparkline.show(ui, theme);
574    }
575
576    /// Reset the dashboard.
577    pub fn reset(&mut self) {
578        *self = Self::new();
579    }
580
581    /// Get Benford digit counts.
582    pub fn benford_counts(&self) -> [usize; 9] {
583        self.benford_counts
584    }
585
586    /// Get account type counts.
587    pub fn account_type_counts(&self) -> [usize; 5] {
588        self.account_type_counts
589    }
590
591    /// Get account type balances (accumulated values).
592    pub fn account_type_balances(&self) -> [f64; 5] {
593        self.account_type_balances
594    }
595
596    /// Get method counts.
597    pub fn method_counts(&self) -> [usize; 5] {
598        self.method_counts
599    }
600
601    /// Get flow rate history.
602    pub fn flow_rate_history(&self) -> &VecDeque<f32> {
603        &self.flow_rate_history
604    }
605
606    /// Get risk history.
607    pub fn risk_history(&self) -> &VecDeque<f32> {
608        &self.risk_history
609    }
610}
611
612impl Default for AnalyticsDashboard {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618/// Get the first digit of a number.
619fn get_first_digit(mut n: f64) -> u8 {
620    n = n.abs();
621    while n >= 10.0 {
622        n /= 10.0;
623    }
624    while n < 1.0 && n > 0.0 {
625        n *= 10.0;
626    }
627    n as u8
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn test_first_digit() {
636        assert_eq!(get_first_digit(123.45), 1);
637        assert_eq!(get_first_digit(9876.0), 9);
638        assert_eq!(get_first_digit(0.0045), 4);
639        assert_eq!(get_first_digit(5.0), 5);
640    }
641
642    #[test]
643    fn test_dashboard_creation() {
644        let dashboard = AnalyticsDashboard::new();
645        assert_eq!(dashboard.total_flows, 0);
646    }
647}