use crate::composition::Element;
use crate::config::PlanningConfig;
use crate::error::Result;
use crate::evidence::{EvidenceKind, EvidenceScope, EvidenceStrength, PlanningEvidence};
use crate::precursor::{PrecursorId, PrecursorSelection, search_precursor_sets};
use crate::process::conventional_solid_state_template;
use crate::provenance::PlanningProvenance;
use crate::provider::{PrecursorCatalog, ProcessEvidenceProvider, ThermodynamicProvider};
use crate::reaction::{BalancedReaction, ThermodynamicConditions};
use crate::rejection::{RejectedCandidate, RejectionCode};
use crate::report::{
ApplicabilityAssessment, ApplicabilityLevel, PlanId, PlanningWarning, SCHEMA_VERSION,
SynthesisPlan, SynthesisPlanningReport, TargetSummary, UnresolvedRequirement, WarningSeverity,
};
use crate::score::{ranking_weights_digest, score_plan};
use crate::target::TargetSpecification;
pub struct Planner {
catalog: Box<dyn PrecursorCatalog>,
thermodynamic_provider: Option<Box<dyn ThermodynamicProvider>>,
process_evidence_provider: Option<Box<dyn ProcessEvidenceProvider>>,
config: PlanningConfig,
}
impl Planner {
pub fn new(
catalog: impl PrecursorCatalog + 'static,
process_evidence_provider: impl ProcessEvidenceProvider + 'static,
thermodynamic_provider: impl ThermodynamicProvider + 'static,
config: PlanningConfig,
) -> Self {
Self {
catalog: Box::new(catalog),
thermodynamic_provider: Some(Box::new(thermodynamic_provider)),
process_evidence_provider: Some(Box::new(process_evidence_provider)),
config,
}
}
pub fn offline_minimal(
catalog: impl PrecursorCatalog + 'static,
config: PlanningConfig,
) -> Self {
Self {
catalog: Box::new(catalog),
thermodynamic_provider: None,
process_evidence_provider: None,
config,
}
}
pub fn plan(
&self,
target: &TargetSpecification,
execution_timestamp: &str,
) -> Result<SynthesisPlanningReport> {
let composition = &target.composition;
let provenance = self.provenance(execution_timestamp);
let contradictory = contradictory_elements(target);
if !contradictory.is_empty() {
return Ok(abstain(target, &contradictory, provenance));
}
let applicability = assess_applicability(target);
let candidates = self
.catalog
.candidates_for(composition, &target.constraints)?;
let mut warnings = Vec::new();
if candidates.is_empty() {
warnings.push(PlanningWarning {
message: "the precursor catalog returned no candidates sharing any \
element with the target"
.to_string(),
severity: WarningSeverity::Caution,
});
}
let outcome = search_precursor_sets(
composition,
&candidates,
&target.constraints,
&self.config.search_budget,
)?;
let mut plans: Vec<SynthesisPlan> = Vec::with_capacity(outcome.accepted.len());
for accepted in &outcome.accepted {
let template = conventional_solid_state_template(composition, accepted);
let mut evidence = template.evidence;
let mut provider_warnings = Vec::new();
if let Some(provider) = &self.thermodynamic_provider {
match provider
.reaction_energy(&accepted.reaction, &ThermodynamicConditions::default())
{
Ok(Some(energy)) => evidence.push(PlanningEvidence {
kind: EvidenceKind::ThermodynamicData,
source_id: None,
statement: format!(
"reaction energy {:.4} eV/atom from the configured \
ThermodynamicProvider",
energy.value_ev_per_atom()
),
strength: EvidenceStrength::Moderate,
applicable_to: EvidenceScope::ExactTarget,
limitations: vec![
"a raw reaction energy is not converted into a favorability \
judgment: thermodynamic favorability is not experimental \
likelihood (AGENTS.md §4.3)"
.to_string(),
],
}),
Ok(None) => {}
Err(err) => provider_warnings.push(PlanningWarning {
message: format!(
"thermodynamic provider failed for this candidate, \
continuing without its data: {err}"
),
severity: WarningSeverity::Info,
}),
}
}
let precursors: Vec<PrecursorSelection> = accepted
.precursors
.iter()
.zip(&accepted.reaction.reactants)
.map(|(id, species)| PrecursorSelection {
precursor: id.clone(),
formula_units: species.coefficient,
})
.collect();
if let Some(provider) = &self.process_evidence_provider {
match provider.precedents(target, &precursors) {
Ok(precedents) => {
for precedent in precedents {
evidence.push(PlanningEvidence {
kind: EvidenceKind::UserProvidedPrecedent,
source_id: None,
statement: precedent.description,
strength: EvidenceStrength::Weak,
applicable_to: EvidenceScope::SimilarMaterial,
limitations: vec![
"ProcessPrecedent has no structured method/condition \
detail yet"
.to_string(),
],
});
}
}
Err(err) => provider_warnings.push(PlanningWarning {
message: format!(
"process evidence provider failed for this candidate, \
continuing without its data: {err}"
),
severity: WarningSeverity::Info,
}),
}
}
let assessment = score_plan(
composition,
&applicability,
Some(&accepted.reaction),
&template.steps,
&evidence,
&self.config.ranking_weights,
);
let mut plan_warnings = template.warnings;
plan_warnings.extend(assessment.warnings);
plan_warnings.extend(provider_warnings);
plans.push(SynthesisPlan {
plan_id: derive_plan_id(&accepted.precursors, &accepted.reaction),
route_family: template.route_family,
precursors,
balanced_reaction: Some(accepted.reaction.clone()),
steps: template.steps,
score: assessment.score,
confidence: assessment.confidence,
applicability: assessment.applicability,
evidence,
warnings: plan_warnings,
assumptions: assessment.assumptions,
unresolved: assessment.unresolved,
manual_review_required: assessment.manual_review_required,
});
}
plans.sort_by(|a, b| {
b.score
.total_ranking_score
.value()
.partial_cmp(&a.score.total_ranking_score.value())
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.plan_id.0.cmp(&b.plan_id.0))
});
let max_plans = self.config.search_budget.max_plans_returned;
let mut rejected_candidates = outcome.rejected;
let overflow = plans.len().saturating_sub(max_plans);
if overflow > 0 {
rejected_candidates.push(RejectedCandidate {
precursors: vec![],
reason_codes: vec![RejectionCode::SearchBudgetExhausted],
explanation: format!(
"{overflow} additional valid plan(s) were found but are not \
included: only the top {max_plans} by total_ranking_score are \
returned (SearchBudget::max_plans_returned)"
),
});
}
plans.truncate(max_plans);
Ok(SynthesisPlanningReport {
schema_version: SCHEMA_VERSION,
target: TargetSummary {
composition: composition.clone(),
structure_present: target.structure.is_some(),
desired_phase: target.desired_phase.as_ref().map(|p| p.phase_name.clone()),
},
applicability,
plans,
rejected_candidates,
unresolved: vec![],
warnings,
provenance,
})
}
fn provenance(&self, execution_timestamp: &str) -> PlanningProvenance {
PlanningProvenance {
gugen_version: PlanningProvenance::gugen_version().to_string(),
build_identifier: None,
schema_version: SCHEMA_VERSION,
chematic_crystal_version: None,
mikiwame_version: None,
precursor_catalog_version: None,
thermodynamic_provider_version: None,
process_template_version: None,
ranking_config_digest: Some(ranking_weights_digest(&self.config.ranking_weights)),
execution_timestamp: execution_timestamp.to_string(),
deterministic_seed: self.config.deterministic_seed,
enabled_features: enabled_features(),
}
}
}
fn enabled_features() -> Vec<String> {
let mut features = Vec::new();
if cfg!(feature = "serde") {
features.push("serde".to_string());
}
if cfg!(feature = "clap") {
features.push("clap".to_string());
}
if cfg!(feature = "mikiwame") {
features.push("mikiwame".to_string());
}
features
}
fn contradictory_elements(target: &TargetSpecification) -> Vec<Element> {
target
.composition
.elements()
.filter(|e| target.constraints.forbidden_elements.contains(e))
.collect()
}
fn abstain(
target: &TargetSpecification,
contradictory: &[Element],
provenance: PlanningProvenance,
) -> SynthesisPlanningReport {
let symbols = contradictory
.iter()
.map(Element::symbol)
.collect::<Vec<_>>()
.join(", ");
SynthesisPlanningReport {
schema_version: SCHEMA_VERSION,
target: TargetSummary {
composition: target.composition.clone(),
structure_present: target.structure.is_some(),
desired_phase: target.desired_phase.as_ref().map(|p| p.phase_name.clone()),
},
applicability: ApplicabilityAssessment {
level: ApplicabilityLevel::OutOfDomain,
rationale: vec![format!(
"target composition requires element(s) {symbols} that \
PlanningConstraints.forbidden_elements also forbids -- no plan can \
ever satisfy both"
)],
},
plans: vec![],
rejected_candidates: vec![],
unresolved: vec![UnresolvedRequirement {
description: "planning".to_string(),
reason: format!(
"target and constraints are self-contradictory over element(s) {symbols}"
),
}],
warnings: vec![],
provenance,
}
}
fn derive_plan_id(precursors: &[PrecursorId], reaction: &BalancedReaction) -> PlanId {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut ids: Vec<&str> = precursors.iter().map(|p| p.0.as_str()).collect();
ids.sort_unstable();
for id in &ids {
id.hash(&mut hasher);
}
for species in reaction.reactants.iter().chain(&reaction.products) {
for (element, amount) in species.composition.iter() {
element.symbol().hash(&mut hasher);
amount.to_bits().hash(&mut hasher);
}
species.coefficient.hash(&mut hasher);
}
PlanId(format!("plan-{:016x}", hasher.finish()))
}
fn assess_applicability(target: &TargetSpecification) -> ApplicabilityAssessment {
let rationale = if target.structure.is_some() {
"structure provided, but gugen has no structural classifier wired in \
to confirm it's in the validated bulk-inorganic domain (AGENTS.md §16 \
lists both in-domain and out-of-domain examples with structure present)"
.to_string()
} else {
"formula-only target, no structure provided (AGENTS.md §16's own \
example for this level)"
.to_string()
};
ApplicabilityAssessment {
level: ApplicabilityLevel::PartiallyInDomain,
rationale: vec![rationale],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composition::Composition;
use crate::config::SearchBudget;
use crate::error::ProviderError;
use crate::precursor::{AvailabilityMetadata, InMemoryPrecursorCatalog, PrecursorCandidate};
use crate::process::ProcessPrecedent;
use crate::reaction::ReactionEnergy;
use crate::target::PlanningConstraints;
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() -> InMemoryPrecursorCatalog {
InMemoryPrecursorCatalog::new(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)]),
])
}
fn barium_titanate_target() -> TargetSpecification {
TargetSpecification {
composition: composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
structure: None,
desired_phase: None,
constraints: PlanningConstraints::default(),
}
}
fn generous_config() -> PlanningConfig {
PlanningConfig {
search_budget: SearchBudget {
max_precursor_sets: 10_000,
max_precursors_per_plan: 3,
max_plans_returned: 20,
},
..PlanningConfig::default()
}
}
#[test]
fn offline_minimal_produces_ranked_plans_from_a_catalog_alone() {
let planner = Planner::offline_minimal(barium_titanate_catalog(), generous_config());
let report = planner
.plan(&barium_titanate_target(), "2026-08-14T00:00:00Z")
.unwrap();
assert!(!report.plans.is_empty(), "expected at least one plan");
assert!(
report
.plans
.iter()
.all(|p| p.balanced_reaction.is_some() && p.manual_review_required),
);
assert_eq!(
report.provenance.execution_timestamp,
"2026-08-14T00:00:00Z"
);
assert!(report.provenance.ranking_config_digest.is_some());
for window in report.plans.windows(2) {
assert!(
window[0].score.total_ranking_score.value()
>= window[1].score.total_ranking_score.value()
);
}
let ids: std::collections::BTreeSet<&str> =
report.plans.iter().map(|p| p.plan_id.0.as_str()).collect();
assert_eq!(
ids.len(),
report.plans.len(),
"plan_id must be unique across the report's plans: {:?}",
report
.plans
.iter()
.map(|p| &p.plan_id.0)
.collect::<Vec<_>>()
);
}
#[test]
fn self_contradictory_target_abstains_with_no_plans() {
let mut target = barium_titanate_target();
target.constraints.forbidden_elements.insert(element("Ba"));
let planner = Planner::offline_minimal(barium_titanate_catalog(), generous_config());
let report = planner.plan(&target, "2026-08-14T00:00:00Z").unwrap();
assert!(report.plans.is_empty());
assert_eq!(
report.applicability.level,
crate::report::ApplicabilityLevel::OutOfDomain
);
}
#[test]
fn empty_catalog_result_produces_a_warning_not_a_panic() {
let empty = InMemoryPrecursorCatalog::new(vec![]);
let planner = Planner::offline_minimal(empty, generous_config());
let report = planner
.plan(&barium_titanate_target(), "2026-08-14T00:00:00Z")
.unwrap();
assert!(report.plans.is_empty());
assert!(
report
.warnings
.iter()
.any(|w| w.message.contains("no candidates"))
);
}
struct FailingThermodynamicProvider;
impl ThermodynamicProvider for FailingThermodynamicProvider {
fn reaction_energy(
&self,
_reaction: &BalancedReaction,
_conditions: &ThermodynamicConditions,
) -> std::result::Result<Option<ReactionEnergy>, ProviderError> {
Err(ProviderError::Unavailable("simulated outage".to_string()))
}
}
struct FailingProcessEvidenceProvider;
impl ProcessEvidenceProvider for FailingProcessEvidenceProvider {
fn precedents(
&self,
_target: &TargetSpecification,
_precursors: &[PrecursorSelection],
) -> std::result::Result<Vec<ProcessPrecedent>, ProviderError> {
Err(ProviderError::Unavailable("simulated outage".to_string()))
}
}
#[test]
fn a_failing_optional_provider_degrades_to_a_warning_not_a_failure() {
let planner = Planner::new(
barium_titanate_catalog(),
FailingProcessEvidenceProvider,
FailingThermodynamicProvider,
generous_config(),
);
let report = planner
.plan(&barium_titanate_target(), "2026-08-14T00:00:00Z")
.unwrap();
assert!(!report.plans.is_empty());
for plan in &report.plans {
assert!(
plan.warnings
.iter()
.filter(|w| w.message.contains("continuing without"))
.count()
>= 2,
"expected both provider failures reflected as warnings: {:?}",
plan.warnings
);
}
}
#[test]
fn overflow_beyond_max_plans_returned_is_explained_not_silently_dropped() {
let tight_config = PlanningConfig {
search_budget: SearchBudget {
max_plans_returned: 1,
..generous_config().search_budget
},
..generous_config()
};
let planner = Planner::offline_minimal(barium_titanate_catalog(), tight_config);
let report = planner
.plan(&barium_titanate_target(), "2026-08-14T00:00:00Z")
.unwrap();
assert_eq!(report.plans.len(), 1);
assert!(report.rejected_candidates.iter().any(|r| {
r.reason_codes
.contains(&RejectionCode::SearchBudgetExhausted)
&& r.explanation.contains("additional valid plan")
}));
}
#[test]
fn plan_id_is_stable_when_an_unrelated_candidate_is_added_to_the_catalog() {
let target = barium_titanate_target();
let baseline = Planner::offline_minimal(barium_titanate_catalog(), generous_config())
.plan(&target, "2026-08-14T00:00:00Z")
.unwrap();
let mut with_extra = 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)]),
];
with_extra.reverse();
let augmented =
Planner::offline_minimal(InMemoryPrecursorCatalog::new(with_extra), generous_config())
.plan(&target, "2026-08-14T00:00:00Z")
.unwrap();
let plan_key = |plan: &SynthesisPlan| {
let mut ids: Vec<String> = plan
.precursors
.iter()
.map(|s| s.precursor.0.clone())
.collect();
ids.sort();
ids
};
let baseline_by_precursors: std::collections::BTreeMap<Vec<String>, &str> = baseline
.plans
.iter()
.map(|p| (plan_key(p), p.plan_id.0.as_str()))
.collect();
assert!(!baseline_by_precursors.is_empty());
for plan in &augmented.plans {
if let Some(&expected_id) = baseline_by_precursors.get(&plan_key(plan)) {
assert_eq!(
plan.plan_id.0.as_str(),
expected_id,
"plan_id for precursor set {:?} changed after an unrelated catalog addition",
plan_key(plan)
);
}
}
}
#[test]
fn missing_availability_metadata_still_flows_through_planning() {
let with_metadata = InMemoryPrecursorCatalog::new(vec![PrecursorCandidate {
id: PrecursorId("BaO".to_string()),
composition: composition(&[("Ba", 1.0), ("O", 1.0)]),
availability: Some(AvailabilityMetadata {
source: "curated_fixture".to_string(),
}),
}]);
let target = TargetSpecification {
composition: composition(&[("Ba", 1.0), ("O", 1.0)]),
structure: None,
desired_phase: None,
constraints: PlanningConstraints::default(),
};
let report = Planner::offline_minimal(with_metadata, generous_config())
.plan(&target, "2026-08-14T00:00:00Z")
.unwrap();
assert!(!report.plans.is_empty());
}
}