use crate::bond::BondOrder;
use crate::molecule::{AtomIdx, Molecule};
use std::fmt;
pub fn implicit_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
let atom = mol.atom(idx);
if atom.wildcard {
return 0;
}
if let Some(h) = atom.hydrogen_count {
return h;
}
valence_inferred_hcount(mol, idx)
}
pub fn valence_inferred_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
let atom = mol.atom(idx);
if atom.wildcard {
return 0;
}
if !atom.element.is_organic_subset() {
return 0;
}
let normal_valences = atom.element.normal_valences();
if normal_valences.is_empty() {
return 0;
}
let charge = atom.charge as i32;
let mut aromatic_count: usize = 0;
let mut non_aromatic_sum: i32 = 0;
for (_, bidx) in mol.neighbors(idx) {
let bond = mol.bond(bidx);
let order = bond.order;
if order == BondOrder::Aromatic {
aromatic_count += 1;
} else if order == BondOrder::Dative && bond.atom1 == idx {
} else {
non_aromatic_sum += order.order_int() as i32;
}
}
if aromatic_count > 0 {
let aromatic_contribution = (aromatic_count as f64 * 1.5).floor() as i32;
let effective_sum = aromatic_contribution.saturating_add(non_aromatic_sum);
let v = normal_valences[0] as i32 + charge;
if v <= 0 || effective_sum >= v {
return 0;
}
return (v - effective_sum) as u8;
}
let bond_sum = non_aromatic_sum;
let valences_to_check: &[u8] = if atom.aromatic {
&normal_valences[..1]
} else {
normal_valences
};
for &v in valences_to_check {
let target = v as i32 + charge;
if target < 0 {
continue;
}
if target >= bond_sum {
return (target - bond_sum) as u8;
}
}
0
}
#[deprecated(
since = "0.1.95",
note = "use `implicit_hcount` directly — the two functions are identical"
)]
pub fn total_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
implicit_hcount(mol, idx)
}
pub fn bond_order_sum(mol: &Molecule, idx: AtomIdx) -> u8 {
mol.neighbors(idx)
.map(|(_, bidx)| mol.bond(bidx).order.order_int())
.fold(0u8, |acc, x| acc.saturating_add(x))
}
pub fn is_pi_bond(order: BondOrder) -> bool {
matches!(
order,
BondOrder::Double | BondOrder::Triple | BondOrder::Quadruple
)
}
#[derive(Debug, Clone)]
pub struct ValenceError {
pub atom: AtomIdx,
pub actual: u8,
pub allowed: &'static [u8],
}
impl fmt::Display for ValenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let valences_str = self
.allowed
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"atom {} has valence {} (allowed: [{}])",
self.atom.0, self.actual, valences_str
)
}
}
impl std::error::Error for ValenceError {}
pub fn validate_valence(mol: &Molecule) -> Vec<ValenceError> {
let mut errors = Vec::new();
for (idx, atom) in mol.atoms() {
if atom.wildcard {
continue;
}
let valences = atom.element.normal_valences();
if valences.is_empty() {
continue;
}
let bos = bond_order_sum(mol, idx);
let explicit_h = atom.hydrogen_count.unwrap_or(0);
let used = bos.saturating_add(explicit_h);
let charge = atom.charge as i16;
let has_valid = valences.iter().any(|&v| {
let effective = (v as i16 + charge).max(0) as u8;
effective >= used
});
if !has_valid {
errors.push(ValenceError {
atom: idx,
actual: used,
allowed: valences,
});
}
}
errors
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atom::Atom;
use crate::bond::BondOrder;
use crate::element::Element;
use crate::molecule::MoleculeBuilder;
fn single_atom(elem: Element) -> Molecule {
let mut b = MoleculeBuilder::new();
b.add_atom(Atom::organic(elem));
b.build()
}
fn two_atoms(e1: Element, e2: Element, order: BondOrder) -> Molecule {
let mut b = MoleculeBuilder::new();
let a = b.add_atom(Atom::organic(e1));
let c = b.add_atom(Atom::organic(e2));
b.add_bond(a, c, order).unwrap();
b.build()
}
#[test]
fn test_methane() {
let mol = single_atom(Element::C);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
}
#[test]
fn test_ethane_c() {
let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
assert_eq!(implicit_hcount(&mol, AtomIdx(1)), 3);
}
#[test]
fn test_ethylene_c() {
let mol = two_atoms(Element::C, Element::C, BondOrder::Double);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
}
#[test]
fn test_acetylene_c() {
let mol = two_atoms(Element::C, Element::C, BondOrder::Triple);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
}
#[test]
fn test_nitrogen_amine() {
let mol = single_atom(Element::N);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
}
#[test]
fn test_nitrogen_triple() {
let mol = two_atoms(Element::N, Element::C, BondOrder::Triple);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
}
#[test]
fn test_oxygen_ether() {
let mol = single_atom(Element::O);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
}
#[test]
fn test_fluorine() {
let mol = single_atom(Element::F);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
}
#[test]
fn test_bracket_atom_explicit_h() {
let mut b = MoleculeBuilder::new();
let atom = Atom::bracket(Element::N, None, Default::default(), 4, 1, None);
b.add_atom(atom);
let mol = b.build();
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
}
#[test]
fn test_hypervalent_sulfur() {
let mut b = MoleculeBuilder::new();
let s = b.add_atom(Atom::organic(Element::S));
for _ in 0..4 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(s, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
}
#[test]
fn test_validate_valence_valid_molecules() {
let mol = single_atom(Element::C);
assert!(
validate_valence(&mol).is_empty(),
"isolated C must be valid"
);
let mol = single_atom(Element::O);
assert!(
validate_valence(&mol).is_empty(),
"isolated O must be valid"
);
let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
assert!(validate_valence(&mol).is_empty(), "ethane must be valid");
let mol = two_atoms(Element::C, Element::O, BondOrder::Double);
assert!(
validate_valence(&mol).is_empty(),
"formaldehyde must be valid"
);
}
#[test]
fn test_validate_valence_pentavalent_carbon() {
let mut b = MoleculeBuilder::new();
let c = b.add_atom(Atom::organic(Element::C));
for _ in 0..5 {
let h = b.add_atom(Atom::new(Element::C));
b.add_bond(c, h, BondOrder::Single).unwrap();
}
let mol = b.build();
let errors = validate_valence(&mol);
assert_eq!(
errors.len(),
1,
"C with 5 bonds must produce exactly 1 error"
);
assert_eq!(errors[0].atom, AtomIdx(0));
assert_eq!(errors[0].actual, 5);
}
#[test]
fn test_validate_valence_trivalent_oxygen() {
let mut b = MoleculeBuilder::new();
let o = b.add_atom(Atom::organic(Element::O));
for _ in 0..3 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(o, c, BondOrder::Single).unwrap();
}
let mol = b.build();
let errors = validate_valence(&mol);
assert!(
!errors.is_empty(),
"O with 3 bonds must be flagged as over-valenced"
);
assert_eq!(errors[0].atom, AtomIdx(0));
}
#[test]
fn test_validate_valence_ammonium_valid() {
let mut b = MoleculeBuilder::new();
let mut n_atom = Atom::organic(Element::N);
n_atom.charge = 1;
let n = b.add_atom(n_atom);
for _ in 0..4 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(n, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"N+ with 4 bonds must be valid (ammonium-like)"
);
}
#[test]
fn test_te_implicit_hcount_no_explicit_h_stays_zero() {
let mol = single_atom(Element::TE);
assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
}
#[test]
fn test_validate_valence_te_divalent_neutral() {
let mut b = MoleculeBuilder::new();
let te = b.add_atom(Atom::organic(Element::TE));
for _ in 0..2 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(te, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"divalent Te must be valid (RDKit sanitizes C[Te]C OK)"
);
}
#[test]
fn test_validate_valence_te_tetravalent_neutral() {
let mut b = MoleculeBuilder::new();
let te = b.add_atom(Atom::organic(Element::TE));
for _ in 0..4 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(te, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"tetravalent Te must be valid (RDKit sanitizes TeCl4-analog OK)"
);
}
#[test]
fn test_validate_valence_te_hexavalent_neutral() {
let mut b = MoleculeBuilder::new();
let te = b.add_atom(Atom::organic(Element::TE));
for _ in 0..6 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(te, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"hexavalent Te must be valid (RDKit sanitizes TeF6-analog OK)"
);
}
#[test]
fn test_validate_valence_te_overvalent_invalid() {
let mut b = MoleculeBuilder::new();
let te = b.add_atom(Atom::organic(Element::TE));
for _ in 0..8 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(te, c, BondOrder::Single).unwrap();
}
let mol = b.build();
let errors = validate_valence(&mol);
assert_eq!(
errors.len(),
1,
"8-bonded Te must be flagged invalid, matching RDKit's AtomValenceException"
);
assert_eq!(errors[0].actual, 8);
assert_eq!(errors[0].allowed, &[2, 4, 6]);
}
#[test]
fn test_validate_valence_te_cation_telluronium() {
let mut b = MoleculeBuilder::new();
let mut te_atom = Atom::organic(Element::TE);
te_atom.charge = 1;
let te = b.add_atom(te_atom);
for _ in 0..3 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(te, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"Te+ telluronium (3 bonds) must be valid, matching RDKit"
);
}
#[test]
fn test_validate_valence_te_anion_telluride() {
let mut b = MoleculeBuilder::new();
let te_atom = Atom::bracket(Element::TE, None, Default::default(), 0, -2, None);
b.add_atom(te_atom);
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"[Te-2] must be valid, matching RDKit"
);
}
#[test]
fn test_validate_valence_te_anion_hydrotelluride() {
let mut b = MoleculeBuilder::new();
let te_atom = Atom::bracket(Element::TE, None, Default::default(), 1, -1, None);
b.add_atom(te_atom);
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"[TeH-] must be valid, matching RDKit"
);
}
#[test]
fn test_te_bracket_h2_implicit_and_valid() {
let mut b = MoleculeBuilder::new();
let te_atom = Atom::bracket(Element::TE, None, Default::default(), 2, 0, None);
let te = b.add_atom(te_atom);
let mol = b.build();
assert_eq!(implicit_hcount(&mol, te), 2);
assert!(
validate_valence(&mol).is_empty(),
"[TeH2] must be valid, matching RDKit"
);
}
#[test]
fn test_validate_valence_transition_metal_skipped() {
let mut b = MoleculeBuilder::new();
let fe = b.add_atom(Atom::new(Element::FE));
for _ in 0..6 {
let c = b.add_atom(Atom::organic(Element::C));
b.add_bond(fe, c, BondOrder::Single).unwrap();
}
let mol = b.build();
assert!(
validate_valence(&mol).is_empty(),
"Fe with 6 bonds must be skipped"
);
}
#[test]
fn test_dative_donor_n_keeps_full_valence() {
let mol = two_atoms(Element::N, Element::PT, BondOrder::Dative);
assert_eq!(
implicit_hcount(&mol, AtomIdx(0)),
3,
"a dative donor's own lone pair, not one of its 3 normal covalent \
slots, is shared with the acceptor -- N->[Pt] must mean NH3"
);
}
#[test]
fn test_dative_donor_o_keeps_full_valence() {
let mol = two_atoms(Element::O, Element::FE, BondOrder::Dative);
assert_eq!(
implicit_hcount(&mol, AtomIdx(0)),
2,
"O->[Fe] must mean H2O"
);
}
#[test]
fn test_dative_acceptor_side_unaffected() {
let mol = two_atoms(Element::PT, Element::N, BondOrder::Dative);
assert_eq!(
implicit_hcount(&mol, AtomIdx(1)),
2,
"acceptor-side implicit H is intentionally untouched by this fix"
);
}
#[test]
fn test_generalization_not_platinum_specific() {
for acceptor in [Element::FE, Element::CO, Element::PD, Element::RU] {
let mol = two_atoms(Element::N, acceptor, BondOrder::Dative);
assert_eq!(
implicit_hcount(&mol, AtomIdx(0)),
3,
"N->{acceptor:?} must mean NH3 regardless of which metal accepts"
);
}
}
#[test]
fn test_known_divergence_bracketed_dative_donor_can_still_false_positive_in_validate_valence() {
let mut b = MoleculeBuilder::new();
let o = b.add_atom(Atom::bracket(
Element::O,
None,
Default::default(),
2,
0,
None,
));
let pt = b.add_atom(Atom::new(Element::PT));
b.add_bond(o, pt, BondOrder::Dative).unwrap();
let mol = b.build();
assert_eq!(
implicit_hcount(&mol, o),
2,
"bracket H is stored directly regardless of bond wiring"
);
assert_eq!(
validate_valence(&mol).len(),
1,
"known false positive: [OH2]->[Pt] is valid water-donor chemistry \
but validate_valence still flags it via bond_order_sum's \
unchanged (donor-side-inclusive) count -- if this assertion \
ever starts failing because it's empty, bond_order_sum was \
fixed too and this whole test (and its FEASIBILITY.md note) \
should be deleted, not \"corrected\" back to failing"
);
}
}