#![forbid(unsafe_code)]
use chematic::rxn::run_reactants;
use chematic::smarts::{QueryMolecule, find_matches, parse_smarts};
use chematic::smiles::canonical_smiles;
use crate::chem_env::{Molecule, RetroRule, mol_from_smiles};
pub(crate) fn matches_target(
candidate: &Molecule,
target_canon: &str,
target_query: Option<&QueryMolecule>,
target_atom_count: usize,
) -> bool {
let candidate_canon = canonical_smiles(candidate);
if candidate_canon == target_canon {
return true;
}
if target_canon.contains('@') || candidate_canon.contains('@') {
return false;
}
let Some(query) = target_query else {
return false;
};
candidate.atom_count() == target_atom_count
&& find_matches(query, candidate)
.iter()
.any(|m| m.len() == target_atom_count)
}
fn rule_reverses_to(
target_canon: &str,
target_query: Option<&QueryMolecule>,
target_atom_count: usize,
precursor_mols: &[&Molecule],
rule: &RetroRule,
) -> bool {
let Some((lhs, rhs)) = rule.smirks.split_once(">>") else {
return false;
};
let fwd = format!("{rhs}>>{lhs}");
run_reactants(&fwd, precursor_mols)
.into_iter()
.flatten()
.flatten()
.any(|m| matches_target(&m, target_canon, target_query, target_atom_count))
}
pub fn smirks_reproduces(target: &str, precursors: &[String], rules: &[RetroRule]) -> bool {
let Ok(reactant_mols): Result<Vec<_>, _> =
precursors.iter().map(|s| mol_from_smiles(s)).collect()
else {
return false;
};
let Ok(target_mol) = mol_from_smiles(target) else {
return false;
};
let target_canon = canonical_smiles(&target_mol);
let target_query = parse_smarts(target).ok();
let target_atom_count = target_mol.atom_count();
let mol_refs: Vec<_> = reactant_mols.iter().collect();
rules.iter().filter(|r| !r.smirks.is_empty()).any(|rule| {
rule_reverses_to(
&target_canon,
target_query.as_ref(),
target_atom_count,
&mol_refs,
rule,
)
})
}
pub fn rule_reproduces(target: &str, precursors: &[String], rule: &RetroRule) -> bool {
if rule.smirks.is_empty() {
return false;
}
let Ok(reactant_mols): Result<Vec<_>, _> =
precursors.iter().map(|s| mol_from_smiles(s)).collect()
else {
return false;
};
let Ok(target_mol) = mol_from_smiles(target) else {
return false;
};
let target_canon = canonical_smiles(&target_mol);
let target_query = parse_smarts(target).ok();
let target_atom_count = target_mol.atom_count();
let mol_refs: Vec<_> = reactant_mols.iter().collect();
rule_reverses_to(
&target_canon,
target_query.as_ref(),
target_atom_count,
&mol_refs,
rule,
)
}
pub fn route_forward_validated(route: &crate::search::Route, rules: &[RetroRule]) -> bool {
route
.steps
.iter()
.all(|step| smirks_reproduces(&step.target, &step.precursors, rules))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chem_env::RetroRule;
fn co_aliphatic_cleavage() -> RetroRule {
RetroRule {
name: "co_aliphatic_cleavage".to_string(),
smirks: "[C:1][O:2]>>[C:1].[O:2]".to_string(),
..Default::default()
}
}
#[test]
fn co_aliphatic_cleavage_piperidinyl_carbamate_is_source_step_underspecified() {
let target = "O=C(O[C@@H]1CCCNC1)N";
let precursors = vec!["C1CCNCC1".to_string(), "NC(O)=O".to_string()];
let rule = co_aliphatic_cleavage();
assert!(
!rule_reproduces(target, &precursors, &rule),
"expected this exact Finding #4 pilot step to still be Invalid"
);
let reactant_mols: Vec<Molecule> = precursors
.iter()
.map(|s| mol_from_smiles(s).unwrap())
.collect();
let reactant_refs: Vec<&Molecule> = reactant_mols.iter().collect();
let (lhs, rhs) = rule.smirks.split_once(">>").unwrap();
let fwd = format!("{rhs}>>{lhs}");
let products: std::collections::BTreeSet<String> = run_reactants(&fwd, &reactant_refs)
.unwrap()
.into_iter()
.flatten()
.map(|m| canonical_smiles(&m))
.collect();
let stereo_free_correct_connectivity = "O=C(OC1CCCNC1)N";
assert!(
products.contains(stereo_free_correct_connectivity),
"the rule's reversal must still find the right regiochemistry \
(just without stereo): {products:?}"
);
assert!(
!products.contains(canonical_smiles(&mol_from_smiles(target).unwrap()).as_str()),
"the rule's reversal must never spontaneously produce the \
exact stereo-defined target -- it has no stereo information \
to do so from: {products:?}"
);
}
#[test]
fn co_aliphatic_cleavage_recognizes_bracket_style_reversal_match() {
let rule = co_aliphatic_cleavage();
let target = "[CH3][NH]C(=O)[CH](O[CH2][CH3])O[CH2][CH3]";
let precursors = vec![
"[CH3][NH]C(CO[CH2][CH3])=O".to_string(),
"O[CH2][CH3]".to_string(),
];
assert!(
rule_reproduces(target, &precursors, &rule),
"VF2 fallback must recognize this reversal match despite chematic's \
canonical-string mismatch (lessons.md L2)"
);
}
fn cc_single_cleavage() -> RetroRule {
RetroRule {
name: "cc_single_cleavage".to_string(),
smirks: "[C:1][C:2]>>[C:1].[C:2]".to_string(),
..Default::default()
}
}
#[test]
fn cc_single_cleavage_azepane_methane_is_stereo_underspecified_with_implausible_precursor() {
let target = "C1CC[C@H](C)NC[C@@H]1C";
let precursors = vecC".to_string(), "C".to_string()];
let rule = cc_single_cleavage();
assert!(
!rule_reproduces(target, &precursors, &rule),
"expected this exact Finding #4 pilot step to still be Invalid"
);
let reactant_mols: Vec<Molecule> = precursors
.iter()
.map(|s| mol_from_smiles(s).unwrap())
.collect();
let reactant_refs: Vec<&Molecule> = reactant_mols.iter().collect();
let (lhs, rhs) = rule.smirks.split_once(">>").unwrap();
let fwd = format!("{rhs}>>{lhs}");
let products: std::collections::BTreeSet<String> = run_reactants(&fwd, &reactant_refs)
.unwrap()
.into_iter()
.flatten()
.map(|m| canonical_smiles(&m))
.collect();
let stereo_free_correct_connectivity =
canonical_smiles(&mol_from_smiles("C1CC[C@H](C)NCC1C").unwrap());
assert!(
products.contains(&stereo_free_correct_connectivity),
"the rule's reversal must still find the right regiochemistry \
(just missing the newly-formed center's stereo): {products:?}"
);
assert!(
!products.contains(canonical_smiles(&mol_from_smiles(target).unwrap()).as_str()),
"the rule's reversal must never spontaneously produce the \
exact stereo-defined target -- it has no stereo information \
to do so from: {products:?}"
);
let building_blocks = std::fs::read_to_string("data/building_blocks.smi")
.expect("data/building_blocks.smi must be readable from the crate root");
assert!(
building_blocks
.lines()
.any(|line| line.split_whitespace().next() == Some("C")),
"methane ('C') must be present as its own stock entry in \
data/building_blocks.smi for this to be the real mechanism, \
not a stale assumption"
);
}
#[test]
fn vf2_fallback_does_not_launder_wrong_stereochemistry() {
let target_mol = mol_from_smiles("CC[C@@H](C)O").unwrap(); let candidate_mol = mol_from_smiles("CC[C@H](C)O").unwrap(); let target_canon = canonical_smiles(&target_mol);
let target_query = parse_smarts("CC[C@@H](C)O").ok();
let target_atom_count = target_mol.atom_count();
assert_ne!(
target_canon,
canonical_smiles(&candidate_mol),
"sanity check failed: (R)/(S)-2-butanol must canonicalize differently \
for this fixture to exercise the stereo guard"
);
assert!(
!matches_target(
&candidate_mol,
&target_canon,
target_query.as_ref(),
target_atom_count
),
"VF2 structural fallback must not ignore tetrahedral stereochemistry"
);
}
}