use chematic_core::{AtomIdx, BondOrder, Chirality, Molecule};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StereoErrorKind {
ImpossibleCenter,
ConflictingWedges,
RedundantStereo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StereoError {
pub atom_idx: usize,
pub kind: StereoErrorKind,
}
impl fmt::Display for StereoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind_str = match &self.kind {
StereoErrorKind::ImpossibleCenter => {
"impossible stereocenter (< 4 distinct neighbours)"
}
StereoErrorKind::ConflictingWedges => "conflicting wedge directions",
StereoErrorKind::RedundantStereo => "redundant stereo on symmetric atom",
};
write!(f, "atom {}: {}", self.atom_idx, kind_str)
}
}
impl std::error::Error for StereoError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StereoCompleteness {
pub specified: usize,
pub unspecified: usize,
pub total_centers: usize,
}
fn simple_morgan_ranks(mol: &Molecule) -> Vec<u64> {
let n = mol.atom_count();
let mut ranks: Vec<u64> = (0..n)
.map(|i| {
let idx = AtomIdx(i as u32);
let atom = mol.atom(idx);
let deg = mol.neighbors(idx).count() as i64;
let an = atom.element.atomic_number() as i64;
let charge = atom.charge as i64;
(an * 1_000_000 + charge * 1000 + deg) as u64
})
.collect();
let hash_round = |r: u64, nbrs: &[u64]| -> u64 {
let mut h: u64 = 14695981039346656037u64;
let prime: u64 = 1099511628211u64;
h ^= r;
h = h.wrapping_mul(prime);
for &nb in nbrs {
h ^= nb;
h = h.wrapping_mul(prime);
}
h
};
for _ in 0..(n + 2) {
let old_distinct = {
let mut v = ranks.clone();
v.sort_unstable();
v.dedup();
v.len()
};
let new_ranks: Vec<u64> = (0..n)
.map(|i| {
let idx = AtomIdx(i as u32);
let mut nb_ranks: Vec<u64> = mol
.neighbors(idx)
.map(|(nb, _)| ranks[nb.0 as usize])
.collect();
nb_ranks.sort_unstable();
hash_round(ranks[i], &nb_ranks)
})
.collect();
let new_distinct = {
let mut v = new_ranks.clone();
v.sort_unstable();
v.dedup();
v.len()
};
ranks = new_ranks;
if new_distinct <= old_distinct {
break;
}
}
let mut sorted = ranks.clone();
sorted.sort_unstable();
sorted.dedup();
ranks
.iter()
.map(|r| sorted.partition_point(|&u| u < *r) as u64)
.collect()
}
pub fn validate_stereo(mol: &Molecule) -> Vec<StereoError> {
let ranks = simple_morgan_ranks(mol);
let mut errors = Vec::new();
for (idx, atom) in mol.atoms() {
let i = idx.0 as usize;
if atom.chirality == Chirality::None {
continue;
}
let heavy_neighbors: Vec<AtomIdx> = mol
.neighbors(idx)
.filter(|(nb, _)| mol.atom(*nb).element.atomic_number() != 1)
.map(|(nb, _)| nb)
.collect();
let implicit_h = chematic_core::implicit_hcount(mol, idx);
let total_groups = heavy_neighbors.len() + implicit_h as usize;
if total_groups < 4 {
errors.push(StereoError {
atom_idx: i,
kind: StereoErrorKind::ImpossibleCenter,
});
continue; }
let mut has_up = false;
let mut has_down = false;
for (_, bid) in mol.neighbors(idx) {
let bond = mol.bond(bid);
if bond.atom1 == idx {
match bond.order {
BondOrder::Up => has_up = true,
BondOrder::Down => has_down = true,
_ => {}
}
}
}
if has_up && has_down {
errors.push(StereoError {
atom_idx: i,
kind: StereoErrorKind::ConflictingWedges,
});
}
if !heavy_neighbors.is_empty() {
let first_rank = ranks[heavy_neighbors[0].0 as usize];
let all_same = heavy_neighbors
.iter()
.all(|nb| ranks[nb.0 as usize] == first_rank);
if all_same && implicit_h == 0 {
errors.push(StereoError {
atom_idx: i,
kind: StereoErrorKind::RedundantStereo,
});
}
}
}
errors
}
pub fn stereo_centers(mol: &Molecule) -> Vec<(AtomIdx, bool)> {
let ranks = simple_morgan_ranks(mol);
let mut centers = Vec::new();
for (idx, atom) in mol.atoms() {
if atom.aromatic {
continue;
}
let heavy_nbs: Vec<AtomIdx> = mol
.neighbors(idx)
.filter(|(nb, _)| mol.atom(*nb).element.atomic_number() != 1)
.map(|(nb, _)| nb)
.collect();
let implicit_h = chematic_core::implicit_hcount(mol, idx) as usize;
let groups = heavy_nbs.len() + implicit_h;
if groups != 4 {
continue;
}
let implicit_h_rank_sentinel = mol.atom_count() as u64;
let mut nb_ranks: Vec<u64> = heavy_nbs.iter().map(|nb| ranks[nb.0 as usize]).collect();
if implicit_h > 0 {
nb_ranks.push(implicit_h_rank_sentinel);
}
let mut sorted = nb_ranks.clone();
sorted.sort_unstable();
sorted.dedup();
if sorted.len() < 4 {
continue;
}
centers.push((idx, atom.chirality != Chirality::None));
}
centers
}
pub fn stereo_completeness(mol: &Molecule) -> StereoCompleteness {
let centers = stereo_centers(mol);
let specified = centers
.iter()
.filter(|(_, is_specified)| *is_specified)
.count();
let unspecified = centers.len() - specified;
StereoCompleteness {
specified,
unspecified,
total_centers: centers.len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn test_valid_chiral_center_no_errors() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let errors = validate_stereo(&mol);
assert!(
errors.is_empty(),
"L-alanine should have no stereo errors: {errors:?}"
);
}
#[test]
fn test_impossible_center_explicit_h_zero() {
use chematic_core::{Atom, BondOrder, Chirality, Element, MoleculeBuilder};
let mut b = MoleculeBuilder::new();
let mut c = Atom::new(Element::C);
c.chirality = Chirality::CounterClockwise;
c.hydrogen_count = Some(0); let ci = b.add_atom(c);
let cl = b.add_atom(Atom::new(Element::CL));
b.add_bond(ci, cl, BondOrder::Single).unwrap();
let mol = b.build();
let errors = validate_stereo(&mol);
assert!(
errors
.iter()
.any(|e| e.atom_idx == 0 && e.kind == StereoErrorKind::ImpossibleCenter),
"should detect ImpossibleCenter (1 group total): {errors:?}"
);
}
#[test]
fn test_stereo_completeness_alanine() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let sc = stereo_completeness(&mol);
assert_eq!(sc.specified, 1);
assert_eq!(sc.unspecified, 0);
assert_eq!(sc.total_centers, 1);
}
#[test]
fn test_stereo_completeness_unspecified() {
let mol = parse("NC(C)C(=O)O").unwrap();
let sc = stereo_completeness(&mol);
assert_eq!(sc.specified, 0);
assert!(sc.unspecified >= 1, "should detect unspecified center");
}
#[test]
fn test_no_centers_in_benzene() {
let mol = parse("c1ccccc1").unwrap();
let sc = stereo_completeness(&mol);
assert_eq!(sc.total_centers, 0);
}
#[test]
fn test_stereo_completeness_negative_charge_no_panic() {
let acetate = parse("CC(=O)[O-]").unwrap();
let sc = stereo_completeness(&acetate);
assert_eq!(sc.total_centers, 0);
}
#[test]
fn test_stereo_completeness_positive_charge_no_panic() {
let cation = parse("C[NH3+]").unwrap();
let sc = stereo_completeness(&cation);
assert_eq!(sc.total_centers, 0);
}
#[test]
fn test_stereo_completeness_mixed_salt_no_panic() {
let salt = parse("CC(=O)[O-].C[NH3+]").unwrap();
let sc = stereo_completeness(&salt);
assert_eq!(sc.total_centers, 0);
}
#[test]
fn test_stereo_completeness_doubly_negative_charge_no_panic() {
let phosphate = parse("[O-]P(=O)([O-])OC").unwrap();
let sc = stereo_completeness(&phosphate);
assert_eq!(sc.total_centers, 0);
let errors = validate_stereo(&phosphate);
assert!(errors.is_empty());
}
#[test]
fn test_stereo_completeness_rank_zero_collision_chain() {
let mol = parse("C[C@@H](Cl)C(Br)(F)I").unwrap();
let sc = stereo_completeness(&mol);
assert_eq!(
sc.specified, 1,
"annotated chiral carbon must not be dropped by the rank-0 collision: {sc:?}"
);
assert_eq!(
sc.total_centers, 2,
"expected 2 stereocenters total (1 fixed + 1 pre-existing, bug-unrelated \
unspecified center at the quaternary carbon): {sc:?}"
);
}
#[test]
fn test_stereo_completeness_rank_zero_collision_different_neighbor() {
let mol = parse("OC[C@@H](N)C(Br)(F)I").unwrap();
let sc = stereo_completeness(&mol);
assert_eq!(
sc.specified, 1,
"annotated chiral carbon must not be dropped by the rank-0 collision: {sc:?}"
);
assert_eq!(
sc.total_centers, 2,
"expected 2 stereocenters total (1 fixed + 1 pre-existing, bug-unrelated \
unspecified center at the quaternary carbon): {sc:?}"
);
}
#[test]
fn test_stereo_centers_mixed_specified_and_unspecified() {
let mol = parse("[C@](F)(Cl)(Br)C(I)(N)O").unwrap();
let centers = stereo_centers(&mol);
assert_eq!(
centers.len(),
2,
"expected exactly 2 stereocenter candidates: {centers:?}"
);
assert!(
centers.contains(&(AtomIdx(0), true)),
"atom 0 should be a specified stereocenter: {centers:?}"
);
assert!(
centers.contains(&(AtomIdx(4), false)),
"atom 4 should be an unspecified stereocenter candidate: {centers:?}"
);
let sc = stereo_completeness(&mol);
assert_eq!(sc.specified, 1);
assert_eq!(sc.unspecified, 1);
assert_eq!(sc.total_centers, 2);
}
#[test]
fn test_stereo_centers_negative_formal_charge_no_panic() {
let acetate = parse("CC(=O)[O-]").unwrap();
let centers = stereo_centers(&acetate);
assert!(
centers.is_empty(),
"acetate has no stereocenters: {centers:?}"
);
}
#[test]
fn test_stereo_centers_rank_zero_sentinel_collision_fixed() {
let mol = parse("C[C@@H](Cl)C(Br)(F)I").unwrap();
let centers = stereo_centers(&mol);
assert!(
centers.contains(&(AtomIdx(1), true)),
"atom 1 must be reported as a specified stereocenter: {centers:?}"
);
}
}