Skip to main content

molframe_spatial/planning/
options.rs

1//! Public, validated spatial planning profiles.
2
3use crate::{SpatialBackend, SpatialError, SpatialOption};
4
5/// Tunable decisions used when [`SpatialBackend::Auto`] is requested.
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7pub struct AutoBackendProfile {
8    /// Largest Cartesian product evaluated directly.
9    pub brute_force_pair_limit: usize,
10    /// Smallest target set for which a k-d tree may be selected.
11    pub kd_target_minimum: usize,
12    /// Required target-to-query size ratio for selecting a k-d tree.
13    pub kd_query_ratio: usize,
14}
15
16impl AutoBackendProfile {
17    /// General-purpose profile used by [`Default`].
18    pub const BALANCED: Self = Self {
19        brute_force_pair_limit: 250_000,
20        kd_target_minimum: 20_000,
21        kd_query_ratio: 8,
22    };
23
24    /// Validates that every threshold defines an unambiguous plan.
25    ///
26    /// # Errors
27    ///
28    /// Returns the first invalid automatic-planning field.
29    pub fn validate(self) -> Result<(), SpatialError> {
30        if self.brute_force_pair_limit == 0 {
31            return Err(SpatialError::InvalidOption(
32                SpatialOption::BruteForcePairLimit,
33            ));
34        }
35        if self.kd_target_minimum == 0 {
36            return Err(SpatialError::InvalidOption(SpatialOption::KdTargetMinimum));
37        }
38        if self.kd_query_ratio == 0 {
39            return Err(SpatialError::InvalidOption(SpatialOption::KdQueryRatio));
40        }
41        Ok(())
42    }
43
44    /// Resolves one workload after validating the profile.
45    ///
46    /// The same cost ladder holds for periodic workloads: a k-d tree keeps
47    /// wrapped targets and queries lattice images, a cell list builds the
48    /// fractional-coordinate grid, and brute force stays the small-workload
49    /// choice. Selection is never pinned by periodicity alone.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error when the profile is invalid.
54    pub fn resolve(
55        self,
56        left_count: usize,
57        right_count: usize,
58    ) -> Result<SpatialBackend, SpatialError> {
59        self.validate()?;
60        let pair_count = left_count
61            .checked_mul(right_count)
62            .ok_or(SpatialError::NumericRangeExceeded)?;
63        if pair_count <= self.brute_force_pair_limit {
64            return Ok(SpatialBackend::BruteForce);
65        }
66        if right_count >= self.kd_target_minimum && left_count < right_count / self.kd_query_ratio {
67            return Ok(SpatialBackend::KdTree);
68        }
69        Ok(SpatialBackend::CellList)
70    }
71}
72
73impl Default for AutoBackendProfile {
74    fn default() -> Self {
75        Self::BALANCED
76    }
77}
78
79/// Rule for deriving a one-shot neighbour-list skin from the query cutoff.
80#[derive(Clone, Copy, PartialEq, Debug)]
81pub struct NeighborSkinProfile {
82    /// Fraction of the cutoff used as the preferred skin.
83    pub cutoff_ratio: f32,
84    /// Lower bound for the derived skin, in ångströms.
85    pub minimum: f32,
86}
87
88impl NeighborSkinProfile {
89    /// General-purpose profile used by [`Default`].
90    pub const BALANCED: Self = Self {
91        cutoff_ratio: 0.2,
92        minimum: 0.5,
93    };
94
95    /// Validates finite, non-negative skin parameters.
96    ///
97    /// # Errors
98    ///
99    /// Returns the first invalid skin field.
100    pub fn validate(self) -> Result<(), SpatialError> {
101        if !self.cutoff_ratio.is_finite() || self.cutoff_ratio < 0.0 {
102            return Err(SpatialError::InvalidOption(
103                SpatialOption::NeighborSkinRatio,
104            ));
105        }
106        if !self.minimum.is_finite() || self.minimum < 0.0 {
107            return Err(SpatialError::InvalidOption(
108                SpatialOption::NeighborSkinMinimum,
109            ));
110        }
111        Ok(())
112    }
113
114    /// Computes the skin associated with `cutoff`.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error for invalid profile fields or a non-finite result.
119    pub fn skin(self, cutoff: f32) -> Result<f32, SpatialError> {
120        self.validate()?;
121        let skin = (cutoff * self.cutoff_ratio).max(self.minimum);
122        if skin.is_finite() {
123            Ok(skin)
124        } else {
125            Err(SpatialError::InvalidOption(
126                SpatialOption::NeighborSkinRatio,
127            ))
128        }
129    }
130}
131
132impl Default for NeighborSkinProfile {
133    fn default() -> Self {
134        Self::BALANCED
135    }
136}
137
138/// Memory and coarsening policy for contiguous cell grids.
139#[derive(Clone, Copy, PartialEq, Debug)]
140pub struct CellGridOptions {
141    /// Maximum number of allocated grid cells.
142    pub maximum_cell_count: usize,
143    /// Edge multiplier used when a grid exceeds the cell budget.
144    pub edge_growth_factor: f32,
145}
146
147impl CellGridOptions {
148    /// General-purpose profile used by [`Default`].
149    pub const MEMORY_BALANCED: Self = Self {
150        maximum_cell_count: 1_000_000,
151        edge_growth_factor: 2.0,
152    };
153
154    /// Validates a non-empty budget and a convergent coarsening factor.
155    ///
156    /// # Errors
157    ///
158    /// Returns the first invalid grid field.
159    pub fn validate(self) -> Result<(), SpatialError> {
160        if self.maximum_cell_count == 0 {
161            return Err(SpatialError::InvalidOption(SpatialOption::MaximumCellCount));
162        }
163        if !self.edge_growth_factor.is_finite() || self.edge_growth_factor <= 1.0 {
164            return Err(SpatialError::InvalidOption(SpatialOption::CellGrowthFactor));
165        }
166        Ok(())
167    }
168}
169
170impl Default for CellGridOptions {
171    fn default() -> Self {
172        Self::MEMORY_BALANCED
173    }
174}
175
176/// Image-search budget for periodic k-d tree queries.
177#[derive(Clone, Copy, PartialEq, Eq, Debug)]
178pub struct KdPeriodicOptions {
179    /// Maximum query images evaluated for one periodic tree query.
180    pub maximum_image_count: usize,
181}
182
183impl KdPeriodicOptions {
184    /// General-purpose bounded image policy used by [`Default`].
185    pub const BALANCED: Self = Self {
186        maximum_image_count: 4_096,
187    };
188
189    /// Validates a non-empty image budget.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error when no image can be evaluated.
194    pub fn validate(self) -> Result<(), SpatialError> {
195        if self.maximum_image_count == 0 {
196            Err(SpatialError::InvalidOption(
197                SpatialOption::KdPeriodicImageLimit,
198            ))
199        } else {
200            Ok(())
201        }
202    }
203}
204
205impl Default for KdPeriodicOptions {
206    fn default() -> Self {
207        Self::BALANCED
208    }
209}
210
211/// Complete construction options for a reusable neighbour list.
212#[derive(Clone, Copy, PartialEq, Debug)]
213pub struct NeighborListOptions {
214    /// Absolute displacement skin, in ångströms.
215    pub skin: f32,
216    /// Cell-grid policy used to construct the candidate set.
217    pub cell_grid: CellGridOptions,
218}
219
220impl NeighborListOptions {
221    /// Creates options with an explicit skin and the default cell-grid policy.
222    #[must_use]
223    pub const fn with_skin(skin: f32) -> Self {
224        Self {
225            skin,
226            cell_grid: CellGridOptions::MEMORY_BALANCED,
227        }
228    }
229
230    /// Validates the skin and cell-grid fields.
231    ///
232    /// # Errors
233    ///
234    /// Returns the first invalid field.
235    pub fn validate(self) -> Result<(), SpatialError> {
236        if !self.skin.is_finite() || self.skin < 0.0 {
237            return Err(SpatialError::InvalidOption(
238                SpatialOption::NeighborSkinMinimum,
239            ));
240        }
241        self.cell_grid.validate()
242    }
243}
244
245/// Complete options for a one-shot spatial query.
246#[derive(Clone, Copy, PartialEq, Debug)]
247pub struct SpatialSearchOptions {
248    /// Requested implementation, including inspectable automatic selection.
249    pub backend: SpatialBackend,
250    /// Automatic backend thresholds.
251    pub automatic: AutoBackendProfile,
252    /// One-shot neighbour-list skin rule.
253    pub neighbor_skin: NeighborSkinProfile,
254    /// Cell-grid memory and coarsening policy.
255    pub cell_grid: CellGridOptions,
256    /// Periodic k-d tree image-search budget.
257    pub kd_periodic: KdPeriodicOptions,
258}
259
260impl SpatialSearchOptions {
261    /// General-purpose automatic search profile used by [`Default`].
262    pub const BALANCED: Self = Self {
263        backend: SpatialBackend::Auto,
264        automatic: AutoBackendProfile::BALANCED,
265        neighbor_skin: NeighborSkinProfile::BALANCED,
266        cell_grid: CellGridOptions::MEMORY_BALANCED,
267        kd_periodic: KdPeriodicOptions::BALANCED,
268    };
269
270    /// Returns the balanced profile with an explicit backend.
271    #[must_use]
272    pub const fn with_backend(backend: SpatialBackend) -> Self {
273        Self {
274            backend,
275            ..Self::BALANCED
276        }
277    }
278
279    /// Validates all query policy, including branches unused by this workload.
280    ///
281    /// # Errors
282    ///
283    /// Returns the first invalid field.
284    pub fn validate(self) -> Result<(), SpatialError> {
285        self.automatic.validate()?;
286        self.neighbor_skin.validate()?;
287        self.cell_grid.validate()?;
288        self.kd_periodic.validate()
289    }
290
291    /// Produces an inspectable execution plan for one workload shape.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error for invalid profile fields or derived values.
296    pub fn plan(
297        self,
298        left_count: usize,
299        right_count: usize,
300        cutoff: f32,
301    ) -> Result<SpatialPlan, SpatialError> {
302        if !cutoff.is_finite() || cutoff < 0.0 {
303            return Err(SpatialError::InvalidCutoff);
304        }
305        self.validate()?;
306        let backend = match self.backend {
307            SpatialBackend::Auto => self.automatic.resolve(left_count, right_count)?,
308            backend => backend,
309        };
310        let neighbor_skin = if backend == SpatialBackend::NeighborList {
311            Some(self.neighbor_skin.skin(cutoff)?)
312        } else {
313            None
314        };
315        Ok(SpatialPlan {
316            requested_backend: self.backend,
317            backend,
318            neighbor_skin,
319            options: self,
320        })
321    }
322}
323
324impl Default for SpatialSearchOptions {
325    fn default() -> Self {
326        Self::BALANCED
327    }
328}
329
330/// Resolved, provenance-friendly decision for a spatial workload.
331#[derive(Clone, Copy, PartialEq, Debug)]
332pub struct SpatialPlan {
333    /// Backend requested by the caller.
334    pub requested_backend: SpatialBackend,
335    /// Concrete backend selected for execution.
336    pub backend: SpatialBackend,
337    /// Derived neighbour skin when the selected backend requires one.
338    pub neighbor_skin: Option<f32>,
339    /// Complete validated policy that produced this decision.
340    pub options: SpatialSearchOptions,
341}
342
343#[cfg(test)]
344#[path = "options_tests.rs"]
345mod tests;