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 let has_valid = valences.iter().any(|&v| {
236 let effective = (v as i16 + charge).max(0) as u8;
237 effective >= used
238 });
239
240 if !has_valid {
241 errors.push(ValenceError {
242 atom: idx,
243 actual: used,
244 allowed: valences,
245 });
246 }
247 }
248 errors
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::atom::Atom;
255 use crate::bond::BondOrder;
256 use crate::element::Element;
257 use crate::molecule::MoleculeBuilder;
258
259 fn single_atom(elem: Element) -> Molecule {
260 let mut b = MoleculeBuilder::new();
261 b.add_atom(Atom::organic(elem));
262 b.build()
263 }
264
265 fn two_atoms(e1: Element, e2: Element, order: BondOrder) -> Molecule {
266 let mut b = MoleculeBuilder::new();
267 let a = b.add_atom(Atom::organic(e1));
268 let c = b.add_atom(Atom::organic(e2));
269 b.add_bond(a, c, order).unwrap();
270 b.build()
271 }
272
273 #[test]
274 fn test_methane() {
275 // C alone: 0 bonds, valence 4 → 4 implicit H
276 let mol = single_atom(Element::C);
277 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
278 }
279
280 #[test]
281 fn test_ethane_c() {
282 // CC: each C has 1 single bond → valence 4 → 3 implicit H
283 let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
284 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
285 assert_eq!(implicit_hcount(&mol, AtomIdx(1)), 3);
286 }
287
288 #[test]
289 fn test_ethylene_c() {
290 // C=C: double bond → bond_sum=2 → 4-2=2 implicit H
291 let mol = two_atoms(Element::C, Element::C, BondOrder::Double);
292 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
293 }
294
295 #[test]
296 fn test_acetylene_c() {
297 // C#C: triple bond → bond_sum=3 → 4-3=1 implicit H
298 let mol = two_atoms(Element::C, Element::C, BondOrder::Triple);
299 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
300 }
301
302 #[test]
303 fn test_nitrogen_amine() {
304 // N alone: 0 bonds, first normal valence=3 → 3 implicit H (NH3)
305 let mol = single_atom(Element::N);
306 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 3);
307 }
308
309 #[test]
310 fn test_nitrogen_triple() {
311 // N#C: N has triple bond → bond_sum=3 → 3-3=0 (nitrile N)
312 let mol = two_atoms(Element::N, Element::C, BondOrder::Triple);
313 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
314 }
315
316 #[test]
317 fn test_oxygen_ether() {
318 // O alone: 0 bonds, valence 2 → 2 implicit H (water)
319 let mol = single_atom(Element::O);
320 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 2);
321 }
322
323 #[test]
324 fn test_fluorine() {
325 // F alone: valence 1 → 1 implicit H (HF)
326 let mol = single_atom(Element::F);
327 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 1);
328 }
329
330 #[test]
331 fn test_bracket_atom_explicit_h() {
332 // [NH4+] — bracket atom: explicit H=4 returned directly
333 let mut b = MoleculeBuilder::new();
334 let atom = Atom::bracket(Element::N, None, Default::default(), 4, 1, None);
335 b.add_atom(atom);
336 let mol = b.build();
337 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 4);
338 }
339
340 #[test]
341 fn test_hypervalent_sulfur() {
342 // S with four single bonds: bond_sum=4, S valences=[2,4,6] → target=4 → 0 H
343 let mut b = MoleculeBuilder::new();
344 let s = b.add_atom(Atom::organic(Element::S));
345 for _ in 0..4 {
346 let c = b.add_atom(Atom::organic(Element::C));
347 b.add_bond(s, c, BondOrder::Single).unwrap();
348 }
349 let mol = b.build();
350 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
351 }
352
353 // ---------------------------------------------------------------------------
354 // validate_valence tests
355 // ---------------------------------------------------------------------------
356
357 #[test]
358 fn test_validate_valence_valid_molecules() {
359 // All normal molecules should produce no errors.
360 // methane (C, 0 bonds): valid
361 let mol = single_atom(Element::C);
362 assert!(
363 validate_valence(&mol).is_empty(),
364 "isolated C must be valid"
365 );
366
367 // water (O, 0 bonds): valid
368 let mol = single_atom(Element::O);
369 assert!(
370 validate_valence(&mol).is_empty(),
371 "isolated O must be valid"
372 );
373
374 // ethane (C–C): C has bond_sum=1, max valence 4 → valid
375 let mol = two_atoms(Element::C, Element::C, BondOrder::Single);
376 assert!(validate_valence(&mol).is_empty(), "ethane must be valid");
377
378 // formaldehyde (C=O): C bond_sum=2, O bond_sum=2 → both valid
379 let mol = two_atoms(Element::C, Element::O, BondOrder::Double);
380 assert!(
381 validate_valence(&mol).is_empty(),
382 "formaldehyde must be valid"
383 );
384 }
385
386 #[test]
387 fn test_validate_valence_pentavalent_carbon() {
388 // C with 5 single bonds: bond_sum=5 > max(C valences)=4 → error
389 let mut b = MoleculeBuilder::new();
390 let c = b.add_atom(Atom::organic(Element::C));
391 for _ in 0..5 {
392 let h = b.add_atom(Atom::new(Element::C));
393 b.add_bond(c, h, BondOrder::Single).unwrap();
394 }
395 let mol = b.build();
396 let errors = validate_valence(&mol);
397 assert_eq!(
398 errors.len(),
399 1,
400 "C with 5 bonds must produce exactly 1 error"
401 );
402 assert_eq!(errors[0].atom, AtomIdx(0));
403 assert_eq!(errors[0].actual, 5);
404 }
405
406 #[test]
407 fn test_validate_valence_trivalent_oxygen() {
408 // O with 3 single bonds: bond_sum=3 > max(O valences)=2 → error
409 let mut b = MoleculeBuilder::new();
410 let o = b.add_atom(Atom::organic(Element::O));
411 for _ in 0..3 {
412 let c = b.add_atom(Atom::organic(Element::C));
413 b.add_bond(o, c, BondOrder::Single).unwrap();
414 }
415 let mol = b.build();
416 let errors = validate_valence(&mol);
417 assert!(
418 !errors.is_empty(),
419 "O with 3 bonds must be flagged as over-valenced"
420 );
421 assert_eq!(errors[0].atom, AtomIdx(0));
422 }
423
424 #[test]
425 fn test_validate_valence_ammonium_valid() {
426 // [NH4+]: N with charge +1 and 4 bonds: effective max = 3+1=4 → valid
427 let mut b = MoleculeBuilder::new();
428 let mut n_atom = Atom::organic(Element::N);
429 n_atom.charge = 1;
430 let n = b.add_atom(n_atom);
431 for _ in 0..4 {
432 let c = b.add_atom(Atom::organic(Element::C));
433 b.add_bond(n, c, BondOrder::Single).unwrap();
434 }
435 let mol = b.build();
436 assert!(
437 validate_valence(&mol).is_empty(),
438 "N+ with 4 bonds must be valid (ammonium-like)"
439 );
440 }
441
442 // ---------------------------------------------------------------------------
443 // Te (tellurium) tests — element.rs now has a real normal_valences() entry
444 // for atomic number 52 ([2, 4, 6], source-verified against RDKit; see
445 // element.rs::test_te_valence_source_verified). These pin the resulting
446 // implicit_hcount()/validate_valence() behavior against RDKit's own output.
447 // ---------------------------------------------------------------------------
448
449 #[test]
450 fn test_te_implicit_hcount_no_explicit_h_stays_zero() {
451 // Te is outside the OpenSMILES organic subset, so implicit_hcount() must
452 // still return 0 for a bare (non-bracket) Te atom, unchanged by the new
453 // valence entry (guarded by the is_organic_subset() short-circuit).
454 let mol = single_atom(Element::TE);
455 assert_eq!(implicit_hcount(&mol, AtomIdx(0)), 0);
456 }
457
458 #[test]
459 fn test_validate_valence_te_divalent_neutral() {
460 // C[Te]C (dimethyl telluride analog). RDKit: explicitValence=2,
461 // totalValence=2, sanitizes OK.
462 let mut b = MoleculeBuilder::new();
463 let te = b.add_atom(Atom::organic(Element::TE));
464 for _ in 0..2 {
465 let c = b.add_atom(Atom::organic(Element::C));
466 b.add_bond(te, c, BondOrder::Single).unwrap();
467 }
468 let mol = b.build();
469 assert!(
470 validate_valence(&mol).is_empty(),
471 "divalent Te must be valid (RDKit sanitizes C[Te]C OK)"
472 );
473 }
474
475 #[test]
476 fn test_validate_valence_te_tetravalent_neutral() {
477 // Cl[Te](Cl)(Cl)Cl (TeCl4 analog). RDKit: explicitValence=4, sanitizes OK.
478 let mut b = MoleculeBuilder::new();
479 let te = b.add_atom(Atom::organic(Element::TE));
480 for _ in 0..4 {
481 let c = b.add_atom(Atom::organic(Element::C));
482 b.add_bond(te, c, BondOrder::Single).unwrap();
483 }
484 let mol = b.build();
485 assert!(
486 validate_valence(&mol).is_empty(),
487 "tetravalent Te must be valid (RDKit sanitizes TeCl4-analog OK)"
488 );
489 }
490
491 #[test]
492 fn test_validate_valence_te_hexavalent_neutral() {
493 // F[Te](F)(F)(F)(F)F (TeF6 analog). RDKit: explicitValence=6, sanitizes OK.
494 let mut b = MoleculeBuilder::new();
495 let te = b.add_atom(Atom::organic(Element::TE));
496 for _ in 0..6 {
497 let c = b.add_atom(Atom::organic(Element::C));
498 b.add_bond(te, c, BondOrder::Single).unwrap();
499 }
500 let mol = b.build();
501 assert!(
502 validate_valence(&mol).is_empty(),
503 "hexavalent Te must be valid (RDKit sanitizes TeF6-analog OK)"
504 );
505 }
506
507 #[test]
508 fn test_validate_valence_te_overvalent_invalid() {
509 // 8 single bonds on Te (Cl x8 analog). RDKit rejects during sanitization:
510 // "Explicit valence for atom # 1 Te, 8, is greater than permitted".
511 let mut b = MoleculeBuilder::new();
512 let te = b.add_atom(Atom::organic(Element::TE));
513 for _ in 0..8 {
514 let c = b.add_atom(Atom::organic(Element::C));
515 b.add_bond(te, c, BondOrder::Single).unwrap();
516 }
517 let mol = b.build();
518 let errors = validate_valence(&mol);
519 assert_eq!(
520 errors.len(),
521 1,
522 "8-bonded Te must be flagged invalid, matching RDKit's AtomValenceException"
523 );
524 assert_eq!(errors[0].actual, 8);
525 assert_eq!(errors[0].allowed, &[2, 4, 6]);
526 }
527
528 #[test]
529 fn test_validate_valence_te_cation_telluronium() {
530 // C[Te+](C)C (trimethyltelluronium). RDKit: charge=+1, degree=3,
531 // explicitValence=3 (effective valence 2+1=3), sanitizes OK.
532 let mut b = MoleculeBuilder::new();
533 let mut te_atom = Atom::organic(Element::TE);
534 te_atom.charge = 1;
535 let te = b.add_atom(te_atom);
536 for _ in 0..3 {
537 let c = b.add_atom(Atom::organic(Element::C));
538 b.add_bond(te, c, BondOrder::Single).unwrap();
539 }
540 let mol = b.build();
541 assert!(
542 validate_valence(&mol).is_empty(),
543 "Te+ telluronium (3 bonds) must be valid, matching RDKit"
544 );
545 }
546
547 #[test]
548 fn test_validate_valence_te_anion_telluride() {
549 // [Te-2] (isolated telluride dianion). RDKit: charge=-2, degree=0,
550 // explicitValence=0, sanitizes OK.
551 let mut b = MoleculeBuilder::new();
552 let te_atom = Atom::bracket(Element::TE, None, Default::default(), 0, -2, None);
553 b.add_atom(te_atom);
554 let mol = b.build();
555 assert!(
556 validate_valence(&mol).is_empty(),
557 "[Te-2] must be valid, matching RDKit"
558 );
559 }
560
561 #[test]
562 fn test_validate_valence_te_anion_hydrotelluride() {
563 // [TeH-]. RDKit: charge=-1, explicit H=1, totalValence=1, sanitizes OK.
564 let mut b = MoleculeBuilder::new();
565 let te_atom = Atom::bracket(Element::TE, None, Default::default(), 1, -1, None);
566 b.add_atom(te_atom);
567 let mol = b.build();
568 assert!(
569 validate_valence(&mol).is_empty(),
570 "[TeH-] must be valid, matching RDKit"
571 );
572 }
573
574 #[test]
575 fn test_te_bracket_h2_implicit_and_valid() {
576 // [TeH2]. RDKit: charge=0, explicit H=2, totalValence=2, sanitizes OK.
577 // Bracket atoms return their stored H count directly from implicit_hcount().
578 let mut b = MoleculeBuilder::new();
579 let te_atom = Atom::bracket(Element::TE, None, Default::default(), 2, 0, None);
580 let te = b.add_atom(te_atom);
581 let mol = b.build();
582 assert_eq!(implicit_hcount(&mol, te), 2);
583 assert!(
584 validate_valence(&mol).is_empty(),
585 "[TeH2] must be valid, matching RDKit"
586 );
587 }
588
589 #[test]
590 fn test_validate_valence_transition_metal_skipped() {
591 // Fe has no normal_valences → always valid regardless of bonds
592 let mut b = MoleculeBuilder::new();
593 let fe = b.add_atom(Atom::new(Element::FE));
594 for _ in 0..6 {
595 let c = b.add_atom(Atom::organic(Element::C));
596 b.add_bond(fe, c, BondOrder::Single).unwrap();
597 }
598 let mol = b.build();
599 assert!(
600 validate_valence(&mol).is_empty(),
601 "Fe with 6 bonds must be skipped"
602 );
603 }
604
605 // -------------------------------------------------------------------
606 // Donor-side dative-bond implicit H -- regression tests for the
607 // platinum coordination-chemistry benchmark
608 // (validation/platinum/FEASIBILITY.md). A bare, un-bracketed donor
609 // atom's own normal covalent valence must not be spent by its own
610 // dative bond: `N->[Pt]` must still mean NH3, matching RDKit's
611 // identical treatment of the same `->` SMILES syntax. Before this fix,
612 // `order_int()`'s `1` for `Dative` was summed exactly like a real
613 // covalent bond, giving NH2 instead.
614 // -------------------------------------------------------------------
615
616 #[test]
617 fn test_dative_donor_n_keeps_full_valence() {
618 // N->Pt : N is the donor (atom1). Bare N, no other substituents.
619 let mol = two_atoms(Element::N, Element::PT, BondOrder::Dative);
620 assert_eq!(
621 implicit_hcount(&mol, AtomIdx(0)),
622 3,
623 "a dative donor's own lone pair, not one of its 3 normal covalent \
624 slots, is shared with the acceptor -- N->[Pt] must mean NH3"
625 );
626 }
627
628 #[test]
629 fn test_dative_donor_o_keeps_full_valence() {
630 // O->Fe : O is the donor. Matches the corpus's water/DMSO-oxygen
631 // style dative ligands (see cisplatin_diaqua_activation_product in
632 // pt_corpus.jsonl).
633 let mol = two_atoms(Element::O, Element::FE, BondOrder::Dative);
634 assert_eq!(
635 implicit_hcount(&mol, AtomIdx(0)),
636 2,
637 "O->[Fe] must mean H2O"
638 );
639 }
640
641 #[test]
642 fn test_dative_acceptor_side_unaffected() {
643 // Pt->N (reversed: Pt is the donor per this specific bond's stored
644 // direction, N is the acceptor) -- the fix is donor-side-only by
645 // design (see valence_inferred_hcount's doc comment), so N here
646 // gets no special treatment; this pins that scope boundary rather
647 // than leaving it implicit.
648 let mol = two_atoms(Element::PT, Element::N, BondOrder::Dative);
649 // N is atom2 (the acceptor) here: bond_sum=1 (unchanged, counted
650 // like a normal covalent bond), giving valence 3 - 1 = 2H, not 3.
651 assert_eq!(
652 implicit_hcount(&mol, AtomIdx(1)),
653 2,
654 "acceptor-side implicit H is intentionally untouched by this fix"
655 );
656 }
657
658 #[test]
659 fn test_generalization_not_platinum_specific() {
660 // Same fix, non-Pt acceptors (Fe, Co) -- confirms this is a general
661 // dative-bond fix, not special-cased to Pt (task's generalization
662 // gate, see FEASIBILITY.md section 16/6).
663 for acceptor in [Element::FE, Element::CO, Element::PD, Element::RU] {
664 let mol = two_atoms(Element::N, acceptor, BondOrder::Dative);
665 assert_eq!(
666 implicit_hcount(&mol, AtomIdx(0)),
667 3,
668 "N->{acceptor:?} must mean NH3 regardless of which metal accepts"
669 );
670 }
671 }
672
673 #[test]
674 fn test_known_divergence_bracketed_dative_donor_can_still_false_positive_in_validate_valence() {
675 // KNOWN, DOCUMENTED, NOT FIXED HERE (see FEASIBILITY.md's residual
676 // limitations): `validate_valence` uses `bond_order_sum`, a
677 // separate, PUBLIC function (also used by chematic-cip/tautomer/
678 // chematic-ff) that was deliberately left untouched -- widening it
679 // to exempt donor-side dative bonds too would change its meaning
680 // for those other consumers, a much larger blast radius than this
681 // benchmark measured or scoped. The practical consequence: a
682 // BRACKETED dative donor whose only listed normal valence is
683 // already exactly met by its own explicit H count (e.g. O, whose
684 // only valence is 2) still gets a spurious `ValenceError` from
685 // `validate_valence`, even though `implicit_hcount`/
686 // `valence_inferred_hcount` correctly agree the atom is valid.
687 // Nitrogen is NOT affected in practice ([NH3]->[Pt] does not
688 // trigger this) only because N's valence list [3, 5] happens to
689 // have a second, higher tier that absorbs the extra count -- an
690 // element-specific coincidence, not a general exemption. This
691 // corpus never hits this path (it uses bare, un-bracketed donor
692 // atoms throughout), which is why `pt_corpus.jsonl`'s
693 // `valence_errors` fields are all empty.
694 let mut b = MoleculeBuilder::new();
695 let o = b.add_atom(Atom::bracket(
696 Element::O,
697 None,
698 Default::default(),
699 2,
700 0,
701 None,
702 ));
703 let pt = b.add_atom(Atom::new(Element::PT));
704 b.add_bond(o, pt, BondOrder::Dative).unwrap();
705 let mol = b.build();
706
707 assert_eq!(
708 implicit_hcount(&mol, o),
709 2,
710 "bracket H is stored directly regardless of bond wiring"
711 );
712 assert_eq!(
713 validate_valence(&mol).len(),
714 1,
715 "known false positive: [OH2]->[Pt] is valid water-donor chemistry \
716 but validate_valence still flags it via bond_order_sum's \
717 unchanged (donor-side-inclusive) count -- if this assertion \
718 ever starts failing because it's empty, bond_order_sum was \
719 fixed too and this whole test (and its FEASIBILITY.md note) \
720 should be deleted, not \"corrected\" back to failing"
721 );
722 }
723}