Skip to main content

rust_igraph/algorithms/properties/
eigenvector.rs

1//! Eigenvector centrality (ALGO-PR-012 / PR-012b).
2//!
3//! Counterpart of `igraph_eigenvector_centrality()` from
4//! `references/igraph/src/centrality/eigenvector.c`. The eigenvector
5//! centrality of `v` is its component in the dominant eigenvector of
6//! the (possibly weighted, possibly directed) adjacency matrix —
7//! largest real-part eigenvalue, real and non-negative by
8//! Perron-Frobenius for strongly-connected non-negative weights.
9//!
10//! Implemented via shifted power iteration on `(M + σI)`:
11//! `x_new[v] = σ·x_old[v] + Σ_{u ~ v} m_{u,v}·x_old[u]`, then normalize
12//! so `max|x| == 1`. The shift breaks the `±λ` bipartite symmetry of
13//! symmetric matrices and turns the "largest-real-part" eigenvalue of
14//! `M` into the unique "largest-magnitude" eigenvalue of `M + σI`, so
15//! plain power iteration converges to it for non-negative `M`. Iterate
16//! until L1 change < `1e-12` or 5000 iterations.
17//!
18//! ## Variants
19//!
20//! - [`eigenvector_centrality`] — undirected, unweighted; backward
21//!   compatible (returns `Vec<f64>` for the vector only).
22//! - [`eigenvector_centrality_weighted`] — undirected, weighted.
23//! - [`eigenvector_centrality_directed`] — directed, unweighted.
24//! - [`eigenvector_centrality_directed_weighted`] — directed, weighted.
25//! - [`eigenvector_centrality_full`] — single-entry master function
26//!   matching upstream's signature `(g, mode, weights?) -> {vec, λ}`.
27//!
28//! ## Directed-mode convention
29//!
30//! For mode = [`EigenvectorMode::Out`] (the standard convention; matches
31//! upstream's `IGRAPH_OUT`), the centrality of `v` is proportional to
32//! the sum of centralities of vertices pointing into `v`, i.e. we walk
33//! `v`'s incoming edges. For [`EigenvectorMode::In`] we walk outgoing
34//! edges. [`EigenvectorMode::All`] treats the graph as undirected and
35//! falls through to the symmetric path.
36//!
37//! ## DAG short-circuit
38//!
39//! When the graph is a DAG and weights are non-negative, the adjacency
40//! matrix is nilpotent and every eigenvalue is zero. ARPACK behaviour
41//! is then non-deterministic; matching upstream, we return `eigenvalue
42//! = 0` and a vector of `1`s on sinks (for [`EigenvectorMode::Out`]) /
43//! sources (for [`EigenvectorMode::In`]) and `0`s elsewhere. See
44//! <https://github.com/igraph/igraph/issues/2679> for upstream
45//! rationale.
46
47use crate::core::{Graph, IgraphError, IgraphResult};
48
49// Tighter than the L1-convergence default we use elsewhere because
50// rustdoc/conformance compare bit-precise vs python-igraph's ARPACK.
51const DEFAULT_EPS: f64 = 1e-14;
52const DEFAULT_MAX_ITER: usize = 5000;
53
54// Tolerance for the shifted-power-iter (slightly looser since we run on
55// `(M + σI)` and need to recover λ ≈ ρ - σ which loses ~log10(σ/ρ) bits).
56const SHIFTED_EPS: f64 = 1e-12;
57const SHIFTED_MAX_ITER: usize = 5000;
58
59/// How to consider edge directions for [`eigenvector_centrality_full`]
60/// and friends. Mirrors upstream's `IGRAPH_OUT` / `IGRAPH_IN` /
61/// `IGRAPH_ALL`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum EigenvectorMode {
64    /// Standard convention: centrality of `v` is proportional to the
65    /// sum of centralities of vertices pointing to `v` (we walk
66    /// incoming edges of `v`). Equivalent to ARPACK's "left
67    /// eigenvector of the adjacency matrix".
68    Out,
69    /// Reverse convention: centrality of `v` is proportional to the
70    /// sum of centralities of vertices `v` points to (we walk outgoing
71    /// edges of `v`). Equivalent to ARPACK's "right eigenvector".
72    In,
73    /// Treat the graph as undirected and use the symmetric eigenvector
74    /// path. Mandatory for undirected input.
75    All,
76}
77
78/// Output of [`eigenvector_centrality_full`]: the normalised
79/// centrality vector and the dominant eigenvalue of the (possibly
80/// shifted-back) adjacency matrix.
81#[derive(Debug, Clone, PartialEq)]
82pub struct EigenvectorScores {
83    /// Centrality per vertex, length `vcount()`. Max-absolute element
84    /// is exactly `1.0` (matching python-igraph), unless all entries
85    /// are zero (empty / DAG / all-zero-weight sentinels).
86    pub vector: Vec<f64>,
87    /// Largest-real-part eigenvalue of the adjacency / weighted
88    /// adjacency matrix. `0.0` for the empty-edge, all-zero-weight,
89    /// and DAG sentinel cases.
90    pub eigenvalue: f64,
91}
92
93/// Backward-compatible undirected, unweighted entry point.
94///
95/// Returns just the centrality vector (max-1 normalised). Directed
96/// graphs return [`IgraphError::Unsupported`] — use
97/// [`eigenvector_centrality_directed`] or [`eigenvector_centrality_full`]
98/// for the directed paths.
99///
100/// Counterpart of `igraph_eigenvector_centrality(g, &v, NULL, IGRAPH_ALL,
101/// NULL, NULL)`.
102///
103/// # Examples
104///
105/// ```
106/// use rust_igraph::{Graph, eigenvector_centrality};
107///
108/// // Triangle: every vertex has identical centrality 1.0.
109/// let mut g = Graph::with_vertices(3);
110/// g.add_edge(0, 1).unwrap();
111/// g.add_edge(1, 2).unwrap();
112/// g.add_edge(2, 0).unwrap();
113/// let ec = eigenvector_centrality(&g).unwrap();
114/// assert!((ec[0] - 1.0).abs() < 1e-9);
115/// assert!((ec[1] - 1.0).abs() < 1e-9);
116/// assert!((ec[2] - 1.0).abs() < 1e-9);
117/// ```
118pub fn eigenvector_centrality(graph: &Graph) -> IgraphResult<Vec<f64>> {
119    if graph.is_directed() {
120        return Err(IgraphError::Unsupported(
121            "directed eigenvector_centrality — use eigenvector_centrality_directed or _full",
122        ));
123    }
124    undirected_unweighted(graph).map(|s| s.vector)
125}
126
127/// Undirected weighted eigenvector centrality.
128///
129/// `weights.len()` must equal `graph.ecount()`. Returns the
130/// max-1-normalised vector and the dominant eigenvalue of the weighted
131/// symmetric adjacency `W[i,j] = Σ_{e: i~j} w_e`. Non-negative weights
132/// are assumed (negative weights are accepted but a Perron-Frobenius
133/// guarantee no longer applies and entries may be negative; matches
134/// upstream).
135///
136/// All-zero weights and edge-less graphs both return a vector of `1`s
137/// and `eigenvalue = 0`, matching upstream's sentinel.
138///
139/// Counterpart of `igraph_eigenvector_centrality(g, &v, &λ, IGRAPH_ALL,
140/// weights, NULL)`.
141///
142/// # Examples
143///
144/// ```
145/// use rust_igraph::{Graph, eigenvector_centrality_weighted};
146///
147/// // Weighted star K_{1,4} with unit weights → leaf:centre = 1:2.
148/// let mut g = Graph::with_vertices(5);
149/// for v in 1..5 {
150///     g.add_edge(0, v).unwrap();
151/// }
152/// let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
153/// assert!((s.vector[0] - 1.0).abs() < 1e-9);
154/// assert!((s.vector[1] - 0.5).abs() < 1e-9);
155/// assert!((s.eigenvalue - 2.0).abs() < 1e-6);
156/// ```
157pub fn eigenvector_centrality_weighted(
158    graph: &Graph,
159    weights: &[f64],
160) -> IgraphResult<EigenvectorScores> {
161    if graph.is_directed() {
162        return Err(IgraphError::Unsupported(
163            "directed eigenvector_centrality_weighted — use eigenvector_centrality_directed_weighted",
164        ));
165    }
166    validate_weights(graph, weights)?;
167    undirected_weighted(graph, Some(weights))
168}
169
170/// Directed unweighted eigenvector centrality.
171///
172/// `mode` controls which side of the adjacency matrix's left/right
173/// eigenvector is returned. Undirected input is rejected — use
174/// [`eigenvector_centrality`] instead.
175///
176/// Counterpart of `igraph_eigenvector_centrality(g, &v, &λ, mode, NULL,
177/// NULL)` on directed graphs.
178///
179/// # Examples
180///
181/// ```
182/// use rust_igraph::{Graph, EigenvectorMode, eigenvector_centrality_directed};
183///
184/// // Directed 4-cycle 0→1→2→3→0 plus chord 1→3.
185/// let mut g = Graph::new(4, true).unwrap();
186/// g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)]).unwrap();
187/// let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
188/// // Eigenvalue ≈ 1.22074 (real root of x³ = 1 + x²).
189/// assert!((s.eigenvalue - 1.22074).abs() < 1e-3);
190/// ```
191pub fn eigenvector_centrality_directed(
192    graph: &Graph,
193    mode: EigenvectorMode,
194) -> IgraphResult<EigenvectorScores> {
195    eigenvector_centrality_full(graph, mode, None)
196}
197
198/// Directed weighted eigenvector centrality.
199///
200/// Like [`eigenvector_centrality_directed`] but with per-edge weights.
201/// Weights of parallel edges are summed (`W[i,j] = Σ_{e: i→j} w_e`).
202///
203/// Counterpart of `igraph_eigenvector_centrality(g, &v, &λ, mode,
204/// weights, NULL)` on directed graphs.
205///
206/// # Examples
207///
208/// ```
209/// use rust_igraph::{Graph, eigenvector_centrality_directed_weighted, EigenvectorMode};
210///
211/// let mut g = Graph::new(3, true).unwrap();
212/// g.add_edge(0, 1).unwrap();
213/// g.add_edge(1, 2).unwrap();
214/// g.add_edge(2, 0).unwrap();
215/// let r = eigenvector_centrality_directed_weighted(&g, EigenvectorMode::All, &[1.0, 1.0, 1.0]).unwrap();
216/// assert_eq!(r.vector.len(), 3);
217/// ```
218pub fn eigenvector_centrality_directed_weighted(
219    graph: &Graph,
220    mode: EigenvectorMode,
221    weights: &[f64],
222) -> IgraphResult<EigenvectorScores> {
223    eigenvector_centrality_full(graph, mode, Some(weights))
224}
225
226/// Master entry point matching upstream's signature.
227///
228/// Dispatches to the undirected / directed and weighted / unweighted
229/// branches based on `graph.is_directed()`, `mode`, and `weights`.
230/// Validates `weights.len() == graph.ecount()` when supplied.
231///
232/// Counterpart of `igraph_eigenvector_centrality(g, &v, &λ, mode,
233/// weights, NULL)` — the C-level "do everything" signature.
234///
235/// # Examples
236///
237/// ```
238/// use rust_igraph::{Graph, eigenvector_centrality_full, EigenvectorMode};
239///
240/// let mut g = Graph::with_vertices(3);
241/// g.add_edge(0, 1).unwrap();
242/// g.add_edge(1, 2).unwrap();
243/// g.add_edge(2, 0).unwrap();
244/// let r = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
245/// assert_eq!(r.vector.len(), 3);
246/// ```
247pub fn eigenvector_centrality_full(
248    graph: &Graph,
249    mode: EigenvectorMode,
250    weights: Option<&[f64]>,
251) -> IgraphResult<EigenvectorScores> {
252    if let Some(w) = weights {
253        validate_weights(graph, w)?;
254    }
255    // Undirected input forces ALL mode (matches upstream).
256    let effective_mode = if graph.is_directed() {
257        mode
258    } else {
259        EigenvectorMode::All
260    };
261    match effective_mode {
262        EigenvectorMode::All => match weights {
263            None => undirected_unweighted(graph),
264            Some(w) => undirected_weighted(graph, Some(w)),
265        },
266        EigenvectorMode::Out | EigenvectorMode::In => directed_path(graph, effective_mode, weights),
267    }
268}
269
270// ---------- internal: shared validation ----------
271
272fn validate_weights(graph: &Graph, weights: &[f64]) -> IgraphResult<()> {
273    let m = graph.ecount();
274    if weights.len() != m {
275        return Err(IgraphError::InvalidArgument(format!(
276            "weights vector length ({}) not equal to number of edges ({})",
277            weights.len(),
278            m
279        )));
280    }
281    Ok(())
282}
283
284// ---------- internal: undirected unweighted ----------
285
286fn undirected_unweighted(graph: &Graph) -> IgraphResult<EigenvectorScores> {
287    let n = graph.vcount();
288    let n_us = n as usize;
289    if n == 0 {
290        return Ok(EigenvectorScores {
291            vector: Vec::new(),
292            eigenvalue: 0.0,
293        });
294    }
295    if graph.ecount() == 0 {
296        return Ok(EigenvectorScores {
297            vector: vec![1.0; n_us],
298            eigenvalue: 0.0,
299        });
300    }
301    if n == 1 {
302        return Ok(EigenvectorScores {
303            vector: vec![1.0],
304            eigenvalue: 0.0,
305        });
306    }
307
308    let mut adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
309    for v in 0..n {
310        adj.push(graph.neighbors(v)?);
311    }
312
313    let mut x = vec![1.0_f64; n_us];
314    let mut x_new = vec![0.0_f64; n_us];
315
316    for _ in 0..DEFAULT_MAX_ITER {
317        // x_new = (A + I) · x — shift breaks ±λ bipartite symmetry.
318        for v in 0..n_us {
319            let mut sum = x[v];
320            for &u in &adj[v] {
321                sum += x[u as usize];
322            }
323            x_new[v] = sum;
324        }
325        let max = x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
326        if max > 0.0 {
327            for slot in &mut x_new {
328                *slot /= max;
329            }
330        }
331        let mut diff = 0.0_f64;
332        for v in 0..n_us {
333            diff += (x_new[v] - x[v]).abs();
334        }
335        std::mem::swap(&mut x, &mut x_new);
336        if diff < DEFAULT_EPS {
337            break;
338        }
339    }
340
341    // Eigenvalue from Rayleigh quotient: λ = (xᵀ·A·x) / (xᵀ·x).
342    let mut ax = vec![0.0_f64; n_us];
343    for v in 0..n_us {
344        let mut sum = 0.0_f64;
345        for &u in &adj[v] {
346            sum += x[u as usize];
347        }
348        ax[v] = sum;
349    }
350    let num: f64 = x.iter().zip(ax.iter()).map(|(a, b)| a * b).sum();
351    let den: f64 = x.iter().map(|a| a * a).sum();
352    let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
353
354    // Sign cleanup: drop tiny negatives that come from numerical drift
355    // (only valid because A here is non-negative).
356    for slot in &mut x {
357        if *slot < 0.0 {
358            *slot = 0.0;
359        }
360    }
361
362    Ok(EigenvectorScores {
363        vector: x,
364        eigenvalue,
365    })
366}
367
368// ---------- internal: undirected weighted ----------
369
370#[allow(
371    clippy::too_many_lines,
372    clippy::many_single_char_names,
373    clippy::cast_possible_truncation
374)]
375fn undirected_weighted(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<EigenvectorScores> {
376    let n = graph.vcount();
377    let n_us = n as usize;
378    let m = graph.ecount();
379    if n == 0 {
380        return Ok(EigenvectorScores {
381            vector: Vec::new(),
382            eigenvalue: 0.0,
383        });
384    }
385    if m == 0 {
386        return Ok(EigenvectorScores {
387            vector: vec![1.0; n_us],
388            eigenvalue: 0.0,
389        });
390    }
391    if let Some(w) = weights {
392        if w.iter().all(|&x| x == 0.0) {
393            return Ok(EigenvectorScores {
394                vector: vec![1.0; n_us],
395                eigenvalue: 0.0,
396            });
397        }
398    }
399    if n == 1 {
400        return Ok(EigenvectorScores {
401            vector: vec![1.0],
402            eigenvalue: 0.0,
403        });
404    }
405
406    let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
407
408    // Pre-cache incident edges per vertex (undirected → both endpoints
409    // appear in their respective incidence lists).
410    let mut inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
411    for v in 0..n {
412        inc.push(graph.incident(v)?);
413    }
414
415    // Seed: weighted strength fallback to degree if zero strength (matches C).
416    let mut x = vec![0.0_f64; n_us];
417    for v in 0..n {
418        let v_us = v as usize;
419        let mut strength = 0.0_f64;
420        for &e in &inc[v_us] {
421            let w = weights.map_or(1.0, |ws| ws[e as usize]);
422            strength += w;
423        }
424        if strength != 0.0 {
425            x[v_us] = strength.abs();
426        } else if !negative_weights {
427            x[v_us] = 0.0;
428        } else {
429            // Negative weights can produce zero strength even on
430            // non-zero-degree vertices; seed those with 1.
431            x[v_us] = if inc[v_us].is_empty() { 0.0 } else { 1.0 };
432        }
433    }
434    // Make sure at least one entry is non-zero so power iter has signal.
435    if x.iter().all(|&y| y == 0.0) {
436        x.fill(1.0);
437    }
438    // Normalise seed to max=1 to keep numerics tame.
439    let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
440    if max0 > 0.0 {
441        for slot in &mut x {
442            *slot /= max0;
443        }
444    }
445
446    // Shifted power iter on (W + σI), σ = 1 suffices since W is bounded
447    // and we just need to break ±λ symmetry. For negative weights, this
448    // doesn't guarantee convergence to largest-real, but matches the
449    // upstream "best effort" contract.
450    let shift = 1.0_f64;
451    let mut x_new = vec![0.0_f64; n_us];
452
453    for _ in 0..SHIFTED_MAX_ITER {
454        // x_new = σ·x + W·x  (W·x walks incident edges with weight w_e)
455        for v in 0..n_us {
456            let mut sum = shift * x[v];
457            for &e in &inc[v] {
458                let other = graph.edge_other(e, v as u32)?;
459                let w = weights.map_or(1.0, |ws| ws[e as usize]);
460                sum += w * x[other as usize];
461            }
462            x_new[v] = sum;
463        }
464        // For negative-weight case, track signed component with greatest
465        // magnitude so we converge to largest real eigenvalue, not just
466        // largest magnitude. For positive weights this is equivalent to
467        // max abs.
468        let pivot = if negative_weights {
469            let mut best = 0.0_f64;
470            for &v in &x_new {
471                if v.abs() > best.abs() {
472                    best = v;
473                }
474            }
475            best
476        } else {
477            x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
478        };
479        if pivot != 0.0 {
480            for slot in &mut x_new {
481                *slot /= pivot;
482            }
483        }
484        let mut diff = 0.0_f64;
485        for v in 0..n_us {
486            diff += (x_new[v] - x[v]).abs();
487        }
488        std::mem::swap(&mut x, &mut x_new);
489        if diff < SHIFTED_EPS {
490            break;
491        }
492    }
493
494    // Rayleigh quotient on the original W (no shift): λ = xᵀ·W·x / xᵀ·x
495    let mut wx = vec![0.0_f64; n_us];
496    for v in 0..n_us {
497        let mut sum = 0.0_f64;
498        for &e in &inc[v] {
499            let other = graph.edge_other(e, v as u32)?;
500            let w = weights.map_or(1.0, |ws| ws[e as usize]);
501            sum += w * x[other as usize];
502        }
503        wx[v] = sum;
504    }
505    let num: f64 = x.iter().zip(wx.iter()).map(|(a, b)| a * b).sum();
506    let den: f64 = x.iter().map(|a| a * a).sum();
507    let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
508
509    // Renormalise to max-abs = 1 (Rayleigh-quotient pass may have
510    // produced a vector whose dominant component is signed).
511    let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
512    if max > 0.0 {
513        for slot in &mut x {
514            *slot /= max;
515        }
516    }
517    if !negative_weights {
518        for slot in &mut x {
519            if *slot < 0.0 {
520                *slot = 0.0;
521            }
522        }
523    }
524
525    Ok(EigenvectorScores {
526        vector: x,
527        eigenvalue,
528    })
529}
530
531// ---------- internal: directed (with optional weights) ----------
532
533#[allow(
534    clippy::too_many_lines,
535    clippy::many_single_char_names,
536    clippy::cast_possible_truncation,
537    clippy::needless_range_loop
538)]
539fn directed_path(
540    graph: &Graph,
541    mode: EigenvectorMode,
542    weights: Option<&[f64]>,
543) -> IgraphResult<EigenvectorScores> {
544    let n = graph.vcount();
545    let n_us = n as usize;
546    let m = graph.ecount();
547    if n == 0 {
548        return Ok(EigenvectorScores {
549            vector: Vec::new(),
550            eigenvalue: 0.0,
551        });
552    }
553    if m == 0 {
554        return Ok(EigenvectorScores {
555            vector: vec![1.0; n_us],
556            eigenvalue: 0.0,
557        });
558    }
559    if let Some(w) = weights {
560        if w.iter().all(|&x| x == 0.0) {
561            return Ok(EigenvectorScores {
562                vector: vec![1.0; n_us],
563                eigenvalue: 0.0,
564            });
565        }
566    }
567
568    let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
569
570    // DAG short-circuit (only valid for non-negative weights — see
571    // upstream comment block on lines 350-369). Returns 1s on sinks
572    // (Out) / sources (In) and 0s elsewhere.
573    if !negative_weights && crate::algorithms::properties::is_dag::is_dag(graph) {
574        return dag_sentinel(graph, mode, weights);
575    }
576
577    // For Out: centrality of v ∝ Σ_{u: u→v} c_u, so we walk IN-edges of v.
578    // For In:  centrality of v ∝ Σ_{u: v→u} c_u, so we walk OUT-edges of v.
579    // Pre-build per-vertex (edge_id, neighbour) list.
580    let mut walk: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_us];
581    let m_u32 = u32::try_from(m)
582        .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
583    match mode {
584        EigenvectorMode::Out => {
585            for e in 0..m_u32 {
586                let (u, v) = graph.edge(e)?;
587                walk[v as usize].push((e, u));
588            }
589        }
590        EigenvectorMode::In => {
591            for e in 0..m_u32 {
592                let (u, v) = graph.edge(e)?;
593                walk[u as usize].push((e, v));
594            }
595        }
596        EigenvectorMode::All => unreachable!("All is dispatched to undirected_*"),
597    }
598
599    // Seed: in-strength (Out) / out-strength (In). Fallback to in-degree
600    // when negative weights produce zero strength on a non-empty list.
601    let mut x = vec![0.0_f64; n_us];
602    for v in 0..n_us {
603        let mut strength = 0.0_f64;
604        for &(e, _) in &walk[v] {
605            let w = weights.map_or(1.0, |ws| ws[e as usize]);
606            strength += w;
607        }
608        if strength != 0.0 {
609            x[v] = strength.abs();
610        } else if !negative_weights {
611            x[v] = 0.0;
612        } else {
613            x[v] = if walk[v].is_empty() { 0.0 } else { 1.0 };
614        }
615    }
616    if x.iter().all(|&y| y == 0.0) {
617        x.fill(1.0);
618    }
619    let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
620    if max0 > 0.0 {
621        for slot in &mut x {
622            *slot /= max0;
623        }
624    }
625
626    // Shift: σ = max absolute row sum of M ensures (M + σI) has its
627    // largest-magnitude eigenvalue equal to largest-real-part of M + σ,
628    // because for non-negative M the largest-real eigenvalue is real
629    // and non-negative (Perron-Frobenius). We pad by 1 to also break
630    // exact tie configurations.
631    let mut row_norm: f64 = 0.0;
632    for v in 0..n_us {
633        let mut s = 0.0_f64;
634        for &(e, _) in &walk[v] {
635            let w = weights.map_or(1.0, |ws| ws[e as usize]);
636            s += w.abs();
637        }
638        if s > row_norm {
639            row_norm = s;
640        }
641    }
642    let shift = row_norm.max(1.0) + 1.0;
643
644    let mut x_new = vec![0.0_f64; n_us];
645    for _ in 0..SHIFTED_MAX_ITER {
646        for v in 0..n_us {
647            let mut sum = shift * x[v];
648            for &(e, nei) in &walk[v] {
649                let w = weights.map_or(1.0, |ws| ws[e as usize]);
650                sum += w * x[nei as usize];
651            }
652            x_new[v] = sum;
653        }
654        let pivot = if negative_weights {
655            let mut best = 0.0_f64;
656            for &v in &x_new {
657                if v.abs() > best.abs() {
658                    best = v;
659                }
660            }
661            best
662        } else {
663            x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
664        };
665        if pivot != 0.0 {
666            for slot in &mut x_new {
667                *slot /= pivot;
668            }
669        }
670        let mut diff = 0.0_f64;
671        for v in 0..n_us {
672            diff += (x_new[v] - x[v]).abs();
673        }
674        std::mem::swap(&mut x, &mut x_new);
675        if diff < SHIFTED_EPS {
676            break;
677        }
678    }
679
680    // Rayleigh quotient on unshifted M.
681    let mut mx = vec![0.0_f64; n_us];
682    for v in 0..n_us {
683        let mut sum = 0.0_f64;
684        for &(e, nei) in &walk[v] {
685            let w = weights.map_or(1.0, |ws| ws[e as usize]);
686            sum += w * x[nei as usize];
687        }
688        mx[v] = sum;
689    }
690    let num: f64 = x.iter().zip(mx.iter()).map(|(a, b)| a * b).sum();
691    let den: f64 = x.iter().map(|a| a * a).sum();
692    let mut eigenvalue = if den > 0.0 { num / den } else { 0.0 };
693
694    // Pathological case: dominant eigenvalue is zero (i.e. M is
695    // nilpotent yet we slipped past the DAG fast-path due to numerical
696    // noise). Fall back to upstream's "all zeros" output.
697    if !negative_weights && eigenvalue <= SHIFTED_EPS {
698        eigenvalue = 0.0;
699        x.fill(0.0);
700    } else {
701        let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
702        if max > 0.0 {
703            for slot in &mut x {
704                *slot /= max;
705            }
706        }
707        if !negative_weights {
708            for slot in &mut x {
709                if *slot < 0.0 {
710                    *slot = 0.0;
711                }
712            }
713        }
714    }
715
716    Ok(EigenvectorScores {
717        vector: x,
718        eigenvalue,
719    })
720}
721
722// DAG sentinel: 1s on sinks (Out mode) / sources (In mode), 0s elsewhere.
723#[allow(clippy::many_single_char_names)]
724fn dag_sentinel(
725    graph: &Graph,
726    mode: EigenvectorMode,
727    weights: Option<&[f64]>,
728) -> IgraphResult<EigenvectorScores> {
729    let n = graph.vcount();
730    let n_us = n as usize;
731    let m = graph.ecount();
732    // Compute the strength in the "outgoing" direction (Out → vertex
733    // with no outgoing edges; In → vertex with no incoming edges).
734    // Upstream's logic: sinks for Out mode = vertices where out-strength
735    // is zero.
736    let mut strength = vec![0.0_f64; n_us];
737    let m_u32 = u32::try_from(m)
738        .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
739    for e in 0..m_u32 {
740        let (u, v) = graph.edge(e)?;
741        let w = weights.map_or(1.0, |ws| ws[e as usize]);
742        match mode {
743            EigenvectorMode::Out => strength[u as usize] += w,
744            EigenvectorMode::In => strength[v as usize] += w,
745            EigenvectorMode::All => unreachable!(),
746        }
747    }
748    let vector: Vec<f64> = strength
749        .iter()
750        .map(|&s| if s == 0.0 { 1.0 } else { 0.0 })
751        .collect();
752    Ok(EigenvectorScores {
753        vector,
754        eigenvalue: 0.0,
755    })
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    fn close(actual: &[f64], expected: &[f64], tol: f64) {
763        assert_eq!(actual.len(), expected.len(), "length mismatch");
764        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
765            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
766        }
767    }
768
769    #[test]
770    fn empty_graph_yields_empty() {
771        let g = Graph::with_vertices(0);
772        assert!(eigenvector_centrality(&g).unwrap().is_empty());
773    }
774
775    #[test]
776    fn singleton_yields_one() {
777        let g = Graph::with_vertices(1);
778        assert_eq!(eigenvector_centrality(&g).unwrap(), vec![1.0]);
779    }
780
781    #[test]
782    fn isolated_vertices_yield_uniform_one() {
783        let g = Graph::with_vertices(3);
784        let ec = eigenvector_centrality(&g).unwrap();
785        close(&ec, &[1.0, 1.0, 1.0], 1e-9);
786    }
787
788    #[test]
789    fn triangle_uniform_one() {
790        let mut g = Graph::with_vertices(3);
791        g.add_edge(0, 1).unwrap();
792        g.add_edge(1, 2).unwrap();
793        g.add_edge(2, 0).unwrap();
794        let ec = eigenvector_centrality(&g).unwrap();
795        close(&ec, &[1.0, 1.0, 1.0], 1e-9);
796    }
797
798    #[test]
799    fn star_4_centre_one_leaves_inverse_sqrt_three() {
800        let mut g = Graph::with_vertices(4);
801        for v in 1..4 {
802            g.add_edge(0, v).unwrap();
803        }
804        let ec = eigenvector_centrality(&g).unwrap();
805        let inv_sqrt3 = 1.0 / 3f64.sqrt();
806        close(&ec, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
807    }
808
809    #[test]
810    fn k4_uniform_one() {
811        let mut g = Graph::with_vertices(4);
812        for u in 0..4u32 {
813            for v in (u + 1)..4 {
814                g.add_edge(u, v).unwrap();
815            }
816        }
817        let ec = eigenvector_centrality(&g).unwrap();
818        close(&ec, &[1.0, 1.0, 1.0, 1.0], 1e-9);
819    }
820
821    #[test]
822    fn directed_graph_returns_unsupported() {
823        let mut g = Graph::new(3, true).unwrap();
824        g.add_edge(0, 1).unwrap();
825        assert!(eigenvector_centrality(&g).is_err());
826    }
827
828    // ---- weighted undirected ----
829
830    #[test]
831    fn weighted_star_unit_matches_unweighted() {
832        let mut g = Graph::with_vertices(5);
833        for v in 1..5 {
834            g.add_edge(0, v).unwrap();
835        }
836        let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
837        close(&s.vector, &[1.0, 0.5, 0.5, 0.5, 0.5], 1e-9);
838        assert!((s.eigenvalue - 2.0).abs() < 1e-6);
839    }
840
841    #[test]
842    fn weighted_k5_unit_eigenvalue_four() {
843        let mut g = Graph::with_vertices(5);
844        for u in 0..5u32 {
845            for v in (u + 1)..5 {
846                g.add_edge(u, v).unwrap();
847            }
848        }
849        let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
850        close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
851        assert!((s.eigenvalue - 4.0).abs() < 1e-6);
852    }
853
854    #[test]
855    fn weighted_empty_returns_ones() {
856        let g = Graph::with_vertices(4);
857        let s = eigenvector_centrality_weighted(&g, &[]).unwrap();
858        close(&s.vector, &[1.0; 4], 1e-15);
859        assert!(s.eigenvalue.abs() < f64::EPSILON);
860    }
861
862    #[test]
863    fn weighted_all_zero_returns_ones() {
864        let mut g = Graph::with_vertices(3);
865        g.add_edge(0, 1).unwrap();
866        g.add_edge(1, 2).unwrap();
867        let s = eigenvector_centrality_weighted(&g, &[0.0, 0.0]).unwrap();
868        close(&s.vector, &[1.0; 3], 1e-15);
869        assert!(s.eigenvalue.abs() < f64::EPSILON);
870    }
871
872    #[test]
873    fn weighted_length_mismatch_errors() {
874        let mut g = Graph::with_vertices(3);
875        g.add_edge(0, 1).unwrap();
876        g.add_edge(1, 2).unwrap();
877        assert!(eigenvector_centrality_weighted(&g, &[1.0]).is_err());
878    }
879
880    // ---- directed ----
881
882    #[test]
883    fn directed_full_eigenvalue_n_minus_one() {
884        // K_5 directed (no loops) has dominant eigenvalue n-1=4.
885        let mut g = Graph::new(5, true).unwrap();
886        for u in 0..5u32 {
887            for v in 0..5u32 {
888                if u != v {
889                    g.add_edge(u, v).unwrap();
890                }
891            }
892        }
893        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
894        close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
895        assert!((s.eigenvalue - 4.0).abs() < 1e-6);
896    }
897
898    #[test]
899    fn directed_dag_out_star_returns_leaves_one() {
900        // out-star: 0 → 1, 2, 3, 4. Sinks (Out mode) are 1..4.
901        let mut g = Graph::new(5, true).unwrap();
902        for v in 1..5u32 {
903            g.add_edge(0, v).unwrap();
904        }
905        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
906        close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
907        assert!(s.eigenvalue.abs() < f64::EPSILON);
908    }
909
910    #[test]
911    fn directed_dag_in_star_in_mode_marks_leaves() {
912        // In-star: 1, 2, 3, 4 → 0. With In mode (centrality ∝ vertices
913        // we point TO), upstream's DAG sentinel marks sources of the
914        // adjacency matrix — vertices with no incoming edges, i.e. the
915        // leaves 1..4. Vertex 0 has 4 incoming edges, no outgoing, so
916        // it gets 0. Symmetric with the out-star + Out-mode case.
917        let mut g = Graph::new(5, true).unwrap();
918        for v in 1..5u32 {
919            g.add_edge(v, 0).unwrap();
920        }
921        let s = eigenvector_centrality_directed(&g, EigenvectorMode::In).unwrap();
922        close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
923        assert!(s.eigenvalue.abs() < f64::EPSILON);
924    }
925
926    #[test]
927    fn directed_dag_in_star_out_mode_marks_centre() {
928        // In-star: 1, 2, 3, 4 → 0. With default Out mode, upstream
929        // marks SINKS (no outgoing edges) = vertex 0 only. Matches the
930        // golden case at references/igraph/tests/unit/igraph_eigenvector_centrality.out.
931        let mut g = Graph::new(5, true).unwrap();
932        for v in 1..5u32 {
933            g.add_edge(v, 0).unwrap();
934        }
935        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
936        close(&s.vector, &[1.0, 0.0, 0.0, 0.0, 0.0], 1e-12);
937        assert!(s.eigenvalue.abs() < f64::EPSILON);
938    }
939
940    #[test]
941    fn directed_small_cycle_chord_eigenvalue_matches_arpack() {
942        // 0→1, 1→2, 2→3, 3→0, 1→3 — from upstream's golden test.
943        // Expected eigenvalue ≈ 1.22074 (real root of x³ = 1 + x²).
944        let mut g = Graph::new(4, true).unwrap();
945        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
946            .unwrap();
947        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
948        assert!(
949            (s.eigenvalue - 1.220_744).abs() < 1e-3,
950            "expected ≈ 1.22074, got {}",
951            s.eigenvalue
952        );
953        // Vertex 3 should be most central (max-1 by construction).
954        let max_idx = s
955            .vector
956            .iter()
957            .enumerate()
958            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
959            .unwrap()
960            .0;
961        assert_eq!(max_idx, 3);
962    }
963
964    #[test]
965    fn directed_empty_directed_returns_ones() {
966        let g = Graph::new(5, true).unwrap();
967        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
968        close(&s.vector, &[1.0; 5], 1e-12);
969        assert!(s.eigenvalue.abs() < f64::EPSILON);
970    }
971
972    // ---- master function dispatch ----
973
974    #[test]
975    fn full_undirected_with_all_mode_works() {
976        let mut g = Graph::with_vertices(3);
977        g.add_edge(0, 1).unwrap();
978        g.add_edge(1, 2).unwrap();
979        g.add_edge(2, 0).unwrap();
980        let s = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
981        close(&s.vector, &[1.0, 1.0, 1.0], 1e-9);
982    }
983
984    #[test]
985    fn full_undirected_forces_all_mode() {
986        // Passing Out on undirected graph still hits the symmetric path.
987        let mut g = Graph::with_vertices(3);
988        g.add_edge(0, 1).unwrap();
989        g.add_edge(1, 2).unwrap();
990        g.add_edge(2, 0).unwrap();
991        let a = eigenvector_centrality_full(&g, EigenvectorMode::Out, None).unwrap();
992        let b = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
993        close(&a.vector, &b.vector, 1e-12);
994        assert!((a.eigenvalue - b.eigenvalue).abs() < 1e-12);
995    }
996
997    // ---- stress / hard spectrum cases ----
998
999    #[test]
1000    fn directed_cycle_50_converges_to_unit_eigenvalue() {
1001        // 0→1→2→...→49→0. Adjacency eigenvalues are 50th roots of
1002        // unity; largest-real = 1 (only λ=1 itself is real). With our
1003        // shift σ = max_row_norm+1 = 2, shifted eigenvalues are
1004        // ω_k + 2, of which |ω_0 + 2| = 3 is the strict maximum.
1005        // Power iter should converge in O(1) iterations.
1006        let n: u32 = 50;
1007        let mut g = Graph::new(n, true).unwrap();
1008        for i in 0..n {
1009            g.add_edge(i, (i + 1) % n).unwrap();
1010        }
1011        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
1012        assert!(
1013            (s.eigenvalue - 1.0).abs() < 1e-9,
1014            "expected λ ≈ 1.0, got {}",
1015            s.eigenvalue
1016        );
1017        // Circulant graph: vector should be constant.
1018        let mx = s.vector.iter().copied().fold(0.0_f64, f64::max);
1019        let mn = s.vector.iter().copied().fold(f64::INFINITY, f64::min);
1020        assert!(mx - mn < 1e-9, "vec spread {} too large", mx - mn);
1021    }
1022
1023    #[test]
1024    fn directed_k10_eigenvalue_n_minus_one() {
1025        // Complete digraph K_10 (no loops): eigenvalue = n - 1 = 9,
1026        // uniform vector.
1027        let n: u32 = 10;
1028        let mut g = Graph::new(n, true).unwrap();
1029        for u in 0..n {
1030            for v in 0..n {
1031                if u != v {
1032                    g.add_edge(u, v).unwrap();
1033                }
1034            }
1035        }
1036        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
1037        assert!(
1038            (s.eigenvalue - 9.0).abs() < 1e-9,
1039            "expected λ = 9, got {}",
1040            s.eigenvalue
1041        );
1042        close(&s.vector, &[1.0; 10], 1e-9);
1043    }
1044
1045    #[test]
1046    fn directed_cycle_chord_eigenvector_components_match_arpack() {
1047        // Upstream golden: cycle 4 + chord 1→3, mode = Out.
1048        // Vector (max-1) = [0.819173, 0.671044, 0.549700, 1.000000]
1049        // λ ≈ 1.220744.
1050        let mut g = Graph::new(4, true).unwrap();
1051        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
1052            .unwrap();
1053        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
1054        assert!((s.eigenvalue - 1.220_744).abs() < 1e-4);
1055        close(
1056            &s.vector,
1057            &[0.819_173, 0.671_044, 0.549_700, 1.000_000],
1058            1e-3,
1059        );
1060    }
1061}