Skip to main content

rust_igraph/algorithms/paths/
histogram.rs

1//! All-pairs shortest path length histogram (ALGO-SP-012).
2//!
3//! Counterpart of `igraph_path_length_hist` from
4//! `references/igraph/src/paths/histogram.c` (141 lines).
5//!
6//! BFS from every vertex, counting vertex pairs by shortest-path
7//! distance. Directed graphs can be treated either directed
8//! (`IGRAPH_OUT` mode) or undirected (`IGRAPH_ALL` mode). For
9//! undirected treatment each unordered pair is counted once; for
10//! directed treatment each ordered pair is counted.
11
12use std::collections::VecDeque;
13
14use crate::core::{Graph, IgraphResult, VertexId};
15
16/// Result of [`path_length_hist`].
17///
18/// `hist[d]` is the number of vertex pairs whose shortest-path distance
19/// is `d + 1` (so `hist[0]` counts pairs at distance 1, `hist[1]` at
20/// distance 2, etc.). For undirected treatment each *unordered* pair is
21/// counted once; for directed each *ordered* pair.
22///
23/// `unconnected` is the number of (ordered or unordered, matching above)
24/// pairs for which no path exists.
25#[derive(Debug, Clone)]
26pub struct PathLengthHistResult {
27    /// `hist[d]` = count of vertex pairs at distance `d + 1`.
28    pub hist: Vec<f64>,
29    /// Number of vertex pairs with no connecting path.
30    pub unconnected: f64,
31}
32
33/// Compute a histogram of all shortest-path lengths.
34///
35/// For every vertex pair `(u, v)` the function determines the
36/// shortest-path length and adds it to the histogram. Self-loops and
37/// multi-edges are traversed by the BFS; self-pairs (distance 0) are
38/// *not* counted.
39///
40/// When `directed` is `false` (or the graph is undirected), both edge
41/// endpoints participate symmetrically and each *unordered* pair is
42/// counted once. When `directed` is `true` (on a directed graph),
43/// traversal follows out-edges and each *ordered* pair is counted.
44///
45/// # Examples
46///
47/// ```
48/// use rust_igraph::{Graph, path_length_hist};
49///
50/// // Undirected path 0-1-2.
51/// let mut g = Graph::with_vertices(3);
52/// g.add_edge(0, 1).unwrap();
53/// g.add_edge(1, 2).unwrap();
54/// let r = path_length_hist(&g, false).unwrap();
55/// // Pairs at distance 1: {0,1}, {1,2} → 2
56/// // Pairs at distance 2: {0,2}         → 1
57/// assert_eq!(r.hist, vec![2.0, 1.0]);
58/// assert_eq!(r.unconnected, 0.0);
59/// ```
60pub fn path_length_hist(graph: &Graph, directed: bool) -> IgraphResult<PathLengthHistResult> {
61    let n = graph.vcount();
62    let n_us = n as usize;
63
64    let effective_directed = directed && graph.is_directed();
65
66    if n == 0 {
67        return Ok(PathLengthHistResult {
68            hist: Vec::new(),
69            unconnected: 0.0,
70        });
71    }
72
73    let mut hist: Vec<f64> = Vec::new();
74    let mut unconnected: f64 = 0.0;
75
76    let mut visited: Vec<u32> = vec![0; n_us];
77    let mut queue: VecDeque<(VertexId, u32)> = VecDeque::new();
78
79    for i in 0..n {
80        let marker = i
81            .checked_add(1)
82            .ok_or(crate::core::IgraphError::Internal("vertex marker overflow"))?;
83        let mut nodes_reached: u32 = 1;
84
85        queue.clear();
86        queue.push_back((i, 0));
87        visited[i as usize] = marker;
88
89        while let Some((v, dist)) = queue.pop_front() {
90            let neis = neighbors_for_mode(graph, v, effective_directed)?;
91            for nb in neis {
92                if visited[nb as usize] == marker {
93                    continue;
94                }
95                visited[nb as usize] = marker;
96                nodes_reached = nodes_reached
97                    .checked_add(1)
98                    .ok_or(crate::core::IgraphError::Internal("nodes_reached overflow"))?;
99
100                let idx = dist as usize;
101                if idx >= hist.len() {
102                    hist.resize(idx + 1, 0.0);
103                }
104                hist[idx] += 1.0;
105
106                queue.push_back((
107                    nb,
108                    dist.checked_add(1)
109                        .ok_or(crate::core::IgraphError::Internal("BFS distance overflow"))?,
110                ));
111            }
112        }
113
114        unconnected += f64::from(n - nodes_reached);
115    }
116
117    if !effective_directed {
118        for h in &mut hist {
119            *h /= 2.0;
120        }
121        unconnected /= 2.0;
122    }
123
124    Ok(PathLengthHistResult { hist, unconnected })
125}
126
127fn neighbors_for_mode(graph: &Graph, v: VertexId, directed: bool) -> IgraphResult<Vec<VertexId>> {
128    if directed {
129        graph.out_neighbors_vec(v)
130    } else if graph.is_directed() {
131        let mut out = graph.out_neighbors_vec(v)?;
132        let in_neis = graph.in_neighbors_vec(v)?;
133        out.extend(in_neis);
134        Ok(out)
135    } else {
136        graph.neighbors(v)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::create;
144
145    fn approx_eq(a: f64, b: f64) -> bool {
146        (a - b).abs() < 1e-9
147    }
148
149    #[test]
150    fn empty_graph() {
151        let g = Graph::with_vertices(0);
152        let r = path_length_hist(&g, false).expect("ok");
153        assert!(r.hist.is_empty());
154        assert!(approx_eq(r.unconnected, 0.0));
155    }
156
157    #[test]
158    fn single_vertex() {
159        let g = Graph::with_vertices(1);
160        let r = path_length_hist(&g, false).expect("ok");
161        assert!(r.hist.is_empty());
162        assert!(approx_eq(r.unconnected, 0.0));
163    }
164
165    #[test]
166    fn two_isolated_vertices() {
167        let g = Graph::with_vertices(2);
168        let r = path_length_hist(&g, false).expect("ok");
169        assert!(r.hist.is_empty());
170        assert!(approx_eq(r.unconnected, 1.0));
171    }
172
173    #[test]
174    fn single_edge_undirected() {
175        let g = create(&[(0, 1)], 2, false).expect("ok");
176        let r = path_length_hist(&g, false).expect("ok");
177        assert_eq!(r.hist, vec![1.0]);
178        assert!(approx_eq(r.unconnected, 0.0));
179    }
180
181    #[test]
182    fn path_3_undirected() {
183        // 0-1-2: pairs at dist 1 = 2 ({0,1},{1,2}); dist 2 = 1 ({0,2})
184        let g = create(&[(0, 1), (1, 2)], 3, false).expect("ok");
185        let r = path_length_hist(&g, false).expect("ok");
186        assert_eq!(r.hist, vec![2.0, 1.0]);
187        assert!(approx_eq(r.unconnected, 0.0));
188    }
189
190    #[test]
191    fn triangle_undirected() {
192        // 0-1, 1-2, 0-2: all pairs at distance 1
193        let g = create(&[(0, 1), (1, 2), (0, 2)], 3, false).expect("ok");
194        let r = path_length_hist(&g, false).expect("ok");
195        assert_eq!(r.hist, vec![3.0]);
196        assert!(approx_eq(r.unconnected, 0.0));
197    }
198
199    #[test]
200    fn star_4_undirected() {
201        // 0-1, 0-2, 0-3: dist 1 = 3 ({0,1},{0,2},{0,3}); dist 2 = 3
202        // ({1,2},{1,3},{2,3})
203        let g = create(&[(0, 1), (0, 2), (0, 3)], 4, false).expect("ok");
204        let r = path_length_hist(&g, false).expect("ok");
205        assert_eq!(r.hist, vec![3.0, 3.0]);
206        assert!(approx_eq(r.unconnected, 0.0));
207    }
208
209    #[test]
210    fn disconnected_components_undirected() {
211        // {0-1} + {2-3}: 2 pairs connected at dist 1, 4 unconnected pairs
212        let g = create(&[(0, 1), (2, 3)], 4, false).expect("ok");
213        let r = path_length_hist(&g, false).expect("ok");
214        assert_eq!(r.hist, vec![2.0]);
215        assert!(approx_eq(r.unconnected, 4.0));
216    }
217
218    #[test]
219    fn directed_path_out_mode() {
220        // 0->1->2 directed
221        let g = create(&[(0, 1), (1, 2)], 3, true).expect("ok");
222        let r = path_length_hist(&g, true).expect("ok");
223        // Ordered pairs reachable: (0,1)=1, (0,2)=2, (1,2)=1
224        // hist[0] (dist 1) = 2, hist[1] (dist 2) = 1
225        assert_eq!(r.hist, vec![2.0, 1.0]);
226        // Unreachable ordered pairs: (1,0), (2,0), (2,1) = 3
227        assert!(approx_eq(r.unconnected, 3.0));
228    }
229
230    #[test]
231    fn directed_path_undirected_mode() {
232        // 0->1->2 directed but treated as undirected
233        let g = create(&[(0, 1), (1, 2)], 3, true).expect("ok");
234        let r = path_length_hist(&g, false).expect("ok");
235        // Same as undirected path 0-1-2
236        assert_eq!(r.hist, vec![2.0, 1.0]);
237        assert!(approx_eq(r.unconnected, 0.0));
238    }
239
240    #[test]
241    fn directed_cycle() {
242        // 0->1->2->0
243        let g = create(&[(0, 1), (1, 2), (2, 0)], 3, true).expect("ok");
244        let r = path_length_hist(&g, true).expect("ok");
245        // Every ordered pair reachable:
246        // (0,1)=1, (1,2)=1, (2,0)=1, (0,2)=2, (1,0)=2, (2,1)=2
247        // hist[0] (dist 1) = 3, hist[1] (dist 2) = 3
248        assert_eq!(r.hist, vec![3.0, 3.0]);
249        assert!(approx_eq(r.unconnected, 0.0));
250    }
251
252    #[test]
253    fn self_loop_ignored_in_counting() {
254        let g = create(&[(0, 0), (0, 1)], 2, false).expect("ok");
255        let r = path_length_hist(&g, false).expect("ok");
256        assert_eq!(r.hist, vec![1.0]);
257        assert!(approx_eq(r.unconnected, 0.0));
258    }
259
260    #[test]
261    fn multi_edge_does_not_double_count() {
262        let g = create(&[(0, 1), (0, 1), (1, 2)], 3, false).expect("ok");
263        let r = path_length_hist(&g, false).expect("ok");
264        assert_eq!(r.hist, vec![2.0, 1.0]);
265        assert!(approx_eq(r.unconnected, 0.0));
266    }
267
268    #[test]
269    fn k4_undirected() {
270        // K4: all 6 pairs at distance 1
271        let g = create(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], 4, false).expect("ok");
272        let r = path_length_hist(&g, false).expect("ok");
273        assert_eq!(r.hist, vec![6.0]);
274        assert!(approx_eq(r.unconnected, 0.0));
275    }
276}
277
278#[cfg(all(test, feature = "proptest-harness"))]
279mod proptests {
280    use super::*;
281    use crate::create;
282    use proptest::prelude::*;
283
284    fn arb_graph(max_v: u32) -> impl Strategy<Value = (Graph, bool)> {
285        (1..=max_v).prop_flat_map(|n| {
286            let max_edges = (n as usize).saturating_mul(n.saturating_sub(1) as usize);
287            let max_e = max_edges.min(20);
288            (
289                proptest::collection::vec((0..n, 0..n), 0..=max_e),
290                Just(n),
291                proptest::bool::ANY,
292            )
293                .prop_map(|(edges, n, directed)| {
294                    let edge_tuples: Vec<(u32, u32)> = edges.into_iter().collect();
295                    let g = create(&edge_tuples, n, directed).expect("arb graph creation");
296                    (g, directed)
297                })
298        })
299    }
300
301    proptest! {
302        #[test]
303        fn hist_counts_are_non_negative((g, directed) in arb_graph(10)) {
304            let r = path_length_hist(&g, directed).expect("ok");
305            for &h in &r.hist {
306                prop_assert!(h >= 0.0, "negative histogram count: {h}");
307            }
308            prop_assert!(r.unconnected >= 0.0);
309        }
310
311        #[test]
312        fn total_pairs_equals_expected((g, directed) in arb_graph(10)) {
313            let r = path_length_hist(&g, directed).expect("ok");
314            let n = g.vcount() as f64;
315            let total: f64 = r.hist.iter().sum::<f64>() + r.unconnected;
316            let effective_directed = directed && g.is_directed();
317            let expected = if effective_directed {
318                n * (n - 1.0)
319            } else {
320                n * (n - 1.0) / 2.0
321            };
322            prop_assert!(
323                (total - expected).abs() < 1e-9,
324                "total {total} != expected {expected}"
325            );
326        }
327
328        #[test]
329        fn undirected_mode_on_undirected_graph_halves_directed(
330            edges in proptest::collection::vec((0u32..8, 0u32..8), 0..=15)
331        ) {
332            let g = create(&edges, 8, false).expect("ok");
333            let r_undir = path_length_hist(&g, false).expect("ok");
334            let r_dir = path_length_hist(&g, true).expect("ok");
335            // For undirected graph, directed=true is forced to false internally,
336            // so results should be identical.
337            prop_assert_eq!(r_undir.hist.len(), r_dir.hist.len());
338            for (a, b) in r_undir.hist.iter().zip(r_dir.hist.iter()) {
339                prop_assert!((a - b).abs() < 1e-9);
340            }
341            prop_assert!((r_undir.unconnected - r_dir.unconnected).abs() < 1e-9);
342        }
343    }
344}