use std::fs;
#[cfg(feature = "perf-instrumentation")]
use std::sync::atomic::{AtomicU64, Ordering};
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::parse_smarts;
use chematic::smiles::{canonical_smiles, parse};
use sha2::{Digest, Sha256};
pub use chematic::core::Molecule;
#[derive(Debug, Clone)]
pub struct RetroRule {
pub name: String,
pub template_id: String,
pub smirks: String,
pub weight: f64,
pub required_elements: u64,
}
impl Default for RetroRule {
fn default() -> Self {
Self {
name: String::new(),
template_id: String::new(),
smirks: String::new(),
weight: 1.0,
required_elements: 0,
}
}
}
pub fn template_id_for_smirks(smirks: &str) -> String {
let digest = Sha256::digest(smirks.trim().as_bytes());
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
format!("smirks-sha256:{hex}")
}
pub struct ChemEnv {
canon_set: FxHashSet<String>,
bb_count: usize,
}
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 bb_count = 0usize;
for smiles in iter {
let Ok(mol) = parse(&smiles) else { continue };
let canon = canonical_stock_identity(&mol);
if !canon_set.insert(canon) {
continue; }
bb_count += 1;
}
Self {
canon_set,
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 {
self.canon_set.contains(&canonical_stock_identity(mol))
}
pub fn content_sha256(&self) -> String {
let mut sorted: Vec<&str> = self.canon_set.iter().map(String::as_str).collect();
sorted.sort_unstable();
let mut hasher = Sha256::new();
hasher.update(b"renkin-retrospect-stock-v1\0");
hasher.update((sorted.len() as u64).to_be_bytes());
for smi in sorted {
hasher.update((smi.len() as u64).to_be_bytes());
hasher.update(smi.as_bytes());
}
format!("sha256:{}", crate::sha256_hex(hasher.finalize()))
}
}
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)
}
pub fn clear_atom_maps(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
for (_, atom) in mol.atoms() {
let mut a = atom.clone();
a.atom_map = None;
builder.add_atom(a);
}
for (_, bond) in mol.bonds() {
let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
}
builder.copy_stereo_groups_from(mol);
builder.copy_stereo_from(mol);
builder.copy_bond_directions_from(mol);
builder.build()
}
#[cfg(test)]
mod clear_atom_maps_tests {
use super::*;
fn canon_after_clear(smiles: &str) -> String {
let mol = mol_from_smiles(smiles).unwrap_or_else(|e| panic!("{smiles}: {e}"));
to_canonical(&clear_atom_maps(&mol))
}
#[test]
fn normal_mapped_atom_matches_unmapped_canonical() {
assert_eq!(
canon_after_clear("[CH3:1]O"),
to_canonical(&mol_from_smiles("CO").unwrap())
);
}
#[test]
fn multi_digit_atom_map_matches_unmapped_canonical() {
assert_eq!(
canon_after_clear("[CH3:123]O"),
to_canonical(&mol_from_smiles("CO").unwrap())
);
}
#[test]
fn isotope_survives_map_clearing() {
assert_eq!(
canon_after_clear("[13CH4:1]"),
to_canonical(&mol_from_smiles("[13CH4]").unwrap())
);
}
#[test]
fn formal_charge_survives_map_clearing() {
assert_eq!(
canon_after_clear("[NH4+:1]"),
to_canonical(&mol_from_smiles("[NH4+]").unwrap())
);
}
#[test]
fn tetrahedral_stereo_survives_map_clearing() {
assert_eq!(
canon_after_clear("[C@H:1](F)(Cl)Br"),
to_canonical(&mol_from_smiles("[C@H](F)(Cl)Br").unwrap())
);
assert_ne!(
canon_after_clear("[C@H:1](F)(Cl)Br"),
canon_after_clear("[C@@H:1](F)(Cl)Br")
);
}
#[test]
fn aromatic_atoms_survive_map_clearing() {
assert_eq!(
canon_after_clear("[cH:1]1ccccc1"),
to_canonical(&mol_from_smiles("c1ccccc1").unwrap())
);
}
#[test]
fn disconnected_fragments_survive_map_clearing() {
assert_eq!(
canon_after_clear("[CH3:1]O.[Na+:2]"),
to_canonical(&mol_from_smiles("CO.[Na+]").unwrap())
);
}
#[test]
fn already_unmapped_smiles_is_a_no_op() {
assert_eq!(
canon_after_clear("CC(=O)O"),
to_canonical(&mol_from_smiles("CC(=O)O").unwrap())
);
}
#[test]
fn explicit_colon_bond_with_ring_closure_digit_is_not_corrupted() {
let mapped = "[cH:1]:1:c:c:c:c:c:1";
let benzene = to_canonical(&mol_from_smiles("c1ccccc1").unwrap());
assert_eq!(
canon_after_clear(mapped),
benzene,
"structural atom-map clearing must keep the ring closed"
);
let regex_corrupted = to_canonical(&mol_from_smiles("[cH]:c:c:c:c:c").unwrap());
assert_ne!(
regex_corrupted, benzene,
"fixture no longer demonstrates the regex-corruption failure mode"
);
}
#[test]
fn heavy_atom_count_is_unchanged_by_map_clearing() {
let mol = mol_from_smiles("[CH3:1][CH2:2][OH:3]").unwrap();
let cleared = clear_atom_maps(&mol);
assert_eq!(mol.atoms().count(), cleared.atoms().count());
assert_eq!(mol.bonds().count(), cleared.bonds().count());
assert!(cleared.atoms().all(|(_, a)| a.atom_map.is_none()));
}
}
static STANDARDIZE_OPTS: StandardizeOptions = StandardizeOptions {
canonical_tautomer: false,
neutralize_charges: false,
remove_explicit_h: true,
largest_fragment_only: false,
zwitterion_handling: ZwitterionHandling::Keep,
};
pub(crate) fn canonical_stock_identity(mol: &Molecule) -> String {
canonical_smiles(&standardize(mol, &STANDARDIZE_OPTS))
}
pub(crate) fn canonical_stock_identity_from_smiles(smiles: &str) -> Result<String> {
let mol = parse(smiles).with_context(|| format!("Failed to parse SMILES: {smiles}"))?;
Ok(canonical_stock_identity(&mol))
}
pub(crate) 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 ester_cleavage_graph(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, o_idx) = {
let aa = mol.atom(a);
let ab = mol.atom(b);
if aa.element == Element::C && ab.element == Element::O {
(a, b)
} else if aa.element == Element::O && ab.element == Element::C {
(b, a)
} else {
continue;
}
};
let has_keto_o = mol.neighbors(c_idx).any(|(nb, bond_idx)| {
nb != o_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, o_idx) {
continue;
}
let comp_c = get_component(mol, c_idx, c_idx, o_idx);
let comp_o = get_component(mol, o_idx, c_idx, o_idx);
if comp_o.len() <= 1 {
continue;
}
let Some(frag_acid) = build_sub_molecule_with_oh(mol, &comp_c, c_idx) else {
continue;
};
let Some(frag_alcohol) = build_sub_molecule(mol, &comp_o) else {
continue;
};
let precs_acid = split_fragments(&frag_acid);
let precs_alcohol = split_fragments(&frag_alcohol);
if precs_acid.is_empty() || precs_alcohol.is_empty() {
continue;
}
let mut key_parts: Vec<&str> = precs_acid
.iter()
.chain(precs_alcohol.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_alcohol);
results.push(prec_set);
}
results
}
fn sulfonamide_cleavage_graph(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 (s_idx, n_idx) = {
let aa = mol.atom(a);
let ab = mol.atom(b);
if aa.element == Element::S && ab.element == Element::N {
(a, b)
} else if aa.element == Element::N && ab.element == Element::S {
(b, a)
} 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, s_idx, n_idx) {
continue;
}
let comp_s = get_component(mol, s_idx, s_idx, n_idx); let comp_n = get_component(mol, n_idx, s_idx, n_idx);
let Some(frag_so2cl) = build_sub_molecule_with_cl(mol, &comp_s, s_idx) else {
continue;
};
let Some(frag_amine) = build_sub_molecule(mol, &comp_n) else {
continue;
};
let precs_so2cl = split_fragments(&frag_so2cl);
let precs_amine = split_fragments(&frag_amine);
if precs_so2cl.is_empty() || precs_amine.is_empty() {
continue;
}
let mut key_parts: Vec<&str> = precs_so2cl
.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_so2cl;
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())
}
#[cfg(feature = "perf-instrumentation")]
static APPLY_RETRO_CALLS: AtomicU64 = AtomicU64::new(0);
#[cfg(feature = "perf-instrumentation")]
pub fn apply_retro_call_count() -> u64 {
APPLY_RETRO_CALLS.load(Ordering::Relaxed)
}
#[cfg(not(feature = "perf-instrumentation"))]
pub fn apply_retro_call_count() -> u64 {
0
}
#[cfg(feature = "perf-instrumentation")]
pub fn reset_apply_retro_call_count() {
APPLY_RETRO_CALLS.store(0, Ordering::Relaxed);
}
#[cfg(not(feature = "perf-instrumentation"))]
pub fn reset_apply_retro_call_count() {}
pub fn apply_retro(mol: &Molecule, rule: &RetroRule) -> Vec<Vec<PrecursorMol>> {
#[cfg(feature = "perf-instrumentation")]
APPLY_RETRO_CALLS.fetch_add(1, Ordering::Relaxed);
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),
"ester_cleavage" => ester_cleavage_graph(mol),
"sulfonamide_retro" => sulfonamide_cleavage_graph(mol),
"boc_deprotection_retro" => boc_deprotection(mol),
"cbz_deprotection_retro" => cbz_deprotection(mol),
_ => vec![],
};
}
if !rule.smirks.contains('#') {
return 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();
}
let variants = application_smirks_variants(&rule.smirks);
let mut outcomes: Vec<Vec<PrecursorMol>> = Vec::new();
let mut seen_signatures: FxHashSet<Vec<String>> = FxHashSet::default();
for variant in variants.iter() {
for products in run_reactants(variant, &[mol]).unwrap_or_default() {
if products
.iter()
.any(|p| aromaticity_integrity_violation(p).is_some())
{
continue;
}
let precursors: Vec<PrecursorMol> = products
.into_iter()
.flat_map(|product_mol| split_fragments(&product_mol))
.collect();
let mut signature: Vec<String> = precursors.iter().map(|p| p.smiles.clone()).collect();
signature.sort_unstable();
if seen_signatures.insert(signature) {
outcomes.push(precursors);
}
}
}
outcomes
}
pub struct PrecursorMol {
pub smiles: String,
pub mol: Molecule,
}
pub(crate) 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()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AromaticityIntegrityViolation {
AromaticAtomNotInRing,
AromaticAtomWithoutAromaticBond,
}
impl AromaticityIntegrityViolation {
pub fn reason_code(self) -> &'static str {
match self {
Self::AromaticAtomNotInRing => "aromatic_atom_not_in_ring",
Self::AromaticAtomWithoutAromaticBond => "aromatic_atom_without_aromatic_bond",
}
}
}
pub fn aromaticity_integrity_violation(mol: &Molecule) -> Option<AromaticityIntegrityViolation> {
for (idx, atom) in mol.atoms() {
if !atom.aromatic {
continue;
}
let mut in_ring = false;
let mut has_aromatic_bond = false;
for (neighbor, bidx) in mol.neighbors(idx) {
if !is_bridge_bond(mol, idx, neighbor) {
in_ring = true;
}
if mol.bond(bidx).order == BondOrder::Aromatic {
has_aromatic_bond = true;
}
}
if !in_ring {
return Some(AromaticityIntegrityViolation::AromaticAtomNotInRing);
}
if !has_aromatic_bond {
return Some(AromaticityIntegrityViolation::AromaticAtomWithoutAromaticBond);
}
}
None
}
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
}
const MAX_HASH_ATOM_VARIANTS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HashAtomSide {
Lhs,
Rhs,
}
struct HashAtomOccurrence {
byte_range: std::ops::Range<usize>,
atomic_number: u8,
atom_map: Option<u32>,
side: HashAtomSide,
}
fn find_hash_atoms(smirks: &str) -> Option<Vec<HashAtomOccurrence>> {
let arrow_pos = smirks.find(">>");
let mut occurrences = Vec::new();
let mut i = 0;
while i < smirks.len() {
if smirks.as_bytes()[i] != b'[' {
i += 1;
continue;
}
let start = i;
let end = smirks[i..].find(']').map(|rel| i + rel + 1)?;
let inner = &smirks[start + 1..end - 1];
if let Some(hash_pos) = inner.find('#') {
if hash_pos != 0 {
return None;
}
let rest = &inner[1..];
let (num_str, map_str) = match rest.split_once(':') {
Some((n, m)) => (n, Some(m)),
None => (rest, None),
};
if num_str.is_empty() || !num_str.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let atomic_number: u32 = num_str.parse().ok()?;
if atomic_number == 0 || atomic_number > 118 {
return None;
}
let atom_map = match map_str {
None => None,
Some(m) if !m.is_empty() && m.bytes().all(|b| b.is_ascii_digit()) => {
Some(m.parse().ok()?)
}
Some(_) => return None, };
let side = match arrow_pos {
Some(pos) if start < pos => HashAtomSide::Lhs,
_ => HashAtomSide::Rhs,
};
occurrences.push(HashAtomOccurrence {
byte_range: start..end,
atomic_number: atomic_number as u8,
atom_map,
side,
});
}
i = end;
}
Some(occurrences)
}
fn hash_atom_candidate_symbols(atomic_number: u8) -> Vec<String> {
match Element::from_atomic_number(atomic_number) {
Some(elem) => {
let upper = elem.symbol().to_string();
let lower = upper.to_lowercase();
if upper == lower {
vec![upper]
} else {
vec![upper, lower]
}
}
None => vec![],
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAtomUnsupportedReason {
UnhandledSyntax,
InconsistentElement,
VariantLimitExceeded { total_combinations: usize },
NoValidVariant,
}
#[derive(Debug, Clone)]
enum HashAtomExpansion {
NotApplicable,
Unsupported(HashAtomUnsupportedReason),
Expanded { variants: Vec<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MappedAtomRole {
Spectator,
ReactionCenter,
Unknown,
}
fn split_top_level_dots(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut start = 0usize;
for (i, b) in s.bytes().enumerate() {
match b {
b'[' => depth += 1,
b']' => depth -= 1,
b'.' if depth == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
fn parse_side_as_query_fragments(side_text: &str) -> Option<Vec<chematic::smarts::QueryMolecule>> {
split_top_level_dots(side_text)
.into_iter()
.map(|frag| parse_smarts(frag).ok())
.collect()
}
fn mapped_atom_signature(
fragments: &[chematic::smarts::QueryMolecule],
map: u16,
) -> Option<Vec<(Option<u16>, chematic::smarts::BondQuery)>> {
let mut found = None;
for qmol in fragments {
for (atom_idx, qatom) in qmol.atoms.iter().enumerate() {
if qatom.atom_map != Some(map) {
continue;
}
if found.is_some() {
return None; }
let sig = qmol.adj[atom_idx]
.iter()
.map(|&(bond_idx, neighbor_idx)| {
(
qmol.atoms[neighbor_idx].atom_map,
qmol.bonds[bond_idx].query.clone(),
)
})
.collect();
found = Some(sig);
}
}
found
}
fn bond_signature_multiset_eq(
a: &[(Option<u16>, chematic::smarts::BondQuery)],
b: &[(Option<u16>, chematic::smarts::BondQuery)],
) -> bool {
if a.len() != b.len() {
return false;
}
let mut remaining: Vec<&(Option<u16>, chematic::smarts::BondQuery)> = b.iter().collect();
for item in a {
let Some(pos) = remaining
.iter()
.position(|r| r.0 == item.0 && r.1 == item.1)
else {
return false;
};
remaining.remove(pos);
}
true
}
fn classify_mapped_atom_roles(smirks: &str) -> FxHashMap<u32, MappedAtomRole> {
let mut roles = FxHashMap::default();
let Some((lhs_text, rhs_text)) = smirks.split_once(">>") else {
return roles;
};
let Some(lhs_frags) = parse_side_as_query_fragments(lhs_text) else {
return roles;
};
let Some(rhs_frags) = parse_side_as_query_fragments(rhs_text) else {
return roles;
};
let lhs_maps: FxHashSet<u16> = lhs_frags
.iter()
.flat_map(|q| q.atoms.iter().filter_map(|a| a.atom_map))
.collect();
let rhs_maps: FxHashSet<u16> = rhs_frags
.iter()
.flat_map(|q| q.atoms.iter().filter_map(|a| a.atom_map))
.collect();
for &map in lhs_maps.intersection(&rhs_maps) {
let role = match (
mapped_atom_signature(&lhs_frags, map),
mapped_atom_signature(&rhs_frags, map),
) {
(Some(l), Some(r)) => {
if bond_signature_multiset_eq(&l, &r) {
MappedAtomRole::Spectator
} else {
MappedAtomRole::ReactionCenter
}
}
_ => MappedAtomRole::Unknown,
};
roles.insert(map as u32, role);
}
roles
}
fn expand_hash_atom_variants(smirks: &str) -> HashAtomExpansion {
let occurrences = match find_hash_atoms(smirks) {
Some(o) => o,
None => return HashAtomExpansion::Unsupported(HashAtomUnsupportedReason::UnhandledSyntax),
};
if occurrences.is_empty() {
return HashAtomExpansion::NotApplicable;
}
let mut element_by_map: Vec<(u32, u8)> = Vec::new();
for occ in &occurrences {
let Some(m) = occ.atom_map else { continue };
match element_by_map.iter().find(|(map, _)| *map == m) {
Some((_, an)) if *an != occ.atomic_number => {
return HashAtomExpansion::Unsupported(
HashAtomUnsupportedReason::InconsistentElement,
);
}
Some(_) => {}
None => element_by_map.push((m, occ.atomic_number)),
}
}
let mapped_atom_roles = classify_mapped_atom_roles(smirks);
let is_reaction_center =
|m: u32| mapped_atom_roles.get(&m) == Some(&MappedAtomRole::ReactionCenter);
let mut group_key: Vec<(HashAtomSide, Option<u32>, usize)> = Vec::new(); let mut group_members: Vec<Vec<usize>> = Vec::new();
let mut group_atomic_number: Vec<u8> = Vec::new();
let mut next_disambiguator = 0usize;
for (idx, occ) in occurrences.iter().enumerate() {
let key_side = match occ.atom_map {
Some(m) if !is_reaction_center(m) => HashAtomSide::Lhs,
_ => occ.side,
};
let existing = occ.atom_map.and_then(|m| {
group_key
.iter()
.position(|(s, gm, _)| *s == key_side && *gm == Some(m))
});
match existing {
Some(gi) => group_members[gi].push(idx),
None => {
let key = match occ.atom_map {
Some(m) => (key_side, Some(m), 0),
None => {
next_disambiguator += 1;
(occ.side, None, next_disambiguator)
}
};
group_key.push(key);
group_members.push(vec![idx]);
group_atomic_number.push(occ.atomic_number);
}
}
}
let mut group_candidates: Vec<Vec<String>> = Vec::with_capacity(group_key.len());
for &an in &group_atomic_number {
let candidates = hash_atom_candidate_symbols(an);
if candidates.is_empty() {
return HashAtomExpansion::Unsupported(HashAtomUnsupportedReason::UnhandledSyntax);
}
group_candidates.push(candidates);
}
let total_combinations: usize = group_candidates.iter().map(Vec::len).product();
if total_combinations > MAX_HASH_ATOM_VARIANTS {
return HashAtomExpansion::Unsupported(HashAtomUnsupportedReason::VariantLimitExceeded {
total_combinations,
});
}
let mut combo_indices = vec![0usize; group_candidates.len()];
let mut variants = Vec::new();
for _ in 0..total_combinations {
let mut replacements: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for (gi, members) in group_members.iter().enumerate() {
let symbol = &group_candidates[gi][combo_indices[gi]];
for &occ_idx in members {
let occ = &occurrences[occ_idx];
let replacement = match occ.atom_map {
Some(m) => format!("[{symbol}:{m}]"),
None => format!("[{symbol}]"),
};
replacements.push((occ.byte_range.clone(), replacement));
}
}
replacements.sort_by_key(|r| std::cmp::Reverse(r.0.start));
let mut candidate = smirks.to_string();
for (range, replacement) in replacements {
candidate.replace_range(range, &replacement);
}
if chematic::rxn::parse_reaction(&candidate).is_ok() {
variants.push(candidate);
}
let mut gi = 0;
while gi < combo_indices.len() {
combo_indices[gi] += 1;
if combo_indices[gi] < group_candidates[gi].len() {
break;
}
combo_indices[gi] = 0;
gi += 1;
}
}
if variants.is_empty() {
return HashAtomExpansion::Unsupported(HashAtomUnsupportedReason::NoValidVariant);
}
HashAtomExpansion::Expanded { variants }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConcreteApplicationStatus {
Direct,
HashAtomVariants { variant_count: usize },
Unsupported { reason: HashAtomUnsupportedReason },
}
pub fn concrete_application_status(smirks: &str) -> ConcreteApplicationStatus {
match expand_hash_atom_variants(smirks) {
HashAtomExpansion::NotApplicable => ConcreteApplicationStatus::Direct,
HashAtomExpansion::Unsupported(reason) => ConcreteApplicationStatus::Unsupported { reason },
HashAtomExpansion::Expanded { variants } => ConcreteApplicationStatus::HashAtomVariants {
variant_count: variants.len(),
},
}
}
fn hash_atom_variant_cache()
-> &'static std::sync::Mutex<FxHashMap<String, std::sync::Arc<Vec<String>>>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<FxHashMap<String, std::sync::Arc<Vec<String>>>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()))
}
pub fn application_smirks_variants(smirks: &str) -> std::sync::Arc<Vec<String>> {
let cache = hash_atom_variant_cache();
if let Some(hit) = cache.lock().unwrap().get(smirks) {
return std::sync::Arc::clone(hit);
}
let computed = std::sync::Arc::new(match expand_hash_atom_variants(smirks) {
HashAtomExpansion::Expanded { variants } => variants,
HashAtomExpansion::NotApplicable => vec![smirks.to_string()],
HashAtomExpansion::Unsupported(_) => Vec::new(),
});
cache
.lock()
.unwrap()
.insert(smirks.to_string(), std::sync::Arc::clone(&computed));
computed
}
fn rr(name: &str, smirks: &str) -> RetroRule {
let required_elements = required_elements_from_smirks(smirks);
RetroRule {
name: name.into(),
template_id: format!("rule:{name}"),
smirks: smirks.into(),
required_elements,
..Default::default()
}
}
pub fn default_rules() -> Vec<RetroRule> {
vec![
rr("ester_cleavage", ""), 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)[OH]>>[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_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", ""),
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 top_templates_by_weight(mut rules: Vec<RetroRule>, k: usize) -> Vec<RetroRule> {
if rules.len() <= k {
return rules;
}
rules.sort_by(|a, b| {
b.weight
.partial_cmp(&a.weight)
.unwrap_or(std::cmp::Ordering::Equal)
});
rules.truncate(k);
rules
}
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}"),
template_id: template_id_for_smirks(smirks),
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 content_sha256_is_order_independent_and_detects_content_change() {
let a = ChemEnv::in_memory(&["CCO", "CC(=O)O", "C"]);
let b = ChemEnv::in_memory(&["C", "CC(=O)O", "CCO"]);
assert_eq!(
a.content_sha256(),
b.content_sha256(),
"hashing must not depend on input order"
);
let c = ChemEnv::in_memory(&["CCO", "CC(=O)O"]);
assert_ne!(
a.content_sha256(),
c.content_sha256(),
"a different BB set must hash differently even under the same caller-supplied label"
);
}
#[test]
fn parse_aspirin_roundtrip() {
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
assert_eq!(mol.atom_count(), 13);
}
#[test]
fn hash_atom_not_applicable_for_plain_smirks() {
let smirks = "[N:1][CH2:2][c:3]>>[N:1].[Br][CH2:2][c:3]";
assert!(matches!(
expand_hash_atom_variants(smirks),
HashAtomExpansion::NotApplicable
));
assert_eq!(
concrete_application_status(smirks),
ConcreteApplicationStatus::Direct
);
}
#[test]
fn hash_atom_expands_bare_nitrogen_wildcard_into_validated_variants() {
let smirks = "[#7:2]:[c:1](-[NH:4]-[c:5]):[#7:3]>>Cl-[c:1](:[#7:2]):[#7:3].[NH2:4]-[c:5]";
match expand_hash_atom_variants(smirks) {
HashAtomExpansion::Expanded { variants } => {
assert!(!variants.is_empty());
for v in &variants {
assert!(
chematic::rxn::parse_reaction(v).is_ok(),
"every returned variant must independently re-parse: {v}"
);
assert!(
!v.contains('#'),
"no variant may still contain a hash atom: {v}"
);
}
assert!(
variants
.iter()
.any(|v| v.contains("[n:2]") && v.contains("[n:3]")),
"expected an all-aromatic variant among {variants:?}"
);
}
other => panic!("expected Expanded, got {other:?}"),
}
}
#[test]
fn hash_atom_allows_independent_aromaticity_choice_per_side() {
let smirks = "[#7:2]:[c:1]:[c:3]>>Cl-[c:1](=[#7:2])-[c:3]";
let HashAtomExpansion::Expanded { variants } = expand_hash_atom_variants(smirks) else {
panic!("expected an Expanded outcome");
};
let has_mixed_reading = variants.iter().any(|v| {
let lhs = v.split(">>").next().unwrap();
let rhs = v.split(">>").nth(1).unwrap();
lhs.contains("[n:2]") != rhs.contains("[n:2]")
});
assert!(
has_mixed_reading,
"expected at least one variant where the two sides disagree on aromaticity \
for atom-map 2, among {variants:?}"
);
}
#[test]
fn hash_atom_same_side_same_atom_map_must_agree_with_itself() {
let smirks = "[#7:2]-[C:1]-[#7:2]>>[N:1]";
let HashAtomExpansion::Expanded { variants } = expand_hash_atom_variants(smirks) else {
panic!("expected an Expanded outcome");
};
for v in &variants {
let lhs = v.split(">>").next().unwrap();
let upper_count = lhs.matches("[N:2]").count();
let lower_count = lhs.matches("[n:2]").count();
assert!(
upper_count == 0 || lower_count == 0,
"both LHS occurrences of atom-map 2 must share one reading within a variant: {v}"
);
}
}
#[test]
fn hash_atom_bails_on_inconsistent_element_for_same_atom_map() {
let smirks = "[#7:2]-[C:1]>>[#8:2]-[C:1]";
assert_eq!(
concrete_application_status(smirks),
ConcreteApplicationStatus::Unsupported {
reason: HashAtomUnsupportedReason::InconsistentElement
}
);
}
#[test]
fn hash_atom_bails_on_combined_primitive() {
let smirks = "[#7;+0:2]-[C:1]>>[N:2]-[C:1]";
assert_eq!(
concrete_application_status(smirks),
ConcreteApplicationStatus::Unsupported {
reason: HashAtomUnsupportedReason::UnhandledSyntax
}
);
}
#[test]
fn hash_atom_expansion_fails_closed_when_combinatorial_space_exceeds_cap() {
let smirks = "[#7]-[#8]-[#16]-[#7]-[#8]>>[#7]-[#8]-[#16]-[#7]-[#8]";
assert_eq!(
concrete_application_status(smirks),
ConcreteApplicationStatus::Unsupported {
reason: HashAtomUnsupportedReason::VariantLimitExceeded {
total_combinations: 1024
}
}
);
assert!(application_smirks_variants(smirks).is_empty());
}
#[test]
fn triple_bond_hash_character_is_not_mistaken_for_a_hash_atom() {
let smirks = "[C:2]-[C:1]#[N:3]>>O=[C:1](-[C:2])-[NH2:3]";
assert_eq!(
concrete_application_status(smirks),
ConcreteApplicationStatus::Direct,
"a triple bond outside any bracket must never be treated as a [#N] atom"
);
assert_eq!(
application_smirks_variants(smirks).as_slice(),
&[smirks.to_string()]
);
}
#[test]
fn load_rules_from_file_keeps_one_logical_rule_per_line_hash_atom_smirks_unchanged() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"renkin_hash_atom_loader_test_{}.smi",
std::process::id()
));
let plain = "[N:1][CH2:2][c:3]>>[N:1].[Br][CH2:2][c:3]";
let hash_atom =
"[#7:2]:[c:1](-[NH:4]-[c:5]):[#7:3]>>Cl-[c:1](:[#7:2]):[#7:3].[NH2:4]-[c:5]";
std::fs::write(&path, format!("{plain}\t10\n{hash_atom}\t167\n")).unwrap();
let rules = load_rules_from_file(path.to_str().unwrap());
std::fs::remove_file(&path).ok();
assert_eq!(rules.len(), 2, "exactly one RetroRule per raw line");
assert_eq!(rules[0].smirks, plain);
assert_eq!(rules[0].name, "extracted_0");
assert_eq!(
rules[1].smirks, hash_atom,
"hash-atom SMIRKS must be stored unchanged"
);
assert_eq!(rules[1].name, "extracted_1");
assert_eq!(rules[1].template_id, template_id_for_smirks(hash_atom));
let mut by_id = std::collections::HashMap::new();
for r in &rules {
assert!(
by_id
.insert(r.template_id.clone(), (&r.name, &r.smirks))
.is_none(),
"template_id must be unique across the loaded rule set"
);
}
}
#[test]
fn apply_retro_succeeds_end_to_end_on_hash_atom_template_without_changing_rule_identity() {
let hash_atom_retro =
"[#7:2]:[c:1](-[NH:4]-[c:5]):[#7:3]>>Cl-[c:1](:[#7:2]):[#7:3].[NH2:4]-[c:5]";
let rule = RetroRule {
name: "extracted_21".to_string(),
template_id: template_id_for_smirks(hash_atom_retro),
smirks: hash_atom_retro.to_string(),
weight: 1.0,
required_elements: required_elements_from_smirks(hash_atom_retro),
};
assert_eq!(
concrete_application_status(&rule.smirks),
ConcreteApplicationStatus::HashAtomVariants { variant_count: 4 }
);
let target = mol_from_smiles("c1ccc(Nc2ncccn2)cc1").unwrap(); let outcomes = apply_retro(&target, &rule);
assert!(
!outcomes.is_empty(),
"apply_retro must succeed on the unmodified rule via the internal variant path"
);
let mut found_expected = false;
for outcome in &outcomes {
let smiles: Vec<&str> = outcome.iter().map(|p| p.smiles.as_str()).collect();
let has_chloropyrimidine = smiles
.iter()
.any(|s| s.contains("Cl") && (s.contains('n') || s.contains('N')));
let has_aniline = smiles.iter().any(|s| {
mol_from_smiles(s)
.map(|m| m.atom_count() == 7) .unwrap_or(false)
});
if has_chloropyrimidine && has_aniline {
found_expected = true;
}
}
assert!(
found_expected,
"expected 2-chloropyrimidine + aniline among outcomes: {:?}",
outcomes
.iter()
.map(|o| o.iter().map(|p| &p.smiles).collect::<Vec<_>>())
.collect::<Vec<_>>()
);
assert_eq!(rule.smirks, hash_atom_retro);
assert_eq!(rule.name, "extracted_21");
}
#[test]
fn apply_retro_dedupes_identical_outcomes_across_hash_atom_variants() {
let smirks = "[#7:1]-[CH3:2]>>[#7:1]-[H].[CH3:2]-Cl";
let target = mol_from_smiles("CNC").unwrap(); let rule = RetroRule {
name: "extracted_test".to_string(),
template_id: template_id_for_smirks(smirks),
smirks: smirks.to_string(),
weight: 1.0,
required_elements: 0,
};
let outcomes = apply_retro(&target, &rule);
let mut signatures: Vec<Vec<String>> = outcomes
.iter()
.map(|o| {
let mut s: Vec<String> = o.iter().map(|p| p.smiles.clone()).collect();
s.sort_unstable();
s
})
.collect();
signatures.sort();
let mut deduped = signatures.clone();
deduped.dedup();
assert_eq!(
signatures, deduped,
"apply_retro must not report the same precursor set twice: {signatures:?}"
);
}
#[test]
fn apply_retro_rejects_spectator_atom_aromaticity_flip_with_unrelated_ring() {
let smirks = "[#7:2]-[CH2:1]-[C:3]>>O=[C:1](-[#7:2])-[C:3]";
let rule = RetroRule {
name: "extracted_45".to_string(),
template_id: template_id_for_smirks(smirks),
smirks: smirks.to_string(),
weight: 1.0,
required_elements: required_elements_from_smirks(smirks),
};
let target = mol_from_smiles("c1ccccc1CCCNCC").unwrap();
let outcomes = apply_retro(&target, &rule);
assert!(
!outcomes.is_empty(),
"the real (non-spectator-corrupted) N->N reading must still succeed"
);
for outcome in &outcomes {
for p in outcome {
assert_eq!(
aromaticity_integrity_violation(&p.mol),
None,
"outcome {:?} must not contain an aromaticity-integrity violation",
p.smiles
);
}
}
}
#[test]
fn apply_retro_rejects_spectator_atom_aromaticity_flip_on_real_ring() {
let smirks = "[#7:2]-[CH2:1]-[C:3]>>O=[C:1](-[#7:2])-[C:3]";
let rule = RetroRule {
name: "extracted_45".to_string(),
template_id: template_id_for_smirks(smirks),
smirks: smirks.to_string(),
weight: 1.0,
required_elements: required_elements_from_smirks(smirks),
};
let target = mol_from_smiles("O=C(OC)C1CN(CCN1)C(=O)OC(C)(C)C").unwrap();
let outcomes = apply_retro(&target, &rule);
for outcome in &outcomes {
for p in outcome {
assert_eq!(
aromaticity_integrity_violation(&p.mol),
None,
"outcome {:?} must not contain an aromaticity-integrity violation",
p.smiles
);
}
}
}
#[test]
fn aromaticity_integrity_accepts_valid_acetanilide() {
let mol = mol_from_smiles("CC(=O)Nc1ccccc1").unwrap(); assert_eq!(aromaticity_integrity_violation(&mol), None);
}
#[test]
fn aromaticity_integrity_accepts_real_heteroaromatic_ring() {
let mol = mol_from_smiles("c1ccncc1").unwrap();
assert_eq!(aromaticity_integrity_violation(&mol), None);
}
#[test]
fn aromaticity_integrity_violation_detects_acyclic_aromatic_atom() {
let bad_variant = "[N:2]-[CH2:1]-[C:3]>>O=[C:1](-[n:2])-[C:3]";
let target = mol_from_smiles("c1ccccc1CCCNCC").unwrap();
let results = run_reactants(bad_variant, &[&target]).unwrap_or_default();
assert!(
!results.is_empty(),
"the bad variant must still match an acyclic amine (that's what makes it dangerous)"
);
for group in &results {
for product in group {
assert_eq!(
aromaticity_integrity_violation(product),
Some(AromaticityIntegrityViolation::AromaticAtomNotInRing),
"product {:?} must be flagged AromaticAtomNotInRing",
canonical_smiles(product)
);
}
}
}
#[test]
fn aromaticity_integrity_violation_detects_ring_atom_without_aromatic_bond() {
let bad_variant = "[N:2]-[CH2:1]-[C:3]>>O=[C:1](-[n:2])-[C:3]";
let target = mol_from_smiles("O=C(OC)C1CN(CCN1)C(=O)OC(C)(C)C").unwrap();
let results = run_reactants(bad_variant, &[&target]).unwrap_or_default();
assert!(
!results.is_empty(),
"the bad variant must still match a piperazine ring nitrogen"
);
for group in &results {
for product in group {
assert_eq!(
aromaticity_integrity_violation(product),
Some(AromaticityIntegrityViolation::AromaticAtomWithoutAromaticBond),
"product {:?} must be flagged AromaticAtomWithoutAromaticBond",
canonical_smiles(product)
);
}
}
}
#[test]
fn building_block_recognized_by_exact_match() {
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 stock_membership_requires_exact_identity_not_substructure() {
let env = ChemEnv::in_memory(&["Cc1ccccc1"]); let benzene = mol_from_smiles("c1ccccc1").unwrap();
assert!(
!env.is_building_block(&benzene),
"benzene is a substructure of toluene but is not the same molecule"
);
}
#[test]
#[ignore = "one-off diagnostic for issue #71: sweeps the full 402-compound \
stock against every one-step retro-fragment of the real 4,903-target \
corpus. Run explicitly with `cargo test --lib -- --ignored --nocapture \
issue_71_before_after_stock_identity_diff`."]
fn issue_71_before_after_stock_identity_diff() {
use chematic::smarts::{QueryMolecule, find_matches, parse_smarts};
use std::collections::HashSet;
struct OldEnv {
canon_set: HashSet<String>,
vf2_index: FxHashMap<(usize, usize), Vec<QueryMolecule>>,
}
impl OldEnv {
fn load(path: &str) -> Self {
let content = std::fs::read_to_string(path).unwrap();
let mut canon_set = HashSet::new();
let mut vf2_raw = Vec::new();
for line in content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let Some(smiles) = line.split_whitespace().next() else {
continue;
};
let Ok(mol) = parse(smiles) else { continue };
let canon = canonical_smiles(&mol);
if !canon_set.insert(canon) {
continue;
}
if 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 (a, b, q) in vf2_raw {
vf2_index.entry((a, b)).or_default().push(q);
}
Self {
canon_set,
vf2_index,
}
}
fn is_building_block(&self, mol: &Molecule) -> bool {
let canon = canonical_smiles(mol);
if self.canon_set.contains(&canon) {
return true;
}
let key = (mol.atom_count(), mol.bonds().count());
if let Some(cands) = self.vf2_index.get(&key) {
let n = mol.atom_count();
return cands
.iter()
.any(|q| find_matches(q, mol).iter().any(|m| m.len() == n));
}
false
}
}
let old_env = OldEnv::load("data/building_blocks.smi");
let new_env = ChemEnv::load("data/building_blocks.smi").unwrap();
let rules = default_rules();
let corpus = std::fs::read_to_string("data/comparison/sample_full_sorted.jsonl").unwrap();
let mut probe_smiles: HashSet<String> = HashSet::new();
for line in corpus.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(smi) = v.get("canonical_smiles").and_then(|s| s.as_str()) else {
continue;
};
let Ok(mol) = mol_from_smiles(smi) else {
continue;
};
for rule in &rules {
for prec_set in apply_retro(&mol, rule) {
for p in prec_set {
probe_smiles.insert(p.smiles);
}
}
}
}
let mut only_old: Vec<&str> = Vec::new();
let mut only_new: Vec<&str> = Vec::new();
let mut both = 0usize;
let mut neither = 0usize;
for smi in &probe_smiles {
let Ok(mol) = mol_from_smiles(smi) else {
continue;
};
match (
old_env.is_building_block(&mol),
new_env.is_building_block(&mol),
) {
(true, true) => both += 1,
(true, false) => only_old.push(smi),
(false, true) => only_new.push(smi),
(false, false) => neither += 1,
}
}
only_old.sort_unstable();
only_new.sort_unstable();
println!("probe set size: {}", probe_smiles.len());
println!("both accept (real matches, unaffected): {both}");
println!("neither accepts: {neither}");
println!(
"OLD-only accepts (VF2 false positives fixed by this PR): {}",
only_old.len()
);
for s in &only_old {
println!(" FIXED FALSE POSITIVE: {s}");
}
println!(
"NEW-only accepts (previously-missed true matches, now correct): {}",
only_new.len()
);
for s in &only_new {
println!(" NEWLY CORRECT MATCH: {s}");
}
}
#[test]
fn vf2_false_positive_regressions_from_issue_71() {
let env = ChemEnv::load("data/building_blocks.smi").unwrap();
let false_positives = [
(
"C=C/C/C=C",
"1,4-pentadiene, via cc_single_cleavage (#L679)",
),
("O=C/C(=O)O", "glyoxylic acid, via wittig_retro (#L1640)"),
(
"c1ccc(cc1)CC=O",
"phenylacetaldehyde, via co_aliphatic_cleavage (#L4575)",
),
];
for (smiles, label) in false_positives {
let mol = mol_from_smiles(smiles).unwrap();
assert!(
!env.is_building_block(&mol),
"{label} ({smiles}) must not be a stock hit — it is not in data/building_blocks.smi"
);
}
}
#[test]
fn ester_cleavage_fires_on_aspirin() {
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let rule = rr("ester_cleavage", ""); let results = apply_retro(&mol, &rule);
assert!(!results.is_empty(), "ester_cleavage must match aspirin");
for prec_set in &results {
for p in prec_set {
assert!(
mol_from_smiles(&p.smiles).is_ok(),
"invalid precursor: {}",
p.smiles
);
}
}
}
#[test]
fn ester_cleavage_skips_free_acid() {
let mol = mol_from_smiles("CC(=O)O").unwrap(); let rule = rr("ester_cleavage", "");
let results = apply_retro(&mol, &rule);
assert!(
results.is_empty(),
"free carboxylic acid should not be cleaved"
);
}
#[test]
#[cfg(not(feature = "perf-instrumentation"))]
fn apply_retro_call_count_is_zero_without_perf_instrumentation() {
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let rule = rr("ester_cleavage", "");
apply_retro(&mol, &rule);
assert_eq!(
apply_retro_call_count(),
0,
"without perf-instrumentation the counter must never move"
);
}
#[test]
#[cfg(feature = "perf-instrumentation")]
fn apply_retro_call_count_tracks_calls_with_perf_instrumentation() {
reset_apply_retro_call_count();
let mol = mol_from_smiles("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let rule = rr("ester_cleavage", "");
apply_retro(&mol, &rule);
apply_retro(&mol, &rule);
assert!(
apply_retro_call_count() >= 2,
"two apply_retro calls after reset must be reflected in the counter"
);
}
#[test]
fn ester_cleavage_ethyl_benzoate() {
let mol = mol_from_smiles("CCOC(=O)c1ccccc1").unwrap();
let rule = rr("ester_cleavage", "");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"ethyl benzoate ester cleavage must fire"
);
}
#[test]
fn sulfonamide_cleavage_fires_on_aryl_sulfonamide() {
let mol = mol_from_smiles("O=S(=O)(c1ccccc1)Nc1ccccc1").unwrap();
let rule = rr("sulfonamide_retro", ""); let results = apply_retro(&mol, &rule);
assert!(!results.is_empty(), "aryl sulfonamide cleavage must fire");
for prec_set in &results {
for p in prec_set {
assert!(
mol_from_smiles(&p.smiles).is_ok(),
"invalid precursor: {}",
p.smiles
);
}
}
}
#[test]
fn sulfonamide_cleavage_skips_non_sulfonyl() {
let mol = mol_from_smiles("CSNc1ccccc1").unwrap(); let rule = rr("sulfonamide_retro", "");
let results = apply_retro(&mol, &rule);
assert!(
results.is_empty(),
"non-sulfonyl S-N must not be cleaved as sulfonamide"
);
}
#[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());
}
fn aryl_carboxylation_rule() -> RetroRule {
default_rules()
.into_iter()
.find(|r| r.name == "aryl_carboxylation_retro")
.expect("aryl_carboxylation_retro must be in default_rules()")
}
#[test]
fn aryl_carboxylation_fires_on_benzoic_acid() {
let mol = mol_from_smiles("OC(=O)c1ccccc1").unwrap();
let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
!results.is_empty(),
"free benzoic acid must still disconnect via aryl_carboxylation_retro"
);
}
#[test]
fn aryl_carboxylation_fires_on_substituted_benzoic_acid() {
let mol = mol_from_smiles("OC(=O)c1ccc(Cl)cc1").unwrap(); let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
!results.is_empty(),
"substituted free acid must still disconnect via aryl_carboxylation_retro"
);
}
#[test]
fn aryl_carboxylation_skips_methyl_ester() {
let mol = mol_from_smiles("COC(=O)c1ccccc1").unwrap(); let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
results.is_empty(),
"methyl benzoate must NOT disconnect via aryl_carboxylation_retro \
(that would silently drop the OMe group — ester_cleavage is the correct rule)"
);
}
#[test]
fn aryl_carboxylation_skips_ethyl_ester() {
let mol = mol_from_smiles("CCOC(=O)c1ccccc1").unwrap(); let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
results.is_empty(),
"ethyl benzoate must NOT disconnect via aryl_carboxylation_retro"
);
}
#[test]
fn aryl_carboxylation_skips_amide() {
let mol = mol_from_smiles("NC(=O)c1ccccc1").unwrap(); let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
results.is_empty(),
"benzamide (N, not O) must not match the carboxylation pattern"
);
}
#[test]
fn aryl_carboxylation_skips_carboxylate_anion() {
let mol = mol_from_smiles("[O-]C(=O)c1ccccc1").unwrap(); let results = apply_retro(&mol, &aryl_carboxylation_rule());
assert!(
results.is_empty(),
"carboxylate anion must not fire aryl_carboxylation_retro (free-acid-only by design)"
);
}
#[test]
fn methyl_benzoate_ester_cleavage_gives_correct_precursors() {
let mol = mol_from_smiles("COC(=O)c1ccccc1").unwrap();
let rule = rr("ester_cleavage", "");
let results = apply_retro(&mol, &rule);
assert!(
!results.is_empty(),
"ester_cleavage must fire on methyl benzoate"
);
let found_correct_split = results.iter().any(|set| {
let smiles: Vec<String> = set.iter().map(|p| p.smiles.clone()).collect();
let has_acid = smiles.iter().any(|s| {
mol_from_smiles(s)
.map(|m| {
canonical_smiles(&m)
== canonical_smiles(&mol_from_smiles("OC(=O)c1ccccc1").unwrap())
})
.unwrap_or(false)
});
let has_methanol = smiles.iter().any(|s| {
mol_from_smiles(s)
.map(|m| {
canonical_smiles(&m) == canonical_smiles(&mol_from_smiles("CO").unwrap())
})
.unwrap_or(false)
});
has_acid && has_methanol
});
assert!(
found_correct_split,
"ester_cleavage must split methyl benzoate into benzoic acid + methanol, got: {:?}",
results
.iter()
.map(|set| set.iter().map(|p| p.smiles.clone()).collect::<Vec<_>>())
.collect::<Vec<_>>()
);
}
struct SubstituentPreservationCase {
rule_name: &'static str,
target: &'static str,
expected_preserved_fragment: &'static str,
}
const SUBSTITUENT_PRESERVATION_CASES: &[SubstituentPreservationCase] = &[
SubstituentPreservationCase {
rule_name: "friedel_crafts_acylation_retro",
target: "COC(=O)c1ccccc1", expected_preserved_fragment: "COC(=O)Cl", },
SubstituentPreservationCase {
rule_name: "negishi_retro",
target: "OCc1ccccc1", expected_preserved_fragment: "CO", },
SubstituentPreservationCase {
rule_name: "claisen_retro",
target: "CC(C)C(=O)CC(=O)OCC", expected_preserved_fragment: "CC(C)C(=O)O", },
SubstituentPreservationCase {
rule_name: "michael_retro",
target: "c1ccccc1CC(=O)CC", expected_preserved_fragment: "C=C(O)Cc1ccccc1",
},
SubstituentPreservationCase {
rule_name: "reductive_amination_retro",
target: "CC(C)(C)NCC", expected_preserved_fragment: "CC(C)(C)N", },
SubstituentPreservationCase {
rule_name: "wittig_retro",
target: "CC(C)=CC", expected_preserved_fragment: "CC(C)=O", },
SubstituentPreservationCase {
rule_name: "grignard_addition_retro",
target: "CCC(O)(C)CC", expected_preserved_fragment: "CCC(C)=O", },
SubstituentPreservationCase {
rule_name: "aryl_amine_retro",
target: "c1ccccc1NCC", expected_preserved_fragment: "CCN", },
];
fn formula_fingerprint(mol: &Molecule) -> std::collections::BTreeMap<Element, i64> {
let mut counts = std::collections::BTreeMap::new();
for (_, atom) in mol.atoms() {
*counts.entry(atom.element).or_insert(0) += 1;
}
for h in chematic::chem::implicit_hcount_per_atom(mol) {
if h > 0 {
*counts.entry(Element::H).or_insert(0) += h as i64;
}
}
counts
}
#[test]
fn substituent_preservation_regression_suite() {
let rules = default_rules();
for case in SUBSTITUENT_PRESERVATION_CASES {
let rule = rules
.iter()
.find(|r| r.name == case.rule_name)
.unwrap_or_else(|| panic!("{} must be in default_rules()", case.rule_name));
let mol = mol_from_smiles(case.target)
.unwrap_or_else(|_| panic!("target must parse: {}", case.target));
let results = apply_retro(&mol, rule);
let expected_formula = formula_fingerprint(
&mol_from_smiles(case.expected_preserved_fragment).unwrap_or_else(|_| {
panic!(
"expected_preserved_fragment must parse: {}",
case.expected_preserved_fragment
)
}),
);
let found = results.iter().any(|set| {
set.iter()
.any(|p| formula_fingerprint(&p.mol) == expected_formula)
});
assert!(
found,
"{}: expected precursor fragment '{}' (preserving the target's real \
substituent) not found for target '{}'. Got: {:?}",
case.rule_name,
case.expected_preserved_fragment,
case.target,
results
.iter()
.map(|set| set.iter().map(|p| p.smiles.clone()).collect::<Vec<_>>())
.collect::<Vec<_>>()
);
}
}
#[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 bromobenzene_canon = canonical_smiles(&mol_from_smiles("Brc1ccccc1").unwrap());
let benzene_canon = canonical_smiles(&mol_from_smiles("c1ccccc1").unwrap());
let has_bromobenzene = all_smiles.contains(&bromobenzene_canon);
let has_benzene = all_smiles.contains(&benzene_canon);
assert!(
has_bromobenzene,
"expected bromobenzene fragment ({bromobenzene_canon:?}); 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}"
);
}
#[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_removed_from_default_rules() {
let rules = default_rules();
for removed in [
"aryl_chloride_retro",
"aryl_iodide_retro",
"aryl_fluoride_snAr_retro",
] {
assert!(
rules.iter().all(|r| r.name != removed),
"{removed} must not be present in default_rules() (31.11: atom-loss, no tracked reagent)"
);
}
}
#[test]
fn default_rule_names_never_use_extracted_prefix() {
let rules = default_rules();
for rule in &rules {
assert!(
!rule.name.starts_with("extracted_"),
"hand-crafted rule {:?} must not use the extracted_ name prefix \
reserved for load_rules_from_file",
rule.name
);
}
}
fn write_templates_file(dir: &std::path::Path, name: &str, content: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
path.to_str().unwrap().to_string()
}
#[test]
fn template_id_stable_across_file_reordering() {
let dir = std::env::temp_dir();
let a = "[O:3]=[C:2]-[OH:1]>>C-[O:1]-[C:2]=[O:3]";
let b = "[NH2:1]-[c:2]>>O=[N+:1](-[O-])-[c:2]";
let path1 = write_templates_file(
&dir,
"renkin_tid_order1.smi",
&format!("{a}\t10\n{b}\t20\n"),
);
let path2 = write_templates_file(
&dir,
"renkin_tid_order2.smi",
&format!("{b}\t20\n{a}\t10\n"),
);
let rules1 = load_rules_from_file(&path1);
let rules2 = load_rules_from_file(&path2);
let id_a_1 = rules1
.iter()
.find(|r| r.smirks == a)
.unwrap()
.template_id
.clone();
let id_a_2 = rules2
.iter()
.find(|r| r.smirks == a)
.unwrap()
.template_id
.clone();
assert_eq!(id_a_1, id_a_2, "template_id must not depend on line order");
std::fs::remove_file(&path1).ok();
std::fs::remove_file(&path2).ok();
}
#[test]
fn template_id_stable_when_count_changes() {
let dir = std::env::temp_dir();
let smirks = "[O:3]=[C:2]-[OH:1]>>C-[O:1]-[C:2]=[O:3]";
let path1 = write_templates_file(&dir, "renkin_tid_count1.smi", &format!("{smirks}\t1\n"));
let path2 = write_templates_file(
&dir,
"renkin_tid_count2.smi",
&format!("{smirks}\t999999\n"),
);
let id1 = load_rules_from_file(&path1)[0].template_id.clone();
let id2 = load_rules_from_file(&path2)[0].template_id.clone();
assert_eq!(id1, id2, "template_id must not depend on count");
std::fs::remove_file(&path1).ok();
std::fs::remove_file(&path2).ok();
}
#[test]
fn different_smirks_give_different_template_id() {
let dir = std::env::temp_dir();
let path = write_templates_file(
&dir,
"renkin_tid_distinct.smi",
"[O:3]=[C:2]-[OH:1]>>C-[O:1]-[C:2]=[O:3]\t1\n[NH2:1]-[c:2]>>O=[N+:1](-[O-])-[c:2]\t1\n",
);
let rules = load_rules_from_file(&path);
assert_eq!(rules.len(), 2);
assert_ne!(
rules[0].template_id, rules[1].template_id,
"different SMIRKS must produce different template_id"
);
std::fs::remove_file(&path).ok();
}
#[test]
fn extracted_template_id_uses_smirks_sha256_prefix() {
let dir = std::env::temp_dir();
let path = write_templates_file(
&dir,
"renkin_tid_prefix.smi",
"[O:3]=[C:2]-[OH:1]>>C-[O:1]-[C:2]=[O:3]\t1\n",
);
let rules = load_rules_from_file(&path);
assert!(rules[0].template_id.starts_with("smirks-sha256:"));
std::fs::remove_file(&path).ok();
}
#[test]
fn hand_crafted_rule_template_id_is_stable_rule_prefix() {
let rules = default_rules();
for rule in &rules {
assert_eq!(
rule.template_id,
format!("rule:{}", rule.name),
"hand-crafted rule {:?} must have template_id `rule:<name>`",
rule.name
);
}
let rules_again = default_rules();
for (r1, r2) in rules.iter().zip(rules_again.iter()) {
assert_eq!(r1.template_id, r2.template_id);
}
}
#[test]
fn default_rules_never_reduce_halobenzene_to_bare_benzene() {
let benzene_smi = canonical_smiles(&mol_from_smiles("c1ccccc1").unwrap());
let rules = default_rules();
for (name, smi) in [
("chlorobenzene", "Clc1ccccc1"),
("iodobenzene", "Ic1ccccc1"),
("fluorobenzene", "Fc1ccccc1"),
] {
let mol = mol_from_smiles(smi).unwrap();
for rule in &rules {
for set in apply_retro(&mol, rule) {
let is_bare_benzene = set.len() == 1
&& canonical_smiles(&mol_from_smiles(&set[0].smiles).unwrap())
== benzene_smi;
assert!(
!is_bare_benzene,
"{name}: rule '{}' must not reduce it to bare benzene with no tracked halogen precursor",
rule.name
);
}
}
}
}
#[test]
fn aryl_chloride_to_bromide_unaffected_by_halide_rule_removal() {
let rules = default_rules();
let rule = rules
.iter()
.find(|r| r.name == "aryl_chloride_to_bromide")
.expect("aryl_chloride_to_bromide must still be in default_rules()");
let mol = mol_from_smiles("Clc1ccccc1").unwrap();
let results = apply_retro(&mol, rule);
assert!(
!results.is_empty(),
"aryl_chloride_to_bromide must still fire on chlorobenzene"
);
let bromobenzene_smi = canonical_smiles(&mol_from_smiles("Brc1ccccc1").unwrap());
let flat: Vec<_> = results
.iter()
.flat_map(|s| s.iter().map(|p| p.smiles.as_str()))
.collect();
assert!(
flat.iter().any(|s| *s == bromobenzene_smi),
"products must include bromobenzene; 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.contains(&"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(canonical_smiles).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());
}
}