Skip to main content

rust_igraph/algorithms/properties/
sum_connectivity.rs

1//! Sum-connectivity, inverse sum indeg, and symmetric division deg (ALGO-TR-047).
2//!
3//! - **Sum-connectivity index** `SCI(G) = Σ_{(u,v)∈E} 1/√(d_u + d_v)`
4//!   Introduced by Zhou & Trinajstić (2009). Related to the Randić
5//!   index but uses degree sums instead of products.
6//! - **Inverse sum indeg index** `ISI(G) = Σ_{(u,v)∈E} (d_u·d_v)/(d_u+d_v)`
7//!   The inverse of the sum of reciprocals of endpoint degrees, a
8//!   descriptor with strong predictive power for total surface area.
9//! - **Symmetric division deg index** `SDD(G) = Σ_{(u,v)∈E} (d_u²+d_v²)/(d_u·d_v)`
10//!   Equivalently `d_u/d_v + d_v/d_u` per edge. Introduced by
11//!   Vukičević & Gašperov (2010).
12
13#![allow(
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::needless_range_loop,
18    clippy::too_many_lines
19)]
20
21use crate::core::{Graph, IgraphResult};
22
23/// Compute the sum-connectivity index.
24///
25/// `SCI(G) = Σ_{(u,v)∈E} 1/√(d_u + d_v)`
26///
27/// Self-loops are skipped. Edges where both endpoints have degree 0
28/// (impossible in practice) are skipped.
29///
30/// # Examples
31///
32/// ```
33/// use rust_igraph::{Graph, sum_connectivity_index};
34///
35/// // Path 0-1-2: degrees [1,2,1]
36/// // edge(0,1): 1/√3, edge(1,2): 1/√3
37/// // SCI = 2/√3
38/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
39/// assert!((sum_connectivity_index(&g).unwrap() - 2.0/3.0_f64.sqrt()).abs() < 1e-10);
40/// ```
41pub fn sum_connectivity_index(graph: &Graph) -> IgraphResult<f64> {
42    let n = graph.vcount();
43    if n == 0 {
44        return Ok(0.0);
45    }
46
47    let mut sci = 0.0_f64;
48
49    for (u, v) in graph.edges() {
50        if u == v {
51            continue;
52        }
53        let du = graph.degree(u)? as f64;
54        let dv = graph.degree(v)? as f64;
55        let sum_d = du + dv;
56        if sum_d <= 0.0 {
57            continue;
58        }
59        sci += 1.0 / sum_d.sqrt();
60    }
61
62    Ok(sci)
63}
64
65/// Compute the inverse sum indeg index.
66///
67/// `ISI(G) = Σ_{(u,v)∈E} (d_u · d_v) / (d_u + d_v)`
68///
69/// This equals `½ · Σ 1/(1/d_u + 1/d_v)`, the harmonic mean of
70/// endpoint degrees summed over edges, divided by 2.
71///
72/// Self-loops are skipped. Edges with `d_u + d_v = 0` are skipped.
73///
74/// # Examples
75///
76/// ```
77/// use rust_igraph::{Graph, inverse_sum_indeg_index};
78///
79/// // K_3: all degrees 2
80/// // each edge: (2·2)/(2+2) = 1, 3 edges → ISI = 3
81/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
82/// assert!((inverse_sum_indeg_index(&g).unwrap() - 3.0).abs() < 1e-10);
83/// ```
84pub fn inverse_sum_indeg_index(graph: &Graph) -> IgraphResult<f64> {
85    let n = graph.vcount();
86    if n == 0 {
87        return Ok(0.0);
88    }
89
90    let mut isi = 0.0_f64;
91
92    for (u, v) in graph.edges() {
93        if u == v {
94            continue;
95        }
96        let du = graph.degree(u)? as f64;
97        let dv = graph.degree(v)? as f64;
98        let sum_d = du + dv;
99        if sum_d <= 0.0 {
100            continue;
101        }
102        isi += du * dv / sum_d;
103    }
104
105    Ok(isi)
106}
107
108/// Compute the symmetric division deg index.
109///
110/// `SDD(G) = Σ_{(u,v)∈E} (d_u² + d_v²) / (d_u · d_v)`
111///
112/// Equivalently, each edge contributes `d_u/d_v + d_v/d_u`.
113/// For regular graphs every edge contributes 2, so `SDD = 2m`.
114///
115/// Self-loops are skipped. Edges with `d_u · d_v = 0` are skipped.
116///
117/// # Examples
118///
119/// ```
120/// use rust_igraph::{Graph, symmetric_division_deg_index};
121///
122/// // K_3: all degrees 2 → each edge contributes 2, SDD = 6
123/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
124/// assert!((symmetric_division_deg_index(&g).unwrap() - 6.0).abs() < 1e-10);
125/// ```
126pub fn symmetric_division_deg_index(graph: &Graph) -> IgraphResult<f64> {
127    let n = graph.vcount();
128    if n == 0 {
129        return Ok(0.0);
130    }
131
132    let mut sdd = 0.0_f64;
133
134    for (u, v) in graph.edges() {
135        if u == v {
136            continue;
137        }
138        let du = graph.degree(u)? as f64;
139        let dv = graph.degree(v)? as f64;
140        let prod = du * dv;
141        if prod <= 0.0 {
142            continue;
143        }
144        sdd += (du * du + dv * dv) / prod;
145    }
146
147    Ok(sdd)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn single_edge() -> Graph {
155        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
156    }
157
158    fn path3() -> Graph {
159        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
160    }
161
162    fn path4() -> Graph {
163        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
164    }
165
166    fn k3() -> Graph {
167        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
168    }
169
170    fn k4() -> Graph {
171        Graph::from_edges(
172            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
173            false,
174            Some(4),
175        )
176        .unwrap()
177    }
178
179    fn cycle4() -> Graph {
180        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
181    }
182
183    fn cycle5() -> Graph {
184        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)], false, Some(5)).unwrap()
185    }
186
187    fn star5() -> Graph {
188        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
189    }
190
191    // --- sum_connectivity_index ---
192
193    #[test]
194    fn sci_empty() {
195        let g = Graph::with_vertices(0);
196        assert!((sum_connectivity_index(&g).unwrap() - 0.0).abs() < 1e-10);
197    }
198
199    #[test]
200    fn sci_single_vertex() {
201        let g = Graph::with_vertices(1);
202        assert!((sum_connectivity_index(&g).unwrap() - 0.0).abs() < 1e-10);
203    }
204
205    #[test]
206    fn sci_no_edges() {
207        let g = Graph::with_vertices(3);
208        assert!((sum_connectivity_index(&g).unwrap() - 0.0).abs() < 1e-10);
209    }
210
211    #[test]
212    fn sci_single_edge() {
213        // d_u=d_v=1: 1/√2
214        let expected = 1.0 / 2.0_f64.sqrt();
215        assert!((sum_connectivity_index(&single_edge()).unwrap() - expected).abs() < 1e-10);
216    }
217
218    #[test]
219    fn sci_path3() {
220        // (0,1): 1/√3, (1,2): 1/√3 → 2/√3
221        let expected = 2.0 / 3.0_f64.sqrt();
222        assert!((sum_connectivity_index(&path3()).unwrap() - expected).abs() < 1e-10);
223    }
224
225    #[test]
226    fn sci_path4() {
227        // (0,1): 1/√3, (1,2): 1/√4=1/2, (2,3): 1/√3
228        let expected = 2.0 / 3.0_f64.sqrt() + 0.5;
229        assert!((sum_connectivity_index(&path4()).unwrap() - expected).abs() < 1e-10);
230    }
231
232    #[test]
233    fn sci_k3() {
234        // all degrees 2: each 1/√4=1/2, 3 edges → 3/2
235        assert!((sum_connectivity_index(&k3()).unwrap() - 1.5).abs() < 1e-10);
236    }
237
238    #[test]
239    fn sci_k4() {
240        // all degrees 3: each 1/√6, 6 edges → 6/√6 = √6
241        let expected = 6.0 / 6.0_f64.sqrt();
242        assert!((sum_connectivity_index(&k4()).unwrap() - expected).abs() < 1e-10);
243    }
244
245    #[test]
246    fn sci_cycle4() {
247        // all degrees 2: each 1/2, 4 edges → 2
248        assert!((sum_connectivity_index(&cycle4()).unwrap() - 2.0).abs() < 1e-10);
249    }
250
251    #[test]
252    fn sci_cycle5() {
253        // all degrees 2: each 1/2, 5 edges → 5/2
254        assert!((sum_connectivity_index(&cycle5()).unwrap() - 2.5).abs() < 1e-10);
255    }
256
257    #[test]
258    fn sci_star5() {
259        // center=4, leaf=1: 1/√5, 4 edges → 4/√5
260        let expected = 4.0 / 5.0_f64.sqrt();
261        assert!((sum_connectivity_index(&star5()).unwrap() - expected).abs() < 1e-10);
262    }
263
264    #[test]
265    fn sci_regular_formula() {
266        // r-regular: SCI = m/√(2r)
267        for g in &[k3(), k4(), cycle4(), cycle5()] {
268            let m = g.ecount() as f64;
269            let r = g.degree(0).unwrap() as f64;
270            let expected = m / (2.0 * r).sqrt();
271            assert!((sum_connectivity_index(g).unwrap() - expected).abs() < 1e-8);
272        }
273    }
274
275    #[test]
276    fn sci_positive() {
277        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
278            assert!(sum_connectivity_index(g).unwrap() > 0.0);
279        }
280    }
281
282    // --- inverse_sum_indeg_index ---
283
284    #[test]
285    fn isi_empty() {
286        let g = Graph::with_vertices(0);
287        assert!((inverse_sum_indeg_index(&g).unwrap() - 0.0).abs() < 1e-10);
288    }
289
290    #[test]
291    fn isi_single_vertex() {
292        let g = Graph::with_vertices(1);
293        assert!((inverse_sum_indeg_index(&g).unwrap() - 0.0).abs() < 1e-10);
294    }
295
296    #[test]
297    fn isi_no_edges() {
298        let g = Graph::with_vertices(3);
299        assert!((inverse_sum_indeg_index(&g).unwrap() - 0.0).abs() < 1e-10);
300    }
301
302    #[test]
303    fn isi_single_edge() {
304        // (1·1)/(1+1) = 1/2
305        assert!((inverse_sum_indeg_index(&single_edge()).unwrap() - 0.5).abs() < 1e-10);
306    }
307
308    #[test]
309    fn isi_path3() {
310        // (0,1): (1·2)/3 = 2/3, (1,2): same → 4/3
311        let expected = 4.0 / 3.0;
312        assert!((inverse_sum_indeg_index(&path3()).unwrap() - expected).abs() < 1e-10);
313    }
314
315    #[test]
316    fn isi_path4() {
317        // (0,1): (1·2)/3=2/3, (1,2): (2·2)/4=1, (2,3): (2·1)/3=2/3
318        // ISI = 2/3 + 1 + 2/3 = 7/3
319        let expected = 7.0 / 3.0;
320        assert!((inverse_sum_indeg_index(&path4()).unwrap() - expected).abs() < 1e-10);
321    }
322
323    #[test]
324    fn isi_k3() {
325        // all: (2·2)/4=1, 3 edges → 3
326        assert!((inverse_sum_indeg_index(&k3()).unwrap() - 3.0).abs() < 1e-10);
327    }
328
329    #[test]
330    fn isi_k4() {
331        // all: (3·3)/6=3/2, 6 edges → 9
332        assert!((inverse_sum_indeg_index(&k4()).unwrap() - 9.0).abs() < 1e-10);
333    }
334
335    #[test]
336    fn isi_cycle4() {
337        // all: (2·2)/4=1, 4 edges → 4
338        assert!((inverse_sum_indeg_index(&cycle4()).unwrap() - 4.0).abs() < 1e-10);
339    }
340
341    #[test]
342    fn isi_cycle5() {
343        // all: (2·2)/4=1, 5 edges → 5
344        assert!((inverse_sum_indeg_index(&cycle5()).unwrap() - 5.0).abs() < 1e-10);
345    }
346
347    #[test]
348    fn isi_star5() {
349        // (4·1)/5=4/5, 4 edges → 16/5
350        let expected = 16.0 / 5.0;
351        assert!((inverse_sum_indeg_index(&star5()).unwrap() - expected).abs() < 1e-10);
352    }
353
354    #[test]
355    fn isi_regular_formula() {
356        // r-regular: ISI = m · r²/(2r) = m·r/2
357        for g in &[k3(), k4(), cycle4(), cycle5()] {
358            let m = g.ecount() as f64;
359            let r = g.degree(0).unwrap() as f64;
360            let expected = m * r / 2.0;
361            assert!((inverse_sum_indeg_index(g).unwrap() - expected).abs() < 1e-8);
362        }
363    }
364
365    #[test]
366    fn isi_positive() {
367        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
368            assert!(inverse_sum_indeg_index(g).unwrap() > 0.0);
369        }
370    }
371
372    #[test]
373    fn isi_diamond() {
374        // K4 minus (2,3): edges 0-1,0-2,0-3,1-2,1-3
375        // deg=[3,3,2,2]
376        // (0,1): 9/6=3/2, (0,2): 6/5, (0,3): 6/5
377        // (1,2): 6/5, (1,3): 6/5
378        // ISI = 3/2 + 4·(6/5) = 3/2 + 24/5 = 15/10+48/10 = 63/10
379        let g =
380            Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3)], false, Some(4)).unwrap();
381        let expected = 63.0 / 10.0;
382        assert!((inverse_sum_indeg_index(&g).unwrap() - expected).abs() < 1e-10);
383    }
384
385    // --- symmetric_division_deg_index ---
386
387    #[test]
388    fn sdd_empty() {
389        let g = Graph::with_vertices(0);
390        assert!((symmetric_division_deg_index(&g).unwrap() - 0.0).abs() < 1e-10);
391    }
392
393    #[test]
394    fn sdd_single_vertex() {
395        let g = Graph::with_vertices(1);
396        assert!((symmetric_division_deg_index(&g).unwrap() - 0.0).abs() < 1e-10);
397    }
398
399    #[test]
400    fn sdd_no_edges() {
401        let g = Graph::with_vertices(3);
402        assert!((symmetric_division_deg_index(&g).unwrap() - 0.0).abs() < 1e-10);
403    }
404
405    #[test]
406    fn sdd_single_edge() {
407        // (1²+1²)/(1·1) = 2
408        assert!((symmetric_division_deg_index(&single_edge()).unwrap() - 2.0).abs() < 1e-10);
409    }
410
411    #[test]
412    fn sdd_path3() {
413        // (0,1): (1+4)/(1·2) = 5/2
414        // (1,2): (4+1)/(2·1) = 5/2
415        // SDD = 5
416        assert!((symmetric_division_deg_index(&path3()).unwrap() - 5.0).abs() < 1e-10);
417    }
418
419    #[test]
420    fn sdd_path4() {
421        // (0,1): (1+4)/2=5/2, (1,2): (4+4)/4=2, (2,3): (4+1)/2=5/2
422        // SDD = 5/2+2+5/2 = 7
423        assert!((symmetric_division_deg_index(&path4()).unwrap() - 7.0).abs() < 1e-10);
424    }
425
426    #[test]
427    fn sdd_k3() {
428        // all: (4+4)/4=2, 3 edges → 6
429        assert!((symmetric_division_deg_index(&k3()).unwrap() - 6.0).abs() < 1e-10);
430    }
431
432    #[test]
433    fn sdd_k4() {
434        // all: (9+9)/9=2, 6 edges → 12
435        assert!((symmetric_division_deg_index(&k4()).unwrap() - 12.0).abs() < 1e-10);
436    }
437
438    #[test]
439    fn sdd_cycle4() {
440        // all: 2, 4 edges → 8
441        assert!((symmetric_division_deg_index(&cycle4()).unwrap() - 8.0).abs() < 1e-10);
442    }
443
444    #[test]
445    fn sdd_cycle5() {
446        // all: 2, 5 edges → 10
447        assert!((symmetric_division_deg_index(&cycle5()).unwrap() - 10.0).abs() < 1e-10);
448    }
449
450    #[test]
451    fn sdd_star5() {
452        // center=4, leaf=1: (16+1)/(4·1)=17/4, 4 edges → 17
453        assert!((symmetric_division_deg_index(&star5()).unwrap() - 17.0).abs() < 1e-10);
454    }
455
456    #[test]
457    fn sdd_regular_equals_2m() {
458        // r-regular: each edge contributes r/r + r/r = 2, so SDD = 2m
459        for g in &[k3(), k4(), cycle4(), cycle5()] {
460            let expected = 2.0 * g.ecount() as f64;
461            assert!((symmetric_division_deg_index(g).unwrap() - expected).abs() < 1e-10);
462        }
463    }
464
465    #[test]
466    fn sdd_geq_2m() {
467        // SDD(G) >= 2m by AM-GM: d_u/d_v + d_v/d_u >= 2
468        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
469            let sdd = symmetric_division_deg_index(g).unwrap();
470            assert!(sdd >= 2.0 * g.ecount() as f64 - 1e-10);
471        }
472    }
473
474    #[test]
475    fn sdd_diamond() {
476        // K4 minus (2,3): edges 0-1,0-2,0-3,1-2,1-3
477        // deg=[3,3,2,2]
478        // (0,1): (9+9)/9=2
479        // (0,2): (9+4)/6=13/6
480        // (0,3): 13/6
481        // (1,2): 13/6
482        // (1,3): 13/6
483        // SDD = 2 + 4·(13/6) = 2 + 52/6 = 2 + 26/3 = 32/3
484        let g =
485            Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3)], false, Some(4)).unwrap();
486        let expected = 32.0 / 3.0;
487        assert!((symmetric_division_deg_index(&g).unwrap() - expected).abs() < 1e-10);
488    }
489
490    // --- cross-consistency ---
491
492    #[test]
493    fn all_positive_for_connected() {
494        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
495            assert!(sum_connectivity_index(g).unwrap() > 0.0);
496            assert!(inverse_sum_indeg_index(g).unwrap() > 0.0);
497            assert!(symmetric_division_deg_index(g).unwrap() > 0.0);
498        }
499    }
500
501    #[test]
502    fn with_isolated_vertex() {
503        let g = Graph::from_edges(&[(0, 1)], false, Some(3)).unwrap();
504        // single edge with isolated: d_u=d_v=1
505        let sci = sum_connectivity_index(&g).unwrap();
506        assert!((sci - 1.0 / 2.0_f64.sqrt()).abs() < 1e-10);
507
508        let isi = inverse_sum_indeg_index(&g).unwrap();
509        assert!((isi - 0.5).abs() < 1e-10);
510
511        let sdd = symmetric_division_deg_index(&g).unwrap();
512        assert!((sdd - 2.0).abs() < 1e-10);
513    }
514
515    #[test]
516    fn isi_leq_second_zagreb_over_2() {
517        // ISI(G) = Σ d_u·d_v/(d_u+d_v) <= Σ d_u·d_v / 2 = M₂/2
518        // since d_u+d_v >= 2 for connected endpoints
519        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
520            let isi = inverse_sum_indeg_index(g).unwrap();
521            let mut m2 = 0.0_f64;
522            for (u, v) in g.edges() {
523                if u == v {
524                    continue;
525                }
526                let du = g.degree(u).unwrap() as f64;
527                let dv = g.degree(v).unwrap() as f64;
528                m2 += du * dv;
529            }
530            assert!(isi <= m2 / 2.0 + 1e-10);
531        }
532    }
533}