1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
// SPDX-License-Identifier: BUSL-1.1
//! Per-shard PageRank execution state for distributed BSP.
use std::collections::HashMap;
/// Per-superstep outbound cross-shard contributions: `target_shard_id ->
/// [(destination_vertex_name, contribution)]`. The coordinator routes each entry
/// to the shard that owns `target_shard_id` for the next superstep.
pub type OutboundContributions = HashMap<u16, Vec<(String, f64)>>;
/// Per-shard PageRank state maintained across supersteps.
#[derive(Debug)]
pub struct ShardPageRankState {
pub vertex_count: usize,
pub rank: Vec<f64>,
pub next_rank: Vec<f64>,
pub out_degrees: Vec<usize>,
pub is_dangling: Vec<bool>,
pub boundary_edges: HashMap<u32, Vec<(String, u16)>>,
pub incoming_contributions: HashMap<String, f64>,
}
impl ShardPageRankState {
/// Initialize from local CSR partition.
pub fn init<F>(
vertex_count: usize,
out_degrees: Vec<usize>,
_ghost_lookup: F,
csr_out_edges: &dyn Fn(u32) -> Vec<(String, bool, u16)>,
) -> Self
where
F: Fn(&str) -> Option<u16>,
{
let init_rank = if vertex_count > 0 {
1.0 / vertex_count as f64
} else {
0.0
};
let rank = vec![init_rank; vertex_count];
let next_rank = vec![0.0; vertex_count];
let is_dangling: Vec<bool> = out_degrees.iter().map(|&d| d == 0).collect();
let mut boundary_edges: HashMap<u32, Vec<(String, u16)>> = HashMap::new();
for node in 0..vertex_count {
for (dst_name, is_ghost, target_shard) in csr_out_edges(node as u32) {
if is_ghost {
boundary_edges
.entry(node as u32)
.or_default()
.push((dst_name, target_shard));
}
}
}
Self {
vertex_count,
rank,
next_rank,
out_degrees,
is_dangling,
boundary_edges,
incoming_contributions: HashMap::new(),
}
}
/// Execute one superstep. Returns `(local_delta, local_dangling_sum,
/// outbound_contributions)`.
///
/// # Global dangling mass
///
/// `global_dangling_sum` is the dangling-node rank mass aggregated by the
/// coordinator from ALL shards' previous-superstep local sums (supplied via
/// the `local_dangling_sum` field of each shard's return value). The base
/// teleport term therefore uses the GLOBAL dangling mass rather than this
/// shard's local dangling mass, so dangling-node rank redistributes across
/// the WHOLE graph, not just the shard that owns the dangling node.
///
/// On superstep 0 the coordinator passes `0.0` (no previous local sums exist
/// yet) and the base collapses to the plain teleport `(1−d)/n` — identical
/// to before, correct for a single-shard graph with no dangling nodes. The
/// 1-superstep lag converges to the correct fixed point, exactly like the
/// existing cross-shard contribution round-trip.
///
/// The returned `local_dangling_sum` is this shard's dangling-node mass
/// computed from `self.rank` BEFORE the rank swap; the coordinator sums these
/// across shards into the next superstep's `global_dangling_sum`.
///
/// # Incoming contributions
///
/// Cross-shard contributions accumulated via [`Self::add_remote_contribution`]
/// are folded into `next_rank` **after** the local scatter and **before** the
/// rank swap. This ordering is mandatory for a stateless per-superstep round
/// trip: applying them after the swap silently lands them in the *previous*
/// iteration's rank vector.
///
/// `node_id_to_local` maps an incoming contribution's destination vertex name
/// to its local dense index; contributions whose target is not owned by this
/// shard are dropped.
///
/// # Personalization (Personalized PageRank)
///
/// `personalization` is `None` for standard (uniform) PageRank and
/// `Some(p)` for Personalized PageRank, where `p` is this shard's per-owned-node
/// GLOBALLY-normalized seed share, positionally aligned with `self.rank` /
/// `self.next_rank` (so `Σ_global p_i == 1.0` across ALL shards). When present,
/// both the teleport mass and the dangling mass redistribute by `p` instead of
/// uniformly — identical to single-node `build_personalization` semantics. The
/// scalar `redistributed` total mass is the same in both branches; only HOW it
/// is spread differs (uniform `/ n` vs. per-node `* p_i`).
pub fn superstep(
&mut self,
damping: f64,
global_n: usize,
global_dangling_sum: f64,
personalization: Option<&[f64]>,
local_edge_iter: &dyn Fn(u32) -> Vec<u32>,
node_id_to_local: &dyn Fn(&str) -> Option<u32>,
) -> (f64, f64, OutboundContributions) {
let n = global_n as f64;
// Compute THIS shard's local dangling mass from the PRE-SWAP rank vector
// and return it so the coordinator can aggregate it for the NEXT superstep.
let local_dangling_sum: f64 = self
.rank
.iter()
.enumerate()
.filter(|(i, _)| self.is_dangling[*i])
.map(|(_, r)| r)
.sum();
// Total mass to redistribute this superstep (SCALAR, matches single-node):
// the (1 - damping) teleport budget plus the damped GLOBAL dangling mass
// (aggregated by the coordinator from all shards' previous-superstep local
// sums). When there are no dangling nodes anywhere, `global_dangling_sum`
// is 0.0 and `redistributed == 1 - damping`.
let redistributed = (1.0 - damping) + damping * global_dangling_sum;
match personalization {
// Uniform: every node gets an equal share of the redistributed mass.
None => {
let base = redistributed / n;
for r in self.next_rank.iter_mut() {
*r = base;
}
}
// PPR: each owned node's base is its GLOBAL seed share of the
// redistributed mass. Summed over all shards this injects exactly
// `redistributed * Σ_global p_i == redistributed` (mass conserved).
Some(p) => {
for (slot, &pi) in self.next_rank.iter_mut().zip(p) {
*slot = redistributed * pi;
}
}
}
let mut outbound: HashMap<u16, Vec<(String, f64)>> = HashMap::new();
for u in 0..self.vertex_count {
let deg = self.out_degrees[u];
if deg == 0 {
continue;
}
let contrib = damping * self.rank[u] / deg as f64;
// Scatter to local edges.
for dst in local_edge_iter(u as u32) {
self.next_rank[dst as usize] += contrib;
}
// Scatter to boundary edges (cross-shard).
if let Some(boundary) = self.boundary_edges.get(&(u as u32)) {
for (dst_name, target_shard) in boundary {
outbound
.entry(*target_shard)
.or_default()
.push((dst_name.clone(), contrib));
}
}
}
// Fold incoming cross-shard contributions into next_rank BEFORE the
// swap so they land in the iteration being computed, not the prior one.
for (vertex_name, contrib) in &self.incoming_contributions {
if let Some(local_id) = node_id_to_local(vertex_name) {
self.next_rank[local_id as usize] += contrib;
}
}
let delta: f64 = self
.rank
.iter()
.zip(self.next_rank.iter())
.map(|(old, new)| (old - new).abs())
.sum();
std::mem::swap(&mut self.rank, &mut self.next_rank);
self.incoming_contributions.clear();
(delta, local_dangling_sum, outbound)
}
pub fn add_remote_contribution(&mut self, vertex_name: String, value: f64) {
*self
.incoming_contributions
.entry(vertex_name)
.or_insert(0.0) += value;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shard_state_init() {
let state = ShardPageRankState::init(3, vec![2, 1, 0], |_| None, &|_node| Vec::new());
assert_eq!(state.vertex_count, 3);
assert!(!state.is_dangling[0]);
assert!(state.is_dangling[2]);
}
#[test]
fn shard_state_with_ghost_edges() {
let state = ShardPageRankState::init(
2,
vec![2, 1],
|node| if node == "remote" { Some(5) } else { None },
&|node| {
if node == 0 {
vec![("remote".into(), true, 5)]
} else {
Vec::new()
}
},
);
assert_eq!(state.boundary_edges.len(), 1);
assert_eq!(state.boundary_edges[&0][0].1, 5);
}
#[test]
fn shard_superstep_local_only() {
let mut state = ShardPageRankState::init(3, vec![1, 1, 1], |_| None, &|_| Vec::new());
// No dangling nodes in this ring, so global_dangling_sum = 0.0 — behaviour
// is unchanged: base == teleport only, mass is conserved.
let (delta, local_dangling_sum, outbound) = state.superstep(
0.85,
3,
0.0, // no dangling nodes anywhere
None, // uniform PageRank
&|node| match node {
0 => vec![1],
1 => vec![2],
2 => vec![0],
_ => Vec::new(),
},
&|_name| None,
);
assert!(outbound.is_empty());
assert!(delta >= 0.0);
// No dangling nodes → local_dangling_sum must be 0.0.
assert_eq!(local_dangling_sum, 0.0);
let sum: f64 = state.rank.iter().sum();
assert!((sum - 1.0).abs() < 1e-6);
}
#[test]
fn global_dangling_sum_used_in_base() {
// 2 vertices: vertex 0 has out-degree 1 (non-dangling), vertex 1 has
// out-degree 0 (dangling). Simulate a cross-shard scenario by passing a
// global_dangling_sum LARGER than this shard's own dangling mass — as if
// additional dangling mass exists on other shards. Assert each node's
// base reflects the GLOBAL value, not just the local shard's dangling sum.
let n: usize = 10; // fictional global node count — larger than local 2
let damping = 0.85_f64;
// Local dangling mass = rank of vertex 1 = 1/2 (uniform init).
// External dangling mass = 0.3 (simulated from other shards).
let local_dangling_init = 1.0 / 2.0_f64;
let extra_dangling = 0.3_f64;
let global_dangling = local_dangling_init + extra_dangling;
let mut state = ShardPageRankState::init(
2,
vec![1, 0], // vertex 1 is dangling
|_| None,
&|_| Vec::new(), // no boundary edges in this focused test
);
// Edge 0 → 1 (local, no boundary edges needed for this assertion).
let (_delta, _local_dangling_sum, _outbound) = state.superstep(
damping,
n,
global_dangling,
None, // uniform PageRank
&|node| if node == 0 { vec![1] } else { Vec::new() },
&|_name| None,
);
// The base each node receives must be ((1-d) + d*global_dangling) / n.
let expected_base = ((1.0 - damping) + damping * global_dangling) / n as f64;
// Vertex 0: base + contribution from no in-edge in this pass
// (vertex 1 is dangling; no out-edges scatter to 0 locally).
// Vertex 1: base + damping * rank[0] / 1 (edge 0→1).
//
// We assert the minimum: both nodes start with at least `expected_base`
// in next_rank (the base is applied before any scatter). Since next_rank
// after the swap is state.rank, and vertex 1 also got vertex 0's scatter,
// we check vertex 0's rank == expected_base (it received no in-edge
// scatter this superstep).
let rank_v0 = state.rank[0];
assert!(
(rank_v0 - expected_base).abs() < 1e-12,
"vertex 0 rank should equal base={expected_base:.15} (no in-edge scatter), got {rank_v0:.15}"
);
}
#[test]
fn personalized_superstep_biases_base_toward_seed() {
// 3-node ring, no dangling nodes. A seed concentrated on vertex 0
// (p = [1, 0, 0]) must give vertex 0 a strictly-larger teleport base than
// its peers — the distributed analogue of single-node PPR biasing.
let damping = 0.85_f64;
let n: usize = 3;
// Globally-normalized seed share: all mass on vertex 0.
let p = [1.0_f64, 0.0, 0.0];
let mut state = ShardPageRankState::init(3, vec![1, 1, 1], |_| None, &|_| Vec::new());
let (_delta, local_dangling_sum, outbound) = state.superstep(
damping,
n,
0.0, // no dangling nodes
Some(&p),
&|node| match node {
0 => vec![1],
1 => vec![2],
2 => vec![0],
_ => Vec::new(),
},
&|_name| None,
);
assert!(outbound.is_empty());
assert_eq!(local_dangling_sum, 0.0);
// redistributed = (1 - d) since dangling sum is 0. The seeded node's base
// is `redistributed * 1.0`, peers' base is `redistributed * 0.0 == 0.0`,
// before any scatter. After scatter, vertex 0 (seeded base + incoming from
// vertex 2 which has base 0 → 0 contribution) must strictly outrank the
// others.
let redistributed = 1.0 - damping;
// Vertex 0 receives base = redistributed*1.0 plus a scatter from vertex 2,
// whose pre-swap rank was the uniform init 1/3 (init unchanged here).
// Regardless, vertex 0's rank must exceed both peers'.
assert!(
state.rank[0] > state.rank[1] && state.rank[0] > state.rank[2],
"seeded vertex 0 base must dominate: {:?}",
state.rank
);
// The seeded node's base alone (before scatter) is `redistributed`.
assert!(
state.rank[0] >= redistributed - 1e-12,
"seeded base should be at least redistributed={redistributed}, got {}",
state.rank[0]
);
}
#[test]
fn remote_contribution_accumulation() {
let mut state = ShardPageRankState::init(2, vec![1, 0], |_| None, &|_| Vec::new());
state.add_remote_contribution("n0".into(), 0.1);
state.add_remote_contribution("n0".into(), 0.2);
state.add_remote_contribution("n1".into(), 0.3);
assert!((state.incoming_contributions["n0"] - 0.3).abs() < 1e-10);
assert!((state.incoming_contributions["n1"] - 0.3).abs() < 1e-10);
}
}