scirs2_graph/centrality/extended.rs
1//! Extended centrality measures for graph analysis.
2//!
3//! This module provides advanced centrality algorithms beyond the standard
4//! degree, betweenness, closeness, and eigenvector centralities found in
5//! `measures.rs`.
6//!
7//! ## Algorithms
8//!
9//! | Struct / Function | Description |
10//! |-------------------|-------------|
11//! | [`VoteRankCentrality`] | VoteRank — iterative influential spreader identification |
12//! | [`HITSCentrality`] | HITS (Hyperlink-Induced Topic Search) — hubs and authorities |
13//! | [`TrustCentrality`] | Trust/distrust centrality for signed networks |
14//! | [`CoreDecomposition`] | k-core decomposition and k-shell index |
15//! | [`EffectiveResistance`] | Effective resistance distance matrix (via Laplacian pseudo-inverse) |
16//!
17//! ## Example
18//! ```rust,no_run
19//! use scirs2_core::ndarray::Array2;
20//! use scirs2_graph::centrality::extended::{HITSCentrality, CoreDecomposition, EffectiveResistance};
21//!
22//! let mut adj = Array2::<f64>::zeros((4, 4));
23//! adj[[0,1]] = 1.0; adj[[1,0]] = 1.0;
24//! adj[[1,2]] = 1.0; adj[[2,1]] = 1.0;
25//! adj[[2,3]] = 1.0; adj[[3,2]] = 1.0;
26//! adj[[0,3]] = 1.0; adj[[3,0]] = 1.0;
27//!
28//! let hits = HITSCentrality::new(100, 1e-8);
29//! let (hubs, auths) = hits.compute(&adj).unwrap();
30//!
31//! let core = CoreDecomposition::compute(&adj);
32//! println!("Core numbers: {:?}", core.core_numbers);
33//!
34//! let er = EffectiveResistance::compute(&adj).unwrap();
35//! println!("Resistance(0,2) = {:.4}", er.resistance(0, 2).unwrap());
36//! ```
37
38use std::collections::VecDeque;
39
40use scirs2_core::ndarray::{Array1, Array2};
41
42use crate::error::{GraphError, Result};
43
44// ─────────────────────────────────────────────────────────────────────────────
45// VoteRankCentrality
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// VoteRank algorithm for identifying influential spreaders.
49///
50/// VoteRank iteratively selects the most influential spreader by a voting
51/// process: each non-selected node votes for its neighbors, and the node
52/// with the highest vote score is chosen as the next spreader. After
53/// selection, the voting ability of the spreader's neighbors is reduced by
54/// `1 / degree(selected)`.
55///
56/// # Reference
57/// Zhang et al. (2016). "Identifying a set of influential spreaders in
58/// complex networks." *Scientific Reports*, 6, 27823.
59///
60/// The algorithm produces an ordered list of `k` influential nodes,
61/// where the first node is the single most influential spreader.
62#[derive(Debug, Clone)]
63pub struct VoteRankCentrality {
64 /// Number of spreaders to identify.
65 pub k: usize,
66}
67
68impl VoteRankCentrality {
69 /// Create a VoteRank instance to find `k` influential spreaders.
70 pub fn new(k: usize) -> Self {
71 Self { k }
72 }
73
74 /// Run VoteRank on an undirected graph (adjacency matrix).
75 ///
76 /// Returns the ordered list of selected node indices (most influential first).
77 pub fn compute(&self, adj: &Array2<f64>) -> Result<Vec<usize>> {
78 let n = adj.nrows();
79 if n == 0 {
80 return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
81 }
82 let k = self.k.min(n);
83
84 // Compute degree of each node
85 let degree: Vec<f64> = (0..n)
86 .map(|i| adj.row(i).iter().filter(|&&x| x != 0.0).count() as f64)
87 .collect();
88
89 // Voting ability of each node (starts at 1.0, reduced after neighbours are selected)
90 let mut vote_ability = vec![1.0_f64; n];
91
92 // Track selected nodes
93 let mut selected = Vec::with_capacity(k);
94 let mut is_selected = vec![false; n];
95
96 for _ in 0..k {
97 // Compute vote scores: score[i] = sum of vote_ability[j] for all j ~ i
98 let mut scores = vec![0.0_f64; n];
99 for i in 0..n {
100 if is_selected[i] {
101 continue;
102 }
103 for j in 0..n {
104 if adj[[i, j]] != 0.0 && !is_selected[j] {
105 scores[i] += vote_ability[j];
106 }
107 }
108 }
109
110 // Select node with highest score (ties broken by lowest index)
111 let best = (0..n).filter(|&i| !is_selected[i]).max_by(|&a, &b| {
112 scores[a]
113 .partial_cmp(&scores[b])
114 .unwrap_or(std::cmp::Ordering::Equal)
115 });
116
117 match best {
118 Some(node) if scores[node] > 0.0 => {
119 is_selected[node] = true;
120 selected.push(node);
121 // Reduce voting ability of neighbours
122 let deg_inv = if degree[node] > 0.0 {
123 1.0 / degree[node]
124 } else {
125 0.0
126 };
127 for j in 0..n {
128 if adj[[node, j]] != 0.0 {
129 vote_ability[j] = (vote_ability[j] - deg_inv).max(0.0);
130 }
131 }
132 }
133 _ => break, // No more non-trivial nodes to select
134 }
135 }
136
137 Ok(selected)
138 }
139
140 /// Compute the VoteRank score (vote count at time of selection) for each
141 /// selected node, returned as `Vec<(node_index, score)>`.
142 pub fn compute_with_scores(&self, adj: &Array2<f64>) -> Result<Vec<(usize, f64)>> {
143 let n = adj.nrows();
144 if n == 0 {
145 return Err(GraphError::InvalidGraph("empty adjacency matrix".into()));
146 }
147 let k = self.k.min(n);
148
149 let degree: Vec<f64> = (0..n)
150 .map(|i| adj.row(i).iter().filter(|&&x| x != 0.0).count() as f64)
151 .collect();
152 let mut vote_ability = vec![1.0_f64; n];
153 let mut selected = Vec::with_capacity(k);
154 let mut is_selected = vec![false; n];
155
156 for _ in 0..k {
157 let mut scores = vec![0.0_f64; n];
158 for i in 0..n {
159 if is_selected[i] {
160 continue;
161 }
162 for j in 0..n {
163 if adj[[i, j]] != 0.0 && !is_selected[j] {
164 scores[i] += vote_ability[j];
165 }
166 }
167 }
168 let best = (0..n).filter(|&i| !is_selected[i]).max_by(|&a, &b| {
169 scores[a]
170 .partial_cmp(&scores[b])
171 .unwrap_or(std::cmp::Ordering::Equal)
172 });
173 match best {
174 Some(node) if scores[node] > 0.0 => {
175 is_selected[node] = true;
176 selected.push((node, scores[node]));
177 let deg_inv = if degree[node] > 0.0 {
178 1.0 / degree[node]
179 } else {
180 0.0
181 };
182 for j in 0..n {
183 if adj[[node, j]] != 0.0 {
184 vote_ability[j] = (vote_ability[j] - deg_inv).max(0.0);
185 }
186 }
187 }
188 _ => break,
189 }
190 }
191
192 Ok(selected)
193 }
194}
195
196// ─────────────────────────────────────────────────────────────────────────────
197// HITSCentrality
198// ─────────────────────────────────────────────────────────────────────────────
199
200/// HITS (Hyperlink-Induced Topic Search) centrality — hubs and authorities.
201///
202/// HITS assigns two scores to each node:
203/// - **Hub score** `h_i`: node `i` is a good hub if it points to many good authorities.
204/// - **Authority score** `a_i`: node `i` is a good authority if it is pointed to by many good hubs.
205///
206/// Update rules (power iteration):
207/// `a_i ← Σ_{j: j→i} h_j`
208/// `h_i ← Σ_{j: i→j} a_j`
209///
210/// Followed by L2 normalisation of both vectors at each iteration.
211///
212/// For undirected graphs, the adjacency matrix is symmetric, so hubs and
213/// authorities converge to proportional values (both equal the principal
214/// eigenvector of `A`).
215///
216/// # Reference
217/// Kleinberg (1999). "Authoritative sources in a hyperlinked environment."
218/// *J. ACM*, 46(5), 604–632.
219#[derive(Debug, Clone)]
220pub struct HITSCentrality {
221 /// Maximum number of power iterations.
222 pub max_iter: usize,
223 /// Convergence tolerance (L∞ norm of successive hub/auth changes).
224 pub tol: f64,
225}
226
227impl HITSCentrality {
228 /// Create a HITS instance.
229 pub fn new(max_iter: usize, tol: f64) -> Self {
230 Self { max_iter, tol }
231 }
232
233 /// Compute HITS hub and authority scores.
234 ///
235 /// # Returns
236 /// `(hub_scores, authority_scores)` — both normalised to unit L2 norm.
237 pub fn compute(&self, adj: &Array2<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
238 let n = adj.nrows();
239 if n == 0 {
240 return Err(GraphError::InvalidGraph("empty adjacency".into()));
241 }
242
243 // Initialise: uniform hubs
244 let mut h = Array1::from_elem(n, 1.0_f64 / n as f64);
245 let mut a = Array1::<f64>::zeros(n);
246
247 for iter in 0..self.max_iter {
248 // Authority update: a = A^T h (for directed; A^T[i,j] = A[j,i])
249 let mut new_a = Array1::<f64>::zeros(n);
250 for i in 0..n {
251 for j in 0..n {
252 // edge j → i means adj[j,i] != 0
253 new_a[i] += adj[[j, i]] * h[j];
254 }
255 }
256 // Normalise a
257 let a_norm = new_a.iter().map(|&x| x * x).sum::<f64>().sqrt();
258 if a_norm > 1e-14 {
259 new_a.iter_mut().for_each(|x| *x /= a_norm);
260 }
261
262 // Hub update: h = A a (for directed: h_i = sum_j A[i,j] a_j)
263 let mut new_h = Array1::<f64>::zeros(n);
264 for i in 0..n {
265 for j in 0..n {
266 new_h[i] += adj[[i, j]] * new_a[j];
267 }
268 }
269 // Normalise h
270 let h_norm = new_h.iter().map(|&x| x * x).sum::<f64>().sqrt();
271 if h_norm > 1e-14 {
272 new_h.iter_mut().for_each(|x| *x /= h_norm);
273 }
274
275 // Check convergence
276 let h_diff = h
277 .iter()
278 .zip(new_h.iter())
279 .map(|(&a, &b)| (a - b).abs())
280 .fold(0.0_f64, f64::max);
281 let a_diff = a
282 .iter()
283 .zip(new_a.iter())
284 .map(|(&a, &b)| (a - b).abs())
285 .fold(0.0_f64, f64::max);
286
287 a = new_a;
288 h = new_h;
289
290 if h_diff < self.tol && a_diff < self.tol && iter > 0 {
291 break;
292 }
293 }
294
295 Ok((h, a))
296 }
297}
298
299// ─────────────────────────────────────────────────────────────────────────────
300// TrustCentrality — signed network centrality
301// ─────────────────────────────────────────────────────────────────────────────
302
303/// Trust-based centrality for signed networks.
304///
305/// In signed networks, edges have positive (trust) or negative (distrust) weights.
306/// Trust centrality measures the net trust flow to a node:
307///
308/// `trust_centrality(i) = Σ_{j: w_{ji} > 0} |w_{ji}| − Σ_{j: w_{ji} < 0} |w_{ji}|`
309///
310/// Extended measures:
311/// - **Net trust**: incoming trust minus incoming distrust.
312/// - **Positive centrality**: betweenness/closeness restricted to positive edges.
313/// - **Balance score**: proportion of triangles that are structurally balanced.
314///
315/// # Reference
316/// Guha et al. (2004). "Propagation of trust and distrust."
317/// *WWW '04*, 403–412.
318#[derive(Debug, Clone)]
319pub struct TrustCentrality;
320
321/// Results from trust centrality computation.
322#[derive(Debug, Clone)]
323pub struct TrustCentralityResult {
324 /// Net trust score per node: positive = trusted, negative = distrusted.
325 pub net_trust: Array1<f64>,
326 /// Positive in-degree (number of trusting neighbours).
327 pub positive_in_degree: Vec<usize>,
328 /// Negative in-degree (number of distrusting neighbours).
329 pub negative_in_degree: Vec<usize>,
330 /// Trust ratio = positive_in_degree / (positive_in_degree + negative_in_degree).
331 pub trust_ratio: Array1<f64>,
332 /// Structural balance score of the entire network ∈ [0, 1].
333 pub global_balance_score: f64,
334}
335
336impl TrustCentrality {
337 /// Compute trust centrality measures for a signed adjacency matrix.
338 ///
339 /// # Arguments
340 /// * `adj` — signed adjacency matrix; positive entries = trust, negative = distrust.
341 /// The magnitude is the weight; sign is the sentiment.
342 pub fn compute(adj: &Array2<f64>) -> Result<TrustCentralityResult> {
343 let n = adj.nrows();
344 if n == 0 {
345 return Err(GraphError::InvalidGraph("empty adjacency".into()));
346 }
347
348 let mut net_trust = Array1::<f64>::zeros(n);
349 let mut pos_in = vec![0usize; n];
350 let mut neg_in = vec![0usize; n];
351
352 for i in 0..n {
353 for j in 0..n {
354 let w = adj[[j, i]]; // incoming to i from j
355 if w > 0.0 {
356 net_trust[i] += w;
357 pos_in[i] += 1;
358 } else if w < 0.0 {
359 net_trust[i] += w; // subtracts
360 neg_in[i] += 1;
361 }
362 }
363 }
364
365 let trust_ratio = Array1::from_iter((0..n).map(|i| {
366 let total = pos_in[i] + neg_in[i];
367 if total == 0 {
368 0.5 // neutral
369 } else {
370 pos_in[i] as f64 / total as f64
371 }
372 }));
373
374 // Structural balance score: proportion of signed triangles that are balanced.
375 // A triangle (i,j,k) is balanced if the product of the three signs is positive.
376 let mut balanced = 0u64;
377 let mut total_triangles = 0u64;
378 for i in 0..n {
379 for j in (i + 1)..n {
380 for k in (j + 1)..n {
381 let w_ij = adj[[i, j]];
382 let w_jk = adj[[j, k]];
383 let w_ik = adj[[i, k]];
384 // Only count if all three edges exist (non-zero)
385 if w_ij != 0.0 && w_jk != 0.0 && w_ik != 0.0 {
386 total_triangles += 1;
387 let product = w_ij.signum() * w_jk.signum() * w_ik.signum();
388 if product > 0.0 {
389 balanced += 1;
390 }
391 }
392 }
393 }
394 }
395 let global_balance_score = if total_triangles > 0 {
396 balanced as f64 / total_triangles as f64
397 } else {
398 1.0 // no triangles → trivially balanced
399 };
400
401 Ok(TrustCentralityResult {
402 net_trust,
403 positive_in_degree: pos_in,
404 negative_in_degree: neg_in,
405 trust_ratio,
406 global_balance_score,
407 })
408 }
409
410 /// Compute the propagated trust from a set of source nodes using a
411 /// random-walk-with-restart trust propagation model.
412 ///
413 /// The trust score of node `i` measures how much aggregate trust flows
414 /// to `i` starting from the `sources` via positive edges (negative edges
415 /// act as barriers / trust killers).
416 ///
417 /// # Arguments
418 /// * `adj` — signed adjacency matrix.
419 /// * `sources` — node indices that act as trusted seeds.
420 /// * `alpha` — restart probability (teleportation back to sources) ∈ (0, 1).
421 /// * `max_iter` — maximum power iterations.
422 /// * `tol` — convergence tolerance.
423 pub fn propagated_trust(
424 adj: &Array2<f64>,
425 sources: &[usize],
426 alpha: f64,
427 max_iter: usize,
428 tol: f64,
429 ) -> Result<Array1<f64>> {
430 let n = adj.nrows();
431 if n == 0 {
432 return Err(GraphError::InvalidGraph("empty adjacency".into()));
433 }
434 for &s in sources {
435 if s >= n {
436 return Err(GraphError::InvalidParameter {
437 param: "source node".into(),
438 value: s.to_string(),
439 expected: format!("< {n}"),
440 context: "TrustCentrality::propagated_trust".into(),
441 });
442 }
443 }
444
445 // Build positive-only row-normalised transition matrix
446 // t_ij = adj+[i,j] / sum_k adj+[i,k]
447 let mut trans = Array2::<f64>::zeros((n, n));
448 for i in 0..n {
449 let row_sum: f64 = (0..n).map(|j| adj[[i, j]].max(0.0)).sum();
450 if row_sum > 1e-14 {
451 for j in 0..n {
452 if adj[[i, j]] > 0.0 {
453 trans[[i, j]] = adj[[i, j]] / row_sum;
454 }
455 }
456 }
457 }
458
459 // Personalised PageRank-style trust propagation
460 let mut personalization = Array1::<f64>::zeros(n);
461 for &s in sources {
462 personalization[s] = 1.0;
463 }
464 let src_sum = personalization.sum();
465 if src_sum > 0.0 {
466 personalization.iter_mut().for_each(|x| *x /= src_sum);
467 }
468
469 let mut trust = personalization.clone();
470 for _ in 0..max_iter {
471 // new_trust = (1 - alpha) * T^T * trust + alpha * personalization
472 let mut new_trust = Array1::<f64>::zeros(n);
473 for i in 0..n {
474 for j in 0..n {
475 new_trust[i] += trans[[j, i]] * trust[j];
476 }
477 new_trust[i] = (1.0 - alpha) * new_trust[i] + alpha * personalization[i];
478 }
479 let diff = trust
480 .iter()
481 .zip(new_trust.iter())
482 .map(|(&a, &b)| (a - b).abs())
483 .fold(0.0_f64, f64::max);
484 trust = new_trust;
485 if diff < tol {
486 break;
487 }
488 }
489
490 Ok(trust)
491 }
492}
493
494// ─────────────────────────────────────────────────────────────────────────────
495// CoreDecomposition
496// ─────────────────────────────────────────────────────────────────────────────
497
498/// k-core decomposition and k-shell index for undirected graphs.
499///
500/// The **k-core** of a graph is the maximal subgraph in which every node has
501/// degree at least `k`. The **core number** (or coreness) of a node is the
502/// largest `k` for which the node belongs to the k-core.
503///
504/// The **k-shell** consists of nodes with core number exactly `k`
505/// (in the k-core but not in the (k+1)-core).
506///
507/// ## Algorithm
508/// Iterative degree peeling: repeatedly remove nodes with degree < k,
509/// starting from k=1 upwards.
510///
511/// ## Time complexity
512/// O(m) where m is the number of edges.
513#[derive(Debug, Clone)]
514pub struct CoreDecomposition {
515 /// Core number (coreness) of each node.
516 pub core_numbers: Vec<usize>,
517 /// Shell index = core number (same as core number; kept separately for clarity).
518 pub shell_index: Vec<usize>,
519 /// Maximum core number (degeneracy of the graph).
520 pub max_core: usize,
521 /// Number of nodes.
522 pub num_nodes: usize,
523}
524
525impl CoreDecomposition {
526 /// Compute k-core decomposition from an adjacency matrix.
527 pub fn compute(adj: &Array2<f64>) -> Self {
528 let n = adj.nrows();
529 if n == 0 {
530 return Self {
531 core_numbers: Vec::new(),
532 shell_index: Vec::new(),
533 max_core: 0,
534 num_nodes: 0,
535 };
536 }
537
538 // Build adjacency list (unweighted; any non-zero edge counts)
539 let mut degree: Vec<usize> = (0..n)
540 .map(|i| (0..n).filter(|&j| adj[[i, j]] != 0.0).count())
541 .collect();
542 let mut core_numbers = vec![0usize; n];
543 let mut processed = vec![false; n];
544
545 // Iterative degree-peeling (Batagelj & Zaversnik 2003).
546 //
547 // Canonical implementation: nodes are stored in a sorted array `vert[0..n]`
548 // ordered by degree, with `bin[d]` marking the first index of bin d and
549 // `pos[v]` giving each node's position in `vert`. When a node's degree is
550 // decremented the algorithm swaps it to the front of its current bin
551 // (no duplicates are ever inserted).
552 let max_deg = degree.iter().copied().max().unwrap_or(0);
553
554 // bin[d] = first index in `vert` of the degree-d bucket.
555 // Build by counting nodes per degree, then computing prefix sums.
556 let mut bin = vec![0usize; max_deg + 2];
557 for &d in °ree {
558 bin[d] += 1;
559 }
560 // Convert counts to start positions (bin[d] becomes the first index)
561 let mut start = 0usize;
562 for d in 0..=max_deg {
563 let cnt = bin[d];
564 bin[d] = start;
565 start += cnt;
566 }
567 bin[max_deg + 1] = n;
568
569 // vert[i] = node at position i (sorted by degree ascending)
570 // pos[v] = position of node v in vert
571 let mut vert = vec![0usize; n];
572 let mut pos = vec![0usize; n];
573 let mut tmp_bin = bin.clone();
574 for v in 0..n {
575 let d = degree[v];
576 pos[v] = tmp_bin[d];
577 vert[tmp_bin[d]] = v;
578 tmp_bin[d] += 1;
579 }
580
581 for i in 0..n {
582 let v = vert[i];
583 core_numbers[v] = degree[v];
584 processed[v] = true;
585
586 for u in 0..n {
587 if adj[[v, u]] == 0.0 || processed[u] {
588 continue;
589 }
590 // Only decrement u's degree if it is strictly higher than v's
591 // core number. This is the canonical Batagelj-Zaversnik condition:
592 // if deg[u] == deg[v] they are in the same k-core and u keeps its
593 // current effective degree.
594 if degree[u] <= core_numbers[v] {
595 continue;
596 }
597 // Move u to the front of its current degree-bucket (swap with the
598 // node at bin[du]) then advance bin[du], placing u into the next
599 // lower bin. This keeps `vert` sorted by current degree.
600 let du = degree[u];
601 let pu = pos[u];
602 let pw = bin[du]; // first position of bin du (may be within processed range)
603 let w = vert[pw]; // node currently at that position
604 if u != w {
605 vert[pu] = w;
606 vert[pw] = u;
607 pos[w] = pu;
608 pos[u] = pw;
609 }
610 bin[du] += 1;
611 degree[u] -= 1;
612 }
613 }
614
615 let max_core = core_numbers.iter().copied().max().unwrap_or(0);
616 let shell_index = core_numbers.clone();
617
618 Self {
619 core_numbers,
620 shell_index,
621 max_core,
622 num_nodes: n,
623 }
624 }
625
626 /// Return the set of node indices in the k-core (core number >= k).
627 pub fn k_core_nodes(&self, k: usize) -> Vec<usize> {
628 (0..self.num_nodes)
629 .filter(|&i| self.core_numbers[i] >= k)
630 .collect()
631 }
632
633 /// Return the set of node indices in the k-shell (core number == k).
634 pub fn k_shell_nodes(&self, k: usize) -> Vec<usize> {
635 (0..self.num_nodes)
636 .filter(|&i| self.core_numbers[i] == k)
637 .collect()
638 }
639
640 /// Return the main core (nodes with maximum core number).
641 pub fn main_core_nodes(&self) -> Vec<usize> {
642 self.k_core_nodes(self.max_core)
643 }
644
645 /// Compute the degeneracy ordering (nodes ordered by core number ascending).
646 pub fn degeneracy_ordering(&self) -> Vec<usize> {
647 let mut order: Vec<usize> = (0..self.num_nodes).collect();
648 order.sort_by_key(|&i| self.core_numbers[i]);
649 order
650 }
651
652 /// Compute the coreness distribution: number of nodes at each shell level.
653 pub fn shell_distribution(&self) -> Vec<(usize, usize)> {
654 let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
655 for &c in &self.core_numbers {
656 *counts.entry(c).or_insert(0) += 1;
657 }
658 let mut dist: Vec<(usize, usize)> = counts.into_iter().collect();
659 dist.sort_by_key(|&(k, _)| k);
660 dist
661 }
662}
663
664// ─────────────────────────────────────────────────────────────────────────────
665// EffectiveResistance
666// ─────────────────────────────────────────────────────────────────────────────
667
668/// Effective resistance (resistance distance) matrix.
669///
670/// The effective resistance between nodes `i` and `j` in an undirected weighted
671/// graph is the electrical resistance between `i` and `j` when each edge `(u,v)`
672/// with weight `w` is replaced by a resistor of resistance `1/w`.
673///
674/// ## Computation
675///
676/// For a connected graph with Laplacian `L`, the Moore-Penrose pseudo-inverse
677/// `L⁺` satisfies:
678///
679/// `R(i,j) = L⁺[i,i] + L⁺[j,j] − 2 L⁺[i,j]`
680///
681/// where `L⁺ = (L + J/n)⁻¹ − J/n` with `J` the all-ones matrix.
682///
683/// ## Properties
684/// - `R(i,j) ≥ 0` with equality iff `i = j`.
685/// - The triangle inequality holds: `R(i,j) ≤ R(i,k) + R(k,j)` (metric).
686/// - Captures both path length and the number of parallel paths (redundancy).
687///
688/// ## Reference
689/// Klein & Randić (1993). "Resistance distance." *J. Math. Chem.*, 12, 81–95.
690#[derive(Debug, Clone)]
691pub struct EffectiveResistance {
692 /// Pseudo-inverse of the Laplacian, shape `(n, n)`.
693 pub l_plus: Array2<f64>,
694 /// Pre-computed resistance matrix (`R[i,j]` = effective resistance), shape `(n, n)`.
695 pub resistance_matrix: Array2<f64>,
696 /// Number of nodes.
697 pub num_nodes: usize,
698}
699
700impl EffectiveResistance {
701 /// Compute the effective resistance matrix from a weighted adjacency matrix.
702 ///
703 /// # Note
704 /// The graph must be connected. If it is not, the result is only valid
705 /// within connected components.
706 pub fn compute(adj: &Array2<f64>) -> Result<Self> {
707 let n = adj.nrows();
708 if n == 0 {
709 return Err(GraphError::InvalidGraph("empty adjacency".into()));
710 }
711
712 // Build Laplacian L = D - A
713 let mut lap = Array2::<f64>::zeros((n, n));
714 for i in 0..n {
715 let deg: f64 = adj.row(i).iter().map(|&x| x.abs()).sum();
716 lap[[i, i]] = deg;
717 for j in 0..n {
718 if i != j {
719 lap[[i, j]] = -adj[[i, j]];
720 }
721 }
722 }
723
724 // Compute pseudo-inverse: L⁺ = (L + J/n)⁻¹ − J/n
725 // where J is the all-ones matrix.
726 let mut m = lap.clone();
727 let inv_n = 1.0 / n as f64;
728 for i in 0..n {
729 for j in 0..n {
730 m[[i, j]] += inv_n;
731 }
732 }
733
734 // Invert m via Gaussian elimination with partial pivoting
735 let m_inv = invert_matrix(&m)?;
736
737 // L⁺ = m_inv - J/n
738 let mut l_plus = m_inv;
739 for i in 0..n {
740 for j in 0..n {
741 l_plus[[i, j]] -= inv_n;
742 }
743 }
744
745 // Compute resistance matrix: R[i,j] = L⁺[i,i] + L⁺[j,j] - 2*L⁺[i,j]
746 let mut r = Array2::<f64>::zeros((n, n));
747 for i in 0..n {
748 for j in 0..n {
749 r[[i, j]] = (l_plus[[i, i]] + l_plus[[j, j]] - 2.0 * l_plus[[i, j]]).max(0.0);
750 }
751 }
752
753 Ok(Self {
754 l_plus,
755 resistance_matrix: r,
756 num_nodes: n,
757 })
758 }
759
760 /// Return the effective resistance between nodes `i` and `j`.
761 pub fn resistance(&self, i: usize, j: usize) -> Result<f64> {
762 let n = self.num_nodes;
763 if i >= n || j >= n {
764 return Err(GraphError::InvalidParameter {
765 param: "i or j".into(),
766 value: format!("{i}, {j}"),
767 expected: format!("< {n}"),
768 context: "EffectiveResistance::resistance".into(),
769 });
770 }
771 Ok(self.resistance_matrix[[i, j]])
772 }
773
774 /// Compute the Kirchhoff index (sum of all pairwise effective resistances).
775 ///
776 /// `Kf = Σ_{i<j} R(i,j)`
777 pub fn kirchhoff_index(&self) -> f64 {
778 let n = self.num_nodes;
779 let mut kf = 0.0_f64;
780 for i in 0..n {
781 for j in (i + 1)..n {
782 kf += self.resistance_matrix[[i, j]];
783 }
784 }
785 kf
786 }
787
788 /// Return the node with minimum average effective resistance
789 /// (the "resistance centre" of the graph).
790 pub fn resistance_centre(&self) -> usize {
791 let n = self.num_nodes;
792 let avg_resistance: Vec<f64> = (0..n)
793 .map(|i| (0..n).map(|j| self.resistance_matrix[[i, j]]).sum::<f64>() / n as f64)
794 .collect();
795 avg_resistance
796 .iter()
797 .enumerate()
798 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
799 .map(|(i, _)| i)
800 .unwrap_or(0)
801 }
802
803 /// Return the effective resistance diameter (maximum pairwise resistance).
804 pub fn resistance_diameter(&self) -> f64 {
805 self.resistance_matrix
806 .iter()
807 .cloned()
808 .fold(0.0_f64, f64::max)
809 }
810}
811
812// ─────────────────────────────────────────────────────────────────────────────
813// Helper: matrix inversion via Gaussian elimination
814// ─────────────────────────────────────────────────────────────────────────────
815
816/// Invert an `n × n` matrix via Gaussian elimination with partial pivoting.
817fn invert_matrix(a: &Array2<f64>) -> Result<Array2<f64>> {
818 let n = a.nrows();
819 // Augmented matrix [A | I]
820 let mut aug: Vec<Vec<f64>> = (0..n)
821 .map(|i| {
822 let mut row: Vec<f64> = (0..n).map(|j| a[[i, j]]).collect();
823 row.extend((0..n).map(|j| if i == j { 1.0 } else { 0.0 }));
824 row
825 })
826 .collect();
827
828 for col in 0..n {
829 // Partial pivot
830 let mut max_row = col;
831 let mut max_val = aug[col][col].abs();
832 for row in (col + 1)..n {
833 if aug[row][col].abs() > max_val {
834 max_val = aug[row][col].abs();
835 max_row = row;
836 }
837 }
838 if max_val < 1e-14 {
839 return Err(GraphError::LinAlgError {
840 operation: "matrix_inversion".into(),
841 details: "matrix is singular or near-singular".into(),
842 });
843 }
844 aug.swap(col, max_row);
845
846 // Eliminate
847 let pivot = aug[col][col];
848 for k in col..(2 * n) {
849 aug[col][k] /= pivot;
850 }
851 for row in 0..n {
852 if row != col {
853 let factor = aug[row][col];
854 for k in col..(2 * n) {
855 let val = aug[col][k];
856 aug[row][k] -= factor * val;
857 }
858 }
859 }
860 }
861
862 // Extract the right half (inverse)
863 let mut inv = Array2::<f64>::zeros((n, n));
864 for i in 0..n {
865 for j in 0..n {
866 inv[[i, j]] = aug[i][n + j];
867 }
868 }
869 Ok(inv)
870}
871
872// ─────────────────────────────────────────────────────────────────────────────
873// Tests
874// ─────────────────────────────────────────────────────────────────────────────
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 fn complete4() -> Array2<f64> {
881 let mut adj = Array2::<f64>::zeros((4, 4));
882 for i in 0..4 {
883 for j in 0..4 {
884 if i != j {
885 adj[[i, j]] = 1.0;
886 }
887 }
888 }
889 adj
890 }
891
892 fn path4() -> Array2<f64> {
893 let mut adj = Array2::<f64>::zeros((4, 4));
894 adj[[0, 1]] = 1.0;
895 adj[[1, 0]] = 1.0;
896 adj[[1, 2]] = 1.0;
897 adj[[2, 1]] = 1.0;
898 adj[[2, 3]] = 1.0;
899 adj[[3, 2]] = 1.0;
900 adj
901 }
902
903 #[test]
904 fn test_voterank_returns_k_nodes() {
905 let adj = complete4();
906 let vr = VoteRankCentrality::new(3);
907 let spreaders = vr.compute(&adj).unwrap();
908 assert_eq!(spreaders.len(), 3);
909 // All selected nodes should be distinct
910 let mut uniq = spreaders.clone();
911 uniq.sort();
912 uniq.dedup();
913 assert_eq!(uniq.len(), 3);
914 }
915
916 #[test]
917 fn test_voterank_with_scores() {
918 let adj = complete4();
919 let vr = VoteRankCentrality::new(2);
920 let results = vr.compute_with_scores(&adj).unwrap();
921 assert_eq!(results.len(), 2);
922 for (_, score) in &results {
923 assert!(*score > 0.0);
924 }
925 }
926
927 #[test]
928 fn test_hits_undirected_symmetric() {
929 // For undirected graph, hubs ~ authorities (both = principal eigenvector)
930 let adj = complete4();
931 let hits = HITSCentrality::new(100, 1e-10);
932 let (h, a) = hits.compute(&adj).unwrap();
933 assert_eq!(h.len(), 4);
934 assert_eq!(a.len(), 4);
935 // All nodes should have equal hub/authority score on K4
936 let h_vals: Vec<f64> = h.to_vec();
937 let max_h = h_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
938 let min_h = h_vals.iter().cloned().fold(f64::INFINITY, f64::min);
939 assert!(
940 (max_h - min_h).abs() < 1e-6,
941 "Hub scores should be equal on K4: max={max_h}, min={min_h}"
942 );
943 }
944
945 #[test]
946 fn test_hits_directed_hub_auth() {
947 // Star graph: node 0 → 1, 2, 3 (directed out-star)
948 let mut adj = Array2::<f64>::zeros((4, 4));
949 adj[[0, 1]] = 1.0;
950 adj[[0, 2]] = 1.0;
951 adj[[0, 3]] = 1.0;
952 let hits = HITSCentrality::new(100, 1e-10);
953 let (h, a) = hits.compute(&adj).unwrap();
954 // Node 0 should be the top hub; nodes 1,2,3 top authorities
955 assert!(h[0] > h[1].max(h[2]).max(h[3]));
956 assert!(a[1] > a[0]);
957 }
958
959 #[test]
960 fn test_trust_centrality_balanced_triangle() {
961 // Balanced signed triangle: all positive
962 let mut adj = Array2::<f64>::zeros((3, 3));
963 adj[[0, 1]] = 1.0;
964 adj[[1, 0]] = 1.0;
965 adj[[1, 2]] = 1.0;
966 adj[[2, 1]] = 1.0;
967 adj[[0, 2]] = 1.0;
968 adj[[2, 0]] = 1.0;
969 let res = TrustCentrality::compute(&adj).unwrap();
970 assert_eq!(res.global_balance_score, 1.0); // all positive → balanced
971 for &nt in res.net_trust.iter() {
972 assert!(nt > 0.0);
973 }
974 }
975
976 #[test]
977 fn test_trust_centrality_imbalanced() {
978 // Unbalanced triangle: two positive, one negative
979 let mut adj = Array2::<f64>::zeros((3, 3));
980 adj[[0, 1]] = 1.0;
981 adj[[1, 0]] = 1.0;
982 adj[[1, 2]] = 1.0;
983 adj[[2, 1]] = 1.0;
984 adj[[0, 2]] = -1.0;
985 adj[[2, 0]] = -1.0;
986 let res = TrustCentrality::compute(&adj).unwrap();
987 // One negative edge → some distrust
988 assert!((res.global_balance_score - 0.0).abs() < 1e-9);
989 }
990
991 #[test]
992 fn test_core_decomposition_path() {
993 let adj = path4();
994 let core = CoreDecomposition::compute(&adj);
995 // In a path graph, all nodes have core number 1
996 for &c in &core.core_numbers {
997 assert_eq!(c, 1, "All path nodes should have core number 1");
998 }
999 assert_eq!(core.max_core, 1);
1000 }
1001
1002 #[test]
1003 fn test_core_decomposition_complete() {
1004 let adj = complete4();
1005 let core = CoreDecomposition::compute(&adj);
1006 // K4: all nodes have core number 3 (= n-1)
1007 for &c in &core.core_numbers {
1008 assert_eq!(c, 3);
1009 }
1010 assert_eq!(core.max_core, 3);
1011 assert_eq!(core.main_core_nodes().len(), 4);
1012 }
1013
1014 #[test]
1015 fn test_effective_resistance_path() {
1016 let adj = path4();
1017 let er = EffectiveResistance::compute(&adj).unwrap();
1018 // For a path graph, R(0,k) = k (one resistor per hop, weight=1)
1019 let r01 = er.resistance(0, 1).unwrap();
1020 let r02 = er.resistance(0, 2).unwrap();
1021 let r03 = er.resistance(0, 3).unwrap();
1022 assert!((r01 - 1.0).abs() < 1e-8, "R(0,1)={r01}");
1023 assert!((r02 - 2.0).abs() < 1e-8, "R(0,2)={r02}");
1024 assert!((r03 - 3.0).abs() < 1e-8, "R(0,3)={r03}");
1025 }
1026
1027 #[test]
1028 fn test_effective_resistance_complete() {
1029 let adj = complete4();
1030 let er = EffectiveResistance::compute(&adj).unwrap();
1031 // In K_n, R(i,j) = 2/n for i != j
1032 let r01 = er.resistance(0, 1).unwrap();
1033 assert!((r01 - 2.0 / 4.0).abs() < 1e-8, "R(0,1) in K4 = {r01}");
1034 }
1035
1036 #[test]
1037 fn test_kirchhoff_index() {
1038 let adj = path4();
1039 let er = EffectiveResistance::compute(&adj).unwrap();
1040 let kf = er.kirchhoff_index();
1041 // Kirchhoff index of path P_4: known to be Kf = sum R(i,j) for i<j
1042 // R(0,1)=1, R(0,2)=2, R(0,3)=3, R(1,2)=1, R(1,3)=2, R(2,3)=1 → Kf = 10
1043 assert!((kf - 10.0).abs() < 1e-7, "Kf(P4) = {kf}");
1044 }
1045
1046 #[test]
1047 fn test_resistance_centre_path() {
1048 let adj = path4();
1049 let er = EffectiveResistance::compute(&adj).unwrap();
1050 let centre = er.resistance_centre();
1051 // For path graph, nodes 1 and 2 are more central than 0 and 3
1052 assert!(centre == 1 || centre == 2);
1053 }
1054
1055 #[test]
1056 fn test_propagated_trust() {
1057 let mut adj = Array2::<f64>::zeros((4, 4));
1058 adj[[0, 1]] = 1.0;
1059 adj[[1, 2]] = 0.8;
1060 adj[[2, 3]] = 0.6;
1061 let trust = TrustCentrality::propagated_trust(&adj, &[0], 0.15, 100, 1e-9).unwrap();
1062 // Trust should be highest at source and decay outwards
1063 assert!(trust[0] > trust[3], "Source trust should be > distal trust");
1064 }
1065}