use std::collections::BinaryHeap;
use std::sync::Arc;
use anyhow::Result;
use chematic::chem::sa_score;
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Serialize;
use smallvec::{SmallVec, smallvec};
use crate::chem_env::{
ChemEnv, PrecursorMol, RetroRule, apply_retro, mol_from_smiles, to_canonical,
};
use crate::score::{step_cost, template_bonus};
type RetroEntry = (String, f64, Vec<String>);
type RetroCache = FxHashMap<String, Arc<Vec<RetroEntry>>>;
#[derive(Debug, Clone, Serialize)]
pub struct ReactionStep {
pub rule: String,
pub target: String,
pub precursors: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Route {
pub steps: Vec<ReactionStep>,
pub depth: u32,
}
#[derive(Debug, Clone)]
struct FEntry {
smiles: String,
}
#[derive(Debug, Clone)]
struct PathNode {
step: ReactionStep,
prev: Option<Arc<PathNode>>,
}
fn collect_path(mut cur: Option<&Arc<PathNode>>) -> Vec<ReactionStep> {
let mut steps = Vec::new();
while let Some(node) = cur {
steps.push(node.step.clone());
cur = node.prev.as_ref();
}
steps.reverse();
steps
}
#[derive(Debug, Clone)]
struct Node {
frontier: SmallVec<[FEntry; 6]>,
path: Option<Arc<PathNode>>,
depth: u32,
g: f64,
h: f64,
}
impl Node {
fn f(&self) -> f64 {
self.g + self.h
}
}
impl PartialEq for Node {
fn eq(&self, other: &Self) -> bool {
self.f().to_bits() == other.f().to_bits()
}
}
impl Eq for Node {}
impl PartialOrd for Node {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Node {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.f()
.partial_cmp(&self.f())
.unwrap_or(std::cmp::Ordering::Equal)
}
}
fn elem_mask_from_smiles(smiles: &str) -> u64 {
const TWO_CHAR: &[(&str, u64)] = &[
("Cl", 17),
("Br", 35),
("Si", 14),
("Se", 34),
("Te", 52),
("Sn", 50),
("Zn", 30),
("Pd", 46),
("Cu", 29),
("Fe", 26),
];
const ONE_CHAR: &[(char, u64)] = &[
('B', 5),
('C', 6),
('N', 7),
('O', 8),
('F', 9),
('P', 15),
('S', 16),
('I', 53),
];
let mut mask: u64 = 0;
for (sym, an) in TWO_CHAR {
if smiles.contains(*sym) {
mask |= 1u64 << an;
}
}
for (ch, an) in ONE_CHAR {
let lo = ch.to_ascii_lowercase();
if smiles.chars().any(|c| c == *ch || c == lo) {
mask |= 1u64 << an;
}
}
mask
}
fn state_key(frontier: &[FEntry]) -> String {
let mut keys: Vec<&str> = frontier.iter().map(|e| e.smiles.as_str()).collect();
keys.sort();
keys.join("|")
}
fn is_bb(smiles: &str, env: &ChemEnv) -> bool {
if env.is_building_block_smiles(smiles) {
return true;
}
mol_from_smiles(smiles)
.map(|mol| env.is_building_block(&mol))
.unwrap_or(false)
}
fn compute_h(frontier: &[FEntry], env: &ChemEnv, sa_cache: &mut FxHashMap<String, f64>) -> f64 {
frontier
.iter()
.filter(|e| !is_bb(&e.smiles, env))
.map(|e| {
let sa = if let Some(&v) = sa_cache.get(&e.smiles) {
v
} else {
let v = mol_from_smiles(&e.smiles)
.map(|m| sa_score(&m).clamp(1.0, 10.0))
.unwrap_or(5.5);
sa_cache.insert(e.smiles.clone(), v);
v
};
1.0 + 0.5 * (sa - 1.0) / 9.0
})
.sum()
}
fn beam_prune(heap: &mut BinaryHeap<Node>, beam_width: usize) {
if beam_width == 0 || heap.len() <= beam_width {
return;
}
let mut nodes: Vec<Node> = heap.drain().collect();
nodes.sort_by(|a, b| {
a.f()
.partial_cmp(&b.f())
.unwrap_or(std::cmp::Ordering::Equal)
});
nodes.truncate(beam_width);
*heap = nodes.into_iter().collect();
}
pub struct SearchConfig {
pub max_depth: u32,
pub max_routes: usize,
pub beam_width: usize,
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
pub nn_scorer: Option<std::sync::Arc<crate::scorer::nn::TemplateScorer>>,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
max_depth: 5,
max_routes: 5,
beam_width: 0,
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
nn_scorer: None,
}
}
}
pub fn find_routes(
target_smiles: &str,
env: &ChemEnv,
rules: &[RetroRule],
config: &SearchConfig,
) -> Result<Vec<Route>> {
let target_mol = mol_from_smiles(target_smiles)?;
let target_canonical = to_canonical(&target_mol);
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
let ranked_rules: Vec<&RetroRule> = {
if let Some(sc) = &config.nn_scorer {
sc.top_k_indices(target_smiles, rules.len())
.into_iter()
.filter_map(|i| rules.get(i))
.collect()
} else {
rules.iter().collect()
}
};
#[cfg(not(all(not(target_arch = "wasm32"), feature = "nn-scoring")))]
let ranked_rules: Vec<&RetroRule> = rules.iter().collect();
let max_rule_weight = rules.iter().map(|r| r.weight).fold(1.0_f64, f64::max);
let mut routes: Vec<Route> = Vec::new();
let mut closed: FxHashSet<String> = FxHashSet::default();
let mut heap: BinaryHeap<Node> = BinaryHeap::new();
let mut sa_cache: FxHashMap<String, f64> = FxHashMap::default();
let mut retro_cache: RetroCache = FxHashMap::default();
let initial: SmallVec<[FEntry; 6]> = smallvec![FEntry {
smiles: target_canonical,
}];
let h0 = compute_h(&initial, env, &mut sa_cache);
heap.push(Node {
frontier: initial,
path: None,
depth: 0,
g: 0.0,
h: h0,
});
while let Some(node) = heap.pop() {
if routes.len() >= config.max_routes {
break;
}
let mut n_unsolved = 0usize;
let mut first_unsolved: Option<&FEntry> = None;
for e in node.frontier.iter() {
if !is_bb(&e.smiles, env) {
n_unsolved += 1;
if first_unsolved.is_none() {
first_unsolved = Some(e);
}
}
}
if n_unsolved == 0 {
routes.push(Route {
steps: collect_path(node.path.as_ref()),
depth: node.depth,
});
}
if node.depth >= config.max_depth {
continue;
}
let key = state_key(&node.frontier);
if closed.contains(&key) {
continue;
}
closed.insert(key);
let Some(target_entry) = first_unsolved.or_else(|| node.frontier.first()) else {
continue;
};
let target_smi = target_entry.smiles.clone();
let Ok(target_mol) = mol_from_smiles(&target_smi) else {
continue;
};
let target_elem_mask: u64 = elem_mask_from_smiles(&target_smi);
let expansions: Arc<Vec<RetroEntry>> = if let Some(cached) = retro_cache.get(&target_smi) {
Arc::clone(cached) } else {
#[cfg(not(target_arch = "wasm32"))]
let raw: Vec<(String, f64, Vec<PrecursorMol>)> = ranked_rules
.par_iter()
.copied()
.filter(|rule| {
rule.required_elements == 0
|| (target_elem_mask & rule.required_elements == rule.required_elements)
})
.flat_map(|rule| {
apply_retro(&target_mol, rule)
.into_iter()
.map(|precs| (rule.name.to_string(), rule.weight, precs))
.collect::<Vec<_>>()
})
.collect();
#[cfg(target_arch = "wasm32")]
let raw: Vec<(String, f64, Vec<PrecursorMol>)> = ranked_rules
.iter()
.copied()
.filter(|rule| {
rule.required_elements == 0
|| (target_elem_mask & rule.required_elements == rule.required_elements)
})
.flat_map(|rule| {
apply_retro(&target_mol, rule)
.into_iter()
.map(|precs| (rule.name.to_string(), rule.weight, precs))
.collect::<Vec<_>>()
})
.collect();
let entries: Vec<(String, f64, Vec<String>)> = raw
.into_iter()
.filter(|(_, _, precs)| {
!precs.is_empty() && !precs.iter().any(|p| p.smiles == target_smi)
})
.map(|(rule_name, rule_weight, precs)| {
let step_c = step_cost(&precs.iter().map(|p| &p.mol).collect::<Vec<_>>())
- template_bonus(rule_weight, max_rule_weight);
let smiles_list: Vec<String> = precs.iter().map(|p| p.smiles.clone()).collect();
(rule_name, step_c, smiles_list)
})
.collect();
let arc = Arc::new(entries);
retro_cache.insert(target_smi.clone(), Arc::clone(&arc));
arc };
for (rule_name, step_c, precursor_smiles) in expansions.iter() {
let new_frontier: SmallVec<[FEntry; 6]> = node
.frontier
.iter()
.filter(|e| e.smiles != target_smi)
.cloned()
.chain(
precursor_smiles
.iter()
.map(|s| FEntry { smiles: s.clone() }),
)
.collect();
let new_h = compute_h(&new_frontier, env, &mut sa_cache);
let new_path = Some(Arc::new(PathNode {
step: ReactionStep {
rule: rule_name.clone(),
target: target_smi.clone(),
precursors: precursor_smiles.clone(),
},
prev: node.path.clone(),
}));
heap.push(Node {
frontier: new_frontier,
path: new_path,
depth: node.depth + 1,
g: node.g + step_c,
h: new_h,
});
}
beam_prune(&mut heap, config.beam_width);
}
Ok(routes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chem_env::{ChemEnv, default_rules};
fn aspirin_env() -> ChemEnv {
ChemEnv::load("data/building_blocks.smi").unwrap_or_else(|_| {
ChemEnv::in_memory(&["CC(=O)O", "Oc1ccccc1C(=O)O", "c1ccccc1C(=O)O", "C", "O"])
})
}
fn cfg(depth: u32) -> SearchConfig {
SearchConfig {
max_depth: depth,
max_routes: 5,
beam_width: 0,
..Default::default()
}
}
#[test]
fn aspirin_finds_route_depth1() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert!(
!routes.is_empty(),
"must find at least one route for aspirin"
);
assert!(
routes.iter().any(|r| r.depth <= 2),
"must find a route with depth ≤ 2"
);
}
#[test]
fn building_block_target_returns_depth0() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)O", &env, &rules, &cfg(2)).unwrap();
assert!(
routes.iter().any(|r| r.depth == 0),
"building block must return depth-0 route"
);
}
#[test]
fn anthranilic_acid_recognized_as_bb() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("c1ccc(N)cc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert!(
routes.iter().any(|r| r.depth == 0),
"anthranilic acid is in building blocks"
);
}
#[test]
fn beam_width_limits_does_not_panic() {
let env = aspirin_env();
let rules = default_rules();
let cfg_beam = SearchConfig {
max_depth: 3,
max_routes: 3,
beam_width: 10,
..Default::default()
};
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam);
assert!(routes.is_ok());
}
#[test]
fn no_routes_for_unknown_target_within_depth() {
let env = ChemEnv::in_memory(&["O"]); let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(1)).unwrap();
let _ = routes;
}
#[test]
fn invalid_smiles_returns_err() {
let env = aspirin_env();
let rules = default_rules();
let result = find_routes("[C(", &env, &rules, &cfg(3));
assert!(result.is_err(), "invalid SMILES must return Err");
}
#[test]
fn max_depth_one_caps_all_routes() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(1)).unwrap();
for r in &routes {
assert!(
r.depth <= 1,
"route with depth {} exceeds max_depth=1",
r.depth
);
}
}
#[test]
fn beam_width_one_does_not_exceed_unrestricted() {
let env = aspirin_env();
let rules = default_rules();
let cfg_beam = SearchConfig {
max_depth: 3,
max_routes: 10,
beam_width: 1,
..Default::default()
};
let cfg_full = SearchConfig {
max_depth: 3,
max_routes: 10,
beam_width: 0,
..Default::default()
};
let routes_beam = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
let routes_full = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_full).unwrap();
assert!(
routes_beam.len() <= routes_full.len(),
"beam=1 ({}) should find ≤ routes than beam=0 ({})",
routes_beam.len(),
routes_full.len()
);
}
#[test]
fn route_steps_are_populated() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
let non_zero: Vec<_> = routes.iter().filter(|r| r.depth > 0).collect();
assert!(
!non_zero.is_empty(),
"must find at least one multi-step route"
);
for r in non_zero {
assert!(
!r.steps.is_empty(),
"route with depth>0 must have non-empty steps"
);
for step in &r.steps {
assert!(!step.rule.is_empty(), "step.rule must be non-empty");
assert!(!step.target.is_empty(), "step.target must be non-empty");
assert!(
!step.precursors.is_empty(),
"step.precursors must be non-empty"
);
}
}
}
#[test]
fn symmetric_biaryl_routes_deduplicated() {
let env = ChemEnv::in_memory(&["Brc1ccccc1", "c1ccccc1"]);
let rules = default_rules();
let cfg = SearchConfig {
max_depth: 2,
max_routes: 10,
beam_width: 0,
..Default::default()
};
let routes = find_routes("c1ccc(-c2ccccc2)cc1", &env, &rules, &cfg).unwrap();
assert_eq!(
routes.len(),
1,
"symmetric biphenyl should produce exactly 1 deduplicated route; got {}",
routes.len()
);
}
}