Skip to main content

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        let s = Snapshot::of(&chain(10_000));
523        assert_eq!(s.nodes(), 10_000);
524        assert_eq!(s.edges(), 9_999);
525        for i in 0..s.nodes() - 1 {
526            assert_eq!(s.out(i), [i + 1], "at {i}");
527        }
528        assert!(s.out(9_999).is_empty());
529        s.prefetch(0);
530        assert!(s.memory_bytes() > 0);
531    }
532}