subdiv_kernels/inverse.rs
1//! Inverse stencil maps -- "which outputs change when these inputs change".
2//!
3//! A [`StencilTable`] maps input (control) points to output points: each output
4//! row is a weighted sum of a bounded set of inputs. The *inverse* map is its
5//! transpose -- for each input point, the set of output rows whose stencil
6//! references it. Moving one control point changes exactly the outputs its
7//! transpose lists; everything else is bit-identical. This is the locality
8//! property the sparse / incremental evaluation path is built on.
9//!
10//! [`InverseStencilMap`] inverts a single table. The map is topology-only --
11//! it depends on the stencils' sparsity pattern, not on any weights or data --
12//! so it has the same lifetime as the stencil table: build it once when the
13//! stencils are built, reuse it across every position edit.
14
15use crate::StencilTable;
16use crate::csr::CsrVec;
17
18/// Transpose of a single [`StencilTable`].
19///
20/// [`affected_outputs`](Self::affected_outputs) maps a set of changed input
21/// indices to the sorted, unique set of output rows whose stencils reference any
22/// of them.
23#[derive(Debug, Clone)]
24pub struct InverseStencilMap {
25 /// CSR transpose: row `i` lists the output rows referencing input `i`.
26 /// `transpose.len()` is one past the largest referenced input index.
27 transpose: CsrVec,
28 output_count: usize,
29}
30
31impl<'a> From<&'a StencilTable> for InverseStencilMap {
32 /// Build the inverse (transpose) of `table` in a single pass over its
33 /// entries.
34 fn from(table: &'a StencilTable) -> Self {
35 let output_count = table.output_count();
36 let input_count = table
37 .indices
38 .iter()
39 .copied()
40 .max()
41 .map_or(0, |m| m as usize + 1);
42
43 // Bucket each output row under every input its stencil references. One
44 // pass over all stencil entries; visiting rows in increasing order
45 // leaves each bucket sorted ascending.
46 let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); input_count];
47 for row in 0..output_count {
48 let start = table.offsets[row] as usize;
49 let end = table.offsets[row + 1] as usize;
50 for &input in &table.indices[start..end] {
51 buckets[input as usize].push(row as u32);
52 }
53 }
54
55 Self {
56 transpose: CsrVec::from_jagged_u32(&buckets),
57 output_count,
58 }
59 }
60}
61
62impl InverseStencilMap {
63 /// Number of output rows of the original table.
64 #[inline]
65 pub fn output_count(&self) -> usize {
66 self.output_count
67 }
68
69 /// Output rows affected by changing `changed_inputs`, sorted ascending with
70 /// duplicates removed. Input indices that no output references (or that are
71 /// out of range) contribute nothing.
72 pub fn affected_outputs(&self, changed_inputs: &[u32]) -> Vec<u32> {
73 // Mark each affected row in a scratch bitset, collecting it on first
74 // sight, then sort the (small) result. This drops the original's full
75 // O(output_count) scan-to-collect -- the dominant cost on a large mesh --
76 // without sorting the multiplicity-laden gather (which a plain
77 // sort+dedup does, and which loses badly once the affected set is a
78 // sizeable fraction). Robust across "tiny fraction of a huge mesh" and
79 // "sizeable fraction of a small one"; a near-total change set should take
80 // the dense path anyway (see the design doc's honest-payoff note).
81 let mut seen = vec![false; self.output_count];
82 let mut affected = Vec::new();
83 for &input in changed_inputs {
84 let i = input as usize;
85 if i < self.transpose.len() {
86 for &row in self.transpose.row(i) {
87 let r = row as usize;
88 if !seen[r] {
89 seen[r] = true;
90 affected.push(row);
91 }
92 }
93 }
94 }
95 affected.sort_unstable();
96 affected
97 }
98}
99
100/// Per-level inverse maps for a multi-level refinement.
101///
102/// `level_stencils[k]` maps level-k vertices to level-(k+1) vertices, so the
103/// inverse of level `k` maps a changed set in level-k space to the affected set
104/// in level-(k+1) space. [`affected_outputs`](Self::affected_outputs) threads a
105/// base-cage change set forward through every level to the final-level outputs
106/// it touches -- equivalent to inverting the composed table, but without paying
107/// the composition cost.
108///
109/// The chain is topology-only: build it once when the stencils are built and
110/// reuse it across every position edit.
111#[derive(Debug, Clone)]
112pub struct InverseStencilChain {
113 levels: Vec<InverseStencilMap>,
114}
115
116impl<'a> From<&'a [StencilTable]> for InverseStencilChain {
117 /// Build per-level inverse maps from a refinement's per-level stencil tables
118 /// (e.g. [`RefinementResult::level_stencils`](crate::RefinementResult::level_stencils)).
119 fn from(level_stencils: &'a [StencilTable]) -> Self {
120 Self {
121 levels: level_stencils.iter().map(InverseStencilMap::from).collect(),
122 }
123 }
124}
125
126impl InverseStencilChain {
127 /// Number of refinement levels in the chain.
128 #[inline]
129 pub fn level_count(&self) -> usize {
130 self.levels.len()
131 }
132
133 /// Final-level output rows affected by changing the given base-level input
134 /// (control) points, sorted ascending with duplicates removed.
135 ///
136 /// With no levels the chain is the identity: the affected outputs are the
137 /// changed inputs themselves.
138 pub fn affected_outputs(&self, changed_base_inputs: &[u32]) -> Vec<u32> {
139 // One-shot: allocate a scratch and delegate to the reusable path. For a
140 // hot edit loop, cache an `AffectedScratch` and call
141 // `affected_outputs_into` directly to avoid this per-call allocation.
142 let mut scratch = AffectedScratch::default();
143 let mut out = Vec::new();
144 self.affected_outputs_into(changed_base_inputs, &mut scratch, &mut out);
145 out
146 }
147
148 /// Like [`affected_outputs`](Self::affected_outputs) but writes into `out`
149 /// (cleared first) and reuses `scratch`, so a hot edit loop allocates
150 /// nothing after the scratch has warmed up. Same sorted, deduped result.
151 pub fn affected_outputs_into(
152 &self,
153 changed_base_inputs: &[u32],
154 scratch: &mut AffectedScratch,
155 out: &mut Vec<u32>,
156 ) {
157 let AffectedScratch {
158 stamp,
159 generation,
160 front,
161 back,
162 } = scratch;
163
164 out.clear();
165
166 if self.levels.is_empty() {
167 // Identity: the affected outputs are the changed inputs themselves.
168 out.extend_from_slice(changed_base_inputs);
169 out.sort_unstable();
170 out.dedup();
171 return;
172 }
173
174 let max_output = self
175 .levels
176 .iter()
177 .map(|level| level.output_count)
178 .max()
179 .unwrap_or(0);
180 if stamp.len() < max_output {
181 stamp.resize(max_output, 0);
182 }
183
184 front.clear();
185 front.extend_from_slice(changed_base_inputs);
186
187 // Propagate forward, deduping each level's affected rows by generation
188 // stamp (so the buffer is never cleared between calls).
189 for level in &self.levels {
190 *generation = generation.wrapping_add(1);
191 if *generation == 0 {
192 // Counter wrapped; reset so stale 0-stamps aren't read as seen.
193 stamp.iter_mut().for_each(|s| *s = 0);
194 *generation = 1;
195 }
196 let g = *generation;
197
198 back.clear();
199 for &input in front.iter() {
200 let i = input as usize;
201 if i < level.transpose.len() {
202 for &row in level.transpose.row(i) {
203 let r = row as usize;
204 if stamp[r] != g {
205 stamp[r] = g;
206 back.push(row);
207 }
208 }
209 }
210 }
211 back.sort_unstable();
212 std::mem::swap(front, back);
213 }
214
215 std::mem::swap(out, front);
216 }
217}
218
219/// Reusable scratch for [`InverseStencilChain::affected_outputs_into`].
220///
221/// Holds a generation-stamped row buffer plus ping-pong work vectors so a hot
222/// edit loop pays no per-query allocation: build one and reuse it across edits.
223/// Safe to reuse across different chains/topologies -- the row buffer grows as
224/// needed and the generation counter is monotonic.
225#[derive(Debug, Clone, Default)]
226pub struct AffectedScratch {
227 /// Per-row "last seen" generation; a row is hit this pass iff its stamp
228 /// equals the current generation. Sized to the largest level's output.
229 stamp: Vec<u32>,
230 generation: u32,
231 /// Ping-pong buffers for the change set propagating through the levels.
232 front: Vec<u32>,
233 back: Vec<u32>,
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 /// A small table: 4 outputs over 3 inputs.
241 /// - out0 = in0
242 /// - out1 = (in0 + in1) / 2
243 /// - out2 = in1
244 /// - out3 = (in1 + in2) / 2
245 fn sample_table() -> StencilTable {
246 StencilTable {
247 offsets: vec![0, 1, 3, 4, 6],
248 indices: vec![0, 0, 1, 1, 1, 2],
249 weights: vec![1.0, 0.5, 0.5, 1.0, 0.5, 0.5],
250 }
251 }
252
253 /// Brute-force oracle: an output is affected iff its stencil references any
254 /// changed input.
255 fn brute_force_affected(table: &StencilTable, changed: &[u32]) -> Vec<u32> {
256 let set: std::collections::HashSet<u32> = changed.iter().copied().collect();
257 (0..table.output_count() as u32)
258 .filter(|&r| {
259 let s = table.offsets[r as usize] as usize;
260 let e = table.offsets[r as usize + 1] as usize;
261 table.indices[s..e].iter().any(|i| set.contains(i))
262 })
263 .collect()
264 }
265
266 #[test]
267 fn affected_outputs_for_single_input() {
268 let inv = InverseStencilMap::from(&sample_table());
269 // in0 feeds out0, out1.
270 assert_eq!(inv.affected_outputs(&[0]), vec![0, 1]);
271 // in1 feeds out1, out2, out3.
272 assert_eq!(inv.affected_outputs(&[1]), vec![1, 2, 3]);
273 // in2 feeds out3 only.
274 assert_eq!(inv.affected_outputs(&[2]), vec![3]);
275 }
276
277 #[test]
278 fn affected_outputs_is_sorted_unique_union() {
279 let inv = InverseStencilMap::from(&sample_table());
280 // Union of in0 -> {0,1} and in2 -> {3}, with a duplicate input.
281 assert_eq!(inv.affected_outputs(&[2, 0, 0]), vec![0, 1, 3]);
282 }
283
284 #[test]
285 fn affected_outputs_matches_brute_force() {
286 let table = sample_table();
287 let inv = InverseStencilMap::from(&table);
288 for changed in [
289 vec![],
290 vec![0],
291 vec![1],
292 vec![2],
293 vec![0, 1],
294 vec![0, 2],
295 vec![1, 2],
296 vec![0, 1, 2],
297 ] {
298 assert_eq!(
299 inv.affected_outputs(&changed),
300 brute_force_affected(&table, &changed),
301 "changed = {changed:?}"
302 );
303 }
304 }
305
306 #[test]
307 fn out_of_range_or_empty_input_affects_nothing() {
308 let inv = InverseStencilMap::from(&sample_table());
309 assert!(inv.affected_outputs(&[99]).is_empty());
310 assert!(inv.affected_outputs(&[]).is_empty());
311 }
312
313 /// Two chained tables: A(3) -> B(4) -> C(3).
314 fn two_level_tables() -> (StencilTable, StencilTable) {
315 let t0 = sample_table(); // A(3) -> B(4)
316 // B(4) -> C(3): c0 = b0, c1 = (b1 + b2)/2, c2 = b3.
317 let t1 = StencilTable {
318 offsets: vec![0, 1, 3, 4],
319 indices: vec![0, 1, 2, 3],
320 weights: vec![1.0, 0.5, 0.5, 1.0],
321 };
322 (t0, t1)
323 }
324
325 #[test]
326 fn chain_affected_outputs_matches_composed_table() {
327 // Oracle: invert the *composed* A->C table by brute force. The chain's
328 // forward propagation must give the identical affected set.
329 let (t0, t1) = two_level_tables();
330 let chain = InverseStencilChain::from([t0.clone(), t1.clone()].as_slice());
331 let composed = t0.compose(&t1);
332 for changed in [vec![], vec![0], vec![1], vec![2], vec![0, 2], vec![0, 1, 2]] {
333 assert_eq!(
334 chain.affected_outputs(&changed),
335 brute_force_affected(&composed, &changed),
336 "changed = {changed:?}"
337 );
338 }
339 }
340
341 #[test]
342 fn chain_single_level_matches_single_map() {
343 let t0 = sample_table();
344 let chain = InverseStencilChain::from(std::slice::from_ref(&t0));
345 let map = InverseStencilMap::from(&t0);
346 for changed in [vec![0], vec![1], vec![2], vec![0, 1, 2]] {
347 assert_eq!(
348 chain.affected_outputs(&changed),
349 map.affected_outputs(&changed)
350 );
351 }
352 }
353
354 #[test]
355 fn chain_with_no_levels_is_identity() {
356 let chain = InverseStencilChain::from(&[] as &[StencilTable]);
357 assert_eq!(chain.affected_outputs(&[2, 0, 0]), vec![0, 2]);
358 assert!(chain.affected_outputs(&[]).is_empty());
359 }
360
361 #[test]
362 fn affected_outputs_into_matches_oracle_and_reuses_scratch() {
363 let (t0, t1) = two_level_tables();
364 let chain = InverseStencilChain::from([t0.clone(), t1.clone()].as_slice());
365 let composed = t0.compose(&t1);
366 let mut scratch = AffectedScratch::default();
367 let mut out = Vec::new();
368 // Reuse the SAME scratch across queries with different change sets; each
369 // must be correct (the generation stamps must not leak between calls).
370 for changed in [vec![0u32], vec![2], vec![], vec![0, 1, 2], vec![1]] {
371 chain.affected_outputs_into(&changed, &mut scratch, &mut out);
372 assert_eq!(
373 out,
374 brute_force_affected(&composed, &changed),
375 "changed = {changed:?}"
376 );
377 }
378 }
379
380 #[test]
381 fn affected_outputs_into_no_levels_is_identity_and_clears_out() {
382 let chain = InverseStencilChain::from(&[] as &[StencilTable]);
383 let mut scratch = AffectedScratch::default();
384 let mut out = vec![999]; // must be cleared before filling
385 chain.affected_outputs_into(&[2, 0, 0], &mut scratch, &mut out);
386 assert_eq!(out, vec![0, 2]);
387 }
388}