Skip to main content

molframe_spatial/queries/
search.rs

1//! Backend selection and public fixed-radius operations.
2
3use crate::brute;
4use crate::{
5    CellList, KdTree, NeighborList, NeighborPair, PeriodicBox, SpatialBackend, SpatialError,
6    SpatialSearchOptions,
7};
8use molframe_core::selection::AtomSelection;
9use molframe_core::{CoordinateGeneration, ExecutionContext};
10use std::borrow::Cow;
11
12#[derive(Clone, Copy)]
13enum PairOrder {
14    Sorted,
15    Unsorted,
16}
17
18/// How a pair enumeration should be produced.
19#[derive(Clone, Copy)]
20struct PairRequest {
21    options: SpatialSearchOptions,
22    order: PairOrder,
23}
24
25impl PairRequest {
26    const fn sorted(options: SpatialSearchOptions) -> Self {
27        Self {
28            options,
29            order: PairOrder::Sorted,
30        }
31    }
32    const fn unsorted(options: SpatialSearchOptions) -> Self {
33        Self {
34            options,
35            order: PairOrder::Unsorted,
36        }
37    }
38}
39
40#[derive(Clone, Copy)]
41struct PairDispatch {
42    plan: crate::SpatialPlan,
43    options: SpatialSearchOptions,
44    order: PairOrder,
45}
46
47/// Enumerates unique unordered pairs no further apart than `cutoff`.
48///
49/// Results are sorted by `(first, second)` for every backend. Self-pairs are
50/// excluded, including when the two selections overlap.
51///
52/// # Errors
53///
54/// Returns an error for an invalid cutoff or an out-of-range atom index.
55pub fn pairs_within(
56    positions: &[[f32; 3]],
57    left: &AtomSelection,
58    right: &AtomSelection,
59    cutoff: f32,
60    backend: SpatialBackend,
61    periodic: Option<&PeriodicBox>,
62    context: &ExecutionContext,
63) -> Result<Vec<NeighborPair>, SpatialError> {
64    pairs_within_with_options(
65        positions,
66        left,
67        right,
68        cutoff,
69        SpatialSearchOptions::with_backend(backend),
70        periodic,
71        context,
72    )
73}
74
75/// Enumerates unique same-selection pairs without sorting the returned vector.
76///
77/// The deterministic ordering contract of [`pairs_within`] remains unchanged.
78/// This variant is intended for reductions such as histograms and counts that
79/// do not consume pair order. For different left and right selections it keeps
80/// the canonical implementation so overlapping selections remain duplicate-free.
81///
82/// # Errors
83///
84/// Returns the same validation and backend errors as [`pairs_within`].
85pub fn pairs_within_unsorted(
86    positions: &[[f32; 3]],
87    left: &AtomSelection,
88    right: &AtomSelection,
89    cutoff: f32,
90    backend: SpatialBackend,
91    periodic: Option<&PeriodicBox>,
92    context: &ExecutionContext,
93) -> Result<Vec<NeighborPair>, SpatialError> {
94    pairs_within_unsorted_with_options(
95        positions,
96        left,
97        right,
98        cutoff,
99        SpatialSearchOptions::with_backend(backend),
100        periodic,
101        context,
102    )
103}
104
105/// Enumerates pairs under a complete, inspectable planning profile.
106///
107/// # Errors
108///
109/// Returns an error for invalid options, cutoff or atom indices.
110pub fn pairs_within_with_options(
111    positions: &[[f32; 3]],
112    left: &AtomSelection,
113    right: &AtomSelection,
114    cutoff: f32,
115    options: SpatialSearchOptions,
116    periodic: Option<&PeriodicBox>,
117    context: &ExecutionContext,
118) -> Result<Vec<NeighborPair>, SpatialError> {
119    pairs_with_order(
120        positions,
121        left,
122        right,
123        cutoff,
124        periodic,
125        PairRequest::sorted(options),
126        context,
127    )
128}
129
130/// Executes a fixed-radius query without sorting identical-selection results.
131///
132/// # Errors
133///
134/// Returns the same validation and backend errors as [`pairs_within`].
135pub fn pairs_within_unsorted_with_options(
136    positions: &[[f32; 3]],
137    left: &AtomSelection,
138    right: &AtomSelection,
139    cutoff: f32,
140    options: SpatialSearchOptions,
141    periodic: Option<&PeriodicBox>,
142    context: &ExecutionContext,
143) -> Result<Vec<NeighborPair>, SpatialError> {
144    pairs_with_order(
145        positions,
146        left,
147        right,
148        cutoff,
149        periodic,
150        PairRequest::unsorted(options),
151        context,
152    )
153}
154
155fn pairs_with_order(
156    positions: &[[f32; 3]],
157    left: &AtomSelection,
158    right: &AtomSelection,
159    cutoff: f32,
160    periodic: Option<&PeriodicBox>,
161    request: PairRequest,
162    context: &ExecutionContext,
163) -> Result<Vec<NeighborPair>, SpatialError> {
164    validate_cutoff(cutoff)?;
165
166    let indices = super::indices::IndexWorkspace::new(left, right, positions.len(), context)?;
167    let left_indices = indices.left();
168    let right_indices = indices.right();
169    let plan = request
170        .options
171        .plan(left_indices.len(), right_indices.len(), cutoff)?;
172
173    dispatch_pairs(
174        positions,
175        left_indices,
176        right_indices,
177        cutoff,
178        PairDispatch {
179            plan,
180            options: request.options,
181            order: request.order,
182        },
183        periodic,
184        context,
185    )
186}
187
188/// Dispatches already-validated index slices to one spatial backend.
189///
190/// Every plan contains a concrete backend selected before dispatch.
191fn dispatch_pairs(
192    positions: &[[f32; 3]],
193    left: &[u32],
194    right: &[u32],
195    cutoff: f32,
196    dispatch: PairDispatch,
197    periodic: Option<&PeriodicBox>,
198    context: &ExecutionContext,
199) -> Result<Vec<NeighborPair>, SpatialError> {
200    let same_selection = left == right;
201    let mut backend = dispatch.plan.backend;
202    // `auto` is distribution-aware: cutoff-sized cells holding many points make
203    // a cell list quadratic in the crowd, so a tree takes over.
204    let switched = backend == SpatialBackend::CellList
205        && dispatch.plan.requested_backend == SpatialBackend::Auto
206        && super::crowding::is_crowded(positions, right, cutoff);
207    if switched {
208        backend = SpatialBackend::KdTree;
209    }
210    let cell_pairs = || {
211        let index = CellList::build_in(
212            positions,
213            right,
214            cutoff,
215            periodic.copied(),
216            dispatch.options.cell_grid,
217            context,
218        )?;
219        if same_selection {
220            let sorted = matches!(dispatch.order, PairOrder::Sorted);
221            if context.worker_budget() > 1 {
222                crate::backends::cell::pairs_same_selection_parallel(
223                    &index, left, cutoff, context, sorted,
224                )
225            } else if sorted {
226                crate::backends::cell::pairs_same_selection(&index, left, cutoff)
227            } else {
228                crate::backends::cell::pairs_same_selection_unordered(&index, left, cutoff)
229            }
230        } else {
231            index.pairs(left, cutoff)
232        }
233    };
234
235    match backend {
236        SpatialBackend::CellList => cell_pairs(),
237        SpatialBackend::KdTree => {
238            let found = KdTree::build_in(
239                positions,
240                right,
241                periodic.copied(),
242                dispatch.options.kd_periodic,
243                context,
244            )
245            .and_then(|tree| {
246                tree.pairs_in(
247                    left,
248                    cutoff,
249                    same_selection,
250                    matches!(dispatch.order, PairOrder::Sorted),
251                    context,
252                )
253            });
254            // A cutoff whose periodic images exceed the tree's budget is still
255            // served by the cell list when the tree was only `auto`'s choice.
256            match found {
257                Err(SpatialError::PeriodicImageLimitExceeded { .. }) if switched => cell_pairs(),
258                other => other,
259            }
260        }
261        SpatialBackend::NeighborList => neighbor_list_pairs(
262            positions,
263            left,
264            right,
265            cutoff,
266            dispatch.plan.neighbor_skin,
267            dispatch.options,
268            periodic,
269        ),
270        SpatialBackend::BruteForce => {
271            let cutoff_squared = cutoff * cutoff;
272            if same_selection {
273                if matches!(dispatch.order, PairOrder::Sorted) {
274                    Ok(brute::pairs_same_selection(
275                        positions,
276                        left,
277                        cutoff_squared,
278                        periodic,
279                    ))
280                } else {
281                    Ok(brute::pairs_same_selection_unordered(
282                        positions,
283                        left,
284                        cutoff_squared,
285                        periodic,
286                    ))
287                }
288            } else {
289                Ok(brute::pairs(
290                    positions,
291                    left,
292                    right,
293                    cutoff_squared,
294                    periodic,
295                ))
296            }
297        }
298        SpatialBackend::Auto => Err(SpatialError::InvalidOption(
299            crate::SpatialOption::UnresolvedBackend,
300        )),
301    }
302}
303
304/// Executes the one-shot neighbour-list backend.
305///
306/// The helper keeps backend dispatch small while preserving the explicit
307/// `NeighborList` backend semantics.
308fn neighbor_list_pairs(
309    positions: &[[f32; 3]],
310    left: &[u32],
311    right: &[u32],
312    cutoff: f32,
313    skin: Option<f32>,
314    options: SpatialSearchOptions,
315    periodic: Option<&PeriodicBox>,
316) -> Result<Vec<NeighborPair>, SpatialError> {
317    let Some(skin) = skin else {
318        return Err(SpatialError::InvalidOption(
319            crate::SpatialOption::NeighborSkinMinimum,
320        ));
321    };
322    let list = NeighborList::build_with_options(
323        positions,
324        left,
325        right,
326        cutoff,
327        periodic,
328        CoordinateGeneration::INITIAL,
329        crate::NeighborListOptions {
330            skin,
331            cell_grid: options.cell_grid,
332        },
333    )?;
334
335    list.pairs(positions, cutoff, periodic)
336}
337
338/// Validates an atom selection, borrowing existing sparse index storage.
339///
340/// Runtime is `O(N)`. Sparse and empty selections need no allocation; other
341/// representations expand into one contiguous index buffer.
342pub(super) fn checked_indices(
343    selection: &AtomSelection,
344    position_count: usize,
345) -> Result<Cow<'_, [u32]>, SpatialError> {
346    if let AtomSelection::Sparse(indices) = selection {
347        for &atom in indices {
348            if usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?
349                >= position_count
350            {
351                return Err(SpatialError::AtomOutOfBounds(atom));
352            }
353        }
354        return Ok(Cow::Borrowed(indices));
355    }
356    if selection.is_empty() {
357        return Ok(Cow::Borrowed(&[]));
358    }
359    let capacity =
360        usize::try_from(selection.len()).map_err(|_| SpatialError::NumericRangeExceeded)?;
361    let mut indices = Vec::with_capacity(capacity);
362
363    for atom in selection {
364        let position = usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?;
365        if position >= position_count {
366            return Err(SpatialError::AtomOutOfBounds(atom));
367        }
368
369        indices.push(atom);
370    }
371
372    Ok(Cow::Owned(indices))
373}
374
375/// Validates a non-negative finite cutoff.
376///
377/// Runtime and auxiliary space are `O(1)`.
378pub(super) fn validate_cutoff(cutoff: f32) -> Result<(), SpatialError> {
379    if cutoff.is_finite() && cutoff >= 0.0 {
380        Ok(())
381    } else {
382        Err(SpatialError::InvalidCutoff)
383    }
384}
385
386#[cfg(test)]
387#[path = "search_parallel_tests.rs"]
388mod parallel_tests;
389#[cfg(test)]
390#[path = "search_tests.rs"]
391mod tests;