Skip to main content

molframe_query/execution/
eval.rs

1//! Compiled query evaluation over immutable structures.
2
3use crate::ast::{Expr, SameKey};
4use crate::plan::{PhysicalExpr, PhysicalQuery};
5use crate::spatial::SpatialResolver;
6use molframe_chem::SmartsDataError;
7use molframe_core::contract::AnalysisPolicy;
8use molframe_core::diagnostic::{Code, Diagnostic};
9use molframe_core::selection::AtomSelection;
10use molframe_core::structure::Structure;
11use std::collections::BTreeMap;
12
13mod geometric;
14mod query;
15
16/// Named selections supplied to `group <name>` expressions.
17pub type Groups = BTreeMap<Box<str>, AtomSelection>;
18
19/// A selected atom set and non-fatal findings produced while resolving it.
20#[derive(Clone, Debug)]
21pub struct Evaluation {
22    /// The selected atom indices.
23    pub selection: AtomSelection,
24    /// Ambiguities and absent symbols worth reporting.
25    pub warnings: Vec<Diagnostic>,
26}
27
28/// A parsed and reusable selection plan.
29#[derive(Clone, Debug)]
30pub struct Query {
31    pub(crate) expr: Expr,
32    pub(crate) warnings: Vec<Diagnostic>,
33    pub(crate) source: Box<str>,
34}
35
36impl PhysicalQuery {
37    /// Executes this structure-bound plan over the complete topology.
38    ///
39    /// # Errors
40    ///
41    /// Returns diagnostics when data required by a predicate is unavailable.
42    pub fn evaluate(
43        &self,
44        structure: &Structure,
45        policy: &AnalysisPolicy,
46        groups: &Groups,
47        spatial: Option<&dyn SpatialResolver>,
48    ) -> Result<Evaluation, Vec<Diagnostic>> {
49        self.evaluate_in(
50            structure,
51            &AtomSelection::All(structure.atom_count()),
52            policy,
53            groups,
54            spatial,
55        )
56    }
57
58    /// Executes inside a current view; `global` logical fallbacks can escape it.
59    ///
60    /// # Errors
61    ///
62    /// Returns the same diagnostics as [`PhysicalQuery::evaluate`].
63    pub fn evaluate_in(
64        &self,
65        structure: &Structure,
66        universe: &AtomSelection,
67        policy: &AnalysisPolicy,
68        groups: &Groups,
69        spatial: Option<&dyn SpatialResolver>,
70    ) -> Result<Evaluation, Vec<Diagnostic>> {
71        // The universe's own shape bounds every position it can name, so one
72        // comparison replaces a walk over the atoms it contains. Asking about
73        // each atom instead made every evaluation read the whole structure
74        // before doing any work, which is what an `all` or `none` selection
75        // cannot afford: their evaluation is otherwise constant.
76        let atom_count = structure.atom_count();
77        let bound = universe.position_bound();
78        if bound > atom_count {
79            return Err(vec![
80                Diagnostic::new(Code::E6009).with_context("atom", bound.to_string()),
81            ]);
82        }
83
84        let mut context = Context {
85            structure,
86            policy,
87            groups,
88            spatial,
89            warnings: self.warnings.clone(),
90        };
91
92        let selection = context
93            .eval_physical(&self.expr, universe)
94            .map_err(|finding| vec![finding])?;
95
96        context.warnings.sort_by_key(Diagnostic::code);
97
98        Ok(Evaluation {
99            selection,
100            warnings: context.warnings,
101        })
102    }
103}
104
105/// Picks the universe the right operand of a conjunction is evaluated in.
106///
107/// An operand whose answer for one atom depends only on that atom may run
108/// inside the left result, which is cheaper and cannot change the outcome. An
109/// operand that reads its surroundings (`within`, `byres`, `bonded`, ...) runs
110/// in the enclosing universe instead, so its targets and neighbours do not
111/// depend on which side of `and` it was written on; the final intersection
112/// still restricts it to the left result.
113///
114/// A negated left operand is an explicit exclusion, so the surroundings the
115/// right operand reads must not contain what it removed: `not X and within 4
116/// of X` keeps the written order and runs inside the left result.
117fn narrowed<'a>(
118    atom_local: bool,
119    excludes: bool,
120    universe: &'a AtomSelection,
121    left: &'a AtomSelection,
122) -> &'a AtomSelection {
123    if atom_local || excludes {
124        left
125    } else {
126        universe
127    }
128}
129
130/// Immutable evaluation dependencies plus evaluation-local warning state.
131struct Context<'a> {
132    structure: &'a Structure,
133    policy: &'a AnalysisPolicy,
134    groups: &'a Groups,
135    spatial: Option<&'a dyn SpatialResolver>,
136    warnings: Vec<Diagnostic>,
137}
138
139impl Context<'_> {
140    /// Evaluates a lowered physical expression inside `universe`.
141    ///
142    /// Constant branches and pre-resolved memberships avoid falling back to the
143    /// logical evaluator. Allocation is limited to selections produced by
144    /// individual operators.
145    fn eval_physical(
146        &mut self,
147        expr: &PhysicalExpr,
148        universe: &AtomSelection,
149    ) -> Result<AtomSelection, Diagnostic> {
150        match expr {
151            PhysicalExpr::All => Ok(universe.clone()),
152            PhysicalExpr::None => Ok(AtomSelection::Empty),
153            PhysicalExpr::And(left, right) => {
154                let first = self.eval_physical(left, universe)?;
155
156                if first.is_empty() {
157                    return Ok(first);
158                }
159
160                let second = self.eval_physical(
161                    right,
162                    narrowed(
163                        crate::plan::atom_local(right),
164                        crate::plan::excludes(left),
165                        universe,
166                        &first,
167                    ),
168                )?;
169                Ok(first.intersect(&second))
170            }
171            PhysicalExpr::Or(left, right) => {
172                let left = self.eval_physical(left, universe)?;
173                let right = self.eval_physical(right, universe)?;
174
175                Ok(left.union(&right))
176            }
177            PhysicalExpr::Not(target) => {
178                let selected = self.eval_physical(target, universe)?;
179                Ok(universe.difference(&selected))
180            }
181            PhysicalExpr::ResolvedMembership { column, symbols } => {
182                crate::predicate::membership_symbols(self.structure, universe, *column, symbols)
183            }
184            PhysicalExpr::Logical(logical) => self.eval(logical, universe),
185        }
186    }
187
188    /// Evaluates a logical expression inside `universe`.
189    ///
190    /// Boolean operations short-circuit empty conjunctions while preserving
191    /// `global` semantics and the original diagnostic behavior.
192    fn eval(&mut self, expr: &Expr, universe: &AtomSelection) -> Result<AtomSelection, Diagnostic> {
193        match expr {
194            Expr::All => Ok(universe.clone()),
195            Expr::None => Ok(AtomSelection::Empty),
196            Expr::And(left, right) => {
197                let first = self.eval(left, universe)?;
198
199                if first.is_empty() {
200                    return Ok(first);
201                }
202
203                let second = self.eval(
204                    right,
205                    narrowed(
206                        crate::plan::logical_atom_local(right),
207                        crate::plan::logical_excludes(left),
208                        universe,
209                        &first,
210                    ),
211                )?;
212                Ok(first.intersect(&second))
213            }
214            Expr::Or(left, right) => {
215                let left = self.eval(left, universe)?;
216                let right = self.eval(right, universe)?;
217
218                Ok(left.union(&right))
219            }
220            Expr::Not(target) => {
221                let selected = self.eval(target, universe)?;
222                Ok(universe.difference(&selected))
223            }
224            Expr::Global(target) => {
225                let all = AtomSelection::All(self.structure.atom_count());
226                self.eval(target, &all)
227            }
228            Expr::ByResidue(target) => {
229                let selected = self.eval(target, universe)?;
230
231                Ok(crate::expand::same_residue(
232                    self.structure,
233                    universe,
234                    &selected,
235                ))
236            }
237            Expr::Same { key, target } => {
238                let selected = self.eval(target, universe)?;
239                self.same(key, universe, &selected)
240            }
241            Expr::Bonded { depth, target } => {
242                let selected = self.eval(target, universe)?;
243
244                crate::connectivity::bonded(self.structure, universe, &selected, *depth)
245            }
246            Expr::Geometric(geometric) => self.geometric(geometric, universe),
247            Expr::Comparison {
248                column,
249                operator,
250                value,
251                absolute,
252            } => crate::predicate::comparison(
253                self.structure,
254                universe,
255                *column,
256                *operator,
257                *value,
258                *absolute,
259                self.policy,
260            ),
261            Expr::Membership { column, values } => self.membership(*column, values, universe),
262            Expr::Group(name) => Ok(self.group(name, universe)),
263            Expr::Atom {
264                segment,
265                residue,
266                name,
267            } => crate::predicate::atom_selector(
268                self.structure,
269                universe,
270                segment,
271                *residue,
272                name,
273                self.policy,
274            ),
275            Expr::Macro(macro_name) => crate::macros::macro_selection(
276                self.structure,
277                universe,
278                *macro_name,
279                &mut self.warnings,
280            ),
281            Expr::Chirality(configuration) => {
282                crate::macros::chirality_selection(self.structure, universe, configuration)
283            }
284            Expr::Smarts { pattern, .. } => smarts_selection(pattern, self.structure, universe),
285        }
286    }
287
288    /// Atoms of `universe` whose `column` value is one of `values`; an empty
289    /// result is recorded as a warning.
290    fn membership(
291        &mut self,
292        column: crate::ast::Column,
293        values: &[Box<str>],
294        universe: &AtomSelection,
295    ) -> Result<AtomSelection, Diagnostic> {
296        let selected =
297            crate::predicate::membership(self.structure, universe, column, values, self.policy)?;
298        if selected.is_empty() {
299            self.warnings.push(Diagnostic::new(Code::W4003));
300        }
301        Ok(selected)
302    }
303
304    /// Atoms of `universe` in the named group; an unknown group is a warning
305    /// and selects nothing.
306    fn group(&mut self, name: &str, universe: &AtomSelection) -> AtomSelection {
307        if let Some(group) = self.groups.get(name) {
308            universe.intersect(group)
309        } else {
310            self.warnings
311                .push(Diagnostic::new(Code::W4003).with_context("group", name.to_string()));
312            AtomSelection::Empty
313        }
314    }
315
316    /// Expands `selected` according to a `same` relation.
317    ///
318    /// Delegates to specialized hierarchy, connectivity, or column expansion
319    /// implementations while preserving `universe`.
320    fn same(
321        &self,
322        key: &SameKey,
323        universe: &AtomSelection,
324        selected: &AtomSelection,
325    ) -> Result<AtomSelection, Diagnostic> {
326        match key {
327            SameKey::Residue => Ok(crate::expand::same_residue(
328                self.structure,
329                universe,
330                selected,
331            )),
332            SameKey::Chain => Ok(crate::expand::same_chain(
333                self.structure,
334                universe,
335                selected,
336            )),
337            SameKey::Model => {
338                if selected.is_empty() {
339                    Ok(AtomSelection::Empty)
340                } else {
341                    Ok(universe.clone())
342                }
343            }
344            SameKey::Entity => Ok(crate::expand::same_entity(
345                self.structure,
346                universe,
347                selected,
348            )),
349            SameKey::Column(column) => crate::predicate::same_column(
350                self.structure,
351                universe,
352                selected,
353                *column,
354                self.policy,
355            ),
356            SameKey::Fragment => {
357                crate::connectivity::same_fragment(self.structure, universe, selected)
358            }
359            SameKey::Segment => crate::predicate::same_column(
360                self.structure,
361                universe,
362                selected,
363                crate::ast::Column::SegmentId,
364                self.policy,
365            ),
366        }
367    }
368}
369
370/// Builds the standard missing-data diagnostic for an evaluation dependency.
371///
372/// Runtime and space are `O(1)` apart from diagnostic-owned context storage.
373fn missing(data: &'static str) -> Diagnostic {
374    Diagnostic::new(Code::E4003).with_context("required", data)
375}
376
377fn missing_smarts_data(error: SmartsDataError) -> Diagnostic {
378    let required = match error {
379        SmartsDataError::Connectivity => "resolved connectivity",
380        SmartsDataError::Aromaticity => "CCD aromaticity",
381        SmartsDataError::FormalCharge => "CCD formal charges",
382        SmartsDataError::Stereochemistry => "CCD stereochemistry",
383    };
384    missing(required)
385}
386
387fn smarts_selection(
388    pattern: &molframe_chem::SmartsPattern,
389    structure: &Structure,
390    universe: &AtomSelection,
391) -> Result<AtomSelection, Diagnostic> {
392    let selected: AtomSelection = pattern
393        .find_structure_matches(structure)
394        .map_err(missing_smarts_data)?
395        .into_iter()
396        .flat_map(|matched| matched.atom_indices.into_vec())
397        .filter_map(|atom| u32::try_from(atom).ok())
398        .collect();
399    Ok(universe.intersect(&selected))
400}
401
402#[cfg(test)]
403#[path = "eval_tests.rs"]
404mod tests;
405
406#[cfg(test)]
407#[path = "eval_universe_tests.rs"]
408mod universe_tests;