use std::collections::{HashMap, HashSet};
use chematic::core::Element;
use serde::Serialize;
use crate::bridge::route_graph::{ParseOutcome, RouteDocument, RouteNode, RouteSource};
use crate::chem_env::{RetroRule, mol_from_smiles};
use crate::synthesizability::heavy_atom_counts;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditFindingCode {
RawOutputNotDecodable,
MultipleOrZeroRoots,
RootMismatch,
CycleDetected,
DisconnectedReference,
UnparseableSmilesInRoute,
ChildlessNonLeaf,
AmbiguousLeafStatus,
DegenerateSelfReferentialStep,
StepArityMismatch,
LeafClaimedStockNotMatched,
LeafUnresolved,
UnaccountedTargetElement,
ChargeImbalance,
StereoCenterCountMismatch,
ForwardReactionNotReproduced,
ForwardValidationNotEvaluable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditSeverity {
Gating,
Informational,
}
impl AuditFindingCode {
fn severity(self) -> AuditSeverity {
match self {
AuditFindingCode::ChargeImbalance
| AuditFindingCode::StereoCenterCountMismatch
| AuditFindingCode::ForwardValidationNotEvaluable => AuditSeverity::Informational,
_ => AuditSeverity::Gating,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditFinding {
pub code: AuditFindingCode,
pub severity: AuditSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub node: Option<String>,
}
impl AuditFinding {
fn new(code: AuditFindingCode) -> Self {
Self {
code,
severity: code.severity(),
node: None,
}
}
fn at(code: AuditFindingCode, node: impl Into<String>) -> Self {
Self {
code,
severity: code.severity(),
node: Some(node.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditStatus {
Pass,
Fail,
Partial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Pass,
Fail,
NotEvaluable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StockNotEvaluableReason {
StockNotProvided,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct StockValidationResult {
pub status: CheckStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<StockNotEvaluableReason>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditedStep {
pub target: String,
pub precursors: Vec<String>,
pub forward_validation: crate::bridge::forward::ForwardValidationResult,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditReport {
pub source: RouteSource,
pub status: AuditStatus,
pub route_tree_parseable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reaction_steps_parseable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stock_validation: Option<StockValidationResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_element_accounting_status: Option<crate::synthesizability::ElementAccountingStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub normalized_route_sha256: Option<String>,
pub steps: Vec<AuditedStep>,
pub findings: Vec<AuditFinding>,
}
fn parse_failure_report(source: RouteSource, defects: &[AuditFindingCode]) -> AuditReport {
AuditReport {
source,
status: AuditStatus::Fail,
route_tree_parseable: false,
reaction_steps_parseable: None,
stock_validation: None,
target_element_accounting_status: None,
normalized_route_sha256: None,
steps: Vec::new(),
findings: defects.iter().copied().map(AuditFinding::new).collect(),
}
}
fn reaction_steps_parseable(root: &RouteNode) -> bool {
fn walk(node: &RouteNode) -> bool {
let mut ok = true;
for child in &node.children {
if child.canonical_smiles == node.canonical_smiles {
ok = false;
}
ok = walk(child) && ok;
}
ok
}
walk(root)
}
fn validate_stock_leaves(
root: &RouteNode,
configured_stock: &HashSet<String>,
) -> (bool, Vec<AuditFinding>) {
let mut findings = Vec::new();
let mut all_ok = true;
fn iter_leaves<'a>(node: &'a RouteNode, out: &mut Vec<&'a RouteNode>) {
if node.children.is_empty() {
out.push(node);
} else {
for c in &node.children {
iter_leaves(c, out);
}
}
}
let mut leaves = Vec::new();
iter_leaves(root, &mut leaves);
for leaf in leaves {
match leaf.is_stock_leaf {
Some(true) => {
if !configured_stock.contains(&leaf.canonical_smiles) {
findings.push(AuditFinding::at(
AuditFindingCode::LeafClaimedStockNotMatched,
leaf.canonical_smiles.clone(),
));
all_ok = false;
}
}
Some(false) | None => {
findings.push(AuditFinding::at(
AuditFindingCode::LeafUnresolved,
leaf.canonical_smiles.clone(),
));
all_ok = false;
}
}
}
(all_ok, findings)
}
fn net_charge(canonical_smiles: &str) -> Option<i32> {
let mol = mol_from_smiles(canonical_smiles).ok()?;
Some(mol.atoms().map(|(_, a)| i32::from(a.charge)).sum())
}
fn stereo_center_count(canonical_smiles: &str) -> Option<usize> {
let mol = mol_from_smiles(canonical_smiles).ok()?;
Some(
mol.atoms()
.filter(|(_, a)| a.chirality != chematic::core::Chirality::None)
.count(),
)
}
fn target_element_accounting(
root: &RouteNode,
) -> (
crate::synthesizability::ElementAccountingStatus,
Vec<AuditFinding>,
) {
use crate::synthesizability::ElementAccountingStatus;
let mut findings = Vec::new();
let mut any_evaluated = false;
let mut unaccounted = false;
fn walk(
node: &RouteNode,
any_evaluated: &mut bool,
unaccounted: &mut bool,
findings: &mut Vec<AuditFinding>,
) {
if !node.children.is_empty()
&& let Some(target_counts) = heavy_atom_counts(&node.canonical_smiles)
{
let mut precursor_counts: HashMap<Element, usize> = HashMap::new();
let mut countable = true;
for c in &node.children {
match heavy_atom_counts(&c.canonical_smiles) {
Some(counts) => {
for (el, n) in counts {
*precursor_counts.entry(el).or_insert(0) += n;
}
}
None => countable = false,
}
}
if countable {
*any_evaluated = true;
let elements_in_excess = target_counts
.iter()
.any(|(el, n)| *n > precursor_counts.get(el).copied().unwrap_or(0));
if elements_in_excess {
*unaccounted = true;
findings.push(AuditFinding::at(
AuditFindingCode::UnaccountedTargetElement,
node.canonical_smiles.clone(),
));
}
let target_charge = net_charge(&node.canonical_smiles);
let precursor_charge: i32 = node
.children
.iter()
.map(|c| net_charge(&c.canonical_smiles).unwrap_or(0))
.sum();
if target_charge.is_some_and(|t| t != precursor_charge) {
findings.push(AuditFinding::at(
AuditFindingCode::ChargeImbalance,
node.canonical_smiles.clone(),
));
}
let target_stereo = stereo_center_count(&node.canonical_smiles);
let precursor_stereo: usize = node
.children
.iter()
.map(|c| stereo_center_count(&c.canonical_smiles).unwrap_or(0))
.sum();
if target_stereo.is_some_and(|t| t != precursor_stereo) {
findings.push(AuditFinding::at(
AuditFindingCode::StereoCenterCountMismatch,
node.canonical_smiles.clone(),
));
}
}
}
for c in &node.children {
walk(c, any_evaluated, unaccounted, findings);
}
}
walk(root, &mut any_evaluated, &mut unaccounted, &mut findings);
let status = if !any_evaluated {
ElementAccountingStatus::NotEvaluable
} else if unaccounted {
ElementAccountingStatus::UnaccountedTargetElement
} else {
ElementAccountingStatus::Accounted
};
(status, findings)
}
pub fn audit(
outcome: &ParseOutcome,
configured_stock: Option<&HashSet<String>>,
rules: Option<&[RetroRule]>,
) -> AuditReport {
let (Some(document), true) = (&outcome.document, outcome.parseable) else {
return parse_failure_report(outcome.source, &outcome.defects);
};
audit_document(document, configured_stock, rules)
}
pub fn audit_document(
document: &RouteDocument,
configured_stock: Option<&HashSet<String>>,
rules: Option<&[RetroRule]>,
) -> AuditReport {
let mut findings = Vec::new();
let steps_ok = reaction_steps_parseable(&document.root);
if !steps_ok {
findings.push(AuditFinding::new(
AuditFindingCode::DegenerateSelfReferentialStep,
));
}
let stock_validation = match configured_stock {
Some(stock) => {
let (ok, f) = validate_stock_leaves(&document.root, stock);
findings.extend(f);
StockValidationResult {
status: if ok {
CheckStatus::Pass
} else {
CheckStatus::Fail
},
reason: None,
}
}
None => StockValidationResult {
status: CheckStatus::NotEvaluable,
reason: Some(StockNotEvaluableReason::StockNotProvided),
},
};
let (element_status, element_findings) = target_element_accounting(&document.root);
findings.extend(element_findings);
let rules_by_template_id =
rules.and_then(|rs| crate::candidate::index_rules_by_template_id(rs).ok());
let steps: Vec<AuditedStep> = document
.steps()
.into_iter()
.map(|step| {
let forward_validation = crate::bridge::forward::validate_step_forward(
&step.target,
&step.precursors,
step.reaction_evidence.as_ref(),
rules_by_template_id.as_ref(),
);
match forward_validation.status {
CheckStatus::Fail => findings.push(AuditFinding::at(
AuditFindingCode::ForwardReactionNotReproduced,
step.target.clone(),
)),
CheckStatus::NotEvaluable => findings.push(AuditFinding::at(
AuditFindingCode::ForwardValidationNotEvaluable,
step.target.clone(),
)),
CheckStatus::Pass => {}
}
AuditedStep {
target: step.target,
precursors: step.precursors,
forward_validation,
}
})
.collect();
let any_fail = !steps_ok
|| findings.iter().any(|f| f.severity == AuditSeverity::Gating)
|| stock_validation.status == CheckStatus::Fail
|| steps
.iter()
.any(|s| s.forward_validation.status == CheckStatus::Fail);
let any_not_evaluable = stock_validation.status == CheckStatus::NotEvaluable
|| element_status == crate::synthesizability::ElementAccountingStatus::NotEvaluable
|| steps
.iter()
.any(|s| s.forward_validation.status == CheckStatus::NotEvaluable);
let status = if any_fail {
AuditStatus::Fail
} else if any_not_evaluable {
AuditStatus::Partial
} else {
AuditStatus::Pass
};
AuditReport {
source: document.source,
status,
route_tree_parseable: true,
reaction_steps_parseable: Some(steps_ok),
stock_validation: Some(stock_validation),
target_element_accounting_status: Some(element_status),
normalized_route_sha256: Some(crate::bridge::route_graph::normalized_route_sha256(
document,
)),
steps,
findings,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::route_graph::normalize_renkin_route;
use crate::search::{AtomEconomyStatus, ReactionStep, Route};
use crate::synthesizability::ElementAccountingStatus;
const TARGET: &str = "CCOC(=O)c1ccccc1";
const ETHANOL: &str = "CCO";
const BENZOIC_ACID: &str = "O=C(O)c1ccccc1";
fn canon(smiles: &str) -> String {
crate::chem_env::to_canonical(&mol_from_smiles(smiles).unwrap())
}
fn step(target: &str, precursors: &[&str]) -> ReactionStep {
ReactionStep {
rule: "r".to_string(),
template_id: "t1".to_string(),
target: target.to_string(),
precursors: precursors.iter().map(|s| s.to_string()).collect(),
conditions: None,
atom_economy: None,
atom_economy_raw_percent: None,
atom_economy_status: AtomEconomyStatus::NotEvaluable,
step_confidence: 1.0,
procedure_hint: None,
reaction_family: None,
metadata_source: None,
metadata_scope: None,
evidence: None,
}
}
fn route(steps: Vec<ReactionStep>, building_blocks: &[&str]) -> Route {
Route {
steps,
depth: 1,
score: 1.0,
building_blocks: building_blocks.iter().map(|s| s.to_string()).collect(),
confidence: 1.0,
convergency: 1.0,
success_probability: 1.0,
route_cost: 1.0,
}
}
fn single_step_route() -> Route {
route(
vec![step(TARGET, &[ETHANOL, BENZOIC_ACID])],
&[ETHANOL, BENZOIC_ACID],
)
}
fn stock(smiles: &[&str]) -> HashSet<String> {
smiles.iter().map(|s| canon(s)).collect()
}
#[test]
fn all_leaves_matched_and_element_accounted_is_partial_without_forward_rules() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let report = audit(
&outcome,
Some(&stock(&[ETHANOL, BENZOIC_ACID, "CCN"])),
None,
);
assert_eq!(report.status, AuditStatus::Partial);
assert_eq!(
report.stock_validation.as_ref().map(|s| s.status),
Some(CheckStatus::Pass)
);
assert_eq!(
report.target_element_accounting_status,
Some(ElementAccountingStatus::Accounted)
);
assert_eq!(
report.steps[0].forward_validation.status,
CheckStatus::NotEvaluable
);
}
#[test]
fn leaf_claimed_stock_but_not_configured_fails() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let report = audit(&outcome, Some(&stock(&[ETHANOL])), None);
assert_eq!(report.status, AuditStatus::Fail);
assert_eq!(
report.stock_validation.as_ref().map(|s| s.status),
Some(CheckStatus::Fail)
);
assert!(
report
.findings
.iter()
.any(|f| f.code == AuditFindingCode::LeafClaimedStockNotMatched
&& f.node.as_deref() == Some(canon(BENZOIC_ACID).as_str()))
);
}
#[test]
fn no_configured_stock_is_partial_not_a_silent_pass() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let report = audit(&outcome, None, None);
assert_eq!(report.status, AuditStatus::Partial);
let stock_validation = report.stock_validation.expect("stock check always runs");
assert_eq!(stock_validation.status, CheckStatus::NotEvaluable);
assert_eq!(
stock_validation.reason,
Some(StockNotEvaluableReason::StockNotProvided)
);
}
#[test]
fn atom_materializing_from_nowhere_is_unaccounted_and_fails() {
let r = route(vec![step("Clc1ccccc1", &["Brc1ccccc1"])], &["Brc1ccccc1"]);
let outcome = normalize_renkin_route(&r, "Clc1ccccc1");
assert!(outcome.parseable, "{:?}", outcome.defects);
let report = audit(&outcome, Some(&stock(&["Brc1ccccc1"])), None);
assert_eq!(report.status, AuditStatus::Fail);
assert_eq!(
report.target_element_accounting_status,
Some(ElementAccountingStatus::UnaccountedTargetElement)
);
assert!(
report
.findings
.iter()
.any(|f| f.code == AuditFindingCode::UnaccountedTargetElement)
);
}
#[test]
fn leaf_only_route_element_accounting_is_not_evaluable() {
let leaf = RouteNode {
canonical_smiles: canon(TARGET),
is_stock_leaf: Some(true),
reaction_evidence: None,
children: vec![],
};
let (status, findings) = target_element_accounting(&leaf);
assert_eq!(status, ElementAccountingStatus::NotEvaluable);
assert!(findings.is_empty());
}
#[test]
fn informational_findings_never_gate_status_to_fail() {
let target = "[NH4+]";
let precursor = "N";
let root = RouteNode {
canonical_smiles: canon(target),
is_stock_leaf: Some(false),
reaction_evidence: None,
children: vec![RouteNode {
canonical_smiles: canon(precursor),
is_stock_leaf: Some(true),
reaction_evidence: None,
children: vec![],
}],
};
let document = RouteDocument {
source: RouteSource::Renkin,
step_count_collapsed_edges: 1,
root,
};
let report = audit_document(&document, Some(&stock(&[precursor])), None);
assert!(
report
.findings
.iter()
.any(|f| f.code == AuditFindingCode::ChargeImbalance),
"expected a ChargeImbalance finding, got {:?}",
report.findings
);
assert_ne!(
report.status,
AuditStatus::Fail,
"an Informational-severity finding must never gate AuditStatus to Fail, got {:?}",
report
);
}
#[test]
fn unparseable_route_reports_fail_with_tree_not_parseable() {
let r = route(vec![], &[]);
let outcome = normalize_renkin_route(&r, TARGET);
let report = audit(&outcome, None, None);
assert_eq!(report.status, AuditStatus::Fail);
assert!(!report.route_tree_parseable);
assert_eq!(report.reaction_steps_parseable, None);
assert_eq!(report.normalized_route_sha256, None);
assert!(report.steps.is_empty());
assert!(report.stock_validation.is_none());
assert!(
report
.findings
.iter()
.any(|f| f.code == AuditFindingCode::MultipleOrZeroRoots)
);
assert_eq!(
report.source,
RouteSource::Renkin,
"a real normalize_renkin_route() failure must report its actual source"
);
}
#[test]
fn failed_audit_preserves_a_non_renkin_source() {
let outcome = ParseOutcome {
source: RouteSource::AiZynthFinder,
document: None,
parseable: false,
defects: vec![AuditFindingCode::RawOutputNotDecodable],
};
let report = audit(&outcome, None, None);
assert_eq!(report.status, AuditStatus::Fail);
assert_eq!(report.source, RouteSource::AiZynthFinder);
}
#[test]
fn normal_route_reaction_steps_are_parseable() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let document = outcome.document.unwrap();
assert!(reaction_steps_parseable(&document.root));
}
fn co_aliphatic_cleavage_rule() -> 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()
}
}
#[test]
fn independence_structural_and_stock_pass_while_forward_fails() {
let methane = canon("C");
let water = canon("O");
let root = RouteNode {
canonical_smiles: canon("C=O"),
is_stock_leaf: Some(false),
reaction_evidence: Some(
crate::bridge::route_graph::ReactionEvidence::RenkinTemplate {
template_id: "t1".to_string(),
},
),
children: vec![
RouteNode {
canonical_smiles: methane.clone(),
is_stock_leaf: Some(true),
reaction_evidence: None,
children: vec![],
},
RouteNode {
canonical_smiles: water.clone(),
is_stock_leaf: Some(true),
reaction_evidence: None,
children: vec![],
},
],
};
let document = RouteDocument {
source: RouteSource::Renkin,
step_count_collapsed_edges: 1,
root,
};
let rules = vec![co_aliphatic_cleavage_rule()];
let stock: HashSet<String> = [methane, water].into_iter().collect();
let report = audit_document(&document, Some(&stock), Some(&rules));
assert_eq!(
report.stock_validation.as_ref().map(|s| s.status),
Some(CheckStatus::Pass),
"{report:?}"
);
assert_eq!(
report.target_element_accounting_status,
Some(ElementAccountingStatus::Accounted),
"{report:?}"
);
assert_eq!(report.steps[0].forward_validation.status, CheckStatus::Fail);
assert_eq!(
report.status,
AuditStatus::Fail,
"a clear forward FAIL must fail the route even though stock and \
structural checks independently pass"
);
}
#[test]
fn forward_not_evaluable_does_not_corrupt_other_audit_fields() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let report = audit(
&outcome,
Some(&stock(&[ETHANOL, BENZOIC_ACID, "CCN"])),
None,
);
assert!(report.route_tree_parseable);
assert_eq!(report.reaction_steps_parseable, Some(true));
assert_eq!(
report.stock_validation.as_ref().map(|s| s.status),
Some(CheckStatus::Pass)
);
assert_eq!(
report.target_element_accounting_status,
Some(ElementAccountingStatus::Accounted)
);
assert!(report.normalized_route_sha256.is_some());
assert_eq!(report.steps.len(), 1);
assert_eq!(
report.steps[0].forward_validation.status,
CheckStatus::NotEvaluable
);
assert_eq!(
report.status,
AuditStatus::Partial,
"not_evaluable forward validation alone must yield Partial, \
never silently Pass and never Fail"
);
}
}