Skip to main content

cosmolkit_core/chemistry/
stereo_enumerate.rs

1// RDKit marker convention defined in dev/source_reproduction_protocol.md.
2//
3// Source reproduction protocol: dev/source_reproduction_protocol.md
4//
5// This module reproduces RDKit Python's EnumerateStereoisomers call path with
6// one typed potential-stereo owner, one flipper model, and one lazy
7// configuration engine.
8//
9// RDKit source files:
10//   EnumerateStereoisomers.cpp  (lines 1-184)
11//   EnumerateStereoisomers.h    (lines 1-116)
12//   Flippers.cpp                (lines 1-115)
13//   Flippers.h                  (lines 1-108)
14
15use std::collections::HashSet;
16
17use num_bigint::{BigInt, BigUint};
18
19use crate::{
20    AdjacencyList, AtomId, BondDirection, BondId, BondStereo, ChiralTag, ControllingAtom, Molecule,
21    PotentialStereoError, StereoCenter, StereoGroupKind, StereoInfo, StereoSpecified, StereoType,
22    molecule::DerivedCacheBlock, potential_stereo::find_potential_stereo_in_workspace,
23    read_parts::MoleculeReadParts,
24};
25
26// ──────────────────────────────────────────────
27// Error type
28// ──────────────────────────────────────────────
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31pub enum EnumerationError {
32    #[error("stereo error: {0}")]
33    StereoError(#[from] crate::StereoError),
34
35    #[error("canonical isomeric SMILES generation failed: {0}")]
36    SmilesWrite(#[from] crate::SmilesWriteError),
37
38    #[error("stereoisomer enumeration operation failed: {0}")]
39    Operation(#[from] crate::OperationError),
40
41    #[error("stereoisomer embedding failed: {0}")]
42    DistanceGeometry(#[from] crate::DgBoundsError),
43
44    #[error("stereoisomer output violates molecule invariants: {0}")]
45    Invariant(#[from] crate::InvariantError),
46
47    #[error(transparent)]
48    PotentialStereo(#[from] PotentialStereoError),
49
50    #[error("stereo flipper atom index {atom} is outside the molecule")]
51    InvalidFlipperAtom { atom: AtomId },
52
53    #[error("stereo flipper bond index {bond} is outside the molecule")]
54    InvalidFlipperBond { bond: BondId },
55
56    #[error("random bit source failed for {bit_count} centers: {message}")]
57    RandomBitsSource { bit_count: usize, message: String },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) struct FlipperSelectionOptions {
62    pub(crate) only_unassigned: bool,
63    pub(crate) only_stereo_groups: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) enum StereoFlipper {
68    Atom {
69        atom: AtomId,
70    },
71    Bond {
72        bond: BondId,
73    },
74    StereoGroup {
75        original_parities: Vec<(AtomId, ChiralTag)>,
76    },
77}
78
79impl StereoFlipper {
80    pub(crate) fn flip(&self, molecule: &mut Molecule, flag: bool) -> Result<(), EnumerationError> {
81        match self {
82            Self::Atom { atom } => {
83                // RDKit✔️✔️: class _AtomFlipper(object):
84                // RDKit✔️✔️:
85                // RDKit✔️✔️:   def __init__(self, atom):
86                // RDKit✔️✔️:     self.atom = atom
87                // RDKit✔️✔️:
88                // RDKit✔️✔️:   def flip(self, flag):
89                // RDKit✔️✔️:     if flag:
90                // RDKit✔️✔️:       self.atom.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CW)
91                // RDKit✔️✔️:     else:
92                // RDKit✔️✔️:       self.atom.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CCW)
93                let atom_state = molecule
94                    .topology_block_mut()
95                    .atoms
96                    .get_mut(atom.index())
97                    .ok_or(EnumerationError::InvalidFlipperAtom { atom: *atom })?;
98                atom_state.set_chiral_tag(if flag {
99                    ChiralTag::TetrahedralCw
100                } else {
101                    ChiralTag::TetrahedralCcw
102                });
103            }
104            Self::Bond { bond } => {
105                // RDKit✔️✔️: class _BondFlipper(object):
106                // RDKit✔️✔️:
107                // RDKit✔️✔️:   def __init__(self, bond):
108                // RDKit✔️✔️:     self.bond = bond
109                // RDKit✔️✔️:
110                // RDKit✔️✔️:   def flip(self, flag):
111                // RDKit✔️✔️:     if flag:
112                // RDKit✔️✔️:       self.bond.SetStereo(Chem.BondStereo.STEREOCIS)
113                // RDKit✔️✔️:     else:
114                // RDKit✔️✔️:       self.bond.SetStereo(Chem.BondStereo.STEREOTRANS)
115                let bond_state = molecule
116                    .topology_block_mut()
117                    .bonds
118                    .get_mut(bond.index())
119                    .ok_or(EnumerationError::InvalidFlipperBond { bond: *bond })?;
120                bond_state.set_stereo(if flag {
121                    BondStereo::Cis
122                } else {
123                    BondStereo::Trans
124                });
125            }
126            Self::StereoGroup { original_parities } => {
127                // RDKit✔️✔️: class _StereoGroupFlipper(object):
128                // RDKit✔️✔️:
129                // RDKit✔️✔️:   def __init__(self, group):
130                // RDKit✔️✔️:     self._original_parities = [(a, a.GetChiralTag()) for a in group.GetAtoms()]
131                // RDKit✔️✔️:
132                // RDKit✔️✔️:   def flip(self, flag):
133                // RDKit✔️✔️:     if flag:
134                // RDKit✔️✔️:       for a, original_parity in self._original_parities:
135                // RDKit✔️✔️:         a.SetChiralTag(original_parity)
136                // RDKit✔️✔️:     else:
137                // RDKit✔️✔️:       for a, original_parity in self._original_parities:
138                // RDKit✔️✔️:         if original_parity == Chem.ChiralType.CHI_TETRAHEDRAL_CW:
139                // RDKit✔️✔️:           a.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CCW)
140                // RDKit✔️✔️:         elif original_parity == Chem.ChiralType.CHI_TETRAHEDRAL_CCW:
141                // RDKit✔️✔️:           a.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CW)
142                let topology = molecule.topology_block_mut();
143                for (atom, original_parity) in original_parities {
144                    let atom_state = topology
145                        .atoms
146                        .get_mut(atom.index())
147                        .ok_or(EnumerationError::InvalidFlipperAtom { atom: *atom })?;
148                    let parity = if flag {
149                        *original_parity
150                    } else {
151                        match original_parity {
152                            ChiralTag::TetrahedralCw => ChiralTag::TetrahedralCcw,
153                            ChiralTag::TetrahedralCcw => ChiralTag::TetrahedralCw,
154                            other => *other,
155                        }
156                    };
157                    atom_state.set_chiral_tag(parity);
158                }
159            }
160        }
161        Ok(())
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Hash)]
166pub(crate) struct ConfigurationBits {
167    value: BigUint,
168}
169
170impl ConfigurationBits {
171    fn zero() -> Self {
172        Self {
173            value: BigUint::from(0_u8),
174        }
175    }
176
177    fn from_biguint(value: BigUint) -> Self {
178        Self { value }
179    }
180
181    pub(crate) fn bit(&self, index: usize) -> bool {
182        self.value.bit(index as u64)
183    }
184
185    pub(crate) fn value(&self) -> &BigUint {
186        &self.value
187    }
188
189    fn embedding_seed(&self) -> i32 {
190        // RDKit✔️✔️:       # mask bitflag to fit within C++ int.
191        // RDKit✔️✔️:       cid = EmbedMolecule(ntm, randomSeed=(bitflag & 0x7fffffff))
192        self.value.iter_u32_digits().next().unwrap_or(0) as i32 & 0x7fff_ffff
193    }
194}
195
196pub(crate) trait RandomBitsSource {
197    fn getrandbits(&mut self, bit_count: usize) -> Result<ConfigurationBits, EnumerationError>;
198}
199
200struct CallbackRandomBitsSource<F> {
201    callback: F,
202}
203
204impl<F> RandomBitsSource for CallbackRandomBitsSource<F>
205where
206    F: FnMut(usize) -> Result<BigUint, String>,
207{
208    fn getrandbits(&mut self, bit_count: usize) -> Result<ConfigurationBits, EnumerationError> {
209        (self.callback)(bit_count)
210            .map(ConfigurationBits::from_biguint)
211            .map_err(|message| EnumerationError::RandomBitsSource { bit_count, message })
212    }
213}
214
215const PYTHON_MT_STATE_SIZE: usize = 624;
216const PYTHON_MT_MIDDLE_WORD: usize = 397;
217const PYTHON_MT_MATRIX_A: u32 = 0x9908_b0df;
218const PYTHON_MT_UPPER_MASK: u32 = 0x8000_0000;
219const PYTHON_MT_LOWER_MASK: u32 = 0x7fff_ffff;
220
221#[derive(Clone)]
222pub(crate) struct PythonRandom {
223    index: usize,
224    state: [u32; PYTHON_MT_STATE_SIZE],
225}
226
227impl PythonRandom {
228    pub(crate) fn from_integer_seed(seed: &BigInt) -> Self {
229        // CPython✔️✔️: /* This algorithm relies on the number being unsigned.
230        // CPython✔️✔️:  * So: if the arg is a PyLong, use its absolute value.
231        // CPython✔️✔️:  * Otherwise use its hash value, cast to unsigned.
232        // CPython✔️✔️:  */
233        // CPython✔️✔️: if (PyLong_CheckExact(arg)) {
234        // CPython✔️✔️:     n = PyNumber_Absolute(arg);
235        // CPython✔️✔️: }
236        // CPython✔️✔️: /* Now split n into 32-bit chunks, from the right. */
237        // CPython✔️✔️: bits = _PyLong_NumBits(n);
238        // CPython✔️✔️: /* Figure out how many 32-bit chunks this gives us. */
239        // CPython✔️✔️: keyused = bits == 0 ? 1 : (bits - 1) / 32 + 1;
240        let mut key = seed.magnitude().to_u32_digits();
241        if key.is_empty() {
242            key.push(0);
243        }
244        Self::from_seed_words(&key)
245    }
246
247    fn from_seed_words(key: &[u32]) -> Self {
248        let mut random = Self {
249            index: PYTHON_MT_STATE_SIZE,
250            state: [0; PYTHON_MT_STATE_SIZE],
251        };
252        random.init_by_array(key);
253        random
254    }
255
256    fn init_genrand(&mut self, seed: u32) {
257        // CPython✔️✔️: mt[0]= s;
258        // CPython✔️✔️: for (mti=1; mti<N; mti++) {
259        // CPython✔️✔️:     mt[mti] =
260        // CPython✔️✔️:     (1812433253U * (mt[mti-1] ^ (mt[mti-1] >> 30)) + mti);
261        // CPython✔️✔️: }
262        self.state[0] = seed;
263        for index in 1..PYTHON_MT_STATE_SIZE {
264            self.state[index] = 1_812_433_253_u32
265                .wrapping_mul(self.state[index - 1] ^ (self.state[index - 1] >> 30))
266                .wrapping_add(index as u32);
267        }
268        self.index = PYTHON_MT_STATE_SIZE;
269    }
270
271    fn init_by_array(&mut self, key: &[u32]) {
272        // CPython✔️✔️: init_genrand(self, 19650218U);
273        // CPython✔️✔️: i=1; j=0;
274        // CPython✔️✔️: k = (N>key_length ? N : key_length);
275        // CPython✔️✔️: for (; k; k--) {
276        // CPython✔️✔️:     mt[i] = (mt[i] ^ ((mt[i-1] ^ (mt[i-1] >> 30)) * 1664525U))
277        // CPython✔️✔️:              + init_key[j] + (uint32_t)j; /* non linear */
278        // CPython✔️✔️:     i++; j++;
279        // CPython✔️✔️:     if (i>=N) { mt[0] = mt[N-1]; i=1; }
280        // CPython✔️✔️:     if (j>=key_length) j=0;
281        // CPython✔️✔️: }
282        // CPython✔️✔️: for (k=N-1; k; k--) {
283        // CPython✔️✔️:     mt[i] = (mt[i] ^ ((mt[i-1] ^ (mt[i-1] >> 30)) * 1566083941U))
284        // CPython✔️✔️:              - (uint32_t)i; /* non linear */
285        // CPython✔️✔️:     i++;
286        // CPython✔️✔️:     if (i>=N) { mt[0] = mt[N-1]; i=1; }
287        // CPython✔️✔️: }
288        // CPython✔️✔️: mt[0] = 0x80000000U; /* MSB is 1; assuring non-zero initial array */
289        debug_assert!(!key.is_empty());
290        self.init_genrand(19_650_218);
291        let mut state_index = 1;
292        let mut key_index = 0;
293        for _ in 0..PYTHON_MT_STATE_SIZE.max(key.len()) {
294            self.state[state_index] = (self.state[state_index]
295                ^ (self.state[state_index - 1] ^ (self.state[state_index - 1] >> 30))
296                    .wrapping_mul(1_664_525))
297            .wrapping_add(key[key_index])
298            .wrapping_add(key_index as u32);
299            state_index += 1;
300            key_index += 1;
301            if state_index >= PYTHON_MT_STATE_SIZE {
302                self.state[0] = self.state[PYTHON_MT_STATE_SIZE - 1];
303                state_index = 1;
304            }
305            if key_index >= key.len() {
306                key_index = 0;
307            }
308        }
309        for _ in 0..PYTHON_MT_STATE_SIZE - 1 {
310            self.state[state_index] = (self.state[state_index]
311                ^ (self.state[state_index - 1] ^ (self.state[state_index - 1] >> 30))
312                    .wrapping_mul(1_566_083_941))
313            .wrapping_sub(state_index as u32);
314            state_index += 1;
315            if state_index >= PYTHON_MT_STATE_SIZE {
316                self.state[0] = self.state[PYTHON_MT_STATE_SIZE - 1];
317                state_index = 1;
318            }
319        }
320        self.state[0] = 0x8000_0000;
321    }
322
323    fn genrand_uint32(&mut self) -> u32 {
324        // CPython✔️✔️: if (self->index >= N) { /* generate N words at one time */
325        // CPython✔️✔️:     int kk;
326        // CPython✔️✔️:     for (kk=0;kk<N-M;kk++) {
327        // CPython✔️✔️:         y = (mt[kk]&UPPER_MASK)|(mt[kk+1]&LOWER_MASK);
328        // CPython✔️✔️:         mt[kk] = mt[kk+M] ^ (y >> 1) ^ mag01[y & 0x1U];
329        // CPython✔️✔️:     }
330        // CPython✔️✔️:     for (;kk<N-1;kk++) {
331        // CPython✔️✔️:         y = (mt[kk]&UPPER_MASK)|(mt[kk+1]&LOWER_MASK);
332        // CPython✔️✔️:         mt[kk] = mt[kk+(M-N)] ^ (y >> 1) ^ mag01[y & 0x1U];
333        // CPython✔️✔️:     }
334        // CPython✔️✔️:     y = (mt[N-1]&UPPER_MASK)|(mt[0]&LOWER_MASK);
335        // CPython✔️✔️:     mt[N-1] = mt[M-1] ^ (y >> 1) ^ mag01[y & 0x1U];
336        // CPython✔️✔️:     self->index = 0;
337        // CPython✔️✔️: }
338        if self.index >= PYTHON_MT_STATE_SIZE {
339            for index in 0..PYTHON_MT_STATE_SIZE - PYTHON_MT_MIDDLE_WORD {
340                let value = (self.state[index] & PYTHON_MT_UPPER_MASK)
341                    | (self.state[index + 1] & PYTHON_MT_LOWER_MASK);
342                self.state[index] = self.state[index + PYTHON_MT_MIDDLE_WORD]
343                    ^ (value >> 1)
344                    ^ if value & 1 == 0 {
345                        0
346                    } else {
347                        PYTHON_MT_MATRIX_A
348                    };
349            }
350            for index in PYTHON_MT_STATE_SIZE - PYTHON_MT_MIDDLE_WORD..PYTHON_MT_STATE_SIZE - 1 {
351                let value = (self.state[index] & PYTHON_MT_UPPER_MASK)
352                    | (self.state[index + 1] & PYTHON_MT_LOWER_MASK);
353                self.state[index] = self.state
354                    [index + PYTHON_MT_MIDDLE_WORD - PYTHON_MT_STATE_SIZE]
355                    ^ (value >> 1)
356                    ^ if value & 1 == 0 {
357                        0
358                    } else {
359                        PYTHON_MT_MATRIX_A
360                    };
361            }
362            let value = (self.state[PYTHON_MT_STATE_SIZE - 1] & PYTHON_MT_UPPER_MASK)
363                | (self.state[0] & PYTHON_MT_LOWER_MASK);
364            self.state[PYTHON_MT_STATE_SIZE - 1] = self.state[PYTHON_MT_MIDDLE_WORD - 1]
365                ^ (value >> 1)
366                ^ if value & 1 == 0 {
367                    0
368                } else {
369                    PYTHON_MT_MATRIX_A
370                };
371            self.index = 0;
372        }
373
374        // CPython✔️✔️: y = mt[self->index++];
375        // CPython✔️✔️: y ^= (y >> 11);
376        // CPython✔️✔️: y ^= (y << 7) & 0x9d2c5680U;
377        // CPython✔️✔️: y ^= (y << 15) & 0xefc60000U;
378        // CPython✔️✔️: y ^= (y >> 18);
379        // CPython✔️✔️: return y;
380        let mut value = self.state[self.index];
381        self.index += 1;
382        value ^= value >> 11;
383        value ^= (value << 7) & 0x9d2c_5680;
384        value ^= (value << 15) & 0xefc6_0000;
385        value ^= value >> 18;
386        value
387    }
388}
389
390impl RandomBitsSource for PythonRandom {
391    fn getrandbits(&mut self, bit_count: usize) -> Result<ConfigurationBits, EnumerationError> {
392        // CPython✔️✔️: if (k < 0) {
393        // CPython✔️✔️:     PyErr_SetString(PyExc_ValueError,
394        // CPython✔️✔️:                     "number of bits must be non-negative");
395        // CPython✔️✔️:     return NULL;
396        // CPython✔️✔️: }
397        // The Rust boundary is `usize`, so the source's negative branch is
398        // structurally unrepresentable.
399        // CPython✔️✔️: if (k == 0)
400        // CPython✔️✔️:     return PyLong_FromLong(0);
401        if bit_count == 0 {
402            return Ok(ConfigurationBits::zero());
403        }
404        // CPython✔️✔️: if (k <= 32)  /* Fast path */
405        // CPython✔️✔️:     return PyLong_FromUnsignedLong(genrand_uint32(self) >> (32 - k));
406        if bit_count <= 32 {
407            return Ok(ConfigurationBits::from_biguint(BigUint::from(
408                self.genrand_uint32() >> (32 - bit_count),
409            )));
410        }
411
412        // CPython✔️✔️: words = (Py_ssize_t)((k - 1u) / 32u + 1u);
413        // CPython✔️✔️: /* Fill-out bits of long integer, by 32-bit words, from least significant
414        // CPython✔️✔️:    to most significant. */
415        // CPython✔️✔️: for (i = 0; i < words; i++, k -= 32)
416        // CPython✔️✔️: {
417        // CPython✔️✔️:     r = genrand_uint32(self);
418        // CPython✔️✔️:     if (k < 32)
419        // CPython✔️✔️:         r >>= (32 - k);  /* Drop least significant bits */
420        // CPython✔️✔️:     wordarray[i] = r;
421        // CPython✔️✔️: }
422        let word_count = (bit_count - 1) / 32 + 1;
423        let mut remaining = bit_count;
424        let mut value = BigUint::from(0_u8);
425        for word_index in 0..word_count {
426            let mut word = self.genrand_uint32();
427            if remaining < 32 {
428                word >>= 32 - remaining;
429            }
430            value |= BigUint::from(word) << (word_index * 32);
431            remaining = remaining.saturating_sub(32);
432        }
433        Ok(ConfigurationBits::from_biguint(value))
434    }
435}
436
437fn cpython_tuple_hash(values: &[i64]) -> i64 {
438    const XXPRIME_1: u64 = 11_400_714_785_074_694_791;
439    const XXPRIME_2: u64 = 14_029_467_366_897_019_727;
440    const XXPRIME_5: u64 = 2_870_177_450_012_600_261;
441
442    // CPython✔️✔️: Py_uhash_t acc = _PyHASH_XXPRIME_5;
443    // CPython✔️✔️: for (i = 0; i < len; i++) {
444    // CPython✔️✔️:     Py_uhash_t lane = PyObject_Hash(item[i]);
445    // CPython✔️✔️:     if (lane == (Py_uhash_t)-1) {
446    // CPython✔️✔️:         return -1;
447    // CPython✔️✔️:     }
448    // CPython✔️✔️:     acc += lane * _PyHASH_XXPRIME_2;
449    // CPython✔️✔️:     acc = _PyHASH_XXROTATE(acc);
450    // CPython✔️✔️:     acc *= _PyHASH_XXPRIME_1;
451    // CPython✔️✔️: }
452    let mut accumulator = XXPRIME_5;
453    for &value in values {
454        let lane = value as u64;
455        accumulator = accumulator.wrapping_add(lane.wrapping_mul(XXPRIME_2));
456        accumulator = accumulator.rotate_left(31);
457        accumulator = accumulator.wrapping_mul(XXPRIME_1);
458    }
459    // CPython✔️✔️: /* Add input length, mangled to keep the historical value of hash(()). */
460    // CPython✔️✔️: acc += len ^ (_PyHASH_XXPRIME_5 ^ 3527539UL);
461    accumulator = accumulator.wrapping_add((values.len() as u64) ^ (XXPRIME_5 ^ 3_527_539));
462    // CPython✔️✔️: if (acc == (Py_uhash_t)-1) {
463    // CPython✔️✔️:     return 1546275796;
464    // CPython✔️✔️: }
465    // CPython✔️✔️: return acc;
466    if accumulator == u64::MAX {
467        1_546_275_796
468    } else {
469        accumulator as i64
470    }
471}
472
473fn default_python_random_seed(molecule: &Molecule) -> BigInt {
474    // RDKit✔️✔️: if options.rand is None:
475    // RDKit✔️✔️:   # deterministic random seed invariant to input atom order
476    // RDKit✔️✔️:   seed = hash(tuple(sorted([(a.GetDegree(), a.GetAtomicNum()) for a in tm.GetAtoms()])))
477    // RDKit✔️✔️:   rand = random.Random(seed)
478    let adjacency = AdjacencyList::from_topology(molecule.num_atoms(), molecule.bonds());
479    let mut atom_invariants = molecule
480        .atoms()
481        .iter()
482        .map(|atom| {
483            (
484                adjacency.neighbors_of(atom.id().index()).len(),
485                atom.atomic_number(),
486            )
487        })
488        .collect::<Vec<_>>();
489    atom_invariants.sort_unstable();
490    let inner_hashes = atom_invariants
491        .into_iter()
492        .map(|(degree, atomic_number)| {
493            cpython_tuple_hash(&[degree as i64, i64::from(atomic_number)])
494        })
495        .collect::<Vec<_>>();
496    BigInt::from(cpython_tuple_hash(&inner_hashes))
497}
498
499fn theoretical_configuration_count(center_count: usize) -> BigUint {
500    BigUint::from(1_u8) << center_count
501}
502
503pub(crate) struct RangeBitsGenerator {
504    next: BigUint,
505    end: BigUint,
506}
507
508impl RangeBitsGenerator {
509    pub(crate) fn new(center_count: usize) -> Self {
510        // RDKit✔️✔️: class _RangeBitsGenerator(object):
511        // RDKit✔️✔️:
512        // RDKit✔️✔️:   def __init__(self, nCenters):
513        // RDKit✔️✔️:     self.nCenters = nCenters
514        Self {
515            next: BigUint::from(0_u8),
516            end: theoretical_configuration_count(center_count),
517        }
518    }
519}
520
521impl Iterator for RangeBitsGenerator {
522    type Item = ConfigurationBits;
523
524    fn next(&mut self) -> Option<Self::Item> {
525        // RDKit✔️✔️:   def __iter__(self):
526        // RDKit✔️✔️:     for val in range(2**self.nCenters):
527        // RDKit✔️✔️:       yield val
528        if self.next >= self.end {
529            return None;
530        }
531        let value = self.next.clone();
532        self.next += 1_u8;
533        Some(ConfigurationBits::from_biguint(value))
534    }
535
536    fn size_hint(&self) -> (usize, Option<usize>) {
537        let remaining = &self.end - &self.next;
538        match usize::try_from(&remaining) {
539            Ok(remaining) => (remaining, Some(remaining)),
540            Err(_) => (usize::MAX, None),
541        }
542    }
543}
544
545pub(crate) struct UniqueRandomBitsGenerator<R> {
546    center_count: usize,
547    _max_isomers: usize,
548    random: R,
549    already_seen: HashSet<ConfigurationBits>,
550    theoretical_count: BigUint,
551}
552
553impl<R> UniqueRandomBitsGenerator<R> {
554    pub(crate) fn new(center_count: usize, max_isomers: usize, random: R) -> Self {
555        // RDKit✔️✔️: class _UniqueRandomBitsGenerator(object):
556        // RDKit✔️✔️:
557        // RDKit✔️✔️:   def __init__(self, nCenters, maxIsomers, rand):
558        // RDKit✔️✔️:     self.nCenters = nCenters
559        // RDKit✔️✔️:     self.maxIsomers = maxIsomers
560        // RDKit✔️✔️:     self.rand = rand
561        // RDKit✔️✔️:     self.already_seen = set()
562        Self {
563            center_count,
564            _max_isomers: max_isomers,
565            random,
566            already_seen: HashSet::new(),
567            theoretical_count: theoretical_configuration_count(center_count),
568        }
569    }
570}
571
572impl<R: RandomBitsSource> Iterator for UniqueRandomBitsGenerator<R> {
573    type Item = Result<ConfigurationBits, EnumerationError>;
574
575    fn next(&mut self) -> Option<Self::Item> {
576        // RDKit✔️✔️:   def __iter__(self):
577        // RDKit✔️✔️:     # note: important that this is not 'while True' otherwise it
578        // RDKit✔️✔️:     # would be possible to have an infinite loop caused by all
579        // RDKit✔️✔️:     # isomers failing the embedding process
580        // RDKit✔️✔️:     while len(self.already_seen) < 2**self.nCenters:
581        while BigUint::from(self.already_seen.len()) < self.theoretical_count {
582            // RDKit✔️✔️:       bits = self.rand.getrandbits(self.nCenters)
583            let bits = match self.random.getrandbits(self.center_count) {
584                Ok(bits) => bits,
585                Err(error) => return Some(Err(error)),
586            };
587            // RDKit✔️✔️:       if bits in self.already_seen:
588            // RDKit✔️✔️:         continue
589            if !self.already_seen.insert(bits.clone()) {
590                continue;
591            }
592
593            // RDKit✔️✔️:       self.already_seen.add(bits)
594            // RDKit✔️✔️:       yield bits
595            return Some(Ok(bits));
596        }
597        None
598    }
599}
600
601pub(crate) fn select_stereo_flippers(
602    molecule: &mut Molecule,
603    options: FlipperSelectionOptions,
604) -> Result<Vec<StereoFlipper>, EnumerationError> {
605    // RDKit✔️✔️: def _getFlippers(mol, options):
606    // RDKit✔️✔️:   sinfo = Chem.FindPotentialStereo(mol)
607    // RDKit✔️✔️:   flippers = []
608    // RDKit✔️✔️:   if not options.onlyStereoGroups:
609    // RDKit✔️✔️:     for si in sinfo:
610    // RDKit✔️✔️:       if options.onlyUnassigned and si.specified not in (Chem.StereoSpecified.Unspecified,
611    // RDKit✔️✔️:                                                          Chem.StereoSpecified.Unknown):
612    // RDKit✔️✔️:         continue
613    // RDKit✔️✔️:       if si.type == Chem.StereoType.Atom_Tetrahedral:
614    // RDKit✔️✔️:         flippers.append(_AtomFlipper(mol.GetAtomWithIdx(si.centeredOn)))
615    // RDKit✔️✔️:       elif si.type == Chem.StereoType.Bond_Double:
616    // RDKit✔️✔️:         bnd = mol.GetBondWithIdx(si.centeredOn)
617    // RDKit✔️✔️:         if not bnd.GetStereoAtoms():
618    // RDKit✔️✔️:           if si.controllingAtoms[0] == Chem.Atom.NOATOM or \
619    // RDKit✔️✔️:             si.controllingAtoms[2] == Chem.Atom.NOATOM:
620    // RDKit✔️✔️:             continue
621    // RDKit✔️✔️:           bnd.SetStereoAtoms(si.controllingAtoms[0], si.controllingAtoms[2])
622    // RDKit✔️✔️:         flippers.append(_BondFlipper(mol.GetBondWithIdx(si.centeredOn)))
623    // RDKit✔️✔️:       ## FIX: support atropisomers
624    // RDKit✔️✔️:
625    // RDKit✔️✔️:   if options.onlyUnassigned:
626    // RDKit✔️✔️:     # otherwise these will be counted twice
627    // RDKit✔️✔️:     for group in mol.GetStereoGroups():
628    // RDKit✔️✔️:       if group.GetGroupType() != Chem.StereoGroupType.STEREO_ABSOLUTE:
629    // RDKit✔️✔️:         flippers.append(_StereoGroupFlipper(group))
630    // RDKit✔️✔️:
631    // RDKit✔️✔️:   return flippers
632    let stereo_info = find_potential_stereo_in_workspace(molecule, false, true)?;
633    select_stereo_flippers_from_info(molecule, &stereo_info, options)
634}
635
636fn select_stereo_flippers_from_info(
637    molecule: &mut Molecule,
638    stereo_info: &[StereoInfo],
639    options: FlipperSelectionOptions,
640) -> Result<Vec<StereoFlipper>, EnumerationError> {
641    let mut flippers = Vec::new();
642
643    if !options.only_stereo_groups {
644        for info in stereo_info {
645            if options.only_unassigned
646                && !matches!(
647                    info.specified(),
648                    StereoSpecified::Unspecified | StereoSpecified::Unknown
649                )
650            {
651                continue;
652            }
653            match (info.stereo_type(), info.center()) {
654                (StereoType::AtomTetrahedral, StereoCenter::Atom(atom)) => {
655                    flippers.push(StereoFlipper::Atom { atom });
656                }
657                (StereoType::BondDouble, StereoCenter::Bond(bond)) => {
658                    let bond_state = molecule
659                        .bonds()
660                        .get(bond.index())
661                        .ok_or(EnumerationError::InvalidFlipperBond { bond })?;
662                    if bond_state.stereo_atoms().is_none() {
663                        let Some(ControllingAtom::Atom(begin_controller)) =
664                            info.controlling_atoms().first().copied()
665                        else {
666                            continue;
667                        };
668                        let Some(ControllingAtom::Atom(end_controller)) =
669                            info.controlling_atoms().get(2).copied()
670                        else {
671                            continue;
672                        };
673                        let bond_state = molecule
674                            .topology_block_mut()
675                            .bonds
676                            .get_mut(bond.index())
677                            .ok_or(EnumerationError::InvalidFlipperBond { bond })?;
678                        bond_state.set_stereo_atoms(Some([begin_controller, end_controller]));
679                    }
680                    flippers.push(StereoFlipper::Bond { bond });
681                }
682                // The Python source deliberately has no atropisomer flipper branch.
683                (StereoType::BondAtropisomer, StereoCenter::Bond(_)) => {}
684                _ => {}
685            }
686        }
687    }
688
689    if options.only_unassigned {
690        for group in molecule.stereo_groups() {
691            if group.kind() == StereoGroupKind::Absolute {
692                continue;
693            }
694            let mut original_parities = Vec::with_capacity(group.atoms().len());
695            for atom in group.atoms() {
696                let atom_state = molecule
697                    .atoms()
698                    .get(atom.index())
699                    .ok_or(EnumerationError::InvalidFlipperAtom { atom: *atom })?;
700                original_parities.push((*atom, atom_state.chiral_tag()));
701            }
702            flippers.push(StereoFlipper::StereoGroup { original_parities });
703        }
704    }
705
706    Ok(flippers)
707}
708
709#[derive(Debug)]
710pub(crate) struct EnumerationWorkspace {
711    molecule: Molecule,
712    flippers: Vec<StereoFlipper>,
713}
714
715impl EnumerationWorkspace {
716    pub(crate) fn prepare(
717        source: &Molecule,
718        options: FlipperSelectionOptions,
719    ) -> Result<Self, EnumerationError> {
720        // RDKit✔️✔️:   tm = Chem.Mol(m)
721        let mut molecule = source.clone();
722
723        // RDKit✔️✔️:   for atom in tm.GetAtoms():
724        // RDKit✔️✔️:     atom.ClearProp("_CIPCode")
725        // RDKit✔️✔️:   for bond in tm.GetBonds():
726        // RDKit✔️✔️:     if bond.GetBondDir() == Chem.BondDir.EITHERDOUBLE or bond.GetBondDir() == Chem.BondDir.UNKNOWN:
727        // RDKit✔️✔️:       bond.SetBondDir(Chem.BondDir.NONE)
728        let topology = molecule.topology_block_mut();
729        for atom in &mut topology.atoms {
730            atom.clear_prop("_CIPCode");
731        }
732        for bond in &mut topology.bonds {
733            if matches!(
734                bond.direction(),
735                BondDirection::EitherDouble | BondDirection::Unknown
736            ) {
737                bond.set_direction(BondDirection::None);
738            }
739        }
740
741        // RDKit✔️✔️:   flippers = _getFlippers(tm, options)
742        // RDKit✔️✔️:   nCenters = len(flippers)
743        let flippers = select_stereo_flippers(&mut molecule, options)?;
744
745        // RDKit✔️✔️:   if not nCenters:
746        // RDKit✔️✔️:     yield tm
747        // RDKit✔️✔️:     return
748        // The no-center yield is owned by the later lazy iterator. Preserving
749        // an empty flipper list here keeps that branch explicit without
750        // applying the chiral flag reserved for enumerated workspaces.
751        if !flippers.is_empty() {
752            // RDKit✔️✔️:   tm.SetProp('_MolFileChiralFlag', '1')
753            molecule
754                .properties_mut()
755                .set_prop("_MolFileChiralFlag", "1");
756        }
757
758        Ok(Self { molecule, flippers })
759    }
760
761    pub(crate) fn center_count(&self) -> usize {
762        self.flippers.len()
763    }
764
765    pub(crate) fn molecule(&self) -> &Molecule {
766        &self.molecule
767    }
768
769    pub(crate) fn into_molecule(self) -> Molecule {
770        self.molecule
771    }
772
773    pub(crate) fn apply_configuration(
774        &mut self,
775        configuration: &ConfigurationBits,
776    ) -> Result<(), EnumerationError> {
777        // RDKit✔️✔️:   for bitflag in bitsource:
778        // RDKit✔️✔️:     for i in range(nCenters):
779        // RDKit✔️✔️:       flag = bool(bitflag & (1 << i))
780        // RDKit✔️✔️:       flippers[i].flip(flag)
781        for (index, flipper) in self.flippers.iter().enumerate() {
782            flipper.flip(&mut self.molecule, configuration.bit(index))?;
783        }
784        Ok(())
785    }
786
787    pub(crate) fn finalize_configuration(
788        &self,
789        unique: bool,
790        isomers_seen: &mut HashSet<String>,
791    ) -> Result<Option<Molecule>, EnumerationError> {
792        // RDKit✔️✔️:     # from this point on we no longer need the stereogroups (if any are there), so
793        // RDKit✔️✔️:     # remove them:
794        // RDKit✔️✔️:     if tm.GetStereoGroups():
795        // RDKit✔️✔️:       isomer = Chem.RWMol(tm)
796        // RDKit✔️✔️:       isomer.SetStereoGroups([])
797        // RDKit✔️✔️:     else:
798        // RDKit✔️✔️:       isomer = Chem.Mol(tm)
799        let mut isomer = self.molecule.clone();
800        if !isomer.stereo_groups().is_empty() {
801            isomer.topology_block_mut().stereo_groups.clear();
802        }
803
804        // RDKit✔️✔️:     Chem.SetDoubleBondNeighborDirections(isomer)
805        crate::notation::smiles::set_double_bond_neighbor_directions_from_stereo(&mut isomer)?;
806
807        // RDKit✔️✔️:     isomer.ClearComputedProps(includeRings=False)
808        clear_computed_props_preserving_rings(&mut isomer);
809
810        // RDKit✔️✔️:     Chem.AssignStereochemistry(isomer, cleanIt=True, force=True, flagPossibleStereoCenters=True)
811        // `ClearComputedProps()` removed `_StereochemDone`, so the source's
812        // `force=True` branch cannot return early. The pinned RDKit reference
813        // uses legacy stereo perception, which is the shared implementation
814        // called here with the remaining two source arguments set to true.
815        crate::notation::smiles::assign_stereochemistry_cleanup_subset(&mut isomer, true)?;
816
817        // RDKit✔️✔️:     if options.unique:
818        // RDKit✔️✔️:       cansmi = Chem.MolToSmiles(isomer, isomericSmiles=True)
819        // RDKit✔️✔️:       if cansmi in isomersSeen:
820        // RDKit✔️✔️:         continue
821        // RDKit✔️✔️:
822        // RDKit✔️✔️:       isomersSeen.add(cansmi)
823        if unique {
824            let canonical_isomeric_smiles =
825                MoleculeReadParts::from_molecule(&isomer).canonical_isomeric_smiles()?;
826            if !isomers_seen.insert(canonical_isomeric_smiles) {
827                return Ok(None);
828            }
829        }
830
831        Ok(Some(isomer))
832    }
833
834    pub(crate) fn apply_embedding_filter(
835        &self,
836        mut isomer: Molecule,
837        configuration: &ConfigurationBits,
838    ) -> Result<Option<Molecule>, EnumerationError> {
839        // RDKit✔️✔️:     if options.tryEmbedding:
840        // RDKit✔️✔️:       ntm = Chem.AddHs(isomer)
841        let with_hydrogens = isomer.with_hydrogens()?;
842
843        // RDKit✔️✔️:       # mask bitflag to fit within C++ int.
844        // RDKit✔️✔️:       cid = EmbedMolecule(ntm, randomSeed=(bitflag & 0x7fffffff))
845        // The Python call selects rdDistGeom's legacy keyword overload, whose
846        // defaults differ from both bare `EmbedParameters` and ETKDGv3.
847        let (embedded_with_hydrogens, conformer_id) =
848            crate::distgeom::rd_distgeom_embed_molecule_wrapper(
849                &with_hydrogens,
850                0,
851                configuration.embedding_seed(),
852                true,
853                false,
854                2.0,
855                true,
856                1,
857                std::collections::BTreeMap::new(),
858                1e-3,
859                false,
860                true,
861                true,
862                true,
863                false,
864                false,
865                true,
866                2,
867                true,
868            )?;
869
870        // RDKit✔️✔️:       if cid >= 0:
871        if conformer_id < 0 {
872            // RDKit✔️✔️:     if cid >= 0:
873            // The caller interprets `None` as the source's failed-embedding
874            // branch and advances the finite configuration source.
875            return Ok(None);
876        }
877
878        // RDKit✔️✔️:         conf = Chem.Conformer(isomer.GetNumAtoms())
879        // RDKit✔️✔️:         for aid in range(isomer.GetNumAtoms()):
880        // RDKit✔️✔️:           conf.SetAtomPosition(aid, ntm.GetConformer().GetAtomPosition(aid))
881        let heavy_atom_count = isomer.num_atoms();
882        let embedded_coordinates = embedded_with_hydrogens
883            .conformers_3d()
884            .first()
885            .ok_or_else(|| {
886                crate::DgBoundsError::CoordinateUpdateFailed(
887                    "EmbedMolecule returned a nonnegative conformer id without coordinates"
888                        .to_string(),
889                )
890            })?
891            .coordinates();
892        if embedded_coordinates.len() < heavy_atom_count {
893            return Err(crate::DgBoundsError::CoordinateUpdateFailed(
894                "embedded hydrogen-expanded conformer has fewer rows than the source molecule"
895                    .to_string(),
896            )
897            .into());
898        }
899        let coordinates = embedded_coordinates[..heavy_atom_count].to_vec();
900
901        // RDKit✔️✔️:         isomer.AddConformer(conf)
902        // Python's `AddConformer` wrapper defaults `assignId` to false, and a
903        // newly constructed RDKit conformer has id zero. Preserve that exact
904        // append behavior, including a duplicate id when conformers already
905        // exist, inside this private owned workspace.
906        let coordinate_block = isomer.coordinate_block_mut();
907        coordinate_block
908            .conformers_3d
909            .push(crate::Conformer3D::new(0, coordinates, true));
910        coordinate_block.source_coordinate_dim = Some(crate::CoordinateDimension::ThreeD);
911
912        // RDKit✔️✔️:     else:
913        // RDKit✔️✔️:       cid = 1
914        // The non-embedding branch is represented by the caller retaining the
915        // finalized isomer directly; this function is called only when the
916        // option is true.
917        // RDKit✔️✔️:     if cid >= 0:
918        Ok(Some(isomer))
919    }
920
921    pub(crate) fn finalize_configuration_with_embedding(
922        &self,
923        configuration: &ConfigurationBits,
924        unique: bool,
925        try_embedding: bool,
926        isomers_seen: &mut HashSet<String>,
927    ) -> Result<Option<Molecule>, EnumerationError> {
928        let Some(isomer) = self.finalize_configuration(unique, isomers_seen)? else {
929            // RDKit✔️✔️:       if cansmi in isomersSeen:
930            // RDKit✔️✔️:         continue
931            return Ok(None);
932        };
933
934        // RDKit✔️✔️:     if options.tryEmbedding:
935        if try_embedding {
936            self.apply_embedding_filter(isomer, configuration)
937        } else {
938            // RDKit✔️✔️:     else:
939            // RDKit✔️✔️:       cid = 1
940            // RDKit✔️✔️:     if cid >= 0:
941            Ok(Some(isomer))
942        }
943    }
944}
945
946fn clear_computed_props_preserving_rings(molecule: &mut Molecule) {
947    // RDKit✔️✔️: void ROMol::clearComputedProps(bool includeRings) const {
948    // RDKit✔️✔️:   // the SSSR information:
949    // RDKit✔️✔️:   if (includeRings) {
950    // RDKit✔️✔️:     this->dp_ringInfo->reset();
951    // RDKit✔️✔️:   }
952    // `includeRings` is false on the enumeration path, so the ring block is
953    // retained while the remaining derived state is reset.
954    let rings = molecule.derived_cache_mut().rings.take();
955    *molecule.derived_cache_mut() = DerivedCacheBlock::default();
956    molecule.derived_cache_mut().rings = rings;
957
958    // RDKit✔️✔️:   RDProps::clearComputedProps();
959    molecule.properties_mut().clear_computed_props();
960    molecule.clear_computed_property_cache();
961
962    // RDKit✔️✔️:   for (auto atom : atoms()) {
963    // RDKit✔️✔️:     atom->clearComputedProps();
964    // RDKit✔️✔️:   }
965    // RDKit✔️✔️:
966    // RDKit✔️✔️:   for (auto bond : bonds()) {
967    // RDKit✔️✔️:     bond->clearComputedProps();
968    // RDKit✔️✔️:   }
969    // RDKit✔️✔️: }
970    let topology = molecule.topology_block_mut();
971    for atom in &mut topology.atoms {
972        atom.clear_computed_props();
973    }
974    for bond in &mut topology.bonds {
975        bond.clear_computed_props();
976    }
977}
978
979// ──────────────────────────────────────────────
980// Python-parity lazy public API
981// ──────────────────────────────────────────────
982
983/// Options for lazy stereoisomer enumeration.
984///
985/// The defaults reproduce RDKit Python's `StereoEnumerationOptions`. A
986/// `random_seed` is consulted only when `max_isomers` selects the random
987/// configuration source; `None` uses RDKit's molecule-invariant default seed.
988#[derive(Debug, Clone, PartialEq, Eq, Hash)]
989pub struct StereoisomerOptions {
990    pub try_embedding: bool,
991    pub only_unassigned: bool,
992    pub only_stereo_groups: bool,
993    pub max_isomers: usize,
994    pub random_seed: Option<BigInt>,
995    pub unique: bool,
996}
997
998impl Default for StereoisomerOptions {
999    fn default() -> Self {
1000        // RDKit✔️✔️:   def __init__(self, tryEmbedding=False, onlyUnassigned=True, maxIsomers=1024, rand=None,
1001        // RDKit✔️✔️:                unique=True, onlyStereoGroups=False):
1002        // RDKit✔️✔️:     self.tryEmbedding = tryEmbedding
1003        // RDKit✔️✔️:     self.onlyUnassigned = onlyUnassigned
1004        // RDKit✔️✔️:     self.onlyStereoGroups = onlyStereoGroups
1005        // RDKit✔️✔️:     self.maxIsomers = maxIsomers
1006        // RDKit✔️✔️:     self.rand = rand
1007        // RDKit✔️✔️:     self.unique = unique
1008        Self {
1009            try_embedding: false,
1010            only_unassigned: true,
1011            only_stereo_groups: false,
1012            max_isomers: 1024,
1013            random_seed: None,
1014            unique: true,
1015        }
1016    }
1017}
1018
1019impl StereoisomerOptions {
1020    fn flipper_selection(&self) -> FlipperSelectionOptions {
1021        FlipperSelectionOptions {
1022            only_unassigned: self.only_unassigned,
1023            only_stereo_groups: self.only_stereo_groups,
1024        }
1025    }
1026}
1027
1028enum ConfigurationSource {
1029    Exhaustive(RangeBitsGenerator),
1030    Random(UniqueRandomBitsGenerator<PythonRandom>),
1031    Callback(Box<dyn Iterator<Item = Result<ConfigurationBits, EnumerationError>> + Send + Sync>),
1032}
1033
1034impl Iterator for ConfigurationSource {
1035    type Item = Result<ConfigurationBits, EnumerationError>;
1036
1037    fn next(&mut self) -> Option<Self::Item> {
1038        match self {
1039            Self::Exhaustive(source) => source.next().map(Ok),
1040            Self::Random(source) => source.next(),
1041            Self::Callback(source) => source.next(),
1042        }
1043    }
1044}
1045
1046/// Lazy iterator over source-ordered stereoisomers.
1047///
1048/// Construction performs the source-defined preprocessing and candidate
1049/// discovery. Configuration application, uniqueness, optional embedding, and
1050/// their errors are deferred until `next()` requests an output.
1051pub struct StereoisomerIterator {
1052    workspace: Option<EnumerationWorkspace>,
1053    configurations: Option<ConfigurationSource>,
1054    pending_no_center: Option<Molecule>,
1055    isomers_seen: HashSet<String>,
1056    unique: bool,
1057    try_embedding: bool,
1058    max_isomers: usize,
1059    yielded: usize,
1060    finished: bool,
1061}
1062
1063impl StereoisomerIterator {
1064    fn new(molecule: &Molecule, options: StereoisomerOptions) -> Result<Self, EnumerationError> {
1065        Self::new_with_configuration_source(molecule, options, None)
1066    }
1067
1068    fn new_with_configuration_source(
1069        molecule: &Molecule,
1070        options: StereoisomerOptions,
1071        callback: Option<Box<dyn FnMut(usize) -> Result<BigUint, String> + Send + Sync + 'static>>,
1072    ) -> Result<Self, EnumerationError> {
1073        // RDKit✔️✔️:   tm = Chem.Mol(m)
1074        // RDKit✔️✔️:   for atom in tm.GetAtoms():
1075        // RDKit✔️✔️:     atom.ClearProp("_CIPCode")
1076        // RDKit✔️✔️:   for bond in tm.GetBonds():
1077        // RDKit✔️✔️:     if bond.GetBondDir() == Chem.BondDir.EITHERDOUBLE or bond.GetBondDir() == Chem.BondDir.UNKNOWN:
1078        // RDKit✔️✔️:       bond.SetBondDir(Chem.BondDir.NONE)
1079        // RDKit✔️✔️:   flippers = _getFlippers(tm, options)
1080        // RDKit✔️✔️:   nCenters = len(flippers)
1081        let workspace = EnumerationWorkspace::prepare(molecule, options.flipper_selection())?;
1082        let center_count = workspace.center_count();
1083
1084        // RDKit✔️✔️:   if not nCenters:
1085        // RDKit✔️✔️:     yield tm
1086        // RDKit✔️✔️:     return
1087        if center_count == 0 {
1088            return Ok(Self {
1089                workspace: None,
1090                configurations: None,
1091                pending_no_center: Some(workspace.into_molecule()),
1092                isomers_seen: HashSet::new(),
1093                unique: options.unique,
1094                try_embedding: options.try_embedding,
1095                max_isomers: options.max_isomers,
1096                yielded: 0,
1097                finished: false,
1098            });
1099        }
1100
1101        // RDKit✔️✔️:   if (options.maxIsomers == 0 or 2**nCenters <= options.maxIsomers):
1102        // RDKit✔️✔️:     bitsource = _RangeBitsGenerator(nCenters)
1103        // RDKit✔️✔️:   else:
1104        // RDKit✔️✔️:     if options.rand is None:
1105        // RDKit✔️✔️:       seed = hash(tuple(sorted([(a.GetDegree(), a.GetAtomicNum()) for a in tm.GetAtoms()])))
1106        // RDKit✔️✔️:       rand = random.Random(seed)
1107        // RDKit✔️✔️:     else:
1108        // RDKit✔️✔️:       rand = random.Random(options.rand)
1109        // RDKit✔️✔️:     bitsource = _UniqueRandomBitsGenerator(nCenters, options.maxIsomers, rand)
1110        let configuration_count = theoretical_configuration_count(center_count);
1111        let configurations = if options.max_isomers == 0
1112            || configuration_count <= BigUint::from(options.max_isomers)
1113        {
1114            ConfigurationSource::Exhaustive(RangeBitsGenerator::new(center_count))
1115        } else if let Some(callback) = callback {
1116            ConfigurationSource::Callback(Box::new(UniqueRandomBitsGenerator::new(
1117                center_count,
1118                options.max_isomers,
1119                CallbackRandomBitsSource { callback },
1120            )))
1121        } else {
1122            let seed = options
1123                .random_seed
1124                .as_ref()
1125                .cloned()
1126                .unwrap_or_else(|| default_python_random_seed(workspace.molecule()));
1127            ConfigurationSource::Random(UniqueRandomBitsGenerator::new(
1128                center_count,
1129                options.max_isomers,
1130                PythonRandom::from_integer_seed(&seed),
1131            ))
1132        };
1133
1134        Ok(Self {
1135            workspace: Some(workspace),
1136            configurations: Some(configurations),
1137            pending_no_center: None,
1138            isomers_seen: HashSet::new(),
1139            unique: options.unique,
1140            try_embedding: options.try_embedding,
1141            max_isomers: options.max_isomers,
1142            yielded: 0,
1143            finished: false,
1144        })
1145    }
1146
1147    #[must_use]
1148    pub fn yielded_count(&self) -> usize {
1149        self.yielded
1150    }
1151}
1152
1153impl Iterator for StereoisomerIterator {
1154    type Item = Result<Molecule, EnumerationError>;
1155
1156    fn next(&mut self) -> Option<Self::Item> {
1157        if self.finished {
1158            return None;
1159        }
1160
1161        if let Some(isomer) = self.pending_no_center.take() {
1162            self.finished = true;
1163            self.yielded = 1;
1164            return Some(
1165                crate::invariants::enforce_molecule_invariants(&isomer)
1166                    .map(|()| isomer)
1167                    .map_err(EnumerationError::from),
1168            );
1169        }
1170
1171        // RDKit✔️✔️:       if options.maxIsomers != 0 and numIsomers >= options.maxIsomers:
1172        // RDKit✔️✔️:         break
1173        if self.max_isomers != 0 && self.yielded >= self.max_isomers {
1174            self.finished = true;
1175            return None;
1176        }
1177
1178        loop {
1179            // RDKit✔️✔️:   for bitflag in bitsource:
1180            let configuration = match self.configurations.as_mut()?.next() {
1181                Some(Ok(configuration)) => configuration,
1182                Some(Err(error)) => {
1183                    self.finished = true;
1184                    return Some(Err(error));
1185                }
1186                None => {
1187                    self.finished = true;
1188                    return None;
1189                }
1190            };
1191
1192            let workspace = self
1193                .workspace
1194                .as_mut()
1195                .expect("enumeration workspace exists whenever configurations exist");
1196            if let Err(error) = workspace.apply_configuration(&configuration) {
1197                self.finished = true;
1198                return Some(Err(error));
1199            }
1200
1201            let isomer = match workspace.finalize_configuration_with_embedding(
1202                &configuration,
1203                self.unique,
1204                self.try_embedding,
1205                &mut self.isomers_seen,
1206            ) {
1207                Ok(Some(isomer)) => isomer,
1208                Ok(None) => continue,
1209                Err(error) => {
1210                    self.finished = true;
1211                    return Some(Err(error));
1212                }
1213            };
1214
1215            if let Err(error) = crate::invariants::enforce_molecule_invariants(&isomer) {
1216                self.finished = true;
1217                return Some(Err(error.into()));
1218            }
1219
1220            // RDKit✔️✔️:     if cid >= 0:
1221            // RDKit✔️✔️:       yield isomer
1222            // RDKit✔️✔️:       numIsomers += 1
1223            self.yielded += 1;
1224            return Some(Ok(isomer));
1225        }
1226    }
1227}
1228
1229impl std::iter::FusedIterator for StereoisomerIterator {}
1230
1231/// Return RDKit Python's upper-bound stereoisomer count for `molecule`.
1232pub fn stereoisomer_count(
1233    molecule: &Molecule,
1234    options: &StereoisomerOptions,
1235) -> Result<BigUint, EnumerationError> {
1236    // RDKit✔️✔️: def GetStereoisomerCount(m, options=StereoEnumerationOptions()):
1237    // RDKit✔️✔️:   tm = Chem.Mol(m)
1238    // RDKit✔️✔️:   flippers = _getFlippers(tm, options)
1239    // RDKit✔️✔️:   return 2**len(flippers)
1240    let mut workspace = molecule.clone();
1241    let flippers = select_stereo_flippers(&mut workspace, options.flipper_selection())?;
1242    Ok(theoretical_configuration_count(flippers.len()))
1243}
1244
1245/// Create a lazy source-ordered stereoisomer iterator.
1246pub fn enumerate_stereoisomers(
1247    molecule: &Molecule,
1248    options: StereoisomerOptions,
1249) -> Result<StereoisomerIterator, EnumerationError> {
1250    StereoisomerIterator::new(molecule, options)
1251}
1252
1253/// Create a lazy stereoisomer iterator using a source-compatible random-bit
1254/// callback when the option boundary selects bounded random enumeration.
1255///
1256/// Configuration uniqueness, finite exhaustion, output uniqueness, embedding,
1257/// and `max_isomers` accounting remain owned by the canonical iterator. The
1258/// callback supplies only Python-compatible `getrandbits(n)` values.
1259pub fn enumerate_stereoisomers_with_random_bits<F>(
1260    molecule: &Molecule,
1261    options: StereoisomerOptions,
1262    callback: F,
1263) -> Result<StereoisomerIterator, EnumerationError>
1264where
1265    F: FnMut(usize) -> Result<BigUint, String> + Send + Sync + 'static,
1266{
1267    StereoisomerIterator::new_with_configuration_source(molecule, options, Some(Box::new(callback)))
1268}
1269
1270// ──────────────────────────────────────────────
1271#[cfg(test)]
1272mod tests {
1273    use std::collections::VecDeque;
1274
1275    use super::*;
1276    use crate::{
1277        AtomSpec, BondOrder, BondSpec, Conformer3D, Element, Molecule, MoleculeBuilder,
1278        StereoDescriptor, StereoGroup,
1279    };
1280
1281    fn stereo_info(
1282        stereo_type: StereoType,
1283        specified: StereoSpecified,
1284        center: StereoCenter,
1285        controlling_atoms: Vec<ControllingAtom>,
1286    ) -> StereoInfo {
1287        StereoInfo::new(
1288            stereo_type,
1289            specified,
1290            center,
1291            StereoDescriptor::None,
1292            0,
1293            controlling_atoms,
1294        )
1295        .unwrap()
1296    }
1297
1298    struct ScriptedRandomBits {
1299        values: VecDeque<Result<BigUint, String>>,
1300        requested_bit_counts: Vec<usize>,
1301    }
1302
1303    impl ScriptedRandomBits {
1304        fn values(values: impl IntoIterator<Item = u64>) -> Self {
1305            Self {
1306                values: values
1307                    .into_iter()
1308                    .map(|value| Ok(BigUint::from(value)))
1309                    .collect(),
1310                requested_bit_counts: Vec::new(),
1311            }
1312        }
1313
1314        fn error(message: impl Into<String>) -> Self {
1315            Self {
1316                values: VecDeque::from([Err(message.into())]),
1317                requested_bit_counts: Vec::new(),
1318            }
1319        }
1320    }
1321
1322    impl RandomBitsSource for ScriptedRandomBits {
1323        fn getrandbits(&mut self, bit_count: usize) -> Result<ConfigurationBits, EnumerationError> {
1324            self.requested_bit_counts.push(bit_count);
1325            match self.values.pop_front() {
1326                Some(Ok(value)) => Ok(ConfigurationBits::from_biguint(value)),
1327                Some(Err(message)) => {
1328                    Err(EnumerationError::RandomBitsSource { bit_count, message })
1329                }
1330                None => Err(EnumerationError::RandomBitsSource {
1331                    bit_count,
1332                    message: "scripted source exhausted".to_owned(),
1333                }),
1334            }
1335        }
1336    }
1337
1338    #[test]
1339    fn python_range_bits_generator_reproduces_zero_center_and_lsb_first_order() {
1340        let zero_center_values = RangeBitsGenerator::new(0)
1341            .map(|bits| bits.value().clone())
1342            .collect::<Vec<_>>();
1343        assert_eq!(zero_center_values, vec![BigUint::from(0_u8)]);
1344
1345        let values = RangeBitsGenerator::new(3).collect::<Vec<_>>();
1346        assert_eq!(values.len(), 8);
1347        for (value, bits) in values.iter().enumerate() {
1348            assert_eq!(bits.value(), &BigUint::from(value));
1349            assert_eq!(bits.bit(0), value & 0b001 != 0);
1350            assert_eq!(bits.bit(1), value & 0b010 != 0);
1351            assert_eq!(bits.bit(2), value & 0b100 != 0);
1352        }
1353    }
1354
1355    #[test]
1356    fn python_range_bits_generator_preserves_arbitrary_width_counts_and_laziness() {
1357        assert_eq!(
1358            theoretical_configuration_count(130),
1359            BigUint::from(1_u8) << 130
1360        );
1361
1362        let prefix = RangeBitsGenerator::new(130)
1363            .take(4)
1364            .map(|bits| bits.value().clone())
1365            .collect::<Vec<_>>();
1366        assert_eq!(
1367            prefix,
1368            [0_u8, 1, 2, 3]
1369                .into_iter()
1370                .map(BigUint::from)
1371                .collect::<Vec<_>>()
1372        );
1373
1374        // Constructing a million-center range allocates only its arbitrary-width
1375        // endpoint and current value; consuming a prefix never materializes 2^N rows.
1376        let huge_prefix = RangeBitsGenerator::new(1_000_000)
1377            .take(3)
1378            .map(|bits| bits.value().clone())
1379            .collect::<Vec<_>>();
1380        assert_eq!(
1381            huge_prefix,
1382            [0_u8, 1, 2]
1383                .into_iter()
1384                .map(BigUint::from)
1385                .collect::<Vec<_>>()
1386        );
1387    }
1388
1389    #[test]
1390    fn python_unique_random_bits_generator_rejects_duplicates_and_exhausts_finitely() {
1391        let random = ScriptedRandomBits::values([3, 1, 3, 0, 2]);
1392        let mut generator = UniqueRandomBitsGenerator::new(2, 1, random);
1393        let values = generator
1394            .by_ref()
1395            .map(|result| result.unwrap().value().clone())
1396            .collect::<Vec<_>>();
1397        assert_eq!(
1398            values,
1399            [3_u8, 1, 0, 2]
1400                .into_iter()
1401                .map(BigUint::from)
1402                .collect::<Vec<_>>()
1403        );
1404        assert_eq!(generator.random.requested_bit_counts, vec![2; 5]);
1405        assert!(generator.next().is_none());
1406
1407        let mut zero_centers =
1408            UniqueRandomBitsGenerator::new(0, usize::MAX, ScriptedRandomBits::values([0, 0]));
1409        assert_eq!(
1410            zero_centers.next().unwrap().unwrap().value(),
1411            &BigUint::from(0_u8)
1412        );
1413        assert!(zero_centers.next().is_none());
1414        assert_eq!(zero_centers.random.requested_bit_counts, vec![0]);
1415    }
1416
1417    #[test]
1418    fn python_unique_random_bits_generator_propagates_source_errors_structurally() {
1419        let mut generator =
1420            UniqueRandomBitsGenerator::new(130, 1024, ScriptedRandomBits::error("fixture failure"));
1421        assert_eq!(
1422            generator.next().unwrap().unwrap_err(),
1423            EnumerationError::RandomBitsSource {
1424                bit_count: 130,
1425                message: "fixture failure".to_owned(),
1426            }
1427        );
1428        assert_eq!(generator.random.requested_bit_counts, vec![130]);
1429    }
1430
1431    fn python_random_values(seed: &BigInt, widths: &[usize]) -> Vec<BigUint> {
1432        let mut random = PythonRandom::from_integer_seed(seed);
1433        widths
1434            .iter()
1435            .map(|&width| random.getrandbits(width).unwrap().value().clone())
1436            .collect()
1437    }
1438
1439    fn decimal_biguint(value: &str) -> BigUint {
1440        value.parse().unwrap()
1441    }
1442
1443    #[test]
1444    fn python_random_matches_cpython_integer_seed_and_getrandbits_sequence_exactly() {
1445        let widths = [0, 1, 2, 31, 32, 33, 64, 65, 130];
1446        let values = python_random_values(&BigInt::from(0xdead_beef_u32), &widths);
1447        let expected = [
1448            "0",
1449            "0",
1450            "1",
1451            "1008527992",
1452            "3864962434",
1453            "6868874809",
1454            "16491411380257451511",
1455            "25412851181257523183",
1456            "668769172828971426222707781417953853951",
1457        ]
1458        .map(decimal_biguint);
1459        assert_eq!(values, expected);
1460    }
1461
1462    #[test]
1463    fn python_random_uses_all_integer_seed_bits_and_absolute_negative_seed_semantics() {
1464        let seed = (BigInt::from(1_u8) << 100) + BigInt::from(0x1234_5678_u32);
1465        let widths = [0, 1, 2, 31, 32, 33, 64, 65, 130];
1466        let positive = python_random_values(&seed, &widths);
1467        let negative = python_random_values(&(-seed), &widths);
1468        let expected = [
1469            "0",
1470            "1",
1471            "0",
1472            "552449039",
1473            "185676661",
1474            "7120073926",
1475            "18340008931984535783",
1476            "34270592875609026320",
1477            "1287945476240184999319519200333061618674",
1478        ]
1479        .map(decimal_biguint);
1480        assert_eq!(positive, expected);
1481        assert_eq!(negative, expected);
1482    }
1483
1484    #[test]
1485    fn python_default_random_seed_is_exact_and_invariant_to_input_atom_order() {
1486        let cco = Molecule::from_smiles("CCO").unwrap();
1487        let occ = Molecule::from_smiles("OCC").unwrap();
1488        assert_ne!(
1489            cco.atoms()
1490                .iter()
1491                .map(|atom| atom.atomic_number())
1492                .collect::<Vec<_>>(),
1493            occ.atoms()
1494                .iter()
1495                .map(|atom| atom.atomic_number())
1496                .collect::<Vec<_>>()
1497        );
1498
1499        let cco_seed = default_python_random_seed(&cco);
1500        let occ_seed = default_python_random_seed(&occ);
1501        assert_eq!(cco_seed, BigInt::from(1_554_427_866_021_819_617_i64));
1502        assert_eq!(cco_seed, occ_seed);
1503
1504        let expected = [1_u8, 7, 7, 5, 3, 1, 4, 2, 2, 0, 3, 3]
1505            .into_iter()
1506            .map(BigUint::from)
1507            .collect::<Vec<_>>();
1508        let first = python_random_values(&cco_seed, &[3; 12]);
1509        let repeated = python_random_values(&cco_seed, &[3; 12]);
1510        assert_eq!(first, expected);
1511        assert_eq!(repeated, expected);
1512    }
1513
1514    struct CounterRandom {
1515        next_value: BigUint,
1516    }
1517
1518    impl RandomBitsSource for CounterRandom {
1519        fn getrandbits(&mut self, bit_count: usize) -> Result<ConfigurationBits, EnumerationError> {
1520            let value = self.next_value.clone();
1521            self.next_value += 1_u8;
1522            if value >= theoretical_configuration_count(bit_count) {
1523                return Err(EnumerationError::RandomBitsSource {
1524                    bit_count,
1525                    message: "counter random source exhausted the declared bit width".to_owned(),
1526                });
1527            }
1528            Ok(ConfigurationBits::from_biguint(value))
1529        }
1530    }
1531
1532    #[test]
1533    fn python_random_custom_getrandbits_boundary_matches_rdkit_counter_fixture() {
1534        let random = CounterRandom {
1535            next_value: BigUint::from(0_u8),
1536        };
1537        let values = UniqueRandomBitsGenerator::new(3, 3, random)
1538            .take(3)
1539            .map(|result| result.unwrap().value().clone())
1540            .collect::<Vec<_>>();
1541        assert_eq!(
1542            values,
1543            [0_u8, 1, 2]
1544                .into_iter()
1545                .map(BigUint::from)
1546                .collect::<Vec<_>>()
1547        );
1548    }
1549
1550    #[test]
1551    fn python_random_instances_are_parallel_and_repeat_call_isolated() {
1552        let seed = BigInt::from(-5_473_470_236_788_694_370_i64);
1553        let expected = [6_u8, 1, 4, 6, 4, 2, 0, 7, 6, 5, 2, 6]
1554            .into_iter()
1555            .map(BigUint::from)
1556            .collect::<Vec<_>>();
1557        let outputs = std::thread::scope(|scope| {
1558            (0..8)
1559                .map(|_| {
1560                    let seed = &seed;
1561                    scope.spawn(move || python_random_values(seed, &[3; 12]))
1562                })
1563                .collect::<Vec<_>>()
1564                .into_iter()
1565                .map(|thread| thread.join().unwrap())
1566                .collect::<Vec<_>>()
1567        });
1568        assert_eq!(outputs, vec![expected; 8]);
1569    }
1570
1571    fn tetrahedral_center(
1572        builder: &mut MoleculeBuilder,
1573        tag: ChiralTag,
1574        cip_code: Option<&str>,
1575    ) -> AtomId {
1576        let mut center = AtomSpec::new(Element::C)
1577            .with_no_implicit(true)
1578            .with_chiral_tag(tag)
1579            .with_prop("keep_atom", "center");
1580        if let Some(cip_code) = cip_code {
1581            center = center.with_prop("_CIPCode", cip_code);
1582        }
1583        let center = builder.add_atom(center);
1584        for element in [Element::F, Element::CL, Element::BR, Element::I] {
1585            let ligand = builder.add_atom(AtomSpec::new(element));
1586            builder
1587                .add_bond(BondSpec::new(center, ligand, BondOrder::Single))
1588                .unwrap();
1589        }
1590        center
1591    }
1592
1593    #[test]
1594    fn python_enumerator_preprocessing_preserves_no_center_source_state_and_owned_blocks() {
1595        let mut builder = MoleculeBuilder::new()
1596            .with_name("preprocessing fixture")
1597            .with_property("keep_molecule", "yes")
1598            .with_property("_MolFileChiralFlag", "0");
1599        let atoms = (0..4)
1600            .map(|index| {
1601                let spec = if index == 0 {
1602                    AtomSpec::new(Element::C)
1603                        .with_prop("_CIPCode", "R")
1604                        .with_prop("keep_atom", "yes")
1605                } else {
1606                    AtomSpec::new(Element::C)
1607                };
1608                builder.add_atom(spec)
1609            })
1610            .collect::<Vec<_>>();
1611        for (index, direction) in [
1612            BondDirection::EitherDouble,
1613            BondDirection::Unknown,
1614            BondDirection::BeginWedge,
1615        ]
1616        .into_iter()
1617        .enumerate()
1618        {
1619            builder
1620                .add_bond(
1621                    BondSpec::new(atoms[index], atoms[index + 1], BondOrder::Single)
1622                        .with_direction(direction)
1623                        .with_prop("keep_bond", index.to_string()),
1624                )
1625                .unwrap();
1626        }
1627        let coordinates = vec![
1628            [0.0, 0.0, 0.0],
1629            [1.0, 0.0, 0.0],
1630            [2.0, 1.0, 0.0],
1631            [3.0, 1.0, 1.0],
1632        ];
1633        builder
1634            .add_conformer(
1635                Conformer3D::new(7, coordinates.clone(), true).with_prop("keep_conf", "yes"),
1636            )
1637            .unwrap();
1638        let source = builder.build().unwrap();
1639        let source_before = source.clone();
1640
1641        let workspace = EnumerationWorkspace::prepare(
1642            &source,
1643            FlipperSelectionOptions {
1644                only_unassigned: true,
1645                only_stereo_groups: false,
1646            },
1647        )
1648        .unwrap();
1649
1650        assert_eq!(source, source_before);
1651        assert_eq!(workspace.center_count(), 0);
1652        assert_eq!(workspace.molecule().atoms()[0].prop("_CIPCode"), None);
1653        assert_eq!(
1654            workspace.molecule().atoms()[0].prop("keep_atom"),
1655            Some("yes")
1656        );
1657        assert_eq!(
1658            workspace
1659                .molecule()
1660                .bonds()
1661                .iter()
1662                .map(crate::Bond::direction)
1663                .collect::<Vec<_>>(),
1664            vec![
1665                BondDirection::None,
1666                BondDirection::None,
1667                BondDirection::BeginWedge,
1668            ]
1669        );
1670        assert_eq!(workspace.molecule().bonds()[2].prop("keep_bond"), Some("2"));
1671        assert_eq!(workspace.molecule().prop("keep_molecule"), Some("yes"));
1672        assert_eq!(workspace.molecule().prop("_MolFileChiralFlag"), Some("0"));
1673        assert_eq!(workspace.molecule().conformers_3d().len(), 1);
1674        assert_eq!(
1675            workspace.molecule().conformers_3d()[0].coordinates(),
1676            coordinates
1677        );
1678        assert_eq!(
1679            workspace.molecule().conformers_3d()[0]
1680                .props()
1681                .get("keep_conf")
1682                .map(String::as_str),
1683            Some("yes")
1684        );
1685    }
1686
1687    #[test]
1688    fn python_enumerator_preprocessing_filters_fully_assigned_and_partial_centers_exactly() {
1689        let mut assigned_builder = MoleculeBuilder::new();
1690        let assigned =
1691            tetrahedral_center(&mut assigned_builder, ChiralTag::TetrahedralCw, Some("R"));
1692        let assigned_source = assigned_builder.build().unwrap();
1693        let assigned_before = assigned_source.clone();
1694        let assigned_only = EnumerationWorkspace::prepare(
1695            &assigned_source,
1696            FlipperSelectionOptions {
1697                only_unassigned: true,
1698                only_stereo_groups: false,
1699            },
1700        )
1701        .unwrap();
1702        assert_eq!(assigned_only.center_count(), 0);
1703        assert_eq!(
1704            assigned_only.molecule().atoms()[assigned.index()].prop("_CIPCode"),
1705            None
1706        );
1707        assert_eq!(assigned_source, assigned_before);
1708
1709        let mut partial_builder = MoleculeBuilder::new();
1710        let retained =
1711            tetrahedral_center(&mut partial_builder, ChiralTag::TetrahedralCw, Some("S"));
1712        let enumerated =
1713            tetrahedral_center(&mut partial_builder, ChiralTag::Unspecified, Some("stale"));
1714        let partial_source = partial_builder.build().unwrap();
1715        let partial_before = partial_source.clone();
1716        let mut partial = EnumerationWorkspace::prepare(
1717            &partial_source,
1718            FlipperSelectionOptions {
1719                only_unassigned: true,
1720                only_stereo_groups: false,
1721            },
1722        )
1723        .unwrap();
1724
1725        assert_eq!(partial.center_count(), 1);
1726        assert_eq!(
1727            partial.flippers,
1728            vec![StereoFlipper::Atom { atom: enumerated }]
1729        );
1730        assert_eq!(partial.molecule().prop("_MolFileChiralFlag"), Some("1"));
1731        partial
1732            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(0_u8)))
1733            .unwrap();
1734        assert_eq!(
1735            partial.molecule().atoms()[retained.index()].chiral_tag(),
1736            ChiralTag::TetrahedralCw
1737        );
1738        assert_eq!(
1739            partial.molecule().atoms()[enumerated.index()].chiral_tag(),
1740            ChiralTag::TetrahedralCcw
1741        );
1742        partial
1743            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(1_u8)))
1744            .unwrap();
1745        assert_eq!(
1746            partial.molecule().atoms()[enumerated.index()].chiral_tag(),
1747            ChiralTag::TetrahedralCw
1748        );
1749        assert_eq!(partial_source, partial_before);
1750    }
1751
1752    #[test]
1753    fn python_enumerator_preprocessing_clears_either_double_and_applies_stereoany_bond_bits() {
1754        let mut builder = MoleculeBuilder::new();
1755        let begin = builder.add_atom(AtomSpec::new(Element::C).with_no_implicit(true));
1756        let end = builder.add_atom(AtomSpec::new(Element::C).with_no_implicit(true));
1757        let begin_first = builder.add_atom(AtomSpec::new(Element::F));
1758        let begin_second = builder.add_atom(AtomSpec::new(Element::CL));
1759        let end_first = builder.add_atom(AtomSpec::new(Element::BR));
1760        let end_second = builder.add_atom(AtomSpec::new(Element::I));
1761        let double_bond = builder
1762            .add_bond(
1763                BondSpec::new(begin, end, BondOrder::Double)
1764                    .with_stereo(BondStereo::Any)
1765                    .with_direction(BondDirection::EitherDouble),
1766            )
1767            .unwrap();
1768        builder
1769            .add_bond(
1770                BondSpec::new(begin, begin_first, BondOrder::Single)
1771                    .with_direction(BondDirection::Unknown),
1772            )
1773            .unwrap();
1774        builder
1775            .add_bond(BondSpec::new(begin, begin_second, BondOrder::Single))
1776            .unwrap();
1777        builder
1778            .add_bond(BondSpec::new(end, end_first, BondOrder::Single))
1779            .unwrap();
1780        builder
1781            .add_bond(BondSpec::new(end, end_second, BondOrder::Single))
1782            .unwrap();
1783        let source = builder.build().unwrap();
1784        let source_before = source.clone();
1785        let mut workspace = EnumerationWorkspace::prepare(
1786            &source,
1787            FlipperSelectionOptions {
1788                only_unassigned: true,
1789                only_stereo_groups: false,
1790            },
1791        )
1792        .unwrap();
1793
1794        assert_eq!(workspace.center_count(), 1);
1795        assert_eq!(
1796            workspace.flippers,
1797            vec![StereoFlipper::Bond { bond: double_bond }]
1798        );
1799        assert_eq!(
1800            workspace.molecule().bonds()[double_bond.index()].direction(),
1801            BondDirection::None
1802        );
1803        assert_eq!(
1804            workspace.molecule().bonds()[1].direction(),
1805            BondDirection::None
1806        );
1807        assert_eq!(
1808            workspace.molecule().bonds()[double_bond.index()].stereo(),
1809            BondStereo::Any
1810        );
1811        assert!(
1812            workspace.molecule().bonds()[double_bond.index()]
1813                .stereo_atoms()
1814                .is_some()
1815        );
1816        workspace
1817            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(0_u8)))
1818            .unwrap();
1819        assert_eq!(
1820            workspace.molecule().bonds()[double_bond.index()].stereo(),
1821            BondStereo::Trans
1822        );
1823        workspace
1824            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(1_u8)))
1825            .unwrap();
1826        assert_eq!(
1827            workspace.molecule().bonds()[double_bond.index()].stereo(),
1828            BondStereo::Cis
1829        );
1830        assert_eq!(source, source_before);
1831    }
1832
1833    #[test]
1834    fn python_enumerator_preprocessing_selects_enhanced_stereo_groups_once_in_group_order() {
1835        let mut builder = MoleculeBuilder::new();
1836        let first = tetrahedral_center(&mut builder, ChiralTag::TetrahedralCw, Some("R"));
1837        let second = tetrahedral_center(&mut builder, ChiralTag::TetrahedralCcw, Some("S"));
1838        builder
1839            .add_stereo_group(StereoGroup::new(
1840                StereoGroupKind::Absolute,
1841                vec![first],
1842                Vec::new(),
1843            ))
1844            .unwrap();
1845        builder
1846            .add_stereo_group(StereoGroup::new(
1847                StereoGroupKind::Or,
1848                vec![first, second],
1849                Vec::new(),
1850            ))
1851            .unwrap();
1852        let source = builder.build().unwrap();
1853        let source_before = source.clone();
1854        let mut workspace = EnumerationWorkspace::prepare(
1855            &source,
1856            FlipperSelectionOptions {
1857                only_unassigned: true,
1858                only_stereo_groups: true,
1859            },
1860        )
1861        .unwrap();
1862
1863        assert_eq!(workspace.center_count(), 1);
1864        assert_eq!(workspace.molecule().stereo_groups(), source.stereo_groups());
1865        assert!(matches!(
1866            &workspace.flippers[0],
1867            StereoFlipper::StereoGroup { original_parities }
1868                if original_parities == &vec![
1869                    (first, ChiralTag::TetrahedralCw),
1870                    (second, ChiralTag::TetrahedralCcw),
1871                ]
1872        ));
1873        workspace
1874            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(0_u8)))
1875            .unwrap();
1876        assert_eq!(
1877            workspace.molecule().atoms()[first.index()].chiral_tag(),
1878            ChiralTag::TetrahedralCcw
1879        );
1880        assert_eq!(
1881            workspace.molecule().atoms()[second.index()].chiral_tag(),
1882            ChiralTag::TetrahedralCw
1883        );
1884        workspace
1885            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(1_u8)))
1886            .unwrap();
1887        assert_eq!(
1888            workspace.molecule().atoms()[first.index()].chiral_tag(),
1889            ChiralTag::TetrahedralCw
1890        );
1891        assert_eq!(
1892            workspace.molecule().atoms()[second.index()].chiral_tag(),
1893            ChiralTag::TetrahedralCcw
1894        );
1895        assert_eq!(source, source_before);
1896    }
1897
1898    #[test]
1899    fn python_enumerator_finalization_clears_computed_state_preserves_rings_and_removes_groups() {
1900        let mut source = Molecule::from_smiles("FC(Cl)C(Cl)F")
1901            .unwrap()
1902            .with_assigned_rings()
1903            .unwrap();
1904        source
1905            .properties_mut()
1906            .set_prop("keep_molecule", "persistent");
1907        source
1908            .properties_mut()
1909            .set_computed_prop("drop_molecule", "computed");
1910        source.topology_block_mut().atoms[1].set_prop("keep_atom", "persistent");
1911        source.topology_block_mut().atoms[1].set_computed_prop("drop_atom", "computed");
1912        source.topology_block_mut().bonds[0].set_prop("keep_bond", "persistent");
1913        source.topology_block_mut().bonds[0].set_computed_prop("drop_bond", "computed");
1914        source
1915            .topology_block_mut()
1916            .stereo_groups
1917            .push(StereoGroup::new(
1918                StereoGroupKind::Or,
1919                vec![AtomId::new(1), AtomId::new(3)],
1920                Vec::new(),
1921            ));
1922        let source_before = source.clone();
1923        let source_rings = source.derived_cache().rings.clone();
1924
1925        let mut workspace = EnumerationWorkspace::prepare(
1926            &source,
1927            FlipperSelectionOptions {
1928                only_unassigned: false,
1929                only_stereo_groups: false,
1930            },
1931        )
1932        .unwrap();
1933        workspace
1934            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(0_u8)))
1935            .unwrap();
1936        let output = workspace
1937            .finalize_configuration(false, &mut HashSet::new())
1938            .unwrap()
1939            .unwrap();
1940
1941        assert_eq!(source, source_before);
1942        assert!(output.stereo_groups().is_empty());
1943        assert_eq!(output.derived_cache().rings, source_rings);
1944        assert_eq!(output.prop("keep_molecule"), Some("persistent"));
1945        assert_eq!(output.prop("drop_molecule"), None);
1946        assert_eq!(output.atoms()[1].prop("keep_atom"), Some("persistent"));
1947        assert_eq!(output.atoms()[1].prop("drop_atom"), None);
1948        assert_eq!(output.bonds()[0].prop("keep_bond"), Some("persistent"));
1949        assert_eq!(output.bonds()[0].prop("drop_bond"), None);
1950        assert_eq!(output.prop("_MolFileChiralFlag"), Some("1"));
1951        assert_eq!(output.prop("_StereochemDone"), Some("1"));
1952        assert!(output.properties().is_prop_computed("_StereochemDone"));
1953        for center in [1, 3] {
1954            assert!(output.atoms()[center].prop("_CIPCode").is_some());
1955            assert_eq!(output.atoms()[center].prop("_ChiralityPossible"), Some("1"));
1956        }
1957    }
1958
1959    #[test]
1960    fn python_enumerator_finalization_continues_on_meso_canonical_duplicates_in_source_order() {
1961        let source = Molecule::from_smiles("FC(Cl)C(Cl)F").unwrap();
1962        let mut workspace = EnumerationWorkspace::prepare(
1963            &source,
1964            FlipperSelectionOptions {
1965                only_unassigned: true,
1966                only_stereo_groups: false,
1967            },
1968        )
1969        .unwrap();
1970        assert_eq!(workspace.center_count(), 2);
1971
1972        let mut seen = HashSet::new();
1973        let mut outputs = Vec::new();
1974        let mut emitted_by_configuration = Vec::new();
1975        for configuration in 0_u8..4 {
1976            workspace
1977                .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(
1978                    configuration,
1979                )))
1980                .unwrap();
1981            let finalized = workspace.finalize_configuration(true, &mut seen).unwrap();
1982            emitted_by_configuration.push(finalized.is_some());
1983            if let Some(isomer) = finalized {
1984                outputs.push(
1985                    MoleculeReadParts::from_molecule(&isomer)
1986                        .canonical_isomeric_smiles()
1987                        .unwrap(),
1988                );
1989            }
1990        }
1991
1992        assert_eq!(emitted_by_configuration, vec![true, true, true, false]);
1993        assert_eq!(
1994            outputs,
1995            vec![
1996                "F[C@H](Cl)[C@@H](F)Cl",
1997                "F[C@@H](Cl)[C@@H](F)Cl",
1998                "F[C@H](Cl)[C@H](F)Cl",
1999            ]
2000        );
2001        assert_eq!(seen.len(), 3);
2002    }
2003
2004    #[test]
2005    fn python_enumerator_preserves_tetrahedral_center_when_double_bond_configurations_change() {
2006        let source =
2007            Molecule::from_smiles("Cl.N=C(N)c1ccc(C=CC(=O)NCC(O)CNC(=O)C=Cc2ccc(C(=N)N)cc2)cc1")
2008                .unwrap();
2009        let source_before = source.clone();
2010
2011        let non_unique = enumerate_stereoisomers(
2012            &source,
2013            StereoisomerOptions {
2014                max_isomers: 0,
2015                unique: false,
2016                ..Default::default()
2017            },
2018        )
2019        .unwrap()
2020        .collect::<Result<Vec<_>, _>>()
2021        .unwrap();
2022        assert_eq!(non_unique.len(), 8);
2023        for isomer in &non_unique[2..6] {
2024            assert!(
2025                isomer.atoms()[14].prop("_CIPCode").is_some(),
2026                "enumeration finalization must retain the source-assigned CIP code"
2027            );
2028        }
2029        assert_eq!(
2030            non_unique
2031                .iter()
2032                .map(|isomer| isomer.atoms()[14].chiral_tag())
2033                .collect::<Vec<_>>(),
2034            vec![
2035                ChiralTag::Unspecified,
2036                ChiralTag::Unspecified,
2037                ChiralTag::TetrahedralCcw,
2038                ChiralTag::TetrahedralCw,
2039                ChiralTag::TetrahedralCcw,
2040                ChiralTag::TetrahedralCw,
2041                ChiralTag::Unspecified,
2042                ChiralTag::Unspecified,
2043            ]
2044        );
2045
2046        let unique = enumerate_stereoisomers(
2047            &source,
2048            StereoisomerOptions {
2049                max_isomers: 0,
2050                unique: true,
2051                ..Default::default()
2052            },
2053        )
2054        .unwrap()
2055        .collect::<Result<Vec<_>, _>>()
2056        .unwrap();
2057        assert_eq!(
2058            unique
2059                .iter()
2060                .map(|isomer| {
2061                    MoleculeReadParts::from_molecule(isomer).canonical_isomeric_smiles()
2062                })
2063                .collect::<Result<Vec<_>, _>>()
2064                .unwrap(),
2065            vec![
2066                "Cl.N=C(N)c1ccc(/C=C/C(=O)NCC(O)CNC(=O)/C=C/c2ccc(C(=N)N)cc2)cc1",
2067                "Cl.N=C(N)c1ccc(/C=C\\C(=O)NC[C@H](O)CNC(=O)/C=C/c2ccc(C(=N)N)cc2)cc1",
2068                "Cl.N=C(N)c1ccc(/C=C\\C(=O)NC[C@@H](O)CNC(=O)/C=C/c2ccc(C(=N)N)cc2)cc1",
2069                "Cl.N=C(N)c1ccc(/C=C\\C(=O)NCC(O)CNC(=O)/C=C\\c2ccc(C(=N)N)cc2)cc1",
2070            ]
2071        );
2072        assert_eq!(source, source_before);
2073    }
2074
2075    #[test]
2076    fn python_enumerator_finalization_sets_double_bond_directions_and_handles_cumulenes() {
2077        let mut builder = MoleculeBuilder::new();
2078        let begin = builder.add_atom(AtomSpec::new(Element::C).with_no_implicit(true));
2079        let end = builder.add_atom(AtomSpec::new(Element::C).with_no_implicit(true));
2080        let begin_first = builder.add_atom(AtomSpec::new(Element::F));
2081        let begin_second = builder.add_atom(AtomSpec::new(Element::CL));
2082        let end_first = builder.add_atom(AtomSpec::new(Element::BR));
2083        let end_second = builder.add_atom(AtomSpec::new(Element::I));
2084        let double_bond = builder
2085            .add_bond(
2086                BondSpec::new(begin, end, BondOrder::Double)
2087                    .with_stereo(BondStereo::Any)
2088                    .with_stereo_atoms(begin_first, end_first),
2089            )
2090            .unwrap();
2091        for (center, ligand) in [
2092            (begin, begin_first),
2093            (begin, begin_second),
2094            (end, end_first),
2095            (end, end_second),
2096        ] {
2097            builder
2098                .add_bond(BondSpec::new(center, ligand, BondOrder::Single))
2099                .unwrap();
2100        }
2101        let source = builder.build().unwrap();
2102        let mut workspace = EnumerationWorkspace::prepare(
2103            &source,
2104            FlipperSelectionOptions {
2105                only_unassigned: true,
2106                only_stereo_groups: false,
2107            },
2108        )
2109        .unwrap();
2110        workspace
2111            .apply_configuration(&ConfigurationBits::from_biguint(BigUint::from(0_u8)))
2112            .unwrap();
2113        let trans = workspace
2114            .finalize_configuration(false, &mut HashSet::new())
2115            .unwrap()
2116            .unwrap();
2117        assert_eq!(trans.bonds()[double_bond.index()].stereo(), BondStereo::E);
2118        assert!(trans.bonds().iter().any(|bond| matches!(
2119            bond.direction(),
2120            BondDirection::EndDownRight | BondDirection::EndUpRight
2121        )));
2122
2123        let cumulene = Molecule::from_smiles("CC=C=CC").unwrap();
2124        let cumulene_workspace = EnumerationWorkspace {
2125            molecule: cumulene,
2126            flippers: Vec::new(),
2127        };
2128        let cumulene_output = cumulene_workspace
2129            .finalize_configuration(false, &mut HashSet::new())
2130            .unwrap()
2131            .unwrap();
2132        assert_eq!(
2133            MoleculeReadParts::from_molecule(&cumulene_output)
2134                .canonical_isomeric_smiles()
2135                .unwrap(),
2136            "CC=C=CC"
2137        );
2138        assert_eq!(cumulene_output.prop("_StereochemDone"), Some("1"));
2139    }
2140
2141    #[test]
2142    fn python_enumerator_try_embedding_masks_configuration_to_signed_cxx_seed() {
2143        let cases = [
2144            (0_u64, 0_i32),
2145            (1, 1),
2146            (0x7fff_ffff, 0x7fff_ffff),
2147            (0x8000_0000, 0),
2148            (0xffff_ffff, 0x7fff_ffff),
2149            (0x1_0000_0005, 5),
2150        ];
2151        for (configuration, expected) in cases {
2152            assert_eq!(
2153                ConfigurationBits::from_biguint(BigUint::from(configuration)).embedding_seed(),
2154                expected,
2155                "configuration {configuration:#x}"
2156            );
2157        }
2158    }
2159
2160    #[test]
2161    fn python_enumerator_try_embedding_matches_rdkit_rejection_map_and_exhausts_finitely() {
2162        // RDKit UnitTestMol3D.py:
2163        // testEnumerateStereoisomersMaxIsomersShouldBeReturnedEvenWithTryEmbedding
2164        // testEnumerateStereoisomersTryEmbeddingShouldNotInfiniteLoopWhenMaxIsomersIsLargerThanActual
2165        let source = Molecule::from_smiles("BrC=CC1OC(C2)(F)C2(Cl)C1").unwrap();
2166        let source_before = source.clone();
2167        let mut workspace = EnumerationWorkspace::prepare(
2168            &source,
2169            FlipperSelectionOptions {
2170                only_unassigned: true,
2171                only_stereo_groups: false,
2172            },
2173        )
2174        .unwrap();
2175        assert_eq!(workspace.center_count(), 4);
2176
2177        let mut seen = HashSet::new();
2178        let mut succeeded = Vec::new();
2179        let mut rejected = Vec::new();
2180        let mut attempts = 0_usize;
2181        for configuration in RangeBitsGenerator::new(workspace.center_count()) {
2182            attempts += 1;
2183            workspace.apply_configuration(&configuration).unwrap();
2184            match workspace
2185                .finalize_configuration_with_embedding(&configuration, true, true, &mut seen)
2186                .unwrap()
2187            {
2188                Some(isomer) => {
2189                    assert_eq!(isomer.conformers_3d().len(), 1);
2190                    assert_eq!(
2191                        isomer.conformers_3d()[0].coordinates().len(),
2192                        source.num_atoms()
2193                    );
2194                    succeeded.push(configuration.value().clone());
2195                }
2196                None => rejected.push(configuration.value().clone()),
2197            }
2198        }
2199
2200        assert_eq!(attempts, 16);
2201        assert_eq!(
2202            succeeded,
2203            [0_u8, 1, 6, 7, 8, 9, 14, 15]
2204                .into_iter()
2205                .map(BigUint::from)
2206                .collect::<Vec<_>>()
2207        );
2208        assert_eq!(
2209            rejected,
2210            [2_u8, 3, 4, 5, 10, 11, 12, 13]
2211                .into_iter()
2212                .map(BigUint::from)
2213                .collect::<Vec<_>>()
2214        );
2215        assert_eq!(succeeded.len(), 8);
2216        assert_eq!(source, source_before);
2217
2218        // A max of two counts successful outputs, not attempted or rejected
2219        // configurations. Configurations 0 and 1 are the first two successes.
2220        let mut seen = HashSet::new();
2221        let mut emitted = Vec::new();
2222        let mut attempts = 0_usize;
2223        for configuration in RangeBitsGenerator::new(workspace.center_count()) {
2224            attempts += 1;
2225            workspace.apply_configuration(&configuration).unwrap();
2226            if workspace
2227                .finalize_configuration_with_embedding(&configuration, true, true, &mut seen)
2228                .unwrap()
2229                .is_some()
2230            {
2231                emitted.push(configuration.value().clone());
2232                if emitted.len() == 2 {
2233                    break;
2234                }
2235            }
2236        }
2237        assert_eq!(attempts, 2);
2238        assert_eq!(emitted, vec![BigUint::from(0_u8), BigUint::from(1_u8)]);
2239    }
2240
2241    #[test]
2242    fn python_enumerator_try_embedding_preserves_existing_conformers_and_copies_heavy_rows() {
2243        let base = Molecule::from_smiles("CC(F)Cl").unwrap();
2244        let existing_coordinates = (0..base.num_atoms())
2245            .map(|index| [index as f64, index as f64 + 0.25, index as f64 + 0.5])
2246            .collect::<Vec<_>>();
2247        let source = base
2248            .with_added_3d_conformer(existing_coordinates.clone(), true)
2249            .unwrap();
2250        let source_before = source.clone();
2251        let mut workspace = EnumerationWorkspace::prepare(
2252            &source,
2253            FlipperSelectionOptions {
2254                only_unassigned: true,
2255                only_stereo_groups: false,
2256            },
2257        )
2258        .unwrap();
2259        assert_eq!(workspace.center_count(), 1);
2260
2261        let configuration = ConfigurationBits::from_biguint(BigUint::from(1_u8));
2262        workspace.apply_configuration(&configuration).unwrap();
2263        let finalized = workspace
2264            .finalize_configuration(false, &mut HashSet::new())
2265            .unwrap()
2266            .unwrap();
2267        let embedded_a = workspace
2268            .apply_embedding_filter(finalized.clone(), &configuration)
2269            .unwrap()
2270            .unwrap();
2271        let embedded_b = workspace
2272            .apply_embedding_filter(finalized, &configuration)
2273            .unwrap()
2274            .unwrap();
2275
2276        assert_eq!(embedded_a.num_atoms(), source.num_atoms());
2277        assert_eq!(embedded_a.conformers_3d().len(), 2);
2278        assert_eq!(
2279            embedded_a.conformers_3d()[0].coordinates(),
2280            existing_coordinates
2281        );
2282        assert_eq!(embedded_a.conformers_3d()[0].id(), 0);
2283        assert_eq!(embedded_a.conformers_3d()[1].id(), 0);
2284        assert_eq!(
2285            embedded_a.conformers_3d()[1].coordinates().len(),
2286            source.num_atoms()
2287        );
2288        assert!(
2289            embedded_a.conformers_3d()[1]
2290                .coordinates()
2291                .iter()
2292                .flatten()
2293                .all(|coordinate| coordinate.is_finite())
2294        );
2295        assert_eq!(
2296            embedded_a.conformers_3d()[1].coordinates(),
2297            embedded_b.conformers_3d()[1].coordinates(),
2298            "the configuration-derived explicit seed must be deterministic"
2299        );
2300        assert_eq!(source, source_before);
2301    }
2302
2303    #[test]
2304    fn python_flippers_repeat_exact_atom_bond_and_group_setter_directions() {
2305        let mut builder = MoleculeBuilder::new();
2306        let atom =
2307            builder.add_atom(AtomSpec::new(Element::C).with_chiral_tag(ChiralTag::TetrahedralCw));
2308        let other =
2309            builder.add_atom(AtomSpec::new(Element::C).with_chiral_tag(ChiralTag::TetrahedralCcw));
2310        let untouched =
2311            builder.add_atom(AtomSpec::new(Element::F).with_chiral_tag(ChiralTag::Other));
2312        let end_controller = builder.add_atom(AtomSpec::new(Element::CL));
2313        let bond = builder
2314            .add_bond(
2315                BondSpec::new(atom, other, BondOrder::Double)
2316                    .with_stereo_atoms(untouched, end_controller),
2317            )
2318            .unwrap();
2319        builder
2320            .add_bond(BondSpec::new(atom, untouched, BondOrder::Single))
2321            .unwrap();
2322        builder
2323            .add_bond(BondSpec::new(other, end_controller, BondOrder::Single))
2324            .unwrap();
2325        let mut molecule = builder.build().unwrap();
2326
2327        let atom_flipper = StereoFlipper::Atom { atom };
2328        atom_flipper.flip(&mut molecule, false).unwrap();
2329        assert_eq!(
2330            molecule.atoms()[atom.index()].chiral_tag(),
2331            ChiralTag::TetrahedralCcw
2332        );
2333        atom_flipper.flip(&mut molecule, true).unwrap();
2334        atom_flipper.flip(&mut molecule, true).unwrap();
2335        assert_eq!(
2336            molecule.atoms()[atom.index()].chiral_tag(),
2337            ChiralTag::TetrahedralCw
2338        );
2339
2340        let bond_flipper = StereoFlipper::Bond { bond };
2341        bond_flipper.flip(&mut molecule, false).unwrap();
2342        assert_eq!(molecule.bonds()[bond.index()].stereo(), BondStereo::Trans);
2343        bond_flipper.flip(&mut molecule, true).unwrap();
2344        bond_flipper.flip(&mut molecule, true).unwrap();
2345        assert_eq!(molecule.bonds()[bond.index()].stereo(), BondStereo::Cis);
2346
2347        let group_flipper = StereoFlipper::StereoGroup {
2348            original_parities: vec![
2349                (atom, ChiralTag::TetrahedralCw),
2350                (other, ChiralTag::TetrahedralCcw),
2351                (untouched, ChiralTag::Other),
2352            ],
2353        };
2354        group_flipper.flip(&mut molecule, false).unwrap();
2355        assert_eq!(
2356            molecule.atoms()[atom.index()].chiral_tag(),
2357            ChiralTag::TetrahedralCcw
2358        );
2359        assert_eq!(
2360            molecule.atoms()[other.index()].chiral_tag(),
2361            ChiralTag::TetrahedralCw
2362        );
2363        assert_eq!(
2364            molecule.atoms()[untouched.index()].chiral_tag(),
2365            ChiralTag::Other
2366        );
2367        group_flipper.flip(&mut molecule, true).unwrap();
2368        group_flipper.flip(&mut molecule, true).unwrap();
2369        assert_eq!(
2370            molecule.atoms()[atom.index()].chiral_tag(),
2371            ChiralTag::TetrahedralCw
2372        );
2373        assert_eq!(
2374            molecule.atoms()[other.index()].chiral_tag(),
2375            ChiralTag::TetrahedralCcw
2376        );
2377        assert_eq!(
2378            molecule.atoms()[untouched.index()].chiral_tag(),
2379            ChiralTag::Other
2380        );
2381    }
2382
2383    fn grouped_selection_molecule() -> (Molecule, AtomId, AtomId, AtomId, AtomId, BondId) {
2384        let mut builder = MoleculeBuilder::new();
2385        let assigned =
2386            builder.add_atom(AtomSpec::new(Element::C).with_chiral_tag(ChiralTag::TetrahedralCw));
2387        let unassigned = builder.add_atom(AtomSpec::new(Element::C));
2388        let begin_controller =
2389            builder.add_atom(AtomSpec::new(Element::F).with_chiral_tag(ChiralTag::TetrahedralCcw));
2390        let end_controller = builder.add_atom(AtomSpec::new(Element::CL));
2391        let double_bond = builder
2392            .add_bond(BondSpec::new(assigned, unassigned, BondOrder::Double))
2393            .unwrap();
2394        builder
2395            .add_bond(BondSpec::new(assigned, begin_controller, BondOrder::Single))
2396            .unwrap();
2397        builder
2398            .add_bond(BondSpec::new(unassigned, end_controller, BondOrder::Single))
2399            .unwrap();
2400        builder
2401            .add_stereo_group(StereoGroup::new(
2402                StereoGroupKind::Absolute,
2403                vec![assigned],
2404                Vec::new(),
2405            ))
2406            .unwrap();
2407        builder
2408            .add_stereo_group(StereoGroup::new(
2409                StereoGroupKind::Or,
2410                vec![assigned, unassigned],
2411                Vec::new(),
2412            ))
2413            .unwrap();
2414        builder
2415            .add_stereo_group(StereoGroup::new(
2416                StereoGroupKind::And,
2417                vec![begin_controller],
2418                Vec::new(),
2419            ))
2420            .unwrap();
2421        (
2422            builder.build().unwrap(),
2423            assigned,
2424            unassigned,
2425            begin_controller,
2426            end_controller,
2427            double_bond,
2428        )
2429    }
2430
2431    #[test]
2432    fn python_flipper_selection_filters_records_and_groups_in_source_order() {
2433        let (mut molecule, assigned, unassigned, begin_controller, end_controller, double_bond) =
2434            grouped_selection_molecule();
2435        let records = vec![
2436            stereo_info(
2437                StereoType::AtomTetrahedral,
2438                StereoSpecified::Specified,
2439                StereoCenter::Atom(assigned),
2440                Vec::new(),
2441            ),
2442            stereo_info(
2443                StereoType::AtomTetrahedral,
2444                StereoSpecified::Unspecified,
2445                StereoCenter::Atom(unassigned),
2446                Vec::new(),
2447            ),
2448            stereo_info(
2449                StereoType::BondDouble,
2450                StereoSpecified::Unknown,
2451                StereoCenter::Bond(double_bond),
2452                vec![
2453                    ControllingAtom::Atom(begin_controller),
2454                    ControllingAtom::Missing,
2455                    ControllingAtom::Atom(end_controller),
2456                    ControllingAtom::Missing,
2457                ],
2458            ),
2459        ];
2460
2461        let all = select_stereo_flippers_from_info(
2462            &mut molecule.clone(),
2463            &records,
2464            FlipperSelectionOptions {
2465                only_unassigned: false,
2466                only_stereo_groups: false,
2467            },
2468        )
2469        .unwrap();
2470        assert_eq!(
2471            all,
2472            vec![
2473                StereoFlipper::Atom { atom: assigned },
2474                StereoFlipper::Atom { atom: unassigned },
2475                StereoFlipper::Bond { bond: double_bond },
2476            ]
2477        );
2478
2479        let unassigned_only = select_stereo_flippers_from_info(
2480            &mut molecule,
2481            &records,
2482            FlipperSelectionOptions {
2483                only_unassigned: true,
2484                only_stereo_groups: false,
2485            },
2486        )
2487        .unwrap();
2488        assert_eq!(unassigned_only.len(), 4);
2489        assert_eq!(
2490            &unassigned_only[..2],
2491            &[
2492                StereoFlipper::Atom { atom: unassigned },
2493                StereoFlipper::Bond { bond: double_bond },
2494            ]
2495        );
2496        assert!(matches!(
2497            &unassigned_only[2],
2498            StereoFlipper::StereoGroup { original_parities }
2499                if original_parities == &vec![
2500                    (assigned, ChiralTag::TetrahedralCw),
2501                    (unassigned, ChiralTag::Unspecified),
2502                ]
2503        ));
2504        assert!(matches!(
2505            &unassigned_only[3],
2506            StereoFlipper::StereoGroup { original_parities }
2507                if original_parities == &vec![(begin_controller, ChiralTag::TetrahedralCcw)]
2508        ));
2509
2510        let groups_only = select_stereo_flippers_from_info(
2511            &mut molecule,
2512            &records,
2513            FlipperSelectionOptions {
2514                only_unassigned: true,
2515                only_stereo_groups: true,
2516            },
2517        )
2518        .unwrap();
2519        assert_eq!(groups_only, unassigned_only[2..]);
2520
2521        let disabled_groups_only = select_stereo_flippers_from_info(
2522            &mut molecule,
2523            &records,
2524            FlipperSelectionOptions {
2525                only_unassigned: false,
2526                only_stereo_groups: true,
2527            },
2528        )
2529        .unwrap();
2530        assert!(disabled_groups_only.is_empty());
2531    }
2532
2533    #[test]
2534    fn python_bond_flipper_selection_initializes_preserves_and_rejects_controllers_exactly() {
2535        let (molecule, _, _, begin_controller, end_controller, double_bond) =
2536            grouped_selection_molecule();
2537        let complete = stereo_info(
2538            StereoType::BondDouble,
2539            StereoSpecified::Unspecified,
2540            StereoCenter::Bond(double_bond),
2541            vec![
2542                ControllingAtom::Atom(begin_controller),
2543                ControllingAtom::Missing,
2544                ControllingAtom::Atom(end_controller),
2545                ControllingAtom::Missing,
2546            ],
2547        );
2548        let options = FlipperSelectionOptions {
2549            only_unassigned: false,
2550            only_stereo_groups: false,
2551        };
2552
2553        let mut initialized = molecule.clone();
2554        let flippers = select_stereo_flippers_from_info(
2555            &mut initialized,
2556            std::slice::from_ref(&complete),
2557            options,
2558        )
2559        .unwrap();
2560        assert_eq!(flippers, vec![StereoFlipper::Bond { bond: double_bond }]);
2561        assert_eq!(
2562            initialized.bonds()[double_bond.index()].stereo_atoms(),
2563            Some([begin_controller, end_controller])
2564        );
2565
2566        let mut preinitialized = molecule.clone();
2567        preinitialized.topology_block_mut().bonds[double_bond.index()]
2568            .set_stereo_atoms(Some([end_controller, begin_controller]));
2569        select_stereo_flippers_from_info(
2570            &mut preinitialized,
2571            std::slice::from_ref(&complete),
2572            options,
2573        )
2574        .unwrap();
2575        assert_eq!(
2576            preinitialized.bonds()[double_bond.index()].stereo_atoms(),
2577            Some([end_controller, begin_controller])
2578        );
2579
2580        for missing in [0, 2] {
2581            let mut controllers = complete.controlling_atoms().to_vec();
2582            controllers[missing] = ControllingAtom::Missing;
2583            let incomplete = stereo_info(
2584                StereoType::BondDouble,
2585                StereoSpecified::Unspecified,
2586                StereoCenter::Bond(double_bond),
2587                controllers,
2588            );
2589            let mut candidate = molecule.clone();
2590            let flippers = select_stereo_flippers_from_info(
2591                &mut candidate,
2592                std::slice::from_ref(&incomplete),
2593                options,
2594            )
2595            .unwrap();
2596            assert!(flippers.is_empty());
2597            assert_eq!(candidate.bonds()[double_bond.index()].stereo_atoms(), None);
2598        }
2599    }
2600
2601    #[test]
2602    fn python_flipper_selection_preserves_represented_atropisomers() {
2603        let mut builder = MoleculeBuilder::new();
2604        let begin = builder.add_atom(AtomSpec::new(Element::C));
2605        let end = builder.add_atom(AtomSpec::new(Element::C));
2606        let begin_controller = builder.add_atom(AtomSpec::new(Element::F));
2607        let end_controller = builder.add_atom(AtomSpec::new(Element::CL));
2608        let bond = builder
2609            .add_bond(BondSpec::new(begin, end, BondOrder::Single).with_stereo(BondStereo::AtropCw))
2610            .unwrap();
2611        builder
2612            .add_bond(BondSpec::new(begin, begin_controller, BondOrder::Single))
2613            .unwrap();
2614        builder
2615            .add_bond(BondSpec::new(end, end_controller, BondOrder::Single))
2616            .unwrap();
2617        let mut molecule = builder.build().unwrap();
2618        let info = stereo_info(
2619            StereoType::BondAtropisomer,
2620            StereoSpecified::Specified,
2621            StereoCenter::Bond(bond),
2622            vec![
2623                ControllingAtom::Atom(begin_controller),
2624                ControllingAtom::Missing,
2625                ControllingAtom::Atom(end_controller),
2626                ControllingAtom::Missing,
2627            ],
2628        );
2629
2630        let flippers = select_stereo_flippers_from_info(
2631            &mut molecule,
2632            &[info],
2633            FlipperSelectionOptions {
2634                only_unassigned: false,
2635                only_stereo_groups: false,
2636            },
2637        )
2638        .unwrap();
2639        assert!(flippers.is_empty());
2640        assert_eq!(molecule.bonds()[bond.index()].stereo(), BondStereo::AtropCw);
2641        assert_eq!(molecule.bonds()[bond.index()].stereo_atoms(), None);
2642    }
2643}