use gugen::{
Composition, Element, InMemoryPrecursorCatalog, Planner, PlanningConfig, PlanningConstraints,
PrecursorCandidate, PrecursorId, TargetSpecification,
};
use std::collections::BTreeSet;
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,
}
}
struct LiteratureFixture {
name: &'static str,
category: &'static str,
target: Composition,
literature_precursor_ids: BTreeSet<&'static str>,
catalog: Vec<PrecursorCandidate>,
citation: &'static str,
}
fn fixtures() -> Vec<LiteratureFixture> {
vec![
LiteratureFixture {
name: "LaAlO3",
category: "perovskite oxide",
target: composition(&[("La", 1.0), ("Al", 1.0), ("O", 3.0)]),
literature_precursor_ids: BTreeSet::from(["La2O3", "Al2O3"]),
catalog: vec![
candidate("La2O3", &[("La", 2.0), ("O", 3.0)]),
candidate("Al2O3", &[("Al", 2.0), ("O", 3.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
candidate("La2(CO3)3", &[("La", 2.0), ("C", 3.0), ("O", 9.0)]),
],
citation: "0.5 La2O3 + 0.5 Al2O3 -> LaAlO3, cross-validated across 10 independent \
paper DOIs in the Kononova et al. 2019 dataset (CC BY 4.0), recounted directly \
against the correctly-licensed figshare file (Phase 14); representative entry: \
DOI 10.1149/2.053405jes",
},
LiteratureFixture {
name: "MgAl2O4",
category: "spinel oxide",
target: composition(&[("Mg", 1.0), ("Al", 2.0), ("O", 4.0)]),
literature_precursor_ids: BTreeSet::from(["MgO", "Al2O3"]),
catalog: vec![
candidate("MgO", &[("Mg", 1.0), ("O", 1.0)]),
candidate("Al2O3", &[("Al", 2.0), ("O", 3.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
candidate("MgCO3", &[("Mg", 1.0), ("C", 1.0), ("O", 3.0)]),
],
citation: "1 Al2O3 + 1 MgO -> MgAl2O4, cross-validated across 16 independent paper \
DOIs in the Kononova et al. 2019 dataset (CC BY 4.0), recounted directly \
against the correctly-licensed figshare file (Phase 14); representative entry: \
DOI 10.1007/s11663-014-0207-8 -- read directly (Phase 10), confirmed to report \
both the route and its actual firing conditions.",
},
LiteratureFixture {
name: "LiFePO4",
category: "phosphate",
target: composition(&[("Li", 1.0), ("Fe", 1.0), ("P", 1.0), ("O", 4.0)]),
literature_precursor_ids: BTreeSet::from(["FePO4", "Li2CO3"]),
catalog: vec![
candidate("FePO4", &[("Fe", 1.0), ("P", 1.0), ("O", 4.0)]),
candidate("Li2CO3", &[("Li", 2.0), ("C", 1.0), ("O", 3.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
],
citation: "Two separate claims, deliberately not fused into one (a fused version \
of this citation was caught and corrected before this fixture was ever \
committed -- see tasks/todo.md's Phase 14 section): (1) the Kononova et al. \
2019 dataset (CC BY 4.0) attests that FePO4 + Li2CO3 is a real, independently \
reported precursor set for a LiFePO4 target -- 6 distinct paper DOIs, recounted \
by querying the correctly-licensed figshare file directly on 2026-08-14 (Phase \
14); representative entry: DOI 10.1016/j.electacta.2009.03.063, Chang, Lv, \
Tang, Li, Yuan, Wang, \"Synthesis and characterization of high-density \
LiFePO4/C composites as cathode materials for lithium-ion batteries,\" \
Electrochimica Acta (2009) -- title/authors/venue/year confirmed via CrossRef; \
the paper itself is paywalled with no accessible full text found (confirmed via \
Unpaywall), so its specific reported conditions were not independently read \
(same disclosed-but-unread tier as this suite's existing LaAlO3 citation), only \
the corpus's own attribution of this route to this DOI and the route's presence \
among 6 independent attestations. (2) The exact reaction 4 FePO4 + 2 Li2CO3 -> \
4 LiFePO4 + 2 CO2 + O2 is gugen's own balance() output, not a corpus claim -- \
it balances exactly within gugen's existing curated byproduct allow-list (CO2 \
and O2, no widening needed), verified by actually running balance() before \
adopting this fixture. The corpus attests only the (target, precursor-set) \
pair, not this specific coefficient/byproduct choice: at least 3 of the 6 \
DOIs' own titles (electacta.2012.02.102, jallcom.2010.02.173, \
jpowsour.2008.07.032 -- checked via CrossRef, not assumed) name carbothermal \
reduction or carbon coating, i.e. carbon-mediated Fe3+ -> Fe2+ reduction \
(LiFePO4/C composites), a different real-world mechanism than the O2-release \
byproduct gugen's balancer independently arrived at. gugen does not model \
carbothermal reduction; the O2-release route is a chemically valid balance of \
the same target/precursor formulas, not a claim that any of these 6 papers used \
that exact mechanism.",
},
LiteratureFixture {
name: "CaO",
category: "simple binary oxide",
target: composition(&[("Ca", 1.0), ("O", 1.0)]),
literature_precursor_ids: BTreeSet::from(["CaCO3"]),
catalog: vec![
candidate("CaCO3", &[("Ca", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
],
citation: "CaCO3 -> CaO + CO2 at 900 C. Seesanong, Seangarun, Boonchom, \
Laohavisuti, Boonmee, Thompho, Rungrojchaipon, \"Low-Cost and Eco-Friendly \
Calcium Oxide Prepared via Thermal Decompositions of Calcium Carbonate and \
Calcium Acetate Precursors Derived from Waste Oyster Shells,\" Materials \
17(15), 3875 (2024), DOI 10.3390/ma17153875. NOT from the Kononova dataset \
(empirical finding: that corpus has zero simple-binary-oxide-target entries).",
},
LiteratureFixture {
name: "BaTiO3",
category: "carbonate precursor route",
target: composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
literature_precursor_ids: BTreeSet::from(["BaCO3", "TiO2"]),
catalog: vec![
candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]),
],
citation: "1 BaCO3 + 1 TiO2 -> BaTiO3 + CO2, the strongest-attested route in this \
suite: 83 independent paper DOIs in the Kononova et al. 2019 dataset (CC BY \
4.0) report it, recounted directly against the correctly-licensed figshare \
file (Phase 14). The original representative entry (DOI \
10.1111/j.1551-2916.2006.01172.x) was confirmed on direct reading to be a \
NaNbO3-BaTiO3 solid-solution study, not plain BaTiO3 -- a topic mismatch found \
while sourcing Phase 10's condition data, not a Phase 8 transcription error \
(the same DOI also tags a separate, different-target NaNbO3 record in the same \
corpus). Replaced with an independently verified, directly-read example: DOI \
10.3390/cryst14040304, Qi et al., \"The Effect of Sputtering Target Density on \
the Crystal and Electronic Structure of Epitaxial BaTiO3 Thin Films,\" Crystals \
14(4), 304 (2024), open access (CC BY) -- read directly, confirms exactly this \
route: \"TiO2 ... and BaCO3 ... powders were mixed in a molar ratio of 1:1 and \
calcined.\" This DOI is not itself one of the 83 Kononova-corpus attestations \
(that 2019 corpus predates this 2024 paper) -- it is a separate, independently \
verified confirmation of the same route, a stronger evidentiary tier than \
naming an unread corpus entry. Also a perovskite target (BaTiO3 satisfies both \
categories in this list -- a real overlap, not a fixture-selection error) and \
the same route used throughout this crate's own examples/tests since Phase 1.",
},
]
}
fn plan_fixture(fixture: &LiteratureFixture) -> gugen::SynthesisPlanningReport {
let catalog = InMemoryPrecursorCatalog::new(fixture.catalog.clone());
let planner = Planner::builder(catalog, PlanningConfig::default()).build();
let target = TargetSpecification {
composition: fixture.target.clone(),
structure: None,
desired_phase: None,
constraints: PlanningConstraints::default(),
};
planner
.plan(&target, "2026-08-14T00:00:00Z")
.unwrap_or_else(|e| panic!("{} must plan without error: {e}", fixture.name))
}
#[test]
fn every_literature_route_is_recovered_exactly() {
let mut missing = Vec::new();
for fixture in fixtures() {
let report = plan_fixture(&fixture);
let found = report.plans.iter().any(|p| {
let ids: BTreeSet<&str> = p
.precursors
.iter()
.map(|s| s.precursor.0.as_str())
.collect();
ids == fixture.literature_precursor_ids
});
if !found {
missing.push(format!(
"{} ({}): cited route {:?} not recovered; accepted sets were {:?} -- {}",
fixture.name,
fixture.category,
fixture.literature_precursor_ids,
report
.plans
.iter()
.map(|p| p
.precursors
.iter()
.map(|s| s.precursor.0.clone())
.collect::<Vec<_>>())
.collect::<Vec<_>>(),
fixture.citation
));
}
}
assert!(
missing.is_empty(),
"known-route recovery failed for: {missing:#?}"
);
}
#[test]
fn a_valid_alternative_precursor_is_accepted_alongside_the_cited_route() {
let batio3 = fixtures().into_iter().find(|f| f.name == "BaTiO3").unwrap();
let report = plan_fixture(&batio3);
let sets: Vec<BTreeSet<&str>> = report
.plans
.iter()
.map(|p| {
p.precursors
.iter()
.map(|s| s.precursor.0.as_str())
.collect()
})
.collect();
assert!(
sets.contains(&BTreeSet::from(["BaCO3", "TiO2"])),
"cited route missing: {sets:?}"
);
assert!(
sets.contains(&BTreeSet::from(["BaO", "TiO2"])),
"valid alternative (BaO instead of BaCO3) missing -- a partial precursor match \
that should still be a real, present plan: {sets:?}"
);
}
#[test]
fn every_recovered_plan_still_requires_manual_review_with_an_explicit_warning() {
for fixture in fixtures() {
let report = plan_fixture(&fixture);
for plan in &report.plans {
assert!(
plan.manual_review_required,
"{}: manual_review_required must always be true in v0.1",
fixture.name
);
assert!(
plan.warnings
.iter()
.any(|w| w.severity == gugen::WarningSeverity::Severe),
"{}: every plan must carry an explicit Severe safety warning: {:?}",
fixture.name,
plan.warnings
);
}
}
}
#[test]
fn confidence_overall_is_measured_not_assumed_to_be_constant() {
let mut distinct = BTreeSet::new();
let mut total = 0;
for fixture in fixtures() {
let report = plan_fixture(&fixture);
for plan in &report.plans {
total += 1;
distinct.insert(plan.confidence.overall.value().to_bits());
assert_eq!(
plan.confidence.process_conditions.value(),
0.0,
"{}: process_conditions must be 0.0 in v0.1 (no condition is ever resolved)",
fixture.name
);
}
}
assert!(total > 0, "fixture suite must produce plans to measure");
assert_eq!(
distinct,
BTreeSet::from([0.75_f64.to_bits()]),
"confidence.overall was NOT constant at 0.75 across the fixture suite -- the \
false-confidence finding documented in tasks/todo.md no longer matches reality; \
re-derive the report before trusting it"
);
}
#[test]
fn planning_is_reproducible_across_repeated_runs() {
for fixture in fixtures() {
let a = plan_fixture(&fixture);
let b = plan_fixture(&fixture);
assert_eq!(
a, b,
"{}: two runs of the same input diverged",
fixture.name
);
}
}