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, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceBasis {
DeclaredRuleTemplate,
DerivedGraphRuleRoundtrip,
SourceToolReaction,
}
#[derive(Debug, Clone, Serialize)]
pub struct ForwardValidationResult {
pub status: CheckStatus,
pub method: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence_basis: Option<EvidenceBasis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<ForwardNotEvaluableReason>,
}
const METHOD: &str = "declared_reaction_replay";
fn not_evaluable(
reason: ForwardNotEvaluableReason,
evidence_basis: Option<EvidenceBasis>,
) -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::NotEvaluable,
method: METHOD,
evidence_basis,
reason: Some(reason),
}
}
fn pass(evidence_basis: EvidenceBasis) -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::Pass,
method: METHOD,
evidence_basis: Some(evidence_basis),
reason: None,
}
}
fn fail(evidence_basis: EvidenceBasis) -> ForwardValidationResult {
ForwardValidationResult {
status: CheckStatus::Fail,
method: METHOD,
evidence_basis: Some(evidence_basis),
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, EvidenceBasis), ForwardNotEvaluableReason> {
use ForwardNotEvaluableReason::MissingReactionRepresentation;
match evidence {
ReactionEvidence::RenkinTemplate {
template_id,
declared_smirks,
} => {
if let Some(s) = declared_smirks {
if s.is_empty() {
return Err(MissingReactionRepresentation);
}
return Ok((s.as_str(), false, EvidenceBasis::DerivedGraphRuleRoundtrip));
}
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,
EvidenceBasis::DeclaredRuleTemplate,
))
}
ReactionEvidence::AiZynthFinderTemplate { smirks } => {
if smirks.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((smirks.as_str(), true, EvidenceBasis::SourceToolReaction))
}
ReactionEvidence::SyntheseusReaction { reaction_smiles } => {
if reaction_smiles.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((
reaction_smiles.as_str(),
true,
EvidenceBasis::SourceToolReaction,
))
}
ReactionEvidence::SynPlannerReaction { smiles, .. } => {
if smiles.is_empty() {
return Err(MissingReactionRepresentation);
}
Ok((smiles.as_str(), true, EvidenceBasis::SourceToolReaction))
}
}
}
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,
None,
);
};
let (smirks, try_both_orientations, basis) =
match declared_smirks(evidence, rules_by_template_id) {
Ok(v) => v,
Err(reason) => return not_evaluable(reason, None),
};
if !has_atom_mapping(smirks) {
return not_evaluable(ForwardNotEvaluableReason::MissingAtomMapping, Some(basis));
}
let Some((lhs, rhs)) = smirks.split_once(">>") else {
return not_evaluable(
ForwardNotEvaluableReason::UnsupportedReactionFormat,
Some(basis),
);
};
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,
Some(basis),
);
};
let Ok(precursor_mols): Result<Vec<_>, _> = precursor_canonical
.iter()
.map(|s| mol_from_smiles(s))
.collect()
else {
return not_evaluable(
ForwardNotEvaluableReason::ReactionApplicationError,
Some(basis),
);
};
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, Some(basis)),
Ok(true) => pass(basis),
Ok(false) => fail(basis),
}
}
#[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(),
declared_smirks: None,
};
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_eq!(
result.evidence_basis,
Some(EvidenceBasis::DeclaredRuleTemplate),
"{result:?}"
);
assert!(result.reason.is_none());
}
#[test]
fn renkin_graph_based_rule_step_reports_derived_graph_rule_roundtrip_basis() {
let target = "CC(=O)Oc1ccccc1C(=O)O";
let precursors = vec!["CC(O)=O".to_string(), "Oc1ccccc1C(=O)O".to_string()];
let smirks =
crate::chem_env::declared_forward_smirks("ester_cleavage", target, &precursors)
.expect("must derive a real forward smirks for this ester_cleavage outcome");
let evidence = ReactionEvidence::RenkinTemplate {
template_id: "rule:ester_cleavage".to_string(),
declared_smirks: Some(smirks),
};
let result = validate_step_forward(target, &precursors, Some(&evidence), None);
assert_eq!(result.status, CheckStatus::Pass, "{result:?}");
assert_eq!(
result.evidence_basis,
Some(EvidenceBasis::DerivedGraphRuleRoundtrip),
"{result:?}"
);
}
#[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(),
declared_smirks: None,
};
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(),
declared_smirks: None,
};
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)
);
assert_eq!(
result.evidence_basis, None,
"no ReactionEvidence at all -> no basis to report: {result:?}"
);
}
#[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:?}");
assert_eq!(
result.evidence_basis,
Some(EvidenceBasis::SourceToolReaction),
"{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)
);
assert_eq!(
result.evidence_basis,
Some(EvidenceBasis::SourceToolReaction),
"{result:?}"
);
}
#[test]
fn renkin_graph_based_rule_step_with_unresolvable_template_id_reports_no_basis() {
let evidence = ReactionEvidence::RenkinTemplate {
template_id: "rule:ester_cleavage".to_string(),
declared_smirks: None,
};
let result = validate_step_forward(
"CC(=O)Oc1ccccc1C(=O)O",
&["C".to_string(), "O".to_string()],
Some(&evidence),
None,
);
assert_eq!(result.status, CheckStatus::NotEvaluable, "{result:?}");
assert_eq!(
result.reason,
Some(ForwardNotEvaluableReason::MissingReactionRepresentation)
);
assert_eq!(result.evidence_basis, None, "{result:?}");
}
}