yo_graph/snapshot.rs
1//! The graph flattened into dense arrays, which is the shape every algorithm
2//! wants (`11` section 8).
3//!
4//! [`crate::Adjacency`] is built for a graph that is being changed: a run is
5//! found by hashing the node and the label, it is grown and shrunk in place, and
6//! a node is whatever `u64` the caller decided to call it. That is the right
7//! structure for a traversal, which touches a handful of runs and wants each one
8//! to be a probe and a sequential read, and it is the wrong structure for
9//! PageRank, which touches every run twenty times and wants node zero's
10//! neighbours to be the first thing in the array.
11//!
12//! So an algorithm runs over one of these instead. It is the same edges,
13//! renumbered from zero, in one flat CSR per direction, taken at a point in
14//! time. Nothing in here can change the graph and nothing here notices when the
15//! graph changes, which is the trade: a snapshot goes stale, and in exchange
16//! every algorithm below it is arrays and index arithmetic with no hashing on
17//! any inner loop.
18//!
19//! ```
20//! use yo_graph::{Graph, NO_PROPS, Snapshot};
21//!
22//! const FOLLOWS: u32 = 1;
23//!
24//! let mut g = Graph::new();
25//! g.link(10, 20, FOLLOWS, NO_PROPS)?;
26//! g.link(20, 30, FOLLOWS, NO_PROPS)?;
27//!
28//! let s = Snapshot::of(&g);
29//! // Ids are renumbered from zero, in the order the graph's own ids sort.
30//! assert_eq!(s.nodes(), 3);
31//! assert_eq!(s.dense(20), Some(1));
32//! assert_eq!(s.out(1), [2]);
33//! assert_eq!(s.into_(1), [0]);
34//! assert_eq!(s.id(2), 30);
35//! # Ok::<(), yo_common::Error>(())
36//! ```
37//!
38//! # Why the numbering is sorted and not arrival order
39//!
40//! Because it has to be reproducible. Two snapshots of the same graph have to
41//! give the same dense id to the same node, or a caller cannot hold a PageRank
42//! vector from one run and a component table from another and compare them by
43//! index. The property store hands its ids back in whatever order its table
44//! happens to hold them, so the ids are sorted, and sorting is also the cheapest
45//! way to build the reverse map for the case that matters.
46//!
47//! # Two ways to turn a graph's id into a dense one
48//!
49//! A graph whose ids came out of a counter, which is every graph the wire path
50//! builds, has ids that are already `0..n`. That gets a direct table: one
51//! `Vec<u32>` indexed by the id, one load per lookup, no comparisons. A graph
52//! whose ids are hashes or timestamps gets a binary search over the sorted ids,
53//! which is about twenty dependent loads instead of one.
54//!
55//! The rule is the table when the highest id is less than twice the node count,
56//! so the table is never more than twice the size of the ids it replaces, and
57//! the choice is made once when the snapshot is built rather than per lookup.
58//! It only matters while the snapshot is being built, because after that every
59//! algorithm works in dense ids and never asks.
60//!
61//! # The reverse index is transposed and not read
62//!
63//! The plane can index incoming edges and this does not use that. The incoming
64//! CSR here is the transpose of the outgoing one, which is a counting sort over
65//! the edges that were projected, because that is the only way the two can be
66//! guaranteed to be the same set of edges. It also means an algorithm that needs
67//! predecessors, which is most of the interesting ones, works on a graph built
68//! with [`crate::Graph::out_only`].
69
70use crate::{Dir, Graph};
71
72/// A graph as dense arrays: ids renumbered from zero, one CSR per direction.
73#[derive(Debug, Default, Clone)]
74pub struct Snapshot {
75 /// The graph's own id for each dense id, ascending.
76 ids: Vec<u64>,
77 /// Where node `i`'s outgoing neighbours start, with a final total.
78 out_at: Vec<u64>,
79 /// Every outgoing neighbour, grouped by source.
80 out_to: Vec<u32>,
81 /// The same, transposed.
82 in_at: Vec<u64>,
83 in_to: Vec<u32>,
84}
85
86impl Snapshot {
87 /// Every node and every edge of `g`, under every label.
88 #[must_use]
89 pub fn of(g: &Graph) -> Snapshot {
90 let labels = g.labels().to_vec();
91 Snapshot::labelled(g, &labels)
92 }
93
94 /// Every node of `g`, and the edges under the labels named.
95 ///
96 /// Every node, including the ones that have no edge under any of these
97 /// labels, because an algorithm's answer is a vector indexed by node and a
98 /// node that was left out of the numbering would shift every answer after
99 /// it. An isolated node is an empty run and costs two offsets.
100 #[must_use]
101 pub fn labelled(g: &Graph, labels: &[u32]) -> Snapshot {
102 project(g, labels, None).0
103 }
104
105 /// The same projection, and a weight for every outgoing edge in it.
106 ///
107 /// The weight is read off the edge's own document, out of the field named,
108 /// which is where a weight lives in this engine: an edge is a document and a
109 /// weight is one of its fields. The weights come back in the order the
110 /// outgoing runs are in, so the weight of the edge `out(node)[i]` is
111 /// `weights[out_at(node) + i]`, and a shortest path only has to index the
112 /// two arrays together.
113 ///
114 /// An edge whose document has no such field, or has one that is not a
115 /// number, or has a negative one, gets `missing`. A negative weight is
116 /// refused rather than clamped because every shortest path algorithm worth
117 /// having needs weights that do not go backwards, and quietly turning a
118 /// minus five into a zero is a worse answer than using the default the
119 /// caller chose.
120 ///
121 /// A float is rounded to the nearest whole number, and anything above four
122 /// billion is held at four billion, because a weight is `u32` so that an
123 /// edge costs four bytes rather than eight.
124 #[must_use]
125 pub fn weighted(g: &Graph, labels: &[u32], field: &[u8], missing: u32) -> (Snapshot, Vec<u32>) {
126 project(g, labels, Some((field, missing)))
127 }
128
129 /// How many nodes there are, which is the length of every answer.
130 #[must_use]
131 pub fn nodes(&self) -> u32 {
132 self.ids.len() as u32
133 }
134
135 /// How many edges were projected, counting a parallel edge as its own.
136 #[must_use]
137 pub fn edges(&self) -> u64 {
138 self.out_to.len() as u64
139 }
140
141 /// Whether there is nothing here.
142 #[must_use]
143 pub fn is_empty(&self) -> bool {
144 self.ids.is_empty()
145 }
146
147 /// The graph's own id for a dense one.
148 ///
149 /// # Panics
150 ///
151 /// If `node` is not a node of this snapshot, which is a bug in the caller:
152 /// every dense id an algorithm can be holding came out of `0..nodes()`.
153 #[must_use]
154 pub fn id(&self, node: u32) -> u64 {
155 self.ids[node as usize]
156 }
157
158 /// The dense id for one of the graph's, or `None` if it has no such node.
159 #[must_use]
160 pub fn dense(&self, id: u64) -> Option<u32> {
161 self.ids.binary_search(&id).ok().map(|at| at as u32)
162 }
163
164 /// Node `node`'s outgoing neighbours.
165 #[must_use]
166 pub fn out(&self, node: u32) -> &[u32] {
167 run(&self.out_at, &self.out_to, node)
168 }
169
170 /// Where node `node`'s outgoing run starts in the flat array.
171 ///
172 /// Only useful next to something that was built alongside that array, which
173 /// in practice means the weights out of [`Snapshot::weighted`]: the weight
174 /// of `out(node)[i]` is `weights[out_at(node) + i]`.
175 #[must_use]
176 pub fn out_at(&self, node: u32) -> usize {
177 self.out_at[node as usize] as usize
178 }
179
180 /// Node `node`'s incoming neighbours.
181 ///
182 /// The trailing underscore is because `in` is a keyword, and the name is
183 /// still `in` because that is the word for what it is.
184 #[must_use]
185 pub fn into_(&self, node: u32) -> &[u32] {
186 run(&self.in_at, &self.in_to, node)
187 }
188
189 /// Neighbours in whichever direction, for an algorithm that takes one.
190 #[must_use]
191 pub fn neighbours(&self, node: u32, dir: Dir) -> &[u32] {
192 match dir {
193 Dir::Out => self.out(node),
194 Dir::In => self.into_(node),
195 }
196 }
197
198 /// How many edges leave `node`.
199 #[must_use]
200 pub fn out_degree(&self, node: u32) -> u32 {
201 self.out(node).len() as u32
202 }
203
204 /// How many edges arrive at `node`.
205 #[must_use]
206 pub fn in_degree(&self, node: u32) -> u32 {
207 self.into_(node).len() as u32
208 }
209
210 /// Ask the cache for a node's outgoing run, before the loop that reads it.
211 ///
212 /// The same call [`crate::Adjacency::prefetch`] is for and much cheaper to
213 /// serve, because a dense run is one load of the offset and then a
214 /// contiguous read rather than a hash and a probe.
215 pub fn prefetch(&self, node: u32) {
216 let at = self.out_at[node as usize] as usize;
217 if at < self.out_to.len() {
218 yo_common::prefetch(&self.out_to[at]);
219 }
220 }
221
222 /// Resident bytes.
223 #[must_use]
224 pub fn memory_bytes(&self) -> usize {
225 self.ids.capacity() * size_of::<u64>()
226 + (self.out_at.capacity() + self.in_at.capacity()) * size_of::<u64>()
227 + (self.out_to.capacity() + self.in_to.capacity()) * size_of::<u32>()
228 }
229}
230
231/// The projection itself, with or without weights.
232///
233/// One function rather than two, because the weights have to come out in the
234/// same order the neighbours do and the only way to be sure of that is for the
235/// same loop to write both.
236fn project(g: &Graph, labels: &[u32], weight: Option<(&[u8], u32)>) -> (Snapshot, Vec<u32>) {
237 let mut ids: Vec<u64> = g.node_props().iter().map(|(id, _)| id).collect();
238 ids.sort_unstable();
239 let n = ids.len();
240 let map = Map::of(&ids);
241
242 // One pass to count and one to fill, which is the standard CSR build. The
243 // counts are accumulated one to the right so that the prefix sum leaves the
244 // starts in place and the fill can use the same array as its cursor.
245 let mut out_at = vec![0u64; n + 1];
246 for label in labels {
247 g.adjacency().for_each_run(*label, Dir::Out, |node, ns, _| {
248 let Some(at) = map.dense(node) else { return };
249 out_at[at as usize + 1] += ns.len() as u64;
250 });
251 }
252 for i in 0..n {
253 out_at[i + 1] += out_at[i];
254 }
255
256 let mut out_to = vec![0u32; out_at[n] as usize];
257 let mut weights = match weight {
258 Some(_) => vec![0u32; out_at[n] as usize],
259 None => Vec::new(),
260 };
261 let mut cursor = out_at.clone();
262 for label in labels {
263 g.adjacency()
264 .for_each_run(*label, Dir::Out, |node, ns, es| {
265 let Some(at) = map.dense(node) else { return };
266 for (i, to) in ns.iter().enumerate() {
267 // A neighbour the map does not know cannot happen: the plane's
268 // ends are nodes and every node is in the map. It is skipped
269 // rather than asserted because a snapshot is a read and a read
270 // should not be able to panic.
271 let Some(to) = map.dense(*to) else { continue };
272 let put = cursor[at as usize] as usize;
273 out_to[put] = to;
274 if let Some((field, missing)) = weight {
275 weights[put] = weigh(g, es[i], field, missing);
276 }
277 cursor[at as usize] += 1;
278 }
279 });
280 }
281
282 let (in_at, in_to) = transpose(n, &out_at, &out_to);
283 let s = Snapshot {
284 ids,
285 out_at,
286 out_to,
287 in_at,
288 in_to,
289 };
290 (s, weights)
291}
292
293/// One edge's weight, out of its own document.
294fn weigh(g: &Graph, slot: u32, field: &[u8], missing: u32) -> u32 {
295 let Some(value) = g.edge(slot).and_then(|doc| doc.get(field)) else {
296 return missing;
297 };
298 if let Some(n) = value.as_int() {
299 return u32::try_from(n).unwrap_or(if n < 0 { missing } else { u32::MAX });
300 }
301 if let Some(n) = value.as_float() {
302 if n.is_nan() || n < 0.0 {
303 return missing;
304 }
305 // Rounded rather than truncated, so an edge that weighs 0.6 does not
306 // turn into a free one.
307 return if n >= f64::from(u32::MAX) {
308 u32::MAX
309 } else {
310 n.round() as u32
311 };
312 }
313 missing
314}
315
316/// One node's slice out of a CSR.
317#[inline]
318fn run<'a>(at: &[u64], to: &'a [u32], node: u32) -> &'a [u32] {
319 let i = node as usize;
320 let (from, upto) = (at[i] as usize, at[i + 1] as usize);
321 &to[from..upto]
322}
323
324/// The reverse CSR, as a counting sort over the forward one.
325fn transpose(n: usize, out_at: &[u64], out_to: &[u32]) -> (Vec<u64>, Vec<u32>) {
326 let mut in_at = vec![0u64; n + 1];
327 for to in out_to {
328 in_at[*to as usize + 1] += 1;
329 }
330 for i in 0..n {
331 in_at[i + 1] += in_at[i];
332 }
333 let mut in_to = vec![0u32; out_to.len()];
334 let mut cursor = in_at.clone();
335 for from in 0..n {
336 let (a, b) = (out_at[from] as usize, out_at[from + 1] as usize);
337 for to in &out_to[a..b] {
338 in_to[cursor[*to as usize] as usize] = from as u32;
339 cursor[*to as usize] += 1;
340 }
341 }
342 (in_at, in_to)
343}
344
345/// A graph's ids to dense ones, either way round.
346#[derive(Debug)]
347enum Map<'a> {
348 /// The ids are close enough to `0..n` to index an array with.
349 Table(Vec<u32>),
350 /// They are not, so they are searched for.
351 Search(&'a [u64]),
352}
353
354/// A dense id that no node has, for a hole in the table.
355const NONE: u32 = u32::MAX;
356
357impl<'a> Map<'a> {
358 /// Whichever of the two suits these ids, which are sorted.
359 fn of(ids: &'a [u64]) -> Map<'a> {
360 let top = ids.last().copied().unwrap_or(0);
361 // Twice the node count, so the table is never more than twice the size
362 // of the sorted ids it is standing in for, and a graph numbered from a
363 // counter is always under it.
364 if !ids.is_empty() && top < 2 * ids.len() as u64 {
365 let mut table = vec![NONE; top as usize + 1];
366 for (at, id) in ids.iter().enumerate() {
367 table[*id as usize] = at as u32;
368 }
369 return Map::Table(table);
370 }
371 Map::Search(ids)
372 }
373
374 #[inline]
375 fn dense(&self, id: u64) -> Option<u32> {
376 match self {
377 Map::Table(table) => match table.get(id as usize) {
378 Some(&NONE) | None => None,
379 Some(at) => Some(*at),
380 },
381 Map::Search(ids) => ids.binary_search(&id).ok().map(|at| at as u32),
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::graph::NO_PROPS;
390
391 /// A graph with a hop in it, built the way the wire path builds one.
392 fn chain(n: u64) -> Graph {
393 let mut g = Graph::new();
394 for i in 0..n - 1 {
395 g.link(i, i + 1, 1, NO_PROPS).unwrap();
396 }
397 g
398 }
399
400 #[test]
401 fn the_dense_ids_are_the_graphs_ids_in_order() {
402 let mut g = Graph::new();
403 g.link(900, 100, 1, NO_PROPS).unwrap();
404 g.link(100, 500, 1, NO_PROPS).unwrap();
405 let s = Snapshot::of(&g);
406 assert_eq!(s.nodes(), 3);
407 assert_eq!(s.edges(), 2);
408 assert_eq!((s.id(0), s.id(1), s.id(2)), (100, 500, 900));
409 assert_eq!(s.dense(500), Some(1));
410 assert_eq!(s.dense(501), None);
411 assert_eq!(s.out(0), [1]);
412 assert_eq!(s.out(2), [0]);
413 assert_eq!(s.into_(0), [2]);
414 assert!(s.out(1).is_empty());
415 }
416
417 /// An isolated node is in the numbering, or every answer after it would be
418 /// about a different node than the caller thinks.
419 #[test]
420 fn a_node_with_no_edges_is_still_a_node() {
421 let mut g = Graph::new();
422 g.link(0, 2, 1, NO_PROPS).unwrap();
423 g.add_node(1).unwrap();
424 let s = Snapshot::of(&g);
425 assert_eq!(s.nodes(), 3);
426 assert_eq!(s.id(1), 1);
427 assert!(s.out(1).is_empty());
428 assert!(s.into_(1).is_empty());
429 assert_eq!(s.out(0), [2]);
430 }
431
432 #[test]
433 fn only_the_labels_asked_for_come_along() {
434 let mut g = Graph::new();
435 g.link(0, 1, 7, NO_PROPS).unwrap();
436 g.link(0, 2, 9, NO_PROPS).unwrap();
437 let all = Snapshot::of(&g);
438 assert_eq!(all.out(0), [1, 2]);
439 let one = Snapshot::labelled(&g, &[9]);
440 assert_eq!(one.nodes(), 3, "every node, whatever the labels");
441 assert_eq!(one.out(0), [2]);
442 assert_eq!(one.edges(), 1);
443 let none = Snapshot::labelled(&g, &[]);
444 assert_eq!(none.nodes(), 3);
445 assert_eq!(none.edges(), 0);
446 }
447
448 /// The transpose is the forward index read the other way, edge for edge,
449 /// including a parallel edge and a self loop.
450 #[test]
451 fn the_reverse_index_is_the_forward_one_transposed() {
452 let mut g = Graph::new();
453 g.link(0, 1, 1, NO_PROPS).unwrap();
454 g.link(0, 1, 1, NO_PROPS).unwrap();
455 g.link(1, 1, 1, NO_PROPS).unwrap();
456 let s = Snapshot::of(&g);
457 assert_eq!(s.out(0), [1, 1]);
458 assert_eq!(s.into_(1), [0, 0, 1]);
459 assert_eq!(s.out(1), [1]);
460 assert_eq!(s.edges(), 3);
461 assert_eq!(s.in_degree(1), 3);
462 assert_eq!(s.out_degree(0), 2);
463
464 let mut forward = 0;
465 let mut back = 0;
466 for i in 0..s.nodes() {
467 forward += s.out(i).len();
468 back += s.into_(i).len();
469 }
470 assert_eq!(forward, back);
471 }
472
473 /// A graph built with only outgoing edges indexed still gets predecessors,
474 /// because the reverse index here is built and not read.
475 #[test]
476 fn an_out_only_graph_still_has_predecessors() {
477 let mut g = Graph::out_only();
478 g.link(0, 1, 1, NO_PROPS).unwrap();
479 g.link(2, 1, 1, NO_PROPS).unwrap();
480 assert!(g.neighbours(1, 1, Dir::In).is_empty(), "not in the plane");
481 let s = Snapshot::of(&g);
482 assert_eq!(s.into_(1), [0, 2]);
483 }
484
485 /// The two maps are the same map, which is what makes the fast one safe to
486 /// choose.
487 #[test]
488 fn a_dense_numbering_and_a_scattered_one_agree() {
489 let dense: Vec<u64> = (0..64).collect();
490 let scattered: Vec<u64> = (0..64).map(|i| i * 1000 + 7).collect();
491 let table = Map::of(&dense);
492 let search = Map::of(&scattered);
493 assert!(matches!(table, Map::Table(_)), "a counter gets the table");
494 assert!(matches!(search, Map::Search(_)), "hashes get the search");
495 for i in 0..64u64 {
496 assert_eq!(table.dense(i), Some(i as u32));
497 assert_eq!(search.dense(i * 1000 + 7), Some(i as u32));
498 }
499 assert_eq!(table.dense(64), None);
500 assert_eq!(table.dense(u64::MAX), None);
501 assert_eq!(search.dense(8), None);
502
503 // A hole in the middle is a hole and not the node next to it.
504 let holed = [0u64, 1, 3];
505 let map = Map::of(&holed);
506 assert!(matches!(map, Map::Table(_)));
507 assert_eq!(map.dense(2), None);
508 assert_eq!(map.dense(3), Some(2));
509 }
510
511 #[test]
512 fn an_empty_graph_snapshots_to_nothing() {
513 let s = Snapshot::of(&Graph::new());
514 assert!(s.is_empty());
515 assert_eq!(s.nodes(), 0);
516 assert_eq!(s.edges(), 0);
517 assert_eq!(s.dense(0), None);
518 }
519
520 #[test]
521 fn a_long_chain_reads_back_end_to_end() {
522 // A shorter chain under Miri. Every node is read back, so this costs
523 // its length twice over, once to build and once to check, and a chain
524 // reads back end to end at any length. The counts below all come from
525 // the one number: written out again they would stop agreeing with it
526 // and the loop would walk a chain the asserts were not about.
527 let n = if cfg!(miri) { 60u64 } else { 10_000 };
528 let s = Snapshot::of(&chain(n));
529 assert_eq!(u64::from(s.nodes()), n);
530 assert_eq!(s.edges(), n - 1);
531 for i in 0..s.nodes() - 1 {
532 assert_eq!(s.out(i), [i + 1], "at {i}");
533 }
534 assert!(s.out(s.nodes() - 1).is_empty());
535 s.prefetch(0);
536 assert!(s.memory_bytes() > 0);
537 }
538}