use crate::balance;
use crate::composition::{Composition, Element};
#[cfg(feature = "search_diagnostics")]
use crate::error::require_finite;
use crate::error::{ProviderError, Result};
use crate::provider::PrecursorCatalog;
use crate::reaction::{BalancedReaction, ReactionSpecies};
use crate::rejection::{RejectedCandidate, RejectionCode};
use crate::target::PlanningConstraints;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PrecursorId(pub String);
impl std::fmt::Display for PrecursorId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AvailabilityMetadata {
pub source: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PrecursorCandidate {
pub id: PrecursorId,
pub composition: Composition,
pub availability: Option<AvailabilityMetadata>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PrecursorSelection {
pub precursor: PrecursorId,
pub formula_units: u64,
}
#[derive(Debug, Clone)]
pub struct InMemoryPrecursorCatalog {
candidates: Vec<PrecursorCandidate>,
}
impl InMemoryPrecursorCatalog {
pub fn new(mut candidates: Vec<PrecursorCandidate>) -> Self {
candidates.sort_by(|a, b| a.id.0.cmp(&b.id.0));
candidates.dedup_by(|a, b| a.id == b.id);
Self { candidates }
}
}
impl PrecursorCatalog for InMemoryPrecursorCatalog {
fn candidates_for(
&self,
target: &Composition,
_constraints: &PlanningConstraints,
) -> std::result::Result<Vec<PrecursorCandidate>, ProviderError> {
let target_elements: BTreeSet<Element> = target.elements().collect();
Ok(self
.candidates
.iter()
.filter(|c| {
c.composition
.elements()
.any(|e| target_elements.contains(&e))
})
.cloned()
.collect())
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AcceptedPrecursorSet {
pub precursors: Vec<PrecursorId>,
pub reaction: crate::reaction::BalancedReaction,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PrecursorSearchOutcome {
pub accepted: Vec<AcceptedPrecursorSet>,
pub rejected: Vec<RejectedCandidate>,
}
#[derive(Debug, Clone, Copy)]
struct TotalF64(f64);
impl PartialEq for TotalF64 {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for TotalF64 {}
impl PartialOrd for TotalF64 {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TotalF64 {
fn cmp(&self, other: &Self) -> Ordering {
self.0.total_cmp(&other.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum TieBreakKey {
IndexOrder(Vec<usize>),
FusionPrioritySum(TotalF64),
MarginalCoverage(std::cmp::Reverse<usize>),
}
#[allow(dead_code)]
enum TieBreakMode<'a> {
IndexOrder,
FusionPrioritySum(&'a BTreeMap<PrecursorId, f64>),
MarginalCoverage,
}
#[derive(Debug, Clone)]
struct SearchState {
chosen: Vec<usize>,
missing: BTreeSet<Element>,
priority: SearchPriority,
tie_break_key: TieBreakKey,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SearchPriority {
elements_missing: usize,
depth: usize,
}
impl PartialEq for SearchState {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for SearchState {}
impl Ord for SearchState {
fn cmp(&self, other: &Self) -> Ordering {
other
.priority
.elements_missing
.cmp(&self.priority.elements_missing)
.then_with(|| other.priority.depth.cmp(&self.priority.depth))
.then_with(|| other.tie_break_key.cmp(&self.tie_break_key))
}
}
impl PartialOrd for SearchState {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[allow(clippy::too_many_arguments)]
fn try_extend_state(
parent_chosen: &[usize],
parent_missing_len: usize,
next: usize,
candidates: &[PrecursorCandidate],
target_elements: &BTreeSet<Element>,
byproduct_elements: &BTreeSet<Element>,
forbidden_elements: &BTreeSet<Element>,
tie_break_mode: &TieBreakMode<'_>,
rejected: &mut Vec<RejectedCandidate>,
) -> Option<SearchState> {
let mut chosen = Vec::with_capacity(parent_chosen.len() + 1);
chosen.extend_from_slice(parent_chosen);
chosen.push(next);
let combo_elements: BTreeSet<Element> = chosen
.iter()
.flat_map(|&i| candidates[i].composition.elements())
.collect();
if let Some(bad) = combo_elements
.iter()
.find(|e| forbidden_elements.contains(e))
{
rejected.push(RejectedCandidate {
precursors: chosen.iter().map(|&i| candidates[i].id.clone()).collect(),
reason_codes: vec![RejectionCode::ForbiddenElementPresent],
explanation: format!(
"precursor set contains forbidden element {bad} -- every larger \
combination built on this one is pruned for the same reason"
),
});
return None;
}
let unremovable: Vec<Element> = combo_elements
.difference(target_elements)
.filter(|e| !byproduct_elements.contains(e))
.copied()
.collect();
if !unremovable.is_empty() {
rejected.push(RejectedCandidate {
precursors: chosen.iter().map(|&i| candidates[i].id.clone()).collect(),
reason_codes: vec![RejectionCode::UnsupportedByproductRequired],
explanation: format!(
"precursor set introduces element(s) with no curated byproduct to remove \
them: {} -- every larger combination built on this one is pruned for the \
same reason",
join_symbols(&unremovable)
),
});
return None;
}
let missing: BTreeSet<Element> = target_elements
.difference(&combo_elements)
.copied()
.collect();
let priority = SearchPriority {
elements_missing: missing.len(),
depth: chosen.len(),
};
let tie_break_key = match tie_break_mode {
TieBreakMode::IndexOrder => TieBreakKey::IndexOrder(chosen.clone()),
TieBreakMode::FusionPrioritySum(ranks) => {
let sum: f64 = chosen
.iter()
.map(|&i| ranks.get(&candidates[i].id).copied().unwrap_or(f64::MAX))
.sum();
TieBreakKey::FusionPrioritySum(TotalF64(sum))
}
TieBreakMode::MarginalCoverage => {
let marginal = parent_missing_len.saturating_sub(missing.len());
TieBreakKey::MarginalCoverage(std::cmp::Reverse(marginal))
}
};
Some(SearchState {
chosen,
missing,
priority,
tie_break_key,
})
}
fn gcd_u64(a: u64, b: u64) -> u64 {
if b == 0 { a } else { gcd_u64(b, a % b) }
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CanonicalReactionKey {
reactants: BTreeSet<(Composition, u64)>,
products: BTreeSet<(Composition, u64)>,
}
impl CanonicalReactionKey {
fn from_reaction(reaction: &BalancedReaction) -> Self {
let g = reaction
.reactants()
.iter()
.chain(reaction.products().iter())
.map(ReactionSpecies::coefficient)
.fold(0u64, gcd_u64)
.max(1);
let side = |species: &[ReactionSpecies]| -> BTreeSet<(Composition, u64)> {
species
.iter()
.map(|s| (s.composition.clone(), s.coefficient() / g))
.collect()
};
Self {
reactants: side(reaction.reactants()),
products: side(reaction.products()),
}
}
}
fn evaluate_complete_state(
chosen: &[usize],
candidates: &[PrecursorCandidate],
target: &Composition,
byproduct_subsets: &[Vec<Composition>],
accepted: &mut Vec<AcceptedPrecursorSet>,
rejected: &mut Vec<RejectedCandidate>,
) -> Result<usize> {
let chosen_candidates: Vec<&PrecursorCandidate> =
chosen.iter().map(|&i| &candidates[i]).collect();
let ids: Vec<PrecursorId> = chosen_candidates.iter().map(|c| c.id.clone()).collect();
let reactant_compositions: Vec<Composition> = chosen_candidates
.iter()
.map(|c| c.composition.clone())
.collect();
let mut found = Vec::new();
let mut balance_calls = 0usize;
for subset in byproduct_subsets {
let mut products = vec![target.clone()];
products.extend(subset.iter().cloned());
balance_calls += 1;
let results = balance::balance(&reactant_compositions, &products)?;
let genuine: Vec<BalancedReaction> = results
.into_iter()
.filter(|reaction| reaction.products().iter().any(|s| &s.composition == target))
.collect();
if !genuine.is_empty() {
found = genuine;
break;
}
}
if found.is_empty() {
rejected.push(RejectedCandidate {
precursors: ids,
reason_codes: vec![RejectionCode::NoStoichiometricBalance],
explanation: "no integer balance exists for this precursor set against the target, \
with or without curated byproducts"
.to_string(),
});
return Ok(balance_calls);
}
for reaction in found {
let matched_ids: Vec<PrecursorId> = reaction
.reactants()
.iter()
.map(|species| {
chosen_candidates
.iter()
.find(|c| c.composition == species.composition)
.map(|c| c.id.clone())
.expect(
"balance() only returns reactant species drawn from \
the compositions it was given",
)
})
.collect();
let candidate_set = AcceptedPrecursorSet {
precursors: matched_ids,
reaction,
};
let candidate_key = CanonicalReactionKey::from_reaction(&candidate_set.reaction);
if let Some(existing_index) = accepted
.iter()
.position(|a| CanonicalReactionKey::from_reaction(&a.reaction) == candidate_key)
{
if candidate_set.precursors < accepted[existing_index].precursors {
let superseded = std::mem::replace(&mut accepted[existing_index], candidate_set);
rejected.push(RejectedCandidate {
precursors: superseded.precursors,
reason_codes: vec![RejectionCode::DuplicatePlan],
explanation: "this precursor set and balanced reaction were already \
found via a different combination of candidates; a \
lexicographically-smaller equivalent precursor set was found \
and is kept instead, so the result does not depend on which \
combination was evaluated first"
.to_string(),
});
} else {
rejected.push(RejectedCandidate {
precursors: candidate_set.precursors,
reason_codes: vec![RejectionCode::DuplicatePlan],
explanation: "this precursor set and balanced reaction were already \
found via a different combination of candidates (a larger \
combination's extra precursor solved to a zero coefficient, \
collapsing to the same effective reactants, or a different \
catalog entry shares this composition)"
.to_string(),
});
}
continue;
}
accepted.push(candidate_set);
}
Ok(balance_calls)
}
pub fn search_precursor_sets(
target: &Composition,
candidates: &[PrecursorCandidate],
constraints: &PlanningConstraints,
budget: &crate::config::SearchBudget,
) -> Result<PrecursorSearchOutcome> {
let core = search_precursor_sets_core(
target,
candidates,
constraints,
budget,
&TieBreakMode::IndexOrder,
None,
)?;
Ok(PrecursorSearchOutcome {
accepted: core.accepted,
rejected: core.rejected,
})
}
#[allow(dead_code)]
struct CoreOutcome {
accepted: Vec<AcceptedPrecursorSet>,
rejected: Vec<RejectedCandidate>,
considered: usize,
balance_calls: usize,
children_generated: usize,
complete_states_evaluated: usize,
budget_exhausted: bool,
gold_pushed_to_frontier: bool,
gold_pop_index: Option<usize>,
}
#[allow(clippy::too_many_arguments)]
fn search_precursor_sets_core(
target: &Composition,
candidates: &[PrecursorCandidate],
constraints: &PlanningConstraints,
budget: &crate::config::SearchBudget,
tie_break_mode: &TieBreakMode<'_>,
gold_indices: Option<&[usize]>,
) -> Result<CoreOutcome> {
let target_elements: BTreeSet<Element> = target.elements().collect();
let byproducts = balance::curated_byproducts()?;
let byproduct_elements: BTreeSet<Element> =
byproducts.iter().flat_map(Composition::elements).collect();
let byproduct_subsets = power_set(&byproducts);
let mut accepted: Vec<AcceptedPrecursorSet> = Vec::new();
let mut rejected = Vec::new();
let mut frontier: BinaryHeap<SearchState> = BinaryHeap::new();
let mut children_generated = 0usize;
let mut gold_pushed_to_frontier = false;
for next in 0..candidates.len() {
if let Some(state) = try_extend_state(
&[],
target_elements.len(),
next,
candidates,
&target_elements,
&byproduct_elements,
&constraints.forbidden_elements,
tie_break_mode,
&mut rejected,
) {
children_generated += 1;
if gold_indices == Some(state.chosen.as_slice()) {
gold_pushed_to_frontier = true;
}
frontier.push(state);
}
}
let mut considered = 0usize;
let mut balance_calls = 0usize;
let mut complete_states_evaluated = 0usize;
let mut budget_exhausted = false;
let mut gold_pop_index: Option<usize> = None;
while let Some(state) = frontier.pop() {
if considered >= budget.max_precursor_sets {
budget_exhausted = true;
break;
}
considered += 1;
if gold_indices == Some(state.chosen.as_slice()) {
gold_pop_index = Some(considered);
}
if state.missing.is_empty() {
complete_states_evaluated += 1;
balance_calls += evaluate_complete_state(
&state.chosen,
candidates,
target,
&byproduct_subsets,
&mut accepted,
&mut rejected,
)?;
}
if state.chosen.len() >= budget.max_precursors_per_plan {
if !state.missing.is_empty() {
rejected.push(RejectedCandidate {
precursors: state
.chosen
.iter()
.map(|&i| candidates[i].id.clone())
.collect(),
reason_codes: vec![RejectionCode::PrecursorCountExceeded],
explanation: format!(
"reached the {}-precursor limit (SearchBudget::max_precursors_per_plan) \
while still missing target element(s): {}",
budget.max_precursors_per_plan,
join_symbols(&state.missing.iter().copied().collect::<Vec<_>>())
),
});
}
continue;
}
let start = state.chosen.last().map_or(0, |&i| i + 1);
for next in start..candidates.len() {
if let Some(child) = try_extend_state(
&state.chosen,
state.missing.len(),
next,
candidates,
&target_elements,
&byproduct_elements,
&constraints.forbidden_elements,
tie_break_mode,
&mut rejected,
) {
children_generated += 1;
if gold_indices == Some(child.chosen.as_slice()) {
gold_pushed_to_frontier = true;
}
frontier.push(child);
}
}
}
if budget_exhausted {
rejected.push(RejectedCandidate {
precursors: vec![],
reason_codes: vec![RejectionCode::SearchBudgetExhausted],
explanation: format!(
"stopped after considering {considered} precursor-set combination(s) in \
priority order ({balance_calls} balance() call(s) attempted); more were \
possible"
),
});
}
Ok(CoreOutcome {
accepted,
rejected,
considered,
balance_calls,
children_generated,
complete_states_evaluated,
budget_exhausted,
gold_pushed_to_frontier,
gold_pop_index,
})
}
#[cfg(feature = "search_diagnostics")]
#[derive(Debug, Clone)]
pub enum TieBreakPolicy {
IndexOrder,
FusionPrioritySum(BTreeMap<PrecursorId, f64>),
MarginalCoverage,
}
#[cfg(feature = "search_diagnostics")]
impl TieBreakPolicy {
fn as_mode(&self) -> TieBreakMode<'_> {
match self {
TieBreakPolicy::IndexOrder => TieBreakMode::IndexOrder,
TieBreakPolicy::FusionPrioritySum(ranks) => TieBreakMode::FusionPrioritySum(ranks),
TieBreakPolicy::MarginalCoverage => TieBreakMode::MarginalCoverage,
}
}
}
#[cfg(feature = "search_diagnostics")]
const ALL_REJECTION_CODES: &[RejectionCode] = &[
RejectionCode::NoStoichiometricBalance,
RejectionCode::MissingTargetElement,
RejectionCode::ForbiddenElementPresent,
RejectionCode::PrecursorCountExceeded,
RejectionCode::UnsupportedByproductRequired,
RejectionCode::AtmosphereConflict,
RejectionCode::UserConstraintViolation,
RejectionCode::HazardPolicyBlocked,
RejectionCode::ThermodynamicDataUnavailable,
RejectionCode::SearchBudgetExhausted,
RejectionCode::DuplicatePlan,
];
#[cfg(feature = "search_diagnostics")]
#[derive(Debug, Clone)]
pub struct SearchDiagnosticTrace {
pub recovered: bool,
pub budget_exhausted: bool,
pub states_popped: usize,
pub children_generated: usize,
pub complete_states_evaluated: usize,
pub balance_calls: usize,
pub prune_counts: Vec<(RejectionCode, usize)>,
pub gold_present_in_candidates: bool,
pub gold_pushed_to_frontier: bool,
pub gold_pop_index: Option<usize>,
pub gold_covers_all_target_elements: bool,
pub gold_accepted: bool,
pub accepted: Vec<AcceptedPrecursorSet>,
}
#[cfg(feature = "search_diagnostics")]
pub fn search_precursor_sets_diagnostic(
target: &Composition,
candidates: &[PrecursorCandidate],
constraints: &PlanningConstraints,
budget: &crate::config::SearchBudget,
tie_break: &TieBreakPolicy,
gold: &[PrecursorId],
) -> Result<SearchDiagnosticTrace> {
if let TieBreakPolicy::FusionPrioritySum(ranks) = tie_break {
for &rank in ranks.values() {
require_finite("FusionPrioritySum rank", rank)?;
}
}
let gold_indices: Option<Vec<usize>> = {
let mut indices: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|(_, c)| gold.contains(&c.id))
.map(|(i, _)| i)
.collect();
indices.sort_unstable();
if indices.len() == gold.len() {
Some(indices)
} else {
None
}
};
let target_elements: BTreeSet<Element> = target.elements().collect();
let gold_covers_all_target_elements = gold_indices.as_ref().is_some_and(|indices| {
let covered: BTreeSet<Element> = indices
.iter()
.flat_map(|&i| candidates[i].composition.elements())
.collect();
target_elements.iter().all(|e| covered.contains(e))
});
let core = search_precursor_sets_core(
target,
candidates,
constraints,
budget,
&tie_break.as_mode(),
gold_indices.as_deref(),
)?;
let gold_id_set: BTreeSet<&PrecursorId> = gold.iter().collect();
let gold_accepted = core.accepted.iter().any(|a| {
a.precursors.len() == gold.len() && a.precursors.iter().all(|id| gold_id_set.contains(id))
});
let prune_counts: Vec<(RejectionCode, usize)> = ALL_REJECTION_CODES
.iter()
.map(|&code| {
let count = core
.rejected
.iter()
.filter(|r| r.reason_codes.contains(&code))
.count();
(code, count)
})
.filter(|&(_, count)| count > 0)
.collect();
Ok(SearchDiagnosticTrace {
recovered: gold_accepted,
budget_exhausted: core.budget_exhausted,
states_popped: core.considered,
children_generated: core.children_generated,
complete_states_evaluated: core.complete_states_evaluated,
balance_calls: core.balance_calls,
prune_counts,
gold_present_in_candidates: gold_indices.is_some(),
gold_pushed_to_frontier: core.gold_pushed_to_frontier,
gold_pop_index: core.gold_pop_index,
gold_covers_all_target_elements,
gold_accepted,
accepted: core.accepted,
})
}
fn join_symbols(elements: &[Element]) -> String {
elements
.iter()
.map(Element::symbol)
.collect::<Vec<_>>()
.join(", ")
}
fn power_set<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
let mut subsets = Vec::with_capacity(1 << items.len());
for size in 0..=items.len() {
for combo in index_combinations(items.len(), size) {
subsets.push(combo.iter().map(|&i| items[i].clone()).collect());
}
}
subsets
}
fn index_combinations(n: usize, size: usize) -> Vec<Vec<usize>> {
fn recurse(
start: usize,
n: usize,
size: usize,
current: &mut Vec<usize>,
out: &mut Vec<Vec<usize>>,
) {
if current.len() == size {
out.push(current.clone());
return;
}
for i in start..n {
current.push(i);
recurse(i + 1, n, size, current, out);
current.pop();
}
}
let mut out = Vec::new();
recurse(0, n, size, &mut Vec::new(), &mut out);
out
}
#[cfg(test)]
fn generate_combinations(n: usize, max_size: usize, budget: usize) -> (Vec<Vec<usize>>, bool) {
let mut result = Vec::new();
let mut exhausted = false;
'sizes: for size in 1..=max_size.min(n) {
for combo in index_combinations(n, size) {
if result.len() >= budget {
exhausted = true;
break 'sizes;
}
result.push(combo);
}
}
(result, exhausted)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SearchBudget;
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 barium_titanate_catalog() -> Vec<PrecursorCandidate> {
vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
]
}
fn generous_budget() -> SearchBudget {
SearchBudget {
max_precursor_sets: 10_000,
max_precursors_per_plan: 3,
max_plans_returned: 100,
}
}
#[test]
fn accepts_a_set_that_covers_every_target_element() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let ba_ti = outcome.accepted.iter().find(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["BaCO3", "TiO2"])
});
assert!(
ba_ti.is_some(),
"BaCO3 + TiO2 must be accepted: {:?}",
outcome.accepted
);
}
#[test]
fn duplicate_composition_candidates_keep_canonical_chemistry_order_invariant() {
let target = composition(&[("Ba", 1.0), ("Fe", 2.0), ("O", 4.0)]);
let ba_o = candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]);
let fe2o3 = candidate("Fe2O3", &[("Fe", 2.0), ("O", 3.0)]);
let afe2o3 = candidate("AFe2O3", &[("Fe", 2.0), ("O", 3.0)]);
let budget = SearchBudget {
max_precursor_sets: 1_000,
max_precursors_per_plan: 2,
max_plans_returned: 100,
};
let order_a = vec![fe2o3.clone(), ba_o.clone(), afe2o3.clone()];
let outcome_a =
search_precursor_sets(&target, &order_a, &PlanningConstraints::default(), &budget)
.unwrap();
assert!(
!outcome_a.rejected.iter().any(|r| matches!(
r.reason_codes.first(),
Some(RejectionCode::SearchBudgetExhausted)
)),
"fixture must be genuinely exhaustive, not budget-limited"
);
let order_b = vec![fe2o3.clone(), afe2o3.clone(), ba_o.clone()];
let outcome_b =
search_precursor_sets(&target, &order_b, &PlanningConstraints::default(), &budget)
.unwrap();
assert!(
!outcome_b.rejected.iter().any(|r| matches!(
r.reason_codes.first(),
Some(RejectionCode::SearchBudgetExhausted)
)),
"fixture must be genuinely exhaustive, not budget-limited"
);
fn canonical_label(id: &str) -> &'static str {
match id {
"BaO" => "BaO-comp",
"Fe2O3" | "AFe2O3" => "Fe-oxide-comp",
other => panic!("unexpected id in fixture: {other}"),
}
}
fn canonical_sets_of(outcome: &PrecursorSearchOutcome) -> BTreeSet<BTreeSet<&'static str>> {
outcome
.accepted
.iter()
.map(|a| {
a.precursors
.iter()
.map(|p| canonical_label(p.0.as_str()))
.collect::<BTreeSet<_>>()
})
.collect()
}
assert_eq!(
canonical_sets_of(&outcome_a),
canonical_sets_of(&outcome_b),
"the invariant that matters for recall: under canonical \
composition-multiset identity, both orderings must recover \
the exact same chemistry -- and they do."
);
assert_eq!(
outcome_a.accepted.len(),
1,
"Order A must collapse to exactly one accepted entry (Phase 30.6 fix). Got: {:?}",
outcome_a.accepted
);
assert_eq!(
outcome_b.accepted.len(),
1,
"Order B must collapse to exactly one accepted entry. Got: {:?}",
outcome_b.accepted
);
fn ids_of(outcome: &PrecursorSearchOutcome) -> BTreeSet<&str> {
outcome.accepted[0]
.precursors
.iter()
.map(|p| p.0.as_str())
.collect()
}
let expected = BTreeSet::from(["BaO", "AFe2O3"]);
assert_eq!(
ids_of(&outcome_a),
expected,
"got: {:?}",
outcome_a.accepted
);
assert_eq!(
ids_of(&outcome_b),
expected,
"got: {:?}",
outcome_b.accepted
);
}
#[test]
fn canonical_reaction_key_ignores_reactant_vector_order_and_composition_synonym() {
use crate::reaction::{BalancedReaction, ReactionSpecies};
let ba_o = composition(&[("Ba", 1.0), ("O", 1.0)]);
let fe2o3 = composition(&[("Fe", 2.0), ("O", 3.0)]);
let product = composition(&[("Ba", 1.0), ("Fe", 2.0), ("O", 4.0)]);
let forward = BalancedReaction::new(
vec![
ReactionSpecies::new(fe2o3.clone(), 1).unwrap(),
ReactionSpecies::new(ba_o.clone(), 1).unwrap(),
],
vec![ReactionSpecies::new(product.clone(), 1).unwrap()],
)
.unwrap();
let reversed = BalancedReaction::new(
vec![
ReactionSpecies::new(ba_o, 1).unwrap(),
ReactionSpecies::new(fe2o3, 1).unwrap(),
],
vec![ReactionSpecies::new(product, 1).unwrap()],
)
.unwrap();
assert_ne!(forward, reversed);
assert_eq!(
CanonicalReactionKey::from_reaction(&forward),
CanonicalReactionKey::from_reaction(&reversed)
);
}
#[test]
fn canonical_reaction_key_does_not_conflate_different_reactions() {
use crate::reaction::{BalancedReaction, ReactionSpecies};
let ba_o = composition(&[("Ba", 1.0), ("O", 1.0)]);
let sr_o = composition(&[("Sr", 1.0), ("O", 1.0)]);
let product_a = composition(&[("Ba", 1.0), ("O", 1.0)]);
let product_b = composition(&[("Sr", 1.0), ("O", 1.0)]);
let a = BalancedReaction::new(
vec![ReactionSpecies::new(ba_o, 1).unwrap()],
vec![ReactionSpecies::new(product_a, 1).unwrap()],
)
.unwrap();
let b = BalancedReaction::new(
vec![ReactionSpecies::new(sr_o, 1).unwrap()],
vec![ReactionSpecies::new(product_b, 1).unwrap()],
)
.unwrap();
assert_ne!(
CanonicalReactionKey::from_reaction(&a),
CanonicalReactionKey::from_reaction(&b)
);
}
#[test]
fn canonical_reaction_key_is_coefficient_scale_invariant() {
use crate::reaction::{BalancedReaction, ReactionSpecies};
let ba_co3 = composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]);
let ba_o = composition(&[("Ba", 1.0), ("O", 1.0)]);
let co2 = composition(&[("C", 1.0), ("O", 2.0)]);
let scale_1 = BalancedReaction::new(
vec![ReactionSpecies::new(ba_co3.clone(), 1).unwrap()],
vec![
ReactionSpecies::new(ba_o.clone(), 1).unwrap(),
ReactionSpecies::new(co2.clone(), 1).unwrap(),
],
)
.unwrap();
let scale_2 = BalancedReaction::new(
vec![ReactionSpecies::new(ba_co3, 2).unwrap()],
vec![
ReactionSpecies::new(ba_o, 2).unwrap(),
ReactionSpecies::new(co2, 2).unwrap(),
],
)
.unwrap();
assert_ne!(
scale_1, scale_2,
"sanity check: BalancedReaction's own PartialEq IS scale-sensitive"
);
assert_eq!(
CanonicalReactionKey::from_reaction(&scale_1),
CanonicalReactionKey::from_reaction(&scale_2)
);
}
#[test]
fn rejects_sets_with_unremovable_extra_elements() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = vec![
candidate("SrCO3", &[("Sr", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let bad_root = outcome.rejected.iter().find(|r| {
let ids: BTreeSet<&str> = r.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["SrCO3"])
});
assert_eq!(
bad_root.map(|r| r.reason_codes.clone()),
Some(vec![RejectionCode::UnsupportedByproductRequired])
);
assert!(
outcome
.accepted
.iter()
.all(|a| !a.precursors.iter().any(|p| p.0 == "SrCO3")),
"no accepted set may use SrCO3, directly or via any larger combination: {:?}",
outcome.accepted
);
}
#[test]
fn accepts_a_nitrate_precursor_via_the_curated_no2_byproduct() {
let target = composition(&[("Ba", 1.0), ("O", 1.0)]);
let catalog = vec![candidate(
"Ba(NO3)2",
&[("Ba", 1.0), ("N", 2.0), ("O", 6.0)],
)];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let nitrate_route = outcome.accepted.iter().find(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["Ba(NO3)2"])
});
assert!(
nitrate_route.is_some(),
"Ba(NO3)2 -> BaO + NO2 + O2 must now be accepted: {:?}",
outcome
);
}
#[test]
fn accepts_an_oxalate_precursor_via_the_curated_co_byproduct() {
let target = composition(&[("Fe", 1.0), ("O", 1.0)]);
let catalog = vec![candidate("FeC2O4", &[("Fe", 1.0), ("C", 2.0), ("O", 4.0)])];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let oxalate_route = outcome.accepted.iter().find(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["FeC2O4"])
});
assert!(
oxalate_route.is_some(),
"FeC2O4 -> FeO + CO2 + CO must now be accepted: {:?}",
outcome
);
}
#[test]
fn search_finds_exactly_one_batio3_route_even_though_the_full_curated_set_is_ambiguous() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let ba_ti_routes: Vec<_> = outcome
.accepted
.iter()
.filter(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["BaCO3", "TiO2"])
})
.collect();
assert_eq!(
ba_ti_routes.len(),
1,
"exactly one BaCO3+TiO2 route must be accepted, not the CO-inclusive ambiguous \
second solution: {:?}",
outcome.accepted
);
assert_eq!(
ba_ti_routes[0].reaction.products().len(),
2,
"the accepted route's byproduct must be plain CO2, not the CO+O2 split"
);
}
#[test]
fn accepts_an_acetate_precursor_via_the_curated_acetone_byproduct() {
let target = composition(&[("Ba", 1.0), ("O", 1.0)]);
let catalog = vec![candidate(
"Ba(CH3COO)2",
&[("Ba", 1.0), ("C", 4.0), ("H", 6.0), ("O", 4.0)],
)];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let acetate_route = outcome.accepted.iter().find(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["Ba(CH3COO)2"])
});
assert!(
acetate_route.is_some(),
"Ba(CH3COO)2 -> BaO + (CH3)2CO + CO2 must now be accepted: {:?}",
outcome
);
}
#[test]
fn search_finds_exactly_one_ba2tio4_route_even_though_the_full_curated_set_is_ambiguous() {
let target = composition(&[("Ba", 2.0), ("Ti", 1.0), ("O", 4.0)]);
let catalog = vec![
candidate("Ba(OH)2", &[("Ba", 1.0), ("O", 2.0), ("H", 2.0)]),
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let carbonate_routes: Vec<_> = outcome
.accepted
.iter()
.filter(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids == BTreeSet::from(["BaCO3", "TiO2"])
})
.collect();
assert_eq!(
carbonate_routes.len(),
1,
"exactly one BaCO3+TiO2 route must be accepted, not the acetone-inclusive \
ambiguous alternative: {:?}",
outcome.accepted
);
assert!(
outcome.accepted.iter().all(|a| {
let ids: BTreeSet<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
ids != BTreeSet::from(["Ba(OH)2", "BaCO3", "TiO2"])
}),
"the 3-candidate acetone-splitting route must never be accepted: {:?}",
outcome.accepted
);
}
#[test]
fn never_generates_a_combination_larger_than_the_configured_maximum() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let budget = SearchBudget {
max_precursors_per_plan: 2,
..generous_budget()
};
let outcome =
search_precursor_sets(&target, &catalog, &PlanningConstraints::default(), &budget)
.unwrap();
for a in &outcome.accepted {
assert!(a.precursors.len() <= 2);
}
for r in &outcome.rejected {
assert!(r.precursors.len() <= 2);
}
}
#[test]
fn rejects_combinations_containing_a_forbidden_element() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let mut constraints = PlanningConstraints::default();
constraints.forbidden_elements.insert(element("C"));
let outcome =
search_precursor_sets(&target, &catalog, &constraints, &generous_budget()).unwrap();
assert!(
outcome
.accepted
.iter()
.all(|a| !a.precursors.iter().any(|p| p.0 == "BaCO3")),
"no accepted set may use BaCO3 once C is forbidden"
);
let forbidden_rejection = outcome
.rejected
.iter()
.find(|r| r.precursors.iter().any(|p| p.0 == "BaCO3"))
.expect("BaCO3-containing combinations must be rejected, not silently dropped");
assert_eq!(
forbidden_rejection.reason_codes,
vec![RejectionCode::ForbiddenElementPresent]
);
}
#[test]
fn duplicate_catalog_entries_do_not_duplicate_results() {
let target = composition(&[("Ba", 1.0), ("O", 1.0)]);
let raw = vec![
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
];
let catalog = InMemoryPrecursorCatalog::new(raw);
let candidates = catalog
.candidates_for(&target, &PlanningConstraints::default())
.unwrap();
assert_eq!(
candidates.len(),
1,
"duplicate PrecursorId entries must collapse to one"
);
let outcome = search_precursor_sets(
&target,
&candidates,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let single_bao_accepts = outcome
.accepted
.iter()
.filter(|a| a.precursors == vec![PrecursorId("BaO".to_string())])
.count();
assert_eq!(single_bao_accepts, 1);
}
#[test]
fn accepted_precursor_ids_stay_aligned_with_reaction_reactants() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
for accepted in &outcome.accepted {
assert_eq!(
accepted.precursors.len(),
accepted.reaction.reactants().len(),
"precursors and reactants must be the same length: {accepted:?}"
);
for (id, species) in accepted
.precursors
.iter()
.zip(accepted.reaction.reactants())
{
let candidate = catalog.iter().find(|c| &c.id == id).unwrap();
assert_eq!(
candidate.composition, species.composition,
"precursor id {id} must match its reactant composition"
);
}
}
assert!(
!outcome.accepted.is_empty(),
"fixture must actually exercise the search, not vacuously pass"
);
}
#[test]
fn a_redundant_larger_combination_is_rejected_as_a_duplicate_not_double_accepted() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
];
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let expected_ids = BTreeSet::from([
PrecursorId("BaO".to_string()),
PrecursorId("TiO2".to_string()),
]);
let occurrences = outcome
.accepted
.iter()
.filter(|a| a.precursors.iter().cloned().collect::<BTreeSet<_>>() == expected_ids)
.count();
assert_eq!(
occurrences, 1,
"the {{BaO, TiO2}} precursor set must be accepted exactly once: {:?}",
outcome.accepted
);
assert!(
outcome.rejected.iter().any(|r| {
r.reason_codes == vec![RejectionCode::DuplicatePlan]
&& r.precursors.contains(&PrecursorId("BaO".to_string()))
&& r.precursors.contains(&PrecursorId("TiO2".to_string()))
}),
"the redundant 3-candidate collapse must be explained as DuplicatePlan, not \
silently dropped or silently double-accepted: {:?}",
outcome.rejected
);
}
#[test]
fn duplicate_collapse_keeps_the_lexicographically_smallest_precursor_set_regardless_of_arrival_order()
{
let target = composition(&[("Ba", 1.0), ("O", 1.0)]);
let vendor_a = candidate("BaO-vendorA", &[("Ba", 1.0), ("O", 1.0)]);
let vendor_b = candidate("BaO-vendorB", &[("Ba", 1.0), ("O", 1.0)]);
let forward = search_precursor_sets(
&target,
&[vendor_a.clone(), vendor_b.clone()],
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let reversed = search_precursor_sets(
&target,
&[vendor_b, vendor_a],
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let expected = vec![PrecursorId("BaO-vendorA".to_string())];
assert_eq!(
forward
.accepted
.iter()
.map(|a| a.precursors.clone())
.collect::<Vec<_>>(),
vec![expected.clone()],
"forward order must keep the lexicographically-smaller id: {:?}",
forward.accepted
);
assert_eq!(
reversed
.accepted
.iter()
.map(|a| a.precursors.clone())
.collect::<Vec<_>>(),
vec![expected],
"reversed order must keep the same id, not whichever arrived first: {:?}",
reversed.accepted
);
assert!(
reversed
.rejected
.iter()
.any(|r| r.reason_codes == vec![RejectionCode::DuplicatePlan]),
"the superseded vendorB-first entry must be recorded as DuplicatePlan, not \
silently dropped: {:?}",
reversed.rejected
);
}
#[test]
fn result_is_independent_of_catalog_insertion_order() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let mut shuffled = barium_titanate_catalog();
shuffled.reverse();
let a = search_precursor_sets(
&target,
&InMemoryPrecursorCatalog::new(barium_titanate_catalog())
.candidates_for(&target, &PlanningConstraints::default())
.unwrap(),
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let b = search_precursor_sets(
&target,
&InMemoryPrecursorCatalog::new(shuffled)
.candidates_for(&target, &PlanningConstraints::default())
.unwrap(),
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let ids_a: BTreeSet<Vec<String>> = a
.accepted
.iter()
.map(|s| s.precursors.iter().map(|p| p.0.clone()).collect())
.collect();
let ids_b: BTreeSet<Vec<String>> = b
.accepted
.iter()
.map(|s| s.precursors.iter().map(|p| p.0.clone()).collect())
.collect();
assert_eq!(ids_a, ids_b);
}
#[test]
fn budget_exhaustion_is_reported_distinctly_from_no_candidates() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let tiny_budget = SearchBudget {
max_precursor_sets: 1,
max_precursors_per_plan: 3,
max_plans_returned: 100,
};
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&tiny_budget,
)
.unwrap();
let exhaustion = outcome
.rejected
.iter()
.find(|r| r.reason_codes == vec![RejectionCode::SearchBudgetExhausted]);
assert!(
exhaustion.is_some(),
"must report budget exhaustion: {:?}",
outcome.rejected
);
assert!(exhaustion.unwrap().precursors.is_empty());
}
#[test]
fn missing_availability_metadata_does_not_block_acceptance() {
let target = composition(&[("Ba", 1.0), ("O", 1.0)]);
let with_metadata = vec![PrecursorCandidate {
id: PrecursorId("BaO".to_string()),
composition: composition(&[("Ba", 1.0), ("O", 1.0)]),
availability: Some(AvailabilityMetadata {
source: "curated_fixture".to_string(),
}),
}];
let without_metadata = vec![candidate("BaO", &[("Ba", 1.0), ("O", 1.0)])];
let a = search_precursor_sets(
&target,
&with_metadata,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
let b = search_precursor_sets(
&target,
&without_metadata,
&PlanningConstraints::default(),
&generous_budget(),
)
.unwrap();
assert_eq!(a.accepted.len(), 1);
assert_eq!(b.accepted.len(), 1);
assert_eq!(a.accepted[0].reaction, b.accepted[0].reaction);
}
#[test]
fn in_memory_catalog_scopes_to_target_relevant_candidates_and_ignores_insertion_order() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = InMemoryPrecursorCatalog::new(barium_titanate_catalog());
let result = catalog
.candidates_for(&target, &PlanningConstraints::default())
.unwrap();
let ids: Vec<&str> = result.iter().map(|c| c.id.0.as_str()).collect();
assert!(
!ids.contains(&"NaCl"),
"NaCl shares no element with Ba-Ti-O and must be scoped out"
);
assert!(ids.contains(&"BaCO3") && ids.contains(&"BaO") && ids.contains(&"TiO2"));
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted);
}
fn brute_force_accepted(
target: &Composition,
candidates: &[PrecursorCandidate],
constraints: &PlanningConstraints,
max_precursors_per_plan: usize,
) -> Vec<AcceptedPrecursorSet> {
let target_elements: BTreeSet<Element> = target.elements().collect();
let byproducts = balance::curated_byproducts().unwrap();
let byproduct_elements: BTreeSet<Element> =
byproducts.iter().flat_map(Composition::elements).collect();
let byproduct_subsets = power_set(&byproducts);
let (combos, _exhausted) =
generate_combinations(candidates.len(), max_precursors_per_plan, usize::MAX);
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for combo in &combos {
let combo_elements: BTreeSet<Element> = combo
.iter()
.flat_map(|&i| candidates[i].composition.elements())
.collect();
if combo_elements
.iter()
.any(|e| constraints.forbidden_elements.contains(e))
{
continue;
}
if !target_elements.is_subset(&combo_elements) {
continue;
}
let unremovable = combo_elements
.difference(&target_elements)
.any(|e| !byproduct_elements.contains(e));
if unremovable {
continue;
}
evaluate_complete_state(
combo,
candidates,
target,
&byproduct_subsets,
&mut accepted,
&mut rejected,
)
.unwrap();
}
accepted
}
fn canonical_sort(accepted: Vec<AcceptedPrecursorSet>) -> Vec<Vec<String>> {
let mut sets: Vec<Vec<String>> = accepted
.into_iter()
.map(|a| {
let mut ids: Vec<String> = a.precursors.iter().map(|p| p.0.clone()).collect();
ids.sort();
ids
})
.collect();
sets.sort();
sets
}
#[test]
fn search_matches_brute_force_enumeration_under_an_unlimited_budget() {
let scenarios: Vec<(Composition, Vec<PrecursorCandidate>)> = vec![
(
composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
barium_titanate_catalog(),
),
(
composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
candidate("SrCO3", &[("Sr", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
],
),
(
composition(&[("Fe", 2.0), ("O", 3.0)]),
vec![
candidate("Fe2O3", &[("Fe", 2.0), ("O", 3.0)]),
candidate("Fe", &[("Fe", 1.0)]),
candidate("O2", &[("O", 2.0)]),
candidate("FeCO3", &[("Fe", 1.0), ("C", 1.0), ("O", 3.0)]),
],
),
];
for (target, catalog) in scenarios {
let outcome = search_precursor_sets(
&target,
&catalog,
&PlanningConstraints::default(),
&SearchBudget {
max_precursor_sets: usize::MAX,
..SearchBudget::default()
},
)
.unwrap();
let reference = brute_force_accepted(
&target,
&catalog,
&PlanningConstraints::default(),
SearchBudget::default().max_precursors_per_plan,
);
assert_eq!(
canonical_sort(outcome.accepted),
canonical_sort(reference),
"frontier search and brute-force enumeration must agree under an \
unlimited budget for target {target:?}"
);
assert!(
!outcome
.rejected
.iter()
.any(|r| r.reason_codes == vec![RejectionCode::SearchBudgetExhausted]),
"an unlimited budget must never report SearchBudgetExhausted"
);
}
}
fn state_with_key(tie_break_key: TieBreakKey) -> SearchState {
SearchState {
chosen: vec![0],
missing: BTreeSet::new(),
priority: SearchPriority {
elements_missing: 0,
depth: 1,
},
tie_break_key,
}
}
#[test]
fn index_order_tie_break_prefers_lexicographically_smaller_chosen() {
let smaller = state_with_key(TieBreakKey::IndexOrder(vec![0, 1]));
let larger = state_with_key(TieBreakKey::IndexOrder(vec![0, 2]));
assert!(
smaller > larger,
"a lexicographically smaller chosen vector must pop first (compare Greater)"
);
}
#[test]
fn fusion_priority_sum_tie_break_prefers_lower_summed_rank() {
let better = state_with_key(TieBreakKey::FusionPrioritySum(TotalF64(1.0)));
let worse = state_with_key(TieBreakKey::FusionPrioritySum(TotalF64(5.0)));
assert!(
better > worse,
"a lower summed fused rank (more consensus-supported) must pop first"
);
}
#[test]
fn marginal_coverage_tie_break_prefers_higher_raw_coverage() {
let covers_more = state_with_key(TieBreakKey::MarginalCoverage(std::cmp::Reverse(3)));
let covers_less = state_with_key(TieBreakKey::MarginalCoverage(std::cmp::Reverse(1)));
assert!(
covers_more > covers_less,
"a larger raw marginal-coverage value must pop first"
);
}
#[test]
fn total_f64_eq_agrees_with_cmp_on_zero_and_negative_zero() {
let zero = TotalF64(0.0);
let neg_zero = TotalF64(-0.0);
assert_eq!(zero.cmp(&neg_zero), Ordering::Greater);
assert_ne!(zero, neg_zero, "eq must agree with cmp() != Equal here");
}
#[test]
fn total_f64_eq_agrees_with_cmp_on_nan() {
let nan_a = TotalF64(f64::NAN);
let nan_b = TotalF64(f64::NAN);
assert_eq!(nan_a.cmp(&nan_b), Ordering::Equal);
assert_eq!(nan_a, nan_b, "eq must agree with cmp() == Equal here");
}
#[test]
fn total_f64_eq_agrees_with_cmp_on_different_nan_payloads() {
let nan_a = TotalF64(f64::from_bits(0x7ff8_0000_0000_0001));
let nan_b = TotalF64(f64::from_bits(0x7ff8_0000_0000_0002));
assert_eq!(nan_a.eq(&nan_b), nan_a.cmp(&nan_b) == Ordering::Equal);
}
#[test]
fn total_f64_eq_agrees_with_cmp_on_infinities() {
let pos_inf = TotalF64(f64::INFINITY);
let neg_inf = TotalF64(f64::NEG_INFINITY);
assert_eq!(pos_inf.cmp(&neg_inf), Ordering::Greater);
assert_ne!(pos_inf, neg_inf);
assert_eq!(TotalF64(f64::INFINITY), TotalF64(f64::INFINITY));
}
#[test]
fn search_state_eq_iff_cmp_equal_same_chosen_different_priority() {
let base = state_with_key(TieBreakKey::IndexOrder(vec![0]));
let mut different_priority = base.clone();
different_priority.priority.elements_missing = 1;
assert_eq!(base.chosen, different_priority.chosen);
assert_ne!(
base.cmp(&different_priority),
Ordering::Equal,
"differing priority must make cmp non-Equal"
);
assert_ne!(
base, different_priority,
"eq must agree with cmp() != Equal: a chosen-only PartialEq would \
wrongly report these as equal"
);
}
#[test]
fn search_state_eq_iff_cmp_equal_same_chosen_different_tie_break_key() {
let a = state_with_key(TieBreakKey::IndexOrder(vec![0]));
let b = state_with_key(TieBreakKey::IndexOrder(vec![1]));
let mut a = a;
let mut b = b;
a.chosen = vec![0];
b.chosen = vec![0];
assert_eq!(a.chosen, b.chosen);
assert_ne!(
a.cmp(&b),
Ordering::Equal,
"differing tie_break_key must make cmp non-Equal"
);
assert_ne!(
a, b,
"eq must agree with cmp() != Equal: a chosen-only PartialEq would \
wrongly report these as equal"
);
}
#[test]
fn search_state_eq_iff_cmp_equal_when_every_ordering_field_matches() {
let a = state_with_key(TieBreakKey::IndexOrder(vec![0, 1]));
let b = state_with_key(TieBreakKey::IndexOrder(vec![0, 1]));
assert_eq!(a.cmp(&b), Ordering::Equal);
assert_eq!(a, b, "eq must agree with cmp() == Equal");
}
#[cfg(feature = "search_diagnostics")]
#[test]
fn diagnostic_search_under_index_order_matches_plain_search_precursor_sets() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let budget = generous_budget();
let plain =
search_precursor_sets(&target, &catalog, &PlanningConstraints::default(), &budget)
.unwrap();
let gold = vec![
PrecursorId("BaCO3".to_string()),
PrecursorId("TiO2".to_string()),
];
let trace = search_precursor_sets_diagnostic(
&target,
&catalog,
&PlanningConstraints::default(),
&budget,
&TieBreakPolicy::IndexOrder,
&gold,
)
.unwrap();
let plain_found_gold = plain.accepted.iter().any(|a| {
let mut got: Vec<&str> = a.precursors.iter().map(|p| p.0.as_str()).collect();
got.sort_unstable();
got == vec!["BaCO3", "TiO2"]
});
assert_eq!(
plain_found_gold, trace.recovered,
"the diagnostic wrapper under IndexOrder must agree with plain \
search_precursor_sets on whether this exact route is accepted"
);
assert!(trace.gold_present_in_candidates);
assert!(trace.gold_covers_all_target_elements);
assert!(trace.gold_pushed_to_frontier);
assert!(trace.gold_pop_index.is_some());
assert!(!trace.budget_exhausted);
}
#[cfg(feature = "search_diagnostics")]
#[test]
fn diagnostic_search_reports_gold_absent_when_gold_references_an_unknown_precursor() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let gold = vec![PrecursorId("NotInCatalog".to_string())];
let trace = search_precursor_sets_diagnostic(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
&TieBreakPolicy::IndexOrder,
&gold,
)
.unwrap();
assert!(!trace.gold_present_in_candidates);
assert!(!trace.gold_covers_all_target_elements);
assert!(!trace.gold_pushed_to_frontier);
assert_eq!(trace.gold_pop_index, None);
assert!(!trace.recovered);
}
#[cfg(feature = "search_diagnostics")]
#[test]
fn diagnostic_search_marginal_coverage_policy_runs_and_agrees_on_recall_ceiling() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let catalog = barium_titanate_catalog();
let gold = vec![
PrecursorId("BaCO3".to_string()),
PrecursorId("TiO2".to_string()),
];
let trace = search_precursor_sets_diagnostic(
&target,
&catalog,
&PlanningConstraints::default(),
&generous_budget(),
&TieBreakPolicy::MarginalCoverage,
&gold,
)
.unwrap();
assert!(
trace.recovered,
"a generous budget must still recover this route"
);
assert!(!trace.budget_exhausted);
}
}