use std::collections::{HashMap, HashSet};
use std::fs;
use anyhow::{Context, Result};
use chematic::chem::standardize::{StandardizeOptions, ZwitterionHandling, standardize};
use chematic::chem::aromatic_ring_count;
use chematic::core::{Atom, AtomIdx, 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: &'static str,
pub smirks: &'static str,
}
struct BbEntry {
query: QueryMolecule,
}
pub struct ChemEnv {
building_blocks: HashMap<(usize, usize), Vec<BbEntry>>,
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}"))?;
Ok(Self::from_entries(Self::parse_smi_content(&content)))
}
pub fn in_memory(smiles_list: &[&str]) -> Self {
let entries: Vec<_> = smiles_list
.iter()
.filter_map(|s| Self::smiles_to_entry(s))
.collect();
Self::from_entries(entries)
}
fn from_entries(entries: Vec<(usize, usize, BbEntry)>) -> Self {
let bb_count = entries.len();
let mut building_blocks: HashMap<(usize, usize), Vec<BbEntry>> = HashMap::new();
for (n_atoms, n_bonds, entry) in entries {
building_blocks.entry((n_atoms, n_bonds)).or_default().push(entry);
}
Self { building_blocks, bb_count }
}
fn parse_smi_content(content: &str) -> Vec<(usize, usize, BbEntry)> {
content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|line| {
let smiles = line.split_whitespace().next()?;
Self::smiles_to_entry(smiles)
})
.collect()
}
fn smiles_to_entry(smiles: &str) -> Option<(usize, usize, BbEntry)> {
let mol = parse(smiles).ok()?;
let query = parse_smarts(smiles).ok()?;
Some((mol.atom_count(), mol.bonds().count(), BbEntry { query }))
}
pub fn bb_count(&self) -> usize {
self.bb_count
}
pub fn is_building_block(&self, mol: &Molecule) -> bool {
let key = (mol.atom_count(), mol.bonds().count());
let Some(candidates) = self.building_blocks.get(&key) else { return false; };
let n_atoms = mol.atom_count();
candidates.iter().any(|bb| {
let matches = find_matches(&bb.query, mol);
matches.iter().any(|m| m.len() == n_atoms)
})
}
}
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 = HashSet::new();
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) -> HashSet<AtomIdx> {
let mut visited = HashSet::new();
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: &HashSet<AtomIdx>) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: HashMap<AtomIdx, AtomIdx> = HashMap::new();
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: &HashSet<AtomIdx>,
cut_atom: AtomIdx,
) -> Option<Molecule> {
let mut builder = MoleculeBuilder::new();
let mut idx_map: HashMap<AtomIdx, AtomIdx> = HashMap::new();
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 biaryl_cleavage(mol: &Molecule) -> Vec<Vec<PrecursorMol>> {
let mut results: Vec<Vec<PrecursorMol>> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
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
}
pub fn apply_retro(mol: &Molecule, rule: &RetroRule) -> Vec<Vec<PrecursorMol>> {
if rule.smirks.is_empty() {
return match rule.name {
"suzuki_retro" => biaryl_cleavage(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 has_aromatic = canonical_smiles(&std_mol)
.chars()
.any(|c| matches!(c, 'c' | 'n' | 'o' | 's' | 'p'));
if has_aromatic && aromatic_ring_count(&std_mol) == 0 {
return None;
}
let smi = to_canonical(&std_mol);
let final_mol = parse(&smi).ok()?;
Some(PrecursorMol { smiles: smi, mol: final_mol })
})
.collect()
}
pub fn default_rules() -> Vec<RetroRule> {
vec[O:3]>>[C:1](=[O:2])O.[O:3]",
},
RetroRule {
name: "amide_cleavage",
smirks: "[C:1](=[O:2])[N:3]>>[C:1](=[O:2])O.[N:3]",
},
RetroRule {
name: "friedel_crafts_acylation_retro",
smirks: "[c:1][C:2](=[O:3])>>[c:1].[C:2](=[O:3])Cl",
},
RetroRule {
name: "aryl_carboxylation_retro",
smirks: "[c:1][C:2](=O)O>>[c:1].[C:2](=O)O",
},
RetroRule {
name: "aryl_amine_retro",
smirks: "[c:1][N:2]>>[c:1].[N:2]",
},
RetroRule {
name: "buchwald_hartwig_retro",
smirks: "[c:1][N:2]>>[c:1]Br.[N:2]",
},
RetroRule {
name: "aryl_ether_retro",
smirks: "[c:1][O:2]>>[c:1]O.[O:2]",
},
RetroRule {
name: "suzuki_retro",
smirks: "",
},
RetroRule {
name: "cc_single_cleavage",
smirks: "[C:1][C:2]>>[C:1].[C:2]",
},
RetroRule {
name: "wittig_retro",
smirks: "[C:1]=[C:2]>>[C:1]=O.[C:2]=O",
},
RetroRule {
name: "reductive_amination_retro",
smirks: "[C:1][N:2]>>[C:1]=O.[N:2]",
},
RetroRule {
name: "cn_aliphatic_cleavage",
smirks: "[C:1][N:2]>>[C:1].[N:2]",
},
RetroRule {
name: "co_aliphatic_cleavage",
smirks: "[C:1][O:2]>>[C:1].[O:2]",
},
RetroRule {
name: "alcohol_oxidation_retro",
smirks: "[C:1][OH:2]>>[C:1]=O",
},
]
}
#[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 = RetroRule { name: "ester_cleavage", smirks: "[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() {
let mol = mol_from_smiles("c1ccc(N)cc1C(=O)O").unwrap();
let rule = RetroRule { name: "aryl_carboxylation_retro", smirks: "[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 degenerate_route_not_in_precursors() {
let mol = mol_from_smiles("c1ccc(N)cc1C(=O)O").unwrap();
let rule = RetroRule { name: "aryl_carboxylation_retro", smirks: "[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 = RetroRule { name: "suzuki_retro", smirks: "" };
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 };
let routes = find_routes("c1ccc(-c2ccccc2)cc1", &env, &rules, &cfg).unwrap();
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 };
let routes = find_routes("Fc1ccc(-c2ccccc2)cc1", &env, &rules, &cfg).unwrap();
assert!(!routes.is_empty(), "4-fluorobiphenyl must be solvable");
}
#[test]
fn wittig_retro_cleaves_alkene() {
let mol = mol_from_smiles("C=C").unwrap(); let rule = RetroRule { name: "wittig_retro", smirks: "[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:?}"
);
}
}