Skip to main content

ringkernel_accnet/gui/
rankings.rs

1//! Account ranking panels for analytics dashboard.
2//!
3//! Shows top accounts by various metrics:
4//! - PageRank (influence in the network)
5//! - Centrality (degree/betweenness)
6//! - Risk score (composite anomaly indicator)
7
8use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Vec2};
9use std::collections::HashMap;
10
11use super::theme::AccNetTheme;
12use crate::models::{AccountFlags, AccountType, AccountingNetwork};
13
14/// A ranked account entry.
15#[derive(Clone)]
16pub struct RankedAccount {
17    /// Account index.
18    pub index: u16,
19    /// Display name or code.
20    pub name: String,
21    /// Account type.
22    pub account_type: AccountType,
23    /// Metric value.
24    pub value: f64,
25    /// Risk level (0-1).
26    pub risk: f32,
27}
28
29/// Top accounts ranking panel.
30pub struct TopAccountsPanel {
31    /// Title of the panel.
32    pub title: String,
33    /// Ranked accounts.
34    pub accounts: Vec<RankedAccount>,
35    /// Maximum accounts to show.
36    pub max_display: usize,
37    /// Bar color based on account type.
38    pub use_type_colors: bool,
39}
40
41impl TopAccountsPanel {
42    /// Create empty panel.
43    pub fn new(title: impl Into<String>) -> Self {
44        Self {
45            title: title.into(),
46            accounts: Vec::new(),
47            max_display: 10,
48            use_type_colors: true,
49        }
50    }
51
52    /// Create top accounts by PageRank.
53    pub fn by_pagerank(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
54        let pagerank = network.compute_pagerank(20, 0.85);
55
56        let mut accounts: Vec<RankedAccount> = pagerank
57            .iter()
58            .enumerate()
59            .filter_map(|(idx, &rank)| {
60                if idx < network.accounts.len() {
61                    let acc = &network.accounts[idx];
62                    let name = metadata
63                        .get(&acc.index)
64                        .cloned()
65                        .unwrap_or_else(|| format!("#{}", acc.index));
66                    let risk = Self::calculate_account_risk(acc);
67                    Some(RankedAccount {
68                        index: acc.index,
69                        name,
70                        account_type: acc.account_type,
71                        value: rank,
72                        risk,
73                    })
74                } else {
75                    None
76                }
77            })
78            .collect();
79
80        accounts.sort_by(|a, b| {
81            b.value
82                .partial_cmp(&a.value)
83                .unwrap_or(std::cmp::Ordering::Equal)
84        });
85        accounts.truncate(10);
86
87        Self {
88            title: "Top Accounts by PageRank".to_string(),
89            accounts,
90            max_display: 10,
91            use_type_colors: true,
92        }
93    }
94
95    /// Create top accounts by degree centrality.
96    pub fn by_centrality(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
97        let max_possible = (network.accounts.len().saturating_sub(1) * 2) as f64;
98
99        let mut accounts: Vec<RankedAccount> = network
100            .accounts
101            .iter()
102            .map(|acc| {
103                let degree = (acc.in_degree + acc.out_degree) as f64;
104                let centrality = if max_possible > 0.0 {
105                    degree / max_possible
106                } else {
107                    0.0
108                };
109                let name = metadata
110                    .get(&acc.index)
111                    .cloned()
112                    .unwrap_or_else(|| format!("#{}", acc.index));
113                let risk = Self::calculate_account_risk(acc);
114                RankedAccount {
115                    index: acc.index,
116                    name,
117                    account_type: acc.account_type,
118                    value: centrality,
119                    risk,
120                }
121            })
122            .collect();
123
124        accounts.sort_by(|a, b| {
125            b.value
126                .partial_cmp(&a.value)
127                .unwrap_or(std::cmp::Ordering::Equal)
128        });
129        accounts.truncate(10);
130
131        Self {
132            title: "Top Accounts by Centrality".to_string(),
133            accounts,
134            max_display: 10,
135            use_type_colors: true,
136        }
137    }
138
139    /// Create top accounts by risk score.
140    pub fn by_risk(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
141        let mut accounts: Vec<RankedAccount> = network
142            .accounts
143            .iter()
144            .map(|acc| {
145                let risk = Self::calculate_account_risk(acc);
146                let name = metadata
147                    .get(&acc.index)
148                    .cloned()
149                    .unwrap_or_else(|| format!("#{}", acc.index));
150                RankedAccount {
151                    index: acc.index,
152                    name,
153                    account_type: acc.account_type,
154                    value: risk as f64,
155                    risk,
156                }
157            })
158            .collect();
159
160        accounts.sort_by(|a, b| {
161            b.value
162                .partial_cmp(&a.value)
163                .unwrap_or(std::cmp::Ordering::Equal)
164        });
165        accounts.truncate(10);
166
167        Self {
168            title: "Top Accounts by Risk".to_string(),
169            accounts,
170            max_display: 10,
171            use_type_colors: false, // Use risk colors instead
172        }
173    }
174
175    /// Create top accounts by transaction volume.
176    pub fn by_volume(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
177        let max_volume = network
178            .accounts
179            .iter()
180            .map(|a| a.transaction_count as f64)
181            .fold(1.0, f64::max);
182
183        let mut accounts: Vec<RankedAccount> = network
184            .accounts
185            .iter()
186            .map(|acc| {
187                let volume = acc.transaction_count as f64 / max_volume;
188                let name = metadata
189                    .get(&acc.index)
190                    .cloned()
191                    .unwrap_or_else(|| format!("#{}", acc.index));
192                let risk = Self::calculate_account_risk(acc);
193                RankedAccount {
194                    index: acc.index,
195                    name,
196                    account_type: acc.account_type,
197                    value: volume,
198                    risk,
199                }
200            })
201            .collect();
202
203        accounts.sort_by(|a, b| {
204            b.value
205                .partial_cmp(&a.value)
206                .unwrap_or(std::cmp::Ordering::Equal)
207        });
208        accounts.truncate(10);
209
210        Self {
211            title: "Top Accounts by Volume".to_string(),
212            accounts,
213            max_display: 10,
214            use_type_colors: true,
215        }
216    }
217
218    /// Calculate composite risk score for an account.
219    fn calculate_account_risk(acc: &crate::models::AccountNode) -> f32 {
220        let mut risk = 0.0f32;
221
222        // Suspense account flag = high base risk
223        if acc.flags.has(AccountFlags::IS_SUSPENSE_ACCOUNT) {
224            risk += 0.4;
225        }
226
227        // GAAP violation flag
228        if acc.flags.has(AccountFlags::HAS_GAAP_VIOLATION) {
229            risk += 0.25;
230        }
231
232        // Fraud pattern flag
233        if acc.flags.has(AccountFlags::HAS_FRAUD_PATTERN) {
234            risk += 0.3;
235        }
236
237        // Anomaly flag
238        if acc.flags.has(AccountFlags::HAS_ANOMALY) {
239            risk += 0.2;
240        }
241
242        // High degree concentration
243        let degree = (acc.in_degree + acc.out_degree) as f32;
244        if degree > 50.0 {
245            risk += 0.1;
246        }
247
248        risk.min(1.0)
249    }
250
251    /// Render the ranking panel.
252    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
253        let width = ui.available_width();
254        let row_height = 18.0;
255        let header_height = 20.0;
256        let total_height =
257            header_height + self.accounts.len().min(self.max_display) as f32 * row_height + 10.0;
258
259        let (response, painter) =
260            ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
261        let rect = response.rect;
262
263        // Title
264        painter.text(
265            Pos2::new(rect.left() + 5.0, rect.top()),
266            egui::Align2::LEFT_TOP,
267            &self.title,
268            egui::FontId::proportional(11.0),
269            theme.text_secondary,
270        );
271
272        if self.accounts.is_empty() {
273            painter.text(
274                Pos2::new(rect.center().x, rect.top() + 40.0),
275                egui::Align2::CENTER_CENTER,
276                "No data",
277                egui::FontId::proportional(10.0),
278                theme.text_secondary,
279            );
280            return response;
281        }
282
283        let max_value = self.accounts.iter().map(|a| a.value).fold(0.001, f64::max);
284
285        let rank_width = 20.0;
286        let name_width = 70.0;
287        let bar_area_start = rect.left() + rank_width + name_width;
288        let bar_area_width = width - rank_width - name_width - 50.0;
289
290        for (i, account) in self.accounts.iter().take(self.max_display).enumerate() {
291            let y = rect.top() + header_height + i as f32 * row_height;
292
293            // Rank number
294            painter.text(
295                Pos2::new(rect.left() + 5.0, y + row_height / 2.0),
296                egui::Align2::LEFT_CENTER,
297                format!("{}.", i + 1),
298                egui::FontId::proportional(9.0),
299                theme.text_secondary,
300            );
301
302            // Account name
303            painter.text(
304                Pos2::new(rect.left() + rank_width, y + row_height / 2.0),
305                egui::Align2::LEFT_CENTER,
306                &account.name[..account.name.len().min(10)],
307                egui::FontId::proportional(9.0),
308                theme.text_primary,
309            );
310
311            // Bar
312            let bar_width = ((account.value / max_value) as f32 * bar_area_width).max(3.0);
313            let bar_color = if self.use_type_colors {
314                Self::type_color(account.account_type, theme)
315            } else {
316                Self::risk_color(account.risk)
317            };
318
319            let bar_rect = Rect::from_min_size(
320                Pos2::new(bar_area_start, y + 2.0),
321                Vec2::new(bar_width, row_height - 4.0),
322            );
323            painter.rect_filled(bar_rect, 2.0, bar_color);
324
325            // Value
326            painter.text(
327                Pos2::new(bar_area_start + bar_area_width + 5.0, y + row_height / 2.0),
328                egui::Align2::LEFT_CENTER,
329                format!("{:.3}", account.value),
330                egui::FontId::proportional(8.0),
331                theme.text_secondary,
332            );
333
334            // Risk indicator dot
335            let risk_x = rect.right() - 10.0;
336            let risk_color = Self::risk_color(account.risk);
337            painter.circle_filled(Pos2::new(risk_x, y + row_height / 2.0), 4.0, risk_color);
338        }
339
340        response
341    }
342
343    fn type_color(account_type: AccountType, theme: &AccNetTheme) -> Color32 {
344        match account_type {
345            AccountType::Asset => theme.asset_color,
346            AccountType::Liability => theme.liability_color,
347            AccountType::Equity => theme.equity_color,
348            AccountType::Revenue => theme.revenue_color,
349            AccountType::Expense => theme.expense_color,
350            AccountType::Contra => Color32::from_rgb(150, 150, 150),
351        }
352    }
353
354    fn risk_color(risk: f32) -> Color32 {
355        if risk < 0.25 {
356            Color32::from_rgb(80, 180, 100) // Green - low risk
357        } else if risk < 0.5 {
358            Color32::from_rgb(180, 180, 80) // Yellow - medium
359        } else if risk < 0.75 {
360            Color32::from_rgb(220, 140, 60) // Orange - elevated
361        } else {
362            Color32::from_rgb(200, 60, 60) // Red - high risk
363        }
364    }
365}
366
367/// Pattern statistics panel showing detected anomalies.
368pub struct PatternStatsPanel {
369    /// Circular flows detected.
370    pub circular_flows: usize,
371    /// Velocity anomalies (rapid multi-hop).
372    pub velocity_anomalies: usize,
373    /// Timing anomalies (off-hours).
374    pub timing_anomalies: usize,
375    /// Amount clustering (structuring).
376    pub amount_clustering: usize,
377    /// Dormant account reactivations.
378    pub dormant_reactivations: usize,
379    /// Round amount anomalies.
380    pub round_amounts: usize,
381}
382
383impl PatternStatsPanel {
384    /// Create from network analysis.
385    pub fn from_network(network: &AccountingNetwork) -> Self {
386        // Analyze flows for patterns
387        let mut circular_flows = 0;
388        let mut velocity_anomalies = 0;
389        let mut amount_clustering = 0;
390        let mut dormant_reactivations = 0;
391        let mut round_amounts = 0;
392
393        // Check for circular flow flags
394        for flow in &network.flows {
395            if flow.flags.has(crate::models::FlowFlags::IS_CIRCULAR) {
396                circular_flows += 1;
397            }
398            if flow.flags.has(crate::models::FlowFlags::IS_ANOMALOUS) {
399                velocity_anomalies += 1;
400            }
401
402            // Check for round amounts (potential structuring)
403            let amount = flow.amount.to_f64().abs();
404            if amount > 100.0 && amount % 1000.0 == 0.0 {
405                round_amounts += 1;
406            }
407
408            // Check for amount clustering around thresholds
409            let threshold_distances = [
410                (amount - 9999.0).abs(),
411                (amount - 10000.0).abs(),
412                (amount - 4999.0).abs(),
413                (amount - 5000.0).abs(),
414            ];
415            if threshold_distances.iter().any(|&d| d < 100.0) {
416                amount_clustering += 1;
417            }
418        }
419
420        // Check for dormant reactivations
421        for acc in &network.accounts {
422            if acc.flags.has(AccountFlags::IS_DORMANT) && acc.transaction_count > 0 {
423                dormant_reactivations += 1;
424            }
425        }
426
427        // Estimate timing anomalies from fraud pattern count
428        let timing_anomalies = network.statistics.fraud_pattern_count / 4;
429
430        Self {
431            circular_flows,
432            velocity_anomalies,
433            timing_anomalies,
434            amount_clustering,
435            dormant_reactivations,
436            round_amounts,
437        }
438    }
439
440    /// Render the pattern stats panel.
441    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
442        let width = ui.available_width();
443        let patterns = [
444            (
445                "Circular Flows",
446                self.circular_flows,
447                "⟲",
448                Color32::from_rgb(200, 80, 80),
449            ),
450            (
451                "Velocity Anomalies",
452                self.velocity_anomalies,
453                "⚡",
454                Color32::from_rgb(220, 160, 60),
455            ),
456            (
457                "Timing Anomalies",
458                self.timing_anomalies,
459                "⏰",
460                Color32::from_rgb(180, 100, 180),
461            ),
462            (
463                "Amount Clustering",
464                self.amount_clustering,
465                "▣",
466                Color32::from_rgb(100, 160, 200),
467            ),
468            (
469                "Dormant Reactivated",
470                self.dormant_reactivations,
471                "💤",
472                Color32::from_rgb(140, 140, 180),
473            ),
474            (
475                "Round Amounts",
476                self.round_amounts,
477                "○",
478                Color32::from_rgb(160, 200, 160),
479            ),
480        ];
481
482        let row_height = 18.0;
483        let total_height = 20.0 + patterns.len() as f32 * row_height + 5.0;
484
485        let (response, painter) =
486            ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
487        let rect = response.rect;
488
489        // Title
490        painter.text(
491            Pos2::new(rect.left() + 5.0, rect.top()),
492            egui::Align2::LEFT_TOP,
493            "Detected Patterns",
494            egui::FontId::proportional(11.0),
495            theme.text_secondary,
496        );
497
498        let _total: usize = patterns.iter().map(|(_, c, _, _)| c).sum();
499        let max_count = patterns
500            .iter()
501            .map(|(_, c, _, _)| *c)
502            .max()
503            .unwrap_or(1)
504            .max(1);
505
506        for (i, (name, count, icon, color)) in patterns.iter().enumerate() {
507            let y = rect.top() + 18.0 + i as f32 * row_height;
508
509            // Icon
510            painter.text(
511                Pos2::new(rect.left() + 10.0, y + row_height / 2.0),
512                egui::Align2::LEFT_CENTER,
513                *icon,
514                egui::FontId::proportional(10.0),
515                *color,
516            );
517
518            // Name
519            painter.text(
520                Pos2::new(rect.left() + 25.0, y + row_height / 2.0),
521                egui::Align2::LEFT_CENTER,
522                *name,
523                egui::FontId::proportional(9.0),
524                theme.text_primary,
525            );
526
527            // Mini bar
528            let bar_start = rect.left() + 120.0;
529            let bar_max_width = width - 160.0;
530            let bar_width = (*count as f32 / max_count as f32 * bar_max_width).max(2.0);
531
532            let bar_rect = Rect::from_min_size(
533                Pos2::new(bar_start, y + 4.0),
534                Vec2::new(bar_width, row_height - 8.0),
535            );
536            painter.rect_filled(bar_rect, 2.0, *color);
537
538            // Count
539            painter.text(
540                Pos2::new(rect.right() - 25.0, y + row_height / 2.0),
541                egui::Align2::RIGHT_CENTER,
542                count.to_string(),
543                egui::FontId::proportional(9.0),
544                if *count > 0 {
545                    *color
546                } else {
547                    theme.text_secondary
548                },
549            );
550        }
551
552        response
553    }
554}
555
556/// Amount distribution histogram showing transaction amounts.
557pub struct AmountDistribution {
558    /// Histogram buckets (count per range).
559    pub buckets: Vec<usize>,
560    /// Bucket labels.
561    pub labels: Vec<String>,
562    /// Title.
563    pub title: String,
564}
565
566impl AmountDistribution {
567    /// Create from network flows.
568    pub fn from_network(network: &AccountingNetwork) -> Self {
569        // Define amount ranges (log scale for Benford compliance check)
570        let ranges = [
571            (0.0, 100.0, "<100"),
572            (100.0, 500.0, "100-500"),
573            (500.0, 1000.0, "500-1K"),
574            (1000.0, 5000.0, "1K-5K"),
575            (5000.0, 10000.0, "5K-10K"),
576            (10000.0, 50000.0, "10K-50K"),
577            (50000.0, 100000.0, "50K-100K"),
578            (100000.0, f64::MAX, ">100K"),
579        ];
580
581        let mut buckets = vec![0usize; ranges.len()];
582
583        for flow in &network.flows {
584            let amount = flow.amount.to_f64().abs();
585            for (i, &(min, max, _)) in ranges.iter().enumerate() {
586                if amount >= min && amount < max {
587                    buckets[i] += 1;
588                    break;
589                }
590            }
591        }
592
593        Self {
594            buckets,
595            labels: ranges.iter().map(|(_, _, l)| l.to_string()).collect(),
596            title: "Transaction Amount Distribution".to_string(),
597        }
598    }
599
600    /// Render the distribution.
601    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
602        let width = ui.available_width();
603        let height = 90.0;
604
605        let (response, painter) = ui.allocate_painter(Vec2::new(width, height), Sense::hover());
606        let rect = response.rect;
607
608        // Title
609        painter.text(
610            Pos2::new(rect.left() + 5.0, rect.top()),
611            egui::Align2::LEFT_TOP,
612            &self.title,
613            egui::FontId::proportional(11.0),
614            theme.text_secondary,
615        );
616
617        if self.buckets.is_empty() {
618            return response;
619        }
620
621        let chart_left = rect.left() + 5.0;
622        let chart_top = rect.top() + 18.0;
623        let chart_width = width - 10.0;
624        let chart_height = height - 35.0;
625
626        let max_count = *self.buckets.iter().max().unwrap_or(&1).max(&1);
627        let bar_width = chart_width / self.buckets.len() as f32;
628        let gap = bar_width * 0.1;
629
630        // Background
631        painter.rect_filled(
632            Rect::from_min_size(
633                Pos2::new(chart_left, chart_top),
634                Vec2::new(chart_width, chart_height),
635            ),
636            2.0,
637            Color32::from_rgb(25, 25, 35),
638        );
639
640        // Bars
641        for (i, &count) in self.buckets.iter().enumerate() {
642            let x = chart_left + i as f32 * bar_width;
643            let bar_height = (count as f32 / max_count as f32) * (chart_height - 5.0);
644
645            let bar_rect = Rect::from_min_max(
646                Pos2::new(x + gap, chart_top + chart_height - bar_height),
647                Pos2::new(x + bar_width - gap, chart_top + chart_height),
648            );
649
650            // Gradient color based on position (small to large amounts)
651            let t = i as f32 / (self.buckets.len() - 1) as f32;
652            let color = Color32::from_rgb(
653                (80.0 + 120.0 * t) as u8,
654                (180.0 - 60.0 * t) as u8,
655                (200.0 - 100.0 * t) as u8,
656            );
657
658            painter.rect_filled(bar_rect, 2.0, color);
659
660            // Label
661            if i < self.labels.len() {
662                painter.text(
663                    Pos2::new(x + bar_width / 2.0, chart_top + chart_height + 3.0),
664                    egui::Align2::CENTER_TOP,
665                    &self.labels[i],
666                    egui::FontId::proportional(7.0),
667                    theme.text_secondary,
668                );
669            }
670        }
671
672        response
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use uuid::Uuid;
680
681    #[test]
682    fn test_risk_color() {
683        let low = TopAccountsPanel::risk_color(0.1);
684        let high = TopAccountsPanel::risk_color(0.9);
685        assert_ne!(low, high);
686    }
687
688    #[test]
689    fn test_pattern_stats() {
690        let network = AccountingNetwork::new(Uuid::new_v4(), 2024, 1);
691        let stats = PatternStatsPanel::from_network(&network);
692        assert_eq!(stats.circular_flows, 0);
693    }
694}