use crate::composition::{Composition, Element};
use crate::frac::Frac;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct GrammarId(pub &'static str);
impl std::fmt::Display for GrammarId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum GrammarEvidenceClass {
Stoichiometric,
CommonDecompositionHeuristic,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProposedIntermediate {
pub composition: Composition,
pub grammar: GrammarId,
pub evidence_class: GrammarEvidenceClass,
pub rationale: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct DedupedProposal {
pub composition: Composition,
pub grammars: Vec<GrammarId>,
pub evidence_class: GrammarEvidenceClass,
pub rationale: &'static str,
}
pub trait TransformationGrammar {
fn id(&self) -> GrammarId;
fn propose(&self, precursors: &[Composition]) -> Vec<ProposedIntermediate>;
}
fn validate_proposed_composition(inputs: &[&Composition], proposed: &Composition) -> bool {
let allowed: BTreeSet<Element> = inputs.iter().flat_map(|c| c.elements()).collect();
if !proposed.elements().all(|e| allowed.contains(&e)) {
return false;
}
if inputs.iter().any(|c| **c == *proposed) {
return false;
}
proposed.len() <= allowed.len()
}
fn el(symbol: &'static str) -> Element {
Element::new(symbol).unwrap_or_else(|_| panic!("{symbol} is a valid element symbol"))
}
fn frac(n: i128) -> Frac {
Frac::new(n, 1).expect("small integer literal never overflows Frac")
}
pub struct CarbonateToOxideGrammar;
impl TransformationGrammar for CarbonateToOxideGrammar {
fn id(&self) -> GrammarId {
GrammarId("carbonate-to-oxide")
}
fn propose(&self, precursors: &[Composition]) -> Vec<ProposedIntermediate> {
let (c, o, h) = (el("C"), el("O"), el("H"));
let mut out = Vec::new();
for p in precursors {
if p.len() < 3 || p.amount_of_frac(h).is_some() {
continue;
}
let (Some(c_amt), Some(o_amt)) = (p.amount_of_frac(c), p.amount_of_frac(o)) else {
continue;
};
let Ok(three_c) = c_amt.checked_mul(frac(3)) else {
continue;
};
if o_amt < three_c {
continue;
}
let Ok(two_c) = c_amt.checked_mul(frac(2)) else {
continue;
};
let Ok(new_o) = o_amt.checked_sub(two_c) else {
continue;
};
if new_o.is_zero() {
continue;
}
let pairs: Vec<(Element, f64)> = p
.iter()
.filter(|(e, _)| *e != c)
.map(|(e, amt)| {
if e == o {
(e, new_o.to_f64())
} else {
(e, amt)
}
})
.collect();
let Ok(proposed) = Composition::new(pairs) else {
continue;
};
if !validate_proposed_composition(&[p], &proposed) {
continue;
}
out.push(ProposedIntermediate {
composition: proposed,
grammar: self.id(),
evidence_class: GrammarEvidenceClass::Stoichiometric,
rationale: "carbonate decomposition MCO3 -> MO + CO2: removed all C, removed 2xC \
of O, all other elements unchanged",
});
}
out
}
}
pub struct HydroxideToOxideGrammar;
impl TransformationGrammar for HydroxideToOxideGrammar {
fn id(&self) -> GrammarId {
GrammarId("hydroxide-to-oxide")
}
fn propose(&self, precursors: &[Composition]) -> Vec<ProposedIntermediate> {
let (o, h, c) = (el("O"), el("H"), el("C"));
let mut out = Vec::new();
for p in precursors {
if p.len() < 2 || p.amount_of_frac(c).is_some() {
continue;
}
let (Some(o_amt), Some(h_amt)) = (p.amount_of_frac(o), p.amount_of_frac(h)) else {
continue;
};
if o_amt != h_amt {
continue;
}
let Ok(half_h) = h_amt.checked_div(frac(2)) else {
continue;
};
let Ok(new_o) = o_amt.checked_sub(half_h) else {
continue;
};
if new_o.is_zero() {
continue;
}
let pairs: Vec<(Element, f64)> = p
.iter()
.filter(|(e, _)| *e != h)
.map(|(e, amt)| {
if e == o {
(e, new_o.to_f64())
} else {
(e, amt)
}
})
.collect();
let Ok(proposed) = Composition::new(pairs) else {
continue;
};
if !validate_proposed_composition(&[p], &proposed) {
continue;
}
out.push(ProposedIntermediate {
composition: proposed,
grammar: self.id(),
evidence_class: GrammarEvidenceClass::Stoichiometric,
rationale: "hydroxide decomposition M(OH)n -> MO(n/2) + (n/2) H2O: removed all \
H, removed half of O, all other elements unchanged",
});
}
out
}
}
pub struct NitrateToOxideGrammar;
impl TransformationGrammar for NitrateToOxideGrammar {
fn id(&self) -> GrammarId {
GrammarId("nitrate-to-oxide")
}
fn propose(&self, precursors: &[Composition]) -> Vec<ProposedIntermediate> {
let (n, o, h) = (el("N"), el("O"), el("H"));
let mut out = Vec::new();
for p in precursors {
if p.amount_of_frac(h).is_some() {
continue;
}
let others: Vec<Element> = p.elements().filter(|e| *e != n && *e != o).collect();
if others.len() != 1 {
continue;
}
let metal = others[0];
let (Some(n_amt), Some(o_amt), Some(metal_amt)) = (
p.amount_of_frac(n),
p.amount_of_frac(o),
p.amount_of_frac(metal),
) else {
continue;
};
let Ok(three_n) = n_amt.checked_mul(frac(3)) else {
continue;
};
if o_amt != three_n {
continue; }
let Ok(new_o) = n_amt.checked_div(frac(2)) else {
continue;
};
let Ok(proposed) = Composition::new([(metal, metal_amt.to_f64()), (o, new_o.to_f64())])
else {
continue;
};
if !validate_proposed_composition(&[p], &proposed) {
continue;
}
out.push(ProposedIntermediate {
composition: proposed,
grammar: self.id(),
evidence_class: GrammarEvidenceClass::Stoichiometric,
rationale: "nitrate decomposition M(NO3)n -> MO(n/2) + n NOx: oxide side derived \
from charge balance only; nitrogen byproduct composition not fixed",
});
}
out
}
}
pub struct AcidCarbonatePhosphateGrammar;
impl TransformationGrammar for AcidCarbonatePhosphateGrammar {
fn id(&self) -> GrammarId {
GrammarId("acid-carbonate-phosphate")
}
fn propose(&self, precursors: &[Composition]) -> Vec<ProposedIntermediate> {
let (h, p_el, o, c) = (el("H"), el("P"), el("O"), el("C"));
let is_phosphoric_acid = |comp: &Composition| -> bool {
if comp.len() != 3 {
return false;
}
let (Some(h_amt), Some(p_amt), Some(o_amt)) = (
comp.amount_of_frac(h),
comp.amount_of_frac(p_el),
comp.amount_of_frac(o),
) else {
return false;
};
h_amt == p_amt.checked_mul(frac(3)).unwrap_or(frac(0))
&& o_amt == p_amt.checked_mul(frac(4)).unwrap_or(frac(0))
};
let monovalent_carbonate_metal = |comp: &Composition| -> Option<(Element, Frac)> {
if comp.len() != 3 || comp.amount_of_frac(h).is_some() {
return None;
}
let others: Vec<Element> = comp.elements().filter(|e| *e != c && *e != o).collect();
if others.len() != 1 {
return None;
}
let metal = others[0];
let (Some(c_amt), Some(o_amt), Some(metal_amt)) = (
comp.amount_of_frac(c),
comp.amount_of_frac(o),
comp.amount_of_frac(metal),
) else {
return None;
};
if o_amt != c_amt.checked_mul(frac(3)).ok()? {
return None; }
if metal_amt != c_amt.checked_mul(frac(2)).ok()? {
return None; }
Some((metal, metal_amt))
};
let mut out = Vec::new();
for (i, a) in precursors.iter().enumerate() {
for (j, b) in precursors.iter().enumerate() {
if i == j {
continue;
}
if !is_phosphoric_acid(a) {
continue;
}
let Some((metal, _)) = monovalent_carbonate_metal(b) else {
continue;
};
let Ok(proposed) =
Composition::new([(metal, 1.0), (h, 2.0), (p_el, 1.0), (o, 4.0)])
else {
continue;
};
if !validate_proposed_composition(&[a, b], &proposed) {
continue;
}
out.push(ProposedIntermediate {
composition: proposed,
grammar: self.id(),
evidence_class: GrammarEvidenceClass::CommonDecompositionHeuristic,
rationale: "acid+carbonate monobasic phosphate salt formation: \
2 H3PO4 + M2CO3 -> 2 MH2PO4 + CO2 + H2O; other phosphate basicities \
(M2HPO4, M3PO4) are chemically plausible but not proposed",
});
}
}
out
}
}
pub fn default_grammars() -> Vec<Box<dyn TransformationGrammar>> {
vec![
Box::new(CarbonateToOxideGrammar),
Box::new(HydroxideToOxideGrammar),
Box::new(NitrateToOxideGrammar),
Box::new(AcidCarbonatePhosphateGrammar),
]
}
pub fn propose_all(
grammars: &[Box<dyn TransformationGrammar>],
precursors: &[Composition],
per_grammar_cap: usize,
combined_cap: usize,
) -> Vec<DedupedProposal> {
use std::collections::BTreeMap;
let mut by_composition: BTreeMap<
Composition,
(BTreeSet<GrammarId>, GrammarEvidenceClass, &'static str),
> = BTreeMap::new();
for grammar in grammars {
let mut proposals = grammar.propose(precursors);
proposals.truncate(per_grammar_cap);
for proposal in proposals {
by_composition
.entry(proposal.composition.clone())
.and_modify(|(ids, evidence_class, _)| {
ids.insert(proposal.grammar);
if proposal.evidence_class < *evidence_class {
*evidence_class = proposal.evidence_class;
}
})
.or_insert_with(|| {
let mut ids = BTreeSet::new();
ids.insert(proposal.grammar);
(ids, proposal.evidence_class, proposal.rationale)
});
}
}
let mut out: Vec<DedupedProposal> = by_composition
.into_iter()
.map(
|(composition, (ids, evidence_class, rationale))| DedupedProposal {
composition,
grammars: ids.into_iter().collect(),
evidence_class,
rationale,
},
)
.collect();
out.sort_by(|a, b| {
a.evidence_class
.cmp(&b.evidence_class)
.then_with(|| b.grammars.len().cmp(&a.grammars.len()))
.then_with(|| a.composition.cmp(&b.composition))
});
out.truncate(combined_cap);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn comp(pairs: &[(&'static str, f64)]) -> Composition {
Composition::new(pairs.iter().map(|(s, a)| (el(s), *a))).unwrap()
}
#[test]
fn carbonate_to_oxide_strips_carbon_and_two_thirds_of_oxygen() {
let caco3 = comp(&[("Ca", 1.0), ("C", 1.0), ("O", 3.0)]);
let out = CarbonateToOxideGrammar.propose(std::slice::from_ref(&caco3));
assert_eq!(out.len(), 1);
assert_eq!(out[0].composition, comp(&[("Ca", 1.0), ("O", 1.0)]));
assert_eq!(out[0].evidence_class, GrammarEvidenceClass::Stoichiometric);
}
#[test]
fn carbonate_to_oxide_handles_a_non_unit_metal_ratio() {
let la2co33 = comp(&[("La", 2.0), ("C", 3.0), ("O", 9.0)]);
let out = CarbonateToOxideGrammar.propose(std::slice::from_ref(&la2co33));
assert_eq!(out.len(), 1);
assert_eq!(out[0].composition, comp(&[("La", 2.0), ("O", 3.0)]));
}
#[test]
fn carbonate_to_oxide_skips_bicarbonate_like_hydrogen_bearing_compositions() {
let bicarbonate = comp(&[("K", 1.0), ("H", 1.0), ("C", 1.0), ("O", 3.0)]);
assert!(
CarbonateToOxideGrammar
.propose(std::slice::from_ref(&bicarbonate))
.is_empty()
);
}
#[test]
fn carbonate_to_oxide_skips_a_composition_with_too_little_oxygen() {
let not_carbonate = comp(&[("Ca", 1.0), ("C", 1.0), ("O", 2.0)]);
assert!(
CarbonateToOxideGrammar
.propose(std::slice::from_ref(¬_carbonate))
.is_empty()
);
}
#[test]
fn hydroxide_to_oxide_strips_hydrogen_and_half_of_oxygen() {
let caoh2 = comp(&[("Ca", 1.0), ("O", 2.0), ("H", 2.0)]);
let out = HydroxideToOxideGrammar.propose(std::slice::from_ref(&caoh2));
assert_eq!(out.len(), 1);
assert_eq!(out[0].composition, comp(&[("Ca", 1.0), ("O", 1.0)]));
}
#[test]
fn hydroxide_to_oxide_skips_a_composition_without_exact_one_to_one_o_h() {
let not_hydroxide = comp(&[("Na", 1.0), ("O", 1.0), ("H", 3.0)]);
assert!(
HydroxideToOxideGrammar
.propose(std::slice::from_ref(¬_hydroxide))
.is_empty()
);
}
#[test]
fn nitrate_to_oxide_derives_the_oxide_from_charge_balance() {
let ca_no3_2 = comp(&[("Ca", 1.0), ("N", 2.0), ("O", 6.0)]);
let out = NitrateToOxideGrammar.propose(std::slice::from_ref(&ca_no3_2));
assert_eq!(out.len(), 1);
assert_eq!(out[0].composition, comp(&[("Ca", 1.0), ("O", 1.0)]));
}
#[test]
fn nitrate_to_oxide_handles_a_trivalent_metal() {
let fe_no3_3 = comp(&[("Fe", 1.0), ("N", 3.0), ("O", 9.0)]);
let out = NitrateToOxideGrammar.propose(std::slice::from_ref(&fe_no3_3));
assert_eq!(out.len(), 1);
assert_eq!(out[0].composition, comp(&[("Fe", 1.0), ("O", 1.5)]));
}
#[test]
fn nitrate_to_oxide_skips_multi_metal_compositions() {
let ambiguous = comp(&[("Ca", 1.0), ("K", 1.0), ("N", 3.0), ("O", 9.0)]);
assert!(
NitrateToOxideGrammar
.propose(std::slice::from_ref(&ambiguous))
.is_empty()
);
}
#[test]
fn nitrate_to_oxide_skips_nitric_acid_instead_of_treating_hydrogen_as_the_metal() {
let hno3 = comp(&[("H", 1.0), ("N", 1.0), ("O", 3.0)]);
assert!(
NitrateToOxideGrammar
.propose(std::slice::from_ref(&hno3))
.is_empty()
);
}
#[test]
fn acid_carbonate_phosphate_proposes_the_monobasic_salt() {
let h3po4 = comp(&[("H", 3.0), ("P", 1.0), ("O", 4.0)]);
let k2co3 = comp(&[("K", 2.0), ("C", 1.0), ("O", 3.0)]);
let out = AcidCarbonatePhosphateGrammar.propose(&[h3po4, k2co3]);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].composition,
comp(&[("K", 1.0), ("H", 2.0), ("P", 1.0), ("O", 4.0)])
);
assert_eq!(
out[0].evidence_class,
GrammarEvidenceClass::CommonDecompositionHeuristic
);
}
#[test]
fn acid_carbonate_phosphate_skips_a_divalent_carbonate_metal() {
let h3po4 = comp(&[("H", 3.0), ("P", 1.0), ("O", 4.0)]);
let caco3 = comp(&[("Ca", 1.0), ("C", 1.0), ("O", 3.0)]);
assert!(
AcidCarbonatePhosphateGrammar
.propose(&[h3po4, caco3])
.is_empty()
);
}
#[test]
fn validate_rejects_a_proposal_that_invents_an_element() {
let input = comp(&[("Ca", 1.0), ("C", 1.0), ("O", 3.0)]);
let invented = comp(&[("Ca", 1.0), ("N", 1.0)]);
assert!(!validate_proposed_composition(&[&input], &invented));
}
#[test]
fn validate_rejects_a_no_op_identical_to_its_own_input() {
let input = comp(&[("Ca", 1.0), ("O", 1.0)]);
assert!(!validate_proposed_composition(&[&input], &input.clone()));
}
#[test]
fn propose_all_dedups_across_grammars_and_keeps_every_contributor() {
let caco3 = comp(&[("Ca", 1.0), ("C", 1.0), ("O", 3.0)]);
let caoh2 = comp(&[("Ca", 1.0), ("O", 2.0), ("H", 2.0)]);
let grammars = default_grammars();
let out = propose_all(&grammars, &[caco3, caoh2], 50, 200);
let cao = comp(&[("Ca", 1.0), ("O", 1.0)]);
let hit = out.iter().find(|p| p.composition == cao).unwrap();
assert_eq!(hit.grammars.len(), 2);
assert_eq!(hit.evidence_class, GrammarEvidenceClass::Stoichiometric);
}
#[test]
fn propose_all_respects_the_combined_cap() {
let mut precursors = Vec::new();
for i in 1..=10 {
precursors.push(comp(&[
("Ca", i as f64),
("C", i as f64),
("O", 3.0 * i as f64),
]));
}
let grammars: Vec<Box<dyn TransformationGrammar>> = vec![Box::new(CarbonateToOxideGrammar)];
let out = propose_all(&grammars, &precursors, 50, 3);
assert_eq!(out.len(), 3);
}
}