Skip to main content

datasynth_eval/banking/
network_structure.rs

1//! Network structure evaluator.
2//!
3//! Validates that multi-party criminal networks have realistic topology:
4//! - Power-law degree distribution (max >> average)
5//! - Hubs + long tail (not uniform)
6//! - Role distribution reflects network type
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::EvalResult;
13
14/// Per-node observation from a generated network.
15#[derive(Debug, Clone)]
16pub struct NetworkNodeObservation {
17    pub network_id: String,
18    pub degree: usize,
19    pub role: String, // "coordinator" | "smurf" | "middleman" | "cash_out" | ...
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct NetworkStructureThresholds {
24    /// Max degree should exceed average by at least this factor (power-law signature)
25    pub min_hub_ratio: f64,
26    /// Minimum number of distinct roles per network
27    pub min_roles: usize,
28    /// Maximum allowed fraction of degree-uniform networks (topology quality check)
29    pub max_uniform_degree_rate: f64,
30}
31
32impl Default for NetworkStructureThresholds {
33    fn default() -> Self {
34        Self {
35            min_hub_ratio: 2.5,
36            min_roles: 2,
37            max_uniform_degree_rate: 0.30,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NetworkStructureAnalysis {
44    pub total_networks: usize,
45    pub mean_hub_ratio: f64,
46    pub mean_role_diversity: f64,
47    pub uniform_degree_networks: usize,
48    pub power_law_networks: usize,
49    pub passes: bool,
50    pub issues: Vec<String>,
51}
52
53pub struct NetworkStructureAnalyzer {
54    pub thresholds: NetworkStructureThresholds,
55}
56
57impl NetworkStructureAnalyzer {
58    pub fn new() -> Self {
59        Self {
60            thresholds: NetworkStructureThresholds::default(),
61        }
62    }
63
64    pub fn analyze(
65        &self,
66        observations: &[NetworkNodeObservation],
67    ) -> EvalResult<NetworkStructureAnalysis> {
68        // Group by network_id
69        let mut by_network: HashMap<String, Vec<&NetworkNodeObservation>> = HashMap::new();
70        for obs in observations {
71            by_network
72                .entry(obs.network_id.clone())
73                .or_default()
74                .push(obs);
75        }
76        let total_networks = by_network.len();
77
78        let mut hub_ratios = Vec::new();
79        let mut role_counts = Vec::new();
80        let mut uniform_count = 0usize;
81        let mut power_law_count = 0usize;
82
83        for nodes in by_network.values() {
84            // Degree stats
85            let degrees: Vec<usize> = nodes.iter().map(|n| n.degree).collect();
86            let max_deg = *degrees.iter().max().unwrap_or(&0);
87            let avg_deg = if !degrees.is_empty() {
88                degrees.iter().sum::<usize>() as f64 / degrees.len() as f64
89            } else {
90                0.0
91            };
92            let hub_ratio = if avg_deg > 0.0 {
93                max_deg as f64 / avg_deg
94            } else {
95                1.0
96            };
97            hub_ratios.push(hub_ratio);
98
99            // Check uniformity (all nodes same degree = hub-and-spoke limitation)
100            let unique_degrees: std::collections::HashSet<usize> =
101                degrees.iter().copied().collect();
102            if unique_degrees.len() <= 2 {
103                uniform_count += 1;
104            }
105            if hub_ratio >= self.thresholds.min_hub_ratio {
106                power_law_count += 1;
107            }
108
109            // Role diversity
110            let roles: std::collections::HashSet<&str> =
111                nodes.iter().map(|n| n.role.as_str()).collect();
112            role_counts.push(roles.len());
113        }
114
115        let mean_hub_ratio = if !hub_ratios.is_empty() {
116            hub_ratios.iter().sum::<f64>() / hub_ratios.len() as f64
117        } else {
118            0.0
119        };
120        let mean_role_diversity = if !role_counts.is_empty() {
121            role_counts.iter().sum::<usize>() as f64 / role_counts.len() as f64
122        } else {
123            0.0
124        };
125        let uniform_rate = if total_networks > 0 {
126            uniform_count as f64 / total_networks as f64
127        } else {
128            0.0
129        };
130
131        let mut issues = Vec::new();
132        if total_networks > 0 && mean_hub_ratio < self.thresholds.min_hub_ratio {
133            issues.push(format!(
134                "Mean hub ratio {:.2} below minimum {:.2} — networks are too uniform",
135                mean_hub_ratio, self.thresholds.min_hub_ratio,
136            ));
137        }
138        if total_networks > 0 && mean_role_diversity < self.thresholds.min_roles as f64 {
139            issues.push(format!(
140                "Mean role diversity {:.1} below minimum {} — networks lack role variety",
141                mean_role_diversity, self.thresholds.min_roles,
142            ));
143        }
144        if uniform_rate > self.thresholds.max_uniform_degree_rate {
145            issues.push(format!(
146                "{:.1}% of networks have uniform degree — too hub-and-spoke",
147                uniform_rate * 100.0,
148            ));
149        }
150
151        Ok(NetworkStructureAnalysis {
152            total_networks,
153            mean_hub_ratio,
154            mean_role_diversity,
155            uniform_degree_networks: uniform_count,
156            power_law_networks: power_law_count,
157            passes: issues.is_empty(),
158            issues,
159        })
160    }
161}
162
163impl Default for NetworkStructureAnalyzer {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_power_law_network_passes() {
175        // Simulate a power-law network: one hub (degree 10), many leaves (degree 1)
176        let mut obs = Vec::new();
177        obs.push(NetworkNodeObservation {
178            network_id: "NET1".into(),
179            degree: 10,
180            role: "coordinator".into(),
181        });
182        for _ in 0..10 {
183            obs.push(NetworkNodeObservation {
184                network_id: "NET1".into(),
185                degree: 1,
186                role: "smurf".into(),
187            });
188        }
189        obs.push(NetworkNodeObservation {
190            network_id: "NET1".into(),
191            degree: 3,
192            role: "middleman".into(),
193        });
194
195        let a = NetworkStructureAnalyzer::new();
196        let r = a.analyze(&obs).unwrap();
197        assert!(r.passes, "Issues: {:?}", r.issues);
198        assert_eq!(r.power_law_networks, 1);
199    }
200
201    #[test]
202    fn test_uniform_degree_fails() {
203        // Hub-and-spoke: coordinator + 5 smurfs all with degree 1
204        let mut obs = Vec::new();
205        for _ in 0..10 {
206            obs.push(NetworkNodeObservation {
207                network_id: "NET1".into(),
208                degree: 1,
209                role: "smurf".into(),
210            });
211        }
212        let a = NetworkStructureAnalyzer::new();
213        let r = a.analyze(&obs).unwrap();
214        assert!(!r.passes);
215    }
216}