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