use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use crate::bridge::audit::AuditFindingCode;
use crate::bridge::route_graph::{
ParseOutcome, ReactionEvidence, RouteDocument, RouteSource, StepInfo, build, count_edges,
};
use crate::chem_env::{mol_from_smiles, to_canonical};
#[derive(Debug, Deserialize)]
pub struct SyntheseusRouteV1 {
#[serde(default)]
pub schema_version: Option<u32>,
pub target: String,
#[serde(default)]
pub steps: Vec<SyntheseusStep>,
#[serde(default)]
pub starting_molecules: Vec<String>,
#[serde(default)]
pub molecule_metadata: HashMap<String, SyntheseusMoleculeMetadata>,
}
#[derive(Debug, Deserialize)]
pub struct SyntheseusStep {
pub product: String,
pub reactants: Vec<String>,
pub reaction_metadata: SyntheseusReactionMetadata,
}
#[derive(Debug, Deserialize)]
pub struct SyntheseusReactionMetadata {
pub reaction_smiles: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct SyntheseusMoleculeMetadata {
#[serde(default)]
pub is_purchasable: Option<bool>,
}
fn canonicalize(smiles: &str) -> Option<String> {
mol_from_smiles(smiles).ok().map(|m| to_canonical(&m))
}
pub fn normalize_syntheseus_route(input: &SyntheseusRouteV1) -> ParseOutcome {
let mut defects = Vec::new();
if input.schema_version != Some(1) {
return ParseOutcome {
source: RouteSource::Syntheseus,
document: None,
parseable: false,
defects: vec![AuditFindingCode::RawOutputNotDecodable],
};
}
if input.steps.is_empty() {
return ParseOutcome {
source: RouteSource::Syntheseus,
document: None,
parseable: false,
defects: vec![AuditFindingCode::MultipleOrZeroRoots],
};
}
let Some(target_canon) = canonicalize(&input.target) else {
return ParseOutcome {
source: RouteSource::Syntheseus,
document: None,
parseable: false,
defects: vec![AuditFindingCode::UnparseableSmilesInRoute],
};
};
let mut starting_canon: HashSet<String> = HashSet::new();
for smi in &input.starting_molecules {
match canonicalize(smi) {
Some(c) => {
starting_canon.insert(c);
}
None => defects.push(AuditFindingCode::UnparseableSmilesInRoute),
}
}
let mut purchasable_by_canon: HashMap<String, Option<bool>> = HashMap::new();
for (smi, meta) in &input.molecule_metadata {
if let Some(c) = canonicalize(smi) {
purchasable_by_canon.insert(c, meta.is_purchasable);
}
}
let mut steps_by_target: HashMap<String, StepInfo> = HashMap::new();
for step in &input.steps {
match canonicalize(&step.product) {
Some(canon) => {
steps_by_target.insert(
canon,
StepInfo {
precursors: step.reactants.as_slice(),
reaction_evidence: ReactionEvidence::SyntheseusReaction {
reaction_smiles: step.reaction_metadata.reaction_smiles.clone(),
},
},
);
}
None => defects.push(AuditFindingCode::UnparseableSmilesInRoute),
}
}
let root_canon = canonicalize(&input.steps[0].product);
if root_canon.as_deref() != Some(target_canon.as_str()) {
defects.push(AuditFindingCode::RootMismatch);
}
let resolve_leaf = |smi: &str| -> (Option<bool>, Option<AuditFindingCode>) {
if !starting_canon.contains(smi) {
return (None, Some(AuditFindingCode::AmbiguousLeafStatus));
}
match purchasable_by_canon.get(smi).copied().flatten() {
Some(p) => (Some(p), None),
None => (None, Some(AuditFindingCode::AmbiguousLeafStatus)),
}
};
let mut on_stack: HashSet<String> = HashSet::new();
let root_start = root_canon.unwrap_or(target_canon);
let root_node = build(
&root_start,
&steps_by_target,
&resolve_leaf,
&mut on_stack,
&mut defects,
);
let parseable = defects.is_empty();
let document = parseable.then(|| RouteDocument {
source: RouteSource::Syntheseus,
step_count_collapsed_edges: count_edges(&root_node),
root: root_node,
});
ParseOutcome {
source: RouteSource::Syntheseus,
document,
parseable,
defects,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::route_graph::RouteNode;
fn load_fixture(name: &str) -> SyntheseusRouteV1 {
let path = format!(
"{}/tests/fixtures/syntheseus/0.7.2/{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_linear_route_normalizes_cleanly() {
let input = load_fixture("linear_two_leaf_route.json");
let outcome = normalize_syntheseus_route(&input);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert_eq!(doc.source, RouteSource::Syntheseus);
assert_eq!(doc.root.children.len(), 2);
assert!(
doc.root
.children
.iter()
.all(|c| c.is_stock_leaf == Some(true))
);
assert!(matches!(
doc.root.reaction_evidence,
Some(ReactionEvidence::SyntheseusReaction { .. })
));
}
#[test]
fn real_convergent_route_normalizes_by_duplicating_the_shared_subtree() {
let mut input = load_fixture("convergent_route.json");
input
.molecule_metadata
.get_mut("CC")
.expect("fixture has a CC entry")
.is_purchasable = Some(true);
let outcome = normalize_syntheseus_route(&input);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert_eq!(doc.step_count_collapsed_edges, 4);
let leaves: Vec<&RouteNode> = {
fn collect<'a>(node: &'a RouteNode, out: &mut Vec<&'a RouteNode>) {
if node.children.is_empty() {
out.push(node);
} else {
for c in &node.children {
collect(c, out);
}
}
}
let mut out = Vec::new();
collect(&doc.root, &mut out);
out
};
assert_eq!(leaves.len(), 2, "CC appears as a leaf under both parents");
assert!(leaves.iter().all(|l| l.canonical_smiles == "CC"));
assert!(
leaves.iter().all(|l| l.is_stock_leaf == Some(true)),
"both duplicated CC leaves carry the same resolved purchasability claim"
);
}
#[test]
fn convergent_fixture_ambiguous_leaf_is_a_gating_finding() {
let input = load_fixture("convergent_route.json");
let outcome = normalize_syntheseus_route(&input);
assert!(!outcome.parseable);
assert!(
outcome
.defects
.contains(&AuditFindingCode::AmbiguousLeafStatus)
);
}
#[test]
fn wrong_schema_version_is_rejected() {
let mut input = load_fixture("linear_two_leaf_route.json");
input.schema_version = Some(2);
let outcome = normalize_syntheseus_route(&input);
assert!(!outcome.parseable);
assert_eq!(
outcome.defects,
vec![AuditFindingCode::RawOutputNotDecodable]
);
}
#[test]
fn empty_steps_is_multiple_or_zero_roots() {
let mut input = load_fixture("linear_two_leaf_route.json");
input.steps.clear();
let outcome = normalize_syntheseus_route(&input);
assert!(!outcome.parseable);
assert_eq!(outcome.defects, vec![AuditFindingCode::MultipleOrZeroRoots]);
}
#[test]
fn root_mismatch_detected() {
let mut input = load_fixture("linear_two_leaf_route.json");
input.target = "CCN".to_string();
let outcome = normalize_syntheseus_route(&input);
assert!(!outcome.parseable);
assert!(outcome.defects.contains(&AuditFindingCode::RootMismatch));
}
#[test]
fn leaf_outside_starting_molecules_is_ambiguous() {
let mut input = load_fixture("linear_two_leaf_route.json");
input.starting_molecules.clear();
let outcome = normalize_syntheseus_route(&input);
assert!(!outcome.parseable);
assert!(
outcome
.defects
.contains(&AuditFindingCode::AmbiguousLeafStatus)
);
}
}