Skip to main content

rust_igraph/algorithms/properties/
closeness_weighted.rs

1//! Weighted closeness centrality (ALGO-PR-007b).
2//!
3//! Counterpart of `igraph_closeness(_, _, _, _, igraph_vss_all(),
4//! IGRAPH_OUT, &weights, /*normalized=*/true)` from
5//! `references/igraph/src/centrality/closeness.c`. The weighted
6//! variant uses Dijkstra (instead of BFS) for per-vertex shortest
7//! distances, then divides the count of reachable vertices by the
8//! sum of those distances.
9//!
10//! Phase-1 minimal slice: undirected (treated as `IGRAPH_ALL` via
11//! `Graph::neighbors`) / directed-OUT, normalized = true. Other modes
12//! (`IN`, weighted-non-normalized) ship later — same loop body, just
13//! changes which neighbour list is followed.
14//!
15//! Edge weights must be non-negative and finite; violations propagate
16//! the [`crate::IgraphError::InvalidArgument`] that `dijkstra_distances`
17//! raises.
18
19use crate::algorithms::paths::dijkstra::dijkstra_distances;
20use crate::core::{Graph, IgraphResult};
21
22/// Per-vertex weighted closeness centrality.
23///
24/// Returns `Vec<Option<f64>>`: `Some(reach / sum_dist)` if the vertex
25/// has at least one reachable neighbour with finite total distance,
26/// `None` for isolated vertices.
27///
28/// `weights[e]` is the weight of edge `e`; `weights.len()` must equal
29/// `graph.ecount()`. All weights must be `>= 0` and finite.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, closeness_weighted};
35///
36/// // Star with non-uniform weights.
37/// let mut g = Graph::with_vertices(4);
38/// g.add_edge(0, 1).unwrap();   // weight 1.0
39/// g.add_edge(0, 2).unwrap();   // weight 2.0
40/// g.add_edge(0, 3).unwrap();   // weight 3.0
41/// let c = closeness_weighted(&g, &[1.0, 2.0, 3.0]).unwrap();
42/// // Centre: 3 reachable, sum = 1+2+3 = 6 → 0.5.
43/// assert!((c[0].unwrap() - 0.5).abs() < 1e-12);
44/// ```
45pub fn closeness_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<Vec<Option<f64>>> {
46    let n = graph.vcount();
47    let n_us = n as usize;
48    let mut out = Vec::with_capacity(n_us);
49    for v in 0..n {
50        let d = dijkstra_distances(graph, v, weights)?;
51        let mut sum_dist: f64 = 0.0;
52        let mut reach: u64 = 0;
53        let v_us = v as usize;
54        for (target, val) in d.iter().enumerate() {
55            if target == v_us {
56                continue;
57            }
58            if let Some(dist) = val {
59                sum_dist += *dist;
60                reach += 1;
61            }
62        }
63        if reach == 0 || sum_dist == 0.0 {
64            // Isolated, or all reachable distances are zero (only possible
65            // with a degenerate graph carrying zero-weight edges to the
66            // source's own neighbours and nothing else). Match upstream's
67            // NaN by reporting None.
68            out.push(None);
69        } else {
70            #[allow(clippy::cast_precision_loss)]
71            let val = (reach as f64) / sum_dist;
72            out.push(Some(val));
73        }
74    }
75    Ok(out)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn approx_eq(a: Option<f64>, b: Option<f64>, tol: f64) {
83        match (a, b) {
84            (Some(x), Some(y)) => {
85                assert!((x - y).abs() < tol, "{x} vs {y}");
86            }
87            (None, None) => {}
88            _ => panic!("{a:?} vs {b:?}"),
89        }
90    }
91
92    #[test]
93    fn empty_graph_yields_empty() {
94        let g = Graph::with_vertices(0);
95        assert!(closeness_weighted(&g, &[]).unwrap().is_empty());
96    }
97
98    #[test]
99    fn isolated_vertices_all_none() {
100        let g = Graph::with_vertices(3);
101        let c = closeness_weighted(&g, &[]).unwrap();
102        assert_eq!(c, vec![None, None, None]);
103    }
104
105    #[test]
106    fn singleton_is_none() {
107        let g = Graph::with_vertices(1);
108        assert_eq!(closeness_weighted(&g, &[]).unwrap(), vec![None]);
109    }
110
111    #[test]
112    fn star_with_uniform_weights_matches_unweighted() {
113        let mut g = Graph::with_vertices(4);
114        for v in 1..4 {
115            g.add_edge(0, v).unwrap();
116        }
117        let c = closeness_weighted(&g, &[1.0, 1.0, 1.0]).unwrap();
118        // Centre: 3 reachable, sum=3 → 1.0.
119        approx_eq(c[0], Some(1.0), 1e-12);
120        // Leaves: 3 reachable (centre at 1, two leaves at 2 each), sum=5 → 0.6.
121        for &leaf_val in &c[1..4] {
122            approx_eq(leaf_val, Some(0.6), 1e-12);
123        }
124    }
125
126    #[test]
127    fn star_with_non_uniform_weights() {
128        let mut g = Graph::with_vertices(4);
129        g.add_edge(0, 1).unwrap();
130        g.add_edge(0, 2).unwrap();
131        g.add_edge(0, 3).unwrap();
132        let c = closeness_weighted(&g, &[1.0, 2.0, 3.0]).unwrap();
133        // Centre: sum=1+2+3=6 → 3/6 = 0.5.
134        approx_eq(c[0], Some(0.5), 1e-12);
135        // Leaf 1: paths to {0,2,3} cost {1, 1+2=3, 1+3=4} sum=8 → 3/8.
136        approx_eq(c[1], Some(3.0 / 8.0), 1e-12);
137        // Leaf 2: paths to {0,1,3} cost {2, 2+1=3, 2+3=5} sum=10 → 0.3.
138        approx_eq(c[2], Some(0.3), 1e-12);
139        // Leaf 3: paths to {0,1,2} cost {3, 3+1=4, 3+2=5} sum=12 → 0.25.
140        approx_eq(c[3], Some(0.25), 1e-12);
141    }
142
143    #[test]
144    fn directed_path_uses_out_edges() {
145        let mut g = Graph::new(3, true).unwrap();
146        g.add_edge(0, 1).unwrap();
147        g.add_edge(1, 2).unwrap();
148        let c = closeness_weighted(&g, &[1.0, 1.0]).unwrap();
149        // 0 reaches {1,2}, sum=1+2=3 → 2/3
150        approx_eq(c[0], Some(2.0 / 3.0), 1e-12);
151        // 1 reaches {2}, sum=1 → 1.0
152        approx_eq(c[1], Some(1.0), 1e-12);
153        // 2 reaches none → None
154        assert_eq!(c[2], None);
155    }
156
157    #[test]
158    fn disconnected_components_within_component_only() {
159        let mut g = Graph::with_vertices(4);
160        g.add_edge(0, 1).unwrap();
161        g.add_edge(1, 2).unwrap();
162        let c = closeness_weighted(&g, &[1.0, 1.0]).unwrap();
163        approx_eq(c[0], Some(2.0 / 3.0), 1e-12);
164        approx_eq(c[1], Some(1.0), 1e-12);
165        approx_eq(c[2], Some(2.0 / 3.0), 1e-12);
166        assert_eq!(c[3], None);
167    }
168
169    #[test]
170    fn unit_weights_match_unweighted() {
171        // Validate the equivalence to unweighted closeness on K3.
172        let mut g = Graph::with_vertices(3);
173        g.add_edge(0, 1).unwrap();
174        g.add_edge(1, 2).unwrap();
175        g.add_edge(2, 0).unwrap();
176        let cw = closeness_weighted(&g, &[1.0; 3]).unwrap();
177        let cu = crate::closeness(&g).unwrap();
178        for (a, b) in cw.iter().zip(cu.iter()) {
179            approx_eq(*a, *b, 1e-12);
180        }
181    }
182
183    #[test]
184    fn weights_size_mismatch_errors() {
185        let mut g = Graph::with_vertices(2);
186        g.add_edge(0, 1).unwrap();
187        assert!(closeness_weighted(&g, &[]).is_err());
188        assert!(closeness_weighted(&g, &[1.0, 2.0]).is_err());
189    }
190
191    #[test]
192    fn negative_weight_errors() {
193        let mut g = Graph::with_vertices(2);
194        g.add_edge(0, 1).unwrap();
195        assert!(closeness_weighted(&g, &[-1.0]).is_err());
196    }
197}