Skip to main content

mnem_core/
ppr.rs

1//! Personalized PageRank over an [`AdjacencyIndex`].
2//!
3//! Part of experiment E2 (LLM-free GraphRAG - PPR graph-expand). The
4//! algorithm is hand-rolled power iteration on a compact row-stochastic
5//! CSR (compressed-sparse-row) matrix. The `sprs` crate was considered
6//! and rejected for this turn: the power-iteration inner loop is a
7//! three-line sparse mat-vec, and keeping the algebra hand-rolled lets
8//! us
9//!
10//! 1. guarantee byte-determinism across platforms (no third-party SIMD
11//!    scheduler surprise),
12//! 2. hold the binary-size delta for the `mnem-http` release build
13//!    under 100 KiB (E2 T2 gate),
14//! 3. drop the algorithm into WASM builds later without auditing a new
15//!    transitive graph.
16//!
17//! The power iteration itself follows the standard [Haveliwala 2002]
18//! formulation: `r_{t+1} = (1 - d) * p + d * M^T r_t`, where `M` is the
19//! row-stochastic adjacency (outgoing edges normalised to sum to 1 per
20//! source). Dead-ends (nodes with no out-edges) are handled by
21//! redistributing their mass uniformly over the personalization vector:
22//! the standard "teleport on dangling" fix that keeps the total mass at
23//! exactly 1.0 every iteration.
24//!
25//! # Determinism
26//!
27//! - Node ordering is derived once from `iter_edges` by inserting in
28//!   first-seen order, then **re-sorted ascending**. Fixed ordering
29//!   means the same graph always produces the same CSR layout.
30//! - No parallelism. No RNG. No HashMap-iteration-order dependency.
31//! - Fixed `max_iter` + fixed `eps` convergence: two runs over the same
32//!   inputs produce byte-identical score vectors (property-tested).
33//!
34//! [Haveliwala 2002]: https://www-cs.stanford.edu/~taherh/papers/topic-sensitive-pagerank.pdf
35
36use std::collections::BTreeMap;
37
38use crate::id::NodeId;
39use crate::index::hybrid::AdjacencyIndex;
40
41/// Default damping factor (`0.85`), matching the original PageRank paper
42/// and the LightRAG / GraphRAG reference implementations.
43pub const DEFAULT_DAMPING: f32 = 0.85;
44/// Default iteration cap. 15 is enough for `eps = 1e-6` on the graph
45/// sizes E2 targets; verified by the convergence test.
46pub const DEFAULT_MAX_ITER: u32 = 15;
47/// Default L1-delta convergence threshold.
48pub const DEFAULT_EPS: f32 = 1e-6;
49
50/// Gap 02 R6 numeric pin: graph-size threshold for default-on PPR.
51///
52/// Above this node count, PPR is opt-in only (see Gap 02 solution.md
53/// `ยง250k-V gate`). The size gate skips PPR and falls back to decay
54/// expansion when `|V| > PPR_DEFAULT_MAX_NODES && !cfg.ppr_opt_in`,
55/// recording a labelled counter and emitting a
56/// `warnings[]::PprSizeGateSkipped` entry on the response. The
57/// threshold is chosen to match the HNSW memory derivation shared
58/// with `GRAPH_SIZE_GATE_V` (see `benchmarks/leiden-wallclock-vs-V.md`)
59/// so operators reason about a single graph-scale cliff, not two.
60///
61/// `#tunable: default=250_000; rationale="matches HNSW-memory cliff; see benchmarks/leiden-wallclock-vs-V.md"`
62pub const PPR_DEFAULT_MAX_NODES: usize = 250_000;
63
64/// Gap 02 #17 pure gate helper.
65///
66/// Returns `true` iff `adj` has strictly more than
67/// [`PPR_DEFAULT_MAX_NODES`] unique node ids across both sides of
68/// every edge AND `opt_in` is `false`. Callers use this to decide
69/// whether to skip the PPR walk and fall back to decay expansion.
70///
71/// Node-count is derived by a single O(|E|) pass over
72/// [`AdjacencyIndex::iter_edges`] collecting into a `BTreeSet`. The
73/// pass early-exits the moment the threshold is exceeded, so the
74/// common case (graph well under the gate) pays the full count and
75/// the pathological case (graph well over) pays exactly
76/// `PPR_DEFAULT_MAX_NODES + 1` inserts.
77///
78/// Split out from the retriever so the gate logic is unit-testable
79/// without a full repo fixture.
80#[must_use]
81pub fn exceeds_size_gate(adj: &(dyn AdjacencyIndex + Send + Sync), opt_in: bool) -> bool {
82    if opt_in {
83        return false;
84    }
85    let mut uniq: std::collections::BTreeSet<NodeId> = std::collections::BTreeSet::new();
86    for edge in adj.iter_edges() {
87        uniq.insert(edge.src);
88        uniq.insert(edge.dst);
89        if uniq.len() > PPR_DEFAULT_MAX_NODES {
90            return true;
91        }
92    }
93    false
94}
95
96/// PPR configuration. Split out from the runner so CLI / HTTP / MCP
97/// DTOs can carry just these three numbers.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct PprConfig {
100    /// Damping factor `d` in `(1 - d) * p + d * M^T r`. Default
101    /// [`DEFAULT_DAMPING`].
102    pub damping: f32,
103    /// Maximum power-iteration steps. Default [`DEFAULT_MAX_ITER`].
104    pub max_iter: u32,
105    /// L1-delta convergence threshold. Stop when
106    /// `|| r_{t+1} - r_t ||_1 < eps`. Default [`DEFAULT_EPS`].
107    pub eps: f32,
108}
109
110impl Default for PprConfig {
111    fn default() -> Self {
112        Self {
113            damping: DEFAULT_DAMPING,
114            max_iter: DEFAULT_MAX_ITER,
115            eps: DEFAULT_EPS,
116        }
117    }
118}
119
120/// Row-stochastic sparse transition matrix in plain CSR form, plus the
121/// ordered `NodeId` -> matrix-row mapping. Public so tests / downstream
122/// code can inspect it.
123#[derive(Clone, Debug)]
124pub struct SparseTransition {
125    /// Sorted `NodeId` sequence: `nodes[i]` is row/column `i`.
126    pub nodes: Vec<NodeId>,
127    /// CSR row pointers. Length `nodes.len() + 1`.
128    pub row_ptr: Vec<usize>,
129    /// CSR column indices. Length `nnz`.
130    pub col_idx: Vec<usize>,
131    /// CSR values. Length `nnz`. Row-stochastic: each non-empty row
132    /// sums to 1.0. Dead rows (no out-edges) have zero values in the
133    /// CSR and are handled by teleporting their mass in [`ppr`].
134    pub values: Vec<f32>,
135    /// True for rows that had at least one out-edge. Dead rows
136    /// redistribute their mass to the personalization vector each
137    /// iteration.
138    pub has_outgoing: Vec<bool>,
139}
140
141/// Build a row-stochastic transition matrix from an adjacency index.
142///
143/// Edge weights are **summed** when the same `(src, dst)` pair appears
144/// more than once (e.g. authored edge plus a KNN echo), then each row
145/// is L1-normalised. Self-loops are preserved (PPR handles them fine
146/// and tests pin the behaviour).
147///
148/// Node ordering is first-seen on the iterator, then sorted ascending.
149/// The sort is what gives us determinism regardless of whether the
150/// caller presented edges in `(src, dst)` order, interleaved, etc.
151#[must_use]
152pub fn sparse_transition_matrix(adj: &dyn AdjacencyIndex) -> SparseTransition {
153    // Pass 1: collect the unique NodeId set. Using a BTreeMap here
154    // gives us automatic sorted iteration for deterministic row order.
155    let mut id_to_row: BTreeMap<NodeId, usize> = BTreeMap::new();
156    // Pass 1 also records every (src, dst, weight) triple for the
157    // second-pass row assembly. Vec<_> to keep insertion order at this
158    // stage; the dedupe+sum happens in pass 2.
159    let mut triples: Vec<(NodeId, NodeId, f32)> = Vec::with_capacity(adj.edge_count());
160    for edge in adj.iter_edges() {
161        id_to_row.entry(edge.src).or_insert(0);
162        id_to_row.entry(edge.dst).or_insert(0);
163        triples.push((edge.src, edge.dst, edge.weight));
164    }
165    // Fill in the true row indices now that the BTreeMap iteration
166    // order is stable.
167    let nodes: Vec<NodeId> = id_to_row.keys().copied().collect();
168    for (i, id) in nodes.iter().enumerate() {
169        if let Some(slot) = id_to_row.get_mut(id) {
170            *slot = i;
171        }
172    }
173
174    // Pass 2: accumulate per-row `(col, weight)` entries with duplicate
175    // dedupe via a per-row BTreeMap. BTreeMap keyed on `usize` gives us
176    // sorted column order inside each row - a requirement for clean
177    // CSR + deterministic mat-vec.
178    let n = nodes.len();
179    let mut per_row: Vec<BTreeMap<usize, f32>> = (0..n).map(|_| BTreeMap::new()).collect();
180    for (s, d, w) in triples {
181        let si = id_to_row[&s];
182        let di = id_to_row[&d];
183        // Guard: silently drop non-positive weights. The upstream
184        // KnnEdge contract guarantees weights in (0, 1]; authored
185        // edges default to 1.0. Anything else is a defensive no-op
186        // rather than a panic so a corrupted repo still completes the
187        // retrieve call.
188        if w <= 0.0 || !w.is_finite() {
189            continue;
190        }
191        *per_row[si].entry(di).or_insert(0.0) += w;
192    }
193
194    // Pass 3: row-normalise and flatten to CSR.
195    let mut row_ptr: Vec<usize> = Vec::with_capacity(n + 1);
196    let mut col_idx: Vec<usize> = Vec::new();
197    let mut values: Vec<f32> = Vec::new();
198    let mut has_outgoing: Vec<bool> = vec![false; n];
199    row_ptr.push(0);
200    for (i, row) in per_row.iter().enumerate() {
201        let sum: f32 = row.values().sum();
202        if sum > 0.0 {
203            has_outgoing[i] = true;
204            for (&c, &w) in row {
205                col_idx.push(c);
206                values.push(w / sum);
207            }
208        }
209        row_ptr.push(col_idx.len());
210    }
211
212    SparseTransition {
213        nodes,
214        row_ptr,
215        col_idx,
216        values,
217        has_outgoing,
218    }
219}
220
221/// Run personalised PageRank power iteration.
222///
223/// - `adj`: any [`AdjacencyIndex`]. Edge weights are used directly.
224/// - `personalization`: seed distribution. Keys absent from the graph
225///   are ignored; zero / negative values are ignored. The vector is
226///   L1-normalised internally, so caller-side magnitudes do not need
227///   to sum to 1.
228/// - `cfg`: damping, iter cap, convergence threshold.
229///
230/// Returns a `BTreeMap<NodeId, f32>` so iteration order downstream is
231/// also deterministic.
232///
233/// # Panics
234///
235/// Never. A malformed / empty graph returns an empty map, and a zero
236/// personalization vector falls back to the uniform distribution so the
237/// algorithm still produces a valid ranking.
238#[allow(clippy::many_single_char_names)]
239#[must_use]
240pub fn ppr(
241    adj: &dyn AdjacencyIndex,
242    personalization: &BTreeMap<NodeId, f32>,
243    cfg: PprConfig,
244) -> BTreeMap<NodeId, f32> {
245    let m = sparse_transition_matrix(adj);
246    ppr_with_matrix(&m, personalization, cfg)
247}
248
249/// Run personalised PageRank using a pre-built [`SparseTransition`].
250///
251/// Byte-identical to [`ppr`] on the same inputs; the only difference is
252/// that the caller supplies the CSR matrix instead of having it
253/// re-derived from an [`AdjacencyIndex`] on every call. Useful for
254/// callers (e.g. the HTTP layer) that cache the matrix per op-id and
255/// re-run PPR with different personalization vectors across requests.
256///
257/// The convergence criterion is the standard Page/Brin 1998 L1 early-
258/// stop: iteration halts as soon as `|| r_{t+1} - r_t ||_1 < cfg.eps`.
259///
260/// # Panics
261///
262/// Never. See [`ppr`].
263#[allow(clippy::many_single_char_names)]
264#[must_use]
265pub fn ppr_with_matrix(
266    m: &SparseTransition,
267    personalization: &BTreeMap<NodeId, f32>,
268    cfg: PprConfig,
269) -> BTreeMap<NodeId, f32> {
270    let n = m.nodes.len();
271    if n == 0 {
272        return BTreeMap::new();
273    }
274    // Damping clamp so a user passing 1.0 or 1.5 cannot break the
275    // algorithm's contraction property.
276    let damping = cfg.damping.clamp(0.0, 0.999);
277
278    // Build the personalization vector in row-order. Ignore keys not
279    // in the graph; clamp negatives / non-finites to zero; L1-normalise.
280    let mut p = vec![0f32; n];
281    let mut psum = 0f32;
282    for (id, &w) in personalization {
283        if let Ok(idx) = m.nodes.binary_search(id)
284            && w > 0.0
285            && w.is_finite()
286        {
287            p[idx] += w;
288            psum += w;
289        }
290    }
291    if psum > 0.0 {
292        for v in &mut p {
293            *v /= psum;
294        }
295    } else {
296        // Degenerate case: caller gave us nothing usable. Fall back to
297        // uniform so the algorithm still converges to a non-trivial
298        // ranking. Documented in the function contract.
299        let u = 1.0 / n as f32;
300        p.fill(u);
301    }
302
303    let mut r = p.clone();
304    let mut next = vec![0f32; n];
305    for _iter in 0..cfg.max_iter {
306        // `dangling_mass` collects r[i] for every dead-row i. Dead
307        // rows get redistributed to p (teleport) each step - the
308        // standard PageRank-on-dangling fix. Skipping this drains
309        // total mass and breaks L1 conservation.
310        let mut dangling_mass = 0f32;
311        for i in 0..n {
312            if !m.has_outgoing[i] {
313                dangling_mass += r[i];
314            }
315        }
316        // Base term: (1 - d) * p + d * dangling_mass * p
317        // (the teleport-on-dangling term folds into the personal-
318        // ization scaling).
319        let teleport_scale = (1.0 - damping) + damping * dangling_mass;
320        for i in 0..n {
321            next[i] = teleport_scale * p[i];
322        }
323        // Add d * (M^T r) via a single CSR pass. For each row i with
324        // out-edges, distribute r[i] * values[k] to next[col_idx[k]].
325        for i in 0..n {
326            if !m.has_outgoing[i] {
327                continue;
328            }
329            let start = m.row_ptr[i];
330            let end = m.row_ptr[i + 1];
331            let ri = r[i];
332            if ri == 0.0 {
333                continue;
334            }
335            for k in start..end {
336                let j = m.col_idx[k];
337                next[j] += damping * ri * m.values[k];
338            }
339        }
340        // Convergence: L1 delta. Computed AFTER the mat-vec so the
341        // early-exit check sees the fresh distribution. L1-normalise
342        // `next` defensively against f32 drift so mass stays at 1.0
343        // even after 15 iterations of accumulated rounding - this is
344        // what keeps the proptest's byte-identity check passing.
345        let next_sum: f32 = next.iter().sum();
346        if next_sum > 0.0 {
347            for v in &mut next {
348                *v /= next_sum;
349            }
350        }
351        let mut delta = 0f32;
352        for i in 0..n {
353            delta += (next[i] - r[i]).abs();
354        }
355        std::mem::swap(&mut r, &mut next);
356        next.fill(0.0);
357        if delta < cfg.eps {
358            break;
359        }
360    }
361
362    // Emit in NodeId-sorted order (BTreeMap gives us this for free).
363    let mut out: BTreeMap<NodeId, f32> = BTreeMap::new();
364    for (i, id) in m.nodes.iter().enumerate() {
365        out.insert(*id, r[i]);
366    }
367    out
368}