Skip to main content

rust_igraph/algorithms/properties/
balaban_index.rs

1//! Balaban J index (ALGO-TR-043).
2//!
3//! The **Balaban J index** is a distance-based topological index:
4//!
5//! `J(G) = m / (μ + 1) · Σ_{(u,v)∈E} 1/√(σ_u · σ_v)`
6//!
7//! where `m` = number of edges, `μ` = cyclomatic number = `m - n + c`
8//! (with `c` = number of connected components), and
9//! `σ_v = Σ_w d(v, w)` is the distance sum (transmission) of vertex `v`.
10//!
11//! Only vertices reachable from each other contribute to the distance sum.
12//! Isolated vertices have `σ = 0` and edges incident to them are skipped.
13
14#![allow(
15    clippy::cast_possible_truncation,
16    clippy::cast_precision_loss,
17    clippy::many_single_char_names,
18    clippy::needless_range_loop,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphResult};
23use std::collections::VecDeque;
24
25/// Compute the Balaban J index of a graph.
26///
27/// `J(G) = m / (μ + 1) · Σ_{(u,v)∈E} 1/√(σ_u · σ_v)`
28///
29/// Returns 0.0 for graphs with no edges.
30///
31/// # Examples
32///
33/// ```
34/// use rust_igraph::{Graph, balaban_j_index};
35///
36/// // Path 0-1-2: m=2, n=3, c=1, μ=0
37/// // σ_0=3, σ_1=2, σ_2=3
38/// // J = 2/1 · (1/√(3·2) + 1/√(2·3)) = 2 · 2/√6
39/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
40/// let j = balaban_j_index(&g).unwrap();
41/// assert!((j - 4.0 / 6.0_f64.sqrt()).abs() < 1e-10);
42/// ```
43pub fn balaban_j_index(graph: &Graph) -> IgraphResult<f64> {
44    let n = graph.vcount() as usize;
45    let m = graph.ecount();
46    if m == 0 || n == 0 {
47        return Ok(0.0);
48    }
49
50    let (dist, sigma) = compute_distances_and_sums(graph, n);
51    let components = count_components(&dist, n);
52
53    let mu = m + components - n;
54    let prefix = m as f64 / (mu as f64 + 1.0);
55
56    let mut sum = 0.0;
57    for (u, v) in graph.edges() {
58        let ui = u as usize;
59        let vi = v as usize;
60        if ui == vi {
61            continue;
62        }
63        let su = sigma[ui];
64        let sv = sigma[vi];
65        if su > 0.0 && sv > 0.0 {
66            sum += 1.0 / (su * sv).sqrt();
67        }
68    }
69
70    Ok(prefix * sum)
71}
72
73fn compute_distances_and_sums(graph: &Graph, n: usize) -> (Vec<u32>, Vec<f64>) {
74    let adj = build_adj_list(graph, n);
75    let mut dist = vec![u32::MAX; n * n];
76
77    for src in 0..n {
78        bfs_distances(&adj, n, src, &mut dist);
79    }
80
81    let mut sigma = vec![0.0_f64; n];
82    for v in 0..n {
83        let mut s = 0.0_f64;
84        for w in 0..n {
85            let d = dist[v * n + w];
86            if d != u32::MAX {
87                s += f64::from(d);
88            }
89        }
90        sigma[v] = s;
91    }
92
93    (dist, sigma)
94}
95
96fn count_components(dist: &[u32], n: usize) -> usize {
97    let mut visited = vec![false; n];
98    let mut components = 0_usize;
99    for v in 0..n {
100        if !visited[v] {
101            components += 1;
102            for w in 0..n {
103                if dist[v * n + w] != u32::MAX {
104                    visited[w] = true;
105                }
106            }
107        }
108    }
109    components
110}
111
112fn build_adj_list(graph: &Graph, n: usize) -> Vec<Vec<usize>> {
113    let mut adj = vec![Vec::new(); n];
114    for (u, v) in graph.edges() {
115        let ui = u as usize;
116        let vi = v as usize;
117        adj[ui].push(vi);
118        if !graph.is_directed() && ui != vi {
119            adj[vi].push(ui);
120        }
121    }
122    adj
123}
124
125fn bfs_distances(adj: &[Vec<usize>], n: usize, src: usize, dist: &mut [u32]) {
126    dist[src * n + src] = 0;
127    let mut queue = VecDeque::new();
128    queue.push_back(src);
129    while let Some(v) = queue.pop_front() {
130        let d = dist[src * n + v];
131        for &w in &adj[v] {
132            if dist[src * n + w] == u32::MAX {
133                dist[src * n + w] = d.saturating_add(1);
134                queue.push_back(w);
135            }
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn single_edge() -> Graph {
145        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
146    }
147
148    fn path3() -> Graph {
149        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
150    }
151
152    fn path4() -> Graph {
153        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
154    }
155
156    fn k3() -> Graph {
157        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
158    }
159
160    fn k4() -> Graph {
161        Graph::from_edges(
162            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
163            false,
164            Some(4),
165        )
166        .unwrap()
167    }
168
169    fn cycle4() -> Graph {
170        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
171    }
172
173    fn star5() -> Graph {
174        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
175    }
176
177    #[test]
178    fn bj_empty() {
179        let g = Graph::with_vertices(0);
180        assert!((balaban_j_index(&g).unwrap() - 0.0).abs() < 1e-10);
181    }
182
183    #[test]
184    fn bj_no_edges() {
185        let g = Graph::with_vertices(3);
186        assert!((balaban_j_index(&g).unwrap() - 0.0).abs() < 1e-10);
187    }
188
189    #[test]
190    fn bj_single_edge() {
191        // m=1, n=2, c=1, μ=0
192        // σ_0=1, σ_1=1
193        // J = 1/1 · 1/√(1·1) = 1
194        assert!((balaban_j_index(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
195    }
196
197    #[test]
198    fn bj_path3() {
199        // m=2, n=3, c=1, μ=0
200        // σ_0=0+1+2=3, σ_1=1+0+1=2, σ_2=2+1+0=3
201        // J = 2/1 · (1/√(3·2) + 1/√(2·3)) = 4/√6
202        let j = balaban_j_index(&path3()).unwrap();
203        assert!((j - 4.0 / 6.0_f64.sqrt()).abs() < 1e-10);
204    }
205
206    #[test]
207    fn bj_k3() {
208        // m=3, n=3, c=1, μ=1
209        // all σ=2
210        // J = 3/2 · 3·(1/√(2·2)) = 3/2 · 3/2 = 9/4 = 2.25
211        let j = balaban_j_index(&k3()).unwrap();
212        assert!((j - 2.25).abs() < 1e-10);
213    }
214
215    #[test]
216    fn bj_cycle4() {
217        // m=4, n=4, c=1, μ=1
218        // σ_i = 1+2+1 = 4 for all i
219        // J = 4/2 · 4·(1/√(4·4)) = 2 · 4 · 1/4 = 2
220        let j = balaban_j_index(&cycle4()).unwrap();
221        assert!((j - 2.0).abs() < 1e-10);
222    }
223
224    #[test]
225    fn bj_star5() {
226        // m=4, n=5, c=1, μ=0
227        // σ_0(center)=1+1+1+1=4, σ_i(leaf)=1+2+2+2=7
228        // J = 4/1 · 4·(1/√(4·7)) = 16/√28 = 16/(2√7)
229        let j = balaban_j_index(&star5()).unwrap();
230        let expected = 16.0 / (2.0 * 7.0_f64.sqrt());
231        assert!((j - expected).abs() < 1e-10);
232    }
233
234    #[test]
235    fn bj_k4() {
236        // m=6, n=4, c=1, μ=3
237        // all σ=3
238        // J = 6/4 · 6·(1/√(3·3)) = 6/4 · 6/3 = 6/4 · 2 = 3
239        let j = balaban_j_index(&k4()).unwrap();
240        assert!((j - 3.0).abs() < 1e-10);
241    }
242
243    #[test]
244    fn bj_path4() {
245        // m=3, n=4, c=1, μ=0
246        // σ_0=0+1+2+3=6, σ_1=1+0+1+2=4, σ_2=2+1+0+1=4, σ_3=3+2+1+0=6
247        // J = 3/1 · (1/√(6·4) + 1/√(4·4) + 1/√(4·6))
248        //   = 3 · (1/√24 + 1/4 + 1/√24)
249        //   = 3 · (2/√24 + 0.25)
250        let j = balaban_j_index(&path4()).unwrap();
251        let expected = 3.0 * (2.0 / 24.0_f64.sqrt() + 0.25);
252        assert!((j - expected).abs() < 1e-10);
253    }
254
255    #[test]
256    fn bj_positive_for_connected() {
257        for g in &[
258            single_edge(),
259            path3(),
260            path4(),
261            k3(),
262            k4(),
263            cycle4(),
264            star5(),
265        ] {
266            assert!(balaban_j_index(g).unwrap() > 0.0);
267        }
268    }
269
270    #[test]
271    fn bj_with_isolated() {
272        // Graph with an isolated vertex — should still work
273        let g = Graph::from_edges(&[(0, 1)], false, Some(3)).unwrap();
274        let j = balaban_j_index(&g).unwrap();
275        // m=1, n=3, c=2, μ=0
276        // σ_0=1, σ_1=1, σ_2=0
277        // J = 1/1 · 1/√(1·1) = 1
278        assert!((j - 1.0).abs() < 1e-10);
279    }
280
281    #[test]
282    fn bj_regular_graphs() {
283        // For r-regular graph: all σ equal, so J = m/(μ+1) · m/σ
284        // K3: σ=2 for all, m=3, μ=1 → J = 3/2 · 3/2 = 9/4
285        let j = balaban_j_index(&k3()).unwrap();
286        assert!((j - 2.25).abs() < 1e-10);
287    }
288
289    #[test]
290    fn bj_single_vertex() {
291        let g = Graph::with_vertices(1);
292        assert!((balaban_j_index(&g).unwrap() - 0.0).abs() < 1e-10);
293    }
294
295    #[test]
296    fn bj_cycle5() {
297        // m=5, n=5, c=1, μ=1
298        // σ_i = 1+2+2+1 = 6 for all i
299        // J = 5/2 · 5·(1/√(6·6)) = 5/2 · 5/6 = 25/12
300        let g =
301            Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)], false, Some(5)).unwrap();
302        let j = balaban_j_index(&g).unwrap();
303        assert!((j - 25.0 / 12.0).abs() < 1e-10);
304    }
305
306    #[test]
307    fn bj_cycle6() {
308        // m=6, n=6, c=1, μ=1
309        // σ_i = 1+2+3+3+2+1-3 = wait: 0+1+2+3+2+1 = 9 for all i
310        // J = 6/2 · 6·(1/√(9·9)) = 3 · 6/9 = 2
311        let g = Graph::from_edges(
312            &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)],
313            false,
314            Some(6),
315        )
316        .unwrap();
317        let j = balaban_j_index(&g).unwrap();
318        assert!((j - 2.0).abs() < 1e-10);
319    }
320
321    #[test]
322    fn bj_two_components() {
323        // Two disconnected edges: 0-1 and 2-3
324        // m=2, n=4, c=2, μ=0
325        // σ_0=1, σ_1=1, σ_2=1, σ_3=1
326        // J = 2/1 · (1/√(1·1) + 1/√(1·1)) = 2 · 2 = 4
327        let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
328        let j = balaban_j_index(&g).unwrap();
329        assert!((j - 4.0).abs() < 1e-10);
330    }
331
332    #[test]
333    fn bj_two_triangles() {
334        // Two disjoint K_3: 0-1-2 and 3-4-5
335        // m=6, n=6, c=2, μ=2
336        // σ_i = 2 for each (within component), σ across components = MAX → ignored
337        // J = 6/3 · 6·(1/√(2·2)) = 2 · 3 = 6
338        let g = Graph::from_edges(
339            &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5)],
340            false,
341            Some(6),
342        )
343        .unwrap();
344        let j = balaban_j_index(&g).unwrap();
345        assert!((j - 6.0).abs() < 1e-10);
346    }
347
348    #[test]
349    fn bj_path5() {
350        // m=4, n=5, c=1, μ=0
351        // σ_0=0+1+2+3+4=10, σ_1=1+0+1+2+3=7, σ_2=2+1+0+1+2=6
352        // σ_3=3+2+1+0+1=7, σ_4=4+3+2+1+0=10
353        // J = 4/1 · (1/√(10·7) + 1/√(7·6) + 1/√(6·7) + 1/√(7·10))
354        //   = 4 · (2/√70 + 2/√42)
355        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
356        let j = balaban_j_index(&g).unwrap();
357        let expected = 4.0 * (2.0 / 70.0_f64.sqrt() + 2.0 / 42.0_f64.sqrt());
358        assert!((j - expected).abs() < 1e-10);
359    }
360
361    #[test]
362    fn bj_k5() {
363        // m=10, n=5, c=1, μ=6
364        // all σ=4 (each vertex at distance 1 from 4 others)
365        // J = 10/7 · 10·(1/√(4·4)) = 10/7 · 10/4 = 100/28 = 25/7
366        let edges: Vec<(u32, u32)> = (0..5_u32)
367            .flat_map(|i| ((i + 1)..5).map(move |j| (i, j)))
368            .collect();
369        let g = Graph::from_edges(&edges, false, Some(5)).unwrap();
370        let j = balaban_j_index(&g).unwrap();
371        assert!((j - 25.0 / 7.0).abs() < 1e-10);
372    }
373
374    #[test]
375    fn bj_complete_bipartite_k23() {
376        // K_{2,3}: parts {0,1} and {2,3,4}, every vertex in one part
377        // connected to every vertex in the other.
378        // m=6, n=5, c=1, μ=2
379        // σ_0 = 0+1+1+1+1 = wait, distances:
380        // 0→1: 2 (through 2,3,or4), 0→2: 1, 0→3: 1, 0→4: 1
381        // so σ_0 = 2+1+1+1 = 5, σ_1 = 2+1+1+1 = 5
382        // σ_2 = 1+1+0+2+2 = 6, σ_3 = same = 6, σ_4 = same = 6
383        // wait let me recalculate: 2→3 dist = 2 (through 0 or 1)
384        // σ_2 = 1+1+0+2+2 = 6
385        // J = 6/3 · 6·(1/√(5·6)) = 2 · 6/√30 = 12/√30
386        let g = Graph::from_edges(
387            &[(0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (1, 4)],
388            false,
389            Some(5),
390        )
391        .unwrap();
392        let j = balaban_j_index(&g).unwrap();
393        let expected = 12.0 / 30.0_f64.sqrt();
394        assert!((j - expected).abs() < 1e-10);
395    }
396
397    #[test]
398    fn bj_monotone_with_edges() {
399        // Adding edges to a path should change J, but J stays positive
400        let p = path4();
401        let jp = balaban_j_index(&p).unwrap();
402        assert!(jp > 0.0);
403
404        // Add edge to make it a cycle
405        let c = cycle4();
406        let jc = balaban_j_index(&c).unwrap();
407        assert!(jc > 0.0);
408        assert!((jp - jc).abs() > 1e-12);
409    }
410
411    #[test]
412    fn bj_isomorphic_same_value() {
413        // Two isomorphic copies of K3 with different vertex labels should give same J
414        let g1 = Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap();
415        let g2 = Graph::from_edges(&[(0, 2), (2, 1), (0, 1)], false, Some(3)).unwrap();
416        let j1 = balaban_j_index(&g1).unwrap();
417        let j2 = balaban_j_index(&g2).unwrap();
418        assert!((j1 - j2).abs() < 1e-10);
419    }
420
421    #[test]
422    fn bj_regular_formula() {
423        // For r-regular connected graph: all σ equal, J = m/(μ+1) · m/σ
424        // Verify this formula holds for several regular graphs
425        for g in &[k3(), k4(), cycle4()] {
426            let n = g.vcount() as usize;
427            let m = g.ecount();
428            let j = balaban_j_index(g).unwrap();
429
430            let deg0 = g.degree(0).unwrap();
431            let sigma0: f64 = {
432                let (_, sigma) = compute_distances_and_sums(g, n);
433                sigma[0]
434            };
435            let components = {
436                let (dist, _) = compute_distances_and_sums(g, n);
437                count_components(&dist, n)
438            };
439            let mu = m + components - n;
440            let prefix = m as f64 / (mu as f64 + 1.0);
441            let expected = prefix * m as f64 / sigma0;
442
443            assert!(
444                (j - expected).abs() < 1e-10,
445                "Regular formula failed for {deg0}-regular graph: J={j}, expected={expected}"
446            );
447        }
448    }
449
450    #[test]
451    fn bj_diamond() {
452        // Diamond graph: K4 minus one edge, e.g., missing (2,3)
453        // 0-1, 0-2, 0-3, 1-2, 1-3 (5 edges)
454        // n=4, m=5, c=1, μ=2
455        // σ_0 = 1+1+1 = 3, σ_1 = 1+1+1 = 3
456        // σ_2 = 1+1+2 = 4, σ_3 = 1+1+2 = 4
457        // prefix = 5/3
458        // edges: (0,1):1/√(3·3)=1/3, (0,2):1/√(3·4)=1/√12,
459        //        (0,3):1/√(3·4)=1/√12, (1,2):1/√(3·4)=1/√12,
460        //        (1,3):1/√(3·4)=1/√12
461        // sum = 1/3 + 4/√12 = 1/3 + 4/(2√3) = 1/3 + 2/√3
462        let g =
463            Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3)], false, Some(4)).unwrap();
464        let j = balaban_j_index(&g).unwrap();
465        let expected = 5.0 / 3.0 * (1.0 / 3.0 + 2.0 / 3.0_f64.sqrt());
466        assert!((j - expected).abs() < 1e-10);
467    }
468}