Skip to main content

rust_igraph/algorithms/properties/
assortativity_weighted.rs

1//! Weighted degree assortativity coefficient (ALGO-PR-006b).
2//!
3//! Counterpart of `igraph_assortativity_degree(_, _, /*directed=*/false,
4//! &weights)` from `references/igraph/src/misc/mixing.c`. Same Pearson
5//! framework as PR-006 but each vertex's "type" is its **strength**
6//! (weighted degree) and each edge's contribution is multiplied by
7//! its weight `w`:
8//!
9//! ```text
10//! For each edge e = (u, v) with weight w:
11//!   num1 += w * (s_u * s_v)
12//!   num2 += w * (s_u + s_v)
13//!   den1 += w * (s_u^2 + s_v^2)
14//! W = Σ w_i
15//! num1 /= W;  den1 /= 2W;  num2 /= 2W;  num2 *= num2
16//! r = (num1 - num2) / (den1 - num2)
17//! ```
18//!
19//! Returns `None` for graphs with no edges, zero total weight, or zero
20//! variance (matches upstream's `IGRAPH_NAN`). Phase-1 minimal slice:
21//! undirected only; directed weighted variant ships in PR-006c.
22//!
23//! Strength accounting for undirected self-loops follows
24//! `igraph_strength(_, _, _, IGRAPH_ALL, IGRAPH_LOOPS, &weights)` —
25//! a self-loop with weight `w` contributes `2w` to its vertex's
26//! strength.
27
28use crate::core::graph::EdgeId;
29use crate::core::{Graph, IgraphError, IgraphResult};
30
31/// Weighted degree assortativity coefficient.
32///
33/// `weights[e]` must be non-negative and finite; `weights.len()` must
34/// equal `graph.ecount()`. Returns `None` when the metric is undefined
35/// (no edges, zero total weight, or all-equal strength → zero
36/// variance). Directed graphs return [`IgraphError::Unsupported`].
37///
38/// # Examples
39///
40/// ```
41/// use rust_igraph::{Graph, assortativity_degree_weighted};
42///
43/// // Path 0-1-2 with unit weights → strengths [1, 2, 1]; r = -1.0
44/// // (matches the unweighted PR-006 result on the same graph).
45/// let mut g = Graph::with_vertices(3);
46/// g.add_edge(0, 1).unwrap();
47/// g.add_edge(1, 2).unwrap();
48/// let r = assortativity_degree_weighted(&g, &[1.0, 1.0]).unwrap();
49/// assert!((r.unwrap() - (-1.0_f64)).abs() < 1e-12);
50/// ```
51pub fn assortativity_degree_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<Option<f64>> {
52    if graph.is_directed() {
53        return Err(IgraphError::Unsupported(
54            "directed weighted assortativity is PR-006c; not yet ported",
55        ));
56    }
57    let m = graph.ecount();
58    if m == 0 {
59        return Ok(None);
60    }
61    if weights.len() != m {
62        return Err(IgraphError::InvalidArgument(format!(
63            "weights vector size ({}) differs from edge count ({})",
64            weights.len(),
65            m
66        )));
67    }
68    for (e, &w) in weights.iter().enumerate() {
69        if w.is_nan() || w < 0.0 || !w.is_finite() {
70            return Err(IgraphError::InvalidArgument(format!(
71                "weight at edge {e} must be non-negative and finite (got {w})"
72            )));
73        }
74    }
75
76    // Per-vertex strength: sum of incident-edge weights, with self-loops
77    // contributing 2*w to match upstream's LOOPS_TWICE convention.
78    let n = graph.vcount();
79    let n_us = n as usize;
80    let mut strength = vec![0.0_f64; n_us];
81
82    let m_u = u32::try_from(m).map_err(|_| IgraphError::Internal("ecount overflows u32"))?;
83    for e in 0..m_u {
84        let (u, v) = graph.edge(e as EdgeId)?;
85        let edge_w = weights[e as usize];
86        if u == v {
87            strength[u as usize] += 2.0 * edge_w;
88        } else {
89            strength[u as usize] += edge_w;
90            strength[v as usize] += edge_w;
91        }
92    }
93
94    let mut num1 = 0.0_f64;
95    let mut num2 = 0.0_f64;
96    let mut den1 = 0.0_f64;
97    let mut total_w = 0.0_f64;
98
99    for e in 0..m_u {
100        let (u, v) = graph.edge(e as EdgeId)?;
101        let edge_w = weights[e as usize];
102        let su = strength[u as usize];
103        let sv = strength[v as usize];
104        num1 += edge_w * (su * sv);
105        num2 += edge_w * (su + sv);
106        den1 += edge_w * (su * su + sv * sv);
107        total_w += edge_w;
108    }
109
110    if total_w == 0.0 {
111        return Ok(None);
112    }
113
114    num1 /= total_w;
115    den1 /= 2.0 * total_w;
116    num2 /= 2.0 * total_w;
117    num2 *= num2;
118
119    let denom = den1 - num2;
120    if denom == 0.0 {
121        return Ok(None);
122    }
123    Ok(Some((num1 - num2) / denom))
124}
125
126/// Directed weighted degree assortativity (PR-006d).
127///
128/// Counterpart of `igraph_assortativity_degree(_, _, /*directed=*/true,
129/// &weights)` from `references/igraph/src/misc/mixing.c:351-405`. Pearson
130/// correlation between out-strength of source and in-strength of target,
131/// each edge weighted by `w`:
132///
133/// ```text
134/// For each edge (u → v) with weight w:
135///   num1 += w * (s_out[u] * s_in[v])
136///   num2 += w * s_out[u]
137///   num3 += w * s_in[v]
138///   den1 += w * s_out[u]^2
139///   den2 += w * s_in[v]^2
140/// W = Σ w
141/// r = (num1 - num2*num3/W) / (sqrt(den1 - num2^2/W) * sqrt(den2 - num3^2/W))
142/// ```
143///
144/// Returns `None` for graphs with no edges, zero total weight, or zero
145/// variance (matches upstream's `IGRAPH_NAN`). Undirected graphs route
146/// to the symmetric formula via [`assortativity_degree_weighted`].
147///
148/// # Examples
149///
150/// ```
151/// use rust_igraph::{Graph, assortativity_degree_directed_weighted};
152///
153/// // Directed 3-cycle 0→1→2→0 with unit weights: every vertex has
154/// // out-strength 1 and in-strength 1, so both variance terms vanish.
155/// let mut g = Graph::new(3, true).unwrap();
156/// g.add_edge(0, 1).unwrap();
157/// g.add_edge(1, 2).unwrap();
158/// g.add_edge(2, 0).unwrap();
159/// assert_eq!(
160///     assortativity_degree_directed_weighted(&g, &[1.0, 1.0, 1.0]).unwrap(),
161///     None
162/// );
163/// ```
164pub fn assortativity_degree_directed_weighted(
165    graph: &Graph,
166    weights: &[f64],
167) -> IgraphResult<Option<f64>> {
168    if !graph.is_directed() {
169        return assortativity_degree_weighted(graph, weights);
170    }
171    let m = graph.ecount();
172    if m == 0 {
173        return Ok(None);
174    }
175    if weights.len() != m {
176        return Err(IgraphError::InvalidArgument(format!(
177            "weights vector size ({}) differs from edge count ({})",
178            weights.len(),
179            m
180        )));
181    }
182    for (e, &w) in weights.iter().enumerate() {
183        if w.is_nan() || w < 0.0 || !w.is_finite() {
184            return Err(IgraphError::InvalidArgument(format!(
185                "weight at edge {e} must be non-negative and finite (got {w})"
186            )));
187        }
188    }
189
190    let n = graph.vcount();
191    let n_us = n as usize;
192    let mut out_strength = vec![0.0_f64; n_us];
193    let mut in_strength = vec![0.0_f64; n_us];
194
195    let m_u = u32::try_from(m).map_err(|_| IgraphError::Internal("ecount overflows u32"))?;
196    for e in 0..m_u {
197        let (u, v) = graph.edge(e as EdgeId)?;
198        let edge_w = weights[e as usize];
199        out_strength[u as usize] += edge_w;
200        in_strength[v as usize] += edge_w;
201    }
202
203    let mut num1 = 0.0_f64;
204    let mut num2 = 0.0_f64;
205    let mut num3 = 0.0_f64;
206    let mut den1 = 0.0_f64;
207    let mut den2 = 0.0_f64;
208    let mut total_w = 0.0_f64;
209
210    for e in 0..m_u {
211        let (u, v) = graph.edge(e as EdgeId)?;
212        let edge_w = weights[e as usize];
213        let so = out_strength[u as usize];
214        let si = in_strength[v as usize];
215        num1 += edge_w * so * si;
216        num2 += edge_w * so;
217        num3 += edge_w * si;
218        den1 += edge_w * so * so;
219        den2 += edge_w * si * si;
220        total_w += edge_w;
221    }
222
223    if total_w == 0.0 {
224        return Ok(None);
225    }
226    let num = num1 - num2 * num3 / total_w;
227    let var_from = den1 - num2 * num2 / total_w;
228    let var_to = den2 - num3 * num3 / total_w;
229    if var_from <= 0.0 || var_to <= 0.0 {
230        return Ok(None);
231    }
232    let den = var_from.sqrt() * var_to.sqrt();
233    if den == 0.0 {
234        return Ok(None);
235    }
236    Ok(Some(num / den))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn close(a: f64, b: f64, tol: f64) {
244        assert!((a - b).abs() < tol, "{a} vs {b}");
245    }
246
247    #[test]
248    fn empty_graph_is_none() {
249        let g = Graph::with_vertices(0);
250        assert_eq!(assortativity_degree_weighted(&g, &[]).unwrap(), None);
251    }
252
253    #[test]
254    fn no_edges_is_none() {
255        let g = Graph::with_vertices(5);
256        assert_eq!(assortativity_degree_weighted(&g, &[]).unwrap(), None);
257    }
258
259    #[test]
260    fn unit_weights_match_unweighted_path_3() {
261        // Path 0-1-2 with unit weights → -1.0 (same as unweighted PR-006).
262        let mut g = Graph::with_vertices(3);
263        g.add_edge(0, 1).unwrap();
264        g.add_edge(1, 2).unwrap();
265        let r = assortativity_degree_weighted(&g, &[1.0, 1.0])
266            .unwrap()
267            .unwrap();
268        close(r, -1.0, 1e-12);
269    }
270
271    #[test]
272    fn unit_weights_match_unweighted_diamond() {
273        // K4 minus an edge → -2/3 (matches PR-006's diamond test).
274        let mut g = Graph::with_vertices(4);
275        for &(u, v) in &[(0u32, 1), (0, 2), (1, 2), (1, 3), (2, 3)] {
276            g.add_edge(u, v).unwrap();
277        }
278        let weights = vec![1.0; 5];
279        let r = assortativity_degree_weighted(&g, &weights)
280            .unwrap()
281            .unwrap();
282        close(r, -2.0 / 3.0, 1e-12);
283    }
284
285    #[test]
286    fn k4_regular_unit_weights_returns_none() {
287        // K4 every vertex same strength → variance zero → None.
288        let mut g = Graph::with_vertices(4);
289        for u in 0..4u32 {
290            for v in (u + 1)..4 {
291                g.add_edge(u, v).unwrap();
292            }
293        }
294        assert_eq!(assortativity_degree_weighted(&g, &[1.0; 6]).unwrap(), None);
295    }
296
297    #[test]
298    fn weighted_path_breaks_perfect_disassortativity() {
299        // Path 0-1-2 with weights (1, 4): strengths [1, 5, 4].
300        // Edge 0=(0,1) w=1: num1 += 1*(1*5)=5; num2 += 1*(1+5)=6;
301        //                   den1 += 1*(1+25)=26
302        // Edge 1=(1,2) w=4: num1 += 4*(5*4)=80; num2 += 4*(5+4)=36;
303        //                   den1 += 4*(25+16)=164
304        // W = 5; num1=85/5=17; num2=42/10=4.2; num2^2=17.64; den1=190/10=19
305        // r = (17 - 17.64) / (19 - 17.64) = -0.64 / 1.36 ≈ -0.470588...
306        let mut g = Graph::with_vertices(3);
307        g.add_edge(0, 1).unwrap();
308        g.add_edge(1, 2).unwrap();
309        let r = assortativity_degree_weighted(&g, &[1.0, 4.0])
310            .unwrap()
311            .unwrap();
312        close(r, -0.64 / 1.36, 1e-12);
313    }
314
315    #[test]
316    fn weights_size_mismatch_errors() {
317        let mut g = Graph::with_vertices(2);
318        g.add_edge(0, 1).unwrap();
319        assert!(assortativity_degree_weighted(&g, &[]).is_err());
320    }
321
322    #[test]
323    fn negative_weight_errors() {
324        let mut g = Graph::with_vertices(2);
325        g.add_edge(0, 1).unwrap();
326        assert!(assortativity_degree_weighted(&g, &[-1.0]).is_err());
327    }
328
329    #[test]
330    fn nan_weight_errors() {
331        let mut g = Graph::with_vertices(2);
332        g.add_edge(0, 1).unwrap();
333        assert!(assortativity_degree_weighted(&g, &[f64::NAN]).is_err());
334    }
335
336    #[test]
337    fn directed_returns_unsupported() {
338        let mut g = Graph::new(2, true).unwrap();
339        g.add_edge(0, 1).unwrap();
340        assert!(assortativity_degree_weighted(&g, &[1.0]).is_err());
341    }
342
343    #[test]
344    fn zero_total_weight_returns_none() {
345        let mut g = Graph::with_vertices(3);
346        g.add_edge(0, 1).unwrap();
347        g.add_edge(1, 2).unwrap();
348        // All weights zero → W = 0 → None (avoid division by zero).
349        assert_eq!(
350            assortativity_degree_weighted(&g, &[0.0, 0.0]).unwrap(),
351            None
352        );
353    }
354
355    // ---- PR-006d: Directed weighted assortativity ----------------
356
357    #[test]
358    fn directed_weighted_3_cycle_uniform_returns_none() {
359        let mut g = Graph::new(3, true).unwrap();
360        g.add_edge(0, 1).unwrap();
361        g.add_edge(1, 2).unwrap();
362        g.add_edge(2, 0).unwrap();
363        assert_eq!(
364            assortativity_degree_directed_weighted(&g, &[1.0, 1.0, 1.0]).unwrap(),
365            None
366        );
367    }
368
369    #[test]
370    fn directed_weighted_unit_weights_match_unweighted_directed() {
371        // Directed chain 0→1→2→3→4 with unit weights should match the
372        // unweighted directed assortativity (formula collapses identically).
373        let mut g = Graph::new(5, true).unwrap();
374        g.add_edge(0, 1).unwrap();
375        g.add_edge(1, 2).unwrap();
376        g.add_edge(2, 3).unwrap();
377        g.add_edge(3, 4).unwrap();
378        let unweighted =
379            crate::algorithms::properties::assortativity::assortativity_degree_directed(&g)
380                .unwrap();
381        let weighted = assortativity_degree_directed_weighted(&g, &[1.0; 4]).unwrap();
382        match (unweighted, weighted) {
383            (Some(u), Some(w)) => close(u, w, 1e-12),
384            (None, None) => {}
385            _ => panic!("disagreement between unweighted={unweighted:?} weighted={weighted:?}"),
386        }
387    }
388
389    #[test]
390    fn directed_weighted_undirected_routes_to_undirected_weighted() {
391        // For an undirected input, the directed function should behave
392        // exactly like the undirected weighted variant.
393        let mut g = Graph::with_vertices(3);
394        g.add_edge(0, 1).unwrap();
395        g.add_edge(1, 2).unwrap();
396        let r1 = assortativity_degree_directed_weighted(&g, &[1.0, 1.0]).unwrap();
397        let r2 = assortativity_degree_weighted(&g, &[1.0, 1.0]).unwrap();
398        assert_eq!(r1, r2);
399    }
400
401    #[test]
402    fn directed_weighted_empty_no_edges_is_none() {
403        let g = Graph::new(3, true).unwrap();
404        assert_eq!(
405            assortativity_degree_directed_weighted(&g, &[]).unwrap(),
406            None
407        );
408    }
409
410    #[test]
411    fn directed_weighted_negative_weight_errors() {
412        let mut g = Graph::new(2, true).unwrap();
413        g.add_edge(0, 1).unwrap();
414        assert!(assortativity_degree_directed_weighted(&g, &[-1.0]).is_err());
415    }
416
417    #[test]
418    fn directed_weighted_size_mismatch_errors() {
419        let mut g = Graph::new(2, true).unwrap();
420        g.add_edge(0, 1).unwrap();
421        assert!(assortativity_degree_directed_weighted(&g, &[]).is_err());
422        assert!(assortativity_degree_directed_weighted(&g, &[1.0, 2.0]).is_err());
423    }
424}