Skip to main content

chematic_core/
valence.rs

1//! Valence model: implicit hydrogen count for organic-subset atoms.
2//!
3//! Reference: OpenSMILES specification, section 3.4 (Implicit hydrogen)
4//! <http://opensmiles.org/opensmiles-spec.html>
5
6use crate::bond::BondOrder;
7use crate::molecule::{AtomIdx, Molecule};
8use std::fmt;
9
10/// Compute the implicit hydrogen count for atom `idx`.
11///
12/// - Bracket atoms (`hydrogen_count.is_some()`): return the stored value directly.
13/// - Wildcard atoms: return 0.
14/// - Organic-subset atoms: derive from the normal-valence table.
15/// - All other atoms: return 0 (no implicit H rule defined).
16///
17/// # Algorithm
18/// 1. Sum the integer bond orders of all bonds on the atom.
19/// 2. Find the smallest normal valence >= bond_sum (adjusted for formal charge).
20/// 3. implicit_H = adjusted_valence - bond_sum.
21///
22/// Charge adjustment:
23/// - Positive charge: increases target valence (e.g. [NH4]+ has 4 bonds → valence 4).
24/// - Negative charge: decreases target valence.
25pub fn implicit_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
26    let atom = mol.atom(idx);
27
28    // Wildcards have no defined implicit H.
29    if atom.wildcard {
30        return 0;
31    }
32
33    // Bracket atoms store the explicit H count.
34    if let Some(h) = atom.hydrogen_count {
35        return h;
36    }
37
38    valence_inferred_hcount(mol, idx)
39}
40
41/// Compute the hydrogen count that organic-subset (unbracketed) valence
42/// inference would give for atom `idx`, **ignoring** any stored explicit
43/// `hydrogen_count` -- unlike [`implicit_hcount`], which returns the stored
44/// value directly for bracket atoms.
45///
46/// Used to decide whether a bracket atom's explicit H count is genuinely
47/// disambiguating information (differs from what organic-subset spelling
48/// would infer) or merely repeats it, in which case the atom can be
49/// canonically re-spelled without brackets for that reason -- see
50/// `chematic-smiles`'s `emit_atom`/`initial_invariant`, which both need
51/// "would this atom's H count survive being unbracketed" independent of
52/// whatever notation the atom happened to be parsed from.
53pub fn valence_inferred_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
54    let atom = mol.atom(idx);
55
56    if atom.wildcard {
57        return 0;
58    }
59
60    // Only the organic subset gets implicit H.
61    if !atom.element.is_organic_subset() {
62        return 0;
63    }
64
65    let normal_valences = atom.element.normal_valences();
66    if normal_valences.is_empty() {
67        return 0;
68    }
69
70    let charge = atom.charge as i32;
71
72    // Separate aromatic bonds from non-aromatic bonds.
73    let mut aromatic_count: usize = 0;
74    let mut non_aromatic_sum: i32 = 0;
75    for (_, bidx) in mol.neighbors(idx) {
76        let order = mol.bond(bidx).order;
77        if order == BondOrder::Aromatic {
78            aromatic_count += 1;
79        } else {
80            non_aromatic_sum += order.order_int() as i32;
81        }
82    }
83
84    if aromatic_count > 0 {
85        // Aromatic molecule (pre-Kekulization): each aromatic bond contributes 1.5
86        // to the effective bond order (OpenSMILES convention).
87        //
88        // floor(1.5 × n) gives the contribution from n aromatic bonds:
89        //   n=2 → 3  benzene CH:   4−3=1H ✓   pyridine N: 3−3=0H ✓
90        //   n=3 → 4  junction C:   4−4=0H ✓
91        //
92        // Combined with non-aromatic substituents (e.g. N−CH₃) this correctly yields
93        // 0 H for all substituted aromatic atoms without needing Kekulization.
94        // Always use the lowest normal valence; aromatic atoms cannot be hypervalent.
95        let aromatic_contribution = (aromatic_count as f64 * 1.5).floor() as i32;
96        let effective_sum = aromatic_contribution.saturating_add(non_aromatic_sum);
97        let v = normal_valences[0] as i32 + charge;
98        if v <= 0 || effective_sum >= v {
99            return 0;
100        }
101        return (v - effective_sum) as u8;
102    }
103
104    // Non-aromatic path (or post-Kekulization molecule where all bonds are explicit).
105    let bond_sum = non_aromatic_sum;
106
107    // For atoms that carry the aromatic flag but reside in a kekulized molecule
108    // (bonds are Single/Double, not Aromatic), use only the lowest normal valence.
109    // Rationale: after Kekulization, a substituted aromatic N (e.g. N−CH₃ in caffeine
110    // with one ring double bond) has bond_sum=4, which would select valence 5 and
111    // give 1 implicit H.  Capping at the primary valence (3) returns 0 H instead.
112    let valences_to_check: &[u8] = if atom.aromatic {
113        &normal_valences[..1]
114    } else {
115        normal_valences
116    };
117
118    // Iterate through valences (ascending) and pick the smallest ≥ bond_sum.
119    for &v in valences_to_check {
120        let target = v as i32 + charge;
121        if target < 0 {
122            continue;
123        }
124        if target >= bond_sum {
125            return (target - bond_sum) as u8;
126        }
127    }
128
129    // bond_sum exceeds all consulted valences → 0 implicit H.
130    0
131}
132
133#[deprecated(
134    since = "0.1.95",
135    note = "use `implicit_hcount` directly — the two functions are identical"
136)]
137/// Alias for [`implicit_hcount`]; kept for API compatibility.
138pub fn total_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
139    implicit_hcount(mol, idx)
140}
141
142/// Sum of integer bond orders for heavy-atom bonds on `idx`.
143/// Aromatic bonds count as 1 (pre-Kekulization representation).
144pub fn bond_order_sum(mol: &Molecule, idx: AtomIdx) -> u8 {
145    mol.neighbors(idx)
146        .map(|(_, bidx)| mol.bond(bidx).order.order_int())
147        .fold(0u8, |acc, x| acc.saturating_add(x))
148}
149
150/// Returns true if the bond is counted as a "double bond equivalent" in valence sums.
151pub fn is_pi_bond(order: BondOrder) -> bool {
152    matches!(
153        order,
154        BondOrder::Double | BondOrder::Triple | BondOrder::Quadruple
155    )
156}
157
158// ---------------------------------------------------------------------------
159// Valence validation
160// ---------------------------------------------------------------------------
161
162/// A valence violation on a specific atom.
163///
164/// Returned by [`validate_valence`] for each atom whose observed bond-order sum
165/// exceeds all allowed normal valences (after formal-charge adjustment).
166#[derive(Debug, Clone)]
167pub struct ValenceError {
168    /// Index of the over-valenced atom.
169    pub atom: AtomIdx,
170    /// Observed bond-order sum (+ explicit bracket H count).
171    pub actual: u8,
172    /// Allowed normal valences for the element (from [`crate::Element::normal_valences`]).
173    pub allowed: &'static [u8],
174}
175
176impl fmt::Display for ValenceError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        let valences_str = self
179            .allowed
180            .iter()
181            .map(|v| v.to_string())
182            .collect::<Vec<_>>()
183            .join(", ");
184        write!(
185            f,
186            "atom {} has valence {} (allowed: [{}])",
187            self.atom.0, self.actual, valences_str
188        )
189    }
190}
191
192impl std::error::Error for ValenceError {}
193
194/// Check every atom in `mol` for valence violations.
195///
196/// Returns one [`ValenceError`] per over-valenced atom; an empty `Vec` means
197/// all atoms have valid valence.
198///
199/// Atoms without defined normal valences (transition metals, etc.) are skipped.
200/// Formal charge shifts the effective maximum: each unit of positive charge
201/// adds one to the allowed ceiling (e.g. `[NH4+]` with 4 bonds is valid).
202///
203/// Aromatic bonds are counted as 1 each (`order_int()`).  Molecules still
204/// written with `BondOrder::Aromatic` are handled correctly; fully kekulized
205/// molecules are also supported.
206pub fn validate_valence(mol: &Molecule) -> Vec<ValenceError> {
207    let mut errors = Vec::new();
208    for (idx, atom) in mol.atoms() {
209        if atom.wildcard {
210            continue;
211        }
212        let valences = atom.element.normal_valences();
213        if valences.is_empty() {
214            continue;
215        }
216
217        let bos = bond_order_sum(mol, idx);
218        let explicit_h = atom.hydrogen_count.unwrap_or(0);
219        let used = bos.saturating_add(explicit_h);
220        let charge = atom.charge as i16;
221
222        let has_valid = valences.iter().any(|&v| {
223            let effective = (v as i16 + charge).max(0) as u8;
224            effective >= used
225        });
226
227        if !has_valid {
228            errors.push(ValenceError {
229                atom: idx,
230                actual: used,
231                allowed: valences,
232            });
233        }
234    }
235    errors
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::atom::Atom;
242    use crate::bond::BondOrder;
243    use crate::element::Element;
244    use crate::molecule::MoleculeBuilder;
245
246    fn single_atom(elem: Element) -> Molecule {
247        let mut b = MoleculeBuilder::new();
248        b.add_atom(Atom::organic(elem));
249        b.build()
250    }
251
252    fn two_atoms(e1: Element, e2: Element, order: BondOrder) -> Molecule {
253        let mut b = MoleculeBuilder::new();
254        let a = b.add_atom(Atom::organic(e1));
255        let c = b.add_atom(Atom::organic(e2));
256        b.add_bond(a, c, order).unwrap();
257        b.build()
258    }
259
260    #[test]
261    fn test_methane() {
262        // C alone: 0 bonds, valence 4 → 4 implicit H
263        let mol = single_atom(Element::C);
264        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
265    }
266
267    #[test]
268    fn test_ethane_c() {
269        // CC: each C has 1 single bond → valence 4 → 3 implicit H
270        let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
271        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
272        assert_eq!(implicit_hcount(&mol, AtomIdx(1)), 3);
273    }
274
275    #[test]
276    fn test_ethylene_c() {
277        // C=C: double bond → bond_sum=2 → 4-2=2 implicit H
278        let mol = two_atoms(Element::C, Element::C, BondOrder::Double);
279        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
280    }
281
282    #[test]
283    fn test_acetylene_c() {
284        // C#C: triple bond → bond_sum=3 → 4-3=1 implicit H
285        let mol = two_atoms(Element::C, Element::C, BondOrder::Triple);
286        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
287    }
288
289    #[test]
290    fn test_nitrogen_amine() {
291        // N alone: 0 bonds, first normal valence=3 → 3 implicit H (NH3)
292        let mol = single_atom(Element::N);
293        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
294    }
295
296    #[test]
297    fn test_nitrogen_triple() {
298        // N#C: N has triple bond → bond_sum=3 → 3-3=0 (nitrile N)
299        let mol = two_atoms(Element::N, Element::C, BondOrder::Triple);
300        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
301    }
302
303    #[test]
304    fn test_oxygen_ether() {
305        // O alone: 0 bonds, valence 2 → 2 implicit H (water)
306        let mol = single_atom(Element::O);
307        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
308    }
309
310    #[test]
311    fn test_fluorine() {
312        // F alone: valence 1 → 1 implicit H (HF)
313        let mol = single_atom(Element::F);
314        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
315    }
316
317    #[test]
318    fn test_bracket_atom_explicit_h() {
319        // [NH4+] — bracket atom: explicit H=4 returned directly
320        let mut b = MoleculeBuilder::new();
321        let atom = Atom::bracket(Element::N, None, Default::default(), 4, 1, None);
322        b.add_atom(atom);
323        let mol = b.build();
324        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
325    }
326
327    #[test]
328    fn test_hypervalent_sulfur() {
329        // S with four single bonds: bond_sum=4, S valences=[2,4,6] → target=4 → 0 H
330        let mut b = MoleculeBuilder::new();
331        let s = b.add_atom(Atom::organic(Element::S));
332        for _ in 0..4 {
333            let c = b.add_atom(Atom::organic(Element::C));
334            b.add_bond(s, c, BondOrder::Single).unwrap();
335        }
336        let mol = b.build();
337        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
338    }
339
340    // ---------------------------------------------------------------------------
341    // validate_valence tests
342    // ---------------------------------------------------------------------------
343
344    #[test]
345    fn test_validate_valence_valid_molecules() {
346        // All normal molecules should produce no errors.
347        // methane (C, 0 bonds): valid
348        let mol = single_atom(Element::C);
349        assert!(
350            validate_valence(&mol).is_empty(),
351            "isolated C must be valid"
352        );
353
354        // water (O, 0 bonds): valid
355        let mol = single_atom(Element::O);
356        assert!(
357            validate_valence(&mol).is_empty(),
358            "isolated O must be valid"
359        );
360
361        // ethane (C–C): C has bond_sum=1, max valence 4 → valid
362        let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
363        assert!(validate_valence(&mol).is_empty(), "ethane must be valid");
364
365        // formaldehyde (C=O): C bond_sum=2, O bond_sum=2 → both valid
366        let mol = two_atoms(Element::C, Element::O, BondOrder::Double);
367        assert!(
368            validate_valence(&mol).is_empty(),
369            "formaldehyde must be valid"
370        );
371    }
372
373    #[test]
374    fn test_validate_valence_pentavalent_carbon() {
375        // C with 5 single bonds: bond_sum=5 > max(C valences)=4 → error
376        let mut b = MoleculeBuilder::new();
377        let c = b.add_atom(Atom::organic(Element::C));
378        for _ in 0..5 {
379            let h = b.add_atom(Atom::new(Element::C));
380            b.add_bond(c, h, BondOrder::Single).unwrap();
381        }
382        let mol = b.build();
383        let errors = validate_valence(&mol);
384        assert_eq!(
385            errors.len(),
386            1,
387            "C with 5 bonds must produce exactly 1 error"
388        );
389        assert_eq!(errors[0].atom, AtomIdx(0));
390        assert_eq!(errors[0].actual, 5);
391    }
392
393    #[test]
394    fn test_validate_valence_trivalent_oxygen() {
395        // O with 3 single bonds: bond_sum=3 > max(O valences)=2 → error
396        let mut b = MoleculeBuilder::new();
397        let o = b.add_atom(Atom::organic(Element::O));
398        for _ in 0..3 {
399            let c = b.add_atom(Atom::organic(Element::C));
400            b.add_bond(o, c, BondOrder::Single).unwrap();
401        }
402        let mol = b.build();
403        let errors = validate_valence(&mol);
404        assert!(
405            !errors.is_empty(),
406            "O with 3 bonds must be flagged as over-valenced"
407        );
408        assert_eq!(errors[0].atom, AtomIdx(0));
409    }
410
411    #[test]
412    fn test_validate_valence_ammonium_valid() {
413        // [NH4+]: N with charge +1 and 4 bonds: effective max = 3+1=4 → valid
414        let mut b = MoleculeBuilder::new();
415        let mut n_atom = Atom::organic(Element::N);
416        n_atom.charge = 1;
417        let n = b.add_atom(n_atom);
418        for _ in 0..4 {
419            let c = b.add_atom(Atom::organic(Element::C));
420            b.add_bond(n, c, BondOrder::Single).unwrap();
421        }
422        let mol = b.build();
423        assert!(
424            validate_valence(&mol).is_empty(),
425            "N+ with 4 bonds must be valid (ammonium-like)"
426        );
427    }
428
429    // ---------------------------------------------------------------------------
430    // Te (tellurium) tests — element.rs now has a real normal_valences() entry
431    // for atomic number 52 ([2, 4, 6], source-verified against RDKit; see
432    // element.rs::test_te_valence_source_verified). These pin the resulting
433    // implicit_hcount()/validate_valence() behavior against RDKit's own output.
434    // ---------------------------------------------------------------------------
435
436    #[test]
437    fn test_te_implicit_hcount_no_explicit_h_stays_zero() {
438        // Te is outside the OpenSMILES organic subset, so implicit_hcount() must
439        // still return 0 for a bare (non-bracket) Te atom, unchanged by the new
440        // valence entry (guarded by the is_organic_subset() short-circuit).
441        let mol = single_atom(Element::TE);
442        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
443    }
444
445    #[test]
446    fn test_validate_valence_te_divalent_neutral() {
447        // C[Te]C (dimethyl telluride analog). RDKit: explicitValence=2,
448        // totalValence=2, sanitizes OK.
449        let mut b = MoleculeBuilder::new();
450        let te = b.add_atom(Atom::organic(Element::TE));
451        for _ in 0..2 {
452            let c = b.add_atom(Atom::organic(Element::C));
453            b.add_bond(te, c, BondOrder::Single).unwrap();
454        }
455        let mol = b.build();
456        assert!(
457            validate_valence(&mol).is_empty(),
458            "divalent Te must be valid (RDKit sanitizes C[Te]C OK)"
459        );
460    }
461
462    #[test]
463    fn test_validate_valence_te_tetravalent_neutral() {
464        // Cl[Te](Cl)(Cl)Cl (TeCl4 analog). RDKit: explicitValence=4, sanitizes OK.
465        let mut b = MoleculeBuilder::new();
466        let te = b.add_atom(Atom::organic(Element::TE));
467        for _ in 0..4 {
468            let c = b.add_atom(Atom::organic(Element::C));
469            b.add_bond(te, c, BondOrder::Single).unwrap();
470        }
471        let mol = b.build();
472        assert!(
473            validate_valence(&mol).is_empty(),
474            "tetravalent Te must be valid (RDKit sanitizes TeCl4-analog OK)"
475        );
476    }
477
478    #[test]
479    fn test_validate_valence_te_hexavalent_neutral() {
480        // F[Te](F)(F)(F)(F)F (TeF6 analog). RDKit: explicitValence=6, sanitizes OK.
481        let mut b = MoleculeBuilder::new();
482        let te = b.add_atom(Atom::organic(Element::TE));
483        for _ in 0..6 {
484            let c = b.add_atom(Atom::organic(Element::C));
485            b.add_bond(te, c, BondOrder::Single).unwrap();
486        }
487        let mol = b.build();
488        assert!(
489            validate_valence(&mol).is_empty(),
490            "hexavalent Te must be valid (RDKit sanitizes TeF6-analog OK)"
491        );
492    }
493
494    #[test]
495    fn test_validate_valence_te_overvalent_invalid() {
496        // 8 single bonds on Te (Cl x8 analog). RDKit rejects during sanitization:
497        // "Explicit valence for atom # 1 Te, 8, is greater than permitted".
498        let mut b = MoleculeBuilder::new();
499        let te = b.add_atom(Atom::organic(Element::TE));
500        for _ in 0..8 {
501            let c = b.add_atom(Atom::organic(Element::C));
502            b.add_bond(te, c, BondOrder::Single).unwrap();
503        }
504        let mol = b.build();
505        let errors = validate_valence(&mol);
506        assert_eq!(
507            errors.len(),
508            1,
509            "8-bonded Te must be flagged invalid, matching RDKit's AtomValenceException"
510        );
511        assert_eq!(errors[0].actual, 8);
512        assert_eq!(errors[0].allowed, &[2, 4, 6]);
513    }
514
515    #[test]
516    fn test_validate_valence_te_cation_telluronium() {
517        // C[Te+](C)C (trimethyltelluronium). RDKit: charge=+1, degree=3,
518        // explicitValence=3 (effective valence 2+1=3), sanitizes OK.
519        let mut b = MoleculeBuilder::new();
520        let mut te_atom = Atom::organic(Element::TE);
521        te_atom.charge = 1;
522        let te = b.add_atom(te_atom);
523        for _ in 0..3 {
524            let c = b.add_atom(Atom::organic(Element::C));
525            b.add_bond(te, c, BondOrder::Single).unwrap();
526        }
527        let mol = b.build();
528        assert!(
529            validate_valence(&mol).is_empty(),
530            "Te+ telluronium (3 bonds) must be valid, matching RDKit"
531        );
532    }
533
534    #[test]
535    fn test_validate_valence_te_anion_telluride() {
536        // [Te-2] (isolated telluride dianion). RDKit: charge=-2, degree=0,
537        // explicitValence=0, sanitizes OK.
538        let mut b = MoleculeBuilder::new();
539        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 0, -2, None);
540        b.add_atom(te_atom);
541        let mol = b.build();
542        assert!(
543            validate_valence(&mol).is_empty(),
544            "[Te-2] must be valid, matching RDKit"
545        );
546    }
547
548    #[test]
549    fn test_validate_valence_te_anion_hydrotelluride() {
550        // [TeH-]. RDKit: charge=-1, explicit H=1, totalValence=1, sanitizes OK.
551        let mut b = MoleculeBuilder::new();
552        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 1, -1, None);
553        b.add_atom(te_atom);
554        let mol = b.build();
555        assert!(
556            validate_valence(&mol).is_empty(),
557            "[TeH-] must be valid, matching RDKit"
558        );
559    }
560
561    #[test]
562    fn test_te_bracket_h2_implicit_and_valid() {
563        // [TeH2]. RDKit: charge=0, explicit H=2, totalValence=2, sanitizes OK.
564        // Bracket atoms return their stored H count directly from implicit_hcount().
565        let mut b = MoleculeBuilder::new();
566        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 2, 0, None);
567        let te = b.add_atom(te_atom);
568        let mol = b.build();
569        assert_eq!(implicit_hcount(&mol, te), 2);
570        assert!(
571            validate_valence(&mol).is_empty(),
572            "[TeH2] must be valid, matching RDKit"
573        );
574    }
575
576    #[test]
577    fn test_validate_valence_transition_metal_skipped() {
578        // Fe has no normal_valences → always valid regardless of bonds
579        let mut b = MoleculeBuilder::new();
580        let fe = b.add_atom(Atom::new(Element::FE));
581        for _ in 0..6 {
582            let c = b.add_atom(Atom::organic(Element::C));
583            b.add_bond(fe, c, BondOrder::Single).unwrap();
584        }
585        let mol = b.build();
586        assert!(
587            validate_valence(&mol).is_empty(),
588            "Fe with 6 bonds must be skipped"
589        );
590    }
591}