1use rustc_hash::{FxHashMap, FxHashSet};
25use std::collections::VecDeque;
26
27use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
28
29fn is_ring_eligible(order: BondOrder) -> bool {
35 matches!(
36 order,
37 BondOrder::Single
38 | BondOrder::Double
39 | BondOrder::Triple
40 | BondOrder::Quadruple
41 | BondOrder::Aromatic
42 | BondOrder::Up
43 | BondOrder::Down
44 )
45}
46
47#[derive(Debug, Clone)]
56pub struct RingSet(Vec<Vec<AtomIdx>>);
57
58impl RingSet {
59 pub fn rings(&self) -> &[Vec<AtomIdx>] {
61 &self.0
62 }
63
64 pub fn ring_count(&self) -> usize {
66 self.0.len()
67 }
68
69 pub fn contains_atom(&self, atom: AtomIdx) -> bool {
71 self.0.iter().any(|ring| ring.contains(&atom))
72 }
73
74 pub fn atoms_in_ring_count(&self, atom: AtomIdx) -> usize {
76 self.0.iter().filter(|ring| ring.contains(&atom)).count()
77 }
78}
79
80pub fn find_sssr(mol: &Molecule) -> RingSet {
89 let v = mol.atom_count();
90 let e = mol
93 .bonds()
94 .filter(|(_, b)| is_ring_eligible(b.order))
95 .count();
96
97 if v == 0 || e == 0 {
98 return RingSet(Vec::new());
99 }
100
101 let (components, _) = bfs_spanning_forest(mol);
104 let r = (e as isize) - (v as isize) + (components as isize);
105
106 if r <= 0 {
107 return RingSet(Vec::new());
108 }
109 let r = r as usize;
110
111 let ring_bonds: Vec<(BondIdx, AtomIdx, AtomIdx)> = mol
112 .bonds()
113 .filter(|(_, b)| is_ring_eligible(b.order))
114 .map(|(bidx, b)| (bidx, b.atom1, b.atom2))
115 .collect();
116
117 let mut candidates: Vec<(Vec<BondIdx>, Vec<AtomIdx>)> = Vec::new();
122 for root_idx in 0..v {
123 let root = AtomIdx(root_idx as u32);
124 let (dist, parent) = bfs_tree(mol, root);
125 for &(bidx, x, y) in &ring_bonds {
126 if x == root || y == root {
127 continue; }
129 if dist[x.0 as usize] == usize::MAX || dist[y.0 as usize] == usize::MAX {
130 continue; }
132 if let Some(candidate) = horton_candidate(mol, root, x, y, bidx, &parent) {
133 candidates.push(candidate);
134 }
135 }
136 }
137
138 let ranks = canonical_atom_ranks(mol);
142 candidates.sort_by_cached_key(|c| (c.0.len(), canonical_cycle_key(&c.1, &ranks)));
143 candidates.dedup_by(|a, b| a.0 == b.0);
146
147 let mut basis: FxHashMap<BondIdx, Vec<BondIdx>> = FxHashMap::default();
150 let mut selected_atoms: Vec<Vec<AtomIdx>> = Vec::new();
151
152 for (bond_set, atom_seq) in candidates {
153 let reduced = gf2_reduce(&bond_set, &basis);
155
156 if !reduced.is_empty() {
157 let pivot = *reduced.iter().min().unwrap();
159 basis.insert(pivot, reduced);
160 selected_atoms.push(atom_seq);
161
162 if selected_atoms.len() == r {
163 break;
164 }
165 }
166 }
167
168 selected_atoms.sort_by_key(|ring| ring.len());
170 RingSet(selected_atoms)
171}
172
173fn bfs_spanning_forest(mol: &Molecule) -> (usize, Vec<Option<AtomIdx>>) {
184 let n = mol.atom_count();
185 let mut visited = vec![false; n];
186 let mut parent: Vec<Option<AtomIdx>> = vec![None; n];
187 let mut components = 0;
188 let mut queue: VecDeque<AtomIdx> = VecDeque::new();
189
190 for start in 0..n {
191 if visited[start] {
192 continue;
193 }
194 components += 1;
195 let start_idx = AtomIdx(start as u32);
196 visited[start] = true;
197 queue.push_back(start_idx);
198
199 while let Some(current) = queue.pop_front() {
200 for (neighbor, bidx) in mol.neighbors(current) {
201 if !is_ring_eligible(mol.bond(bidx).order) {
204 continue;
205 }
206 let ni = neighbor.0 as usize;
207 if !visited[ni] {
208 visited[ni] = true;
209 parent[ni] = Some(current);
210 queue.push_back(neighbor);
211 }
212 }
213 }
214 }
215
216 (components, parent)
217}
218
219fn bfs_tree(mol: &Molecule, root: AtomIdx) -> (Vec<usize>, Vec<Option<AtomIdx>>) {
230 let n = mol.atom_count();
231 let mut dist = vec![usize::MAX; n];
232 let mut parent: Vec<Option<AtomIdx>> = vec![None; n];
233 let mut queue: VecDeque<AtomIdx> = VecDeque::new();
234
235 dist[root.0 as usize] = 0;
236 queue.push_back(root);
237
238 while let Some(current) = queue.pop_front() {
239 for (neighbor, bidx) in mol.neighbors(current) {
240 if !is_ring_eligible(mol.bond(bidx).order) {
241 continue;
242 }
243 let ni = neighbor.0 as usize;
244 if dist[ni] == usize::MAX {
245 dist[ni] = dist[current.0 as usize] + 1;
246 parent[ni] = Some(current);
247 queue.push_back(neighbor);
248 }
249 }
250 }
251
252 (dist, parent)
253}
254
255pub fn find_smallest_rings_bfs(mol: &Molecule, root: AtomIdx) -> Vec<Vec<AtomIdx>> {
270 find_smallest_rings_bfs_with_blocked_bonds(mol, root, &FxHashSet::default())
271}
272
273pub fn find_smallest_rings_bfs_with_blocked_bonds(
277 mol: &Molecule,
278 root: AtomIdx,
279 blocked_bonds: &FxHashSet<BondIdx>,
280) -> Vec<Vec<AtomIdx>> {
281 if root.0 as usize >= mol.atom_count() {
282 return Vec::new();
283 }
284
285 let neighbors: Vec<AtomIdx> = mol
286 .neighbors(root)
287 .filter(|(_, bidx)| {
288 is_ring_eligible(mol.bond(*bidx).order) && !blocked_bonds.contains(bidx)
289 })
290 .map(|(neighbor, _)| neighbor)
291 .collect();
292 if neighbors.len() < 2 {
293 return Vec::new();
294 }
295
296 let mut best_size = usize::MAX;
297 let mut rings = Vec::new();
298 for (left_pos, &left) in neighbors.iter().enumerate() {
299 for &right in neighbors.iter().skip(left_pos + 1) {
300 let mut dist = vec![usize::MAX; mol.atom_count()];
301 let mut queue = VecDeque::new();
302 dist[left.0 as usize] = 0;
303 queue.push_back(left);
304
305 while let Some(current) = queue.pop_front() {
306 if current == right {
307 break;
308 }
309 for (next, bidx) in mol.neighbors(current) {
310 if next == root
311 || !is_ring_eligible(mol.bond(bidx).order)
312 || blocked_bonds.contains(&bidx)
313 {
314 continue;
315 }
316 let next_i = next.0 as usize;
317 if dist[next_i] == usize::MAX {
318 dist[next_i] = dist[current.0 as usize] + 1;
319 queue.push_back(next);
320 }
321 }
322 }
323
324 let right_dist = dist[right.0 as usize];
325 if right_dist == usize::MAX {
326 continue;
327 }
328 let ring_size = right_dist + 2;
329 if ring_size > best_size {
330 continue;
331 }
332
333 if ring_size < best_size {
334 best_size = ring_size;
335 rings.clear();
336 }
337 let mut path = vec![left];
338 enumerate_shortest_paths(
339 mol,
340 root,
341 right,
342 &dist,
343 blocked_bonds,
344 &mut path,
345 &mut rings,
346 );
347 }
348 }
349
350 rings.sort();
351 rings.dedup();
352 rings
353}
354
355pub fn find_smallest_rings_bfs_with_trimmed_bonds(
360 mol: &Molecule,
361 root: AtomIdx,
362 blocked_bonds: &FxHashSet<BondIdx>,
363) -> Vec<Vec<AtomIdx>> {
364 let trimmed = trim_ring_bonds(mol, blocked_bonds);
365 find_smallest_rings_bfs_with_blocked_bonds(mol, root, &trimmed)
366}
367
368pub fn trim_ring_bonds(mol: &Molecule, blocked_bonds: &FxHashSet<BondIdx>) -> FxHashSet<BondIdx> {
373 let mut active_degree = vec![0usize; mol.atom_count()];
374 for (bond, entry) in mol.bonds() {
375 if !is_ring_eligible(entry.order) || blocked_bonds.contains(&bond) {
376 continue;
377 }
378 active_degree[entry.atom1.0 as usize] += 1;
379 active_degree[entry.atom2.0 as usize] += 1;
380 }
381
382 let mut trimmed = blocked_bonds.clone();
383 let mut queue: VecDeque<AtomIdx> = active_degree
384 .iter()
385 .enumerate()
386 .filter(|(_, degree)| **degree < 2)
387 .map(|(idx, _)| AtomIdx(idx as u32))
388 .collect();
389 let mut queued = vec![false; mol.atom_count()];
390 for atom in &queue {
391 queued[atom.0 as usize] = true;
392 }
393
394 while let Some(atom) = queue.pop_front() {
395 for (neighbor, bond) in mol.neighbors(atom) {
396 if !is_ring_eligible(mol.bond(bond).order) || !trimmed.insert(bond) {
397 continue;
398 }
399 let neighbor_degree = &mut active_degree[neighbor.0 as usize];
400 *neighbor_degree = neighbor_degree.saturating_sub(1);
401 if *neighbor_degree < 2 && !queued[neighbor.0 as usize] {
402 queued[neighbor.0 as usize] = true;
403 queue.push_back(neighbor);
404 }
405 }
406 }
407
408 trimmed
409}
410
411pub fn find_smallest_rings_bfs_with_rdkit_tree(
417 mol: &Molecule,
418 root: AtomIdx,
419 blocked_bonds: &FxHashSet<BondIdx>,
420) -> Vec<Vec<AtomIdx>> {
421 if root.0 as usize >= mol.atom_count() {
422 return Vec::new();
423 }
424
425 let mut state = vec![0u8; mol.atom_count()];
426 let mut parent: Vec<Option<AtomIdx>> = vec![None; mol.atom_count()];
427 let mut depth = vec![0usize; mol.atom_count()];
428 let mut queue = VecDeque::new();
429 let mut best_size = usize::MAX;
430 let mut rings = Vec::new();
431 state[root.0 as usize] = 1;
432 queue.push_back(root);
433
434 'bfs: while let Some(current) = queue.pop_front() {
435 state[current.0 as usize] = 2;
436 if depth[current.0 as usize] + 1 > best_size {
437 break;
438 }
439 for (neighbor, bond) in mol.neighbors(current) {
440 if !is_ring_eligible(mol.bond(bond).order) || blocked_bonds.contains(&bond) {
441 continue;
442 }
443 if parent[current.0 as usize] == Some(neighbor) {
444 continue;
445 }
446 match state[neighbor.0 as usize] {
447 0 => {
448 state[neighbor.0 as usize] = 1;
449 parent[neighbor.0 as usize] = Some(current);
450 depth[neighbor.0 as usize] = depth[current.0 as usize] + 1;
451 queue.push_back(neighbor);
452 }
453 1 => {
454 let mut ring = vec![neighbor];
455 let mut ancestor = parent[neighbor.0 as usize];
456 while ancestor.is_some() && ancestor != Some(root) {
457 let atom = ancestor.expect("BFS node has a parent");
458 ring.push(atom);
459 ancestor = parent[atom.0 as usize];
460 }
461 ring.insert(0, current);
462 ancestor = parent[current.0 as usize];
463 while let Some(atom) = ancestor {
464 if ring.contains(&atom) {
465 ring.clear();
466 break;
467 }
468 ring.insert(0, atom);
469 ancestor = parent[atom.0 as usize];
470 }
471 if ring.len() > 1 {
472 if ring.len() <= best_size {
473 if ring.len() < best_size {
474 best_size = ring.len();
475 rings.clear();
476 }
477 rings.push(ring);
478 } else {
479 break 'bfs;
480 }
481 }
482 }
483 _ => {}
484 }
485 }
486 }
487
488 rings.sort();
489 rings.dedup();
490 rings
491}
492
493pub fn select_rdkit_d2_roots(mol: &Molecule) -> Vec<AtomIdx> {
498 let degree2: Vec<bool> = (0..mol.atom_count())
499 .map(|raw| {
500 mol.neighbors(AtomIdx(raw as u32))
501 .filter(|(_, bond)| is_ring_eligible(mol.bond(*bond).order))
502 .count()
503 == 2
504 })
505 .collect();
506 let mut seen = vec![false; mol.atom_count()];
507 let mut roots = Vec::new();
508 for raw in 0..mol.atom_count() {
509 if !degree2[raw] || seen[raw] {
510 continue;
511 }
512 roots.push(AtomIdx(raw as u32));
513 let mut stack = vec![AtomIdx(raw as u32)];
514 seen[raw] = true;
515 while let Some(atom) = stack.pop() {
516 for (neighbor, bond) in mol.neighbors(atom) {
517 let neighbor_i = neighbor.0 as usize;
518 if degree2[neighbor_i]
519 && is_ring_eligible(mol.bond(bond).order)
520 && !seen[neighbor_i]
521 {
522 seen[neighbor_i] = true;
523 stack.push(neighbor);
524 }
525 }
526 }
527 }
528 roots
529}
530
531pub fn find_symmetrized_sssr(mol: &Molecule) -> RingSet {
540 let base = find_sssr(mol);
541 if base.rings().is_empty() {
542 return base;
543 }
544
545 let base_bonds: Vec<FxHashSet<BondIdx>> = base
546 .rings()
547 .iter()
548 .map(|ring| ring_bond_set(mol, ring))
549 .collect();
550 let mut bond_ring_count: FxHashMap<BondIdx, usize> = FxHashMap::default();
551 for ring_bonds in &base_bonds {
552 for &bond in ring_bonds {
553 *bond_ring_count.entry(bond).or_insert(0) += 1;
554 }
555 }
556
557 let base_keys: FxHashSet<Vec<u32>> = base_bonds.iter().map(bond_set_key).collect();
558 let mut seen = base_keys.clone();
559 let mut rings = base.rings().to_vec();
560 let d2_roots = select_rdkit_d2_roots(mol);
561
562 let mut accept_candidate = |candidate: Vec<AtomIdx>| {
563 let candidate_bonds = ring_bond_set(mol, &candidate);
564 let key = bond_set_key(&candidate_bonds);
565 if base_keys.contains(&key) || !seen.insert(key) {
566 return false;
567 }
568 let accepted = base_bonds.iter().any(|basis| {
569 basis.iter().any(|bond| candidate_bonds.contains(bond))
570 && basis.iter().all(|bond| {
571 bond_ring_count.get(bond).copied().unwrap_or(0) != 1
572 || candidate_bonds.contains(bond)
573 })
574 });
575 if accepted
576 && base
577 .rings()
578 .iter()
579 .any(|ring| ring.len() == candidate.len())
580 {
581 rings.push(candidate);
582 true
583 } else {
584 false
585 }
586 };
587 let mut direct_replacements: Vec<Vec<AtomIdx>> = Vec::new();
588
589 if d2_roots.is_empty() {
590 for root in 0..mol.atom_count() {
591 for candidate in find_smallest_rings_bfs(mol, AtomIdx(root as u32)) {
592 accept_candidate(candidate);
593 }
594 }
595 } else {
596 let mut duplicate_groups: FxHashMap<Vec<u32>, (Vec<AtomIdx>, Vec<AtomIdx>)> =
597 FxHashMap::default();
598 let mut active_blocked = FxHashSet::default();
599 for &root in &d2_roots {
600 let candidates = find_smallest_rings_bfs_with_blocked_bonds(mol, root, &active_blocked);
601 if candidates.is_empty() {
602 for (_, bond) in mol.neighbors(root) {
603 if is_ring_eligible(mol.bond(bond).order) {
604 active_blocked.insert(bond);
605 }
606 }
607 active_blocked = trim_ring_bonds(mol, &active_blocked);
608 continue;
609 }
610 for candidate in candidates {
611 let key = bond_set_key(&ring_bond_set(mol, &candidate));
612 let entry = duplicate_groups
613 .entry(key)
614 .or_insert_with(|| (candidate.clone(), Vec::new()));
615 if !entry.1.contains(&root) {
616 entry.1.push(root);
617 }
618 }
619 }
620
621 for (_, (original_candidate, duplicate_roots)) in duplicate_groups {
622 if duplicate_roots.len() < 2 {
623 accept_candidate(original_candidate);
624 continue;
625 }
626 let mut replacements = Vec::new();
627 for &root in &duplicate_roots {
628 let mut blocked = FxHashSet::default();
629 for &other in &duplicate_roots {
630 if other == root {
631 continue;
632 }
633 for (_, bond) in mol.neighbors(other) {
634 if is_ring_eligible(mol.bond(bond).order) {
635 blocked.insert(bond);
636 }
637 }
638 }
639 let trimmed = trim_ring_bonds(mol, &blocked);
640 replacements.extend(find_smallest_rings_bfs_with_rdkit_tree(mol, root, &trimmed));
641 }
642 if let Some(min_size) = replacements.iter().map(Vec::len).min() {
643 replacements.retain(|candidate| candidate.len() == min_size);
644 }
645 replacements.sort_by_key(|candidate| bond_set_key(&ring_bond_set(mol, candidate)));
646 for replacement in replacements {
647 direct_replacements.push(replacement);
648 }
649 }
650 }
651
652 #[allow(clippy::drop_non_drop)]
653 drop(accept_candidate);
654 for replacement in direct_replacements {
655 let key = bond_set_key(&ring_bond_set(mol, &replacement));
656 if seen.insert(key)
657 && base
658 .rings()
659 .iter()
660 .any(|ring| ring.len() == replacement.len())
661 {
662 rings.push(replacement);
663 }
664 }
665
666 let mut extras = rings.split_off(base.ring_count());
671 extras.sort_by_key(|ring| basis_exchange_key(mol, ring, &base_bonds));
672 rings.extend(extras);
673
674 let ranks = canonical_atom_ranks(mol);
675 rings.sort_by_cached_key(|ring| (ring.len(), canonical_cycle_key(ring, &ranks)));
676 RingSet(rings)
677}
678
679fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> FxHashSet<BondIdx> {
680 let mut bonds = FxHashSet::default();
681 for i in 0..ring.len() {
682 if let Some((bond, _)) = mol.bond_between(ring[i], ring[(i + 1) % ring.len()]) {
683 bonds.insert(bond);
684 }
685 }
686 bonds
687}
688
689fn bond_set_key(set: &FxHashSet<BondIdx>) -> Vec<u32> {
690 let mut key: Vec<u32> = set.iter().map(|bond| bond.0).collect();
691 key.sort_unstable();
692 key
693}
694
695fn basis_exchange_key(
701 mol: &Molecule,
702 candidate: &[AtomIdx],
703 base_bonds: &[FxHashSet<BondIdx>],
704) -> Vec<Vec<u32>> {
705 let candidate_set = ring_bond_set(mol, candidate);
706 let candidate_key = bond_set_key(&candidate_set);
707 let candidate_len = candidate.len();
708 let mut best: Option<Vec<Vec<u32>>> = None;
709 for (replace_idx, base_ring) in base_bonds.iter().enumerate() {
710 if base_ring.len() != candidate_len {
711 continue;
712 }
713 let mut rows = base_bonds
714 .iter()
715 .enumerate()
716 .map(|(idx, set)| {
717 if idx == replace_idx {
718 candidate_key.clone()
719 } else {
720 bond_set_key(set)
721 }
722 })
723 .collect::<Vec<_>>();
724 if gf2_rank(&rows) != base_bonds.len() {
725 continue;
726 }
727 rows.sort_unstable();
728 if best.as_ref().is_none_or(|current| rows < *current) {
729 best = Some(rows);
730 }
731 }
732 best.unwrap_or_else(|| vec![candidate_key])
733}
734
735fn gf2_rank(rows: &[Vec<u32>]) -> usize {
736 let mut basis: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
737 let mut rank = 0;
738 for row in rows {
739 let mut reduced = row.clone();
740 while let Some(&pivot) = reduced.first() {
741 let Some(existing) = basis.get(&pivot) else {
742 basis.insert(pivot, reduced);
743 rank += 1;
744 break;
745 };
746 let mut xor = Vec::with_capacity(reduced.len() + existing.len());
747 let mut left = 0;
748 let mut right = 0;
749 while left < reduced.len() || right < existing.len() {
750 match (reduced.get(left), existing.get(right)) {
751 (Some(&a), Some(&b)) if a == b => {
752 left += 1;
753 right += 1;
754 }
755 (Some(&a), Some(&b)) if a < b => {
756 xor.push(a);
757 left += 1;
758 }
759 (Some(_), Some(&b)) => {
760 xor.push(b);
761 right += 1;
762 }
763 (Some(&a), None) => {
764 xor.push(a);
765 left += 1;
766 }
767 (None, Some(&b)) => {
768 xor.push(b);
769 right += 1;
770 }
771 (None, None) => break,
772 }
773 }
774 reduced = xor;
775 }
776 }
777 rank
778}
779
780fn enumerate_shortest_paths(
784 mol: &Molecule,
785 excluded: AtomIdx,
786 target: AtomIdx,
787 dist: &[usize],
788 blocked_bonds: &FxHashSet<BondIdx>,
789 path: &mut Vec<AtomIdx>,
790 rings: &mut Vec<Vec<AtomIdx>>,
791) {
792 let current = *path.last().expect("shortest-path prefix is non-empty");
793 if current == target {
794 let mut ring = Vec::with_capacity(path.len() + 1);
795 ring.push(excluded);
796 ring.extend(path.iter().copied());
797 rings.push(ring);
798 return;
799 }
800
801 let current_dist = dist[current.0 as usize];
802 for (next, bidx) in mol.neighbors(current) {
803 if next == excluded
804 || !is_ring_eligible(mol.bond(bidx).order)
805 || blocked_bonds.contains(&bidx)
806 {
807 continue;
808 }
809 if dist[next.0 as usize] != current_dist + 1 {
810 continue;
811 }
812 path.push(next);
813 enumerate_shortest_paths(mol, excluded, target, dist, blocked_bonds, path, rings);
814 path.pop();
815 }
816}
817
818fn horton_candidate(
826 mol: &Molecule,
827 root: AtomIdx,
828 x: AtomIdx,
829 y: AtomIdx,
830 bidx: BondIdx,
831 parent: &[Option<AtomIdx>],
832) -> Option<(Vec<BondIdx>, Vec<AtomIdx>)> {
833 let path_x = path_to_root(x, parent); let path_y = path_to_root(y, parent); debug_assert_eq!(*path_x.last().unwrap(), root);
836 debug_assert_eq!(*path_y.last().unwrap(), root);
837
838 let interior_x: FxHashSet<AtomIdx> = path_x[..path_x.len() - 1].iter().copied().collect();
840 if path_y[..path_y.len() - 1]
841 .iter()
842 .any(|a| interior_x.contains(a))
843 {
844 return None;
845 }
846
847 let mut ring_atoms: Vec<AtomIdx> = path_x.clone();
849 for &a in path_y.iter().rev().skip(1) {
850 ring_atoms.push(a);
851 }
852
853 let mut bond_set: Vec<BondIdx> = Vec::new();
854 for i in 0..path_x.len().saturating_sub(1) {
855 let (b, _) = mol.bond_between(path_x[i], path_x[i + 1])?;
856 bond_set.push(b);
857 }
858 for i in 0..path_y.len().saturating_sub(1) {
859 let (b, _) = mol.bond_between(path_y[i], path_y[i + 1])?;
860 bond_set.push(b);
861 }
862 bond_set.push(bidx);
863 bond_set.sort();
864 bond_set.dedup();
865
866 Some((bond_set, ring_atoms))
867}
868
869fn path_to_root(start: AtomIdx, parent: &[Option<AtomIdx>]) -> Vec<AtomIdx> {
872 let mut chain = Vec::new();
873 let mut current = start;
874 loop {
875 chain.push(current);
876 match parent[current.0 as usize] {
877 Some(p) => current = p,
878 None => break,
879 }
880 }
881 chain
882}
883
884fn canonical_atom_ranks(mol: &Molecule) -> Vec<u64> {
899 let n = mol.atom_count();
900 let mut keys: Vec<u64> = (0..n)
901 .map(|i| {
902 let idx = AtomIdx(i as u32);
903 let atom = mol.atom(idx);
904 let z = atom.element.atomic_number() as u64;
905 let degree = mol.degree(idx) as u64;
906 let charge = (atom.charge as i64 + 8) as u64; let aromatic = u64::from(atom.aromatic);
908 (z << 24) | (degree << 16) | (charge << 8) | aromatic
909 })
910 .collect();
911
912 const ROUNDS: usize = 3;
913 for _ in 0..ROUNDS {
914 let mut next = Vec::with_capacity(n);
915 for i in 0..n {
916 let mut neighbor_keys: Vec<u64> = mol
917 .neighbors(AtomIdx(i as u32))
918 .map(|(nb, _)| keys[nb.0 as usize])
919 .collect();
920 neighbor_keys.sort_unstable();
921 let mut h = keys[i];
922 for nk in neighbor_keys {
923 h = h.wrapping_mul(1_000_003).wrapping_add(nk);
924 }
925 next.push(h);
926 }
927 keys = next;
928 }
929 keys
930}
931
932fn canonical_cycle_key(atom_seq: &[AtomIdx], ranks: &[u64]) -> u64 {
936 let mut vals: Vec<u64> = atom_seq.iter().map(|a| ranks[a.0 as usize]).collect();
937 vals.sort_unstable();
938 let mut h: u64 = 0;
939 for v in vals {
940 h = h.wrapping_mul(1_000_003).wrapping_add(v);
941 }
942 h
943}
944
945fn gf2_reduce(cycle: &[BondIdx], basis: &FxHashMap<BondIdx, Vec<BondIdx>>) -> Vec<BondIdx> {
956 let mut current: Vec<BondIdx> = cycle.to_vec();
957 while let Some(&pivot) = current.iter().min() {
958 match basis.get(&pivot) {
959 None => return current, Some(basis_row) => current = sym_diff(¤t, basis_row),
962 }
963 }
964 current
965}
966
967fn sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
969 let mut result = Vec::new();
970 let mut i = 0;
971 let mut j = 0;
972 while i < a.len() && j < b.len() {
973 match a[i].cmp(&b[j]) {
974 std::cmp::Ordering::Less => {
975 result.push(a[i]);
976 i += 1;
977 }
978 std::cmp::Ordering::Greater => {
979 result.push(b[j]);
980 j += 1;
981 }
982 std::cmp::Ordering::Equal => {
983 i += 1;
985 j += 1;
986 }
987 }
988 }
989 result.extend_from_slice(&a[i..]);
990 result.extend_from_slice(&b[j..]);
991 result
992}
993
994#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
1002
1003 fn cyclohexane() -> chematic_core::Molecule {
1005 let mut b = MoleculeBuilder::new();
1006 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1007 for i in 0..6 {
1008 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
1009 .unwrap();
1010 }
1011 b.build()
1012 }
1013
1014 fn benzene() -> chematic_core::Molecule {
1016 let mut b = MoleculeBuilder::new();
1017 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1018 for i in 0..6 {
1019 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
1020 .unwrap();
1021 }
1022 b.build()
1023 }
1024
1025 fn naphthalene() -> chematic_core::Molecule {
1030 let mut b = MoleculeBuilder::new();
1031 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1032 let ring1 = [0usize, 1, 2, 3, 4, 9];
1034 for i in 0..6 {
1035 b.add_bond(
1036 atoms[ring1[i]],
1037 atoms[ring1[(i + 1) % 6]],
1038 BondOrder::Single,
1039 )
1040 .unwrap();
1041 }
1042 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
1045 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
1046 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
1047 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
1048 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
1049 b.build()
1050 }
1051
1052 fn norbornane() -> chematic_core::Molecule {
1059 let mut b = MoleculeBuilder::new();
1060 let atoms: Vec<_> = (0..7).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1061 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
1063 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
1064 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
1065 b.add_bond(atoms[0], atoms[4], BondOrder::Single).unwrap();
1067 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
1068 b.add_bond(atoms[5], atoms[3], BondOrder::Single).unwrap();
1069 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
1071 b.add_bond(atoms[6], atoms[3], BondOrder::Single).unwrap();
1072 b.build()
1073 }
1074
1075 #[test]
1076 fn test_azulene_sssr_minimal() {
1077 let mol = chematic_smiles::parse("C1=CC2=CC=CC=CC2=C1").expect("azulene SMILES");
1083 let sssr = find_sssr(&mol);
1084 let mut sizes: Vec<usize> = sssr.rings().iter().map(|r| r.len()).collect();
1085 sizes.sort_unstable();
1086 assert_eq!(sizes, vec![5, 7], "azulene SSSR must be minimal [5, 7]");
1087 }
1088
1089 #[test]
1090 fn test_indolizine_sssr_minimal() {
1091 let mol = chematic_smiles::parse("c1ccn2ccccc12").expect("indolizine SMILES");
1096 let sssr = find_sssr(&mol);
1097 let mut sizes: Vec<usize> = sssr.rings().iter().map(|r| r.len()).collect();
1098 sizes.sort_unstable();
1099 assert_eq!(sizes, vec![5, 6], "indolizine SSSR must be minimal [5, 6]");
1100 }
1101
1102 #[test]
1103 fn test_cyclohexane_sssr() {
1104 let mol = cyclohexane();
1105 let rings = find_sssr(&mol);
1106 assert_eq!(rings.ring_count(), 1, "cyclohexane has exactly 1 ring");
1107 assert_eq!(rings.rings()[0].len(), 6, "cyclohexane ring has 6 atoms");
1108 }
1109
1110 #[test]
1111 fn blocked_bond_shortest_ring_search_is_non_mutating() {
1112 let mol = cyclohexane();
1113 let (bond, _) = mol.bond_between(AtomIdx(0), AtomIdx(1)).unwrap();
1114 let mut blocked = FxHashSet::default();
1115 blocked.insert(bond);
1116 assert!(find_smallest_rings_bfs_with_blocked_bonds(&mol, AtomIdx(0), &blocked).is_empty());
1117 assert_eq!(find_sssr(&mol).ring_count(), 1);
1118 }
1119
1120 #[test]
1121 fn rdkit_d2_root_selection_collapses_degree2_chains() {
1122 let roots = select_rdkit_d2_roots(&benzene());
1123 assert_eq!(roots, vec![AtomIdx(0)]);
1124 }
1125
1126 #[test]
1127 fn test_benzene_sssr() {
1128 let mol = benzene();
1129 let rings = find_sssr(&mol);
1130 assert_eq!(rings.ring_count(), 1, "benzene has exactly 1 ring");
1131 assert_eq!(rings.rings()[0].len(), 6, "benzene ring has 6 atoms");
1132 }
1133
1134 #[test]
1135 fn test_naphthalene_sssr() {
1136 let mol = naphthalene();
1137 let rings = find_sssr(&mol);
1138 assert_eq!(rings.ring_count(), 2, "naphthalene SSSR has 2 rings");
1141 for ring in rings.rings() {
1142 assert_eq!(ring.len(), 6, "each naphthalene SSSR ring has 6 atoms");
1143 }
1144 }
1145
1146 #[test]
1147 fn test_norbornane_sssr() {
1148 let mol = norbornane();
1149 let rings = find_sssr(&mol);
1150 assert_eq!(rings.ring_count(), 2, "norbornane SSSR has 2 rings");
1152 for ring in rings.rings() {
1154 assert_eq!(ring.len(), 5, "each norbornane SSSR ring has 5 atoms");
1155 }
1156 }
1157
1158 #[test]
1159 fn test_acyclic_molecule() {
1160 let mut b = MoleculeBuilder::new();
1162 let c1 = b.add_atom(Atom::new(Element::C));
1163 let c2 = b.add_atom(Atom::new(Element::C));
1164 b.add_bond(c1, c2, BondOrder::Single).unwrap();
1165 let mol = b.build();
1166 let rings = find_sssr(&mol);
1167 assert_eq!(rings.ring_count(), 0);
1168 }
1169
1170 #[test]
1171 fn test_contains_atom() {
1172 let mol = cyclohexane();
1173 let rings = find_sssr(&mol);
1174 for i in 0..6u32 {
1175 assert!(
1176 rings.contains_atom(AtomIdx(i)),
1177 "atom {} should be in a ring",
1178 i
1179 );
1180 }
1181 }
1182
1183 #[test]
1184 fn test_atoms_in_ring_count_benzene() {
1185 let mol = benzene();
1186 let rings = find_sssr(&mol);
1187 for i in 0..6u32 {
1188 assert_eq!(
1189 rings.atoms_in_ring_count(AtomIdx(i)),
1190 1,
1191 "each benzene atom is in exactly 1 ring"
1192 );
1193 }
1194 }
1195
1196 fn anthracene() -> chematic_core::Molecule {
1199 let mut b = MoleculeBuilder::new();
1200 let atoms: Vec<_> = (0..14).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1201 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
1203 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
1204 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
1205 b.add_bond(atoms[3], atoms[8], BondOrder::Single).unwrap();
1206 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
1207 b.add_bond(atoms[9], atoms[0], BondOrder::Single).unwrap();
1208 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
1210 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
1211 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
1212 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
1213 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
1214 b.add_bond(atoms[7], atoms[10], BondOrder::Single).unwrap();
1216 b.add_bond(atoms[10], atoms[11], BondOrder::Single).unwrap();
1217 b.add_bond(atoms[11], atoms[12], BondOrder::Single).unwrap();
1218 b.add_bond(atoms[12], atoms[13], BondOrder::Single).unwrap();
1219 b.add_bond(atoms[13], atoms[6], BondOrder::Single).unwrap();
1220 b.build()
1221 }
1222
1223 fn spiro_nonane() -> chematic_core::Molecule {
1226 let mut b = MoleculeBuilder::new();
1227 let atoms: Vec<_> = (0..9).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1228 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
1231 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
1232 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
1233 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
1234 b.add_bond(atoms[4], atoms[0], BondOrder::Single).unwrap();
1235 b.add_bond(atoms[0], atoms[5], BondOrder::Single).unwrap();
1237 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
1238 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
1239 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
1240 b.add_bond(atoms[8], atoms[0], BondOrder::Single).unwrap();
1241 b.build()
1242 }
1243
1244 fn dodecane_ring() -> chematic_core::Molecule {
1246 let mut b = MoleculeBuilder::new();
1247 let atoms: Vec<_> = (0..12).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1248 for i in 0..12 {
1249 b.add_bond(atoms[i], atoms[(i + 1) % 12], BondOrder::Single)
1250 .unwrap();
1251 }
1252 b.build()
1253 }
1254
1255 fn disconnected_rings() -> chematic_core::Molecule {
1257 let mut b = MoleculeBuilder::new();
1258 let benzene_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1260 for i in 0..6 {
1261 b.add_bond(
1262 benzene_atoms[i],
1263 benzene_atoms[(i + 1) % 6],
1264 BondOrder::Single,
1265 )
1266 .unwrap();
1267 }
1268 let hexane_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1270 for i in 0..6 {
1271 b.add_bond(
1272 hexane_atoms[i],
1273 hexane_atoms[(i + 1) % 6],
1274 BondOrder::Single,
1275 )
1276 .unwrap();
1277 }
1278 b.build()
1279 }
1280
1281 fn adamantane() -> chematic_core::Molecule {
1284 let mut b = MoleculeBuilder::new();
1285 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1286 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
1289 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
1290 b.add_bond(atoms[2], atoms[5], BondOrder::Single).unwrap();
1291 b.add_bond(atoms[0], atoms[3], BondOrder::Single).unwrap();
1293 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
1294 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
1295 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
1297 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
1298 b.add_bond(atoms[7], atoms[5], BondOrder::Single).unwrap();
1299 b.add_bond(atoms[1], atoms[3], BondOrder::Single).unwrap();
1302 b.add_bond(atoms[2], atoms[4], BondOrder::Single).unwrap();
1303 b.build()
1304 }
1305
1306 #[test]
1307 fn test_anthracene_sssr() {
1308 let mol = anthracene();
1309 let rings = find_sssr(&mol);
1310 assert_eq!(rings.ring_count(), 3, "anthracene SSSR has 3 rings");
1316 for ring in rings.rings() {
1317 assert_eq!(ring.len(), 6, "each anthracene SSSR ring has 6 atoms");
1318 }
1319 let all_ring_atoms: std::collections::HashSet<_> = rings
1320 .rings()
1321 .iter()
1322 .flat_map(|r| r.iter().copied())
1323 .collect();
1324 assert_eq!(
1325 all_ring_atoms.len(),
1326 14,
1327 "anthracene SSSR atoms cover every atom"
1328 );
1329 }
1330
1331 #[test]
1332 fn test_spiro_nonane_sssr() {
1333 let mol = spiro_nonane();
1334 let rings = find_sssr(&mol);
1335 assert_eq!(rings.ring_count(), 2, "spiro[4.4]nonane SSSR has 2 rings");
1339 for ring in rings.rings() {
1340 assert_eq!(ring.len(), 5, "each spiro nonane SSSR ring is 5-membered");
1341 }
1342 }
1343
1344 #[test]
1345 fn test_dodecane_ring_sssr() {
1346 let mol = dodecane_ring();
1347 let rings = find_sssr(&mol);
1348 assert_eq!(rings.ring_count(), 1, "12-membered ring has 1 SSSR entry");
1349 assert_eq!(
1350 rings.rings()[0].len(),
1351 12,
1352 "12-membered ring SSSR has 12 atoms"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_disconnected_rings_sssr() {
1358 let mol = disconnected_rings();
1359 let rings = find_sssr(&mol);
1360 assert_eq!(
1362 rings.ring_count(),
1363 2,
1364 "two disconnected rings yield 2 SSSR entries"
1365 );
1366 let sizes: Vec<_> = rings.rings().iter().map(|r| r.len()).collect();
1367 assert!(sizes.contains(&6), "one ring should be 6-membered");
1368 }
1369
1370 #[test]
1371 fn test_adamantane_sssr() {
1372 let mol = adamantane();
1373 let rings = find_sssr(&mol);
1374 assert!(
1378 rings.ring_count() >= 3,
1379 "adamantane SSSR has at least 3 rings"
1380 );
1381 for ring in rings.rings() {
1383 assert!(!ring.is_empty(), "each ring should have atoms");
1384 assert!(ring.len() <= 10, "ring should not exceed molecule size");
1385 }
1386 }
1387
1388 #[test]
1389 fn test_macrocycle_atom_in_ring_count() {
1390 let mol = dodecane_ring();
1391 let rings = find_sssr(&mol);
1392 for i in 0..12u32 {
1393 assert_eq!(
1394 rings.atoms_in_ring_count(AtomIdx(i)),
1395 1,
1396 "each dodecane atom is in exactly 1 ring"
1397 );
1398 }
1399 }
1400
1401 #[test]
1402 fn test_figueras_bfs_finds_all_smallest_cubane_faces_through_each_root() {
1403 let mol = chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES");
1404 let mut faces = std::collections::BTreeSet::new();
1405 for root in 0..mol.atom_count() {
1406 for ring in find_smallest_rings_bfs(&mol, AtomIdx(root as u32)) {
1407 assert_eq!(ring.len(), 4, "cubane's smallest rings are square faces");
1408 let mut face = ring.into_iter().map(|a| a.0).collect::<Vec<_>>();
1409 face.sort_unstable();
1410 faces.insert(face);
1411 }
1412 }
1413 assert_eq!(
1414 faces.len(),
1415 6,
1416 "cubane has six symmetry-equivalent square faces"
1417 );
1418
1419 let mol = chematic_smiles::parse("C12C3C4C5C1C6C7C2C8C3C9C4C1C5C6C2C7C8C9C12")
1420 .expect("dodecahedrane SMILES");
1421 let mut faces = std::collections::BTreeSet::new();
1422 for root in 0..mol.atom_count() {
1423 for ring in find_smallest_rings_bfs(&mol, AtomIdx(root as u32)) {
1424 assert_eq!(
1425 ring.len(),
1426 5,
1427 "dodecahedrane's smallest rings are pentagons"
1428 );
1429 let mut face = ring.into_iter().map(|a| a.0).collect::<Vec<_>>();
1430 face.sort_unstable();
1431 faces.insert(face);
1432 }
1433 }
1434 assert_eq!(
1435 faces.len(),
1436 12,
1437 "dodecahedrane has twelve symmetry-equivalent pentagonal faces"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_symmetrized_sssr_adds_only_verified_duplicate_faces() {
1443 let benzene = chematic_smiles::parse("c1ccccc1").expect("benzene SMILES");
1444 assert_eq!(find_symmetrized_sssr(&benzene).ring_count(), 1);
1445
1446 let cubane = chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES");
1447 assert_eq!(find_symmetrized_sssr(&cubane).ring_count(), 6);
1448
1449 let dodeca = chematic_smiles::parse("C12C3C4C5C1C6C7C2C8C3C9C4C1C5C6C2C7C8C9C12")
1450 .expect("dodecahedrane SMILES");
1451 assert_eq!(find_symmetrized_sssr(&dodeca).ring_count(), 12);
1452 }
1453
1454 #[test]
1455 fn test_cubane_sssr() {
1456 let mol = chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES");
1481 let sssr = find_sssr(&mol);
1482
1483 assert_eq!(
1484 sssr.rings().len(),
1485 5,
1486 "cubane must have exactly 5 SSSR rings (cycle rank 12−8+1=5)"
1487 );
1488 for ring in sssr.rings() {
1489 assert!(
1490 ring.len() <= 6,
1491 "cubane SSSR rings must be ≤ 6-membered, got {}",
1492 ring.len()
1493 );
1494 }
1495 let four_membered = sssr.rings().iter().filter(|r| r.len() == 4).count();
1496 assert_eq!(
1497 four_membered, 5,
1498 "Horton SSSR should find all 5 basis rings as 4-membered faces, got {four_membered}"
1499 );
1500 }
1501
1502 fn permute_molecule(mol: &chematic_core::Molecule, perm: &[usize]) -> chematic_core::Molecule {
1505 let mut old_to_new = vec![0u32; perm.len()];
1506 for (new_idx, &old_idx) in perm.iter().enumerate() {
1507 old_to_new[old_idx] = new_idx as u32;
1508 }
1509 let mut builder = MoleculeBuilder::new();
1510 for &old_idx in perm {
1511 builder.add_atom(mol.atom(AtomIdx(old_idx as u32)).clone());
1512 }
1513 for (_, bond) in mol.bonds() {
1514 let a = AtomIdx(old_to_new[bond.atom1.0 as usize]);
1515 let b = AtomIdx(old_to_new[bond.atom2.0 as usize]);
1516 let _ = builder.add_bond(a, b, bond.order);
1517 }
1518 builder.build()
1519 }
1520
1521 #[test]
1531 fn find_sssr_ring_size_multiset_is_permutation_invariant() {
1532 let cases: Vec<(&str, chematic_core::Molecule)> = vec![
1533 ("naphthalene", naphthalene()),
1534 ("norbornane", norbornane()),
1535 ("spiro_nonane", spiro_nonane()),
1536 ("adamantane", adamantane()),
1537 (
1538 "cubane",
1539 chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES"),
1540 ),
1541 ];
1542
1543 for (name, mol) in cases {
1544 let n = mol.atom_count();
1545 let mut orig_sizes: Vec<usize> = find_sssr(&mol).rings().iter().map(Vec::len).collect();
1546 orig_sizes.sort_unstable();
1547
1548 let perms: Vec<Vec<usize>> = vec![(0..n).rev().collect(), {
1549 let mut p: Vec<usize> = (0..n).collect();
1550 if n > 2 {
1551 p.rotate_left(n / 3 + 1);
1552 }
1553 p
1554 }];
1555 for perm in perms {
1556 let permuted = permute_molecule(&mol, &perm);
1557 let mut perm_sizes: Vec<usize> =
1558 find_sssr(&permuted).rings().iter().map(Vec::len).collect();
1559 perm_sizes.sort_unstable();
1560 assert_eq!(
1561 orig_sizes, perm_sizes,
1562 "{name}: SSSR ring-size multiset changed under atom permutation {perm:?}"
1563 );
1564 }
1565 }
1566 }
1567
1568 #[test]
1571 fn sssr_ignores_zero_order_bonds() {
1572 let mut b = MoleculeBuilder::new();
1575 let mut a_atom = Atom::new(chematic_core::Element::C);
1576 a_atom.hydrogen_count = Some(3);
1577 let mut b_atom = Atom::new(chematic_core::Element::C);
1578 b_atom.hydrogen_count = Some(3);
1579 let a = b.add_atom(a_atom);
1580 let bb = b.add_atom(b_atom);
1581 b.add_bond(a, bb, BondOrder::Single).unwrap();
1582 b.add_bond(a, bb, BondOrder::Zero)
1583 .expect_err("duplicate bond — MoleculeBuilder should reject or ignore it");
1584 let mol = b.build();
1587 let sssr = find_sssr(&mol);
1588 assert_eq!(
1589 sssr.rings().len(),
1590 0,
1591 "single bond between two atoms → no ring"
1592 );
1593 }
1594
1595 #[test]
1596 fn sssr_ignores_zero_order_bond_as_third_bond() {
1597 let mut b = MoleculeBuilder::new();
1602 let atoms: Vec<_> = (0..4)
1603 .map(|_| {
1604 let mut a = Atom::new(chematic_core::Element::C);
1605 a.hydrogen_count = Some(2);
1606 b.add_atom(a)
1607 })
1608 .collect();
1609 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
1611 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
1612 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
1613 b.add_bond(atoms[3], atoms[0], BondOrder::Single).unwrap();
1614 let _ = b.add_bond(atoms[0], atoms[2], BondOrder::Zero);
1617 let mol = b.build();
1618 let sssr = find_sssr(&mol);
1619 assert_eq!(
1621 sssr.rings().len(),
1622 1,
1623 "zero-order diagonal bond must not create extra rings: found {:?}",
1624 sssr.rings().iter().map(|r| r.len()).collect::<Vec<_>>()
1625 );
1626 }
1627}