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, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuditPolicy {
Informational,
#[default]
Standard,
Strict,
}
impl AuditPolicy {
pub fn as_str(self) -> &'static str {
match self {
Self::Informational => "informational",
Self::Standard => "standard",
Self::Strict => "strict",
}
}
}
impl std::str::FromStr for AuditPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"informational" => Ok(Self::Informational),
"standard" => Ok(Self::Standard),
"strict" => Ok(Self::Strict),
_ => Err(format!(
"unsupported policy {s:?} (only informational|standard|strict supported)"
)),
}
}
}
fn derive_status(any_gating: bool, any_not_evaluable: bool, policy: AuditPolicy) -> AuditStatus {
if any_gating {
return match policy {
AuditPolicy::Informational => AuditStatus::Partial,
AuditPolicy::Standard | AuditPolicy::Strict => AuditStatus::Fail,
};
}
if any_not_evaluable {
return match policy {
AuditPolicy::Informational | AuditPolicy::Standard => AuditStatus::Partial,
AuditPolicy::Strict => AuditStatus::Fail,
};
}
AuditStatus::Pass
}
#[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],
policy: AuditPolicy,
) -> AuditReport {
let any_gating = defects
.iter()
.any(|d| d.severity() == AuditSeverity::Gating);
AuditReport {
source,
status: derive_status(any_gating, false, policy),
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 {
audit_with_policy(outcome, configured_stock, rules, AuditPolicy::Standard)
}
pub fn audit_with_policy(
outcome: &ParseOutcome,
configured_stock: Option<&HashSet<String>>,
rules: Option<&[RetroRule]>,
policy: AuditPolicy,
) -> AuditReport {
let (Some(document), true) = (&outcome.document, outcome.parseable) else {
return parse_failure_report(outcome.source, &outcome.defects, policy);
};
audit_document_with_policy(document, configured_stock, rules, policy)
}
pub fn audit_document(
document: &RouteDocument,
configured_stock: Option<&HashSet<String>>,
rules: Option<&[RetroRule]>,
) -> AuditReport {
audit_document_with_policy(document, configured_stock, rules, AuditPolicy::Standard)
}
pub fn audit_document_with_policy(
document: &RouteDocument,
configured_stock: Option<&HashSet<String>>,
rules: Option<&[RetroRule]>,
policy: AuditPolicy,
) -> 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 = derive_status(any_fail, any_not_evaluable, policy);
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"
);
}
#[test]
fn derive_status_matches_the_published_policy_table_for_every_combination() {
use AuditPolicy::{Informational, Standard, Strict};
let cases = [
(true, true, Informational, AuditStatus::Partial),
(true, false, Informational, AuditStatus::Partial),
(true, true, Standard, AuditStatus::Fail),
(true, false, Standard, AuditStatus::Fail),
(true, true, Strict, AuditStatus::Fail),
(true, false, Strict, AuditStatus::Fail),
(false, true, Informational, AuditStatus::Partial),
(false, true, Standard, AuditStatus::Partial),
(false, true, Strict, AuditStatus::Fail),
(false, false, Informational, AuditStatus::Pass),
(false, false, Standard, AuditStatus::Pass),
(false, false, Strict, AuditStatus::Pass),
];
for (any_gating, any_not_evaluable, policy, expected) in cases {
assert_eq!(
derive_status(any_gating, any_not_evaluable, policy),
expected,
"derive_status({any_gating}, {any_not_evaluable}, {policy:?}) should be {expected:?}"
);
}
}
#[test]
fn audit_policy_from_str_round_trips_and_rejects_unknown_values() {
assert_eq!(
"informational".parse::<AuditPolicy>(),
Ok(AuditPolicy::Informational)
);
assert_eq!("standard".parse::<AuditPolicy>(), Ok(AuditPolicy::Standard));
assert_eq!("strict".parse::<AuditPolicy>(), Ok(AuditPolicy::Strict));
assert_eq!(AuditPolicy::default(), AuditPolicy::Standard);
for p in [
AuditPolicy::Informational,
AuditPolicy::Standard,
AuditPolicy::Strict,
] {
assert_eq!(p.as_str().parse::<AuditPolicy>(), Ok(p));
}
let err = "bogus".parse::<AuditPolicy>().unwrap_err();
assert!(err.contains("unsupported policy"), "{err}");
}
#[test]
fn standard_policy_wrapper_is_byte_identical_to_explicit_standard() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let document = outcome.document.unwrap();
let via_wrapper = audit_document(&document, Some(&stock(&[ETHANOL, BENZOIC_ACID])), None);
let via_explicit = audit_document_with_policy(
&document,
Some(&stock(&[ETHANOL, BENZOIC_ACID])),
None,
AuditPolicy::Standard,
);
assert_eq!(via_wrapper.status, via_explicit.status);
assert_eq!(
serde_json::to_string(&via_wrapper).unwrap(),
serde_json::to_string(&via_explicit).unwrap()
);
}
#[test]
fn policy_never_changes_the_finding_set_only_the_gating_case_status() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let document = outcome.document.unwrap();
let configured = Some(stock(&[ETHANOL]));
let informational = audit_document_with_policy(
&document,
configured.as_ref(),
None,
AuditPolicy::Informational,
);
let standard =
audit_document_with_policy(&document, configured.as_ref(), None, AuditPolicy::Standard);
let strict =
audit_document_with_policy(&document, configured.as_ref(), None, AuditPolicy::Strict);
let strip_status = |mut v: serde_json::Value| {
v.as_object_mut().unwrap().remove("status");
v
};
let a = strip_status(serde_json::to_value(&informational).unwrap());
let b = strip_status(serde_json::to_value(&standard).unwrap());
let c = strip_status(serde_json::to_value(&strict).unwrap());
assert_eq!(a, b, "informational vs standard: only status may differ");
assert_eq!(a, c, "informational vs strict: only status may differ");
assert_eq!(informational.status, AuditStatus::Partial);
assert_eq!(standard.status, AuditStatus::Fail);
assert_eq!(strict.status, AuditStatus::Fail);
}
#[test]
fn policy_never_changes_the_finding_set_only_the_not_evaluable_case_status() {
let outcome = normalize_renkin_route(&single_step_route(), TARGET);
let document = outcome.document.unwrap();
let informational =
audit_document_with_policy(&document, None, None, AuditPolicy::Informational);
let standard = audit_document_with_policy(&document, None, None, AuditPolicy::Standard);
let strict = audit_document_with_policy(&document, None, None, AuditPolicy::Strict);
let strip_status = |mut v: serde_json::Value| {
v.as_object_mut().unwrap().remove("status");
v
};
let a = strip_status(serde_json::to_value(&informational).unwrap());
let b = strip_status(serde_json::to_value(&standard).unwrap());
let c = strip_status(serde_json::to_value(&strict).unwrap());
assert_eq!(a, b, "informational vs standard: only status may differ");
assert_eq!(a, c, "informational vs strict: only status may differ");
assert_eq!(informational.status, AuditStatus::Partial);
assert_eq!(standard.status, AuditStatus::Partial);
assert_eq!(strict.status, AuditStatus::Fail);
}
#[test]
fn a_route_with_nothing_wrong_passes_under_every_policy() {
const METHANOL: &str = "CO";
const METHANE: &str = "C";
const WATER: &str = "O";
let rule = 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()
};
let r = route(vec![step(METHANOL, &[METHANE, WATER])], &[METHANE, WATER]);
let outcome = normalize_renkin_route(&r, METHANOL);
let document = outcome.document.unwrap();
let configured = stock(&[METHANE, WATER]);
for policy in [
AuditPolicy::Informational,
AuditPolicy::Standard,
AuditPolicy::Strict,
] {
let report = audit_document_with_policy(
&document,
Some(&configured),
Some(std::slice::from_ref(&rule)),
policy,
);
assert_eq!(
report.status,
AuditStatus::Pass,
"a fully-clean route must Pass under every policy, got {report:?} for {policy:?}"
);
}
}
#[test]
fn parse_failure_is_also_policy_aware_not_hardcoded_fail() {
let empty = route(vec![], &[]);
let outcome = normalize_renkin_route(&empty, TARGET);
assert!(!outcome.parseable);
let informational = audit_with_policy(&outcome, None, None, AuditPolicy::Informational);
let standard = audit_with_policy(&outcome, None, None, AuditPolicy::Standard);
let strict = audit_with_policy(&outcome, None, None, AuditPolicy::Strict);
assert_eq!(informational.status, AuditStatus::Partial);
assert_eq!(standard.status, AuditStatus::Fail);
assert_eq!(strict.status, AuditStatus::Fail);
assert_eq!(
serde_json::to_string(&informational.findings).unwrap(),
serde_json::to_string(&standard.findings).unwrap()
);
assert_eq!(
serde_json::to_string(&informational.findings).unwrap(),
serde_json::to_string(&strict.findings).unwrap()
);
}
}