use crate::atom::SquarePlanarPermutation;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StereoGeometry {
Tetrahedral,
SquarePlanar,
}
impl StereoGeometry {
fn rotation_group(self) -> &'static [[u8; 4]] {
match self {
Self::Tetrahedral => &TETRAHEDRAL_ROTATIONS,
Self::SquarePlanar => &SQUARE_PLANAR_ROTATIONS,
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StereoConfiguration {
geometry: StereoGeometry,
slots: [u32; 4],
}
#[allow(dead_code)]
impl StereoConfiguration {
pub(crate) fn new(
geometry: StereoGeometry,
slots: [u32; 4],
) -> Result<Self, StereoGeometryError> {
if let Some(dup) = find_duplicate(slots) {
return Err(StereoGeometryError::DuplicateSlotId(dup));
}
Ok(Self { geometry, slots })
}
pub(crate) fn renumber(
&self,
id_map: impl Fn(u32) -> Option<u32>,
) -> Result<StereoConfiguration, StereoGeometryError> {
let mut new_slots = [0u32; 4];
for (i, slot) in self.slots.iter().enumerate() {
new_slots[i] = id_map(*slot).ok_or(StereoGeometryError::UnknownLigandId(*slot))?;
}
StereoConfiguration::new(self.geometry, new_slots)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct CanonicalStereoConfiguration {
geometry: StereoGeometry,
representative: [u32; 4],
}
impl CanonicalStereoConfiguration {
pub(crate) fn representative(&self) -> [u32; 4] {
self.representative
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StereoGeometryError {
DuplicateSlotId(u32),
UnknownLigandId(u32),
MismatchedLigandSet,
}
impl core::fmt::Display for StereoGeometryError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::DuplicateSlotId(id) => {
write!(f, "duplicate ligand id {id} appears in two stereo slots")
}
Self::UnknownLigandId(id) => {
write!(f, "no renumbering answer for ligand id {id}")
}
Self::MismatchedLigandSet => {
write!(
f,
"original and canonical orders do not name the same ligand ids"
)
}
}
}
}
impl std::error::Error for StereoGeometryError {}
fn apply(perm: &[u8; 4], arr: [u32; 4]) -> [u32; 4] {
[
arr[perm[0] as usize],
arr[perm[1] as usize],
arr[perm[2] as usize],
arr[perm[3] as usize],
]
}
const TETRAHEDRAL_ROTATIONS: [[u8; 4]; 12] = [
[0, 1, 2, 3], [1, 2, 0, 3],
[2, 0, 1, 3],
[1, 3, 2, 0],
[3, 0, 2, 1],
[2, 1, 3, 0],
[3, 1, 0, 2],
[0, 2, 3, 1],
[0, 3, 1, 2],
[1, 0, 3, 2],
[2, 3, 0, 1],
[3, 2, 1, 0],
];
const SQUARE_PLANAR_ROTATIONS: [[u8; 4]; 8] = [
[0, 1, 2, 3], [2, 1, 0, 3], [0, 3, 2, 1], [2, 3, 0, 1], [1, 0, 3, 2], [1, 2, 3, 0], [3, 0, 1, 2], [3, 2, 1, 0], ];
fn find_duplicate(slots: [u32; 4]) -> Option<u32> {
for i in 0..4 {
for j in (i + 1)..4 {
if slots[i] == slots[j] {
return Some(slots[i]);
}
}
}
None
}
pub(crate) fn canonicalize_configuration(
geometry: StereoGeometry,
ligand_order: [u32; 4],
) -> Result<CanonicalStereoConfiguration, StereoGeometryError> {
if let Some(dup) = find_duplicate(ligand_order) {
return Err(StereoGeometryError::DuplicateSlotId(dup));
}
let group = geometry.rotation_group();
let mut best = apply(&group[0], ligand_order);
for perm in &group[1..] {
let candidate = apply(perm, ligand_order);
if candidate < best {
best = candidate;
}
}
Ok(CanonicalStereoConfiguration {
geometry,
representative: best,
})
}
pub(crate) fn equivalent_under_rotation(
a: &CanonicalStereoConfiguration,
b: &CanonicalStereoConfiguration,
) -> bool {
a == b
}
pub fn remap_tetrahedral_parity(
original: [u32; 4],
canonical: [u32; 4],
) -> Result<bool, StereoGeometryError> {
if let Some(dup) = find_duplicate(original) {
return Err(StereoGeometryError::DuplicateSlotId(dup));
}
if let Some(dup) = find_duplicate(canonical) {
return Err(StereoGeometryError::DuplicateSlotId(dup));
}
let mut sorted_original = original;
sorted_original.sort_unstable();
let mut sorted_canonical = canonical;
sorted_canonical.sort_unstable();
if sorted_original != sorted_canonical {
return Err(StereoGeometryError::MismatchedLigandSet);
}
let orig = canonicalize_configuration(StereoGeometry::Tetrahedral, original)?;
let canon = canonicalize_configuration(StereoGeometry::Tetrahedral, canonical)?;
Ok(orig.representative() != canon.representative())
}
fn to_base_slots(tag: SquarePlanarPermutation, order: [u32; 4]) -> [u32; 4] {
let [(a, b), (c, d)] = tag.trans_pairs();
[
order[a as usize],
order[c as usize],
order[b as usize],
order[d as usize],
]
}
pub fn remap_square_planar_tag(
tag: SquarePlanarPermutation,
original: [u32; 4],
canonical: [u32; 4],
) -> Option<SquarePlanarPermutation> {
let canon_original =
canonicalize_configuration(StereoGeometry::SquarePlanar, to_base_slots(tag, original))
.ok()?;
[
SquarePlanarPermutation::SP1,
SquarePlanarPermutation::SP2,
SquarePlanarPermutation::SP3,
]
.into_iter()
.find(|&candidate| {
let slots_candidate = to_base_slots(candidate, canonical);
match canonicalize_configuration(StereoGeometry::SquarePlanar, slots_candidate) {
Ok(canon_candidate) => equivalent_under_rotation(&canon_original, &canon_candidate),
Err(_) => false,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn permutations_of_4_u8() -> Vec<[u8; 4]> {
let mut out = Vec::with_capacity(24);
for a in 0..4u8 {
for b in 0..4u8 {
if b == a {
continue;
}
for c in 0..4u8 {
if c == a || c == b {
continue;
}
for d in 0..4u8 {
if d == a || d == b || d == c {
continue;
}
out.push([a, b, c, d]);
}
}
}
}
out
}
fn permutations_of_4_u32() -> Vec<[u32; 4]> {
permutations_of_4_u8()
.into_iter()
.map(|p| [p[0] as u32, p[1] as u32, p[2] as u32, p[3] as u32])
.collect()
}
fn compose(g: &[u8; 4], h: &[u8; 4]) -> [u8; 4] {
[
h[g[0] as usize],
h[g[1] as usize],
h[g[2] as usize],
h[g[3] as usize],
]
}
const IDENTITY: [u8; 4] = [0, 1, 2, 3];
fn assert_is_group(table: &[[u8; 4]], expected_order: usize, name: &str) {
assert_eq!(table.len(), expected_order, "{name}: wrong group order");
for i in 0..table.len() {
for j in (i + 1)..table.len() {
assert_ne!(table[i], table[j], "{name}: duplicate row at {i},{j}");
}
}
for row in table {
let mut sorted = *row;
sorted.sort_unstable();
assert_eq!(
sorted,
[0, 1, 2, 3],
"{name}: row {row:?} not a permutation"
);
}
assert!(
table.contains(&IDENTITY),
"{name}: identity element missing"
);
for g in table {
for h in table {
let gh = compose(g, h);
assert!(
table.contains(&gh),
"{name}: not closed, compose({g:?},{h:?})={gh:?} not in table"
);
}
}
for g in table {
let has_inverse = table.iter().any(|h| compose(g, h) == IDENTITY);
assert!(has_inverse, "{name}: {g:?} has no inverse in table");
}
}
#[test]
fn tetrahedral_rotations_form_a_group_of_order_12() {
assert_is_group(&TETRAHEDRAL_ROTATIONS, 12, "TETRAHEDRAL_ROTATIONS");
}
#[test]
fn square_planar_rotations_form_a_group_of_order_8() {
assert_is_group(&SQUARE_PLANAR_ROTATIONS, 8, "SQUARE_PLANAR_ROTATIONS");
}
#[test]
fn tetrahedral_rotations_are_independently_confirmed_even_permutations() {
fn is_odd(p: [u8; 4]) -> bool {
let mut visited = [false; 4];
let mut num_cycles = 0usize;
for start in 0..4 {
if !visited[start] {
num_cycles += 1;
let mut j = start;
while !visited[j] {
visited[j] = true;
j = p[j] as usize;
}
}
}
(4 - num_cycles) % 2 == 1
}
let mut brute_force_even: Vec<[u8; 4]> = permutations_of_4_u8()
.into_iter()
.filter(|&p| !is_odd(p))
.collect();
brute_force_even.sort_unstable();
let mut table_sorted = TETRAHEDRAL_ROTATIONS.to_vec();
table_sorted.sort_unstable();
assert_eq!(
brute_force_even, table_sorted,
"TETRAHEDRAL_ROTATIONS must equal the brute-force even-permutation set"
);
}
#[test]
fn square_planar_rotations_stabilize_sp1_partition() {
let reference: [u32; 4] = [0, 1, 2, 3];
let sp1_base = to_base_slots(SquarePlanarPermutation::SP1, reference);
assert_eq!(
sp1_base, reference,
"SP1.trans_pairs() must already match the (0,2)/(1,3) base convention"
);
let partition_of = |arr: [u32; 4]| -> [[u32; 2]; 2] {
let mut p1 = [arr[0], arr[2]];
let mut p2 = [arr[1], arr[3]];
p1.sort_unstable();
p2.sort_unstable();
let mut both = [p1, p2];
both.sort_unstable();
both
};
let expected = partition_of(sp1_base);
for perm in &SQUARE_PLANAR_ROTATIONS {
let rotated = apply(perm, reference);
assert_eq!(
partition_of(rotated),
expected,
"rotation {perm:?} does not stabilize SP1's trans-pair partition"
);
}
}
#[test]
fn orbit_counts_are_2_for_tetrahedral_and_3_for_square_planar() {
for (geometry, expected_orbits) in [
(StereoGeometry::Tetrahedral, 2),
(StereoGeometry::SquarePlanar, 3),
] {
let mut representatives: Vec<[u32; 4]> = permutations_of_4_u32()
.into_iter()
.map(|order| {
canonicalize_configuration(geometry, order)
.expect("4 distinct ids never duplicate")
.representative()
})
.collect();
representatives.sort_unstable();
representatives.dedup();
assert_eq!(
representatives.len(),
expected_orbits,
"{geometry:?}: expected {expected_orbits} orbits, got {}: {representatives:?}",
representatives.len()
);
}
}
#[test]
fn canonicalization_is_idempotent() {
for geometry in [StereoGeometry::Tetrahedral, StereoGeometry::SquarePlanar] {
for order in permutations_of_4_u32() {
let once = canonicalize_configuration(geometry, order).unwrap();
let twice = canonicalize_configuration(geometry, once.representative()).unwrap();
assert_eq!(once, twice, "idempotence failed for {geometry:?} {order:?}");
}
}
}
#[test]
fn duplicate_slot_id_fails_closed() {
let err =
canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 1, 3]).unwrap_err();
assert_eq!(err, StereoGeometryError::DuplicateSlotId(1));
}
#[test]
fn equivalent_under_rotation_matches_equality_and_respects_geometry() {
let a = canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
let b = canonicalize_configuration(
StereoGeometry::Tetrahedral,
apply(&TETRAHEDRAL_ROTATIONS[3], [1, 2, 3, 4]),
)
.unwrap();
assert!(equivalent_under_rotation(&a, &b));
let odd = canonicalize_configuration(StereoGeometry::Tetrahedral, [2, 1, 3, 4]).unwrap();
assert!(!equivalent_under_rotation(&a, &odd));
let sp = canonicalize_configuration(StereoGeometry::SquarePlanar, [1, 2, 3, 4]).unwrap();
let te = canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
assert!(!equivalent_under_rotation(&sp, &te));
}
#[test]
fn remap_tetrahedral_parity_matches_hand_cases() {
assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 4]).unwrap());
assert!(remap_tetrahedral_parity([1, 2, 3, 4], [2, 1, 3, 4]).unwrap());
assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [2, 1, 4, 3]).unwrap());
assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [2, 3, 1, 4]).unwrap());
}
#[test]
fn remap_tetrahedral_parity_fails_closed_on_duplicate() {
assert_eq!(
remap_tetrahedral_parity([1, 1, 3, 4], [1, 2, 3, 4]).unwrap_err(),
StereoGeometryError::DuplicateSlotId(1)
);
}
#[test]
fn remap_tetrahedral_parity_fails_closed_on_mismatched_ligand_set() {
assert_eq!(
remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 5]).unwrap_err(),
StereoGeometryError::MismatchedLigandSet
);
assert!(remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 4]).is_ok());
assert!(remap_tetrahedral_parity([1, 2, 3, 4], [4, 3, 2, 1]).is_ok());
}
#[test]
fn remap_square_planar_tag_identity_is_a_no_op() {
for tag in [
SquarePlanarPermutation::SP1,
SquarePlanarPermutation::SP2,
SquarePlanarPermutation::SP3,
] {
assert_eq!(
remap_square_planar_tag(tag, [10, 20, 30, 40], [10, 20, 30, 40]),
Some(tag)
);
}
}
#[test]
fn duplicate_chemistry_distinct_ids_keeps_cisplatin_transplatin_shaped_tags_distinct() {
const CL1: u32 = 101;
const CL2: u32 = 102;
const N1: u32 = 103;
const N2: u32 = 104;
let order: [u32; 4] = [CL1, N1, CL2, N2];
let sp1 = canonicalize_configuration(
StereoGeometry::SquarePlanar,
to_base_slots(SquarePlanarPermutation::SP1, order),
)
.unwrap();
let sp2 = canonicalize_configuration(
StereoGeometry::SquarePlanar,
to_base_slots(SquarePlanarPermutation::SP2, order),
)
.unwrap();
let sp3 = canonicalize_configuration(
StereoGeometry::SquarePlanar,
to_base_slots(SquarePlanarPermutation::SP3, order),
)
.unwrap();
assert!(
!equivalent_under_rotation(&sp1, &sp2),
"SP1-shaped and SP2-shaped configurations must NOT collapse to one orbit even \
though both slot assignments repeat 2xCl+2xN chemistry"
);
assert!(!equivalent_under_rotation(&sp1, &sp3));
assert!(!equivalent_under_rotation(&sp2, &sp3));
assert_eq!(
remap_square_planar_tag(SquarePlanarPermutation::SP1, order, order),
Some(SquarePlanarPermutation::SP1)
);
assert_eq!(
remap_square_planar_tag(SquarePlanarPermutation::SP2, order, order),
Some(SquarePlanarPermutation::SP2)
);
}
#[test]
fn remap_square_planar_tag_full_24_by_3_table() {
let tags = [
SquarePlanarPermutation::SP1,
SquarePlanarPermutation::SP2,
SquarePlanarPermutation::SP3,
];
let mut checked = 0;
for order in permutations_of_4_u32() {
for &tag in &tags {
let predicted = tags
.into_iter()
.find(|&candidate| {
let a = to_base_slots(tag, order);
let b = to_base_slots(candidate, [0, 1, 2, 3]);
let ca =
canonicalize_configuration(StereoGeometry::SquarePlanar, a).unwrap();
let cb =
canonicalize_configuration(StereoGeometry::SquarePlanar, b).unwrap();
equivalent_under_rotation(&ca, &cb)
})
.expect("exactly one of the 3 tags must match (3 orbits, 3 tags)");
assert_eq!(
remap_square_planar_tag(tag, order, [0, 1, 2, 3]),
Some(predicted),
"order={order:?} tag={tag:?}"
);
checked += 1;
}
}
assert_eq!(checked, 24 * 3);
}
#[test]
fn remap_square_planar_tag_none_on_mismatched_id_set() {
assert_eq!(
remap_square_planar_tag(SquarePlanarPermutation::SP1, [1, 2, 3, 4], [1, 2, 3, 5]),
None
);
}
#[test]
fn remap_square_planar_tag_none_on_duplicate_original() {
assert_eq!(
remap_square_planar_tag(SquarePlanarPermutation::SP1, [1, 1, 3, 4], [1, 2, 3, 4]),
None
);
}
#[test]
fn stereo_configuration_new_rejects_duplicate_slot_id() {
assert_eq!(
StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 1, 3]).unwrap_err(),
StereoGeometryError::DuplicateSlotId(1)
);
}
#[test]
fn stereo_configuration_new_accepts_distinct_ids() {
assert!(StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).is_ok());
}
#[test]
fn renumber_remaps_every_slot() {
let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
let renumbered = cfg
.renumber(|id| Some(id * 10))
.expect("total map succeeds");
assert_eq!(renumbered.geometry, StereoGeometry::Tetrahedral);
assert_eq!(renumbered.slots, [10, 20, 30, 40]);
}
#[test]
fn renumber_fails_closed_on_unmapped_id() {
let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
let err = cfg
.renumber(|id| if id == 3 { None } else { Some(id) })
.unwrap_err();
assert_eq!(err, StereoGeometryError::UnknownLigandId(3));
}
#[test]
fn renumber_rejects_id_map_that_creates_a_duplicate() {
let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
let err = cfg
.renumber(|id| Some(if id == 1 || id == 2 { 10 } else { id }))
.unwrap_err();
assert_eq!(err, StereoGeometryError::DuplicateSlotId(10));
}
#[test]
fn renumber_preserves_rotation_equivalence() {
for geometry in [StereoGeometry::Tetrahedral, StereoGeometry::SquarePlanar] {
let a = StereoConfiguration {
geometry,
slots: [1, 2, 3, 4],
};
for perm in geometry.rotation_group() {
let b = StereoConfiguration {
geometry,
slots: apply(perm, a.slots),
};
let ca = canonicalize_configuration(geometry, a.slots).unwrap();
let cb = canonicalize_configuration(geometry, b.slots).unwrap();
assert!(equivalent_under_rotation(&ca, &cb));
let map = |id: u32| -> Option<u32> {
match id {
1 => Some(40),
2 => Some(5),
3 => Some(77),
4 => Some(1),
_ => None,
}
};
let a2 = a.renumber(map).unwrap();
let b2 = b.renumber(map).unwrap();
let ca2 = canonicalize_configuration(a2.geometry, a2.slots).unwrap();
let cb2 = canonicalize_configuration(b2.geometry, b2.slots).unwrap();
assert!(
equivalent_under_rotation(&ca2, &cb2),
"renumbering broke rotation-equivalence for {geometry:?} perm {perm:?}"
);
}
}
}
}