use super::model::{CommercialPrecursorCatalog, CommercialPrecursorOffer};
use crate::composition::{Composition, Element};
use crate::frac::{Frac, gcd};
impl CommercialPrecursorCatalog {
pub(crate) fn offers_matching<'a>(
&'a self,
composition: &'a Composition,
) -> impl Iterator<Item = &'a CommercialPrecursorOffer> + 'a {
let target_canonical = canonical_ratio_key(composition);
self.offers().iter().filter(move |o| {
&o.composition == composition
|| (target_canonical.is_some()
&& canonical_ratio_key(&o.composition) == target_canonical)
})
}
}
fn canonical_ratio_key(composition: &Composition) -> Option<Vec<(Element, i128)>> {
if composition.len() <= 1 {
return None;
}
let terms: Vec<(Element, Frac)> = composition
.elements()
.map(|element| {
let amount = composition
.amount_frac_of(element)
.expect("every element yielded by Composition::elements() has an amount");
(element, amount)
})
.collect();
let mut lcm_den: i128 = 1;
for (_, amount) in &terms {
lcm_den = checked_lcm(lcm_den, amount.denominator())?;
}
let mut scaled: Vec<(Element, i128)> = Vec::with_capacity(terms.len());
for (element, amount) in terms {
let factor = lcm_den.checked_div(amount.denominator())?;
let numerator = amount.numerator().checked_mul(factor)?;
scaled.push((element, numerator));
}
let divisor = scaled
.iter()
.fold(0u128, |acc, (_, numerator)| {
gcd(acc, numerator.unsigned_abs())
})
.max(1) as i128;
Some(
scaled
.into_iter()
.map(|(element, numerator)| (element, numerator / divisor))
.collect(),
)
}
fn checked_lcm(a: i128, b: i128) -> Option<i128> {
let g = gcd(a.unsigned_abs(), b.unsigned_abs()).max(1) as i128;
(a / g).checked_mul(b)
}
#[cfg(test)]
mod tests {
use super::super::formula::parse_formula;
use super::super::model::*;
use super::*;
use crate::commercial_catalog::test_support::*;
use proptest::prelude::*;
#[test]
fn composition_eq_itself_stays_literal_even_though_commercial_matching_does_not() {
let fe2o3 = parse_formula("Fe2O3").unwrap();
let fe4o6 = parse_formula("Fe4O6").unwrap();
assert_ne!(fe2o3, fe4o6);
}
#[test]
fn canonical_ratio_key_reduces_fe2o3_and_fe4o6_to_the_same_key() {
let fe2o3 = parse_formula("Fe2O3").unwrap();
let fe4o6 = parse_formula("Fe4O6").unwrap();
assert_eq!(
canonical_ratio_key(&fe2o3).unwrap(),
canonical_ratio_key(&fe4o6).unwrap()
);
}
#[test]
fn canonical_ratio_key_reduces_al2o3_and_al4o6_to_the_same_key() {
let al2o3 = parse_formula("Al2O3").unwrap();
let al4o6 = parse_formula("Al4O6").unwrap();
assert_eq!(
canonical_ratio_key(&al2o3).unwrap(),
canonical_ratio_key(&al4o6).unwrap()
);
}
#[test]
fn canonical_ratio_key_matches_fractional_equivalent_compositions() {
let half_scale = parse_formula("La0.5Sr0.5MnO3").unwrap();
let double_scale = parse_formula("La1Sr1Mn2O6").unwrap();
assert_eq!(
canonical_ratio_key(&half_scale).unwrap(),
canonical_ratio_key(&double_scale).unwrap()
);
}
#[test]
fn canonical_ratio_key_does_not_match_a_genuinely_different_ratio() {
let feo = parse_formula("FeO").unwrap();
let fe2o3 = parse_formula("Fe2O3").unwrap();
assert_ne!(
canonical_ratio_key(&feo).unwrap(),
canonical_ratio_key(&fe2o3).unwrap()
);
}
#[test]
fn canonical_ratio_key_does_not_bridge_single_element_allotropes() {
let o2 = parse_formula("O2").unwrap();
let o3 = parse_formula("O3").unwrap();
assert!(canonical_ratio_key(&o2).is_none());
assert!(canonical_ratio_key(&o3).is_none());
let (catalog, _) = CommercialPrecursorCatalog::from_offers(vec![offer("A", "O3")]);
assert!(
catalog.offers_matching(&o2).next().is_none(),
"O2 must not match an O3 offer via canonical-ratio bridging"
);
}
#[test]
fn canonical_ratio_key_does_not_match_hydrate_vs_anhydrous() {
let anhydrous = parse_formula("CuSO4").unwrap();
let hydrate = parse_formula("CuSO4\u{B7}5H2O").unwrap();
assert_ne!(
canonical_ratio_key(&anhydrous).unwrap(),
canonical_ratio_key(&hydrate).unwrap()
);
}
#[test]
fn canonical_ratio_key_is_deterministic() {
let fe2o3 = parse_formula("Fe2O3").unwrap();
let key_a = canonical_ratio_key(&fe2o3).unwrap();
let key_b = canonical_ratio_key(&fe2o3).unwrap();
assert_eq!(key_a, key_b);
let reordered = Composition::new([(element("O"), 3.0), (element("Fe"), 2.0)]).unwrap();
assert_eq!(canonical_ratio_key(&reordered).unwrap(), key_a);
}
#[test]
fn offers_matching_uses_canonical_ratio_equality() {
let (catalog, _) = CommercialPrecursorCatalog::from_offers(vec![
offer("A", "Fe2O3"),
offer("B", "Fe4O6"),
offer("C", "FeO"),
]);
let target = parse_formula("Fe2O3").unwrap();
let matches: Vec<&str> = catalog
.offers_matching(&target)
.map(|o| o.offer_id.0.as_str())
.collect();
assert_eq!(
matches,
vec!["A", "B"],
"C (FeO) has a different ratio and must not match"
);
}
#[test]
fn offers_matching_preserves_the_original_formula_spelling_in_provenance() {
let (catalog, _) = CommercialPrecursorCatalog::from_offers(vec![offer("B", "Fe4O6")]);
let target = parse_formula("Fe2O3").unwrap();
let matched = catalog.offers_matching(&target).next().unwrap();
assert_eq!(matched.formula, "Fe4O6");
}
const MATCHING_ELEMENT_POOL: &[&str] = &[
"Fe", "O", "Al", "Ba", "Ti", "Ca", "S", "Cu", "La", "Sr", "Mn", "Zn",
];
fn arbitrary_multi_element_composition() -> impl Strategy<Value = Composition> {
prop::collection::hash_map(
prop::sample::select(MATCHING_ELEMENT_POOL),
1u32..=99u32,
2..=4,
)
.prop_map(|pairs| {
Composition::new(pairs.into_iter().map(|(sym, n)| (element(sym), n as f64))).unwrap()
})
}
proptest! {
#[test]
fn canonical_ratio_key_is_scale_invariant(
composition in arbitrary_multi_element_composition(),
scale in 1i128..=20,
) {
let scaled = Composition::new(
composition
.elements()
.map(|e| (e, composition.amount_of(e).unwrap() * scale as f64)),
)
.unwrap();
let original_key = canonical_ratio_key(&composition);
prop_assert!(original_key.is_some());
prop_assert_eq!(original_key, canonical_ratio_key(&scaled));
}
#[test]
fn canonical_ratio_key_is_deterministic_under_reordering(
composition in arbitrary_multi_element_composition(),
) {
let key_a = canonical_ratio_key(&composition);
let mut pairs: Vec<(Element, f64)> = composition
.elements()
.map(|e| (e, composition.amount_of(e).unwrap()))
.collect();
pairs.reverse();
let reordered = Composition::new(pairs).unwrap();
prop_assert_eq!(key_a, canonical_ratio_key(&reordered));
}
#[test]
fn canonical_ratio_key_equality_implies_proportional_composition(
a in arbitrary_multi_element_composition(),
b in arbitrary_multi_element_composition(),
) {
if let (Some(key_a), Some(key_b)) = (canonical_ratio_key(&a), canonical_ratio_key(&b)) {
if key_a == key_b {
let elements_a: Vec<Element> = a.elements().collect();
let elements_b: Vec<Element> = b.elements().collect();
prop_assert_eq!(&elements_a, &elements_b);
let first = elements_a[0];
let a_first = a.amount_frac_of(first).unwrap();
let b_first = b.amount_frac_of(first).unwrap();
for &el in &elements_a[1..] {
let a_el = a.amount_frac_of(el).unwrap();
let b_el = b.amount_frac_of(el).unwrap();
prop_assert_eq!(
a_first.checked_mul(b_el).unwrap(),
a_el.checked_mul(b_first).unwrap()
);
}
}
}
}
}
}