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
255fn horton_candidate(
263 mol: &Molecule,
264 root: AtomIdx,
265 x: AtomIdx,
266 y: AtomIdx,
267 bidx: BondIdx,
268 parent: &[Option<AtomIdx>],
269) -> Option<(Vec<BondIdx>, Vec<AtomIdx>)> {
270 let path_x = path_to_root(x, parent); let path_y = path_to_root(y, parent); debug_assert_eq!(*path_x.last().unwrap(), root);
273 debug_assert_eq!(*path_y.last().unwrap(), root);
274
275 let interior_x: FxHashSet<AtomIdx> = path_x[..path_x.len() - 1].iter().copied().collect();
277 if path_y[..path_y.len() - 1]
278 .iter()
279 .any(|a| interior_x.contains(a))
280 {
281 return None;
282 }
283
284 let mut ring_atoms: Vec<AtomIdx> = path_x.clone();
286 for &a in path_y.iter().rev().skip(1) {
287 ring_atoms.push(a);
288 }
289
290 let mut bond_set: Vec<BondIdx> = Vec::new();
291 for i in 0..path_x.len().saturating_sub(1) {
292 let (b, _) = mol.bond_between(path_x[i], path_x[i + 1])?;
293 bond_set.push(b);
294 }
295 for i in 0..path_y.len().saturating_sub(1) {
296 let (b, _) = mol.bond_between(path_y[i], path_y[i + 1])?;
297 bond_set.push(b);
298 }
299 bond_set.push(bidx);
300 bond_set.sort();
301 bond_set.dedup();
302
303 Some((bond_set, ring_atoms))
304}
305
306fn path_to_root(start: AtomIdx, parent: &[Option<AtomIdx>]) -> Vec<AtomIdx> {
309 let mut chain = Vec::new();
310 let mut current = start;
311 loop {
312 chain.push(current);
313 match parent[current.0 as usize] {
314 Some(p) => current = p,
315 None => break,
316 }
317 }
318 chain
319}
320
321fn canonical_atom_ranks(mol: &Molecule) -> Vec<u64> {
336 let n = mol.atom_count();
337 let mut keys: Vec<u64> = (0..n)
338 .map(|i| {
339 let idx = AtomIdx(i as u32);
340 let atom = mol.atom(idx);
341 let z = atom.element.atomic_number() as u64;
342 let degree = mol.degree(idx) as u64;
343 let charge = (atom.charge as i64 + 8) as u64; let aromatic = u64::from(atom.aromatic);
345 (z << 24) | (degree << 16) | (charge << 8) | aromatic
346 })
347 .collect();
348
349 const ROUNDS: usize = 3;
350 for _ in 0..ROUNDS {
351 let mut next = Vec::with_capacity(n);
352 for i in 0..n {
353 let mut neighbor_keys: Vec<u64> = mol
354 .neighbors(AtomIdx(i as u32))
355 .map(|(nb, _)| keys[nb.0 as usize])
356 .collect();
357 neighbor_keys.sort_unstable();
358 let mut h = keys[i];
359 for nk in neighbor_keys {
360 h = h.wrapping_mul(1_000_003).wrapping_add(nk);
361 }
362 next.push(h);
363 }
364 keys = next;
365 }
366 keys
367}
368
369fn canonical_cycle_key(atom_seq: &[AtomIdx], ranks: &[u64]) -> u64 {
373 let mut vals: Vec<u64> = atom_seq.iter().map(|a| ranks[a.0 as usize]).collect();
374 vals.sort_unstable();
375 let mut h: u64 = 0;
376 for v in vals {
377 h = h.wrapping_mul(1_000_003).wrapping_add(v);
378 }
379 h
380}
381
382fn gf2_reduce(cycle: &[BondIdx], basis: &FxHashMap<BondIdx, Vec<BondIdx>>) -> Vec<BondIdx> {
393 let mut current: Vec<BondIdx> = cycle.to_vec();
394 while let Some(&pivot) = current.iter().min() {
395 match basis.get(&pivot) {
396 None => return current, Some(basis_row) => current = sym_diff(¤t, basis_row),
399 }
400 }
401 current
402}
403
404fn sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
406 let mut result = Vec::new();
407 let mut i = 0;
408 let mut j = 0;
409 while i < a.len() && j < b.len() {
410 match a[i].cmp(&b[j]) {
411 std::cmp::Ordering::Less => {
412 result.push(a[i]);
413 i += 1;
414 }
415 std::cmp::Ordering::Greater => {
416 result.push(b[j]);
417 j += 1;
418 }
419 std::cmp::Ordering::Equal => {
420 i += 1;
422 j += 1;
423 }
424 }
425 }
426 result.extend_from_slice(&a[i..]);
427 result.extend_from_slice(&b[j..]);
428 result
429}
430
431#[cfg(test)]
436mod tests {
437 use super::*;
438 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
439
440 fn cyclohexane() -> chematic_core::Molecule {
442 let mut b = MoleculeBuilder::new();
443 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
444 for i in 0..6 {
445 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
446 .unwrap();
447 }
448 b.build()
449 }
450
451 fn benzene() -> chematic_core::Molecule {
453 let mut b = MoleculeBuilder::new();
454 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
455 for i in 0..6 {
456 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
457 .unwrap();
458 }
459 b.build()
460 }
461
462 fn naphthalene() -> chematic_core::Molecule {
467 let mut b = MoleculeBuilder::new();
468 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
469 let ring1 = [0usize, 1, 2, 3, 4, 9];
471 for i in 0..6 {
472 b.add_bond(
473 atoms[ring1[i]],
474 atoms[ring1[(i + 1) % 6]],
475 BondOrder::Single,
476 )
477 .unwrap();
478 }
479 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
482 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
483 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
484 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
485 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
486 b.build()
487 }
488
489 fn norbornane() -> chematic_core::Molecule {
496 let mut b = MoleculeBuilder::new();
497 let atoms: Vec<_> = (0..7).map(|_| b.add_atom(Atom::new(Element::C))).collect();
498 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
500 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
501 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
502 b.add_bond(atoms[0], atoms[4], BondOrder::Single).unwrap();
504 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
505 b.add_bond(atoms[5], atoms[3], BondOrder::Single).unwrap();
506 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
508 b.add_bond(atoms[6], atoms[3], BondOrder::Single).unwrap();
509 b.build()
510 }
511
512 #[test]
513 fn test_azulene_sssr_minimal() {
514 let mol = chematic_smiles::parse("C1=CC2=CC=CC=CC2=C1").expect("azulene SMILES");
520 let sssr = find_sssr(&mol);
521 let mut sizes: Vec<usize> = sssr.rings().iter().map(|r| r.len()).collect();
522 sizes.sort_unstable();
523 assert_eq!(sizes, vec![5, 7], "azulene SSSR must be minimal [5, 7]");
524 }
525
526 #[test]
527 fn test_indolizine_sssr_minimal() {
528 let mol = chematic_smiles::parse("c1ccn2ccccc12").expect("indolizine SMILES");
533 let sssr = find_sssr(&mol);
534 let mut sizes: Vec<usize> = sssr.rings().iter().map(|r| r.len()).collect();
535 sizes.sort_unstable();
536 assert_eq!(sizes, vec![5, 6], "indolizine SSSR must be minimal [5, 6]");
537 }
538
539 #[test]
540 fn test_cyclohexane_sssr() {
541 let mol = cyclohexane();
542 let rings = find_sssr(&mol);
543 assert_eq!(rings.ring_count(), 1, "cyclohexane has exactly 1 ring");
544 assert_eq!(rings.rings()[0].len(), 6, "cyclohexane ring has 6 atoms");
545 }
546
547 #[test]
548 fn test_benzene_sssr() {
549 let mol = benzene();
550 let rings = find_sssr(&mol);
551 assert_eq!(rings.ring_count(), 1, "benzene has exactly 1 ring");
552 assert_eq!(rings.rings()[0].len(), 6, "benzene ring has 6 atoms");
553 }
554
555 #[test]
556 fn test_naphthalene_sssr() {
557 let mol = naphthalene();
558 let rings = find_sssr(&mol);
559 assert_eq!(rings.ring_count(), 2, "naphthalene SSSR has 2 rings");
562 for ring in rings.rings() {
563 assert_eq!(ring.len(), 6, "each naphthalene SSSR ring has 6 atoms");
564 }
565 }
566
567 #[test]
568 fn test_norbornane_sssr() {
569 let mol = norbornane();
570 let rings = find_sssr(&mol);
571 assert_eq!(rings.ring_count(), 2, "norbornane SSSR has 2 rings");
573 for ring in rings.rings() {
575 assert_eq!(ring.len(), 5, "each norbornane SSSR ring has 5 atoms");
576 }
577 }
578
579 #[test]
580 fn test_acyclic_molecule() {
581 let mut b = MoleculeBuilder::new();
583 let c1 = b.add_atom(Atom::new(Element::C));
584 let c2 = b.add_atom(Atom::new(Element::C));
585 b.add_bond(c1, c2, BondOrder::Single).unwrap();
586 let mol = b.build();
587 let rings = find_sssr(&mol);
588 assert_eq!(rings.ring_count(), 0);
589 }
590
591 #[test]
592 fn test_contains_atom() {
593 let mol = cyclohexane();
594 let rings = find_sssr(&mol);
595 for i in 0..6u32 {
596 assert!(
597 rings.contains_atom(AtomIdx(i)),
598 "atom {} should be in a ring",
599 i
600 );
601 }
602 }
603
604 #[test]
605 fn test_atoms_in_ring_count_benzene() {
606 let mol = benzene();
607 let rings = find_sssr(&mol);
608 for i in 0..6u32 {
609 assert_eq!(
610 rings.atoms_in_ring_count(AtomIdx(i)),
611 1,
612 "each benzene atom is in exactly 1 ring"
613 );
614 }
615 }
616
617 fn anthracene() -> chematic_core::Molecule {
620 let mut b = MoleculeBuilder::new();
621 let atoms: Vec<_> = (0..14).map(|_| b.add_atom(Atom::new(Element::C))).collect();
622 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
624 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
625 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
626 b.add_bond(atoms[3], atoms[8], BondOrder::Single).unwrap();
627 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
628 b.add_bond(atoms[9], atoms[0], BondOrder::Single).unwrap();
629 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
631 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
632 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
633 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
634 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
635 b.add_bond(atoms[7], atoms[10], BondOrder::Single).unwrap();
637 b.add_bond(atoms[10], atoms[11], BondOrder::Single).unwrap();
638 b.add_bond(atoms[11], atoms[12], BondOrder::Single).unwrap();
639 b.add_bond(atoms[12], atoms[13], BondOrder::Single).unwrap();
640 b.add_bond(atoms[13], atoms[6], BondOrder::Single).unwrap();
641 b.build()
642 }
643
644 fn spiro_nonane() -> chematic_core::Molecule {
647 let mut b = MoleculeBuilder::new();
648 let atoms: Vec<_> = (0..9).map(|_| b.add_atom(Atom::new(Element::C))).collect();
649 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
652 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
653 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
654 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
655 b.add_bond(atoms[4], atoms[0], BondOrder::Single).unwrap();
656 b.add_bond(atoms[0], atoms[5], BondOrder::Single).unwrap();
658 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
659 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
660 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
661 b.add_bond(atoms[8], atoms[0], BondOrder::Single).unwrap();
662 b.build()
663 }
664
665 fn dodecane_ring() -> chematic_core::Molecule {
667 let mut b = MoleculeBuilder::new();
668 let atoms: Vec<_> = (0..12).map(|_| b.add_atom(Atom::new(Element::C))).collect();
669 for i in 0..12 {
670 b.add_bond(atoms[i], atoms[(i + 1) % 12], BondOrder::Single)
671 .unwrap();
672 }
673 b.build()
674 }
675
676 fn disconnected_rings() -> chematic_core::Molecule {
678 let mut b = MoleculeBuilder::new();
679 let benzene_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
681 for i in 0..6 {
682 b.add_bond(
683 benzene_atoms[i],
684 benzene_atoms[(i + 1) % 6],
685 BondOrder::Single,
686 )
687 .unwrap();
688 }
689 let hexane_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
691 for i in 0..6 {
692 b.add_bond(
693 hexane_atoms[i],
694 hexane_atoms[(i + 1) % 6],
695 BondOrder::Single,
696 )
697 .unwrap();
698 }
699 b.build()
700 }
701
702 fn adamantane() -> chematic_core::Molecule {
705 let mut b = MoleculeBuilder::new();
706 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
707 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
710 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
711 b.add_bond(atoms[2], atoms[5], BondOrder::Single).unwrap();
712 b.add_bond(atoms[0], atoms[3], BondOrder::Single).unwrap();
714 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
715 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
716 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
718 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
719 b.add_bond(atoms[7], atoms[5], BondOrder::Single).unwrap();
720 b.add_bond(atoms[1], atoms[3], BondOrder::Single).unwrap();
723 b.add_bond(atoms[2], atoms[4], BondOrder::Single).unwrap();
724 b.build()
725 }
726
727 #[test]
728 fn test_anthracene_sssr() {
729 let mol = anthracene();
730 let rings = find_sssr(&mol);
731 assert_eq!(rings.ring_count(), 3, "anthracene SSSR has 3 rings");
737 for ring in rings.rings() {
738 assert_eq!(ring.len(), 6, "each anthracene SSSR ring has 6 atoms");
739 }
740 let all_ring_atoms: std::collections::HashSet<_> = rings
741 .rings()
742 .iter()
743 .flat_map(|r| r.iter().copied())
744 .collect();
745 assert_eq!(
746 all_ring_atoms.len(),
747 14,
748 "anthracene SSSR atoms cover every atom"
749 );
750 }
751
752 #[test]
753 fn test_spiro_nonane_sssr() {
754 let mol = spiro_nonane();
755 let rings = find_sssr(&mol);
756 assert_eq!(rings.ring_count(), 2, "spiro[4.4]nonane SSSR has 2 rings");
760 for ring in rings.rings() {
761 assert_eq!(ring.len(), 5, "each spiro nonane SSSR ring is 5-membered");
762 }
763 }
764
765 #[test]
766 fn test_dodecane_ring_sssr() {
767 let mol = dodecane_ring();
768 let rings = find_sssr(&mol);
769 assert_eq!(rings.ring_count(), 1, "12-membered ring has 1 SSSR entry");
770 assert_eq!(
771 rings.rings()[0].len(),
772 12,
773 "12-membered ring SSSR has 12 atoms"
774 );
775 }
776
777 #[test]
778 fn test_disconnected_rings_sssr() {
779 let mol = disconnected_rings();
780 let rings = find_sssr(&mol);
781 assert_eq!(
783 rings.ring_count(),
784 2,
785 "two disconnected rings yield 2 SSSR entries"
786 );
787 let sizes: Vec<_> = rings.rings().iter().map(|r| r.len()).collect();
788 assert!(sizes.contains(&6), "one ring should be 6-membered");
789 }
790
791 #[test]
792 fn test_adamantane_sssr() {
793 let mol = adamantane();
794 let rings = find_sssr(&mol);
795 assert!(
799 rings.ring_count() >= 3,
800 "adamantane SSSR has at least 3 rings"
801 );
802 for ring in rings.rings() {
804 assert!(!ring.is_empty(), "each ring should have atoms");
805 assert!(ring.len() <= 10, "ring should not exceed molecule size");
806 }
807 }
808
809 #[test]
810 fn test_macrocycle_atom_in_ring_count() {
811 let mol = dodecane_ring();
812 let rings = find_sssr(&mol);
813 for i in 0..12u32 {
814 assert_eq!(
815 rings.atoms_in_ring_count(AtomIdx(i)),
816 1,
817 "each dodecane atom is in exactly 1 ring"
818 );
819 }
820 }
821
822 #[test]
823 fn test_cubane_sssr() {
824 let mol = chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES");
849 let sssr = find_sssr(&mol);
850
851 assert_eq!(
852 sssr.rings().len(),
853 5,
854 "cubane must have exactly 5 SSSR rings (cycle rank 12−8+1=5)"
855 );
856 for ring in sssr.rings() {
857 assert!(
858 ring.len() <= 6,
859 "cubane SSSR rings must be ≤ 6-membered, got {}",
860 ring.len()
861 );
862 }
863 let four_membered = sssr.rings().iter().filter(|r| r.len() == 4).count();
864 assert_eq!(
865 four_membered, 5,
866 "Horton SSSR should find all 5 basis rings as 4-membered faces, got {four_membered}"
867 );
868 }
869
870 fn permute_molecule(mol: &chematic_core::Molecule, perm: &[usize]) -> chematic_core::Molecule {
873 let mut old_to_new = vec![0u32; perm.len()];
874 for (new_idx, &old_idx) in perm.iter().enumerate() {
875 old_to_new[old_idx] = new_idx as u32;
876 }
877 let mut builder = MoleculeBuilder::new();
878 for &old_idx in perm {
879 builder.add_atom(mol.atom(AtomIdx(old_idx as u32)).clone());
880 }
881 for (_, bond) in mol.bonds() {
882 let a = AtomIdx(old_to_new[bond.atom1.0 as usize]);
883 let b = AtomIdx(old_to_new[bond.atom2.0 as usize]);
884 let _ = builder.add_bond(a, b, bond.order);
885 }
886 builder.build()
887 }
888
889 #[test]
899 fn find_sssr_ring_size_multiset_is_permutation_invariant() {
900 let cases: Vec<(&str, chematic_core::Molecule)> = vec![
901 ("naphthalene", naphthalene()),
902 ("norbornane", norbornane()),
903 ("spiro_nonane", spiro_nonane()),
904 ("adamantane", adamantane()),
905 (
906 "cubane",
907 chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES"),
908 ),
909 ];
910
911 for (name, mol) in cases {
912 let n = mol.atom_count();
913 let mut orig_sizes: Vec<usize> = find_sssr(&mol).rings().iter().map(Vec::len).collect();
914 orig_sizes.sort_unstable();
915
916 let perms: Vec<Vec<usize>> = vec![(0..n).rev().collect(), {
917 let mut p: Vec<usize> = (0..n).collect();
918 if n > 2 {
919 p.rotate_left(n / 3 + 1);
920 }
921 p
922 }];
923 for perm in perms {
924 let permuted = permute_molecule(&mol, &perm);
925 let mut perm_sizes: Vec<usize> =
926 find_sssr(&permuted).rings().iter().map(Vec::len).collect();
927 perm_sizes.sort_unstable();
928 assert_eq!(
929 orig_sizes, perm_sizes,
930 "{name}: SSSR ring-size multiset changed under atom permutation {perm:?}"
931 );
932 }
933 }
934 }
935
936 #[test]
939 fn sssr_ignores_zero_order_bonds() {
940 let mut b = MoleculeBuilder::new();
943 let mut a_atom = Atom::new(chematic_core::Element::C);
944 a_atom.hydrogen_count = Some(3);
945 let mut b_atom = Atom::new(chematic_core::Element::C);
946 b_atom.hydrogen_count = Some(3);
947 let a = b.add_atom(a_atom);
948 let bb = b.add_atom(b_atom);
949 b.add_bond(a, bb, BondOrder::Single).unwrap();
950 b.add_bond(a, bb, BondOrder::Zero)
951 .expect_err("duplicate bond — MoleculeBuilder should reject or ignore it");
952 let mol = b.build();
955 let sssr = find_sssr(&mol);
956 assert_eq!(
957 sssr.rings().len(),
958 0,
959 "single bond between two atoms → no ring"
960 );
961 }
962
963 #[test]
964 fn sssr_ignores_zero_order_bond_as_third_bond() {
965 let mut b = MoleculeBuilder::new();
970 let atoms: Vec<_> = (0..4)
971 .map(|_| {
972 let mut a = Atom::new(chematic_core::Element::C);
973 a.hydrogen_count = Some(2);
974 b.add_atom(a)
975 })
976 .collect();
977 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
979 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
980 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
981 b.add_bond(atoms[3], atoms[0], BondOrder::Single).unwrap();
982 let _ = b.add_bond(atoms[0], atoms[2], BondOrder::Zero);
985 let mol = b.build();
986 let sssr = find_sssr(&mol);
987 assert_eq!(
989 sssr.rings().len(),
990 1,
991 "zero-order diagonal bond must not create extra rings: found {:?}",
992 sssr.rings().iter().map(|r| r.len()).collect::<Vec<_>>()
993 );
994 }
995}