Skip to main content

rust_igraph/algorithms/properties/
convergence_degree.rs

1//! ALGO-PR-028 — convergence degree.
2//!
3//! [`convergence_degree`] computes a per-edge value in `[-1, 1]`
4//! (directed) or `[0, 1]` (undirected) that measures whether the
5//! shortest paths through the edge originate from a larger or smaller
6//! vertex set than they terminate in.
7//!
8//! - `In(e)` — number of source vertices `s` for which `e` lies on at
9//!   least one shortest path rooted at `s`.
10//! - `Out(e)` — number of vertices `t` for which `e` lies on at least
11//!   one shortest path terminating at `t`.
12//! - convergence = `(In - Out) / (In + Out)` (directed) or its absolute
13//!   value (undirected).
14//!
15//! Counterpart of `igraph_convergence_degree()` from
16//! `references/igraph/src/properties/convergence_degree.c`.
17
18use std::collections::VecDeque;
19
20use crate::core::graph::EdgeId;
21use crate::core::{Graph, IgraphResult, VertexId};
22
23/// Per-edge convergence degree.
24///
25/// Edges that lie on no shortest path between any source/sink pair
26/// produce `NaN` (matching upstream's `0/0` semantics).
27///
28/// Time complexity: O(|V|·(|V| + |E|)) — BFS per source vertex.
29///
30/// # Examples
31/// ```
32/// use rust_igraph::{Graph, convergence_degree};
33///
34/// // Directed star with one outgoing edge: 1→0, 2→0, 3→0, 4→0, 0→5.
35/// let mut g = Graph::new(6, true).unwrap();
36/// for u in [1, 2, 3, 4] { g.add_edge(u, 0).unwrap(); }
37/// g.add_edge(0, 5).unwrap();
38///
39/// let res = convergence_degree(&g).unwrap();
40/// // 0→5 sees four sources converging into one terminus → +2/3.
41/// assert!((res[4] - (4.0 / 6.0)).abs() < 1e-12);
42/// // Each leaf-to-hub edge has one source, two sinks → −1/3.
43/// for r in &res[..4] {
44///     assert!((r - (-1.0 / 3.0)).abs() < 1e-12);
45/// }
46/// ```
47pub fn convergence_degree(graph: &Graph) -> IgraphResult<Vec<f64>> {
48    let (result, _, _) = convergence_degree_full(graph)?;
49    Ok(result)
50}
51
52/// Convergence degree along with the per-edge `In(e)` / `Out(e)` counts.
53///
54/// All three returned vectors have length `graph.ecount()`.
55///
56/// # Examples
57/// ```
58/// use rust_igraph::{Graph, convergence_degree_full};
59///
60/// // Path 0—1—2—3 (undirected): the middle edge is touched by every
61/// // source/sink pair across it; the end edges are not.
62/// let mut g = Graph::with_vertices(4);
63/// for (u, v) in [(0, 1), (1, 2), (2, 3)] { g.add_edge(u, v).unwrap(); }
64/// let (_, ins, outs) = convergence_degree_full(&g).unwrap();
65/// for (i, o) in ins.iter().zip(outs.iter()) {
66///     assert!(i + o > 0.0);
67/// }
68/// ```
69pub fn convergence_degree_full(graph: &Graph) -> IgraphResult<(Vec<f64>, Vec<f64>, Vec<f64>)> {
70    let n = graph.vcount();
71    let m = graph.ecount();
72    let directed = graph.is_directed();
73
74    let mut ins: Vec<f64> = vec![0.0; m];
75    let mut outs: Vec<f64> = vec![0.0; m];
76
77    if n == 0 || m == 0 {
78        return Ok((vec![f64::NAN; m], ins, outs));
79    }
80
81    let n_us = n as usize;
82    let mut geodist: Vec<i64> = vec![0; n_us];
83    let mut queue: VecDeque<(VertexId, i64)> = VecDeque::new();
84
85    // Directed graphs need two passes: OUT-BFS feeds `ins`, IN-BFS feeds
86    // `outs`. Undirected graphs only need one pass; the orientation of
87    // every tree edge is decided by endpoint comparison.
88    let passes: &[bool] = if directed { &[true, false] } else { &[true] };
89
90    for &is_out_pass in passes {
91        for src in 0..n {
92            geodist.fill(0);
93            queue.clear();
94            geodist[src as usize] = 1;
95            queue.push_back((src, 0));
96
97            while let Some((actnode, actdist)) = queue.pop_front() {
98                let eids = if directed && !is_out_pass {
99                    graph.incident_in(actnode)?
100                } else {
101                    graph.incident(actnode)?
102                };
103                for &eid in &eids {
104                    let (a, b) = graph.edge(eid)?;
105                    let neighbor = if a == actnode { b } else { a };
106
107                    let nd = geodist[neighbor as usize];
108                    if nd != 0 {
109                        if nd - 1 == actdist + 1 {
110                            // Tree edge: same BFS layer, another shortest path.
111                            bump(
112                                directed,
113                                is_out_pass,
114                                actnode,
115                                neighbor,
116                                eid,
117                                &mut ins,
118                                &mut outs,
119                            );
120                        }
121                        // Otherwise the neighbour is at a strictly smaller
122                        // layer (already counted) or further along the
123                        // queue — neither contributes here.
124                    } else {
125                        geodist[neighbor as usize] = actdist + 2;
126                        queue.push_back((neighbor, actdist + 1));
127                        bump(
128                            directed,
129                            is_out_pass,
130                            actnode,
131                            neighbor,
132                            eid,
133                            &mut ins,
134                            &mut outs,
135                        );
136                    }
137                }
138            }
139        }
140    }
141
142    let mut result = vec![0.0_f64; m];
143    for i in 0..m {
144        let s = ins[i] + outs[i];
145        result[i] = if s > 0.0 {
146            (ins[i] - outs[i]) / s
147        } else {
148            f64::NAN
149        };
150    }
151    if !directed {
152        for r in &mut result {
153            if *r < 0.0 {
154                *r = -*r;
155            }
156        }
157    }
158
159    Ok((result, ins, outs))
160}
161
162#[inline]
163fn bump(
164    directed: bool,
165    is_out_pass: bool,
166    actnode: VertexId,
167    neighbor: VertexId,
168    eid: EdgeId,
169    ins: &mut [f64],
170    outs: &mut [f64],
171) {
172    let i = eid as usize;
173    if directed {
174        if is_out_pass {
175            ins[i] += 1.0;
176        } else {
177            outs[i] += 1.0;
178        }
179    } else if actnode < neighbor {
180        ins[i] += 1.0;
181    } else {
182        outs[i] += 1.0;
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
191        if a.is_nan() && b.is_nan() {
192            return true;
193        }
194        (a - b).abs() <= tol
195    }
196
197    fn approx_eq_vec(a: &[f64], b: &[f64], tol: f64) -> bool {
198        a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| approx_eq(*x, *y, tol))
199    }
200
201    #[test]
202    fn empty_graph_is_empty() {
203        let g = Graph::with_vertices(0);
204        let (r, i, o) = convergence_degree_full(&g).unwrap();
205        assert!(r.is_empty());
206        assert!(i.is_empty());
207        assert!(o.is_empty());
208    }
209
210    #[test]
211    fn no_edges_returns_empty_vectors() {
212        let g = Graph::with_vertices(5);
213        let res = convergence_degree(&g).unwrap();
214        assert!(res.is_empty());
215    }
216
217    #[test]
218    fn upstream_undirected_test_case_matches_out() {
219        // First test case from references/igraph/tests/unit/igraph_convergence_degree.c.
220        // Undirected graph with 7 vertices and edges:
221        //   (0,1), (0,2), (0,3), (1,2), (1,3), (2,3), (3,4), (4,5), (4,6), (5,6)
222        // Expected: 0.0000 0.0000 0.6000 0.0000 0.6000 0.6000 0.1429 0.6667 0.6667 0.0000
223        let mut g = Graph::with_vertices(7);
224        for (u, v) in [
225            (0, 1),
226            (0, 2),
227            (0, 3),
228            (1, 2),
229            (1, 3),
230            (2, 3),
231            (3, 4),
232            (4, 5),
233            (4, 6),
234            (5, 6),
235        ] {
236            g.add_edge(u, v).unwrap();
237        }
238        let res = convergence_degree(&g).unwrap();
239        let expected = [
240            0.0,
241            0.0,
242            0.6,
243            0.0,
244            0.6,
245            0.6,
246            1.0 / 7.0,
247            2.0 / 3.0,
248            2.0 / 3.0,
249            0.0,
250        ];
251        assert!(
252            approx_eq_vec(&res, &expected, 1e-4),
253            "got {res:?}, want {expected:?}"
254        );
255    }
256
257    #[test]
258    fn upstream_directed_test_case_matches_out() {
259        // Second test case: directed, 6 vertices, edges (1,0)(2,0)(3,0)(4,0)(0,5).
260        // Expected: -0.3333 -0.3333 -0.3333 -0.3333 0.6667
261        let mut g = Graph::new(6, true).unwrap();
262        for u in [1u32, 2, 3, 4] {
263            g.add_edge(u, 0).unwrap();
264        }
265        g.add_edge(0, 5).unwrap();
266        let res = convergence_degree(&g).unwrap();
267        let expected = [-1.0 / 3.0, -1.0 / 3.0, -1.0 / 3.0, -1.0 / 3.0, 2.0 / 3.0];
268        assert!(
269            approx_eq_vec(&res, &expected, 1e-12),
270            "got {res:?}, want {expected:?}"
271        );
272    }
273
274    #[test]
275    fn directed_full_returns_consistent_in_out() {
276        let mut g = Graph::new(6, true).unwrap();
277        for u in [1u32, 2, 3, 4] {
278            g.add_edge(u, 0).unwrap();
279        }
280        g.add_edge(0, 5).unwrap();
281        let (r, ins, outs) = convergence_degree_full(&g).unwrap();
282        // All edges except 0→5 have ins=1, outs=2.
283        for i in 0..4 {
284            assert!(approx_eq(ins[i], 1.0, 1e-12), "ins[{i}] = {}", ins[i]);
285            assert!(approx_eq(outs[i], 2.0, 1e-12), "outs[{i}] = {}", outs[i]);
286        }
287        // 0→5: ins counts the four leaves + 0 itself → 5; outs from 5 + 0 → 1.
288        assert!(approx_eq(ins[4], 5.0, 1e-12), "ins[4] = {}", ins[4]);
289        assert!(approx_eq(outs[4], 1.0, 1e-12), "outs[4] = {}", outs[4]);
290        // sanity: spot-check the result derivation.
291        for i in 0..r.len() {
292            let expected = (ins[i] - outs[i]) / (ins[i] + outs[i]);
293            assert!(approx_eq(r[i], expected, 1e-12));
294        }
295    }
296
297    #[test]
298    fn single_undirected_edge_balanced() {
299        let mut g = Graph::with_vertices(2);
300        g.add_edge(0, 1).unwrap();
301        let (r, ins, outs) = convergence_degree_full(&g).unwrap();
302        // BFS from 0 finds 1 via edge 0 (actnode=0 < neighbor=1) → ins += 1.
303        // BFS from 1 finds 0 via edge 0 (actnode=1 > neighbor=0) → outs += 1.
304        assert!(approx_eq(ins[0], 1.0, 1e-12));
305        assert!(approx_eq(outs[0], 1.0, 1e-12));
306        // (1-1)/(1+1) = 0; absolute value still 0.
307        assert!(approx_eq(r[0], 0.0, 1e-12));
308    }
309
310    #[test]
311    fn directed_single_edge() {
312        let mut g = Graph::new(2, true).unwrap();
313        g.add_edge(0, 1).unwrap();
314        let (r, ins, outs) = convergence_degree_full(&g).unwrap();
315        assert!(approx_eq(ins[0], 1.0, 1e-12));
316        assert!(approx_eq(outs[0], 1.0, 1e-12));
317        assert!(approx_eq(r[0], 0.0, 1e-12));
318    }
319
320    #[test]
321    fn undirected_self_loop_yields_nan() {
322        // BFS through a self-loop never produces a tree edge (the layer
323        // check fails), so ins+outs == 0, giving 0/0 → NaN.
324        let mut g = Graph::with_vertices(1);
325        g.add_edge(0, 0).unwrap();
326        let res = convergence_degree(&g).unwrap();
327        assert_eq!(res.len(), 1);
328        assert!(res[0].is_nan(), "expected NaN, got {}", res[0]);
329    }
330
331    #[test]
332    fn isolated_vertices_dont_break_things() {
333        // Path 0-1-2 + isolated vertices 3, 4.
334        let mut g = Graph::with_vertices(5);
335        g.add_edge(0, 1).unwrap();
336        g.add_edge(1, 2).unwrap();
337        let res = convergence_degree(&g).unwrap();
338        assert_eq!(res.len(), 2);
339        // Both edges land at |1/3| under the upstream (actnode <
340        // neighbor) orientation rule. The undirected pass takes
341        // absolute values so they're identical by symmetry.
342        let expected = 1.0 / 3.0;
343        for r in &res {
344            assert!(approx_eq(*r, expected, 1e-12), "got {r}, want {expected}");
345        }
346    }
347
348    #[test]
349    fn complete_undirected_is_symmetric() {
350        // K_4: every edge is symmetric → result == 0 for all.
351        let mut g = Graph::with_vertices(4);
352        for u in 0..4u32 {
353            for v in (u + 1)..4 {
354                g.add_edge(u, v).unwrap();
355            }
356        }
357        let res = convergence_degree(&g).unwrap();
358        for r in &res {
359            assert!(approx_eq(*r, 0.0, 1e-12), "K_4 edge gave {r}");
360        }
361    }
362
363    #[test]
364    fn star_directed_out_edges_have_negative_convergence() {
365        // 0 → 1, 0 → 2, 0 → 3 (directed star). Each edge: ins = 1 (just
366        // the hub), outs = 3 (every vertex needs the edge to reach a
367        // leaf in the IN-BFS).
368        // Actually let's just verify directional asymmetry shows up.
369        let mut g = Graph::new(4, true).unwrap();
370        for v in [1, 2, 3] {
371            g.add_edge(0, v).unwrap();
372        }
373        let (r, ins, outs) = convergence_degree_full(&g).unwrap();
374        // OUT pass: BFS from 0 → discover 1,2,3 via these edges → ins[i] = 1 each.
375        // BFS from any leaf → no out edges → no contribution.
376        for &x in &ins {
377            assert!(approx_eq(x, 1.0, 1e-12), "ins = {x}");
378        }
379        // IN pass: BFS from 1 going IN → 0 via reversed (0,1). No more in-edges.
380        // Same for 2, 3. BFS from 0 has no incoming. So outs[i] = 1 each.
381        for &x in &outs {
382            assert!(approx_eq(x, 1.0, 1e-12), "outs = {x}");
383        }
384        for &x in &r {
385            assert!(approx_eq(x, 0.0, 1e-12), "result = {x}");
386        }
387    }
388
389    #[test]
390    fn parallel_edges_share_credit() {
391        // Two parallel undirected edges (0,1). When the BFS-discovery
392        // sets geodist[1] for the first edge, the second edge falls in
393        // the "already seen but at the same layer" branch — i.e. it's
394        // also recognised as a shortest-path tree edge and bumps the
395        // counter. So both parallel edges get equal credit.
396        let mut g = Graph::with_vertices(2);
397        g.add_edge(0, 1).unwrap();
398        g.add_edge(0, 1).unwrap();
399        let (_r, ins, outs) = convergence_degree_full(&g).unwrap();
400        for i in 0..2 {
401            assert!(approx_eq(ins[i], 1.0, 1e-12), "ins[{i}] = {}", ins[i]);
402            assert!(approx_eq(outs[i], 1.0, 1e-12), "outs[{i}] = {}", outs[i]);
403        }
404    }
405
406    #[test]
407    fn result_length_matches_ecount() {
408        let mut g = Graph::with_vertices(5);
409        for (u, v) in [(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)] {
410            g.add_edge(u, v).unwrap();
411        }
412        let res = convergence_degree(&g).unwrap();
413        assert_eq!(res.len(), g.ecount());
414    }
415
416    #[test]
417    fn undirected_results_are_non_negative() {
418        // The undirected convention takes |x|, so every non-NaN result
419        // is in [0, 1].
420        let mut g = Graph::with_vertices(6);
421        for (u, v) in [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (4, 5)] {
422            g.add_edge(u, v).unwrap();
423        }
424        let res = convergence_degree(&g).unwrap();
425        for r in &res {
426            assert!(r.is_nan() || (*r >= -1e-12 && *r <= 1.0 + 1e-12), "got {r}");
427        }
428    }
429
430    #[test]
431    fn directed_results_within_unit_interval() {
432        let mut g = Graph::new(6, true).unwrap();
433        for (u, v) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)] {
434            g.add_edge(u, v).unwrap();
435        }
436        let res = convergence_degree(&g).unwrap();
437        for r in &res {
438            assert!(
439                r.is_nan() || (-1.0 - 1e-12 <= *r && *r <= 1.0 + 1e-12),
440                "got {r}"
441            );
442        }
443    }
444
445    #[test]
446    fn ins_plus_outs_strictly_positive_for_simple_path() {
447        // Path P_4: 0-1-2-3. Every edge is on the unique shortest path
448        // between every pair straddling it.
449        let mut g = Graph::with_vertices(4);
450        for (u, v) in [(0, 1), (1, 2), (2, 3)] {
451            g.add_edge(u, v).unwrap();
452        }
453        let (_, ins, outs) = convergence_degree_full(&g).unwrap();
454        for (i, o) in ins.iter().zip(outs.iter()) {
455            assert!(*i + *o > 0.0, "i + o = {} on path edge", i + o);
456        }
457    }
458
459    #[test]
460    fn balanced_directed_cycle_is_zero() {
461        // Directed cycle C_n: each edge sees exactly one "input" (its
462        // tail's reach) and one "output" (its head's reverse reach).
463        // Result should be 0 for all edges.
464        let mut g = Graph::new(5, true).unwrap();
465        for u in 0u32..5 {
466            g.add_edge(u, (u + 1) % 5).unwrap();
467        }
468        let res = convergence_degree(&g).unwrap();
469        // All values should be equal by symmetry.
470        let first = res[0];
471        for r in &res {
472            assert!(approx_eq(*r, first, 1e-12), "asymmetric: {res:?}");
473        }
474    }
475}