Skip to main content

molframe_spatial/backends/
kd.rs

1//! Balanced three-dimensional k-d tree.
2
3use crate::brute::{canonicalise, distance_squared, finite};
4use crate::{KdPeriodicOptions, NeighborPair, PeriodicBox, SpatialError};
5use std::cmp::Ordering;
6use std::collections::{BTreeMap, BinaryHeap};
7
8#[path = "kd_periodic.rs"]
9mod periodic_helpers;
10#[path = "kd_stream.rs"]
11mod stream;
12use periodic_helpers::{for_each_shift, periodic_image_limits, validate_image_budget};
13
14#[derive(Clone, Copy, Debug)]
15struct Node {
16    atom: u32,
17    position: [f32; 3],
18    axis: usize,
19    left: Option<usize>,
20    right: Option<usize>,
21}
22
23/// A balanced static k-dimensional tree over selected atoms.
24///
25/// Construction uses linear-time median partitioning at each level, for
26/// O(n log n) total work and one flat node allocation.
27#[derive(Debug)]
28pub struct KdTree<'a> {
29    positions: &'a [[f32; 3]],
30    nodes: Vec<Node>,
31    root: Option<usize>,
32    periodic: Option<PeriodicBox>,
33    periodic_options: KdPeriodicOptions,
34    reservation: Option<molframe_core::MemoryReservation>,
35}
36
37impl<'a> KdTree<'a> {
38    /// Builds a balanced tree over finite target positions.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error when a target index is outside `positions`.
43    pub fn build(
44        positions: &'a [[f32; 3]],
45        targets: &[u32],
46        periodic: Option<PeriodicBox>,
47    ) -> Result<Self, SpatialError> {
48        Self::build_with_options(positions, targets, periodic, KdPeriodicOptions::default())
49    }
50
51    /// Builds a balanced tree with an explicit periodic image budget.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error for invalid options or target indices.
56    pub fn build_with_options(
57        positions: &'a [[f32; 3]],
58        targets: &[u32],
59        periodic: Option<PeriodicBox>,
60        periodic_options: KdPeriodicOptions,
61    ) -> Result<Self, SpatialError> {
62        validate_indices(targets, positions.len())?;
63        periodic_options.validate()?;
64
65        let mut entries = finite_entries(positions, targets, periodic.as_ref());
66        let mut nodes = Vec::with_capacity(entries.len());
67        let root = build_nodes(&mut entries, 0, &mut nodes);
68
69        Ok(Self {
70            positions,
71            nodes,
72            root,
73            periodic,
74            periodic_options,
75            reservation: None,
76        })
77    }
78
79    /// Finds all indexed pairs within `cutoff`.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error for an invalid cutoff or query index.
84    pub fn pairs(&self, query: &[u32], cutoff: f32) -> Result<Vec<NeighborPair>, SpatialError> {
85        validate_cutoff(cutoff)?;
86        validate_indices(query, self.positions.len())?;
87
88        let cutoff_squared = cutoff * cutoff;
89        let mut found = Vec::new();
90
91        for &atom in query {
92            let index = usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?;
93            let Some(position) = self.positions.get(index).copied() else {
94                continue;
95            };
96
97            if !finite(position) {
98                continue;
99            }
100
101            self.append_radius_pairs(atom, position, cutoff, cutoff_squared, &mut found)?;
102        }
103
104        canonicalise(&mut found);
105        Ok(found)
106    }
107
108    /// Returns the nearest `count` targets in increasing distance order.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error when `query` is outside the coordinate array.
113    pub fn k_nearest(&self, query: u32, count: usize) -> Result<Vec<(u32, f32)>, SpatialError> {
114        let index = usize::try_from(query).map_err(|_| SpatialError::NumericRangeExceeded)?;
115        let Some(point) = self.positions.get(index).copied() else {
116            return Err(SpatialError::AtomOutOfBounds(query));
117        };
118
119        if count == 0 || !finite(point) {
120            return Ok(Vec::new());
121        }
122
123        let Some(root) = self.root else {
124            return Ok(Vec::new());
125        };
126
127        if let Some(periodic) = self.periodic {
128            return self.periodic_nearest(root, query, point, count, &periodic);
129        }
130
131        let capacity = count
132            .checked_add(1)
133            .ok_or(SpatialError::NumericRangeExceeded)?;
134        let mut heap = BinaryHeap::with_capacity(capacity);
135
136        self.nearest_search(root, query, point, count, &mut heap);
137
138        Ok(sorted_candidates(heap))
139    }
140
141    fn append_radius_pairs(
142        &self,
143        atom: u32,
144        position: [f32; 3],
145        cutoff: f32,
146        cutoff_squared: f32,
147        found: &mut Vec<NeighborPair>,
148    ) -> Result<(), SpatialError> {
149        let Some(root) = self.root else {
150            return Ok(());
151        };
152        let Some(periodic) = self.periodic else {
153            self.radius_search(root, position, cutoff_squared, &mut |_, target, squared| {
154                if atom != target {
155                    found.push(NeighborPair::new(atom, target, squared));
156                }
157            });
158            return Ok(());
159        };
160
161        let limits = periodic_image_limits(&periodic, cutoff)?;
162        validate_image_budget(limits, self.periodic_options)?;
163        let wrapped = periodic.wrap(position);
164        for_each_shift(limits, |shift| {
165            let image = periodic.translated(wrapped, shift)?;
166            self.radius_search(root, image, cutoff_squared, &mut |_, target, _| {
167                if atom == target {
168                    return;
169                }
170                let Ok(index) = usize::try_from(target) else {
171                    return;
172                };
173                let Some(target_position) = self.positions.get(index).copied() else {
174                    return;
175                };
176                let squared = periodic.distance_squared(position, target_position);
177                if squared <= cutoff_squared {
178                    found.push(NeighborPair::new(atom, target, squared));
179                }
180            });
181            Ok(())
182        })?;
183        Ok(())
184    }
185
186    /// Recursively visits k-d nodes that can intersect a query sphere.
187    ///
188    /// The tree is structurally balanced, so recursion depth is `O(log N)`.
189    /// No per-query traversal vector is allocated.
190    fn radius_search(
191        &self,
192        node_index: usize,
193        point: [f32; 3],
194        cutoff_squared: f32,
195        visit: &mut impl FnMut(usize, u32, f32),
196    ) {
197        let Some(node) = self.nodes.get(node_index).copied() else {
198            return;
199        };
200
201        let position = node.position;
202
203        let squared = distance_squared(point, position, None);
204
205        if squared <= cutoff_squared {
206            visit(node_index, node.atom, squared);
207        }
208
209        let delta = point[node.axis] - position[node.axis];
210        let (near, far) = ordered_children(node, delta);
211
212        if let Some(near) = near {
213            self.radius_search(near, point, cutoff_squared, visit);
214        }
215
216        if delta * delta <= cutoff_squared
217            && let Some(far) = far
218        {
219            self.radius_search(far, point, cutoff_squared, visit);
220        }
221    }
222
223    /// Recursively performs branch-and-bound nearest-neighbour search.
224    ///
225    /// The near child is processed before the far-child pruning decision,
226    /// tightening the heap radius as early as possible.
227    fn nearest_search(
228        &self,
229        node_index: usize,
230        query: u32,
231        point: [f32; 3],
232        count: usize,
233        heap: &mut BinaryHeap<Candidate>,
234    ) {
235        let Some(node) = self.nodes.get(node_index).copied() else {
236            return;
237        };
238
239        let position = node.position;
240
241        let squared = distance_squared(point, position, None);
242
243        if node.atom != query {
244            push_candidate(heap, Candidate::new(node.atom, squared), count);
245        }
246
247        let axis = node.axis;
248        let delta = point[axis] - position[axis];
249        let (near, far) = ordered_children(node, delta);
250
251        if let Some(near) = near {
252            self.nearest_search(near, query, point, count, heap);
253        }
254
255        if should_visit_far(heap, count, delta)
256            && let Some(far) = far
257        {
258            self.nearest_search(far, query, point, count, heap);
259        }
260    }
261
262    fn periodic_nearest(
263        &self,
264        root: usize,
265        query: u32,
266        point: [f32; 3],
267        count: usize,
268        periodic: &PeriodicBox,
269    ) -> Result<Vec<(u32, f32)>, SpatialError> {
270        const NEAREST_IMAGE_LIMITS: [i64; 3] = [2; 3];
271        validate_image_budget(NEAREST_IMAGE_LIMITS, self.periodic_options)?;
272        let wrapped = periodic.wrap(point);
273        let mut best_by_atom = BTreeMap::new();
274
275        for_each_shift(NEAREST_IMAGE_LIMITS, |shift| {
276            let image = periodic.translated(wrapped, shift)?;
277            let capacity = count
278                .checked_add(1)
279                .ok_or(SpatialError::NumericRangeExceeded)?;
280            let mut image_heap = BinaryHeap::with_capacity(capacity);
281            self.nearest_search(root, query, image, count, &mut image_heap);
282            for (atom, _) in sorted_candidates(image_heap) {
283                let index =
284                    usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?;
285                let Some(target) = self.positions.get(index).copied() else {
286                    continue;
287                };
288                let squared = periodic.distance_squared(point, target);
289                best_by_atom
290                    .entry(atom)
291                    .and_modify(|best: &mut f32| *best = best.min(squared))
292                    .or_insert(squared);
293            }
294            Ok(())
295        })?;
296
297        let capacity = count
298            .checked_add(1)
299            .ok_or(SpatialError::NumericRangeExceeded)?;
300        let mut heap = BinaryHeap::with_capacity(capacity);
301        for (atom, squared) in best_by_atom {
302            push_candidate(&mut heap, Candidate::new(atom, squared), count);
303        }
304        Ok(sorted_candidates(heap))
305    }
306}
307
308#[derive(Clone, Copy, Debug)]
309struct Entry {
310    atom: u32,
311    position: [f32; 3],
312}
313
314/// Returns finite target entries in the tree coordinate system.
315///
316/// Runtime is `O(T)` with `O(F)` output space for `F` finite targets.
317fn finite_entries(
318    positions: &[[f32; 3]],
319    targets: &[u32],
320    periodic: Option<&PeriodicBox>,
321) -> Vec<Entry> {
322    let mut entries = Vec::with_capacity(targets.len());
323    entries.extend(targets.iter().filter_map(|&atom| {
324        let index = usize::try_from(atom).ok()?;
325        let position = positions.get(index).copied()?;
326        if !finite(position) {
327            return None;
328        }
329        let position = match periodic {
330            Some(periodic) => periodic.wrap(position),
331            None => position,
332        };
333        Some(Entry { atom, position })
334    }));
335    entries
336}
337
338/// Builds one balanced k-d subtree by median partitioning.
339///
340/// Construction satisfies `O(N log N)` total expected/average partition work
341/// and `O(log N)` recursion depth because each call splits by index median.
342fn build_nodes(entries: &mut [Entry], depth: usize, nodes: &mut Vec<Node>) -> Option<usize> {
343    if entries.is_empty() {
344        return None;
345    }
346
347    let axis = depth % 3;
348    let middle = entries.len() / 2;
349
350    entries.select_nth_unstable_by(middle, |left, right| {
351        left.position[axis]
352            .total_cmp(&right.position[axis])
353            .then_with(|| left.atom.cmp(&right.atom))
354    });
355
356    let (left, rest) = entries.split_at_mut(middle);
357    let (median, right) = rest.split_first_mut()?;
358    let entry = *median;
359
360    let node_index = nodes.len();
361    nodes.push(Node {
362        atom: entry.atom,
363        position: entry.position,
364        axis,
365        left: None,
366        right: None,
367    });
368
369    let left_node = build_nodes(left, depth + 1, nodes);
370    let right_node = build_nodes(right, depth + 1, nodes);
371
372    if let Some(node) = nodes.get_mut(node_index) {
373        node.left = left_node;
374        node.right = right_node;
375    }
376
377    Some(node_index)
378}
379
380/// Orders a node's children by the query's position relative to its split.
381///
382/// Runtime and auxiliary space are `O(1)`.
383#[inline]
384fn ordered_children(node: Node, delta: f32) -> (Option<usize>, Option<usize>) {
385    if delta <= 0.0 {
386        (node.left, node.right)
387    } else {
388        (node.right, node.left)
389    }
390}
391
392/// Returns whether branch-and-bound search must inspect the far subtree.
393///
394/// Runtime and auxiliary space are `O(1)`.
395#[inline]
396fn should_visit_far(heap: &BinaryHeap<Candidate>, count: usize, delta: f32) -> bool {
397    if heap.len() < count {
398        return true;
399    }
400
401    heap.peek()
402        .is_some_and(|candidate| delta * delta <= candidate.squared)
403}
404
405#[derive(Clone, Copy, Debug)]
406struct Candidate {
407    atom: u32,
408    squared: f32,
409}
410
411impl Candidate {
412    /// Creates a nearest-neighbour heap candidate.
413    ///
414    /// Runtime and auxiliary space are `O(1)`.
415    const fn new(atom: u32, squared: f32) -> Self {
416        Self { atom, squared }
417    }
418}
419
420impl PartialEq for Candidate {
421    /// Tests exact candidate identity using the floating-point bit pattern.
422    fn eq(&self, other: &Self) -> bool {
423        self.atom == other.atom && self.squared.to_bits() == other.squared.to_bits()
424    }
425}
426
427impl Eq for Candidate {}
428
429impl PartialOrd for Candidate {
430    /// Delegates candidate ordering to its total ordering.
431    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
432        Some(self.cmp(other))
433    }
434}
435
436impl Ord for Candidate {
437    /// Orders the heap by distance and then atom index.
438    fn cmp(&self, other: &Self) -> Ordering {
439        self.squared
440            .total_cmp(&other.squared)
441            .then_with(|| self.atom.cmp(&other.atom))
442    }
443}
444
445/// Inserts a candidate into a fixed-size max heap.
446///
447/// Runtime is `O(log K)` and heap space remains `O(K)`.
448fn push_candidate(heap: &mut BinaryHeap<Candidate>, candidate: Candidate, count: usize) {
449    heap.push(candidate);
450
451    if heap.len() > count {
452        let _discarded = heap.pop();
453    }
454}
455
456/// Converts the bounded heap to increasing distance order.
457///
458/// Runtime is `O(K log K)` as provided by `BinaryHeap::into_sorted_vec`.
459fn sorted_candidates(heap: BinaryHeap<Candidate>) -> Vec<(u32, f32)> {
460    heap.into_sorted_vec()
461        .into_iter()
462        .map(|candidate| (candidate.atom, candidate.squared))
463        .collect()
464}
465
466/// Validates a finite non-negative cutoff.
467///
468/// Runtime and auxiliary space are `O(1)`.
469fn validate_cutoff(cutoff: f32) -> Result<(), SpatialError> {
470    if cutoff.is_finite() && cutoff >= 0.0 {
471        Ok(())
472    } else {
473        Err(SpatialError::InvalidCutoff)
474    }
475}
476
477/// Validates atom indices against a coordinate-array length.
478///
479/// Runtime is `O(N)` and requires no allocation.
480fn validate_indices(atoms: &[u32], position_count: usize) -> Result<(), SpatialError> {
481    for &atom in atoms {
482        let index = usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?;
483        if index >= position_count {
484            return Err(SpatialError::AtomOutOfBounds(atom));
485        }
486    }
487
488    Ok(())
489}
490
491#[cfg(test)]
492#[path = "kd_tests.rs"]
493mod tests;