chematic_core/stereo_geometry.rs
1//! Generalized stereo-configuration geometry: a coordination geometry plus
2//! the equivalence class of ligand-slot permutations under that geometry's
3//! *proper rotation group*.
4//!
5//! # Why this exists
6//!
7//! Before this module, chematic had two independent, hand-written
8//! stereo-remapping algorithms living in `chematic-smiles/src/canonical.rs`:
9//! `permutation_is_odd` (tetrahedral `@`/`@@` parity via cycle counting) and
10//! `remap_square_planar` (`@SP1`/`@SP2`/`@SP3` trans-pair-partition matching).
11//! Both solve the same underlying problem -- "given a declared stereo tag
12//! against one neighbor ordering, what tag describes the same physical
13//! arrangement against a *different* neighbor ordering?" -- with unrelated
14//! code, unrelated proofs, and no shared vocabulary. Every future geometry
15//! (trigonal-bipyramidal, octahedral) would otherwise need its own bespoke
16//! third algorithm.
17//!
18//! This module replaces both with one idea, standard in stereochemistry and
19//! crystallography: a stereo configuration is [`StereoGeometry`] (a
20//! coordination shape) plus an ordering of ligand-slot ids, and two orderings
21//! describe the *same physical arrangement* iff one can be reached from the
22//! other by a **proper rotation** of that geometry (a rotation realizable by
23//! physically rotating the rigid coordination shape in 3-space -- reflections
24//! excluded, since a reflection generally produces a different stereoisomer).
25//! The set of proper rotations of a geometry forms a group acting on the
26//! ligand-slot permutations; "canonicalizing" a configuration means picking
27//! the lexicographically-smallest ordering reachable under that group, and
28//! two configurations are equivalent iff they canonicalize to the same
29//! representative.
30//!
31//! See `docs/rfcs/generalized_stereo_geometry_rfc.md` for the full
32//! derivation, oracle/regression provenance, and the TBP/octahedral
33//! extension sketch.
34//!
35//! # Scope
36//!
37//! [`StereoGeometry::Tetrahedral`] and [`StereoGeometry::SquarePlanar`] only.
38//! `StereoGeometry` is `#[non_exhaustive]` so future geometries (TBP,
39//! octahedral) can be added without a breaking change, but none are
40//! implemented here.
41//!
42//! # Independent derivation
43//!
44//! This module was derived from group-theory fundamentals (orbit-stabilizer
45//! theorem, explicit permutation enumeration) and this codebase's own
46//! previously oracle-verified `SquarePlanarPermutation::trans_pairs()` --
47//! zero dependency on, and zero code copied from, any third-party
48//! cheminformatics library.
49
50use crate::atom::SquarePlanarPermutation;
51
52// ---------------------------------------------------------------------------
53// Core types
54// ---------------------------------------------------------------------------
55
56/// A coordination geometry with a defined ligand-slot count and proper
57/// rotation group. `#[non_exhaustive]`: trigonal-bipyramidal and octahedral
58/// are architected for (see the RFC's extension sketch) but not implemented.
59#[non_exhaustive]
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum StereoGeometry {
62 /// 4-coordinate tetrahedral center (`@`/`@@`). Proper rotation group:
63 /// A4 (alternating group on 4 points, order 12 -- all even permutations).
64 Tetrahedral,
65 /// 4-coordinate square-planar center (`@SP1`/`@SP2`/`@SP3`). Proper
66 /// rotation group: the order-8 stabilizer, under S4, of the trans-pair
67 /// partition `{0,2}|{1,3}` (see [`SQUARE_PLANAR_ROTATIONS`]'s doc for the
68 /// orbit-stabilizer derivation).
69 SquarePlanar,
70}
71
72impl StereoGeometry {
73 /// The proper rotation group for this geometry, as `apply(perm,
74 /// arr)[i] = arr[perm[i]]`-convention permutations of the 4 ligand
75 /// slots. `const`, hand-derived -- not runtime-generated.
76 fn rotation_group(self) -> &'static [[u8; 4]] {
77 match self {
78 Self::Tetrahedral => &TETRAHEDRAL_ROTATIONS,
79 Self::SquarePlanar => &SQUARE_PLANAR_ROTATIONS,
80 }
81 }
82}
83
84/// A declared stereo configuration: a geometry plus the raw ligand ids
85/// occupying its 4 slots, in the order that geometry's tag semantics were
86/// declared against (e.g. SMILES chirality-neighbor order). `slots` holds
87/// raw `u32` ids only -- never chemical identity -- so callers that need to
88/// treat chemically-identical-but-distinct-atom ligands specially (e.g.
89/// duplicate-ligand detection for CIP-style priority) must do so at a layer
90/// above this one; see the RFC's "duplicate ligands" section.
91///
92/// `pub(crate)`, not `pub`: this type, [`CanonicalStereoConfiguration`],
93/// [`canonicalize_configuration`], and [`equivalent_under_rotation`] are all
94/// hardcoded to `[u32; 4]`, which only fits the two 4-coordinate geometries
95/// this PR implements. Publishing them now would commit chematic-core's
96/// public API to "every geometry has exactly 4 slots" -- a claim
97/// [`StereoGeometry`]'s own `#[non_exhaustive]` explicitly declines to make,
98/// since trigonal-bipyramidal (5 slots) and octahedral (6 slots) are
99/// architected for (see the RFC's extension sketch). Only the two bridge
100/// functions actual callers need --
101/// [`remap_tetrahedral_parity`]/[`remap_square_planar_tag`], both already
102/// geometry-specific and arity-fixed by their own OpenSMILES tag semantics,
103/// not by an assumption this module bakes in -- are `pub`. Fields are
104/// private even at `pub(crate)` scope: the only way to build one is
105/// [`StereoConfiguration::new`], which runs the same duplicate check
106/// [`canonicalize_configuration`] does, so no code path inside this crate
107/// can construct an unvalidated configuration either.
108// No production caller constructs a `StereoConfiguration` today -- both
109// production bridge functions (`remap_tetrahedral_parity`/
110// `remap_square_planar_tag`) operate directly on raw `[u32; 4]` arrays via
111// `canonicalize_configuration`, not through this wrapper type. This type,
112// `new`, and `renumber` exist as tested, validated infrastructure per this
113// PR's required API (atom-renumbering transformation) ahead of a production
114// consumer -- same shape as `stereo_constraints.rs`'s own
115// `TetrahedralConstraint`/`StereoConstraintSet::unsupported`, which carry an
116// identical `#[allow(dead_code)]` for the same reason (see that module).
117// Promote by removing this `allow` once a real caller constructs one.
118#[allow(dead_code)]
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub(crate) struct StereoConfiguration {
121 geometry: StereoGeometry,
122 slots: [u32; 4],
123}
124
125#[allow(dead_code)]
126impl StereoConfiguration {
127 /// Construct a configuration, rejecting a duplicate slot id up front --
128 /// the same check [`canonicalize_configuration`] runs, applied here too
129 /// so a `StereoConfiguration` can never exist in an already-invalid
130 /// state (struct-literal construction is unavailable outside this
131 /// module; private fields, no other constructor).
132 pub(crate) fn new(
133 geometry: StereoGeometry,
134 slots: [u32; 4],
135 ) -> Result<Self, StereoGeometryError> {
136 if let Some(dup) = find_duplicate(slots) {
137 return Err(StereoGeometryError::DuplicateSlotId(dup));
138 }
139 Ok(Self { geometry, slots })
140 }
141
142 /// Remap every slot id through `id_map` (e.g. an atom-renumbering table),
143 /// preserving `geometry`. Fails closed two ways, both returning
144 /// [`StereoGeometryError`] rather than silently producing a bad
145 /// configuration: [`StereoGeometryError::UnknownLigandId`] the moment
146 /// any slot's id has no answer in `id_map`; and
147 /// [`StereoGeometryError::DuplicateSlotId`] if `id_map`, while total on
148 /// the input slots, is not *injective* on them -- e.g. mapping two
149 /// distinct input ids to the same output id -- which would otherwise
150 /// silently manufacture a duplicate that could never have been accepted
151 /// by [`Self::new`]/[`canonicalize_configuration`] directly. The input
152 /// itself is already known duplicate-free (a `StereoConfiguration` can
153 /// only exist via [`Self::new`]'s own check), so any duplicate detected
154 /// here was created BY the renumbering, not carried over from it.
155 pub(crate) fn renumber(
156 &self,
157 id_map: impl Fn(u32) -> Option<u32>,
158 ) -> Result<StereoConfiguration, StereoGeometryError> {
159 let mut new_slots = [0u32; 4];
160 for (i, slot) in self.slots.iter().enumerate() {
161 new_slots[i] = id_map(*slot).ok_or(StereoGeometryError::UnknownLigandId(*slot))?;
162 }
163 StereoConfiguration::new(self.geometry, new_slots)
164 }
165}
166
167/// A [`StereoConfiguration`] reduced to its canonical representative under
168/// its geometry's proper rotation group -- the "configuration class" /
169/// equivalence-class identity. Two configurations describe the same physical
170/// arrangement iff their canonical forms are equal (see
171/// [`equivalent_under_rotation`]). Fields are private: the only way to
172/// compare two configurations is through this type's own equality /
173/// [`equivalent_under_rotation`], never by peeking at which specific
174/// group-orbit member happened to sort first. `pub(crate)`: see
175/// [`StereoConfiguration`]'s doc for why this whole family of types is not
176/// yet public.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
178pub(crate) struct CanonicalStereoConfiguration {
179 geometry: StereoGeometry,
180 representative: [u32; 4],
181}
182
183impl CanonicalStereoConfiguration {
184 /// The lexicographically-smallest slot ordering in this configuration's
185 /// rotation orbit. Exposed so callers can feed it back into
186 /// [`canonicalize_configuration`] (e.g. to verify idempotence) or into
187 /// [`StereoConfiguration::renumber`]; not meaningful as "the" canonical
188 /// spelling of anything outside this module -- only equality between two
189 /// [`CanonicalStereoConfiguration`] values is a meaningful comparison.
190 pub(crate) fn representative(&self) -> [u32; 4] {
191 self.representative
192 }
193}
194
195/// Fail-closed errors for this module. No panics, no silent
196/// modulo-wrapping, no lossy fallback for out-of-range/duplicate/unmapped
197/// ligand ids anywhere in this module.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum StereoGeometryError {
200 /// The same raw ligand id appeared in two (or more) slots of a
201 /// configuration -- a data-integrity problem, not a valid 4-distinct-
202 /// ligand arrangement. Also returned by [`StereoConfiguration::renumber`]
203 /// when a non-injective `id_map` *creates* a duplicate that wasn't in
204 /// the original configuration -- reusing this variant rather than adding
205 /// a separate "renumbering created a duplicate" one, since the resulting
206 /// state is identical either way (two slots now share one id) and every
207 /// caller's fail-closed handling is the same regardless of which
208 /// operation produced it.
209 DuplicateSlotId(u32),
210 /// [`StereoConfiguration::renumber`]'s `id_map` had no answer for a
211 /// slot's id.
212 UnknownLigandId(u32),
213 /// [`remap_tetrahedral_parity`]'s `original` and `canonical` arrays
214 /// don't name the same 4 distinct ids (as a set) -- e.g. a foreign id
215 /// present in one but not the other. Computing a parity flip by
216 /// comparing canonical representatives is only meaningful when both
217 /// arrays are genuine permutations of the *same* 4 ids; without this
218 /// check, two arrays naming different id sets would (correctly, but
219 /// meaninglessly) canonicalize to unequal representatives, which the
220 /// parity computation would misread as "needs a flip" -- a
221 /// confident-looking wrong answer for malformed input, not the honest
222 /// "can't tell" this variant exists to report instead.
223 MismatchedLigandSet,
224}
225
226impl core::fmt::Display for StereoGeometryError {
227 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
228 match self {
229 Self::DuplicateSlotId(id) => {
230 write!(f, "duplicate ligand id {id} appears in two stereo slots")
231 }
232 Self::UnknownLigandId(id) => {
233 write!(f, "no renumbering answer for ligand id {id}")
234 }
235 Self::MismatchedLigandSet => {
236 write!(
237 f,
238 "original and canonical orders do not name the same ligand ids"
239 )
240 }
241 }
242 }
243}
244
245impl std::error::Error for StereoGeometryError {}
246
247// ---------------------------------------------------------------------------
248// Rotation groups
249// ---------------------------------------------------------------------------
250
251/// Apply a rotation-group permutation to a 4-slot ligand order.
252/// `apply(perm, arr)[i] = arr[perm[i]]`.
253fn apply(perm: &[u8; 4], arr: [u32; 4]) -> [u32; 4] {
254 [
255 arr[perm[0] as usize],
256 arr[perm[1] as usize],
257 arr[perm[2] as usize],
258 arr[perm[3] as usize],
259 ]
260}
261
262/// The tetrahedral proper rotation group: **A4**, the alternating group on
263/// 4 points (all even permutations), order 12. A physical rotation of a
264/// rigid tetrahedron permutes its 4 vertices by an even permutation only
265/// (this is the standard identification of the tetrahedron's rotation
266/// group with A4; an odd permutation of the 4 vertices requires a
267/// reflection, which is *not* realizable by a proper rotation and is
268/// exactly the operation that flips `@`<->`@@`). 24 total orderings / 12
269/// rotations = 2 orbits, matching the existing `@`/`@@` (CW/CCW) 2-state
270/// tag this table backs.
271///
272/// Listed as: identity, the 8 three-cycles, the 3 double-transpositions
273/// (1 + 8 + 3 = 12). Cross-checked in this module's tests against an
274/// independently-written brute-force parity function (not
275/// [`remap_tetrahedral_parity`]'s own internals) over all 24 permutations
276/// of `[0,1,2,3]`.
277const TETRAHEDRAL_ROTATIONS: [[u8; 4]; 12] = [
278 [0, 1, 2, 3], // identity
279 // 3-cycles (8): (012),(021),(013),(031),(023),(032),(123),(132)
280 [1, 2, 0, 3],
281 [2, 0, 1, 3],
282 [1, 3, 2, 0],
283 [3, 0, 2, 1],
284 [2, 1, 3, 0],
285 [3, 1, 0, 2],
286 [0, 2, 3, 1],
287 [0, 3, 1, 2],
288 // double-transpositions (3): (01)(23),(02)(13),(03)(12)
289 [1, 0, 3, 2],
290 [2, 3, 0, 1],
291 [3, 2, 1, 0],
292];
293
294/// The square-planar proper rotation group: the order-**8** stabilizer,
295/// under S4 (order 24), of the trans-pair partition `{0,2}|{1,3}` --
296/// [`SquarePlanarPermutation::SP1`]'s own partition (`trans_pairs()`). NOT
297/// the naive "4 in-plane rotations only" group (order 4, which would give
298/// 24/4 = 6 orbits and cannot recover the 3 real SP1/SP2/SP3 tags) -- a
299/// square-planar center's rotation symmetry includes the 4 rotations that
300/// swap the two trans-pairs (a 90 degree rotation about an axis in the
301/// molecular plane, through the midpoints of two opposite edges of the
302/// square, is a genuine proper rotation of the physical complex and swaps
303/// which pair is "trans-pair A" vs "trans-pair B") in addition to the 4
304/// that preserve each trans-pair individually.
305///
306/// Derivation (orbit-stabilizer theorem): S4 acts transitively on the 3 ways
307/// to partition `{0,1,2,3}` into two unordered pairs (`{0,1}|{2,3}`,
308/// `{0,2}|{1,3}`, `{0,3}|{1,2}`) -- any of the 3 partitions can be mapped to
309/// any other by some permutation, and the action is transitive with a
310/// 3-element orbit, so by orbit-stabilizer the stabilizer of one partition
311/// has order `|S4| / 3 = 24 / 3 = 8`.
312///
313/// Explicit enumeration of the 8-element stabilizer of `{0,2}|{1,3}`, in two
314/// cases:
315/// - **Block-preserving** (maps `{0,2}` to itself and `{1,3}` to itself):
316/// independently permute within each block -> 2 x 2 = 4 elements:
317/// identity, `(02)`, `(13)`, `(02)(13)`.
318/// - **Block-swapping** (maps `{0,2}` to `{1,3}` and vice versa): a
319/// bijection `{0,2}->{1,3}` (2 choices) combined with a bijection
320/// `{1,3}->{0,2}` (2 choices) -> 4 elements: `(01)(23)`, `(0123)`,
321/// `(0321)`, `(03)(12)`.
322///
323/// 4 + 4 = 8, matching the orbit-stabilizer count. This exact 8-element set
324/// is asserted against `SquarePlanarPermutation::SP1.trans_pairs()` at
325/// runtime in this module's tests (`square_planar_rotations_stabilize_sp1_partition`),
326/// not just hand-verified in this comment -- the table below is
327/// *load-bearing*, verifiably tied to the pre-existing, oracle-verified
328/// `trans_pairs()` semantics, not an independently-guessed group.
329const SQUARE_PLANAR_ROTATIONS: [[u8; 4]; 8] = [
330 [0, 1, 2, 3], // identity
331 [2, 1, 0, 3], // (02)
332 [0, 3, 2, 1], // (13)
333 [2, 3, 0, 1], // (02)(13)
334 [1, 0, 3, 2], // (01)(23)
335 [1, 2, 3, 0], // (0123)
336 [3, 0, 1, 2], // (0321) -- inverse of (0123)
337 [3, 2, 1, 0], // (03)(12)
338];
339
340// ---------------------------------------------------------------------------
341// Canonicalization
342// ---------------------------------------------------------------------------
343
344/// Detect a duplicate raw ligand id among the 4 slots, without heap
345/// allocation (plain O(1)-bounded pairwise scan over a fixed 4-element
346/// array -- no `HashSet`, deterministic regardless of any hashing).
347fn find_duplicate(slots: [u32; 4]) -> Option<u32> {
348 for i in 0..4 {
349 for j in (i + 1)..4 {
350 if slots[i] == slots[j] {
351 return Some(slots[i]);
352 }
353 }
354 }
355 None
356}
357
358/// Reduce `ligand_order` to its canonical representative under `geometry`'s
359/// proper rotation group: the lexicographically-smallest ordering reachable
360/// by applying any element of the rotation group.
361///
362/// Fails closed with [`StereoGeometryError::DuplicateSlotId`] if the same
363/// raw id occupies two slots -- not a valid 4-distinct-ligand arrangement,
364/// and letting it through would make "lexicographically smallest" pick an
365/// arbitrary, meaningless tie-break among rotation-equivalent duplicates.
366///
367/// `pub(crate)`: see [`StereoConfiguration`]'s doc for why this isn't public
368/// yet (hardcoded `[u32; 4]` arity, deferred until a second geometry family
369/// forces the real generalization).
370pub(crate) fn canonicalize_configuration(
371 geometry: StereoGeometry,
372 ligand_order: [u32; 4],
373) -> Result<CanonicalStereoConfiguration, StereoGeometryError> {
374 if let Some(dup) = find_duplicate(ligand_order) {
375 return Err(StereoGeometryError::DuplicateSlotId(dup));
376 }
377 let group = geometry.rotation_group();
378 let mut best = apply(&group[0], ligand_order);
379 for perm in &group[1..] {
380 let candidate = apply(perm, ligand_order);
381 if candidate < best {
382 best = candidate;
383 }
384 }
385 Ok(CanonicalStereoConfiguration {
386 geometry,
387 representative: best,
388 })
389}
390
391/// `true` iff `a` and `b` describe the same physical arrangement -- i.e. one
392/// is reachable from the other by a proper rotation of their (shared)
393/// geometry. Configurations of different geometries are never equivalent.
394/// Currently just `a == b` (both fields, including the private
395/// `representative`, participate in equality), kept as a named function so
396/// call sites read as a geometric claim rather than an incidental struct
397/// comparison. `pub(crate)`: see [`StereoConfiguration`]'s doc.
398pub(crate) fn equivalent_under_rotation(
399 a: &CanonicalStereoConfiguration,
400 b: &CanonicalStereoConfiguration,
401) -> bool {
402 a == b
403}
404
405// ---------------------------------------------------------------------------
406// Bridge functions -- replace `chematic-smiles/src/canonical.rs`'s
407// `permutation_is_odd` (tetrahedral) and `remap_square_planar`
408// (square-planar) at their exact call sites.
409// ---------------------------------------------------------------------------
410
411/// Whether remapping a declared tetrahedral tag from `original` neighbor
412/// order to `canonical` neighbor order requires flipping `@`<->`@@`.
413///
414/// Two orderings of the same 4 distinct ids differ by an even permutation
415/// (no flip) iff they canonicalize to the same [`StereoGeometry::Tetrahedral`]
416/// representative -- by definition, since [`TETRAHEDRAL_ROTATIONS`] (A4) is
417/// exactly the even-permutation group. This is a direct restatement of
418/// classic cycle-counting permutation parity through the rotation-orbit
419/// abstraction, not a different rule -- see this module's
420/// `tetrahedral_rotations_are_independently_confirmed_even_permutations`
421/// test for a from-scratch cross-check.
422///
423/// Fails closed with [`StereoGeometryError::DuplicateSlotId`] if either
424/// array has a repeated id, or [`StereoGeometryError::MismatchedLigandSet`]
425/// if `original` and `canonical` don't name the same 4 ids as a set (the
426/// caller -- `canonical.rs`'s `corrected_chirality` -- treats any `Err` the
427/// same as its pre-existing "no verifiable order" pass-through-unchanged
428/// fallback, matching the documented safe-no-op behavior for a 2-state tag;
429/// see that call site's own comment for why this is the right fallback,
430/// not a workaround).
431///
432/// The mismatched-set check matters, not just for symmetry with
433/// [`remap_square_planar_tag`]'s analogous guard: without it, two arrays
434/// naming *different* id sets (e.g. `[1,2,3,4]` vs `[1,2,3,5]`) each
435/// canonicalize successfully (no duplicates in either one alone) to
436/// necessarily-*unequal* representatives -- purely because they contain
437/// different ids, not because one is an odd permutation of the other -- and
438/// the naive `orig.representative != canon.representative` test would
439/// misread that as "needs a parity flip," a confident-looking wrong answer
440/// for malformed input rather than the honest "can't tell."
441pub fn remap_tetrahedral_parity(
442 original: [u32; 4],
443 canonical: [u32; 4],
444) -> Result<bool, StereoGeometryError> {
445 // Each array's OWN internal-duplicate check first, so a genuinely
446 // duplicated id is always reported as `DuplicateSlotId` (the more
447 // specific diagnosis) even when the two arrays also happen to differ as
448 // sets -- `MismatchedLigandSet` below is reserved for the case where
449 // *neither* array has an internal duplicate but they still don't name
450 // the same 4 ids.
451 if let Some(dup) = find_duplicate(original) {
452 return Err(StereoGeometryError::DuplicateSlotId(dup));
453 }
454 if let Some(dup) = find_duplicate(canonical) {
455 return Err(StereoGeometryError::DuplicateSlotId(dup));
456 }
457 let mut sorted_original = original;
458 sorted_original.sort_unstable();
459 let mut sorted_canonical = canonical;
460 sorted_canonical.sort_unstable();
461 if sorted_original != sorted_canonical {
462 return Err(StereoGeometryError::MismatchedLigandSet);
463 }
464 let orig = canonicalize_configuration(StereoGeometry::Tetrahedral, original)?;
465 let canon = canonicalize_configuration(StereoGeometry::Tetrahedral, canonical)?;
466 Ok(orig.representative() != canon.representative())
467}
468
469/// Convert `(tag, order)` into the geometry's own base-convention slot
470/// array: an ordering where position 0/2 are one of `tag`'s trans-pairs and
471/// position 1/3 are the other -- [`SquarePlanarPermutation::SP1`]'s own
472/// convention (`(0,2)`/`(1,3)`), which is also exactly the partition
473/// [`SQUARE_PLANAR_ROTATIONS`] stabilizes. Built directly from
474/// `tag.trans_pairs()`, not a per-tag hand-written special case: given
475/// `trans_pairs() = [(a,b),(c,d)]`, the reorder `[a,c,b,d]` always puts `a`
476/// at 0 and `b` at 2 (the pair `(a,b)`) and `c` at 1 and `d` at 3 (the pair
477/// `(c,d)`).
478fn to_base_slots(tag: SquarePlanarPermutation, order: [u32; 4]) -> [u32; 4] {
479 let [(a, b), (c, d)] = tag.trans_pairs();
480 [
481 order[a as usize],
482 order[c as usize],
483 order[b as usize],
484 order[d as usize],
485 ]
486}
487
488/// Remap a declared square-planar tag from `original` neighbor order to
489/// `canonical` neighbor order -- the [`StereoGeometry`]-based counterpart to
490/// the removed `remap_square_planar`.
491///
492/// `(tag, original)` and `(candidate, canonical)` describe the same physical
493/// arrangement iff [`to_base_slots`]'s outputs for each are in the same
494/// [`StereoGeometry::SquarePlanar`] rotation orbit (their unordered
495/// trans-pair-of-ids partition, `{{slots[0],slots[2]}, {slots[1],slots[3]}}`,
496/// is exactly what a `SquarePlanar`-orbit reduces to -- see
497/// [`SQUARE_PLANAR_ROTATIONS`]'s doc). Tries all 3 tags against `canonical`
498/// and returns the (unique, when one exists) match.
499///
500/// `None` -- never a guessed tag -- whenever no candidate matches: this
501/// happens exactly when `original`/`canonical` don't name the same 4
502/// distinct ids (mismatched id set, or a duplicate id in either array),
503/// since in that case every candidate's canonicalization either errors
504/// (`DuplicateSlotId`) or lands on a representative array containing ids
505/// [`original`] didn't have, which can never equal `original`'s own
506/// representative.
507///
508/// Unlike [`remap_tetrahedral_parity`], this function does NOT need an
509/// explicit mismatched-id-set guard: it compares via
510/// [`equivalent_under_rotation`] (full array equality, both `geometry` and
511/// `representative`), not a boolean not-equal test, so a candidate whose
512/// representative merely differs *for the wrong reason* (different id set,
513/// not a real rotation-inequivalence) still correctly fails the equality
514/// check and falls through to `None` rather than being misread as a match
515/// -- verified by `remap_square_planar_tag_none_on_mismatched_id_set` below.
516pub fn remap_square_planar_tag(
517 tag: SquarePlanarPermutation,
518 original: [u32; 4],
519 canonical: [u32; 4],
520) -> Option<SquarePlanarPermutation> {
521 let canon_original =
522 canonicalize_configuration(StereoGeometry::SquarePlanar, to_base_slots(tag, original))
523 .ok()?;
524 [
525 SquarePlanarPermutation::SP1,
526 SquarePlanarPermutation::SP2,
527 SquarePlanarPermutation::SP3,
528 ]
529 .into_iter()
530 .find(|&candidate| {
531 let slots_candidate = to_base_slots(candidate, canonical);
532 match canonicalize_configuration(StereoGeometry::SquarePlanar, slots_candidate) {
533 Ok(canon_candidate) => equivalent_under_rotation(&canon_original, &canon_candidate),
534 Err(_) => false,
535 }
536 })
537}
538
539// ---------------------------------------------------------------------------
540// Tests
541// ---------------------------------------------------------------------------
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 /// All 24 permutations of `[0,1,2,3]`, as `[u8;4]` for group-element
548 /// tests and `[u32;4]` for configuration tests.
549 fn permutations_of_4_u8() -> Vec<[u8; 4]> {
550 let mut out = Vec::with_capacity(24);
551 for a in 0..4u8 {
552 for b in 0..4u8 {
553 if b == a {
554 continue;
555 }
556 for c in 0..4u8 {
557 if c == a || c == b {
558 continue;
559 }
560 for d in 0..4u8 {
561 if d == a || d == b || d == c {
562 continue;
563 }
564 out.push([a, b, c, d]);
565 }
566 }
567 }
568 }
569 out
570 }
571
572 fn permutations_of_4_u32() -> Vec<[u32; 4]> {
573 permutations_of_4_u8()
574 .into_iter()
575 .map(|p| [p[0] as u32, p[1] as u32, p[2] as u32, p[3] as u32])
576 .collect()
577 }
578
579 /// Compose two rotation-table permutations so that `apply(compose(g,h),
580 /// arr) == apply(g, apply(h, arr))` for all `arr` -- i.e.
581 /// `compose(g,h)[i] = h[g[i]]`. Used only by the group-axiom tests
582 /// below, not by production code.
583 fn compose(g: &[u8; 4], h: &[u8; 4]) -> [u8; 4] {
584 [
585 h[g[0] as usize],
586 h[g[1] as usize],
587 h[g[2] as usize],
588 h[g[3] as usize],
589 ]
590 }
591
592 const IDENTITY: [u8; 4] = [0, 1, 2, 3];
593
594 fn assert_is_group(table: &[[u8; 4]], expected_order: usize, name: &str) {
595 assert_eq!(table.len(), expected_order, "{name}: wrong group order");
596
597 // No duplicate rows.
598 for i in 0..table.len() {
599 for j in (i + 1)..table.len() {
600 assert_ne!(table[i], table[j], "{name}: duplicate row at {i},{j}");
601 }
602 }
603
604 // Every row is actually a permutation of 0..4.
605 for row in table {
606 let mut sorted = *row;
607 sorted.sort_unstable();
608 assert_eq!(
609 sorted,
610 [0, 1, 2, 3],
611 "{name}: row {row:?} not a permutation"
612 );
613 }
614
615 // Identity present.
616 assert!(
617 table.contains(&IDENTITY),
618 "{name}: identity element missing"
619 );
620
621 // Closure.
622 for g in table {
623 for h in table {
624 let gh = compose(g, h);
625 assert!(
626 table.contains(&gh),
627 "{name}: not closed, compose({g:?},{h:?})={gh:?} not in table"
628 );
629 }
630 }
631
632 // Every element has an inverse in the table.
633 for g in table {
634 let has_inverse = table.iter().any(|h| compose(g, h) == IDENTITY);
635 assert!(has_inverse, "{name}: {g:?} has no inverse in table");
636 }
637 }
638
639 #[test]
640 fn tetrahedral_rotations_form_a_group_of_order_12() {
641 assert_is_group(&TETRAHEDRAL_ROTATIONS, 12, "TETRAHEDRAL_ROTATIONS");
642 }
643
644 #[test]
645 fn square_planar_rotations_form_a_group_of_order_8() {
646 assert_is_group(&SQUARE_PLANAR_ROTATIONS, 8, "SQUARE_PLANAR_ROTATIONS");
647 }
648
649 /// Independent second derivation for the tetrahedral table: brute-force
650 /// cycle-counting parity over all 24 permutations, written from scratch
651 /// here (not reusing `remap_tetrahedral_parity`'s internals, which don't
652 /// even compute parity directly anymore) -- must select exactly the 12
653 /// rows in [`TETRAHEDRAL_ROTATIONS`].
654 #[test]
655 fn tetrahedral_rotations_are_independently_confirmed_even_permutations() {
656 fn is_odd(p: [u8; 4]) -> bool {
657 let mut visited = [false; 4];
658 let mut num_cycles = 0usize;
659 for start in 0..4 {
660 if !visited[start] {
661 num_cycles += 1;
662 let mut j = start;
663 while !visited[j] {
664 visited[j] = true;
665 j = p[j] as usize;
666 }
667 }
668 }
669 (4 - num_cycles) % 2 == 1
670 }
671
672 let mut brute_force_even: Vec<[u8; 4]> = permutations_of_4_u8()
673 .into_iter()
674 .filter(|&p| !is_odd(p))
675 .collect();
676 brute_force_even.sort_unstable();
677
678 let mut table_sorted = TETRAHEDRAL_ROTATIONS.to_vec();
679 table_sorted.sort_unstable();
680
681 assert_eq!(
682 brute_force_even, table_sorted,
683 "TETRAHEDRAL_ROTATIONS must equal the brute-force even-permutation set"
684 );
685 }
686
687 /// Load-bearing check tying [`SQUARE_PLANAR_ROTATIONS`] to the
688 /// pre-existing, oracle-verified `SquarePlanarPermutation::SP1::trans_pairs()`
689 /// -- not just to a partition literal written by hand in this file. Every
690 /// group element, applied to the reference order `[0,1,2,3]`, must
691 /// preserve SP1's own trans-pair partition as an *unordered pair of
692 /// unordered pairs* (individual pairs may swap which "half" they land in
693 /// -- that's exactly the block-swapping half of the group).
694 #[test]
695 fn square_planar_rotations_stabilize_sp1_partition() {
696 let reference: [u32; 4] = [0, 1, 2, 3];
697
698 // Derive the reference partition FROM `trans_pairs()` at runtime,
699 // via the same `to_base_slots` production code uses -- not a
700 // hardcoded `{0,2}|{1,3}` literal in this test. `to_base_slots` for
701 // SP1 must be a no-op: SP1 *is* the base convention by definition.
702 let sp1_base = to_base_slots(SquarePlanarPermutation::SP1, reference);
703 assert_eq!(
704 sp1_base, reference,
705 "SP1.trans_pairs() must already match the (0,2)/(1,3) base convention"
706 );
707
708 let partition_of = |arr: [u32; 4]| -> [[u32; 2]; 2] {
709 let mut p1 = [arr[0], arr[2]];
710 let mut p2 = [arr[1], arr[3]];
711 p1.sort_unstable();
712 p2.sort_unstable();
713 let mut both = [p1, p2];
714 both.sort_unstable();
715 both
716 };
717 let expected = partition_of(sp1_base);
718
719 for perm in &SQUARE_PLANAR_ROTATIONS {
720 let rotated = apply(perm, reference);
721 assert_eq!(
722 partition_of(rotated),
723 expected,
724 "rotation {perm:?} does not stabilize SP1's trans-pair partition"
725 );
726 }
727 }
728
729 /// The flagship test: exactly 2 orbits for Tetrahedral, exactly 3 for
730 /// SquarePlanar, over all 24 orderings of 4 distinct ids -- would catch
731 /// a wrong group (e.g. 6 orbits from the naive order-4 in-plane-only
732 /// square-planar group).
733 #[test]
734 fn orbit_counts_are_2_for_tetrahedral_and_3_for_square_planar() {
735 for (geometry, expected_orbits) in [
736 (StereoGeometry::Tetrahedral, 2),
737 (StereoGeometry::SquarePlanar, 3),
738 ] {
739 let mut representatives: Vec<[u32; 4]> = permutations_of_4_u32()
740 .into_iter()
741 .map(|order| {
742 canonicalize_configuration(geometry, order)
743 .expect("4 distinct ids never duplicate")
744 .representative()
745 })
746 .collect();
747 representatives.sort_unstable();
748 representatives.dedup();
749 assert_eq!(
750 representatives.len(),
751 expected_orbits,
752 "{geometry:?}: expected {expected_orbits} orbits, got {}: {representatives:?}",
753 representatives.len()
754 );
755 }
756 }
757
758 #[test]
759 fn canonicalization_is_idempotent() {
760 for geometry in [StereoGeometry::Tetrahedral, StereoGeometry::SquarePlanar] {
761 for order in permutations_of_4_u32() {
762 let once = canonicalize_configuration(geometry, order).unwrap();
763 let twice = canonicalize_configuration(geometry, once.representative()).unwrap();
764 assert_eq!(once, twice, "idempotence failed for {geometry:?} {order:?}");
765 }
766 }
767 }
768
769 #[test]
770 fn duplicate_slot_id_fails_closed() {
771 let err =
772 canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 1, 3]).unwrap_err();
773 assert_eq!(err, StereoGeometryError::DuplicateSlotId(1));
774 }
775
776 #[test]
777 fn equivalent_under_rotation_matches_equality_and_respects_geometry() {
778 let a = canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
779 let b = canonicalize_configuration(
780 StereoGeometry::Tetrahedral,
781 apply(&TETRAHEDRAL_ROTATIONS[3], [1, 2, 3, 4]),
782 )
783 .unwrap();
784 assert!(equivalent_under_rotation(&a, &b));
785
786 let odd = canonicalize_configuration(StereoGeometry::Tetrahedral, [2, 1, 3, 4]).unwrap();
787 assert!(!equivalent_under_rotation(&a, &odd));
788
789 let sp = canonicalize_configuration(StereoGeometry::SquarePlanar, [1, 2, 3, 4]).unwrap();
790 // Different geometry, same raw slots -- never equivalent.
791 let te = canonicalize_configuration(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
792 assert!(!equivalent_under_rotation(&sp, &te));
793 }
794
795 // -------------------------------------------------------------------
796 // remap_tetrahedral_parity
797 // -------------------------------------------------------------------
798
799 #[test]
800 fn remap_tetrahedral_parity_matches_hand_cases() {
801 // Identity: never odd.
802 assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 4]).unwrap());
803 // Single transposition: odd.
804 assert!(remap_tetrahedral_parity([1, 2, 3, 4], [2, 1, 3, 4]).unwrap());
805 // Double transposition: even.
806 assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [2, 1, 4, 3]).unwrap());
807 // 3-cycle: even.
808 assert!(!remap_tetrahedral_parity([1, 2, 3, 4], [2, 3, 1, 4]).unwrap());
809 }
810
811 #[test]
812 fn remap_tetrahedral_parity_fails_closed_on_duplicate() {
813 assert_eq!(
814 remap_tetrahedral_parity([1, 1, 3, 4], [1, 2, 3, 4]).unwrap_err(),
815 StereoGeometryError::DuplicateSlotId(1)
816 );
817 }
818
819 /// The bug an independent review caught: `original`/`canonical` naming
820 /// *different* id sets (neither internally duplicated) must fail closed
821 /// with `MismatchedLigandSet`, not silently return a confident-looking
822 /// `Ok(true)`/`Ok(false)`. Before this check existed,
823 /// `remap_tetrahedral_parity([1,2,3,4], [1,2,3,5])` returned `Ok(true)`:
824 /// the two arrays canonicalize to necessarily-unequal representatives
825 /// (they contain different ids), which the naive `!=` parity test
826 /// misread as "needs a flip" -- a wrong answer for malformed input, not
827 /// an honest "can't tell."
828 #[test]
829 fn remap_tetrahedral_parity_fails_closed_on_mismatched_ligand_set() {
830 assert_eq!(
831 remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 5]).unwrap_err(),
832 StereoGeometryError::MismatchedLigandSet
833 );
834 // Same multiset, different order -- must NOT be flagged as
835 // mismatched (this is the ordinary, common case this function
836 // exists to compute a real parity answer for).
837 assert!(remap_tetrahedral_parity([1, 2, 3, 4], [1, 2, 3, 4]).is_ok());
838 assert!(remap_tetrahedral_parity([1, 2, 3, 4], [4, 3, 2, 1]).is_ok());
839 }
840
841 // -------------------------------------------------------------------
842 // remap_square_planar_tag
843 // -------------------------------------------------------------------
844
845 #[test]
846 fn remap_square_planar_tag_identity_is_a_no_op() {
847 for tag in [
848 SquarePlanarPermutation::SP1,
849 SquarePlanarPermutation::SP2,
850 SquarePlanarPermutation::SP3,
851 ] {
852 assert_eq!(
853 remap_square_planar_tag(tag, [10, 20, 30, 40], [10, 20, 30, 40]),
854 Some(tag)
855 );
856 }
857 }
858
859 /// Duplicate-*chemistry*, distinct-*ids* case, shaped like
860 /// cisplatin/transplatin's real coordination chemistry: `2xCl + 2xNH3`
861 /// ligand composition (whether this particular slot arrangement happens
862 /// to be the cis or trans isomer specifically depends on which of the
863 /// two identical-composition slots the caller places where, which this
864 /// test deliberately does not commit to -- see the note below). This
865 /// module only ever sees 4 raw slot ids -- it has no concept of "these
866 /// two are chemically the same ligand" at all (see the RFC's
867 /// duplicate-ligand section) -- so two chemically-identical Cl ligands
868 /// still get two distinct ids here (`CL1`/`CL2`), same for the two NH3
869 /// nitrogens (`N1`/`N2`). Proves directly, at the geometry-module level
870 /// (independent of `square_planar_stereo.rs`'s end-to-end
871 /// `cisplatin_and_transplatin_have_distinct_canonical_identity`, which
872 /// checks the same property through the full parser/writer instead):
873 /// SP1/SP2/SP3, applied to this one fixed 2xCl+2xN slot assignment,
874 /// canonicalize to *three different* representatives -- chemical-
875 /// identity duplication never collapses any of them into one orbit.
876 #[test]
877 fn duplicate_chemistry_distinct_ids_keeps_cisplatin_transplatin_shaped_tags_distinct() {
878 const CL1: u32 = 101;
879 const CL2: u32 = 102;
880 const N1: u32 = 103;
881 const N2: u32 = 104;
882 // Deliberately not naming which of SP1/SP2/SP3 is "the cis one" or
883 // "the trans one" for this specific order -- that reading is a real
884 // but easy-to-get-backwards derived fact (which pair of positions a
885 // given tag makes trans depends on both the tag AND which ligand
886 // sits at which position), and getting it right or wrong doesn't
887 // change what this test actually checks: all 3 tags, applied to the
888 // SAME slot assignment, must land in 3 distinct orbits.
889 let order: [u32; 4] = [CL1, N1, CL2, N2];
890
891 let sp1 = canonicalize_configuration(
892 StereoGeometry::SquarePlanar,
893 to_base_slots(SquarePlanarPermutation::SP1, order),
894 )
895 .unwrap();
896 let sp2 = canonicalize_configuration(
897 StereoGeometry::SquarePlanar,
898 to_base_slots(SquarePlanarPermutation::SP2, order),
899 )
900 .unwrap();
901 let sp3 = canonicalize_configuration(
902 StereoGeometry::SquarePlanar,
903 to_base_slots(SquarePlanarPermutation::SP3, order),
904 )
905 .unwrap();
906
907 assert!(
908 !equivalent_under_rotation(&sp1, &sp2),
909 "SP1-shaped and SP2-shaped configurations must NOT collapse to one orbit even \
910 though both slot assignments repeat 2xCl+2xN chemistry"
911 );
912 assert!(!equivalent_under_rotation(&sp1, &sp3));
913 assert!(!equivalent_under_rotation(&sp2, &sp3));
914
915 // remap_square_planar_tag itself, the actual production bridge
916 // function, must also keep them distinct end to end (original order
917 // unpermuted, i.e. "canonical" == "original" -- this is the
918 // identity-remap case, so the returned tag must be the same tag
919 // fed in, for both SP1 and SP2, distinctly).
920 assert_eq!(
921 remap_square_planar_tag(SquarePlanarPermutation::SP1, order, order),
922 Some(SquarePlanarPermutation::SP1)
923 );
924 assert_eq!(
925 remap_square_planar_tag(SquarePlanarPermutation::SP2, order, order),
926 Some(SquarePlanarPermutation::SP2)
927 );
928 }
929
930 #[test]
931 fn remap_square_planar_tag_full_24_by_3_table() {
932 // Direct unit-level version of the same 24-permutations x 3-tags
933 // sweep `square_planar_stereo.rs`'s end-to-end oracle test performs,
934 // exercised against `remap_square_planar_tag` itself rather than
935 // through the SMILES parser/writer. Reference/"canonical" order is
936 // fixed at the identity [0,1,2,3] (ligand ids 0..3 by value), same
937 // convention `square_planar_stereo.rs`'s own `predict` helper uses.
938 let tags = [
939 SquarePlanarPermutation::SP1,
940 SquarePlanarPermutation::SP2,
941 SquarePlanarPermutation::SP3,
942 ];
943 let mut checked = 0;
944 for order in permutations_of_4_u32() {
945 for &tag in &tags {
946 // `order[i]` = which ligand id sits at original position i.
947 let predicted = tags
948 .into_iter()
949 .find(|&candidate| {
950 let a = to_base_slots(tag, order);
951 let b = to_base_slots(candidate, [0, 1, 2, 3]);
952 let ca =
953 canonicalize_configuration(StereoGeometry::SquarePlanar, a).unwrap();
954 let cb =
955 canonicalize_configuration(StereoGeometry::SquarePlanar, b).unwrap();
956 equivalent_under_rotation(&ca, &cb)
957 })
958 .expect("exactly one of the 3 tags must match (3 orbits, 3 tags)");
959 assert_eq!(
960 remap_square_planar_tag(tag, order, [0, 1, 2, 3]),
961 Some(predicted),
962 "order={order:?} tag={tag:?}"
963 );
964 checked += 1;
965 }
966 }
967 assert_eq!(checked, 24 * 3);
968 }
969
970 #[test]
971 fn remap_square_planar_tag_none_on_mismatched_id_set() {
972 // `canonical` doesn't contain the same 4 ids as `original` (5
973 // replaces 4) -- must fail closed, never guess a tag.
974 assert_eq!(
975 remap_square_planar_tag(SquarePlanarPermutation::SP1, [1, 2, 3, 4], [1, 2, 3, 5]),
976 None
977 );
978 }
979
980 #[test]
981 fn remap_square_planar_tag_none_on_duplicate_original() {
982 assert_eq!(
983 remap_square_planar_tag(SquarePlanarPermutation::SP1, [1, 1, 3, 4], [1, 2, 3, 4]),
984 None
985 );
986 }
987
988 // -------------------------------------------------------------------
989 // StereoConfiguration::new
990 // -------------------------------------------------------------------
991
992 #[test]
993 fn stereo_configuration_new_rejects_duplicate_slot_id() {
994 // Struct-literal construction is unavailable outside this module
995 // (private fields) -- `new` is the only way in, and it must run the
996 // same duplicate check `canonicalize_configuration` does, so no
997 // `StereoConfiguration` can ever exist in an already-invalid state.
998 assert_eq!(
999 StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 1, 3]).unwrap_err(),
1000 StereoGeometryError::DuplicateSlotId(1)
1001 );
1002 }
1003
1004 #[test]
1005 fn stereo_configuration_new_accepts_distinct_ids() {
1006 assert!(StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).is_ok());
1007 }
1008
1009 // -------------------------------------------------------------------
1010 // StereoConfiguration::renumber
1011 // -------------------------------------------------------------------
1012
1013 #[test]
1014 fn renumber_remaps_every_slot() {
1015 let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
1016 let renumbered = cfg
1017 .renumber(|id| Some(id * 10))
1018 .expect("total map succeeds");
1019 assert_eq!(renumbered.geometry, StereoGeometry::Tetrahedral);
1020 assert_eq!(renumbered.slots, [10, 20, 30, 40]);
1021 }
1022
1023 #[test]
1024 fn renumber_fails_closed_on_unmapped_id() {
1025 let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
1026 let err = cfg
1027 .renumber(|id| if id == 3 { None } else { Some(id) })
1028 .unwrap_err();
1029 assert_eq!(err, StereoGeometryError::UnknownLigandId(3));
1030 }
1031
1032 /// The bug an independent review caught: a non-injective `id_map` --
1033 /// total on every input slot, but mapping two *distinct* input ids to
1034 /// the *same* output id -- must fail closed, not silently manufacture a
1035 /// duplicate that could never have been accepted by
1036 /// [`StereoConfiguration::new`] directly. Before this check existed,
1037 /// mapping both `1` and `2` to `10` would succeed and return
1038 /// `slots: [10, 10, 3, 4]`.
1039 #[test]
1040 fn renumber_rejects_id_map_that_creates_a_duplicate() {
1041 let cfg = StereoConfiguration::new(StereoGeometry::Tetrahedral, [1, 2, 3, 4]).unwrap();
1042 // Both 1 and 2 map to 10 (non-injective); 3 and 4 map to themselves.
1043 let err = cfg
1044 .renumber(|id| Some(if id == 1 || id == 2 { 10 } else { id }))
1045 .unwrap_err();
1046 assert_eq!(err, StereoGeometryError::DuplicateSlotId(10));
1047 }
1048
1049 /// Renumbering invariance: if two configurations describe the same
1050 /// physical arrangement (same rotation orbit), applying the *same*
1051 /// (possibly non-order-preserving) renumbering map to both must still
1052 /// leave them describing the same physical arrangement.
1053 #[test]
1054 fn renumber_preserves_rotation_equivalence() {
1055 for geometry in [StereoGeometry::Tetrahedral, StereoGeometry::SquarePlanar] {
1056 let a = StereoConfiguration {
1057 geometry,
1058 slots: [1, 2, 3, 4],
1059 };
1060 for perm in geometry.rotation_group() {
1061 let b = StereoConfiguration {
1062 geometry,
1063 slots: apply(perm, a.slots),
1064 };
1065 // Sanity: same orbit before renumbering.
1066 let ca = canonicalize_configuration(geometry, a.slots).unwrap();
1067 let cb = canonicalize_configuration(geometry, b.slots).unwrap();
1068 assert!(equivalent_under_rotation(&ca, &cb));
1069
1070 // Deliberately non-monotonic bijection on {1,2,3,4}.
1071 let map = |id: u32| -> Option<u32> {
1072 match id {
1073 1 => Some(40),
1074 2 => Some(5),
1075 3 => Some(77),
1076 4 => Some(1),
1077 _ => None,
1078 }
1079 };
1080 let a2 = a.renumber(map).unwrap();
1081 let b2 = b.renumber(map).unwrap();
1082 let ca2 = canonicalize_configuration(a2.geometry, a2.slots).unwrap();
1083 let cb2 = canonicalize_configuration(b2.geometry, b2.slots).unwrap();
1084 assert!(
1085 equivalent_under_rotation(&ca2, &cb2),
1086 "renumbering broke rotation-equivalence for {geometry:?} perm {perm:?}"
1087 );
1088 }
1089 }
1090 }
1091}