use std::collections::{HashMap, HashSet, VecDeque};
use chematic_core::{AtomIdx, BondIdx, BondOrder, CipCode, Chirality, Molecule};
#[derive(Debug)]
pub struct CipAssignment {
pub assignments: Vec<(AtomIdx, CipCode)>,
}
impl CipAssignment {
pub fn get(&self, idx: AtomIdx) -> Option<CipCode> {
self.assignments
.iter()
.find(|(i, _)| *i == idx)
.map(|(_, c)| *c)
}
}
pub fn assign_cip(mol: &Molecule) -> CipAssignment {
let mut assignments = Vec::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
if let Some(code) = assign_tetrahedral(mol, idx) {
assignments.push((idx, code));
}
}
for j in 0..mol.bond_count() {
let bidx = BondIdx(j as u32);
if let Some((atom_idx, code)) = assign_ez(mol, bidx) {
assignments.push((atom_idx, code));
}
}
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
if is_allene_central(mol, idx) {
if let Some((atom_idx, code)) = assign_allene(mol, idx) {
assignments.push((atom_idx, code));
}
}
}
CipAssignment { assignments }
}
type SphereLayer = Vec<(u8, Option<u16>, f64)>;
fn atom_key(mol: &Molecule, idx: AtomIdx) -> (u8, Option<u16>, f64) {
if idx.0 == u32::MAX {
return (1, None, 1.007825);
}
let a = mol.atom(idx);
(a.element.atomic_number(), a.isotope, a.element.atomic_mass())
}
fn cmp_key(a: (u8, Option<u16>, f64), b: (u8, Option<u16>, f64)) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
match a.0.cmp(&b.0) {
Equal => {}
other => return other,
}
match (a.1, b.1) {
(Some(x), Some(y)) => match x.cmp(&y) {
Equal => {}
other => return other,
},
(Some(_), None) => return Greater,
(None, Some(_)) => return Less,
(None, None) => {}
}
b.2.partial_cmp(&a.2).unwrap_or(Equal)
}
struct ExpandState {
node: AtomIdx,
parent: AtomIdx,
depth: usize,
visited: HashSet<AtomIdx>,
}
fn cip_branch_spheres(mol: &Molecule, center: AtomIdx, start: AtomIdx) -> Vec<SphereLayer> {
let mut layers: HashMap<usize, Vec<(u8, Option<u16>, f64)>> = HashMap::new();
let max_depth = 8usize;
let start_key = atom_key(mol, start);
layers.entry(1).or_default().push(start_key);
let mut expand_queue: VecDeque<ExpandState> = VecDeque::new();
{
let mut v = HashSet::new();
v.insert(center);
v.insert(start);
expand_queue.push_back(ExpandState {
node: start,
parent: center,
depth: 1,
visited: v,
});
}
while let Some(state) = expand_queue.pop_front() {
if state.depth >= max_depth {
continue;
}
let child_depth = state.depth + 1;
if let Some((_, bond_to_parent)) = mol.bond_between(state.node, state.parent) {
if bond_to_parent.order == BondOrder::Double {
let phantom_key = atom_key(mol, state.parent);
layers.entry(child_depth).or_default().push(phantom_key);
}
}
for (nb, _) in mol.neighbors(state.node) {
if nb == state.parent || nb == center {
continue;
}
let child_key = atom_key(mol, nb);
let layer = layers.entry(child_depth).or_default();
if state.visited.contains(&nb) {
layer.push(child_key);
} else {
layer.push(child_key);
let mut child_visited = state.visited.clone();
child_visited.insert(nb);
expand_queue.push_back(ExpandState {
node: nb,
parent: state.node,
depth: child_depth,
visited: child_visited,
});
}
}
}
let max_layer = layers.keys().copied().max().unwrap_or(0);
let mut result = Vec::new();
for d in 1..=max_layer {
let mut layer = layers.remove(&d).unwrap_or_default();
layer.sort_by(|a, b| cmp_key(*b, *a)); result.push(layer);
}
result
}
fn compare_branches(
mol: &Molecule,
center: AtomIdx,
a: AtomIdx,
b: AtomIdx,
) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
let a_key = atom_key(mol, a);
let b_key = atom_key(mol, b);
match cmp_key(a_key, b_key) {
Equal => {}
other => return other,
}
let a_spheres = cip_branch_spheres(mol, center, a);
let b_spheres = cip_branch_spheres(mol, center, b);
let max_depth = a_spheres.len().max(b_spheres.len());
for d in 0..max_depth {
let a_layer = a_spheres.get(d).map(|v| v.as_slice()).unwrap_or(&[]);
let b_layer = b_spheres.get(d).map(|v| v.as_slice()).unwrap_or(&[]);
let min_len = a_layer.len().min(b_layer.len());
for i in 0..min_len {
match cmp_key(a_layer[i], b_layer[i]) {
Equal => {}
other => return other,
}
}
match a_layer.len().cmp(&b_layer.len()) {
Equal => {}
other => return other,
}
}
Equal
}
fn rank_substituents(mol: &Molecule, center: AtomIdx, subs: &[AtomIdx]) -> Option<Vec<u8>> {
let n = subs.len();
if n == 0 {
return Some(vec![]);
}
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&i, &j| compare_branches(mol, center, subs[i], subs[j]).reverse());
for k in 0..n - 1 {
let i = indices[k];
let j = indices[k + 1];
if compare_branches(mol, center, subs[i], subs[j]) == std::cmp::Ordering::Equal {
return None;
}
}
let mut ranks = vec![0u8; n];
for (rank_from_top, &idx) in indices.iter().enumerate() {
ranks[idx] = (n - rank_from_top) as u8;
}
Some(ranks)
}
fn assign_tetrahedral(mol: &Molecule, idx: AtomIdx) -> Option<CipCode> {
let atom = mol.atom(idx);
if atom.chirality == Chirality::None {
return None;
}
let mut neighbors: Vec<AtomIdx> = mol.neighbors(idx).map(|(nb, _)| nb).collect();
let has_bracket_h = atom.hydrogen_count.map_or(false, |h| h > 0);
if has_bracket_h {
let has_preceding = neighbors
.first()
.map(|&nb| nb.0 < idx.0)
.unwrap_or(false);
let h_insert_pos = if has_preceding { 1 } else { 0 };
neighbors.insert(h_insert_pos, AtomIdx(u32::MAX));
}
if neighbors.len() != 4 {
return None;
}
let ranks = rank_substituents(mol, idx, &neighbors)?;
let lowest_pos = ranks.iter().position(|&r| r == 1)?;
let parity_odd = lowest_pos % 2 == 1;
let smiles_cw = atom.chirality == Chirality::Clockwise;
let cw_from_lowest = smiles_cw ^ parity_odd;
let remaining_ranks: Vec<u8> = (0..4usize)
.filter(|&i| i != lowest_pos)
.map(|i| ranks[i])
.collect();
let remaining_swaps_odd = {
let mut r = remaining_ranks.clone();
let target = [2u8, 3, 4];
let mut swaps = 0usize;
for i in 0..3 {
if r[i] != target[i] {
if let Some(j_rel) = r[i + 1..].iter().position(|&x| x == target[i]) {
r.swap(i, j_rel + i + 1);
swaps += 1;
} else {
return None; }
}
}
swaps % 2 == 1
};
let is_r = cw_from_lowest ^ remaining_swaps_odd;
Some(if is_r { CipCode::R } else { CipCode::S })
}
fn substituent_is_up(mol: &Molecule, alkene_end: AtomIdx, sub: AtomIdx) -> Option<bool> {
let (_, bond) = mol.bond_between(alkene_end, sub)?;
match bond.order {
BondOrder::Up => {
Some(bond.atom1 == alkene_end)
}
BondOrder::Down => {
Some(bond.atom1 == sub)
}
_ => None,
}
}
fn is_allene_central(mol: &Molecule, idx: AtomIdx) -> bool {
let dbl_count = mol
.neighbors(idx)
.filter(|(_, bidx)| mol.bond(*bidx).order == BondOrder::Double)
.count();
dbl_count == 2 && mol.neighbors(idx).count() == 2
}
fn assign_allene(mol: &Molecule, central_idx: AtomIdx) -> Option<(AtomIdx, CipCode)> {
let terminals: Vec<AtomIdx> = mol
.neighbors(central_idx)
.filter(|(_, bidx)| mol.bond(*bidx).order == BondOrder::Double)
.map(|(nb, _)| nb)
.collect();
if terminals.len() != 2 {
return None;
}
let t1 = terminals[0];
let t2 = terminals[1];
let subs_t1: Vec<AtomIdx> = mol
.neighbors(t1)
.filter(|&(nb, bidx)| nb != central_idx && mol.bond(bidx).order != BondOrder::Double)
.map(|(nb, _)| nb)
.collect();
let subs_t2: Vec<AtomIdx> = mol
.neighbors(t2)
.filter(|&(nb, bidx)| nb != central_idx && mol.bond(bidx).order != BondOrder::Double)
.map(|(nb, _)| nb)
.collect();
if subs_t1.is_empty() || subs_t2.is_empty() {
return None;
}
let high_t1 = highest_stereo_sub(mol, t1, &subs_t1)?;
let high_t2 = highest_stereo_sub(mol, t2, &subs_t2)?;
let up_t1 = substituent_is_up(mol, t1, high_t1)?;
let up_t2 = substituent_is_up(mol, t2, high_t2)?;
let code = if up_t1 == up_t2 { CipCode::Z } else { CipCode::E };
Some((t1, code))
}
fn assign_ez(mol: &Molecule, bond_idx: BondIdx) -> Option<(AtomIdx, CipCode)> {
let bond = mol.bond(bond_idx);
if bond.order != BondOrder::Double {
return None;
}
let a1 = bond.atom1;
let a2 = bond.atom2;
let subs_a1: Vec<AtomIdx> = mol
.neighbors(a1)
.filter(|&(nb, bidx)| nb != a2 && mol.bond(bidx).order != BondOrder::Double)
.map(|(nb, _)| nb)
.collect();
let subs_a2: Vec<AtomIdx> = mol
.neighbors(a2)
.filter(|&(nb, bidx)| nb != a1 && mol.bond(bidx).order != BondOrder::Double)
.map(|(nb, _)| nb)
.collect();
if subs_a1.is_empty() || subs_a2.is_empty() {
return None; }
let high_sub_a1 = highest_stereo_sub(mol, a1, &subs_a1)?;
let high_sub_a2 = highest_stereo_sub(mol, a2, &subs_a2)?;
let up_a1 = substituent_is_up(mol, a1, high_sub_a1)?;
let up_a2 = substituent_is_up(mol, a2, high_sub_a2)?;
let code = if up_a1 == up_a2 { CipCode::Z } else { CipCode::E };
Some((a1, code))
}
fn highest_stereo_sub(mol: &Molecule, alkene_end: AtomIdx, subs: &[AtomIdx]) -> Option<AtomIdx> {
let mut sorted: Vec<AtomIdx> = subs.to_vec();
sorted.sort_by(|&a, &b| compare_branches(mol, alkene_end, a, b).reverse());
sorted
.into_iter()
.find(|&sub| substituent_is_up(mol, alkene_end, sub).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn cip_at(smiles: &str, atom_idx: usize) -> Option<CipCode> {
let mol = parse(smiles).unwrap();
let assignment = assign_cip(&mol);
assignment.get(AtomIdx(atom_idx as u32))
}
#[test]
fn test_l_alanine_s() {
assert_eq!(
cip_at("N[C@@H](C)C(=O)O", 1),
Some(CipCode::S),
"L-alanine should be S"
);
}
#[test]
fn test_d_alanine_r() {
assert_eq!(
cip_at("N[C@H](C)C(=O)O", 1),
Some(CipCode::R),
"D-alanine should be R"
);
}
#[test]
fn test_chfclbr_r() {
assert_eq!(
cip_at("[C@@H](F)(Cl)Br", 0),
Some(CipCode::R),
"[C@@H](F)(Cl)Br should be R"
);
}
#[test]
fn test_chfclbr_s() {
assert_eq!(
cip_at("[C@H](F)(Cl)Br", 0),
Some(CipCode::S),
"[C@H](F)(Cl)Br should be S"
);
}
#[test]
fn test_no_chirality() {
let mol = parse("CC(=O)O").unwrap();
let assignment = assign_cip(&mol);
let tetrahedral: Vec<_> = assignment
.assignments
.iter()
.filter(|(_, c)| matches!(c, CipCode::R | CipCode::S))
.collect();
assert!(tetrahedral.is_empty(), "acetic acid has no chiral centers");
}
#[test]
fn test_symmetric_center_none() {
let mol = parse("CC(N)(N)CC").unwrap();
let assignment = assign_cip(&mol);
assert!(
assignment.assignments.is_empty(),
"no stereo annotation → no assignment"
);
}
#[test]
fn test_assignment_get() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let a = assign_cip(&mol);
assert!(a.get(AtomIdx(1)).is_some(), "atom 1 should have CIP code");
assert!(a.get(AtomIdx(0)).is_none(), "atom 0 (N) has no chirality");
}
#[test]
fn test_r_lactic_acid_gives_answer() {
let mol = parse("OC(=O)[C@@H](O)C").unwrap();
let assignment = assign_cip(&mol);
let chiral_idx = (0..mol.atom_count())
.map(|i| AtomIdx(i as u32))
.find(|&i| mol.atom(i).chirality != Chirality::None)
.unwrap();
let code = assignment.get(chiral_idx);
assert!(
code == Some(CipCode::R) || code == Some(CipCode::S),
"should give R or S for lactic acid, got {:?}",
code
);
}
#[test]
fn test_trans_2_butene_e() {
let mol = parse("C/C=C/C").unwrap();
let assignment = assign_cip(&mol);
let has_e = assignment.assignments.iter().any(|(_, c)| *c == CipCode::E);
assert!(
has_e,
"Expected E for trans-2-butene, got {:?}",
assignment.assignments
);
}
#[test]
fn test_cis_2_butene_z() {
let mol = parse("C/C=C\\C").unwrap();
let assignment = assign_cip(&mol);
let has_z = assignment.assignments.iter().any(|(_, c)| *c == CipCode::Z);
assert!(
has_z,
"Expected Z for cis-2-butene, got {:?}",
assignment.assignments
);
}
#[test]
fn test_fceccl_e() {
let mol = parse("F/C=C/Cl").unwrap();
let assignment = assign_cip(&mol);
let has_e = assignment.assignments.iter().any(|(_, c)| *c == CipCode::E);
assert!(
has_e,
"Expected E for F/C=C/Cl, got {:?}",
assignment.assignments
);
}
#[test]
fn test_fceccl_z() {
let mol = parse("F/C=C\\Cl").unwrap();
let assignment = assign_cip(&mol);
let has_z = assignment.assignments.iter().any(|(_, c)| *c == CipCode::Z);
assert!(
has_z,
"Expected Z for F/C=C\\Cl, got {:?}",
assignment.assignments
);
}
#[test]
fn test_no_ez_no_stereo_bond() {
let mol = parse("C=C").unwrap();
let assignment = assign_cip(&mol);
let has_ez = assignment
.assignments
.iter()
.any(|(_, c)| matches!(c, CipCode::E | CipCode::Z));
assert!(!has_ez, "plain C=C should have no E/Z");
}
#[test]
fn test_ez_terminal_no_crash() {
let mol = parse("/C=C/F").unwrap();
let _ = assign_cip(&mol);
}
#[test]
fn test_cip_assignment_methane() {
let mol = parse("C").unwrap();
let assignment = assign_cip(&mol);
assert!(assignment.assignments.is_empty());
}
#[test]
fn test_multiple_chiral_centers() {
let mol = parse("N[C@@H](C)[C@H](C)N").unwrap();
let assignment = assign_cip(&mol);
let rs_count = assignment
.assignments
.iter()
.filter(|(_, c)| matches!(c, CipCode::R | CipCode::S))
.count();
assert_eq!(rs_count, 2, "should assign 2 chiral centers");
}
#[test]
fn test_cip_assignment_struct() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let a = assign_cip(&mol);
assert!(!a.assignments.is_empty());
let code = a.get(AtomIdx(1));
assert_eq!(code, Some(CipCode::S));
}
#[test]
fn test_r_s_are_consistent() {
let r_code = cip_at("[C@@H](F)(Cl)Br", 0);
let s_code = cip_at("[C@H](F)(Cl)Br", 0);
assert_ne!(r_code, s_code, "@ and @@ must give opposite results");
}
#[test]
fn test_e_z_are_consistent() {
let mol_e = parse("C/C=C/C").unwrap();
let mol_z = parse("C/C=C\\C").unwrap();
let assign_e = assign_cip(&mol_e);
let assign_z = assign_cip(&mol_z);
let has_e = assign_e.assignments.iter().any(|(_, c)| *c == CipCode::E);
let has_z = assign_z.assignments.iter().any(|(_, c)| *c == CipCode::Z);
assert!(has_e && has_z, "trans must be E, cis must be Z");
}
#[test]
fn test_canonical_preserves_ez() {
use chematic_smiles::canonical_smiles;
let mol_e = parse("F/C=C/Cl").unwrap();
let can_e = canonical_smiles(&mol_e);
let mol_e2 = parse(&can_e).unwrap_or_else(|err| panic!("canonical E not parseable: {can_e} → {err}"));
let assign_e = assign_cip(&mol_e2);
assert!(
assign_e.assignments.iter().any(|(_, c)| *c == CipCode::E),
"canonical SMILES of E isomer must still be E, canonical='{can_e}'"
);
let mol_z = parse("F/C=C\\Cl").unwrap();
let can_z = canonical_smiles(&mol_z);
let mol_z2 = parse(&can_z).unwrap_or_else(|err| panic!("canonical Z not parseable: {can_z} → {err}"));
let assign_z = assign_cip(&mol_z2);
assert!(
assign_z.assignments.iter().any(|(_, c)| *c == CipCode::Z),
"canonical SMILES of Z isomer must still be Z, canonical='{can_z}'"
);
}
#[test]
fn test_allene_no_stereo_no_assignment() {
let mol = parse("C=C=C").unwrap();
let a = assign_cip(&mol);
let has_allene = a.assignments.iter().any(|(_, c)| matches!(c, CipCode::E | CipCode::Z));
assert!(!has_allene, "unspecified allene should have no axial chirality");
}
#[test]
fn test_allene_two_enantiomers_differ() {
use chematic_core::{Atom, BondOrder as BO, Element, MoleculeBuilder};
let mut b = MoleculeBuilder::new();
let f1 = b.add_atom(Atom::new(Element::F));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
let f5 = b.add_atom(Atom::new(Element::F));
b.add_bond(f1, c2, BO::Up).unwrap(); b.add_bond(c2, c3, BO::Double).unwrap();
b.add_bond(c3, c4, BO::Double).unwrap();
b.add_bond(c4, f5, BO::Down).unwrap(); let mol_a = b.build();
let mut b2 = MoleculeBuilder::new();
let f1b = b2.add_atom(Atom::new(Element::F));
let c2b = b2.add_atom(Atom::new(Element::C));
let c3b = b2.add_atom(Atom::new(Element::C));
let c4b = b2.add_atom(Atom::new(Element::C));
let f5b = b2.add_atom(Atom::new(Element::F));
b2.add_bond(f1b, c2b, BO::Up).unwrap();
b2.add_bond(c2b, c3b, BO::Double).unwrap();
b2.add_bond(c3b, c4b, BO::Double).unwrap();
b2.add_bond(c4b, f5b, BO::Up).unwrap(); let mol_b = b2.build();
let code_a = assign_cip(&mol_a).assignments.iter()
.find(|(_, c)| matches!(c, CipCode::E | CipCode::Z))
.map(|(_, c)| *c);
let code_b = assign_cip(&mol_b).assignments.iter()
.find(|(_, c)| matches!(c, CipCode::E | CipCode::Z))
.map(|(_, c)| *c);
assert!(code_a.is_some(), "allene A should get an axial chirality code");
assert!(code_b.is_some(), "allene B should get an axial chirality code");
assert_ne!(code_a, code_b, "the two allene enantiomers must get different codes");
}
#[test]
fn test_non_allene_not_detected() {
let mol = parse("O=C=O").unwrap();
let a = assign_cip(&mol);
let has_allene = a.assignments.iter().any(|(_, c)| matches!(c, CipCode::E | CipCode::Z));
assert!(!has_allene, "CO2 should not get axial chirality (no stereo bonds)");
}
#[test]
fn test_cip_enantiomers_consistent_with_mass_tiebreaker() {
let l_ala = parse("C[C@H](N)C(=O)O").unwrap();
let d_ala = parse("C[C@@H](N)C(=O)O").unwrap();
let l_assign = assign_cip(&l_ala);
let d_assign = assign_cip(&d_ala);
let l_code = l_assign.assignments.iter()
.find(|(_, c)| matches!(c, CipCode::R | CipCode::S))
.map(|(_, c)| *c);
let d_code = d_assign.assignments.iter()
.find(|(_, c)| matches!(c, CipCode::R | CipCode::S))
.map(|(_, c)| *c);
assert!(l_code.is_some(), "L-alanine should assign R or S");
assert!(d_code.is_some(), "D-alanine should assign R or S");
assert_ne!(l_code, d_code, "L and D enantiomers should have opposite R/S");
}
#[test]
fn test_cip_tied_substituents_no_assignment() {
let mol = parse("CC(F)Br").unwrap(); let assignment = assign_cip(&mol);
assert!(assignment.assignments.iter().all(|(_, c)| !matches!(c, CipCode::R | CipCode::S)),
"achiral molecule should not get R/S");
}
#[test]
fn test_cip_atomic_mass_tiebreaker_infrastructure() {
use chematic_core::Element;
assert!((Element::C.atomic_mass() - 12.0).abs() < 0.01, "C ~= 12.0 u, got {}", Element::C.atomic_mass());
assert!((Element::N.atomic_mass() - 14.0).abs() < 0.01, "N ~= 14.0 u, got {}", Element::N.atomic_mass());
assert!((Element::O.atomic_mass() - 16.0).abs() < 0.01, "O ~= 16.0 u, got {}", Element::O.atomic_mass());
assert!((Element::H.atomic_mass() - 1.0).abs() < 0.01, "H ~= 1.0 u, got {}", Element::H.atomic_mass());
assert!((Element::F.atomic_mass() - 19.0).abs() < 0.01, "F ~= 19.0 u, got {}", Element::F.atomic_mass());
assert_eq!(Element::C.atomic_mass(), Element::C.atomic_mass(), "same element = same mass");
assert!(Element::N.atomic_mass() > Element::C.atomic_mass(), "N > C in mass");
assert!(Element::O.atomic_mass() > Element::N.atomic_mass(), "O > N in mass");
}
}