Skip to main content

rust_igraph/algorithms/properties/
resistance.rs

1//! Effective resistance and Kirchhoff index (ALGO-TR-022).
2//!
3//! Computes resistance-distance metrics derived from the Laplacian
4//! pseudoinverse. Applicable to connected undirected graphs.
5//!
6//! - **Effective resistance**: `R(u,v) = L†(u,u) + L†(v,v) - 2·L†(u,v)`
7//!   where `L†` is the Moore-Penrose pseudoinverse of the Laplacian.
8//! - **Kirchhoff index**: `Kf(G) = Σ_{u<v} R(u,v) = n · Σ_{i≥2} 1/λ_i`
9//!   — the sum of all pairwise effective resistances.
10//! - **Resistance centrality**: `C_R(v) = n / Σ_u R(v,u)` — vertices
11//!   with lower total resistance to all others are more central.
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::similar_names,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphError, IgraphResult};
23
24/// Compute the dense Laplacian matrix L = D - A (row-major, n×n).
25fn dense_laplacian(graph: &Graph) -> Vec<f64> {
26    let n = graph.vcount() as usize;
27    let mut lap = vec![0.0_f64; n * n];
28    for (u, v) in graph.edges() {
29        let ui = u as usize;
30        let vi = v as usize;
31        lap[ui * n + vi] -= 1.0;
32        lap[vi * n + ui] -= 1.0;
33        lap[ui * n + ui] += 1.0;
34        lap[vi * n + vi] += 1.0;
35    }
36    lap
37}
38
39/// Jacobi eigenvalue algorithm for real symmetric matrices.
40/// Returns eigenvalues (sorted decreasing) and eigenvector columns.
41fn jacobi_eigen_full(mat: &mut [f64], n: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
42    if n == 0 {
43        return (Vec::new(), Vec::new());
44    }
45    if n == 1 {
46        return (vec![mat[0]], vec![vec![1.0]]);
47    }
48
49    let mut v = vec![0.0_f64; n * n];
50    for i in 0..n {
51        v[i * n + i] = 1.0;
52    }
53
54    let max_sweeps = 100;
55    for _ in 0..max_sweeps {
56        let mut max_off = 0.0_f64;
57        let mut p = 0;
58        let mut q = 1;
59        for i in 0..n {
60            for j in (i + 1)..n {
61                let val = mat[i * n + j].abs();
62                if val > max_off {
63                    max_off = val;
64                    p = i;
65                    q = j;
66                }
67            }
68        }
69
70        if max_off < 1e-14 {
71            break;
72        }
73
74        let app = mat[p * n + p];
75        let aqq = mat[q * n + q];
76        let apq = mat[p * n + q];
77
78        let (cos, sin) = if (app - aqq).abs() < 1e-300 {
79            let s = std::f64::consts::FRAC_1_SQRT_2;
80            (s, s)
81        } else {
82            let tau = (aqq - app) / (2.0 * apq);
83            let t = if tau >= 0.0 {
84                1.0 / (tau + (1.0 + tau * tau).sqrt())
85            } else {
86                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
87            };
88            let c = 1.0 / (1.0 + t * t).sqrt();
89            (c, t * c)
90        };
91
92        for i in 0..n {
93            if i == p || i == q {
94                continue;
95            }
96            let aip = mat[i * n + p];
97            let aiq = mat[i * n + q];
98            mat[i * n + p] = cos * aip - sin * aiq;
99            mat[p * n + i] = mat[i * n + p];
100            mat[i * n + q] = sin * aip + cos * aiq;
101            mat[q * n + i] = mat[i * n + q];
102        }
103
104        let new_pp = cos * cos * app - 2.0 * sin * cos * apq + sin * sin * aqq;
105        let new_qq = sin * sin * app + 2.0 * sin * cos * apq + cos * cos * aqq;
106        mat[p * n + p] = new_pp;
107        mat[q * n + q] = new_qq;
108        mat[p * n + q] = 0.0;
109        mat[q * n + p] = 0.0;
110
111        for i in 0..n {
112            let vip = v[i * n + p];
113            let viq = v[i * n + q];
114            v[i * n + p] = cos * vip - sin * viq;
115            v[i * n + q] = sin * vip + cos * viq;
116        }
117    }
118
119    let eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
120    let mut indices: Vec<usize> = (0..n).collect();
121    indices.sort_by(|&a, &b| {
122        eigenvalues[b]
123            .partial_cmp(&eigenvalues[a])
124            .unwrap_or(std::cmp::Ordering::Equal)
125    });
126
127    let sorted_vals: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
128    let sorted_vecs: Vec<Vec<f64>> = indices
129        .iter()
130        .map(|&idx| {
131            let mut col = vec![0.0_f64; n];
132            for i in 0..n {
133                col[i] = v[i * n + idx];
134            }
135            col
136        })
137        .collect();
138
139    (sorted_vals, sorted_vecs)
140}
141
142/// Compute the Laplacian pseudoinverse L† from the eigendecomposition.
143///
144/// For a connected graph: `L† = Σ_{λ_i > ε} (1/λ_i) · φ_i · φ_i^T`
145/// (skip the zero eigenvalue corresponding to the constant eigenvector).
146fn laplacian_pseudoinverse(graph: &Graph) -> Vec<f64> {
147    let n = graph.vcount() as usize;
148    if n == 0 {
149        return Vec::new();
150    }
151
152    let mut lap = dense_laplacian(graph);
153    let (eigenvalues, eigenvectors) = jacobi_eigen_full(&mut lap, n);
154
155    let mut lpinv = vec![0.0_f64; n * n];
156    let eps = 1e-10;
157
158    for (j, &lam) in eigenvalues.iter().enumerate() {
159        if lam.abs() < eps {
160            continue;
161        }
162        let inv_lam = 1.0 / lam;
163        let phi = &eigenvectors[j];
164        for u in 0..n {
165            for v in u..n {
166                let contrib = inv_lam * phi[u] * phi[v];
167                lpinv[u * n + v] += contrib;
168                if u != v {
169                    lpinv[v * n + u] += contrib;
170                }
171            }
172        }
173    }
174
175    lpinv
176}
177
178/// Compute the effective resistance between two vertices.
179///
180/// `R(u,v) = L†(u,u) + L†(v,v) - 2·L†(u,v)`
181///
182/// For connected undirected graphs only. The effective resistance is
183/// a metric (satisfies triangle inequality) and equals the commute
184/// time divided by `2·|E|`.
185///
186/// # Examples
187///
188/// ```
189/// use rust_igraph::{Graph, effective_resistance};
190///
191/// // Path 0-1-2: R(0,2) = 2 (two unit resistors in series)
192/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
193/// let r = effective_resistance(&g, 0, 2).unwrap();
194/// assert!((r - 2.0).abs() < 0.01);
195/// ```
196pub fn effective_resistance(graph: &Graph, u: u32, v: u32) -> IgraphResult<f64> {
197    let n = graph.vcount();
198    if u >= n || v >= n {
199        return Err(IgraphError::InvalidArgument(format!(
200            "vertex index out of range: u={u}, v={v}, vcount={n}"
201        )));
202    }
203    if graph.is_directed() {
204        return Err(IgraphError::InvalidArgument(
205            "effective_resistance is defined for undirected graphs only".into(),
206        ));
207    }
208    if u == v {
209        return Ok(0.0);
210    }
211
212    let n = n as usize;
213    let lpinv = laplacian_pseudoinverse(graph);
214    let ui = u as usize;
215    let vi = v as usize;
216
217    Ok(lpinv[ui * n + ui] + lpinv[vi * n + vi] - 2.0 * lpinv[ui * n + vi])
218}
219
220/// Compute the effective resistance matrix for all pairs.
221///
222/// Returns a flattened `n × n` matrix in row-major order where entry
223/// `(u,v)` is the effective resistance `R(u,v)`.
224///
225/// # Examples
226///
227/// ```
228/// use rust_igraph::{Graph, effective_resistance_matrix};
229///
230/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
231/// let r = effective_resistance_matrix(&g).unwrap();
232/// // R(0,1) = 1, R(1,2) = 1, R(0,2) = 2
233/// assert!((r[0 * 3 + 1] - 1.0).abs() < 0.01);
234/// assert!((r[0 * 3 + 2] - 2.0).abs() < 0.01);
235/// ```
236pub fn effective_resistance_matrix(graph: &Graph) -> IgraphResult<Vec<f64>> {
237    let n = graph.vcount() as usize;
238    if graph.is_directed() {
239        return Err(IgraphError::InvalidArgument(
240            "effective_resistance_matrix is defined for undirected graphs only".into(),
241        ));
242    }
243
244    let lpinv = laplacian_pseudoinverse(graph);
245    let mut result = vec![0.0_f64; n * n];
246
247    for u in 0..n {
248        for v in (u + 1)..n {
249            let r = lpinv[u * n + u] + lpinv[v * n + v] - 2.0 * lpinv[u * n + v];
250            result[u * n + v] = r;
251            result[v * n + u] = r;
252        }
253    }
254
255    Ok(result)
256}
257
258/// Compute the Kirchhoff index of a graph.
259///
260/// `Kf(G) = Σ_{u<v} R(u,v) = n · Σ_{i≥2} 1/λ_i`
261///
262/// where `λ_i` are the non-zero Laplacian eigenvalues.
263/// The Kirchhoff index measures the overall resistance of the network.
264///
265/// # Examples
266///
267/// ```
268/// use rust_igraph::{Graph, kirchhoff_index};
269///
270/// // Path 0-1-2: R(0,1)=1, R(0,2)=2, R(1,2)=1 → Kf = 4
271/// let g = Graph::from_edges(&[(0,1),(1,2)], false, Some(3)).unwrap();
272/// let kf = kirchhoff_index(&g).unwrap();
273/// assert!((kf - 4.0).abs() < 0.01);
274/// ```
275pub fn kirchhoff_index(graph: &Graph) -> IgraphResult<f64> {
276    let n = graph.vcount() as usize;
277    if graph.is_directed() {
278        return Err(IgraphError::InvalidArgument(
279            "kirchhoff_index is defined for undirected graphs only".into(),
280        ));
281    }
282    if n <= 1 {
283        return Ok(0.0);
284    }
285
286    let mut lap = dense_laplacian(graph);
287    let (eigenvalues, _) = jacobi_eigen_full(&mut lap, n);
288
289    let eps = 1e-10;
290    let mut kf = 0.0_f64;
291    for &lam in &eigenvalues {
292        if lam.abs() > eps {
293            kf += 1.0 / lam;
294        }
295    }
296    kf *= n as f64;
297
298    Ok(kf)
299}
300
301/// Compute resistance centrality for all vertices.
302///
303/// `C_R(v) = (n-1) / Σ_{u≠v} R(v,u)`
304///
305/// Vertices with lower total resistance to all others receive higher
306/// centrality scores. Returns `0.0` for isolated vertices.
307///
308/// # Examples
309///
310/// ```
311/// use rust_igraph::{Graph, resistance_centrality};
312///
313/// // K_3: all vertices equivalent, so equal centrality
314/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
315/// let c = resistance_centrality(&g).unwrap();
316/// assert!((c[0] - c[1]).abs() < 0.01);
317/// assert!((c[1] - c[2]).abs() < 0.01);
318/// ```
319pub fn resistance_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
320    let n = graph.vcount() as usize;
321    if graph.is_directed() {
322        return Err(IgraphError::InvalidArgument(
323            "resistance_centrality is defined for undirected graphs only".into(),
324        ));
325    }
326    if n <= 1 {
327        return Ok(vec![0.0; n]);
328    }
329
330    let rmat = effective_resistance_matrix(graph)?;
331    let mut centrality = vec![0.0_f64; n];
332
333    for v in 0..n {
334        let total_r: f64 = (0..n).filter(|&u| u != v).map(|u| rmat[v * n + u]).sum();
335        if total_r > 1e-300 {
336            centrality[v] = (n - 1) as f64 / total_r;
337        }
338    }
339
340    Ok(centrality)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn path3() -> Graph {
348        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
349    }
350
351    fn k3() -> Graph {
352        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
353    }
354
355    fn k4() -> Graph {
356        Graph::from_edges(
357            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
358            false,
359            Some(4),
360        )
361        .unwrap()
362    }
363
364    fn star4() -> Graph {
365        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
366    }
367
368    fn cycle4() -> Graph {
369        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
370    }
371
372    // --- effective_resistance ---
373
374    #[test]
375    fn er_self() {
376        let g = k3();
377        let r = effective_resistance(&g, 0, 0).unwrap();
378        assert!(r.abs() < 1e-10);
379    }
380
381    #[test]
382    fn er_path_adjacent() {
383        let g = path3();
384        let r = effective_resistance(&g, 0, 1).unwrap();
385        assert!((r - 1.0).abs() < 0.01);
386    }
387
388    #[test]
389    fn er_path_ends() {
390        let g = path3();
391        let r = effective_resistance(&g, 0, 2).unwrap();
392        assert!((r - 2.0).abs() < 0.01);
393    }
394
395    #[test]
396    fn er_complete_graph() {
397        // K_n: R(u,v) = 2/n for all u≠v
398        let g = k4();
399        let r = effective_resistance(&g, 0, 1).unwrap();
400        assert!((r - 0.5).abs() < 0.01); // 2/4 = 0.5
401    }
402
403    #[test]
404    fn er_symmetric() {
405        let g = star4();
406        let r01 = effective_resistance(&g, 0, 1).unwrap();
407        let r10 = effective_resistance(&g, 1, 0).unwrap();
408        assert!((r01 - r10).abs() < 1e-10);
409    }
410
411    #[test]
412    fn er_triangle_inequality() {
413        let g = path3();
414        let r01 = effective_resistance(&g, 0, 1).unwrap();
415        let r12 = effective_resistance(&g, 1, 2).unwrap();
416        let r02 = effective_resistance(&g, 0, 2).unwrap();
417        assert!(r02 <= r01 + r12 + 1e-10);
418    }
419
420    #[test]
421    fn er_nonneg() {
422        let g = cycle4();
423        for u in 0..4_u32 {
424            for v in 0..4_u32 {
425                let r = effective_resistance(&g, u, v).unwrap();
426                assert!(r >= -1e-10, "R({u},{v}) = {r} < 0");
427            }
428        }
429    }
430
431    #[test]
432    fn er_out_of_range() {
433        let g = k3();
434        assert!(effective_resistance(&g, 0, 5).is_err());
435    }
436
437    #[test]
438    fn er_directed_error() {
439        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
440        assert!(effective_resistance(&g, 0, 1).is_err());
441    }
442
443    // --- effective_resistance_matrix ---
444
445    #[test]
446    fn erm_symmetric() {
447        let g = star4();
448        let r = effective_resistance_matrix(&g).unwrap();
449        let n = 4;
450        for u in 0..n {
451            for v in 0..n {
452                assert!(
453                    (r[u * n + v] - r[v * n + u]).abs() < 1e-10,
454                    "R({u},{v}) != R({v},{u})"
455                );
456            }
457        }
458    }
459
460    #[test]
461    fn erm_diagonal_zero() {
462        let g = k3();
463        let r = effective_resistance_matrix(&g).unwrap();
464        let n = 3;
465        for v in 0..n {
466            assert!(r[v * n + v].abs() < 1e-10);
467        }
468    }
469
470    #[test]
471    fn erm_path3() {
472        let g = path3();
473        let r = effective_resistance_matrix(&g).unwrap();
474        let n = 3;
475        assert!((r[1] - 1.0).abs() < 0.01); // R(0,1)
476        assert!((r[2] - 2.0).abs() < 0.01); // R(0,2)
477        assert!((r[n + 2] - 1.0).abs() < 0.01); // R(1,2)
478    }
479
480    // --- kirchhoff_index ---
481
482    #[test]
483    fn kf_empty() {
484        let g = Graph::with_vertices(1);
485        let kf = kirchhoff_index(&g).unwrap();
486        assert!(kf.abs() < 1e-10);
487    }
488
489    #[test]
490    fn kf_path3() {
491        let g = path3();
492        let kf = kirchhoff_index(&g).unwrap();
493        // R(0,1)=1, R(0,2)=2, R(1,2)=1 → Kf = 4
494        assert!((kf - 4.0).abs() < 0.1);
495    }
496
497    #[test]
498    fn kf_complete() {
499        // K_n: Kf = C(n,2) · 2/n = n(n-1)/2 · 2/n = n-1
500        let g = k4();
501        let kf = kirchhoff_index(&g).unwrap();
502        assert!((kf - 3.0).abs() < 0.1);
503    }
504
505    #[test]
506    fn kf_cycle4() {
507        // C_4: R(adj)=3/4, R(opp)=1. Kf = 4*(3/4) + 2*(1) = 5
508        let g = cycle4();
509        let kf = kirchhoff_index(&g).unwrap();
510        assert!((kf - 5.0).abs() < 0.1);
511    }
512
513    #[test]
514    fn kf_equals_sum_of_resistances() {
515        let g = star4();
516        let rmat = effective_resistance_matrix(&g).unwrap();
517        let n = 4;
518        let mut sum = 0.0_f64;
519        for u in 0..n {
520            for v in (u + 1)..n {
521                sum += rmat[u * n + v];
522            }
523        }
524        let kf = kirchhoff_index(&g).unwrap();
525        assert!((kf - sum).abs() < 0.1);
526    }
527
528    #[test]
529    fn kf_directed_error() {
530        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
531        assert!(kirchhoff_index(&g).is_err());
532    }
533
534    // --- resistance_centrality ---
535
536    #[test]
537    fn rc_k3_symmetric() {
538        let g = k3();
539        let c = resistance_centrality(&g).unwrap();
540        assert!((c[0] - c[1]).abs() < 0.01);
541        assert!((c[1] - c[2]).abs() < 0.01);
542    }
543
544    #[test]
545    fn rc_star_center_highest() {
546        let g = star4();
547        let c = resistance_centrality(&g).unwrap();
548        assert!(c[0] > c[1]);
549        assert!(c[0] > c[2]);
550        assert!(c[0] > c[3]);
551    }
552
553    #[test]
554    fn rc_path_center_highest() {
555        let g = path3();
556        let c = resistance_centrality(&g).unwrap();
557        // Center vertex (1) should have highest centrality
558        assert!(c[1] > c[0]);
559        assert!(c[1] > c[2]);
560    }
561
562    #[test]
563    fn rc_all_positive() {
564        let g = k4();
565        let c = resistance_centrality(&g).unwrap();
566        for &v in &c {
567            assert!(v > 0.0);
568        }
569    }
570
571    #[test]
572    fn rc_single_vertex() {
573        let g = Graph::with_vertices(1);
574        let c = resistance_centrality(&g).unwrap();
575        assert!(c[0].abs() < 1e-10);
576    }
577}