use crate::composition::{Composition, Element};
use crate::error::{GugenError, Result};
use crate::frac::Frac;
use crate::reaction::{BalancedReaction, ReactionSpecies};
use std::collections::BTreeSet;
pub fn curated_byproducts() -> Result<Vec<Composition>> {
let c = Element::new("C")?;
let h = Element::new("H")?;
let n = Element::new("N")?;
let o = Element::new("O")?;
Ok(vec![
Composition::new([(c, 1.0), (o, 2.0)])?, Composition::new([(h, 2.0), (o, 1.0)])?, Composition::new([(o, 2.0)])?, Composition::new([(n, 1.0), (o, 2.0)])?, Composition::new([(c, 1.0), (o, 1.0)])?, Composition::new([(c, 3.0), (h, 6.0), (o, 1.0)])?, ])
}
pub fn balance(
reactants: &[Composition],
products: &[Composition],
) -> Result<Vec<BalancedReaction>> {
if reactants.is_empty() || products.is_empty() {
return Err(GugenError::EmptyReaction);
}
let mut elements: BTreeSet<Element> = BTreeSet::new();
for composition in reactants.iter().chain(products.iter()) {
elements.extend(composition.elements());
}
let elements: Vec<Element> = elements.into_iter().collect();
let species_count = reactants.len() + products.len();
let mut matrix: Vec<Vec<Frac>> = Vec::with_capacity(elements.len());
for &element in &elements {
let mut row = Vec::with_capacity(species_count);
for composition in reactants {
row.push(
composition
.amount_frac_of(element)
.unwrap_or_else(Frac::zero),
);
}
for composition in products {
let amount = composition
.amount_frac_of(element)
.unwrap_or_else(Frac::zero);
row.push(amount.checked_neg()?);
}
matrix.push(row);
}
let pivots = row_reduce(&mut matrix)?;
let pivot_cols: BTreeSet<usize> = pivots.iter().copied().collect();
let free_cols: Vec<usize> = (0..species_count)
.filter(|c| !pivot_cols.contains(c))
.collect();
let mut results = Vec::new();
for &free_col in &free_cols {
let mut vector = vec![Frac::zero(); species_count];
vector[free_col] = Frac::one();
for (row_idx, &pivot_col) in pivots.iter().enumerate() {
vector[pivot_col] = Frac::zero().checked_sub(matrix[row_idx][free_col])?;
}
if let Some(reaction) = vector_to_reaction(&vector, reactants, products)? {
results.push(reaction);
}
}
Ok(results)
}
fn row_reduce(matrix: &mut [Vec<Frac>]) -> Result<Vec<usize>> {
let rows = matrix.len();
let cols = matrix.first().map_or(0, Vec::len);
let mut pivots = Vec::new();
let mut pivot_row = 0;
for col in 0..cols {
if pivot_row >= rows {
break;
}
let Some(sel) = (pivot_row..rows).find(|&r| !matrix[r][col].is_zero()) else {
continue;
};
matrix.swap(pivot_row, sel);
let pivot_val = matrix[pivot_row][col];
for cell in &mut matrix[pivot_row] {
*cell = cell.checked_div(pivot_val)?;
}
let pivot_row_snapshot = matrix[pivot_row].clone();
for (r, row) in matrix.iter_mut().enumerate() {
if r == pivot_row {
continue;
}
let factor = row[col];
if factor.is_zero() {
continue;
}
for (cell, &pivot_cell) in row.iter_mut().zip(&pivot_row_snapshot) {
let sub = factor.checked_mul(pivot_cell)?;
*cell = cell.checked_sub(sub)?;
}
}
pivots.push(col);
pivot_row += 1;
}
Ok(pivots)
}
fn vector_to_reaction(
vector: &[Frac],
reactants: &[Composition],
products: &[Composition],
) -> Result<Option<BalancedReaction>> {
let all_non_negative = vector.iter().all(|f| !is_negative(f));
let all_non_positive = vector.iter().all(|f| is_negative(f) || f.is_zero());
if !all_non_negative && !all_non_positive {
return Ok(None);
}
let negate = all_non_positive && !all_non_negative;
let mut signed = Vec::with_capacity(vector.len());
for &f in vector {
signed.push(if negate { f.checked_neg()? } else { f });
}
let Some(scaled) = scale_to_integers(&signed)? else {
return Ok(None);
};
let (reactant_coeffs, product_coeffs) = scaled.split_at(reactants.len());
let reactant_species: Vec<ReactionSpecies> = reactants
.iter()
.zip(reactant_coeffs)
.filter(|&(_, &coeff)| coeff != 0)
.map(|(composition, &coeff)| {
ReactionSpecies::new(composition.clone(), coeff)
.expect("coeff != 0 already filtered above")
})
.collect();
let product_species: Vec<ReactionSpecies> = products
.iter()
.zip(product_coeffs)
.filter(|&(_, &coeff)| coeff != 0)
.map(|(composition, &coeff)| {
ReactionSpecies::new(composition.clone(), coeff)
.expect("coeff != 0 already filtered above")
})
.collect();
match BalancedReaction::new(reactant_species, product_species) {
Ok(reaction) => Ok(Some(reaction)),
Err(GugenError::EmptyReaction) => Ok(None),
Err(other) => Err(other),
}
}
fn is_negative(f: &Frac) -> bool {
f.numerator() < 0
}
fn scale_to_integers(vector: &[Frac]) -> Result<Option<Vec<u64>>> {
let mut lcm: i128 = 1;
for f in vector {
if f.is_zero() {
continue;
}
let Some(next) = checked_lcm(lcm, f.denominator()) else {
return Ok(None);
};
lcm = next;
}
let lcm_frac = Frac::new(lcm, 1)?;
let mut integers: Vec<i128> = Vec::with_capacity(vector.len());
for f in vector {
let Ok(scaled) = f.checked_mul(lcm_frac) else {
return Ok(None);
};
debug_assert_eq!(scaled.denominator(), 1);
integers.push(scaled.numerator());
}
let g = integers
.iter()
.filter(|&&n| n != 0)
.map(|&n| n.unsigned_abs())
.fold(0u128, gcd)
.max(1);
let mut result = Vec::with_capacity(integers.len());
for n in integers {
let reduced = n / (g as i128);
let Ok(as_u64) = u64::try_from(reduced) else {
return Ok(None);
};
result.push(as_u64);
}
Ok(Some(result))
}
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)
}
fn gcd(a: u128, b: u128) -> u128 {
if b == 0 { a } else { gcd(b, a % b) }
}
#[cfg(test)]
mod tests {
use super::*;
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()
}
#[test]
fn simple_one_to_one_reaction() {
let reactants = vec![
composition(&[("Ba", 1.0), ("O", 1.0)]),
composition(&[("Ti", 1.0), ("O", 2.0)]),
];
let products = vec![composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)])];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants().len(), 2);
assert_eq!(r.products().len(), 1);
assert!(r.reactants().iter().all(|s| s.coefficient() == 1));
assert!(r.products().iter().all(|s| s.coefficient() == 1));
}
#[test]
fn carbonate_decomposes_to_oxide_plus_co2() {
let reactants = vec![composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)])];
let products = vec![
composition(&[("Ba", 1.0), ("O", 1.0)]),
composition(&[("C", 1.0), ("O", 2.0)]),
];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants()[0].coefficient(), 1);
assert_eq!(r.products().len(), 2);
assert!(r.products().iter().all(|s| s.coefficient() == 1));
}
#[test]
fn nitrate_decomposes_to_oxide_plus_no2_and_o2() {
let reactants = vec![composition(&[("Ba", 1.0), ("N", 2.0), ("O", 6.0)])];
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let no2 = composition(&[("N", 1.0), ("O", 2.0)]);
let o2 = composition(&[("O", 2.0)]);
let products = vec![bao.clone(), no2.clone(), o2.clone()];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants()[0].coefficient(), 2);
let coeff_of = |c: &Composition| {
r.products()
.iter()
.find(|s| s.composition == *c)
.unwrap()
.coefficient()
};
assert_eq!(coeff_of(&bao), 2);
assert_eq!(coeff_of(&no2), 4);
assert_eq!(coeff_of(&o2), 1);
}
#[test]
fn oxalate_decomposes_to_oxide_plus_co2_and_co() {
let reactants = vec![composition(&[("Fe", 1.0), ("C", 2.0), ("O", 4.0)])];
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let co2 = composition(&[("C", 1.0), ("O", 2.0)]);
let co = composition(&[("C", 1.0), ("O", 1.0)]);
let products = vec![feo.clone(), co2.clone(), co.clone()];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants()[0].coefficient(), 1);
let coeff_of = |c: &Composition| {
r.products()
.iter()
.find(|s| s.composition == *c)
.unwrap()
.coefficient()
};
assert_eq!(coeff_of(&feo), 1);
assert_eq!(coeff_of(&co2), 1);
assert_eq!(coeff_of(&co), 1);
}
#[test]
fn acetate_decomposes_to_oxide_plus_acetone_and_co2() {
let reactants = vec![composition(&[
("Ba", 1.0),
("C", 4.0),
("H", 6.0),
("O", 4.0),
])];
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let acetone = composition(&[("C", 3.0), ("H", 6.0), ("O", 1.0)]);
let co2 = composition(&[("C", 1.0), ("O", 2.0)]);
let products = vec![bao.clone(), acetone.clone(), co2.clone()];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants()[0].coefficient(), 1);
let coeff_of = |c: &Composition| {
r.products()
.iter()
.find(|s| s.composition == *c)
.unwrap()
.coefficient()
};
assert_eq!(coeff_of(&bao), 1);
assert_eq!(coeff_of(&acetone), 1);
assert_eq!(coeff_of(&co2), 1);
}
#[test]
fn offering_every_curated_byproduct_at_once_can_introduce_real_ambiguity_co_does_here() {
let reactants = vec![
composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
composition(&[("Ti", 1.0), ("O", 2.0)]),
];
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let co2 = composition(&[("C", 1.0), ("O", 2.0)]);
let h2o = composition(&[("H", 2.0), ("O", 1.0)]);
let o2 = composition(&[("O", 2.0)]);
let no2 = composition(&[("N", 1.0), ("O", 2.0)]);
let co = composition(&[("C", 1.0), ("O", 1.0)]);
let acetone = composition(&[("C", 3.0), ("H", 6.0), ("O", 1.0)]);
let targeted = balance(&reactants, &[target.clone(), co2.clone()]).unwrap();
assert_eq!(
targeted.len(),
1,
"targeted subset {{target, CO2}} must balance"
);
let everything = balance(&reactants, &[target, co2, h2o, o2, no2, co, acetone]).unwrap();
assert_eq!(
everything.len(),
2,
"CO's presence alongside CO2/O2 creates a second, genuinely valid basis vector \
for this specific reaction -- acetone does not add a third, since this reaction \
has no hydrogen at all -- see this test's own doc comment"
);
assert!(
everything.contains(&targeted[0]),
"the canonical, search-found answer must still be among the results"
);
}
#[test]
fn offering_every_curated_byproduct_at_once_can_introduce_more_ambiguity_once_hydrogen_and_carbon_coexist_acetone_does_there()
{
let reactants = vec![
composition(&[("Ba", 1.0), ("O", 2.0), ("H", 2.0)]),
composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
composition(&[("Ti", 1.0), ("O", 2.0)]),
];
let target = composition(&[("Ba", 2.0), ("Ti", 1.0), ("O", 4.0)]);
let co2 = composition(&[("C", 1.0), ("O", 2.0)]);
let h2o = composition(&[("H", 2.0), ("O", 1.0)]);
let o2 = composition(&[("O", 2.0)]);
let no2 = composition(&[("N", 1.0), ("O", 2.0)]);
let co = composition(&[("C", 1.0), ("O", 1.0)]);
let acetone = composition(&[("C", 3.0), ("H", 6.0), ("O", 1.0)]);
let smallest_subset = balance(&reactants, &[target.clone(), co2.clone()]).unwrap();
assert_eq!(
smallest_subset.len(),
1,
"the size-1 {{CO2}} subset alone must already balance this combination, \
protecting the real search from ever reaching an acetone-inclusive subset"
);
let everything = balance(&reactants, &[target, co2, h2o, o2, no2, co, acetone]).unwrap();
assert_eq!(
everything.len(),
4,
"acetone's presence, once both hydrogen and carbon are already present in the \
reactants, adds a genuinely new fourth basis vector -- see this test's own doc \
comment"
);
assert!(
everything.contains(&smallest_subset[0]),
"the canonical, search-found answer must still be among the results"
);
}
#[test]
fn oxygen_as_byproduct() {
let reactants = vec![composition(&[("Ag", 2.0), ("O", 1.0)])];
let products = vec![composition(&[("Ag", 1.0)]), composition(&[("O", 2.0)])];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.reactants()[0].coefficient(), 2);
let ag = r
.products()
.iter()
.find(|s| s.composition.amount_of(element("Ag")).is_some())
.unwrap();
let o2 = r
.products()
.iter()
.find(|s| s.composition.amount_of(element("O")).is_some())
.unwrap();
assert_eq!(ag.coefficient(), 4);
assert_eq!(o2.coefficient(), 1);
}
#[test]
fn oxygen_as_reactant() {
let reactants = vec![composition(&[("Fe", 1.0)]), composition(&[("O", 2.0)])];
let products = vec![composition(&[("Fe", 2.0), ("O", 3.0)])];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
let fe = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("Fe")).is_some())
.unwrap();
let o2 = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("O")).is_some())
.unwrap();
assert_eq!(fe.coefficient(), 4);
assert_eq!(o2.coefficient(), 3);
assert_eq!(r.products()[0].coefficient(), 2);
}
#[test]
fn multiple_precursors() {
let reactants = vec![
composition(&[("Li", 2.0), ("O", 2.0), ("H", 2.0)]),
composition(&[("C", 1.0), ("O", 2.0)]),
];
let products = vec![
composition(&[("Li", 2.0), ("C", 1.0), ("O", 3.0)]),
composition(&[("H", 2.0), ("O", 1.0)]),
];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].reactants().iter().all(|s| s.coefficient() == 1));
assert!(results[0].products().iter().all(|s| s.coefficient() == 1));
}
#[test]
fn no_solution_for_disjoint_elements() {
let reactants = vec![composition(&[("Fe", 1.0)])];
let products = vec![composition(&[("Na", 1.0), ("Cl", 1.0)])];
let results = balance(&reactants, &products).unwrap();
assert!(results.is_empty());
}
#[test]
fn multiple_solutions_for_iron_oxide_family() {
let reactants = vec![composition(&[("Fe", 1.0)]), composition(&[("O", 2.0)])];
let products = vec![
composition(&[("Fe", 1.0), ("O", 1.0)]), composition(&[("Fe", 2.0), ("O", 3.0)]), composition(&[("Fe", 3.0), ("O", 4.0)]), ];
let results = balance(&reactants, &products).unwrap();
assert_eq!(
results.len(),
3,
"expected one independent balance per iron oxide"
);
for r in &results {
assert_eq!(r.products().len(), 1);
assert!(r.reactants().iter().all(|s| s.coefficient() > 0));
}
}
#[test]
fn coefficients_are_gcd_normalized() {
let reactants = vec![composition(&[("H", 2.0)]), composition(&[("O", 2.0)])];
let products = vec![composition(&[("H", 2.0), ("O", 1.0)])];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
let h2 = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("H")).is_some())
.unwrap();
let o2 = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("O")).is_some())
.unwrap();
assert_eq!(h2.coefficient(), 2);
assert_eq!(o2.coefficient(), 1);
assert_eq!(r.products()[0].coefficient(), 2);
}
#[test]
fn element_conservation_holds() {
let reactants = vec![
composition(&[("Ba", 1.0), ("O", 1.0)]),
composition(&[("Ti", 1.0), ("O", 2.0)]),
];
let products = vec![composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)])];
let results = balance(&reactants, &products).unwrap();
let r = &results[0];
for &el in &[element("Ba"), element("Ti"), element("O")] {
let lhs: f64 = r
.reactants()
.iter()
.map(|s| s.composition.amount_of(el).unwrap_or(0.0) * s.coefficient() as f64)
.sum();
let rhs: f64 = r
.products()
.iter()
.map(|s| s.composition.amount_of(el).unwrap_or(0.0) * s.coefficient() as f64)
.sum();
assert!(
(lhs - rhs).abs() < 1e-9,
"element {el} unbalanced: {lhs} vs {rhs}"
);
}
}
#[test]
fn permutation_invariance() {
let reactants_a = vec![
composition(&[("Ba", 1.0), ("O", 1.0)]),
composition(&[("Ti", 1.0), ("O", 2.0)]),
];
let reactants_b = vec![
composition(&[("Ti", 1.0), ("O", 2.0)]),
composition(&[("Ba", 1.0), ("O", 1.0)]),
];
let products = vec![composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)])];
let results_a = balance(&reactants_a, &products).unwrap();
let results_b = balance(&reactants_b, &products).unwrap();
assert_eq!(results_a.len(), 1);
assert_eq!(results_b.len(), 1);
let total_a: u64 = results_a[0]
.reactants()
.iter()
.map(|s| s.coefficient())
.sum();
let total_b: u64 = results_b[0]
.reactants()
.iter()
.map(|s| s.coefficient())
.sum();
assert_eq!(total_a, total_b);
}
#[test]
fn large_but_representable_coefficients() {
let reactants = vec![composition(&[("Na", 3.0)]), composition(&[("K", 97.0)])];
let products = vec![composition(&[("Na", 3.0), ("K", 97.0)])];
let results = balance(&reactants, &products).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
let na = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("Na")).is_some())
.unwrap();
let k = r
.reactants()
.iter()
.find(|s| s.composition.amount_of(element("K")).is_some())
.unwrap();
assert_eq!(na.coefficient(), 1);
assert_eq!(k.coefficient(), 1);
assert_eq!(r.products()[0].coefficient(), 1);
}
#[test]
fn rejects_empty_reactant_or_product_list() {
let comp = composition(&[("Fe", 1.0)]);
assert!(balance(&[], std::slice::from_ref(&comp)).is_err());
assert!(balance(&[comp], &[]).is_err());
}
#[test]
fn scale_to_integers_reports_denominator_overflow_as_no_solution_for_that_candidate() {
let huge_a = Frac::new(1, i128::MAX / 2).unwrap();
let huge_b = Frac::new(1, (i128::MAX / 2) - 1).unwrap();
let result = scale_to_integers(&[huge_a, huge_b]).unwrap();
assert!(
result.is_none(),
"LCM of two near-i128::MAX denominators must overflow, not panic"
);
}
#[test]
fn scale_to_integers_reports_multiply_overflow_as_no_solution_not_an_error() {
let huge_numerator = Frac::new(i128::MAX, 1).unwrap();
let denominator_two = Frac::new(1, 2).unwrap();
let result = scale_to_integers(&[huge_numerator, denominator_two]);
assert!(
matches!(result, Ok(None)),
"a numerator already at i128::MAX times an LCM of 2 must overflow \
the multiply step as Ok(None), not Err: {result:?}"
);
}
}