use std::collections::HashSet;
use crate::chem_env;
use crate::search::Route;
use crate::synthesizability::schema::{
AccountingFailurePolicy, AssessmentProvenance, AssessmentStatus, EvidencePolicy,
ForwardValidationPolicy, ForwardValidationStatus, HardFailure, RouteAssessment,
SYNTHESIZABILITY_SCHEMA_VERSION, StockTerminationStatus, SynthesizabilityAssessment,
SynthesizabilityConfig, SynthesizabilityConfigSummary, ValidationGap,
};
use crate::synthesizability::{element_accounting, provenance, signals};
use crate::validation::StepValidationStatus;
pub struct AssessmentContext<'a> {
pub stock: Option<&'a [String]>,
pub stock_source: Option<String>,
pub template_metadata_hash: Option<String>,
pub search_config_summary: Option<String>,
pub git_commit: Option<String>,
pub embedded_fallback_used: Option<bool>,
pub forward_validation: Option<&'a [Option<Vec<StepValidationStatus>>]>,
pub rules: &'a [crate::chem_env::RetroRule],
}
pub fn assess_routes(
target: &str,
routes: &[Route],
context: &AssessmentContext,
config: &SynthesizabilityConfig,
) -> anyhow::Result<SynthesizabilityAssessment> {
let mut warnings: Vec<String> = Vec::new();
let rules_count = context.rules.len();
let rules_hash = provenance::compute_rules_hash(context.rules);
let canonicalized_stock = signals::canonicalize_stock(context.stock);
let stock_count = canonicalized_stock.count;
let stock_hash = canonicalized_stock.hash.clone();
let stock_input_status = canonicalized_stock.input_status;
if !canonicalized_stock.unparseable_raw_entries.is_empty() {
warnings.push(format!(
"{} stock entr{} failed to parse/canonicalize under the kernel's own pipeline and were excluded from the canonicalized stock set",
canonicalized_stock.unparseable_raw_entries.len(),
if canonicalized_stock.unparseable_raw_entries.len() == 1 { "y" } else { "ies" }
));
}
let assessment_config_hash = provenance::compute_assessment_config_hash(config);
let config_used = SynthesizabilityConfigSummary {
require_verified_stock_terminal: config.require_verified_stock_terminal,
require_target_element_accounting: config.require_target_element_accounting,
forward_validation_policy: config.forward_validation_policy,
evidence_policy: config.evidence_policy,
reagent_omission_template_allowlist: config.reagent_omission_template_allowlist.clone(),
accounting_failure_policy: config.accounting_failure_policy,
max_route_diagnostics: config.max_route_diagnostics,
include_all_route_diagnostics: config.include_all_route_diagnostics,
};
let canonical_target = match chem_env::mol_from_smiles(target)
.ok()
.map(|mol| chem_env::to_canonical(&mol))
{
Some(c) => c,
None => {
return Ok(early_assessment(
target,
AssessmentStatus::InvalidTarget,
None,
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
routes.len(),
context,
assessment_config_hash,
config_used,
warnings,
));
}
};
if stock_input_status == crate::synthesizability::schema::StockInputStatus::AllEntriesInvalid {
warnings.push(format!(
"All {} supplied stock entries failed to parse/canonicalize -- this is a data-quality failure on the stock input, not the same as no stock being supplied at all",
context.stock.map(<[String]>::len).unwrap_or(0)
));
return Ok(early_assessment(
target,
AssessmentStatus::EvaluationError,
Some(canonical_target),
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
routes.len(),
context,
assessment_config_hash,
config_used,
warnings,
));
}
if let Some(fv) = context.forward_validation
&& fv.len() != routes.len()
{
warnings.push(format!(
"AssessmentContext::forward_validation has {} entries but {} routes were supplied -- index correspondence cannot be trusted",
fv.len(),
routes.len()
));
return Ok(early_assessment(
target,
AssessmentStatus::EvaluationError,
Some(canonical_target),
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
routes.len(),
context,
assessment_config_hash,
config_used,
warnings,
));
}
if config.forward_validation_policy == ForwardValidationPolicy::RequireAllValid
&& let Some(fv) = context.forward_validation
{
let mismatched_route = routes.iter().zip(fv.iter()).position(|(route, per_step)| {
per_step
.as_ref()
.is_some_and(|steps| steps.len() != route.steps.len())
});
if let Some(route_index) = mismatched_route {
warnings.push(format!(
"route {route_index}'s forward-validation slice does not match its own step count -- RequireAllValid cannot be enforced for this assessment"
));
return Ok(early_assessment(
target,
AssessmentStatus::EvaluationError,
Some(canonical_target),
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
routes.len(),
context,
assessment_config_hash,
config_used,
warnings,
));
}
}
if routes.is_empty() {
return Ok(early_assessment(
target,
AssessmentStatus::NoRouteFoundWithinBudget,
Some(canonical_target),
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
routes.len(),
context,
assessment_config_hash,
config_used,
warnings,
));
}
let mut route_assessments: Vec<RouteAssessment> = Vec::with_capacity(routes.len());
for (idx, route) in routes.iter().enumerate() {
route_assessments.push(assess_one_route(
idx,
route,
&canonical_target,
context,
config,
&canonicalized_stock,
));
}
route_assessments.sort_by(cmp_route_assessments);
let (status, selected_route) = match route_assessments.first() {
Some(best) if best.hard_failures.is_empty() => {
let status = if best.validation_gaps.is_empty() {
AssessmentStatus::RouteSupported
} else {
AssessmentStatus::RouteSupportedWithValidationGaps
};
(status, Some(best.clone()))
}
_ => (AssessmentStatus::RoutesFoundButRejected, None),
};
let reproducibility_hash = provenance::compute_reproducibility_hash(
&rules_hash,
&stock_hash,
&assessment_config_hash,
&canonical_target,
&route_assessments,
);
let total_assessed = route_assessments.len();
let route_assessments: Vec<RouteAssessment> = if config.include_all_route_diagnostics {
let mut out = route_assessments;
if total_assessed > config.max_route_diagnostics {
warnings.push(format!(
"Computed assessments for all {total_assessed} supplied routes; output truncated to the top {} by §4.8 quality ordering (max_route_diagnostics)",
config.max_route_diagnostics
));
out.truncate(config.max_route_diagnostics);
}
out
} else {
match route_assessments.first() {
Some(r) if config.max_route_diagnostics > 0 => vec![r.clone()],
_ => Vec::new(),
}
};
if config.max_route_diagnostics == 0 {
warnings.push(
"max_route_diagnostics is 0; no route can appear in the output even though routes were supplied and assessed".to_string(),
);
}
let route_assessments_returned_count = route_assessments.len();
let route_assessments_truncated = route_assessments_returned_count < total_assessed;
Ok(SynthesizabilityAssessment {
schema_version: SYNTHESIZABILITY_SCHEMA_VERSION,
target: target.to_string(),
canonical_target: Some(canonical_target.clone()),
status,
selected_route,
route_assessments,
routes_supplied_count: routes.len(),
routes_assessed_count: total_assessed,
route_assessments_returned_count,
route_assessments_truncated,
provenance: AssessmentProvenance {
renkin_version: env!("CARGO_PKG_VERSION").to_string(),
assessment_schema_version: SYNTHESIZABILITY_SCHEMA_VERSION,
canonical_target,
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
stock_source: context.stock_source.clone(),
template_metadata_hash: context.template_metadata_hash.clone(),
search_config_summary: context.search_config_summary.clone(),
assessment_config_hash,
git_commit: context.git_commit.clone(),
embedded_fallback_used: context.embedded_fallback_used,
reproducibility_hash,
reproducibility_exclusions: vec![
"timing_ms".to_string(),
"wall_clock_timestamp".to_string(),
],
},
config_used,
warnings,
})
}
#[allow(clippy::too_many_arguments)]
fn early_assessment(
target: &str,
status: AssessmentStatus,
canonical_target: Option<String>,
rules_count: usize,
rules_hash: String,
stock_count: usize,
stock_hash: String,
stock_input_status: crate::synthesizability::schema::StockInputStatus,
routes_supplied_count: usize,
context: &AssessmentContext,
assessment_config_hash: String,
config_used: SynthesizabilityConfigSummary,
warnings: Vec<String>,
) -> SynthesizabilityAssessment {
let canonical_target_for_hash = canonical_target.clone().unwrap_or_default();
let route_assessments: Vec<RouteAssessment> = Vec::new();
let reproducibility_hash = provenance::compute_reproducibility_hash(
&rules_hash,
&stock_hash,
&assessment_config_hash,
&canonical_target_for_hash,
&route_assessments,
);
SynthesizabilityAssessment {
schema_version: SYNTHESIZABILITY_SCHEMA_VERSION,
target: target.to_string(),
canonical_target,
status,
selected_route: None,
route_assessments,
routes_supplied_count,
routes_assessed_count: 0,
route_assessments_returned_count: 0,
route_assessments_truncated: false,
provenance: AssessmentProvenance {
renkin_version: env!("CARGO_PKG_VERSION").to_string(),
assessment_schema_version: SYNTHESIZABILITY_SCHEMA_VERSION,
canonical_target: canonical_target_for_hash,
rules_count,
rules_hash,
stock_count,
stock_hash,
stock_input_status,
stock_source: context.stock_source.clone(),
template_metadata_hash: context.template_metadata_hash.clone(),
search_config_summary: context.search_config_summary.clone(),
assessment_config_hash,
git_commit: context.git_commit.clone(),
embedded_fallback_used: context.embedded_fallback_used,
reproducibility_hash,
reproducibility_exclusions: vec![
"timing_ms".to_string(),
"wall_clock_timestamp".to_string(),
],
},
config_used,
warnings,
}
}
fn assess_one_route(
route_index: usize,
route: &Route,
canonical_target: &str,
context: &AssessmentContext,
config: &SynthesizabilityConfig,
canonicalized_stock: &signals::CanonicalizedStock,
) -> RouteAssessment {
let mut hard_failures: Vec<HardFailure> = Vec::new();
let mut validation_gaps: Vec<ValidationGap> = Vec::new();
let mut route_warnings: Vec<String> = Vec::new();
check_route_structure(route, &mut hard_failures, &mut route_warnings);
let stock_termination_status = assess_stock_termination(
route,
canonical_target,
config,
canonicalized_stock,
&mut hard_failures,
&mut validation_gaps,
&mut route_warnings,
);
let target_element_accounting_status = assess_element_accounting(
route,
config,
&mut hard_failures,
&mut validation_gaps,
&mut route_warnings,
);
let forward_validation_status = assess_forward_validation(
route_index,
route,
context,
config,
&mut hard_failures,
&mut validation_gaps,
&mut route_warnings,
);
let evidence_coverage = signals::compute_evidence_coverage(route);
assess_evidence(route, &evidence_coverage, config, &mut validation_gaps);
let route_id = provenance::compute_route_id(canonical_target, route);
RouteAssessment {
route_id,
route_depth: route.depth,
route_cost: route.route_cost,
stock_termination_status,
target_element_accounting_status,
forward_validation_status,
evidence_coverage,
hard_failures,
validation_gaps,
warnings: route_warnings,
}
}
fn check_route_structure(
route: &Route,
hard_failures: &mut Vec<HardFailure>,
route_warnings: &mut Vec<String>,
) {
let any_unparseable = route.steps.iter().any(|step| {
provenance::try_canonicalize(&step.target).is_none()
|| step
.precursors
.iter()
.any(|p| provenance::try_canonicalize(p).is_none())
});
if any_unparseable {
hard_failures.push(HardFailure::RouteStructureUnparseable);
}
if route.steps.is_empty() {
route_warnings.push(
"Route has zero steps (the target itself was classified as a stock hit by search); the canonical target itself is independently re-verified against the configured stock in its place.".to_string(),
);
return;
}
let leaves: HashSet<String> = route
.building_blocks
.iter()
.map(|s| provenance::canonicalize_or_raw(s))
.collect();
let targets: HashSet<String> = route
.steps
.iter()
.map(|s| provenance::canonicalize_or_raw(&s.target))
.collect();
let disconnected = route
.steps
.iter()
.flat_map(|s| s.precursors.iter())
.any(|p| {
let canon = provenance::canonicalize_or_raw(p);
!leaves.contains(&canon) && !targets.contains(&canon)
});
if disconnected {
hard_failures.push(HardFailure::RouteGraphInconsistent);
}
}
fn assess_stock_termination(
route: &Route,
canonical_target: &str,
config: &SynthesizabilityConfig,
canonicalized_stock: &signals::CanonicalizedStock,
hard_failures: &mut Vec<HardFailure>,
validation_gaps: &mut Vec<ValidationGap>,
route_warnings: &mut Vec<String>,
) -> StockTerminationStatus {
if !config.require_verified_stock_terminal {
return StockTerminationStatus::StockCheckNotPerformed;
}
let leaves_to_check: Vec<String> = if route.steps.is_empty() {
vec![canonical_target.to_string()]
} else {
route.building_blocks.clone()
};
let result = signals::check_stock_termination(&leaves_to_check, canonicalized_stock);
match result.status {
StockTerminationStatus::AllLeavesVerifiedInConfiguredStock => {}
StockTerminationStatus::OneOrMoreLeavesNotInStock
| StockTerminationStatus::StockIdentityUnavailable
| StockTerminationStatus::StockCheckError => {
let mut seen: HashSet<String> = HashSet::new();
for leaf in result
.unmatched_leaves
.iter()
.chain(result.unparseable_leaves.iter())
{
if seen.insert(leaf.clone()) {
hard_failures.push(HardFailure::StockTerminalMismatch { leaf: leaf.clone() });
}
}
}
StockTerminationStatus::StockNotSupplied
| StockTerminationStatus::StockSuppliedButEmpty => {
validation_gaps.push(ValidationGap::StockNotSupplied);
}
StockTerminationStatus::StockCheckNotPerformed => {
route_warnings.push(
"signals::check_stock_termination unexpectedly returned StockCheckNotPerformed while the check was required".to_string(),
);
}
}
result.status
}
fn assess_element_accounting(
route: &Route,
config: &SynthesizabilityConfig,
hard_failures: &mut Vec<HardFailure>,
validation_gaps: &mut Vec<ValidationGap>,
route_warnings: &mut Vec<String>,
) -> crate::synthesizability::schema::ElementAccountingStatus {
let result = element_accounting::compute_element_accounting(route);
if !config.require_target_element_accounting {
return result.status;
}
let mut failing = result.failing_step_indices.clone();
failing.sort_unstable();
failing.dedup();
for step_index in failing {
let Some(step) = route.steps.get(step_index) else {
route_warnings.push(format!(
"element_accounting reported a failing step_index {step_index} out of range for this route ({} steps)",
route.steps.len()
));
continue;
};
if config
.reagent_omission_template_allowlist
.contains(&step.template_id)
{
validation_gaps.push(ValidationGap::ReagentOmissionAccountingGap {
step_index,
template_id: step.template_id.clone(),
});
} else {
match config.accounting_failure_policy {
AccountingFailurePolicy::HardFailure => {
hard_failures.push(HardFailure::UnaccountedTargetElement { step_index });
}
AccountingFailurePolicy::ValidationGap => {
validation_gaps
.push(ValidationGap::UnaccountedTargetElementNotEnforced { step_index });
}
}
}
}
result.status
}
fn assess_forward_validation(
route_index: usize,
route: &Route,
context: &AssessmentContext,
config: &SynthesizabilityConfig,
hard_failures: &mut Vec<HardFailure>,
validation_gaps: &mut Vec<ValidationGap>,
route_warnings: &mut Vec<String>,
) -> ForwardValidationStatus {
let per_step: Option<&[StepValidationStatus]> = context
.forward_validation
.and_then(|all| all.get(route_index))
.and_then(|opt| opt.as_deref());
let status = signals::rollup_forward_validation(per_step, route.steps.len());
let invalid_step_indices = |steps: &[StepValidationStatus]| -> Vec<usize> {
steps
.iter()
.enumerate()
.filter(|(_, s)| **s == StepValidationStatus::Invalid)
.map(|(i, _)| i)
.collect()
};
let not_evaluable_step_indices = |steps: &[StepValidationStatus]| -> Vec<usize> {
steps
.iter()
.enumerate()
.filter(|(_, s)| **s == StepValidationStatus::NotEvaluable)
.map(|(i, _)| i)
.collect()
};
match config.forward_validation_policy {
ForwardValidationPolicy::Ignore => {}
ForwardValidationPolicy::RequireAllValid => match status {
ForwardValidationStatus::AllEvaluatedStepsValid => {}
ForwardValidationStatus::OneOrMoreStepsInvalid => {
if let Some(steps) = per_step {
for step_index in invalid_step_indices(steps) {
hard_failures.push(HardFailure::ForwardValidationFailed { step_index });
}
}
}
ForwardValidationStatus::NotEvaluated => {
validation_gaps.push(ValidationGap::ForwardValidationNotRun);
}
ForwardValidationStatus::PartiallyEvaluated => {
if let Some(steps) = per_step {
for step_index in not_evaluable_step_indices(steps) {
validation_gaps.push(ValidationGap::StepNotEvaluable { step_index });
}
}
}
ForwardValidationStatus::ValidatorError => {
route_warnings.push(
"forward-validation input was structurally inconsistent with this route (e.g. wrong step count); RequireAllValid could not be enforced -- this should be unreachable, the assessment-level guard should have caught it first".to_string(),
);
}
},
ForwardValidationPolicy::RequireNoInvalid => {
if status == ForwardValidationStatus::OneOrMoreStepsInvalid
&& let Some(steps) = per_step
{
for step_index in invalid_step_indices(steps) {
hard_failures.push(HardFailure::ForwardValidationFailed { step_index });
}
}
}
}
status
}
fn assess_evidence(
route: &Route,
coverage: &crate::synthesizability::schema::EvidenceCoverage,
config: &SynthesizabilityConfig,
validation_gaps: &mut Vec<ValidationGap>,
) {
match config.evidence_policy {
EvidencePolicy::Ignore => {}
EvidencePolicy::RequireAnyEvidence => {
if coverage.steps_without_evidence > 0 {
validation_gaps.push(ValidationGap::NoEvidence);
}
}
EvidencePolicy::RequireExactSubstrate => {
if coverage.exact_substrate_evidence_steps < route.steps.len() {
validation_gaps.push(ValidationGap::NoExactSubstrateEvidence);
}
}
}
}
fn cmp_route_assessments(a: &RouteAssessment, b: &RouteAssessment) -> std::cmp::Ordering {
a.hard_failures
.len()
.cmp(&b.hard_failures.len())
.then_with(|| a.validation_gaps.len().cmp(&b.validation_gaps.len()))
.then_with(|| {
stock_rank(a.stock_termination_status).cmp(&stock_rank(b.stock_termination_status))
})
.then_with(|| {
accounting_rank(a.target_element_accounting_status)
.cmp(&accounting_rank(b.target_element_accounting_status))
})
.then_with(|| {
forward_rank(a.forward_validation_status)
.cmp(&forward_rank(b.forward_validation_status))
})
.then_with(|| {
b.evidence_coverage
.exact_substrate_evidence_steps
.cmp(&a.evidence_coverage.exact_substrate_evidence_steps)
})
.then_with(|| {
b.evidence_coverage
.template_level_evidence_steps
.cmp(&a.evidence_coverage.template_level_evidence_steps)
})
.then_with(|| a.route_cost.total_cmp(&b.route_cost))
.then_with(|| a.route_depth.cmp(&b.route_depth))
.then_with(|| a.route_id.cmp(&b.route_id))
}
fn stock_rank(status: StockTerminationStatus) -> u8 {
match status {
StockTerminationStatus::AllLeavesVerifiedInConfiguredStock => 0,
_ => 1,
}
}
fn accounting_rank(status: crate::synthesizability::schema::ElementAccountingStatus) -> u8 {
match status {
crate::synthesizability::schema::ElementAccountingStatus::Accounted => 0,
_ => 1,
}
}
fn forward_rank(status: ForwardValidationStatus) -> u8 {
match status {
ForwardValidationStatus::AllEvaluatedStepsValid => 0,
ForwardValidationStatus::PartiallyEvaluated => 1,
ForwardValidationStatus::NotEvaluated
| ForwardValidationStatus::OneOrMoreStepsInvalid
| ForwardValidationStatus::ValidatorError => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chem_env::RetroRule;
use crate::search::ReactionStep;
use crate::synthesizability::schema::ElementAccountingStatus;
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: crate::search::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 {
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<'a>(rules: &'a [RetroRule]) -> AssessmentContext<'a> {
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,
}
}
#[test]
fn invalid_target_short_circuits_before_anything_else() {
let rules: Vec<RetroRule> = vec![];
let ctx = empty_context(&rules);
let result = assess_routes(
"this-is-not-a-smiles(((",
&[],
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("assess_routes should 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 zero_routes_is_no_route_found_within_budget() {
let rules: Vec<RetroRule> = vec![];
let ctx = empty_context(&rules);
let result = assess_routes("CCO", &[], &ctx, &SynthesizabilityConfig::conservative())
.expect("valid target, empty routes must not Err");
assert_eq!(result.status, AssessmentStatus::NoRouteFoundWithinBudget);
assert!(result.canonical_target.is_some());
}
#[test]
fn forward_validation_length_mismatch_is_evaluation_error() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let fv: Vec<Option<Vec<StepValidationStatus>>> = vec![]; let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("length mismatch must surface as a status, not an Err");
assert_eq!(result.status, AssessmentStatus::EvaluationError);
}
#[test]
fn per_route_validator_error_escalates_under_require_all_valid() {
let rules: Vec<RetroRule> = vec![];
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,
StepValidationStatus::Valid,
])];
let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let config = SynthesizabilityConfig {
forward_validation_policy: ForwardValidationPolicy::RequireAllValid,
..SynthesizabilityConfig::conservative()
};
let result = assess_routes("CCO", &routes, &ctx, &config)
.expect("per-route length mismatch must surface as a status, not an Err");
assert_eq!(result.status, AssessmentStatus::EvaluationError);
assert!(result.route_assessments.is_empty());
}
#[test]
fn per_route_validator_error_tolerated_under_ignore_policy() {
let rules: Vec<RetroRule> = vec![];
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,
StepValidationStatus::Valid,
])];
let ctx = AssessmentContext {
forward_validation: Some(&fv),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("must not Err");
assert_ne!(result.status, AssessmentStatus::EvaluationError);
assert_eq!(
result.route_assessments[0].forward_validation_status,
ForwardValidationStatus::ValidatorError
);
}
#[test]
fn stock_entries_all_invalid_escalates_to_evaluation_error() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let stock = vec!["[C(".to_string(), "not-a-smiles(((".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("all-invalid stock must surface as a status, not an Err");
assert_eq!(result.status, AssessmentStatus::EvaluationError);
assert!(result.route_assessments.is_empty());
assert_eq!(
result.provenance.stock_input_status,
crate::synthesizability::schema::StockInputStatus::AllEntriesInvalid
);
}
#[test]
fn invalid_target_wins_over_all_invalid_stock() {
let rules: Vec<RetroRule> = vec![];
let stock = vec!["[C(".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"not-a-smiles(((",
&[],
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("must not Err");
assert_eq!(result.status, AssessmentStatus::InvalidTarget);
}
#[test]
fn clean_route_with_verified_stock_is_route_supported() {
let rules: Vec<RetroRule> = vec![];
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),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(result.status, AssessmentStatus::RouteSupported);
let selected = result.selected_route.expect("a route must be selected");
assert!(selected.hard_failures.is_empty());
assert!(selected.validation_gaps.is_empty());
}
#[test]
fn unsupplied_stock_is_a_gap_not_a_hard_failure() {
let rules: Vec<RetroRule> = vec![];
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.status,
AssessmentStatus::RouteSupportedWithValidationGaps
);
let selected = result.selected_route.unwrap();
assert!(selected.hard_failures.is_empty());
assert!(
selected
.validation_gaps
.contains(&ValidationGap::StockNotSupplied)
);
}
#[test]
fn stock_mismatch_is_a_hard_failure() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let stock = vec!["c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
assert!(result.selected_route.is_none());
assert!(
result.route_assessments[0].hard_failures.iter().any(
|hf| matches!(hf, HardFailure::StockTerminalMismatch { leaf } if leaf == "CC=O")
)
);
}
#[test]
fn disabling_stock_requirement_skips_the_check_entirely() {
let rules: Vec<RetroRule> = vec![];
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();
let selected = result.selected_route.expect("still supported");
assert_eq!(
selected.stock_termination_status,
StockTerminationStatus::StockCheckNotPerformed
);
assert!(selected.hard_failures.is_empty());
assert!(selected.validation_gaps.is_empty());
assert_eq!(result.status, AssessmentStatus::RouteSupported);
}
#[test]
fn route_graph_inconsistent_when_a_precursor_is_orphaned() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["orphan-leaf"])],
&["CC=O"], )];
let ctx = empty_context(&rules);
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert!(
result.route_assessments[0]
.hard_failures
.contains(&HardFailure::RouteGraphInconsistent)
);
}
#[test]
fn route_graph_connectivity_uses_canonical_identity_not_raw_string_equality() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![
step(
"rule:x",
"rule:x",
"CC(=O)c1ccc(cc1)CC=O", &["c1ccc(cc1)CC=O"], ),
step(
"rule:y",
"rule:y",
"O=CCc1ccccc1", &["CC=O"],
),
],
&["CC=O"],
)];
let ctx = empty_context(&rules);
let result = assess_routes(
"CC(=O)c1ccc(cc1)CC=O",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert!(
!result.route_assessments[0]
.hard_failures
.contains(&HardFailure::RouteGraphInconsistent),
"hard_failures should not contain RouteGraphInconsistent: {:?}",
result.route_assessments[0].hard_failures
);
}
#[test]
fn route_structure_unparseable_when_a_step_smiles_is_garbage() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step("rule:x", "rule:x", "not-a-smiles(((", &["CC=O"])],
&["CC=O"],
)];
let ctx = empty_context(&rules);
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert!(
result.route_assessments[0]
.hard_failures
.contains(&HardFailure::RouteStructureUnparseable)
);
}
#[test]
fn zero_step_route_is_legal_not_inconsistent() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(vec![], &[])];
let ctx = empty_context(&rules);
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert!(result.route_assessments[0].hard_failures.is_empty());
assert!(!result.route_assessments[0].warnings.is_empty());
}
#[test]
fn zero_step_route_verifies_the_target_itself_against_stock_when_present() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(vec![], &[])];
let stock = vec!["CCO".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::AllLeavesVerifiedInConfiguredStock
);
assert_eq!(result.status, AssessmentStatus::RouteSupported);
}
#[test]
fn zero_step_route_target_not_in_stock_is_a_hard_failure_not_vacuous_success() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(vec![], &[])];
let stock = vec!["c1ccccc1".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
.iter()
.any(|hf| matches!(hf, HardFailure::StockTerminalMismatch { .. }))
);
assert_eq!(result.status, AssessmentStatus::RoutesFoundButRejected);
}
#[test]
fn allowlisted_accounting_failure_is_a_gap_under_conservative() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step(
"boc_deprotection_retro",
"rule:boc_deprotection_retro",
"Brc1ccccc1",
&["c1ccccc1"],
)],
&["c1ccccc1"],
)];
let stock = vec!["c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let result = assess_routes(
"Brc1ccccc1",
&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:boc_deprotection_retro"
)));
}
#[test]
fn non_allowlisted_accounting_failure_is_hard_failure_under_conservative_and_gap_under_diagnostic()
{
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step(
"some_other_rule",
"rule:some_other_rule",
"Brc1ccccc1",
&["c1ccccc1"],
)],
&["c1ccccc1"],
)];
let stock = vec!["c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let conservative = assess_routes(
"Brc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
conservative.status,
AssessmentStatus::RoutesFoundButRejected
);
assert!(
conservative.route_assessments[0]
.hard_failures
.iter()
.any(|hf| matches!(hf, HardFailure::UnaccountedTargetElement { step_index: 0 }))
);
let diagnostic = assess_routes(
"Brc1ccccc1",
&routes,
&ctx,
&SynthesizabilityConfig::diagnostic(),
)
.unwrap();
assert_eq!(
diagnostic.status,
AssessmentStatus::RouteSupportedWithValidationGaps
);
assert!(
diagnostic.route_assessments[0]
.validation_gaps
.iter()
.any(|g| matches!(
g,
ValidationGap::UnaccountedTargetElementNotEnforced { step_index: 0 }
))
);
}
#[test]
fn disabling_accounting_requirement_ignores_even_allowlisted_failures() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![route(
vec![step(
"some_other_rule",
"rule:some_other_rule",
"Brc1ccccc1",
&["c1ccccc1"],
)],
&["c1ccccc1"],
)];
let stock = vec!["c1ccccc1".to_string()];
let ctx = AssessmentContext {
stock: Some(&stock),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.require_target_element_accounting = false;
let result = assess_routes("Brc1ccccc1", &routes, &ctx, &config).unwrap();
assert_eq!(result.status, AssessmentStatus::RouteSupported);
let selected = result.selected_route.unwrap();
assert_eq!(
selected.target_element_accounting_status,
ElementAccountingStatus::UnaccountedTargetElement
);
assert!(selected.hard_failures.is_empty());
assert!(selected.validation_gaps.is_empty());
}
#[test]
fn selection_prefers_fewer_hard_failures_over_route_cost() {
let rules: Vec<RetroRule> = vec![];
let cheap_but_broken = {
let mut r = route(
vec![step("rule:x", "rule:x", "not-a-smiles(((", &["CC=O"])],
&["CC=O"],
);
r.route_cost = 0.1;
r
};
let clean_but_pricier = {
let mut r = route(vec![step("rule:y", "rule:y", "CCO", &["CC=O"])], &["CC=O"]);
r.route_cost = 9.0;
r
};
let routes = vec![cheap_but_broken, clean_but_pricier];
let stock = vec!["CC=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.status, AssessmentStatus::RouteSupported);
let selected = result.selected_route.unwrap();
assert_eq!(selected.route_cost, 9.0);
assert!(selected.hard_failures.is_empty());
}
#[test]
fn max_route_diagnostics_truncates_and_warns() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![
route(vec![step("rule:x", "rule:x", "CCO", &["CC=O"])], &["CC=O"]),
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),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.include_all_route_diagnostics = true;
config.max_route_diagnostics = 1;
let result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert_eq!(result.route_assessments.len(), 1);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("max_route_diagnostics"))
);
assert_eq!(result.routes_supplied_count, 2);
assert_eq!(result.routes_assessed_count, 2);
assert_eq!(result.route_assessments_returned_count, 1);
assert!(result.route_assessments_truncated);
}
#[test]
fn audit_counts_agree_with_output_when_nothing_is_truncated() {
let rules: Vec<RetroRule> = vec![];
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),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(result.routes_supplied_count, 1);
assert_eq!(result.routes_assessed_count, 1);
assert_eq!(result.route_assessments_returned_count, 1);
assert!(!result.route_assessments_truncated);
}
#[test]
fn audit_counts_reflect_supplied_routes_even_on_early_short_circuit() {
let rules: Vec<RetroRule> = vec![];
let ctx = empty_context(&rules);
let result = assess_routes("CCO", &[], &ctx, &SynthesizabilityConfig::conservative())
.expect("must not Err");
assert_eq!(result.routes_supplied_count, 0);
assert_eq!(result.routes_assessed_count, 0);
assert_eq!(result.route_assessments_returned_count, 0);
assert!(!result.route_assessments_truncated);
let routes = vec![route(
vec![step("rule:x", "rule:x", "CCO", &["CC=O"])],
&["CC=O"],
)];
let result2 = assess_routes(
"not-a-smiles(((",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.expect("must not Err");
assert_eq!(result2.status, AssessmentStatus::InvalidTarget);
assert_eq!(result2.routes_supplied_count, 1);
assert_eq!(result2.routes_assessed_count, 0);
}
#[test]
fn max_route_diagnostics_truncation_never_drops_the_best_route() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![
route(
vec![step("rule:x", "rule:x", "CCO", &["c1ccccc1"])],
&["c1ccccc1"],
),
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),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.include_all_route_diagnostics = true;
config.max_route_diagnostics = 1;
let result = assess_routes("CCO", &routes, &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);
}
#[test]
fn max_route_diagnostics_zero_never_changes_the_verdict() {
let rules: Vec<RetroRule> = vec![];
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),
..empty_context(&rules)
};
let mut config = SynthesizabilityConfig::conservative();
config.max_route_diagnostics = 0;
let result = assess_routes("CCO", &routes, &ctx, &config).unwrap();
assert_eq!(
result.status,
AssessmentStatus::RouteSupported,
"max_route_diagnostics=0 must not turn a clean route into RoutesFoundButRejected"
);
assert!(result.selected_route.is_some());
assert!(result.route_assessments.is_empty());
assert!(
result
.warnings
.iter()
.any(|w| w.contains("max_route_diagnostics is 0"))
);
}
#[test]
fn include_all_route_diagnostics_toggles_output_breadth_not_the_verdict() {
let rules: Vec<RetroRule> = vec![];
let routes = vec![
route(
vec![step("rule:x", "rule:x", "CCO", &["c1ccccc1"])],
&["c1ccccc1"],
),
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),
..empty_context(&rules)
};
let mut narrow = SynthesizabilityConfig::conservative();
narrow.include_all_route_diagnostics = false;
let narrow_result = assess_routes("CCO", &routes, &ctx, &narrow).unwrap();
assert_eq!(narrow_result.route_assessments.len(), 1);
assert_eq!(narrow_result.status, AssessmentStatus::RouteSupported);
let mut wide = SynthesizabilityConfig::conservative();
wide.include_all_route_diagnostics = true;
let wide_result = assess_routes("CCO", &routes, &ctx, &wide).unwrap();
assert_eq!(wide_result.route_assessments.len(), 2);
assert_eq!(wide_result.status, narrow_result.status);
assert_eq!(
wide_result.selected_route.as_ref().map(|r| &r.route_id),
narrow_result.selected_route.as_ref().map(|r| &r.route_id)
);
}
#[test]
fn stock_source_label_is_echoed_into_provenance() {
let rules: Vec<RetroRule> = vec![];
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("data/building_blocks.smi".to_string()),
..empty_context(&rules)
};
let result = assess_routes(
"CCO",
&routes,
&ctx,
&SynthesizabilityConfig::conservative(),
)
.unwrap();
assert_eq!(
result.provenance.stock_source,
Some("data/building_blocks.smi".to_string())
);
}
}