Skip to main content

rust_igraph/algorithms/properties/
resistance_ratios.rs

1//! Resistance-distance-based ratio indices (ALGO-TR-119).
2//!
3//! Measures derived from effective resistance (Kirchhoff) concepts:
4//!
5//! - **Kirchhoff index ratio** — Kirchhoff index / (n*(n-1)/2 * diameter),
6//!   normalized resistance sum
7//! - **Resistance regularity** — min effective resistance / max effective
8//!   resistance between adjacent pairs
9//! - **Spanning tree ratio** — log(number of spanning trees) / (n-1)*log(n),
10//!   a normalized complexity measure via Kirchhoff's matrix-tree theorem
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 Kirchhoff index ratio.
25///
26/// The Kirchhoff index Kf(G) is the sum of effective resistances over all
27/// vertex pairs. For a connected graph, we normalize by the number of pairs
28/// times the diameter: `Kf / (pairs * diameter)`. Values near 1 indicate
29/// a tree-like resistance structure; lower values indicate more redundant
30/// paths. Returns 0.0 for disconnected or trivial graphs.
31///
32/// We approximate the Kirchhoff index using BFS distances: for connected
33/// graphs, `resistance(u,v) >= dist(u,v)/max_degree` and
34/// `resistance(u,v) <= dist(u,v)`. We use the sum of distances divided
35/// by pairs*diameter as a proxy (the Wiener index ratio).
36///
37/// # Examples
38///
39/// ```
40/// use rust_igraph::{Graph, kirchhoff_index_ratio};
41///
42/// // Path graph 0-1-2-3: tree, sum of distances = 1+2+3+1+2+1 = 10
43/// // pairs=6, diameter=3, ratio = 10/(6*3) = 10/18 ≈ 0.556
44/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3)], false, Some(4)).unwrap();
45/// let r = kirchhoff_index_ratio(&g).unwrap();
46/// assert!(r > 0.5 && r < 0.6);
47/// ```
48pub fn kirchhoff_index_ratio(graph: &Graph) -> IgraphResult<f64> {
49    let n = graph.vcount() as usize;
50    if n < 2 {
51        return Ok(0.0);
52    }
53
54    let (dist_sum, diameter, connected) = bfs_all_pairs_stats(graph, n)?;
55    if !connected || diameter == 0 {
56        return Ok(0.0);
57    }
58
59    let pairs = n * (n - 1) / 2;
60    Ok(dist_sum as f64 / (pairs as f64 * diameter as f64))
61}
62
63/// Compute the resistance regularity ratio.
64///
65/// For each edge (u,v), the effective resistance is at least 1/min(deg(u),deg(v))
66/// and at most 1. We use `1/min(deg(u), deg(v))` as a proxy for edge
67/// resistance, then compute `min_resistance / max_resistance` over all edges.
68/// Values near 1 indicate uniform edge resistances (regular graph);
69/// values near 0 indicate highly non-uniform resistances. Returns 0.0
70/// for trivial or edgeless graphs.
71///
72/// # Examples
73///
74/// ```
75/// use rust_igraph::{Graph, resistance_regularity};
76///
77/// // K_3: all edges have same resistance → ratio = 1.0
78/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
79/// assert!((resistance_regularity(&g).unwrap() - 1.0).abs() < 1e-10);
80/// ```
81pub fn resistance_regularity(graph: &Graph) -> IgraphResult<f64> {
82    let n = graph.vcount() as usize;
83    if n < 2 {
84        return Ok(0.0);
85    }
86
87    let m = graph.ecount();
88    if m == 0 {
89        return Ok(0.0);
90    }
91
92    let mut degrees = Vec::with_capacity(n);
93    for v in 0..n {
94        degrees.push(graph.degree(v as u32)?);
95    }
96
97    let mut min_r = f64::MAX;
98    let mut max_r = 0.0_f64;
99
100    for v in 0..n {
101        let nbrs = graph.neighbors(v as u32)?;
102        for &u in &nbrs {
103            let ui = u as usize;
104            if ui > v {
105                let min_deg = degrees[v].min(degrees[ui]);
106                if min_deg == 0 {
107                    continue;
108                }
109                let r = 1.0 / min_deg as f64;
110                if r < min_r {
111                    min_r = r;
112                }
113                if r > max_r {
114                    max_r = r;
115                }
116            }
117        }
118    }
119
120    if max_r < 1e-30 {
121        return Ok(0.0);
122    }
123
124    Ok(min_r / max_r)
125}
126
127/// Compute the spanning tree ratio.
128///
129/// Uses Kirchhoff's matrix-tree theorem: the number of spanning trees τ(G)
130/// equals (1/n) * product of non-zero Laplacian eigenvalues. We compute
131/// `log(τ) / ((n-1) * log(n))` as a normalized measure. Values near 1
132/// indicate a graph rich in spanning trees (complete-graph-like); values
133/// near 0 indicate few spanning trees (tree-like). Returns 0.0 for
134/// disconnected or trivial graphs.
135///
136/// # Examples
137///
138/// ```
139/// use rust_igraph::{Graph, spanning_tree_ratio};
140///
141/// // K_3: τ = 3, log(3)/((3-1)*log(3)) = 1/(2) = 0.5
142/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
143/// let r = spanning_tree_ratio(&g).unwrap();
144/// assert!(r > 0.45 && r < 0.55);
145/// ```
146pub fn spanning_tree_ratio(graph: &Graph) -> IgraphResult<f64> {
147    let n = graph.vcount() as usize;
148    if n < 2 {
149        return Ok(0.0);
150    }
151
152    let m = graph.ecount();
153    if m == 0 {
154        return Ok(0.0);
155    }
156
157    // Build Laplacian matrix
158    let laplacian = build_laplacian(graph, n)?;
159
160    // Compute eigenvalues via QR iteration
161    let eigenvalues = symmetric_eigenvalues(&laplacian, n);
162
163    // Sum log of non-zero eigenvalues (those > epsilon)
164    let eps = 1e-10;
165    let mut log_product = 0.0_f64;
166    let mut nonzero_count = 0_usize;
167    for &ev in &eigenvalues {
168        if ev > eps {
169            log_product += ev.ln();
170            nonzero_count += 1;
171        }
172    }
173
174    if nonzero_count < n - 1 {
175        // Graph is disconnected
176        return Ok(0.0);
177    }
178
179    // log(τ) = log_product - log(n)
180    let log_tau = log_product - (n as f64).ln();
181    let normalizer = (n - 1) as f64 * (n as f64).ln();
182    if normalizer < 1e-30 {
183        return Ok(0.0);
184    }
185
186    Ok((log_tau / normalizer).clamp(0.0, 1.0))
187}
188
189/// BFS from every vertex, return (`sum_of_distances`, `diameter`, `is_connected`).
190fn bfs_all_pairs_stats(graph: &Graph, n: usize) -> IgraphResult<(u64, u32, bool)> {
191    let mut total_sum = 0_u64;
192    let mut diameter = 0_u32;
193
194    for s in 0..n {
195        let mut dist = vec![u32::MAX; n];
196        dist[s] = 0;
197        let mut queue = std::collections::VecDeque::new();
198        queue.push_back(s);
199        let mut visit_count = 1_usize;
200
201        while let Some(v) = queue.pop_front() {
202            let nbrs = graph.neighbors(v as u32)?;
203            for &u in &nbrs {
204                let ui = u as usize;
205                if dist[ui] == u32::MAX {
206                    dist[ui] = dist[v] + 1;
207                    visit_count += 1;
208                    queue.push_back(ui);
209                }
210            }
211        }
212
213        if visit_count < n {
214            return Ok((0, 0, false));
215        }
216
217        for t in (s + 1)..n {
218            total_sum += dist[t] as u64;
219            if dist[t] > diameter {
220                diameter = dist[t];
221            }
222        }
223    }
224
225    Ok((total_sum, diameter, true))
226}
227
228/// Build the Laplacian matrix L = D - A.
229fn build_laplacian(graph: &Graph, n: usize) -> IgraphResult<Vec<Vec<f64>>> {
230    let mut lap = vec![vec![0.0_f64; n]; n];
231
232    for v in 0..n {
233        let nbrs = graph.neighbors(v as u32)?;
234        lap[v][v] = nbrs.len() as f64;
235        for &u in &nbrs {
236            let ui = u as usize;
237            lap[v][ui] -= 1.0;
238        }
239    }
240
241    Ok(lap)
242}
243
244/// Compute eigenvalues of a symmetric matrix via Jacobi iteration.
245fn symmetric_eigenvalues(mat: &[Vec<f64>], n: usize) -> Vec<f64> {
246    if n == 0 {
247        return Vec::new();
248    }
249    if n == 1 {
250        return vec![mat[0][0]];
251    }
252
253    let mut a = mat.to_vec();
254    let max_iter = 100 * n * n;
255
256    for _ in 0..max_iter {
257        // Find the largest off-diagonal element
258        let mut max_val = 0.0_f64;
259        let mut p = 0_usize;
260        let mut q = 1_usize;
261        for i in 0..n {
262            for j in (i + 1)..n {
263                if a[i][j].abs() > max_val {
264                    max_val = a[i][j].abs();
265                    p = i;
266                    q = j;
267                }
268            }
269        }
270
271        if max_val < 1e-12 {
272            break;
273        }
274
275        // Compute rotation angle
276        let app = a[p][p];
277        let aqq = a[q][q];
278        let apq = a[p][q];
279
280        let theta = if (app - aqq).abs() < 1e-30 {
281            std::f64::consts::FRAC_PI_4
282        } else {
283            0.5 * (2.0 * apq / (app - aqq)).atan()
284        };
285
286        let cos_t = theta.cos();
287        let sin_t = theta.sin();
288
289        // Apply Jacobi rotation
290        let mut new_a = a.clone();
291        for i in 0..n {
292            if i != p && i != q {
293                new_a[i][p] = cos_t * a[i][p] + sin_t * a[i][q];
294                new_a[p][i] = new_a[i][p];
295                new_a[i][q] = -sin_t * a[i][p] + cos_t * a[i][q];
296                new_a[q][i] = new_a[i][q];
297            }
298        }
299        new_a[p][p] = cos_t * cos_t * app + 2.0 * sin_t * cos_t * apq + sin_t * sin_t * aqq;
300        new_a[q][q] = sin_t * sin_t * app - 2.0 * sin_t * cos_t * apq + cos_t * cos_t * aqq;
301        new_a[p][q] = 0.0;
302        new_a[q][p] = 0.0;
303
304        a = new_a;
305    }
306
307    (0..n).map(|i| a[i][i]).collect()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn empty() -> Graph {
315        Graph::with_vertices(0)
316    }
317
318    fn single() -> Graph {
319        Graph::with_vertices(1)
320    }
321
322    fn single_edge() -> Graph {
323        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
324    }
325
326    fn path3() -> Graph {
327        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
328    }
329
330    fn path4() -> Graph {
331        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
332    }
333
334    fn k3() -> Graph {
335        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
336    }
337
338    fn k4() -> Graph {
339        Graph::from_edges(
340            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
341            false,
342            Some(4),
343        )
344        .unwrap()
345    }
346
347    fn cycle4() -> Graph {
348        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
349    }
350
351    fn star5() -> Graph {
352        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
353    }
354
355    fn disconnected() -> Graph {
356        Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap()
357    }
358
359    // --- kirchhoff_index_ratio ---
360
361    #[test]
362    fn kir_empty() {
363        assert!(kirchhoff_index_ratio(&empty()).unwrap().abs() < 1e-10);
364    }
365
366    #[test]
367    fn kir_single() {
368        assert!(kirchhoff_index_ratio(&single()).unwrap().abs() < 1e-10);
369    }
370
371    #[test]
372    fn kir_disconnected() {
373        assert!(kirchhoff_index_ratio(&disconnected()).unwrap().abs() < 1e-10);
374    }
375
376    #[test]
377    fn kir_single_edge() {
378        // sum=1, pairs=1, diameter=1 → 1.0
379        assert!((kirchhoff_index_ratio(&single_edge()).unwrap() - 1.0).abs() < 1e-10);
380    }
381
382    #[test]
383    fn kir_k3() {
384        // distances: 1+1+1=3, pairs=3, diameter=1 → 3/(3*1)=1.0
385        assert!((kirchhoff_index_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
386    }
387
388    #[test]
389    fn kir_path4() {
390        // distances: 1+2+3+1+2+1=10, pairs=6, diameter=3 → 10/18 ≈ 0.556
391        let r = kirchhoff_index_ratio(&path4()).unwrap();
392        assert!((r - 10.0 / 18.0).abs() < 1e-10);
393    }
394
395    #[test]
396    fn kir_in_01() {
397        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
398            let r = kirchhoff_index_ratio(g).unwrap();
399            assert!(r >= -1e-10);
400            assert!(r <= 1.0 + 1e-10);
401        }
402    }
403
404    // --- resistance_regularity ---
405
406    #[test]
407    fn rr_empty() {
408        assert!(resistance_regularity(&empty()).unwrap().abs() < 1e-10);
409    }
410
411    #[test]
412    fn rr_single() {
413        assert!(resistance_regularity(&single()).unwrap().abs() < 1e-10);
414    }
415
416    #[test]
417    fn rr_k3() {
418        // All degrees 2, all edges have resistance proxy 1/2 → ratio = 1.0
419        assert!((resistance_regularity(&k3()).unwrap() - 1.0).abs() < 1e-10);
420    }
421
422    #[test]
423    fn rr_k4() {
424        // All degrees 3 → ratio = 1.0
425        assert!((resistance_regularity(&k4()).unwrap() - 1.0).abs() < 1e-10);
426    }
427
428    #[test]
429    fn rr_cycle4() {
430        // All degrees 2 → ratio = 1.0
431        assert!((resistance_regularity(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
432    }
433
434    #[test]
435    fn rr_star5() {
436        // Center degree 4, leaves degree 1
437        // Edge resistance proxy: 1/min(4,1) = 1/1 = 1.0 for all edges
438        // So ratio = 1.0
439        assert!((resistance_regularity(&star5()).unwrap() - 1.0).abs() < 1e-10);
440    }
441
442    #[test]
443    fn rr_path3() {
444        // degrees: 1, 2, 1
445        // edge (0,1): 1/min(1,2) = 1/1 = 1.0
446        // edge (1,2): 1/min(2,1) = 1/1 = 1.0
447        // ratio = 1.0
448        assert!((resistance_regularity(&path3()).unwrap() - 1.0).abs() < 1e-10);
449    }
450
451    #[test]
452    fn rr_in_01() {
453        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
454            let r = resistance_regularity(g).unwrap();
455            assert!(r >= -1e-10);
456            assert!(r <= 1.0 + 1e-10);
457        }
458    }
459
460    #[test]
461    fn rr_paw() {
462        // Paw: 0-1, 1-2, 0-2, 2-3. Degrees: 2, 2, 3, 1
463        // edge(0,1): 1/min(2,2) = 0.5
464        // edge(1,2): 1/min(2,3) = 0.5
465        // edge(0,2): 1/min(2,3) = 0.5
466        // edge(2,3): 1/min(3,1) = 1.0
467        // min=0.5, max=1.0, ratio=0.5
468        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
469        assert!((resistance_regularity(&g).unwrap() - 0.5).abs() < 1e-10);
470    }
471
472    // --- spanning_tree_ratio ---
473
474    #[test]
475    fn str_empty() {
476        assert!(spanning_tree_ratio(&empty()).unwrap().abs() < 1e-10);
477    }
478
479    #[test]
480    fn str_single() {
481        assert!(spanning_tree_ratio(&single()).unwrap().abs() < 1e-10);
482    }
483
484    #[test]
485    fn str_disconnected() {
486        assert!(spanning_tree_ratio(&disconnected()).unwrap().abs() < 1e-10);
487    }
488
489    #[test]
490    fn str_single_edge() {
491        // τ=1, log(1)=0, ratio=0. But normalizer = (2-1)*log(2) = log(2)
492        // log(τ)/normalizer = 0/log(2) = 0
493        let r = spanning_tree_ratio(&single_edge()).unwrap();
494        assert!(r.abs() < 1e-10);
495    }
496
497    #[test]
498    fn str_k3() {
499        // τ(K_3)=3, log(3)/((3-1)*log(3)) = 1/2 = 0.5
500        let r = spanning_tree_ratio(&k3()).unwrap();
501        assert!((r - 0.5).abs() < 0.05);
502    }
503
504    #[test]
505    fn str_k4() {
506        // τ(K_4)=16, log(16)/((4-1)*log(4)) = 4*log(2)/(3*2*log(2)) = 4/6 = 2/3
507        let r = spanning_tree_ratio(&k4()).unwrap();
508        assert!((r - 2.0 / 3.0).abs() < 0.05);
509    }
510
511    #[test]
512    fn str_path_tree() {
513        // Path (tree) has τ=1 → log(1)=0 → ratio=0
514        let r = spanning_tree_ratio(&path4()).unwrap();
515        assert!(r.abs() < 1e-10);
516    }
517
518    #[test]
519    fn str_in_01() {
520        for g in &[single_edge(), path3(), k3(), k4(), cycle4(), star5()] {
521            let r = spanning_tree_ratio(g).unwrap();
522            assert!(r >= -1e-10);
523            assert!(r <= 1.0 + 1e-10);
524        }
525    }
526
527    #[test]
528    fn str_cycle4() {
529        // τ(C_4)=4, log(4)/((4-1)*log(4)) = 1/3
530        let r = spanning_tree_ratio(&cycle4()).unwrap();
531        assert!((r - 1.0 / 3.0).abs() < 0.05);
532    }
533
534    // --- cross-consistency ---
535
536    #[test]
537    fn regular_graphs_unit_resistance() {
538        // Regular graphs should have resistance regularity = 1.0
539        assert!((resistance_regularity(&k3()).unwrap() - 1.0).abs() < 1e-10);
540        assert!((resistance_regularity(&k4()).unwrap() - 1.0).abs() < 1e-10);
541        assert!((resistance_regularity(&cycle4()).unwrap() - 1.0).abs() < 1e-10);
542    }
543
544    #[test]
545    fn trees_zero_spanning_ratio() {
546        // Trees have exactly 1 spanning tree → ratio = 0
547        assert!(spanning_tree_ratio(&path3()).unwrap().abs() < 1e-10);
548        assert!(spanning_tree_ratio(&path4()).unwrap().abs() < 1e-10);
549        assert!(spanning_tree_ratio(&star5()).unwrap().abs() < 1e-10);
550    }
551
552    #[test]
553    fn complete_diameter_one_kirchhoff() {
554        // Complete graphs: diameter=1, all distances=1, sum=pairs → ratio=1.0
555        assert!((kirchhoff_index_ratio(&k3()).unwrap() - 1.0).abs() < 1e-10);
556        assert!((kirchhoff_index_ratio(&k4()).unwrap() - 1.0).abs() < 1e-10);
557    }
558}