Skip to main content

rust_igraph/algorithms/properties/
basic.rs

1//! Basic graph metrics — density and mean shortest-path length (ALGO-PR-003).
2//!
3//! Counterparts of:
4//! - `igraph_density()`             from `references/igraph/src/properties/basic_properties.c:71`
5//! - `igraph_average_path_length()` from `references/igraph/src/paths/shortest_paths.c:329`
6//!
7//! Phase-1 minimal slice:
8//! - **Density** matches upstream's default `loops = false` mode:
9//!   `m / (n*(n-1)/2)` for undirected, `m / (n*(n-1))` for directed.
10//!   Self-loops are not subtracted; if the graph has self-loops the
11//!   result may exceed 1 (this matches upstream — caller's responsibility).
12//! - **Mean distance** matches upstream's `unconn = true` default —
13//!   unreachable pairs are skipped from the average. Returns `None`
14//!   when no connected pairs exist (graph too small or fully disconnected).
15
16use crate::algorithms::paths::distances::distances;
17use crate::core::{Graph, IgraphResult};
18
19/// Edge density of `graph`. Counterpart of
20/// `igraph_density(_, NULL_weights, _, /*loops=*/false)`.
21///
22/// Returns `None` when:
23/// - `vcount() == 0` (matches upstream's `IGRAPH_NAN`).
24/// - `vcount() == 1` and `loops=false` (no possible edges).
25///
26/// Self-loops in the input graph are *not* removed; they inflate the
27/// numerator. Use `simplify` (when it lands) to drop them first.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, density};
33///
34/// // K3 (triangle) on 3 vertices, undirected: 3 edges, max = 3 → 1.0.
35/// let mut g = Graph::with_vertices(3);
36/// g.add_edge(0, 1).unwrap();
37/// g.add_edge(1, 2).unwrap();
38/// g.add_edge(2, 0).unwrap();
39/// assert_eq!(density(&g).unwrap(), Some(1.0));
40/// ```
41pub fn density(graph: &Graph) -> IgraphResult<Option<f64>> {
42    let n = graph.vcount();
43    if n == 0 || n == 1 {
44        return Ok(None);
45    }
46    let m = graph.ecount();
47    let directed = graph.is_directed();
48
49    // Sub-1 worst case for n: u32::try_from(n) is fine since n is u32 by type.
50    let n_f = f64::from(n);
51    #[allow(clippy::cast_precision_loss)]
52    let m_f = m as f64;
53
54    // Match upstream's float ordering exactly so f64 results agree to
55    // the bit. See `igraph_density()` in
56    // `references/igraph/src/properties/basic_properties.c:99-103`.
57    let result = if directed {
58        m_f / n_f / (n_f - 1.0)
59    } else {
60        m_f / n_f * 2.0 / (n_f - 1.0)
61    };
62    Ok(Some(result))
63}
64
65/// Mean unweighted shortest-path length over all reachable ordered pairs.
66/// Counterpart of `igraph_average_path_length(_, NULL_weights, _, _, /*directed=*/true, /*unconn=*/true)`.
67///
68/// Returns `None` when no connected pairs exist (e.g. `vcount() < 2`,
69/// or all pairs are unreachable). Edge directions matter for directed
70/// graphs (uses `distances` which follows out-edges).
71///
72/// # Examples
73///
74/// ```
75/// use rust_igraph::{Graph, mean_distance};
76///
77/// // Path 0-1-2-3-4: ordered pairs at distance 1, 2, 3, 4 (4 each direction
78/// // → 8, 6, 4, 2 contributions); 20 pairs total. Mean = 40/20 = 2.0.
79/// let mut g = Graph::with_vertices(5);
80/// for i in 0..4u32 { g.add_edge(i, i + 1).unwrap(); }
81/// assert_eq!(mean_distance(&g).unwrap(), Some(2.0));
82/// ```
83pub fn mean_distance(graph: &Graph) -> IgraphResult<Option<f64>> {
84    let n = graph.vcount();
85    if n < 2 {
86        return Ok(None);
87    }
88    let mut sum: u64 = 0;
89    let mut count: u64 = 0;
90    for v in 0..n {
91        let d = distances(graph, v)?;
92        let v_us = v as usize;
93        for (target, &val) in d.iter().enumerate() {
94            if target == v_us {
95                continue;
96            }
97            if let Some(dist) = val {
98                sum += u64::from(dist);
99                count += 1;
100            }
101        }
102    }
103    if count == 0 {
104        return Ok(None);
105    }
106    #[allow(clippy::cast_precision_loss)]
107    let mean = (sum as f64) / (count as f64);
108    Ok(Some(mean))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn density_empty_graph_is_none() {
117        let g = Graph::with_vertices(0);
118        assert_eq!(density(&g).unwrap(), None);
119    }
120
121    #[test]
122    fn density_singleton_is_none() {
123        let g = Graph::with_vertices(1);
124        assert_eq!(density(&g).unwrap(), None);
125    }
126
127    #[test]
128    fn density_complete_undirected_is_one() {
129        let mut g = Graph::with_vertices(4);
130        for u in 0..4u32 {
131            for v in (u + 1)..4 {
132                g.add_edge(u, v).unwrap();
133            }
134        }
135        assert_eq!(density(&g).unwrap(), Some(1.0));
136    }
137
138    #[test]
139    fn density_no_edges_is_zero() {
140        let g = Graph::with_vertices(5);
141        assert_eq!(density(&g).unwrap(), Some(0.0));
142    }
143
144    #[test]
145    fn density_directed_complete_is_one() {
146        // Directed complete graph (no self-loops): n*(n-1) edges = max.
147        let mut g = Graph::new(3, true).unwrap();
148        for u in 0..3u32 {
149            for v in 0..3u32 {
150                if u != v {
151                    g.add_edge(u, v).unwrap();
152                }
153            }
154        }
155        assert_eq!(density(&g).unwrap(), Some(1.0));
156    }
157
158    #[test]
159    fn density_path_5_is_2_over_10() {
160        // 4 edges among C(5,2)=10 possible → 0.4.
161        let mut g = Graph::with_vertices(5);
162        for i in 0..4 {
163            g.add_edge(i, i + 1).unwrap();
164        }
165        assert_eq!(density(&g).unwrap(), Some(0.4));
166    }
167
168    #[test]
169    fn mean_distance_n_lt_2_is_none() {
170        let g = Graph::with_vertices(0);
171        assert_eq!(mean_distance(&g).unwrap(), None);
172        let g = Graph::with_vertices(1);
173        assert_eq!(mean_distance(&g).unwrap(), None);
174    }
175
176    #[test]
177    fn mean_distance_isolated_vertices_is_none() {
178        let g = Graph::with_vertices(5);
179        assert_eq!(mean_distance(&g).unwrap(), None);
180    }
181
182    #[test]
183    fn mean_distance_path_5_is_2() {
184        let mut g = Graph::with_vertices(5);
185        for i in 0..4 {
186            g.add_edge(i, i + 1).unwrap();
187        }
188        assert_eq!(mean_distance(&g).unwrap(), Some(2.0));
189    }
190
191    #[test]
192    fn mean_distance_triangle_is_1() {
193        let mut g = Graph::with_vertices(3);
194        g.add_edge(0, 1).unwrap();
195        g.add_edge(1, 2).unwrap();
196        g.add_edge(2, 0).unwrap();
197        assert_eq!(mean_distance(&g).unwrap(), Some(1.0));
198    }
199
200    #[test]
201    fn mean_distance_two_components_only_within_components() {
202        // {0-1-2} and {3-4}: connected pairs are within each component.
203        // {0-1-2}: 6 ordered pairs (0↔1=1, 0↔2=2, 1↔2=1) → sum 2*(1+2+1) = 8.
204        // {3-4}: 2 ordered pairs (3↔4) → sum 2.
205        // Total: 8 connected pairs, sum 10, mean = 10/8 = 1.25.
206        let mut g = Graph::with_vertices(5);
207        g.add_edge(0, 1).unwrap();
208        g.add_edge(1, 2).unwrap();
209        g.add_edge(3, 4).unwrap();
210        assert_eq!(mean_distance(&g).unwrap(), Some(1.25));
211    }
212
213    #[test]
214    fn mean_distance_directed_uses_out_edges() {
215        // 0 -> 1 -> 2: only 3 ordered reachable pairs: (0,1)=1, (0,2)=2, (1,2)=1.
216        // Sum = 4, count = 3, mean = 4/3.
217        let mut g = Graph::new(3, true).unwrap();
218        g.add_edge(0, 1).unwrap();
219        g.add_edge(1, 2).unwrap();
220        let four_thirds = 4.0_f64 / 3.0;
221        assert_eq!(mean_distance(&g).unwrap(), Some(four_thirds));
222    }
223}