use crate::composition::Composition;
use crate::config::SearchBudget;
use crate::error::GugenError;
use crate::precursor::{PrecursorCandidate, PrecursorId, search_precursor_sets};
use crate::reaction::BalancedReaction;
use crate::target::PlanningConstraints;
use std::collections::BTreeSet;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq)]
pub enum RouteError {
#[error("a synthesis route needs at least one stage")]
EmptyRoute,
#[error("the final stage's products do not include the target composition")]
FinalStageMissingTarget,
#[error(
"stage {stage_index}'s reactant {composition:?} is not explained by any base precursor or an earlier stage's product"
)]
UnexplainedReactant {
composition: Composition,
stage_index: usize,
},
#[error("underlying single-step search failed: {0}")]
Search(#[from] GugenError),
}
#[derive(Debug, Clone, PartialEq)]
pub struct SynthesisRoute {
stages: Vec<BalancedReaction>,
}
impl SynthesisRoute {
pub fn new(
stages: Vec<BalancedReaction>,
base_precursors: &[Composition],
target: &Composition,
) -> Result<Self, RouteError> {
let Some(last) = stages.last() else {
return Err(RouteError::EmptyRoute);
};
if !last.products().iter().any(|s| &s.composition == target) {
return Err(RouteError::FinalStageMissingTarget);
}
let mut known: BTreeSet<Composition> = base_precursors.iter().cloned().collect();
for (stage_index, stage) in stages.iter().enumerate() {
for reactant in stage.reactants() {
if !known.contains(&reactant.composition) {
return Err(RouteError::UnexplainedReactant {
composition: reactant.composition.clone(),
stage_index,
});
}
}
known.extend(stage.products().iter().map(|s| s.composition.clone()));
}
Ok(Self { stages })
}
pub fn stages(&self) -> &[BalancedReaction] {
&self.stages
}
pub fn final_reaction(&self) -> &BalancedReaction {
self.stages
.last()
.expect("SynthesisRoute::new guarantees at least one stage")
}
}
pub fn search_two_step_routes(
target: &Composition,
base_candidates: &[PrecursorCandidate],
intermediate_candidates: &[Composition],
constraints: &PlanningConstraints,
budget: &SearchBudget,
) -> Result<Vec<SynthesisRoute>, RouteError> {
let base_compositions: Vec<Composition> = base_candidates
.iter()
.map(|c| c.composition.clone())
.collect();
let mut routes = Vec::new();
let direct = search_precursor_sets(target, base_candidates, constraints, budget)?;
for accepted in &direct.accepted {
if let Ok(route) =
SynthesisRoute::new(vec![accepted.reaction.clone()], &base_compositions, target)
{
routes.push(route);
}
}
for (index, intermediate) in intermediate_candidates.iter().enumerate() {
if intermediate == target {
continue;
}
let stage_one = search_precursor_sets(intermediate, base_candidates, constraints, budget)?;
let Some(first_stage) = stage_one.accepted.first() else {
continue;
};
let synthetic_id = PrecursorId(format!("__gugen_multi_step_intermediate_{index}"));
let mut expanded_pool: Vec<PrecursorCandidate> = base_candidates.to_vec();
expanded_pool.push(PrecursorCandidate {
id: synthetic_id.clone(),
composition: intermediate.clone(),
availability: None,
});
let stage_two = search_precursor_sets(target, &expanded_pool, constraints, budget)?;
for accepted in &stage_two.accepted {
if !accepted.precursors.contains(&synthetic_id) {
continue;
}
if let Ok(route) = SynthesisRoute::new(
vec![first_stage.reaction.clone(), accepted.reaction.clone()],
&base_compositions,
target,
) {
routes.push(route);
}
}
}
Ok(routes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composition::Element;
fn element(symbol: &str) -> Element {
Element::new(symbol).unwrap()
}
fn composition(pairs: &[(&str, f64)]) -> Composition {
Composition::new(pairs.iter().map(|&(sym, amt)| (element(sym), amt))).unwrap()
}
fn candidate(id: &str, pairs: &[(&str, f64)]) -> PrecursorCandidate {
PrecursorCandidate {
id: PrecursorId(id.to_string()),
composition: composition(pairs),
availability: None,
}
}
fn tight_budget() -> SearchBudget {
SearchBudget {
max_precursor_sets: 10_000,
max_precursors_per_plan: 4,
max_plans_returned: 20,
}
}
fn five_element_fixture() -> (Composition, Vec<PrecursorCandidate>, Composition) {
let target = composition(&[
("Fe", 1.0),
("Li", 1.0),
("Na", 1.0),
("K", 1.0),
("O", 1.0),
]);
let base = vec![
candidate("Fe", &[("Fe", 1.0)]),
candidate("Li", &[("Li", 1.0)]),
candidate("Na", &[("Na", 1.0)]),
candidate("K", &[("K", 1.0)]),
candidate("O2", &[("O", 2.0)]),
];
let intermediate = composition(&[("Fe", 1.0), ("Li", 1.0), ("Na", 1.0)]);
(target, base, intermediate)
}
#[test]
fn direct_search_cannot_reach_a_five_way_target_under_a_tight_budget() {
let (target, base, _intermediate) = five_element_fixture();
let outcome = search_precursor_sets(
&target,
&base,
&PlanningConstraints::default(),
&tight_budget(),
)
.unwrap();
assert!(
outcome.accepted.is_empty(),
"arity-5 target should be unreachable at max_precursors_per_plan=4"
);
}
#[test]
fn two_step_search_recovers_the_route_the_direct_search_cannot_reach() {
let (target, base, intermediate) = five_element_fixture();
let routes = search_two_step_routes(
&target,
&base,
&[intermediate],
&PlanningConstraints::default(),
&tight_budget(),
)
.unwrap();
assert_eq!(routes.len(), 1, "expected exactly one two-step route");
let route = &routes[0];
assert_eq!(route.stages().len(), 2);
assert!(
route
.final_reaction()
.products()
.iter()
.any(|s| s.composition == target)
);
}
#[test]
fn a_directly_reachable_target_is_not_duplicated_as_a_spurious_two_step_route() {
let base = vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
];
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let unrelated_intermediate = composition(&[("Fe", 1.0), ("Li", 1.0)]);
let generous = SearchBudget::default();
let routes = search_two_step_routes(
&target,
&base,
&[unrelated_intermediate],
&PlanningConstraints::default(),
&generous,
)
.unwrap();
assert_eq!(
routes.len(),
1,
"the direct route must appear exactly once, not duplicated"
);
assert_eq!(routes[0].stages().len(), 1);
}
#[test]
fn a_duplicated_intermediate_candidate_produces_a_duplicated_route_not_deduplicated() {
let (target, base, intermediate) = five_element_fixture();
let routes = search_two_step_routes(
&target,
&base,
&[intermediate.clone(), intermediate],
&PlanningConstraints::default(),
&tight_budget(),
)
.unwrap();
assert_eq!(
routes.len(),
2,
"duplicate intermediate_candidates entries are not deduplicated by this function"
);
assert_eq!(routes[0], routes[1]);
}
#[test]
fn search_two_step_routes_is_deterministic_across_repeated_calls() {
let (target, base, intermediate) = five_element_fixture();
let run = || {
search_two_step_routes(
&target,
&base,
std::slice::from_ref(&intermediate),
&PlanningConstraints::default(),
&tight_budget(),
)
.unwrap()
};
assert_eq!(
run(),
run(),
"repeated calls with identical input must agree exactly"
);
}
#[test]
fn an_unreachable_target_returns_no_routes_without_panicking() {
let base = vec![candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)])];
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let intermediate = composition(&[("Na", 1.0), ("Cl", 1.0)]);
let routes = search_two_step_routes(
&target,
&base,
&[intermediate],
&PlanningConstraints::default(),
&SearchBudget::default(),
)
.unwrap();
assert!(routes.is_empty());
}
#[test]
fn a_spurious_identity_accepted_set_does_not_poison_the_whole_search() {
let target = composition(&[
("Al", 1.0),
("N", 1.0),
("Nd", 1.0),
("O", 1.0),
("Si", 1.0),
]);
let base = vec![
candidate("AlN", &[("Al", 1.0), ("N", 1.0)]),
candidate("Al2O3", &[("Al", 2.0), ("O", 3.0)]),
candidate("Nd2O3", &[("Nd", 2.0), ("O", 3.0)]),
candidate("Si3N4", &[("Si", 3.0), ("N", 4.0)]),
candidate("O2", &[("O", 2.0)]),
];
let routes = search_two_step_routes(
&target,
&base,
&[],
&PlanningConstraints::default(),
&SearchBudget::default(),
)
.expect("a spurious O2->O2 accepted entry must not abort the whole search");
assert!(
!routes.is_empty(),
"the legitimate direct routes must survive the spurious entry"
);
assert!(
routes.iter().all(|r| r
.final_reaction()
.products()
.iter()
.any(|s| s.composition == target)),
"every surviving route must actually produce the real target, not the spurious O2 no-op"
);
}
fn simple_reaction(
reactant: (&str, &[(&str, f64)]),
product: (&str, &[(&str, f64)]),
) -> BalancedReaction {
use crate::reaction::ReactionSpecies;
BalancedReaction::new(
vec![ReactionSpecies::new(composition(reactant.1), 1).unwrap()],
vec![ReactionSpecies::new(composition(product.1), 1).unwrap()],
)
.unwrap_or_else(|e| panic!("{reactant:?} -> {product:?} should conserve: {e}"))
}
#[test]
fn synthesis_route_rejects_empty_stages() {
let target = composition(&[("Fe", 1.0)]);
let err = SynthesisRoute::new(Vec::new(), &[], &target).unwrap_err();
assert_eq!(err, RouteError::EmptyRoute);
}
#[test]
fn synthesis_route_rejects_a_final_stage_that_does_not_produce_the_target() {
let stage = simple_reaction(("Fe", &[("Fe", 1.0)]), ("Fe", &[("Fe", 1.0)]));
let target = composition(&[("Li", 1.0)]);
let err =
SynthesisRoute::new(vec![stage], &[composition(&[("Fe", 1.0)])], &target).unwrap_err();
assert_eq!(err, RouteError::FinalStageMissingTarget);
}
#[test]
fn synthesis_route_rejects_a_stage_with_an_unexplained_reactant() {
let stage_one = simple_reaction(("Fe", &[("Fe", 1.0)]), ("Fe", &[("Fe", 1.0)]));
let stage_two = simple_reaction(("Li", &[("Li", 1.0)]), ("Li", &[("Li", 1.0)]));
let target = composition(&[("Li", 1.0)]);
let err = SynthesisRoute::new(
vec![stage_one, stage_two],
&[composition(&[("Fe", 1.0)])],
&target,
)
.unwrap_err();
assert_eq!(
err,
RouteError::UnexplainedReactant {
composition: composition(&[("Li", 1.0)]),
stage_index: 1,
}
);
}
}