use std::collections::HashMap;
use chematic::core::Element;
use crate::chem_env::mol_from_smiles;
use crate::synthesizability::schema::ElementAccountingStatus;
pub(crate) struct ElementAccountingResult {
pub status: ElementAccountingStatus,
pub failing_step_indices: Vec<usize>,
}
fn heavy_atom_counts(smiles: &str) -> Option<HashMap<Element, usize>> {
let mol = mol_from_smiles(smiles).ok()?;
let mut counts: HashMap<Element, usize> = HashMap::new();
for (_, atom) in mol.atoms() {
if atom.element != Element::H {
*counts.entry(atom.element).or_insert(0) += 1;
}
}
Some(counts)
}
pub(crate) fn compute_element_accounting(route: &crate::search::Route) -> ElementAccountingResult {
let mut any_evaluated = false;
let mut failing_step_indices = Vec::new();
for (idx, step) in route.steps.iter().enumerate() {
let Some(target_counts) = heavy_atom_counts(&step.target) else {
continue;
};
let mut precursor_counts: HashMap<Element, usize> = HashMap::new();
let mut all_precursors_parsed = true;
for precursor in &step.precursors {
match heavy_atom_counts(precursor) {
Some(counts) => {
for (element, n) in counts {
*precursor_counts.entry(element).or_insert(0) += n;
}
}
None => {
all_precursors_parsed = false;
break;
}
}
}
if !all_precursors_parsed {
continue;
}
any_evaluated = true;
let step_fails = target_counts
.iter()
.any(|(element, n)| *n > precursor_counts.get(element).copied().unwrap_or(0));
if step_fails {
failing_step_indices.push(idx);
}
}
if !any_evaluated {
return ElementAccountingResult {
status: ElementAccountingStatus::NotEvaluable,
failing_step_indices: Vec::new(),
};
}
if failing_step_indices.is_empty() {
ElementAccountingResult {
status: ElementAccountingStatus::Accounted,
failing_step_indices: Vec::new(),
}
} else {
ElementAccountingResult {
status: ElementAccountingStatus::UnaccountedTargetElement,
failing_step_indices,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::{AtomEconomyStatus, ReactionStep, Route};
fn step(rule: &str, target: &str, precursors: &[&str]) -> ReactionStep {
ReactionStep {
rule: rule.to_string(),
template_id: format!("rule:{rule}"),
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_case_is_accounted() {
let r = route(vec![step(
"ester_cleavage",
"CC(=O)Oc1ccccc1",
&["CC(=O)O", "Oc1ccccc1"],
)]);
let result = compute_element_accounting(&r);
assert_eq!(result.status, ElementAccountingStatus::Accounted);
assert!(result.failing_step_indices.is_empty());
}
#[test]
fn clear_violation_is_unaccounted() {
let r = route(vec![step("suzuki_retro", "Brc1ccccc1", &["c1ccccc1"])]);
let result = compute_element_accounting(&r);
assert_eq!(
result.status,
ElementAccountingStatus::UnaccountedTargetElement
);
assert_eq!(result.failing_step_indices, vec![0]);
}
#[test]
fn precursor_excess_is_not_a_failure() {
let r = route(vec![step("amide_cleavage", "CC(N)=O", &["CC(=O)O", "CCN"])]);
let result = compute_element_accounting(&r);
assert_eq!(result.status, ElementAccountingStatus::Accounted);
}
#[test]
fn unparseable_target_makes_step_not_evaluable_but_other_steps_still_count() {
let r = route(vec![
step("bogus_rule", "[C(", &["CCO"]),
step("suzuki_retro", "Brc1ccccc1", &["c1ccccc1"]),
]);
let result = compute_element_accounting(&r);
assert_eq!(
result.status,
ElementAccountingStatus::UnaccountedTargetElement
);
assert_eq!(result.failing_step_indices, vec![1]);
}
#[test]
fn all_steps_unparseable_is_not_evaluable() {
let r = route(vec![step("bogus_rule", "[C(", &["[N("])]);
let result = compute_element_accounting(&r);
assert_eq!(result.status, ElementAccountingStatus::NotEvaluable);
assert!(result.failing_step_indices.is_empty());
}
#[test]
fn zero_step_route_is_not_evaluable() {
let r = route(vec![]);
let result = compute_element_accounting(&r);
assert_eq!(result.status, ElementAccountingStatus::NotEvaluable);
}
#[test]
fn atom_economy_status_alone_cannot_distinguish_omission_from_loss() {
use chematic::chem::molecular_weight;
let omission_target_w = molecular_weight(&mol_from_smiles("C1CCCCC1").unwrap());
let omission_precursor_w = molecular_weight(&mol_from_smiles("c1ccccc1").unwrap());
let omission_ratio = omission_target_w / omission_precursor_w * 100.0;
assert!(
omission_ratio > 100.0,
"fixture must exceed the expected range, got {omission_ratio}"
);
let mut omission_step = step("hydrogenation", "C1CCCCC1", &["c1ccccc1"]);
omission_step.atom_economy_status = AtomEconomyStatus::AboveExpectedRange;
omission_step.atom_economy_raw_percent = Some(omission_ratio);
let omission_route = route(vec![omission_step]);
let omission_accounting = compute_element_accounting(&omission_route);
let loss_target_w = molecular_weight(&mol_from_smiles("Nc1ccccc1").unwrap());
let loss_precursor_w = molecular_weight(&mol_from_smiles("c1ccccc1").unwrap());
let loss_ratio = loss_target_w / loss_precursor_w * 100.0;
assert!(
loss_ratio > 100.0,
"fixture must exceed the expected range, got {loss_ratio}"
);
let mut loss_step = step("amination", "Nc1ccccc1", &["c1ccccc1"]);
loss_step.atom_economy_status = AtomEconomyStatus::AboveExpectedRange;
loss_step.atom_economy_raw_percent = Some(loss_ratio);
let loss_route = route(vec![loss_step]);
let loss_accounting = compute_element_accounting(&loss_route);
assert_eq!(
omission_route.steps[0].atom_economy_status,
loss_route.steps[0].atom_economy_status
);
assert_eq!(
omission_accounting.status,
ElementAccountingStatus::Accounted
);
assert_eq!(
loss_accounting.status,
ElementAccountingStatus::UnaccountedTargetElement
);
}
#[test]
fn atom_economy_above_expected_range_with_missing_target_carbon() {
use chematic::chem::molecular_weight;
let target_w = molecular_weight(&mol_from_smiles("Cc1ccccc1").unwrap());
let precursor_w = molecular_weight(&mol_from_smiles("c1ccccc1").unwrap());
let ratio = target_w / precursor_w * 100.0;
assert!(
ratio > 100.0,
"fixture must exceed the expected range, got {ratio}"
);
let r = route(vec![step("methylation", "Cc1ccccc1", &["c1ccccc1"])]);
let result = compute_element_accounting(&r);
assert_eq!(
result.status,
ElementAccountingStatus::UnaccountedTargetElement
);
}
}