use std::collections::BTreeMap;
use serde::Deserialize;
use crate::bridge::audit::AuditFindingCode;
use crate::bridge::route_graph::{
ParseOutcome, ReactionEvidence, RouteDocument, RouteNode, RouteSource,
SynPlannerRuleProvenance, count_edges,
};
use crate::chem_env::{mol_from_smiles, to_canonical};
#[derive(Debug, Deserialize)]
pub struct SynPlannerNode {
#[serde(rename = "type")]
pub node_type: String,
pub smiles: String,
#[serde(default)]
pub in_stock: Option<bool>,
#[serde(default)]
pub rule_id: Option<serde_json::Value>,
#[serde(default)]
pub rule_source: Option<String>,
#[serde(default)]
pub rule_key: Option<String>,
#[serde(default)]
pub children: Vec<SynPlannerNode>,
}
fn scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn rule_provenance(node: &SynPlannerNode) -> Option<SynPlannerRuleProvenance> {
let rule_id = node.rule_id.as_ref().and_then(scalar_to_string);
if rule_id.is_none() && node.rule_source.is_none() && node.rule_key.is_none() {
return None;
}
Some(SynPlannerRuleProvenance {
rule_id,
rule_source: node.rule_source.clone(),
rule_key: node.rule_key.clone(),
})
}
fn canonicalize(smiles: &str) -> Option<String> {
mol_from_smiles(smiles).ok().map(|m| to_canonical(&m))
}
fn synplanner_mol_to_route_node(
node: &SynPlannerNode,
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 = Some(ReactionEvidence::SynPlannerReaction {
smiles: reaction.smiles.clone(),
rule_provenance: rule_provenance(reaction),
});
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(synplanner_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_synplanner_route(node: &SynPlannerNode) -> ParseOutcome {
let mut defects = Vec::new();
let root = synplanner_mol_to_route_node(node, &mut defects);
let parseable = defects.is_empty();
let document = parseable.then(|| RouteDocument {
source: RouteSource::SynPlanner,
step_count_collapsed_edges: count_edges(&root),
root,
});
ParseOutcome {
source: RouteSource::SynPlanner,
document,
parseable,
defects,
}
}
pub fn parse_synplanner_routes(
value: serde_json::Value,
) -> Result<BTreeMap<String, SynPlannerNode>, serde_json::Error> {
serde_json::from_value(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn load_fixture(name: &str) -> BTreeMap<String, SynPlannerNode> {
let path = format!(
"{}/tests/fixtures/synplanner/v1.6.0/{name}",
env!("CARGO_MANIFEST_DIR")
);
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
let value: serde_json::Value =
serde_json::from_str(&content).unwrap_or_else(|e| panic!("{path}: {e}"));
parse_synplanner_routes(value).unwrap_or_else(|e| panic!("{path}: {e}"))
}
#[test]
fn real_two_step_hand_built_route_normalizes_cleanly_with_rule_provenance() {
let routes = load_fixture("route_1_two_step.json");
let (_, node) = routes.iter().next().unwrap();
let outcome = normalize_synplanner_route(node);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
assert_eq!(doc.source, RouteSource::SynPlanner);
assert_eq!(doc.step_count_collapsed_edges, 2);
let steps = doc.steps();
assert_eq!(steps.len(), 2);
for step in &steps {
match &step.reaction_evidence {
Some(ReactionEvidence::SynPlannerReaction {
smiles,
rule_provenance,
}) => {
assert!(!smiles.is_empty());
let rp = rule_provenance
.as_ref()
.expect("this fixture's route_metadata populates rule provenance");
assert!(rp.rule_id.is_some());
}
other => panic!("expected SynPlannerReaction evidence, got {other:?}"),
}
}
}
#[test]
fn full_fields_fixture_carries_rule_id_as_a_string_despite_json_number_source() {
let routes = load_fixture("route_3_full_fields.json");
let (route_id, node) = routes.iter().next().unwrap();
assert_eq!(
route_id, "7",
"non-sequential route ID must round-trip as-is"
);
let outcome = normalize_synplanner_route(node);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
let reaction_evidence = doc.root.reaction_evidence.as_ref().unwrap();
let ReactionEvidence::SynPlannerReaction {
rule_provenance, ..
} = reaction_evidence
else {
panic!("expected SynPlannerReaction evidence");
};
let rp = rule_provenance.as_ref().unwrap();
assert_eq!(rp.rule_id.as_deref(), Some("17"));
assert_eq!(rp.rule_source.as_deref(), Some("handcrafted"));
assert_eq!(rp.rule_key.as_deref(), Some("chy:0017"));
}
#[test]
fn real_planning_route_has_no_rule_provenance_since_cli_never_passes_route_metadata() {
let routes = load_fixture("real_planning_route_2step.json");
let (_, node) = routes.iter().next().unwrap();
let outcome = normalize_synplanner_route(node);
assert!(outcome.parseable, "{:?}", outcome.defects);
let doc = outcome.document.unwrap();
for step in doc.steps() {
match step.reaction_evidence {
Some(ReactionEvidence::SynPlannerReaction {
smiles,
rule_provenance,
}) => {
assert!(!smiles.is_empty());
assert!(
rule_provenance.is_none(),
"real synplan planning CLI output never passes route_metadata"
);
}
other => panic!("expected SynPlannerReaction evidence, got {other:?}"),
}
}
}
#[test]
fn real_planning_route_atom_mapped_smiles_actually_passes_forward_validation() {
let routes = load_fixture("real_planning_route_1step.json");
let (_, node) = routes.iter().next().unwrap();
let outcome = normalize_synplanner_route(node);
let doc = outcome.document.unwrap();
let step = &doc.steps()[0];
let result = crate::bridge::forward::validate_step_forward(
&step.target,
&step.precursors,
step.reaction_evidence.as_ref(),
None,
);
assert_ne!(
result.reason,
Some(crate::bridge::forward::ForwardNotEvaluableReason::MissingAtomMapping),
"real planning output's atom maps must reach the gate unchanged: {result:?}"
);
}
#[test]
fn stock_leaf_without_in_stock_field_is_ambiguous_not_guessed() {
let corrupt = SynPlannerNode {
node_type: "mol".to_string(),
smiles: "CCO".to_string(),
in_stock: None,
rule_id: None,
rule_source: None,
rule_key: None,
children: vec![],
};
let outcome = normalize_synplanner_route(&corrupt);
assert!(!outcome.parseable);
assert!(
outcome
.defects
.contains(&AuditFindingCode::AmbiguousLeafStatus)
);
}
#[test]
fn structurally_corrupt_route_fails_loud_not_silently() {
let corrupt = SynPlannerNode {
node_type: "mol".to_string(),
smiles: "CCO".to_string(),
in_stock: None,
rule_id: None,
rule_source: None,
rule_key: None,
children: vec![
SynPlannerNode {
node_type: "reaction".to_string(),
smiles: String::new(),
in_stock: None,
rule_id: None,
rule_source: None,
rule_key: None,
children: vec![],
},
SynPlannerNode {
node_type: "reaction".to_string(),
smiles: String::new(),
in_stock: None,
rule_id: None,
rule_source: None,
rule_key: None,
children: vec![],
},
],
};
let outcome = normalize_synplanner_route(&corrupt);
assert!(!outcome.parseable);
assert!(
outcome
.defects
.contains(&AuditFindingCode::RawOutputNotDecodable)
);
}
}