Skip to main content

rust_igraph/algorithms/properties/
joint_degree_distribution.rs

1//! Joint degree distribution (ALGO-PR-045).
2//!
3//! Counterpart of `igraph_joint_degree_distribution()` in
4//! `references/igraph/src/misc/mixing.c:852-927`.
5//!
6//! Computes `P_ij`: the (possibly normalized) count of connected ordered
7//! vertex pairs having degrees i and j.
8
9use crate::algorithms::properties::degree::{DegreeMode, degree_sequence};
10use crate::core::{Graph, IgraphResult};
11
12/// Compute the joint degree distribution matrix.
13///
14/// Returns a matrix `P` where `P[i][j]` counts (or gives the probability of)
15/// a randomly chosen ordered pair of connected vertices having degrees `i`
16/// and `j`.
17///
18/// For undirected graphs, `from_mode` and `to_mode` are forced to
19/// [`DegreeMode::All`] and each edge contributes to both `P[d(u)][d(v)]`
20/// and `P[d(v)][d(u)]`.
21///
22/// For directed graphs with `directed_neighbors = true`, only `u → v` arcs
23/// are counted (incrementing `P[deg_from(u)][deg_to(v)]`). With
24/// `directed_neighbors = false`, each edge also contributes in reverse.
25///
26/// # Parameters
27///
28/// * `graph` — the input graph.
29/// * `from_mode` — degree mode for sources (Out/In/All). Ignored for
30///   undirected graphs.
31/// * `to_mode` — degree mode for targets. Ignored for undirected graphs.
32/// * `directed_neighbors` — whether to treat connections as directed.
33///   Ignored for undirected graphs.
34/// * `normalized` — if `true`, scale matrix so entries sum to 1.0.
35/// * `max_from_degree` — if `Some(k)`, clip row count to `k+1`.
36/// * `max_to_degree` — if `Some(k)`, clip column count to `k+1`.
37///
38/// # Examples
39///
40/// ```
41/// use rust_igraph::{Graph, joint_degree_distribution, DegreeMode};
42///
43/// let mut g = Graph::with_vertices(4);
44/// g.add_edge(0, 1).unwrap();
45/// g.add_edge(1, 2).unwrap();
46/// g.add_edge(2, 3).unwrap();
47/// // Path graph: degrees are [1, 2, 2, 1]
48/// let p = joint_degree_distribution(
49///     &g, DegreeMode::All, DegreeMode::All, false, false, None, None,
50/// ).unwrap();
51/// // P[1][2] counts edges connecting degree-1 and degree-2 vertices
52/// assert!(p[1][2] > 0.0);
53/// ```
54#[allow(clippy::too_many_arguments)]
55pub fn joint_degree_distribution(
56    graph: &Graph,
57    from_mode: DegreeMode,
58    to_mode: DegreeMode,
59    directed_neighbors: bool,
60    normalized: bool,
61    max_from_degree: Option<u32>,
62    max_to_degree: Option<u32>,
63) -> IgraphResult<Vec<Vec<f64>>> {
64    let vcount = graph.vcount();
65    let ecount = graph.ecount();
66
67    // For undirected graphs, force modes.
68    let (fm, tm, dir_neigh) = if graph.is_directed() {
69        (from_mode, to_mode, directed_neighbors)
70    } else {
71        (DegreeMode::All, DegreeMode::All, false)
72    };
73
74    // Compute degree vectors.
75    let deg_from = degree_sequence(graph, fm)?;
76    let deg_to = if std::mem::discriminant(&fm) == std::mem::discriminant(&tm) {
77        deg_from.clone()
78    } else {
79        degree_sequence(graph, tm)?
80    };
81
82    // Determine matrix dimensions.
83    let nrow = if let Some(max) = max_from_degree {
84        max as usize + 1
85    } else if vcount == 0 {
86        0
87    } else {
88        *deg_from.iter().max().unwrap_or(&0) as usize + 1
89    };
90
91    let ncol = if let Some(max) = max_to_degree {
92        max as usize + 1
93    } else if vcount == 0 {
94        0
95    } else {
96        *deg_to.iter().max().unwrap_or(&0) as usize + 1
97    };
98
99    let mut matrix = vec![vec![0.0_f64; ncol]; nrow];
100    let mut sum = 0.0_f64;
101
102    for eid in 0..ecount {
103        #[allow(clippy::cast_possible_truncation)]
104        let (src, tgt) = graph.edge(eid as u32)?;
105
106        let from_type = deg_from[src as usize] as usize;
107        let to_type = deg_to[tgt as usize] as usize;
108
109        if from_type < nrow && to_type < ncol {
110            matrix[from_type][to_type] += 1.0;
111            sum += 1.0;
112        }
113
114        if !dir_neigh {
115            let rev_from = deg_from[tgt as usize] as usize;
116            let rev_to = deg_to[src as usize] as usize;
117            if rev_from < nrow && rev_to < ncol {
118                matrix[rev_from][rev_to] += 1.0;
119                sum += 1.0;
120            }
121        }
122    }
123
124    if normalized && ecount > 0 && sum > 0.0 {
125        let inv = 1.0 / sum;
126        for row in &mut matrix {
127            for val in row.iter_mut() {
128                *val *= inv;
129            }
130        }
131    }
132
133    Ok(matrix)
134}
135
136#[cfg(test)]
137#[allow(clippy::float_cmp)]
138mod tests {
139    use super::*;
140    use crate::core::Graph;
141
142    #[test]
143    fn empty_graph() {
144        let g = Graph::new(0, false).unwrap();
145        let p = joint_degree_distribution(
146            &g,
147            DegreeMode::All,
148            DegreeMode::All,
149            false,
150            false,
151            None,
152            None,
153        )
154        .unwrap();
155        assert!(p.is_empty());
156    }
157
158    #[test]
159    fn no_edges() {
160        let g = Graph::new(5, false).unwrap();
161        let p = joint_degree_distribution(
162            &g,
163            DegreeMode::All,
164            DegreeMode::All,
165            false,
166            false,
167            None,
168            None,
169        )
170        .unwrap();
171        // Max degree is 0, so matrix is 1×1 with value 0.
172        assert_eq!(p.len(), 1);
173        assert_eq!(p[0][0], 0.0);
174    }
175
176    #[test]
177    fn single_edge_undirected() {
178        let mut g = Graph::new(2, false).unwrap();
179        g.add_edges(vec![(0, 1)]).unwrap();
180        let p = joint_degree_distribution(
181            &g,
182            DegreeMode::All,
183            DegreeMode::All,
184            false,
185            false,
186            None,
187            None,
188        )
189        .unwrap();
190        // Both vertices have degree 1. Undirected → counts both directions.
191        assert_eq!(p.len(), 2); // rows 0..1
192        assert_eq!(p[1][1], 2.0); // (deg1→deg1) counted twice
193    }
194
195    #[test]
196    fn single_edge_normalized() {
197        let mut g = Graph::new(2, false).unwrap();
198        g.add_edges(vec![(0, 1)]).unwrap();
199        let p = joint_degree_distribution(
200            &g,
201            DegreeMode::All,
202            DegreeMode::All,
203            false,
204            true,
205            None,
206            None,
207        )
208        .unwrap();
209        assert_eq!(p[1][1], 1.0); // normalized: all mass at (1,1)
210    }
211
212    #[test]
213    fn path_graph() {
214        // 0-1-2-3: degrees [1,2,2,1]
215        let mut g = Graph::new(4, false).unwrap();
216        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
217        let p = joint_degree_distribution(
218            &g,
219            DegreeMode::All,
220            DegreeMode::All,
221            false,
222            false,
223            None,
224            None,
225        )
226        .unwrap();
227        // Edge (0,1): forward p[1][2]+=1, reverse p[2][1]+=1
228        // Edge (1,2): forward p[2][2]+=1, reverse p[2][2]+=1
229        // Edge (2,3): forward p[2][1]+=1, reverse p[1][2]+=1
230        assert_eq!(p[1][2], 2.0);
231        assert_eq!(p[2][1], 2.0);
232        assert_eq!(p[2][2], 2.0);
233    }
234
235    #[test]
236    fn directed_out_in() {
237        // 0→1→2: out-degrees [1,1,0], in-degrees [0,1,1]
238        let mut g = Graph::new(3, true).unwrap();
239        g.add_edges(vec![(0, 1), (1, 2)]).unwrap();
240        let p =
241            joint_degree_distribution(&g, DegreeMode::Out, DegreeMode::In, true, false, None, None)
242                .unwrap();
243        // Edge 0→1: from_deg=out(0)=1, to_deg=in(1)=1 → p[1][1] += 1
244        // Edge 1→2: from_deg=out(1)=1, to_deg=in(2)=1 → p[1][1] += 1
245        assert_eq!(p[1][1], 2.0);
246    }
247
248    #[test]
249    fn max_degree_clips() {
250        let mut g = Graph::new(4, false).unwrap();
251        g.add_edges(vec![(0, 1), (1, 2), (2, 3)]).unwrap();
252        let p = joint_degree_distribution(
253            &g,
254            DegreeMode::All,
255            DegreeMode::All,
256            false,
257            false,
258            Some(1),
259            Some(1),
260        )
261        .unwrap();
262        // Matrix is 2×2 (indices 0..=1)
263        assert_eq!(p.len(), 2);
264        assert_eq!(p[0].len(), 2);
265        // Only p[1][1] from edges between deg-1 vertices: edges (0,1) and (2,3)
266        // where both endpoints have deg≤1... actually vertex 1 has deg 2 which is clipped
267        // edge (0,1): from_deg=1, to_deg=2 → 2 >= nrow=2, skipped in forward
268        //            reverse: from_deg=2, to_deg=1 → 2 >= nrow=2, skipped
269        // edge (1,2): both deg=2, both >= nrow=2, skipped both ways
270        // edge (2,3): from_deg=2, to_deg=1 → 2 >= nrow=2, skipped forward
271        //            reverse: from_deg=1, to_deg=2 → 2 >= ncol=2, skipped
272        // So p[1][1] = 0 — only deg-1-to-deg-1 direct connections exist (none in this graph)
273        assert_eq!(p[1][1], 0.0);
274    }
275
276    #[test]
277    fn triangle_undirected() {
278        let mut g = Graph::new(3, false).unwrap();
279        g.add_edges(vec![(0, 1), (1, 2), (2, 0)]).unwrap();
280        let p = joint_degree_distribution(
281            &g,
282            DegreeMode::All,
283            DegreeMode::All,
284            false,
285            false,
286            None,
287            None,
288        )
289        .unwrap();
290        // All vertices have degree 2. Each of 3 edges counted twice → p[2][2] = 6
291        assert_eq!(p[2][2], 6.0);
292    }
293
294    #[test]
295    fn triangle_normalized() {
296        let mut g = Graph::new(3, false).unwrap();
297        g.add_edges(vec![(0, 1), (1, 2), (2, 0)]).unwrap();
298        let p = joint_degree_distribution(
299            &g,
300            DegreeMode::All,
301            DegreeMode::All,
302            false,
303            true,
304            None,
305            None,
306        )
307        .unwrap();
308        assert_eq!(p[2][2], 1.0);
309    }
310
311    #[test]
312    fn directed_not_directed_neighbors() {
313        // 0→1: with directed_neighbors=false, counts both directions
314        let mut g = Graph::new(2, true).unwrap();
315        g.add_edges(vec![(0, 1)]).unwrap();
316        let p = joint_degree_distribution(
317            &g,
318            DegreeMode::Out,
319            DegreeMode::In,
320            false,
321            false,
322            None,
323            None,
324        )
325        .unwrap();
326        // out-degrees: [1, 0], in-degrees: [0, 1]
327        // Forward: p[out(0)=1][in(1)=1] += 1 → p[1][1] = 1
328        // Reverse: p[out(1)=0][in(0)=0] += 1 → p[0][0] = 1
329        assert_eq!(p[1][1], 1.0);
330        assert_eq!(p[0][0], 1.0);
331    }
332
333    #[test]
334    fn self_loop_undirected() {
335        let mut g = Graph::new(2, false).unwrap();
336        g.add_edges(vec![(0, 0), (0, 1)]).unwrap();
337        let p = joint_degree_distribution(
338            &g,
339            DegreeMode::All,
340            DegreeMode::All,
341            false,
342            false,
343            None,
344            None,
345        )
346        .unwrap();
347        // degrees: vertex 0 has degree 3 (loop=2 + edge=1), vertex 1 has degree 1
348        // edge (0,0): both endpoints are vertex 0 with deg 3
349        //   forward: p[3][3] += 1, reverse: p[3][3] += 1 → 2
350        // edge (0,1): deg 3 and deg 1
351        //   forward: p[3][1] += 1, reverse: p[1][3] += 1
352        assert_eq!(p[3][3], 2.0);
353        assert_eq!(p[3][1], 1.0);
354        assert_eq!(p[1][3], 1.0);
355    }
356}