use std::collections::{HashMap, HashSet, VecDeque};
use chematic_core::{AtomIdx, BondIdx, BondOrder, Chirality, CipCode, Molecule, implicit_hcount};
#[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 tetrahedral_stereo_neighbors(
mol: &Molecule,
center: AtomIdx,
) -> Option<(CipCode, [AtomIdx; 4])> {
let atom = mol.atom(center);
if atom.chirality == Chirality::None {
return None;
}
let neighbors = stereo_neighbors(mol, center);
if neighbors.len() != 4 {
return None;
}
let cip_code = assign_tetrahedral(mol, center)?;
let ranks = rank_substituents(mol, center, &neighbors)?;
let mut sorted: Vec<(u8, AtomIdx)> = ranks
.iter()
.zip(neighbors.iter())
.map(|(&r, &n)| (r, n))
.collect();
sorted.sort_by_key(|x| std::cmp::Reverse(x.0));
let arr = [sorted[0].1, sorted[1].1, sorted[2].1, sorted[3].1];
Some((cip_code, arr))
}
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)
&& let Some((atom_idx, code)) = assign_allene(mol, idx)
{
assignments.push((atom_idx, code));
}
}
CipAssignment { assignments }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CipMode {
LegacyFast,
Accurate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CipUnresolvedReason {
Tied,
BudgetExceeded,
}
#[derive(Debug, Clone, Default)]
pub struct CipModeAssignment {
pub assignments: Vec<(AtomIdx, CipCode)>,
pub unresolved: Vec<(AtomIdx, CipUnresolvedReason)>,
}
impl CipModeAssignment {
pub fn get(&self, idx: AtomIdx) -> Option<CipCode> {
self.assignments
.iter()
.find(|(i, _)| *i == idx)
.map(|(_, c)| *c)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CipModeError {
Accurate(chematic_cip::CipCompareError),
}
impl std::fmt::Display for CipModeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CipModeError::Accurate(e) => write!(f, "accurate CIP engine error: {e}"),
}
}
}
impl std::error::Error for CipModeError {}
pub fn assign_cip_with_mode(
mol: &Molecule,
mode: CipMode,
) -> Result<CipModeAssignment, CipModeError> {
let legacy = assign_cip(mol);
match mode {
CipMode::LegacyFast => Ok(CipModeAssignment {
assignments: legacy.assignments,
unresolved: Vec::new(),
}),
CipMode::Accurate => {
let budget = chematic_cip::CipBudget::default_budget();
let accurate = chematic_cip::assign_cip_accurate_experimental(mol, budget)
.map_err(CipModeError::Accurate)?;
let unresolved_idx: HashSet<AtomIdx> = accurate
.skipped
.iter()
.filter(|(_, r)| {
matches!(
r,
chematic_cip::SkipReason::Tied | chematic_cip::SkipReason::BudgetExceeded
)
})
.map(|(idx, _)| *idx)
.collect();
let accurate_idx: HashSet<AtomIdx> =
accurate.assignments.iter().map(|(idx, _)| *idx).collect();
let mut assignments: Vec<(AtomIdx, CipCode)> = legacy
.assignments
.into_iter()
.filter(|(idx, _)| !accurate_idx.contains(idx) && !unresolved_idx.contains(idx))
.collect();
assignments.extend(accurate.assignments);
let unresolved = accurate
.skipped
.into_iter()
.filter_map(|(idx, reason)| match reason {
chematic_cip::SkipReason::Tied => Some((idx, CipUnresolvedReason::Tied)),
chematic_cip::SkipReason::BudgetExceeded => {
Some((idx, CipUnresolvedReason::BudgetExceeded))
}
chematic_cip::SkipReason::NotFourSubstituents => None,
})
.collect();
Ok(CipModeAssignment {
assignments,
unresolved,
})
}
}
}
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)
&& 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();
let is_double = mol
.bond_between(state.node, nb)
.is_some_and(|(_, b)| b.order == BondOrder::Double);
if is_double {
layer.push(child_key);
}
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 stereo_token(code: Option<CipCode>) -> u8 {
match code {
Some(CipCode::R) => 2,
Some(CipCode::S) => 1,
_ => 0,
}
}
fn cip_branch_stereo_spheres(
mol: &Molecule,
center: AtomIdx,
start: AtomIdx,
provisional: &HashMap<AtomIdx, CipCode>,
) -> Vec<Vec<u8>> {
let mut layers: HashMap<usize, Vec<u8>> = HashMap::new();
let max_depth = 8usize;
layers
.entry(1)
.or_default()
.push(stereo_token(provisional.get(&start).copied()));
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)
&& bond_to_parent.order == BondOrder::Double
{
layers.entry(child_depth).or_default().push(0u8);
}
for (nb, _) in mol.neighbors(state.node) {
if nb == state.parent || nb == center {
continue;
}
let token = stereo_token(provisional.get(&nb).copied());
let layer = layers.entry(child_depth).or_default();
if state.visited.contains(&nb) {
layer.push(token);
} else {
layer.push(token);
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_unstable_by(|a, b| b.cmp(a)); result.push(layer);
}
result
}
pub(crate) fn is_potential_stereocenter_rule5(
mol: &Molecule,
idx: AtomIdx,
provisional: &HashMap<AtomIdx, CipCode>,
) -> bool {
let atom = mol.atom(idx);
if atom.aromatic {
return false;
}
match atom.element.atomic_number() {
6 | 7 | 15 | 16 | 34 => {}
_ => return false,
}
let mut neighbors: Vec<AtomIdx> = mol.neighbors(idx).map(|(nb, _)| nb).collect();
let h = implicit_hcount(mol, idx);
if h > 1 {
return false;
}
for _ in 0..h {
neighbors.push(AtomIdx(u32::MAX));
}
if neighbors.len() == 3 && h == 0 && matches!(atom.element.atomic_number(), 15 | 16 | 34) {
neighbors.push(AtomIdx(u32::MAX));
}
if neighbors.len() != 4 {
return false;
}
if let Some(ranks) = rank_substituents(mol, idx, &neighbors) {
let mut r = ranks;
r.sort_unstable();
return r.windows(2).all(|w| w[0] != w[1]);
}
if provisional.is_empty() {
return false;
}
let sigs: Vec<_> = neighbors
.iter()
.map(|&nb| {
let is_sentinel = nb.0 == u32::MAX;
(
atom_key(mol, nb),
if is_sentinel {
vec![]
} else {
cip_branch_spheres(mol, idx, nb)
},
if is_sentinel {
vec![]
} else {
cip_branch_stereo_spheres(mol, idx, nb, provisional)
},
)
})
.collect();
for i in 0..4 {
for j in (i + 1)..4 {
if sigs[i] == sigs[j] {
return false;
}
}
}
true
}
pub(crate) 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 stereo_neighbors(mol: &Molecule, idx: AtomIdx) -> Vec<AtomIdx> {
if let Some(order) = mol.stereo_neighbor_order(idx) {
return order.iter().map(|&n| AtomIdx(n)).collect();
}
let atom = mol.atom(idx);
let mut neighbors: Vec<AtomIdx> = mol.neighbors(idx).map(|(nb, _)| nb).collect();
let has_bracket_h = atom.hydrogen_count.is_some_and(|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));
}
neighbors
}
fn assign_tetrahedral(mol: &Molecule, idx: AtomIdx) -> Option<CipCode> {
let atom = mol.atom(idx);
if atom.chirality == Chirality::None {
return None;
}
let neighbors = stereo_neighbors(mol, idx);
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] {
let j_rel = r[i + 1..].iter().position(|&x| x == target[i])?;
r.swap(i, j_rel + i + 1);
swaps += 1;
}
}
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"
);
}
#[test]
fn test_cip_naphthalene_assignment() {
let mol = parse("c1ccc2ccccc2c1").expect("naphthalene");
let assignment = assign_cip(&mol);
assert!(
assignment.assignments.is_empty(),
"naphthalene should have no chiral assignments (planar symmetric structure)"
);
}
#[test]
fn test_cip_decalin_assignment() {
let mol = parse("CC(C)C1CCC2CCCCC2C1").expect("decalin");
let assignment = assign_cip(&mol);
assert!(
!mol.atom(chematic_core::AtomIdx(0)).element.atomic_number() != 6
|| assignment.assignments.len() <= mol.atom_count(),
"decalin CIP assignment should complete"
);
}
#[test]
fn test_cip_fused_ring_no_crash() {
let mol = parse("C1CCC2CCCCC2C1").expect("decalin");
let assignment = assign_cip(&mol);
assert!(assignment.assignments.len() <= mol.atom_count());
}
#[test]
fn test_tetrahedral_stable_when_ring_bond_opens_before_other_neighbors() {
let smi_a = "CN1CCC[C@@H]1c1cccnc1";
let smi_b = "c1ccncc1[C@@H]1N(CCC1)C"; assert_eq!(cip_at(smi_a, 5), Some(CipCode::R));
assert_eq!(cip_at(smi_b, 6), Some(CipCode::R));
}
#[test]
fn test_tetrahedral_double_bond_duplicates_into_own_sphere() {
assert_eq!(
cip_at("C=C(C)[C@@H]1CN[C@@H](C(=O)O)[C@@H]1CC(=O)O", 3),
Some(CipCode::R)
);
}
#[test]
fn cip_mode_legacy_fast_matches_assign_cip_byte_for_byte() {
let smis = [
"C[C@H](N)C(=O)O",
"C=C(C)[C@@H]1CN[C@@H](C(=O)O)[C@@H]1CC(=O)O",
"C1CCN(P2(N3CCCC3)=N[P@@](N3CCCC3)(N3CC3)=N[P@](N3CCCC3)(N3CC3)=N2)C1",
];
for smi in smis {
let mol = chematic_smiles::parse(smi).expect("valid SMILES");
let legacy = assign_cip(&mol);
let via_mode = assign_cip_with_mode(&mol, CipMode::LegacyFast).expect("infallible");
assert_eq!(legacy.assignments, via_mode.assignments, "smiles={smi}");
assert!(via_mode.unresolved.is_empty(), "smiles={smi}");
}
}
#[test]
fn cip_mode_accurate_surfaces_unresolved_not_a_guess_for_tied_phosphorus() {
let smi = "CNP1(NC)=N[P@](NC)(N2CC2)=NP(NC)(NC)=N[P@@](NC)(N2CC2)=N1";
let mol = chematic_smiles::parse(smi).expect("valid SMILES");
let result = assign_cip_with_mode(&mol, CipMode::Accurate).expect("no engine error");
for atom_idx in [6u32, 19u32] {
let idx = AtomIdx(atom_idx);
assert!(
result.get(idx).is_none(),
"atom={atom_idx} should not have a guessed label"
);
assert!(
result.unresolved.iter().any(|(i, _)| *i == idx),
"atom={atom_idx} should be in unresolved"
);
}
}
#[test]
fn cip_mode_accurate_does_not_hide_oracle_unstable_answers() {
let mol = chematic_smiles::parse("N[P@]1(Cl)=NP(N2CC2)(N2CC2)=N[P@](N)(Cl)=N1")
.expect("valid SMILES");
let result = assign_cip_with_mode(&mol, CipMode::Accurate).expect("no engine error");
assert_eq!(result.get(AtomIdx(12)), Some(CipCode::S));
}
#[test]
fn cip_mode_accurate_merges_legacy_ez_with_accurate_tetrahedral() {
let mol = chematic_smiles::parse("C/C=C/[C@H](N)C(=O)O").expect("valid SMILES");
let result = assign_cip_with_mode(&mol, CipMode::Accurate).expect("no engine error");
let has_ez = result
.assignments
.iter()
.any(|(_, c)| matches!(c, CipCode::E | CipCode::Z));
let has_rs = result
.assignments
.iter()
.any(|(_, c)| matches!(c, CipCode::R | CipCode::S));
assert!(
has_ez,
"expected an E/Z assignment: {:?}",
result.assignments
);
assert!(
has_rs,
"expected an R/S assignment: {:?}",
result.assignments
);
}
}