Skip to main content

molframe_core/topology/
csr.rs

1//! Compressed sparse row storage for per-item adjacency.
2//!
3//! A `Vec<Vec<T>>` adjacency costs one heap allocation and one 24-byte header
4//! per row, so a billion-atom neighbour list spends 24 GB and a billion
5//! allocator round trips before storing a single edge. `Csr` holds the same
6//! information as two flat vectors: a row's items are a contiguous slice, found
7//! by two loads and a subtraction.
8//!
9//! Offsets are `usize` rather than `u32` because edge counts outrun row counts:
10//! a billion atoms at fifty neighbours each is 5e10 edges, well past `u32`.
11
12/// Rows of `T` stored contiguously, addressed by a prefix-sum offset table.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Csr<T> {
15    /// Row `i` occupies `values[offsets[i]..offsets[i + 1]]`.
16    ///
17    /// Always holds `rows + 1` entries, so the final row needs no special case.
18    offsets: Vec<usize>,
19    /// Every row's items, concatenated in row order.
20    values: Vec<T>,
21}
22
23impl<T> Csr<T> {
24    /// An adjacency with no rows at all.
25    #[must_use]
26    pub fn empty() -> Self {
27        Self {
28            offsets: vec![0],
29            values: Vec::new(),
30        }
31    }
32
33    /// The number of rows.
34    #[must_use]
35    pub fn rows(&self) -> usize {
36        self.offsets.len() - 1
37    }
38
39    /// Whether there are no rows.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.rows() == 0
43    }
44
45    /// The total number of stored items across every row.
46    #[must_use]
47    pub fn len(&self) -> usize {
48        self.values.len()
49    }
50
51    /// The items of row `index`, or an empty slice when it is out of range.
52    ///
53    /// Two loads and a subtraction, with no pointer chase.
54    #[must_use]
55    pub fn row(&self, index: usize) -> &[T] {
56        let (Some(start), Some(end)) = (
57            self.offsets.get(index),
58            index.checked_add(1).and_then(|next| self.offsets.get(next)),
59        ) else {
60            return &[];
61        };
62        match self.values.get(*start..*end) {
63            Some(items) => items,
64            None => &[],
65        }
66    }
67
68    /// The items of row `index` as a mutable slice, empty when out of range.
69    pub fn row_mut(&mut self, index: usize) -> &mut [T] {
70        let (Some(start), Some(end)) = (
71            self.offsets.get(index),
72            index.checked_add(1).and_then(|next| self.offsets.get(next)),
73        ) else {
74            return &mut [];
75        };
76        let (start, end) = (*start, *end);
77        match self.values.get_mut(start..end) {
78            Some(items) => items,
79            None => &mut [],
80        }
81    }
82
83    /// Every row in order, as slices.
84    pub fn iter(&self) -> impl Iterator<Item = &[T]> {
85        (0..self.rows()).map(|index| self.row(index))
86    }
87}
88
89/// Scatters items into their rows before freezing into a [`Csr`].
90///
91/// The shape is fixed at construction, so scattering is a write to a cursor and
92/// never reallocates or moves an earlier row.
93#[derive(Debug)]
94pub struct CsrBuilder<T> {
95    /// The next free slot of each row, starting at that row's offset.
96    cursors: Vec<usize>,
97    /// The finished prefix sums, moved into the `Csr` unchanged.
98    offsets: Vec<usize>,
99    /// Value storage, sized to the declared total before any row is read.
100    values: Vec<T>,
101}
102
103impl<T: Clone + Default> CsrBuilder<T> {
104    /// Allocates the offset table for a known shape and fills value storage.
105    ///
106    /// `degrees[i]` is the exact item count of row `i`. Callers that already
107    /// run a counting pass — the usual shape for a neighbour search — pay no
108    /// reallocation at all: this reserves the exact total, and rows may then be
109    /// scattered into in any order.
110    #[must_use]
111    pub fn with_degrees(degrees: &[usize]) -> Self {
112        let mut offsets = Vec::with_capacity(degrees.len() + 1);
113        let mut total = 0usize;
114        offsets.push(0);
115        for degree in degrees {
116            total = total.saturating_add(*degree);
117            offsets.push(total);
118        }
119
120        Self {
121            cursors: offsets[..degrees.len()].to_vec(),
122            offsets,
123            values: vec![T::default(); total],
124        }
125    }
126
127    /// Writes `item` into the next free slot of `row`.
128    ///
129    /// Items beyond a row's declared degree, and rows beyond the declared row
130    /// count, are dropped rather than corrupting a neighbouring row.
131    pub fn push(&mut self, row: usize, item: T) {
132        let (Some(cursor), Some(end)) = (self.cursors.get_mut(row), self.offsets.get(row + 1))
133        else {
134            return;
135        };
136        if *cursor >= *end {
137            return;
138        }
139        if let Some(slot) = self.values.get_mut(*cursor) {
140            *slot = item;
141            *cursor += 1;
142        }
143    }
144
145    /// Freezes the scattered rows, closing any gap left by an under-filled row.
146    ///
147    /// Rows given a degree larger than the number of items actually pushed keep
148    /// no default padding: the surviving items are shifted down and the offsets
149    /// rebuilt, so every row is exactly as long as what reached it.
150    #[must_use]
151    pub fn finish(mut self) -> Csr<T> {
152        let mut offsets = Vec::with_capacity(self.offsets.len());
153        let mut write = 0usize;
154        offsets.push(0);
155
156        for row in 0..self.cursors.len() {
157            let (Some(start), Some(cursor)) = (self.offsets.get(row), self.cursors.get(row)) else {
158                break;
159            };
160            for read in *start..*cursor {
161                self.values.swap(write, read);
162                write += 1;
163            }
164            offsets.push(write);
165        }
166
167        self.values.truncate(write);
168        Csr {
169            offsets,
170            values: self.values,
171        }
172    }
173}
174
175impl<T: Clone + Default + Ord> CsrBuilder<T> {
176    /// Freezes the scattered rows with each row sorted and deduplicated.
177    ///
178    /// A candidate-pair search may report the same pair from two cells, so an
179    /// adjacency built from one is canonicalised before use. Sorting happens
180    /// within each row's own slice and compaction is a single linear pass, so
181    /// no per-row allocation is involved.
182    #[must_use]
183    pub fn finish_canonical(mut self) -> Csr<T> {
184        let mut offsets = Vec::with_capacity(self.offsets.len());
185        let mut write = 0usize;
186        offsets.push(0);
187
188        for row in 0..self.cursors.len() {
189            let (Some(start), Some(cursor)) = (self.offsets.get(row), self.cursors.get(row)) else {
190                break;
191            };
192            let (start, cursor) = (*start, *cursor);
193            if let Some(items) = self.values.get_mut(start..cursor) {
194                items.sort_unstable();
195            }
196
197            let row_start = write;
198            for read in start..cursor {
199                if write > row_start && self.values.get(write - 1) == self.values.get(read) {
200                    continue;
201                }
202                self.values.swap(write, read);
203                write += 1;
204            }
205            offsets.push(write);
206        }
207
208        self.values.truncate(write);
209        Csr {
210            offsets,
211            values: self.values,
212        }
213    }
214}
215
216pub(super) fn stored_range(
217    starts: &[u32],
218    counts: &[u32],
219    index: usize,
220) -> Option<std::ops::Range<u32>> {
221    let start = *starts.get(index)?;
222    let count = *counts.get(index)?;
223
224    Some(start..start.checked_add(count)?)
225}
226
227#[cfg(test)]
228#[path = "csr_tests.rs"]
229mod tests;