Skip to main content

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 &degree {
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}