Skip to main content

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}