use renkin::chem_env::RetroRule;
use renkin::evidence::{ExampleMatch, ReactionExample, ResolvedReactionExample, StepEvidence};
use renkin::search::{AtomEconomyStatus, ReactionStep, Route};
use renkin::synthesizability::{
AssessmentContext, AssessmentStatus, ElementAccountingStatus, EvidencePolicy,
ForwardValidationPolicy, ForwardValidationStatus, HardFailure, SYNTHESIZABILITY_SCHEMA_VERSION,
StockTerminationStatus, SynthesizabilityConfig, ValidationGap, assess_routes,
};
use renkin::validation::StepValidationStatus;
fn no_rules() -> Vec<RetroRule> {
vec![]
}
fn step(rule: &str, template_id: &str, target: &str, precursors: &[&str]) -> ReactionStep {
ReactionStep {
rule: rule.to_string(),
template_id: template_id.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 step_with_evidence(
rule: &str,
template_id: &str,
target: &str,
precursors: &[&str],
evidence: StepEvidence,
) -> ReactionStep {
ReactionStep {
evidence: Some(evidence),
..step(rule, template_id, target, precursors)
}
}
fn route(steps: Vec<ReactionStep>, building_blocks: &[&str]) -> Route {
Route {
depth: steps.len() as u32,
steps,
score: 0.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 empty_context(rules: &[RetroRule]) -> AssessmentContext<'_> {
AssessmentContext {
stock: None,
stock_source: None,
template_metadata_hash: None,
search_config_summary: None,
git_commit: None,
embedded_fallback_used: None,
forward_validation: None,
rules,
}
}
fn resolved_example(match_kind: ExampleMatch, id: &str) -> ResolvedReactionExample {
ResolvedReactionExample {
match_kind,
example: ReactionExample {
id: id.to_string(),
target_smiles: "CCO".to_string(),
precursor_smiles: vec!["CC=O".to_string()],
conditions: None,
reported_yield: None,
warnings: vec![],
reference_ids: vec![],
dataset_record_id: None,
notes: None,
},
}
}
fn evidence_with(examples: Vec<ResolvedReactionExample>) -> StepEvidence {
let total = examples.len();
StepEvidence {
condition_candidates: vec![],
reported_yields: vec![],
references: vec![],
warnings: vec![],
examples,
template_examples_total: total,
}
}
#[test]
fn invalid_target_smiles_is_invalid_target() {
let rules = no_rules();
let ctx = empty_context(&rules);
let result = assess_routes(
"not-a-smiles(((",
&[],
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("assess_routes must not Err for a bad target");
assert_eq!(result.status, AssessmentStatus::InvalidTarget);
assert_eq!(result.canonical_target, None);
assert!(result.route_assessments.is_empty());
assert_eq!(result.provenance.canonical_target, "");
}
#[test]
fn no_routes_is_always_no_route_found_within_budget_never_indeterminate() {
let rules = no_rules();
for search_config_summary in [
None,
Some("depth=6,beam=50,max_routes=20".to_string()),
Some("depth=1,beam=1,max_routes=1".to_string()),
] {
let ctx = AssessmentContext {
search_config_summary,
..empty_context(&rules)
};
let result = assess_routes("CCO", &[], &ctx, &SynthesizabilityConfig::conservative())
.expect("empty routes must not Err");
assert_eq!(result.status, AssessmentStatus::NoRouteFoundWithinBudget);
assert_ne!(result.status, AssessmentStatus::Indeterminate);
assert!(result.canonical_target.is_some());
}
}
#[test]
fn stock_full_termination_all_leaves_verified() {
let rules = no_rules();
let routes = vec![route(
vec![step(
"esterification_retro",
"rule:esterification_retro",
"CCOC(=O)c1ccccc1",
&["CCO", "O=C(O)c1ccccc1"],
)],
&["CCO", "O=C(O)c1ccccc1"],
)];
let stock = vec!["CCO".to_string(), "O=C(O)c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CCOC(=O)c1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::AllLeavesVerifiedInConfiguredStock
);
assert!(result.route_assessments[0].hard_failures.is_empty());
assert_eq!(result.status, AssessmentStatus::RouteSupported);
}
fn issue71_fixture_route(rule_name: &str, target: &str, false_positive_leaf: &str) -> Route {
route(
vec![step(
rule_name,
&format!("rule:{rule_name}"),
target,
&[false_positive_leaf],
)],
&[false_positive_leaf],
)
}
#[test]
fn issue71_pentadiene_false_positive_is_not_in_stock() {
let rules = no_rules();
let routes = vec![issue71_fixture_route(
"cc_single_cleavage",
"CC=C", "C=C/C/C=C",
)];
let stock = vec!["CCO".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CC=C",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::OneOrMoreLeavesNotInStock
);
assert!(result.route_assessments[0].hard_failures.contains(
&HardFailure::StockTerminalMismatch {
leaf: "C=C/C/C=C".to_string()
}
));
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
assert!(result.selected_route.is_none());
}
#[test]
fn issue71_glyoxylic_acid_false_positive_is_not_in_stock() {
let rules = no_rules();
let routes = vec![issue71_fixture_route(
"wittig_retro",
"O=CO", "O=C/C(=O)O",
)];
let stock = vec!["CCO".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"O=CO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::OneOrMoreLeavesNotInStock
);
assert!(result.route_assessments[0].hard_failures.contains(
&HardFailure::StockTerminalMismatch {
leaf: "O=C/C(=O)O".to_string()
}
));
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn issue71_phenylacetaldehyde_false_positive_is_not_in_stock() {
let rules = no_rules();
let routes = vec![issue71_fixture_route(
"co_aliphatic_cleavage",
"c1ccccc1", "c1ccc(cc1)CC=O",
)];
let stock = vec!["CCO".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"c1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::OneOrMoreLeavesNotInStock
);
assert!(result.route_assessments[0].hard_failures.contains(
&HardFailure::StockTerminalMismatch {
leaf: "c1ccc(cc1)CC=O".to_string()
}
));
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn stock_not_supplied_is_a_gap_not_a_hard_failure_top_level() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let ctx = empty_context(&rules); let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::StockNotSupplied
);
assert!(
result.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::StockNotSupplied)
);
assert!(result.route_assessments[0].hard_failures.is_empty());
assert_eq!(
result.status,
AssessmentStatus::RouteSupportedWithValidationGaps
);
}
#[test]
fn stock_check_disabled_by_config_is_not_performed() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let ctx = empty_context(&rules);
let mut config = SynthesizabilityConfig::conservative();
config.require_verified_stock_terminal = false;
let result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::StockCheckNotPerformed
);
assert!(result.route_assessments[0].hard_failures.is_empty());
assert!(result.route_assessments[0].validation_gaps.is_empty());
assert_eq!(result.status, AssessmentStatus::RouteSupported);
}
#[test]
fn target_element_accounted_ethyl_benzoate_parity_with_python() {
let rules = no_rules();
let routes = vec![route(
vec![step(
"esterification_retro",
"rule:esterification_retro",
"CCOC(=O)c1ccccc1",
&["CCO", "O=C(O)c1ccccc1"],
)],
&["CCO", "O=C(O)c1ccccc1"],
)];
let stock = vec!["CCO".to_string(), "O=C(O)c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CCOC(=O)c1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].target_element_accounting_status,
ElementAccountingStatus::Accounted
);
let json = serde_json::to_string(&result.route_assessments[0]).unwrap();
assert!(json.contains("\"target_element_accounting_status\":\"accounted\""));
assert_eq!(result.status, AssessmentStatus::RouteSupported);
}
#[test]
fn target_element_unaccounted_chlorobenzene_parity_with_python() {
let rules = no_rules();
let routes = vec![route(
vec![step(
"some_extracted_rule",
"rule:some_extracted_rule",
"Clc1ccccc1",
&["Brc1ccccc1"],
)],
&["Brc1ccccc1"],
)];
let stock = vec!["Brc1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"Clc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].target_element_accounting_status,
ElementAccountingStatus::UnaccountedTargetElement
);
let json = serde_json::to_string(&result.route_assessments[0]).unwrap();
assert!(json.contains("\"target_element_accounting_status\":\"unaccounted_target_element\""));
assert!(
result.route_assessments[0]
.hard_failures
.contains(&HardFailure::UnaccountedTargetElement { step_index: 0 })
);
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn forward_validation_all_valid_end_to_end() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let fv: Vec<Option<Vec<StepValidationStatus>>> = vec![Some(vec![StepValidationStatus::Valid])];
let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].forward_validation_status,
ForwardValidationStatus::AllEvaluatedStepsValid
);
assert!(result.route_assessments[0].hard_failures.is_empty());
}
#[test]
fn forward_validation_one_invalid_step_gated_only_when_policy_requires_it() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let fv: Vec<Option<Vec<StepValidationStatus>>> =
vec![Some(vec![StepValidationStatus::Invalid])];
let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let ignored = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
ignored.route_assessments[0].forward_validation_status,
ForwardValidationStatus::OneOrMoreStepsInvalid
);
assert!(
!ignored.route_assessments[0]
.hard_failures
.iter()
.any(|hf| matches!(hf, HardFailure::ForwardValidationFailed { .. }))
);
let mut config = SynthesizabilityConfig::conservative();
config.forward_validation_policy = ForwardValidationPolicy::RequireNoInvalid;
let strict = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert!(
strict.route_assessments[0]
.hard_failures
.contains(&HardFailure::ForwardValidationFailed { step_index: 0 })
);
assert_eq!(strict.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn forward_validation_not_evaluated_is_never_a_hard_failure_gap_depends_on_policy() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let fv: Vec<Option<Vec<StepValidationStatus>>> = vec![None]; let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let ignored = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
ignored.route_assessments[0].forward_validation_status,
ForwardValidationStatus::NotEvaluated
);
assert!(
!ignored.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::ForwardValidationNotRun)
);
assert!(ignored.route_assessments[0].hard_failures.is_empty());
let mut require_all = SynthesizabilityConfig::conservative();
require_all.forward_validation_policy = ForwardValidationPolicy::RequireAllValid;
let strict = assess_routes("CCO", &routes, &ctx, &require_all).unwrap();
assert!(
strict.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::ForwardValidationNotRun)
);
assert!(
!strict.route_assessments[0]
.hard_failures
.iter()
.any(|hf| matches!(hf, HardFailure::ForwardValidationFailed { .. }))
);
let mut require_no_invalid = SynthesizabilityConfig::conservative();
require_no_invalid.forward_validation_policy = ForwardValidationPolicy::RequireNoInvalid;
let tolerant = assess_routes("CCO", &routes, &ctx, &require_no_invalid).unwrap();
assert!(
!tolerant.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::ForwardValidationNotRun)
);
assert!(tolerant.route_assessments[0].hard_failures.is_empty());
}
#[test]
fn evidence_exact_substrate_present_is_never_gated() {
let rules = no_rules();
let evidence = evidence_with(vec![resolved_example(ExampleMatch::ExactSubstrate, "ex1")]);
let routes = vec![route(
vec![step_with_evidence(
"rule:x",
"rule:x",
"CCO",
&["CC=O"],
evidence,
)],
&["CC=O"],
)];
let ctx = empty_context(&rules);
let mut config = SynthesizabilityConfig::conservative();
config.evidence_policy = EvidencePolicy::RequireExactSubstrate;
let result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
let ra = &result.route_assessments[0];
assert_eq!(ra.evidence_coverage.exact_substrate_evidence_steps, 1);
assert!(
!ra.validation_gaps
.contains(&ValidationGap::NoExactSubstrateEvidence)
);
assert!(!ra.validation_gaps.contains(&ValidationGap::NoEvidence));
}
#[test]
fn evidence_template_level_only_gap_present_under_require_exact_substrate_absent_under_ignore() {
let rules = no_rules();
let evidence = evidence_with(vec![resolved_example(ExampleMatch::TemplateOnly, "ex1")]);
let routes = vec![route(
vec![step_with_evidence(
"rule:x",
"rule:x",
"CCO",
&["CC=O"],
evidence,
)],
&["CC=O"],
)];
let ctx = empty_context(&rules);
let ignore_result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
let ra = &ignore_result.route_assessments[0];
assert_eq!(ra.evidence_coverage.template_level_evidence_steps, 1);
assert_eq!(ra.evidence_coverage.exact_substrate_evidence_steps, 0);
assert!(
!ra.validation_gaps
.contains(&ValidationGap::NoExactSubstrateEvidence)
);
let mut config = SynthesizabilityConfig::conservative();
config.evidence_policy = EvidencePolicy::RequireExactSubstrate;
let strict_result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert!(
strict_result.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::NoExactSubstrateEvidence)
);
}
#[test]
fn evidence_none_at_all_gap_present_under_require_any_absent_under_ignore() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])], &["CC=O"],
)];
let ctx = empty_context(&rules);
let ignore_result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
let ra = &ignore_result.route_assessments[0];
assert_eq!(ra.evidence_coverage.steps_without_evidence, 1);
assert!(!ra.validation_gaps.contains(&ValidationGap::NoEvidence));
let mut config = SynthesizabilityConfig::conservative();
config.evidence_policy = EvidencePolicy::RequireAnyEvidence;
let strict_result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert!(
strict_result.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::NoEvidence)
);
}
#[test]
fn route_order_independence_selected_route_and_full_assessment_list_match() {
let rules = no_rules();
let clean = route(vec![step("rule:x", "rule:x", "CCO", &["CC=O"])], &["CC=O"]);
let mismatched = route(
vec![step("rule:y", "rule:y", "CCO", &["Oc1ccccc1"])],
&["Oc1ccccc1"], );
let stock = vec!["CC=O".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.include_all_route_diagnostics = true;
let forward =
assess_routes("CCO", &[clean.clone(), mismatched.clone()], &ctx, &config).unwrap();
let backward = assess_routes("CCO", &[mismatched, clean], &ctx, &config).unwrap();
assert_eq!(forward.status, backward.status);
assert_eq!(forward.selected_route, backward.selected_route);
assert_eq!(forward.route_assessments, backward.route_assessments);
assert_eq!(forward.route_assessments.len(), 2);
assert!(forward.route_assessments[0].hard_failures.is_empty());
}
#[test]
fn route_id_is_independent_of_precursor_order_within_a_step_via_public_api() {
let rules = no_rules();
let route_a = route(
vec![step(
"rule:x",
"rule:x",
"CCOC(=O)c1ccccc1",
&["CCO", "O=C(O)c1ccccc1"],
)],
&["CCO", "O=C(O)c1ccccc1"],
);
let route_b = route(
vec![step(
"rule:x",
"rule:x",
"CCOC(=O)c1ccccc1",
&["O=C(O)c1ccccc1", "CCO"],
)],
&["O=C(O)c1ccccc1", "CCO"],
);
let ctx = empty_context(&rules);
let config = SynthesizabilityConfig::conservative();
let result_a = assess_routes("CCOC(=O)c1ccccc1", &[route_a], &ctx, &config).unwrap();
let result_b = assess_routes("CCOC(=O)c1ccccc1", &[route_b], &ctx, &config).unwrap();
assert_eq!(
result_a.route_assessments[0].route_id,
result_b.route_assessments[0].route_id
);
}
#[test]
fn byte_identical_inputs_produce_byte_identical_json() {
let rules = no_rules();
let worse = route(
vec![step(
"rule:x",
"rule:x",
"CCOC(=O)c1ccccc1",
&["CCN", "CCCl"],
)],
&["CCN", "CCCl"], );
let better = route(
vec![step(
"esterification_retro",
"rule:esterification_retro",
"CCOC(=O)c1ccccc1",
&["CCO", "O=C(O)c1ccccc1"],
)],
&["CCO", "O=C(O)c1ccccc1"],
);
let routes = vec![worse, better];
let stock = vec!["CCO".to_string(), "O=C(O)c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
stock_source: Some("data/building_blocks.smi".to_string()),
template_metadata_hash: Some("sha256:abc".to_string()),
search_config_summary: Some("depth=6,beam=50,max_routes=20".to_string()),
git_commit: Some("deadbeef".to_string()),
embedded_fallback_used: Some(false),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.include_all_route_diagnostics = true;
let result_a = assess_routes("CCOC(=O)c1ccccc1", &routes, &ctx, &config).unwrap();
let result_b = assess_routes("CCOC(=O)c1ccccc1", &routes, &ctx, &config).unwrap();
assert_eq!(result_a.route_assessments.len(), 2);
assert!(result_a.route_assessments.iter().any(|ra| {
ra.hard_failures
.iter()
.filter(|hf| matches!(hf, HardFailure::StockTerminalMismatch { .. }))
.count()
>= 2
}));
let json_a = serde_json::to_string(&result_a).unwrap();
let json_b = serde_json::to_string(&result_b).unwrap();
assert_eq!(json_a, json_b);
}
#[test]
fn forward_validation_length_mismatch_is_evaluation_error_top_level() {
let rules = no_rules();
let routes = vec![
route(vec![step("rule:x", "rule:x", "CCO", &["CC=O"])], &["CC=O"]),
route(vec![step("rule:y", "rule:y", "CCN", &["CCCl"])], &["CCCl"]),
];
let fv: Vec<Option<Vec<StepValidationStatus>>> = vec![Some(vec![StepValidationStatus::Valid])];
let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("a malformed context must surface as a status, not an Err");
assert_eq!(result.status, AssessmentStatus::EvaluationError);
assert!(result.route_assessments.is_empty());
assert!(result.selected_route.is_none());
assert!(!result.warnings.is_empty());
}
#[test]
fn embedded_fallback_used_is_echoed_verbatim_never_defaulted() {
let rules = no_rules();
for input in [None, Some(true), Some(false)] {
let ctx = AssessmentContext {
embedded_fallback_used: input,
..empty_context(&rules)
};
let result =
assess_routes("CCO", &[], &ctx, &SynthesizabilityConfig::conservative()).unwrap();
assert_eq!(result.provenance.embedded_fallback_used, input);
}
}
#[test]
fn issue71_style_mismatch_surfaces_correctly_when_leaf_is_not_the_top_level_target() {
let rules = no_rules();
let routes = vec![route(
vec![
step("rule:step0", "rule:step0", "CCO", &["CC=O"]),
step(
"rule:step1",
"rule:step1",
"CC=O",
&["C=C/C/C=C", "O=C/C(=O)O"],
),
],
&["C=C/C/C=C", "O=C/C(=O)O"],
)];
let stock = vec!["O=C/C(=O)O".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.route_assessments[0].stock_termination_status,
StockTerminationStatus::OneOrMoreLeavesNotInStock
);
assert!(result.route_assessments[0].hard_failures.contains(
&HardFailure::StockTerminalMismatch {
leaf: "C=C/C/C=C".to_string()
}
));
assert!(!result.route_assessments[0].hard_failures.contains(
&HardFailure::StockTerminalMismatch {
leaf: "O=C/C(=O)O".to_string()
}
));
assert_ne!(result.canonical_target.unwrap(), "C=C/C/C=C");
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn allowlisted_accounting_failure_is_a_validation_gap_end_to_end() {
let rules = no_rules();
let routes = vec![route(
vec![step(
"cbz_deprotection_retro",
"rule:cbz_deprotection_retro",
"Clc1ccccc1",
&["Brc1ccccc1"],
)],
&["Brc1ccccc1"],
)];
let stock = vec!["Brc1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"Clc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.status,
AssessmentStatus::RouteSupportedWithValidationGaps
);
let selected = result.selected_route.unwrap();
assert!(selected.hard_failures.is_empty());
assert!(selected.validation_gaps.iter().any(|g| matches!(
g,
ValidationGap::ReagentOmissionAccountingGap { template_id, .. }
if template_id == "rule:cbz_deprotection_retro"
)));
}
#[test]
fn non_allowlisted_accounting_failure_is_hard_failure_under_conservative_and_gap_under_diagnostic_end_to_end()
{
let rules = no_rules();
let routes = vec![route(
vec![step(
"custom_extracted_rule",
"rule:custom_extracted_rule",
"Clc1ccccc1",
&["Brc1ccccc1"],
)],
&["Brc1ccccc1"],
)];
let stock = vec!["Brc1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let conservative = assess_routes(
"Clc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
conservative.status,
AssessmentStatus::RoutesFoundButRejected
);
assert!(
conservative.route_assessments[0]
.hard_failures
.contains(&HardFailure::UnaccountedTargetElement { step_index: 0 })
);
let diagnostic = assess_routes(
"Clc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::diagnostic(),
)
.unwrap();
assert_eq!(
diagnostic.status,
AssessmentStatus::RouteSupportedWithValidationGaps
);
assert!(
diagnostic.route_assessments[0]
.validation_gaps
.contains(&ValidationGap::UnaccountedTargetElementNotEnforced { step_index: 0 })
);
}
#[test]
fn aryl_amine_retro_accounting_failure_is_a_hard_failure_not_allowlisted_per_issue_73() {
let rules = no_rules();
let routes = vec![route(
vec![step(
"aryl_amine_retro",
"rule:aryl_amine_retro",
"Clc1ccccc1",
&["Brc1ccccc1"],
)],
&["Brc1ccccc1"],
)];
let stock = vec!["Brc1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"Clc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
assert!(
result.route_assessments[0]
.hard_failures
.contains(&HardFailure::UnaccountedTargetElement { step_index: 0 })
);
assert!(
!result.route_assessments[0]
.validation_gaps
.iter()
.any(|g| matches!(g, ValidationGap::ReagentOmissionAccountingGap { .. }))
);
assert!(
!SynthesizabilityConfig::conservative()
.reagent_omission_template_allowlist
.iter()
.any(|t| t == "rule:aryl_amine_retro")
);
}
#[test]
fn max_route_diagnostics_truncates_to_the_best_route_regardless_of_input_order() {
let rules = no_rules();
let worse = route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O", "CCN"])],
&["CC=O", "CCN"], );
let better = route(vec![step("rule:y", "rule:y", "CCO", &["CC=O"])], &["CC=O"]);
let stock = vec!["CC=O".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.include_all_route_diagnostics = true;
config.max_route_diagnostics = 1;
let result = assess_routes("CCO", &[worse, better], &ctx, &config).unwrap();
assert_eq!(result.route_assessments.len(), 1);
let survivor = &result.route_assessments[0];
assert!(survivor.hard_failures.is_empty());
assert_eq!(
survivor.stock_termination_status,
StockTerminationStatus::AllLeavesVerifiedInConfiguredStock
);
assert_eq!(result.status, AssessmentStatus::RouteSupported);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("max_route_diagnostics"))
);
}
#[test]
fn stock_source_label_echoed_into_provenance_top_level() {
let rules = no_rules();
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let stock = vec!["CC=O".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
stock_source: Some("embedded".to_string()),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(result.provenance.stock_source, Some("embedded".to_string()));
}
#[test]
fn full_provenance_smoke_test_every_field_populated_for_a_normal_run() {
let rules = vecO.[O:2]>>[C:1](=O)[O:2]".to_string(),
weight: 1.0,
required_elements: 0,
}];
let routes = vec![route(
vec![step(
"esterification_retro",
"rule:esterification_retro",
"CCOC(=O)c1ccccc1",
&["CCO", "O=C(O)c1ccccc1"],
)],
&["CCO", "O=C(O)c1ccccc1"],
)];
let stock = vec!["CCO".to_string(), "O=C(O)c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
stock_source: Some("data/building_blocks.smi".to_string()),
template_metadata_hash: Some("sha256:meta".to_string()),
search_config_summary: Some("depth=6,beam=50,max_routes=20".to_string()),
git_commit: Some("deadbeef".to_string()),
embedded_fallback_used: Some(false),
forward_validation: None,
rules: &rules,
};
let config = SynthesizabilityConfig::conservative();
let result = assess_routes("CCOC(=O)c1ccccc1", &routes, &ctx, &config).unwrap();
assert_eq!(result.status, AssessmentStatus::RouteSupported);
let prov = &result.provenance;
assert!(!prov.renkin_version.is_empty());
assert_eq!(
prov.assessment_schema_version,
SYNTHESIZABILITY_SCHEMA_VERSION
);
assert_eq!(
prov.canonical_target,
result.canonical_target.clone().unwrap()
);
assert_eq!(prov.rules_count, 1);
assert!(prov.rules_hash.starts_with("sha256:"));
assert_eq!(prov.stock_count, 2);
assert!(prov.stock_hash.starts_with("sha256:"));
assert_eq!(
prov.stock_source,
Some("data/building_blocks.smi".to_string())
);
assert_eq!(prov.template_metadata_hash, Some("sha256:meta".to_string()));
assert_eq!(
prov.search_config_summary,
Some("depth=6,beam=50,max_routes=20".to_string())
);
assert!(prov.assessment_config_hash.starts_with("sha256:"));
assert_eq!(prov.git_commit, Some("deadbeef".to_string()));
assert_eq!(prov.embedded_fallback_used, Some(false));
assert!(prov.reproducibility_hash.starts_with("sha256:"));
assert!(!prov.reproducibility_exclusions.is_empty());
assert!(
prov.reproducibility_exclusions
.contains(&"timing_ms".to_string())
);
assert!(
prov.reproducibility_exclusions
.contains(&"wall_clock_timestamp".to_string())
);
let cfg = &result.config_used;
assert_eq!(
cfg.require_verified_stock_terminal,
config.require_verified_stock_terminal
);
assert_eq!(
cfg.require_target_element_accounting,
config.require_target_element_accounting
);
assert_eq!(cfg.max_route_diagnostics, config.max_route_diagnostics);
assert_eq!(
cfg.reagent_omission_template_allowlist,
config.reagent_omission_template_allowlist
);
}