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.
205pub fn eigenvector_centrality_directed_weighted(
206    graph: &Graph,
207    mode: EigenvectorMode,
208    weights: &[f64],
209) -> IgraphResult<EigenvectorScores> {
210    eigenvector_centrality_full(graph, mode, Some(weights))
211}
212
213/// Master entry point matching upstream's signature.
214///
215/// Dispatches to the undirected / directed and weighted / unweighted
216/// branches based on `graph.is_directed()`, `mode`, and `weights`.
217/// Validates `weights.len() == graph.ecount()` when supplied.
218///
219/// Counterpart of `igraph_eigenvector_centrality(g, &v, &λ, mode,
220/// weights, NULL)` — the C-level "do everything" signature.
221pub fn eigenvector_centrality_full(
222    graph: &Graph,
223    mode: EigenvectorMode,
224    weights: Option<&[f64]>,
225) -> IgraphResult<EigenvectorScores> {
226    if let Some(w) = weights {
227        validate_weights(graph, w)?;
228    }
229    // Undirected input forces ALL mode (matches upstream).
230    let effective_mode = if graph.is_directed() {
231        mode
232    } else {
233        EigenvectorMode::All
234    };
235    match effective_mode {
236        EigenvectorMode::All => match weights {
237            None => undirected_unweighted(graph),
238            Some(w) => undirected_weighted(graph, Some(w)),
239        },
240        EigenvectorMode::Out | EigenvectorMode::In => directed_path(graph, effective_mode, weights),
241    }
242}
243
244// ---------- internal: shared validation ----------
245
246fn validate_weights(graph: &Graph, weights: &[f64]) -> IgraphResult<()> {
247    let m = graph.ecount();
248    if weights.len() != m {
249        return Err(IgraphError::InvalidArgument(format!(
250            "weights vector length ({}) not equal to number of edges ({})",
251            weights.len(),
252            m
253        )));
254    }
255    Ok(())
256}
257
258// ---------- internal: undirected unweighted ----------
259
260fn undirected_unweighted(graph: &Graph) -> IgraphResult<EigenvectorScores> {
261    let n = graph.vcount();
262    let n_us = n as usize;
263    if n == 0 {
264        return Ok(EigenvectorScores {
265            vector: Vec::new(),
266            eigenvalue: 0.0,
267        });
268    }
269    if graph.ecount() == 0 {
270        return Ok(EigenvectorScores {
271            vector: vec![1.0; n_us],
272            eigenvalue: 0.0,
273        });
274    }
275    if n == 1 {
276        return Ok(EigenvectorScores {
277            vector: vec![1.0],
278            eigenvalue: 0.0,
279        });
280    }
281
282    let mut adj: Vec<Vec<u32>> = Vec::with_capacity(n_us);
283    for v in 0..n {
284        adj.push(graph.neighbors(v)?);
285    }
286
287    let mut x = vec![1.0_f64; n_us];
288    let mut x_new = vec![0.0_f64; n_us];
289
290    for _ in 0..DEFAULT_MAX_ITER {
291        // x_new = (A + I) · x — shift breaks ±λ bipartite symmetry.
292        for v in 0..n_us {
293            let mut sum = x[v];
294            for &u in &adj[v] {
295                sum += x[u as usize];
296            }
297            x_new[v] = sum;
298        }
299        let max = x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
300        if max > 0.0 {
301            for slot in &mut x_new {
302                *slot /= max;
303            }
304        }
305        let mut diff = 0.0_f64;
306        for v in 0..n_us {
307            diff += (x_new[v] - x[v]).abs();
308        }
309        std::mem::swap(&mut x, &mut x_new);
310        if diff < DEFAULT_EPS {
311            break;
312        }
313    }
314
315    // Eigenvalue from Rayleigh quotient: λ = (xᵀ·A·x) / (xᵀ·x).
316    let mut ax = vec![0.0_f64; n_us];
317    for v in 0..n_us {
318        let mut sum = 0.0_f64;
319        for &u in &adj[v] {
320            sum += x[u as usize];
321        }
322        ax[v] = sum;
323    }
324    let num: f64 = x.iter().zip(ax.iter()).map(|(a, b)| a * b).sum();
325    let den: f64 = x.iter().map(|a| a * a).sum();
326    let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
327
328    // Sign cleanup: drop tiny negatives that come from numerical drift
329    // (only valid because A here is non-negative).
330    for slot in &mut x {
331        if *slot < 0.0 {
332            *slot = 0.0;
333        }
334    }
335
336    Ok(EigenvectorScores {
337        vector: x,
338        eigenvalue,
339    })
340}
341
342// ---------- internal: undirected weighted ----------
343
344#[allow(
345    clippy::too_many_lines,
346    clippy::many_single_char_names,
347    clippy::cast_possible_truncation
348)]
349fn undirected_weighted(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<EigenvectorScores> {
350    let n = graph.vcount();
351    let n_us = n as usize;
352    let m = graph.ecount();
353    if n == 0 {
354        return Ok(EigenvectorScores {
355            vector: Vec::new(),
356            eigenvalue: 0.0,
357        });
358    }
359    if m == 0 {
360        return Ok(EigenvectorScores {
361            vector: vec![1.0; n_us],
362            eigenvalue: 0.0,
363        });
364    }
365    if let Some(w) = weights {
366        if w.iter().all(|&x| x == 0.0) {
367            return Ok(EigenvectorScores {
368                vector: vec![1.0; n_us],
369                eigenvalue: 0.0,
370            });
371        }
372    }
373    if n == 1 {
374        return Ok(EigenvectorScores {
375            vector: vec![1.0],
376            eigenvalue: 0.0,
377        });
378    }
379
380    let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
381
382    // Pre-cache incident edges per vertex (undirected → both endpoints
383    // appear in their respective incidence lists).
384    let mut inc: Vec<Vec<u32>> = Vec::with_capacity(n_us);
385    for v in 0..n {
386        inc.push(graph.incident(v)?);
387    }
388
389    // Seed: weighted strength fallback to degree if zero strength (matches C).
390    let mut x = vec![0.0_f64; n_us];
391    for v in 0..n {
392        let v_us = v as usize;
393        let mut strength = 0.0_f64;
394        for &e in &inc[v_us] {
395            let w = weights.map_or(1.0, |ws| ws[e as usize]);
396            strength += w;
397        }
398        if strength != 0.0 {
399            x[v_us] = strength.abs();
400        } else if !negative_weights {
401            x[v_us] = 0.0;
402        } else {
403            // Negative weights can produce zero strength even on
404            // non-zero-degree vertices; seed those with 1.
405            x[v_us] = if inc[v_us].is_empty() { 0.0 } else { 1.0 };
406        }
407    }
408    // Make sure at least one entry is non-zero so power iter has signal.
409    if x.iter().all(|&y| y == 0.0) {
410        x.fill(1.0);
411    }
412    // Normalise seed to max=1 to keep numerics tame.
413    let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
414    if max0 > 0.0 {
415        for slot in &mut x {
416            *slot /= max0;
417        }
418    }
419
420    // Shifted power iter on (W + σI), σ = 1 suffices since W is bounded
421    // and we just need to break ±λ symmetry. For negative weights, this
422    // doesn't guarantee convergence to largest-real, but matches the
423    // upstream "best effort" contract.
424    let shift = 1.0_f64;
425    let mut x_new = vec![0.0_f64; n_us];
426
427    for _ in 0..SHIFTED_MAX_ITER {
428        // x_new = σ·x + W·x  (W·x walks incident edges with weight w_e)
429        for v in 0..n_us {
430            let mut sum = shift * x[v];
431            for &e in &inc[v] {
432                let other = graph.edge_other(e, v as u32)?;
433                let w = weights.map_or(1.0, |ws| ws[e as usize]);
434                sum += w * x[other as usize];
435            }
436            x_new[v] = sum;
437        }
438        // For negative-weight case, track signed component with greatest
439        // magnitude so we converge to largest real eigenvalue, not just
440        // largest magnitude. For positive weights this is equivalent to
441        // max abs.
442        let pivot = if negative_weights {
443            let mut best = 0.0_f64;
444            for &v in &x_new {
445                if v.abs() > best.abs() {
446                    best = v;
447                }
448            }
449            best
450        } else {
451            x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
452        };
453        if pivot != 0.0 {
454            for slot in &mut x_new {
455                *slot /= pivot;
456            }
457        }
458        let mut diff = 0.0_f64;
459        for v in 0..n_us {
460            diff += (x_new[v] - x[v]).abs();
461        }
462        std::mem::swap(&mut x, &mut x_new);
463        if diff < SHIFTED_EPS {
464            break;
465        }
466    }
467
468    // Rayleigh quotient on the original W (no shift): λ = xᵀ·W·x / xᵀ·x
469    let mut wx = vec![0.0_f64; n_us];
470    for v in 0..n_us {
471        let mut sum = 0.0_f64;
472        for &e in &inc[v] {
473            let other = graph.edge_other(e, v as u32)?;
474            let w = weights.map_or(1.0, |ws| ws[e as usize]);
475            sum += w * x[other as usize];
476        }
477        wx[v] = sum;
478    }
479    let num: f64 = x.iter().zip(wx.iter()).map(|(a, b)| a * b).sum();
480    let den: f64 = x.iter().map(|a| a * a).sum();
481    let eigenvalue = if den > 0.0 { num / den } else { 0.0 };
482
483    // Renormalise to max-abs = 1 (Rayleigh-quotient pass may have
484    // produced a vector whose dominant component is signed).
485    let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
486    if max > 0.0 {
487        for slot in &mut x {
488            *slot /= max;
489        }
490    }
491    if !negative_weights {
492        for slot in &mut x {
493            if *slot < 0.0 {
494                *slot = 0.0;
495            }
496        }
497    }
498
499    Ok(EigenvectorScores {
500        vector: x,
501        eigenvalue,
502    })
503}
504
505// ---------- internal: directed (with optional weights) ----------
506
507#[allow(
508    clippy::too_many_lines,
509    clippy::many_single_char_names,
510    clippy::cast_possible_truncation,
511    clippy::needless_range_loop
512)]
513fn directed_path(
514    graph: &Graph,
515    mode: EigenvectorMode,
516    weights: Option<&[f64]>,
517) -> IgraphResult<EigenvectorScores> {
518    let n = graph.vcount();
519    let n_us = n as usize;
520    let m = graph.ecount();
521    if n == 0 {
522        return Ok(EigenvectorScores {
523            vector: Vec::new(),
524            eigenvalue: 0.0,
525        });
526    }
527    if m == 0 {
528        return Ok(EigenvectorScores {
529            vector: vec![1.0; n_us],
530            eigenvalue: 0.0,
531        });
532    }
533    if let Some(w) = weights {
534        if w.iter().all(|&x| x == 0.0) {
535            return Ok(EigenvectorScores {
536                vector: vec![1.0; n_us],
537                eigenvalue: 0.0,
538            });
539        }
540    }
541
542    let negative_weights = weights.is_some_and(|w| w.iter().any(|&x| x < 0.0));
543
544    // DAG short-circuit (only valid for non-negative weights — see
545    // upstream comment block on lines 350-369). Returns 1s on sinks
546    // (Out) / sources (In) and 0s elsewhere.
547    if !negative_weights && crate::algorithms::properties::is_dag::is_dag(graph) {
548        return dag_sentinel(graph, mode, weights);
549    }
550
551    // For Out: centrality of v ∝ Σ_{u: u→v} c_u, so we walk IN-edges of v.
552    // For In:  centrality of v ∝ Σ_{u: v→u} c_u, so we walk OUT-edges of v.
553    // Pre-build per-vertex (edge_id, neighbour) list.
554    let mut walk: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_us];
555    let m_u32 = u32::try_from(m)
556        .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
557    match mode {
558        EigenvectorMode::Out => {
559            for e in 0..m_u32 {
560                let (u, v) = graph.edge(e)?;
561                walk[v as usize].push((e, u));
562            }
563        }
564        EigenvectorMode::In => {
565            for e in 0..m_u32 {
566                let (u, v) = graph.edge(e)?;
567                walk[u as usize].push((e, v));
568            }
569        }
570        EigenvectorMode::All => unreachable!("All is dispatched to undirected_*"),
571    }
572
573    // Seed: in-strength (Out) / out-strength (In). Fallback to in-degree
574    // when negative weights produce zero strength on a non-empty list.
575    let mut x = vec![0.0_f64; n_us];
576    for v in 0..n_us {
577        let mut strength = 0.0_f64;
578        for &(e, _) in &walk[v] {
579            let w = weights.map_or(1.0, |ws| ws[e as usize]);
580            strength += w;
581        }
582        if strength != 0.0 {
583            x[v] = strength.abs();
584        } else if !negative_weights {
585            x[v] = 0.0;
586        } else {
587            x[v] = if walk[v].is_empty() { 0.0 } else { 1.0 };
588        }
589    }
590    if x.iter().all(|&y| y == 0.0) {
591        x.fill(1.0);
592    }
593    let max0 = x.iter().fold(0.0_f64, |acc, &y| acc.max(y.abs()));
594    if max0 > 0.0 {
595        for slot in &mut x {
596            *slot /= max0;
597        }
598    }
599
600    // Shift: σ = max absolute row sum of M ensures (M + σI) has its
601    // largest-magnitude eigenvalue equal to largest-real-part of M + σ,
602    // because for non-negative M the largest-real eigenvalue is real
603    // and non-negative (Perron-Frobenius). We pad by 1 to also break
604    // exact tie configurations.
605    let mut row_norm: f64 = 0.0;
606    for v in 0..n_us {
607        let mut s = 0.0_f64;
608        for &(e, _) in &walk[v] {
609            let w = weights.map_or(1.0, |ws| ws[e as usize]);
610            s += w.abs();
611        }
612        if s > row_norm {
613            row_norm = s;
614        }
615    }
616    let shift = row_norm.max(1.0) + 1.0;
617
618    let mut x_new = vec![0.0_f64; n_us];
619    for _ in 0..SHIFTED_MAX_ITER {
620        for v in 0..n_us {
621            let mut sum = shift * x[v];
622            for &(e, nei) in &walk[v] {
623                let w = weights.map_or(1.0, |ws| ws[e as usize]);
624                sum += w * x[nei as usize];
625            }
626            x_new[v] = sum;
627        }
628        let pivot = if negative_weights {
629            let mut best = 0.0_f64;
630            for &v in &x_new {
631                if v.abs() > best.abs() {
632                    best = v;
633                }
634            }
635            best
636        } else {
637            x_new.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
638        };
639        if pivot != 0.0 {
640            for slot in &mut x_new {
641                *slot /= pivot;
642            }
643        }
644        let mut diff = 0.0_f64;
645        for v in 0..n_us {
646            diff += (x_new[v] - x[v]).abs();
647        }
648        std::mem::swap(&mut x, &mut x_new);
649        if diff < SHIFTED_EPS {
650            break;
651        }
652    }
653
654    // Rayleigh quotient on unshifted M.
655    let mut mx = vec![0.0_f64; n_us];
656    for v in 0..n_us {
657        let mut sum = 0.0_f64;
658        for &(e, nei) in &walk[v] {
659            let w = weights.map_or(1.0, |ws| ws[e as usize]);
660            sum += w * x[nei as usize];
661        }
662        mx[v] = sum;
663    }
664    let num: f64 = x.iter().zip(mx.iter()).map(|(a, b)| a * b).sum();
665    let den: f64 = x.iter().map(|a| a * a).sum();
666    let mut eigenvalue = if den > 0.0 { num / den } else { 0.0 };
667
668    // Pathological case: dominant eigenvalue is zero (i.e. M is
669    // nilpotent yet we slipped past the DAG fast-path due to numerical
670    // noise). Fall back to upstream's "all zeros" output.
671    if !negative_weights && eigenvalue <= SHIFTED_EPS {
672        eigenvalue = 0.0;
673        x.fill(0.0);
674    } else {
675        let max = x.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
676        if max > 0.0 {
677            for slot in &mut x {
678                *slot /= max;
679            }
680        }
681        if !negative_weights {
682            for slot in &mut x {
683                if *slot < 0.0 {
684                    *slot = 0.0;
685                }
686            }
687        }
688    }
689
690    Ok(EigenvectorScores {
691        vector: x,
692        eigenvalue,
693    })
694}
695
696// DAG sentinel: 1s on sinks (Out mode) / sources (In mode), 0s elsewhere.
697#[allow(clippy::many_single_char_names)]
698fn dag_sentinel(
699    graph: &Graph,
700    mode: EigenvectorMode,
701    weights: Option<&[f64]>,
702) -> IgraphResult<EigenvectorScores> {
703    let n = graph.vcount();
704    let n_us = n as usize;
705    let m = graph.ecount();
706    // Compute the strength in the "outgoing" direction (Out → vertex
707    // with no outgoing edges; In → vertex with no incoming edges).
708    // Upstream's logic: sinks for Out mode = vertices where out-strength
709    // is zero.
710    let mut strength = vec![0.0_f64; n_us];
711    let m_u32 = u32::try_from(m)
712        .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
713    for e in 0..m_u32 {
714        let (u, v) = graph.edge(e)?;
715        let w = weights.map_or(1.0, |ws| ws[e as usize]);
716        match mode {
717            EigenvectorMode::Out => strength[u as usize] += w,
718            EigenvectorMode::In => strength[v as usize] += w,
719            EigenvectorMode::All => unreachable!(),
720        }
721    }
722    let vector: Vec<f64> = strength
723        .iter()
724        .map(|&s| if s == 0.0 { 1.0 } else { 0.0 })
725        .collect();
726    Ok(EigenvectorScores {
727        vector,
728        eigenvalue: 0.0,
729    })
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    fn close(actual: &[f64], expected: &[f64], tol: f64) {
737        assert_eq!(actual.len(), expected.len(), "length mismatch");
738        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
739            assert!((a - e).abs() < tol, "vertex {i}: actual={a} expected={e}");
740        }
741    }
742
743    #[test]
744    fn empty_graph_yields_empty() {
745        let g = Graph::with_vertices(0);
746        assert!(eigenvector_centrality(&g).unwrap().is_empty());
747    }
748
749    #[test]
750    fn singleton_yields_one() {
751        let g = Graph::with_vertices(1);
752        assert_eq!(eigenvector_centrality(&g).unwrap(), vec![1.0]);
753    }
754
755    #[test]
756    fn isolated_vertices_yield_uniform_one() {
757        let g = Graph::with_vertices(3);
758        let ec = eigenvector_centrality(&g).unwrap();
759        close(&ec, &[1.0, 1.0, 1.0], 1e-9);
760    }
761
762    #[test]
763    fn triangle_uniform_one() {
764        let mut g = Graph::with_vertices(3);
765        g.add_edge(0, 1).unwrap();
766        g.add_edge(1, 2).unwrap();
767        g.add_edge(2, 0).unwrap();
768        let ec = eigenvector_centrality(&g).unwrap();
769        close(&ec, &[1.0, 1.0, 1.0], 1e-9);
770    }
771
772    #[test]
773    fn star_4_centre_one_leaves_inverse_sqrt_three() {
774        let mut g = Graph::with_vertices(4);
775        for v in 1..4 {
776            g.add_edge(0, v).unwrap();
777        }
778        let ec = eigenvector_centrality(&g).unwrap();
779        let inv_sqrt3 = 1.0 / 3f64.sqrt();
780        close(&ec, &[1.0, inv_sqrt3, inv_sqrt3, inv_sqrt3], 1e-9);
781    }
782
783    #[test]
784    fn k4_uniform_one() {
785        let mut g = Graph::with_vertices(4);
786        for u in 0..4u32 {
787            for v in (u + 1)..4 {
788                g.add_edge(u, v).unwrap();
789            }
790        }
791        let ec = eigenvector_centrality(&g).unwrap();
792        close(&ec, &[1.0, 1.0, 1.0, 1.0], 1e-9);
793    }
794
795    #[test]
796    fn directed_graph_returns_unsupported() {
797        let mut g = Graph::new(3, true).unwrap();
798        g.add_edge(0, 1).unwrap();
799        assert!(eigenvector_centrality(&g).is_err());
800    }
801
802    // ---- weighted undirected ----
803
804    #[test]
805    fn weighted_star_unit_matches_unweighted() {
806        let mut g = Graph::with_vertices(5);
807        for v in 1..5 {
808            g.add_edge(0, v).unwrap();
809        }
810        let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
811        close(&s.vector, &[1.0, 0.5, 0.5, 0.5, 0.5], 1e-9);
812        assert!((s.eigenvalue - 2.0).abs() < 1e-6);
813    }
814
815    #[test]
816    fn weighted_k5_unit_eigenvalue_four() {
817        let mut g = Graph::with_vertices(5);
818        for u in 0..5u32 {
819            for v in (u + 1)..5 {
820                g.add_edge(u, v).unwrap();
821            }
822        }
823        let s = eigenvector_centrality_weighted(&g, &vec![1.0; g.ecount()]).unwrap();
824        close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
825        assert!((s.eigenvalue - 4.0).abs() < 1e-6);
826    }
827
828    #[test]
829    fn weighted_empty_returns_ones() {
830        let g = Graph::with_vertices(4);
831        let s = eigenvector_centrality_weighted(&g, &[]).unwrap();
832        close(&s.vector, &[1.0; 4], 1e-15);
833        assert!(s.eigenvalue.abs() < f64::EPSILON);
834    }
835
836    #[test]
837    fn weighted_all_zero_returns_ones() {
838        let mut g = Graph::with_vertices(3);
839        g.add_edge(0, 1).unwrap();
840        g.add_edge(1, 2).unwrap();
841        let s = eigenvector_centrality_weighted(&g, &[0.0, 0.0]).unwrap();
842        close(&s.vector, &[1.0; 3], 1e-15);
843        assert!(s.eigenvalue.abs() < f64::EPSILON);
844    }
845
846    #[test]
847    fn weighted_length_mismatch_errors() {
848        let mut g = Graph::with_vertices(3);
849        g.add_edge(0, 1).unwrap();
850        g.add_edge(1, 2).unwrap();
851        assert!(eigenvector_centrality_weighted(&g, &[1.0]).is_err());
852    }
853
854    // ---- directed ----
855
856    #[test]
857    fn directed_full_eigenvalue_n_minus_one() {
858        // K_5 directed (no loops) has dominant eigenvalue n-1=4.
859        let mut g = Graph::new(5, true).unwrap();
860        for u in 0..5u32 {
861            for v in 0..5u32 {
862                if u != v {
863                    g.add_edge(u, v).unwrap();
864                }
865            }
866        }
867        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
868        close(&s.vector, &[1.0, 1.0, 1.0, 1.0, 1.0], 1e-9);
869        assert!((s.eigenvalue - 4.0).abs() < 1e-6);
870    }
871
872    #[test]
873    fn directed_dag_out_star_returns_leaves_one() {
874        // out-star: 0 → 1, 2, 3, 4. Sinks (Out mode) are 1..4.
875        let mut g = Graph::new(5, true).unwrap();
876        for v in 1..5u32 {
877            g.add_edge(0, v).unwrap();
878        }
879        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
880        close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
881        assert!(s.eigenvalue.abs() < f64::EPSILON);
882    }
883
884    #[test]
885    fn directed_dag_in_star_in_mode_marks_leaves() {
886        // In-star: 1, 2, 3, 4 → 0. With In mode (centrality ∝ vertices
887        // we point TO), upstream's DAG sentinel marks sources of the
888        // adjacency matrix — vertices with no incoming edges, i.e. the
889        // leaves 1..4. Vertex 0 has 4 incoming edges, no outgoing, so
890        // it gets 0. Symmetric with the out-star + Out-mode case.
891        let mut g = Graph::new(5, true).unwrap();
892        for v in 1..5u32 {
893            g.add_edge(v, 0).unwrap();
894        }
895        let s = eigenvector_centrality_directed(&g, EigenvectorMode::In).unwrap();
896        close(&s.vector, &[0.0, 1.0, 1.0, 1.0, 1.0], 1e-12);
897        assert!(s.eigenvalue.abs() < f64::EPSILON);
898    }
899
900    #[test]
901    fn directed_dag_in_star_out_mode_marks_centre() {
902        // In-star: 1, 2, 3, 4 → 0. With default Out mode, upstream
903        // marks SINKS (no outgoing edges) = vertex 0 only. Matches the
904        // golden case at references/igraph/tests/unit/igraph_eigenvector_centrality.out.
905        let mut g = Graph::new(5, true).unwrap();
906        for v in 1..5u32 {
907            g.add_edge(v, 0).unwrap();
908        }
909        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
910        close(&s.vector, &[1.0, 0.0, 0.0, 0.0, 0.0], 1e-12);
911        assert!(s.eigenvalue.abs() < f64::EPSILON);
912    }
913
914    #[test]
915    fn directed_small_cycle_chord_eigenvalue_matches_arpack() {
916        // 0→1, 1→2, 2→3, 3→0, 1→3 — from upstream's golden test.
917        // Expected eigenvalue ≈ 1.22074 (real root of x³ = 1 + x²).
918        let mut g = Graph::new(4, true).unwrap();
919        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
920            .unwrap();
921        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
922        assert!(
923            (s.eigenvalue - 1.220_744).abs() < 1e-3,
924            "expected ≈ 1.22074, got {}",
925            s.eigenvalue
926        );
927        // Vertex 3 should be most central (max-1 by construction).
928        let max_idx = s
929            .vector
930            .iter()
931            .enumerate()
932            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
933            .unwrap()
934            .0;
935        assert_eq!(max_idx, 3);
936    }
937
938    #[test]
939    fn directed_empty_directed_returns_ones() {
940        let g = Graph::new(5, true).unwrap();
941        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
942        close(&s.vector, &[1.0; 5], 1e-12);
943        assert!(s.eigenvalue.abs() < f64::EPSILON);
944    }
945
946    // ---- master function dispatch ----
947
948    #[test]
949    fn full_undirected_with_all_mode_works() {
950        let mut g = Graph::with_vertices(3);
951        g.add_edge(0, 1).unwrap();
952        g.add_edge(1, 2).unwrap();
953        g.add_edge(2, 0).unwrap();
954        let s = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
955        close(&s.vector, &[1.0, 1.0, 1.0], 1e-9);
956    }
957
958    #[test]
959    fn full_undirected_forces_all_mode() {
960        // Passing Out on undirected graph still hits the symmetric path.
961        let mut g = Graph::with_vertices(3);
962        g.add_edge(0, 1).unwrap();
963        g.add_edge(1, 2).unwrap();
964        g.add_edge(2, 0).unwrap();
965        let a = eigenvector_centrality_full(&g, EigenvectorMode::Out, None).unwrap();
966        let b = eigenvector_centrality_full(&g, EigenvectorMode::All, None).unwrap();
967        close(&a.vector, &b.vector, 1e-12);
968        assert!((a.eigenvalue - b.eigenvalue).abs() < 1e-12);
969    }
970
971    // ---- stress / hard spectrum cases ----
972
973    #[test]
974    fn directed_cycle_50_converges_to_unit_eigenvalue() {
975        // 0→1→2→...→49→0. Adjacency eigenvalues are 50th roots of
976        // unity; largest-real = 1 (only λ=1 itself is real). With our
977        // shift σ = max_row_norm+1 = 2, shifted eigenvalues are
978        // ω_k + 2, of which |ω_0 + 2| = 3 is the strict maximum.
979        // Power iter should converge in O(1) iterations.
980        let n: u32 = 50;
981        let mut g = Graph::new(n, true).unwrap();
982        for i in 0..n {
983            g.add_edge(i, (i + 1) % n).unwrap();
984        }
985        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
986        assert!(
987            (s.eigenvalue - 1.0).abs() < 1e-9,
988            "expected λ ≈ 1.0, got {}",
989            s.eigenvalue
990        );
991        // Circulant graph: vector should be constant.
992        let mx = s.vector.iter().copied().fold(0.0_f64, f64::max);
993        let mn = s.vector.iter().copied().fold(f64::INFINITY, f64::min);
994        assert!(mx - mn < 1e-9, "vec spread {} too large", mx - mn);
995    }
996
997    #[test]
998    fn directed_k10_eigenvalue_n_minus_one() {
999        // Complete digraph K_10 (no loops): eigenvalue = n - 1 = 9,
1000        // uniform vector.
1001        let n: u32 = 10;
1002        let mut g = Graph::new(n, true).unwrap();
1003        for u in 0..n {
1004            for v in 0..n {
1005                if u != v {
1006                    g.add_edge(u, v).unwrap();
1007                }
1008            }
1009        }
1010        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
1011        assert!(
1012            (s.eigenvalue - 9.0).abs() < 1e-9,
1013            "expected λ = 9, got {}",
1014            s.eigenvalue
1015        );
1016        close(&s.vector, &[1.0; 10], 1e-9);
1017    }
1018
1019    #[test]
1020    fn directed_cycle_chord_eigenvector_components_match_arpack() {
1021        // Upstream golden: cycle 4 + chord 1→3, mode = Out.
1022        // Vector (max-1) = [0.819173, 0.671044, 0.549700, 1.000000]
1023        // λ ≈ 1.220744.
1024        let mut g = Graph::new(4, true).unwrap();
1025        g.add_edges(vec![(0u32, 1u32), (1, 2), (2, 3), (3, 0), (1, 3)])
1026            .unwrap();
1027        let s = eigenvector_centrality_directed(&g, EigenvectorMode::Out).unwrap();
1028        assert!((s.eigenvalue - 1.220_744).abs() < 1e-4);
1029        close(
1030            &s.vector,
1031            &[0.819_173, 0.671_044, 0.549_700, 1.000_000],
1032            1e-3,
1033        );
1034    }
1035}