use serde::Deserialize;
use crate::bridge::audit::AuditFindingCode;
use crate::bridge::route_graph::{
ParseOutcome, ReactionEvidence, RouteDocument, RouteNode, RouteSource, count_edges,
};
use crate::chem_env::{mol_from_smiles, to_canonical};
#[derive(Debug, Deserialize)]
pub struct AzfNode {
#[serde(rename = "type")]
pub node_type: String,
pub smiles: String,
#[serde(default)]
pub in_stock: Option<bool>,
#[serde(default)]
pub metadata: Option<AzfMetadata>,
#[serde(default)]
pub children: Vec<AzfNode>,
}
#[derive(Debug, Deserialize)]
pub struct AzfMetadata {
#[serde(default)]
pub mapped_reaction_smiles: Option<String>,
}
fn canonicalize(smiles: &str) -> Option<String> {
mol_from_smiles(smiles).ok().map(|m| to_canonical(&m))
}
fn azf_mol_to_route_node(node: &AzfNode, defects: &mut Vec<AuditFindingCode>) -> RouteNode {
if node.node_type != "mol" {
defects.push(AuditFindingCode::RawOutputNotDecodable);
return RouteNode {
canonical_smiles: node.smiles.clone(),
is_stock_leaf: None,
reaction_evidence: None,
children: vec![],
};
}
let Some(canon) = canonicalize(&node.smiles) else {
defects.push(AuditFindingCode::UnparseableSmilesInRoute);
return RouteNode {
canonical_smiles: node.smiles.clone(),
is_stock_leaf: None,
reaction_evidence: None,
children: vec![],
};
};
if node.children.is_empty() {
if node.in_stock.is_none() {
defects.push(AuditFindingCode::AmbiguousLeafStatus);
}
return RouteNode {
canonical_smiles: canon,
is_stock_leaf: node.in_stock,
reaction_evidence: None,
children: vec![],
};
}
let [reaction] = node.children.as_slice() else {
defects.push(AuditFindingCode::RawOutputNotDecodable);
return RouteNode {
canonical_smiles: canon,
is_stock_leaf: Some(false),
reaction_evidence: None,
children: vec![],
};
};
if reaction.node_type != "reaction" {
defects.push(AuditFindingCode::RawOutputNotDecodable);
return RouteNode {
canonical_smiles: canon,
is_stock_leaf: Some(false),
reaction_evidence: None,
children: vec![],
};
}
let reaction_evidence = reaction
.metadata
.as_ref()
.and_then(|m| m.mapped_reaction_smiles.clone())
.map(|smirks| ReactionEvidence::AiZynthFinderTemplate { smirks });
let mut children = Vec::new();
for precursor in &reaction.children {
if precursor.node_type != "mol" {
defects.push(AuditFindingCode::RawOutputNotDecodable);
continue;
}
let Some(p_canon) = canonicalize(&precursor.smiles) else {
defects.push(AuditFindingCode::UnparseableSmilesInRoute);
continue;
};
if p_canon == canon {
defects.push(AuditFindingCode::DegenerateSelfReferentialStep);
continue;
}
children.push(azf_mol_to_route_node(precursor, defects));
}
if children.is_empty() {
defects.push(AuditFindingCode::ChildlessNonLeaf);
}
RouteNode {
canonical_smiles: canon,
is_stock_leaf: Some(false),
reaction_evidence,
children,
}
}
pub fn normalize_aizynthfinder_route(node: &AzfNode) -> ParseOutcome {
let mut defects = Vec::new();
let root = azf_mol_to_route_node(node, &mut defects);
let parseable = defects.is_empty();
let document = parseable.then(|| RouteDocument {
source: RouteSource::AiZynthFinder,
step_count_collapsed_edges: count_edges(&root),
root,
});
ParseOutcome {
source: RouteSource::AiZynthFinder,
document,
parseable,
defects,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load_fixture(name: &str) -> Vec<AzfNode> {
let path = format!(
"{}/tests/fixtures/aizynthfinder/v4.4.1/{name}",
env!("CARGO_MANIFEST_DIR")
);
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
serde_json::from_str(&content).unwrap_or_else(|e| panic!("{path}: {e}"))
}
#[test]
fn real_single_step_route_normalizes_cleanly() {
let routes = load_fixture("single_trees.json");
let outcome = normalize_aizynthfinder_route(&routes[0]);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert_eq!(doc.source, RouteSource::AiZynthFinder);
assert_eq!(doc.root.children.len(), 1);
assert!(matches!(
doc.root.reaction_evidence,
Some(ReactionEvidence::AiZynthFinderTemplate { .. })
));
}
#[test]
fn real_two_step_route_normalizes_cleanly_with_evidence_at_both_steps() {
let routes = load_fixture("single_trees.json");
let outcome = normalize_aizynthfinder_route(&routes[1]);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert_eq!(doc.step_count_collapsed_edges, 2);
let steps = doc.steps();
assert_eq!(steps.len(), 2);
for step in &steps {
assert!(
matches!(
step.reaction_evidence,
Some(ReactionEvidence::AiZynthFinderTemplate { .. })
),
"expected reaction_evidence on every step, got {:?}",
step.reaction_evidence
);
}
}
#[test]
fn real_route_leaves_carry_the_source_tools_own_stock_claim() {
let routes = load_fixture("single_trees.json");
let outcome = normalize_aizynthfinder_route(&routes[1]);
let doc = outcome.document.unwrap();
fn leaves(node: &RouteNode, out: &mut Vec<bool>) {
if node.children.is_empty() {
out.push(
node.is_stock_leaf
.expect("real fixture leaves are unambiguous"),
);
}
for c in &node.children {
leaves(c, out);
}
}
let mut claims = Vec::new();
leaves(&doc.root, &mut claims);
assert!(
claims.iter().all(|&c| c),
"real benzocaine route's leaves are all AiZynthFinder in_stock=true, got {claims:?}"
);
}
#[test]
fn missing_atom_mapping_fixture_has_no_reaction_evidence_at_the_mutated_step() {
let routes = load_fixture("single_trees_missing_atom_mapping.json");
let outcome = normalize_aizynthfinder_route(&routes[0]);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert!(
doc.root.reaction_evidence.is_none(),
"the mutated fixture's outer reaction node has mapped_reaction_smiles stripped -- \
normalize_aizynthfinder_route must not invent evidence that isn't there"
);
}
#[test]
fn structurally_corrupt_route_fails_loud_not_silently() {
let corrupt = AzfNode {
node_type: "mol".to_string(),
smiles: "CCO".to_string(),
in_stock: None,
metadata: None,
children: vec![
AzfNode {
node_type: "reaction".to_string(),
smiles: String::new(),
in_stock: None,
metadata: None,
children: vec![],
},
AzfNode {
node_type: "reaction".to_string(),
smiles: String::new(),
in_stock: None,
metadata: None,
children: vec![],
},
],
};
let outcome = normalize_aizynthfinder_route(&corrupt);
assert!(!outcome.parseable);
assert!(
outcome
.defects
.contains(&AuditFindingCode::RawOutputNotDecodable)
);
}
}