Skip to main content

weavatrix_graph/algo/network/centrality/
hits.rs

1#![allow(clippy::cast_precision_loss)]
2
3use super::math::square_root;
4use crate::{GraphError, IndexGraphView, Result, String, Vec};
5
6/// Canonically ordered node-score pairs returned by HITS.
7pub type HitsScores<Node> = Vec<(Node, f64)>;
8
9/// HITS hub and authority scores in canonical node order.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Hits<Node> {
12    hubs: HitsScores<Node>,
13    authorities: HitsScores<Node>,
14    iterations: usize,
15    converged: bool,
16}
17
18impl<Node> Hits<Node> {
19    /// Returns L2-normalized hub scores.
20    #[must_use]
21    pub fn hubs(&self) -> &[(Node, f64)] {
22        &self.hubs
23    }
24
25    /// Returns L2-normalized authority scores.
26    #[must_use]
27    pub fn authorities(&self) -> &[(Node, f64)] {
28        &self.authorities
29    }
30
31    /// Returns the number of completed power iterations.
32    #[must_use]
33    pub fn iterations(&self) -> usize {
34        self.iterations
35    }
36
37    /// Reports whether the requested tolerance was reached.
38    #[must_use]
39    pub fn converged(&self) -> bool {
40        self.converged
41    }
42
43    /// Consumes the result into hub and authority vectors.
44    #[must_use]
45    pub fn into_scores(self) -> (HitsScores<Node>, HitsScores<Node>) {
46        (self.hubs, self.authorities)
47    }
48}
49
50impl<Node> Hits<Node>
51where
52    Node: Copy + Eq,
53{
54    /// Returns one node's hub score.
55    #[must_use]
56    pub fn hub(&self, node: Node) -> Option<f64> {
57        score(&self.hubs, node)
58    }
59
60    /// Returns one node's authority score.
61    #[must_use]
62    pub fn authority(&self, node: Node) -> Option<f64> {
63        score(&self.authorities, node)
64    }
65}
66
67/// Computes HITS hub and authority scores on topological relationships.
68///
69/// Parallel edges with equal endpoints are treated as one relationship, so
70/// duplicate provenance does not bias repository rankings.
71///
72/// # Errors
73///
74/// Returns an error for zero iterations or a non-positive, non-finite
75/// tolerance.
76pub fn hits<G>(graph: &G, max_iterations: usize, tolerance: f64) -> Result<Hits<G::Node>>
77where
78    G: IndexGraphView,
79{
80    hits_filtered(graph, max_iterations, tolerance, |_| true)
81}
82
83/// Computes HITS scores using accepted relationships only.
84///
85/// The predicate is evaluated exactly once per edge.
86///
87/// # Errors
88///
89/// Returns an error for zero iterations or a non-positive, non-finite
90/// tolerance.
91pub fn hits_filtered<G, F>(
92    graph: &G,
93    max_iterations: usize,
94    tolerance: f64,
95    allows_edge: F,
96) -> Result<Hits<G::Node>>
97where
98    G: IndexGraphView,
99    F: Fn(G::Edge) -> bool,
100{
101    validate(max_iterations, tolerance)?;
102    let mut nodes = graph.node_indices().collect::<Vec<_>>();
103    nodes.sort_unstable_by_key(|node| G::node_slot(*node));
104    let slots = nodes
105        .iter()
106        .map(|node| G::node_slot(*node))
107        .collect::<Vec<_>>();
108    let dense = slots
109        .iter()
110        .enumerate()
111        .all(|(expected, &slot)| expected == slot);
112    let edges = accepted_edges(graph, allows_edge);
113    if nodes.is_empty() || edges.is_empty() {
114        return Ok(zero_result(nodes));
115    }
116    let initial = 1.0 / square_root(nodes.len() as f64);
117    let mut hubs = vec![initial; graph.node_bound()];
118    let mut authorities = vec![initial; graph.node_bound()];
119    let mut next_hubs = vec![0.0; graph.node_bound()];
120    let mut next_authorities = vec![0.0; graph.node_bound()];
121    for iteration in 1..=max_iterations {
122        next_authorities.fill(0.0);
123        for &(source, target) in &edges {
124            next_authorities[target] += hubs[source];
125        }
126        normalize(&mut next_authorities, &slots, dense);
127        next_hubs.fill(0.0);
128        for &(source, target) in &edges {
129            next_hubs[source] += next_authorities[target];
130        }
131        normalize(&mut next_hubs, &slots, dense);
132        if vectors_converged(
133            &hubs,
134            &next_hubs,
135            &authorities,
136            &next_authorities,
137            &slots,
138            dense,
139            tolerance,
140        ) {
141            return Ok(result(
142                &nodes,
143                &slots,
144                &next_hubs,
145                &next_authorities,
146                iteration,
147                true,
148            ));
149        }
150        core::mem::swap(&mut hubs, &mut next_hubs);
151        core::mem::swap(&mut authorities, &mut next_authorities);
152    }
153    Ok(result(
154        &nodes,
155        &slots,
156        &hubs,
157        &authorities,
158        max_iterations,
159        false,
160    ))
161}
162
163fn accepted_edges<G, F>(graph: &G, allows_edge: F) -> Vec<(usize, usize)>
164where
165    G: IndexGraphView,
166    F: Fn(G::Edge) -> bool,
167{
168    let mut edges = graph
169        .edge_references()
170        .filter(|(edge, _)| allows_edge(*edge))
171        .filter_map(|(_, endpoints)| {
172            let source = G::node_slot(endpoints.source());
173            let target = G::node_slot(endpoints.target());
174            (source != target).then_some((source, target))
175        })
176        .collect::<Vec<_>>();
177    if !edges.is_sorted() {
178        edges.sort_unstable();
179    }
180    edges.dedup();
181    edges
182}
183
184fn normalize(values: &mut [f64], slots: &[usize], dense: bool) {
185    let norm = if dense {
186        square_root(
187            values[..slots.len()]
188                .iter()
189                .map(|value| value * value)
190                .sum(),
191        )
192    } else {
193        square_root(slots.iter().map(|&slot| values[slot] * values[slot]).sum())
194    };
195    if norm > 0.0 {
196        if dense {
197            for value in &mut values[..slots.len()] {
198                *value /= norm;
199            }
200        } else {
201            for &slot in slots {
202                values[slot] /= norm;
203            }
204        }
205    }
206}
207
208fn vectors_converged(
209    hubs: &[f64],
210    next_hubs: &[f64],
211    authorities: &[f64],
212    next_authorities: &[f64],
213    slots: &[usize],
214    dense: bool,
215    tolerance: f64,
216) -> bool {
217    if dense {
218        hubs.iter()
219            .zip(next_hubs)
220            .zip(authorities.iter().zip(next_authorities))
221            .take(slots.len())
222            .all(|((hub, next_hub), (authority, next_authority))| {
223                (hub - next_hub).abs() <= tolerance
224                    && (authority - next_authority).abs() <= tolerance
225            })
226    } else {
227        slots.iter().all(|&slot| {
228            (hubs[slot] - next_hubs[slot]).abs() <= tolerance
229                && (authorities[slot] - next_authorities[slot]).abs() <= tolerance
230        })
231    }
232}
233
234fn result<Node>(
235    nodes: &[Node],
236    slots: &[usize],
237    hubs: &[f64],
238    authorities: &[f64],
239    iterations: usize,
240    converged: bool,
241) -> Hits<Node>
242where
243    Node: Copy,
244{
245    Hits {
246        hubs: nodes
247            .iter()
248            .zip(slots)
249            .map(|(&node, &slot)| (node, hubs[slot]))
250            .collect(),
251        authorities: nodes
252            .iter()
253            .zip(slots)
254            .map(|(&node, &slot)| (node, authorities[slot]))
255            .collect(),
256        iterations,
257        converged,
258    }
259}
260
261fn zero_result<Node>(nodes: Vec<Node>) -> Hits<Node>
262where
263    Node: Copy,
264{
265    Hits {
266        hubs: nodes.iter().map(|&node| (node, 0.0)).collect(),
267        authorities: nodes.into_iter().map(|node| (node, 0.0)).collect(),
268        iterations: 0,
269        converged: true,
270    }
271}
272
273fn score<Node>(scores: &[(Node, f64)], node: Node) -> Option<f64>
274where
275    Node: Copy + Eq,
276{
277    scores
278        .iter()
279        .find_map(|(candidate, value)| (*candidate == node).then_some(*value))
280}
281
282fn validate(max_iterations: usize, tolerance: f64) -> Result<()> {
283    if max_iterations == 0 || !tolerance.is_finite() || tolerance <= 0.0 {
284        return Err(GraphError::InvalidAlgorithmParameter {
285            algorithm: "hits",
286            parameter: "max_iterations/tolerance",
287            value: String::from("must be finite, positive, and non-zero"),
288        });
289    }
290    Ok(())
291}