datasynth_eval/banking/
network_structure.rs1use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::EvalResult;
13
14#[derive(Debug, Clone)]
16pub struct NetworkNodeObservation {
17 pub network_id: String,
18 pub degree: usize,
19 pub role: String, }
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct NetworkStructureThresholds {
24 pub min_hub_ratio: f64,
26 pub min_roles: usize,
28 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 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 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 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 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 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 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}