Skip to main content

cosmolkit_core/search/
substruct.rs

1//! Subgraph isomorphism matching (VF2) for molecule pattern matching.
2//!
3//! ## RDKit provenance (protocol: dev/source_reproduction_protocol.md)
4//!
5//! This module reproduces RDKit's substructure matching from:
6//! - `third_party/rdkit/Code/GraphMol/Substruct/vf2.hpp` (~682 lines C++)
7//! - `third_party/rdkit/Code/GraphMol/Substruct/SubstructMatch.cpp` (~735 lines C++)
8//!
9//! The VF2 algorithm implementation is adapted from vflib-2.0 by P. Foggia,
10//! extensively modified by Greg Landrum, ported to Rust with depth-based
11//! term_1/term_2 tracking (BackTrack decrements counters instead of
12//! recomputing from scratch).
13//!
14//! ## Marker convention
15//!
16//! Each copied C++ block below uses the two-axis status marker:
17//! - RDKit✔️✔️: fully reproduced behavior and performance
18//! - RDKit✔️❌: functionally correct, but with a known performance gap
19//! - RDKit❗✔️: unfinished behavior that must not be presented as parity
20//! - RDKit❌❌: not yet ported
21
22use crate::search::query::{
23    QueryMatchContext, and_query_match, atom_predicate_matches_with_context, atom_queries_match,
24    bond_predicate_matches_with_context, bond_queries_match, build_query_match_context,
25    or_query_match, xor_query_match,
26};
27use crate::{
28    Atom, AtomQueryPredicate, Bond, BondOrder, BondQueryPredicate, BondStereo, ChiralTag, Molecule,
29    StereoGroupKind,
30};
31use std::collections::{BTreeMap, BTreeSet, HashSet};
32use std::fmt;
33use std::sync::Arc;
34
35// ---------------------------------------------------------------------------
36// Result types
37// ---------------------------------------------------------------------------
38
39/// Result of a single substructure match.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SubstructMatchResult {
42    /// Mapping from query atom index to molecule atom index.
43    pub atom_mapping: Vec<usize>,
44    /// Mapping from query bond index to molecule bond index.
45    pub bond_mapping: Vec<usize>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum SubstructMatchError {
50    #[error(
51        "RDKit substructure matching branch {branch} is unsupported until {rdkit_function} is source-ported"
52    )]
53    Unsupported {
54        branch: &'static str,
55        rdkit_function: &'static str,
56    },
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SubstructMatchOverload {
61    Molecule,
62    MolBundle,
63    ResonanceMolSupplier,
64    SubstructLibrary,
65}
66
67pub fn check_substruct_match_overload_support(
68    overload: SubstructMatchOverload,
69) -> Result<(), SubstructMatchError> {
70    match overload {
71        SubstructMatchOverload::Molecule => Ok(()),
72        SubstructMatchOverload::MolBundle => Err(SubstructMatchError::Unsupported {
73            branch: "MolBundle substructure-match overloads",
74            rdkit_function: "SubstructMatch(MolBundle, ROMol/MolBundle, params)",
75        }),
76        SubstructMatchOverload::ResonanceMolSupplier => Err(SubstructMatchError::Unsupported {
77            branch: "resonance substructure-match overload",
78            rdkit_function: "SubstructMatch(ResonanceMolSupplier, ROMol, params)",
79        }),
80        SubstructMatchOverload::SubstructLibrary => Err(SubstructMatchError::Unsupported {
81            branch: "SubstructLibrary search overloads",
82            rdkit_function: "SubstructLibrary::getMatches/hasMatch/countMatches",
83        }),
84    }
85}
86
87#[derive(Debug, thiserror::Error)]
88pub enum SubstructMatchParamsJsonError {
89    #[error("invalid substructure match parameter JSON: {0}")]
90    InvalidJson(#[from] serde_json::Error),
91    #[error("invalid JSON value for substructure match parameter '{field}'")]
92    InvalidField { field: &'static str },
93}
94
95type SubstructMatchResultList = Result<Vec<SubstructMatchResult>, SubstructMatchError>;
96
97/// Parameters controlling substructure matching behaviour.
98pub type ExtraAtomCheck = Arc<dyn Fn(&Molecule, &Atom, &Molecule, &Atom) -> bool + Send + Sync>;
99pub type ExtraBondCheck = Arc<dyn Fn(&Bond, &Bond) -> bool + Send + Sync>;
100pub type ExtraFinalCheck = Arc<dyn Fn(&Molecule, &[usize]) -> bool + Send + Sync>;
101
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct AtomCoordsMatchFunctor {
104    pub ref_conf_id: i32,
105    pub query_conf_id: i32,
106    pub tol2: f64,
107}
108
109impl AtomCoordsMatchFunctor {
110    #[must_use]
111    pub fn new(ref_conf_id: i32, query_conf_id: i32, tolerance: f64) -> Self {
112        Self {
113            ref_conf_id,
114            query_conf_id,
115            tol2: tolerance * tolerance,
116        }
117    }
118
119    #[must_use]
120    pub fn matches(
121        &self,
122        query_mol: &Molecule,
123        query_atom: &Atom,
124        target_mol: &Molecule,
125        target_atom: &Atom,
126    ) -> bool {
127        // RDKit✔️✔️: bool AtomCoordsMatchFunctor::operator()(const Atom &queryAtom,
128        // RDKit✔️✔️:                                         const Atom &targetAtom) const {
129        // RDKit✔️✔️:   if (!queryAtom.getOwningMol().getNumConformers() ||
130        // RDKit✔️✔️:       !targetAtom.getOwningMol().getNumConformers()) {
131        // RDKit✔️✔️:     return false;
132        // RDKit✔️✔️:   }
133        // RDKit✔️✔️:   const auto &queryPos = queryAtom.getOwningMol()
134        // RDKit✔️✔️:                              .getConformer(d_queryConfId)
135        // RDKit✔️✔️:                              .getAtomPos(queryAtom.getIdx());
136        // RDKit✔️✔️:   const auto &targetPos = targetAtom.getOwningMol()
137        // RDKit✔️✔️:                               .getConformer(d_refConfId)
138        // RDKit✔️✔️:                               .getAtomPos(targetAtom.getIdx());
139        // RDKit✔️✔️:   return (queryPos - targetPos).lengthSq() <= d_tol2;
140        // RDKit✔️✔️: };
141        // Complexity review: both versions select two conformers, index two
142        // coordinate rows, and compare three squared deltas in O(1) after the
143        // conformer-id lookup. No coordinate data is cloned or allocated.
144        fn conformer(molecule: &Molecule, id: i32) -> Option<&crate::Conformer3D> {
145            if id < 0 {
146                molecule.conformers_3d().first()
147            } else {
148                molecule
149                    .conformers_3d()
150                    .iter()
151                    .find(|conformer| conformer.id() == id as usize)
152            }
153        }
154
155        let Some(query_conformer) = conformer(query_mol, self.query_conf_id) else {
156            return false;
157        };
158        let Some(target_conformer) = conformer(target_mol, self.ref_conf_id) else {
159            return false;
160        };
161        let Some(query_position) = query_conformer.coordinates().get(query_atom.id().index())
162        else {
163            return false;
164        };
165        let Some(target_position) = target_conformer.coordinates().get(target_atom.id().index())
166        else {
167            return false;
168        };
169        query_position
170            .iter()
171            .zip(target_position)
172            .map(|(query, target)| {
173                let delta = query - target;
174                delta * delta
175            })
176            .sum::<f64>()
177            <= self.tol2
178    }
179}
180
181impl Default for AtomCoordsMatchFunctor {
182    fn default() -> Self {
183        Self::new(-1, -1, 1e-4)
184    }
185}
186
187#[derive(Clone)]
188pub struct SubstructMatchParams {
189    /// Maximum number of matches to return (default: 1000).
190    pub max_matches: usize,
191    /// Whether to uniquify results (default: true).
192    pub uniquify: bool,
193    /// Whether atom/bond stereochemistry participates in matching.
194    pub use_chirality: bool,
195    /// Whether enhanced stereo groups participate in final matching.
196    pub use_enhanced_stereo: bool,
197    /// Whether specified query stereo may match unspecified molecule stereo.
198    pub specified_stereo_query_matches_unspecified: bool,
199    /// Whether two query atoms are compared as query trees.
200    pub use_query_query_matches: bool,
201    /// Whether recursive query nodes may be evaluated.
202    pub recursion_possible: bool,
203    /// Maximum matches used while evaluating recursive query nodes.
204    pub max_recursive_matches: usize,
205    /// Requested matcher thread count; matching is currently single-threaded.
206    pub num_threads: i32,
207    /// Whether aromatic bonds may match conjugated single or double bonds.
208    pub aromatic_matches_conjugated: bool,
209    /// Whether aromatic bonds may match any single or double bond.
210    pub aromatic_matches_single_or_double: bool,
211    /// Atom property names that must have equal string values on both atoms.
212    pub atom_properties: Vec<String>,
213    /// Bond property names that must have equal string values on both bonds.
214    pub bond_properties: Vec<String>,
215    /// Optional caller-provided atom compatibility check.
216    pub extra_atom_check: Option<ExtraAtomCheck>,
217    /// Whether `extra_atom_check` replaces the default atom comparison.
218    pub extra_atom_check_overrides_default_check: bool,
219    /// Optional caller-provided bond compatibility check.
220    pub extra_bond_check: Option<ExtraBondCheck>,
221    /// Whether `extra_bond_check` replaces the default bond comparison.
222    pub extra_bond_check_overrides_default_check: bool,
223    /// Whether generic-group labels participate in final matching.
224    pub use_generic_matchers: bool,
225    /// Optional caller-provided final check over target atom indices.
226    pub extra_final_check: Option<ExtraFinalCheck>,
227}
228
229impl fmt::Debug for SubstructMatchParams {
230    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231        formatter
232            .debug_struct("SubstructMatchParams")
233            .field("max_matches", &self.max_matches)
234            .field("uniquify", &self.uniquify)
235            .field("use_chirality", &self.use_chirality)
236            .field("use_enhanced_stereo", &self.use_enhanced_stereo)
237            .field(
238                "specified_stereo_query_matches_unspecified",
239                &self.specified_stereo_query_matches_unspecified,
240            )
241            .field("use_query_query_matches", &self.use_query_query_matches)
242            .field("recursion_possible", &self.recursion_possible)
243            .field("max_recursive_matches", &self.max_recursive_matches)
244            .field("num_threads", &self.num_threads)
245            .field(
246                "aromatic_matches_conjugated",
247                &self.aromatic_matches_conjugated,
248            )
249            .field(
250                "aromatic_matches_single_or_double",
251                &self.aromatic_matches_single_or_double,
252            )
253            .field("atom_properties", &self.atom_properties)
254            .field("bond_properties", &self.bond_properties)
255            .field("extra_atom_check", &self.extra_atom_check.is_some())
256            .field(
257                "extra_atom_check_overrides_default_check",
258                &self.extra_atom_check_overrides_default_check,
259            )
260            .field("extra_bond_check", &self.extra_bond_check.is_some())
261            .field(
262                "extra_bond_check_overrides_default_check",
263                &self.extra_bond_check_overrides_default_check,
264            )
265            .field("use_generic_matchers", &self.use_generic_matchers)
266            .field("extra_final_check", &self.extra_final_check.is_some())
267            .finish()
268    }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
272enum RecursiveQueryCacheKey {
273    Serial(u32),
274    OwnedQuery(usize),
275}
276
277type RecursiveQueryMatchCache = BTreeMap<RecursiveQueryCacheKey, Vec<bool>>;
278
279struct RecursiveLocker {
280    cache: RecursiveQueryMatchCache,
281}
282
283impl RecursiveLocker {
284    fn new(query: &Molecule, recursion_possible: bool) -> Self {
285        // RDKit✔️🔝: RecursiveLocker(const ROMol &query, const bool recursionPossible) {
286        // RDKit✔️🔝:   if (recursionPossible) {
287        // RDKit✔️🔝:     locked.reserve(query.getNumAtoms());
288        // RDKit✔️🔝:   }
289        // RDKit✔️🔝: }
290        // Rust keeps recursive match state in this call-local cache instead of
291        // mutating and locking query nodes. This preserves the source lifetime
292        // semantics while avoiding the O(query atoms) pointer-vector reserve
293        // and every mutex operation. The query and flag remain inputs here so
294        // this constructor is the canonical source boundary.
295        let _ = (query, recursion_possible);
296        Self {
297            cache: RecursiveQueryMatchCache::new(),
298        }
299    }
300}
301
302impl Drop for RecursiveLocker {
303    fn drop(&mut self) {
304        // RDKit✔️✔️: ~RecursiveLocker() {
305        // RDKit✔️✔️:   for (auto v : locked) {
306        // RDKit✔️✔️:     v->clear();
307        // RDKit✔️✔️: #ifdef RDK_BUILD_THREADSAFE_SSS
308        // RDKit✔️✔️:     v->d_mutex.unlock();
309        // RDKit✔️✔️: #endif
310        // RDKit✔️✔️:   }
311        // RDKit✔️✔️: }
312        // Complexity review: dropping the call-local cache clears each stored
313        // atom-membership vector once, matching RDKit's linear clear pass.
314        // No unlock is required because immutable query nodes are never shared
315        // mutably; ownership enforces the same cleanup on every return path.
316        self.cache.clear();
317    }
318}
319
320fn recursive_query_cache_key(
321    query: &crate::search::query::RecursiveStructureQuery,
322) -> RecursiveQueryCacheKey {
323    if query.serial_number() != 0 {
324        RecursiveQueryCacheKey::Serial(query.serial_number())
325    } else {
326        RecursiveQueryCacheKey::OwnedQuery(query as *const _ as usize)
327    }
328}
329
330impl Default for SubstructMatchParams {
331    fn default() -> Self {
332        // RDKit✔️✔️: bool useChirality = false;  //!< Use chirality in determining whether or not
333        // RDKit✔️✔️:                             //!< atoms/bonds match
334        // RDKit✔️✔️: bool uniquify = true;            //!< uniquify (by atom index) match results
335        // RDKit✔️✔️: unsigned int maxMatches = 1000;  //!< maximum number of matches to return
336        // RDKit✔️✔️: bool specifiedStereoQueryMatchesUnspecified =
337        // RDKit✔️✔️:     false;  //!< If set, query atoms and bonds with specified stereochemistry
338        // RDKit✔️✔️:             //!< will match atoms and bonds with unspecified stereochemistry
339        // RDKit✔️✔️: bool useEnhancedStereo = false;
340        // RDKit✔️✔️: bool aromaticMatchesConjugated = false;
341        // RDKit✔️✔️: bool useQueryQueryMatches = false;
342        // RDKit✔️✔️: bool useGenericMatchers = false;
343        // RDKit✔️✔️: bool recursionPossible = true;
344        // RDKit✔️✔️: int numThreads = 1;
345        // RDKit✔️✔️: std::vector<std::string> atomProperties;
346        // RDKit✔️✔️: std::vector<std::string> bondProperties;
347        // RDKit✔️✔️: std::function<bool(const ROMol &, std::span<const unsigned int>)>
348        // RDKit✔️✔️:     extraFinalCheck;
349        // RDKit✔️✔️: unsigned int maxRecursiveMatches = 1000;
350        // RDKit✔️✔️: bool aromaticMatchesSingleOrDouble = false;
351        // RDKit✔️✔️: std::function<bool(const Atom &, const Atom &)> extraAtomCheck;
352        // RDKit✔️✔️: bool extraAtomCheckOverridesDefaultCheck = false;
353        // RDKit✔️✔️: std::function<bool(const Bond &, const Bond &)> extraBondCheck;
354        // RDKit✔️✔️: bool extraBondCheckOverridesDefaultCheck = false;
355        // Complexity review: initialization is O(1) and allocates only empty
356        // Vec headers and absent callback slots, matching the C++ defaults.
357        Self {
358            max_matches: 1000,
359            uniquify: true,
360            use_chirality: false,
361            use_enhanced_stereo: false,
362            specified_stereo_query_matches_unspecified: false,
363            use_query_query_matches: false,
364            recursion_possible: true,
365            max_recursive_matches: 1000,
366            num_threads: 1,
367            aromatic_matches_conjugated: false,
368            aromatic_matches_single_or_double: false,
369            atom_properties: Vec::new(),
370            bond_properties: Vec::new(),
371            extra_atom_check: None,
372            extra_atom_check_overrides_default_check: false,
373            extra_bond_check: None,
374            extra_bond_check_overrides_default_check: false,
375            use_generic_matchers: false,
376            extra_final_check: None,
377        }
378    }
379}
380
381fn json_param_bool(
382    object: &serde_json::Map<String, serde_json::Value>,
383    field: &'static str,
384) -> Result<Option<bool>, SubstructMatchParamsJsonError> {
385    let Some(value) = object.get(field) else {
386        return Ok(None);
387    };
388    match value {
389        serde_json::Value::Bool(value) => Ok(Some(*value)),
390        serde_json::Value::String(value) if value == "true" || value == "1" => Ok(Some(true)),
391        serde_json::Value::String(value) if value == "false" || value == "0" => Ok(Some(false)),
392        _ => Err(SubstructMatchParamsJsonError::InvalidField { field }),
393    }
394}
395
396fn json_param_usize(
397    object: &serde_json::Map<String, serde_json::Value>,
398    field: &'static str,
399) -> Result<Option<usize>, SubstructMatchParamsJsonError> {
400    let Some(value) = object.get(field) else {
401        return Ok(None);
402    };
403    let parsed = match value {
404        serde_json::Value::Number(value) => {
405            value.as_u64().and_then(|value| usize::try_from(value).ok())
406        }
407        serde_json::Value::String(value) => value.parse().ok(),
408        _ => None,
409    };
410    parsed
411        .map(Some)
412        .ok_or(SubstructMatchParamsJsonError::InvalidField { field })
413}
414
415fn json_param_i32(
416    object: &serde_json::Map<String, serde_json::Value>,
417    field: &'static str,
418) -> Result<Option<i32>, SubstructMatchParamsJsonError> {
419    let Some(value) = object.get(field) else {
420        return Ok(None);
421    };
422    let parsed = match value {
423        serde_json::Value::Number(value) => {
424            value.as_i64().and_then(|value| i32::try_from(value).ok())
425        }
426        serde_json::Value::String(value) => value.parse().ok(),
427        _ => None,
428    };
429    parsed
430        .map(Some)
431        .ok_or(SubstructMatchParamsJsonError::InvalidField { field })
432}
433
434pub fn update_substruct_match_params_from_json(
435    params: &mut SubstructMatchParams,
436    json: &str,
437) -> Result<(), SubstructMatchParamsJsonError> {
438    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: updateSubstructMatchParamsFromJSON
439    // RDKit✔️✔️: void updateSubstructMatchParamsFromJSON(SubstructMatchParameters &params,
440    // RDKit✔️✔️:                                         const std::string &json) {
441    // RDKit✔️✔️:   if (json.empty()) {
442    // RDKit✔️✔️:     return;
443    // RDKit✔️✔️:   }
444    // RDKit✔️✔️:   std::istringstream ss;
445    // RDKit✔️✔️:   ss.str(json);
446    // RDKit✔️✔️:   boost::property_tree::ptree pt;
447    // RDKit✔️✔️:   boost::property_tree::read_json(ss, pt);
448    // RDKit✔️✔️:   PT_OPT_GET(useChirality);
449    // RDKit✔️✔️:   PT_OPT_GET(useEnhancedStereo);
450    // RDKit✔️✔️:   PT_OPT_GET(aromaticMatchesConjugated);
451    // RDKit✔️✔️:   PT_OPT_GET(useQueryQueryMatches);
452    // RDKit✔️✔️:   PT_OPT_GET(recursionPossible);
453    // RDKit✔️✔️:   PT_OPT_GET(uniquify);
454    // RDKit✔️✔️:   PT_OPT_GET(maxMatches);
455    // RDKit✔️✔️:   PT_OPT_GET(maxRecursiveMatches);
456    // RDKit✔️✔️:   PT_OPT_GET(numThreads);
457    // RDKit✔️✔️:   PT_OPT_GET(specifiedStereoQueryMatchesUnspecified);
458    // RDKit✔️✔️:   PT_OPT_GET(aromaticMatchesSingleOrDouble);
459    // RDKit✔️✔️: }
460    // END RDKIT CPP FUNCTION
461    //
462    // Local complexity review: both parsers are O(input length), followed by
463    // eleven expected O(1) object lookups. No molecule/query data is touched
464    // or cloned. Staging prevents partial mutation on malformed input.
465    if json.is_empty() {
466        return Ok(());
467    }
468    let value: serde_json::Value = serde_json::from_str(json)?;
469    let object = value
470        .as_object()
471        .ok_or(SubstructMatchParamsJsonError::InvalidField { field: "root" })?;
472    let use_chirality = json_param_bool(object, "useChirality")?;
473    let use_enhanced_stereo = json_param_bool(object, "useEnhancedStereo")?;
474    let aromatic_matches_conjugated = json_param_bool(object, "aromaticMatchesConjugated")?;
475    let use_query_query_matches = json_param_bool(object, "useQueryQueryMatches")?;
476    let recursion_possible = json_param_bool(object, "recursionPossible")?;
477    let uniquify = json_param_bool(object, "uniquify")?;
478    let max_matches = json_param_usize(object, "maxMatches")?;
479    let max_recursive_matches = json_param_usize(object, "maxRecursiveMatches")?;
480    let num_threads = json_param_i32(object, "numThreads")?;
481    let specified_stereo = json_param_bool(object, "specifiedStereoQueryMatchesUnspecified")?;
482    let aromatic_matches_single_or_double =
483        json_param_bool(object, "aromaticMatchesSingleOrDouble")?;
484
485    if let Some(value) = use_chirality {
486        params.use_chirality = value;
487    }
488    if let Some(value) = use_enhanced_stereo {
489        params.use_enhanced_stereo = value;
490    }
491    if let Some(value) = aromatic_matches_conjugated {
492        params.aromatic_matches_conjugated = value;
493    }
494    if let Some(value) = use_query_query_matches {
495        params.use_query_query_matches = value;
496    }
497    if let Some(value) = recursion_possible {
498        params.recursion_possible = value;
499    }
500    if let Some(value) = uniquify {
501        params.uniquify = value;
502    }
503    if let Some(value) = max_matches {
504        params.max_matches = value;
505    }
506    if let Some(value) = max_recursive_matches {
507        params.max_recursive_matches = value;
508    }
509    if let Some(value) = num_threads {
510        params.num_threads = value;
511    }
512    if let Some(value) = specified_stereo {
513        params.specified_stereo_query_matches_unspecified = value;
514    }
515    if let Some(value) = aromatic_matches_single_or_double {
516        params.aromatic_matches_single_or_double = value;
517    }
518    Ok(())
519}
520
521pub fn substruct_match_params_to_json(params: &SubstructMatchParams) -> String {
522    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: substructMatchParamsToJSON
523    // RDKit✔️✔️: std::string substructMatchParamsToJSON(const SubstructMatchParameters &params) {
524    // RDKit✔️✔️:   boost::property_tree::ptree pt;
525    // RDKit✔️✔️:
526    // RDKit✔️✔️:   PT_OPT_PUT(useChirality);
527    // RDKit✔️✔️:   PT_OPT_PUT(useEnhancedStereo);
528    // RDKit✔️✔️:   PT_OPT_PUT(aromaticMatchesConjugated);
529    // RDKit✔️✔️:   PT_OPT_PUT(useQueryQueryMatches);
530    // RDKit✔️✔️:   PT_OPT_PUT(recursionPossible);
531    // RDKit✔️✔️:   PT_OPT_PUT(uniquify);
532    // RDKit✔️✔️:   PT_OPT_PUT(maxMatches);
533    // RDKit✔️✔️:   PT_OPT_PUT(maxRecursiveMatches);
534    // RDKit✔️✔️:   PT_OPT_PUT(numThreads);
535    // RDKit✔️✔️:   PT_OPT_PUT(specifiedStereoQueryMatchesUnspecified);
536    // RDKit✔️✔️:   PT_OPT_PUT(aromaticMatchesSingleOrDouble);
537    // RDKit✔️✔️:
538    // RDKit✔️✔️:   std::stringstream ss;
539    // RDKit✔️✔️:   boost::property_tree::json_parser::write_json(ss, pt);
540    // RDKit✔️✔️:   return ss.str();
541    // RDKit✔️✔️: }
542    // END RDKIT CPP FUNCTION
543    //
544    // Local complexity review: both implementations serialize the same fixed
545    // eleven scalar fields in O(output length), without molecule/query work.
546    let fields = serde_json::json!({
547        "useChirality": params.use_chirality.to_string(),
548        "useEnhancedStereo": params.use_enhanced_stereo.to_string(),
549        "aromaticMatchesConjugated": params.aromatic_matches_conjugated.to_string(),
550        "useQueryQueryMatches": params.use_query_query_matches.to_string(),
551        "recursionPossible": params.recursion_possible.to_string(),
552        "uniquify": params.uniquify.to_string(),
553        "maxMatches": params.max_matches.to_string(),
554        "maxRecursiveMatches": params.max_recursive_matches.to_string(),
555        "numThreads": params.num_threads.to_string(),
556        "specifiedStereoQueryMatchesUnspecified": params.specified_stereo_query_matches_unspecified.to_string(),
557        "aromaticMatchesSingleOrDouble": params.aromatic_matches_single_or_double.to_string(),
558    });
559    serde_json::to_string_pretty(&fields).expect("fixed scalar JSON serialization cannot fail")
560        + "\n"
561}
562
563// ---------------------------------------------------------------------------
564// Internal minimum-degree graph representation
565// ---------------------------------------------------------------------------
566
567/// Minimal adjacency info needed for VF2.
568///
569/// `nbrs[i]` is a slice into `edges`.
570#[derive(Debug, Clone)]
571struct Vf2Graph {
572    n_atoms: usize,
573    n_bonds: usize,
574    /// Canonical source/target endpoints indexed by bond id.
575    edge_endpoints: Vec<(usize, usize)>,
576    /// For each atom index, the neighbor indices and bond ids.
577    adjacency: Vec<Vec<(usize, usize)>>, // (neighbor_atom_index, bond_index)
578}
579
580/// Build a VF2-compatible adjacency view from a molecule.
581///
582/// The C++ code iterates `out_edges` via Boost graph. We pre-build adjacency
583/// once and use raw index lookups.
584fn build_vf2_graph(mol: &Molecule) -> Vf2Graph {
585    // RDKit source (implicit in vf2.hpp usage of out_edges):
586    //   The VF2 state stores Graph *g1, *g2 and calls:
587    //     boost::out_edges(node, *g)
588    //     boost::out_degree(node, *g)
589    //     boost::adjacent_vertices(node, *g)
590    //   These are all O(1) in Boost adjacency_list.
591    //
592    // RDKit✔️❌: We build a flat adjacency Vec<(usize, usize)> per atom.
593    //   This adds a one-time O(V+E) allocation vs the Boost inline storage,
594    //   but lookups are O(degree) which matches the original hot-path cost.
595    let n_atoms = mol.num_atoms();
596    let mut adjacency: Vec<Vec<(usize, usize)>> = vec![Vec::new(); n_atoms];
597    let mut edge_endpoints = Vec::with_capacity(mol.num_bonds());
598    for (bond_idx, bond) in mol.bonds().iter().enumerate() {
599        let b = bond.begin().index();
600        let e = bond.end().index();
601        edge_endpoints.push((b, e));
602        adjacency[b].push((e, bond_idx));
603        adjacency[e].push((b, bond_idx));
604    }
605    Vf2Graph {
606        n_atoms,
607        n_bonds: mol.num_bonds(),
608        edge_endpoints,
609        adjacency,
610    }
611}
612
613fn get_other_idx(g: &Vf2Graph, edge: usize, vertex: NodeId) -> NodeId {
614    // RDKit✔️✔️: template <class Graph, class VertexDescr, class EdgeDescr>
615    // RDKit✔️✔️: VertexDescr getOtherIdx(const Graph &g, const EdgeDescr &edge,
616    // RDKit✔️✔️:                         const VertexDescr &vertex) {
617    // RDKit✔️✔️:   VertexDescr tmp = boost::source(edge, g);
618    // RDKit✔️✔️:   if (tmp == vertex) {
619    // RDKit✔️✔️:     tmp = boost::target(edge, g);
620    // RDKit✔️✔️:   }
621    // RDKit✔️✔️:   return tmp;
622    // RDKit✔️✔️: }
623    // Complexity review: the endpoint table provides the same O(1) source and
624    // target lookup as the Boost edge descriptor, with no per-call allocation.
625    let (source, target) = g.edge_endpoints[edge];
626    if source == vertex { target } else { source }
627}
628
629impl Vf2Graph {
630    fn out_degree(&self, node: usize) -> usize {
631        self.adjacency[node].len()
632    }
633
634    fn out_edges(&self, node: usize) -> &[(usize, usize)] {
635        &self.adjacency[node]
636    }
637}
638
639// ---------------------------------------------------------------------------
640// Atom and bond matching functors
641// ---------------------------------------------------------------------------
642
643fn property_compat(
644    properties1: &BTreeMap<String, String>,
645    properties2: &BTreeMap<String, String>,
646    properties: &[String],
647) -> bool {
648    // RDKit✔️🔝: bool propertyCompat(const RDProps *r1, const RDProps *r2,
649    // RDKit✔️🔝:                     const std::vector<std::string> &properties) {
650    // RDKit✔️🔝:   PRECONDITION(r1, "bad RDProps");
651    // RDKit✔️🔝:   PRECONDITION(r2, "bad RDProps");
652    // RDKit✔️🔝:
653    // RDKit✔️🔝:   for (const auto &prop : properties) {
654    // RDKit✔️🔝:     std::string prop1;
655    // RDKit✔️🔝:     bool hasprop1 = r1->getPropIfPresent<std::string>(prop, prop1);
656    // RDKit✔️🔝:     std::string prop2;
657    // RDKit✔️🔝:     bool hasprop2 = r2->getPropIfPresent<std::string>(prop, prop2);
658    // RDKit✔️🔝:     if (hasprop1 && hasprop2) {
659    // RDKit✔️🔝:       if (prop1 != prop2) {
660    // RDKit✔️🔝:         return false;
661    // RDKit✔️🔝:       }
662    // RDKit✔️🔝:     } else if (hasprop1 || hasprop2) {
663    // RDKit✔️🔝:       // only one has the property
664    // RDKit✔️🔝:       return false;
665    // RDKit✔️🔝:     }
666    // RDKit✔️🔝:   }
667    // RDKit✔️🔝:   return true;
668    // RDKit✔️🔝: }
669    //
670    // Typed references make both source pointer preconditions
671    // unrepresentable. COSMolKit's canonical atom/bond property maps store
672    // only strings, exactly the type requested by the source function, so a
673    // pair of Option<&String> values preserves the source's present/missing
674    // cases without temporary string copies. Local complexity review: both
675    // implementations scan the requested property list once and short-circuit
676    // at the first mismatch without cloning or allocating. RDKit's Dict scans
677    // its vector of entries for each lookup (O(P*N)); BTreeMap lookup is
678    // O(log N), making this O(P*log N) while preserving lookup semantics.
679    for property in properties {
680        if properties1.get(property) != properties2.get(property) {
681            return false;
682        }
683    }
684    true
685}
686
687// RDKit source (SubstructMatch.cpp):
688//   class AtomLabelFunctor {
689//    public:
690//     AtomLabelFunctor(const ROMol &query, const ROMol &mol,
691//                      const SubstructMatchParameters &ps)
692//         : d_query(query), d_mol(mol), d_params(ps) {};
693//     bool operator()(unsigned int i, unsigned int j) const {
694//       bool res = false;
695//       if (d_params.useChirality) {
696//         const Atom *qAt = d_query.getAtomWithIdx(i);
697//         if (qAt->getChiralTag() == Atom::CHI_TETRAHEDRAL_CW ||
698//             qAt->getChiralTag() == Atom::CHI_TETRAHEDRAL_CCW) {
699//           const Atom *mAt = d_mol.getAtomWithIdx(j);
700//           if (!d_params.specifiedStereoQueryMatchesUnspecified &&
701//               mAt->getChiralTag() != Atom::CHI_TETRAHEDRAL_CW &&
702//               mAt->getChiralTag() != Atom::CHI_TETRAHEDRAL_CCW) {
703//             return false;
704//           }
705//         }
706//       }
707//       res = atomCompat(d_query[i], d_mol[j], d_params);
708//       return res;
709//     }
710//    private:
711//     const ROMol &d_query;
712//     const ROMol &d_mol;
713//     const SubstructMatchParameters &d_params;
714//   };
715//
716// RDKit❗✔️: AtomLabelFunctor is ported as plain functions. The
717//   useChirality specified/unspecified precheck is wired below; the final
718//   tetrahedral parity check remains in MolMatchFinalCheckFunctor.
719
720fn has_chiral_label(atom: &Atom) -> bool {
721    // RDKit✔️✔️: bool hasChiralLabel(const Atom *at) {
722    // RDKit✔️✔️:   PRECONDITION(at, "bad atom");
723    // RDKit✔️✔️:   return at->getChiralTag() == Atom::CHI_TETRAHEDRAL_CW ||
724    // RDKit✔️✔️:          at->getChiralTag() == Atom::CHI_TETRAHEDRAL_CCW;
725    // RDKit✔️✔️: }
726    // Rust's reference type enforces the non-null precondition. Complexity
727    // review: both implementations read one enum and perform at most two O(1)
728    // comparisons without allocation.
729    matches!(
730        atom.chiral_tag(),
731        ChiralTag::TetrahedralCw | ChiralTag::TetrahedralCcw
732    )
733}
734
735type MatchVect = Vec<(i32, i32)>;
736
737fn insert_if_needed(matches: &mut BTreeSet<MatchVect>, candidate: MatchVect) -> bool {
738    // RDKit✔️✔️: bool insertIfNeeded(std::set<MatchVectType> &matches, const MatchVectType &m) {
739    // RDKit✔️✔️:   bool shouldInsert = true;
740    // RDKit✔️✔️:   std::unordered_set<int> matchAsSet;
741    // RDKit✔️✔️:   std::transform(m.begin(), m.end(),
742    // RDKit✔️✔️:                  std::inserter(matchAsSet, matchAsSet.begin()),
743    // RDKit✔️✔️:                  [](const std::pair<int, int> &p) { return p.second; });
744    // RDKit✔️✔️:   for (auto it = matches.begin(); it != matches.end(); ++it) {
745    // RDKit✔️✔️:     std::unordered_set<int> existingMatchAsSet;
746    // RDKit✔️✔️:     std::transform(
747    // RDKit✔️✔️:         it->begin(), it->end(),
748    // RDKit✔️✔️:         std::inserter(existingMatchAsSet, existingMatchAsSet.begin()),
749    // RDKit✔️✔️:         [](const std::pair<int, int> &p) { return p.second; });
750    // RDKit✔️✔️:     if (matchAsSet == existingMatchAsSet) {
751    // RDKit✔️✔️:       if (m < *it) {
752    // RDKit✔️✔️:         matches.erase(it);
753    // RDKit✔️✔️:       } else {
754    // RDKit✔️✔️:         shouldInsert = false;
755    // RDKit✔️✔️:       }
756    // RDKit✔️✔️:       break;
757    // RDKit✔️✔️:     }
758    // RDKit✔️✔️:   }
759    // RDKit✔️✔️:   if (shouldInsert) {
760    // RDKit✔️✔️:     matches.insert(m);
761    // RDKit✔️✔️:   }
762    // RDKit✔️✔️:   return shouldInsert;
763    // RDKit✔️✔️: }
764    // Complexity review: both scan O(number of matches), build one O(match
765    // length) hash set per comparison, and use a logarithmic ordered-set erase
766    // and insert. Rust retains no temporary sets after the call.
767    let candidate_atoms: HashSet<i32> = candidate.iter().map(|pair| pair.1).collect();
768    let existing = matches.iter().find(|existing| {
769        existing.iter().map(|pair| pair.1).collect::<HashSet<_>>() == candidate_atoms
770    });
771    let mut should_insert = true;
772    if let Some(existing) = existing.cloned() {
773        if candidate < existing {
774            matches.remove(&existing);
775        } else {
776            should_insert = false;
777        }
778    }
779    if should_insert {
780        matches.insert(candidate);
781    }
782    should_insert
783}
784
785fn try_to_insert(
786    matches: &mut BTreeSet<MatchVect>,
787    candidate: MatchVect,
788    params: &SubstructMatchParams,
789) -> bool {
790    // RDKit✔️✔️: bool tryToInsert(std::set<MatchVectType> &matches, const MatchVectType &match,
791    // RDKit✔️✔️:                  const SubstructMatchParameters &params) {
792    // RDKit✔️✔️:   if (matches.size() == params.maxMatches) {
793    // RDKit✔️✔️:     return false;
794    // RDKit✔️✔️:   }
795    // RDKit✔️✔️:   if (!params.uniquify) {
796    // RDKit✔️✔️:     matches.insert(match);
797    // RDKit✔️✔️:   } else {
798    // RDKit✔️✔️:     insertIfNeeded(matches, match);
799    // RDKit✔️✔️:   }
800    // RDKit✔️✔️:   return true;
801    // RDKit✔️✔️: }
802    // Complexity review: the limit check is O(1), ordinary insertion is
803    // O(log M), and the uniquify branch delegates to the source-equivalent
804    // O(M * match length) canonical helper without additional copying.
805    if matches.len() == params.max_matches {
806        return false;
807    }
808    if !params.uniquify {
809        matches.insert(candidate);
810    } else {
811        insert_if_needed(matches, candidate);
812    }
813    true
814}
815
816fn atom_label_matches(
817    query: &Molecule,
818    mol: &Molecule,
819    query_index: usize,
820    mol_index: usize,
821    params: &SubstructMatchParams,
822    recursive_cache: Option<&RecursiveQueryMatchCache>,
823    query_ctx: &QueryMatchContext,
824) -> bool {
825    // RDKit✔️✔️: bool operator()(unsigned int i, unsigned int j) const {
826    // RDKit✔️✔️:   bool res = false;
827    // RDKit✔️✔️:     if (d_params.useChirality) {
828    // RDKit✔️✔️:       const Atom *qAt = d_query.getAtomWithIdx(i);
829    // RDKit✔️✔️:       if (qAt->getChiralTag() == Atom::CHI_TETRAHEDRAL_CW ||
830    // RDKit✔️✔️:           qAt->getChiralTag() == Atom::CHI_TETRAHEDRAL_CCW) {
831    // RDKit✔️✔️:         const Atom *mAt = d_mol.getAtomWithIdx(j);
832    // RDKit✔️✔️:         if (!d_params.specifiedStereoQueryMatchesUnspecified &&
833    // RDKit✔️✔️:             mAt->getChiralTag() != Atom::CHI_TETRAHEDRAL_CW &&
834    // RDKit✔️✔️:             mAt->getChiralTag() != Atom::CHI_TETRAHEDRAL_CCW) {
835    // RDKit✔️✔️:           return false;
836    // RDKit✔️✔️:         }
837    // RDKit✔️✔️:       }
838    // RDKit✔️✔️:     }
839    // RDKit✔️✔️:   res = atomCompat(d_query[i], d_mol[j], d_params);
840    // RDKit✔️✔️:   return res;
841    // RDKit✔️✔️: }
842    // Complexity review: the precheck is O(1), then this delegates exactly once
843    // to canonical atom_compat; it introduces no allocation or repeated query
844    // evaluation beyond the source functor.
845    let query_atom = &query.atoms()[query_index];
846    let mol_atom = &mol.atoms()[mol_index];
847    if params.use_chirality
848        && has_chiral_label(query_atom)
849        && !params.specified_stereo_query_matches_unspecified
850        && !has_chiral_label(mol_atom)
851    {
852        return false;
853    }
854    atom_compat(
855        query_atom,
856        query,
857        mol_atom,
858        mol,
859        params,
860        recursive_cache,
861        query_ctx,
862    )
863}
864
865fn atom_matches(query_atom: &Atom, query_mol: &Molecule, mol_atom: &Atom, mol: &Molecule) -> bool {
866    if let Some(query_node) = query_atom.query() {
867        let query_ctx = build_query_match_context(mol);
868        return evaluate_atom_query(
869            query_node,
870            mol_atom,
871            mol,
872            &SubstructMatchParams::default(),
873            None,
874            &query_ctx,
875        );
876    }
877
878    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Atom.cpp :: Atom::Match
879    // RDKit✔️✔️: bool Atom::Match(Atom const *what) const {
880    // RDKit✔️✔️:   PRECONDITION(what, "bad query atom");
881    // RDKit✔️✔️:   bool res = getAtomicNum() == what->getAtomicNum();
882    // RDKit✔️✔️:
883    // RDKit✔️✔️:   // special dummy--dummy match case:
884    // RDKit✔️✔️:   //   [*] matches [*],[1*],[2*],etc.
885    // RDKit✔️✔️:   //   [1*] only matches [*] and [1*]
886    // RDKit✔️✔️:   if (res) {
887    // RDKit✔️✔️:     if (!this->getAtomicNum()) {
888    // RDKit✔️✔️:       // this is the new behavior, based on the isotopes:
889    // RDKit✔️✔️:       int tgt = this->getIsotope();
890    // RDKit✔️✔️:       int test = what->getIsotope();
891    // RDKit✔️✔️:       if (tgt && test && tgt != test) {
892    // RDKit✔️✔️:         res = false;
893    // RDKit✔️✔️:       }
894    // RDKit✔️✔️:     } else {
895    // RDKit✔️✔️:       // standard atom-atom match: The general rule here is that if this atom
896    // RDKit✔️✔️:       // has a property that
897    // RDKit✔️✔️:       // deviates from the default, then the other atom should match that value.
898    // RDKit✔️✔️:       if ((this->getFormalCharge() &&
899    // RDKit✔️✔️:            this->getFormalCharge() != what->getFormalCharge()) ||
900    // RDKit✔️✔️:           (this->getIsotope() && this->getIsotope() != what->getIsotope()) ||
901    // RDKit✔️✔️:           (this->getNumRadicalElectrons() &&
902    // RDKit✔️✔️:            this->getNumRadicalElectrons() != what->getNumRadicalElectrons())) {
903    // RDKit✔️✔️:         res = false;
904    // RDKit✔️✔️:       }
905    // RDKit✔️✔️:     }
906    // RDKit✔️✔️:   }
907    // RDKit✔️✔️:   return res;
908    // RDKit✔️✔️: }
909    // END RDKIT CPP FUNCTION
910    //
911    // Local complexity review: the plain-atom path is constant time and uses
912    // only scalar field reads, exactly as the source. No allocation, cloning,
913    // molecule scan, keyed lookup, or temporary collection is introduced.
914    if query_atom.atomic_number() != mol_atom.atomic_number() {
915        return false;
916    }
917    let _ = query_mol;
918    if query_atom.atomic_number() == 0 {
919        return match (query_atom.isotope(), mol_atom.isotope()) {
920            (Some(query_isotope), Some(mol_isotope)) => query_isotope == mol_isotope,
921            _ => true,
922        };
923    }
924    (query_atom.formal_charge() == 0 || query_atom.formal_charge() == mol_atom.formal_charge())
925        && (query_atom.isotope().is_none() || query_atom.isotope() == mol_atom.isotope())
926        && (query_atom.radical_electrons() == 0
927            || query_atom.radical_electrons() == mol_atom.radical_electrons())
928}
929
930fn recursive_smarts_root_matches(
931    atom: &Atom,
932    recursive_query: &crate::search::query::RecursiveStructureQuery,
933    mol: &Molecule,
934    recursive_cache: Option<&RecursiveQueryMatchCache>,
935) -> bool {
936    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/QueryOps.h :: RecursiveStructureQuery
937    // RDKit✔️✔️: class RDKIT_GRAPHMOL_EXPORT RecursiveStructureQuery
938    // RDKit✔️✔️:     : public Queries::SetQuery<int, Atom const *, true> {
939    // RDKit✔️✔️:   RecursiveStructureQuery(ROMol const *query, unsigned int serialNumber = 0)
940    // RDKit✔️✔️:       : Queries::SetQuery<int, Atom const *, true>(),
941    // RDKit✔️✔️:         d_serialNumber(serialNumber) {
942    // RDKit✔️✔️:     setQueryMol(query);
943    // RDKit✔️✔️:     setDataFunc(getAtIdx);
944    // RDKit✔️✔️:     setDescription("RecursiveStructure");
945    // RDKit✔️✔️:   }
946    // RDKit✔️✔️:   static inline int getAtIdx(Atom const *at) {
947    // RDKit✔️✔️:     PRECONDITION(at, "bad atom argument");
948    // RDKit✔️✔️:     return at->getIdx();
949    // RDKit✔️✔️:   }
950    // END RDKIT CPP FUNCTION
951    //
952    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructMatch.cpp :: detail::RecursiveMatcher
953    // RDKit✔️✔️:   if (!query.hasProp(common_properties::_queryRootAtom)) {
954    // RDKit✔️✔️:     matches.push_back(pairs.begin()->second);
955    // RDKit✔️✔️:   } else {
956    // RDKit✔️✔️:     int rootIdx;
957    // RDKit✔️✔️:     query.getProp(common_properties::_queryRootAtom, rootIdx);
958    // RDKit✔️✔️:     bool found = false;
959    // RDKit✔️✔️:     for (const auto &pairIter : pairs) {
960    // RDKit✔️✔️:       if (pairIter.first == static_cast<unsigned int>(rootIdx)) {
961    // RDKit✔️✔️:         matches.push_back(pairIter.second);
962    // RDKit✔️✔️:         found = true;
963    // RDKit✔️✔️:         break;
964    // RDKit✔️✔️:       }
965    // RDKit✔️✔️:     }
966    // RDKit✔️✔️:   }
967    // END RDKIT CPP FUNCTION
968    //
969    // COSMolKit currently parses the recursive SMARTS used by Lipinski NumHBA
970    // without `_queryRootAtom`; matching therefore uses RDKit's first mapped
971    // query atom as the recursive root and tests membership in the cached
972    // RecursiveStructureQuery atom-index set.
973    if let Some(cache) = recursive_cache {
974        return cache
975            .get(&recursive_query_cache_key(recursive_query))
976            .and_then(|match_starts| match_starts.get(atom.id().index()))
977            .copied()
978            .unwrap_or(false);
979    }
980
981    let Some(query) = recursive_query.query_mol() else {
982        return false;
983    };
984    substruct_match_impl(
985        mol,
986        query,
987        &SubstructMatchParams {
988            max_matches: 1000,
989            uniquify: false,
990            use_chirality: false,
991            specified_stereo_query_matches_unspecified: false,
992            ..Default::default()
993        },
994    )
995    .unwrap_or_default()
996    .into_iter()
997    .any(|matched| matched.atom_mapping.first().copied() == Some(atom.id().index()))
998}
999
1000fn atom_query_predicate_matches_for_substruct(
1001    atom: &Atom,
1002    pred: &AtomQueryPredicate,
1003    mol: &Molecule,
1004    params: &SubstructMatchParams,
1005    recursive_cache: Option<&RecursiveQueryMatchCache>,
1006    query_ctx: &QueryMatchContext,
1007) -> bool {
1008    match pred {
1009        // RDKit✔️✔️: Chiral SMARTS labels are not ordinary atom-compatibility
1010        // constraints when `useChirality` is false. AtomLabelFunctor and
1011        // MolMatchFinalCheckFunctor handle stereochemistry explicitly.
1012        AtomQueryPredicate::ChiralTagMatch(_) | AtomQueryPredicate::ChiralPermutationMatch(_)
1013            if !params.use_chirality =>
1014        {
1015            true
1016        }
1017        AtomQueryPredicate::RecursiveSmarts(recursive_query) => {
1018            recursive_smarts_root_matches(atom, recursive_query, mol, recursive_cache)
1019        }
1020        _ => atom_predicate_matches_with_context(atom, pred, mol, query_ctx),
1021    }
1022}
1023
1024/// RDKit❗✔️: Evaluation of an atom query node for the SMARTS subset currently
1025/// modeled by COSMolKit.
1026///
1027/// Recursive SMARTS are evaluated through the recursive match cache used by
1028/// SubstructMatch; unsupported predicate leaves still evaluate false.
1029fn evaluate_atom_query(
1030    query: &crate::QueryNode<AtomQueryPredicate>,
1031    atom: &Atom,
1032    mol: &Molecule,
1033    params: &SubstructMatchParams,
1034    recursive_cache: Option<&RecursiveQueryMatchCache>,
1035    query_ctx: &QueryMatchContext,
1036) -> bool {
1037    match query {
1038        crate::QueryNode::Predicate(pred) => atom_query_predicate_matches_for_substruct(
1039            atom,
1040            pred,
1041            mol,
1042            params,
1043            recursive_cache,
1044            query_ctx,
1045        ),
1046        crate::QueryNode::And(children) => and_query_match(children, false, |child| {
1047            evaluate_atom_query(child, atom, mol, params, recursive_cache, query_ctx)
1048        }),
1049        crate::QueryNode::Or(children) => or_query_match(children, false, |child| {
1050            evaluate_atom_query(child, atom, mol, params, recursive_cache, query_ctx)
1051        }),
1052        crate::QueryNode::Xor(children) => xor_query_match(children, false, |child| {
1053            evaluate_atom_query(child, atom, mol, params, recursive_cache, query_ctx)
1054        }),
1055        crate::QueryNode::Not(child) => {
1056            !evaluate_atom_query(child, atom, mol, params, recursive_cache, query_ctx)
1057        }
1058    }
1059}
1060
1061// RDKit source (SubstructMatch.cpp):
1062//   class BondLabelFunctor {
1063//    public:
1064//     BondLabelFunctor(const ROMol &query, const ROMol &mol,
1065//                      const SubstructMatchParameters &ps)
1066//         : d_query(query), d_mol(mol), d_params(ps) {};
1067//     bool operator()(MolGraph::edge_descriptor i,
1068//                     MolGraph::edge_descriptor j) const {
1069//       if (d_params.useChirality) {
1070//         const Bond *qBnd = d_query[i];
1071//         if (qBnd->getBondType() == Bond::DOUBLE &&
1072//             qBnd->getStereo() > Bond::STEREOANY) {
1073//           const Bond *mBnd = d_mol[j];
1074//           if (mBnd->getBondType() == Bond::DOUBLE &&
1075//               !d_params.specifiedStereoQueryMatchesUnspecified &&
1076//               mBnd->getStereo() <= Bond::STEREOANY) {
1077//             return false;
1078//           }
1079//         }
1080//       }
1081//       bool res = bondCompat(d_query[i], d_mol[j], d_params);
1082//       return res;
1083//     }
1084//    private:
1085//     const ROMol &d_query;
1086//     const ROMol &d_mol;
1087//     const SubstructMatchParameters &d_params;
1088//   };
1089
1090fn rdkit_bond_stereo_is_above_any(stereo: BondStereo) -> bool {
1091    !matches!(stereo, BondStereo::None | BondStereo::Any)
1092}
1093
1094fn bond_label_matches(
1095    query: &Molecule,
1096    mol: &Molecule,
1097    query_index: usize,
1098    mol_index: usize,
1099    params: &SubstructMatchParams,
1100    query_ctx: &QueryMatchContext,
1101) -> bool {
1102    // RDKit✔️✔️: bool operator()(MolGraph::edge_descriptor i,
1103    // RDKit✔️✔️:                 MolGraph::edge_descriptor j) const {
1104    // RDKit✔️✔️:   if (d_params.useChirality) {
1105    // RDKit✔️✔️:     const Bond *qBnd = d_query[i];
1106    // RDKit✔️✔️:     if (qBnd->getBondType() == Bond::DOUBLE &&
1107    // RDKit✔️✔️:         qBnd->getStereo() > Bond::STEREOANY) {
1108    // RDKit✔️✔️:       const Bond *mBnd = d_mol[j];
1109    // RDKit✔️✔️:       if (mBnd->getBondType() == Bond::DOUBLE &&
1110    // RDKit✔️✔️:           !d_params.specifiedStereoQueryMatchesUnspecified &&
1111    // RDKit✔️✔️:           mBnd->getStereo() <= Bond::STEREOANY) {
1112    // RDKit✔️✔️:         return false;
1113    // RDKit✔️✔️:       }
1114    // RDKit✔️✔️:     }
1115    // RDKit✔️✔️:   }
1116    // RDKit✔️✔️:   bool res = bondCompat(d_query[i], d_mol[j], d_params);
1117    // RDKit✔️✔️:   return res;
1118    // RDKit✔️✔️: }
1119    // Complexity review: the stereo precheck is O(1), then this delegates
1120    // exactly once to canonical bond_compat. It adds no allocation, scan,
1121    // cloning, or repeated query evaluation beyond the source functor.
1122    let query_bond = &query.bonds()[query_index];
1123    let mol_bond = &mol.bonds()[mol_index];
1124    if params.use_chirality
1125        && query_bond.order() == BondOrder::Double
1126        && rdkit_bond_stereo_is_above_any(query_bond.stereo())
1127        && mol_bond.order() == BondOrder::Double
1128        && !params.specified_stereo_query_matches_unspecified
1129        && !rdkit_bond_stereo_is_above_any(mol_bond.stereo())
1130    {
1131        return false;
1132    }
1133    bond_compat(query_bond, query, mol_bond, mol, params, query_ctx)
1134}
1135
1136/// RDKit❗✔️: Evaluation of a bond query node for the currently modeled SMARTS
1137/// bond predicate subset.
1138fn evaluate_bond_query(
1139    query: &crate::QueryNode<BondQueryPredicate>,
1140    bond: &Bond,
1141    mol: &Molecule,
1142    query_ctx: &QueryMatchContext,
1143) -> bool {
1144    match query {
1145        crate::QueryNode::Predicate(pred) => {
1146            bond_predicate_matches_with_context(bond, pred, mol, query_ctx)
1147        }
1148        crate::QueryNode::And(children) => and_query_match(children, false, |child| {
1149            evaluate_bond_query(child, bond, mol, query_ctx)
1150        }),
1151        crate::QueryNode::Or(children) => or_query_match(children, false, |child| {
1152            evaluate_bond_query(child, bond, mol, query_ctx)
1153        }),
1154        crate::QueryNode::Xor(children) => xor_query_match(children, false, |child| {
1155            evaluate_bond_query(child, bond, mol, query_ctx)
1156        }),
1157        crate::QueryNode::Not(child) => !evaluate_bond_query(child, bond, mol, query_ctx),
1158    }
1159}
1160
1161// ---------------------------------------------------------------------------
1162// VF2 State Machine
1163// ---------------------------------------------------------------------------
1164//
1165// ## RDKit source reproduction: vf2.hpp
1166//
1167// The following section reproduces the VF2SubState class from vf2.hpp.
1168// The C++ code is shown as verbatim comments with RDKit markers.
1169//
1170// ### Key design differences from RDKit:
1171//
1172// 1. `core_1`/`core_2`: Same role — mapping from query atom idx → mol atom idx
1173//    and vice versa. Uses `Option<usize>` instead of NULL_NODE sentinel.
1174//
1175// 2. `term_1`/`term_2`: Stores the core_len *depth* at which each atom was
1176//    added to the terminal set, exactly as in vf2.hpp. BackTrack decrements
1177//    counters keyed by depth, not recomputes from scratch.
1178//
1179// 3. No shared_ptr copy semantics: VF2SubState in RDKit uses COW with
1180//    `share_count`. Rust's Clone+Vf2State avoids raw pointer sharing.
1181//    This means each VF2 recursive branch owns its state, which is
1182//    semantically correct but allocates O(depth * n) instead of
1183//    O(n) shared storage. For typical molecule sizes (<1000 atoms) this
1184//    is negligible; for very large searches the COW approach could be
1185//    reinstated with Arc<Vec<NodeId>>.
1186//
1187// 4. No boost graph: hand-rolled Vf2Graph adjacency.
1188
1189// RDKit source (vf2.hpp):
1190//   typedef std::uint32_t node_id;
1191//   const node_id NULL_NODE = 0xFFFFFFFF;
1192
1193type NodeId = usize;
1194const NULL_NODE: NodeId = usize::MAX;
1195
1196// RDKit source (vf2.hpp):
1197//   template <class Graph>
1198//   struct Pair {
1199//     node_id n1, n2;
1200//     bool hasiter{false};
1201//     RDK_ADJ_ITER nbrbeg, nbrend;
1202//     Pair() : n1(NULL_NODE), n2(NULL_NODE) {}
1203//   };
1204
1205#[derive(Debug, Clone)]
1206struct Vf2Pair {
1207    n1: NodeId,
1208    n2: NodeId,
1209    hasiter: bool,
1210    /// VF2+ source atom in the mol graph (g2) whose adjacency drives the
1211    /// neighbor iterator.
1212    nbr_node: NodeId,
1213    /// VF2+ neighbor iterator over mol graph (g2) neighbors.
1214    nbr_cursor: usize,
1215    nbr_end: usize,
1216}
1217
1218impl Vf2Pair {
1219    fn new() -> Self {
1220        Self {
1221            n1: NULL_NODE,
1222            n2: NULL_NODE,
1223            hasiter: false,
1224            nbr_node: NULL_NODE,
1225            nbr_cursor: 0,
1226            nbr_end: 0,
1227        }
1228    }
1229}
1230
1231#[derive(Debug, Clone, Copy)]
1232struct NodeInfo {
1233    id: usize,
1234    in_deg: usize,
1235    out_deg: usize,
1236}
1237
1238fn node_info_cmp1(a: &NodeInfo, b: &NodeInfo) -> std::cmp::Ordering {
1239    // RDKit✔️✔️: static bool nodeInfoComp1(const NodeInfo &a, const NodeInfo &b) {
1240    // RDKit✔️✔️:   if (a.out < b.out) {
1241    // RDKit✔️✔️:     return true;
1242    // RDKit✔️✔️:   }
1243    // RDKit✔️✔️:   if (a.out > b.out) {
1244    // RDKit✔️✔️:     return false;
1245    // RDKit✔️✔️:   }
1246    // RDKit✔️✔️:   if (a.in < b.in) {
1247    // RDKit✔️✔️:     return true;
1248    // RDKit✔️✔️:   }
1249    // RDKit✔️✔️:   if (a.in > b.in) {
1250    // RDKit✔️✔️:     return false;
1251    // RDKit✔️✔️:   }
1252    // RDKit✔️✔️: return false;
1253    // RDKit✔️✔️: }
1254    // Complexity review: both implementations perform at most two integer
1255    // comparisons in O(1) time without allocation or temporary collections.
1256    a.out_deg
1257        .cmp(&b.out_deg)
1258        .then_with(|| a.in_deg.cmp(&b.in_deg))
1259}
1260
1261fn node_info_cmp2(a: &NodeInfo, b: &NodeInfo) -> std::cmp::Ordering {
1262    // RDKit✔️✔️: static int nodeInfoComp2(const NodeInfo &a, const NodeInfo &b) {
1263    // RDKit✔️✔️:   if (!a.in && b.in) {
1264    // RDKit✔️✔️:     return 1;
1265    // RDKit✔️✔️:   }
1266    // RDKit✔️✔️:   if (a.in && !b.in) {
1267    // RDKit✔️✔️:     return -1;
1268    // RDKit✔️✔️:   }
1269    // RDKit✔️✔️:   if (a.out < b.out) {
1270    // RDKit✔️✔️:     return -1;
1271    // RDKit✔️✔️:   }
1272    // RDKit✔️✔️:   if (a.out > b.out) {
1273    // RDKit✔️✔️:     return 1;
1274    // RDKit✔️✔️:   }
1275    // RDKit✔️✔️:   if (a.in < b.in) {
1276    // RDKit✔️✔️:     return -1;
1277    // RDKit✔️✔️:   }
1278    // RDKit✔️✔️:   if (a.in > b.in) {
1279    // RDKit✔️✔️:     return 1;
1280    // RDKit✔️✔️:   }
1281    // RDKit✔️✔️:   return 0;
1282    // RDKit✔️✔️: }
1283    // Complexity review: both implementations perform a bounded sequence of
1284    // integer comparisons in O(1) time without allocation or cloning.
1285    if a.in_deg == 0 && b.in_deg != 0 {
1286        return std::cmp::Ordering::Greater;
1287    }
1288    if a.in_deg != 0 && b.in_deg == 0 {
1289        return std::cmp::Ordering::Less;
1290    }
1291    a.out_deg
1292        .cmp(&b.out_deg)
1293        .then_with(|| a.in_deg.cmp(&b.in_deg))
1294}
1295
1296// RDKit source (vf2.hpp), SortNodesByFrequency:
1297//   Sorts the nodes of a graphs, returning a heap-allocated vector
1298//   with the node ids in the proper orders.
1299//   The sorting criterion takes into account:
1300//     1 - The number of nodes with the same in/out degree.
1301//     2 - The valence of the nodes.
1302//   The nodes at the beginning of the vector are the most singular,
1303//   from which the matching should start.
1304
1305fn sort_nodes_by_frequency(g: &Vf2Graph) -> Vec<NodeId> {
1306    // RDKit✔️✔️: template <class Graph>
1307    // RDKit✔️✔️: node_id *SortNodesByFrequency(const Graph *g) {
1308    // RDKit✔️✔️:   std::vector<NodeInfo> vect;
1309    // RDKit✔️✔️:   vect.reserve(boost::num_vertices(*g));
1310    // RDKit✔️✔️:   typename Graph::vertex_iterator bNode, eNode;
1311    // RDKit✔️✔️:   boost::tie(bNode, eNode) = boost::vertices(*g);
1312    // RDKit✔️✔️:   while (bNode != eNode) {
1313    // RDKit✔️✔️:     NodeInfo t;
1314    // RDKit✔️✔️:     t.id = vect.size();
1315    // RDKit✔️✔️:     t.in = boost::out_degree(*bNode, *g);  // <- assuming undirected graph
1316    // RDKit✔️✔️:     t.out = boost::out_degree(*bNode, *g);
1317    // RDKit✔️✔️:     vect.push_back(t);
1318    // RDKit✔️✔️:     ++bNode;
1319    // RDKit✔️✔️:   }
1320    // RDKit✔️✔️:   std::sort(vect.begin(), vect.end(), nodeInfoComp1);
1321    let mut vect: Vec<NodeInfo> = (0..g.n_atoms)
1322        .map(|i| {
1323            let deg = g.out_degree(i);
1324            NodeInfo {
1325                id: i,
1326                in_deg: deg,
1327                out_deg: deg,
1328            }
1329        })
1330        .collect();
1331    vect.sort_unstable_by(node_info_cmp1);
1332
1333    // RDKit✔️✔️:   unsigned int run = 1;
1334    // RDKit✔️✔️:   for (unsigned int i = 0; i < vect.size(); i += run) {
1335    // RDKit✔️✔️:     for (run = 1; i + run < vect.size() && vect[i + run].in == vect[i].in &&
1336    // RDKit✔️✔️:                   vect[i + run].out == vect[i].out;
1337    // RDKit✔️✔️:          ++run) {
1338    // RDKit✔️✔️:       ;
1339    // RDKit✔️✔️:     }
1340    // RDKit✔️✔️:     for (unsigned int j = 0; j < run; ++j) {
1341    // RDKit✔️✔️:       vect[i + j].in += vect[i + j].out;
1342    // RDKit✔️✔️:       vect[i + j].out = run;
1343    // RDKit✔️✔️:     }
1344    // RDKit✔️✔️:   }
1345    let mut i = 0;
1346    while i < vect.len() {
1347        let mut run = 1;
1348        while i + run < vect.len()
1349            && vect[i + run].in_deg == vect[i].in_deg
1350            && vect[i + run].out_deg == vect[i].out_deg
1351        {
1352            run += 1;
1353        }
1354        for j in 0..run {
1355            vect[i + j].in_deg += vect[i + j].out_deg; // valence sum
1356            vect[i + j].out_deg = run; // frequency
1357        }
1358        i += run;
1359    }
1360
1361    // RDKit✔️✔️:   std::sort(vect.begin(), vect.end(), nodeInfoComp2);
1362    vect.sort_unstable_by(node_info_cmp2);
1363
1364    // RDKit✔️✔️:   node_id *nodes = new node_id[vect.size()];
1365    // RDKit✔️✔️:   for (unsigned int i = 0; i < vect.size(); ++i) {
1366    // RDKit✔️✔️:     nodes[i] = vect[i].id;
1367    // RDKit✔️✔️:   }
1368    // RDKit✔️✔️:
1369    // RDKit✔️✔️:   return nodes;
1370    // RDKit✔️✔️: }
1371    // Complexity review: both versions allocate O(V) node metadata and an
1372    // O(V) result, perform two O(V log V) unstable sorts, and scan runs in
1373    // O(V). Degree lookup and all loop bodies remain O(1) per visited node.
1374    vect.iter().map(|ni| ni.id).collect()
1375}
1376
1377// RDKit source (vf2.hpp), VF2SubState class:
1378//   template <class Graph, class VertexCompatible, class EdgeCompatible,
1379//             class MatchChecking>
1380//   class VF2SubState {
1381//    private:
1382//     Graph *g1, *g2;
1383//     VertexCompatible &vc;
1384//     EdgeCompatible &ec;
1385//     MatchChecking &mc;
1386//     unsigned int n1, n2;
1387//     unsigned int core_len;
1388//     unsigned int t1_len;
1389//     unsigned int t2_len;  // Core nodes are also counted by these...
1390//     node_id *core_1;
1391//     node_id *core_2;
1392//     node_id *term_1;
1393//     node_id *term_2;
1394//     node_id *order;
1395//     long *share_count;
1396//     int *vs_compared;
1397
1398/// RDKit❗✔️: VF2 subgraph isomorphism state.
1399///
1400/// g1 = query graph, g2 = molecule graph.
1401/// core_1[i] = mapping from query atom i -> mol atom j (or None).
1402/// core_2[j] = mapping from mol atom j -> query atom i (or None).
1403/// term_1[i] = depth (core_len) when atom i entered terminal set (0 = not terminal).
1404/// term_2[j] = same for mol atoms.
1405struct Vf2SubState<'a> {
1406    g1: &'a Vf2Graph,
1407    g2: &'a Vf2Graph,
1408    n1: usize,
1409    n2: usize,
1410    core_len: usize,
1411    t1_len: usize,
1412    t2_len: usize,
1413    core_1: Vec<NodeId>,
1414    core_2: Vec<NodeId>,
1415    term_1: Vec<usize>,
1416    term_2: Vec<usize>,
1417    order: Option<Vec<NodeId>>,
1418}
1419
1420impl<'a> Vf2SubState<'a> {
1421    fn new(g1: &'a Vf2Graph, g2: &'a Vf2Graph, sort_nodes: bool) -> Self {
1422        // RDKit✔️✔️: VF2SubState(Graph *ag1, Graph *ag2, VertexCompatible &avc,
1423        // RDKit✔️✔️:             EdgeCompatible &aec, MatchChecking &amc, bool sortNodes = false)
1424        // RDKit✔️✔️:     : g1(ag1),
1425        // RDKit✔️✔️:       g2(ag2),
1426        // RDKit✔️✔️:       vc(avc),
1427        // RDKit✔️✔️:       ec(aec),
1428        // RDKit✔️✔️:       mc(amc),
1429        // RDKit✔️✔️:       n1(num_vertices(*ag1)),
1430        // RDKit✔️✔️:       n2(num_vertices(*ag2)) {
1431        // RDKit✔️✔️:   if (sortNodes) {
1432        // RDKit✔️✔️:     order = SortNodesByFrequency(ag1);
1433        // RDKit✔️✔️:   } else {
1434        // RDKit✔️✔️:     order = nullptr;
1435        // RDKit✔️✔️:   }
1436        // RDKit✔️✔️:
1437        // RDKit✔️✔️:   core_len = 0;
1438        // RDKit✔️✔️:   t1_len = 0;
1439        // RDKit✔️✔️:   t2_len = 0;
1440        // RDKit✔️✔️:
1441        // RDKit✔️✔️:   core_1 = new node_id[n1];
1442        // RDKit✔️✔️:   core_2 = new node_id[n2];
1443        // RDKit✔️✔️:   term_1 = new node_id[n1];
1444        // RDKit✔️✔️:   term_2 = new node_id[n2];
1445        // RDKit✔️✔️:   share_count = new long;
1446        // RDKit✔️✔️:
1447        // RDKit✔️✔️:   for (unsigned int i = 0; i < n1; i++) {
1448        // RDKit✔️✔️:     core_1[i] = NULL_NODE;
1449        // RDKit✔️✔️:     term_1[i] = 0;
1450        // RDKit✔️✔️:   }
1451        // RDKit✔️✔️:   for (unsigned int i = 0; i < n2; i++) {
1452        // RDKit✔️✔️:     core_2[i] = NULL_NODE;
1453        // RDKit✔️✔️:     term_2[i] = 0;
1454        // RDKit✔️✔️:   }
1455        // RDKit✔️✔️:   vs_compared = nullptr;
1456        // RDKit✔️✔️:   // vs_compared = new int[n1*n2];
1457        // RDKit✔️✔️:   // memset((void *)vs_compared,0,n1*n2*sizeof(int));
1458        // RDKit✔️✔️:
1459        // RDKit✔️✔️:   // es_compared = new std::map<unsigned int,bool>();
1460        // RDKit✔️✔️:   *share_count = 1;
1461        // RDKit✔️✔️: }
1462        // The compatibility functors remain explicit arguments to Rust match
1463        // methods, so the state stores only the source fields those methods use.
1464        // Complexity review: both implementations initialize four O(V) arrays
1465        // and optionally run the same O(V log V) ordering routine. Vec uses the
1466        // same contiguous storage and does not add asymptotic or hot-path work.
1467        let n1 = g1.n_atoms;
1468        let n2 = g2.n_atoms;
1469        let order = if sort_nodes {
1470            Some(sort_nodes_by_frequency(g1))
1471        } else {
1472            None
1473        };
1474
1475        // RDKit✔️✔️: core_len = 0; t1_len = 0; t2_len = 0;
1476        // RDKit✔️✔️: core_1[i] = NULL_NODE; term_1[i] = 0;
1477        // RDKit✔️✔️: core_2[j] = NULL_NODE; term_2[j] = 0;
1478        Self {
1479            g1,
1480            g2,
1481            n1,
1482            n2,
1483            core_len: 0,
1484            t1_len: 0,
1485            t2_len: 0,
1486            core_1: vec![NULL_NODE; n1],
1487            core_2: vec![NULL_NODE; n2],
1488            term_1: vec![0usize; n1],
1489            term_2: vec![0usize; n2],
1490            order,
1491        }
1492    }
1493
1494    fn clone_state(&self) -> Self {
1495        // RDKit✔️❌: VF2SubState(const VF2SubState &state)
1496        // RDKit✔️❌:     : g1(state.g1),
1497        // RDKit✔️❌:       g2(state.g2),
1498        // RDKit✔️❌:       vc(state.vc),
1499        // RDKit✔️❌:       ec(state.ec),
1500        // RDKit✔️❌:       mc(state.mc),
1501        // RDKit✔️❌:       n1(state.n1),
1502        // RDKit✔️❌:       n2(state.n2),
1503        // RDKit✔️❌:       order(state.order),
1504        // RDKit✔️❌:       vs_compared(state.vs_compared)
1505        // RDKit✔️❌:   // es_compared(state.es_compared)
1506        // RDKit✔️❌: {
1507        // RDKit✔️❌:   core_len = state.core_len;
1508        // RDKit✔️❌:   t1_len = state.t1_len;
1509        // RDKit✔️❌:   t2_len = state.t2_len;
1510        // RDKit✔️❌:
1511        // RDKit✔️❌:   core_1 = state.core_1;
1512        // RDKit✔️❌:   core_2 = state.core_2;
1513        // RDKit✔️❌:   term_1 = state.term_1;
1514        // RDKit✔️❌:   term_2 = state.term_2;
1515        // RDKit✔️❌:   share_count = state.share_count;
1516        // RDKit✔️❌:
1517        // RDKit✔️❌:   ++(*share_count);
1518        // RDKit✔️❌: }
1519        // Compatibility callbacks are passed to Rust match calls rather than
1520        // stored in the state. Deep-copying Vec state preserves the copied
1521        // values and makes subsequent mutation independent. Complexity review:
1522        // this is O(V) with five allocations, while RDKit shares the arrays and
1523        // increments one reference count in O(1).
1524        Self {
1525            g1: self.g1,
1526            g2: self.g2,
1527            n1: self.n1,
1528            n2: self.n2,
1529            core_len: self.core_len,
1530            t1_len: self.t1_len,
1531            t2_len: self.t2_len,
1532            core_1: self.core_1.clone(),
1533            core_2: self.core_2.clone(),
1534            term_1: self.term_1.clone(),
1535            term_2: self.term_2.clone(),
1536            order: self.order.clone(),
1537        }
1538    }
1539
1540    fn clone(&self) -> Self {
1541        // RDKit✔️❌: VF2SubState *Clone() { return new VF2SubState(*this); }
1542        // Complexity review: this forwards to the single O(V) Rust state-copy
1543        // implementation, while RDKit's shared-array copy is O(1). No second
1544        // clone path is introduced.
1545        self.clone_state()
1546    }
1547
1548    fn debug_order(&self) -> Option<&[NodeId]> {
1549        self.order.as_deref()
1550    }
1551
1552    fn is_goal(&self) -> bool {
1553        // RDKit✔️✔️: bool IsGoal() { return core_len == n1; }
1554        // Complexity review: one integer equality in O(1), without allocation.
1555        self.core_len == self.n1
1556    }
1557
1558    fn match_checks(
1559        &self,
1560        c1: &[NodeId],
1561        c2: &[NodeId],
1562        check: &mut impl FnMut(&[NodeId], &[NodeId]) -> bool,
1563    ) -> bool {
1564        // RDKit✔️✔️: bool MatchChecks(const node_id c1[], const node_id c2[]) {
1565        // RDKit✔️✔️:   return mc(c1, c2);
1566        // RDKit✔️✔️: }
1567        // Complexity review: both forms make one callback invocation and pass
1568        // existing mapping storage by reference without allocation or cloning.
1569        check(c1, c2)
1570    }
1571
1572    fn is_dead(&self) -> bool {
1573        // RDKit✔️✔️: bool IsDead() { return n1 > n2 || t1_len > t2_len; }
1574        // Complexity review: at most two integer comparisons in O(1), without
1575        // allocation or temporary collections.
1576        self.n1 > self.n2 || self.t1_len > self.t2_len
1577    }
1578
1579    fn core_len(&self) -> usize {
1580        // RDKit✔️✔️: unsigned int CoreLen() { return core_len; }
1581        // Complexity review: one field read in O(1), without allocation.
1582        self.core_len
1583    }
1584
1585    // RDKit source (vf2.hpp):
1586    //   bool NextPair(Pair<Graph> &pair) {
1587    //     if (pair.n1 == NULL_NODE) { pair.n1 = 0; }
1588    //     if (pair.n2 == NULL_NODE) { pair.n2 = 0; }
1589    //     else { pair.n2++; }
1590    //     ...
1591    //     if (t1_len > core_len && t2_len > core_len) {
1592    //       while (pair.n1 < n1 &&
1593    //              (core_1[pair.n1] != NULL_NODE || term_1[pair.n1] == 0)) {
1594    //         pair.n1++; pair.n2 = 0;
1595    //       }
1596    //       ...
1597    //     } else if (pair.n1 == 0 && order != nullptr) {
1598    //       // Optimisation: ...
1599    //       unsigned int i = 0;
1600    //       while (i < n1 && core_1[pair.n1 = order[i]] != NULL_NODE) { i++; }
1601    //       ...
1602    //     } else {
1603    //       while (pair.n1 < n1 && core_1[pair.n1] != NULL_NODE) {
1604    //         pair.n1++; pair.n2 = 0;
1605    //       }
1606    //     }
1607    //     // VF2 Plus iterator ...
1608    //     if (pair.hasiter) { ... }
1609    //     else if (t1_len > core_len && t2_len > core_len) {
1610    //       while (pair.n2 < n2 &&
1611    //              (core_2[pair.n2] != NULL_NODE || term_2[pair.n2] == 0)) {
1612    //         pair.n2++;
1613    //       }
1614    //     } else {
1615    //       while (pair.n2 < n2 && core_2[pair.n2] != NULL_NODE) { pair.n2++; }
1616    //     }
1617    //     return pair.n1 < n1 && pair.n2 < n2;
1618    //   }
1619
1620    /// RDKit✔️❌: NextPair — find the next candidate pair (n1 from query,
1621    ///   n2 from mol) to try matching.
1622    ///
1623    /// Uses terminal-set-based iteration from vf2.hpp, including the VF2+
1624    /// neighbor iterator that restricts mol-side candidates to neighbors of
1625    /// the already-mapped terminal predecessor.
1626    fn next_pair(&self, pair: &mut Vf2Pair) -> bool {
1627        // RDKit✔️✔️: bool NextPair(Pair<Graph> &pair) {
1628        // RDKit✔️✔️:   if (pair.n1 == NULL_NODE) {
1629        // RDKit✔️✔️:     pair.n1 = 0;
1630        // RDKit✔️✔️:   }
1631        // RDKit✔️✔️:   if (pair.n2 == NULL_NODE) {
1632        // RDKit✔️✔️:     pair.n2 = 0;
1633        // RDKit✔️✔️:   } else {
1634        // RDKit✔️✔️:     pair.n2++;
1635        // RDKit✔️✔️:   }
1636        // RDKit✔️✔️:
1637        // RDKit✔️✔️: #if 0
1638        // RDKit✔️✔️:   std::cerr<<" **** np: "<< prev_n1<<","<<prev_n2<<std::endl;
1639        // RDKit✔️✔️:   std::cerr<<"in_1 ";
1640        // RDKit✔️✔️:   for(unsigned int i=0;i<n1;++i){
1641        // RDKit✔️✔️:     std::cerr<<"("<<in_1[i]<<","<<out_1[i]<<"), ";
1642        // RDKit✔️✔️:   }
1643        // RDKit✔️✔️:   std::cerr<<std::endl;
1644        // RDKit✔️✔️:   std::cerr<<"in_2 ";
1645        // RDKit✔️✔️:   for(unsigned int i=0;i<n2;++i){
1646        // RDKit✔️✔️:     std::cerr<<"("<<in_2[i]<<","<<out_2[i]<<"), ";
1647        // RDKit✔️✔️:   }
1648        // RDKit✔️✔️:   std::cerr<<std::endl;
1649        // RDKit✔️✔️: #endif
1650        // RDKit✔️✔️:   if (t1_len > core_len && t2_len > core_len) {
1651        // RDKit✔️✔️:     while (pair.n1 < n1 &&
1652        // RDKit✔️✔️:            (core_1[pair.n1] != NULL_NODE || term_1[pair.n1] == 0)) {
1653        // RDKit✔️✔️:       pair.n1++;
1654        // RDKit✔️✔️:       pair.n2 = 0;
1655        // RDKit✔️✔️:     }
1656        // RDKit✔️✔️:
1657        // RDKit✔️✔️:     /* Initialize VF2 Plus neighbor iterator.
1658        // RDKit✔️✔️:      * The next query node (pair.n1) has been selected from the terminal
1659        // RDKit✔️✔️:      * set and is therefore adjacent to an already mapped atom (in
1660        // RDKit✔️✔️:      * core_1). Rather than select pair.n2 from all atoms (0...n2) we can
1661        // RDKit✔️✔️:      * select it from the neighbors of this mapped atom (0...deg(nbor))
1662        // RDKit✔️✔️:      * since it must also be adajcent to this mapped atom!
1663        // RDKit✔️✔️:      */
1664        // RDKit✔️✔️:     if (!pair.hasiter) {
1665        // RDKit✔️✔️:       RDK_ADJ_ITER n1iter_beg, n1iter_end;
1666        // RDKit✔️✔️:       boost::tie(n1iter_beg, n1iter_end) =
1667        // RDKit✔️✔️:           boost::adjacent_vertices(pair.n1, *g1);
1668        // RDKit✔️✔️:
1669        // RDKit✔️✔️:       while (n1iter_beg != n1iter_end && core_1[*n1iter_beg] == NULL_NODE) {
1670        // RDKit✔️✔️:         ++n1iter_beg;
1671        // RDKit✔️✔️:       }
1672        // RDKit✔️✔️:
1673        // RDKit✔️✔️:       assert(n1iter_beg != n1iter_end);
1674        // RDKit✔️✔️:
1675        // RDKit✔️✔️:       boost::tie(pair.nbrbeg, pair.nbrend) =
1676        // RDKit✔️✔️:           boost::adjacent_vertices(core_1[*n1iter_beg], *g2);
1677        // RDKit✔️✔️:       pair.hasiter = true;
1678        // RDKit✔️✔️:     }
1679        // RDKit✔️✔️:   } else if (pair.n1 == 0 && order != nullptr) {
1680        // RDKit✔️✔️:     // Optimisation: if the order vector is laid out in a DFS/BFS then this
1681        // RDKit✔️✔️:     // loop can be replaced with:
1682        // RDKit✔️✔️:     //   pair.n1=order[core_len];
1683        // RDKit✔️✔️:     // :)
1684        // RDKit✔️✔️:     unsigned int i = 0;
1685        // RDKit✔️✔️:     while (i < n1 && core_1[pair.n1 = order[i]] != NULL_NODE) {
1686        // RDKit✔️✔️:       i++;
1687        // RDKit✔️✔️:     }
1688        // RDKit✔️✔️:     if (i == n1) {
1689        // RDKit✔️✔️:       pair.n1 = n1;
1690        // RDKit✔️✔️:     }
1691        // RDKit✔️✔️:   } else {
1692        // RDKit✔️✔️:     while (pair.n1 < n1 && core_1[pair.n1] != NULL_NODE) {
1693        // RDKit✔️✔️:       pair.n1++;
1694        // RDKit✔️✔️:       pair.n2 = 0;
1695        // RDKit✔️✔️:     }
1696        // RDKit✔️✔️:   }
1697        // RDKit✔️✔️:
1698        // RDKit✔️✔️:   /* VF2 Plus iterator available? */
1699        // RDKit✔️✔️:   if (pair.hasiter) {
1700        // RDKit✔️✔️:     while (pair.nbrbeg < pair.nbrend && core_2[*pair.nbrbeg] != NULL_NODE) {
1701        // RDKit✔️✔️:       ++pair.nbrbeg;
1702        // RDKit✔️✔️:     }
1703        // RDKit✔️✔️:
1704        // RDKit✔️✔️:     if (pair.nbrbeg < pair.nbrend) {
1705        // RDKit✔️✔️:       pair.n2 = *pair.nbrbeg;
1706        // RDKit✔️✔️:       ++pair.nbrbeg;
1707        // RDKit✔️✔️:     } else {
1708        // RDKit✔️✔️:       pair.n2 = n2;
1709        // RDKit✔️✔️:     }
1710        // RDKit✔️✔️:   } else if (t1_len > core_len && t2_len > core_len) {
1711        // RDKit✔️✔️:     while (pair.n2 < n2 &&
1712        // RDKit✔️✔️:            (core_2[pair.n2] != NULL_NODE || term_2[pair.n2] == 0)) {
1713        // RDKit✔️✔️:       pair.n2++;
1714        // RDKit✔️✔️:     }
1715        // RDKit✔️✔️:   } else {
1716        // RDKit✔️✔️:     while (pair.n2 < n2 && core_2[pair.n2] != NULL_NODE) {
1717        // RDKit✔️✔️:       pair.n2++;
1718        // RDKit✔️✔️:     }
1719        // RDKit✔️✔️:   }
1720        // RDKit✔️✔️:   return pair.n1 < n1 && pair.n2 < n2;
1721        // RDKit✔️✔️: }
1722        // Complexity review: both versions scan at most O(V) unmapped nodes
1723        // outside the terminal branch and O(degree) adjacency entries in the
1724        // VF2+ branch, with no allocation per candidate pair.
1725        // RDKit✔️✔️: if (pair.n1 == NULL_NODE) pair.n1 = 0;
1726        // RDKit✔️✔️: if (pair.n2 == NULL_NODE) pair.n2 = 0;
1727        // RDKit✔️✔️: else pair.n2++;
1728        if pair.n1 == NULL_NODE {
1729            pair.n1 = 0;
1730        }
1731        if pair.n2 == NULL_NODE {
1732            pair.n2 = 0;
1733        } else {
1734            pair.n2 += 1;
1735        }
1736
1737        // --- Select query node (n1) ---
1738        // RDKit✔️✔️: if (t1_len > core_len && t2_len > core_len) {
1739        if self.t1_len > self.core_len && self.t2_len > self.core_len {
1740            // RDKit✔️✔️: while (pair.n1 < n1 &&
1741            // RDKit✔️✔️:   (core_1[pair.n1] != NULL_NODE || term_1[pair.n1] == 0)) {
1742            // RDKit✔️✔️:   pair.n1++; pair.n2 = 0;
1743            // RDKit✔️✔️: }
1744            while pair.n1 < self.n1
1745                && (self.core_1[pair.n1] != NULL_NODE || self.term_1[pair.n1] == 0)
1746            {
1747                pair.n1 += 1;
1748                pair.n2 = 0;
1749            }
1750            // RDKit✔️✔️: /* Initialize VF2 Plus neighbor iterator.
1751            // RDKit✔️✔️:  * The next query node (pair.n1) has been selected from the terminal
1752            // RDKit✔️✔️:  * set and is therefore adjacent to an already mapped atom (in
1753            // RDKit✔️✔️:  * core_1). Rather than select pair.n2 from all atoms (0...n2) we can
1754            // RDKit✔️✔️:  * select it from the neighbors of this mapped atom (0...deg(nbor))
1755            // RDKit✔️✔️:  * since it must also be adajcent to this mapped atom!
1756            // RDKit✔️✔️:  */
1757            // RDKit✔️✔️: if (!pair.hasiter) {
1758            // RDKit✔️✔️:   boost::tie(n1iter_beg, n1iter_end) =
1759            // RDKit✔️✔️:       boost::adjacent_vertices(pair.n1, *g1);
1760            // RDKit✔️✔️:   while (n1iter_beg != n1iter_end && core_1[*n1iter_beg] == NULL_NODE) {
1761            // RDKit✔️✔️:     ++n1iter_beg;
1762            // RDKit✔️✔️:   }
1763            // RDKit✔️✔️:   assert(n1iter_beg != n1iter_end);
1764            // RDKit✔️✔️:   boost::tie(pair.nbrbeg, pair.nbrend) =
1765            // RDKit✔️✔️:       boost::adjacent_vertices(core_1[*n1iter_beg], *g2);
1766            // RDKit✔️✔️:   pair.hasiter = true;
1767            // RDKit✔️✔️: }
1768            if !pair.hasiter {
1769                let mut mapped_terminal_neighbor = NULL_NODE;
1770                for &(query_neighbor, _) in self.g1.out_edges(pair.n1) {
1771                    if self.core_1[query_neighbor] != NULL_NODE {
1772                        mapped_terminal_neighbor = self.core_1[query_neighbor];
1773                        break;
1774                    }
1775                }
1776                debug_assert_ne!(mapped_terminal_neighbor, NULL_NODE);
1777                if mapped_terminal_neighbor != NULL_NODE {
1778                    pair.nbr_node = mapped_terminal_neighbor;
1779                    pair.nbr_cursor = 0;
1780                    pair.nbr_end = self.g2.out_edges(mapped_terminal_neighbor).len();
1781                    pair.hasiter = true;
1782                }
1783            }
1784        } else if pair.n1 == 0 {
1785            // RDKit✔️✔️: } else if (pair.n1 == 0 && order != nullptr) {
1786            if let Some(order) = &self.order {
1787                // RDKit✔️✔️:   unsigned int i = 0;
1788                // RDKit✔️✔️:   while (i < n1 && core_1[pair.n1 = order[i]] != NULL_NODE) { i++; }
1789                // RDKit✔️✔️:   if (i == n1) pair.n1 = n1;
1790                let mut i = 0;
1791                while i < self.n1 {
1792                    let candidate = order[i];
1793                    if self.core_1[candidate] == NULL_NODE {
1794                        pair.n1 = candidate;
1795                        break;
1796                    }
1797                    i += 1;
1798                }
1799                if i == self.n1 {
1800                    pair.n1 = self.n1;
1801                }
1802            } else {
1803                // RDKit✔️✔️: } else {
1804                // RDKit✔️✔️:   while (pair.n1 < n1 && core_1[pair.n1] != NULL_NODE) {
1805                // RDKit✔️✔️:     pair.n1++; pair.n2 = 0;
1806                // RDKit✔️✔️:   }
1807                while pair.n1 < self.n1 && self.core_1[pair.n1] != NULL_NODE {
1808                    pair.n1 += 1;
1809                    pair.n2 = 0;
1810                }
1811            }
1812        } else {
1813            // RDKit✔️✔️: } else {
1814            // RDKit✔️✔️:   while (pair.n1 < n1 && core_1[pair.n1] != NULL_NODE) {
1815            // RDKit✔️✔️:     pair.n1++; pair.n2 = 0;
1816            // RDKit✔️✔️:   }
1817            while pair.n1 < self.n1 && self.core_1[pair.n1] != NULL_NODE {
1818                pair.n1 += 1;
1819                pair.n2 = 0;
1820            }
1821        }
1822
1823        // --- Select mol node (n2) ---
1824        // RDKit✔️✔️: if (pair.hasiter) { ... }
1825        if pair.hasiter {
1826            // RDKit✔️✔️: while (pair.nbrbeg < pair.nbrend && core_2[*pair.nbrbeg] != NULL_NODE) {
1827            // RDKit✔️✔️:   ++pair.nbrbeg;
1828            // RDKit✔️✔️: }
1829            let neighbors = self.g2.out_edges(pair.nbr_node);
1830            while pair.nbr_cursor < pair.nbr_end
1831                && self.core_2[neighbors[pair.nbr_cursor].0] != NULL_NODE
1832            {
1833                pair.nbr_cursor += 1;
1834            }
1835            // RDKit✔️✔️: if (pair.nbrbeg < pair.nbrend) {
1836            // RDKit✔️✔️:   pair.n2 = *pair.nbrbeg;
1837            // RDKit✔️✔️:   ++pair.nbrbeg;
1838            // RDKit✔️✔️: } else {
1839            // RDKit✔️✔️:   pair.n2 = n2;
1840            // RDKit✔️✔️: }
1841            if pair.nbr_cursor < pair.nbr_end {
1842                pair.n2 = neighbors[pair.nbr_cursor].0;
1843                pair.nbr_cursor += 1;
1844            } else {
1845                pair.n2 = self.n2;
1846            }
1847        } else if self.t1_len > self.core_len && self.t2_len > self.core_len {
1848            // RDKit✔️✔️: } else if (t1_len > core_len && t2_len > core_len) {
1849            // RDKit✔️✔️:   while (pair.n2 < n2 &&
1850            // RDKit✔️✔️:     (core_2[pair.n2] != NULL_NODE || term_2[pair.n2] == 0)) {
1851            // RDKit✔️✔️:     pair.n2++;
1852            // RDKit✔️✔️:   }
1853            while pair.n2 < self.n2
1854                && (self.core_2[pair.n2] != NULL_NODE || self.term_2[pair.n2] == 0)
1855            {
1856                pair.n2 += 1;
1857            }
1858        } else {
1859            // RDKit✔️✔️: } else {
1860            // RDKit✔️✔️:   while (pair.n2 < n2 && core_2[pair.n2] != NULL_NODE) { pair.n2++; }
1861            // RDKit✔️✔️: }
1862            while pair.n2 < self.n2 && self.core_2[pair.n2] != NULL_NODE {
1863                pair.n2 += 1;
1864            }
1865        }
1866
1867        // RDKit✔️✔️: return pair.n1 < n1 && pair.n2 < n2;
1868        pair.n1 < self.n1 && pair.n2 < self.n2
1869    }
1870
1871    // RDKit source (vf2.hpp), IsFeasiblePair:
1872    //   bool IsFeasiblePair(node_id node1, node_id node2) {
1873    //     assert(node1 < n1); assert(node2 < n2);
1874    //     assert(core_1[node1] == NULL_NODE); assert(core_2[node2] == NULL_NODE);
1875    //
1876    //     // O(1) check for adjacency list
1877    //     if (boost::out_degree(node1, *g1) > boost::out_degree(node2, *g2)) {
1878    //       return false;
1879    //     }
1880    //     if (!vc(node1, node2)) { return false; }
1881    //
1882    //     unsigned int other1, other2;
1883    //     // Check the out edges of node1
1884    //     typename Graph::out_edge_iterator bNbrs, eNbrs;
1885    //     boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
1886    //     while (bNbrs != eNbrs) {
1887    //       other1 = getOtherIdx(*g1, *bNbrs, node1);
1888    //       if (core_1[other1] != NULL_NODE) {
1889    //         other2 = core_1[other1];
1890    //         typename Graph::edge_descriptor oEdge;
1891    //         bool found;
1892    //         boost::tie(oEdge, found) = boost::edge(node2, other2, *g2);
1893    //         if (!found || !ec(*bNbrs, oEdge)) { return false; }
1894    //       }
1895    //       ++bNbrs;
1896    //     }
1897    //     return true;
1898    //   }
1899
1900    /// RDKit✔️❌: IsFeasiblePair — check if (node1, node2) can be added.
1901    ///
1902    /// Performs degree check, vertex compatibility, and edge compatibility
1903    /// for already-matched neighbors. RDK_VF2_PRUNING (terminal count
1904    /// pre-check) is not enabled — the C++ code also has it behind an
1905    /// ifdef that is not defined at the top of vf2.hpp.
1906    fn is_feasible_pair(
1907        &self,
1908        node1: NodeId,
1909        node2: NodeId,
1910        atom_fn: &impl Fn(usize, usize) -> bool,
1911        bond_fn: &impl Fn(usize, usize) -> bool,
1912    ) -> bool {
1913        // RDKit✔️✔️: bool IsFeasiblePair(node_id node1, node_id node2) {
1914        // RDKit✔️✔️:   assert(node1 < n1);
1915        // RDKit✔️✔️:   assert(node2 < n2);
1916        // RDKit✔️✔️:   assert(core_1[node1] == NULL_NODE);
1917        // RDKit✔️✔️:   assert(core_2[node2] == NULL_NODE);
1918        // RDKit✔️✔️:
1919        // RDKit✔️✔️:   // std::cerr<<"  ifp:"<<node1<<"-"<<node2<<"
1920        // RDKit✔️✔️:   // "<<vs_compared->size()<<std::endl;
1921        // RDKit✔️✔️:   // int &isCompat=vs_compared[node1*n2+node2];
1922        // RDKit✔️✔️:   // if(isCompat==0){
1923        // RDKit✔️✔️:   //   isCompat=vc(node1,node2)?1:-1;
1924        // RDKit✔️✔️:   // }
1925        // RDKit✔️✔️:   // if( isCompat<0 ){
1926        // RDKit✔️✔️:   //   //std::cerr<<"  short1"<<std::endl;
1927        // RDKit✔️✔️:   //   return false;
1928        // RDKit✔️✔️:   // }
1929        // RDKit✔️✔️:
1930        // RDKit✔️✔️:   // O(1) check for adjacency list
1931        // RDKit✔️✔️:   if (boost::out_degree(node1, *g1) > boost::out_degree(node2, *g2)) {
1932        // RDKit✔️✔️:     return false;
1933        // RDKit✔️✔️:   }
1934        // RDKit✔️✔️:   if (!vc(node1, node2)) {
1935        // RDKit✔️✔️:     return false;
1936        // RDKit✔️✔️:   }
1937        // RDKit✔️✔️:
1938        // RDKit✔️✔️:   unsigned int other1, other2;
1939        // RDKit✔️✔️: #ifdef RDK_VF2_PRUNING
1940        // RDKit✔️✔️:   unsigned int term1 = 0, term2 = 0;
1941        // RDKit✔️✔️:   unsigned int new1 = 0, new2 = 0;
1942        // RDKit✔️✔️: #endif
1943        // RDKit✔️✔️:
1944        // RDKit✔️✔️:   // Check the out edges of node1
1945        // RDKit✔️✔️:   typename Graph::out_edge_iterator bNbrs, eNbrs;
1946        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
1947        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
1948        // RDKit✔️✔️:     other1 = getOtherIdx(*g1, *bNbrs, node1);
1949        // RDKit✔️✔️:     if (core_1[other1] != NULL_NODE) {
1950        // RDKit✔️✔️:       other2 = core_1[other1];
1951        // RDKit✔️✔️:       typename Graph::edge_descriptor oEdge;
1952        // RDKit✔️✔️:       bool found;
1953        // RDKit✔️✔️:       boost::tie(oEdge, found) = boost::edge(node2, other2, *g2);
1954        // RDKit✔️✔️:       if (!found || !ec(*bNbrs, oEdge)) {
1955        // RDKit✔️✔️:         // std::cerr<<"  short2"<<std::endl;
1956        // RDKit✔️✔️:         return false;
1957        // RDKit✔️✔️:       }
1958        // RDKit✔️✔️:     }
1959        // RDKit✔️✔️: #ifdef RDK_VF2_PRUNING
1960        // RDKit✔️✔️:     else {
1961        // RDKit✔️✔️:       if (term_1[other1]) ++term1;
1962        // RDKit✔️✔️:       if (!term_1[other1]) ++new1;
1963        // RDKit✔️✔️:     }
1964        // RDKit✔️✔️: #endif
1965        // RDKit✔️✔️:     ++bNbrs;
1966        // RDKit✔️✔️:   }
1967        // RDKit✔️✔️:
1968        // RDKit✔️✔️: #ifdef RDK_VF2_PRUNING
1969        // RDKit✔️✔️:   // Check the out edges of node2
1970        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node2, *g2);
1971        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
1972        // RDKit✔️✔️:     other2 = getOtherIdx(*g2, *bNbrs, node2);
1973        // RDKit✔️✔️:     if (core_2[other2] != NULL_NODE) {
1974        // RDKit✔️✔️:       // do nothing
1975        // RDKit✔️✔️:     } else {
1976        // RDKit✔️✔️:       if (term_2[other2]) ++term2;
1977        // RDKit✔️✔️:       if (!term_2[other2]) ++new2;
1978        // RDKit✔️✔️:     }
1979        // RDKit✔️✔️:     ++bNbrs;
1980        // RDKit✔️✔️:   }
1981        // RDKit✔️✔️:   // std::cerr<<(termin1 <= termin2 && termout1 <= termout2 &&
1982        // RDKit✔️✔️:   // (termin1+termout1+new1)<=(termin2+termout2+new2))<<std::endl;
1983        // RDKit✔️✔️:
1984        // RDKit✔️✔️:   // n.b. term1+new1 == boost::out_degree(node1) and
1985        // RDKit✔️✔️:   //      term2+new2 == boost::out_degree(node2)
1986        // RDKit✔️✔️:   return term1 <= term2 && (term1 + new1) <= (term2 + new2);
1987        // RDKit✔️✔️: #else
1988        // RDKit✔️✔️:   return true;
1989        // RDKit✔️✔️: #endif
1990        // RDKit✔️✔️: }
1991        // Complexity review: both active builds do O(1) degree and vertex
1992        // checks, scan O(degree(node1)) query edges, and perform target edge
1993        // lookup in O(degree(node2)); neither allocates per candidate.
1994        // RDKit✔️✔️: assert(node1 < n1); assert(node2 < n2);
1995        // RDKit✔️✔️: assert(core_1[node1] == NULL_NODE);
1996        // RDKit✔️✔️: assert(core_2[node2] == NULL_NODE);
1997        debug_assert!(node1 < self.n1);
1998        debug_assert!(node2 < self.n2);
1999        debug_assert_eq!(self.core_1[node1], NULL_NODE);
2000        debug_assert_eq!(self.core_2[node2], NULL_NODE);
2001        if self.core_1[node1] != NULL_NODE || self.core_2[node2] != NULL_NODE {
2002            return false;
2003        }
2004
2005        // RDKit✔️✔️: if (boost::out_degree(node1, *g1) > boost::out_degree(node2, *g2)) {
2006        // RDKit✔️✔️:   return false;
2007        // RDKit✔️✔️: }
2008        if self.g1.out_degree(node1) > self.g2.out_degree(node2) {
2009            return false;
2010        }
2011
2012        // RDKit✔️✔️: if (!vc(node1, node2)) { return false; }
2013        if !atom_fn(node1, node2) {
2014            return false;
2015        }
2016
2017        // RDKit✔️✔️: // Check the out edges of node1
2018        // RDKit✔️✔️: boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
2019        // RDKit✔️✔️: while (bNbrs != eNbrs) {
2020        // RDKit✔️✔️:   other1 = getOtherIdx(*g1, *bNbrs, node1);
2021        // RDKit✔️✔️:   if (core_1[other1] != NULL_NODE) {
2022        // RDKit✔️✔️:     other2 = core_1[other1];
2023        // RDKit✔️✔️:     if (!found || !ec(*bNbrs, oEdge)) { return false; }
2024        // RDKit✔️✔️:   }
2025        // RDKit✔️✔️:   ++bNbrs;
2026        // RDKit✔️✔️: }
2027        for &(_, edge_idx1) in self.g1.out_edges(node1) {
2028            let other1 = get_other_idx(self.g1, edge_idx1, node1);
2029            if other1 == node1 {
2030                continue;
2031            }
2032            if self.core_1[other1] != NULL_NODE {
2033                let other2 = self.core_1[other1];
2034                // Check that (node2, other2) has a matching bond.
2035                let bond_found = self.find_bond(node2, other2);
2036                match bond_found {
2037                    Some(edge_idx2) => {
2038                        if !bond_fn(edge_idx1, edge_idx2) {
2039                            return false;
2040                        }
2041                    }
2042                    None => return false,
2043                }
2044            }
2045        }
2046
2047        true
2048    }
2049
2050    /// Find a bond between atom `a` and `b` in the molecule graph (g2).
2051    fn find_bond(&self, a: NodeId, b: NodeId) -> Option<usize> {
2052        for &(nbr, bond_idx) in self.g2.out_edges(a) {
2053            if nbr == b {
2054                return Some(bond_idx);
2055            }
2056        }
2057        None
2058    }
2059
2060    fn add_pair(&mut self, node1: NodeId, node2: NodeId) {
2061        // RDKit✔️✔️: void AddPair(node_id node1, node_id node2) {
2062        // RDKit✔️✔️:   assert(node1 < n1);
2063        // RDKit✔️✔️:   assert(node2 < n2);
2064        // RDKit✔️✔️:   assert(core_len < n1);
2065        // RDKit✔️✔️:   assert(core_len < n2);
2066        // RDKit✔️✔️:
2067        // RDKit✔️✔️:   ++core_len;
2068        // RDKit✔️✔️:   if (!term_1[node1]) {
2069        // RDKit✔️✔️:     term_1[node1] = core_len;
2070        // RDKit✔️✔️:     ++t1_len;
2071        // RDKit✔️✔️:   }
2072        // RDKit✔️✔️:
2073        // RDKit✔️✔️:   if (!term_2[node2]) {
2074        // RDKit✔️✔️:     term_2[node2] = core_len;
2075        // RDKit✔️✔️:     ++t2_len;
2076        // RDKit✔️✔️:   }
2077        // RDKit✔️✔️:
2078        // RDKit✔️✔️:   core_1[node1] = node2;
2079        // RDKit✔️✔️:   core_2[node2] = node1;
2080        // RDKit✔️✔️:
2081        // RDKit✔️✔️:   typename Graph::out_edge_iterator bNbrs, eNbrs;
2082        // RDKit✔️✔️:   // FIX: this is explicitly ignoring directionality
2083        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
2084        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
2085        // RDKit✔️✔️:     unsigned int other = getOtherIdx(*g1, *bNbrs, node1);
2086        // RDKit✔️✔️:     if (!term_1[other]) {
2087        // RDKit✔️✔️:       term_1[other] = core_len;
2088        // RDKit✔️✔️:       ++t1_len;
2089        // RDKit✔️✔️:     }
2090        // RDKit✔️✔️:     ++bNbrs;
2091        // RDKit✔️✔️:   }
2092        // RDKit✔️✔️:
2093        // RDKit✔️✔️:   // FIX: this is explicitly ignoring directionality
2094        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node2, *g2);
2095        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
2096        // RDKit✔️✔️:     unsigned int other = getOtherIdx(*g2, *bNbrs, node2);
2097        // RDKit✔️✔️:     if (!term_2[other]) {
2098        // RDKit✔️✔️:       term_2[other] = core_len;
2099        // RDKit✔️✔️:       ++t2_len;
2100        // RDKit✔️✔️:     }
2101        // RDKit✔️✔️:     ++bNbrs;
2102        // RDKit✔️✔️:   }
2103        // RDKit✔️✔️: }
2104        // Complexity review: both versions update O(1) mapping fields and scan
2105        // each selected node's adjacency once in O(degree1 + degree2), without
2106        // allocation or whole-graph rescanning.
2107        debug_assert!(node1 < self.n1);
2108        debug_assert!(node2 < self.n2);
2109        debug_assert!(self.core_len < self.n1);
2110        debug_assert!(self.core_len < self.n2);
2111        // RDKit✔️✔️: ++core_len;
2112        self.core_len += 1;
2113        let depth = self.core_len;
2114
2115        // RDKit✔️✔️: if (!term_1[node1]) { term_1[node1] = core_len; ++t1_len; }
2116        if self.term_1[node1] == 0 {
2117            self.term_1[node1] = depth;
2118            self.t1_len += 1;
2119        }
2120
2121        // RDKit✔️✔️: if (!term_2[node2]) { term_2[node2] = core_len; ++t2_len; }
2122        if self.term_2[node2] == 0 {
2123            self.term_2[node2] = depth;
2124            self.t2_len += 1;
2125        }
2126
2127        // RDKit✔️✔️: core_1[node1] = node2; core_2[node2] = node1;
2128        self.core_1[node1] = node2;
2129        self.core_2[node2] = node1;
2130
2131        // RDKit✔️✔️: // FIX: explicitly ignoring directionality
2132        // RDKit✔️✔️: boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
2133        // RDKit✔️✔️: while (bNbrs != eNbrs) {
2134        // RDKit✔️✔️:   unsigned int other = getOtherIdx(*g1, *bNbrs, node1);
2135        // RDKit✔️✔️:   if (!term_1[other]) { term_1[other] = core_len; ++t1_len; }
2136        // RDKit✔️✔️:   ++bNbrs;
2137        // RDKit✔️✔️: }
2138        for &(_, edge) in self.g1.out_edges(node1) {
2139            let other = get_other_idx(self.g1, edge, node1);
2140            if other == node1 {
2141                continue;
2142            }
2143            if self.term_1[other] == 0 {
2144                self.term_1[other] = depth;
2145                self.t1_len += 1;
2146            }
2147        }
2148
2149        // RDKit✔️✔️: boost::tie(bNbrs, eNbrs) = boost::out_edges(node2, *g2);
2150        // RDKit✔️✔️: while (bNbrs != eNbrs) {
2151        // RDKit✔️✔️:   unsigned int other = getOtherIdx(*g2, *bNbrs, node2);
2152        // RDKit✔️✔️:   if (!term_2[other]) { term_2[other] = core_len; ++t2_len; }
2153        // RDKit✔️✔️:   ++bNbrs;
2154        // RDKit✔️✔️: }
2155        for &(_, edge) in self.g2.out_edges(node2) {
2156            let other = get_other_idx(self.g2, edge, node2);
2157            if other == node2 {
2158                continue;
2159            }
2160            if self.term_2[other] == 0 {
2161                self.term_2[other] = depth;
2162                self.t2_len += 1;
2163            }
2164        }
2165    }
2166
2167    fn back_track(&mut self, node1: NodeId, node2: NodeId) {
2168        // RDKit✔️✔️: void BackTrack(node_id node1, node_id node2) {
2169        // RDKit✔️✔️:   if (term_1[node1] == core_len) {
2170        // RDKit✔️✔️:     term_1[node1] = 0;
2171        // RDKit✔️✔️:     --t1_len;
2172        // RDKit✔️✔️:   }
2173        // RDKit✔️✔️:
2174        // RDKit✔️✔️:   typename Graph::out_edge_iterator bNbrs, eNbrs;
2175        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
2176        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
2177        // RDKit✔️✔️:     unsigned int other = getOtherIdx(*g1, *bNbrs, node1);
2178        // RDKit✔️✔️:     if (term_1[other] == core_len) {
2179        // RDKit✔️✔️:       term_1[other] = 0;
2180        // RDKit✔️✔️:       --t1_len;
2181        // RDKit✔️✔️:     }
2182        // RDKit✔️✔️:     ++bNbrs;
2183        // RDKit✔️✔️:   }
2184        // RDKit✔️✔️:
2185        // RDKit✔️✔️:   if (term_2[node2] == core_len) {
2186        // RDKit✔️✔️:     term_2[node2] = 0;
2187        // RDKit✔️✔️:     --t2_len;
2188        // RDKit✔️✔️:   }
2189        // RDKit✔️✔️:
2190        // RDKit✔️✔️:   boost::tie(bNbrs, eNbrs) = boost::out_edges(node2, *g2);
2191        // RDKit✔️✔️:   while (bNbrs != eNbrs) {
2192        // RDKit✔️✔️:     unsigned int other = getOtherIdx(*g2, *bNbrs, node2);
2193        // RDKit✔️✔️:     if (term_2[other] == core_len) {
2194        // RDKit✔️✔️:       term_2[other] = 0;
2195        // RDKit✔️✔️:       --t2_len;
2196        // RDKit✔️✔️:     }
2197        // RDKit✔️✔️:     ++bNbrs;
2198        // RDKit✔️✔️:   }
2199        // RDKit✔️✔️:
2200        // RDKit✔️✔️:   core_1[node1] = NULL_NODE;
2201        // RDKit✔️✔️:   core_2[node2] = NULL_NODE;
2202        // RDKit✔️✔️:   --core_len;
2203        // RDKit✔️✔️: }
2204        // Complexity review: both versions scan each removed node's adjacency
2205        // once in O(degree1 + degree2), mutate depth-tagged entries in place,
2206        // and allocate no temporary collections.
2207        let depth = self.core_len;
2208
2209        // RDKit✔️✔️: if (term_1[node1] == core_len) { term_1[node1] = 0; --t1_len; }
2210        if self.term_1[node1] == depth {
2211            self.term_1[node1] = 0;
2212            self.t1_len -= 1;
2213        }
2214
2215        // RDKit✔️✔️: boost::tie(bNbrs, eNbrs) = boost::out_edges(node1, *g1);
2216        // RDKit✔️✔️: while (bNbrs != eNbrs) {
2217        // RDKit✔️✔️:   unsigned int other = getOtherIdx(*g1, *bNbrs, node1);
2218        // RDKit✔️✔️:   if (term_1[other] == core_len) { term_1[other] = 0; --t1_len; }
2219        // RDKit✔️✔️:   ++bNbrs;
2220        // RDKit✔️✔️: }
2221        for &(_, edge) in self.g1.out_edges(node1) {
2222            let other = get_other_idx(self.g1, edge, node1);
2223            if other == node1 {
2224                continue;
2225            }
2226            if self.term_1[other] == depth {
2227                self.term_1[other] = 0;
2228                self.t1_len -= 1;
2229            }
2230        }
2231
2232        // RDKit✔️✔️: if (term_2[node2] == core_len) { term_2[node2] = 0; --t2_len; }
2233        if self.term_2[node2] == depth {
2234            self.term_2[node2] = 0;
2235            self.t2_len -= 1;
2236        }
2237
2238        // RDKit✔️✔️: boost::tie(bNbrs, eNbrs) = boost::out_edges(node2, *g2);
2239        // RDKit✔️✔️: while (bNbrs != eNbrs) {
2240        // RDKit✔️✔️:   unsigned int other = getOtherIdx(*g2, *bNbrs, node2);
2241        // RDKit✔️✔️:   if (term_2[other] == core_len) { term_2[other] = 0; --t2_len; }
2242        // RDKit✔️✔️:   ++bNbrs;
2243        // RDKit✔️✔️: }
2244        for &(_, edge) in self.g2.out_edges(node2) {
2245            let other = get_other_idx(self.g2, edge, node2);
2246            if other == node2 {
2247                continue;
2248            }
2249            if self.term_2[other] == depth {
2250                self.term_2[other] = 0;
2251                self.t2_len -= 1;
2252            }
2253        }
2254
2255        // RDKit✔️✔️: core_1[node1] = NULL_NODE;
2256        // RDKit✔️✔️: core_2[node2] = NULL_NODE;
2257        // RDKit✔️✔️: --core_len;
2258        self.core_1[node1] = NULL_NODE;
2259        self.core_2[node2] = NULL_NODE;
2260        self.core_len -= 1;
2261    }
2262
2263    fn get_core_set(&self) -> (Vec<NodeId>, Vec<NodeId>) {
2264        // RDKit✔️❌: void GetCoreSet(node_id c1[], node_id c2[]) {
2265        // RDKit✔️❌:   unsigned int i, j;
2266        // RDKit✔️❌:   for (i = 0, j = 0; i < n1; ++i) {
2267        // RDKit✔️❌:     if (core_1[i] != NULL_NODE) {
2268        // RDKit✔️❌:       c1[j] = i;
2269        // RDKit✔️❌:       c2[j] = core_1[i];
2270        // RDKit✔️❌:       ++j;
2271        // RDKit✔️❌:     }
2272        // RDKit✔️❌:   }
2273        // RDKit✔️❌: }
2274        // Complexity review: both scan n1 entries in O(V) and write core_len
2275        // outputs. Rust allocates two result Vecs here, whereas RDKit writes
2276        // into caller-provided arrays, so repeated goal checks pay two extra
2277        // allocations despite identical mapping order and asymptotic cost.
2278        let mut c1 = Vec::with_capacity(self.core_len);
2279        let mut c2 = Vec::with_capacity(self.core_len);
2280        for i in 0..self.n1 {
2281            if self.core_1[i] != NULL_NODE {
2282                c1.push(i);
2283                c2.push(self.core_1[i]);
2284            }
2285        }
2286        (c1, c2)
2287    }
2288
2289    fn match_one(
2290        &mut self,
2291        atom_fn: &impl Fn(usize, usize) -> bool,
2292        bond_fn: &impl Fn(usize, usize) -> bool,
2293        mut match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2294    ) -> Option<(Vec<NodeId>, Vec<NodeId>)> {
2295        // RDKit✔️❌: bool Match(node_id c1[], node_id c2[]) {
2296        // RDKit✔️❌:   if (IsGoal()) {
2297        // RDKit✔️❌:     GetCoreSet(c1, c2);
2298        // RDKit✔️❌:     if (MatchChecks(c1, c2)) {
2299        // RDKit✔️❌:       return true;
2300        // RDKit✔️❌:     }
2301        // RDKit✔️❌:   }
2302        // RDKit✔️❌:
2303        // RDKit✔️❌:   if (IsDead()) {
2304        // RDKit✔️❌:     return false;
2305        // RDKit✔️❌:   }
2306        // RDKit✔️❌:
2307        // RDKit✔️❌:   Pair<Graph> pair;
2308        // RDKit✔️❌:   while (NextPair(pair)) {
2309        // RDKit✔️❌:     if (IsFeasiblePair(pair.n1, pair.n2)) {
2310        // RDKit✔️❌:       AddPair(pair.n1, pair.n2);
2311        // RDKit✔️❌:       if (Match(c1, c2)) {  // recurse
2312        // RDKit✔️❌:         return true;
2313        // RDKit✔️❌:       }
2314        // RDKit✔️❌:       BackTrack(pair.n1, pair.n2);
2315        // RDKit✔️❌:     }
2316        // RDKit✔️❌:   }
2317        // RDKit✔️❌:   return false;
2318        // RDKit✔️❌: }
2319        // Complexity review: candidate generation, feasibility checks, and
2320        // depth-first recursion match RDKit's search tree. The known gap is
2321        // inherited from get_core_set(), which allocates two Vecs at each goal.
2322        if self.is_goal() {
2323            let (c1, c2) = self.get_core_set();
2324            let accepted = match match_check.as_mut() {
2325                Some(check) => self.match_checks(&c1, &c2, check),
2326                None => true,
2327            };
2328            if accepted {
2329                return Some((c1, c2));
2330            }
2331        }
2332        if self.is_dead() {
2333            return None;
2334        }
2335        let mut pair = Vf2Pair::new();
2336        while self.next_pair(&mut pair) {
2337            if self.is_feasible_pair(pair.n1, pair.n2, atom_fn, bond_fn) {
2338                self.add_pair(pair.n1, pair.n2);
2339                if let Some(result) = self.match_one(atom_fn, bond_fn, match_check.as_deref_mut()) {
2340                    return Some(result);
2341                }
2342                self.back_track(pair.n1, pair.n2);
2343            }
2344        }
2345        None
2346    }
2347
2348    fn match_all(
2349        &mut self,
2350        atom_fn: &impl Fn(usize, usize) -> bool,
2351        bond_fn: &impl Fn(usize, usize) -> bool,
2352        mut match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2353        results: &mut Vec<(Vec<NodeId>, Vec<NodeId>)>,
2354        max_matches: usize,
2355    ) -> bool {
2356        // RDKit✔️❌: template <class DoubleBackInsertionSequence>
2357        // RDKit✔️❌: bool MatchAll(node_id c1[], node_id c2[], DoubleBackInsertionSequence &res,
2358        // RDKit✔️❌:               unsigned int lim = 0) {
2359        // RDKit✔️❌:   if (IsGoal()) {
2360        // RDKit✔️❌:     GetCoreSet(c1, c2);
2361        // RDKit✔️❌:     if (MatchChecks(c1, c2)) {
2362        // RDKit✔️❌:       typename DoubleBackInsertionSequence::value_type newSeq;
2363        // RDKit✔️❌:       newSeq.reserve(core_len);
2364        // RDKit✔️❌:       for (unsigned int i = 0; i < core_len; ++i) {
2365        // RDKit✔️❌:         newSeq.emplace_back(c1[i], c2[i]);
2366        // RDKit✔️❌:       }
2367        // RDKit✔️❌:       res.push_back(newSeq);
2368        // RDKit✔️❌:       return lim && res.size() >= lim;
2369        // RDKit✔️❌:     }
2370        // RDKit✔️❌:   }
2371        // RDKit✔️❌:
2372        // RDKit✔️❌:   if (IsDead()) {
2373        // RDKit✔️❌:     return false;
2374        // RDKit✔️❌:   }
2375        // RDKit✔️❌:
2376        // RDKit✔️❌:   Pair<Graph> pair;
2377        // RDKit✔️❌:   while (NextPair(pair)) {
2378        // RDKit✔️❌:     if (IsFeasiblePair(pair.n1, pair.n2)) {
2379        // RDKit✔️❌:       AddPair(pair.n1, pair.n2);
2380        // RDKit✔️❌:       if (MatchAll(c1, c2, res, lim)) {  // recurse
2381        // RDKit✔️❌:         return true;
2382        // RDKit✔️❌:       }
2383        // RDKit✔️❌:       BackTrack(pair.n1, pair.n2);
2384        // RDKit✔️❌:     }
2385        // RDKit✔️❌:   }
2386        // RDKit✔️❌:   return false;
2387        // RDKit✔️❌: }
2388        // Complexity review: the DFS search tree, candidate order, early limit,
2389        // and per-result O(core_len) storage match RDKit. The known extra cost
2390        // is get_core_set() allocating two Vecs before each final check.
2391        if self.is_goal() {
2392            let (c1, c2) = self.get_core_set();
2393            let accepted = match match_check.as_mut() {
2394                Some(check) => self.match_checks(&c1, &c2, check),
2395                None => true,
2396            };
2397            if accepted {
2398                results.push((c1, c2));
2399                return max_matches > 0 && results.len() >= max_matches;
2400            }
2401        }
2402        if self.is_dead() {
2403            return false;
2404        }
2405        let mut pair = Vf2Pair::new();
2406        while self.next_pair(&mut pair) {
2407            if self.is_feasible_pair(pair.n1, pair.n2, atom_fn, bond_fn) {
2408                self.add_pair(pair.n1, pair.n2);
2409                if self.match_all(
2410                    atom_fn,
2411                    bond_fn,
2412                    match_check.as_deref_mut(),
2413                    results,
2414                    max_matches,
2415                ) {
2416                    return true;
2417                }
2418                self.back_track(pair.n1, pair.n2);
2419            }
2420        }
2421        false
2422    }
2423}
2424
2425// ---------------------------------------------------------------------------
2426// VF2 recursive matching
2427// ---------------------------------------------------------------------------
2428//
2429// RDKit source (vf2.hpp):
2430//   bool Match(node_id c1[], node_id c2[]) {
2431//     if (IsGoal()) { GetCoreSet(c1, c2); if (MatchChecks(c1, c2)) return true; }
2432//     if (IsDead()) return false;
2433//     Pair<Graph> pair;
2434//     while (NextPair(pair)) {
2435//       if (IsFeasiblePair(pair.n1, pair.n2)) {
2436//         AddPair(pair.n1, pair.n2);
2437//         if (Match(c1, c2)) return true;  // recurse
2438//         BackTrack(pair.n1, pair.n2);
2439//       }
2440//     }
2441//     return false;
2442//   }
2443
2444/// RDKit✔️❌: Match — find first match via VF2 recursion.
2445///
2446/// Matches RDKit's `Match(c1, c2)` entry point. `match_check` allows
2447/// final verification (like MolMatchFinalCheckFunctor). If None, all
2448/// completed matches are accepted.
2449fn vf2_match(
2450    state: &mut Vf2SubState,
2451    atom_fn: &impl Fn(usize, usize) -> bool,
2452    bond_fn: &impl Fn(usize, usize) -> bool,
2453    match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2454) -> Option<(Vec<NodeId>, Vec<NodeId>)> {
2455    // RDKit✔️❌: template <class SubState>
2456    // RDKit✔️❌: bool match(int *pn, node_id c1[], node_id c2[], SubState &s) {
2457    // RDKit✔️❌:   if (s.Match(c1, c2)) {
2458    // RDKit✔️❌:     // not needed, pn = num query atoms (n1)...
2459    // RDKit✔️❌:     *pn = s.CoreLen();
2460    // RDKit✔️❌:     return true;
2461    // RDKit✔️❌:   }
2462    // RDKit✔️❌:   return false;
2463    // RDKit✔️❌: }
2464    // Rust returns the mapping and its length is available directly. Complexity
2465    // and allocation behavior are exactly those of the single member core.
2466    state.match_one(atom_fn, bond_fn, match_check)
2467}
2468
2469// RDKit source (vf2.hpp), MatchAll:
2470//   template <class DoubleBackInsertionSequence>
2471//   bool MatchAll(node_id c1[], node_id c2[], DoubleBackInsertionSequence &res,
2472//                 unsigned int lim = 0) {
2473//     if (IsGoal()) {
2474//       GetCoreSet(c1, c2);
2475//       if (MatchChecks(c1, c2)) {
2476//         typename DoubleBackInsertionSequence::value_type newSeq;
2477//         newSeq.reserve(core_len);
2478//         for (unsigned int i = 0; i < core_len; ++i) {
2479//           newSeq.emplace_back(c1[i], c2[i]);
2480//         }
2481//         res.push_back(newSeq);
2482//         return lim && res.size() >= lim;
2483//       }
2484//     }
2485//     if (IsDead()) return false;
2486//     Pair<Graph> pair;
2487//     while (NextPair(pair)) {
2488//       if (IsFeasiblePair(pair.n1, pair.n2)) {
2489//         AddPair(pair.n1, pair.n2);
2490//         if (MatchAll(c1, c2, res, lim)) return true;  // recurse
2491//         BackTrack(pair.n1, pair.n2);
2492//       }
2493//     }
2494//     return false;
2495//   }
2496
2497/// RDKit✔️❌: MatchAll — find all matches up to `max_matches`.
2498///
2499/// Collects matches into `results` as (c1, c2) pairs.
2500/// Returns true when the limit has been reached, signaling the caller
2501/// to stop.
2502fn vf2_match_all(
2503    state: &mut Vf2SubState,
2504    atom_fn: &impl Fn(usize, usize) -> bool,
2505    bond_fn: &impl Fn(usize, usize) -> bool,
2506    match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2507    results: &mut Vec<(Vec<NodeId>, Vec<NodeId>)>,
2508    max_matches: usize,
2509) -> bool {
2510    // RDKit✔️❌: template <class SubState, class DoubleBackInsertionSequence>
2511    // RDKit✔️❌: bool match(node_id c1[], node_id c2[], SubState &s,
2512    // RDKit✔️❌:            DoubleBackInsertionSequence &res, unsigned int max_results) {
2513    // RDKit✔️❌:   s.MatchAll(c1, c2, res, max_results);
2514    // RDKit✔️❌:   return !res.empty();
2515    // RDKit✔️❌: }
2516    // Complexity review: this wrapper adds one emptiness check after invoking
2517    // the single member recursion core; it does not copy or re-enumerate results.
2518    state.match_all(atom_fn, bond_fn, match_check, results, max_matches);
2519    !results.is_empty()
2520}
2521
2522fn vf2_entry_one(
2523    g1: &Vf2Graph,
2524    g2: &Vf2Graph,
2525    atom_fn: &impl Fn(usize, usize) -> bool,
2526    bond_fn: &impl Fn(usize, usize) -> bool,
2527    match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2528    result: &mut Vec<(NodeId, NodeId)>,
2529) -> bool {
2530    // RDKit✔️✔️: template <
2531    // RDKit✔️✔️:     class Graph, class VertexLabeling  // binary predicate
2532    // RDKit✔️✔️:     ,
2533    // RDKit✔️✔️:     class EdgeLabeling  // binary predicate
2534    // RDKit✔️✔️:     ,
2535    // RDKit✔️✔️:     class MatchChecking  // binary predicate
2536    // RDKit✔️✔️:     ,
2537    // RDKit✔️✔️:     class
2538    // RDKit✔️✔️:     BackInsertionSequence  // contains
2539    // RDKit✔️✔️:                            // std::pair<vertex_descriptor,vertex_descriptor>
2540    // RDKit✔️✔️:     >
2541    // RDKit✔️✔️: bool vf2(const Graph &g1, const Graph &g2, VertexLabeling &vertex_labeling,
2542    // RDKit✔️✔️:          EdgeLabeling &edge_labeling, MatchChecking &match_checking,
2543    // RDKit✔️✔️:          BackInsertionSequence &F) {
2544    // RDKit✔️✔️:   detail::VF2SubState<const Graph, VertexLabeling, EdgeLabeling, MatchChecking>
2545    // RDKit✔️✔️:       s0(&g1, &g2, vertex_labeling, edge_labeling, match_checking, false);
2546    // RDKit✔️✔️:   detail::node_id *ni1 = new detail::node_id[num_vertices(g1)];
2547    // RDKit✔️✔️:   detail::node_id *ni2 = new detail::node_id[num_vertices(g2)];
2548    // RDKit✔️✔️:   int n = 0;
2549    // RDKit✔️✔️:
2550    // RDKit✔️✔️:   F.clear();
2551    // RDKit✔️✔️:   if (match(&n, ni1, ni2, s0)) {
2552    // RDKit✔️✔️:     auto sz = num_vertices(g1);
2553    // RDKit✔️✔️:     F.reserve(sz);
2554    // RDKit✔️✔️:     for (unsigned int i = 0; i < sz; ++i) {
2555    // RDKit✔️✔️:       F.emplace_back(ni1[i], ni2[i]);
2556    // RDKit✔️✔️:     }
2557    // RDKit✔️✔️:   }
2558    // RDKit✔️✔️:   delete[] ni1;
2559    // RDKit✔️✔️:   delete[] ni2;
2560    // RDKit✔️✔️:
2561    // RDKit✔️✔️:   return !F.empty();
2562    // RDKit✔️✔️: };
2563    // Complexity review: both allocate two O(V) mapping buffers, construct one
2564    // unsorted state, run the same first-match DFS, and fill one O(V) result.
2565    let mut state = Vf2SubState::new(g1, g2, false);
2566    result.clear();
2567    if let Some((c1, c2)) = vf2_match(&mut state, atom_fn, bond_fn, match_check) {
2568        result.reserve(c1.len());
2569        result.extend(c1.into_iter().zip(c2));
2570    }
2571    !result.is_empty()
2572}
2573
2574fn vf2_entry_all(
2575    g1: &Vf2Graph,
2576    g2: &Vf2Graph,
2577    atom_fn: &impl Fn(usize, usize) -> bool,
2578    bond_fn: &impl Fn(usize, usize) -> bool,
2579    match_check: Option<&mut impl FnMut(&[NodeId], &[NodeId]) -> bool>,
2580    results: &mut Vec<(Vec<NodeId>, Vec<NodeId>)>,
2581    max_results: usize,
2582) -> bool {
2583    // RDKit✔️❌: template <class Graph, class VertexLabeling  // binary predicate
2584    // RDKit✔️❌:           ,
2585    // RDKit✔️❌:           class EdgeLabeling  // binary predicate
2586    // RDKit✔️❌:           ,
2587    // RDKit✔️❌:           class MatchChecking  // binary predicate
2588    // RDKit✔️❌:           ,
2589    // RDKit✔️❌:           class DoubleBackInsertionSequence  // contains a back insertion
2590    // RDKit✔️❌:                                              // sequence
2591    // RDKit✔️❌:           >
2592    // RDKit✔️❌: bool vf2_all(const Graph &g1, const Graph &g2, VertexLabeling &vertex_labeling,
2593    // RDKit✔️❌:              EdgeLabeling &edge_labeling, MatchChecking &match_checking,
2594    // RDKit✔️❌:              DoubleBackInsertionSequence &F, unsigned int max_results = 1000) {
2595    // RDKit✔️❌:   detail::VF2SubState<const Graph, VertexLabeling, EdgeLabeling, MatchChecking>
2596    // RDKit✔️❌:       s0(&g1, &g2, vertex_labeling, edge_labeling, match_checking, false);
2597    // RDKit✔️❌:   std::unique_ptr<detail::node_id[]> ni1(new detail::node_id[num_vertices(g1)]);
2598    // RDKit✔️❌:   std::unique_ptr<detail::node_id[]> ni2(new detail::node_id[num_vertices(g2)]);
2599    // RDKit✔️❌:
2600    // RDKit✔️❌:   F.clear();
2601    // RDKit✔️❌:   F.resize(0);
2602    // RDKit✔️❌:
2603    // RDKit✔️❌:   match(ni1.get(), ni2.get(), s0, F, max_results);
2604    // RDKit✔️❌:
2605    // RDKit✔️❌:   return !F.empty();
2606    // RDKit✔️❌: };
2607    // Complexity review: search order and result storage match RDKit. The known
2608    // gap is the member core allocating mapping Vecs before each final check,
2609    // while RDKit reuses ni1/ni2 across goal states.
2610    let mut state = Vf2SubState::new(g1, g2, false);
2611    results.clear();
2612    vf2_match_all(
2613        &mut state,
2614        atom_fn,
2615        bond_fn,
2616        match_check,
2617        results,
2618        max_results,
2619    )
2620}
2621
2622// ---------------------------------------------------------------------------
2623// Final match check (simplified MolMatchFinalCheckFunctor)
2624// ---------------------------------------------------------------------------
2625//
2626// RDKit source (SubstructMatch.cpp):
2627//   bool MolMatchFinalCheckFunctor::operator()(const std::uint32_t q_c[],
2628//                                              const std::uint32_t m_c[]) {
2629//     if (d_params.extraFinalCheck || d_params.useGenericMatchers) { ... }
2630//     HashedStorageType match;
2631//     if (d_params.uniquify) {
2632//       match.resize(d_mol.getNumAtoms());
2633//       std::fill(match.begin(), match.end(), 0);
2634//       for (unsigned int i = 0; i < d_query.getNumAtoms(); ++i) {
2635//         match[m_c[i]] = 1;
2636//       }
2637//       if (matchesSeen.find(match) != matchesSeen.end()) { return false; }
2638//     }
2639//     if (!d_params.useChirality) {
2640//       if (d_params.uniquify) { matchesSeen.insert(match); }
2641//       return true;
2642//     }
2643//     // ... chirality checks ...
2644//   }
2645
2646/// RDKit✔️✔️: Final match atom-set mask used for uniquification.
2647fn match_mask(atom_mapping: &[usize], mol_num_atoms: usize) -> Vec<bool> {
2648    let mut mask = vec![false; mol_num_atoms];
2649    for &ma in atom_mapping {
2650        if ma < mol_num_atoms {
2651            mask[ma] = true;
2652        }
2653    }
2654    mask
2655}
2656
2657fn count_swaps_to_interconvert_i32(reference: &[i32], probe: &[i32]) -> Option<u32> {
2658    crate::source_port_helpers::count_swaps_to_interconvert(reference, probe)
2659        .ok()
2660        .and_then(|swaps| u32::try_from(swaps).ok())
2661}
2662
2663fn rdkit_atom_perturbation_order_from_bond_indices(
2664    mol: &Molecule,
2665    atom_idx: usize,
2666    probe: &[i32],
2667) -> Result<u32, SubstructMatchError> {
2668    // BEGIN RDKIT CPP FUNCTION Atom::getPerturbationOrder
2669    // RDKit✔️✔️: int Atom::getPerturbationOrder(const INT_LIST &probe) const {
2670    // RDKit✔️✔️:   INT_LIST ref;
2671    // RDKit✔️✔️:   for (const auto bond : getOwningMol().atomBonds(this)) {
2672    // RDKit✔️✔️:     ref.push_back(bond->getIdx());
2673    // RDKit✔️✔️:   }
2674    // RDKit✔️✔️:   return static_cast<int>(countSwapsToInterconvert(probe, ref));
2675    // RDKit✔️✔️: }
2676    // END RDKIT CPP FUNCTION
2677    let reference: Vec<i32> = mol
2678        .topology_block()
2679        .adjacency
2680        .neighbors_of(atom_idx)
2681        .iter()
2682        .map(|neighbor| i32::try_from(neighbor.bond.index()))
2683        .collect::<Result<_, _>>()
2684        .map_err(|_| SubstructMatchError::Unsupported {
2685            branch: "MolMatchFinalCheckFunctor/Atom::getPerturbationOrder/bond-index-overflow",
2686            rdkit_function: "Atom::getPerturbationOrder",
2687        })?;
2688    count_swaps_to_interconvert_i32(probe, &reference).ok_or(SubstructMatchError::Unsupported {
2689        branch: "MolMatchFinalCheckFunctor/Atom::getPerturbationOrder/unmodeled-bond-ordering",
2690        rdkit_function: "Atom::getPerturbationOrder/countSwapsToInterconvert",
2691    })
2692}
2693
2694fn rdkit_translate_ez_label_to_cis_trans(stereo: BondStereo) -> BondStereo {
2695    match stereo {
2696        BondStereo::E => BondStereo::Trans,
2697        BondStereo::Z => BondStereo::Cis,
2698        other => other,
2699    }
2700}
2701
2702fn enhanced_stereo_is_ok(
2703    mol: &Molecule,
2704    query: &Molecule,
2705    q_to_mol: &[NodeId],
2706    mol_stereo_groups: &[Option<usize>],
2707    matches: &[Option<bool>],
2708) -> bool {
2709    // RDKit✔️✔️: bool enhancedStereoIsOK(
2710    // RDKit✔️✔️:     const ROMol &mol, const ROMol &query,
2711    // RDKit✔️✔️:     std::unordered_map<unsigned int, unsigned int> &q_to_mol,
2712    // RDKit✔️✔️:     const std::unordered_map<unsigned int, StereoGroup const *>
2713    // RDKit✔️✔️:         &molStereoGroups,
2714    // RDKit✔️✔️:     const std::unordered_map<unsigned int, bool> &matches) {
2715    // RDKit✔️✔️:   std::unordered_map<unsigned int, StereoGroup const *> molAtomsToQueryGroups;
2716    // RDKit✔️✔️:
2717    // RDKit✔️✔️:   // If the query has stereo groups:
2718    // RDKit✔️✔️:   // * OR only matches AND or OR (not absolute)
2719    // RDKit✔️✔️:   // * AND only matches OR
2720    // RDKit✔️✔️:   for (const auto &sg : query.getStereoGroups()) {
2721    // RDKit✔️✔️:     if (sg.getGroupType() == StereoGroupType::STEREO_ABSOLUTE) {
2722    // RDKit✔️✔️:       continue;
2723    // RDKit✔️✔️:     }
2724    // RDKit✔️✔️:     // StereoGroup const* matched_mol_group = nullptr;
2725    // RDKit✔️✔️:     const bool is_and = sg.getGroupType() == StereoGroupType::STEREO_AND;
2726    // RDKit✔️✔️:     for (const auto a : sg.getAtoms()) {
2727    // RDKit✔️✔️:       const auto mol_group = molStereoGroups.find(q_to_mol[a->getIdx()]);
2728    // RDKit✔️✔️:       if (mol_group == molStereoGroups.end()) {
2729    // RDKit✔️✔️:         // group matching absolute. not ok.
2730    // RDKit✔️✔️:         return false;
2731    // RDKit✔️✔️:       } else if (is_and && mol_group->second->getGroupType() !=
2732    // RDKit✔️✔️:                                StereoGroupType::STEREO_AND) {
2733    // RDKit✔️✔️:         // AND matching OR. not ok.
2734    // RDKit✔️✔️:         return false;
2735    // RDKit✔️✔️:       }
2736    // RDKit✔️✔️:
2737    // RDKit✔️✔️:       molAtomsToQueryGroups[q_to_mol[a->getIdx()]] = &sg;
2738    // RDKit✔️✔️:     }
2739    // RDKit✔️✔️:   }
2740    // RDKit✔️✔️:
2741    // RDKit✔️✔️:   // If the mol has stereo groups:
2742    // RDKit✔️✔️:   // * All atoms must either be the same or opposite, you can't mix
2743    // RDKit✔️✔️:   // * Only one stereogroup must cover all matched atoms in the mol stereo group
2744    // RDKit✔️✔️:   for (const auto &sg : mol.getStereoGroups()) {
2745    // RDKit✔️✔️:     if (sg.getGroupType() == StereoGroupType::STEREO_ABSOLUTE) {
2746    // RDKit✔️✔️:       continue;
2747    // RDKit✔️✔️:     }
2748    // RDKit✔️✔️:     bool doesMatch = false;
2749    // RDKit✔️✔️:     bool seen = false;
2750    // RDKit✔️✔️:     StereoGroup const *QGroup = nullptr;
2751    // RDKit✔️✔️:
2752    // RDKit✔️✔️:     for (const auto &a : sg.getAtoms()) {
2753    // RDKit✔️✔️:       auto thisDoesMatch = matches.find(a->getIdx());
2754    // RDKit✔️✔️:       if (thisDoesMatch == matches.end()) {
2755    // RDKit✔️✔️:         // not matched
2756    // RDKit✔️✔️:         continue;
2757    // RDKit✔️✔️:       }
2758    // RDKit✔️✔️:
2759    // RDKit✔️✔️:       auto pos = molAtomsToQueryGroups.find(a->getIdx());
2760    // RDKit✔️✔️:       auto thisQGroup =
2761    // RDKit✔️✔️:           pos == molAtomsToQueryGroups.end() ? nullptr : pos->second;
2762    // RDKit✔️✔️:       if (!seen) {
2763    // RDKit✔️✔️:         doesMatch = thisDoesMatch->second;
2764    // RDKit✔️✔️:         QGroup = thisQGroup;
2765    // RDKit✔️✔️:         seen = true;
2766    // RDKit✔️✔️:       } else if (doesMatch != thisDoesMatch->second) {
2767    // RDKit✔️✔️:         // diastereomer. not ok.
2768    // RDKit✔️✔️:         return false;
2769    // RDKit✔️✔️:       } else if (thisQGroup != QGroup) {
2770    // RDKit✔️✔️:         // mix of groups in query. not ok.
2771    // RDKit✔️✔️:         return false;
2772    // RDKit✔️✔️:       }
2773    // RDKit✔️✔️:     }
2774    // RDKit✔️✔️:   }
2775    // RDKit✔️✔️:
2776    // RDKit✔️✔️:   return true;
2777    // RDKit✔️✔️: }
2778    // Complexity review: both implementations allocate O(mol atoms) lookup
2779    // state and scan each query/target group member once. Vec indexing replaces
2780    // unordered-map lookup with O(1) direct indexing and no worse allocation.
2781    let mut mol_atoms_to_query_groups = vec![None; mol.num_atoms()];
2782    for (query_group_idx, group) in query.stereo_groups().iter().enumerate() {
2783        if group.kind() == StereoGroupKind::Absolute {
2784            continue;
2785        }
2786        let is_and = group.kind() == StereoGroupKind::And;
2787        for atom in group.atoms() {
2788            let mol_atom = q_to_mol[atom.index()];
2789            let Some(mol_group_idx) = mol_stereo_groups[mol_atom] else {
2790                return false;
2791            };
2792            if is_and && mol.stereo_groups()[mol_group_idx].kind() != StereoGroupKind::And {
2793                return false;
2794            }
2795            mol_atoms_to_query_groups[mol_atom] = Some(query_group_idx);
2796        }
2797    }
2798
2799    for group in mol.stereo_groups() {
2800        if group.kind() == StereoGroupKind::Absolute {
2801            continue;
2802        }
2803        let mut first: Option<(bool, Option<usize>)> = None;
2804        for atom in group.atoms() {
2805            let mol_atom = atom.index();
2806            let Some(does_match) = matches[mol_atom] else {
2807                continue;
2808            };
2809            let query_group = mol_atoms_to_query_groups[mol_atom];
2810            match first {
2811                None => first = Some((does_match, query_group)),
2812                Some((first_match, _)) if first_match != does_match => return false,
2813                Some((_, first_group)) if first_group != query_group => return false,
2814                Some(_) => {}
2815            }
2816        }
2817    }
2818    true
2819}
2820
2821struct MolMatchFinalCheckSetup {
2822    mol_stereo_groups: Vec<Option<usize>>,
2823}
2824
2825impl MolMatchFinalCheckSetup {
2826    fn new(_query: &Molecule, mol: &Molecule, params: &SubstructMatchParams) -> Self {
2827        // RDKit✔️✔️: MolMatchFinalCheckFunctor::MolMatchFinalCheckFunctor(
2828        // RDKit✔️✔️:     const ROMol &query, const ROMol &mol, const SubstructMatchParameters &ps)
2829        // RDKit✔️✔️:     : d_query(query), d_mol(mol), d_params(ps) {
2830        // RDKit✔️✔️:   if (d_params.useEnhancedStereo) {
2831        // RDKit✔️✔️:     for (const auto &sg : d_mol.getStereoGroups()) {
2832        // RDKit✔️✔️:       if (sg.getGroupType() == StereoGroupType::STEREO_ABSOLUTE) {
2833        // RDKit✔️✔️:         continue;
2834        // RDKit✔️✔️:       }
2835        // RDKit✔️✔️:       for (const auto a : sg.getAtoms()) {
2836        // RDKit✔️✔️:         d_molStereoGroups[a->getIdx()] = &sg;
2837        // RDKit✔️✔️:       }
2838        // RDKit✔️✔️:     }
2839        // RDKit✔️✔️:   }
2840        // RDKit✔️✔️: }
2841        // Complexity review: both build the group lookup once in O(mol atoms
2842        // plus non-absolute group members), then reuse it across goal checks.
2843        let mut mol_stereo_groups = vec![None; mol.num_atoms()];
2844        if params.use_enhanced_stereo {
2845            for (group_idx, group) in mol.stereo_groups().iter().enumerate() {
2846                if group.kind() == StereoGroupKind::Absolute {
2847                    continue;
2848                }
2849                for atom in group.atoms() {
2850                    mol_stereo_groups[atom.index()] = Some(group_idx);
2851                }
2852            }
2853        }
2854        Self { mol_stereo_groups }
2855    }
2856}
2857
2858fn find_bond_between(mol: &Molecule, begin: usize, end: usize) -> Option<&Bond> {
2859    mol.bonds().iter().find(|bond| {
2860        let b = bond.begin().index();
2861        let e = bond.end().index();
2862        (b == begin && e == end) || (b == end && e == begin)
2863    })
2864}
2865
2866fn rdkit_match_final_check(
2867    mol: &Molecule,
2868    query: &Molecule,
2869    params: &SubstructMatchParams,
2870    c1: &[NodeId],
2871    c2: &[NodeId],
2872    setup: &MolMatchFinalCheckSetup,
2873    matches_seen: &mut Vec<Vec<bool>>,
2874) -> Result<bool, SubstructMatchError> {
2875    // BEGIN RDKIT CPP FUNCTION MolMatchFinalCheckFunctor::operator()
2876    // RDKit✔️✔️: bool MolMatchFinalCheckFunctor::operator()(const std::uint32_t q_c[],
2877    // RDKit✔️✔️:                                            const std::uint32_t m_c[]) {
2878    // RDKit✔️✔️:   if (d_params.extraFinalCheck || d_params.useGenericMatchers) {
2879    // RDKit✔️✔️:     const std::span<const std::uint32_t> aids(m_c, d_query.getNumAtoms());
2880    // RDKit✔️✔️:     if (d_params.useGenericMatchers &&
2881    // RDKit✔️✔️:         !GenericGroups::genericAtomMatcher(d_mol, d_query, aids)) {
2882    // RDKit✔️✔️:       return false;
2883    // RDKit✔️✔️:     }
2884    // RDKit✔️✔️:     if (d_params.extraFinalCheck && !d_params.extraFinalCheck(d_mol, aids)) {
2885    // RDKit✔️✔️:       return false;
2886    // RDKit✔️✔️:     }
2887    // RDKit✔️✔️:   }
2888    // Complexity review: this adds the source-equivalent O(Q) dispatcher and
2889    // its selected generic-group matcher only when the option is enabled.
2890    if params.use_generic_matchers && !super::generic_groups::generic_atom_matcher(mol, query, c2) {
2891        return Ok(false);
2892    }
2893    if let Some(extra_final_check) = &params.extra_final_check
2894        && !extra_final_check(mol, c2)
2895    {
2896        return Ok(false);
2897    }
2898    // RDKit✔️✔️:   HashedStorageType match;
2899    // RDKit✔️✔️:   if (d_params.uniquify) {
2900    // RDKit✔️✔️:     match.resize(d_mol.getNumAtoms());
2901    // RDKit✔️✔️:     std::fill(match.begin(), match.end(), 0);
2902    // RDKit✔️✔️:     for (unsigned int i = 0; i < d_query.getNumAtoms(); ++i) {
2903    // RDKit✔️✔️:       match[m_c[i]] = 1;
2904    // RDKit✔️✔️:     }
2905    // RDKit✔️✔️:     if (matchesSeen.find(match) != matchesSeen.end()) {
2906    // RDKit✔️✔️:       return false;
2907    // RDKit✔️✔️:     }
2908    // RDKit✔️✔️:   }
2909    let mut q_to_mol = vec![NULL_NODE; query.num_atoms()];
2910    for (&qa, &ma) in c1.iter().zip(c2.iter()) {
2911        if qa < q_to_mol.len() {
2912            q_to_mol[qa] = ma;
2913        }
2914    }
2915    let match_key = if params.uniquify {
2916        let mask = match_mask(&q_to_mol, mol.num_atoms());
2917        if matches_seen.iter().any(|existing| *existing == mask) {
2918            return Ok(false);
2919        }
2920        Some(mask)
2921    } else {
2922        None
2923    };
2924
2925    // RDKit✔️✔️:   if (!d_params.useChirality) {
2926    // RDKit✔️✔️:     if (d_params.uniquify) {
2927    // RDKit✔️✔️:       matchesSeen.insert(match);
2928    // RDKit✔️✔️:     }
2929    // RDKit✔️✔️:     return true;
2930    // RDKit✔️✔️:   }
2931    if !params.use_chirality {
2932        if let Some(mask) = match_key {
2933            matches_seen.push(mask);
2934        }
2935        return Ok(true);
2936    }
2937
2938    // RDKit✔️✔️:   std::unordered_map<unsigned int, bool> matches;
2939    let mol_stereo_groups = &setup.mol_stereo_groups;
2940    let mut stereo_matches = vec![None; mol.num_atoms()];
2941
2942    // RDKit✔️✔️:   // check chiral atoms:
2943    // RDKit✔️✔️:   for (unsigned int i = 0; i < d_query.getNumAtoms(); ++i) {
2944    // RDKit✔️✔️:     const Atom *qAt = d_query.getAtomWithIdx(q_c[i]);
2945    // RDKit✔️✔️:     if (qAt->getDegree() < 3 || !detail::hasChiralLabel(qAt)) {
2946    // RDKit✔️✔️:       continue;
2947    // RDKit✔️✔️:     }
2948    for qi in 0..query.num_atoms() {
2949        let q_at = &query.atoms()[qi];
2950        if query.topology_block().adjacency.neighbors_of(qi).len() < 3 || !has_chiral_label(q_at) {
2951            continue;
2952        }
2953        let mi = q_to_mol[qi];
2954        let m_at = &mol.atoms()[mi];
2955        // RDKit✔️✔️:     if (!detail::hasChiralLabel(mAt)) {
2956        // RDKit✔️✔️:       if (d_params.specifiedStereoQueryMatchesUnspecified) {
2957        // RDKit✔️✔️:         continue;
2958        // RDKit✔️✔️:       }
2959        // RDKit✔️✔️:       return false;
2960        // RDKit✔️✔️:     }
2961        if !has_chiral_label(m_at) {
2962            if params.specified_stereo_query_matches_unspecified {
2963                continue;
2964            }
2965            return Ok(false);
2966        }
2967        // RDKit✔️✔️:     if (qAt->getDegree() > mAt->getDegree()) {
2968        // RDKit✔️✔️:       return false;
2969        // RDKit✔️✔️:     }
2970        if query.topology_block().adjacency.neighbors_of(qi).len()
2971            > mol.topology_block().adjacency.neighbors_of(mi).len()
2972        {
2973            return Ok(false);
2974        }
2975
2976        // RDKit✔️✔️:     INT_LIST qOrder;
2977        // RDKit✔️✔️:     INT_LIST mOrder;
2978        // RDKit✔️✔️:     for (unsigned int j = 0; j < d_query.getNumAtoms(); ++j) {
2979        // RDKit✔️✔️:       const Bond *qB = d_query.getBondBetweenAtoms(q_c[i], q_c[j]);
2980        // RDKit✔️✔️:       const Bond *mB = d_mol.getBondBetweenAtoms(m_c[i], m_c[j]);
2981        // RDKit✔️✔️:       if (qB && mB) {
2982        // RDKit✔️✔️:         mOrder.push_back(mB->getIdx());
2983        // RDKit✔️✔️:         qOrder.push_back(qB->getIdx());
2984        // RDKit✔️✔️:         if (mOrder.size() == qAt->getDegree()) {
2985        // RDKit✔️✔️:           break;
2986        // RDKit✔️✔️:         }
2987        // RDKit✔️✔️:       }
2988        // RDKit✔️✔️:     }
2989        let mut q_order: Vec<i32> = Vec::new();
2990        let mut m_order: Vec<i32> = Vec::new();
2991        for qj in 0..query.num_atoms() {
2992            let Some(q_bond) = find_bond_between(query, qi, qj) else {
2993                continue;
2994            };
2995            let mj = q_to_mol[qj];
2996            let Some(m_bond) = find_bond_between(mol, mi, mj) else {
2997                continue;
2998            };
2999            q_order.push(i32::try_from(q_bond.id().index()).map_err(|_| {
3000                SubstructMatchError::Unsupported {
3001                    branch: "MolMatchFinalCheckFunctor/qOrder/bond-index-overflow",
3002                    rdkit_function: "Atom::getPerturbationOrder",
3003                }
3004            })?);
3005            m_order.push(i32::try_from(m_bond.id().index()).map_err(|_| {
3006                SubstructMatchError::Unsupported {
3007                    branch: "MolMatchFinalCheckFunctor/mOrder/bond-index-overflow",
3008                    rdkit_function: "countSwapsToInterconvert",
3009                }
3010            })?);
3011            if m_order.len() == query.topology_block().adjacency.neighbors_of(qi).len() {
3012                break;
3013            }
3014        }
3015        if q_order.len() != query.topology_block().adjacency.neighbors_of(qi).len()
3016            || q_order.len() != m_order.len()
3017        {
3018            return Err(SubstructMatchError::Unsupported {
3019                branch: "MolMatchFinalCheckFunctor/chiral-atom-missing-matched-neighbors",
3020                rdkit_function: "MolMatchFinalCheckFunctor::operator()",
3021            });
3022        }
3023        // RDKit✔️✔️:     int qPermCount = qAt->getPerturbationOrder(qOrder);
3024        let q_perm_count = rdkit_atom_perturbation_order_from_bond_indices(query, qi, &q_order)?;
3025
3026        // RDKit✔️✔️:     unsigned unmatchedNeighbors = mAt->getDegree() - mOrder.size();
3027        // RDKit✔️✔️:     mOrder.insert(mOrder.end(), unmatchedNeighbors, -1);
3028        let unmatched_neighbors = mol
3029            .topology_block()
3030            .adjacency
3031            .neighbors_of(mi)
3032            .len()
3033            .saturating_sub(m_order.len());
3034        m_order.extend(std::iter::repeat_n(-1, unmatched_neighbors));
3035
3036        // RDKit✔️✔️:     INT_LIST moOrder;
3037        // RDKit✔️✔️:     for (const auto &bond : d_mol.atomBonds(mAt)) {
3038        // RDKit✔️✔️:       const int dbidx = bond->getIdx();
3039        // RDKit✔️✔️:       if (std::find(mOrder.begin(), mOrder.end(), dbidx) != mOrder.end()) {
3040        // RDKit✔️✔️:         moOrder.push_back(dbidx);
3041        // RDKit✔️✔️:       } else {
3042        // RDKit✔️✔️:         moOrder.push_back(-1);
3043        // RDKit✔️✔️:       }
3044        // RDKit✔️✔️:     }
3045        let mo_order: Vec<i32> = mol
3046            .topology_block()
3047            .adjacency
3048            .neighbors_of(mi)
3049            .iter()
3050            .map(|neighbor| {
3051                i32::try_from(neighbor.bond.index()).map(|bond_idx| {
3052                    if m_order.contains(&bond_idx) {
3053                        bond_idx
3054                    } else {
3055                        -1
3056                    }
3057                })
3058            })
3059            .collect::<Result<_, _>>()
3060            .map_err(|_| SubstructMatchError::Unsupported {
3061                branch: "MolMatchFinalCheckFunctor/moOrder/bond-index-overflow",
3062                rdkit_function: "countSwapsToInterconvert",
3063            })?;
3064        // RDKit✔️✔️:     const int mPermCount =
3065        // RDKit✔️✔️:         static_cast<int>(countSwapsToInterconvert(moOrder, mOrder));
3066        let m_perm_count = count_swaps_to_interconvert_i32(&mo_order, &m_order).ok_or(
3067            SubstructMatchError::Unsupported {
3068                branch: "MolMatchFinalCheckFunctor/mPermCount/unmodeled-bond-ordering",
3069                rdkit_function: "countSwapsToInterconvert",
3070            },
3071        )?;
3072
3073        // RDKit✔️✔️:     const bool requireMatch = qPermCount % 2 == mPermCount % 2;
3074        // RDKit✔️✔️:     const bool labelsMatch = qAt->getChiralTag() == mAt->getChiralTag();
3075        // RDKit✔️✔️:     const bool matchOK = requireMatch == labelsMatch;
3076        // RDKit✔️✔️:     // if this is not part of a stereogroup and doesn't match, return false
3077        // RDKit✔️✔️:     const auto msg = d_molStereoGroups.find(m_c[i]);
3078        // RDKit✔️✔️:     if (msg == d_molStereoGroups.end()) {
3079        // RDKit✔️✔️:       if (!matchOK) {
3080        // RDKit✔️✔️:         return false;
3081        // RDKit✔️✔️:       }
3082        // RDKit✔️✔️:     } else {
3083        // RDKit✔️✔️:       matches[m_c[i]] = matchOK;
3084        // RDKit✔️✔️:     }
3085        let require_match = q_perm_count % 2 == m_perm_count % 2;
3086        let labels_match = q_at.chiral_tag() == m_at.chiral_tag();
3087        let match_ok = require_match == labels_match;
3088        if mol_stereo_groups[mi].is_some() {
3089            stereo_matches[mi] = Some(match_ok);
3090        } else if !match_ok {
3091            return Ok(false);
3092        }
3093    }
3094
3095    // RDKit✔️✔️:   std::unordered_map<unsigned int, unsigned int> q_to_mol;
3096    // RDKit✔️✔️:   for (unsigned int j = 0; j < d_query.getNumAtoms(); ++j) {
3097    // RDKit✔️✔️:     q_to_mol[q_c[j]] = m_c[j];
3098    // RDKit✔️✔️:   }
3099    // RDKit✔️✔️:
3100    // RDKit✔️✔️:   if (d_params.useEnhancedStereo) {
3101    // RDKit✔️✔️:     if (!detail::enhancedStereoIsOK(d_mol, d_query, q_to_mol, d_molStereoGroups,
3102    // RDKit✔️✔️:                                     matches)) {
3103    // RDKit✔️✔️:       return false;
3104    // RDKit✔️✔️:     }
3105    // RDKit✔️✔️:   }
3106    if params.use_enhanced_stereo
3107        && !enhanced_stereo_is_ok(mol, query, &q_to_mol, mol_stereo_groups, &stereo_matches)
3108    {
3109        return Ok(false);
3110    }
3111
3112    // RDKit✔️✔️:   // now check double bonds
3113    // RDKit✔️✔️:   for (const auto &qBnd : d_query.bonds()) {
3114    // RDKit✔️✔️:     if (qBnd->getBondType() != Bond::DOUBLE ||
3115    // RDKit✔️✔️:         qBnd->getStereo() <= Bond::STEREOANY) {
3116    // RDKit✔️✔️:       continue;
3117    // RDKit✔️✔️:     }
3118    for q_bnd in query.bonds() {
3119        if q_bnd.order() != BondOrder::Double || !rdkit_bond_stereo_is_above_any(q_bnd.stereo()) {
3120            continue;
3121        }
3122        // RDKit✔️✔️:     if (qBnd->getStereoAtoms().size() != 2) {
3123        // RDKit✔️✔️:       continue;
3124        // RDKit✔️✔️:     }
3125        let Some(q_stereo_atoms) = q_bnd.stereo_atoms() else {
3126            continue;
3127        };
3128        // RDKit✔️✔️:     const Bond *mBnd = d_mol.getBondBetweenAtoms(
3129        // RDKit✔️✔️:         q_to_mol[qBnd->getBeginAtomIdx()], q_to_mol[qBnd->getEndAtomIdx()]);
3130        let q_begin_mol = q_to_mol[q_bnd.begin().index()];
3131        let q_end_mol = q_to_mol[q_bnd.end().index()];
3132        let Some(m_bnd) = find_bond_between(mol, q_begin_mol, q_end_mol) else {
3133            return Err(SubstructMatchError::Unsupported {
3134                branch: "MolMatchFinalCheckFunctor/double-bond-matching-bond-missing",
3135                rdkit_function: "MolMatchFinalCheckFunctor::operator()",
3136            });
3137        };
3138        // RDKit✔️✔️:     if (mBnd->getBondType() != Bond::DOUBLE) {
3139        // RDKit✔️✔️:       continue;
3140        // RDKit✔️✔️:     }
3141        if m_bnd.order() != BondOrder::Double {
3142            continue;
3143        }
3144        // RDKit✔️✔️:     if (!d_params.specifiedStereoQueryMatchesUnspecified &&
3145        // RDKit✔️✔️:         mBnd->getStereo() <= Bond::STEREOANY) {
3146        // RDKit✔️✔️:       return false;
3147        // RDKit✔️✔️:     }
3148        if !params.specified_stereo_query_matches_unspecified
3149            && !rdkit_bond_stereo_is_above_any(m_bnd.stereo())
3150        {
3151            return Ok(false);
3152        }
3153        // RDKit✔️✔️:     if (mBnd->getStereoAtoms().size() != 2) {
3154        // RDKit✔️✔️:       continue;
3155        // RDKit✔️✔️:     }
3156        let Some(m_stereo_atoms) = m_bnd.stereo_atoms() else {
3157            continue;
3158        };
3159
3160        // RDKit✔️✔️:     unsigned int end1Matches = 0;
3161        // RDKit✔️✔️:     unsigned int end2Matches = 0;
3162        // RDKit✔️✔️:     if (q_to_mol[qBnd->getBeginAtomIdx()] == mBnd->getBeginAtomIdx()) {
3163        // RDKit✔️✔️:       if (q_to_mol[qBnd->getStereoAtoms()[0]] ==
3164        // RDKit✔️✔️:           static_cast<unsigned>(mBnd->getStereoAtoms()[0])) {
3165        // RDKit✔️✔️:         end1Matches = 1;
3166        // RDKit✔️✔️:       }
3167        // RDKit✔️✔️:       if (q_to_mol[qBnd->getStereoAtoms()[1]] ==
3168        // RDKit✔️✔️:           static_cast<unsigned>(mBnd->getStereoAtoms()[1])) {
3169        // RDKit✔️✔️:         end2Matches = 1;
3170        // RDKit✔️✔️:       }
3171        // RDKit✔️✔️:     } else {
3172        // RDKit✔️✔️:       if (q_to_mol[qBnd->getStereoAtoms()[0]] ==
3173        // RDKit✔️✔️:           static_cast<unsigned>(mBnd->getStereoAtoms()[1])) {
3174        // RDKit✔️✔️:         end1Matches = 1;
3175        // RDKit✔️✔️:       }
3176        // RDKit✔️✔️:       if (q_to_mol[qBnd->getStereoAtoms()[1]] ==
3177        // RDKit✔️✔️:           static_cast<unsigned>(mBnd->getStereoAtoms()[0])) {
3178        // RDKit✔️✔️:         end2Matches = 1;
3179        // RDKit✔️✔️:       }
3180        // RDKit✔️✔️:     }
3181        let mut end1_matches = 0_u32;
3182        let mut end2_matches = 0_u32;
3183        if q_begin_mol == m_bnd.begin().index() {
3184            if q_to_mol[q_stereo_atoms[0].index()] == m_stereo_atoms[0].index() {
3185                end1_matches = 1;
3186            }
3187            if q_to_mol[q_stereo_atoms[1].index()] == m_stereo_atoms[1].index() {
3188                end2_matches = 1;
3189            }
3190        } else {
3191            if q_to_mol[q_stereo_atoms[0].index()] == m_stereo_atoms[1].index() {
3192                end1_matches = 1;
3193            }
3194            if q_to_mol[q_stereo_atoms[1].index()] == m_stereo_atoms[0].index() {
3195                end2_matches = 1;
3196            }
3197        }
3198
3199        // RDKit✔️✔️:     const unsigned totalMatches = end1Matches + end2Matches;
3200        // RDKit✔️✔️:     const auto mStereo =
3201        // RDKit✔️✔️:         Chirality::translateEZLabelToCisTrans(mBnd->getStereo());
3202        // RDKit✔️✔️:     const auto qStereo =
3203        // RDKit✔️✔️:         Chirality::translateEZLabelToCisTrans(qBnd->getStereo());
3204        // RDKit✔️✔️:     if (mStereo == qStereo && totalMatches == 1) {
3205        // RDKit✔️✔️:       return false;
3206        // RDKit✔️✔️:     }
3207        // RDKit✔️✔️:     if (mStereo != qStereo && totalMatches != 1) {
3208        // RDKit✔️✔️:       return false;
3209        // RDKit✔️✔️:     }
3210        let total_matches = end1_matches + end2_matches;
3211        let m_stereo = rdkit_translate_ez_label_to_cis_trans(m_bnd.stereo());
3212        let q_stereo = rdkit_translate_ez_label_to_cis_trans(q_bnd.stereo());
3213        if m_stereo == q_stereo && total_matches == 1 {
3214            return Ok(false);
3215        }
3216        if m_stereo != q_stereo && total_matches != 1 {
3217            return Ok(false);
3218        }
3219    }
3220
3221    // RDKit✔️✔️:   if (d_params.uniquify) {
3222    // RDKit✔️✔️:     matchesSeen.insert(match);
3223    // RDKit✔️✔️:   }
3224    // RDKit✔️✔️:   return true;
3225    if let Some(mask) = match_key {
3226        matches_seen.push(mask);
3227    }
3228    Ok(true)
3229}
3230
3231// ---------------------------------------------------------------------------
3232// Bond mapping builder
3233// ---------------------------------------------------------------------------
3234
3235/// Build the bond mapping for a match result.
3236///
3237/// For each query bond (by index), find the corresponding molecular bond
3238/// that connects the matched query endpoints.
3239#[allow(dead_code)]
3240fn build_bond_mapping(
3241    query_atom_to_mol: &[Option<usize>],
3242    query: &Vf2Graph,
3243    mol: &Vf2Graph,
3244) -> Vec<usize> {
3245    let mut bond_mapping = Vec::with_capacity(query.n_bonds);
3246    for bond_idx in 0..query.n_bonds {
3247        // Find the query atoms connected by this bond.
3248        let mut q_begin = NULL_NODE;
3249        let mut q_end = NULL_NODE;
3250        for qa in 0..query.n_atoms {
3251            for &(nbr, eidx) in &query.adjacency[qa] {
3252                if eidx == bond_idx {
3253                    q_begin = qa;
3254                    q_end = nbr;
3255                    break;
3256                }
3257            }
3258            if q_begin != NULL_NODE {
3259                break;
3260            }
3261        }
3262
3263        if q_begin != NULL_NODE {
3264            let m_begin = query_atom_to_mol[q_begin];
3265            let m_end = query_atom_to_mol[q_end];
3266            if let (Some(mb), Some(me)) = (m_begin, m_end) {
3267                // Find bond between mb and me in mol.
3268                let mut mol_bond_idx = NULL_NODE;
3269                for &(nbr, eidx) in &mol.adjacency[mb] {
3270                    if nbr == me {
3271                        mol_bond_idx = eidx;
3272                        break;
3273                    }
3274                }
3275                bond_mapping.push(mol_bond_idx);
3276            } else {
3277                bond_mapping.push(NULL_NODE);
3278            }
3279        } else {
3280            bond_mapping.push(NULL_NODE);
3281        }
3282    }
3283    bond_mapping
3284}
3285
3286// ---------------------------------------------------------------------------
3287// Public API
3288// ---------------------------------------------------------------------------
3289
3290fn preflight_atom_query(
3291    query: &crate::QueryNode<AtomQueryPredicate>,
3292) -> Result<(), SubstructMatchError> {
3293    match query {
3294        crate::QueryNode::Predicate(AtomQueryPredicate::UnsupportedFeature(branch)) => {
3295            Err(SubstructMatchError::Unsupported {
3296                branch,
3297                rdkit_function: "QueryAtom::Match",
3298            })
3299        }
3300        crate::QueryNode::Predicate(AtomQueryPredicate::RecursiveSmarts(query)) => {
3301            let inner_query = query.query_mol().ok_or(SubstructMatchError::Unsupported {
3302                branch: "recursive SMARTS without a compiled query molecule",
3303                rdkit_function: "RecursiveStructureQuery::getQueryMol",
3304            })?;
3305            preflight_query_molecule(inner_query)
3306        }
3307        crate::QueryNode::Predicate(_) => Ok(()),
3308        crate::QueryNode::And(children)
3309        | crate::QueryNode::Or(children)
3310        | crate::QueryNode::Xor(children) => {
3311            for child in children {
3312                preflight_atom_query(child)?;
3313            }
3314            Ok(())
3315        }
3316        crate::QueryNode::Not(child) => preflight_atom_query(child),
3317    }
3318}
3319
3320fn preflight_bond_query(
3321    query: &crate::QueryNode<BondQueryPredicate>,
3322) -> Result<(), SubstructMatchError> {
3323    match query {
3324        crate::QueryNode::Predicate(BondQueryPredicate::UnsupportedFeature(branch)) => {
3325            Err(SubstructMatchError::Unsupported {
3326                branch,
3327                rdkit_function: "QueryBond::Match",
3328            })
3329        }
3330        crate::QueryNode::Predicate(_) => Ok(()),
3331        crate::QueryNode::And(children)
3332        | crate::QueryNode::Or(children)
3333        | crate::QueryNode::Xor(children) => {
3334            for child in children {
3335                preflight_bond_query(child)?;
3336            }
3337            Ok(())
3338        }
3339        crate::QueryNode::Not(child) => preflight_bond_query(child),
3340    }
3341}
3342
3343fn preflight_query_molecule(query: &Molecule) -> Result<(), SubstructMatchError> {
3344    // This fail-closed preflight has no RDKit counterpart: RDKit query leaves
3345    // are executable, while COSMolKit can preserve explicitly unsupported
3346    // leaves imported from other formats. Inspecting every leaf before VF2
3347    // prevents AND/OR short-circuiting from turning unsupported chemistry into
3348    // a plausible match or mismatch.
3349    //
3350    // Local complexity review: this is O(A + B + Q), where Q includes all
3351    // owned recursive query trees. It allocates no collections and performs no
3352    // molecule or query clones. Each supported leaf is visited once before the
3353    // existing matcher traversal; failure returns at the first unsupported
3354    // leaf.
3355    for atom in query.atoms() {
3356        if let Some(query) = atom.query() {
3357            preflight_atom_query(query)?;
3358        }
3359    }
3360    for bond in query.bonds() {
3361        if let Some(query) = bond.query() {
3362            preflight_bond_query(query)?;
3363        }
3364    }
3365    Ok(())
3366}
3367
3368fn recursive_matcher(
3369    mol: &Molecule,
3370    query: &Molecule,
3371    params: &SubstructMatchParams,
3372    recursive_cache: &mut RecursiveQueryMatchCache,
3373) -> Result<Vec<bool>, SubstructMatchError> {
3374    // RDKit✔️❌: unsigned int RecursiveMatcher(const ROMol &mol, const ROMol &query,
3375    // RDKit✔️❌:                               std::vector<int> &matches,
3376    // RDKit✔️❌:                               SUBQUERY_MAP &subqueryMap,
3377    // RDKit✔️❌:                               const SubstructMatchParameters &params,
3378    // RDKit✔️❌:                               std::vector<RecursiveStructureQuery *> &locked) {
3379    // RDKit✔️❌:   SubstructMatchParameters lparams = params;
3380    // RDKit✔️❌:   lparams.maxMatches = std::max(params.maxRecursiveMatches, params.maxMatches);
3381    // RDKit✔️❌:   lparams.uniquify = false;
3382    // RDKit✔️❌:   for (auto qAtom : query.atoms()) {
3383    // RDKit✔️❌:     if (qAtom->hasQuery()) {
3384    // RDKit✔️❌:       MatchSubqueries(mol, qAtom->getQuery(), lparams, subqueryMap, locked);
3385    // RDKit✔️❌:     }
3386    // RDKit✔️❌:   }
3387    // RDKit✔️❌:
3388    // RDKit✔️❌:   detail::AtomLabelFunctor atomLabeler(query, mol, lparams);
3389    // RDKit✔️❌:   detail::BondLabelFunctor bondLabeler(query, mol, lparams);
3390    // RDKit✔️❌:   MolMatchFinalCheckFunctor matchChecker(query, mol, lparams);
3391    // RDKit✔️❌:
3392    // RDKit✔️❌:   matches.clear();
3393    // RDKit✔️❌:   matches.resize(0);
3394    // RDKit✔️❌:   std::vector<detail::ssPairType> pms;
3395    // RDKit✔️❌:   bool found =
3396    // RDKit✔️❌:       boost::vf2_all(query.getTopology(), mol.getTopology(), atomLabeler,
3397    // RDKit✔️❌:                      bondLabeler, matchChecker, pms, lparams.maxMatches);
3398    // RDKit✔️❌:   unsigned int res = 0;
3399    // RDKit✔️❌:   if (found) {
3400    // RDKit✔️❌:     matches.reserve(pms.size());
3401    // RDKit✔️❌:     for (const auto &pairs : pms) {
3402    // RDKit✔️❌:       if (!query.hasProp(common_properties::_queryRootAtom)) {
3403    // RDKit✔️❌:         matches.push_back(pairs.begin()->second);
3404    // RDKit✔️❌:       } else {
3405    // RDKit✔️❌:         int rootIdx;
3406    // RDKit✔️❌:         query.getProp(common_properties::_queryRootAtom, rootIdx);
3407    // RDKit✔️❌:         bool found = false;
3408    // RDKit✔️❌:         for (const auto &pairIter : pairs) {
3409    // RDKit✔️❌:           if (pairIter.first == static_cast<unsigned int>(rootIdx)) {
3410    // RDKit✔️❌:             matches.push_back(pairIter.second);
3411    // RDKit✔️❌:             found = true;
3412    // RDKit✔️❌:             break;
3413    // RDKit✔️❌:           }
3414    // RDKit✔️❌:         }
3415    // RDKit✔️❌:         if (!found) {
3416    // RDKit✔️❌:           BOOST_LOG(rdErrorLog)
3417    // RDKit✔️❌:               << "no match found for queryRootAtom" << std::endl;
3418    // RDKit✔️❌:         }
3419    // RDKit✔️❌:       }
3420    // RDKit✔️❌:       if (matches.size() == lparams.maxMatches) {
3421    // RDKit✔️❌:         break;
3422    // RDKit✔️❌:       }
3423    // RDKit✔️❌:     }
3424    // RDKit✔️❌:     res = matches.size();
3425    // RDKit✔️❌:   }
3426    // RDKit✔️❌:   return res;
3427    // RDKit✔️❌: }
3428    // Complexity review: nested preparation and VF2 follow the source. The
3429    // membership result is one O(target atoms) bool Vec in place of RDKit's
3430    // ordered set, while the canonical VF2 mapping-allocation gap remains.
3431    let mut local_params = params.clone();
3432    local_params.max_matches = params.max_recursive_matches.max(params.max_matches);
3433    local_params.uniquify = false;
3434    for atom in query.atoms() {
3435        if let Some(query_node) = atom.query() {
3436            match_subqueries(mol, query_node, &local_params, recursive_cache)?;
3437        }
3438    }
3439
3440    let matches = substruct_match_impl_with_recursive_cache(
3441        mol,
3442        query,
3443        &local_params,
3444        Some(recursive_cache),
3445    )?;
3446    let root_index = query
3447        .prop("_queryRootAtom")
3448        .and_then(|value| value.parse::<usize>().ok())
3449        .unwrap_or(0);
3450    let mut match_starts = vec![false; mol.num_atoms()];
3451    for matched in matches.into_iter().take(local_params.max_matches) {
3452        if let Some(&root_atom_idx) = matched.atom_mapping.get(root_index)
3453            && root_atom_idx != NULL_NODE
3454            && root_atom_idx < match_starts.len()
3455        {
3456            match_starts[root_atom_idx] = true;
3457        }
3458    }
3459    Ok(match_starts)
3460}
3461
3462fn match_subqueries(
3463    mol: &Molecule,
3464    query: &crate::QueryNode<AtomQueryPredicate>,
3465    params: &SubstructMatchParams,
3466    recursive_cache: &mut RecursiveQueryMatchCache,
3467) -> Result<(), SubstructMatchError> {
3468    // RDKit✔️❌: void MatchSubqueries(const ROMol &mol, QueryAtom::QUERYATOM_QUERY *query,
3469    // RDKit✔️❌:                      const SubstructMatchParameters &params,
3470    // RDKit✔️❌:                      SUBQUERY_MAP &subqueryMap,
3471    // RDKit✔️❌:                      std::vector<RecursiveStructureQuery *> &locked) {
3472    // RDKit✔️❌:   PRECONDITION(query, "bad query");
3473    // RDKit✔️❌:   if (query->getDescription() == "RecursiveStructure") {
3474    // RDKit✔️❌:     auto *rsq = (RecursiveStructureQuery *)query;
3475    // RDKit✔️❌: #ifdef RDK_BUILD_THREADSAFE_SSS
3476    // RDKit✔️❌:     rsq->d_mutex.lock();
3477    // RDKit✔️❌: #endif
3478    // RDKit✔️❌:     locked.push_back(rsq);
3479    // RDKit✔️❌:     rsq->clear();
3480    // RDKit✔️❌:     bool matchDone = false;
3481    // RDKit✔️❌:     if (rsq->getSerialNumber() &&
3482    // RDKit✔️❌:         subqueryMap.find(rsq->getSerialNumber()) != subqueryMap.end()) {
3483    // RDKit✔️❌:       matchDone = true;
3484    // RDKit✔️❌:       auto orsq =
3485    // RDKit✔️❌:           (const RecursiveStructureQuery *)subqueryMap[rsq->getSerialNumber()];
3486    // RDKit✔️❌:       for (auto setIter = orsq->beginSet(); setIter != orsq->endSet();
3487    // RDKit✔️❌:            ++setIter) {
3488    // RDKit✔️❌:         rsq->insert(*setIter);
3489    // RDKit✔️❌:       }
3490    // RDKit✔️❌:     }
3491    // RDKit✔️❌:
3492    // RDKit✔️❌:     if (!matchDone) {
3493    // RDKit✔️❌:       ROMol const *queryMol = rsq->getQueryMol();
3494    // RDKit✔️❌:       if (queryMol) {
3495    // RDKit✔️❌:         std::vector<int> matchStarts;
3496    // RDKit✔️❌:         unsigned int res = RecursiveMatcher(mol, *queryMol, matchStarts,
3497    // RDKit✔️❌:                                             subqueryMap, params, locked);
3498    // RDKit✔️❌:         if (res) {
3499    // RDKit✔️❌:           for (int &matchStart : matchStarts) {
3500    // RDKit✔️❌:             rsq->insert(matchStart);
3501    // RDKit✔️❌:           }
3502    // RDKit✔️❌:         }
3503    // RDKit✔️❌:       }
3504    // RDKit✔️❌:       if (rsq->getSerialNumber()) {
3505    // RDKit✔️❌:         subqueryMap[rsq->getSerialNumber()] = query;
3506    // RDKit✔️❌:       }
3507    // RDKit✔️❌:     }
3508    // RDKit✔️❌:   }
3509    // RDKit✔️❌:
3510    // RDKit✔️❌:   for (auto childIt = query->beginChildren(); childIt != query->endChildren();
3511    // RDKit✔️❌:        ++childIt) {
3512    // RDKit✔️❌:     MatchSubqueries(mol, childIt->get(), params, subqueryMap, locked);
3513    // RDKit✔️❌:   }
3514    // RDKit✔️❌: }
3515    // Complexity review: every query node is visited once unless a serial-key
3516    // cache hit skips recursive VF2, matching RDKit. BTreeMap lookup is O(log
3517    // R) instead of unordered-map average O(1), but recursive VF2 dominates;
3518    // query trees and match vectors are never cloned here.
3519    match query {
3520        crate::QueryNode::Predicate(AtomQueryPredicate::RecursiveSmarts(recursive_query)) => {
3521            let cache_key = recursive_query_cache_key(recursive_query);
3522            if !recursive_cache.contains_key(&cache_key) {
3523                let match_starts = match recursive_query.query_mol() {
3524                    Some(inner_query) => {
3525                        recursive_matcher(mol, inner_query, params, recursive_cache)?
3526                    }
3527                    None => vec![false; mol.num_atoms()],
3528                };
3529                recursive_cache.insert(cache_key, match_starts);
3530            }
3531        }
3532        crate::QueryNode::Predicate(_) => {}
3533        crate::QueryNode::And(children)
3534        | crate::QueryNode::Or(children)
3535        | crate::QueryNode::Xor(children) => {
3536            for child in children {
3537                match_subqueries(mol, child, params, recursive_cache)?;
3538            }
3539        }
3540        crate::QueryNode::Not(child) => {
3541            match_subqueries(mol, child, params, recursive_cache)?;
3542        }
3543    }
3544    Ok(())
3545}
3546
3547fn populate_recursive_query_match_cache(
3548    mol: &Molecule,
3549    query: &Molecule,
3550    params: &SubstructMatchParams,
3551    recursive_cache: &mut RecursiveQueryMatchCache,
3552) -> Result<(), SubstructMatchError> {
3553    for atom in query.atoms() {
3554        if let Some(query_node) = atom.query() {
3555            match_subqueries(mol, query_node, params, recursive_cache)?;
3556        }
3557    }
3558    Ok(())
3559}
3560
3561fn substruct_match_impl_with_recursive_cache(
3562    mol: &Molecule,
3563    query: &Molecule,
3564    params: &SubstructMatchParams,
3565    recursive_cache: Option<&RecursiveQueryMatchCache>,
3566) -> SubstructMatchResultList {
3567    let query_ctx = build_query_match_context(mol);
3568    substruct_match_impl_with_recursive_cache_and_context(
3569        mol,
3570        query,
3571        params,
3572        recursive_cache,
3573        &query_ctx,
3574    )
3575}
3576
3577fn substruct_match_impl_with_recursive_cache_and_context(
3578    mol: &Molecule,
3579    query: &Molecule,
3580    params: &SubstructMatchParams,
3581    recursive_cache: Option<&RecursiveQueryMatchCache>,
3582    query_ctx: &QueryMatchContext,
3583) -> SubstructMatchResultList {
3584    let m_num_atoms = mol.num_atoms();
3585    let q_num_atoms = query.num_atoms();
3586
3587    // RDKit source (SubstructMatch.cpp):
3588    //   if (!mNumAtoms || !qNumAtoms || qNumAtoms > mNumAtoms) {
3589    //     return matches;
3590    //   }
3591    if m_num_atoms == 0 || q_num_atoms == 0 || q_num_atoms > m_num_atoms {
3592        return Ok(Vec::new());
3593    }
3594
3595    // Build VF2 graphs.
3596    let q_graph = build_vf2_graph(query);
3597    let m_graph = build_vf2_graph(mol);
3598
3599    // Build atom matching closure.
3600    // RDKit source:
3601    //   detail::AtomLabelFunctor atomLabeler(query, mol, params);
3602    //   detail::BondLabelFunctor bondLabeler(query, mol, params);
3603    //   MolMatchFinalCheckFunctor matchChecker(query, mol, params);
3604    let atom_fn = |qi: usize, mj: usize| -> bool {
3605        atom_label_matches(query, mol, qi, mj, params, recursive_cache, query_ctx)
3606    };
3607
3608    let bond_fn = |qei: usize, mei: usize| -> bool {
3609        bond_label_matches(query, mol, qei, mei, params, query_ctx)
3610    };
3611
3612    // RDKit source:
3613    //   bool found = boost::vf2_all(query.getTopology(), mol.getTopology(),
3614    //                               atomLabeler, bondLabeler, matchChecker,
3615    //                               pms, params.maxMatches);
3616    let mut raw_matches: Vec<(Vec<NodeId>, Vec<NodeId>)> = Vec::new();
3617    let mut matches_seen: Vec<Vec<bool>> = Vec::new();
3618    let final_check_setup = MolMatchFinalCheckSetup::new(query, mol, params);
3619    let mut final_check_error: Option<SubstructMatchError> = None;
3620    let mut check_fn = |c1: &[NodeId], c2: &[NodeId]| -> bool {
3621        match rdkit_match_final_check(
3622            mol,
3623            query,
3624            params,
3625            c1,
3626            c2,
3627            &final_check_setup,
3628            &mut matches_seen,
3629        ) {
3630            Ok(accepted) => accepted,
3631            Err(err) => {
3632                final_check_error = Some(err);
3633                false
3634            }
3635        }
3636    };
3637
3638    vf2_entry_all(
3639        &q_graph,
3640        &m_graph,
3641        &atom_fn,
3642        &bond_fn,
3643        Some(&mut check_fn),
3644        &mut raw_matches,
3645        params.max_matches,
3646    );
3647    if let Some(err) = final_check_error {
3648        return Err(err);
3649    }
3650
3651    // RDKit source (SubstructMatch.cpp):
3652    //   if (found) {
3653    //     const unsigned int nQueryAtoms = query.getNumAtoms();
3654    //     matches.reserve(pms.size());
3655    //     MatchVectType matchVect(nQueryAtoms);
3656    //     for (const auto &pairs : pms) {
3657    //       for (const auto &pair : pairs) {
3658    //         matchVect[pair.first] = pair;
3659    //       }
3660    //       matches.push_back(matchVect);
3661    //     }
3662    //   }
3663    let mut results: Vec<SubstructMatchResult> = Vec::new();
3664
3665    for (c1, c2) in &raw_matches {
3666        // Build atom_mapping: query_atom_index -> mol_atom_index.
3667        // RDKit uses MatchVectType (vector<pair<int,int>>) where
3668        // pair.second is the mol atom index and pair.first is query atom index.
3669        let mut atom_to_mol: Vec<Option<usize>> = vec![None; q_num_atoms];
3670        for (&qa, &ma) in c1.iter().zip(c2.iter()) {
3671            if qa < q_num_atoms {
3672                atom_to_mol[qa] = Some(ma);
3673            }
3674        }
3675
3676        // Build bond mapping by looking up bonds between matched atoms.
3677        let mut bond_mapping = Vec::with_capacity(query.num_bonds());
3678        for qbond in query.bonds() {
3679            let q_begin = qbond.begin().index();
3680            let q_end = qbond.end().index();
3681            let m_begin = atom_to_mol[q_begin];
3682            let m_end = atom_to_mol[q_end];
3683            match (m_begin, m_end) {
3684                (Some(mb), Some(me)) => {
3685                    // Find bond between mb and me in mol.
3686                    let found = m_graph.adjacency[mb]
3687                        .iter()
3688                        .find(|&&(nbr, _)| nbr == me)
3689                        .map(|&(_, eidx)| eidx);
3690                    bond_mapping.push(found.unwrap_or(NULL_NODE));
3691                }
3692                _ => {
3693                    bond_mapping.push(NULL_NODE);
3694                }
3695            }
3696        }
3697
3698        results.push(SubstructMatchResult {
3699            atom_mapping: atom_to_mol
3700                .into_iter()
3701                .map(|x| x.unwrap_or(NULL_NODE))
3702                .collect(),
3703            bond_mapping,
3704        });
3705    }
3706
3707    Ok(results)
3708}
3709
3710fn atom_compat(
3711    query_atom: &Atom,
3712    query_mol: &Molecule,
3713    mol_atom: &Atom,
3714    mol: &Molecule,
3715    params: &SubstructMatchParams,
3716    recursive_cache: Option<&RecursiveQueryMatchCache>,
3717    query_ctx: &QueryMatchContext,
3718) -> bool {
3719    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: atomCompat
3720    // RDKit✔️✔️: bool atomCompat(const Atom *a1, const Atom *a2,
3721    // RDKit✔️✔️:                 const SubstructMatchParameters &ps) {
3722    // RDKit✔️✔️:   PRECONDITION(a1, "bad atom");
3723    // RDKit✔️✔️:   PRECONDITION(a2, "bad atom");
3724    // RDKit✔️✔️:   // std::cerr << "\t\tatomCompat: "<< a1 << " " << a1->getIdx() << "-" << a2 <<
3725    // RDKit✔️✔️:   // " " << a2->getIdx() << std::endl;
3726    // RDKit✔️✔️:
3727    // RDKit✔️✔️:   if (ps.extraAtomCheckOverridesDefaultCheck && ps.extraAtomCheck) {
3728    // RDKit✔️✔️:     return ps.extraAtomCheck(*a1, *a2);
3729    // RDKit✔️✔️:   }
3730    // RDKit✔️✔️:   bool res;
3731    // RDKit✔️✔️:   if (ps.useQueryQueryMatches && a1->hasQuery() && a2->hasQuery()) {
3732    // RDKit✔️✔️:     res = static_cast<const QueryAtom *>(a1)->QueryMatch(
3733    // RDKit✔️✔️:         static_cast<const QueryAtom *>(a2));
3734    // RDKit✔️✔️:   } else {
3735    // RDKit✔️✔️:     res = a1->Match(a2);
3736    // RDKit✔️✔️:   }
3737    // RDKit✔️✔️:   if (!res) {
3738    // RDKit✔️✔️:     return false;
3739    // RDKit✔️✔️:   }
3740    // RDKit✔️✔️:   if (!ps.atomProperties.empty()) {
3741    // RDKit✔️✔️:     if (!propertyCompat(a1, a2, ps.atomProperties)) {
3742    // RDKit✔️✔️:       return false;
3743    // RDKit✔️✔️:     }
3744    // RDKit✔️✔️:   }
3745    // RDKit✔️✔️:   if (ps.extraAtomCheck && !ps.extraAtomCheck(*a1, *a2)) {
3746    // RDKit✔️✔️:     return false;
3747    // RDKit✔️✔️:   }
3748    // RDKit✔️✔️:
3749    // RDKit✔️✔️:   return res;
3750    // RDKit✔️✔️: }
3751    // END RDKIT CPP FUNCTION
3752    //
3753    // Typed references make the source pointer preconditions
3754    // unrepresentable. Local complexity review: both implementations perform
3755    // the same constant-time option/flag dispatch, one default query or atom
3756    // match, the requested property scan, and at most one callback invocation
3757    // after the default match. Arc callback dispatch is the Rust equivalent of
3758    // std::function dispatch and allocates nothing per match. Query-tree
3759    // traversal and recursive-cache lookup retain their existing complexity;
3760    // property_compat has the separately documented BTreeMap improvement. No
3761    // atom, molecule, query, property map, or callback is cloned in this hot
3762    // path.
3763    if params.extra_atom_check_overrides_default_check
3764        && let Some(extra_atom_check) = &params.extra_atom_check
3765    {
3766        return extra_atom_check(query_mol, query_atom, mol, mol_atom);
3767    }
3768
3769    let matches = if params.use_query_query_matches
3770        && let (Some(query), Some(mol_query)) = (query_atom.query(), mol_atom.query())
3771    {
3772        atom_queries_match(query, mol_query)
3773    } else if let Some(query_node) = query_atom.query() {
3774        evaluate_atom_query(
3775            query_node,
3776            mol_atom,
3777            mol,
3778            params,
3779            recursive_cache,
3780            query_ctx,
3781        )
3782    } else {
3783        atom_matches(query_atom, query_mol, mol_atom, mol)
3784    };
3785    if !matches {
3786        return false;
3787    }
3788    if !params.atom_properties.is_empty()
3789        && !property_compat(
3790            query_atom.props(),
3791            mol_atom.props(),
3792            &params.atom_properties,
3793        )
3794    {
3795        return false;
3796    }
3797    if let Some(extra_atom_check) = &params.extra_atom_check
3798        && !extra_atom_check(query_mol, query_atom, mol, mol_atom)
3799    {
3800        return false;
3801    }
3802    matches
3803}
3804
3805#[allow(deprecated)]
3806fn chiral_atom_compat(
3807    query_atom: &Atom,
3808    query_mol: &Molecule,
3809    mol_atom: &Atom,
3810    mol: &Molecule,
3811) -> bool {
3812    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: chiralAtomCompat
3813    // RDKit✔️✔️: bool chiralAtomCompat(const Atom *&a1, const Atom *&a2) {
3814    // RDKit✔️✔️:   /// DEPRECATED
3815    // RDKit✔️✔️:   PRECONDITION(a1, "bad atom");
3816    // RDKit✔️✔️:   PRECONDITION(a2, "bad atom");
3817    // RDKit✔️✔️:   bool res = a1->Match(a2);
3818    // RDKit✔️✔️:   if (res) {
3819    // RDKit✔️✔️:     std::string s1, s2;
3820    // RDKit✔️✔️:     bool hascode1 = a1->getPropIfPresent(common_properties::_CIPCode, s1);
3821    // RDKit✔️✔️:     bool hascode2 = a2->getPropIfPresent(common_properties::_CIPCode, s2);
3822    // RDKit✔️✔️:     if (hascode1 || hascode2) {
3823    // RDKit✔️✔️:       res = hascode1 && hascode2 && s1 == s2;
3824    // RDKit✔️✔️:     }
3825    // RDKit✔️✔️:   }
3826    // RDKit✔️✔️:   std::cerr << "\t\tchiralAtomCompat: " << a1 << " " << a1->getIdx() << "-"
3827    // RDKit✔️✔️:             << a2 << " " << a2->getIdx() << std::endl;
3828    // RDKit✔️✔️:   std::cerr << "\t\t    " << res << std::endl;
3829    // RDKit✔️✔️:   return res;
3830    // RDKit✔️✔️: }
3831    // END RDKIT CPP FUNCTION
3832    //
3833    // Rust references make both pointer preconditions unrepresentable. Local
3834    // complexity review: the shared atom matcher has the same source-defined
3835    // atom-query cost, followed by two property lookups and one string
3836    // comparison only after a successful atom match. No molecule, atom,
3837    // property map, or string is cloned. BTreeMap lookup retains the canonical
3838    // atom property representation and has the same logarithmic lookup class
3839    // as RDKit's property dictionary for the modeled state.
3840    let mut matches = atom_matches(query_atom, query_mol, mol_atom, mol);
3841    if matches {
3842        let query_cip = query_atom.prop("_CIPCode");
3843        let mol_cip = mol_atom.prop("_CIPCode");
3844        if query_cip.is_some() || mol_cip.is_some() {
3845            matches = query_cip.is_some() && mol_cip.is_some() && query_cip == mol_cip;
3846        }
3847    }
3848    eprintln!(
3849        "\t\tchiralAtomCompat: {:p} {}-{:p} {}",
3850        query_atom,
3851        query_atom.id().index(),
3852        mol_atom,
3853        mol_atom.id().index()
3854    );
3855    eprintln!("\t\t    {}", u8::from(matches));
3856    matches
3857}
3858
3859fn bond_compat(
3860    query_bond: &Bond,
3861    query_mol: &Molecule,
3862    mol_bond: &Bond,
3863    mol: &Molecule,
3864    params: &SubstructMatchParams,
3865    query_ctx: &QueryMatchContext,
3866) -> bool {
3867    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: bondCompat
3868    // RDKit✔️✔️: bool bondCompat(const Bond *b1, const Bond *b2,
3869    // RDKit✔️✔️:                 const SubstructMatchParameters &ps) {
3870    // RDKit✔️✔️:   PRECONDITION(b1, "bad bond");
3871    // RDKit✔️✔️:   PRECONDITION(b2, "bad bond");
3872    // RDKit✔️✔️:
3873    // RDKit✔️✔️:   if (ps.extraBondCheckOverridesDefaultCheck && ps.extraBondCheck) {
3874    // RDKit✔️✔️:     return ps.extraBondCheck(*b1, *b2);
3875    // RDKit✔️✔️:   }
3876    // RDKit✔️✔️:
3877    // RDKit✔️✔️:   bool res;
3878    // RDKit✔️✔️:
3879    // RDKit✔️✔️:   auto isConjugatedSingleOrDoubleBond([](const Bond *bond) {
3880    // RDKit✔️✔️:     return bond->getIsConjugated() && (bond->getBondType() == Bond::SINGLE ||
3881    // RDKit✔️✔️:                                        bond->getBondType() == Bond::DOUBLE);
3882    // RDKit✔️✔️:   });
3883    // RDKit✔️✔️:   auto isSingleOrDoubleBond([](const Bond *bond) {
3884    // RDKit✔️✔️:     return (bond->getBondType() == Bond::SINGLE ||
3885    // RDKit✔️✔️:             bond->getBondType() == Bond::DOUBLE);
3886    // RDKit✔️✔️:   });
3887    // RDKit✔️✔️:
3888    // RDKit✔️✔️:   if (ps.useQueryQueryMatches && b1->hasQuery() && b2->hasQuery()) {
3889    // RDKit✔️✔️:     res = static_cast<const QueryBond *>(b1)->QueryMatch(
3890    // RDKit✔️✔️:         static_cast<const QueryBond *>(b2));
3891    // RDKit✔️✔️:   } else if (ps.aromaticMatchesConjugated && !b1->hasQuery() &&
3892    // RDKit✔️✔️:              !b2->hasQuery() &&
3893    // RDKit✔️✔️:              ((b1->getBondType() == Bond::AROMATIC &&
3894    // RDKit✔️✔️:                b2->getBondType() == Bond::AROMATIC) ||
3895    // RDKit✔️✔️:               (b1->getBondType() == Bond::AROMATIC &&
3896    // RDKit✔️✔️:                isConjugatedSingleOrDoubleBond(b2)) ||
3897    // RDKit✔️✔️:               (b2->getBondType() == Bond::AROMATIC &&
3898    // RDKit✔️✔️:                isConjugatedSingleOrDoubleBond(b1)))) {
3899    // RDKit✔️✔️:     res = true;
3900    // RDKit✔️✔️:   } else if (ps.aromaticMatchesSingleOrDouble && !b1->hasQuery() &&
3901    // RDKit✔️✔️:              !b2->hasQuery() &&
3902    // RDKit✔️✔️:              ((b1->getBondType() == Bond::AROMATIC &&
3903    // RDKit✔️✔️:                b2->getBondType() == Bond::AROMATIC) ||
3904    // RDKit✔️✔️:               (b1->getBondType() == Bond::AROMATIC &&
3905    // RDKit✔️✔️:                isSingleOrDoubleBond(b2)) ||
3906    // RDKit✔️✔️:               (b2->getBondType() == Bond::AROMATIC &&
3907    // RDKit✔️✔️:                isSingleOrDoubleBond(b1)))) {
3908    // RDKit✔️✔️:     res = true;
3909    // RDKit✔️✔️:   } else {
3910    // RDKit✔️✔️:     res = b1->Match(b2);
3911    // RDKit✔️✔️:   }
3912    // RDKit✔️✔️:   if (!res) {
3913    // RDKit✔️✔️:     return false;
3914    // RDKit✔️✔️:   }
3915    // RDKit✔️✔️:   if (b1->getBondType() == Bond::DATIVE && b2->getBondType() == Bond::DATIVE) {
3916    // RDKit✔️✔️:     // for dative bonds we need to make sure that the direction also matches:
3917    // RDKit✔️✔️:     if (!b1->getBeginAtom()->Match(b2->getBeginAtom()) ||
3918    // RDKit✔️✔️:         !b1->getEndAtom()->Match(b2->getEndAtom())) {
3919    // RDKit✔️✔️:       return false;
3920    // RDKit✔️✔️:     }
3921    // RDKit✔️✔️:   }
3922    // RDKit✔️✔️:   if (!ps.bondProperties.empty()) {
3923    // RDKit✔️✔️:     if (!propertyCompat(b1, b2, ps.bondProperties)) {
3924    // RDKit✔️✔️:       return false;
3925    // RDKit✔️✔️:     }
3926    // RDKit✔️✔️:   }
3927    // RDKit✔️✔️:   if (ps.extraBondCheck && !ps.extraBondCheck(*b1, *b2)) {
3928    // RDKit✔️✔️:     return false;
3929    // RDKit✔️✔️:   }
3930    // RDKit✔️✔️:
3931    // RDKit✔️✔️:   return res;
3932    // RDKit✔️✔️: }
3933    // END RDKIT CPP FUNCTION
3934    //
3935    // Rust references make both pointer preconditions unrepresentable. Local
3936    // complexity review: flag/order checks and dative endpoint lookups are
3937    // constant time; query-tree matching reuses the canonical evaluator with
3938    // its source-equivalent tree complexity. The property scan is linear in
3939    // the requested names with logarithmic canonical BTreeMap lookup, and at
3940    // most one Arc callback dispatch occurs. Nothing is cloned or allocated.
3941    if params.extra_bond_check_overrides_default_check
3942        && let Some(extra_bond_check) = &params.extra_bond_check
3943    {
3944        return extra_bond_check(query_bond, mol_bond);
3945    }
3946
3947    let is_conjugated_single_or_double = |bond: &Bond| {
3948        bond.is_conjugated() && matches!(bond.order(), BondOrder::Single | BondOrder::Double)
3949    };
3950    let is_single_or_double =
3951        |bond: &Bond| matches!(bond.order(), BondOrder::Single | BondOrder::Double);
3952    let aromatic_pair_matches = |other_matches: &dyn Fn(&Bond) -> bool| {
3953        (query_bond.order() == BondOrder::Aromatic && mol_bond.order() == BondOrder::Aromatic)
3954            || (query_bond.order() == BondOrder::Aromatic && other_matches(mol_bond))
3955            || (mol_bond.order() == BondOrder::Aromatic && other_matches(query_bond))
3956    };
3957
3958    let matches = if params.use_query_query_matches
3959        && let (Some(query), Some(mol_query)) = (query_bond.query(), mol_bond.query())
3960    {
3961        bond_queries_match(query, mol_query)
3962    } else if params.aromatic_matches_conjugated
3963        && query_bond.query().is_none()
3964        && mol_bond.query().is_none()
3965        && aromatic_pair_matches(&is_conjugated_single_or_double)
3966    {
3967        true
3968    } else if params.aromatic_matches_single_or_double
3969        && query_bond.query().is_none()
3970        && mol_bond.query().is_none()
3971        && aromatic_pair_matches(&is_single_or_double)
3972    {
3973        true
3974    } else if let Some(query) = query_bond.query() {
3975        evaluate_bond_query(query, mol_bond, mol, query_ctx)
3976    } else {
3977        query_bond.order() == BondOrder::Unspecified
3978            || mol_bond.order() == BondOrder::Unspecified
3979            || query_bond.order() == mol_bond.order()
3980    };
3981    if !matches {
3982        return false;
3983    }
3984
3985    if query_bond.order() == BondOrder::Dative && mol_bond.order() == BondOrder::Dative {
3986        let query_begin = &query_mol.atoms()[query_bond.begin().index()];
3987        let query_end = &query_mol.atoms()[query_bond.end().index()];
3988        let mol_begin = &mol.atoms()[mol_bond.begin().index()];
3989        let mol_end = &mol.atoms()[mol_bond.end().index()];
3990        if !atom_matches(query_begin, query_mol, mol_begin, mol)
3991            || !atom_matches(query_end, query_mol, mol_end, mol)
3992        {
3993            return false;
3994        }
3995    }
3996    if !params.bond_properties.is_empty()
3997        && !property_compat(
3998            query_bond.props(),
3999            mol_bond.props(),
4000            &params.bond_properties,
4001        )
4002    {
4003        return false;
4004    }
4005    if let Some(extra_bond_check) = &params.extra_bond_check
4006        && !extra_bond_check(query_bond, mol_bond)
4007    {
4008        return false;
4009    }
4010    matches
4011}
4012
4013fn remove_duplicates(matches: &mut Vec<SubstructMatchResult>, atom_count: usize) {
4014    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: removeDuplicates
4015    // RDKit✔️✔️: void removeDuplicates(std::vector<MatchVectType> &matches,
4016    // RDKit✔️✔️:                       unsigned int nAtoms) {
4017    // RDKit✔️✔️:   //
4018    // RDKit✔️✔️:   //  This works by tracking the indices of the atoms in each match vector.
4019    // RDKit✔️✔️:   //  This can lead to unexpected behavior when looking at rings and queries
4020    // RDKit✔️✔️:   //  that don't specify bond orders.  For example querying this molecule:
4021    // RDKit✔️✔️:   //    C1CCC=1
4022    // RDKit✔️✔️:   //  with the pattern constructed from SMARTS C~C~C~C will return a
4023    // RDKit✔️✔️:   //  single match, despite the fact that there are 4 different paths
4024    // RDKit✔️✔️:   //  when valence is considered.  The defense of this behavior is
4025    // RDKit✔️✔️:   //  that the 4 paths are equivalent in the semantics of the query.
4026    // RDKit✔️✔️:   //  Also, OELib returns the same results
4027    // RDKit✔️✔️:   //
4028    // RDKit✔️✔️:   std::unordered_set<std::string> seen;
4029    // RDKit✔️✔️:   std::vector<MatchVectType> res;
4030    // RDKit✔️✔️:   res.reserve(matches.size());
4031    // RDKit✔️✔️:   seen.reserve(matches.size());
4032    // RDKit✔️✔️:   for (const auto &match : matches) {
4033    // RDKit✔️✔️:     std::string val(nAtoms, '0');
4034    // RDKit✔️✔️:     for (const auto &ci : match) {
4035    // RDKit✔️✔️:       val[ci.second] = '1';
4036    // RDKit✔️✔️:     }
4037    // RDKit✔️✔️:     const bool inserted = seen.insert(std::move(val)).second;
4038    // RDKit✔️✔️:     if (inserted) {
4039    // RDKit✔️✔️:       res.push_back(match);
4040    // RDKit✔️✔️:     }
4041    // RDKit✔️✔️:   }
4042    // RDKit✔️✔️:   res.shrink_to_fit();
4043    // RDKit✔️✔️:   matches = std::move(res);
4044    // RDKit✔️✔️: }
4045    // END RDKIT CPP FUNCTION
4046    //
4047    // Local complexity review: both versions allocate one atom-count-sized
4048    // signature per examined match and use expected O(1) hash insertion, for
4049    // O(matches * atom_count) time and space bounded by unique signatures.
4050    // Vec<bool> packs the same binary information as the source string. Moving
4051    // accepted Rust match values avoids the source copy and preserves order.
4052    let mut seen = HashSet::with_capacity(matches.len());
4053    let mut unique = Vec::with_capacity(matches.len());
4054    for matched in matches.drain(..) {
4055        let mut signature = vec![false; atom_count];
4056        for &atom_index in &matched.atom_mapping {
4057            signature[atom_index] = true;
4058        }
4059        if seen.insert(signature) {
4060            unique.push(matched);
4061        }
4062    }
4063    unique.shrink_to_fit();
4064    *matches = unique;
4065}
4066
4067fn query_contains_atomic_number(
4068    query: &crate::QueryNode<AtomQueryPredicate>,
4069    atomic_number: u8,
4070) -> bool {
4071    match query {
4072        crate::QueryNode::Predicate(AtomQueryPredicate::AtomicNumber(value)) => {
4073            *value == atomic_number
4074        }
4075        crate::QueryNode::And(children)
4076        | crate::QueryNode::Or(children)
4077        | crate::QueryNode::Xor(children) => children
4078            .iter()
4079            .any(|child| query_contains_atomic_number(child, atomic_number)),
4080        crate::QueryNode::Not(child) => query_contains_atomic_number(child, atomic_number),
4081        crate::QueryNode::Predicate(_) => false,
4082    }
4083}
4084
4085pub(crate) fn is_atom_terminal_r_group_or_query_hydrogen(
4086    molecule: &Molecule,
4087    atom_index: usize,
4088) -> bool {
4089    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: isAtomTerminalRGroupOrQueryHydrogen
4090    // RDKit✔️✔️: bool isAtomTerminalRGroupOrQueryHydrogen(const Atom *atom) {
4091    // RDKit✔️✔️:   return (atom->getDegree() == 1 && isAtomDummy(atom)) ||
4092    // RDKit✔️✔️:          (atom->hasQuery() &&
4093    // RDKit✔️✔️:           describeQuery(atom).find("AtomAtomicNum 1 = val") !=
4094    // RDKit✔️✔️:               std::string::npos);
4095    // RDKit✔️✔️: }
4096    // END RDKIT CPP FUNCTION
4097    //
4098    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/QueryOps.h :: isAtomDummy
4099    // RDKit✔️✔️: inline bool isAtomDummy(const Atom *a) {
4100    // RDKit✔️✔️:   return (!a->hasQuery() && a->getAtomicNum() == 0) ||
4101    // RDKit✔️✔️:          (a->hasQuery() && !a->getQuery()->getNegation() &&
4102    // RDKit✔️✔️:           a->getQuery()->getDescription() == "AtomNull");
4103    // RDKit✔️✔️: }
4104    // END RDKIT CPP FUNCTION
4105    //
4106    // Local complexity review: degree is an indexed adjacency-slice length;
4107    // dummy classification is O(1), and the typed query traversal is O(n)
4108    // time/O(h) stack, matching describeQuery's traversal without allocating
4109    // its intermediate string. No molecule state or query node is cloned.
4110    let atom = &molecule.atoms()[atom_index];
4111    let is_dummy = match atom.query() {
4112        None => atom.atomic_number() == 0,
4113        Some(crate::QueryNode::Predicate(AtomQueryPredicate::Any)) => true,
4114        Some(_) => false,
4115    };
4116    (molecule
4117        .topology_block()
4118        .adjacency
4119        .neighbors_of(atom_index)
4120        .len()
4121        == 1
4122        && is_dummy)
4123        || atom
4124            .query()
4125            .is_some_and(|query| query_contains_atomic_number(query, 1))
4126}
4127
4128fn core_substitution_score(
4129    molecule: &Molecule,
4130    query: &Molecule,
4131    matched: &SubstructMatchResult,
4132) -> f64 {
4133    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: detail::ScoreMatchesByDegreeOfCoreSubstitution
4134    // RDKit✔️✔️: class ScoreMatchesByDegreeOfCoreSubstitution {
4135    // RDKit✔️✔️:  public:
4136    // RDKit✔️✔️:   typedef std::pair<unsigned int, double> IdxScorePair;
4137    // RDKit✔️✔️:   ScoreMatchesByDegreeOfCoreSubstitution(
4138    // RDKit✔️✔️:       const RDKit::ROMol &mol, const RDKit::ROMol &query,
4139    // RDKit✔️✔️:       const std::vector<RDKit::MatchVectType> &matches)
4140    // RDKit✔️✔️:       : d_mol(mol),
4141    // RDKit✔️✔️:         d_query(query),
4142    // RDKit✔️✔️:         d_matches(matches),
4143    // RDKit✔️✔️:         d_sumIndices(0.0),
4144    // RDKit✔️✔️:         d_minIdx(-1),
4145    // RDKit✔️✔️:         d_isSorted(false) {
4146    // RDKit✔️✔️:     PRECONDITION(!matches.empty(), "matches must not be empty");
4147    // RDKit✔️✔️:     auto na = d_mol.getNumAtoms();
4148    // RDKit✔️✔️:     d_sumIndices = static_cast<double>(na * (na + 1) / 2);
4149    // RDKit✔️✔️:     unsigned int i = 0;
4150    // RDKit✔️✔️:     d_matchIdxVsScore.reserve(d_matches.size());
4151    // RDKit✔️✔️:     for (const auto &match : d_matches) {
4152    // RDKit✔️✔️:       d_matchIdxVsScore.emplace_back(i++, computeScore(match));
4153    // RDKit✔️✔️:     }
4154    // RDKit✔️✔️:   }
4155    // RDKit✔️✔️:   const RDKit::MatchVectType &getMostSubstitutedCoreMatch() {
4156    // RDKit✔️✔️:     if (d_minIdx == -1) {
4157    // RDKit✔️✔️:       d_minIdx = std::min_element(d_matchIdxVsScore.begin(),
4158    // RDKit✔️✔️:                                   d_matchIdxVsScore.end(), compare)
4159    // RDKit✔️✔️:                      ->first;
4160    // RDKit✔️✔️:     }
4161    // RDKit✔️✔️:     return d_matches.at(d_minIdx);
4162    // RDKit✔️✔️:   }
4163    // RDKit✔️✔️:   std::vector<MatchVectType> sortMatchesByDegreeOfCoreSubstitution() {
4164    // RDKit✔️✔️:     if (!d_isSorted) {
4165    // RDKit✔️✔️:       std::sort(d_matchIdxVsScore.begin(), d_matchIdxVsScore.end(), compare);
4166    // RDKit✔️✔️:       d_isSorted = true;
4167    // RDKit✔️✔️:       d_minIdx = d_matchIdxVsScore.front().first;
4168    // RDKit✔️✔️:     }
4169    // RDKit✔️✔️:     std::vector<MatchVectType> res(d_matches.size());
4170    // RDKit✔️✔️:     std::transform(
4171    // RDKit✔️✔️:         d_matchIdxVsScore.begin(), d_matchIdxVsScore.end(), res.begin(),
4172    // RDKit✔️✔️:         [this](const IdxScorePair &pair) { return d_matches.at(pair.first); });
4173    // RDKit✔️✔️:     return res;
4174    // RDKit✔️✔️:   }
4175    // RDKit✔️✔️:
4176    // RDKit✔️✔️:  private:
4177    // RDKit✔️✔️:   static bool compare(const IdxScorePair &aPair, const IdxScorePair &bPair) {
4178    // RDKit✔️✔️:     return (aPair.second < bPair.second);
4179    // RDKit✔️✔️:   }
4180    // RDKit✔️✔️:   bool doesRGroupMatchHydrogen(const std::pair<int, int> &pair) const {
4181    // RDKit✔️✔️:     const auto queryAtom = d_query.getAtomWithIdx(pair.first);
4182    // RDKit✔️✔️:     const auto molAtom = d_mol.getAtomWithIdx(pair.second);
4183    // RDKit✔️✔️:     return (molAtom->getAtomicNum() == 1 &&
4184    // RDKit✔️✔️:             isAtomTerminalRGroupOrQueryHydrogen(queryAtom));
4185    // RDKit✔️✔️:   }
4186    // RDKit✔️✔️:   double computeScore(const RDKit::MatchVectType &match) const {
4187    // RDKit✔️✔️:     double penalty = 0.0;
4188    // RDKit✔️✔️:     double i = 0.0;
4189    // RDKit✔️✔️:     for (const auto &pair : match) {
4190    // RDKit✔️✔️:       i += static_cast<double>(pair.second);
4191    // RDKit✔️✔️:       if (doesRGroupMatchHydrogen(pair)) {
4192    // RDKit✔️✔️:         penalty += 1.0;
4193    // RDKit✔️✔️:       }
4194    // RDKit✔️✔️:     }
4195    // RDKit✔️✔️:     penalty += i / d_sumIndices;
4196    // RDKit✔️✔️:     return penalty;
4197    // RDKit✔️✔️:   }
4198    // RDKit✔️✔️:   const RDKit::ROMol &d_mol;
4199    // RDKit✔️✔️:   const RDKit::ROMol &d_query;
4200    // RDKit✔️✔️:   const std::vector<RDKit::MatchVectType> &d_matches;
4201    // RDKit✔️✔️:   std::vector<IdxScorePair> d_matchIdxVsScore;
4202    // RDKit✔️✔️:   double d_sumIndices;
4203    // RDKit✔️✔️:   int d_minIdx;
4204    // RDKit✔️✔️:   bool d_isSorted;
4205    // RDKit✔️✔️: };
4206    // END RDKIT CPP FUNCTION
4207    //
4208    // The Rust wrappers compute and retain the same per-match scores without
4209    // materializing a stateful scorer object.
4210    let atom_count = molecule.num_atoms();
4211    let sum_indices = (atom_count * (atom_count + 1) / 2) as f64;
4212    let mut penalty = 0.0;
4213    let mut index_sum = 0.0;
4214    for (query_index, &molecule_index) in matched.atom_mapping.iter().enumerate() {
4215        index_sum += molecule_index as f64;
4216        if molecule.atoms()[molecule_index].atomic_number() == 1
4217            && is_atom_terminal_r_group_or_query_hydrogen(query, query_index)
4218        {
4219            penalty += 1.0;
4220        }
4221    }
4222    penalty + index_sum / sum_indices
4223}
4224
4225fn get_most_substituted_core_match<'a>(
4226    molecule: &Molecule,
4227    query: &Molecule,
4228    matches: &'a [SubstructMatchResult],
4229) -> &'a SubstructMatchResult {
4230    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: getMostSubstitutedCoreMatch
4231    // RDKit✔️✔️: const MatchVectType &getMostSubstitutedCoreMatch(
4232    // RDKit✔️✔️:     const ROMol &mol, const ROMol &core,
4233    // RDKit✔️✔️:     const std::vector<MatchVectType> &matches) {
4234    // RDKit✔️✔️:   detail::ScoreMatchesByDegreeOfCoreSubstitution matchScorer(mol, core,
4235    // RDKit✔️✔️:                                                              matches);
4236    // RDKit✔️✔️:   return matchScorer.getMostSubstitutedCoreMatch();
4237    // RDKit✔️✔️: }
4238    // END RDKIT CPP FUNCTION
4239    //
4240    // The canonical scorer above reproduces the complete source helper class.
4241    // Local complexity review: one linear score pass and min selection gives
4242    // O(matches * query_atoms) time and O(1) auxiliary space, equivalent to
4243    // constructing and scanning RDKit's score vector, with fewer allocations.
4244    assert!(!matches.is_empty(), "matches must not be empty");
4245    matches
4246        .iter()
4247        .min_by(|left, right| {
4248            core_substitution_score(molecule, query, left)
4249                .total_cmp(&core_substitution_score(molecule, query, right))
4250        })
4251        .expect("non-empty matches")
4252}
4253
4254fn sort_matches_by_degree_of_core_substitution(
4255    molecule: &Molecule,
4256    query: &Molecule,
4257    matches: &[SubstructMatchResult],
4258) -> Vec<SubstructMatchResult> {
4259    // BEGIN RDKIT CPP FUNCTION: third_party/rdkit/Code/GraphMol/Substruct/SubstructUtils.cpp :: sortMatchesByDegreeOfCoreSubstitution
4260    // RDKit✔️✔️: std::vector<MatchVectType> sortMatchesByDegreeOfCoreSubstitution(
4261    // RDKit✔️✔️:     const ROMol &mol, const ROMol &core,
4262    // RDKit✔️✔️:     const std::vector<MatchVectType> &matches) {
4263    // RDKit✔️✔️:   detail::ScoreMatchesByDegreeOfCoreSubstitution matchScorer(mol, core,
4264    // RDKit✔️✔️:                                                              matches);
4265    // RDKit✔️✔️:   return matchScorer.sortMatchesByDegreeOfCoreSubstitution();
4266    // RDKit✔️✔️: }
4267    // END RDKIT CPP FUNCTION
4268    //
4269    // Local complexity review: scores are computed once and the indexed rows
4270    // are sorted in O(matches log matches), matching the source helper. The
4271    // returned mappings are cloned once, as in RDKit's result transform.
4272    assert!(!matches.is_empty(), "matches must not be empty");
4273    let mut scored = matches
4274        .iter()
4275        .enumerate()
4276        .map(|(index, matched)| (index, core_substitution_score(molecule, query, matched)))
4277        .collect::<Vec<_>>();
4278    scored.sort_by(|left, right| left.1.total_cmp(&right.1));
4279    scored
4280        .into_iter()
4281        .map(|(index, _)| matches[index].clone())
4282        .collect()
4283}
4284
4285fn substruct_match_impl(
4286    mol: &Molecule,
4287    query: &Molecule,
4288    params: &SubstructMatchParams,
4289) -> SubstructMatchResultList {
4290    // RDKit✔️❌: std::vector<MatchVectType> SubstructMatch(
4291    // RDKit✔️❌:     const ROMol &mol, const ROMol &query,
4292    // RDKit✔️❌:     const SubstructMatchParameters &params) {
4293    // RDKit✔️❌:   std::vector<MatchVectType> matches;
4294    // RDKit✔️❌:   const auto &mNumAtoms = mol.getNumAtoms();
4295    // RDKit✔️❌:   const auto &qNumAtoms = query.getNumAtoms();
4296    // RDKit✔️❌:   if (!mNumAtoms || !qNumAtoms || qNumAtoms > mNumAtoms) {
4297    // RDKit✔️❌:     return matches;
4298    // RDKit✔️❌:   }
4299    // RDKit✔️❌:
4300    // RDKit✔️❌:   detail::RecursiveLocker locker(query, params.recursionPossible);
4301    // RDKit✔️❌:
4302    // RDKit✔️❌:   if (params.recursionPossible) {
4303    // RDKit✔️❌:     detail::SUBQUERY_MAP subqueryMap;
4304    // RDKit✔️❌:     ROMol::ConstAtomIterator atIt;
4305    // RDKit✔️❌:     for (const auto atom : query.atoms()) {
4306    // RDKit✔️❌:       if (atom->hasQuery()) {
4307    // RDKit✔️❌:         detail::MatchSubqueries(mol, atom->getQuery(), params, subqueryMap,
4308    // RDKit✔️❌:                                 locker.locked);
4309    // RDKit✔️❌:       }
4310    // RDKit✔️❌:     }
4311    // RDKit✔️❌:   }
4312    // RDKit✔️❌:
4313    // RDKit✔️❌:   detail::AtomLabelFunctor atomLabeler(query, mol, params);
4314    // RDKit✔️❌:   detail::BondLabelFunctor bondLabeler(query, mol, params);
4315    // RDKit✔️❌:   MolMatchFinalCheckFunctor matchChecker(query, mol, params);
4316    // RDKit✔️❌:
4317    // RDKit✔️❌:   std::vector<detail::ssPairType> pms;
4318    // RDKit✔️❌:   bool found =
4319    // RDKit✔️❌:       boost::vf2_all(query.getTopology(), mol.getTopology(), atomLabeler,
4320    // RDKit✔️❌:                      bondLabeler, matchChecker, pms, params.maxMatches);
4321    // RDKit✔️❌:   if (found) {
4322    // RDKit✔️❌:     const unsigned int nQueryAtoms = query.getNumAtoms();
4323    // RDKit✔️❌:     matches.reserve(pms.size());
4324    // RDKit✔️❌:     MatchVectType matchVect(nQueryAtoms);
4325    // RDKit✔️❌:     for (const auto &pairs : pms) {
4326    // RDKit✔️❌:       for (const auto &pair : pairs) {
4327    // RDKit✔️❌:         matchVect[pair.first] = pair;
4328    // RDKit✔️❌:       }
4329    // RDKit✔️❌:       matches.push_back(matchVect);
4330    // RDKit✔️❌:     }
4331    // RDKit✔️❌:   }
4332    // RDKit✔️❌:   return matches;
4333    // RDKit✔️❌: }
4334    // Complexity review: preflight adds one linear query-tree scan for
4335    // fail-closed unsupported leaves. Recursive preparation, VF2 search, and
4336    // result materialization otherwise retain RDKit's asymptotic behavior.
4337    // The second marker remains ❌ because Rust's VF2 result path allocates
4338    // mapping Vecs at goal checks, as documented on the canonical VF2 core.
4339    preflight_query_molecule(query)?;
4340    if mol.num_atoms() == 0 || query.num_atoms() == 0 || query.num_atoms() > mol.num_atoms() {
4341        return Ok(Vec::new());
4342    }
4343    let mut recursive_locker = RecursiveLocker::new(query, params.recursion_possible);
4344    if params.recursion_possible {
4345        populate_recursive_query_match_cache(mol, query, params, &mut recursive_locker.cache)?;
4346    }
4347    substruct_match_impl_with_recursive_cache(mol, query, params, Some(&recursive_locker.cache))
4348}
4349
4350/// Check if a molecule contains a substructure match for the given query.
4351///
4352/// This is the public API for `has_substruct_match`.
4353/// RDKit✔️❌: VF2-based substructure matching ported from vf2.hpp + SubstructMatch.cpp.
4354pub fn has_substruct_match(mol: &Molecule, query: &Molecule) -> bool {
4355    let params = SubstructMatchParams::default();
4356    let mut params = params;
4357    params.max_matches = 1;
4358    substruct_match_impl(mol, query, &params)
4359        .map(|matches| !matches.is_empty())
4360        .unwrap_or(false)
4361}
4362
4363/// Get the first substructure match, if any.
4364///
4365/// This is the public API for `get_substruct_match`.
4366/// RDKit✔️❌: VF2-based substructure matching ported from vf2.hpp + SubstructMatch.cpp.
4367pub fn get_substruct_match(mol: &Molecule, query: &Molecule) -> Option<SubstructMatchResult> {
4368    let params = SubstructMatchParams::default();
4369    let mut params = params;
4370    params.max_matches = 1;
4371    substruct_match_impl(mol, query, &params)
4372        .ok()
4373        .and_then(|matches| matches.into_iter().next())
4374}
4375
4376/// Get all substructure matches with default parameters.
4377///
4378/// This is the public API for `get_substruct_matches`.
4379/// RDKit✔️❌: VF2-based substructure matching ported from vf2.hpp + SubstructMatch.cpp.
4380pub fn get_substruct_matches(mol: &Molecule, query: &Molecule) -> Vec<SubstructMatchResult> {
4381    let params = SubstructMatchParams::default();
4382    substruct_match_impl(mol, query, &params).unwrap_or_default()
4383}
4384
4385/// Get all substructure matches with custom parameters.
4386///
4387/// This is the public API for `get_substruct_matches_with_params`.
4388/// RDKit✔️❌: VF2-based substructure matching ported from vf2.hpp + SubstructMatch.cpp.
4389pub fn get_substruct_matches_with_params(
4390    mol: &Molecule,
4391    query: &Molecule,
4392    params: &SubstructMatchParams,
4393) -> Vec<SubstructMatchResult> {
4394    substruct_match_impl(mol, query, params).unwrap_or_default()
4395}
4396
4397/// Get all substructure matches with custom parameters and structured
4398/// unsupported-feature errors for source-porting callers.
4399pub fn try_get_substruct_matches_with_params(
4400    mol: &Molecule,
4401    query: &Molecule,
4402    params: &SubstructMatchParams,
4403) -> SubstructMatchResultList {
4404    substruct_match_impl(mol, query, params)
4405}
4406
4407pub(crate) fn try_get_substruct_matches_with_params_and_context(
4408    mol: &Molecule,
4409    query: &Molecule,
4410    params: &SubstructMatchParams,
4411    query_context: &QueryMatchContext,
4412) -> SubstructMatchResultList {
4413    // This narrow entry retains the canonical preflight, recursive-query
4414    // preparation, VF2 implementation, final checks, and result ordering. It
4415    // only lets callers that run several immutable queries against one target
4416    // reuse the target-derived match context, as RDKit reuses ROMol state.
4417    preflight_query_molecule(query)?;
4418    if mol.num_atoms() == 0 || query.num_atoms() == 0 || query.num_atoms() > mol.num_atoms() {
4419        return Ok(Vec::new());
4420    }
4421    let mut recursive_locker = RecursiveLocker::new(query, params.recursion_possible);
4422    if params.recursion_possible {
4423        populate_recursive_query_match_cache(mol, query, params, &mut recursive_locker.cache)?;
4424    }
4425    substruct_match_impl_with_recursive_cache_and_context(
4426        mol,
4427        query,
4428        params,
4429        Some(&recursive_locker.cache),
4430        query_context,
4431    )
4432}
4433
4434// ---------------------------------------------------------------------------
4435// Tests
4436// ---------------------------------------------------------------------------
4437
4438#[cfg(test)]
4439mod tests {
4440    use super::*;
4441    use crate::MoleculeBuilder;
4442    use crate::search::smarts_parse::compile_query_fixture;
4443
4444    #[test]
4445    fn smarts_ring_connectivity_zero_rejects_ring_atoms() {
4446        let chain = Molecule::from_smiles("CCC").expect("chain");
4447        let ring = Molecule::from_smiles("C1CCCCC1").expect("ring");
4448        let no_ring_bonds = compile_query_fixture("[Cx0]").expect("x0 query");
4449        let has_ring_bond = compile_query_fixture("[Cx]").expect("x query");
4450
4451        assert!(!get_substruct_matches(&chain, &no_ring_bonds).is_empty());
4452        assert!(get_substruct_matches(&ring, &no_ring_bonds).is_empty());
4453        assert!(get_substruct_matches(&chain, &has_ring_bond).is_empty());
4454        assert!(!get_substruct_matches(&ring, &has_ring_bond).is_empty());
4455    }
4456
4457    #[test]
4458    fn shared_count_swaps_substruct_preserves_none_failure_mapping() {
4459        assert_eq!(count_swaps_to_interconvert_i32(&[1, 2], &[1]), None);
4460        assert_eq!(count_swaps_to_interconvert_i32(&[1, 2], &[1, 3]), None);
4461    }
4462
4463    fn make_mol_c() -> Molecule {
4464        // Methane: C
4465        let mut builder = MoleculeBuilder::new();
4466        builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4467        builder.build().expect("build methane")
4468    }
4469
4470    fn make_mol_cc() -> Molecule {
4471        // Ethane: CC
4472        let mut builder = MoleculeBuilder::new();
4473        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4474        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4475        builder
4476            .add_bond(crate::BondSpec::new(c0, c1, BondOrder::Single))
4477            .expect("add bond");
4478        builder.build().expect("build ethane")
4479    }
4480
4481    fn make_mol_cco() -> Molecule {
4482        // Ethanol: CCO
4483        let mut builder = MoleculeBuilder::new();
4484        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4485        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4486        let o = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
4487        builder
4488            .add_bond(crate::BondSpec::new(c0, c1, BondOrder::Single))
4489            .expect("add C-C bond");
4490        builder
4491            .add_bond(crate::BondSpec::new(c1, o, BondOrder::Single))
4492            .expect("add C-O bond");
4493        builder.build().expect("build ethanol")
4494    }
4495
4496    fn make_mol_coc() -> Molecule {
4497        // Dimethyl ether: COC
4498        let mut builder = MoleculeBuilder::new();
4499        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4500        let o = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
4501        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4502        builder
4503            .add_bond(crate::BondSpec::new(c0, o, BondOrder::Single))
4504            .expect("add C-O bond 1");
4505        builder
4506            .add_bond(crate::BondSpec::new(o, c1, BondOrder::Single))
4507            .expect("add O-C bond 2");
4508        builder.build().expect("build dimethyl ether")
4509    }
4510
4511    #[test]
4512    fn test_has_substruct_match_self() {
4513        let c = make_mol_c();
4514        assert!(
4515            has_substruct_match(&c, &c),
4516            "a molecule should match itself"
4517        );
4518    }
4519
4520    #[test]
4521    fn test_has_substruct_match_cc_in_cco() {
4522        let cc = make_mol_cc();
4523        let cco = make_mol_cco();
4524        assert!(
4525            has_substruct_match(&cco, &cc),
4526            "CCO should contain CC as substructure"
4527        );
4528    }
4529
4530    #[test]
4531    fn test_has_substruct_match_no_match() {
4532        let c = make_mol_c();
4533        let cco = make_mol_cco();
4534        assert!(
4535            !has_substruct_match(&c, &cco),
4536            "a single carbon should not contain CCO"
4537        );
4538    }
4539
4540    #[test]
4541    fn test_get_substruct_match_self() {
4542        let cco = make_mol_cco();
4543        let result = get_substruct_match(&cco, &cco);
4544        assert!(result.is_some(), "self-match should return Some");
4545        let result = result.unwrap();
4546        assert_eq!(result.atom_mapping.len(), 3);
4547        // Identity mapping: 0->0, 1->1, 2->2
4548        for (qa, ma) in result.atom_mapping.iter().enumerate() {
4549            assert_eq!(*ma, qa, "self-match should have identity mapping");
4550        }
4551    }
4552
4553    #[test]
4554    fn test_get_substruct_match_cc_in_cco() {
4555        let cc = make_mol_cc();
4556        let cco = make_mol_cco();
4557        let result = get_substruct_match(&cco, &cc);
4558        assert!(result.is_some(), "CC should match in CCO");
4559    }
4560
4561    #[test]
4562    fn test_get_substruct_match_no_match() {
4563        let c = make_mol_c();
4564        let cco = make_mol_cco();
4565        let result = get_substruct_match(&c, &cco);
4566        assert!(
4567            result.is_none(),
4568            "C should not match CCO (query larger than mol)"
4569        );
4570    }
4571
4572    #[test]
4573    fn test_get_substruct_matches_cco_in_cco() {
4574        let cco = make_mol_cco();
4575        let matches = get_substruct_matches(&cco, &cco);
4576        assert!(!matches.is_empty(), "should find at least self-match");
4577    }
4578
4579    #[test]
4580    fn test_substruct_coc_matches_cco() {
4581        // COC (dimethyl ether) should not match CCO (ethanol) — different topology.
4582        let coc = make_mol_coc();
4583        let cco = make_mol_cco();
4584        assert!(
4585            !has_substruct_match(&cco, &coc),
4586            "CCO should not match COC topology"
4587        );
4588        // But CO should match CCO (CO is a substructure of CCO).
4589        let mut builder = MoleculeBuilder::new();
4590        let c = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4591        let o = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
4592        builder
4593            .add_bond(crate::BondSpec::new(c, o, BondOrder::Single))
4594            .expect("add CO bond");
4595        let co = builder.build().expect("build CO");
4596        assert!(has_substruct_match(&cco, &co), "CCO should match CO");
4597    }
4598
4599    #[test]
4600    fn test_has_substruct_match_empty_mol() {
4601        let empty = Molecule::new();
4602        let c = make_mol_c();
4603        assert!(
4604            !has_substruct_match(&empty, &c),
4605            "empty molecule should not match anything"
4606        );
4607        assert!(
4608            !has_substruct_match(&c, &empty),
4609            "molecule should not match empty query"
4610        );
4611    }
4612
4613    #[test]
4614    fn test_substruct_match_params_max_matches() {
4615        let c = make_mol_c();
4616        let params = SubstructMatchParams {
4617            max_matches: 1,
4618            uniquify: true,
4619            use_chirality: false,
4620            specified_stereo_query_matches_unspecified: false,
4621            ..Default::default()
4622        };
4623        let matches = get_substruct_matches_with_params(&c, &c, &params);
4624        assert_eq!(matches.len(), 1, "max_matches=1 should return one match");
4625    }
4626
4627    #[test]
4628    fn feature_smarts_substruct_matches_required_query_semantics() {
4629        let cases = [
4630            (
4631                "Donor",
4632                "[$([N;!H0;v3,v4&+1]),$([O,S;H1;+0]),n&H1&+0]",
4633                "CCO",
4634                vec![2],
4635                vec![2],
4636            ),
4637            (
4638                "Acceptor",
4639                "[$([O,S;H1;v2;!$(*-*=[O,N,P,S])]),$([O,S;H0;v2]),$([O,S;-]),$([N;v3;!$(N-*=[O,N,P,S])]),n&H0&+0,$([o,s;+0;!$([o,s]:n);!$([o,s]:c:n)])]",
4640                "CC(=O)C",
4641                vec![2],
4642                vec![2],
4643            ),
4644            (
4645                "Aromatic",
4646                "[a]",
4647                "c1ccccc1",
4648                vec![0],
4649                vec![0, 1, 2, 3, 4, 5],
4650            ),
4651            ("Halogen", "[F,Cl,Br,I]", "CCl", vec![1], vec![1]),
4652            (
4653                "Basic",
4654                "[#7;+,$([N;H2&+0][$([C,a]);!$([C,a](=O))]),$([N;H1&+0]([$([C,a]);!$([C,a](=O))])[$([C,a]);!$([C,a](=O))]),$([N;H0&+0]([C;!$(C(=O))])([C;!$(C(=O))])[C;!$(C(=O))])]",
4655                "[NH4+]",
4656                vec![0],
4657                vec![0],
4658            ),
4659            (
4660                "Acidic",
4661                "[$([C,S](=[O,S,P])-[O;H1,-1])]",
4662                "CC(=O)O",
4663                vec![1],
4664                vec![1],
4665            ),
4666        ];
4667
4668        for (name, smarts, smiles, expected_first, expected_atoms) in cases {
4669            let mol = Molecule::from_smiles_with_sanitize(smiles, false)
4670                .unwrap_or_else(|_| panic!("{name} molecule should parse"));
4671            let query = compile_query_fixture(smarts)
4672                .unwrap_or_else(|_| panic!("{name} SMARTS should build query molecule"));
4673            let matches = get_substruct_matches(&mol, &query);
4674            assert!(
4675                !matches.is_empty(),
4676                "{name} should produce at least one match"
4677            );
4678            assert_eq!(
4679                matches[0].atom_mapping, expected_first,
4680                "{name} first match atom mapping"
4681            );
4682            let mut atom_indices: Vec<usize> = matches
4683                .iter()
4684                .flat_map(|matched| matched.atom_mapping.iter().copied())
4685                .filter(|idx| *idx != NULL_NODE)
4686                .collect();
4687            atom_indices.sort_unstable();
4688            atom_indices.dedup();
4689            assert_eq!(atom_indices, expected_atoms, "{name} feature SMARTS");
4690        }
4691    }
4692
4693    #[test]
4694    fn lipinski_hba_recursive_smarts_matches_rdkit_root_semantics() {
4695        const HBA: &str = "[$([O,S;H1;v2]-[!$(*=[O,N,P,S])]),$([O,S;H0;v2]),$([O,S;-]),$([N;v3;!$(N-*=!@[O,N,P,S])]),$([nH0X2,o,s;+0])]";
4696        let cases = [
4697            (
4698                "alcohol oxygen recursive branch",
4699                "[O,S;H1;v2]-[!$(*=[O,N,P,S])]",
4700                "CCO",
4701                vec![vec![2, 1]],
4702            ),
4703            (
4704                "carboxylic acid oxygen rejected by negated recursive neighbor",
4705                "[O,S;H1;v2]-[!$(*=[O,N,P,S])]",
4706                "CC(=O)O",
4707                Vec::<Vec<usize>>::new(),
4708            ),
4709            (
4710                "amine nitrogen recursive branch",
4711                "[N;v3;!$(N-*=!@[O,N,P,S])]",
4712                "CCN",
4713                vec![vec![2]],
4714            ),
4715            (
4716                "amide nitrogen rejected by mixed bond recursive query",
4717                "[N;v3;!$(N-*=!@[O,N,P,S])]",
4718                "CC(=O)N",
4719                Vec::<Vec<usize>>::new(),
4720            ),
4721            (
4722                "mixed single-double non-ring bond query",
4723                "N-*=!@[O,N,P,S]",
4724                "CC(=O)N",
4725                vec![vec![3, 1, 2]],
4726            ),
4727            ("full HBA ethanol", HBA, "CCO", vec![vec![2]]),
4728            ("full HBA carboxylic acid", HBA, "CC(=O)O", vec![vec![2]]),
4729            ("full HBA amide", HBA, "CC(=O)N", vec![vec![2]]),
4730            ("full HBA pyridine", HBA, "c1ccncc1", vec![vec![3]]),
4731            ("full HBA furan", HBA, "c1ccoc1", vec![vec![3]]),
4732        ];
4733
4734        for (name, smarts, smiles, expected) in cases {
4735            let mol = Molecule::from_smiles_with_sanitize(smiles, true)
4736                .unwrap_or_else(|_| panic!("{name} molecule should parse"));
4737            let query = compile_query_fixture(smarts)
4738                .unwrap_or_else(|_| panic!("{name} SMARTS should build"));
4739            let matches = get_substruct_matches(&mol, &query);
4740            let atom_mappings = matches
4741                .iter()
4742                .map(|matched| matched.atom_mapping.clone())
4743                .collect::<Vec<_>>();
4744            assert_eq!(atom_mappings, expected, "{name}");
4745        }
4746    }
4747
4748    #[test]
4749    fn smarts_recursive_compiled_query() {
4750        let query = compile_query_fixture("[$(C=O)_101,$(C=O)_101]")
4751            .expect("recursive SMARTS should compile once during parsing");
4752        let molecule = Molecule::from_smiles("CC(=O)C").expect("acetone fixture");
4753        let mut cache = RecursiveQueryMatchCache::new();
4754        populate_recursive_query_match_cache(
4755            &molecule,
4756            &query,
4757            &SubstructMatchParams::default(),
4758            &mut cache,
4759        )
4760        .expect("compiled recursive queries should populate the match cache");
4761
4762        assert_eq!(
4763            cache.len(),
4764            1,
4765            "equal serial numbers share one compiled result"
4766        );
4767        assert_eq!(
4768            get_substruct_matches(&molecule, &query)[0].atom_mapping,
4769            vec![1]
4770        );
4771    }
4772
4773    #[test]
4774    fn smarts_match_recursive() {
4775        let molecule = Molecule::from_smiles("CC(=O)C").expect("acetone fixture");
4776        let rooted_query = compile_query_fixture("C=O")
4777            .expect("inner query")
4778            .with_prop("_queryRootAtom", "1");
4779        let mut cache = RecursiveQueryMatchCache::new();
4780        let starts = recursive_matcher(
4781            &molecule,
4782            &rooted_query,
4783            &SubstructMatchParams::default(),
4784            &mut cache,
4785        )
4786        .expect("rooted recursive matcher");
4787        assert_eq!(
4788            starts
4789                .iter()
4790                .enumerate()
4791                .filter_map(|(index, matched)| matched.then_some(index))
4792                .collect::<Vec<_>>(),
4793            vec![2]
4794        );
4795
4796        let propane = Molecule::from_smiles("CCC").expect("propane fixture");
4797        let carbon = compile_query_fixture("C").expect("carbon query");
4798        let params = SubstructMatchParams {
4799            max_matches: 1,
4800            max_recursive_matches: 3,
4801            ..SubstructMatchParams::default()
4802        };
4803        let starts = recursive_matcher(
4804            &propane,
4805            &carbon,
4806            &params,
4807            &mut RecursiveQueryMatchCache::new(),
4808        )
4809        .expect("recursive match limit");
4810        assert_eq!(starts, vec![true, true, true]);
4811    }
4812
4813    #[test]
4814    fn smarts_match_subqueries_execute() {
4815        let molecule = Molecule::from_smiles("CC(=O)C").expect("acetone fixture");
4816        let query =
4817            compile_query_fixture("[$(C=O)_101,$(C=O)_101]").expect("serial recursive query");
4818        let query_node = query.atoms()[0].query().expect("atom query tree");
4819        let mut cache = RecursiveQueryMatchCache::new();
4820        match_subqueries(
4821            &molecule,
4822            query_node,
4823            &SubstructMatchParams::default(),
4824            &mut cache,
4825        )
4826        .expect("execute recursive query tree");
4827
4828        assert_eq!(cache.len(), 1, "equal serials reuse one result");
4829        assert_eq!(
4830            cache.get(&RecursiveQueryCacheKey::Serial(101)),
4831            Some(&vec![false, true, false, false])
4832        );
4833    }
4834
4835    #[test]
4836    fn smarts_match_recursive_lock() {
4837        let molecule = Molecule::from_smiles("CC(=O)C").expect("acetone fixture");
4838        let query = compile_query_fixture("[$(C=O)]").expect("recursive SMARTS query");
4839        let enabled = SubstructMatchParams::default();
4840        assert_eq!(
4841            try_get_substruct_matches_with_params(&molecule, &query, &enabled)
4842                .expect("enabled recursive match")[0]
4843                .atom_mapping,
4844            vec![1]
4845        );
4846
4847        let disabled = SubstructMatchParams {
4848            recursion_possible: false,
4849            ..SubstructMatchParams::default()
4850        };
4851        assert!(
4852            try_get_substruct_matches_with_params(&molecule, &query, &disabled)
4853                .expect("disabled recursion is a non-match")
4854                .is_empty()
4855        );
4856
4857        assert_eq!(
4858            try_get_substruct_matches_with_params(&molecule, &query, &enabled)
4859                .expect("recursive state is rebuilt after scoped cleanup")[0]
4860                .atom_mapping,
4861            vec![1]
4862        );
4863    }
4864
4865    #[test]
4866    fn smarts_match_entry() {
4867        let empty = Molecule::new();
4868        let carbon = Molecule::from_smiles("C").expect("carbon fixture");
4869        assert!(
4870            try_get_substruct_matches_with_params(
4871                &empty,
4872                &carbon,
4873                &SubstructMatchParams::default(),
4874            )
4875            .expect("empty target")
4876            .is_empty()
4877        );
4878        assert!(
4879            try_get_substruct_matches_with_params(
4880                &carbon,
4881                &empty,
4882                &SubstructMatchParams::default(),
4883            )
4884            .expect("empty query")
4885            .is_empty()
4886        );
4887
4888        let ethane = Molecule::from_smiles("CC").expect("ethane fixture");
4889        assert!(
4890            try_get_substruct_matches_with_params(
4891                &carbon,
4892                &ethane,
4893                &SubstructMatchParams::default(),
4894            )
4895            .expect("oversized query")
4896            .is_empty()
4897        );
4898
4899        let propane = Molecule::from_smiles("CCC").expect("propane fixture");
4900        let params = SubstructMatchParams {
4901            max_matches: 1,
4902            uniquify: false,
4903            ..SubstructMatchParams::default()
4904        };
4905        let matches = try_get_substruct_matches_with_params(&propane, &ethane, &params)
4906            .expect("bounded entry match");
4907        assert_eq!(matches.len(), 1);
4908        assert_eq!(matches[0].atom_mapping.len(), ethane.num_atoms());
4909        assert_eq!(matches[0].atom_mapping, vec![0, 1]);
4910    }
4911
4912    #[test]
4913    fn smarts_unsupported_query_errors() {
4914        let target = Molecule::from_smiles("CC").expect("target fixture");
4915        let params = SubstructMatchParams::default();
4916
4917        let mut atom_builder = MoleculeBuilder::new();
4918        atom_builder.add_atom(crate::AtomSpec::new(crate::Element::C).with_query(
4919            crate::QueryNode::and(vec![
4920                crate::QueryNode::predicate(AtomQueryPredicate::Any),
4921                crate::QueryNode::not(crate::QueryNode::predicate(
4922                    AtomQueryPredicate::UnsupportedFeature("unsupported atom leaf"),
4923                )),
4924            ]),
4925        ));
4926        let atom_query = atom_builder.build().expect("atom query fixture");
4927        assert_eq!(
4928            try_get_substruct_matches_with_params(&target, &atom_query, &params),
4929            Err(SubstructMatchError::Unsupported {
4930                branch: "unsupported atom leaf",
4931                rdkit_function: "QueryAtom::Match",
4932            })
4933        );
4934
4935        let mut bond_builder = MoleculeBuilder::new();
4936        let begin = bond_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4937        let end = bond_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
4938        bond_builder
4939            .add_bond(
4940                crate::BondSpec::new(begin, end, BondOrder::Single).with_query(
4941                    crate::QueryNode::or(vec![
4942                        crate::QueryNode::predicate(BondQueryPredicate::Any),
4943                        crate::QueryNode::predicate(BondQueryPredicate::UnsupportedFeature(
4944                            "unsupported bond leaf",
4945                        )),
4946                    ]),
4947                ),
4948            )
4949            .expect("bond query edge");
4950        let bond_query = bond_builder.build().expect("bond query fixture");
4951        assert_eq!(
4952            try_get_substruct_matches_with_params(&target, &bond_query, &params),
4953            Err(SubstructMatchError::Unsupported {
4954                branch: "unsupported bond leaf",
4955                rdkit_function: "QueryBond::Match",
4956            })
4957        );
4958
4959        let mut inner_builder = MoleculeBuilder::new();
4960        inner_builder.add_atom(crate::AtomSpec::new(crate::Element::C).with_query(
4961            crate::QueryNode::predicate(AtomQueryPredicate::UnsupportedFeature(
4962                "unsupported recursive leaf",
4963            )),
4964        ));
4965        let recursive_query = crate::search::query::RecursiveStructureQuery::from_molecule(
4966            inner_builder.build().expect("inner query fixture"),
4967            0,
4968        );
4969        let mut outer_builder = MoleculeBuilder::new();
4970        outer_builder.add_atom(crate::AtomSpec::new(crate::Element::DUMMY).with_query(
4971            crate::QueryNode::predicate(AtomQueryPredicate::RecursiveSmarts(recursive_query)),
4972        ));
4973        let outer_query = outer_builder.build().expect("outer query fixture");
4974        assert_eq!(
4975            try_get_substruct_matches_with_params(&target, &outer_query, &params),
4976            Err(SubstructMatchError::Unsupported {
4977                branch: "unsupported recursive leaf",
4978                rdkit_function: "QueryAtom::Match",
4979            })
4980        );
4981    }
4982
4983    #[test]
4984    fn smarts_substruct_property_compat() {
4985        fn properties(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
4986            entries
4987                .iter()
4988                .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
4989                .collect()
4990        }
4991
4992        let requested = vec!["test_prop".to_owned()];
4993        let empty = properties(&[]);
4994        let one = properties(&[("test_prop", "1")]);
4995        let same = properties(&[("test_prop", "1"), ("ignored", "left")]);
4996        let different = properties(&[("test_prop", "2")]);
4997        let unrequested_difference = properties(&[("ignored", "right")]);
4998
4999        assert!(property_compat(&empty, &empty, &requested));
5000        assert!(property_compat(&one, &same, &requested));
5001        assert!(!property_compat(&one, &different, &requested));
5002        assert!(!property_compat(&one, &empty, &requested));
5003        assert!(!property_compat(&empty, &one, &requested));
5004        assert!(property_compat(&empty, &unrequested_difference, &requested));
5005        assert!(property_compat(&one, &different, &[]));
5006        assert!(!property_compat(
5007            &properties(&[("first", "same"), ("second", "left")]),
5008            &properties(&[("first", "same"), ("second", "right")]),
5009            &["first".to_owned(), "second".to_owned()],
5010        ));
5011    }
5012
5013    #[test]
5014    fn smarts_substruct_atom_compat() {
5015        fn carbon_chain(atom_count: usize, first_property: Option<&str>) -> Molecule {
5016            let mut builder = MoleculeBuilder::new();
5017            let mut atoms = Vec::with_capacity(atom_count);
5018            for atom_index in 0..atom_count {
5019                let mut atom = crate::AtomSpec::new(crate::Element::C);
5020                if atom_index == 0
5021                    && let Some(value) = first_property
5022                {
5023                    atom = atom.with_prop("test_prop", value);
5024                }
5025                atoms.push(builder.add_atom(atom));
5026            }
5027            for pair in atoms.windows(2) {
5028                builder
5029                    .add_bond(crate::BondSpec::new(pair[0], pair[1], BondOrder::Single))
5030                    .expect("chain bond");
5031            }
5032            builder.build().expect("carbon chain")
5033        }
5034
5035        let mut property_params = SubstructMatchParams::default();
5036        property_params.atom_properties = vec!["test_prop".to_owned()];
5037        let cases = [
5038            (None, None, 7),
5039            (Some("1"), Some("1"), 1),
5040            (Some("1"), None, 6),
5041            (None, Some("1"), 0),
5042            (Some("1"), Some("2"), 0),
5043        ];
5044        for (target_property, query_property, expected) in cases {
5045            let target = carbon_chain(9, target_property);
5046            let query = carbon_chain(3, query_property);
5047            assert_eq!(
5048                get_substruct_matches_with_params(&target, &query, &property_params).len(),
5049                expected,
5050                "target={target_property:?}, query={query_property:?}"
5051            );
5052        }
5053
5054        let query_query_molecule = |predicate| {
5055            let mut builder = MoleculeBuilder::new();
5056            builder.add_atom(
5057                crate::AtomSpec::new(crate::Element::C)
5058                    .with_query(crate::QueryNode::predicate(predicate)),
5059            );
5060            builder.build().expect("single query atom")
5061        };
5062        let query = query_query_molecule(AtomQueryPredicate::AtomicNumber(6));
5063        let target = query_query_molecule(AtomQueryPredicate::AtomicNumber(8));
5064        assert!(has_substruct_match(&target, &query));
5065        let mut query_query_params = SubstructMatchParams::default();
5066        query_query_params.use_query_query_matches = true;
5067        assert!(get_substruct_matches_with_params(&target, &query, &query_query_params).is_empty());
5068
5069        let carbon = carbon_chain(1, None);
5070        let mut oxygen_builder = MoleculeBuilder::new();
5071        oxygen_builder.add_atom(crate::AtomSpec::new(crate::Element::O));
5072        let oxygen = oxygen_builder.build().expect("oxygen query");
5073
5074        let mut callback_params = SubstructMatchParams::default();
5075        let expected_query_index = 0;
5076        callback_params.extra_atom_check = Some(Arc::new(move |_, query_atom, _, mol_atom| {
5077            query_atom.id().index() == expected_query_index && mol_atom.atomic_number() == 6
5078        }));
5079        callback_params.extra_atom_check_overrides_default_check = true;
5080        assert_eq!(
5081            get_substruct_matches_with_params(&carbon, &oxygen, &callback_params).len(),
5082            1
5083        );
5084
5085        callback_params.extra_atom_check_overrides_default_check = false;
5086        assert!(get_substruct_matches_with_params(&carbon, &oxygen, &callback_params).is_empty());
5087
5088        callback_params.extra_atom_check = Some(Arc::new(|_, _, _, _| false));
5089        assert!(get_substruct_matches_with_params(&carbon, &carbon, &callback_params).is_empty());
5090    }
5091
5092    #[test]
5093    fn smarts_match_atom_coords() {
5094        fn one_atom_with_conformers(conformers: &[(usize, [f64; 3])]) -> Molecule {
5095            let mut builder = MoleculeBuilder::new();
5096            builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5097            for &(id, position) in conformers {
5098                builder
5099                    .add_conformer(crate::Conformer3D::new(id, vec![position], true))
5100                    .expect("add conformer");
5101            }
5102            builder.build().expect("coordinate fixture")
5103        }
5104
5105        let query = one_atom_with_conformers(&[(0, [0.0, 0.0, 0.1]), (7, [5.0, 0.0, 0.0])]);
5106        let target = one_atom_with_conformers(&[(0, [0.0, 0.0, 0.0]), (9, [5.1, 0.0, 0.0])]);
5107        let missing = one_atom_with_conformers(&[]);
5108
5109        let default_matcher = AtomCoordsMatchFunctor::default();
5110        assert!(!default_matcher.matches(&query, &query.atoms()[0], &target, &target.atoms()[0],));
5111        assert!(
5112            !default_matcher.matches(&query, &query.atoms()[0], &missing, &missing.atoms()[0],)
5113        );
5114
5115        let matcher = AtomCoordsMatchFunctor::new(9, 7, 0.15);
5116        assert!(matcher.matches(&query, &query.atoms()[0], &target, &target.atoms()[0],));
5117        let mut params = SubstructMatchParams::default();
5118        params.extra_atom_check = Some(Arc::new(move |query_mol, query_atom, mol, mol_atom| {
5119            matcher.matches(query_mol, query_atom, mol, mol_atom)
5120        }));
5121        assert_eq!(
5122            try_get_substruct_matches_with_params(&target, &query, &params)
5123                .expect("coordinate-constrained match")
5124                .len(),
5125            1
5126        );
5127    }
5128
5129    #[test]
5130    fn smarts_substruct_chiral_atom_compat() {
5131        fn atom(element: crate::Element, cip: Option<&str>) -> Molecule {
5132            let mut spec = crate::AtomSpec::new(element);
5133            if let Some(cip) = cip {
5134                spec = spec.with_prop("_CIPCode", cip);
5135            }
5136            let mut builder = MoleculeBuilder::new();
5137            builder.add_atom(spec);
5138            builder.build().expect("single atom fixture")
5139        }
5140
5141        let carbon = atom(crate::Element::C, None);
5142        let oxygen = atom(crate::Element::O, None);
5143        assert!(!chiral_atom_compat(
5144            &carbon.atoms()[0],
5145            &carbon,
5146            &oxygen.atoms()[0],
5147            &oxygen,
5148        ));
5149
5150        assert!(chiral_atom_compat(
5151            &carbon.atoms()[0],
5152            &carbon,
5153            &carbon.atoms()[0],
5154            &carbon,
5155        ));
5156
5157        let carbon_r = atom(crate::Element::C, Some("R"));
5158        let another_carbon_r = atom(crate::Element::C, Some("R"));
5159        let carbon_s = atom(crate::Element::C, Some("S"));
5160        assert!(chiral_atom_compat(
5161            &carbon_r.atoms()[0],
5162            &carbon_r,
5163            &another_carbon_r.atoms()[0],
5164            &another_carbon_r,
5165        ));
5166        assert!(!chiral_atom_compat(
5167            &carbon_r.atoms()[0],
5168            &carbon_r,
5169            &carbon_s.atoms()[0],
5170            &carbon_s,
5171        ));
5172        assert!(!chiral_atom_compat(
5173            &carbon_r.atoms()[0],
5174            &carbon_r,
5175            &carbon.atoms()[0],
5176            &carbon,
5177        ));
5178        assert!(!chiral_atom_compat(
5179            &carbon.atoms()[0],
5180            &carbon,
5181            &carbon_r.atoms()[0],
5182            &carbon_r,
5183        ));
5184    }
5185
5186    #[test]
5187    fn smarts_substruct_bond_compat() {
5188        fn two_atom_molecule(
5189            begin: crate::Element,
5190            end: crate::Element,
5191            bond: crate::BondSpec,
5192        ) -> Molecule {
5193            let mut builder = MoleculeBuilder::new();
5194            builder.add_atom(crate::AtomSpec::new(begin));
5195            builder.add_atom(crate::AtomSpec::new(end));
5196            builder.add_bond(bond).expect("two-atom bond");
5197            builder.build().expect("two-atom molecule")
5198        }
5199
5200        fn compatible(query: &Molecule, target: &Molecule, params: &SubstructMatchParams) -> bool {
5201            bond_compat(
5202                &query.bonds()[0],
5203                query,
5204                &target.bonds()[0],
5205                target,
5206                params,
5207                &build_query_match_context(target),
5208            )
5209        }
5210
5211        let single = two_atom_molecule(
5212            crate::Element::C,
5213            crate::Element::C,
5214            crate::BondSpec::new(
5215                crate::AtomId::new(0),
5216                crate::AtomId::new(1),
5217                BondOrder::Single,
5218            ),
5219        );
5220        let double = two_atom_molecule(
5221            crate::Element::C,
5222            crate::Element::C,
5223            crate::BondSpec::new(
5224                crate::AtomId::new(0),
5225                crate::AtomId::new(1),
5226                BondOrder::Double,
5227            ),
5228        );
5229        let unspecified = two_atom_molecule(
5230            crate::Element::C,
5231            crate::Element::C,
5232            crate::BondSpec::new(
5233                crate::AtomId::new(0),
5234                crate::AtomId::new(1),
5235                BondOrder::Unspecified,
5236            ),
5237        );
5238        assert!(!compatible(
5239            &single,
5240            &double,
5241            &SubstructMatchParams::default()
5242        ));
5243        assert!(compatible(
5244            &unspecified,
5245            &double,
5246            &SubstructMatchParams::default()
5247        ));
5248
5249        let aromatic = two_atom_molecule(
5250            crate::Element::C,
5251            crate::Element::C,
5252            crate::BondSpec::new(
5253                crate::AtomId::new(0),
5254                crate::AtomId::new(1),
5255                BondOrder::Aromatic,
5256            )
5257            .with_aromatic(true),
5258        );
5259        let conjugated_single = two_atom_molecule(
5260            crate::Element::C,
5261            crate::Element::C,
5262            crate::BondSpec::new(
5263                crate::AtomId::new(0),
5264                crate::AtomId::new(1),
5265                BondOrder::Single,
5266            )
5267            .with_conjugated(true),
5268        );
5269        let mut params = SubstructMatchParams::default();
5270        params.aromatic_matches_conjugated = true;
5271        assert!(compatible(&aromatic, &conjugated_single, &params));
5272        assert!(!compatible(&aromatic, &single, &params));
5273        params.aromatic_matches_conjugated = false;
5274        params.aromatic_matches_single_or_double = true;
5275        assert!(compatible(&aromatic, &single, &params));
5276        assert!(compatible(&aromatic, &double, &params));
5277
5278        let query_single = two_atom_molecule(
5279            crate::Element::C,
5280            crate::Element::C,
5281            crate::BondSpec::new(
5282                crate::AtomId::new(0),
5283                crate::AtomId::new(1),
5284                BondOrder::Single,
5285            )
5286            .with_query(crate::QueryNode::predicate(BondQueryPredicate::Order(
5287                BondOrder::Single,
5288            ))),
5289        );
5290        let query_double = two_atom_molecule(
5291            crate::Element::C,
5292            crate::Element::C,
5293            crate::BondSpec::new(
5294                crate::AtomId::new(0),
5295                crate::AtomId::new(1),
5296                BondOrder::Double,
5297            )
5298            .with_query(crate::QueryNode::predicate(BondQueryPredicate::Order(
5299                BondOrder::Double,
5300            ))),
5301        );
5302        let mut query_query_params = SubstructMatchParams::default();
5303        query_query_params.use_query_query_matches = true;
5304        assert!(!compatible(
5305            &query_single,
5306            &query_double,
5307            &query_query_params
5308        ));
5309
5310        let property_single = two_atom_molecule(
5311            crate::Element::C,
5312            crate::Element::C,
5313            crate::BondSpec::new(
5314                crate::AtomId::new(0),
5315                crate::AtomId::new(1),
5316                BondOrder::Single,
5317            )
5318            .with_prop("test_prop", "left"),
5319        );
5320        let mut property_params = SubstructMatchParams::default();
5321        property_params.bond_properties = vec!["test_prop".to_owned()];
5322        assert!(!compatible(&property_single, &single, &property_params));
5323
5324        let mut callback_params = SubstructMatchParams::default();
5325        callback_params.extra_bond_check = Some(Arc::new(|query, target| {
5326            query.order() == BondOrder::Single && target.order() == BondOrder::Double
5327        }));
5328        callback_params.extra_bond_check_overrides_default_check = true;
5329        assert!(compatible(&single, &double, &callback_params));
5330        callback_params.extra_bond_check_overrides_default_check = false;
5331        assert!(!compatible(&single, &double, &callback_params));
5332        callback_params.extra_bond_check = Some(Arc::new(|_, _| false));
5333        assert!(!compatible(&single, &single, &callback_params));
5334
5335        let dative_cn = two_atom_molecule(
5336            crate::Element::C,
5337            crate::Element::N,
5338            crate::BondSpec::new(
5339                crate::AtomId::new(0),
5340                crate::AtomId::new(1),
5341                BondOrder::Dative,
5342            ),
5343        );
5344        let dative_nc = two_atom_molecule(
5345            crate::Element::N,
5346            crate::Element::C,
5347            crate::BondSpec::new(
5348                crate::AtomId::new(0),
5349                crate::AtomId::new(1),
5350                BondOrder::Dative,
5351            ),
5352        );
5353        assert!(compatible(
5354            &dative_cn,
5355            &dative_cn,
5356            &SubstructMatchParams::default()
5357        ));
5358        assert!(!compatible(
5359            &dative_cn,
5360            &dative_nc,
5361            &SubstructMatchParams::default()
5362        ));
5363    }
5364
5365    #[test]
5366    fn smarts_substruct_remove_duplicates() {
5367        let result = |atom_mapping: &[usize], bond_mapping: &[usize]| SubstructMatchResult {
5368            atom_mapping: atom_mapping.to_vec(),
5369            bond_mapping: bond_mapping.to_vec(),
5370        };
5371        let first = result(&[0, 1, 2, 3], &[10, 11, 12]);
5372        let same_atom_set_different_path = result(&[3, 2, 1, 0], &[20, 21, 22]);
5373        let distinct = result(&[0, 1, 2, 4], &[30, 31, 32]);
5374        let repeated_distinct = result(&[4, 2, 1, 0], &[40, 41, 42]);
5375        let mut matches = vec![
5376            first.clone(),
5377            same_atom_set_different_path,
5378            distinct.clone(),
5379            repeated_distinct,
5380        ];
5381
5382        remove_duplicates(&mut matches, 5);
5383
5384        assert_eq!(matches, vec![first, distinct]);
5385        assert_eq!(matches.capacity(), matches.len());
5386    }
5387
5388    fn core_substitution_fixtures() -> (Molecule, Molecule, Vec<SubstructMatchResult>) {
5389        let mut molecule_builder = MoleculeBuilder::new();
5390        let carbon_zero = molecule_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5391        let hydrogen = molecule_builder.add_atom(crate::AtomSpec::new(crate::Element::H));
5392        let carbon_two = molecule_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5393        molecule_builder
5394            .add_bond(crate::BondSpec::new(
5395                carbon_zero,
5396                hydrogen,
5397                BondOrder::Single,
5398            ))
5399            .expect("C-H bond");
5400        molecule_builder
5401            .add_bond(crate::BondSpec::new(
5402                carbon_zero,
5403                carbon_two,
5404                BondOrder::Single,
5405            ))
5406            .expect("C-C bond");
5407        let molecule = molecule_builder.build().expect("target fixture");
5408
5409        let mut query_builder = MoleculeBuilder::new();
5410        let dummy = query_builder.add_atom(crate::AtomSpec::new(crate::Element::DUMMY));
5411        let carbon = query_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5412        query_builder
5413            .add_bond(crate::BondSpec::new(dummy, carbon, BondOrder::Single))
5414            .expect("query bond");
5415        let query = query_builder.build().expect("query fixture");
5416
5417        let hydrogen_match = SubstructMatchResult {
5418            atom_mapping: vec![1, 0],
5419            bond_mapping: vec![0],
5420        };
5421        let substituted_match = SubstructMatchResult {
5422            atom_mapping: vec![2, 0],
5423            bond_mapping: vec![1],
5424        };
5425        (molecule, query, vec![hydrogen_match, substituted_match])
5426    }
5427
5428    #[test]
5429    fn smarts_substruct_get_most_substituted_core_match() {
5430        let (molecule, query, matches) = core_substitution_fixtures();
5431        assert_eq!(
5432            get_most_substituted_core_match(&molecule, &query, &matches),
5433            &matches[1]
5434        );
5435        assert!(core_substitution_score(&molecule, &query, &matches[1]) < 1.0);
5436        assert!(core_substitution_score(&molecule, &query, &matches[0]) >= 1.0);
5437    }
5438
5439    #[test]
5440    #[should_panic(expected = "matches must not be empty")]
5441    fn smarts_substruct_get_most_substituted_core_match_rejects_empty() {
5442        let (molecule, query, _) = core_substitution_fixtures();
5443        let _ = get_most_substituted_core_match(&molecule, &query, &[]);
5444    }
5445
5446    #[test]
5447    fn smarts_substruct_sort_matches_by_degree_of_core_substitution() {
5448        let (molecule, query, matches) = core_substitution_fixtures();
5449        let sorted = sort_matches_by_degree_of_core_substitution(&molecule, &query, &matches);
5450        assert_eq!(sorted, vec![matches[1].clone(), matches[0].clone()]);
5451        assert_eq!(matches[0].atom_mapping, vec![1, 0]);
5452    }
5453
5454    #[test]
5455    fn smarts_substruct_is_atom_terminal_r_group_or_query_hydrogen() {
5456        let (_, terminal_dummy_query, _) = core_substitution_fixtures();
5457        assert!(is_atom_terminal_r_group_or_query_hydrogen(
5458            &terminal_dummy_query,
5459            0
5460        ));
5461        assert!(!is_atom_terminal_r_group_or_query_hydrogen(
5462            &terminal_dummy_query,
5463            1
5464        ));
5465
5466        let mut hydrogen_query_builder = MoleculeBuilder::new();
5467        hydrogen_query_builder.add_atom(crate::AtomSpec::new(crate::Element::DUMMY).with_query(
5468            crate::QueryNode::predicate(AtomQueryPredicate::AtomicNumber(1)),
5469        ));
5470        let hydrogen_query = hydrogen_query_builder
5471            .build()
5472            .expect("hydrogen query fixture");
5473        assert!(is_atom_terminal_r_group_or_query_hydrogen(
5474            &hydrogen_query,
5475            0
5476        ));
5477
5478        let mut nonterminal_dummy_builder = MoleculeBuilder::new();
5479        let dummy = nonterminal_dummy_builder.add_atom(crate::AtomSpec::new(crate::Element::DUMMY));
5480        let carbon_one =
5481            nonterminal_dummy_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5482        let carbon_two =
5483            nonterminal_dummy_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
5484        nonterminal_dummy_builder
5485            .add_bond(crate::BondSpec::new(dummy, carbon_one, BondOrder::Single))
5486            .expect("first dummy bond");
5487        nonterminal_dummy_builder
5488            .add_bond(crate::BondSpec::new(dummy, carbon_two, BondOrder::Single))
5489            .expect("second dummy bond");
5490        let nonterminal_dummy = nonterminal_dummy_builder
5491            .build()
5492            .expect("nonterminal dummy fixture");
5493        assert!(!is_atom_terminal_r_group_or_query_hydrogen(
5494            &nonterminal_dummy,
5495            0
5496        ));
5497    }
5498
5499    #[test]
5500    fn smarts_substruct_update_substruct_match_params_from_j_s_o_n() {
5501        let mut params = SubstructMatchParams::default();
5502        params.max_matches = 77;
5503        update_substruct_match_params_from_json(&mut params, "").expect("empty JSON no-op");
5504        assert_eq!(params.max_matches, 77);
5505
5506        update_substruct_match_params_from_json(
5507            &mut params,
5508            r#"{
5509                "useChirality": true,
5510                "useEnhancedStereo": "true",
5511                "aromaticMatchesConjugated": true,
5512                "useQueryQueryMatches": "1",
5513                "recursionPossible": false,
5514                "uniquify": "false",
5515                "maxMatches": 12,
5516                "maxRecursiveMatches": "34",
5517                "numThreads": -2,
5518                "specifiedStereoQueryMatchesUnspecified": true,
5519                "aromaticMatchesSingleOrDouble": "true",
5520                "unknownOption": "ignored"
5521            }"#,
5522        )
5523        .expect("source JSON fields");
5524        assert!(params.use_chirality);
5525        assert!(params.use_enhanced_stereo);
5526        assert!(params.aromatic_matches_conjugated);
5527        assert!(params.use_query_query_matches);
5528        assert!(!params.recursion_possible);
5529        assert!(!params.uniquify);
5530        assert_eq!(params.max_matches, 12);
5531        assert_eq!(params.max_recursive_matches, 34);
5532        assert_eq!(params.num_threads, -2);
5533        assert!(params.specified_stereo_query_matches_unspecified);
5534        assert!(params.aromatic_matches_single_or_double);
5535
5536        let before = params.clone();
5537        assert!(
5538            update_substruct_match_params_from_json(
5539                &mut params,
5540                r#"{"useChirality": false, "maxMatches": "invalid"}"#,
5541            )
5542            .is_err()
5543        );
5544        assert_eq!(params.use_chirality, before.use_chirality);
5545        assert_eq!(params.max_matches, before.max_matches);
5546    }
5547
5548    #[test]
5549    fn smarts_substruct_substruct_match_params_to_j_s_o_n() {
5550        let mut params = SubstructMatchParams::default();
5551        params.use_chirality = true;
5552        params.use_enhanced_stereo = true;
5553        params.aromatic_matches_conjugated = true;
5554        params.use_query_query_matches = true;
5555        params.recursion_possible = false;
5556        params.uniquify = false;
5557        params.max_matches = 12;
5558        params.max_recursive_matches = 34;
5559        params.num_threads = -2;
5560        params.specified_stereo_query_matches_unspecified = true;
5561        params.aromatic_matches_single_or_double = true;
5562        params.atom_properties.push("notSerialized".to_owned());
5563        params.bond_properties.push("notSerialized".to_owned());
5564
5565        let json = substruct_match_params_to_json(&params);
5566        let value: serde_json::Value = serde_json::from_str(&json).expect("writer JSON");
5567        let object = value.as_object().expect("JSON object");
5568        assert_eq!(object.len(), 11);
5569        assert_eq!(object["useChirality"], "true");
5570        assert_eq!(object["maxMatches"], "12");
5571        assert_eq!(object["numThreads"], "-2");
5572        assert!(!object.contains_key("atomProperties"));
5573        assert!(!object.contains_key("bondProperties"));
5574
5575        let mut roundtrip = SubstructMatchParams::default();
5576        update_substruct_match_params_from_json(&mut roundtrip, &json).expect("writer roundtrip");
5577        assert_eq!(roundtrip.use_chirality, params.use_chirality);
5578        assert_eq!(roundtrip.use_enhanced_stereo, params.use_enhanced_stereo);
5579        assert_eq!(
5580            roundtrip.aromatic_matches_conjugated,
5581            params.aromatic_matches_conjugated
5582        );
5583        assert_eq!(
5584            roundtrip.use_query_query_matches,
5585            params.use_query_query_matches
5586        );
5587        assert_eq!(roundtrip.recursion_possible, params.recursion_possible);
5588        assert_eq!(roundtrip.uniquify, params.uniquify);
5589        assert_eq!(roundtrip.max_matches, params.max_matches);
5590        assert_eq!(
5591            roundtrip.max_recursive_matches,
5592            params.max_recursive_matches
5593        );
5594        assert_eq!(roundtrip.num_threads, params.num_threads);
5595        assert_eq!(
5596            roundtrip.specified_stereo_query_matches_unspecified,
5597            params.specified_stereo_query_matches_unspecified
5598        );
5599        assert_eq!(
5600            roundtrip.aromatic_matches_single_or_double,
5601            params.aromatic_matches_single_or_double
5602        );
5603    }
5604
5605    #[test]
5606    fn smarts_substruct_match_parameters() {
5607        let params = SubstructMatchParams::default();
5608        assert!(!params.use_chirality);
5609        assert!(!params.use_enhanced_stereo);
5610        assert!(!params.use_generic_matchers);
5611        assert!(params.recursion_possible);
5612        assert!(params.uniquify);
5613        assert_eq!(params.max_matches, 1000);
5614        assert_eq!(params.max_recursive_matches, 1000);
5615        assert_eq!(params.num_threads, 1);
5616        assert!(params.atom_properties.is_empty());
5617        assert!(params.bond_properties.is_empty());
5618        assert!(params.extra_atom_check.is_none());
5619        assert!(params.extra_bond_check.is_none());
5620        assert!(params.extra_final_check.is_none());
5621
5622        assert_eq!(
5623            check_substruct_match_overload_support(SubstructMatchOverload::Molecule),
5624            Ok(())
5625        );
5626        for (overload, expected_branch) in [
5627            (
5628                SubstructMatchOverload::MolBundle,
5629                "MolBundle substructure-match overloads",
5630            ),
5631            (
5632                SubstructMatchOverload::ResonanceMolSupplier,
5633                "resonance substructure-match overload",
5634            ),
5635            (
5636                SubstructMatchOverload::SubstructLibrary,
5637                "SubstructLibrary search overloads",
5638            ),
5639        ] {
5640            assert!(matches!(
5641                check_substruct_match_overload_support(overload),
5642                Err(SubstructMatchError::Unsupported { branch, .. }) if branch == expected_branch
5643            ));
5644        }
5645    }
5646
5647    #[test]
5648    fn maccs_patterns_substruct_matches_required_topology_semantics() {
5649        let cases = [
5650            (
5651                "four-membered ring",
5652                "*1~*~*~*~1",
5653                "C1CCC1",
5654                true,
5655                vec![0, 1, 2, 3],
5656            ),
5657            (
5658                "four-membered ring rejects chain",
5659                "*1~*~*~*~1",
5660                "CCCC",
5661                false,
5662                Vec::new(),
5663            ),
5664            ("ring bond", "*@*(@*)@*", "C12CC1C2", true, vec![0, 2, 1, 3]),
5665            (
5666                "non-ring oxygen bridge",
5667                "*!@[#8]!@*",
5668                "COC",
5669                true,
5670                vec![0, 1, 2],
5671            ),
5672            (
5673                "branch degree",
5674                "*~*(~*)(~*)~*",
5675                "CC(C)(C)C",
5676                true,
5677                vec![0, 1, 2, 3, 4],
5678            ),
5679            (
5680                "recursive ring closure",
5681                "[$([CH3]~*~*~[CH2]~*),$([CH3]~*1~*~[CH2]1)]",
5682                "CC1CC1",
5683                true,
5684                vec![0],
5685            ),
5686        ];
5687
5688        for (name, smarts, smiles, expected_match, expected_first) in cases {
5689            let mol = Molecule::from_smiles_with_sanitize(smiles, true)
5690                .unwrap_or_else(|_| panic!("{name} molecule should parse"));
5691            let query = compile_query_fixture(smarts)
5692                .unwrap_or_else(|_| panic!("{name} MACCS SMARTS should build query"));
5693            let matches = get_substruct_matches(&mol, &query);
5694            assert_eq!(
5695                !matches.is_empty(),
5696                expected_match,
5697                "{name} match truth value"
5698            );
5699            if expected_match {
5700                assert_eq!(
5701                    matches[0].atom_mapping, expected_first,
5702                    "{name} first match"
5703                );
5704            }
5705        }
5706    }
5707
5708    struct MaccsPatternGolden {
5709        bit: u16,
5710        smarts: &'static str,
5711        smiles: &'static str,
5712        first_match: &'static [usize],
5713    }
5714
5715    fn rdkit_maccs_pattern_positive_goldens() -> &'static [MaccsPatternGolden] {
5716        // RDKit source: MACCS.cpp::Patterns initializes these SMARTS strings
5717        // with `RDKit::SmartsToMol(...)`. `first_match` values were generated
5718        // from pinned RDKit 2026.03.1 using `Mol.GetSubstructMatch()`.
5719        &[
5720            MaccsPatternGolden {
5721                bit: 8,
5722                smarts: "[!#6!#1]1~*~*~*~1",
5723                smiles: "O1CCC1",
5724                first_match: &[0, 1, 2, 3],
5725            },
5726            MaccsPatternGolden {
5727                bit: 11,
5728                smarts: "*1~*~*~*~1",
5729                smiles: "C1CCC1",
5730                first_match: &[0, 1, 2, 3],
5731            },
5732            MaccsPatternGolden {
5733                bit: 13,
5734                smarts: "[#8]~[#7](~[#6])~[#6]",
5735                smiles: "ON(C)C",
5736                first_match: &[0, 1, 2, 3],
5737            },
5738            MaccsPatternGolden {
5739                bit: 14,
5740                smarts: "[#16]-[#16]",
5741                smiles: "CSSC",
5742                first_match: &[1, 2],
5743            },
5744            MaccsPatternGolden {
5745                bit: 15,
5746                smarts: "[#8]~[#6](~[#8])~[#8]",
5747                smiles: "O=C(O)O",
5748                first_match: &[0, 1, 2, 3],
5749            },
5750            MaccsPatternGolden {
5751                bit: 16,
5752                smarts: "[!#6!#1]1~*~*~1",
5753                smiles: "O1CC1",
5754                first_match: &[0, 1, 2],
5755            },
5756            MaccsPatternGolden {
5757                bit: 17,
5758                smarts: "[#6]#[#6]",
5759                smiles: "C#C",
5760                first_match: &[0, 1],
5761            },
5762            MaccsPatternGolden {
5763                bit: 19,
5764                smarts: "*1~*~*~*~*~*~*~1",
5765                smiles: "C1CCCCCC1",
5766                first_match: &[0, 1, 2, 3, 4, 5, 6],
5767            },
5768            MaccsPatternGolden {
5769                bit: 20,
5770                smarts: "[#14]",
5771                smiles: "[SiH4]",
5772                first_match: &[0],
5773            },
5774            MaccsPatternGolden {
5775                bit: 21,
5776                smarts: "[#6]=[#6](~[!#6!#1])~[!#6!#1]",
5777                smiles: "C=C(O)O",
5778                first_match: &[0, 1, 2, 3],
5779            },
5780            MaccsPatternGolden {
5781                bit: 22,
5782                smarts: "*1~*~*~1",
5783                smiles: "C1CC1",
5784                first_match: &[0, 1, 2],
5785            },
5786            MaccsPatternGolden {
5787                bit: 23,
5788                smarts: "[#7]~[#6](~[#8])~[#8]",
5789                smiles: "NC(=O)O",
5790                first_match: &[0, 1, 2, 3],
5791            },
5792            MaccsPatternGolden {
5793                bit: 24,
5794                smarts: "[#7]-[#8]",
5795                smiles: "ON(C)C",
5796                first_match: &[1, 0],
5797            },
5798            MaccsPatternGolden {
5799                bit: 25,
5800                smarts: "[#7]~[#6](~[#7])~[#7]",
5801                smiles: "NC(N)N",
5802                first_match: &[0, 1, 2, 3],
5803            },
5804            MaccsPatternGolden {
5805                bit: 26,
5806                smarts: "[#6]=@[#6](@*)@*",
5807                smiles: "C1=C2CCCC2C1",
5808                first_match: &[0, 1, 2, 5],
5809            },
5810            MaccsPatternGolden {
5811                bit: 28,
5812                smarts: "[!#6!#1]~[CH2]~[!#6!#1]",
5813                smiles: "OCO",
5814                first_match: &[0, 1, 2],
5815            },
5816            MaccsPatternGolden {
5817                bit: 30,
5818                smarts: "[#6]~[!#6!#1](~[#6])(~[#6])~*",
5819                smiles: "C[S](C)(C)C",
5820                first_match: &[0, 1, 2, 3, 4],
5821            },
5822            MaccsPatternGolden {
5823                bit: 31,
5824                smarts: "[!#6!#1]~[F,Cl,Br,I]",
5825                smiles: "N[Pt](Cl)(Cl)N",
5826                first_match: &[1, 2],
5827            },
5828            MaccsPatternGolden {
5829                bit: 32,
5830                smarts: "[#6]~[#16]~[#7]",
5831                smiles: "CSN",
5832                first_match: &[0, 1, 2],
5833            },
5834            MaccsPatternGolden {
5835                bit: 33,
5836                smarts: "[#7]~[#16]",
5837                smiles: "CSN",
5838                first_match: &[2, 1],
5839            },
5840            MaccsPatternGolden {
5841                bit: 34,
5842                smarts: "[CH2]=*",
5843                smiles: "C=C",
5844                first_match: &[0, 1],
5845            },
5846            MaccsPatternGolden {
5847                bit: 36,
5848                smarts: "[#16R]",
5849                smiles: "S1CC1",
5850                first_match: &[0],
5851            },
5852            MaccsPatternGolden {
5853                bit: 37,
5854                smarts: "[#7]~[#6](~[#8])~[#7]",
5855                smiles: "NC(=O)N",
5856                first_match: &[0, 1, 2, 3],
5857            },
5858            MaccsPatternGolden {
5859                bit: 38,
5860                smarts: "[#7]~[#6](~[#6])~[#7]",
5861                smiles: "NC(C)N",
5862                first_match: &[0, 1, 2, 3],
5863            },
5864            MaccsPatternGolden {
5865                bit: 39,
5866                smarts: "[#8]~[#16](~[#8])~[#8]",
5867                smiles: "COS(=O)(=O)O",
5868                first_match: &[1, 2, 3, 4],
5869            },
5870            MaccsPatternGolden {
5871                bit: 40,
5872                smarts: "[#16]-[#8]",
5873                smiles: "CSO",
5874                first_match: &[1, 2],
5875            },
5876            MaccsPatternGolden {
5877                bit: 41,
5878                smarts: "[#6]#[#7]",
5879                smiles: "C#N",
5880                first_match: &[0, 1],
5881            },
5882            MaccsPatternGolden {
5883                bit: 43,
5884                smarts: "[!#6!#1!H0]~*~[!#6!#1!H0]",
5885                smiles: "OCO",
5886                first_match: &[0, 1, 2],
5887            },
5888            MaccsPatternGolden {
5889                bit: 44,
5890                smarts: "[!#1;!#6;!#7;!#8;!#9;!#14;!#15;!#16;!#17;!#35;!#53]",
5891                smiles: "[SeH2]",
5892                first_match: &[0],
5893            },
5894            MaccsPatternGolden {
5895                bit: 45,
5896                smarts: "[#6]=[#6]~[#7]",
5897                smiles: "C=CN",
5898                first_match: &[0, 1, 2],
5899            },
5900            MaccsPatternGolden {
5901                bit: 47,
5902                smarts: "[#16]~*~[#7]",
5903                smiles: "SCN",
5904                first_match: &[0, 1, 2],
5905            },
5906            MaccsPatternGolden {
5907                bit: 48,
5908                smarts: "[#8]~[!#6!#1](~[#8])~[#8]",
5909                smiles: "COS(=O)(=O)O",
5910                first_match: &[1, 2, 3, 4],
5911            },
5912            MaccsPatternGolden {
5913                bit: 49,
5914                smarts: "[!+0]",
5915                smiles: "O=N(=O)O",
5916                first_match: &[0],
5917            },
5918            MaccsPatternGolden {
5919                bit: 50,
5920                smarts: "[#6]=[#6](~[#6])~[#6]",
5921                smiles: "CC(C)=C",
5922                first_match: &[3, 1, 0, 2],
5923            },
5924            MaccsPatternGolden {
5925                bit: 51,
5926                smarts: "[#6]~[#16]~[#8]",
5927                smiles: "CSO",
5928                first_match: &[0, 1, 2],
5929            },
5930            MaccsPatternGolden {
5931                bit: 52,
5932                smarts: "[#7]~[#7]",
5933                smiles: "NNO",
5934                first_match: &[0, 1],
5935            },
5936            MaccsPatternGolden {
5937                bit: 53,
5938                smarts: "[!#6!#1!H0]~*~*~*~[!#6!#1!H0]",
5939                smiles: "NCCCO",
5940                first_match: &[0, 1, 2, 3, 4],
5941            },
5942            MaccsPatternGolden {
5943                bit: 54,
5944                smarts: "[!#6!#1!H0]~*~*~[!#6!#1!H0]",
5945                smiles: "NCCN",
5946                first_match: &[0, 1, 2, 3],
5947            },
5948            MaccsPatternGolden {
5949                bit: 55,
5950                smarts: "[#8]~[#16]~[#8]",
5951                smiles: "CS(=O)(=O)C",
5952                first_match: &[2, 1, 3],
5953            },
5954            MaccsPatternGolden {
5955                bit: 56,
5956                smarts: "[#8]~[#7](~[#8])~[#6]",
5957                smiles: "ON(O)C",
5958                first_match: &[0, 1, 2, 3],
5959            },
5960            MaccsPatternGolden {
5961                bit: 57,
5962                smarts: "[#8R]",
5963                smiles: "O1CC1",
5964                first_match: &[0],
5965            },
5966            MaccsPatternGolden {
5967                bit: 58,
5968                smarts: "[!#6!#1]~[#16]~[!#6!#1]",
5969                smiles: "CS(=O)(=O)C",
5970                first_match: &[2, 1, 3],
5971            },
5972            MaccsPatternGolden {
5973                bit: 59,
5974                smarts: "[#16]!:*:*",
5975                smiles: "Sc1ccccc1",
5976                first_match: &[0, 1, 2],
5977            },
5978            MaccsPatternGolden {
5979                bit: 60,
5980                smarts: "[#16]=[#8]",
5981                smiles: "CS(=O)C",
5982                first_match: &[1, 2],
5983            },
5984            MaccsPatternGolden {
5985                bit: 61,
5986                smarts: "*~[#16](~*)~*",
5987                smiles: "C[S](C)(C)C",
5988                first_match: &[0, 1, 2, 3],
5989            },
5990            MaccsPatternGolden {
5991                bit: 62,
5992                smarts: "*@*!@*@*",
5993                smiles: "C1CC1C1CC1",
5994                first_match: &[0, 2, 3, 4],
5995            },
5996            MaccsPatternGolden {
5997                bit: 63,
5998                smarts: "[#7]=[#8]",
5999                smiles: "N=O",
6000                first_match: &[0, 1],
6001            },
6002            MaccsPatternGolden {
6003                bit: 64,
6004                smarts: "*@*!@[#16]",
6005                smiles: "Sc1ccccc1",
6006                first_match: &[2, 1, 0],
6007            },
6008            MaccsPatternGolden {
6009                bit: 65,
6010                smarts: "c:n",
6011                smiles: "c1ncccc1",
6012                first_match: &[0, 1],
6013            },
6014            MaccsPatternGolden {
6015                bit: 66,
6016                smarts: "[#6]~[#6](~[#6])(~[#6])~*",
6017                smiles: "CC(C)(C)C",
6018                first_match: &[0, 1, 2, 3, 4],
6019            },
6020            MaccsPatternGolden {
6021                bit: 67,
6022                smarts: "[!#6!#1]~[#16]",
6023                smiles: "CSSC",
6024                first_match: &[1, 2],
6025            },
6026            MaccsPatternGolden {
6027                bit: 68,
6028                smarts: "[!#6!#1!H0]~[!#6!#1!H0]",
6029                smiles: "NNO",
6030                first_match: &[0, 1],
6031            },
6032            MaccsPatternGolden {
6033                bit: 69,
6034                smarts: "[!#6!#1]~[!#6!#1!H0]",
6035                smiles: "CSN",
6036                first_match: &[1, 2],
6037            },
6038            MaccsPatternGolden {
6039                bit: 70,
6040                smarts: "[!#6!#1]~[#7]~[!#6!#1]",
6041                smiles: "NNO",
6042                first_match: &[0, 1, 2],
6043            },
6044            MaccsPatternGolden {
6045                bit: 71,
6046                smarts: "[#7]~[#8]",
6047                smiles: "N=O",
6048                first_match: &[0, 1],
6049            },
6050            MaccsPatternGolden {
6051                bit: 72,
6052                smarts: "[#8]~*~*~[#8]",
6053                smiles: "OCCO",
6054                first_match: &[0, 1, 2, 3],
6055            },
6056            MaccsPatternGolden {
6057                bit: 73,
6058                smarts: "[#16]=*",
6059                smiles: "CS(=O)C",
6060                first_match: &[1, 2],
6061            },
6062            MaccsPatternGolden {
6063                bit: 74,
6064                smarts: "[CH3]~*~[CH3]",
6065                smiles: "CCC",
6066                first_match: &[0, 1, 2],
6067            },
6068            MaccsPatternGolden {
6069                bit: 75,
6070                smarts: "*!@[#7]@*",
6071                smiles: "CN1CC1",
6072                first_match: &[0, 1, 2],
6073            },
6074            MaccsPatternGolden {
6075                bit: 76,
6076                smarts: "[#6]=[#6](~*)~*",
6077                smiles: "CC(C)=C",
6078                first_match: &[3, 1, 0, 2],
6079            },
6080            MaccsPatternGolden {
6081                bit: 77,
6082                smarts: "[#7]~*~[#7]",
6083                smiles: "NC(=O)N",
6084                first_match: &[0, 1, 3],
6085            },
6086            MaccsPatternGolden {
6087                bit: 78,
6088                smarts: "[#6]=[#7]",
6089                smiles: "C=N",
6090                first_match: &[0, 1],
6091            },
6092            MaccsPatternGolden {
6093                bit: 79,
6094                smarts: "[#7]~*~*~[#7]",
6095                smiles: "NCCN",
6096                first_match: &[0, 1, 2, 3],
6097            },
6098            MaccsPatternGolden {
6099                bit: 80,
6100                smarts: "[#7]~*~*~*~[#7]",
6101                smiles: "NCCCN",
6102                first_match: &[0, 1, 2, 3, 4],
6103            },
6104            MaccsPatternGolden {
6105                bit: 81,
6106                smarts: "[#16]~*(~*)~*",
6107                smiles: "Sc1ccccc1",
6108                first_match: &[0, 1, 2, 6],
6109            },
6110            MaccsPatternGolden {
6111                bit: 82,
6112                smarts: "*~[CH2]~[!#6!#1!H0]",
6113                smiles: "CCO",
6114                first_match: &[0, 1, 2],
6115            },
6116            MaccsPatternGolden {
6117                bit: 83,
6118                smarts: "[!#6!#1]1~*~*~*~*~1",
6119                smiles: "O1CCCC1",
6120                first_match: &[0, 1, 2, 3, 4],
6121            },
6122            MaccsPatternGolden {
6123                bit: 84,
6124                smarts: "[NH2]",
6125                smiles: "CCN",
6126                first_match: &[2],
6127            },
6128            MaccsPatternGolden {
6129                bit: 85,
6130                smarts: "[#6]~[#7](~[#6])~[#6]",
6131                smiles: "CN(C)C",
6132                first_match: &[0, 1, 2, 3],
6133            },
6134            MaccsPatternGolden {
6135                bit: 86,
6136                smarts: "[C;H2,H3][!#6!#1][C;H2,H3]",
6137                smiles: "COC",
6138                first_match: &[0, 1, 2],
6139            },
6140            MaccsPatternGolden {
6141                bit: 87,
6142                smarts: "[F,Cl,Br,I]!@*@*",
6143                smiles: "Clc1ccccc1",
6144                first_match: &[0, 1, 2],
6145            },
6146            MaccsPatternGolden {
6147                bit: 89,
6148                smarts: "[#8]~*~*~*~[#8]",
6149                smiles: "OCCCO",
6150                first_match: &[0, 1, 2, 3, 4],
6151            },
6152            MaccsPatternGolden {
6153                bit: 90,
6154                smarts: "[$([!#6!#1!H0]~*~*~[CH2]~*),$([!#6!#1!H0R]1@[R]@[R]@[CH2R]1),$([!#6!#1!H0]~[R]1@[R]@[CH2R]1)]",
6155                smiles: "N1CCC1",
6156                first_match: &[0],
6157            },
6158            MaccsPatternGolden {
6159                bit: 91,
6160                smarts: "[$([!#6!#1!H0]~*~*~*~[CH2]~*),$([!#6!#1!H0R]1@[R]@[R]@[R]@[CH2R]1),$([!#6!#1!H0]~[R]1@[R]@[R]@[CH2R]1),$([!#6!#1!H0]~*~[R]1@[R]@[CH2R]1)]",
6161                smiles: "NCCCCCN",
6162                first_match: &[0],
6163            },
6164            MaccsPatternGolden {
6165                bit: 92,
6166                smarts: "[#8]~[#6](~[#7])~[#6]",
6167                smiles: "CC(=O)N",
6168                first_match: &[2, 1, 3, 0],
6169            },
6170            MaccsPatternGolden {
6171                bit: 93,
6172                smarts: "[!#6!#1]~[CH3]",
6173                smiles: "COC",
6174                first_match: &[1, 0],
6175            },
6176            MaccsPatternGolden {
6177                bit: 94,
6178                smarts: "[!#6!#1]~[#7]",
6179                smiles: "CSN",
6180                first_match: &[1, 2],
6181            },
6182            MaccsPatternGolden {
6183                bit: 95,
6184                smarts: "[#7]~*~*~[#8]",
6185                smiles: "NCCO",
6186                first_match: &[0, 1, 2, 3],
6187            },
6188            MaccsPatternGolden {
6189                bit: 96,
6190                smarts: "*1~*~*~*~*~1",
6191                smiles: "C1CCCC1",
6192                first_match: &[0, 1, 2, 3, 4],
6193            },
6194            MaccsPatternGolden {
6195                bit: 97,
6196                smarts: "[#7]~*~*~*~[#8]",
6197                smiles: "NCCCO",
6198                first_match: &[0, 1, 2, 3, 4],
6199            },
6200            MaccsPatternGolden {
6201                bit: 98,
6202                smarts: "[!#6!#1]1~*~*~*~*~*~1",
6203                smiles: "O1CCCCC1",
6204                first_match: &[0, 1, 2, 3, 4, 5],
6205            },
6206            MaccsPatternGolden {
6207                bit: 99,
6208                smarts: "[#6]=[#6]",
6209                smiles: "C=C",
6210                first_match: &[0, 1],
6211            },
6212            MaccsPatternGolden {
6213                bit: 100,
6214                smarts: "*~[CH2]~[#7]",
6215                smiles: "CCN",
6216                first_match: &[0, 1, 2],
6217            },
6218            MaccsPatternGolden {
6219                bit: 101,
6220                smarts: "[$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1),$([R]1@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@[R]@1)]",
6221                smiles: "C1CCCCCCC1",
6222                first_match: &[0],
6223            },
6224            MaccsPatternGolden {
6225                bit: 102,
6226                smarts: "[!#6!#1]~[#8]",
6227                smiles: "CSO",
6228                first_match: &[1, 2],
6229            },
6230            MaccsPatternGolden {
6231                bit: 104,
6232                smarts: "[!#6!#1!H0]~*~[CH2]~*",
6233                smiles: "CCCO",
6234                first_match: &[3, 2, 1, 0],
6235            },
6236            MaccsPatternGolden {
6237                bit: 105,
6238                smarts: "*@*(@*)@*",
6239                smiles: "C12CC1C2",
6240                first_match: &[0, 2, 1, 3],
6241            },
6242            MaccsPatternGolden {
6243                bit: 106,
6244                smarts: "[!#6!#1]~*(~[!#6!#1])~[!#6!#1]",
6245                smiles: "COS(=O)(=O)O",
6246                first_match: &[1, 2, 3, 4],
6247            },
6248            MaccsPatternGolden {
6249                bit: 107,
6250                smarts: "[F,Cl,Br,I]~*(~*)~*",
6251                smiles: "CC(C)(C)Cl",
6252                first_match: &[4, 1, 0, 2],
6253            },
6254            MaccsPatternGolden {
6255                bit: 108,
6256                smarts: "[CH3]~*~*~*~[CH2]~*",
6257                smiles: "CCCCCC",
6258                first_match: &[0, 1, 2, 3, 4, 5],
6259            },
6260            MaccsPatternGolden {
6261                bit: 109,
6262                smarts: "*~[CH2]~[#8]",
6263                smiles: "CCO",
6264                first_match: &[0, 1, 2],
6265            },
6266            MaccsPatternGolden {
6267                bit: 110,
6268                smarts: "[#7]~[#6]~[#8]",
6269                smiles: "CC(=O)N",
6270                first_match: &[3, 1, 2],
6271            },
6272            MaccsPatternGolden {
6273                bit: 111,
6274                smarts: "[#7]~*~[CH2]~*",
6275                smiles: "CCCN",
6276                first_match: &[3, 2, 1, 0],
6277            },
6278            MaccsPatternGolden {
6279                bit: 112,
6280                smarts: "*~*(~*)(~*)~*",
6281                smiles: "CC(C)(C)C",
6282                first_match: &[0, 1, 2, 3, 4],
6283            },
6284            MaccsPatternGolden {
6285                bit: 113,
6286                smarts: "[#8]!:*:*",
6287                smiles: "Oc1ccccc1",
6288                first_match: &[0, 1, 2],
6289            },
6290            MaccsPatternGolden {
6291                bit: 114,
6292                smarts: "[CH3]~[CH2]~*",
6293                smiles: "CCC",
6294                first_match: &[0, 1, 2],
6295            },
6296            MaccsPatternGolden {
6297                bit: 115,
6298                smarts: "[CH3]~*~[CH2]~*",
6299                smiles: "CCCC",
6300                first_match: &[0, 1, 2, 3],
6301            },
6302            MaccsPatternGolden {
6303                bit: 116,
6304                smarts: "[$([CH3]~*~*~[CH2]~*),$([CH3]~*1~*~[CH2]1)]",
6305                smiles: "CCCCC",
6306                first_match: &[0],
6307            },
6308            MaccsPatternGolden {
6309                bit: 117,
6310                smarts: "[#7]~*~[#8]",
6311                smiles: "CC(=O)N",
6312                first_match: &[3, 1, 2],
6313            },
6314            MaccsPatternGolden {
6315                bit: 118,
6316                smarts: "[$(*~[CH2]~[CH2]~*),$(*1~[CH2]~[CH2]1)]",
6317                smiles: "CCCC",
6318                first_match: &[0],
6319            },
6320            MaccsPatternGolden {
6321                bit: 119,
6322                smarts: "[#7]=*",
6323                smiles: "N=O",
6324                first_match: &[0, 1],
6325            },
6326            MaccsPatternGolden {
6327                bit: 120,
6328                smarts: "[!#6R]",
6329                smiles: "O1CC1",
6330                first_match: &[0],
6331            },
6332            MaccsPatternGolden {
6333                bit: 121,
6334                smarts: "[#7R]",
6335                smiles: "N1CC1",
6336                first_match: &[0],
6337            },
6338            MaccsPatternGolden {
6339                bit: 122,
6340                smarts: "*~[#7](~*)~*",
6341                smiles: "ON(C)C",
6342                first_match: &[0, 1, 2, 3],
6343            },
6344            MaccsPatternGolden {
6345                bit: 123,
6346                smarts: "[#8]~[#6]~[#8]",
6347                smiles: "OCO",
6348                first_match: &[0, 1, 2],
6349            },
6350            MaccsPatternGolden {
6351                bit: 124,
6352                smarts: "[!#6!#1]~[!#6!#1]",
6353                smiles: "CSSC",
6354                first_match: &[1, 2],
6355            },
6356            MaccsPatternGolden {
6357                bit: 126,
6358                smarts: "*!@[#8]!@*",
6359                smiles: "COC",
6360                first_match: &[0, 1, 2],
6361            },
6362            MaccsPatternGolden {
6363                bit: 127,
6364                smarts: "*@*!@[#8]",
6365                smiles: "Oc1ccccc1",
6366                first_match: &[2, 1, 0],
6367            },
6368            MaccsPatternGolden {
6369                bit: 128,
6370                smarts: "[$(*~[CH2]~*~*~*~[CH2]~*),$([R]1@[CH2R]@[R]@[R]@[R]@[CH2R]1),$(*~[CH2]~[R]1@[R]@[R]@[CH2R]1),$(*~[CH2]~*~[R]1@[R]@[CH2R]1)]",
6371                smiles: "CCCCCCC",
6372                first_match: &[0],
6373            },
6374            MaccsPatternGolden {
6375                bit: 129,
6376                smarts: "[$(*~[CH2]~*~*~[CH2]~*),$([R]1@[CH2]@[R]@[R]@[CH2R]1),$(*~[CH2]~[R]1@[R]@[CH2R]1)]",
6377                smiles: "CCCCCC",
6378                first_match: &[0],
6379            },
6380            MaccsPatternGolden {
6381                bit: 131,
6382                smarts: "[!#6!#1!H0]",
6383                smiles: "CCO",
6384                first_match: &[2],
6385            },
6386            MaccsPatternGolden {
6387                bit: 132,
6388                smarts: "[#8]~*~[CH2]~*",
6389                smiles: "CCCO",
6390                first_match: &[3, 2, 1, 0],
6391            },
6392            MaccsPatternGolden {
6393                bit: 133,
6394                smarts: "*@*!@[#7]",
6395                smiles: "Nc1ccccc1",
6396                first_match: &[2, 1, 0],
6397            },
6398            MaccsPatternGolden {
6399                bit: 135,
6400                smarts: "[#7]!:*:*",
6401                smiles: "Nc1ccccc1",
6402                first_match: &[0, 1, 2],
6403            },
6404            MaccsPatternGolden {
6405                bit: 136,
6406                smarts: "[#8]=*",
6407                smiles: "CS(=O)C",
6408                first_match: &[2, 1],
6409            },
6410            MaccsPatternGolden {
6411                bit: 137,
6412                smarts: "[!C!cR]",
6413                smiles: "O1CC1",
6414                first_match: &[0],
6415            },
6416            MaccsPatternGolden {
6417                bit: 138,
6418                smarts: "[!#6!#1]~[CH2]~*",
6419                smiles: "CCO",
6420                first_match: &[2, 1, 0],
6421            },
6422            MaccsPatternGolden {
6423                bit: 139,
6424                smarts: "[O!H0]",
6425                smiles: "CCO",
6426                first_match: &[2],
6427            },
6428            MaccsPatternGolden {
6429                bit: 140,
6430                smarts: "[#8]",
6431                smiles: "CCO",
6432                first_match: &[2],
6433            },
6434            MaccsPatternGolden {
6435                bit: 141,
6436                smarts: "[CH3]",
6437                smiles: "CC",
6438                first_match: &[0],
6439            },
6440            MaccsPatternGolden {
6441                bit: 142,
6442                smarts: "[#7]",
6443                smiles: "C#N",
6444                first_match: &[1],
6445            },
6446            MaccsPatternGolden {
6447                bit: 144,
6448                smarts: "*!:*:*!:*",
6449                smiles: "Cc1ccccc1C",
6450                first_match: &[0, 1, 6, 7],
6451            },
6452            MaccsPatternGolden {
6453                bit: 145,
6454                smarts: "*1~*~*~*~*~*~1",
6455                smiles: "C1CCCCC1",
6456                first_match: &[0, 1, 2, 3, 4, 5],
6457            },
6458            MaccsPatternGolden {
6459                bit: 147,
6460                smarts: "[$(*~[CH2]~[CH2]~*),$([R]1@[CH2R]@[CH2R]1)]",
6461                smiles: "CCCC",
6462                first_match: &[0],
6463            },
6464            MaccsPatternGolden {
6465                bit: 148,
6466                smarts: "*~[!#6!#1](~*)~*",
6467                smiles: "C[S](C)(C)C",
6468                first_match: &[0, 1, 2, 3],
6469            },
6470            MaccsPatternGolden {
6471                bit: 149,
6472                smarts: "[C;H3,H4]",
6473                smiles: "C",
6474                first_match: &[0],
6475            },
6476            MaccsPatternGolden {
6477                bit: 150,
6478                smarts: "*!@*@*!@*",
6479                smiles: "Cc1ccccc1C",
6480                first_match: &[0, 1, 6, 7],
6481            },
6482            MaccsPatternGolden {
6483                bit: 151,
6484                smarts: "[#7!H0]",
6485                smiles: "CCN",
6486                first_match: &[2],
6487            },
6488            MaccsPatternGolden {
6489                bit: 152,
6490                smarts: "[#8]~[#6](~[#6])~[#6]",
6491                smiles: "CC(C)(C)O",
6492                first_match: &[4, 1, 0, 2],
6493            },
6494            MaccsPatternGolden {
6495                bit: 154,
6496                smarts: "[#6]=[#8]",
6497                smiles: "CC(=O)O",
6498                first_match: &[1, 2],
6499            },
6500            MaccsPatternGolden {
6501                bit: 155,
6502                smarts: "*!@[CH2]!@*",
6503                smiles: "CCC",
6504                first_match: &[0, 1, 2],
6505            },
6506            MaccsPatternGolden {
6507                bit: 156,
6508                smarts: "[#7]~*(~*)~*",
6509                smiles: "CC(C)N",
6510                first_match: &[3, 1, 0, 2],
6511            },
6512            MaccsPatternGolden {
6513                bit: 157,
6514                smarts: "[#6]-[#8]",
6515                smiles: "CCO",
6516                first_match: &[1, 2],
6517            },
6518            MaccsPatternGolden {
6519                bit: 158,
6520                smarts: "[#6]-[#7]",
6521                smiles: "CCN",
6522                first_match: &[1, 2],
6523            },
6524            MaccsPatternGolden {
6525                bit: 162,
6526                smarts: "a",
6527                smiles: "c1ccccc1",
6528                first_match: &[0],
6529            },
6530            MaccsPatternGolden {
6531                bit: 165,
6532                smarts: "[R]",
6533                smiles: "C1CC1",
6534                first_match: &[0],
6535            },
6536        ]
6537    }
6538
6539    #[test]
6540    fn maccs_patterns_match_rdkit_positive_truth_and_first_atom_maps() {
6541        let goldens = rdkit_maccs_pattern_positive_goldens();
6542        assert_eq!(goldens.len(), 136);
6543
6544        for golden in goldens {
6545            let mol =
6546                Molecule::from_smiles_with_sanitize(golden.smiles, true).unwrap_or_else(|error| {
6547                    panic!("MACCS bit {} target SMILES failed: {error}", golden.bit)
6548                });
6549            let query = compile_query_fixture(golden.smarts)
6550                .unwrap_or_else(|error| panic!("MACCS bit {} SMARTS failed: {error}", golden.bit));
6551            let matches = get_substruct_matches(&mol, &query);
6552            assert!(
6553                !matches.is_empty(),
6554                "MACCS bit {} should match RDKit positive target {} with SMARTS {}",
6555                golden.bit,
6556                golden.smiles,
6557                golden.smarts
6558            );
6559            assert_eq!(
6560                matches[0].atom_mapping, golden.first_match,
6561                "MACCS bit {} first RDKit atom map for {}",
6562                golden.bit, golden.smiles
6563            );
6564        }
6565    }
6566
6567    #[test]
6568    fn maccs_bit_030_requires_four_explicit_neighbors_like_rdkit() {
6569        let mol = Molecule::from_smiles("ON(C)C").expect("fixture should parse");
6570        let query = compile_query_fixture("[#6]~[!#6!#1](~[#6])(~[#6])~*")
6571            .expect("MACCS bit 30 SMARTS should build");
6572
6573        assert_eq!(query.num_atoms(), 5);
6574        assert_eq!(query.num_bonds(), 4);
6575        assert!(
6576            !has_substruct_match(&mol, &query),
6577            "RDKit does not match MACCS bit 30 against ON(C)C through the max-matches=1 path"
6578        );
6579        assert!(
6580            get_substruct_matches(&mol, &query).is_empty(),
6581            "RDKit does not match MACCS bit 30 against ON(C)C"
6582        );
6583    }
6584
6585    #[test]
6586    fn test_atom_matches_basic() {
6587        let mut builder = MoleculeBuilder::new();
6588        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6589        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6590        builder
6591            .add_bond(crate::BondSpec::new(c0, c1, BondOrder::Single))
6592            .expect("add bond");
6593        let mol = builder.build().expect("build");
6594        let q_atom = &mol.atoms()[0];
6595        let m_atom = &mol.atoms()[1];
6596        assert!(
6597            atom_matches(q_atom, &mol, m_atom, &mol),
6598            "two carbons should match"
6599        );
6600    }
6601
6602    #[test]
6603    fn test_atom_matches_different_elements() {
6604        let mut builder = MoleculeBuilder::new();
6605        let c = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6606        let o = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
6607        builder
6608            .add_bond(crate::BondSpec::new(c, o, BondOrder::Single))
6609            .expect("add bond");
6610        let mol = builder.build().expect("build");
6611        // Query atom = C, target atom = O — should not match (C != O, and
6612        // atomic number check should reject since 6 != 8).
6613        // But our atom_matches uses query atomic number: query=6, mol=8.
6614        // If query atomic number != 0, it must match. So C does not match O.
6615        let c_atom = &mol.atoms()[0]; // C
6616        let o_atom = &mol.atoms()[1]; // O
6617        assert!(
6618            !atom_matches(c_atom, &mol, o_atom, &mol),
6619            "C should not match O via basic atomic number check"
6620        );
6621    }
6622
6623    #[test]
6624    fn atom_matches_reproduces_rdkit_plain_atom_defaults() {
6625        fn one_atom(spec: crate::AtomSpec) -> Molecule {
6626            let mut builder = MoleculeBuilder::new();
6627            builder.add_atom(spec);
6628            builder.build().expect("one-atom fixture")
6629        }
6630
6631        let dummy = one_atom(crate::AtomSpec::new(crate::Element::DUMMY));
6632        let isotope_one = one_atom(crate::AtomSpec::new(crate::Element::DUMMY).with_isotope(1));
6633        let isotope_two = one_atom(crate::AtomSpec::new(crate::Element::DUMMY).with_isotope(2));
6634        assert!(atom_matches(
6635            &dummy.atoms()[0],
6636            &dummy,
6637            &isotope_one.atoms()[0],
6638            &isotope_one,
6639        ));
6640        assert!(atom_matches(
6641            &isotope_one.atoms()[0],
6642            &isotope_one,
6643            &dummy.atoms()[0],
6644            &dummy,
6645        ));
6646        assert!(!atom_matches(
6647            &isotope_one.atoms()[0],
6648            &isotope_one,
6649            &isotope_two.atoms()[0],
6650            &isotope_two,
6651        ));
6652
6653        let neutral_carbon = one_atom(crate::AtomSpec::new(crate::Element::C));
6654        let charged_carbon =
6655            one_atom(crate::AtomSpec::new(crate::Element::C).with_formal_charge(1));
6656        assert!(atom_matches(
6657            &neutral_carbon.atoms()[0],
6658            &neutral_carbon,
6659            &charged_carbon.atoms()[0],
6660            &charged_carbon,
6661        ));
6662        assert!(!atom_matches(
6663            &charged_carbon.atoms()[0],
6664            &charged_carbon,
6665            &neutral_carbon.atoms()[0],
6666            &neutral_carbon,
6667        ));
6668
6669        let radical_carbon =
6670            one_atom(crate::AtomSpec::new(crate::Element::C).with_radical_electrons(1));
6671        assert!(!atom_matches(
6672            &radical_carbon.atoms()[0],
6673            &radical_carbon,
6674            &neutral_carbon.atoms()[0],
6675            &neutral_carbon,
6676        ));
6677    }
6678
6679    #[test]
6680    fn test_bond_matches_single() {
6681        let mut builder = MoleculeBuilder::new();
6682        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6683        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6684        builder
6685            .add_bond(crate::BondSpec::new(c0, c1, BondOrder::Single))
6686            .expect("add bond");
6687        let mol = builder.build().expect("build");
6688        assert!(bond_compat(
6689            &mol.bonds()[0],
6690            &mol,
6691            &mol.bonds()[0],
6692            &mol,
6693            &SubstructMatchParams::default(),
6694            &build_query_match_context(&mol),
6695        ));
6696    }
6697
6698    #[test]
6699    fn test_vf2_graph_building() {
6700        let cc = make_mol_cc();
6701        let g = build_vf2_graph(&cc);
6702        assert_eq!(g.n_atoms, 2);
6703        assert_eq!(g.n_bonds, 1);
6704        assert_eq!(g.out_degree(0), 1);
6705        assert_eq!(g.out_degree(1), 1);
6706        assert_eq!(g.out_edges(0)[0].0, 1);
6707        assert_eq!(g.out_edges(1)[0].0, 0);
6708    }
6709
6710    #[test]
6711    fn smarts_vf2_other_index() {
6712        let cc = make_mol_cc();
6713        let graph = build_vf2_graph(&cc);
6714        assert_eq!(get_other_idx(&graph, 0, 0), 1);
6715        assert_eq!(get_other_idx(&graph, 0, 1), 0);
6716
6717        // RDKit returns source(edge) when `vertex` is not the source; it does
6718        // not validate that `vertex` is an endpoint.
6719        assert_eq!(get_other_idx(&graph, 0, 99), 0);
6720    }
6721
6722    #[test]
6723    fn test_sort_nodes_by_frequency_small() {
6724        let cc = make_mol_cc();
6725        let g = build_vf2_graph(&cc);
6726        let order = sort_nodes_by_frequency(&g);
6727        // Both nodes have degree 1, so order depends on sort stability.
6728        assert_eq!(order.len(), 2);
6729        // Both should be present.
6730        assert!(order.contains(&0));
6731        assert!(order.contains(&1));
6732    }
6733
6734    #[test]
6735    fn smarts_vf2_node_order() {
6736        let mut builder = MoleculeBuilder::new();
6737        let center = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6738        let leaf1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6739        let leaf2 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6740        let leaf3 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6741        let isolated = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6742        for leaf in [leaf1, leaf2, leaf3] {
6743            builder
6744                .add_bond(crate::BondSpec::new(center, leaf, BondOrder::Single))
6745                .expect("add star bond");
6746        }
6747        let molecule = builder.build().expect("build star and isolated atom");
6748
6749        let order = sort_nodes_by_frequency(&build_vf2_graph(&molecule));
6750        assert_eq!(order[0], center.index());
6751        assert_eq!(order[4], isolated.index());
6752        let mut middle = order[1..4].to_vec();
6753        middle.sort_unstable();
6754        assert_eq!(middle, vec![leaf1.index(), leaf2.index(), leaf3.index()]);
6755    }
6756
6757    #[test]
6758    fn smarts_vf2_node_compare_degree() {
6759        let lower_out = NodeInfo {
6760            id: 0,
6761            in_deg: 9,
6762            out_deg: 1,
6763        };
6764        let higher_out = NodeInfo {
6765            id: 1,
6766            in_deg: 0,
6767            out_deg: 2,
6768        };
6769        assert_eq!(
6770            node_info_cmp1(&lower_out, &higher_out),
6771            std::cmp::Ordering::Less
6772        );
6773
6774        let lower_in = NodeInfo {
6775            id: 2,
6776            in_deg: 1,
6777            out_deg: 3,
6778        };
6779        let higher_in = NodeInfo {
6780            id: 3,
6781            in_deg: 2,
6782            out_deg: 3,
6783        };
6784        assert_eq!(
6785            node_info_cmp1(&lower_in, &higher_in),
6786            std::cmp::Ordering::Less
6787        );
6788        assert_eq!(
6789            node_info_cmp1(&lower_in, &NodeInfo { id: 4, ..lower_in }),
6790            std::cmp::Ordering::Equal
6791        );
6792    }
6793
6794    #[test]
6795    fn smarts_vf2_node_compare_frequency() {
6796        let isolated = NodeInfo {
6797            id: 0,
6798            in_deg: 0,
6799            out_deg: 0,
6800        };
6801        let connected = NodeInfo {
6802            id: 1,
6803            in_deg: 2,
6804            out_deg: 9,
6805        };
6806        assert_eq!(
6807            node_info_cmp2(&isolated, &connected),
6808            std::cmp::Ordering::Greater
6809        );
6810        assert_eq!(
6811            node_info_cmp2(&connected, &isolated),
6812            std::cmp::Ordering::Less
6813        );
6814
6815        let rarer = NodeInfo {
6816            id: 2,
6817            in_deg: 8,
6818            out_deg: 1,
6819        };
6820        let common = NodeInfo {
6821            id: 3,
6822            in_deg: 1,
6823            out_deg: 2,
6824        };
6825        assert_eq!(node_info_cmp2(&rarer, &common), std::cmp::Ordering::Less);
6826
6827        let lower_valence = NodeInfo {
6828            id: 4,
6829            in_deg: 2,
6830            out_deg: 3,
6831        };
6832        let higher_valence = NodeInfo {
6833            id: 5,
6834            in_deg: 4,
6835            out_deg: 3,
6836        };
6837        assert_eq!(
6838            node_info_cmp2(&lower_valence, &higher_valence),
6839            std::cmp::Ordering::Less
6840        );
6841        assert_eq!(
6842            node_info_cmp2(
6843                &lower_valence,
6844                &NodeInfo {
6845                    id: 6,
6846                    ..lower_valence
6847                }
6848            ),
6849            std::cmp::Ordering::Equal
6850        );
6851    }
6852
6853    #[test]
6854    fn test_vf2_state_initial() {
6855        let cc = make_mol_cc();
6856        let g = build_vf2_graph(&cc);
6857        let state = Vf2SubState::new(&g, &g, true);
6858        assert!(!state.is_goal());
6859        assert!(!state.is_dead());
6860        assert_eq!(state.core_len, 0);
6861    }
6862
6863    #[test]
6864    fn smarts_vf2_state_new() {
6865        let query = build_vf2_graph(&make_mol_cc());
6866        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
6867        let target = build_vf2_graph(&target_molecule);
6868
6869        let unsorted = Vf2SubState::new(&query, &target, false);
6870        assert_eq!((unsorted.n1, unsorted.n2), (2, 3));
6871        assert_eq!(
6872            (unsorted.core_len, unsorted.t1_len, unsorted.t2_len),
6873            (0, 0, 0)
6874        );
6875        assert_eq!(unsorted.core_1, vec![NULL_NODE; 2]);
6876        assert_eq!(unsorted.core_2, vec![NULL_NODE; 3]);
6877        assert_eq!(unsorted.term_1, vec![0; 2]);
6878        assert_eq!(unsorted.term_2, vec![0; 3]);
6879        assert!(unsorted.debug_order().is_none());
6880
6881        let sorted = Vf2SubState::new(&query, &target, true);
6882        assert_eq!(
6883            sorted.debug_order(),
6884            Some(sort_nodes_by_frequency(&query).as_slice())
6885        );
6886    }
6887
6888    #[test]
6889    fn smarts_vf2_state_clone() {
6890        let molecule = make_mol_cc();
6891        let graph = build_vf2_graph(&molecule);
6892        let mut original = Vf2SubState::new(&graph, &graph, true);
6893        original.add_pair(0, 0);
6894
6895        let mut cloned = original.clone_state();
6896        assert_eq!(cloned.core_len, original.core_len);
6897        assert_eq!(cloned.core_1, original.core_1);
6898        assert_eq!(cloned.core_2, original.core_2);
6899        assert_eq!(cloned.term_1, original.term_1);
6900        assert_eq!(cloned.term_2, original.term_2);
6901        assert_eq!(cloned.order, original.order);
6902
6903        cloned.add_pair(1, 1);
6904        assert_eq!(cloned.core_len, 2);
6905        assert_eq!(original.core_len, 1);
6906        assert_eq!(original.core_1[1], NULL_NODE);
6907    }
6908
6909    #[test]
6910    fn smarts_vf2_goal() {
6911        let molecule = make_mol_cc();
6912        let graph = build_vf2_graph(&molecule);
6913        let mut state = Vf2SubState::new(&graph, &graph, false);
6914        assert!(!state.is_goal());
6915        state.add_pair(0, 0);
6916        assert!(!state.is_goal());
6917        state.add_pair(1, 1);
6918        assert!(state.is_goal());
6919    }
6920
6921    #[test]
6922    fn smarts_vf2_match_checks() {
6923        let molecule = make_mol_cc();
6924        let graph = build_vf2_graph(&molecule);
6925        let state = Vf2SubState::new(&graph, &graph, false);
6926        let mut seen = None;
6927        let mut check = |c1: &[NodeId], c2: &[NodeId]| {
6928            seen = Some((c1.to_vec(), c2.to_vec()));
6929            c1 == [0, 1] && c2 == [1, 0]
6930        };
6931        assert!(state.match_checks(&[0, 1], &[1, 0], &mut check));
6932        assert_eq!(seen, Some((vec![0, 1], vec![1, 0])));
6933    }
6934
6935    #[test]
6936    fn smarts_vf2_dead() {
6937        let query_molecule = Molecule::from_smiles("CCC").expect("parse query");
6938        let target_molecule = make_mol_cc();
6939        let query = build_vf2_graph(&query_molecule);
6940        let target = build_vf2_graph(&target_molecule);
6941        assert!(Vf2SubState::new(&query, &target, false).is_dead());
6942
6943        let mut terminal_dead = Vf2SubState::new(&target, &query, false);
6944        terminal_dead.t1_len = 2;
6945        terminal_dead.t2_len = 1;
6946        assert!(terminal_dead.is_dead());
6947        terminal_dead.t2_len = 2;
6948        assert!(!terminal_dead.is_dead());
6949    }
6950
6951    #[test]
6952    fn smarts_vf2_core_len() {
6953        let molecule = make_mol_cc();
6954        let graph = build_vf2_graph(&molecule);
6955        let mut state = Vf2SubState::new(&graph, &graph, false);
6956        assert_eq!(state.core_len(), 0);
6957        state.add_pair(0, 0);
6958        assert_eq!(state.core_len(), 1);
6959        state.add_pair(1, 1);
6960        assert_eq!(state.core_len(), 2);
6961    }
6962
6963    #[test]
6964    fn test_vf2_next_pair_initial() {
6965        let cc = make_mol_cc();
6966        let g = build_vf2_graph(&cc);
6967        let state = Vf2SubState::new(&g, &g, true);
6968        let mut pair = Vf2Pair::new();
6969        let has_next = state.next_pair(&mut pair);
6970        assert!(has_next, "should find a next pair");
6971    }
6972
6973    #[test]
6974    fn smarts_vf2_next_pair() {
6975        let mut builder = MoleculeBuilder::new();
6976        let center = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6977        let leaf1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6978        let leaf2 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
6979        for leaf in [leaf1, leaf2] {
6980            builder
6981                .add_bond(crate::BondSpec::new(center, leaf, BondOrder::Single))
6982                .expect("add star bond");
6983        }
6984        let molecule = builder.build().expect("build star");
6985        let graph = build_vf2_graph(&molecule);
6986
6987        let state = Vf2SubState::new(&graph, &graph, true);
6988        let mut pair = Vf2Pair::new();
6989        let mut initial_pairs = Vec::new();
6990        while state.next_pair(&mut pair) {
6991            initial_pairs.push((pair.n1, pair.n2));
6992        }
6993        assert_eq!(
6994            initial_pairs,
6995            vec![
6996                (center.index(), 0),
6997                (center.index(), 1),
6998                (center.index(), 2)
6999            ]
7000        );
7001
7002        let mut terminal_state = Vf2SubState::new(&graph, &graph, true);
7003        terminal_state.add_pair(center.index(), center.index());
7004        let mut terminal_pair = Vf2Pair::new();
7005        let mut target_neighbors = Vec::new();
7006        while terminal_state.next_pair(&mut terminal_pair) {
7007            assert!(terminal_pair.n1 == leaf1.index() || terminal_pair.n1 == leaf2.index());
7008            target_neighbors.push(terminal_pair.n2);
7009        }
7010        target_neighbors.sort_unstable();
7011        assert_eq!(target_neighbors, vec![leaf1.index(), leaf2.index()]);
7012    }
7013
7014    #[test]
7015    fn smarts_vf2_feasible_pair() {
7016        let query_molecule = make_mol_cc();
7017        let query = build_vf2_graph(&query_molecule);
7018
7019        let single_atom = Molecule::from_smiles("C").expect("parse single atom");
7020        let single = build_vf2_graph(&single_atom);
7021        let state = Vf2SubState::new(&query, &single, false);
7022        assert!(!state.is_feasible_pair(0, 0, &|_, _| true, &|_, _| true));
7023
7024        let target_molecule = Molecule::from_smiles("CC").expect("parse target");
7025        let target = build_vf2_graph(&target_molecule);
7026        let state = Vf2SubState::new(&query, &target, false);
7027        assert!(!state.is_feasible_pair(0, 0, &|_, _| false, &|_, _| true));
7028        assert!(state.is_feasible_pair(0, 0, &|_, _| true, &|_, _| true));
7029
7030        let mut mapped = Vf2SubState::new(&query, &target, false);
7031        mapped.add_pair(0, 0);
7032        assert!(!mapped.is_feasible_pair(1, 1, &|_, _| true, &|_, _| false));
7033        assert!(mapped.is_feasible_pair(1, 1, &|_, _| true, &|_, _| true));
7034
7035        let disconnected_molecule = Molecule::from_smiles("CC.CC").expect("parse fragments");
7036        let disconnected = build_vf2_graph(&disconnected_molecule);
7037        let mut missing_edge = Vf2SubState::new(&query, &disconnected, false);
7038        missing_edge.add_pair(0, 0);
7039        assert!(!missing_edge.is_feasible_pair(1, 2, &|_, _| true, &|_, _| true));
7040    }
7041
7042    #[test]
7043    fn smarts_vf2_add_pair() {
7044        let molecule = Molecule::from_smiles("CCC").expect("parse chain");
7045        let graph = build_vf2_graph(&molecule);
7046        let mut state = Vf2SubState::new(&graph, &graph, false);
7047
7048        state.add_pair(1, 1);
7049        assert_eq!(state.core_len, 1);
7050        assert_eq!(state.core_1, vec![NULL_NODE, 1, NULL_NODE]);
7051        assert_eq!(state.core_2, vec![NULL_NODE, 1, NULL_NODE]);
7052        assert_eq!(state.term_1, vec![1, 1, 1]);
7053        assert_eq!(state.term_2, vec![1, 1, 1]);
7054        assert_eq!((state.t1_len, state.t2_len), (3, 3));
7055
7056        state.add_pair(0, 0);
7057        assert_eq!(state.core_len, 2);
7058        assert_eq!(state.core_1, vec![0, 1, NULL_NODE]);
7059        assert_eq!(state.core_2, vec![0, 1, NULL_NODE]);
7060        assert_eq!(state.term_1, vec![1, 1, 1]);
7061        assert_eq!(state.term_2, vec![1, 1, 1]);
7062        assert_eq!((state.t1_len, state.t2_len), (3, 3));
7063    }
7064
7065    #[test]
7066    fn smarts_vf2_core_set() {
7067        let molecule = Molecule::from_smiles("CCC").expect("parse chain");
7068        let graph = build_vf2_graph(&molecule);
7069        let mut state = Vf2SubState::new(&graph, &graph, false);
7070        state.add_pair(2, 0);
7071        state.add_pair(0, 2);
7072
7073        let (c1, c2) = state.get_core_set();
7074        assert_eq!(c1, vec![0, 2]);
7075        assert_eq!(c2, vec![2, 0]);
7076    }
7077
7078    #[test]
7079    fn smarts_vf2_clone() {
7080        let molecule = Molecule::from_smiles("CCC").expect("parse chain");
7081        let graph = build_vf2_graph(&molecule);
7082        let mut state = Vf2SubState::new(&graph, &graph, true);
7083        state.add_pair(1, 1);
7084        let mut cloned = state.clone();
7085
7086        cloned.back_track(1, 1);
7087        assert_eq!(cloned.core_len, 0);
7088        assert_eq!(state.core_len, 1);
7089        assert_eq!(state.core_1[1], 1);
7090        assert_eq!(state.core_2[1], 1);
7091    }
7092
7093    #[test]
7094    fn smarts_vf2_backtrack() {
7095        let molecule = Molecule::from_smiles("CCC").expect("parse chain");
7096        let graph = build_vf2_graph(&molecule);
7097        let mut state = Vf2SubState::new(&graph, &graph, false);
7098        state.add_pair(1, 1);
7099        state.add_pair(0, 0);
7100        assert_eq!(state.core_len, 2);
7101
7102        state.back_track(0, 0);
7103        assert_eq!(state.core_len, 1);
7104        assert_eq!(state.core_1, vec![NULL_NODE, 1, NULL_NODE]);
7105        assert_eq!(state.core_2, vec![NULL_NODE, 1, NULL_NODE]);
7106        assert_eq!(state.term_1, vec![1, 1, 1]);
7107        assert_eq!(state.term_2, vec![1, 1, 1]);
7108        assert_eq!((state.t1_len, state.t2_len), (3, 3));
7109
7110        state.back_track(1, 1);
7111        assert_eq!(state.core_len, 0);
7112        assert_eq!(state.term_1, vec![0, 0, 0]);
7113        assert_eq!(state.term_2, vec![0, 0, 0]);
7114        assert_eq!((state.t1_len, state.t2_len), (0, 0));
7115    }
7116
7117    #[test]
7118    fn smarts_vf2_match_one() {
7119        let query_molecule = make_mol_cc();
7120        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7121        let query = build_vf2_graph(&query_molecule);
7122        let target = build_vf2_graph(&target_molecule);
7123        let mut state = Vf2SubState::new(&query, &target, true);
7124        let mut checks = 0;
7125        let mut accept_second = |_: &[NodeId], _: &[NodeId]| {
7126            checks += 1;
7127            checks == 2
7128        };
7129        let (c1, c2) = state
7130            .match_one(&|_, _| true, &|_, _| true, Some(&mut accept_second))
7131            .expect("second complete mapping accepted");
7132        assert_eq!(c1, vec![0, 1]);
7133        assert_eq!(c2.len(), 2);
7134        assert_eq!(checks, 2);
7135
7136        let mut dead = Vf2SubState::new(&target, &query, true);
7137        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7138        assert!(
7139            dead.match_one(&|_, _| true, &|_, _| true, Some(&mut accept_all))
7140                .is_none()
7141        );
7142    }
7143
7144    #[test]
7145    fn smarts_vf2_match_all() {
7146        let query_molecule = make_mol_cc();
7147        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7148        let query = build_vf2_graph(&query_molecule);
7149        let target = build_vf2_graph(&target_molecule);
7150
7151        let mut state = Vf2SubState::new(&query, &target, true);
7152        let mut results = Vec::new();
7153        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7154        assert!(!state.match_all(
7155            &|_, _| true,
7156            &|_, _| true,
7157            Some(&mut accept_all),
7158            &mut results,
7159            0,
7160        ));
7161        assert_eq!(results.len(), 4);
7162
7163        let mut limited_state = Vf2SubState::new(&query, &target, true);
7164        let mut limited = Vec::new();
7165        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7166        assert!(limited_state.match_all(
7167            &|_, _| true,
7168            &|_, _| true,
7169            Some(&mut accept_all),
7170            &mut limited,
7171            2,
7172        ));
7173        assert_eq!(limited.len(), 2);
7174    }
7175
7176    #[test]
7177    fn smarts_vf2_free_match_one() {
7178        let query_molecule = make_mol_cc();
7179        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7180        let query = build_vf2_graph(&query_molecule);
7181        let target = build_vf2_graph(&target_molecule);
7182        let mut state = Vf2SubState::new(&query, &target, false);
7183        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7184        let (c1, c2) = vf2_match(
7185            &mut state,
7186            &|_, _| true,
7187            &|_, _| true,
7188            Some(&mut accept_all),
7189        )
7190        .expect("free match finds mapping");
7191        assert_eq!(c1.len(), state.core_len());
7192        assert_eq!(c1, vec![0, 1]);
7193        assert_eq!(c2.len(), 2);
7194
7195        let mut dead = Vf2SubState::new(&target, &query, false);
7196        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7197        assert!(vf2_match(&mut dead, &|_, _| true, &|_, _| true, Some(&mut accept_all),).is_none());
7198    }
7199
7200    #[test]
7201    fn smarts_vf2_free_match_all() {
7202        let query_molecule = make_mol_cc();
7203        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7204        let query = build_vf2_graph(&query_molecule);
7205        let target = build_vf2_graph(&target_molecule);
7206        let mut state = Vf2SubState::new(&query, &target, false);
7207        let mut results = Vec::new();
7208        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7209        assert!(vf2_match_all(
7210            &mut state,
7211            &|_, _| true,
7212            &|_, _| true,
7213            Some(&mut accept_all),
7214            &mut results,
7215            2,
7216        ));
7217        assert_eq!(results.len(), 2);
7218
7219        let mut dead = Vf2SubState::new(&target, &query, false);
7220        let mut no_results = Vec::new();
7221        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7222        assert!(!vf2_match_all(
7223            &mut dead,
7224            &|_, _| true,
7225            &|_, _| true,
7226            Some(&mut accept_all),
7227            &mut no_results,
7228            2,
7229        ));
7230        assert!(no_results.is_empty());
7231    }
7232
7233    #[test]
7234    fn smarts_vf2_entry_one() {
7235        let query_molecule = make_mol_cc();
7236        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7237        let query = build_vf2_graph(&query_molecule);
7238        let target = build_vf2_graph(&target_molecule);
7239        let mut result = vec![(99, 99)];
7240        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7241        assert!(vf2_entry_one(
7242            &query,
7243            &target,
7244            &|_, _| true,
7245            &|_, _| true,
7246            Some(&mut accept_all),
7247            &mut result,
7248        ));
7249        assert_eq!(result.len(), 2);
7250        assert!(!result.contains(&(99, 99)));
7251
7252        let mut no_result = vec![(99, 99)];
7253        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7254        assert!(!vf2_entry_one(
7255            &target,
7256            &query,
7257            &|_, _| true,
7258            &|_, _| true,
7259            Some(&mut accept_all),
7260            &mut no_result,
7261        ));
7262        assert!(no_result.is_empty());
7263    }
7264
7265    #[test]
7266    fn smarts_vf2_entry_all() {
7267        let query_molecule = make_mol_cc();
7268        let target_molecule = Molecule::from_smiles("CCC").expect("parse target");
7269        let query = build_vf2_graph(&query_molecule);
7270        let target = build_vf2_graph(&target_molecule);
7271        let mut results = vec![(vec![99], vec![99])];
7272        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7273        assert!(vf2_entry_all(
7274            &query,
7275            &target,
7276            &|_, _| true,
7277            &|_, _| true,
7278            Some(&mut accept_all),
7279            &mut results,
7280            3,
7281        ));
7282        assert_eq!(results.len(), 3);
7283        assert!(!results.iter().any(|mapping| mapping.0 == [99]));
7284
7285        let mut no_results = vec![(vec![99], vec![99])];
7286        let mut accept_all = |_: &[NodeId], _: &[NodeId]| true;
7287        assert!(!vf2_entry_all(
7288            &target,
7289            &query,
7290            &|_, _| true,
7291            &|_, _| true,
7292            Some(&mut accept_all),
7293            &mut no_results,
7294            3,
7295        ));
7296        assert!(no_results.is_empty());
7297    }
7298
7299    #[test]
7300    fn smarts_match_chiral_label() {
7301        let atom = |tag| {
7302            let mut builder = MoleculeBuilder::new();
7303            builder.add_atom(crate::AtomSpec::new(crate::Element::C).with_chiral_tag(tag));
7304            let molecule = builder.build().expect("build one atom");
7305            molecule.atoms()[0].clone()
7306        };
7307        assert!(has_chiral_label(&atom(ChiralTag::TetrahedralCw)));
7308        assert!(has_chiral_label(&atom(ChiralTag::TetrahedralCcw)));
7309        for tag in [
7310            ChiralTag::Unspecified,
7311            ChiralTag::Other,
7312            ChiralTag::Tetrahedral,
7313            ChiralTag::Allene,
7314            ChiralTag::SquarePlanar,
7315            ChiralTag::TrigonalBipyramidal,
7316            ChiralTag::Octahedral,
7317        ] {
7318            assert!(
7319                !has_chiral_label(&atom(tag)),
7320                "unexpected label for {tag:?}"
7321            );
7322        }
7323    }
7324
7325    #[test]
7326    fn smarts_match_enhanced_stereo() {
7327        fn grouped(groups: &[(StereoGroupKind, &[usize])]) -> Molecule {
7328            let mut builder = MoleculeBuilder::new();
7329            let atoms: Vec<_> = (0..3)
7330                .map(|_| builder.add_atom(crate::AtomSpec::new(crate::Element::C)))
7331                .collect();
7332            for (kind, members) in groups {
7333                builder
7334                    .add_stereo_group(crate::StereoGroup::new(
7335                        *kind,
7336                        members.iter().map(|&idx| atoms[idx]).collect(),
7337                        Vec::new(),
7338                    ))
7339                    .expect("add stereo group");
7340            }
7341            builder.build().expect("build grouped molecule")
7342        }
7343
7344        let query_or = grouped(&[(StereoGroupKind::Or, &[0, 1])]);
7345        let mol_or = grouped(&[(StereoGroupKind::Or, &[0, 1])]);
7346        let mol_and = grouped(&[(StereoGroupKind::And, &[0, 1])]);
7347        let identity = [0, 1, 2];
7348        let or_membership = [Some(0), Some(0), None];
7349        let same = [Some(true), Some(true), None];
7350        assert!(enhanced_stereo_is_ok(
7351            &mol_or,
7352            &query_or,
7353            &identity,
7354            &or_membership,
7355            &same,
7356        ));
7357        assert!(enhanced_stereo_is_ok(
7358            &mol_and,
7359            &query_or,
7360            &identity,
7361            &or_membership,
7362            &same,
7363        ));
7364
7365        let query_and = grouped(&[(StereoGroupKind::And, &[0, 1])]);
7366        assert!(!enhanced_stereo_is_ok(
7367            &mol_or,
7368            &query_and,
7369            &identity,
7370            &or_membership,
7371            &same,
7372        ));
7373        assert!(enhanced_stereo_is_ok(
7374            &mol_and,
7375            &query_and,
7376            &identity,
7377            &or_membership,
7378            &same,
7379        ));
7380
7381        let absolute = grouped(&[]);
7382        assert!(!enhanced_stereo_is_ok(
7383            &absolute,
7384            &query_or,
7385            &identity,
7386            &[None, None, None],
7387            &[None, None, None],
7388        ));
7389        assert!(!enhanced_stereo_is_ok(
7390            &mol_or,
7391            &query_or,
7392            &identity,
7393            &or_membership,
7394            &[Some(true), Some(false), None],
7395        ));
7396
7397        let split_query = grouped(&[(StereoGroupKind::Or, &[0]), (StereoGroupKind::Or, &[1])]);
7398        assert!(!enhanced_stereo_is_ok(
7399            &mol_or,
7400            &split_query,
7401            &identity,
7402            &or_membership,
7403            &same,
7404        ));
7405    }
7406
7407    #[test]
7408    fn smarts_match_insert_unique() {
7409        let mut matches = BTreeSet::new();
7410        let first = vec![(0, 2), (1, 1)];
7411        assert!(insert_if_needed(&mut matches, first.clone()));
7412        assert!(!insert_if_needed(&mut matches, first.clone()));
7413
7414        let lexicographically_smaller = vec![(0, 1), (1, 2)];
7415        assert!(insert_if_needed(
7416            &mut matches,
7417            lexicographically_smaller.clone()
7418        ));
7419        assert_eq!(matches, BTreeSet::from([lexicographically_smaller]));
7420
7421        let distinct_atom_set = vec![(0, 3), (1, 2)];
7422        assert!(insert_if_needed(&mut matches, distinct_atom_set.clone()));
7423        assert!(matches.contains(&distinct_atom_set));
7424        assert_eq!(matches.len(), 2);
7425    }
7426
7427    #[test]
7428    fn smarts_match_try_insert() {
7429        let first = vec![(0, 2), (1, 1)];
7430        let duplicate_atom_set = vec![(0, 1), (1, 2)];
7431
7432        let mut params = SubstructMatchParams {
7433            max_matches: 1,
7434            uniquify: false,
7435            ..SubstructMatchParams::default()
7436        };
7437        let mut matches = BTreeSet::new();
7438        assert!(try_to_insert(&mut matches, first.clone(), &params));
7439        assert!(!try_to_insert(
7440            &mut matches,
7441            duplicate_atom_set.clone(),
7442            &params
7443        ));
7444        assert_eq!(matches, BTreeSet::from([first.clone()]));
7445
7446        params.max_matches = 10;
7447        params.uniquify = true;
7448        assert!(try_to_insert(
7449            &mut matches,
7450            duplicate_atom_set.clone(),
7451            &params
7452        ));
7453        assert_eq!(matches, BTreeSet::from([duplicate_atom_set]));
7454
7455        params.uniquify = false;
7456        assert!(try_to_insert(&mut matches, first.clone(), &params));
7457        assert!(try_to_insert(&mut matches, first, &params));
7458        assert_eq!(matches.len(), 2);
7459    }
7460
7461    #[test]
7462    fn smarts_match_final_check_setup() {
7463        let mut builder = MoleculeBuilder::new();
7464        let atoms: Vec<_> = (0..4)
7465            .map(|_| builder.add_atom(crate::AtomSpec::new(crate::Element::C)))
7466            .collect();
7467        builder
7468            .add_stereo_group(crate::StereoGroup::new(
7469                StereoGroupKind::Absolute,
7470                vec![atoms[0]],
7471                Vec::new(),
7472            ))
7473            .expect("add absolute group");
7474        builder
7475            .add_stereo_group(crate::StereoGroup::new(
7476                StereoGroupKind::Or,
7477                vec![atoms[1], atoms[2]],
7478                Vec::new(),
7479            ))
7480            .expect("add OR group");
7481        builder
7482            .add_stereo_group(crate::StereoGroup::new(
7483                StereoGroupKind::And,
7484                vec![atoms[3]],
7485                Vec::new(),
7486            ))
7487            .expect("add AND group");
7488        let molecule = builder.build().expect("build grouped molecule");
7489
7490        let disabled =
7491            MolMatchFinalCheckSetup::new(&molecule, &molecule, &SubstructMatchParams::default());
7492        assert_eq!(disabled.mol_stereo_groups, vec![None; 4]);
7493
7494        let enabled = MolMatchFinalCheckSetup::new(
7495            &molecule,
7496            &molecule,
7497            &SubstructMatchParams {
7498                use_enhanced_stereo: true,
7499                ..SubstructMatchParams::default()
7500            },
7501        );
7502        assert_eq!(
7503            enabled.mol_stereo_groups,
7504            vec![None, Some(1), Some(1), Some(2)]
7505        );
7506    }
7507
7508    #[test]
7509    fn smarts_match_final_check() {
7510        let query = make_mol_cc();
7511        let molecule = Molecule::from_smiles("CCC").expect("parse target");
7512        let mapping = ([0, 1], [0, 1]);
7513
7514        let mut params = SubstructMatchParams::default();
7515        let setup = MolMatchFinalCheckSetup::new(&query, &molecule, &params);
7516        let mut seen = Vec::new();
7517        assert!(
7518            rdkit_match_final_check(
7519                &molecule, &query, &params, &mapping.0, &mapping.1, &setup, &mut seen,
7520            )
7521            .expect("final check")
7522        );
7523        assert!(
7524            !rdkit_match_final_check(
7525                &molecule, &query, &params, &mapping.0, &mapping.1, &setup, &mut seen,
7526            )
7527            .expect("duplicate final check")
7528        );
7529
7530        params.uniquify = false;
7531        params.extra_final_check = Some(Arc::new(|_, atom_ids| atom_ids == [0, 1]));
7532        let setup = MolMatchFinalCheckSetup::new(&query, &molecule, &params);
7533        assert!(
7534            rdkit_match_final_check(
7535                &molecule,
7536                &query,
7537                &params,
7538                &mapping.0,
7539                &mapping.1,
7540                &setup,
7541                &mut Vec::new(),
7542            )
7543            .expect("accepted callback")
7544        );
7545        params.extra_final_check = Some(Arc::new(|_, _| false));
7546        assert!(
7547            !rdkit_match_final_check(
7548                &molecule,
7549                &query,
7550                &params,
7551                &mapping.0,
7552                &mapping.1,
7553                &setup,
7554                &mut Vec::new(),
7555            )
7556            .expect("rejected callback")
7557        );
7558
7559        params.extra_final_check = None;
7560        params.use_generic_matchers = true;
7561        assert!(
7562            rdkit_match_final_check(
7563                &molecule,
7564                &query,
7565                &params,
7566                &mapping.0,
7567                &mapping.1,
7568                &setup,
7569                &mut Vec::new(),
7570            )
7571            .expect("generic matcher without labels accepts the match")
7572        );
7573    }
7574
7575    #[test]
7576    fn smarts_match_atom_label() {
7577        fn one_atom(element: crate::Element, tag: ChiralTag) -> Molecule {
7578            let mut builder = MoleculeBuilder::new();
7579            builder.add_atom(crate::AtomSpec::new(element).with_chiral_tag(tag));
7580            builder.build().expect("build one atom")
7581        }
7582
7583        let query = one_atom(crate::Element::C, ChiralTag::TetrahedralCw);
7584        let unspecified = one_atom(crate::Element::C, ChiralTag::Unspecified);
7585        let specified = one_atom(crate::Element::C, ChiralTag::TetrahedralCcw);
7586        let oxygen = one_atom(crate::Element::O, ChiralTag::TetrahedralCcw);
7587        let context = build_query_match_context(&unspecified);
7588        let mut params = SubstructMatchParams {
7589            use_chirality: true,
7590            ..SubstructMatchParams::default()
7591        };
7592        assert!(!atom_label_matches(
7593            &query,
7594            &unspecified,
7595            0,
7596            0,
7597            &params,
7598            None,
7599            &context,
7600        ));
7601        params.specified_stereo_query_matches_unspecified = true;
7602        assert!(atom_label_matches(
7603            &query,
7604            &unspecified,
7605            0,
7606            0,
7607            &params,
7608            None,
7609            &context,
7610        ));
7611        let context = build_query_match_context(&specified);
7612        assert!(atom_label_matches(
7613            &query, &specified, 0, 0, &params, None, &context,
7614        ));
7615        let context = build_query_match_context(&oxygen);
7616        assert!(!atom_label_matches(
7617            &query, &oxygen, 0, 0, &params, None, &context,
7618        ));
7619    }
7620
7621    #[test]
7622    fn smarts_match_bond_label() {
7623        fn double_bond(stereo: BondStereo) -> Molecule {
7624            let mut builder = MoleculeBuilder::new();
7625            let begin = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
7626            let end = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
7627            builder
7628                .add_bond(crate::BondSpec::new(begin, end, BondOrder::Double).with_stereo(stereo))
7629                .expect("add double bond");
7630            builder.build().expect("build double bond")
7631        }
7632
7633        let query = double_bond(BondStereo::E);
7634        let unspecified = double_bond(BondStereo::None);
7635        let specified = double_bond(BondStereo::Z);
7636        let mut params = SubstructMatchParams {
7637            use_chirality: true,
7638            ..SubstructMatchParams::default()
7639        };
7640        assert!(!bond_label_matches(
7641            &query,
7642            &unspecified,
7643            0,
7644            0,
7645            &params,
7646            &build_query_match_context(&unspecified),
7647        ));
7648        params.specified_stereo_query_matches_unspecified = true;
7649        assert!(bond_label_matches(
7650            &query,
7651            &unspecified,
7652            0,
7653            0,
7654            &params,
7655            &build_query_match_context(&unspecified),
7656        ));
7657        assert!(bond_label_matches(
7658            &query,
7659            &specified,
7660            0,
7661            0,
7662            &params,
7663            &build_query_match_context(&specified),
7664        ));
7665
7666        let single = Molecule::from_smiles("CC").expect("parse single bond");
7667        assert!(!bond_label_matches(
7668            &query,
7669            &single,
7670            0,
7671            0,
7672            &params,
7673            &build_query_match_context(&single),
7674        ));
7675    }
7676}