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;