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 bond = mol.bond(bidx);
77        let order = bond.order;
78        if order == BondOrder::Aromatic {
79            aromatic_count += 1;
80        } else if order == BondOrder::Dative && bond.atom1 == idx {
81            // Donor side of a dative (coordinate) bond: per `BondOrder::Dative`'s
82            // own documented `atom1 (donor) -> atom2 (acceptor)` convention, the
83            // donor shares a lone pair without spending any of its own normal
84            // covalent valence -- e.g. `N->[Pt]` must still imply NH3 (3 implicit
85            // H, valence fully intact), not NH2. Matches RDKit's identical
86            // treatment of the same SMILES dative-arrow syntax. Contributes 0,
87            // unlike a plain covalent bond of the same `order_int()==1`.
88            // The acceptor side (and any other atom for which `idx` is `atom2`)
89            // is intentionally left counted as before -- out of scope here,
90            // since every acceptor in the motivating corpus (Pt, Fe, Co, ...)
91            // is already outside the organic subset and short-circuits above.
92        } else {
93            non_aromatic_sum += order.order_int() as i32;
94        }
95    }
96
97    if aromatic_count > 0 {
98        // Aromatic molecule (pre-Kekulization): each aromatic bond contributes 1.5
99        // to the effective bond order (OpenSMILES convention).
100        //
101        // floor(1.5 × n) gives the contribution from n aromatic bonds:
102        //   n=2 → 3  benzene CH:   4−3=1H ✓   pyridine N: 3−3=0H ✓
103        //   n=3 → 4  junction C:   4−4=0H ✓
104        //
105        // Combined with non-aromatic substituents (e.g. N−CH₃) this correctly yields
106        // 0 H for all substituted aromatic atoms without needing Kekulization.
107        // Always use the lowest normal valence; aromatic atoms cannot be hypervalent.
108        let aromatic_contribution = (aromatic_count as f64 * 1.5).floor() as i32;
109        let effective_sum = aromatic_contribution.saturating_add(non_aromatic_sum);
110        let v = normal_valences[0] as i32 + charge;
111        if v <= 0 || effective_sum >= v {
112            return 0;
113        }
114        return (v - effective_sum) as u8;
115    }
116
117    // Non-aromatic path (or post-Kekulization molecule where all bonds are explicit).
118    let bond_sum = non_aromatic_sum;
119
120    // For atoms that carry the aromatic flag but reside in a kekulized molecule
121    // (bonds are Single/Double, not Aromatic), use only the lowest normal valence.
122    // Rationale: after Kekulization, a substituted aromatic N (e.g. N−CH₃ in caffeine
123    // with one ring double bond) has bond_sum=4, which would select valence 5 and
124    // give 1 implicit H.  Capping at the primary valence (3) returns 0 H instead.
125    let valences_to_check: &[u8] = if atom.aromatic {
126        &normal_valences[..1]
127    } else {
128        normal_valences
129    };
130
131    // Iterate through valences (ascending) and pick the smallest ≥ bond_sum.
132    for &v in valences_to_check {
133        let target = v as i32 + charge;
134        if target < 0 {
135            continue;
136        }
137        if target >= bond_sum {
138            return (target - bond_sum) as u8;
139        }
140    }
141
142    // bond_sum exceeds all consulted valences → 0 implicit H.
143    0
144}
145
146#[deprecated(
147    since = "0.1.95",
148    note = "use `implicit_hcount` directly — the two functions are identical"
149)]
150/// Alias for [`implicit_hcount`]; kept for API compatibility.
151pub fn total_hcount(mol: &Molecule, idx: AtomIdx) -> u8 {
152    implicit_hcount(mol, idx)
153}
154
155/// Sum of integer bond orders for heavy-atom bonds on `idx`.
156/// Aromatic bonds count as 1 (pre-Kekulization representation).
157pub fn bond_order_sum(mol: &Molecule, idx: AtomIdx) -> u8 {
158    mol.neighbors(idx)
159        .map(|(_, bidx)| mol.bond(bidx).order.order_int())
160        .fold(0u8, |acc, x| acc.saturating_add(x))
161}
162
163/// Returns true if the bond is counted as a "double bond equivalent" in valence sums.
164pub fn is_pi_bond(order: BondOrder) -> bool {
165    matches!(
166        order,
167        BondOrder::Double | BondOrder::Triple | BondOrder::Quadruple
168    )
169}
170
171// ---------------------------------------------------------------------------
172// Valence validation
173// ---------------------------------------------------------------------------
174
175/// A valence violation on a specific atom.
176///
177/// Returned by [`validate_valence`] for each atom whose observed bond-order sum
178/// exceeds all allowed normal valences (after formal-charge adjustment).
179#[derive(Debug, Clone)]
180pub struct ValenceError {
181    /// Index of the over-valenced atom.
182    pub atom: AtomIdx,
183    /// Observed bond-order sum (+ explicit bracket H count).
184    pub actual: u8,
185    /// Allowed normal valences for the element (from [`crate::Element::normal_valences`]).
186    pub allowed: &'static [u8],
187}
188
189impl fmt::Display for ValenceError {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        let valences_str = self
192            .allowed
193            .iter()
194            .map(|v| v.to_string())
195            .collect::<Vec<_>>()
196            .join(", ");
197        write!(
198            f,
199            "atom {} has valence {} (allowed: [{}])",
200            self.atom.0, self.actual, valences_str
201        )
202    }
203}
204
205impl std::error::Error for ValenceError {}
206
207/// Check every atom in `mol` for valence violations.
208///
209/// Returns one [`ValenceError`] per over-valenced atom; an empty `Vec` means
210/// all atoms have valid valence.
211///
212/// Atoms without defined normal valences (transition metals, etc.) are skipped.
213/// Formal charge shifts the effective maximum: each unit of positive charge
214/// adds one to the allowed ceiling (e.g. `[NH4+]` with 4 bonds is valid).
215///
216/// Aromatic bonds are counted as 1 each (`order_int()`).  Molecules still
217/// written with `BondOrder::Aromatic` are handled correctly; fully kekulized
218/// molecules are also supported.
219pub fn validate_valence(mol: &Molecule) -> Vec<ValenceError> {
220    let mut errors = Vec::new();
221    for (idx, atom) in mol.atoms() {
222        if atom.wildcard {
223            continue;
224        }
225        let valences = atom.element.normal_valences();
226        if valences.is_empty() {
227            continue;
228        }
229
230        let bos = bond_order_sum(mol, idx);
231        let explicit_h = atom.hydrogen_count.unwrap_or(0);
232        let used = bos.saturating_add(explicit_h);
233        let charge = atom.charge as i16;
234
235        // A positive bracket H count is part of the atom's structural
236        // contract, not merely an additive allowance.  Compare it with the
237        // H count that the surviving graph and formal charge would infer.
238        // Without this check a neutral N with three heavy-atom single bonds
239        // and `[NH]` was accepted through nitrogen's higher `[3, 5]` valence
240        // tier, even though the graph leaves no N-H valence available.  Keep
241        // the aromatic `[nH]` spelling as the explicit aromatic exception;
242        // its H is required by aromaticity rather than organic-subset
243        // valence inference.  Zero-H bracket atoms remain valid query/
244        // reaction-template spellings and are not rewritten by validation.
245        let explicit_h_mismatch = explicit_h > 0
246            && !(atom.aromatic && atom.element == crate::element::Element::N)
247            && atom.element.is_organic_subset()
248            && explicit_h != crate::valence::valence_inferred_hcount(mol, idx);
249
250        let has_valid = valences.iter().any(|&v| {
251            let effective = (v as i16 + charge).max(0) as u8;
252            effective >= used
253        });
254
255        if explicit_h_mismatch || !has_valid {
256            errors.push(ValenceError {
257                atom: idx,
258                actual: used,
259                allowed: valences,
260            });
261        }
262    }
263    errors
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::atom::Atom;
270    use crate::bond::BondOrder;
271    use crate::element::Element;
272    use crate::molecule::MoleculeBuilder;
273
274    fn single_atom(elem: Element) -> Molecule {
275        let mut b = MoleculeBuilder::new();
276        b.add_atom(Atom::organic(elem));
277        b.build()
278    }
279
280    fn two_atoms(e1: Element, e2: Element, order: BondOrder) -> Molecule {
281        let mut b = MoleculeBuilder::new();
282        let a = b.add_atom(Atom::organic(e1));
283        let c = b.add_atom(Atom::organic(e2));
284        b.add_bond(a, c, order).unwrap();
285        b.build()
286    }
287
288    #[test]
289    fn test_methane() {
290        // C alone: 0 bonds, valence 4 → 4 implicit H
291        let mol = single_atom(Element::C);
292        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
293    }
294
295    #[test]
296    fn test_ethane_c() {
297        // CC: each C has 1 single bond → valence 4 → 3 implicit H
298        let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
299        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
300        assert_eq!(implicit_hcount(&mol, AtomIdx(1)), 3);
301    }
302
303    #[test]
304    fn test_ethylene_c() {
305        // C=C: double bond → bond_sum=2 → 4-2=2 implicit H
306        let mol = two_atoms(Element::C, Element::C, BondOrder::Double);
307        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
308    }
309
310    #[test]
311    fn test_acetylene_c() {
312        // C#C: triple bond → bond_sum=3 → 4-3=1 implicit H
313        let mol = two_atoms(Element::C, Element::C, BondOrder::Triple);
314        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
315    }
316
317    #[test]
318    fn test_nitrogen_amine() {
319        // N alone: 0 bonds, first normal valence=3 → 3 implicit H (NH3)
320        let mol = single_atom(Element::N);
321        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
322    }
323
324    #[test]
325    fn test_nitrogen_triple() {
326        // N#C: N has triple bond → bond_sum=3 → 3-3=0 (nitrile N)
327        let mol = two_atoms(Element::N, Element::C, BondOrder::Triple);
328        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
329    }
330
331    #[test]
332    fn test_oxygen_ether() {
333        // O alone: 0 bonds, valence 2 → 2 implicit H (water)
334        let mol = single_atom(Element::O);
335        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
336    }
337
338    #[test]
339    fn test_fluorine() {
340        // F alone: valence 1 → 1 implicit H (HF)
341        let mol = single_atom(Element::F);
342        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
343    }
344
345    #[test]
346    fn test_bracket_atom_explicit_h() {
347        // [NH4+] — bracket atom: explicit H=4 returned directly
348        let mut b = MoleculeBuilder::new();
349        let atom = Atom::bracket(Element::N, None, Default::default(), 4, 1, None);
350        b.add_atom(atom);
351        let mol = b.build();
352        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
353    }
354
355    #[test]
356    fn test_hypervalent_sulfur() {
357        // S with four single bonds: bond_sum=4, S valences=[2,4,6] → target=4 → 0 H
358        let mut b = MoleculeBuilder::new();
359        let s = b.add_atom(Atom::organic(Element::S));
360        for _ in 0..4 {
361            let c = b.add_atom(Atom::organic(Element::C));
362            b.add_bond(s, c, BondOrder::Single).unwrap();
363        }
364        let mol = b.build();
365        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
366    }
367
368    // ---------------------------------------------------------------------------
369    // validate_valence tests
370    // ---------------------------------------------------------------------------
371
372    #[test]
373    fn test_validate_valence_valid_molecules() {
374        // All normal molecules should produce no errors.
375        // methane (C, 0 bonds): valid
376        let mol = single_atom(Element::C);
377        assert!(
378            validate_valence(&mol).is_empty(),
379            "isolated C must be valid"
380        );
381
382        // water (O, 0 bonds): valid
383        let mol = single_atom(Element::O);
384        assert!(
385            validate_valence(&mol).is_empty(),
386            "isolated O must be valid"
387        );
388
389        // ethane (C–C): C has bond_sum=1, max valence 4 → valid
390        let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
391        assert!(validate_valence(&mol).is_empty(), "ethane must be valid");
392
393        // formaldehyde (C=O): C bond_sum=2, O bond_sum=2 → both valid
394        let mol = two_atoms(Element::C, Element::O, BondOrder::Double);
395        assert!(
396            validate_valence(&mol).is_empty(),
397            "formaldehyde must be valid"
398        );
399    }
400
401    #[test]
402    fn test_validate_valence_pentavalent_carbon() {
403        // C with 5 single bonds: bond_sum=5 > max(C valences)=4 → error
404        let mut b = MoleculeBuilder::new();
405        let c = b.add_atom(Atom::organic(Element::C));
406        for _ in 0..5 {
407            let h = b.add_atom(Atom::new(Element::C));
408            b.add_bond(c, h, BondOrder::Single).unwrap();
409        }
410        let mol = b.build();
411        let errors = validate_valence(&mol);
412        assert_eq!(
413            errors.len(),
414            1,
415            "C with 5 bonds must produce exactly 1 error"
416        );
417        assert_eq!(errors[0].atom, AtomIdx(0));
418        assert_eq!(errors[0].actual, 5);
419    }
420
421    #[test]
422    fn test_validate_valence_trivalent_oxygen() {
423        // O with 3 single bonds: bond_sum=3 > max(O valences)=2 → error
424        let mut b = MoleculeBuilder::new();
425        let o = b.add_atom(Atom::organic(Element::O));
426        for _ in 0..3 {
427            let c = b.add_atom(Atom::organic(Element::C));
428            b.add_bond(o, c, BondOrder::Single).unwrap();
429        }
430        let mol = b.build();
431        let errors = validate_valence(&mol);
432        assert!(
433            !errors.is_empty(),
434            "O with 3 bonds must be flagged as over-valenced"
435        );
436        assert_eq!(errors[0].atom, AtomIdx(0));
437    }
438
439    #[test]
440    fn test_validate_valence_ammonium_valid() {
441        // [NH4+]: N with charge +1 and 4 bonds: effective max = 3+1=4 → valid
442        let mut b = MoleculeBuilder::new();
443        let mut n_atom = Atom::organic(Element::N);
444        n_atom.charge = 1;
445        let n = b.add_atom(n_atom);
446        for _ in 0..4 {
447            let c = b.add_atom(Atom::organic(Element::C));
448            b.add_bond(n, c, BondOrder::Single).unwrap();
449        }
450        let mol = b.build();
451        assert!(
452            validate_valence(&mol).is_empty(),
453            "N+ with 4 bonds must be valid (ammonium-like)"
454        );
455    }
456
457    // ---------------------------------------------------------------------------
458    // Te (tellurium) tests — element.rs now has a real normal_valences() entry
459    // for atomic number 52 ([2, 4, 6], source-verified against RDKit; see
460    // element.rs::test_te_valence_source_verified). These pin the resulting
461    // implicit_hcount()/validate_valence() behavior against RDKit's own output.
462    // ---------------------------------------------------------------------------
463
464    #[test]
465    fn test_te_implicit_hcount_no_explicit_h_stays_zero() {
466        // Te is outside the OpenSMILES organic subset, so implicit_hcount() must
467        // still return 0 for a bare (non-bracket) Te atom, unchanged by the new
468        // valence entry (guarded by the is_organic_subset() short-circuit).
469        let mol = single_atom(Element::TE);
470        assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
471    }
472
473    #[test]
474    fn test_validate_valence_te_divalent_neutral() {
475        // C[Te]C (dimethyl telluride analog). RDKit: explicitValence=2,
476        // totalValence=2, sanitizes OK.
477        let mut b = MoleculeBuilder::new();
478        let te = b.add_atom(Atom::organic(Element::TE));
479        for _ in 0..2 {
480            let c = b.add_atom(Atom::organic(Element::C));
481            b.add_bond(te, c, BondOrder::Single).unwrap();
482        }
483        let mol = b.build();
484        assert!(
485            validate_valence(&mol).is_empty(),
486            "divalent Te must be valid (RDKit sanitizes C[Te]C OK)"
487        );
488    }
489
490    #[test]
491    fn test_validate_valence_te_tetravalent_neutral() {
492        // Cl[Te](Cl)(Cl)Cl (TeCl4 analog). RDKit: explicitValence=4, sanitizes OK.
493        let mut b = MoleculeBuilder::new();
494        let te = b.add_atom(Atom::organic(Element::TE));
495        for _ in 0..4 {
496            let c = b.add_atom(Atom::organic(Element::C));
497            b.add_bond(te, c, BondOrder::Single).unwrap();
498        }
499        let mol = b.build();
500        assert!(
501            validate_valence(&mol).is_empty(),
502            "tetravalent Te must be valid (RDKit sanitizes TeCl4-analog OK)"
503        );
504    }
505
506    #[test]
507    fn test_validate_valence_te_hexavalent_neutral() {
508        // F[Te](F)(F)(F)(F)F (TeF6 analog). RDKit: explicitValence=6, sanitizes OK.
509        let mut b = MoleculeBuilder::new();
510        let te = b.add_atom(Atom::organic(Element::TE));
511        for _ in 0..6 {
512            let c = b.add_atom(Atom::organic(Element::C));
513            b.add_bond(te, c, BondOrder::Single).unwrap();
514        }
515        let mol = b.build();
516        assert!(
517            validate_valence(&mol).is_empty(),
518            "hexavalent Te must be valid (RDKit sanitizes TeF6-analog OK)"
519        );
520    }
521
522    #[test]
523    fn test_validate_valence_te_overvalent_invalid() {
524        // 8 single bonds on Te (Cl x8 analog). RDKit rejects during sanitization:
525        // "Explicit valence for atom # 1 Te, 8, is greater than permitted".
526        let mut b = MoleculeBuilder::new();
527        let te = b.add_atom(Atom::organic(Element::TE));
528        for _ in 0..8 {
529            let c = b.add_atom(Atom::organic(Element::C));
530            b.add_bond(te, c, BondOrder::Single).unwrap();
531        }
532        let mol = b.build();
533        let errors = validate_valence(&mol);
534        assert_eq!(
535            errors.len(),
536            1,
537            "8-bonded Te must be flagged invalid, matching RDKit's AtomValenceException"
538        );
539        assert_eq!(errors[0].actual, 8);
540        assert_eq!(errors[0].allowed, &[2, 4, 6]);
541    }
542
543    #[test]
544    fn test_validate_valence_te_cation_telluronium() {
545        // C[Te+](C)C (trimethyltelluronium). RDKit: charge=+1, degree=3,
546        // explicitValence=3 (effective valence 2+1=3), sanitizes OK.
547        let mut b = MoleculeBuilder::new();
548        let mut te_atom = Atom::organic(Element::TE);
549        te_atom.charge = 1;
550        let te = b.add_atom(te_atom);
551        for _ in 0..3 {
552            let c = b.add_atom(Atom::organic(Element::C));
553            b.add_bond(te, c, BondOrder::Single).unwrap();
554        }
555        let mol = b.build();
556        assert!(
557            validate_valence(&mol).is_empty(),
558            "Te+ telluronium (3 bonds) must be valid, matching RDKit"
559        );
560    }
561
562    #[test]
563    fn test_validate_valence_te_anion_telluride() {
564        // [Te-2] (isolated telluride dianion). RDKit: charge=-2, degree=0,
565        // explicitValence=0, sanitizes OK.
566        let mut b = MoleculeBuilder::new();
567        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 0, -2, None);
568        b.add_atom(te_atom);
569        let mol = b.build();
570        assert!(
571            validate_valence(&mol).is_empty(),
572            "[Te-2] must be valid, matching RDKit"
573        );
574    }
575
576    #[test]
577    fn test_validate_valence_te_anion_hydrotelluride() {
578        // [TeH-]. RDKit: charge=-1, explicit H=1, totalValence=1, sanitizes OK.
579        let mut b = MoleculeBuilder::new();
580        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 1, -1, None);
581        b.add_atom(te_atom);
582        let mol = b.build();
583        assert!(
584            validate_valence(&mol).is_empty(),
585            "[TeH-] must be valid, matching RDKit"
586        );
587    }
588
589    #[test]
590    fn test_te_bracket_h2_implicit_and_valid() {
591        // [TeH2]. RDKit: charge=0, explicit H=2, totalValence=2, sanitizes OK.
592        // Bracket atoms return their stored H count directly from implicit_hcount().
593        let mut b = MoleculeBuilder::new();
594        let te_atom = Atom::bracket(Element::TE, None, Default::default(), 2, 0, None);
595        let te = b.add_atom(te_atom);
596        let mol = b.build();
597        assert_eq!(implicit_hcount(&mol, te), 2);
598        assert!(
599            validate_valence(&mol).is_empty(),
600            "[TeH2] must be valid, matching RDKit"
601        );
602    }
603
604    #[test]
605    fn test_validate_valence_transition_metal_skipped() {
606        // Fe has no normal_valences → always valid regardless of bonds
607        let mut b = MoleculeBuilder::new();
608        let fe = b.add_atom(Atom::new(Element::FE));
609        for _ in 0..6 {
610            let c = b.add_atom(Atom::organic(Element::C));
611            b.add_bond(fe, c, BondOrder::Single).unwrap();
612        }
613        let mol = b.build();
614        assert!(
615            validate_valence(&mol).is_empty(),
616            "Fe with 6 bonds must be skipped"
617        );
618    }
619
620    // -------------------------------------------------------------------
621    // Donor-side dative-bond implicit H -- regression tests for the
622    // platinum coordination-chemistry benchmark
623    // (validation/platinum/FEASIBILITY.md). A bare, un-bracketed donor
624    // atom's own normal covalent valence must not be spent by its own
625    // dative bond: `N->[Pt]` must still mean NH3, matching RDKit's
626    // identical treatment of the same `->` SMILES syntax. Before this fix,
627    // `order_int()`'s `1` for `Dative` was summed exactly like a real
628    // covalent bond, giving NH2 instead.
629    // -------------------------------------------------------------------
630
631    #[test]
632    fn test_dative_donor_n_keeps_full_valence() {
633        // N->Pt : N is the donor (atom1). Bare N, no other substituents.
634        let mol = two_atoms(Element::N, Element::PT, BondOrder::Dative);
635        assert_eq!(
636            implicit_hcount(&mol, AtomIdx(0)),
637            3,
638            "a dative donor's own lone pair, not one of its 3 normal covalent \
639             slots, is shared with the acceptor -- N->[Pt] must mean NH3"
640        );
641    }
642
643    #[test]
644    fn test_dative_donor_o_keeps_full_valence() {
645        // O->Fe : O is the donor. Matches the corpus's water/DMSO-oxygen
646        // style dative ligands (see cisplatin_diaqua_activation_product in
647        // pt_corpus.jsonl).
648        let mol = two_atoms(Element::O, Element::FE, BondOrder::Dative);
649        assert_eq!(
650            implicit_hcount(&mol, AtomIdx(0)),
651            2,
652            "O->[Fe] must mean H2O"
653        );
654    }
655
656    #[test]
657    fn test_dative_acceptor_side_unaffected() {
658        // Pt->N (reversed: Pt is the donor per this specific bond's stored
659        // direction, N is the acceptor) -- the fix is donor-side-only by
660        // design (see valence_inferred_hcount's doc comment), so N here
661        // gets no special treatment; this pins that scope boundary rather
662        // than leaving it implicit.
663        let mol = two_atoms(Element::PT, Element::N, BondOrder::Dative);
664        // N is atom2 (the acceptor) here: bond_sum=1 (unchanged, counted
665        // like a normal covalent bond), giving valence 3 - 1 = 2H, not 3.
666        assert_eq!(
667            implicit_hcount(&mol, AtomIdx(1)),
668            2,
669            "acceptor-side implicit H is intentionally untouched by this fix"
670        );
671    }
672
673    #[test]
674    fn test_generalization_not_platinum_specific() {
675        // Same fix, non-Pt acceptors (Fe, Co) -- confirms this is a general
676        // dative-bond fix, not special-cased to Pt (task's generalization
677        // gate, see FEASIBILITY.md section 16/6).
678        for acceptor in [Element::FE, Element::CO, Element::PD, Element::RU] {
679            let mol = two_atoms(Element::N, acceptor, BondOrder::Dative);
680            assert_eq!(
681                implicit_hcount(&mol, AtomIdx(0)),
682                3,
683                "N->{acceptor:?} must mean NH3 regardless of which metal accepts"
684            );
685        }
686    }
687
688    #[test]
689    fn test_known_divergence_bracketed_dative_donor_can_still_false_positive_in_validate_valence() {
690        // KNOWN, DOCUMENTED, NOT FIXED HERE (see FEASIBILITY.md's residual
691        // limitations): `validate_valence` uses `bond_order_sum`, a
692        // separate, PUBLIC function (also used by chematic-cip/tautomer/
693        // chematic-ff) that was deliberately left untouched -- widening it
694        // to exempt donor-side dative bonds too would change its meaning
695        // for those other consumers, a much larger blast radius than this
696        // benchmark measured or scoped. The practical consequence: a
697        // BRACKETED dative donor whose only listed normal valence is
698        // already exactly met by its own explicit H count (e.g. O, whose
699        // only valence is 2) still gets a spurious `ValenceError` from
700        // `validate_valence`, even though `implicit_hcount`/
701        // `valence_inferred_hcount` correctly agree the atom is valid.
702        // Nitrogen is NOT affected in practice ([NH3]->[Pt] does not
703        // trigger this) only because N's valence list [3, 5] happens to
704        // have a second, higher tier that absorbs the extra count -- an
705        // element-specific coincidence, not a general exemption. This
706        // corpus never hits this path (it uses bare, un-bracketed donor
707        // atoms throughout), which is why `pt_corpus.jsonl`'s
708        // `valence_errors` fields are all empty.
709        let mut b = MoleculeBuilder::new();
710        let o = b.add_atom(Atom::bracket(
711            Element::O,
712            None,
713            Default::default(),
714            2,
715            0,
716            None,
717        ));
718        let pt = b.add_atom(Atom::new(Element::PT));
719        b.add_bond(o, pt, BondOrder::Dative).unwrap();
720        let mol = b.build();
721
722        assert_eq!(
723            implicit_hcount(&mol, o),
724            2,
725            "bracket H is stored directly regardless of bond wiring"
726        );
727        assert_eq!(
728            validate_valence(&mol).len(),
729            1,
730            "known false positive: [OH2]->[Pt] is valid water-donor chemistry \
731             but validate_valence still flags it via bond_order_sum's \
732             unchanged (donor-side-inclusive) count -- if this assertion \
733             ever starts failing because it's empty, bond_order_sum was \
734             fixed too and this whole test (and its FEASIBILITY.md note) \
735             should be deleted, not \"corrected\" back to failing"
736        );
737    }
738}