use std::collections::BinaryHeap;
use std::sync::Arc;
use anyhow::Result;
use chematic::chem::{molecular_weight, sa_score};
use rustc_hash::{FxHashMap, FxHashSet, FxHasher};
use serde::Serialize;
use smallvec::{SmallVec, smallvec};
use crate::chem_env::{
ChemEnv, RetroRule, TemplateBondIndex, canonical_stock_identity_from_smiles, mol_from_smiles,
to_canonical,
};
use crate::evidence::{EvidenceScope, MetadataSource, StepEvidence, TemplateMetadataEntry};
use crate::score::{step_cost, template_bonus};
use crate::spectator_bond::SpectatorBondPolicy;
use crate::synthesizability::{ElementAccountingStatus, compute_element_accounting};
struct RetroEntry {
rule_name: String,
template_id: String,
step_cost: f64,
precursor_smiles: Vec<String>,
}
type RetroCache = FxHashMap<String, Arc<Vec<RetroEntry>>>;
#[derive(Debug, Clone, Serialize)]
pub struct ReactionConditions {
#[serde(skip_serializing_if = "Option::is_none")]
pub catalyst: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub solvent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AtomEconomyStatus {
Normal,
AboveExpectedRange,
NotEvaluable,
}
fn compute_atom_economy_raw(target_smiles: &str, precursors: &[String]) -> Option<f64> {
let target_weight = mol_from_smiles(target_smiles)
.ok()
.map(|m| molecular_weight(&m))?;
let precursor_weights: Vec<f64> = precursors
.iter()
.map(|s| mol_from_smiles(s).ok().map(|m| molecular_weight(&m)))
.collect::<Option<Vec<f64>>>()?;
let precursor_weight: f64 = precursor_weights.iter().sum();
if !target_weight.is_finite()
|| !precursor_weight.is_finite()
|| target_weight < 0.0
|| precursor_weight <= 0.0
{
return None;
}
let ratio = target_weight / precursor_weight * 100.0;
ratio.is_finite().then_some(ratio)
}
fn classify_atom_economy(raw: Option<f64>) -> (AtomEconomyStatus, Option<f64>) {
let status = match raw {
Some(r) if r.is_finite() && r > 100.0 + 1e-6 => AtomEconomyStatus::AboveExpectedRange,
Some(r) if r.is_finite() => AtomEconomyStatus::Normal,
_ => AtomEconomyStatus::NotEvaluable,
};
let display = match status {
AtomEconomyStatus::Normal => raw,
AtomEconomyStatus::AboveExpectedRange | AtomEconomyStatus::NotEvaluable => None,
};
(status, display)
}
#[derive(Debug, Clone, Serialize)]
pub struct ReactionStep {
pub rule: String,
pub template_id: String,
pub target: String,
pub precursors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditions: Option<ReactionConditions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub atom_economy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub atom_economy_raw_percent: Option<f64>,
pub atom_economy_status: AtomEconomyStatus,
pub step_confidence: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub procedure_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reaction_family: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_source: Option<MetadataSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_scope: Option<EvidenceScope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<StepEvidence>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Route {
pub steps: Vec<ReactionStep>,
pub depth: u32,
pub score: f64,
pub building_blocks: Vec<String>,
pub confidence: f64,
pub convergency: f64,
pub success_probability: f64,
pub route_cost: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RouteIntegrityDefect {
RootMismatch,
UnparseableSmiles,
EmptyPrecursorList,
Cycle,
Disconnected,
UnaccountedTargetElement,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct RouteIntegrityDiagnostics {
pub root_mismatch: u64,
pub unparseable_smiles: u64,
pub empty_precursor_list: u64,
pub cycle: u64,
pub disconnected: u64,
pub unaccounted_target_element: u64,
pub routes_rejected: u64,
}
impl RouteIntegrityDiagnostics {
fn record(&mut self, defects: &[RouteIntegrityDefect]) {
if defects.is_empty() {
return;
}
self.routes_rejected += 1;
for defect in defects {
match defect {
RouteIntegrityDefect::RootMismatch => self.root_mismatch += 1,
RouteIntegrityDefect::UnparseableSmiles => self.unparseable_smiles += 1,
RouteIntegrityDefect::EmptyPrecursorList => self.empty_precursor_list += 1,
RouteIntegrityDefect::Cycle => self.cycle += 1,
RouteIntegrityDefect::Disconnected => self.disconnected += 1,
RouteIntegrityDefect::UnaccountedTargetElement => {
self.unaccounted_target_element += 1
}
}
}
}
}
fn walk_route_tree<'a>(
step_map: &FxHashMap<&'a str, &'a [String]>,
node: &'a str,
visited: &mut FxHashSet<&'a str>,
on_path: &mut FxHashSet<&'a str>,
has_cycle: &mut bool,
) {
if on_path.contains(node) {
*has_cycle = true;
return;
}
if !visited.insert(node) {
return;
}
on_path.insert(node);
if let Some(precursors) = step_map.get(node) {
for p in *precursors {
walk_route_tree(step_map, p.as_str(), visited, on_path, has_cycle);
}
}
on_path.remove(node);
}
fn route_integrity_defects(route: &Route, target_canonical: &str) -> Vec<RouteIntegrityDefect> {
let mut defects = Vec::new();
if route.steps.is_empty() {
return defects;
}
match mol_from_smiles(&route.steps[0].target) {
Ok(m) if to_canonical(&m) == target_canonical => {}
_ => defects.push(RouteIntegrityDefect::RootMismatch),
}
let mut any_unparseable = false;
for step in &route.steps {
if mol_from_smiles(&step.target).is_err() {
any_unparseable = true;
}
if step.precursors.is_empty() {
defects.push(RouteIntegrityDefect::EmptyPrecursorList);
}
for p in &step.precursors {
if mol_from_smiles(p).is_err() {
any_unparseable = true;
}
}
}
if any_unparseable {
defects.push(RouteIntegrityDefect::UnparseableSmiles);
}
let step_map: FxHashMap<&str, &[String]> = route
.steps
.iter()
.map(|s| (s.target.as_str(), s.precursors.as_slice()))
.collect();
let mut visited: FxHashSet<&str> = FxHashSet::default();
let mut on_path: FxHashSet<&str> = FxHashSet::default();
let mut has_cycle = false;
walk_route_tree(
&step_map,
route.steps[0].target.as_str(),
&mut visited,
&mut on_path,
&mut has_cycle,
);
if has_cycle {
defects.push(RouteIntegrityDefect::Cycle);
}
if step_map.keys().any(|target| !visited.contains(target)) {
defects.push(RouteIntegrityDefect::Disconnected);
}
if compute_element_accounting(route).status == ElementAccountingStatus::UnaccountedTargetElement
{
defects.push(RouteIntegrityDefect::UnaccountedTargetElement);
}
defects
}
#[derive(Debug, Default, Serialize)]
pub struct SearchStats {
pub nodes_expanded: u64,
pub max_depth_reached: bool,
pub beam_limit_hit: bool,
pub matched_templates: u64,
pub stock_hits: u64,
pub retro_cache_hits: u64,
pub retro_cache_misses: u64,
pub ring_context_diagnostics: crate::ring_context::RingContextDiagnostics,
pub route_integrity: RouteIntegrityDiagnostics,
pub crowd_out: CrowdOutDiagnostics,
pub reranker_failures: u64,
}
pub fn diagnose(stats: &SearchStats, max_depth: u32) -> (Vec<&'static str>, Vec<String>) {
let mut causes: Vec<&'static str> = Vec::new();
let mut suggestions: Vec<String> = Vec::new();
if stats.stock_hits == 0 {
causes.push("no matching building block in stock");
suggestions.push("add a custom stock file with --building-blocks".to_string());
}
if stats.max_depth_reached {
causes.push("search depth exhausted");
suggestions.push(format!("try --depth {}", max_depth + 2));
}
if stats.beam_limit_hit {
causes.push("beam width too narrow — candidates were pruned");
suggestions.push("try --beam-width 200".to_string());
}
if stats.matched_templates < 5 {
causes.push("few or no templates matched the target");
suggestions.push("try --templates data/templates_extracted_50000.smi".to_string());
}
if stats.route_integrity.routes_rejected > 0 {
causes.push(
"completed candidate route(s) failed the structural-integrity check and were discarded",
);
suggestions.push(format!(
"{} candidate route(s) were rejected (unaccounted_target_element={}, cycle={}, disconnected={}, unparseable_smiles={}, empty_precursor_list={}, root_mismatch={})",
stats.route_integrity.routes_rejected,
stats.route_integrity.unaccounted_target_element,
stats.route_integrity.cycle,
stats.route_integrity.disconnected,
stats.route_integrity.unparseable_smiles,
stats.route_integrity.empty_precursor_list,
stats.route_integrity.root_mismatch,
));
}
(causes, suggestions)
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CrowdOutDiagnostics {
pub beam_prune_invocations: u64,
pub candidates_evicted_total: u64,
pub evicted_f_min: Option<f64>,
pub evicted_f_max: Option<f64>,
pub final_beam_boundary_f: Option<f64>,
pub rules_attempted_total: u64,
pub retro_expansion_wall_time_us: u64,
pub spectator_bond_loss_findings: Vec<crate::spectator_bond::SpectatorBondLossFinding>,
pub spectator_bond_gated_out: Vec<crate::spectator_bond::GatedCandidateRecord>,
pub cross_template_duplicate_precursor_signatures: u64,
pub stock_terminal_candidates: u64,
pub non_stock_candidates: u64,
pub branching_by_depth: std::collections::BTreeMap<u32, DepthBranching>,
pub candidates_generated_before_dedup: u64,
pub candidates_after_same_template_dedup: u64,
pub candidates_after_cross_template_dedup: u64,
pub candidate_trace: Vec<CandidateTraceRecord>,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct DepthBranching {
pub nodes_expanded: u64,
pub children_produced: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateProvenance {
Handcrafted,
FileBacked,
HashAtom,
}
#[derive(Debug, Clone, Serialize)]
pub struct CandidateTraceRecord {
pub depth: u32,
pub parent_smiles: String,
pub template_id: String,
pub rule_name: String,
pub provenance: CandidateProvenance,
pub precursor_signature: Vec<String>,
pub f_score: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub rank_before_prune: Option<usize>,
pub survived_beam: bool,
pub later_reached_stock: bool,
}
fn extract_building_blocks(steps: &[ReactionStep]) -> Vec<String> {
let targets: std::collections::HashSet<&str> =
steps.iter().map(|s| s.target.as_str()).collect();
let mut bbs: Vec<String> = steps
.iter()
.flat_map(|s| s.precursors.iter())
.filter(|p| !targets.contains(p.as_str()))
.cloned()
.collect();
bbs.sort_unstable();
bbs.dedup();
bbs
}
#[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,
trace_id: Option<u64>,
}
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)
}
}
pub(crate) 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_hash(frontier: &[FEntry]) -> u64 {
use std::hash::{Hash, Hasher};
let mut keys: Vec<&str> = frontier.iter().map(|e| e.smiles.as_str()).collect();
keys.sort_unstable();
let mut h = FxHasher::default();
for k in &keys {
k.hash(&mut h);
}
h.finish()
}
fn is_bb(smiles: &str, env: &ChemEnv) -> bool {
if env.is_building_block_smiles(smiles) {
return true;
}
canonical_stock_identity_from_smiles(smiles)
.map(|canon| env.is_building_block_smiles(&canon))
.unwrap_or(false)
}
pub trait MoleculeValueEstimator: Send + Sync {
fn estimate_cost(&self, smiles: &str) -> f64;
}
pub struct SaScoreEstimator;
impl MoleculeValueEstimator for SaScoreEstimator {
fn estimate_cost(&self, smiles: &str) -> f64 {
let v = mol_from_smiles(smiles)
.map(|m| sa_score(&m).clamp(1.0, 10.0))
.unwrap_or(5.5);
1.0 + 0.5 * (v - 1.0) / 9.0
}
}
pub trait ReactionPrior: Send + Sync {
fn prior(&self, template_name: &str, target_smiles: &str) -> f64;
}
pub struct FrequencyPrior {
pub rule_weights: std::collections::HashMap<String, f64>,
pub max_weight: f64,
}
impl FrequencyPrior {
pub fn from_rules(rules: &[RetroRule]) -> Self {
let max_weight = rules.iter().map(|r| r.weight).fold(1.0_f64, f64::max);
let rule_weights = rules.iter().map(|r| (r.name.clone(), r.weight)).collect();
Self {
rule_weights,
max_weight,
}
}
}
impl ReactionPrior for FrequencyPrior {
fn prior(&self, template_name: &str, _target_smiles: &str) -> f64 {
let w = self.rule_weights.get(template_name).copied().unwrap_or(1.0);
template_bonus(w, self.max_weight)
}
}
fn compute_h(
frontier: &[FEntry],
env: &ChemEnv,
sa_cache: &mut FxHashMap<String, f64>,
estimator: Option<&std::sync::Arc<dyn MoleculeValueEstimator>>,
) -> f64 {
frontier
.iter()
.filter(|e| !is_bb(&e.smiles, env))
.map(|e| {
if let Some(est) = estimator {
return est.estimate_cost(&e.smiles);
}
if let Some(&v) = sa_cache.get(&e.smiles) {
return 1.0 + 0.5 * (v - 1.0) / 9.0;
}
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);
1.0 + 0.5 * (v - 1.0) / 9.0
})
.sum()
}
fn reaction_family_for_rule(rule: &str) -> Option<&'static str> {
match rule {
"ester_cleavage" => Some("esterification"),
"amide_cleavage" => Some("amide_coupling"),
"friedel_crafts_acylation_retro" => Some("friedel_crafts_acylation"),
"aryl_carboxylation_retro" => Some("decarboxylation"),
"aryl_ether_retro" => Some("ullmann_ether"),
"aryl_chloride_to_bromide" => Some("halogen_exchange"),
"suzuki_retro" => Some("suzuki_coupling"),
"heck_retro" | "heck_retro_terminal" => Some("heck_reaction"),
"negishi_retro" => Some("negishi_coupling"),
"wittig_retro" => Some("wittig_reaction"),
"reductive_amination_retro" => Some("reductive_amination"),
"sonogashira_retro" => Some("sonogashira_coupling"),
"sulfonamide_retro" => Some("sulfonamide_formation"),
"diaryl_sulfone_retro" => Some("friedel_crafts_sulfonylation"),
"boc_deprotection_retro" => Some("boc_deprotection"),
"cbz_deprotection_retro" => Some("cbz_deprotection"),
"n_benzylation_retro" => Some("n_benzylation"),
"grignard_addition_retro" => Some("grignard_addition"),
"claisen_retro" => Some("claisen_condensation"),
"michael_retro" => Some("michael_addition"),
"acyl_chloride_from_acid" => Some("acyl_chloride_formation"),
"alcohol_oxidation_retro" => Some("carbonyl_reduction"),
_ => None,
}
}
pub(crate) fn is_extracted_template(rule: &str) -> bool {
rule.starts_with("extracted_")
}
fn conditions_for_rule(rule: &str) -> Option<ReactionConditions> {
macro_rules! cond {
($cat:expr, $sol:expr, $tmp:expr) => {
Some(ReactionConditions {
catalyst: Some($cat.into()),
solvent: Some($sol.into()),
temperature: Some($tmp.into()),
notes: None,
})
};
($cat:expr, $sol:expr, $tmp:expr, $note:expr) => {
Some(ReactionConditions {
catalyst: Some($cat.into()),
solvent: Some($sol.into()),
temperature: Some($tmp.into()),
notes: Some($note.into()),
})
};
}
match rule {
"ester_cleavage" => cond!("NaOH or LiOH (2 eq)", "THF/H₂O (2:1)", "rt → 60 °C"),
"amide_cleavage" => cond!("LiOH (3 eq)", "THF/H₂O (3:1)", "60 °C"),
"friedel_crafts_acylation_retro" => cond!("AlCl₃ (1.2 eq)", "DCM", "0 °C → rt"),
"aryl_carboxylation_retro" => {
cond!("none", "water", "150 °C", "Kolbe-Schmitt / decarboxylation")
}
"aryl_ether_retro" => cond!("Cs₂CO₃ (2 eq)", "DMF", "110 °C", "Ullmann ether retro"),
"aryl_chloride_to_bromide" => cond!("NaBr (excess)", "DMF", "120 °C", "halogen exchange"),
"suzuki_retro" => cond!("Pd(PPh₃)₄ (5 mol%)", "EtOH/H₂O (3:1)", "80 °C"),
"heck_retro" => cond!("Pd(OAc)₂ / PPh₃ (5 mol%)", "DMF", "100 °C"),
"heck_retro_terminal" => cond!("Pd(OAc)₂ / PPh₃ (5 mol%)", "DMF", "100 °C"),
"negishi_retro" => cond!("Pd(PPh₃)₄ / ZnCl₂", "THF", "65 °C"),
"cc_single_cleavage" => None, "wittig_retro" => cond!("Ph₃P (1.2 eq)", "toluene", "0 °C → rt"),
"reductive_amination_retro" => cond!("NaBH₃CN (1.5 eq)", "MeOH", "rt"),
"cn_aliphatic_cleavage" => None,
"co_aliphatic_cleavage" => None,
"alcohol_oxidation_retro" => {
cond!("NaBH₄ (1.2 eq)", "EtOH", "0 °C → rt", "retro = reduction")
}
"sonogashira_retro" => cond!("Pd(PPh₃)₂Cl₂ / CuI (5 mol%)", "Et₃N", "60 °C"),
"sulfonamide_retro" => cond!("Et₃N (2 eq)", "DCM", "0 °C → rt"),
"diaryl_sulfone_retro" => cond!(
"AlCl₃ (1.2 eq)",
"DCM",
"0 °C → rt",
"Friedel-Crafts sulfonylation"
),
"boc_deprotection_retro" => cond!("TFA (20 % in DCM)", "DCM", "rt"),
"n_benzylation_retro" => cond!("K₂CO₃ (2 eq)", "DMF", "60 °C"),
"grignard_addition_retro" => cond!("Mg (1.1 eq)", "THF (dry)", "0 °C → rt"),
"claisen_retro" => cond!("LDA (2.0 eq)", "THF (dry)", "−78 °C"),
"michael_retro" => cond!("DBU or K₂CO₃ (1.2 eq)", "THF", "rt"),
"acyl_chloride_from_acid" => cond!("(COCl)₂ (1.2 eq) + cat. DMF", "DCM", "0 °C → rt"),
"cbz_deprotection_retro" => cond!("H₂ (1 atm), Pd/C (10 %)", "EtOH", "rt"),
_ => None,
}
}
fn procedure_hint_for_rule(rule: &str) -> Option<&'static str> {
match rule {
"ester_cleavage" => {
Some("Dissolve in THF/H₂O, add NaOH (2 eq), stir at 60 °C, acidify to pH 2.")
}
"amide_cleavage" => Some("Reflux in 6M HCl or add LiOH (3 eq) in THF/H₂O at 60 °C."),
"friedel_crafts_acylation_retro" => {
Some("Add acid chloride to arene + AlCl₃ (1.2 eq) in DCM at 0 °C, warm to rt.")
}
"aryl_ether_retro" => {
Some("Mix aryl halide + phenol + Cs₂CO₃ (2 eq) in DMF, heat at 110 °C.")
}
"suzuki_retro" => {
Some("Combine aryl boronate + aryl halide + Pd(PPh₃)₄ in EtOH/H₂O, reflux at 80 °C.")
}
"heck_retro" | "heck_retro_terminal" => {
Some("Add alkene + aryl halide + Pd(OAc)₂/PPh₃ in DMF with Et₃N at 100 °C.")
}
"wittig_retro" => {
Some("Add aldehyde to Ph₃P=CHR (Wittig ylide) in toluene at 0 °C, warm to rt.")
}
"reductive_amination_retro" => {
Some("Mix aldehyde + amine in MeOH, add NaBH₃CN (1.5 eq), stir at rt.")
}
"sonogashira_retro" => {
Some("Combine terminal alkyne + aryl halide + Pd/CuI in Et₃N at 60 °C.")
}
"sulfonamide_retro" => Some("Add sulfonyl chloride to amine + Et₃N (2 eq) in DCM at 0 °C."),
"boc_deprotection_retro" => {
Some("Treat with TFA (20% in DCM) at rt for 1 h, then evaporate.")
}
"cbz_deprotection_retro" => Some("Hydrogenate (H₂, 1 atm) over Pd/C (10%) in EtOH at rt."),
"grignard_addition_retro" => {
Some("Add carbonyl to Grignard reagent in dry THF at 0 °C, then rt; quench with NH₄Cl.")
}
"acyl_chloride_from_acid" => {
Some("Add oxalyl chloride (1.2 eq) + cat. DMF to carboxylic acid in DCM at 0 °C.")
}
"alcohol_oxidation_retro" => {
Some("Reduce ketone/aldehyde with NaBH₄ (1.2 eq) in EtOH at 0 °C → rt.")
}
"claisen_retro" => Some(
"Deprotonate ester α-position with LDA (2 eq) in dry THF at −78 °C, add electrophile.",
),
"michael_retro" => {
Some("Combine Michael donor + acceptor + K₂CO₃ or DBU (1.2 eq) in THF at rt.")
}
"n_benzylation_retro" => {
Some("React amine + benzyl halide + K₂CO₃ (2 eq) in DMF at 60 °C.")
}
_ => None,
}
}
fn convergency_score(steps: &[ReactionStep]) -> f64 {
if steps.is_empty() {
return 1.0;
}
let mut depth_map: rustc_hash::FxHashMap<&str, u32> = rustc_hash::FxHashMap::default();
if let Some(first) = steps.first() {
depth_map.insert(first.target.as_str(), 0);
}
for step in steps {
let d = depth_map.get(step.target.as_str()).copied().unwrap_or(0);
for prec in &step.precursors {
depth_map.entry(prec.as_str()).or_insert(d + 1);
}
}
let targets: rustc_hash::FxHashSet<&str> = steps.iter().map(|s| s.target.as_str()).collect();
let leaf_depths: Vec<u32> = depth_map
.iter()
.filter(|(k, _)| !targets.contains(*k))
.map(|(_, &v)| v)
.collect();
if leaf_depths.len() <= 1 {
return 1.0;
}
let max = leaf_depths.iter().copied().max().unwrap_or(0) as f64;
let min = leaf_depths.iter().copied().min().unwrap_or(0) as f64;
if max == 0.0 {
1.0
} else {
1.0 - (max - min) / max
}
}
fn compute_route_cost(
route: &Route,
prices: Option<&std::collections::HashMap<String, f64>>,
) -> f64 {
use chematic::chem::sa_score;
let bb_cost: f64 = route
.building_blocks
.iter()
.map(|smiles| {
if let Some(map) = prices
&& let Some(&p) = map.get(smiles.as_str())
{
return p;
}
mol_from_smiles(smiles)
.ok()
.map(|m| sa_score(&m))
.unwrap_or(5.0)
})
.sum();
bb_cost + route.steps.len() as f64 * 0.5
}
type BeamEvictionStats = (usize, f64, f64, f64);
type TraceRank = (u64, usize, bool);
fn beam_prune(
heap: &mut BinaryHeap<Node>,
beam_width: usize,
) -> (Option<BeamEvictionStats>, Vec<TraceRank>) {
if beam_width == 0 || heap.len() <= beam_width {
return (None, Vec::new());
}
let mut nodes: Vec<Node> = heap.drain().collect();
nodes.sort_unstable_by(|a, b| {
a.f()
.partial_cmp(&b.f())
.unwrap_or(std::cmp::Ordering::Equal)
});
let trace_ranks: Vec<(u64, usize, bool)> = nodes
.iter()
.enumerate()
.filter_map(|(rank, n)| n.trace_id.map(|id| (id, rank, rank < beam_width)))
.collect();
let evicted = &nodes[beam_width..];
let evicted_f_min = evicted.iter().map(Node::f).fold(f64::INFINITY, f64::min);
let evicted_f_max = evicted
.iter()
.map(Node::f)
.fold(f64::NEG_INFINITY, f64::max);
let boundary_f = nodes[beam_width - 1].f();
let evicted_count = evicted.len();
nodes.truncate(beam_width);
*heap = nodes.into_iter().collect();
(
Some((evicted_count, evicted_f_min, evicted_f_max, boundary_f)),
trace_ranks,
)
}
fn dedup_counts(entries: &[RetroEntry]) -> (u64, u64, u64) {
let mut cross_template_duplicates = 0u64;
let mut seen_same_template: FxHashSet<(&str, Vec<String>)> = FxHashSet::default();
let mut seen_cross_template: FxHashMap<Vec<String>, &str> = FxHashMap::default();
for e in entries {
let mut sig = e.precursor_smiles.clone();
sig.sort_unstable();
seen_same_template.insert((e.template_id.as_str(), sig.clone()));
match seen_cross_template.get(sig.as_slice()) {
Some(&prev_template) if prev_template != e.template_id => {
cross_template_duplicates += 1;
}
Some(_) => {}
None => {
seen_cross_template.insert(sig, e.template_id.as_str());
}
}
}
(
cross_template_duplicates,
seen_same_template.len() as u64,
seen_cross_template.len() as u64,
)
}
fn reranker_rank_bonuses(
reranker: &dyn crate::candidate::CandidateReranker,
target_smi: &str,
target_mol: &crate::chem_env::Molecule,
raw_proposals: &[crate::candidate::RawCandidate],
templates_by_id: &std::collections::HashMap<String, &RetroRule>,
) -> anyhow::Result<FxHashMap<String, f64>> {
let mut candidates = crate::candidate::merge_into_candidates(target_smi, raw_proposals)?;
for c in candidates.iter_mut() {
c.features = crate::candidate::extract_features(c, target_mol, templates_by_id, None);
}
reranker.score_pool(target_smi, &mut candidates)?;
candidates.sort_by(|a, b| {
b.reranker_score
.partial_cmp(&a.reranker_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.candidate_id.cmp(&b.candidate_id))
});
let n = candidates.len();
Ok(candidates
.into_iter()
.enumerate()
.map(|(rank, c)| (c.candidate_id, crate::score::rank_bonus(rank, n)))
.collect())
}
fn classify_provenance(template_id: &str, smirks: &str) -> CandidateProvenance {
if template_id.starts_with("rule:") {
CandidateProvenance::Handcrafted
} else if smirks.contains("[#") {
CandidateProvenance::HashAtom
} else {
CandidateProvenance::FileBacked
}
}
pub struct SearchConfig {
pub max_depth: u32,
pub max_routes: usize,
pub beam_width: usize,
pub forbidden_elements: u64,
pub required_element_present: u64,
pub verbose: bool,
pub bond_index: bool,
pub bb_price_map: Option<std::collections::HashMap<String, f64>>,
pub value_estimator: Option<std::sync::Arc<dyn MoleculeValueEstimator>>,
pub reaction_prior: Option<std::sync::Arc<dyn ReactionPrior>>,
pub template_metadata: Option<std::collections::HashMap<String, TemplateMetadataEntry>>,
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
pub nn_scorer: Option<std::sync::Arc<crate::scorer::nn::TemplateScorer>>,
pub ring_context: crate::ring_context::RingContextConfig,
pub candidate_trace_cap: Option<usize>,
pub reranker: Option<std::sync::Arc<dyn crate::candidate::CandidateReranker>>,
pub timing_diagnostics: bool,
pub spectator_bond_policy: SpectatorBondPolicy,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
max_depth: 5,
max_routes: 5,
beam_width: 0,
forbidden_elements: 0,
required_element_present: 0,
verbose: false,
bond_index: false,
bb_price_map: None,
value_estimator: None,
reaction_prior: None,
template_metadata: None,
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
nn_scorer: None,
ring_context: crate::ring_context::RingContextConfig::Disabled,
candidate_trace_cap: None,
reranker: None,
timing_diagnostics: false,
spectator_bond_policy: SpectatorBondPolicy::Off,
}
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "nn-scoring"))]
fn nn_rank<'a>(
config: &SearchConfig,
rules: &'a [RetroRule],
smiles: &str,
) -> Option<Vec<&'a RetroRule>> {
config.nn_scorer.as_ref().map(|sc| {
sc.top_k_indices(smiles, rules.len())
.into_iter()
.filter_map(|i| rules.get(i))
.collect()
})
}
#[cfg(not(all(not(target_arch = "wasm32"), feature = "nn-scoring")))]
fn nn_rank<'a>(
_config: &SearchConfig,
_rules: &'a [RetroRule],
_smiles: &str,
) -> Option<Vec<&'a RetroRule>> {
None
}
#[derive(Debug, Clone, Copy)]
pub struct SearchControl {
#[cfg(not(target_arch = "wasm32"))]
deadline: Option<std::time::Instant>,
}
impl SearchControl {
pub fn unlimited() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
deadline: None,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_timeout(timeout: std::time::Duration) -> Self {
Self {
deadline: std::time::Instant::now().checked_add(timeout),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_deadline(deadline: std::time::Instant) -> Self {
Self {
deadline: Some(deadline),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn is_expired(&self) -> bool {
self.deadline
.is_some_and(|d| std::time::Instant::now() >= d)
}
#[cfg(target_arch = "wasm32")]
fn is_expired(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchTermination {
Completed,
DeadlineExceeded,
}
#[derive(Debug)]
pub struct SearchRunResult {
pub routes: Vec<Route>,
pub stats: SearchStats,
pub termination: SearchTermination,
}
pub fn find_routes_with_control(
target_smiles: &str,
env: &ChemEnv,
rules: &[RetroRule],
config: &SearchConfig,
control: &SearchControl,
) -> Result<SearchRunResult> {
let target_mol = mol_from_smiles(target_smiles)?;
let target_canonical = to_canonical(&target_mol);
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 template_smirks: FxHashMap<&str, &str> = if config.candidate_trace_cap.is_some() {
rules
.iter()
.map(|r| (r.template_id.as_str(), r.smirks.as_str()))
.collect()
} else {
FxHashMap::default()
};
let bond_idx: Option<TemplateBondIndex> = if config.bond_index {
Some(TemplateBondIndex::build(rules))
} else {
None
};
let mut active_reranker = config.reranker.as_deref();
let mut reranker_failures: u64 = 0;
let templates_by_id: std::collections::HashMap<String, &RetroRule> =
if active_reranker.is_some() {
match crate::candidate::index_rules_by_template_id(rules) {
Ok(m) => m,
Err(e) => {
eprintln!(
"warning: reranker setup failed ({e:#}); falling back to legacy \
ordering for this run"
);
reranker_failures += 1;
active_reranker = None;
std::collections::HashMap::new()
}
}
} else {
std::collections::HashMap::new()
};
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
#[cfg(not(target_arch = "wasm32"))]
let mut nodes_popped: u64 = 0;
let mut nodes_expanded: u64 = 0;
let mut max_depth_reached = false;
let mut beam_limit_hit = false;
let mut matched_templates: u64 = 0;
let mut stock_hits: u64 = 0;
let mut retro_cache_hits: u64 = 0;
let mut ring_context_diagnostics = crate::ring_context::RingContextDiagnostics::default();
let mut retro_cache_misses: u64 = 0;
let mut crowd_out = CrowdOutDiagnostics::default();
let mut route_integrity = RouteIntegrityDiagnostics::default();
let mut termination = SearchTermination::Completed;
let mut routes: Vec<Route> = Vec::new();
let mut closed: FxHashSet<u64> = 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.clone(),
}];
let h0 = compute_h(
&initial,
env,
&mut sa_cache,
config.value_estimator.as_ref(),
);
heap.push(Node {
frontier: initial,
path: None,
depth: 0,
g: 0.0,
h: h0,
trace_id: None,
});
'frontier: while let Some(node) = heap.pop() {
#[cfg(not(target_arch = "wasm32"))]
{
nodes_popped += 1;
}
if routes.len() >= config.max_routes {
break;
}
if control.is_expired() {
termination = SearchTermination::DeadlineExceeded;
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);
}
crowd_out.non_stock_candidates += 1;
} else {
stock_hits += 1;
crowd_out.stock_terminal_candidates += 1;
}
}
if n_unsolved == 0 {
let steps = collect_path(node.path.as_ref());
let building_blocks = extract_building_blocks(&steps);
let candidate = Route {
steps,
depth: node.depth,
score: node.g,
building_blocks,
confidence: 0.0, convergency: 0.0, success_probability: 0.0, route_cost: 0.0, };
let defects = route_integrity_defects(&candidate, &target_canonical);
if defects.is_empty() {
routes.push(candidate);
} else {
route_integrity.record(&defects);
}
}
if node.depth >= config.max_depth {
max_depth_reached = true;
continue;
}
let key = state_hash(&node.frontier);
if closed.contains(&key) {
continue;
}
closed.insert(key);
#[cfg(not(target_arch = "wasm32"))]
{
nodes_expanded += 1;
}
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 expansions: Arc<Vec<RetroEntry>> = if let Some(cached) = retro_cache.get(&target_smi) {
retro_cache_hits += 1;
Arc::clone(cached) } else {
retro_cache_misses += 1;
#[cfg(not(target_arch = "wasm32"))]
let expansion_t0 = config.timing_diagnostics.then(std::time::Instant::now);
let retrieved: Vec<&RetroRule>;
let per_node: Vec<&RetroRule>;
let active_rules: &[&RetroRule] = if let Some(ref idx) = bond_idx {
retrieved = idx
.retrieve(&target_mol, 0, rules) .into_iter()
.filter_map(|i| rules.get(i))
.collect();
&retrieved
} else if let Some(v) = nn_rank(config, rules, &target_smi) {
per_node = v;
&per_node
} else {
&ranked_rules
};
crowd_out.rules_attempted_total += active_rules.len() as u64;
let scored_active_rules: Vec<crate::candidate::ScoredRuleRef<'_>> = active_rules
.iter()
.enumerate()
.map(|(rank, &rule)| crate::candidate::ScoredRuleRef {
rule,
source_rank: rank,
upstream_score: None,
upstream_score_status: crate::candidate::UpstreamScoreStatus::NotApplicable,
})
.collect();
let (raw_proposals, step_ring_diag, step_sbl_findings, step_gated_out) =
crate::candidate::raw_propose(
&target_mol,
&target_smi,
&scored_active_rules,
crate::ring_context::RingContextArgs {
config: config.ring_context.clone(),
},
config.spectator_bond_policy,
);
ring_context_diagnostics.merge(&step_ring_diag);
crowd_out
.spectator_bond_loss_findings
.extend(step_sbl_findings);
crowd_out.spectator_bond_gated_out.extend(step_gated_out);
let reranker_bonus_by_id: Option<FxHashMap<String, f64>> =
if let Some(reranker) = active_reranker {
match reranker_rank_bonuses(
reranker,
&target_smi,
&target_mol,
&raw_proposals,
&templates_by_id,
) {
Ok(map) => Some(map),
Err(e) => {
eprintln!(
"warning: reranker inference failed ({e:#}); falling back to \
legacy ordering for the remainder of this search"
);
reranker_failures += 1;
active_reranker = None;
None
}
}
} else {
None
};
let entries: Vec<RetroEntry> = raw_proposals
.into_iter()
.map(|p| {
let bonus = if let Some(ref map) = reranker_bonus_by_id {
let mut key: Vec<String> =
p.precursors.iter().map(|pm| pm.smiles.clone()).collect();
key.sort_unstable();
*map.get(&crate::candidate::candidate_id_for(&target_smi, &key))
.unwrap_or_else(|| {
panic!(
"candidate_id for proposal (rule {:?}, precursors {:?}) \
missing from reranker_bonus_by_id -- this is a bug in \
reranker_rank_bonuses/candidate_id_for consistency, not a \
reranker failure",
p.rule_name, key
)
})
} else if let Some(ref prior) = config.reaction_prior {
prior.prior(&p.rule_name, &target_smi)
} else {
template_bonus(p.rule_weight, max_rule_weight)
};
let step_c =
step_cost(&p.precursors.iter().map(|pm| &pm.mol).collect::<Vec<_>>())
- bonus;
let smiles_list: Vec<String> =
p.precursors.iter().map(|pm| pm.smiles.clone()).collect();
RetroEntry {
rule_name: p.rule_name,
template_id: p.template_id,
step_cost: step_c,
precursor_smiles: smiles_list,
}
})
.collect();
let (cross_dup, after_same_template, after_cross_template) = dedup_counts(&entries);
crowd_out.cross_template_duplicate_precursor_signatures += cross_dup;
crowd_out.candidates_generated_before_dedup += entries.len() as u64;
crowd_out.candidates_after_same_template_dedup += after_same_template;
crowd_out.candidates_after_cross_template_dedup += after_cross_template;
let arc = Arc::new(entries);
retro_cache.insert(target_smi.clone(), Arc::clone(&arc));
#[cfg(not(target_arch = "wasm32"))]
if let Some(t0) = expansion_t0 {
crowd_out.retro_expansion_wall_time_us += t0.elapsed().as_micros() as u64;
}
arc };
if control.is_expired() {
termination = SearchTermination::DeadlineExceeded;
break;
}
matched_templates += expansions.len() as u64;
{
let depth_entry = crowd_out.branching_by_depth.entry(node.depth).or_default();
depth_entry.nodes_expanded += 1;
depth_entry.children_produced += expansions.len() as u64;
}
for entry in expansions.iter() {
if control.is_expired() {
termination = SearchTermination::DeadlineExceeded;
break 'frontier;
}
let new_frontier: SmallVec<[FEntry; 6]> = node
.frontier
.iter()
.filter(|e| e.smiles != target_smi)
.cloned()
.chain(
entry
.precursor_smiles
.iter()
.map(|s| FEntry { smiles: s.clone() }),
)
.collect();
let new_h = compute_h(
&new_frontier,
env,
&mut sa_cache,
config.value_estimator.as_ref(),
);
let new_path = Some(Arc::new(PathNode {
step: ReactionStep {
rule: entry.rule_name.clone(),
template_id: entry.template_id.clone(),
target: target_smi.clone(),
precursors: entry.precursor_smiles.clone(),
conditions: conditions_for_rule(&entry.rule_name),
atom_economy: None, atom_economy_raw_percent: None, atom_economy_status: AtomEconomyStatus::NotEvaluable, step_confidence: 0.0, reaction_family: reaction_family_for_rule(&entry.rule_name).map(str::to_string),
procedure_hint: procedure_hint_for_rule(&entry.rule_name).map(str::to_string),
metadata_source: (!is_extracted_template(&entry.rule_name))
.then_some(MetadataSource::HandcraftedDefault),
metadata_scope: (!is_extracted_template(&entry.rule_name))
.then_some(EvidenceScope::ReactionFamily),
evidence: None, },
prev: node.path.clone(),
}));
if config.forbidden_elements != 0 {
let mask = config.forbidden_elements;
if entry
.precursor_smiles
.iter()
.filter(|p| is_bb(p, env))
.any(|p| (elem_mask_from_smiles(p) & mask) != 0)
{
continue;
}
}
let trace_id = config.candidate_trace_cap.and_then(|cap| {
if crowd_out.candidate_trace.len() >= cap {
return None;
}
let mut precursor_signature = entry.precursor_smiles.clone();
precursor_signature.sort_unstable();
let smirks = template_smirks
.get(entry.template_id.as_str())
.copied()
.unwrap_or("");
let id = crowd_out.candidate_trace.len() as u64;
crowd_out.candidate_trace.push(CandidateTraceRecord {
depth: node.depth + 1,
parent_smiles: target_smi.clone(),
template_id: entry.template_id.clone(),
rule_name: entry.rule_name.clone(),
provenance: classify_provenance(&entry.template_id, smirks),
precursor_signature,
f_score: node.g + entry.step_cost + new_h,
rank_before_prune: None,
survived_beam: true,
later_reached_stock: false,
});
Some(id)
});
heap.push(Node {
frontier: new_frontier,
path: new_path,
depth: node.depth + 1,
g: node.g + entry.step_cost,
h: new_h,
trace_id,
});
}
if config.beam_width > 0 && heap.len() > config.beam_width {
beam_limit_hit = true;
}
let (eviction_stats, trace_ranks) = beam_prune(&mut heap, config.beam_width);
if let Some((evicted_n, evicted_min, evicted_max, boundary)) = eviction_stats {
crowd_out.beam_prune_invocations += 1;
crowd_out.candidates_evicted_total += evicted_n as u64;
crowd_out.evicted_f_min = Some(
crowd_out
.evicted_f_min
.map_or(evicted_min, |m| m.min(evicted_min)),
);
crowd_out.evicted_f_max = Some(
crowd_out
.evicted_f_max
.map_or(evicted_max, |m| m.max(evicted_max)),
);
crowd_out.final_beam_boundary_f = Some(boundary);
}
for (trace_id, rank, survived) in trace_ranks {
if let Some(record) = crowd_out.candidate_trace.get_mut(trace_id as usize) {
record.rank_before_prune = Some(rank);
record.survived_beam = survived;
}
}
}
if !crowd_out.candidate_trace.is_empty() {
let mut solved_steps: FxHashSet<(String, String, Vec<String>)> = FxHashSet::default();
for route in &routes {
for step in &route.steps {
let mut sig = step.precursors.clone();
sig.sort_unstable();
solved_steps.insert((step.target.clone(), step.template_id.clone(), sig));
}
}
for record in &mut crowd_out.candidate_trace {
let key = (
record.parent_smiles.clone(),
record.template_id.clone(),
record.precursor_signature.clone(),
);
record.later_reached_stock = solved_steps.contains(&key);
}
}
{
let rule_weights: FxHashMap<&str, f64> =
rules.iter().map(|r| (r.name.as_str(), r.weight)).collect();
for route in &mut routes {
let min_w = route
.steps
.iter()
.map(|s| rule_weights.get(s.rule.as_str()).copied().unwrap_or(1.0))
.fold(f64::INFINITY, f64::min);
route.confidence = if min_w.is_infinite() {
1.0
} else {
(min_w / max_rule_weight).clamp(0.0, 1.0)
};
for step in &mut route.steps {
let w = rule_weights.get(step.rule.as_str()).copied().unwrap_or(1.0);
step.step_confidence = (w / max_rule_weight).clamp(0.0, 1.0);
let raw = compute_atom_economy_raw(&step.target, &step.precursors);
let (status, display) = classify_atom_economy(raw);
step.atom_economy_raw_percent = raw;
step.atom_economy_status = status;
step.atom_economy = display;
step.evidence = config
.template_metadata
.as_ref()
.and_then(|m| m.get(&step.template_id))
.and_then(|e| e.to_step_evidence(&step.target, &step.precursors));
}
route.success_probability = route
.steps
.iter()
.map(|s| s.step_confidence)
.product::<f64>()
.clamp(0.0, 1.0);
route.convergency = convergency_score(&route.steps);
route.route_cost = compute_route_cost(route, config.bb_price_map.as_ref());
}
}
if config.forbidden_elements != 0 {
let mask = config.forbidden_elements;
routes.retain(|route| {
let all_targets: std::collections::HashSet<&str> =
route.steps.iter().map(|s| s.target.as_str()).collect();
route.steps.iter().all(|step| {
step.precursors.iter().all(|prec| {
all_targets.contains(prec.as_str()) || (elem_mask_from_smiles(prec) & mask) == 0
})
})
});
}
#[cfg(not(target_arch = "wasm32"))]
if config.verbose {
eprintln!(
"[renkin] search complete\n nodes popped : {}\n nodes expanded : {}\n routes found : {}\n retro cache : {}/{} hits ({:.0}%)\n elapsed : {:.2} s",
nodes_popped,
nodes_expanded,
routes.len(),
retro_cache_hits,
retro_cache_hits + retro_cache_misses,
if retro_cache_hits + retro_cache_misses > 0 {
retro_cache_hits as f64 / (retro_cache_hits + retro_cache_misses) as f64 * 100.0
} else {
0.0
},
t0.elapsed().as_secs_f64()
);
if !matches!(
config.ring_context,
crate::ring_context::RingContextConfig::Disabled
) {
eprintln!(
"[renkin] ring_context_diagnostics: {}",
serde_json::to_string(&ring_context_diagnostics).unwrap_or_default()
);
}
}
if config.required_element_present != 0 {
let need = config.required_element_present;
routes.retain(|route| {
let all_targets: std::collections::HashSet<&str> =
route.steps.iter().map(|s| s.target.as_str()).collect();
let leaf_union: u64 = route
.steps
.iter()
.flat_map(|s| s.precursors.iter())
.filter(|p| !all_targets.contains(p.as_str()))
.fold(0u64, |acc, p| acc | elem_mask_from_smiles(p));
(leaf_union & need) == need
});
}
Ok(SearchRunResult {
routes,
stats: SearchStats {
nodes_expanded,
max_depth_reached,
beam_limit_hit,
matched_templates,
stock_hits,
retro_cache_hits,
retro_cache_misses,
ring_context_diagnostics,
crowd_out,
route_integrity,
reranker_failures,
},
termination,
})
}
pub fn find_routes(
target_smiles: &str,
env: &ChemEnv,
rules: &[RetroRule],
config: &SearchConfig,
) -> Result<(Vec<Route>, SearchStats)> {
let result = find_routes_with_control(
target_smiles,
env,
rules,
config,
&SearchControl::unlimited(),
)?;
Ok((result.routes, result.stats))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chem_env::apply_retro;
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()
.0;
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().0;
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()
.0;
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()
.0;
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()
.0;
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()
.0;
let routes_full = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_full)
.unwrap()
.0;
assert!(
routes_beam.len() <= routes_full.len(),
"beam=1 ({}) should find ≤ routes than beam=0 ({})",
routes_beam.len(),
routes_full.len()
);
}
fn node(f: f64) -> Node {
Node {
frontier: smallvec![FEntry {
smiles: "C".to_string(),
}],
path: None,
depth: 0,
g: f,
h: 0.0,
trace_id: None,
}
}
fn traced_node(f: f64, trace_id: u64) -> Node {
Node {
trace_id: Some(trace_id),
..node(f)
}
}
#[test]
fn beam_prune_returns_none_when_beam_width_zero() {
let mut heap: BinaryHeap<Node> = (0..5).map(|i| node(i as f64)).collect();
let (stats, trace_ranks) = beam_prune(&mut heap, 0);
assert_eq!(stats, None);
assert!(trace_ranks.is_empty());
assert_eq!(heap.len(), 5, "beam_width=0 must not truncate");
}
#[test]
fn beam_prune_returns_none_when_heap_within_beam_width() {
let mut heap: BinaryHeap<Node> = (0..3).map(|i| node(i as f64)).collect();
let (stats, trace_ranks) = beam_prune(&mut heap, 10);
assert_eq!(stats, None);
assert!(trace_ranks.is_empty());
assert_eq!(heap.len(), 3);
}
#[test]
fn beam_prune_reports_exact_eviction_stats() {
let mut heap: BinaryHeap<Node> = (0..5).map(|i| node(i as f64)).collect();
let (evicted_n, evicted_min, evicted_max, boundary) = beam_prune(&mut heap, 2).0.unwrap();
assert_eq!(evicted_n, 3, "5 nodes - beam_width 2 = 3 evicted");
assert_eq!(evicted_min, 2.0, "lowest f among the evicted (f=2,3,4)");
assert_eq!(evicted_max, 4.0, "highest f among the evicted");
assert_eq!(boundary, 1.0, "f of the worst *retained* node (f=0,1)");
assert_eq!(heap.len(), 2);
let mut retained: Vec<f64> = heap.iter().map(Node::f).collect();
retained.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(retained, vec![0.0, 1.0]);
}
#[test]
fn beam_prune_reports_survived_and_evicted_trace_ranks() {
let mut heap: BinaryHeap<Node> = vec![
traced_node(0.0, 100),
traced_node(2.0, 101),
node(1.0),
node(3.0),
node(4.0),
]
.into_iter()
.collect();
let (_, trace_ranks) = beam_prune(&mut heap, 2);
let mut by_id: FxHashMap<u64, (usize, bool)> = trace_ranks
.into_iter()
.map(|(id, rank, survived)| (id, (rank, survived)))
.collect();
assert_eq!(by_id.remove(&100), Some((0, true)), "f=0.0 -> rank 0, kept");
assert_eq!(
by_id.remove(&101),
Some((2, false)),
"f=2.0 -> rank 2, evicted (beam_width=2)"
);
}
#[test]
fn beam_prune_reports_no_trace_ranks_when_nothing_evicted() {
let mut heap: BinaryHeap<Node> = vec![traced_node(0.0, 7), node(1.0)].into_iter().collect();
let (stats, trace_ranks) = beam_prune(&mut heap, 10);
assert_eq!(stats, None, "heap smaller than beam_width -> no eviction");
assert!(trace_ranks.is_empty());
}
#[test]
fn dedup_counts_ignores_same_template_repeats_for_cross_template_duplicates() {
let entries = vec![
RetroEntry {
rule_name: "extracted_1".to_string(),
template_id: "smirks-sha256:aaa".to_string(),
step_cost: 1.0,
precursor_smiles: vec!["CC".to_string(), "O".to_string()],
},
RetroEntry {
rule_name: "extracted_1".to_string(),
template_id: "smirks-sha256:aaa".to_string(),
step_cost: 1.0,
precursor_smiles: vec!["O".to_string(), "CC".to_string()],
},
];
let (cross_dup, after_same_template, after_cross_template) = dedup_counts(&entries);
assert_eq!(
cross_dup, 0,
"identical signature from the SAME template is not cross-template duplication"
);
assert_eq!(
after_same_template, 1,
"both entries collapse to one (template_id, signature) pair"
);
assert_eq!(after_cross_template, 1);
}
#[test]
fn dedup_counts_detects_cross_template_collision() {
let entries = vec![
RetroEntry {
rule_name: "extracted_1".to_string(),
template_id: "smirks-sha256:aaa".to_string(),
step_cost: 1.0,
precursor_smiles: vec!["CC".to_string(), "O".to_string()],
},
RetroEntry {
rule_name: "extracted_2".to_string(),
template_id: "smirks-sha256:bbb".to_string(),
precursor_smiles: vec!["O".to_string(), "CC".to_string()],
step_cost: 1.0,
},
RetroEntry {
rule_name: "extracted_3".to_string(),
template_id: "smirks-sha256:ccc".to_string(),
precursor_smiles: vec!["N".to_string()],
step_cost: 1.0,
},
];
let (cross_dup, after_same_template, after_cross_template) = dedup_counts(&entries);
assert_eq!(
cross_dup, 1,
"extracted_2 duplicates extracted_1's signature; extracted_3 is distinct"
);
assert_eq!(
after_same_template, 3,
"all 3 have distinct (template_id, signature) pairs"
);
assert_eq!(
after_cross_template, 2,
"extracted_1/extracted_2 share one signature; extracted_3 is the second"
);
}
#[test]
fn classify_provenance_distinguishes_handcrafted_file_backed_and_hash_atom() {
assert_eq!(
classify_provenance("rule:esterification", "[C:1](=O)O.[O:2]>>..."),
CandidateProvenance::Handcrafted
);
assert_eq!(
classify_provenance("smirks-sha256:abc", "[C:1](=O)O.[O:2]>>..."),
CandidateProvenance::FileBacked
);
assert_eq!(
classify_provenance("smirks-sha256:abc", "[#7:2]:[c:1]>>..."),
CandidateProvenance::HashAtom
);
}
#[test]
fn crowd_out_diagnostics_default_off_when_beam_unlimited() {
let env = aspirin_env();
let rules = default_rules();
let (_, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert_eq!(stats.crowd_out.beam_prune_invocations, 0);
assert_eq!(stats.crowd_out.candidates_evicted_total, 0);
assert_eq!(stats.crowd_out.evicted_f_min, None);
assert_eq!(stats.crowd_out.evicted_f_max, None);
assert_eq!(stats.crowd_out.final_beam_boundary_f, None);
}
#[test]
fn crowd_out_diagnostics_records_eviction_under_tight_beam() {
let env = aspirin_env();
let rules = default_rules();
let cfg_beam = SearchConfig {
max_depth: 3,
max_routes: 3,
beam_width: 1,
..Default::default()
};
let (_, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
assert!(
stats.crowd_out.beam_prune_invocations > 0,
"beam_width=1 on a multi-rule target must trigger at least one prune"
);
assert!(stats.crowd_out.candidates_evicted_total > 0);
let evicted_min = stats.crowd_out.evicted_f_min.expect("must be Some");
let evicted_max = stats.crowd_out.evicted_f_max.expect("must be Some");
assert!(evicted_min <= evicted_max);
assert!(stats.crowd_out.final_beam_boundary_f.is_some());
}
#[test]
fn crowd_out_diagnostics_stock_and_non_stock_candidates_are_counted() {
let env = aspirin_env();
let rules = default_rules();
let (_, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert!(
stats.crowd_out.stock_terminal_candidates > 0,
"aspirin's search must encounter stock hits (acetic/salicylic acid)"
);
assert!(
stats.crowd_out.stock_terminal_candidates + stats.crowd_out.non_stock_candidates > 0
);
assert!(stats.crowd_out.rules_attempted_total > 0);
}
#[test]
fn crowd_out_diagnostics_branching_by_depth_sums_match_top_level_stats() {
let env = aspirin_env();
let rules = default_rules();
let (_, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert!(!stats.crowd_out.branching_by_depth.is_empty());
let sum_expanded: u64 = stats
.crowd_out
.branching_by_depth
.values()
.map(|d| d.nodes_expanded)
.sum();
let sum_children: u64 = stats
.crowd_out
.branching_by_depth
.values()
.map(|d| d.children_produced)
.sum();
assert_eq!(
sum_expanded, stats.nodes_expanded,
"per-depth nodes_expanded must sum to the top-level total"
);
assert_eq!(
sum_children, stats.matched_templates,
"per-depth children_produced must sum to matched_templates \
(both are bumped at the same call site)"
);
}
#[test]
fn crowd_out_diagnostics_are_deterministic_across_repeated_runs() {
let env = aspirin_env();
let rules = default_rules();
let cfg_beam = SearchConfig {
max_depth: 3,
max_routes: 3,
beam_width: 2,
..Default::default()
};
let (_, stats1) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
let (_, stats2) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
let j1 = serde_json::to_string(&stats1.crowd_out).unwrap();
let j2 = serde_json::to_string(&stats2.crowd_out).unwrap();
assert_eq!(
j1, j2,
"identical inputs must yield byte-identical diagnostics"
);
}
#[test]
fn timing_diagnostics_defaults_to_zero() {
let env = aspirin_env();
let rules = default_rules();
let (_, stats) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).expect("search runs");
assert_eq!(
stats.crowd_out.retro_expansion_wall_time_us, 0,
"must stay 0 -- and therefore deterministic across repeated runs -- unless \
SearchConfig::timing_diagnostics is explicitly opted into"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn timing_diagnostics_opt_in_records_real_time() {
let env = aspirin_env();
let rules = default_rules();
let cfg_timed = SearchConfig {
timing_diagnostics: true,
..cfg(3)
};
let (_, stats) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_timed).expect("search runs");
assert!(
stats.retro_cache_misses > 0,
"sanity check: this target must produce at least one retro-cache-miss \
expansion for this test to be meaningful"
);
assert!(
stats.crowd_out.retro_expansion_wall_time_us > 0,
"opted into timing_diagnostics with a real cache-miss expansion, so real \
elapsed time must have been recorded: {:?}",
stats.crowd_out
);
}
#[test]
fn spectator_bond_policy_off_by_default_produces_empty() {
let env = aspirin_env();
let rules = default_rules();
let (_, stats) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).expect("search runs");
assert!(
stats.crowd_out.spectator_bond_loss_findings.is_empty(),
"must stay empty unless SearchConfig::spectator_bond_policy is explicitly opted \
into: {:?}",
stats.crowd_out.spectator_bond_loss_findings
);
assert!(stats.crowd_out.spectator_bond_gated_out.is_empty());
}
#[test]
fn spectator_bond_policy_diagnostics_only_runs_without_error_on_default_rules() {
let env = aspirin_env();
let rules = default_rules();
let cfg_diag = SearchConfig {
spectator_bond_policy: SpectatorBondPolicy::DiagnosticsOnly,
..cfg(3)
};
let (routes_on, stats_on) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_diag).expect("search runs");
let (routes_off, _) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).expect("search runs");
assert_eq!(
routes_on.len(),
routes_off.len(),
"opting into spectator_bond_policy: DiagnosticsOnly must never change which routes \
are found"
);
assert!(stats_on.crowd_out.spectator_bond_loss_findings.is_empty());
assert!(stats_on.crowd_out.spectator_bond_gated_out.is_empty());
}
#[test]
fn spectator_bond_policy_gated_runs_without_error_on_default_rules() {
let env = aspirin_env();
let rules = default_rules();
let cfg_gated = SearchConfig {
spectator_bond_policy: SpectatorBondPolicy::Gated,
..cfg(3)
};
let (routes_gated, stats_gated) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_gated).expect("search runs");
let (routes_off, _) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).expect("search runs");
assert_eq!(
routes_gated.len(),
routes_off.len(),
"Gated must never change route output when nothing it can evaluate is defective"
);
assert!(stats_gated.crowd_out.spectator_bond_gated_out.is_empty());
}
#[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()
.0;
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 is_extracted_template_detects_name_prefix_only() {
assert!(is_extracted_template("extracted_0"));
assert!(is_extracted_template("extracted_1234"));
assert!(!is_extracted_template("suzuki_retro"));
assert!(!is_extracted_template("cc_single_cleavage"));
}
#[test]
fn absent_metadata_fields_are_omitted_from_json() {
let step = ReactionStep {
rule: "extracted_0".to_string(),
template_id: "smirks-sha256:deadbeef".to_string(),
target: "CC(=O)O".to_string(),
precursors: vec!["C".to_string(), "O=C=O".to_string()],
conditions: None,
atom_economy: None,
atom_economy_raw_percent: None,
atom_economy_status: AtomEconomyStatus::NotEvaluable,
step_confidence: 0.5,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
};
let json = serde_json::to_string(&step).unwrap();
assert!(
!json.contains("metadata_source")
&& !json.contains("metadata_scope")
&& !json.contains("evidence"),
"absent metadata fields must be omitted from JSON, got: {json}"
);
}
#[test]
fn classify_atom_economy_normal_case_unchanged() {
let (status, display) = classify_atom_economy(Some(87.5));
assert_eq!(status, AtomEconomyStatus::Normal);
assert_eq!(display, Some(87.5));
}
#[test]
fn classify_atom_economy_exactly_100_is_normal() {
let (status, display) = classify_atom_economy(Some(100.0));
assert_eq!(status, AtomEconomyStatus::Normal);
assert_eq!(display, Some(100.0));
}
#[test]
fn classify_atom_economy_above_range_is_never_clamped_into_display() {
let (status, display) = classify_atom_economy(Some(183.4));
assert_eq!(status, AtomEconomyStatus::AboveExpectedRange);
assert_eq!(
display, None,
"a ratio above the expected range must never be reported as a display value, clamped or otherwise"
);
}
#[test]
fn classify_atom_economy_not_evaluable_when_no_raw_ratio() {
let (status, display) = classify_atom_economy(None);
assert_eq!(status, AtomEconomyStatus::NotEvaluable);
assert_eq!(display, None);
}
#[test]
fn classify_atom_economy_nan_is_not_evaluable() {
let (status, display) = classify_atom_economy(Some(f64::NAN));
assert_eq!(status, AtomEconomyStatus::NotEvaluable);
assert_eq!(display, None);
}
#[test]
fn classify_atom_economy_positive_infinity_is_not_evaluable() {
let (status, display) = classify_atom_economy(Some(f64::INFINITY));
assert_eq!(status, AtomEconomyStatus::NotEvaluable);
assert_eq!(display, None);
}
#[test]
fn classify_atom_economy_negative_infinity_is_not_evaluable() {
let (status, display) = classify_atom_economy(Some(f64::NEG_INFINITY));
assert_eq!(status, AtomEconomyStatus::NotEvaluable);
assert_eq!(display, None);
}
#[test]
fn compute_raw_one_unparseable_precursor_is_not_evaluable() {
let raw =
compute_atom_economy_raw("CCO", &["not_a_smiles(((".to_string(), "C".to_string()]);
assert_eq!(raw, None);
}
#[test]
fn compute_raw_unparseable_target_is_not_evaluable() {
let raw = compute_atom_economy_raw("not_a_smiles(((", &["CCO".to_string()]);
assert_eq!(raw, None);
}
#[test]
fn compute_raw_empty_precursors_is_not_evaluable() {
let raw = compute_atom_economy_raw("CCO", &[]);
assert_eq!(raw, None);
}
#[test]
fn compute_raw_normal_case_matches_direct_molecular_weight_ratio() {
let target_w = molecular_weight(&mol_from_smiles("CCO").unwrap());
let precursor_w = molecular_weight(&mol_from_smiles("CC=O").unwrap());
let raw = compute_atom_economy_raw("CCO", &["CC=O".to_string()]).unwrap();
assert!((raw - target_w / precursor_w * 100.0).abs() < 1e-9);
}
#[test]
fn compute_raw_reagent_omission_lands_above_expected_range() {
let raw = compute_atom_economy_raw("C1CCCCC1", &["c1ccccc1".to_string()]).unwrap();
assert!(raw > 100.0, "expected > 100%, got {raw}");
let (status, display) = classify_atom_economy(Some(raw));
assert_eq!(status, AtomEconomyStatus::AboveExpectedRange);
assert_eq!(display, None);
}
#[test]
fn above_range_step_omits_atom_economy_but_keeps_raw_and_status_in_json() {
let raw = 183.4;
let (status, display) = classify_atom_economy(Some(raw));
let step = ReactionStep {
rule: "extracted_0".to_string(),
template_id: "smirks-sha256:deadbeef".to_string(),
target: "CC(=O)O".to_string(),
precursors: vec!["C".to_string()],
conditions: None,
atom_economy: display,
atom_economy_raw_percent: Some(raw),
atom_economy_status: status,
step_confidence: 0.5,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
};
let json = serde_json::to_string(&step).unwrap();
assert!(
!json.contains("\"atom_economy\":"),
"atom_economy must be absent (never a clamped 100.0), got: {json}"
);
assert!(
json.contains("\"atom_economy_raw_percent\":183.4"),
"the honest raw ratio must still be reported, got: {json}"
);
assert!(
json.contains("\"atom_economy_status\":\"above_expected_range\""),
"got: {json}"
);
}
#[test]
fn compute_raw_precursor_excess_is_normal_well_under_100() {
let raw = compute_atom_economy_raw("c1ccccc1", &["C1CCCCC1".to_string()]).unwrap();
assert!(raw < 100.0, "expected < 100%, got {raw}");
let (status, _) = classify_atom_economy(Some(raw));
assert_eq!(status, AtomEconomyStatus::Normal);
}
#[test]
fn atom_economy_fields_json_round_trip_by_status() {
fn step_with(
status: AtomEconomyStatus,
display: Option<f64>,
raw: Option<f64>,
) -> ReactionStep {
ReactionStep {
rule: "extracted_0".to_string(),
template_id: "smirks-sha256:deadbeef".to_string(),
target: "CC(=O)O".to_string(),
precursors: vec!["C".to_string()],
conditions: None,
atom_economy: display,
atom_economy_raw_percent: raw,
atom_economy_status: status,
step_confidence: 0.5,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
}
}
let normal = step_with(AtomEconomyStatus::Normal, Some(87.5), Some(87.5));
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&normal).unwrap()).unwrap();
assert_eq!(v["atom_economy"], serde_json::json!(87.5));
assert_eq!(v["atom_economy_raw_percent"], serde_json::json!(87.5));
assert_eq!(v["atom_economy_status"], serde_json::json!("normal"));
let above = step_with(AtomEconomyStatus::AboveExpectedRange, None, Some(183.4));
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&above).unwrap()).unwrap();
assert!(v.get("atom_economy").is_none());
assert_eq!(v["atom_economy_raw_percent"], serde_json::json!(183.4));
assert_eq!(
v["atom_economy_status"],
serde_json::json!("above_expected_range")
);
let not_evaluable = step_with(AtomEconomyStatus::NotEvaluable, None, None);
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(¬_evaluable).unwrap()).unwrap();
assert!(v.get("atom_economy").is_none());
assert!(v.get("atom_economy_raw_percent").is_none());
assert_eq!(v["atom_economy_status"], serde_json::json!("not_evaluable"));
}
#[test]
fn handcrafted_rule_step_is_tagged() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3))
.unwrap()
.0;
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 {
for step in &r.steps {
assert_eq!(
step.metadata_source,
Some(MetadataSource::HandcraftedDefault),
"step using hand-crafted rule {:?} must be tagged HandcraftedDefault",
step.rule
);
assert_eq!(
step.metadata_scope,
Some(EvidenceScope::ReactionFamily),
"step using hand-crafted rule {:?} must be scoped ReactionFamily",
step.rule
);
}
}
}
#[test]
fn no_metadata_configured_means_no_evidence() {
let env = aspirin_env();
let rules = default_rules();
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3))
.unwrap()
.0;
assert!(!routes.is_empty());
for route in &routes {
for step in &route.steps {
assert!(
step.evidence.is_none(),
"no metadata sidecar configured -- step.evidence must stay None"
);
assert!(
!step.template_id.is_empty(),
"template_id must always be populated"
);
}
}
}
#[test]
fn evidence_attached_only_to_matching_template_id() {
let env = aspirin_env();
let rules = default_rules();
let target_template_id = rules
.iter()
.find(|r| r.name == "ester_cleavage")
.unwrap()
.template_id
.clone();
let mut templates = std::collections::HashMap::new();
templates.insert(
target_template_id.clone(),
crate::evidence::TemplateMetadataEntry {
warnings: vec![crate::evidence::ReactionWarning {
code: "test_code".to_string(),
severity: crate::evidence::WarningSeverity::Low,
message: "test warning".to_string(),
source: MetadataSource::Literature,
scope: EvidenceScope::Template,
reference_ids: vec![],
}],
..Default::default()
},
);
let config = SearchConfig {
template_metadata: Some(templates),
..cfg(3)
};
let routes = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &config)
.unwrap()
.0;
let mut saw_match = false;
let mut saw_non_match = false;
for route in &routes {
for step in &route.steps {
if step.template_id == target_template_id {
assert!(
step.evidence.is_some(),
"step using the metadata-matched template must get evidence"
);
saw_match = true;
} else {
assert!(
step.evidence.is_none(),
"step using a non-matched template must not get evidence"
);
saw_non_match = true;
}
}
}
assert!(saw_match, "expected at least one step using ester_cleavage");
assert!(
saw_non_match,
"expected at least one step using a different rule"
);
}
#[test]
fn symmetric_biaryl_routes_deduplicated() {
let env = ChemEnv::in_memory(&["Brc1ccccc1", "OB(O)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()
.0;
assert_eq!(
routes.len(),
1,
"symmetric biphenyl should produce exactly 1 deduplicated route; got {}",
routes.len()
);
}
#[test]
fn confidence_is_between_zero_and_one() {
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 route in &routes {
assert!(
(0.0..=1.0).contains(&route.confidence),
"confidence {} out of [0,1]",
route.confidence
);
}
}
#[test]
fn search_stats_nodes_expanded_nonzero() {
let env = ChemEnv::in_memory(&["O"]); let rules = default_rules();
let (routes, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(2)).unwrap();
assert!(
routes.is_empty(),
"aspirin should be unsolvable with only water as BB"
);
assert!(
stats.nodes_expanded > 0,
"nodes_expanded must be > 0 even for failed search"
);
}
#[test]
fn avoid_elements_removes_forbidden_bbs() {
let env = aspirin_env();
let rules = default_rules();
let config = SearchConfig {
forbidden_elements: crate::chem_env::elem_symbols_to_mask("Cl"),
..cfg(3)
};
let (routes, _) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &config).unwrap();
for route in &routes {
for bb in &route.building_blocks {
assert!(!bb.contains("Cl"), "BB {bb} contains forbidden element Cl");
}
}
}
#[test]
fn find_routes_returns_stats_tuple() {
let env = aspirin_env();
let rules = default_rules();
let (routes, stats) = find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg(3)).unwrap();
assert!(!routes.is_empty());
assert!(stats.nodes_expanded >= routes.len() as u64);
}
#[test]
fn closed_set_discards_better_path_reaching_same_state() {
fn rr(name: &str, smirks: &str) -> RetroRule {
RetroRule {
name: name.to_string(),
template_id: format!("rule:{name}"),
smirks: smirks.to_string(),
weight: 1.0,
required_elements: 0,
}
}
let rules = vec![
rr("r_direct", "[Cl][C:1][C:2][I]>>[Br][C:1][C:2][Br].Cl.I"),
rr("r_step1", "[Cl][C:1][C:2][I]>>[Br][C:1][C:2][I].Cl"),
rr("r_step2", "[Br][C:1][C:2][I]>>[Br][C:1][C:2][Br].I"),
rr("r_final", "[Br][C:1][C:2][Br]>>[F][C:1][C:2][F].Br.Br"),
];
let t_mol = mol_from_smiles("ClCCI").unwrap();
let y_smiles = apply_retro(&t_mol, &rules[1])[0][0].smiles.clone();
let env = ChemEnv::in_memory(&["FCCF", "Cl", "Br", "I"]);
struct FixedPrior;
impl ReactionPrior for FixedPrior {
fn prior(&self, template_name: &str, _target_smiles: &str) -> f64 {
match template_name {
"r_step1" | "r_step2" => 5.0,
_ => 0.0,
}
}
}
struct FixedEstimator {
y_smiles: String,
}
impl MoleculeValueEstimator for FixedEstimator {
fn estimate_cost(&self, smiles: &str) -> f64 {
if smiles == self.y_smiles { 100.0 } else { 0.0 }
}
}
let config = SearchConfig {
max_depth: 5,
max_routes: 10,
beam_width: 0,
reaction_prior: Some(std::sync::Arc::new(FixedPrior)),
value_estimator: Some(std::sync::Arc::new(FixedEstimator {
y_smiles: y_smiles.clone(),
})),
..Default::default()
};
let (routes, _stats) = find_routes("ClCCI", &env, &rules, &config).unwrap();
assert!(!routes.is_empty(), "must find at least the direct route");
let best_score = routes.iter().map(|r| r.score).fold(f64::INFINITY, f64::min);
assert!(
best_score > -1.0,
"expected the boolean closed-set bug to discard the better \
(deeply negative g) route, leaving only the worse (g≈2.29) \
route — but best_score={best_score} suggests the optimal \
route WAS found (bug fixed, or test assumptions stale)"
);
assert!(
(best_score - 2.290).abs() < 0.05,
"expected the only recorded route to be the direct-path route \
(g≈2.13), got best_score={best_score}"
);
}
struct DeterministicReranker;
impl crate::candidate::CandidateReranker for DeterministicReranker {
fn score_pool(
&self,
_target: &str,
candidates: &mut [crate::candidate::ReactionCandidate],
) -> anyhow::Result<()> {
for c in candidates.iter_mut() {
c.reranker_score = Some(c.precursor_smiles.join(".").len() as f64);
}
Ok(())
}
}
fn deterministic_score(precursor_smiles: &[String]) -> f64 {
let mut sorted = precursor_smiles.to_vec();
sorted.sort_unstable();
sorted.join(".").len() as f64
}
#[test]
fn reranker_rank_bonuses_matches_the_canonical_merge_extract_score_pipeline() {
let rules = default_rules();
let target_smi = "CC(=O)Oc1ccccc1C(=O)O"; let target_mol = mol_from_smiles(target_smi).unwrap();
let scored_active_rules: Vec<crate::candidate::ScoredRuleRef<'_>> = rules
.iter()
.enumerate()
.map(|(rank, rule)| crate::candidate::ScoredRuleRef {
rule,
source_rank: rank,
upstream_score: None,
upstream_score_status: crate::candidate::UpstreamScoreStatus::NotApplicable,
})
.collect();
let (raw_proposals, _diag, _sbl_findings, _gated_out) = crate::candidate::raw_propose(
&target_mol,
target_smi,
&scored_active_rules,
crate::ring_context::RingContextArgs {
config: crate::ring_context::RingContextConfig::Disabled,
},
SpectatorBondPolicy::Off,
);
assert!(
raw_proposals.len() >= 2,
"fixture must exercise a multi-candidate pool, got {}",
raw_proposals.len()
);
let templates_by_id = crate::candidate::index_rules_by_template_id(&rules).unwrap();
let via_search_rs = reranker_rank_bonuses(
&DeterministicReranker,
target_smi,
&target_mol,
&raw_proposals,
&templates_by_id,
)
.unwrap();
let mut candidates =
crate::candidate::merge_into_candidates(target_smi, &raw_proposals).unwrap();
for c in candidates.iter_mut() {
c.features = crate::candidate::extract_features(c, &target_mol, &templates_by_id, None);
}
assert!(
candidates.len() >= 2,
"merge must still produce a multi-candidate pool, got {}",
candidates.len()
);
candidates.sort_by(|a, b| {
deterministic_score(&b.precursor_smiles)
.partial_cmp(&deterministic_score(&a.precursor_smiles))
.unwrap()
.then_with(|| a.candidate_id.cmp(&b.candidate_id))
});
let n = candidates.len();
let via_direct: FxHashMap<String, f64> = candidates
.into_iter()
.enumerate()
.map(|(rank, c)| (c.candidate_id, crate::score::rank_bonus(rank, n)))
.collect();
assert_eq!(
via_search_rs.len(),
via_direct.len(),
"same candidate_id set expected from both paths"
);
for (id, direct_bonus) in &via_direct {
let search_bonus = via_search_rs
.get(id)
.unwrap_or_else(|| panic!("candidate_id {id} missing from search.rs's map"));
assert!(
(search_bonus - direct_bonus).abs() < 1e-12,
"bonus mismatch for {id}: search.rs={search_bonus}, direct={direct_bonus}"
);
}
let distinct: std::collections::BTreeSet<u64> =
via_direct.values().map(|v| v.to_bits()).collect();
assert!(
distinct.len() >= 2,
"fixture must produce differentiated ranks, got {distinct:?}"
);
}
#[test]
fn reranker_changes_ordering_only_not_the_candidate_set() {
let env = aspirin_env();
let rules = default_rules();
let target_smi = "CC(=O)Oc1ccccc1C(=O)O";
let legacy_cfg = cfg(2);
let (_routes_legacy, stats_legacy) =
find_routes(target_smi, &env, &rules, &legacy_cfg).unwrap();
let reranked_cfg = SearchConfig {
reranker: Some(std::sync::Arc::new(DeterministicReranker)),
..cfg(2)
};
let (_routes_reranked, stats_reranked) =
find_routes(target_smi, &env, &rules, &reranked_cfg).unwrap();
assert_eq!(
stats_reranked.reranker_failures, 0,
"the deterministic test double must never fail"
);
assert_eq!(
stats_legacy.matched_templates,
stats_reranked.matched_templates
);
assert_eq!(stats_legacy.nodes_expanded, stats_reranked.nodes_expanded);
}
#[test]
fn reranker_under_tight_beam_prunes_safely_and_stays_deterministic() {
let env = aspirin_env();
let rules = default_rules();
let cfg_beam = SearchConfig {
max_depth: 3,
max_routes: 3,
beam_width: 1,
reranker: Some(std::sync::Arc::new(DeterministicReranker)),
..Default::default()
};
let (routes_a, stats_a) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
assert_eq!(stats_a.reranker_failures, 0);
assert!(
stats_a.crowd_out.beam_prune_invocations > 0,
"fixture must actually exercise beam pruning, or this test isn't testing anything \
the beam_width=0 tests don't already cover"
);
let (routes_b, stats_b) =
find_routes("CC(=O)Oc1ccccc1C(=O)O", &env, &rules, &cfg_beam).unwrap();
assert_eq!(
serde_json::to_string(&routes_a).unwrap(),
serde_json::to_string(&routes_b).unwrap(),
"reranker + tight beam must still be deterministic"
);
assert_eq!(stats_b.reranker_failures, 0);
}
#[test]
fn reranker_none_is_byte_identical_to_pre_reranker_ordering() {
let env = aspirin_env();
let rules = default_rules();
let target_smi = "CC(=O)Oc1ccccc1C(=O)O";
let (routes_a, stats_a) = find_routes(target_smi, &env, &rules, &cfg(3)).unwrap();
let (routes_b, stats_b) = find_routes(target_smi, &env, &rules, &cfg(3)).unwrap();
assert_eq!(
serde_json::to_string(&routes_a).unwrap(),
serde_json::to_string(&routes_b).unwrap(),
"reranker: None must be fully deterministic (a stand-in for byte-diffing \
against the pre-wiring binary's output on the same input)"
);
assert_eq!(stats_a.reranker_failures, 0);
assert_eq!(stats_b.reranker_failures, 0);
}
#[test]
fn reranker_some_is_also_fully_deterministic_across_repeated_runs() {
let env = aspirin_env();
let rules = default_rules();
let target_smi = "CC(=O)Oc1ccccc1C(=O)O";
let reranked_cfg = SearchConfig {
reranker: Some(std::sync::Arc::new(DeterministicReranker)),
..cfg(3)
};
let (routes_a, stats_a) = find_routes(target_smi, &env, &rules, &reranked_cfg).unwrap();
let (routes_b, stats_b) = find_routes(target_smi, &env, &rules, &reranked_cfg).unwrap();
assert_eq!(
serde_json::to_string(&routes_a).unwrap(),
serde_json::to_string(&routes_b).unwrap(),
"reranker: Some(..) must be just as deterministic as the legacy path"
);
assert_eq!(stats_a.reranker_failures, 0);
assert_eq!(stats_b.reranker_failures, 0);
}
}
#[cfg(test)]
mod cooperative_cancellation_tests {
use super::*;
use crate::chem_env::{ChemEnv, default_rules};
fn 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(beam_width: usize) -> SearchConfig {
SearchConfig {
max_depth: 5,
max_routes: 5,
beam_width,
..Default::default()
}
}
const TARGET: &str = "CC(=O)Oc1ccccc1C(=O)O";
#[test]
fn wrapper_matches_unlimited_control_exactly() {
let env = env();
let rules = default_rules();
for beam_width in [0, 10, 100] {
let config = cfg(beam_width);
let (wrapper_routes, wrapper_stats) =
find_routes(TARGET, &env, &rules, &config).unwrap();
let controlled = find_routes_with_control(
TARGET,
&env,
&rules,
&config,
&SearchControl::unlimited(),
)
.unwrap();
assert_eq!(controlled.termination, SearchTermination::Completed);
assert_eq!(
serde_json::to_string(&wrapper_routes).unwrap(),
serde_json::to_string(&controlled.routes).unwrap(),
"wrapper vs. find_routes_with_control(unlimited) routes diverged at beam_width={beam_width}"
);
assert_eq!(
serde_json::to_string(&wrapper_stats).unwrap(),
serde_json::to_string(&controlled.stats).unwrap(),
"wrapper vs. find_routes_with_control(unlimited) stats diverged at beam_width={beam_width}"
);
}
}
#[test]
fn unlimited_control_reproduces_known_golden_result() {
let env = env();
let rules = default_rules();
let result =
find_routes_with_control(TARGET, &env, &rules, &cfg(0), &SearchControl::unlimited())
.unwrap();
assert_eq!(result.termination, SearchTermination::Completed);
assert!(
!result.routes.is_empty(),
"aspirin must find at least one route"
);
assert!(
result.routes.iter().any(|r| r.depth <= 2),
"must find a route with depth <= 2, same as aspirin_finds_route_depth1"
);
}
#[test]
fn already_past_deadline_returns_deadline_exceeded_without_panicking() {
let env = env();
let rules = default_rules();
let control = SearchControl::with_deadline(std::time::Instant::now());
let result = find_routes_with_control(TARGET, &env, &rules, &cfg(0), &control);
assert!(
result.is_ok(),
"must not panic or error, even with zero budget"
);
let result = result.unwrap();
assert_eq!(result.termination, SearchTermination::DeadlineExceeded);
}
#[test]
fn checkpoint_one_alone_catches_a_deadline_no_expansion_ever_reaches() {
let env = env();
let rules = default_rules();
let config = SearchConfig {
max_depth: 0,
..cfg(0)
};
let control = SearchControl::with_deadline(std::time::Instant::now());
let result = find_routes_with_control(TARGET, &env, &rules, &config, &control).unwrap();
assert_eq!(result.termination, SearchTermination::DeadlineExceeded);
}
#[test]
fn microsecond_timeout_on_a_real_search_does_not_panic() {
let env = env();
let rules = default_rules();
let control = SearchControl::with_timeout(std::time::Duration::from_micros(1));
let result = find_routes_with_control(TARGET, &env, &rules, &cfg(0), &control);
assert!(result.is_ok());
}
#[test]
fn valid_routes_found_before_deadline_are_not_discarded() {
let env = env();
let rules = default_rules();
let config = cfg(0);
let baseline =
find_routes_with_control(TARGET, &env, &rules, &config, &SearchControl::unlimited())
.unwrap();
assert_eq!(baseline.termination, SearchTermination::Completed);
assert!(
!baseline.routes.is_empty(),
"fixture must find at least one route to be a meaningful test"
);
const FRACTIONS: [u32; 11] = [30, 40, 50, 55, 60, 65, 70, 75, 80, 85, 90];
const MAX_SWEEPS: u32 = 3;
let mut saw_nonempty_partial = false;
for _sweep in 0..MAX_SWEEPS {
for frac in FRACTIONS {
let t0 = std::time::Instant::now();
let _ = find_routes_with_control(
TARGET,
&env,
&rules,
&config,
&SearchControl::unlimited(),
)
.unwrap();
let baseline_elapsed = t0.elapsed();
let partial = find_routes_with_control(
TARGET,
&env,
&rules,
&config,
&SearchControl::with_timeout(baseline_elapsed * frac / 100),
)
.unwrap();
assert!(
partial.routes.len() <= baseline.routes.len(),
"must never return more routes than the full search finds (frac={frac})"
);
for r in &partial.routes {
assert!(
baseline.routes.iter().any(|br| br.depth == r.depth
&& br.steps.len() == r.steps.len()
&& (br.score - r.score).abs() < 1e-9),
"a route present in the deadline-cut result must also exist in the \
unlimited baseline (no fabrication/corruption), frac={frac}"
);
}
if partial.termination == SearchTermination::DeadlineExceeded
&& !partial.routes.is_empty()
{
saw_nonempty_partial = true;
}
}
if saw_nonempty_partial {
break;
}
}
assert!(
saw_nonempty_partial,
"expected at least one sampled deadline fraction, across up to {MAX_SWEEPS} sweeps, \
to catch a nonempty partial route set before full completion -- if this ever \
flakes, the sampled fraction set may need widening for the machine it's running on"
);
}
#[test]
fn call_returns_promptly_after_deadline_leaves_nothing_running() {
let env = env();
let rules = default_rules();
let control = SearchControl::with_timeout(std::time::Duration::from_micros(1));
let t0 = std::time::Instant::now();
let result = find_routes_with_control(TARGET, &env, &rules, &cfg(0), &control).unwrap();
let call_elapsed = t0.elapsed();
assert_eq!(result.termination, SearchTermination::DeadlineExceeded);
assert!(
call_elapsed < std::time::Duration::from_secs(5),
"call took {call_elapsed:?} after an immediate deadline -- looks like it's \
blocking on something instead of returning promptly"
);
}
struct StubReranker;
impl crate::candidate::CandidateReranker for StubReranker {
fn score_pool(
&self,
_target: &str,
candidates: &mut [crate::candidate::ReactionCandidate],
) -> anyhow::Result<()> {
for c in candidates.iter_mut() {
c.reranker_score = Some(c.precursor_smiles.join(".").len() as f64);
}
Ok(())
}
}
#[test]
fn safe_with_and_without_reranker() {
let env = env();
let rules = default_rules();
let no_reranker = cfg(0);
let with_reranker = SearchConfig {
reranker: Some(std::sync::Arc::new(StubReranker)),
..cfg(0)
};
for config in [&no_reranker, &with_reranker] {
let unlimited =
find_routes_with_control(TARGET, &env, &rules, config, &SearchControl::unlimited())
.unwrap();
assert_eq!(unlimited.termination, SearchTermination::Completed);
assert_eq!(unlimited.stats.reranker_failures, 0);
let timed_out = find_routes_with_control(
TARGET,
&env,
&rules,
config,
&SearchControl::with_deadline(std::time::Instant::now()),
)
.unwrap();
assert_eq!(timed_out.termination, SearchTermination::DeadlineExceeded);
}
}
#[test]
fn safe_with_beam_width_zero_and_nonzero() {
let env = env();
let rules = default_rules();
for beam_width in [0usize, 10, 100] {
let config = cfg(beam_width);
let unlimited = find_routes_with_control(
TARGET,
&env,
&rules,
&config,
&SearchControl::unlimited(),
)
.unwrap();
assert_eq!(unlimited.termination, SearchTermination::Completed);
let timed_out = find_routes_with_control(
TARGET,
&env,
&rules,
&config,
&SearchControl::with_deadline(std::time::Instant::now()),
)
.unwrap();
assert_eq!(timed_out.termination, SearchTermination::DeadlineExceeded);
}
}
#[test]
fn max_routes_completion_wins_over_an_already_expired_deadline() {
let env = env();
let rules = default_rules();
let config = SearchConfig {
max_routes: 0,
..cfg(0)
};
let control = SearchControl::with_deadline(std::time::Instant::now());
let result = find_routes_with_control(TARGET, &env, &rules, &config, &control).unwrap();
assert_eq!(
result.termination,
SearchTermination::Completed,
"max_routes was already satisfied (trivially, at 0) -- must report Completed \
even though the deadline had also already passed"
);
assert!(result.routes.is_empty());
}
}
#[cfg(test)]
mod route_integrity_tests {
use super::*;
fn canon(smiles: &str) -> String {
to_canonical(&mol_from_smiles(smiles).unwrap())
}
fn step(target: &str, precursors: &[&str]) -> ReactionStep {
ReactionStep {
rule: "test_rule".to_string(),
template_id: "rule:test_rule".to_string(),
target: target.to_string(),
precursors: precursors.iter().map(|s| s.to_string()).collect(),
conditions: None,
atom_economy: None,
atom_economy_raw_percent: None,
atom_economy_status: AtomEconomyStatus::NotEvaluable,
step_confidence: 1.0,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
}
}
fn route(steps: Vec<ReactionStep>) -> Route {
Route {
steps,
depth: 1,
score: 0.0,
building_blocks: vec![],
confidence: 1.0,
convergency: 1.0,
success_probability: 1.0,
route_cost: 1.0,
}
}
#[test]
fn clean_route_has_no_defects() {
let root = canon("CC(=O)Oc1ccccc1");
let r = route(vec![step(&root, &["CC(=O)O", "Oc1ccccc1"])]);
let defects = route_integrity_defects(&r, &root);
assert!(defects.is_empty(), "expected no defects, got {defects:?}");
}
#[test]
fn empty_steps_is_depth_zero_and_always_passes() {
let r = route(vec![]);
assert!(route_integrity_defects(&r, &canon("CC(=O)O")).is_empty());
}
#[test]
fn flags_root_mismatch() {
let r = route(vec![step("CCO", &["CC=O"])]);
let defects = route_integrity_defects(&r, &canon("c1ccccc1"));
assert!(defects.contains(&RouteIntegrityDefect::RootMismatch));
}
#[test]
fn flags_unparseable_target_smiles() {
let r = route(vec![step("[C(", &["CCO"])]);
let defects = route_integrity_defects(&r, "[C(");
assert!(defects.contains(&RouteIntegrityDefect::UnparseableSmiles));
}
#[test]
fn flags_unparseable_precursor_smiles() {
let root = canon("CC(=O)O");
let r = route(vec![step(&root, &["[C(", "O"])]);
let defects = route_integrity_defects(&r, &root);
assert!(defects.contains(&RouteIntegrityDefect::UnparseableSmiles));
}
#[test]
fn flags_empty_precursor_list() {
let root = canon("CC(=O)O");
let r = route(vec![step(&root, &[])]);
let defects = route_integrity_defects(&r, &root);
assert!(defects.contains(&RouteIntegrityDefect::EmptyPrecursorList));
}
#[test]
fn flags_cycle() {
let a = canon("CCO");
let b = canon("CC=O");
let r = route(vec![step(&a, &[b.as_str()]), step(&b, &[a.as_str()])]);
let defects = route_integrity_defects(&r, &a);
assert!(defects.contains(&RouteIntegrityDefect::Cycle));
}
#[test]
fn flags_disconnected_step() {
let root = canon("CC(=O)Oc1ccccc1");
let orphan = canon("c1ccccc1");
let r = route(vec![
step(&root, &["CC(=O)O", "Oc1ccccc1"]),
step(&orphan, &["C1=CC=CC=C1"]),
]);
let defects = route_integrity_defects(&r, &root);
assert!(defects.contains(&RouteIntegrityDefect::Disconnected));
}
#[test]
fn flags_unaccounted_target_element() {
let root = canon("Brc1ccccc1");
let r = route(vec![step(&root, &["c1ccccc1"])]);
let defects = route_integrity_defects(&r, &root);
assert!(defects.contains(&RouteIntegrityDefect::UnaccountedTargetElement));
}
#[test]
fn isoindolinone_ring_disconnection_is_rejected_not_returned() {
let env = ChemEnv::load("data/building_blocks.smi").unwrap_or_else(|_| {
ChemEnv::in_memory(&["CC(=O)O", "Oc1ccccc1C(=O)O", "c1ccccc1C(=O)O", "C", "O"])
});
let rules = crate::chem_env::load_rules_from_file("data/templates_extracted_500.smi");
assert!(
!rules.is_empty(),
"requires the committed 500-template corpus"
);
let config = SearchConfig {
max_depth: 2,
max_routes: 5,
beam_width: 50,
..Default::default()
};
let (routes, stats) = find_routes("O=C1N(C)Cc2ccccc21", &env, &rules, &config).unwrap();
assert!(
routes.is_empty(),
"every candidate at this depth/beam is known to drop the \
target's nitrogen -- the gate must reject all of them, \
got {} routes",
routes.len()
);
assert!(
stats.route_integrity.unaccounted_target_element > 0,
"rejection must be attributed to unaccounted_target_element, \
got {:?}",
stats.route_integrity
);
}
}