Skip to main content

molframe_spatial/planning/
resolver.rs

1//! Query-language spatial execution over a structure snapshot.
2
3use crate::brute::finite;
4use crate::{
5    CellList, KdTree, NeighborPair, PeriodicBox, SpatialBackend, SpatialSearchOptions,
6    pairs_within_with_options,
7};
8use molframe_core::ExecutionContext;
9use molframe_core::contract::AnalysisPolicy;
10use molframe_core::diagnostic::{Code, Diagnostic};
11use molframe_core::selection::AtomSelection;
12use molframe_core::structure::Structure;
13use std::collections::{HashMap, hash_map::Entry};
14use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
15
16mod dispatch;
17mod matching;
18mod visit;
19use matching::{
20    atom_out_of_bounds, backend_key, cacheable_backend, collect_selection_indices, collect_within,
21    index_cutoff_key, mark_pair_match, periodic_box, radial_squared, select_iso_layer,
22    spatial_diagnostic, update_nearest_distance, validate_shell, z_matches,
23};
24
25/// Maximum number of spatial indices retained by one resolver.
26///
27/// Query predicates can produce arbitrarily many distinct target selections
28/// and cutoffs. Bounding retained indices prevents a long-lived resolver from
29/// accumulating unbounded query-controlled cache state.
30const MAX_CACHED_INDICES: usize = 64;
31
32/// A spatial resolver bound to one immutable structure snapshot.
33#[derive(Debug)]
34pub struct StructureSpatial<'a> {
35    structure: &'a Structure,
36    context: &'a ExecutionContext,
37    options: SpatialSearchOptions,
38    periodic: Option<PeriodicBox>,
39    cache: RwLock<HashMap<IndexKey, Arc<CachedIndex<'a>>>>,
40}
41
42/// Identifies a reusable spatial index within one immutable resolver.
43///
44/// Backend and cutoff precede the potentially large atom selection so equality
45/// checks can reject incompatible plans before comparing selection contents.
46#[derive(Debug, PartialEq, Eq, Hash)]
47struct IndexKey {
48    backend: u8,
49    cutoff: u32,
50    selection: Box<[u32]>,
51}
52
53/// A spatial index retained for reuse by compatible geometric predicates.
54#[derive(Debug)]
55enum CachedIndex<'a> {
56    Cell(CellList<'a>),
57    Kd(KdTree<'a>),
58}
59
60impl<'a> StructureSpatial<'a> {
61    /// Creates a resolver under the policy's periodicity decision.
62    ///
63    /// # Errors
64    ///
65    /// Returns `E5004` when periodicity is requested without a valid unit cell.
66    pub fn new(
67        structure: &'a Structure,
68        policy: &AnalysisPolicy,
69        backend: SpatialBackend,
70        context: &'a ExecutionContext,
71    ) -> Result<Self, Diagnostic> {
72        Self::new_with_options(
73            structure,
74            policy,
75            SpatialSearchOptions::with_backend(backend),
76            context,
77        )
78    }
79
80    /// Creates a resolver under explicit spatial planning options.
81    ///
82    /// # Errors
83    ///
84    /// Returns a diagnostic for invalid options or periodic cell data.
85    pub fn new_with_options(
86        structure: &'a Structure,
87        policy: &AnalysisPolicy,
88        options: SpatialSearchOptions,
89        context: &'a ExecutionContext,
90    ) -> Result<Self, Diagnostic> {
91        options.validate().map_err(spatial_diagnostic)?;
92        let periodic = periodic_box(structure, policy)?;
93
94        Ok(Self {
95            structure,
96            context,
97            options,
98            periodic,
99            cache: RwLock::new(HashMap::new()),
100        })
101    }
102
103    /// Number of structure-bound indices currently retained for reuse.
104    #[must_use]
105    pub fn cached_index_count(&self) -> usize {
106        self.read_cache().len()
107    }
108
109    /// Returns this structure snapshot's coordinate slice.
110    #[inline]
111    fn positions(&self) -> &[[f32; 3]] {
112        self.structure.positions()
113    }
114
115    /// Resolves one atom position or returns the query out-of-bounds diagnostic.
116    #[inline]
117    fn position(&self, atom: u32) -> Result<[f32; 3], Diagnostic> {
118        let index = usize::try_from(atom).map_err(|_| atom_out_of_bounds(atom))?;
119
120        self.positions()
121            .get(index)
122            .copied()
123            .ok_or_else(|| atom_out_of_bounds(atom))
124    }
125
126    /// Selects atoms inside a radial shell around `centre`.
127    ///
128    /// When `z` is provided, radial distance is measured in XY and the
129    /// displacement must also lie inside the requested Z interval.
130    fn radial(
131        &self,
132        universe: &AtomSelection,
133        centre: [f32; 3],
134        inner: f32,
135        outer: f32,
136        z: Option<(f32, f32)>,
137    ) -> Result<AtomSelection, Diagnostic> {
138        validate_shell(inner, outer)?;
139
140        let inner_squared = inner * inner;
141        let outer_squared = outer * outer;
142        let cylindrical = z.is_some();
143        let mut selected = Vec::with_capacity(matching::selection_capacity(universe));
144
145        for atom in universe {
146            let position = self.position(atom)?;
147
148            if !finite(position) {
149                continue;
150            }
151
152            let displacement = self.displacement(centre, position);
153
154            if !z_matches(displacement[2], z) {
155                continue;
156            }
157
158            let squared = radial_squared(displacement, cylindrical);
159
160            if squared >= inner_squared && squared <= outer_squared {
161                selected.push(atom);
162            }
163        }
164
165        Ok(AtomSelection::from_sorted(selected))
166    }
167
168    /// Computes periodic or ordinary displacement from `left` to `right`.
169    #[inline]
170    fn displacement(&self, left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
171        match &self.periodic {
172            Some(periodic) => periodic.displacement(left, right),
173            None => [right[0] - left[0], right[1] - left[1], right[2] - left[2]],
174        }
175    }
176
177    /// Computes the arithmetic centre of finite atoms in `target`.
178    ///
179    /// # Errors
180    ///
181    /// Returns `E5003` for an empty/no-finite target and `E6009` for invalid
182    /// atom indices.
183    fn centre(&self, target: &AtomSelection) -> Result<[f32; 3], Diagnostic> {
184        if target.is_empty() {
185            return Err(Diagnostic::new(Code::E5003));
186        }
187
188        let mut sum = [0.0f64; 3];
189        let mut count = 0u32;
190
191        for atom in target {
192            let position = self.position(atom)?;
193
194            if !finite(position) {
195                continue;
196            }
197
198            sum[0] += f64::from(position[0]);
199            sum[1] += f64::from(position[1]);
200            sum[2] += f64::from(position[2]);
201
202            count = count
203                .checked_add(1)
204                .ok_or_else(|| Diagnostic::new(Code::E3001))?;
205        }
206
207        if count == 0 {
208            return Err(Diagnostic::new(Code::E5003));
209        }
210
211        let divisor = f64::from(count);
212
213        let convert = |value: f64| {
214            crate::numeric::finite_f32(value / divisor).ok_or_else(|| Diagnostic::new(Code::E5003))
215        };
216
217        Ok([convert(sum[0])?, convert(sum[1])?, convert(sum[2])?])
218    }
219
220    /// Resolves a shell around the centre of `target`.
221    ///
222    /// This helper centralises sphere/cylinder centre calculation.
223    fn radial_from_target(
224        &self,
225        universe: &AtomSelection,
226        target: &AtomSelection,
227        inner: f32,
228        outer: f32,
229        z: Option<(f32, f32)>,
230    ) -> Result<AtomSelection, Diagnostic> {
231        let centre = self.centre(target)?;
232        self.radial(universe, centre, inner, outer, z)
233    }
234
235    /// Selects atoms whose nearest target distance lies inside `[inner, outer]`.
236    ///
237    /// A dense nearest-distance buffer keeps pair updates contiguous and avoids
238    /// map lookups for atom-indexed nearest-distance state.
239    fn iso_layer(
240        &self,
241        universe: &AtomSelection,
242        target: &AtomSelection,
243        inner: f32,
244        outer: f32,
245    ) -> Result<AtomSelection, Diagnostic> {
246        validate_shell(inner, outer)?;
247
248        let mut nearest = vec![f32::INFINITY; self.positions().len()];
249        let mut reduction_error = None;
250        self.for_each_pair(universe, target, outer, |pair| {
251            if reduction_error.is_none()
252                && let Err(error) = update_nearest_distance(&mut nearest, universe, target, pair)
253            {
254                reduction_error = Some(error);
255            }
256        })?;
257        if let Some(error) = reduction_error {
258            return Err(error);
259        }
260
261        select_iso_layer(universe, target, &nearest, inner * inner, outer * outer)
262    }
263
264    /// Selects query atoms within `cutoff` of at least one target.
265    ///
266    /// A dense bit vector records membership directly by atom index and avoids
267    /// sorting or deduplicating matched pair endpoints.
268    fn within(
269        &self,
270        query: &AtomSelection,
271        target: &AtomSelection,
272        cutoff: f32,
273    ) -> Result<AtomSelection, Diagnostic> {
274        let mut matched = vec![false; self.positions().len()];
275        let mut reduction_error = None;
276        self.for_each_pair(query, target, cutoff, |pair| {
277            if reduction_error.is_none()
278                && let Err(error) = mark_pair_match(&mut matched, query, target, pair)
279            {
280                reduction_error = Some(error);
281            }
282        })?;
283        if let Some(error) = reduction_error {
284            return Err(error);
285        }
286
287        collect_within(query, target, &matched)
288    }
289
290    /// Searches one pair workload while retaining indices in this resolver.
291    ///
292    /// The resolver itself is configured with `Auto` in a reusable plan.  A
293    /// request may still pin a concrete backend; the target index is keyed by
294    /// that backend, cutoff and selection, so compatible operations share the
295    /// construction without changing the requested algorithm.
296    ///
297    /// # Errors
298    ///
299    /// Returns a diagnostic when the requested backend cannot be planned or
300    /// the spatial kernel rejects the workload.
301    pub fn pairs_with_backend(
302        &self,
303        query: &AtomSelection,
304        target: &AtomSelection,
305        cutoff: f32,
306        requested_backend: SpatialBackend,
307    ) -> Result<Vec<NeighborPair>, Diagnostic> {
308        let requested = SpatialSearchOptions {
309            backend: requested_backend,
310            ..self.options
311        };
312        let backend =
313            cacheable_backend(requested, query.len(), target.len()).map_err(spatial_diagnostic)?;
314
315        match backend {
316            SpatialBackend::CellList | SpatialBackend::KdTree => {
317                self.cached_pairs(query, target, cutoff, backend)
318            }
319            _ => {
320                let options = SpatialSearchOptions {
321                    backend,
322                    ..self.options
323                };
324
325                pairs_within_with_options(
326                    self.positions(),
327                    query,
328                    target,
329                    cutoff,
330                    options,
331                    self.periodic.as_ref(),
332                    self.context,
333                )
334                .map_err(spatial_diagnostic)
335            }
336        }
337    }
338
339    /// Executes a query using a reusable `CellList` or `KdTree`.
340    ///
341    /// Atom selections are materialised only where the index APIs require
342    /// contiguous integer slices.
343    fn cached_pairs(
344        &self,
345        query: &AtomSelection,
346        target: &AtomSelection,
347        cutoff: f32,
348        backend: SpatialBackend,
349    ) -> Result<Vec<NeighborPair>, Diagnostic> {
350        let target_indices = collect_selection_indices(target);
351
352        let key = IndexKey {
353            backend: backend_key(backend),
354            cutoff: index_cutoff_key(backend, cutoff),
355            selection: target_indices.into_boxed_slice(),
356        };
357
358        let index = self.cached_index(key, backend, cutoff)?;
359        let query_indices = collect_selection_indices(query);
360
361        match index.as_ref() {
362            CachedIndex::Cell(index) => index
363                .pairs(&query_indices, cutoff)
364                .map_err(spatial_diagnostic),
365            CachedIndex::Kd(index) => index
366                .pairs(&query_indices, cutoff)
367                .map_err(spatial_diagnostic),
368        }
369    }
370
371    /// Returns or constructs one structure-bound spatial index.
372    ///
373    /// Index construction occurs without holding the cache lock. A second
374    /// lookup after construction resolves concurrent insertions of the same key
375    /// without replacing an index already retained by another thread.
376    fn cached_index(
377        &self,
378        key: IndexKey,
379        backend: SpatialBackend,
380        cutoff: f32,
381    ) -> Result<Arc<CachedIndex<'a>>, Diagnostic> {
382        {
383            let cache = self.read_cache();
384
385            if let Some(index) = cache.get(&key) {
386                return Ok(Arc::clone(index));
387            }
388        }
389
390        let built = Arc::new(self.build_cached_index(backend, key.selection.as_ref(), cutoff)?);
391
392        let mut cache = self.write_cache();
393        let can_retain = cache.len() < MAX_CACHED_INDICES;
394
395        match cache.entry(key) {
396            Entry::Occupied(entry) => Ok(Arc::clone(entry.get())),
397            Entry::Vacant(entry) if can_retain => {
398                entry.insert(Arc::clone(&built));
399                Ok(built)
400            }
401            Entry::Vacant(_) => Ok(built),
402        }
403    }
404
405    /// Constructs one cacheable index over `targets`.
406    ///
407    /// K-d trees are independent from the query cutoff, while cell lists retain
408    /// the cutoff used to define their spatial grid.
409    fn build_cached_index(
410        &self,
411        backend: SpatialBackend,
412        targets: &[u32],
413        cutoff: f32,
414    ) -> Result<CachedIndex<'a>, Diagnostic> {
415        let positions: &'a [[f32; 3]] = self.structure.positions();
416
417        match backend {
418            SpatialBackend::CellList => CellList::build_with_options(
419                positions,
420                targets,
421                cutoff,
422                self.periodic,
423                self.options.cell_grid,
424            )
425            .map(CachedIndex::Cell)
426            .map_err(spatial_diagnostic),
427            SpatialBackend::KdTree => KdTree::build_with_options(
428                positions,
429                targets,
430                self.periodic,
431                self.options.kd_periodic,
432            )
433            .map(CachedIndex::Kd)
434            .map_err(spatial_diagnostic),
435            _ => Err(Diagnostic::new(Code::E9001)),
436        }
437    }
438
439    /// Acquires shared access to the spatial-index cache.
440    ///
441    /// Poisoned locks are recovered because an interrupted cache operation
442    /// cannot invalidate the immutable structure snapshot itself.
443    fn read_cache(&self) -> RwLockReadGuard<'_, HashMap<IndexKey, Arc<CachedIndex<'a>>>> {
444        match self.cache.read() {
445            Ok(cache) => cache,
446            Err(poisoned) => poisoned.into_inner(),
447        }
448    }
449
450    /// Acquires exclusive access to mutate the spatial-index cache.
451    ///
452    /// Poisoned locks are recovered so cache availability is not coupled to a
453    /// prior thread panic.
454    fn write_cache(&self) -> RwLockWriteGuard<'_, HashMap<IndexKey, Arc<CachedIndex<'a>>>> {
455        match self.cache.write() {
456            Ok(cache) => cache,
457            Err(poisoned) => poisoned.into_inner(),
458        }
459    }
460}
461
462#[cfg(test)]
463#[path = "resolver_tests.rs"]
464mod tests;