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}