use crate::composition::{Composition, Element};
use crate::error::{GugenError, Result, require_finite};
use crate::evidence::{EvidenceStrength, PlanningEvidence};
use crate::process::{PlannedStep, ProcessStep, RouteFamily};
use crate::reaction::BalancedReaction;
use crate::report::{
ApplicabilityAssessment, PlanningWarning, UnresolvedRequirement, WarningSeverity,
};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Score01(f64);
impl Score01 {
pub const ZERO: Score01 = Score01(0.0);
pub const ONE: Score01 = Score01(1.0);
pub fn new(value: f64) -> Result<Self> {
require_finite("Score01", value)?;
if !(0.0..=1.0).contains(&value) {
return Err(GugenError::ScoreOutOfRange { value });
}
Ok(Self(value))
}
pub fn value(&self) -> f64 {
self.0
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Score01 {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
Score01::new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PlanScoreBreakdown {
pub stoichiometric_validity: Score01,
pub precursor_coverage: Score01,
pub thermodynamic_support: Option<Score01>,
pub process_simplicity: Score01,
pub evidence_strength: Score01,
pub safety_penalty: Score01,
pub uncertainty_penalty: Score01,
pub total_ranking_score: Score01,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RankingWeights {
pub stoichiometric_validity: f64,
pub precursor_coverage: f64,
pub thermodynamic_support: f64,
pub process_simplicity: f64,
pub evidence_strength: f64,
pub safety_penalty: f64,
pub uncertainty_penalty: f64,
}
impl Default for RankingWeights {
fn default() -> Self {
Self {
stoichiometric_validity: 1.0,
precursor_coverage: 1.0,
thermodynamic_support: 1.0,
process_simplicity: 1.0,
evidence_strength: 1.0,
safety_penalty: 1.0,
uncertainty_penalty: 1.0,
}
}
}
pub fn ranking_weights_digest(weights: &RankingWeights) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for w in [
weights.stoichiometric_validity,
weights.precursor_coverage,
weights.thermodynamic_support,
weights.process_simplicity,
weights.evidence_strength,
weights.safety_penalty,
weights.uncertainty_penalty,
] {
w.to_bits().hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConfidenceAssessment {
pub overall: Score01,
pub stoichiometry: Score01,
pub precursor_selection: Score01,
pub process_conditions: Score01,
pub evidence_coverage: Score01,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PlanningAssumption {
pub statement: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanAssessment {
pub score: PlanScoreBreakdown,
pub confidence: ConfidenceAssessment,
pub applicability: ApplicabilityAssessment,
pub assumptions: Vec<PlanningAssumption>,
pub unresolved: Vec<UnresolvedRequirement>,
pub manual_review_required: bool,
pub warnings: Vec<PlanningWarning>,
}
fn strength_value(strength: EvidenceStrength) -> f64 {
match strength {
EvidenceStrength::Weak => 0.25,
EvidenceStrength::Moderate => 0.6,
EvidenceStrength::Strong => 0.9,
}
}
fn step_bounds(route_family: RouteFamily) -> (usize, usize) {
match route_family {
RouteFamily::ConventionalSolidState => (7, 9),
RouteFamily::Mechanochemical => (4, 6),
}
}
fn resolved_condition_fraction(steps: &[PlannedStep]) -> Score01 {
let mut total = 0u32;
let mut resolved = 0u32;
for planned in steps {
match &planned.step {
ProcessStep::Heat {
temperature,
duration,
atmosphere,
ramp,
..
} => {
for slot in [
temperature.is_some(),
duration.is_some(),
atmosphere.is_some(),
ramp.is_some(),
] {
total += 1;
resolved += slot as u32;
}
}
ProcessStep::Grind { duration, .. } => {
total += 1;
resolved += duration.is_some() as u32;
}
ProcessStep::Form { pressure, .. } => {
total += 1;
resolved += pressure.is_some() as u32;
}
_ => {}
}
}
if total == 0 {
return Score01::ONE;
}
Score01::new(f64::from(resolved) / f64::from(total))
.expect("resolved <= total, so the ratio is within [0, 1]")
}
fn collect_unresolved(
steps: &[PlannedStep],
process_evidence_provider_consulted: bool,
) -> Vec<UnresolvedRequirement> {
const NO_PROVIDER_REASON: &str =
"no thermodynamic or literature evidence provider is wired in yet (AGENTS.md §4.1)";
const CONSULTED_NO_MATCH_REASON: &str = "a process evidence provider was consulted but had \
no matching precedent for this field";
let reason = if process_evidence_provider_consulted {
CONSULTED_NO_MATCH_REASON
} else {
NO_PROVIDER_REASON
};
let mut unresolved = Vec::new();
for planned in steps {
match &planned.step {
ProcessStep::Heat {
purpose,
temperature,
duration,
atmosphere,
ramp,
..
} => {
let named = [
(temperature.is_none(), "temperature"),
(duration.is_none(), "duration"),
(atmosphere.is_none(), "atmosphere"),
(ramp.is_none(), "ramp rate"),
];
for (is_unresolved, field) in named {
if is_unresolved {
unresolved.push(UnresolvedRequirement {
description: format!("{purpose:?} heating step {field}"),
reason: reason.to_string(),
});
}
}
}
ProcessStep::Grind { duration, .. } if duration.is_none() => {
unresolved.push(UnresolvedRequirement {
description: "grinding duration".to_string(),
reason: reason.to_string(),
});
}
ProcessStep::Form { pressure, .. } if pressure.is_none() => {
unresolved.push(UnresolvedRequirement {
description: "forming pressure".to_string(),
reason: reason.to_string(),
});
}
_ => {}
}
}
unresolved
}
#[allow(clippy::too_many_arguments)]
pub fn score_plan(
target: &Composition,
target_applicability: &ApplicabilityAssessment,
balanced_reaction: Option<&BalancedReaction>,
steps: &[PlannedStep],
evidence: &[PlanningEvidence],
process_evidence_provider_consulted: bool,
route_family: RouteFamily,
weights: &RankingWeights,
) -> PlanAssessment {
let stoichiometric_validity = if balanced_reaction.is_some() {
Score01::ONE
} else {
Score01::ZERO
};
let precursor_coverage = match balanced_reaction {
Some(reaction) => {
let target_elements: BTreeSet<Element> = target.elements().collect();
let covered: BTreeSet<Element> = reaction
.reactants
.iter()
.flat_map(|s| s.composition.elements())
.collect();
if target_elements.is_subset(&covered) {
Score01::ONE
} else {
Score01::ZERO
}
}
None => Score01::ZERO,
};
let (min_template_steps, max_template_steps) = step_bounds(route_family);
let step_count = steps.len().clamp(min_template_steps, max_template_steps);
let process_simplicity = Score01::new(
1.0 - (step_count - min_template_steps) as f64
/ (max_template_steps - min_template_steps) as f64,
)
.expect("step_count is clamped to [min_template_steps, max_template_steps]");
let evidence_strength = evidence
.iter()
.map(|e| strength_value(e.strength))
.fold(None::<f64>, |acc, v| Some(acc.map_or(v, |a| a.min(v))))
.map(|v| Score01::new(v).expect("strength_value is within [0, 1]"))
.unwrap_or(Score01::ZERO);
let safety_penalty = Score01::ZERO;
let uncertainty_penalty = Score01::new(1.0 - resolved_condition_fraction(steps).value())
.expect("1.0 minus a Score01 in [0, 1] is within [0, 1]");
let positive_components: Vec<(f64, f64)> = vec![
(
weights.stoichiometric_validity,
stoichiometric_validity.value(),
),
(weights.precursor_coverage, precursor_coverage.value()),
(weights.process_simplicity, process_simplicity.value()),
(weights.evidence_strength, evidence_strength.value()),
];
let weight_sum: f64 = positive_components.iter().map(|(w, _)| w).sum();
let positive_average = if weight_sum > 0.0 {
positive_components.iter().map(|(w, v)| w * v).sum::<f64>() / weight_sum
} else {
0.0
};
let penalty_components: Vec<(f64, f64)> = vec![
(weights.safety_penalty, safety_penalty.value()),
(weights.uncertainty_penalty, uncertainty_penalty.value()),
];
let penalty_weight_sum: f64 = penalty_components.iter().map(|(w, _)| w).sum();
let penalty_average = if penalty_weight_sum > 0.0 {
penalty_components.iter().map(|(w, v)| w * v).sum::<f64>() / penalty_weight_sum
} else {
0.0
};
let total_ranking_score = Score01::new((positive_average - penalty_average).clamp(0.0, 1.0))
.expect("clamped to [0, 1]");
let score = PlanScoreBreakdown {
stoichiometric_validity,
precursor_coverage,
thermodynamic_support: None,
process_simplicity,
evidence_strength,
safety_penalty,
uncertainty_penalty,
total_ranking_score,
};
let process_conditions =
Score01::new(1.0 - uncertainty_penalty.value()).expect("clamped to [0, 1]");
let evidence_coverage = if evidence.is_empty() {
Score01::ZERO
} else {
Score01::ONE
};
let overall = Score01::new(
(stoichiometric_validity.value()
+ precursor_coverage.value()
+ process_conditions.value()
+ evidence_coverage.value())
/ 4.0,
)
.expect("average of four Score01 values is within [0, 1]");
let confidence = ConfidenceAssessment {
overall,
stoichiometry: stoichiometric_validity,
precursor_selection: precursor_coverage,
process_conditions,
evidence_coverage,
};
let warnings = vec![PlanningWarning {
message: "no hazard or safety data source is wired in yet: safety_penalty \
carries no real safety information, and this is not a safety \
clearance (AGENTS.md §15 \"unknown hazardを安全と扱わない\")"
.to_string(),
severity: WarningSeverity::Severe,
}];
let assumptions = vec![PlanningAssumption {
statement: format!(
"applicability is copied from the target-level assessment, not \
independently evaluated per route family: no route-suitability \
precedent exists for this target under {route_family:?} \
specifically (every applicable route family is offered \
unconditionally, AGENTS.md §13)"
),
}];
PlanAssessment {
score,
confidence,
applicability: target_applicability.clone(),
assumptions,
unresolved: collect_unresolved(steps, process_evidence_provider_consulted),
manual_review_required: true,
warnings,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::precursor::{AcceptedPrecursorSet, PrecursorId};
use crate::report::ApplicabilityLevel;
fn in_domain() -> ApplicabilityAssessment {
ApplicabilityAssessment {
level: ApplicabilityLevel::InDomain,
rationale: vec!["bulk inorganic, formula-only target".to_string()],
}
}
fn carbonate_and_oxide_routes() -> (Composition, ProcessTemplateResultPair) {
let ba = Element::new("Ba").unwrap();
let ti = Element::new("Ti").unwrap();
let o = Element::new("O").unwrap();
let c = Element::new("C").unwrap();
let target = Composition::new([(ba, 1.0), (ti, 1.0), (o, 3.0)]).unwrap();
let tio2 = Composition::new([(ti, 1.0), (o, 2.0)]).unwrap();
let baco3 = Composition::new([(ba, 1.0), (c, 1.0), (o, 3.0)]).unwrap();
let co2 = Composition::new([(c, 1.0), (o, 2.0)]).unwrap();
let carbonate_reaction =
crate::balance::balance(&[baco3, tio2.clone()], &[target.clone(), co2])
.unwrap()
.into_iter()
.next()
.expect("BaCO3 + TiO2 -> BaTiO3 + CO2 must balance");
let carbonate_set = AcceptedPrecursorSet {
precursors: vec![
PrecursorId("BaCO3".to_string()),
PrecursorId("TiO2".to_string()),
],
reaction: carbonate_reaction,
};
let bao = Composition::new([(ba, 1.0), (o, 1.0)]).unwrap();
let oxide_reaction = crate::balance::balance(&[bao, tio2], std::slice::from_ref(&target))
.unwrap()
.into_iter()
.next()
.expect("BaO + TiO2 -> BaTiO3 must balance");
let oxide_set = AcceptedPrecursorSet {
precursors: vec![
PrecursorId("BaO".to_string()),
PrecursorId("TiO2".to_string()),
],
reaction: oxide_reaction,
};
let mechanochemical_carbonate =
crate::process::mechanochemical_template(&target, &carbonate_set);
let mechanochemical_oxide = crate::process::mechanochemical_template(&target, &oxide_set);
let carbonate = crate::process::conventional_solid_state_template(&target, &carbonate_set);
let oxide = crate::process::conventional_solid_state_template(&target, &oxide_set);
(
target,
ProcessTemplateResultPair {
carbonate_reaction: carbonate_set.reaction.clone(),
carbonate_steps: carbonate.steps,
carbonate_evidence: carbonate.evidence,
oxide_reaction: oxide_set.reaction.clone(),
oxide_steps: oxide.steps,
oxide_evidence: oxide.evidence,
mechanochemical_carbonate_reaction: carbonate_set.reaction,
mechanochemical_carbonate_steps: mechanochemical_carbonate.steps,
mechanochemical_carbonate_evidence: mechanochemical_carbonate.evidence,
mechanochemical_oxide_reaction: oxide_set.reaction,
mechanochemical_oxide_steps: mechanochemical_oxide.steps,
mechanochemical_oxide_evidence: mechanochemical_oxide.evidence,
},
)
}
struct ProcessTemplateResultPair {
carbonate_reaction: BalancedReaction,
carbonate_steps: Vec<PlannedStep>,
carbonate_evidence: Vec<PlanningEvidence>,
oxide_reaction: BalancedReaction,
oxide_steps: Vec<PlannedStep>,
oxide_evidence: Vec<PlanningEvidence>,
mechanochemical_carbonate_reaction: BalancedReaction,
mechanochemical_carbonate_steps: Vec<PlannedStep>,
mechanochemical_carbonate_evidence: Vec<PlanningEvidence>,
mechanochemical_oxide_reaction: BalancedReaction,
mechanochemical_oxide_steps: Vec<PlannedStep>,
mechanochemical_oxide_evidence: Vec<PlanningEvidence>,
}
#[test]
fn score01_rejects_out_of_range_and_non_finite() {
assert!(Score01::new(-0.01).is_err());
assert!(Score01::new(1.01).is_err());
assert!(Score01::new(f64::NAN).is_err());
assert!(Score01::new(0.0).is_ok());
assert!(Score01::new(1.0).is_ok());
}
#[test]
fn missing_thermodynamic_support_does_not_zero_the_total_score() {
let (target, routes) = carbonate_and_oxide_routes();
let assessment = score_plan(
&target,
&in_domain(),
Some(&routes.oxide_reaction),
&routes.oxide_steps,
&routes.oxide_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
assert_eq!(assessment.score.thermodynamic_support, None);
assert!(
assessment.score.total_ranking_score.value() > 0.0,
"missing thermodynamic data must not zero the total score: {:?}",
assessment.score
);
}
#[test]
fn a_plan_with_no_evidence_scores_lower_than_one_with_evidence() {
let (target, routes) = carbonate_and_oxide_routes();
let with_evidence = score_plan(
&target,
&in_domain(),
Some(&routes.oxide_reaction),
&routes.oxide_steps,
&routes.oxide_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
let without_evidence = score_plan(
&target,
&in_domain(),
Some(&routes.oxide_reaction),
&routes.oxide_steps,
&[],
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
assert!(!routes.oxide_evidence.is_empty());
assert_eq!(without_evidence.score.evidence_strength, Score01::ZERO);
assert_eq!(without_evidence.confidence.evidence_coverage, Score01::ZERO);
assert!(with_evidence.confidence.evidence_coverage.value() > 0.0);
assert!(
with_evidence.confidence.overall.value() > without_evidence.confidence.overall.value()
);
}
#[test]
fn every_plan_requires_manual_review_with_an_explicit_warning() {
let (target, routes) = carbonate_and_oxide_routes();
let assessment = score_plan(
&target,
&in_domain(),
Some(&routes.oxide_reaction),
&routes.oxide_steps,
&routes.oxide_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
assert!(assessment.manual_review_required);
assert_eq!(assessment.score.safety_penalty, Score01::ZERO);
assert!(
assessment
.warnings
.iter()
.any(|w| w.severity == WarningSeverity::Severe),
"safety_penalty=0 must be paired with an explicit Severe warning: {:?}",
assessment.warnings
);
}
#[test]
fn no_balanced_reaction_means_zero_stoichiometric_and_coverage_scores() {
let (target, routes) = carbonate_and_oxide_routes();
let assessment = score_plan(
&target,
&in_domain(),
None,
&routes.oxide_steps,
&routes.oxide_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
assert_eq!(assessment.score.stoichiometric_validity, Score01::ZERO);
assert_eq!(assessment.score.precursor_coverage, Score01::ZERO);
assert_eq!(assessment.confidence.stoichiometry, Score01::ZERO);
assert_eq!(assessment.confidence.precursor_selection, Score01::ZERO);
}
#[test]
fn collects_one_unresolved_entry_per_none_condition_field() {
let (target, routes) = carbonate_and_oxide_routes();
let assessment = score_plan(
&target,
&in_domain(),
Some(&routes.carbonate_reaction),
&routes.carbonate_steps,
&routes.carbonate_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
let expected: usize = routes
.carbonate_steps
.iter()
.map(|p| match &p.step {
ProcessStep::Heat { .. } => 4,
ProcessStep::Grind { .. } | ProcessStep::Form { .. } => 1,
_ => 0,
})
.sum();
assert_eq!(assessment.unresolved.len(), expected);
assert_eq!(assessment.confidence.process_conditions, Score01::ZERO);
}
#[test]
fn process_simplicity_differs_between_carbonate_and_oxide_routes() {
let (target, routes) = carbonate_and_oxide_routes();
let carbonate = score_plan(
&target,
&in_domain(),
Some(&routes.carbonate_reaction),
&routes.carbonate_steps,
&routes.carbonate_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
let oxide = score_plan(
&target,
&in_domain(),
Some(&routes.oxide_reaction),
&routes.oxide_steps,
&routes.oxide_evidence,
false,
RouteFamily::ConventionalSolidState,
&RankingWeights::default(),
);
assert!(
carbonate.score.process_simplicity.value() < oxide.score.process_simplicity.value()
);
}
#[test]
fn mechanochemical_process_simplicity_is_scored_against_its_own_family_range() {
let (target, routes) = carbonate_and_oxide_routes();
let carbonate = score_plan(
&target,
&in_domain(),
Some(&routes.mechanochemical_carbonate_reaction),
&routes.mechanochemical_carbonate_steps,
&routes.mechanochemical_carbonate_evidence,
false,
RouteFamily::Mechanochemical,
&RankingWeights::default(),
);
let oxide = score_plan(
&target,
&in_domain(),
Some(&routes.mechanochemical_oxide_reaction),
&routes.mechanochemical_oxide_steps,
&routes.mechanochemical_oxide_evidence,
false,
RouteFamily::Mechanochemical,
&RankingWeights::default(),
);
assert_eq!(
routes.mechanochemical_oxide_steps.len(),
4,
"oxide-only mechanochemical route must be at Mechanochemical's own minimum"
);
assert_eq!(
routes.mechanochemical_carbonate_steps.len(),
6,
"byproduct-releasing mechanochemical route must be at Mechanochemical's own maximum"
);
assert_eq!(oxide.score.process_simplicity, Score01::ONE);
assert_eq!(carbonate.score.process_simplicity, Score01::ZERO);
assert!(
carbonate.score.process_simplicity.value() < oxide.score.process_simplicity.value()
);
}
#[test]
fn ranking_weights_digest_is_deterministic_and_sensitive_to_changes() {
let a = ranking_weights_digest(&RankingWeights::default());
let b = ranking_weights_digest(&RankingWeights::default());
assert_eq!(a, b);
let changed = RankingWeights {
evidence_strength: 2.0,
..RankingWeights::default()
};
let c = ranking_weights_digest(&changed);
assert_ne!(a, c);
}
}