Skip to main content

rust_igraph/algorithms/properties/
hierarchy_ratios.rs

1//! Hierarchy-based ratio indices (ALGO-TR-120).
2//!
3//! Measures of hierarchical structure in graphs:
4//!
5//! - **Degree hierarchy** — Gini coefficient of the degree sequence,
6//!   measuring inequality in vertex importance
7//! - **Layer ratio** — fraction of vertices reachable at each BFS layer
8//!   from the highest-degree vertex, normalized by an ideal hierarchy
9//! - **Dominance ratio** — fraction of vertex pairs where one dominates
10//!   the other in the neighborhood inclusion order
11
12#![allow(
13    clippy::cast_lossless,
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::needless_range_loop,
18    clippy::similar_names,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphResult};
23
24/// Compute the degree hierarchy (Gini coefficient of degrees).
25///
26/// The Gini coefficient measures inequality in the degree distribution.
27/// Values near 0 indicate all vertices have similar degree (regular graph);
28/// values near 1 indicate extreme inequality (star-like). Returns 0.0
29/// for trivial or edgeless graphs.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, degree_hierarchy};
35///
36/// // K_4: all degrees equal → Gini = 0.0
37/// let g = Graph::from_edges(
38///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
39/// ).unwrap();
40/// assert!(degree_hierarchy(&g).unwrap().abs() < 1e-10);
41/// ```
42pub fn degree_hierarchy(graph: &Graph) -> IgraphResult<f64> {
43    let n = graph.vcount() as usize;
44    if n < 2 {
45        return Ok(0.0);
46    }
47
48    let mut degrees = Vec::with_capacity(n);
49    let mut sum = 0_u64;
50    for v in 0..n {
51        let d = graph.degree(v as u32)?;
52        degrees.push(d);
53        sum += d as u64;
54    }
55
56    if sum == 0 {
57        return Ok(0.0);
58    }
59
60    degrees.sort_unstable();
61
62    // Gini coefficient: (2 * sum_i(i * x_i)) / (n * sum_x) - (n + 1) / n
63    let mut weighted_sum = 0_u64;
64    for (i, &d) in degrees.iter().enumerate() {
65        weighted_sum += (i as u64 + 1) * d as u64;
66    }
67
68    let gini = (2.0 * weighted_sum as f64) / (n as f64 * sum as f64) - (n as f64 + 1.0) / n as f64;
69    Ok(gini.clamp(0.0, 1.0))
70}
71
72/// Compute the layer ratio.
73///
74/// BFS from the highest-degree vertex; measures how concentrated the
75/// graph is around a hub. Returns the ratio of the actual average layer
76/// depth to the maximum possible depth (n-1, for a path). Values near 0
77/// indicate a flat structure (star-like, all vertices near the hub);
78/// values near 1 indicate a deep, chain-like structure. Returns 0.0 for
79/// trivial or disconnected graphs.
80///
81/// # Examples
82///
83/// ```
84/// use rust_igraph::{Graph, layer_ratio};
85///
86/// // Star graph: all leaves at layer 1, avg_depth = 1, max = n-1 = 4
87/// // ratio = 1/4 = 0.25
88/// let g = Graph::from_edges(
89///     &[(0,1),(0,2),(0,3),(0,4)], false, Some(5)
90/// ).unwrap();
91/// let r = layer_ratio(&g).unwrap();
92/// assert!(r > 0.2 && r < 0.3);
93/// ```
94pub fn layer_ratio(graph: &Graph) -> IgraphResult<f64> {
95    let n = graph.vcount() as usize;
96    if n < 2 {
97        return Ok(0.0);
98    }
99
100    // Find highest-degree vertex
101    let mut max_deg = 0_usize;
102    let mut hub = 0_usize;
103    for v in 0..n {
104        let d = graph.degree(v as u32)?;
105        if d > max_deg {
106            max_deg = d;
107            hub = v;
108        }
109    }
110
111    if max_deg == 0 {
112        return Ok(0.0);
113    }
114
115    // BFS from hub
116    let mut dist = vec![u32::MAX; n];
117    dist[hub] = 0;
118    let mut queue = std::collections::VecDeque::new();
119    queue.push_back(hub);
120    let mut visit_count = 1_usize;
121    let mut depth_sum = 0_u64;
122
123    while let Some(v) = queue.pop_front() {
124        let nbrs = graph.neighbors(v as u32)?;
125        for &u in &nbrs {
126            let ui = u as usize;
127            if dist[ui] == u32::MAX {
128                dist[ui] = dist[v] + 1;
129                depth_sum += dist[ui] as u64;
130                visit_count += 1;
131                queue.push_back(ui);
132            }
133        }
134    }
135
136    if visit_count < n {
137        return Ok(0.0);
138    }
139
140    let avg_depth = depth_sum as f64 / (n - 1) as f64;
141    let max_depth = (n - 1) as f64;
142
143    Ok(avg_depth / max_depth)
144}
145
146/// Compute the dominance ratio.
147///
148/// The neighborhood inclusion order: vertex u dominates v if N(v) ⊆ N(u)∪{u}.
149/// The dominance ratio is the fraction of directed pairs (u,v) where u
150/// dominates v. Values near 0 indicate no dominance relationships
151/// (random-like); values near 1 indicate a strongly hierarchical structure.
152/// Returns 0.0 for trivial or edgeless graphs.
153///
154/// # Examples
155///
156/// ```
157/// use rust_igraph::{Graph, dominance_ratio};
158///
159/// // Star K_{1,3}: center dominates leaves, and each leaf dominates other leaves
160/// // (N(leaf_j)={center} ⊆ N(leaf_i)∪{leaf_i}), total 9/12 = 0.75
161/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3)], false, Some(4)).unwrap();
162/// let r = dominance_ratio(&g).unwrap();
163/// assert!((r - 0.75).abs() < 1e-10);
164/// ```
165pub fn dominance_ratio(graph: &Graph) -> IgraphResult<f64> {
166    let n = graph.vcount() as usize;
167    if n < 2 {
168        return Ok(0.0);
169    }
170
171    let m = graph.ecount();
172    if m == 0 {
173        return Ok(0.0);
174    }
175
176    // Build neighbor sets (including the vertex itself for the dominator)
177    let mut nbr_sets: Vec<Vec<bool>> = Vec::with_capacity(n);
178    for v in 0..n {
179        let mut set = vec![false; n];
180        let nbrs = graph.neighbors(v as u32)?;
181        for &u in &nbrs {
182            set[u as usize] = true;
183        }
184        set[v] = true; // N(v) ∪ {v}
185        nbr_sets.push(set);
186    }
187
188    let mut dominance_count = 0_u64;
189    let directed_pairs = (n * (n - 1)) as u64;
190
191    for u in 0..n {
192        for v in 0..n {
193            if u == v {
194                continue;
195            }
196            // Check if u dominates v: N(v)\{v} ⊆ N(u)∪{u}
197            let mut dominates = true;
198            let nbrs_v = graph.neighbors(v as u32)?;
199            for &w in &nbrs_v {
200                if !nbr_sets[u][w as usize] {
201                    dominates = false;
202                    break;
203                }
204            }
205            if dominates {
206                dominance_count += 1;
207            }
208        }
209    }
210
211    Ok(dominance_count as f64 / directed_pairs as f64)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn empty() -> Graph {
219        Graph::with_vertices(0)
220    }
221
222    fn single() -> Graph {
223        Graph::with_vertices(1)
224    }
225
226    fn single_edge() -> Graph {
227        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
228    }
229
230    fn path3() -> Graph {
231        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
232    }
233
234    fn path4() -> Graph {
235        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
236    }
237
238    fn k3() -> Graph {
239        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
240    }
241
242    fn k4() -> Graph {
243        Graph::from_edges(
244            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
245            false,
246            Some(4),
247        )
248        .unwrap()
249    }
250
251    fn cycle4() -> Graph {
252        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
253    }
254
255    fn star5() -> Graph {
256        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
257    }
258
259    fn paw() -> Graph {
260        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
261    }
262
263    // --- degree_hierarchy ---
264
265    #[test]
266    fn dh_empty() {
267        assert!(degree_hierarchy(&empty()).unwrap().abs() < 1e-10);
268    }
269
270    #[test]
271    fn dh_single() {
272        assert!(degree_hierarchy(&single()).unwrap().abs() < 1e-10);
273    }
274
275    #[test]
276    fn dh_k3() {
277        // Regular → Gini = 0
278        assert!(degree_hierarchy(&k3()).unwrap().abs() < 1e-10);
279    }
280
281    #[test]
282    fn dh_k4() {
283        assert!(degree_hierarchy(&k4()).unwrap().abs() < 1e-10);
284    }
285
286    #[test]
287    fn dh_cycle4() {
288        assert!(degree_hierarchy(&cycle4()).unwrap().abs() < 1e-10);
289    }
290
291    #[test]
292    fn dh_star5() {
293        // Degrees: 4,1,1,1,1 → non-zero Gini
294        let r = degree_hierarchy(&star5()).unwrap();
295        assert!(r > 0.1);
296    }
297
298    #[test]
299    fn dh_in_01() {
300        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
301            let r = degree_hierarchy(g).unwrap();
302            assert!(r >= -1e-10);
303            assert!(r <= 1.0 + 1e-10);
304        }
305    }
306
307    // --- layer_ratio ---
308
309    #[test]
310    fn lr_empty() {
311        assert!(layer_ratio(&empty()).unwrap().abs() < 1e-10);
312    }
313
314    #[test]
315    fn lr_single() {
316        assert!(layer_ratio(&single()).unwrap().abs() < 1e-10);
317    }
318
319    #[test]
320    fn lr_star5() {
321        // avg depth = 1, max = 4, ratio = 0.25
322        let r = layer_ratio(&star5()).unwrap();
323        assert!((r - 0.25).abs() < 1e-10);
324    }
325
326    #[test]
327    fn lr_path4() {
328        // Hub is an endpoint (deg 1 at 0 or 3, but center has deg 2)
329        // Actually hub is vertex with max degree = vertex 1 or 2 (deg 2)
330        // BFS from 1: depths 1,0,1,2 → sum=4, avg=4/3, max=3, ratio=4/9
331        let r = layer_ratio(&path4()).unwrap();
332        assert!(r > 0.3 && r < 0.6);
333    }
334
335    #[test]
336    fn lr_k4() {
337        // All at distance 1 from hub, avg=1, max=3, ratio=1/3
338        let r = layer_ratio(&k4()).unwrap();
339        assert!((r - 1.0 / 3.0).abs() < 1e-10);
340    }
341
342    #[test]
343    fn lr_in_01() {
344        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
345            let r = layer_ratio(g).unwrap();
346            assert!(r >= -1e-10);
347            assert!(r <= 1.0 + 1e-10);
348        }
349    }
350
351    // --- dominance_ratio ---
352
353    #[test]
354    fn dr_empty() {
355        assert!(dominance_ratio(&empty()).unwrap().abs() < 1e-10);
356    }
357
358    #[test]
359    fn dr_single() {
360        assert!(dominance_ratio(&single()).unwrap().abs() < 1e-10);
361    }
362
363    #[test]
364    fn dr_single_edge() {
365        // N(0)={1}, N(1)={0}. N(0)∪{0}={0,1}, N(1)∪{1}={0,1}
366        // 0 dominates 1: N(1)\{1}={0} ⊆ {0,1} ✓
367        // 1 dominates 0: N(0)\{0}={1} ⊆ {0,1} ✓
368        // 2/2 = 1.0
369        assert!((dominance_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
370    }
371
372    #[test]
373    fn dr_k3() {
374        // All vertices have same neighborhood structure → all dominate all
375        // Each pair: N(v)\{v}={other two} ⊆ N(u)∪{u}={all three} ✓
376        // 6/6 = 1.0
377        assert!((dominance_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
378    }
379
380    #[test]
381    fn dr_k4() {
382        assert!((dominance_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
383    }
384
385    #[test]
386    fn dr_star5() {
387        // Center (0): N(0)={1,2,3,4}, N(0)∪{0}={0,1,2,3,4}
388        // Leaf (i): N(i)={0}, N(i)∪{i}={0,i}
389        // 0 dominates leaf_i: N(leaf_i)\{leaf_i}={0} ⊆ {0,1,2,3,4} ✓ → 4 pairs
390        // leaf_i dominates 0: N(0)\{0}={1,2,3,4} ⊆ {0,i}? No → 0 pairs
391        // leaf_i dominates leaf_j: N(leaf_j)\{leaf_j}={0} ⊆ {0,i}? Yes! → 12 pairs
392        // Total: 4 + 12 = 16 out of 5*4 = 20
393        let r = dominance_ratio(&star5()).unwrap();
394        assert!((r - 16.0 / 20.0).abs() < 1e-10);
395    }
396
397    #[test]
398    fn dr_in_01() {
399        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5(), paw()] {
400            let r = dominance_ratio(g).unwrap();
401            assert!(r >= -1e-10);
402            assert!(r <= 1.0 + 1e-10);
403        }
404    }
405
406    // --- cross-consistency ---
407
408    #[test]
409    fn regular_zero_hierarchy() {
410        assert!(degree_hierarchy(&k3()).unwrap().abs() < 1e-10);
411        assert!(degree_hierarchy(&k4()).unwrap().abs() < 1e-10);
412        assert!(degree_hierarchy(&cycle4()).unwrap().abs() < 1e-10);
413    }
414
415    #[test]
416    fn complete_full_dominance() {
417        assert!((dominance_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
418        assert!((dominance_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
419    }
420
421    #[test]
422    fn star_hierarchy_measures() {
423        // Star should have high Gini and low layer ratio
424        let dh = degree_hierarchy(&star5()).unwrap();
425        let lr = layer_ratio(&star5()).unwrap();
426        assert!(dh > 0.3);
427        assert!(lr < 0.5);
428    }
429}