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)]
170#[allow(clippy::unwrap_used)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_power_law_network_passes() {
176        // Simulate a power-law network: one hub (degree 10), many leaves (degree 1)
177        let mut obs = Vec::new();
178        obs.push(NetworkNodeObservation {
179            network_id: "NET1".into(),
180            degree: 10,
181            role: "coordinator".into(),
182        });
183        for _ in 0..10 {
184            obs.push(NetworkNodeObservation {
185                network_id: "NET1".into(),
186                degree: 1,
187                role: "smurf".into(),
188            });
189        }
190        obs.push(NetworkNodeObservation {
191            network_id: "NET1".into(),
192            degree: 3,
193            role: "middleman".into(),
194        });
195
196        let a = NetworkStructureAnalyzer::new();
197        let r = a.analyze(&obs).unwrap();
198        assert!(r.passes, "Issues: {:?}", r.issues);
199        assert_eq!(r.power_law_networks, 1);
200    }
201
202    #[test]
203    fn test_uniform_degree_fails() {
204        // Hub-and-spoke: coordinator + 5 smurfs all with degree 1
205        let mut obs = Vec::new();
206        for _ in 0..10 {
207            obs.push(NetworkNodeObservation {
208                network_id: "NET1".into(),
209                degree: 1,
210                role: "smurf".into(),
211            });
212        }
213        let a = NetworkStructureAnalyzer::new();
214        let r = a.analyze(&obs).unwrap();
215        assert!(!r.passes);
216    }
217}