Skip to main content

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}