use std::collections::HashMap;
use chematic::rxn::{TransformError, run_reactants};
use chematic::smarts::{QueryMolecule, parse_smarts};
use chematic::smiles::canonical_smiles;
use serde::Serialize;
use crate::bridge::audit::CheckStatus;
use crate::bridge::route_graph::ReactionEvidence;
use crate::chem_env::{Molecule, RetroRule, mol_from_smiles};
use crate::validation::forward::matches_target;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ForwardNotEvaluableReason {
MissingReactionRepresentation,
MissingAtomMapping,
UnsupportedReactionFormat,
UnsupportedTemplateSyntax,
ReactionApplicationError,
AmbiguousExpectedProduct,
}
#[derive(Debug, Clone, Serialize)]
pub struct ForwardValidationResult {
pub status: CheckStatus,
pub method: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<ForwardNotEvaluableReason>,
}
const METHOD: &str = "declared_reaction_replay";
fn not_evaluable(reason: ForwardNotEvaluableReason) -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::NotEvaluable,
method: METHOD,
reason: Some(reason),
}
}
fn pass() -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::Pass,
method: METHOD,
reason: None,
}
}
fn fail() -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::Fail,
method: METHOD,
reason: None,
}
}
fn has_atom_mapping(smirks: &str) -> bool {
let bytes = smirks.as_bytes();
bytes
.windows(2)
.any(|w| w[0] == b':' && w[1].is_ascii_digit())
}
fn declared_smirks<'a>(
evidence: &'a ReactionEvidence,
rules_by_template_id: Option<&'a HashMap<String, &'a RetroRule>>,
) -> Result<(&'a str, bool), ForwardNotEvaluableReason> {
use ForwardNotEvaluableReason::MissingReactionRepresentation;
match evidence {
ReactionEvidence::RenkinTemplate { template_id } => {
let rule = rules_by_template_id
.and_then(|m| m.get(template_id.as_str()))
.ok_or(MissingReactionRepresentation)?;
if rule.smirks.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((rule.smirks.as_str(), false))
}
ReactionEvidence::AiZynthFinderTemplate { smirks } => {
if smirks.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((smirks.as_str(), true))
}
ReactionEvidence::SyntheseusReaction { reaction_smiles } => {
if reaction_smiles.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((reaction_smiles.as_str(), true))
}
}
}
const MAX_PERMUTE_LEN: usize = 6;
fn index_permutations(n: usize) -> Vec<Vec<usize>> {
if n > MAX_PERMUTE_LEN {
return vec![(0..n).collect()];
}
fn permute(prefix: &mut Vec<usize>, remaining: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if remaining.is_empty() {
out.push(prefix.clone());
return;
}
for i in 0..remaining.len() {
let item = remaining.remove(i);
prefix.push(item);
permute(prefix, remaining, out);
prefix.pop();
remaining.insert(i, item);
}
}
let mut out = Vec::new();
permute(&mut Vec::new(), &mut (0..n).collect(), &mut out);
out
}
fn replay_orientations(
orientations: &[String],
target_canon: &str,
target_query: Option<&QueryMolecule>,
target_atom_count: usize,
precursor_mols: &[&Molecule],
) -> Result<bool, ForwardNotEvaluableReason> {
let mut ran_successfully = false;
let mut last_error = ForwardNotEvaluableReason::ReactionApplicationError;
let mut distinct_products: Vec<String> = Vec::new();
let permutations = index_permutations(precursor_mols.len());
for fwd in orientations {
for perm in &permutations {
let ordered: Vec<&Molecule> = perm.iter().map(|&i| precursor_mols[i]).collect();
match run_reactants(fwd, &ordered) {
Err(TransformError::SmirksParse(_)) => {
last_error = ForwardNotEvaluableReason::UnsupportedTemplateSyntax;
}
Err(TransformError::ReactantCountMismatch { .. }) => {
last_error = ForwardNotEvaluableReason::ReactionApplicationError;
}
Ok(results) => {
ran_successfully = true;
for m in results.into_iter().flatten() {
if matches_target(&m, target_canon, target_query, target_atom_count) {
return Ok(true);
}
let canon = canonical_smiles(&m);
if !distinct_products.contains(&canon) {
distinct_products.push(canon);
}
}
}
}
}
}
if !ran_successfully {
return Err(last_error);
}
match distinct_products.len() {
0 | 1 => Ok(false),
_ => Err(ForwardNotEvaluableReason::AmbiguousExpectedProduct),
}
}
pub fn validate_step_forward(
target_canonical: &str,
precursor_canonical: &[String],
evidence: Option<&ReactionEvidence>,
rules_by_template_id: Option<&HashMap<String, &RetroRule>>,
) -> ForwardValidationResult {
let Some(evidence) = evidence else {
return not_evaluable(ForwardNotEvaluableReason::MissingReactionRepresentation);
};
let (smirks, try_both_orientations) = match declared_smirks(evidence, rules_by_template_id) {
Ok(v) => v,
Err(reason) => return not_evaluable(reason),
};
if !has_atom_mapping(smirks) {
return not_evaluable(ForwardNotEvaluableReason::MissingAtomMapping);
}
let Some((lhs, rhs)) = smirks.split_once(">>") else {
return not_evaluable(ForwardNotEvaluableReason::UnsupportedReactionFormat);
};
let mut orientations = vec![format!("{rhs}>>{lhs}")];
if try_both_orientations {
orientations.push(smirks.to_string());
}
let Ok(target_mol) = mol_from_smiles(target_canonical) else {
return not_evaluable(ForwardNotEvaluableReason::ReactionApplicationError);
};
let Ok(precursor_mols): Result<Vec<_>, _> = precursor_canonical
.iter()
.map(|s| mol_from_smiles(s))
.collect()
else {
return not_evaluable(ForwardNotEvaluableReason::ReactionApplicationError);
};
let mol_refs: Vec<&Molecule> = precursor_mols.iter().collect();
let target_canon = canonical_smiles(&target_mol);
let target_query = parse_smarts(target_canonical).ok();
let target_atom_count = target_mol.atom_count();
match replay_orientations(
&orientations,
&target_canon,
target_query.as_ref(),
target_atom_count,
&mol_refs,
) {
Err(reason) => not_evaluable(reason),
Ok(true) => pass(),
Ok(false) => fail(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::candidate::index_rules_by_template_id;
fn co_aliphatic_cleavage() -> RetroRule {
RetroRule {
name: "co_aliphatic_cleavage".to_string(),
template_id: "t1".to_string(),
smirks: "[C:1][O:2]>>[C:1].[O:2]".to_string(),
..Default::default()
}
}
const METHANE: &str = "C";
const WATER: &str = "O";
const METHANOL: &str = "CO";
#[test]
fn precursor_order_does_not_affect_the_verdict() {
let evidence = ReactionEvidence::AiZynthFinderTemplate {
smirks: "[CH3:1][CH2:2][O:3][C:4](=[O:5])[c:6]1[cH:7][cH:8][c:9]([NH2:10])[cH:11][cH:12]1>>[C:4](=[O:5])([c:6]1[cH:7][cH:8][c:9]([NH2:10])[cH:11][cH:12]1)[OH:13].[CH3:1][CH2:2][OH:3]".to_string(),
};
let target = "CCOC(=O)c1ccc(N)cc1";
let ethanol = "CCO".to_string();
let amino_acid = "Nc1ccc(C(=O)O)cc1".to_string();
let as_listed_by_aizynthfinder = vec![ethanol.clone(), amino_acid.clone()];
let reversed = vec![amino_acid, ethanol];
for precursors in [as_listed_by_aizynthfinder, reversed] {
let result = validate_step_forward(target, &precursors, Some(&evidence), None);
assert_eq!(
result.status,
CheckStatus::Pass,
"precursor order must not change the verdict: {result:?} for {precursors:?}"
);
}
}
#[test]
fn renkin_native_step_that_replays_correctly_passes() {
let rule = co_aliphatic_cleavage();
let rules = vec![rule];
let by_id = index_rules_by_template_id(&rules).unwrap();
let evidence = ReactionEvidence::RenkinTemplate {
template_id: "t1".to_string(),
};
let precursors = vec![METHANE.to_string(), WATER.to_string()];
let result = validate_step_forward(METHANOL, &precursors, Some(&evidence), Some(&by_id));
assert_eq!(result.status, CheckStatus::Pass, "{result:?}");
assert_eq!(result.method, "declared_reaction_replay");
assert!(result.reason.is_none());
}
#[test]
fn renkin_native_step_producing_a_different_parent_fails() {
let rule = co_aliphatic_cleavage();
let rules = vec![rule];
let by_id = index_rules_by_template_id(&rules).unwrap();
let evidence = ReactionEvidence::RenkinTemplate {
template_id: "t1".to_string(),
};
let wrong_target = "CC";
let precursors = vec![METHANE.to_string(), WATER.to_string()];
let result =
validate_step_forward(wrong_target, &precursors, Some(&evidence), Some(&by_id));
assert_eq!(result.status, CheckStatus::Fail, "{result:?}");
assert!(result.reason.is_none());
}
#[test]
fn multiple_distinct_non_matching_products_is_ambiguous_not_fail() {
let rule = co_aliphatic_cleavage();
let rules = vec![rule];
let by_id = index_rules_by_template_id(&rules).unwrap();
let evidence = ReactionEvidence::RenkinTemplate {
template_id: "t1".to_string(),
};
let precursors = vec![
"[CH3][NH]C(CO[CH2][CH3])=O".to_string(),
"O[CH2][CH3]".to_string(),
];
let result = validate_step_forward("CCC", &precursors, Some(&evidence), Some(&by_id));
assert_eq!(result.status, CheckStatus::NotEvaluable, "{result:?}");
assert_eq!(
result.reason,
Some(ForwardNotEvaluableReason::AmbiguousExpectedProduct)
);
}
#[test]
fn aizynthfinder_step_with_no_evidence_is_not_evaluable() {
let result = validate_step_forward("CCOC", &["CCO".to_string()], None, None);
assert_eq!(result.status, CheckStatus::NotEvaluable);
assert_eq!(
result.reason,
Some(ForwardNotEvaluableReason::MissingReactionRepresentation)
);
}
#[test]
fn aizynthfinder_step_with_sufficient_metadata_passes() {
let evidence = ReactionEvidence::AiZynthFinderTemplate {
smirks: "[C:1][O:2].[C:3]>>[C:1][O:2][C:3]".to_string(),
};
let target = "CCOC";
let precursors = vec!["CCO".to_string(), "C".to_string()];
let result = validate_step_forward(target, &precursors, Some(&evidence), None);
assert_eq!(result.status, CheckStatus::Pass, "{result:?}");
}
#[test]
fn missing_atom_mapping_is_distinguished_from_missing_representation() {
let evidence = ReactionEvidence::AiZynthFinderTemplate {
smirks: "CCO.C>>CCOC".to_string(),
};
let result = validate_step_forward(
"CCOC",
&["CCO".to_string(), "C".to_string()],
Some(&evidence),
None,
);
assert_eq!(result.status, CheckStatus::NotEvaluable);
assert_eq!(
result.reason,
Some(ForwardNotEvaluableReason::MissingAtomMapping)
);
}
}