chematic_perception/stereo_validation.rs
1//! Stereochemistry quality validation.
2//!
3//! Detects common stereochemistry errors that arise when reading molecular
4//! files or manually editing structures:
5//!
6//! - [`StereoErrorKind::ImpossibleCenter`] — a chirality annotation (`@`/`@@`)
7//! is present on an atom with fewer than 4 distinct heavy-atom neighbours.
8//! - [`StereoErrorKind::ConflictingWedges`] — the same atom is the base of two
9//! or more stereo bonds (Up or Down) pointing in opposite directions.
10//! - [`StereoErrorKind::RedundantStereo`] — the annotated atom is topologically
11//! equivalent to a neighbour (same Morgan rank), so the stereo specification
12//! is chemically meaningless.
13
14use chematic_core::{AtomIdx, BondOrder, Chirality, Molecule};
15use std::fmt;
16
17// ---------------------------------------------------------------------------
18// Public types
19// ---------------------------------------------------------------------------
20
21/// Kind of stereochemistry error.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum StereoErrorKind {
24 /// Chirality annotation on an atom with < 4 heavy-atom neighbours (or all
25 /// neighbours identical).
26 ImpossibleCenter,
27 /// Two or more Up/Down bonds originate from the same atom with conflicting
28 /// directions (both Up and Down from the same center).
29 ConflictingWedges,
30 /// Stereo annotation on a topologically symmetric atom (all neighbours
31 /// have the same Morgan rank — no priority ordering possible).
32 RedundantStereo,
33}
34
35/// A detected stereochemistry error.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct StereoError {
38 /// 0-based atom index of the problematic center.
39 pub atom_idx: usize,
40 pub kind: StereoErrorKind,
41}
42
43impl fmt::Display for StereoError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 let kind_str = match &self.kind {
46 StereoErrorKind::ImpossibleCenter => {
47 "impossible stereocenter (< 4 distinct neighbours)"
48 }
49 StereoErrorKind::ConflictingWedges => "conflicting wedge directions",
50 StereoErrorKind::RedundantStereo => "redundant stereo on symmetric atom",
51 };
52 write!(f, "atom {}: {}", self.atom_idx, kind_str)
53 }
54}
55
56impl std::error::Error for StereoError {}
57
58/// Summary of stereocenters in a molecule.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct StereoCompleteness {
61 /// Stereocenters with an explicit `@`/`@@` annotation.
62 pub specified: usize,
63 /// Stereocenters with 4 distinct heavy-atom neighbours but no annotation.
64 pub unspecified: usize,
65 /// `specified + unspecified`
66 pub total_centers: usize,
67}
68
69// ---------------------------------------------------------------------------
70// Internal: lightweight Morgan ranks (avoids chematic-smiles dependency)
71// ---------------------------------------------------------------------------
72
73/// Compute simple Morgan connectivity ranks for atoms in `mol`.
74/// Uses initial invariant = atomic_number * 1_000_000 + charge_term * 1000 + degree.
75fn simple_morgan_ranks(mol: &Molecule) -> Vec<u64> {
76 let n = mol.atom_count();
77 let mut ranks: Vec<u64> = (0..n)
78 .map(|i| {
79 let idx = AtomIdx(i as u32);
80 let atom = mol.atom(idx);
81 let deg = mol.neighbors(idx).count() as i64;
82 // `atom.charge` (i8) sign-extends on a plain `as u64` cast for
83 // negative values (e.g. -1i8 as u64 == u64::MAX), which made the
84 // old `atom.charge as u64 * 1000` overflow `u64` unconditionally
85 // for any negatively-charged atom (issue #267). Computing the
86 // whole invariant in i64 and reinterpreting as u64 only once, at
87 // the end, avoids that overflow while leaving the value
88 // bit-for-bit identical to before for the (already-correct)
89 // charge >= 0 case. `atomic_number()` is always >= 1 (never a
90 // 0/wildcard sentinel here), so `an * 1_000_000` always dominates
91 // even the most extreme i8 charge magnitude (128_000 at most),
92 // keeping the final sum non-negative for every realistic and
93 // every representable i8 charge.
94 let an = atom.element.atomic_number() as i64;
95 let charge = atom.charge as i64;
96 (an * 1_000_000 + charge * 1000 + deg) as u64
97 })
98 .collect();
99
100 let hash_round = |r: u64, nbrs: &[u64]| -> u64 {
101 let mut h: u64 = 14695981039346656037u64;
102 let prime: u64 = 1099511628211u64;
103 h ^= r;
104 h = h.wrapping_mul(prime);
105 for &nb in nbrs {
106 h ^= nb;
107 h = h.wrapping_mul(prime);
108 }
109 h
110 };
111
112 for _ in 0..(n + 2) {
113 let old_distinct = {
114 let mut v = ranks.clone();
115 v.sort_unstable();
116 v.dedup();
117 v.len()
118 };
119 let new_ranks: Vec<u64> = (0..n)
120 .map(|i| {
121 let idx = AtomIdx(i as u32);
122 let mut nb_ranks: Vec<u64> = mol
123 .neighbors(idx)
124 .map(|(nb, _)| ranks[nb.0 as usize])
125 .collect();
126 nb_ranks.sort_unstable();
127 hash_round(ranks[i], &nb_ranks)
128 })
129 .collect();
130 let new_distinct = {
131 let mut v = new_ranks.clone();
132 v.sort_unstable();
133 v.dedup();
134 v.len()
135 };
136 ranks = new_ranks;
137 if new_distinct <= old_distinct {
138 break;
139 }
140 }
141
142 // Normalise to consecutive ordinals.
143 let mut sorted = ranks.clone();
144 sorted.sort_unstable();
145 sorted.dedup();
146 ranks
147 .iter()
148 .map(|r| sorted.partition_point(|&u| u < *r) as u64)
149 .collect()
150}
151
152// ---------------------------------------------------------------------------
153// Public API
154// ---------------------------------------------------------------------------
155
156/// Validate the stereochemistry of `mol` and return any errors found.
157///
158/// An empty `Vec` means the stereo annotations are chemically consistent.
159pub fn validate_stereo(mol: &Molecule) -> Vec<StereoError> {
160 let ranks = simple_morgan_ranks(mol);
161 let mut errors = Vec::new();
162
163 for (idx, atom) in mol.atoms() {
164 let i = idx.0 as usize;
165
166 // Only inspect atoms with explicit chirality.
167 if atom.chirality == Chirality::None {
168 continue;
169 }
170
171 let heavy_neighbors: Vec<AtomIdx> = mol
172 .neighbors(idx)
173 .filter(|(nb, _)| mol.atom(*nb).element.atomic_number() != 1)
174 .map(|(nb, _)| nb)
175 .collect();
176
177 // Rule 1: ImpossibleCenter — fewer than 4 distinct heavy neighbours.
178 // (3 heavy + 1 implicit H is OK; < 3 heavy is definitely impossible.)
179 let implicit_h = chematic_core::implicit_hcount(mol, idx);
180 let total_groups = heavy_neighbors.len() + implicit_h as usize;
181 if total_groups < 4 {
182 errors.push(StereoError {
183 atom_idx: i,
184 kind: StereoErrorKind::ImpossibleCenter,
185 });
186 continue; // no point checking further
187 }
188
189 // Rule 2: ConflictingWedges — Up and Down bonds from same center.
190 let mut has_up = false;
191 let mut has_down = false;
192 for (_, bid) in mol.neighbors(idx) {
193 let bond = mol.bond(bid);
194 if bond.atom1 == idx {
195 match bond.order {
196 BondOrder::Up => has_up = true,
197 BondOrder::Down => has_down = true,
198 _ => {}
199 }
200 }
201 }
202 if has_up && has_down {
203 errors.push(StereoError {
204 atom_idx: i,
205 kind: StereoErrorKind::ConflictingWedges,
206 });
207 }
208
209 // Rule 3: RedundantStereo — all heavy neighbours have the same rank.
210 if !heavy_neighbors.is_empty() {
211 let first_rank = ranks[heavy_neighbors[0].0 as usize];
212 let all_same = heavy_neighbors
213 .iter()
214 .all(|nb| ranks[nb.0 as usize] == first_rank);
215 // Also check the center itself doesn't break ties via implicit H.
216 if all_same && implicit_h == 0 {
217 errors.push(StereoError {
218 atom_idx: i,
219 kind: StereoErrorKind::RedundantStereo,
220 });
221 }
222 }
223 }
224
225 errors
226}
227
228/// Return the tetrahedral stereocenter candidates in `mol` that this
229/// classifier recognises: an sp3 atom with 4 distinct heavy-atom-or-
230/// implicit-H groups, paired with whether it carries an explicit `@`/`@@`
231/// chirality annotation (`true` = specified, `false` = a valid candidate
232/// left unannotated).
233///
234/// This is the single source of truth for stereocenter classification;
235/// [`stereo_completeness`] is defined in terms of it.
236pub fn stereo_centers(mol: &Molecule) -> Vec<(AtomIdx, bool)> {
237 let ranks = simple_morgan_ranks(mol);
238 let mut centers = Vec::new();
239
240 for (idx, atom) in mol.atoms() {
241 // Skip aromatics and obvious non-centers.
242 if atom.aromatic {
243 continue;
244 }
245
246 let heavy_nbs: Vec<AtomIdx> = mol
247 .neighbors(idx)
248 .filter(|(nb, _)| mol.atom(*nb).element.atomic_number() != 1)
249 .map(|(nb, _)| nb)
250 .collect();
251 let implicit_h = chematic_core::implicit_hcount(mol, idx) as usize;
252 let groups = heavy_nbs.len() + implicit_h;
253
254 if groups != 4 {
255 continue;
256 } // only tetrahedral candidates
257
258 // Check all neighbours have distinct ranks (including implicit H as a
259 // sentinel rank). `simple_morgan_ranks` normalises to consecutive
260 // ordinals starting at 0 (see its tail: `partition_point(|&u| u <
261 // *r)`), so a real heavy-atom neighbour can legitimately carry rank
262 // 0 -- it's the ordinary "lowest invariant in the molecule" rank,
263 // not a reserved value. Using the literal `0` as the implicit-H
264 // sentinel therefore collided with real rank-0 neighbours (issue
265 // #267's follow-up bug): `dedup()` merged the two, `sorted.len()`
266 // dropped below 4, and a genuine 4-distinct-group stereocenter was
267 // silently skipped. The maximum normalised rank is (number of
268 // distinct invariants - 1), which is always <= atom_count() - 1, so
269 // `atom_count()` itself is never a reachable real rank and is safe
270 // to use as the sentinel here.
271 let implicit_h_rank_sentinel = mol.atom_count() as u64;
272 let mut nb_ranks: Vec<u64> = heavy_nbs.iter().map(|nb| ranks[nb.0 as usize]).collect();
273 if implicit_h > 0 {
274 nb_ranks.push(implicit_h_rank_sentinel);
275 }
276
277 let mut sorted = nb_ranks.clone();
278 sorted.sort_unstable();
279 sorted.dedup();
280 if sorted.len() < 4 {
281 continue;
282 } // symmetric neighbours — not a stereocenter
283
284 centers.push((idx, atom.chirality != Chirality::None));
285 }
286
287 centers
288}
289
290/// Summarise how many stereocenters in `mol` have been specified vs left open.
291///
292/// A potential stereocenter is an sp3 atom with 4 distinct heavy-atom
293/// neighbours (counting one implicit H as a distinct group when present).
294pub fn stereo_completeness(mol: &Molecule) -> StereoCompleteness {
295 let centers = stereo_centers(mol);
296 let specified = centers
297 .iter()
298 .filter(|(_, is_specified)| *is_specified)
299 .count();
300 let unspecified = centers.len() - specified;
301
302 StereoCompleteness {
303 specified,
304 unspecified,
305 total_centers: centers.len(),
306 }
307}
308
309// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use chematic_smiles::parse;
317
318 #[test]
319 fn test_valid_chiral_center_no_errors() {
320 // L-alanine: valid R/S center with 4 distinct groups.
321 let mol = parse("N[C@@H](C)C(=O)O").unwrap();
322 let errors = validate_stereo(&mol);
323 assert!(
324 errors.is_empty(),
325 "L-alanine should have no stereo errors: {errors:?}"
326 );
327 }
328
329 #[test]
330 fn test_impossible_center_explicit_h_zero() {
331 // A carbon with chirality annotation, 1 heavy bond, and explicit H=0
332 // gives total_groups = 1 → ImpossibleCenter.
333 use chematic_core::{Atom, BondOrder, Chirality, Element, MoleculeBuilder};
334 let mut b = MoleculeBuilder::new();
335 let mut c = Atom::new(Element::C);
336 c.chirality = Chirality::CounterClockwise;
337 c.hydrogen_count = Some(0); // force 0 implicit H
338 let ci = b.add_atom(c);
339 let cl = b.add_atom(Atom::new(Element::CL));
340 b.add_bond(ci, cl, BondOrder::Single).unwrap();
341 let mol = b.build();
342 let errors = validate_stereo(&mol);
343 assert!(
344 errors
345 .iter()
346 .any(|e| e.atom_idx == 0 && e.kind == StereoErrorKind::ImpossibleCenter),
347 "should detect ImpossibleCenter (1 group total): {errors:?}"
348 );
349 }
350
351 #[test]
352 fn test_stereo_completeness_alanine() {
353 // L-alanine has 1 specified stereocenter, 0 unspecified.
354 let mol = parse("N[C@@H](C)C(=O)O").unwrap();
355 let sc = stereo_completeness(&mol);
356 assert_eq!(sc.specified, 1);
357 assert_eq!(sc.unspecified, 0);
358 assert_eq!(sc.total_centers, 1);
359 }
360
361 #[test]
362 fn test_stereo_completeness_unspecified() {
363 // Alanine without stereo annotation: 1 unspecified center.
364 let mol = parse("NC(C)C(=O)O").unwrap();
365 let sc = stereo_completeness(&mol);
366 assert_eq!(sc.specified, 0);
367 assert!(sc.unspecified >= 1, "should detect unspecified center");
368 }
369
370 #[test]
371 fn test_no_centers_in_benzene() {
372 let mol = parse("c1ccccc1").unwrap();
373 let sc = stereo_completeness(&mol);
374 assert_eq!(sc.total_centers, 0);
375 }
376
377 // Regression tests for issue #267: `atom.charge as u64` sign-extends for
378 // negative i8 charges (e.g. -1i8 as u64 == u64::MAX), which made the
379 // Morgan-rank invariant's `* 1000` multiply overflow `u64` -- panicking
380 // in debug builds and silently corrupting the invariant in release.
381
382 #[test]
383 fn test_stereo_completeness_negative_charge_no_panic() {
384 // Acetate: the [O-] atom must not trigger an overflow panic.
385 let acetate = parse("CC(=O)[O-]").unwrap();
386 let sc = stereo_completeness(&acetate);
387 assert_eq!(sc.total_centers, 0);
388 }
389
390 #[test]
391 fn test_stereo_completeness_positive_charge_no_panic() {
392 // Small positive i8 charge never overflowed, but keep it as a
393 // regression fixture per the issue's exact repro.
394 let cation = parse("C[NH3+]").unwrap();
395 let sc = stereo_completeness(&cation);
396 assert_eq!(sc.total_centers, 0);
397 }
398
399 #[test]
400 fn test_stereo_completeness_mixed_salt_no_panic() {
401 // Disconnected salt combining both a negative and a positive charge.
402 let salt = parse("CC(=O)[O-].C[NH3+]").unwrap();
403 let sc = stereo_completeness(&salt);
404 assert_eq!(sc.total_centers, 0);
405 }
406
407 #[test]
408 fn test_stereo_completeness_doubly_negative_charge_no_panic() {
409 // A doubly-deprotonated phosphate: charge -2, more extreme than the
410 // issue's -1 repro, to make sure the fix isn't a -1-only special case.
411 let phosphate = parse("[O-]P(=O)([O-])OC").unwrap();
412 let sc = stereo_completeness(&phosphate);
413 assert_eq!(sc.total_centers, 0);
414 // Also exercise the other public entry point sharing the same helper.
415 let errors = validate_stereo(&phosphate);
416 assert!(errors.is_empty());
417 }
418
419 // Regression tests for the implicit-H rank-0 sentinel collision (issue
420 // #267 follow-up, distinct from the overflow bug above): `simple_morgan_ranks`
421 // normalises ranks to consecutive ordinals starting at 0, so an ordinary
422 // heavy-atom neighbour can legitimately carry rank 0. Using the literal
423 // `0` as the implicit-H stand-in collided with such a neighbour, `dedup()`
424 // merged them, and a genuine 4-distinct-group stereocenter was silently
425 // dropped (`specified` undercounted).
426
427 #[test]
428 fn test_stereo_completeness_rank_zero_collision_chain() {
429 // Atom indices: 0=C(methyl) 1=C@@H(chiral) 2=Cl 3=C(quaternary) 4=Br
430 // 5=F 6=I. Verified via `simple_morgan_ranks`: ranks = [0, 6, 2, 5, 4,
431 // 1, 3] -- the chiral atom's methyl neighbour (atom 0) is the
432 // lowest-invariant atom in the whole molecule and normalises to rank
433 // 0, colliding with the old implicit-H sentinel. Before the fix this
434 // atom was dropped entirely (specified=0, unspecified=1, total=1,
435 // counting only the separate, unrelated unspecified center at the
436 // quaternary carbon 3, whose 4 heavy neighbours -- 1, Br, F, I -- are
437 // trivially distinct and is not itself affected by this bug); after
438 // the fix both stereocenters are counted.
439 let mol = parse("C[C@@H](Cl)C(Br)(F)I").unwrap();
440 let sc = stereo_completeness(&mol);
441 assert_eq!(
442 sc.specified, 1,
443 "annotated chiral carbon must not be dropped by the rank-0 collision: {sc:?}"
444 );
445 assert_eq!(
446 sc.total_centers, 2,
447 "expected 2 stereocenters total (1 fixed + 1 pre-existing, bug-unrelated \
448 unspecified center at the quaternary carbon): {sc:?}"
449 );
450 }
451
452 #[test]
453 fn test_stereo_completeness_rank_zero_collision_different_neighbor() {
454 // Same collision class as the chain test above, but with a
455 // structurally different colliding neighbour to confirm the fix
456 // isn't specific to "a bare terminal methyl happens to be rank 0".
457 // Atom indices: 0=O 1=C(CH2, bonded to O) 2=C@@H(chiral) 3=N
458 // 4=C(quaternary) 5=Br 6=F 7=I. Verified via `simple_morgan_ranks`:
459 // ranks = [1, 0, 5, 3, 7, 6, 2, 4] -- here it's the chiral atom's
460 // *substituted* CH2-OH neighbour (atom 1, not a plain methyl) that
461 // lands on rank 0 and collides with the implicit-H sentinel.
462 let mol = parse("OC[C@@H](N)C(Br)(F)I").unwrap();
463 let sc = stereo_completeness(&mol);
464 assert_eq!(
465 sc.specified, 1,
466 "annotated chiral carbon must not be dropped by the rank-0 collision: {sc:?}"
467 );
468 assert_eq!(
469 sc.total_centers, 2,
470 "expected 2 stereocenters total (1 fixed + 1 pre-existing, bug-unrelated \
471 unspecified center at the quaternary carbon): {sc:?}"
472 );
473 }
474
475 #[test]
476 fn test_stereo_centers_mixed_specified_and_unspecified() {
477 // Atom 0 ([C@]) carries an explicit chirality annotation and has 4
478 // distinct fully-explicit heavy neighbours (F, Cl, Br, atom 4) ->
479 // specified stereocenter.
480 // Atom 4 (C(I)(N)O) has no annotation but also has 4 distinct heavy
481 // neighbours (atom 0, I, N, O) -> unspecified candidate.
482 let mol = parse("[C@](F)(Cl)(Br)C(I)(N)O").unwrap();
483 let centers = stereo_centers(&mol);
484
485 assert_eq!(
486 centers.len(),
487 2,
488 "expected exactly 2 stereocenter candidates: {centers:?}"
489 );
490 assert!(
491 centers.contains(&(AtomIdx(0), true)),
492 "atom 0 should be a specified stereocenter: {centers:?}"
493 );
494 assert!(
495 centers.contains(&(AtomIdx(4), false)),
496 "atom 4 should be an unspecified stereocenter candidate: {centers:?}"
497 );
498
499 // stereo_completeness must agree exactly (single source of truth).
500 let sc = stereo_completeness(&mol);
501 assert_eq!(sc.specified, 1);
502 assert_eq!(sc.unspecified, 1);
503 assert_eq!(sc.total_centers, 2);
504 }
505
506 // Regression tests, on `stereo_centers` itself, for the two bugs fixed
507 // upstream of this PR's rebase onto `main` (issue #267 overflow fix,
508 // commit a99fc9b; implicit-H rank-0 sentinel collision fix, commit
509 // 5790bb0). Both were previously exercised only via `stereo_completeness`
510 // aggregate counts; these confirm the new `stereo_centers` API itself is
511 // correct now that it's the single source of truth both bugs lived in.
512
513 #[test]
514 fn test_stereo_centers_negative_formal_charge_no_panic() {
515 // Issue #267's exact repro: acetate's [O-] atom must not overflow
516 // u64 in simple_morgan_ranks (it used to sign-extend and panic in
517 // debug builds). No stereocenters expected -- just confirm
518 // stereo_centers runs to completion with a sensible, empty result.
519 let acetate = parse("CC(=O)[O-]").unwrap();
520 let centers = stereo_centers(&acetate);
521 assert!(
522 centers.is_empty(),
523 "acetate has no stereocenters: {centers:?}"
524 );
525 }
526
527 #[test]
528 fn test_stereo_centers_rank_zero_sentinel_collision_fixed() {
529 // This PR's own body cited this exact repro as a known, pre-existing
530 // limitation: atom 1 is a real, `@@`-annotated stereocenter that
531 // `stereo_centers` used to silently drop because its methyl
532 // neighbour (atom 0) normalises to Morgan rank 0, the same sentinel
533 // value `stereo_centers` used to stand in for the implicit H. Fixed
534 // upstream in commit 5790bb0; confirm atom 1 is now correctly
535 // reported as (AtomIdx(1), true) directly from stereo_centers.
536 let mol = parse("C[C@@H](Cl)C(Br)(F)I").unwrap();
537 let centers = stereo_centers(&mol);
538 assert!(
539 centers.contains(&(AtomIdx(1), true)),
540 "atom 1 must be reported as a specified stereocenter: {centers:?}"
541 );
542 }
543}