use std::fs;
use rustc_hash::{FxHashMap, FxHashSet};
use anyhow::{Context, Result};
use chematic::chem::standardize::{StandardizeOptions, ZwitterionHandling, standardize};
use chematic::core::{Atom, AtomIdx, BondIdx, BondOrder, Element, MoleculeBuilder};
use chematic::rxn::run_reactants;
use chematic::smarts::{QueryMolecule, find_matches, parse_smarts};
use chematic::smiles::{canonical_smiles, parse};
pub use chematic::core::Molecule;
#[derive(Debug, Clone)]
pub struct RetroRule {
pub name: String,
pub smirks: String,
pub weight: f64,
pub required_elements: u64,
}
impl Default for RetroRule {
fn default() -> Self {
Self {
name: String::new(),
smirks: String::new(),
weight: 1.0,
required_elements: 0,
}
}
}
pub struct ChemEnv {
canon_set: FxHashSet<String>,
vf2_index: FxHashMap<(usize, usize), Vec<QueryMolecule>>,
bb_count: usize,
}
const VF2_THRESHOLD: usize = 2000;
impl ChemEnv {
pub fn load(path: &str) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read building blocks from {path}"))?;
let smiles_iter = content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|line| line.split_whitespace().next().map(str::to_owned));
Ok(Self::from_smiles_iter(smiles_iter))
}
pub fn in_memory(smiles_list: &[&str]) -> Self {
Self::from_smiles_iter(smiles_list.iter().map(|s| s.to_string()))
}
fn from_smiles_iter(iter: impl Iterator<Item = String>) -> Self {
let mut canon_set: FxHashSet<String> = FxHashSet::default();
let mut vf2_raw: Vec<(usize, usize, QueryMolecule)> = Vec::new();
let mut bb_count = 0usize;
for smiles in iter {
let Ok(mol) = parse(&smiles) else { continue };
let canon = canonical_smiles(&mol);
if !canon_set.insert(canon) {
continue; }
bb_count += 1;
if bb_count <= VF2_THRESHOLD
&& let Ok(query) = parse_smarts(&smiles)
{
vf2_raw.push((mol.atom_count(), mol.bonds().count(), query));
}
}
let mut vf2_index: FxHashMap<(usize, usize), Vec<QueryMolecule>> = FxHashMap::default();
for (n_atoms, n_bonds, query) in vf2_raw {
vf2_index.entry((n_atoms, n_bonds)).or_default().push(query);
}
Self {
canon_set,
vf2_index,
bb_count,
}
}
pub fn bb_count(&self) -> usize {
self.bb_count
}
pub fn is_building_block_smiles(&self, canonical_smi: &str) -> bool {
self.canon_set.contains(canonical_smi)
}
pub fn is_building_block(&self, mol: &Molecule) -> bool {
let canon = canonical_smiles(mol);
if self.canon_set.contains(&canon) {
return true;
}
if !self.vf2_index.is_empty() {
let key = (mol.atom_count(), mol.bonds().count());
if let Some(candidates) = self.vf2_index.get(&key) {
let n_atoms = mol.atom_count();
return candidates
.iter()
.any(|q| find_matches(q, mol).iter().any(|m| m.len() == n_atoms));
}
}
false
}
}
pub fn mol_from_smiles(smiles: &str) -> Result<Molecule> {
parse(smiles).with_context(|| format!("Failed to parse SMILES: {smiles}"))
}
pub fn to_canonical(mol: &Molecule) -> String {
canonical_smiles(mol)
}
static STANDARDIZE_OPTS: StandardizeOptions = StandardizeOptions {
canonical_tautomer: false,
neutralize_charges: false,
remove_explicit_h: true,
largest_fragment_only: false,
zwitterion_handling: ZwitterionHandling::Keep,
};
fn is_bridge_bond(mol: &Molecule, a: AtomIdx, b: AtomIdx) -> bool {
let mut visited = FxHashSet::default();
let mut stack = vec![a];
visited.insert(a);
while let Some(cur) = stack.pop() {
for (neighbor, _) in mol.neighbors(cur) {
if cur == a && neighbor == b {
continue;
}
if visited.insert(neighbor) {
stack.push(neighbor);
}
}
}
!visited.contains(&b)
}
fn get_component(
mol: &Molecule,
start: AtomIdx,
bridge_a: AtomIdx,
bridge_b: AtomIdx,
) -> FxHashSet<AtomIdx> {
let mut visited = FxHashSet::default();
let mut stack = vec![start];
visited.insert(start);
while let Some(cur) = stack.pop() {
for (neighbor, _) in mol.neighbors(cur) {
if (cur == bridge_a && neighbor == bridge_b)
|| (cur == bridge_b && neighbor == bridge_a)
{
continue;
}
if visited.insert(neighbor) {
stack.push(neighbor);
}
}
}
visited
}
fn build_sub_molecule(mol: &Molecule, atoms: &FxHashSet<AtomIdx>) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: FxHashMap<AtomIdx, AtomIdx> = FxHashMap::default();
for &old_idx in atoms {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
idx_map.insert(old_idx, new_idx);
}
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
if atoms.contains(&a) && atoms.contains(&b) {
let (&new_a, &new_b) = (idx_map.get(&a)?, idx_map.get(&b)?);
builder.add_bond(new_a, new_b, bond.order).ok()?;
}
}
Some(builder.build())
}
fn build_sub_molecule_with_br(
mol: &Molecule,
atoms: &FxHashSet<AtomIdx>,
cut_atom: AtomIdx,
) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: FxHashMap<AtomIdx, AtomIdx> = FxHashMap::default();
for &old_idx in atoms {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
idx_map.insert(old_idx, new_idx);
}
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
if atoms.contains(&a) && atoms.contains(&b) {
let (&new_a, &new_b) = (idx_map.get(&a)?, idx_map.get(&b)?);
builder.add_bond(new_a, new_b, bond.order).ok()?;
}
}
let br_idx = builder.add_atom(Atom::new(Element::BR));
let &cut_new = idx_map.get(&cut_atom)?;
builder.add_bond(cut_new, br_idx, BondOrder::Single).ok()?;
Some(builder.build())
}
fn build_sub_molecule_with_cl(
mol: &Molecule,
atoms: &FxHashSet<AtomIdx>,
cut_atom: AtomIdx,
) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: FxHashMap<AtomIdx, AtomIdx> = FxHashMap::default();
for &old_idx in atoms {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
idx_map.insert(old_idx, new_idx);
}
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
if atoms.contains(&a) && atoms.contains(&b) {
let (&new_a, &new_b) = (idx_map.get(&a)?, idx_map.get(&b)?);
builder.add_bond(new_a, new_b, bond.order).ok()?;
}
}
let cl_idx = builder.add_atom(Atom::new(Element::CL));
let &cut_new = idx_map.get(&cut_atom)?;
builder.add_bond(cut_new, cl_idx, BondOrder::Single).ok()?;
Some(builder.build())
}
fn diaryl_sulfone_cleavage(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let mut results: Vec<Vec<PrecursorMol>> = Vec::new();
let mut seen: FxHashSet<String> = FxHashSet::default();
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
let (ar_idx, s_idx) = {
let atom_a = mol.atom(a);
let atom_b = mol.atom(b);
if atom_a.element == Element::S && atom_b.aromatic && atom_b.element == Element::C {
(b, a)
} else if atom_b.element == Element::S
&& atom_a.aromatic
&& atom_a.element == Element::C
{
(a, b)
} else {
continue;
}
};
let o_double_count = mol
.neighbors(s_idx)
.filter(|&(nb, bond_idx): &(AtomIdx, BondIdx)| {
mol.atom(nb).element == Element::O && mol.bond(bond_idx).order == BondOrder::Double
})
.count();
if o_double_count < 2 {
continue;
}
if !is_bridge_bond(mol, ar_idx, s_idx) {
continue;
}
let comp_ar = get_component(mol, ar_idx, ar_idx, s_idx); let comp_s = get_component(mol, s_idx, ar_idx, s_idx);
let Some(frag_arh) = build_sub_molecule(mol, &comp_ar) else {
continue;
};
let Some(frag_so2cl) = build_sub_molecule_with_cl(mol, &comp_s, s_idx) else {
continue;
};
let precs_arh = split_fragments(&frag_arh);
let precs_so2cl = split_fragments(&frag_so2cl);
if precs_arh.is_empty() || precs_so2cl.is_empty() {
continue;
}
let mut key_parts: Vec<&str> = precs_arh
.iter()
.chain(precs_so2cl.iter())
.map(|p| p.smiles.as_str())
.collect();
key_parts.sort_unstable();
let key = key_parts.join("|");
if !seen.insert(key) {
continue;
}
let mut prec_set = precs_arh;
prec_set.extend(precs_so2cl);
results.push(prec_set);
}
results
}
fn biaryl_cleavage(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let mut results: Vec<Vec<PrecursorMol>> = Vec::new();
let mut seen: FxHashSet<String> = FxHashSet::default();
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
let atom_a = mol.atom(a);
let atom_b = mol.atom(b);
if !atom_a.aromatic || atom_a.element != Element::C {
continue;
}
if !atom_b.aromatic || atom_b.element != Element::C {
continue;
}
if !is_bridge_bond(mol, a, b) {
continue;
}
let comp_a = get_component(mol, a, a, b);
let comp_b = get_component(mol, b, a, b);
for (comp_br, cut, comp_plain) in [(&comp_a, a, &comp_b), (&comp_b, b, &comp_a)] {
let Some(frag_br) = build_sub_molecule_with_br(mol, comp_br, cut) else {
continue;
};
let Some(frag_plain) = build_sub_molecule(mol, comp_plain) else {
continue;
};
let precs_br = split_fragments(&frag_br);
let precs_plain = split_fragments(&frag_plain);
if precs_br.is_empty() || precs_plain.is_empty() {
continue;
}
let mut key_parts: Vec<&str> = precs_br
.iter()
.chain(precs_plain.iter())
.map(|p| p.smiles.as_str())
.collect();
key_parts.sort_unstable();
let key = key_parts.join("|");
if !seen.insert(key) {
continue;
}
let mut prec_set = precs_br;
prec_set.extend(precs_plain);
results.push(prec_set);
}
}
results
}
fn amide_cleavage(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let mut results: Vec<Vec<PrecursorMol>> = Vec::new();
let mut seen: FxHashSet<String> = FxHashSet::default();
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
if bond.order != BondOrder::Single {
continue;
}
let (c_idx, n_idx) = {
let aa = mol.atom(a);
let ab = mol.atom(b);
if aa.element == Element::C && ab.element == Element::N {
(a, b)
} else if aa.element == Element::N && ab.element == Element::C {
(b, a)
} else {
continue;
}
};
let has_keto_o = mol.neighbors(c_idx).any(|(nb, bond_idx)| {
nb != n_idx
&& mol.atom(nb).element == Element::O
&& mol.bond(bond_idx).order == BondOrder::Double
});
if !has_keto_o {
continue;
}
if !is_bridge_bond(mol, c_idx, n_idx) {
continue;
}
let comp_c = get_component(mol, c_idx, c_idx, n_idx);
let comp_n = get_component(mol, n_idx, c_idx, n_idx);
let Some(frag_acid) = build_sub_molecule_with_oh(mol, &comp_c, c_idx) else {
continue;
};
let Some(frag_amine) = build_sub_molecule(mol, &comp_n) else {
continue;
};
let precs_acid = split_fragments(&frag_acid);
let precs_amine = split_fragments(&frag_amine);
if precs_acid.is_empty() || precs_amine.is_empty() {
continue;
}
let mut key_parts: Vec<&str> = precs_acid
.iter()
.chain(precs_amine.iter())
.map(|p| p.smiles.as_str())
.collect();
key_parts.sort_unstable();
let key = key_parts.join("|");
if !seen.insert(key) {
continue;
}
let mut prec_set = precs_acid;
prec_set.extend(precs_amine);
results.push(prec_set);
}
results
}
fn build_sub_molecule_with_oh(
mol: &Molecule,
atoms: &FxHashSet<AtomIdx>,
cut_atom: AtomIdx,
) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: FxHashMap<AtomIdx, AtomIdx> = FxHashMap::default();
for &old_idx in atoms {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
idx_map.insert(old_idx, new_idx);
}
for (_, bond) in mol.bonds() {
let (a, b) = (bond.atom1, bond.atom2);
if atoms.contains(&a) && atoms.contains(&b) {
let (&new_a, &new_b) = (idx_map.get(&a)?, idx_map.get(&b)?);
builder.add_bond(new_a, new_b, bond.order).ok()?;
}
}
let o_idx = builder.add_atom(Atom::new(Element::O));
let &cut_new = idx_map.get(&cut_atom)?;
builder.add_bond(cut_new, o_idx, BondOrder::Single).ok()?;
Some(builder.build())
}
pub fn apply_retro(mol: &Molecule, rule: &RetroRule) -> Vec<Vec<PrecursorMol>> {
if rule.smirks.is_empty() {
return match rule.name.as_str() {
"suzuki_retro" => biaryl_cleavage(mol),
"diaryl_sulfone_retro" => diaryl_sulfone_cleavage(mol),
"amide_cleavage" => amide_cleavage(mol),
"boc_deprotection_retro" => boc_deprotection(mol),
"cbz_deprotection_retro" => cbz_deprotection(mol),
_ => vec![],
};
}
run_reactants(&rule.smirks, &[mol])
.unwrap_or_default()
.into_iter()
.map(|products| {
products
.into_iter()
.flat_map(|product_mol| split_fragments(&product_mol))
.collect()
})
.collect()
}
pub struct PrecursorMol {
pub smiles: String,
pub mol: Molecule,
}
fn split_fragments(mol: &Molecule) -> Vec<PrecursorMol> {
canonical_smiles(mol)
.split('.')
.filter_map(|frag| {
let m = parse(frag).ok()?;
let std_mol = standardize(&m, &STANDARDIZE_OPTS);
let smi = canonical_smiles(&std_mol);
let has_aromatic = smi
.chars()
.any(|c| matches!(c, 'c' | 'n' | 'o' | 's' | 'p'));
let has_ring = smi.chars().any(|c| c.is_ascii_digit());
if has_aromatic && !has_ring {
return None;
}
Some(PrecursorMol {
smiles: smi,
mol: std_mol,
})
})
.collect()
}
fn required_elements_from_smirks(smirks: &str) -> u64 {
let reactant = match smirks.split(">>").next() {
Some(r) if !r.is_empty() => r,
_ => return 0,
};
const ELEMENTS: &[(&str, u64)] = &[
("Cl", 17),
("Br", 35),
("Si", 14),
("Se", 34),
("Te", 52),
("Sn", 50),
("Zn", 30),
("Pd", 46),
("Cu", 29),
("Fe", 26),
("B", 5),
("C", 6),
("N", 7),
("O", 8),
("F", 9),
("P", 15),
("S", 16),
("I", 53),
];
let mut mask: u64 = 0;
let bytes = reactant.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' {
i += 1;
while i < bytes.len() && matches!(bytes[i], b'@' | b'+' | b'-' | b'#') {
i += 1;
}
for (sym, an) in ELEMENTS {
let end = i + sym.len();
if end <= bytes.len() && bytes[i..end].eq_ignore_ascii_case(sym.as_bytes()) {
mask |= 1u64 << an;
break;
}
}
}
i += 1;
}
mask
}
fn rr(name: &str, smirks: &str) -> RetroRule {
let required_elements = required_elements_from_smirks(smirks);
RetroRule {
name: name.into(),
smirks: smirks.into(),
required_elements,
..Default::default()
}
}
pub fn default_rules() -> Vec<RetroRule> {
vec[O:3]>>[C:1](=[O:2])O.[O:3]"),
rr("amide_cleavage", ""),
rr(
"friedel_crafts_acylation_retro",
"[c:1][C:2](=[O:3])>>[c:1].[C:2](=[O:3])Cl",
),
rr(
"aryl_carboxylation_retro",
"[c:1][C:2](=O)O>>[c:1].[C:2](=O)O",
),
rr("aryl_amine_retro", "[c:1][N:2]>>[c:1].[N:2]"),
rr("buchwald_hartwig_retro", "[c:1][N:2]>>[c:1]Br.[N:2]"),
rr("aryl_ether_retro", "[c:1][O:2]>>[c:1]O.[O:2]"),
rr("aryl_chloride_retro", "[c:1][Cl]>>[c:1]"),
rr("aryl_iodide_retro", "[c:1][I]>>[c:1]"),
rr("aryl_fluoride_snAr_retro", "[c:1][F]>>[c:1]"),
rr("aryl_chloride_to_bromide", "[c:1][Cl]>>[c:1][Br]"),
rr("suzuki_retro", ""),
rr("heck_retro", "[c:1][CH:2]=[CH:3]>>[c:1][Br].[CH2:2]=[CH:3]"),
rr(
"heck_retro_terminal",
"[c:1][CH:2]=[CH2:3]>>[c:1][Br].[CH2:2]=[CH2:3]",
),
rr("negishi_retro", "[c:1][CH2:2]>>[c:1][Br].[CH3:2]"),
rr("cc_single_cleavage", "[C:1][C:2]>>[C:1].[C:2]"),
rr("wittig_retro", "[C:1]=[C:2]>>[C:1]=O.[C:2]=O"),
rr("reductive_amination_retro", "[C:1][N:2]>>[C:1]=O.[N:2]"),
rr("cn_aliphatic_cleavage", "[C:1][N:2]>>[C:1].[N:2]"),
rr("co_aliphatic_cleavage", "[C:1][O:2]>>[C:1].[O:2]"),
rr("alcohol_oxidation_retro", "[C:1][OH:2]>>[C:1]=O"),
rr("sonogashira_retro", "[c:1][C:2]#[C:3]>>[c:1]Br.[C:2]#[C:3]"),
rr(
"sulfonamide_retro",
"[S:1](=O)(=O)[N:2]>>[S:1](=O)(=O)Cl.[N:2]",
),
rr("diaryl_sulfone_retro", ""),
rr("boc_deprotection_retro", ""),
rr(
"n_benzylation_retro",
"[N:1][CH2:2][c:3]>>[N:1].[Br][CH2:2][c:3]",
),
rr(
"grignard_addition_retro",
"[C:1]([OH:2])([C:3])[C:4]>>[C:1](=O)[C:3].[C:4]",
),
rr(
"claisen_retro",
"[C:1](=O)[CH2:2][C:3](=O)[O:4]>>[C:1](=O)O.[C:2]=[C:3][O:4]",
),
rr(
"michael_retro",
"[C:1][CH2:2][C:3]=[O:4]>>[C:1].[CH2:2]=[C:3][OH:4]",
),
rr("acyl_chloride_from_acid", "[C:1](=[O:2])Cl>>[C:1](=[O:2])O"),
rr("cbz_deprotection_retro", ""),
]
}
pub fn bond_pairs_from_smirks(smirks: &str) -> Vec<(u8, u8)> {
let reactant = match smirks.split_once(">>") {
Some((lhs, _)) => lhs,
None => return vec![],
};
const ELEMENTS: &[(&str, u8)] = &[
("Cl", 17),
("Br", 35),
("Si", 14),
("Se", 34),
("Te", 52),
("Sn", 50),
("Zn", 30),
("Pd", 46),
("Cu", 29),
("Fe", 26),
("B", 5),
("C", 6),
("N", 7),
("O", 8),
("F", 9),
("P", 15),
("S", 16),
("I", 53),
];
fn elem_at(bytes: &[u8], mut j: usize) -> Option<u8> {
while j < bytes.len() && matches!(bytes[j], b'@' | b'+' | b'-' | b'#') {
j += 1;
}
for (sym, an) in ELEMENTS {
let end = j + sym.len();
if end <= bytes.len() && bytes[j..end].eq_ignore_ascii_case(sym.as_bytes()) {
return Some(*an);
}
}
None
}
let bytes = reactant.as_bytes();
let mut pairs: Vec<(u8, u8)> = Vec::new();
let mut stack: Vec<Option<u8>> = Vec::new(); let mut prev: Option<u8> = None;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'[' => {
if let Some(elem) = elem_at(bytes, i + 1) {
if let Some(p) = prev {
let pair = if p <= elem { (p, elem) } else { (elem, p) };
pairs.push(pair);
}
prev = Some(elem);
}
while i < bytes.len() && bytes[i] != b']' {
i += 1;
}
}
b'(' => stack.push(prev),
b')' => prev = stack.pop().flatten(),
_ => {}
}
i += 1;
}
pairs.sort_unstable();
pairs.dedup();
pairs
}
pub struct TemplateBondIndex {
index: FxHashMap<(u8, u8), Vec<usize>>,
graph_indices: Vec<usize>,
fallback_indices: Vec<usize>,
}
impl TemplateBondIndex {
pub fn build(rules: &[RetroRule]) -> Self {
let mut index: FxHashMap<(u8, u8), Vec<usize>> = FxHashMap::default();
let mut graph_indices = Vec::new();
let mut fallback_indices = Vec::new();
for (i, rule) in rules.iter().enumerate() {
if rule.smirks.is_empty() {
graph_indices.push(i);
continue;
}
let pairs = bond_pairs_from_smirks(&rule.smirks);
if pairs.is_empty() {
fallback_indices.push(i);
} else {
for pair in pairs {
index.entry(pair).or_default().push(i);
}
}
}
Self {
index,
graph_indices,
fallback_indices,
}
}
pub fn retrieve(&self, mol: &Molecule, top_k: usize, rules: &[RetroRule]) -> Vec<usize> {
let mut seen: FxHashSet<usize> = FxHashSet::default();
let mut candidates: Vec<usize> = Vec::new();
for &idx in &self.graph_indices {
if seen.insert(idx) {
candidates.push(idx);
}
}
for &idx in &self.fallback_indices {
if seen.insert(idx) {
candidates.push(idx);
}
}
for (atom_idx, _) in mol.atoms() {
let e1 = mol.atom(atom_idx).element.atomic_number();
for (nb_idx, _bond_idx) in mol.neighbors(atom_idx) {
if nb_idx <= atom_idx {
continue;
}
let e2 = mol.atom(nb_idx).element.atomic_number();
let pair = if e1 <= e2 { (e1, e2) } else { (e2, e1) };
if let Some(indices) = self.index.get(&pair) {
for &idx in indices {
if seen.insert(idx) {
candidates.push(idx);
}
}
}
}
}
if top_k > 0 && candidates.len() > top_k {
let fixed = self.graph_indices.len() + self.fallback_indices.len();
candidates[fixed..].sort_unstable_by(|&a, &b| {
rules[b]
.weight
.partial_cmp(&rules[a].weight)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates.truncate(fixed + top_k);
}
candidates
}
}
pub fn elem_symbols_to_mask(csv: &str) -> u64 {
let mut mask = 0u64;
for sym in csv.split(',') {
let n: Option<u32> = match sym.trim() {
"H" => Some(1),
"B" => Some(5),
"C" => Some(6),
"N" => Some(7),
"O" => Some(8),
"F" => Some(9),
"Si" => Some(14),
"P" => Some(15),
"S" => Some(16),
"Cl" => Some(17),
"Br" => Some(35),
"I" => Some(53),
_ => None,
};
if let Some(n) = n {
mask |= 1u64 << n;
}
}
mask
}
pub fn load_rules_from_file(path: &str) -> Vec<RetroRule> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("Warning: could not read template file {path}: {e}");
return vec![];
}
};
content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.enumerate()
.filter_map(|(i, line)| {
let mut cols = line.splitn(2, '\t');
let smirks = cols.next()?.trim();
let count: f64 = cols
.next()
.and_then(|c| c.trim().parse().ok())
.unwrap_or(1.0);
let weight = (count + 1.0).ln();
let reactant = smirks.split(">>").next()?;
parse_smarts(reactant).ok()?;
let required_elements = required_elements_from_smirks(smirks);
Some(RetroRule {
name: format!("extracted_{i}"),
smirks: smirks.to_string(),
weight,
required_elements,
})
})
.collect()
}
fn boc_deprotection(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let boc_smarts = "[N;!$(N=*)]C(=O)OC(C)(C)C";
let Ok(query) = chematic::smarts::parse_smarts(boc_smarts) else {
return vec![];
};
let matches = chematic::smarts::find_matches(&query, mol);
if matches.is_empty() {
return vec![];
}
let mut results = Vec::new();
let mut seen: FxHashSet<String> = FxHashSet::default();
for m in matches {
if m.len() < 2 {
continue;
}
let Some(&n_idx) = m.get(&0) else { continue };
let Some(&c_idx) = m.get(&1) else { continue };
if !is_bridge_bond(mol, n_idx, c_idx) {
continue;
}
let comp_n = get_component(mol, n_idx, n_idx, c_idx);
let Some(frag_n) = build_sub_molecule(mol, &comp_n) else {
continue;
};
let precs = split_fragments(&frag_n);
if precs.is_empty() {
continue;
}
let key = precs
.iter()
.map(|p| p.smiles.as_str())
.collect::<Vec<_>>()
.join("|");
if !seen.insert(key) {
continue;
}
results.push(precs);
}
results
}
fn cbz_deprotection(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let cbz_smarts = "[N;!$(N=*)]C(=O)OCc1ccccc1";
let Ok(query) = chematic::smarts::parse_smarts(cbz_smarts) else {
return vec![];
};
let matches = chematic::smarts::find_matches(&query, mol);
if matches.is_empty() {
return vec![];
}
let mut results = Vec::new();
let mut seen: FxHashSet<String> = FxHashSet::default();
for m in matches {
if m.len() < 2 {
continue;
}
let Some(&n_idx) = m.get(&0) else { continue };
let Some(&c_idx) = m.get(&1) else { continue };
if !is_bridge_bond(mol, n_idx, c_idx) {
continue;
}
let comp_n = get_component(mol, n_idx, n_idx, c_idx);
let Some(frag_n) = build_sub_molecule(mol, &comp_n) else {
continue;
};
let precs = split_fragments(&frag_n);
if precs.is_empty() {
continue;
}
let key = precs
.iter()
.map(|p| p.smiles.as_str())
.collect::<Vec<_>>()
.join("|");
if !seen.insert(key) {
continue;
}
results.push(precs);
}
results
}
#[cfg(test)]
mod tests {
use super::*;
fn env_aspirin_bbs() -> ChemEnv {
ChemEnv::in_memory(&["CC(=O)O", "Oc1ccccc1C(=O)O", "c1ccccc1C(=O)O", "C", "O"])
}
#[test]
fn parse_aspirin_roundtrip() {
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
assert_eq!(mol.atom_count(), 13);
}
#[test]
fn building_block_recognized_by_vf2() {
let env = env_aspirin_bbs();
let mol = mol_from_smiles("CC(=O)O").unwrap();
assert!(
env.is_building_block(&mol),
"acetic acid should be a building block"
);
}
#[test]
fn non_building_block_rejected() {
let env = env_aspirin_bbs();
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
assert!(
!env.is_building_block(&mol),
"aspirin should not be a building block"
);
}
#[test]
fn building_block_canonical_form_variant() {
let env = ChemEnv::in_memory(&["CC(=O)O"]);
let mol = mol_from_smiles("OC(C)=O").unwrap(); assert!(
env.is_building_block(&mol),
"OC(C)=O is the same as CC(=O)O"
);
}
#[test]
fn benzoic_acid_variant_matches() {
let env = ChemEnv::in_memory(&["c1ccccc1C(=O)O"]);
let mol = mol_from_smiles("c1c(C(=O)O)cccc1").unwrap();
assert!(
env.is_building_block(&mol),
"c1c(C(=O)O)cccc1 is benzoic acid"
);
}
#[test]
fn ester_cleavage_fires_on_aspirin() {
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let rule = rr("ester_cleavage", "[C:1](=[O:2])[O:3]>>[C:1](=[O:2])O.[O:3]");
let results = apply_retro(&mol, &rule);
assert!(!results.is_empty(), "ester_cleavage must match aspirin");
}
#[test]
fn aromatic_ring_fragment_filter() {
use chematic::chem::aromatic_ring_count;
let mol = mol_from_smiles("c1ccc(N)cc1C(=O)O").unwrap();
let rule = rr(
"aryl_carboxylation_retro",
"[c:1][C:2](=O)O>>[c:1].[C:2](=O)O",
);
let results = apply_retro(&mol, &rule);
for precursor_set in &results {
for p in precursor_set {
let smi = &p.smiles;
let has_lowercase = smi
.chars()
.any(|c| matches!(c, 'c' | 'n' | 'o' | 's' | 'p'));
if has_lowercase {
let m = mol_from_smiles(smi).unwrap();
assert!(
aromatic_ring_count(&m) > 0,
"fragment '{smi}' has aromatic atoms but no ring"
);
}
}
}
}
#[test]
fn suzuki_retro_4_phenylpyridine_solvable() {
use crate::search::{SearchConfig, find_routes};
let bbs = [
"Brc1ccccc1",
"c1ccccc1",
"Brc1ccncc1",
"c1ccncc1",
"OB(O)c1ccccc1",
"OB(O)c1ccncc1",
];
let env = ChemEnv::in_memory(&bbs);
let rules = crate::chem_env::default_rules();
let config = SearchConfig {
max_depth: 3,
max_routes: 5,
beam_width: 0,
..Default::default()
};
let (routes, _) = find_routes("c1ccc(-c2ccncc2)cc1", &env, &rules, &config)
.expect("find_routes must not error");
assert!(
!routes.is_empty(),
"4-phenylpyridine must be solvable via suzuki_retro"
);
}
#[test]
fn degenerate_route_not_in_precursors() {
let mol = mol_from_smiles("c1ccc(N)cc1C(=O)O").unwrap();
let rule = rr(
"aryl_carboxylation_retro",
"[c:1][C:2](=O)O>>[c:1].[C:2](=O)O",
);
let results = apply_retro(&mol, &rule);
assert!(!results.is_empty());
}
#[test]
fn suzuki_retro_biphenyl_gives_bromobenzene_and_benzene() {
let mol = mol_from_smiles("c1ccc(-c2ccccc2)cc1").unwrap();
let rule = rr("suzuki_retro", "");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"suzuki_retro must find at least one biaryl disconnection"
);
let all_smiles: Vec<String> = results
.iter()
.flat_map(|set| set.iter().map(|p| p.smiles.clone()))
.collect();
let has_bromobenzene = all_smiles
.iter()
.any(|s| s.contains("Br") && s.contains("c1ccccc1"));
let has_benzene = all_smiles.iter().any(|s| s == "c1ccccc1");
assert!(
has_bromobenzene,
"expected bromobenzene fragment; got {all_smiles:?}"
);
assert!(has_benzene, "expected benzene fragment; got {all_smiles:?}");
}
#[test]
fn suzuki_retro_biphenyl_solvable_with_bb() {
use crate::search::{SearchConfig, find_routes};
let env = ChemEnv::in_memory(&["Brc1ccccc1", "c1ccccc1"]);
let rules = default_rules();
let cfg = SearchConfig {
max_depth: 2,
max_routes: 3,
beam_width: 0,
..Default::default()
};
let routes = find_routes("c1ccc(-c2ccccc2)cc1", &env, &rules, &cfg)
.unwrap()
.0;
assert!(
!routes.is_empty(),
"biphenyl must be solvable with Br-PhH + PhH BBs"
);
assert!(
routes.iter().any(|r| r.depth == 1),
"should need only 1 step"
);
}
#[test]
fn suzuki_retro_4_fluorobiphenyl_solvable() {
use crate::search::{SearchConfig, find_routes};
let env = ChemEnv::load("data/building_blocks.smi")
.unwrap_or_else(|_| ChemEnv::in_memory(&["Brc1ccccc1", "Brc1ccc(F)cc1", "c1ccccc1"]));
let rules = default_rules();
let cfg = SearchConfig {
max_depth: 2,
max_routes: 3,
beam_width: 0,
..Default::default()
};
let routes = find_routes("Fc1ccc(-c2ccccc2)cc1", &env, &rules, &cfg)
.unwrap()
.0;
assert!(!routes.is_empty(), "4-fluorobiphenyl must be solvable");
}
#[test]
fn default_bbs_solve_biphenyl() {
use crate::search::{SearchConfig, find_routes};
let env = ChemEnv::in_memory(crate::DEFAULT_BUILDING_BLOCKS);
let bromobenzene = mol_from_smiles("Brc1ccccc1").unwrap();
let benzene = mol_from_smiles("c1ccccc1").unwrap();
assert!(
env.is_building_block(&bromobenzene),
"DEFAULT_BUILDING_BLOCKS must contain bromobenzene"
);
assert!(
env.is_building_block(&benzene),
"DEFAULT_BUILDING_BLOCKS must contain benzene"
);
let rules = default_rules();
let cfg = SearchConfig {
max_depth: 3,
max_routes: 5,
beam_width: 0,
..Default::default()
};
let routes = find_routes("c1ccc(-c2ccccc2)cc1", &env, &rules, &cfg)
.unwrap()
.0;
assert!(
!routes.is_empty(),
"biphenyl must be solvable with DEFAULT_BUILDING_BLOCKS"
);
}
#[test]
fn amide_cleavage_paracetamol() {
let mol = mol_from_smiles("CC(=O)Nc1ccc(O)cc1").unwrap();
let rule = rr("amide_cleavage", "[C:1](=[O:2])[N:3]>>[C:1](=[O:2])O.[N:3]");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"amide_cleavage must fire on paracetamol"
);
}
#[test]
fn default_bbs_solve_playground_presets() {
use crate::search::{SearchConfig, find_routes};
let env = ChemEnv::in_memory(crate::DEFAULT_BUILDING_BLOCKS);
let rules = default_rules();
let cfg = SearchConfig {
max_depth: 3,
max_routes: 3,
beam_width: 0,
..Default::default()
};
let presets = [
("CC(=O)Oc1ccccc1C(=O)O", "Aspirin"),
("CC(=O)Nc1ccc(O)cc1", "Paracetamol"),
("CC(=O)Nc1ccccc1", "Acetanilide"),
("c1ccc(-c2ccccc2)cc1", "Biphenyl"),
("c1ccc(-c2ccncc2)cc1", "4-Phenylpyridine"),
("Fc1ccc(-c2ccccc2)cc1", "4-Fluorobiphenyl"),
("O=Cc1ccc(-c2ccco2)nc1", "Pyridine-furan biaryl"),
("C=Cc1ccccc1", "Styrene"),
("CCOC(=O)c1ccccc1", "Ethyl benzoate"),
];
for (smiles, name) in presets {
let routes = find_routes(smiles, &env, &rules, &cfg).unwrap().0;
assert!(
!routes.is_empty(),
"{name} ({smiles}) must be solvable with DEFAULT_BUILDING_BLOCKS"
);
}
}
#[test]
fn wittig_retro_cleaves_alkene() {
let mol = mol_from_smiles("C=C").unwrap(); let rule = rr("wittig_retro", "[C:1]=[C:2]>>[C:1]=O.[C:2]=O");
let results = apply_retro(&mol, &rule);
assert!(!results.is_empty(), "wittig_retro must match ethylene");
let smiles: Vec<_> = results[0].iter().map(|p| p.smiles.as_str()).collect();
assert!(
smiles.iter().any(|s| s.contains('O')),
"products should contain oxygen; got {smiles:?}"
);
}
fn all_bond_pairs(mol: &Molecule) -> Vec<(AtomIdx, AtomIdx)> {
mol.bonds().map(|(_, b)| (b.atom1, b.atom2)).collect()
}
#[test]
fn is_bridge_bond_linear_chain() {
let mol = mol_from_smiles("CCC").unwrap();
for (a, b) in all_bond_pairs(&mol) {
assert!(
is_bridge_bond(&mol, a, b),
"every bond in CCC must be a bridge"
);
}
}
#[test]
fn is_bridge_bond_ring_is_not_bridge() {
let mol = mol_from_smiles("c1ccccc1").unwrap();
for (a, b) in all_bond_pairs(&mol) {
assert!(!is_bridge_bond(&mol, a, b), "benzene has no bridge bonds");
}
}
#[test]
fn is_bridge_bond_biphenyl_inter_ring() {
let mol = mol_from_smiles("c1ccc(-c2ccccc2)cc1").unwrap();
let bridges: Vec<_> = all_bond_pairs(&mol)
.into_iter()
.filter(|&(a, b)| is_bridge_bond(&mol, a, b))
.collect();
assert_eq!(bridges.len(), 1, "biphenyl must have exactly 1 bridge bond");
}
#[test]
fn build_sub_molecule_with_br_gives_bromobenzene() {
let mol = mol_from_smiles("c1ccc(-c2ccccc2)cc1").unwrap();
let (a, b) = all_bond_pairs(&mol)
.into_iter()
.find(|&(a, b)| is_bridge_bond(&mol, a, b))
.expect("biphenyl must have a bridge bond");
let comp = get_component(&mol, a, a, b);
let frag = build_sub_molecule_with_br(&mol, &comp, a).unwrap();
let smi = canonical_smiles(&frag);
let expected = canonical_smiles(&mol_from_smiles("Brc1ccccc1").unwrap());
assert_eq!(
smi, expected,
"phenyl + Br should give bromobenzene; got {smi}"
);
}
#[test]
fn build_sub_molecule_with_oh_gives_acetic_acid() {
let mol = mol_from_smiles("CC(=O)Nc1ccccc1").unwrap();
let (c_idx, n_idx) = all_bond_pairs(&mol)
.into_iter()
.find(|&(a, b)| {
mol.atom(a).element == Element::C
&& mol.atom(b).element == Element::N
&& is_bridge_bond(&mol, a, b)
&& mol.neighbors(a).any(|(nb, bi)| {
mol.atom(nb).element == Element::O
&& mol.bond(bi).order == BondOrder::Double
})
})
.or_else(|| {
all_bond_pairs(&mol)
.into_iter()
.find(|&(a, b)| {
mol.atom(b).element == Element::C
&& mol.atom(a).element == Element::N
&& is_bridge_bond(&mol, a, b)
&& mol.neighbors(b).any(|(nb, bi)| {
mol.atom(nb).element == Element::O
&& mol.bond(bi).order == BondOrder::Double
})
})
.map(|(a, b)| (b, a))
})
.expect("acetanilide must have an amide C-N bridge bond");
let comp_c = get_component(&mol, c_idx, c_idx, n_idx);
let frag = build_sub_molecule_with_oh(&mol, &comp_c, c_idx).unwrap();
let smi = canonical_smiles(&frag);
let expected = canonical_smiles(&mol_from_smiles("CC(=O)O").unwrap());
assert_eq!(
smi, expected,
"acetyl + OH should give acetic acid; got {smi}"
);
}
fn smiles_set(results: &[Vec<PrecursorMol>], idx: usize) -> Vec<String> {
results[idx].iter().map(|p| p.smiles.clone()).collect()
}
#[test]
fn friedel_crafts_retro_on_acetophenone() {
let mol = mol_from_smiles("CC(=O)c1ccccc1").unwrap();
let rule = rr(
"friedel_crafts_acylation_retro",
"[c:1][C:2](=[O:3])>>[c:1].[C:2](=[O:3])Cl",
);
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"friedel_crafts_retro must fire on acetophenone"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| s.contains("Cl")),
"products must include acyl chloride; got {flat:?}"
);
}
#[test]
fn heck_retro_terminal_on_styrene() {
let mol = mol_from_smiles("C=Cc1ccccc1").unwrap();
let rule = rr(
"heck_retro_terminal",
"[c:1][CH:2]=[CH2:3]>>[c:1][Br].[CH2:2]=[CH2:3]",
);
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"heck_retro_terminal must fire on styrene"
);
let flat: Vec<String> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.clone()))
.collect();
assert!(
flat.iter().any(|s| s.contains("Br")),
"products must include aryl bromide; got {flat:?}"
);
assert!(
flat.iter().any(|s| s == "C=C" || s == "[CH2]=[CH2]"),
"products must include ethylene; got {flat:?}"
);
}
#[test]
fn heck_retro_internal_on_stilbene() {
let mol = mol_from_smiles("C(=Cc1ccccc1)c1ccccc1").unwrap();
let rule = rr("heck_retro", "[c:1][CH:2]=[CH:3]>>[c:1][Br].[CH2:2]=[CH:3]");
let results = apply_retro(&mol, &rule);
assert!(!results.is_empty(), "heck_retro must fire on stilbene");
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| s.contains("Br")),
"products must include aryl bromide; got {flat:?}"
);
}
#[test]
fn negishi_retro_on_ethylbenzene() {
let mol = mol_from_smiles("CCc1ccccc1").unwrap();
let rule = rr("negishi_retro", "[c:1][CH2:2]>>[c:1][Br].[CH3:2]");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"negishi_retro must fire on ethylbenzene (benzylic CH2)"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| s.contains("Br")),
"products must include aryl bromide; got {flat:?}"
);
}
#[test]
fn alcohol_oxidation_retro_on_ethanol() {
let mol = mol_from_smiles("CCO").unwrap();
let rule = rr("alcohol_oxidation_retro", "[C:1][OH:2]>>[C:1]=O");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"alcohol_oxidation_retro must fire on ethanol"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| s.contains("=O") || s.contains("O=")),
"products must include a carbonyl; got {flat:?}"
);
}
#[test]
fn aryl_chloride_retro_on_chlorobenzene() {
let mol = mol_from_smiles("Clc1ccccc1").unwrap();
let rule = rr("aryl_chloride_retro", "[c:1][Cl]>>[c:1]");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"aryl_chloride_retro must fire on chlorobenzene"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
let benzene_smi = canonical_smiles(&mol_from_smiles("c1ccccc1").unwrap());
assert!(
flat.iter().any(|s| *s == benzene_smi),
"products must include benzene; got {flat:?}"
);
}
#[test]
fn amide_cleavage_graph_gives_clean_two_fragments() {
let mol = mol_from_smiles("CC(=O)Nc1ccccc1").unwrap();
let rule = rr("amide_cleavage", "");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"amide_cleavage must fire on acetanilide"
);
for set in &results {
assert_eq!(
set.len(),
2,
"amide cleavage must yield exactly 2 fragments (no BFS leakage); got {:?}",
set.iter().map(|p| p.smiles.as_str()).collect::<Vec<_>>()
);
}
let acetic = canonical_smiles(&mol_from_smiles("CC(=O)O").unwrap());
let aniline = canonical_smiles(&mol_from_smiles("Nc1ccccc1").unwrap());
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.clone()))
.collect();
assert!(
flat.contains(&acetic),
"must include acetic acid; got {flat:?}"
);
assert!(
flat.contains(&aniline),
"must include aniline; got {flat:?}"
);
}
#[test]
fn reductive_amination_retro_on_benzylamine() {
let mol = mol_from_smiles("NCc1ccccc1").unwrap();
let rule = rr("reductive_amination_retro", "[C:1][N:2]>>[C:1]=O.[N:2]");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"reductive_amination_retro must fire on benzylamine"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| s.contains("=O") || s.contains("O=")),
"products must include aldehyde/ketone; got {flat:?}"
);
}
}
#[test]
fn canonical_smiles_is_deterministic() {
let pairs = [
("Nc1ccccc1", "c1ccc(N)cc1", "aniline"),
("Oc1ccccc1", "c1ccc(O)cc1", "phenol"),
("Brc1ccccc1", "c1ccc(Br)cc1", "bromobenzene"),
("CC(=O)O", "OC(C)=O", "acetic acid"),
];
for (s1, s2, name) in pairs {
let c1 = canonical_smiles(&parse(s1).unwrap());
let c2 = canonical_smiles(&parse(s2).unwrap());
assert_eq!(
c1, c2,
"{name}: '{s1}' and '{s2}' should have the same canonical SMILES"
);
}
}
#[cfg(test)]
mod bug13_regression {
use super::*;
#[test]
fn smirks_amide_cleavage_no_bfs_leakage() {
let mol = parse("CC(=O)Nc1ccccc1").unwrap();
let smirks = "[C:1](=[O:2])[N:3]>>[C:1](=[O:2])O.[N:3]";
let results = run_reactants(smirks, &[&mol]).unwrap_or_default();
assert!(!results.is_empty(), "expected at least one result set");
for group in &results {
assert_eq!(
group.len(),
2,
"expected exactly 2 products, got {}: {:?}",
group.len(),
group.iter().map(canonical_smiles).collect::<Vec<_>>()
);
}
}
}
#[cfg(test)]
mod chematic_regression {
use super::*;
#[test]
fn parse_smarts_accepts_atom_maps() {
assert!(parse_smarts("[C:1](=[O:2])[N:3]").is_ok());
assert!(parse_smarts("[NH2:1]-[c:2]").is_ok());
assert!(parse_smarts("[O:1]=[C:2]").is_ok());
assert!(parse_smarts("[C@:1]").is_ok(), "@ + atom-map must parse");
assert!(
parse_smarts("[C@@H:2]").is_ok(),
"@@ + H + atom-map must parse"
);
assert!(
parse_smarts("[C@H:1]-[c:2]").is_ok(),
"stereo SMIRKS reactant must parse"
);
}
#[test]
fn tetrahedral_stereo_filter_rejects_wrong_enantiomer() {
let smirks = "[C:1]-[C@H:2](-[OH:3])-[c:4]>>[C:1]-[C:2](=[O:3])-[c:4]";
let r_alcohol = parse("C[C@H](O)c1ccccc1").unwrap(); let s_alcohol = parse("C[C@@H](O)c1ccccc1").unwrap();
let r_results = run_reactants(smirks, &[&r_alcohol]).unwrap_or_default();
let s_results = run_reactants(smirks, &[&s_alcohol]).unwrap_or_default();
assert!(
!r_results.is_empty(),
"R-alcohol must match @-SMIRKS (chematic #20 regression)"
);
assert!(
s_results.is_empty(),
"S-alcohol must NOT match @-SMIRKS (chematic #20 regression); got {} result(s)",
s_results.len()
);
}
#[test]
fn run_reactants_products_no_bracket_atoms() {
let mol = parse("CC(=O)Nc1ccccc1").unwrap();
let smirks = "[C:1](=[O:2])[N:3]>>[C:1](=[O:2])O.[N:3]";
let results = run_reactants(smirks, &[&mol]).unwrap_or_default();
assert!(!results.is_empty());
for group in &results {
for product in group {
let canon = canonical_smiles(product);
assert!(
!canon.starts_with('['),
"product has unexpected bracket atom: {canon}"
);
}
}
}
#[test]
fn ez_stereo_filter_rejects_wrong_geometry() {
let smirks = "[C:1]/[C:2]=[C:3]\\[C:4]>>[C:1][C:2]=O.[O:3]=[C:4]";
let z_hexene = parse("CC/C=C\\CC").unwrap(); let e_hexene = parse("CC/C=C/CC").unwrap();
let z_results = run_reactants(smirks, &[&z_hexene]).unwrap_or_default();
let e_results = run_reactants(smirks, &[&e_hexene]).unwrap_or_default();
assert!(
!z_results.is_empty(),
"Z-alkene must match Z-SMIRKS (chematic #21 regression)"
);
assert!(
e_results.is_empty(),
"E-alkene must NOT match Z-SMIRKS (chematic #21 regression); got {} result set(s)",
e_results.len()
);
}
#[test]
fn diaryl_sulfone_retro_diphenyl_sulfone() {
let mol = mol_from_smiles("O=S(=O)(c1ccccc1)c1ccccc1").unwrap(); let rule = rr("diaryl_sulfone_retro", "");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"diaryl_sulfone_retro must fire on diphenyl sulfone"
);
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
let has_so2cl = flat.iter().any(|s| s.contains("Cl") && s.contains('S'));
assert!(has_so2cl, "must produce ArSO2Cl; got {flat:?}");
let has_benzene = flat.iter().any(|s| *s == "c1ccccc1");
assert!(has_benzene, "must produce benzene; got {flat:?}");
}
#[test]
fn diaryl_sulfone_retro_asymmetric() {
let mol = mol_from_smiles("O=S(=O)(c1ccc(C)cc1)c1ccccc1").unwrap();
let rule = rr("diaryl_sulfone_retro", "");
let results = apply_retro(&mol, &rule);
assert!(
results.len() >= 2,
"asymmetric diaryl sulfone must give ≥2 disconnections; got {}",
results.len()
);
}
#[test]
fn diaryl_sulfone_retro_no_fire_on_thioether() {
let mol = mol_from_smiles("c1ccccc1Sc1ccccc1").unwrap(); let rule = rr("diaryl_sulfone_retro", "");
let results = apply_retro(&mol, &rule);
assert!(
results.is_empty(),
"diaryl_sulfone_retro must NOT fire on thioether; got {} result set(s)",
results.len()
);
}
#[test]
fn ez_stereo_e_selective_smirks() {
let smirks = "[C:1]/[C:2]=[C:3]/[C:4]>>[C:1][C:2]=O.[O:3]=[C:4]";
let e_hexene = parse("CC/C=C/CC").unwrap(); let z_hexene = parse("CC/C=C\\CC").unwrap();
let e_results = run_reactants(smirks, &[&e_hexene]).unwrap_or_default();
let z_results = run_reactants(smirks, &[&z_hexene]).unwrap_or_default();
assert!(!e_results.is_empty(), "E-alkene must match E-SMIRKS");
assert!(
z_results.is_empty(),
"Z-alkene must NOT match E-SMIRKS; got {} result set(s)",
z_results.len()
);
}
#[test]
fn ez_stereo_unspecified_smirks_matches_both_geometries() {
let smirks = "[C:1][C:2]=[C:3][C:4]>>[C:1][C:2]=O.[O:3]=[C:4]";
let e_hexene = parse("CC/C=C/CC").unwrap();
let z_hexene = parse("CC/C=C\\CC").unwrap();
let e_results = run_reactants(smirks, &[&e_hexene]).unwrap_or_default();
let z_results = run_reactants(smirks, &[&z_hexene]).unwrap_or_default();
assert!(
!e_results.is_empty(),
"non-stereo SMIRKS must match E-alkene"
);
assert!(
!z_results.is_empty(),
"non-stereo SMIRKS must match Z-alkene"
);
}
#[test]
fn ez_stereo_stilbene_wittig_discrimination() {
let smirks = "[c:1]/[C:2]=[C:3]/[c:4]>>[c:1][C:2]=O.[O:3]=[C:4][c:4]";
let e_stilbene = parse("c1ccccc1/C=C/c1ccccc1").unwrap(); let z_stilbene = parse("c1ccccc1/C=C\\c1ccccc1").unwrap();
let e_results = run_reactants(smirks, &[&e_stilbene]).unwrap_or_default();
let z_results = run_reactants(smirks, &[&z_stilbene]).unwrap_or_default();
assert!(
!e_results.is_empty(),
"E-selective SMIRKS must fire on (E)-stilbene"
);
assert!(
z_results.is_empty(),
"E-selective SMIRKS must NOT fire on (Z)-stilbene; got {} result set(s)",
z_results.len()
);
}
}
#[cfg(test)]
mod phase15_stereo {
use super::*;
#[test]
fn stereo_templates_load_from_file_and_filter() {
let rules = load_rules_from_file("data/templates_extracted.smi");
let stereo_rules: Vec<_> = rules.iter().filter(|r| r.smirks.contains('@')).collect();
assert!(
stereo_rules.len() >= 2,
"top-500 must contain ≥2 @/@@ templates; got {}",
stereo_rules.len()
);
let r_rule = stereo_rules
.iter()
.find(|r| r.smirks.contains("[C@H"))
.expect("R-selective template not found");
let r_alcohol = parse("C[C@H](O)c1ccccc1").unwrap(); let s_alcohol = parse("C[C@@H](O)c1ccccc1").unwrap(); assert!(
!apply_retro(&r_alcohol, r_rule).is_empty(),
"R-template must produce routes for R-alcohol"
);
assert!(
apply_retro(&s_alcohol, r_rule).is_empty(),
"R-template must reject S-alcohol"
);
}
#[test]
fn non_stereo_smirks_matches_both_enantiomers() {
let smirks = "[C:1][CH:2]([OH:3])[c:4]>>[C:1][C:2](=[O:3])[c:4]";
let r_mol = parse("C[C@H](O)c1ccccc1").unwrap();
let s_mol = parse("C[C@@H](O)c1ccccc1").unwrap();
assert!(
!run_reactants(smirks, &[&r_mol])
.unwrap_or_default()
.is_empty(),
"non-stereo SMIRKS must match R-alcohol"
);
assert!(
!run_reactants(smirks, &[&s_mol])
.unwrap_or_default()
.is_empty(),
"non-stereo SMIRKS must match S-alcohol"
);
}
#[test]
fn stereo_transferred_to_product() {
let smirks = "[N:1][C@@H:2](C)C(=O)O>>[N:1][C@@H:2](C)C=O";
let l_ala = parse("N[C@@H](C)C(=O)O").unwrap(); let d_ala = parse("N[C@H](C)C(=O)O").unwrap();
let l_results = run_reactants(smirks, &[&l_ala]).unwrap_or_default();
let d_results = run_reactants(smirks, &[&d_ala]).unwrap_or_default();
assert!(!l_results.is_empty(), "L-alanine must match @@-SMIRKS");
assert!(
d_results.is_empty(),
"D-alanine must NOT match @@-SMIRKS; got {} result(s)",
d_results.len()
);
let product_smiles: Vec<String> =
l_results[0].iter().map(|m| canonical_smiles(m)).collect();
assert!(
product_smiles.iter().any(|s| s.contains('@')),
"product must carry @/@@ stereo annotation; got {:?}",
product_smiles
);
}
#[test]
fn both_stereo_templates_are_enantiomer_selective() {
let rules = load_rules_from_file("data/templates_extracted.smi");
let r_rule = rules.iter().find(|r| r.smirks.contains("[C@H")).unwrap();
let s_rule = rules.iter().find(|r| r.smirks.contains("[C@@H")).unwrap();
let r_mol = parse("C[C@H](O)c1ccccc1").unwrap();
let s_mol = parse("C[C@@H](O)c1ccccc1").unwrap();
assert!(!apply_retro(&r_mol, r_rule).is_empty());
assert!(apply_retro(&s_mol, r_rule).is_empty());
assert!(!apply_retro(&s_mol, s_rule).is_empty());
assert!(apply_retro(&r_mol, s_rule).is_empty());
}
}