1use crate::core::graph::EdgeId;
38use crate::core::{Graph, IgraphError, IgraphResult};
39
40pub fn assortativity(
84 graph: &Graph,
85 values: &[f64],
86 values_in: Option<&[f64]>,
87 weights: Option<&[f64]>,
88 directed: bool,
89 normalized: bool,
90) -> IgraphResult<Option<f64>> {
91 let n = graph.vcount() as usize;
92 let m = graph.ecount();
93
94 if values.len() != n {
95 return Err(IgraphError::InvalidArgument(format!(
96 "assortativity: values length ({}) does not match vertex count ({n})",
97 values.len()
98 )));
99 }
100 if let Some(vin) = values_in {
101 if vin.len() != n {
102 return Err(IgraphError::InvalidArgument(format!(
103 "assortativity: values_in length ({}) does not match vertex count ({n})",
104 vin.len()
105 )));
106 }
107 }
108 if let Some(w) = weights {
109 if w.len() != m {
110 return Err(IgraphError::InvalidArgument(format!(
111 "assortativity: weights length ({}) does not match edge count ({m})",
112 w.len()
113 )));
114 }
115 }
116
117 let directed = directed && graph.is_directed();
118
119 let m_u32 = u32::try_from(m)
120 .map_err(|_| IgraphError::Internal("ecount overflows u32 for assortativity"))?;
121
122 #[allow(clippy::cast_precision_loss)]
123 let total_weight = match weights {
124 Some(w) => w.iter().sum::<f64>(),
125 None => m as f64,
126 };
127
128 let result = if directed {
129 assortativity_directed(
130 graph,
131 values,
132 values_in.unwrap_or(values),
133 weights,
134 normalized,
135 total_weight,
136 m_u32,
137 )?
138 } else {
139 assortativity_undirected(graph, values, weights, normalized, total_weight, m_u32)?
140 };
141
142 Ok(if result.is_finite() {
143 Some(result)
144 } else {
145 None
146 })
147}
148
149#[allow(clippy::too_many_arguments)]
150fn assortativity_undirected(
151 graph: &Graph,
152 values: &[f64],
153 weights: Option<&[f64]>,
154 normalized: bool,
155 total_weight: f64,
156 m_u32: u32,
157) -> IgraphResult<f64> {
158 let mut num1 = 0.0_f64;
159 let mut num2 = 0.0_f64;
160 let mut den1 = 0.0_f64;
161
162 for e in 0..m_u32 {
163 let (from, to) = graph.edge(e as EdgeId)?;
164 let from_value = values[from as usize];
165 let to_value = values[to as usize];
166 let w = weights.map_or(1.0, |ws| ws[e as usize]);
167
168 num1 += w * from_value * to_value;
169 num2 += w * (from_value + to_value);
170 if normalized {
171 den1 += w * (from_value * from_value + to_value * to_value);
172 }
173 }
174
175 num1 /= total_weight;
176 if normalized {
177 den1 /= total_weight * 2.0;
178 }
179 num2 /= total_weight * 2.0;
180 num2 *= num2;
181
182 Ok(if normalized {
183 (num1 - num2) / (den1 - num2)
184 } else {
185 num1 - num2
186 })
187}
188
189#[allow(clippy::too_many_arguments)]
190fn assortativity_directed(
191 graph: &Graph,
192 values: &[f64],
193 values_in: &[f64],
194 weights: Option<&[f64]>,
195 normalized: bool,
196 total_weight: f64,
197 m_u32: u32,
198) -> IgraphResult<f64> {
199 let mut num1 = 0.0_f64;
200 let mut num2 = 0.0_f64;
201 let mut num3 = 0.0_f64;
202 let mut den1 = 0.0_f64;
203 let mut den2 = 0.0_f64;
204
205 for e in 0..m_u32 {
206 let (from, to) = graph.edge(e as EdgeId)?;
207 let from_value = values[from as usize];
208 let to_value = values_in[to as usize];
209 let w = weights.map_or(1.0, |ws| ws[e as usize]);
210
211 num1 += w * from_value * to_value;
212 num2 += w * from_value;
213 num3 += w * to_value;
214 if normalized {
215 den1 += w * (from_value * from_value);
216 den2 += w * (to_value * to_value);
217 }
218 }
219
220 let num = num1 - num2 * num3 / total_weight;
221 Ok(if normalized {
222 let den =
223 (den1 - num2 * num2 / total_weight).sqrt() * (den2 - num3 * num3 / total_weight).sqrt();
224 num / den
225 } else {
226 num / total_weight
227 })
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn assert_close(a: f64, b: f64, tol: f64) {
235 assert!(
236 (a - b).abs() < tol,
237 "expected {b} ± {tol}, got {a} (diff {})",
238 (a - b).abs()
239 );
240 }
241
242 #[test]
243 fn no_edges_is_none() {
244 let g = Graph::with_vertices(3);
245 let values = [1.0, 2.0, 3.0];
246 assert_eq!(
247 assortativity(&g, &values, None, None, false, true).unwrap(),
248 None
249 );
250 }
251
252 #[test]
253 fn wrong_values_length_errors() {
254 let mut g = Graph::with_vertices(3);
255 g.add_edge(0, 1).unwrap();
256 let values = [1.0, 2.0];
257 assert!(assortativity(&g, &values, None, None, false, true).is_err());
258 }
259
260 #[test]
261 fn wrong_weights_length_errors() {
262 let mut g = Graph::with_vertices(3);
263 g.add_edge(0, 1).unwrap();
264 g.add_edge(1, 2).unwrap();
265 let values = [1.0, 2.0, 1.0];
266 let weights = [1.0];
267 assert!(assortativity(&g, &values, None, Some(&weights), false, true).is_err());
268 }
269
270 #[test]
271 fn path_with_degree_values_matches_degree_assortativity() {
272 let mut g = Graph::with_vertices(3);
275 g.add_edge(0, 1).unwrap();
276 g.add_edge(1, 2).unwrap();
277 let values = [1.0, 2.0, 1.0];
278 let r = assortativity(&g, &values, None, None, false, true)
279 .unwrap()
280 .unwrap();
281 assert_close(r, -1.0, 1e-12);
282 }
283
284 #[test]
285 fn constant_values_zero_variance_is_none() {
286 let mut g = Graph::with_vertices(4);
289 for i in 0..4u32 {
290 g.add_edge(i, (i + 1) % 4).unwrap();
291 }
292 let values = [5.0, 5.0, 5.0, 5.0];
293 assert_eq!(
294 assortativity(&g, &values, None, None, false, true).unwrap(),
295 None
296 );
297 }
298
299 #[test]
300 fn perfectly_assortative_two_components() {
301 let mut g = Graph::with_vertices(4);
304 g.add_edge(0, 1).unwrap();
305 g.add_edge(2, 3).unwrap();
306 let values = [1.0, 1.0, 9.0, 9.0];
307 let r = assortativity(&g, &values, None, None, false, true)
308 .unwrap()
309 .unwrap();
310 assert_close(r, 1.0, 1e-12);
311 }
312
313 #[test]
314 fn unnormalized_is_covariance_like() {
315 let mut g = Graph::with_vertices(3);
320 g.add_edge(0, 1).unwrap();
321 g.add_edge(1, 2).unwrap();
322 let values = [1.0, 2.0, 1.0];
323 let r = assortativity(&g, &values, None, None, false, false)
324 .unwrap()
325 .unwrap();
326 assert_close(r, -0.25, 1e-12);
327 }
328
329 #[test]
330 fn weights_reweight_edges() {
331 let mut g = Graph::with_vertices(3);
338 g.add_edge(0, 1).unwrap();
339 g.add_edge(1, 2).unwrap();
340 let values = [1.0, 2.0, 3.0];
341 let r_unw = assortativity(&g, &values, None, None, false, true)
342 .unwrap()
343 .unwrap();
344 assert_close(r_unw, 0.0, 1e-12);
345 let weights = [3.0, 1.0];
346 let r_w = assortativity(&g, &values, None, Some(&weights), false, true)
347 .unwrap()
348 .unwrap();
349 assert!(r_w.is_finite());
352 assert!((r_w - r_unw).abs() > 1e-9);
353 }
354
355 #[test]
356 fn directed_uses_source_and_target_values() {
357 let mut g = Graph::new(3, true).unwrap();
361 g.add_edge(0, 1).unwrap();
362 g.add_edge(1, 2).unwrap();
363 g.add_edge(0, 2).unwrap();
364 let out_values = [2.0, 1.0, 0.0];
365 let in_values = [0.0, 1.0, 2.0];
366 let r = assortativity(&g, &out_values, Some(&in_values), None, true, true)
367 .unwrap()
368 .unwrap();
369 assert_close(r, -0.5, 1e-12);
370 }
371
372 #[test]
373 fn directed_flag_ignored_on_undirected_graph() {
374 let mut g = Graph::with_vertices(3);
377 g.add_edge(0, 1).unwrap();
378 g.add_edge(1, 2).unwrap();
379 let values = [1.0, 2.0, 1.0];
380 let a = assortativity(&g, &values, None, None, false, true).unwrap();
381 let b = assortativity(&g, &values, None, None, true, true).unwrap();
382 assert_eq!(a, b);
383 }
384
385 #[test]
386 fn directed_without_values_in_reuses_values() {
387 let mut g = Graph::new(4, true).unwrap();
390 g.add_edge(0, 1).unwrap();
391 g.add_edge(1, 2).unwrap();
392 g.add_edge(2, 3).unwrap();
393 let values = [1.0, 2.0, 3.0, 4.0];
394 let r = assortativity(&g, &values, None, None, true, true).unwrap();
395 assert!(r.is_some());
396 }
397}