1use rustc_hash::FxHashMap;
15use std::collections::VecDeque;
16
17use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
18
19fn is_ring_eligible(order: BondOrder) -> bool {
25 matches!(
26 order,
27 BondOrder::Single
28 | BondOrder::Double
29 | BondOrder::Triple
30 | BondOrder::Quadruple
31 | BondOrder::Aromatic
32 | BondOrder::Up
33 | BondOrder::Down
34 )
35}
36
37#[derive(Debug, Clone)]
46pub struct RingSet(Vec<Vec<AtomIdx>>);
47
48impl RingSet {
49 pub fn rings(&self) -> &[Vec<AtomIdx>] {
51 &self.0
52 }
53
54 pub fn ring_count(&self) -> usize {
56 self.0.len()
57 }
58
59 pub fn contains_atom(&self, atom: AtomIdx) -> bool {
61 self.0.iter().any(|ring| ring.contains(&atom))
62 }
63
64 pub fn atoms_in_ring_count(&self, atom: AtomIdx) -> usize {
66 self.0.iter().filter(|ring| ring.contains(&atom)).count()
67 }
68}
69
70pub fn find_sssr(mol: &Molecule) -> RingSet {
79 let v = mol.atom_count();
80 let e = mol
83 .bonds()
84 .filter(|(_, b)| is_ring_eligible(b.order))
85 .count();
86
87 if v == 0 || e == 0 {
88 return RingSet(Vec::new());
89 }
90
91 let (components, parent) = bfs_spanning_forest(mol);
93 let r = (e as isize) - (v as isize) + (components as isize);
94
95 if r <= 0 {
96 return RingSet(Vec::new());
97 }
98 let r = r as usize;
99
100 let mut candidate_cycles: Vec<(Vec<BondIdx>, Vec<AtomIdx>)> = Vec::new();
104
105 for (bidx, bond) in mol.bonds() {
106 if !is_ring_eligible(bond.order) {
107 continue;
108 }
109 let u = bond.atom1;
110 let v_atom = bond.atom2;
111
112 let u_parent = parent[u.0 as usize];
115 let v_parent = parent[v_atom.0 as usize];
116
117 let is_tree_edge = (u_parent == Some(v_atom)) || (v_parent == Some(u));
118
119 if !is_tree_edge {
120 if let Some((bond_set, atom_seq)) = fundamental_cycle(mol, u, v_atom, bidx, &parent) {
122 candidate_cycles.push((bond_set, atom_seq));
123 }
124 }
125 }
126
127 candidate_cycles.sort_by_key(|(bonds, _)| bonds.len());
129
130 let mut basis: FxHashMap<BondIdx, Vec<BondIdx>> = FxHashMap::default();
133 let mut selected_atoms: Vec<Vec<AtomIdx>> = Vec::new();
134
135 for (bond_set, atom_seq) in candidate_cycles {
136 let reduced = gf2_reduce(&bond_set, &basis);
138
139 if !reduced.is_empty() {
140 let pivot = *reduced.iter().min().unwrap();
142 basis.insert(pivot, reduced);
143 selected_atoms.push(atom_seq);
144
145 if selected_atoms.len() == r {
146 break;
147 }
148 }
149 }
150
151 selected_atoms.sort_by_key(|ring| ring.len());
153 RingSet(selected_atoms)
154}
155
156fn bfs_spanning_forest(mol: &Molecule) -> (usize, Vec<Option<AtomIdx>>) {
167 let n = mol.atom_count();
168 let mut visited = vec![false; n];
169 let mut parent: Vec<Option<AtomIdx>> = vec![None; n];
170 let mut components = 0;
171 let mut queue: VecDeque<AtomIdx> = VecDeque::new();
172
173 for start in 0..n {
174 if visited[start] {
175 continue;
176 }
177 components += 1;
178 let start_idx = AtomIdx(start as u32);
179 visited[start] = true;
180 queue.push_back(start_idx);
181
182 while let Some(current) = queue.pop_front() {
183 for (neighbor, bidx) in mol.neighbors(current) {
184 if !is_ring_eligible(mol.bond(bidx).order) {
187 continue;
188 }
189 let ni = neighbor.0 as usize;
190 if !visited[ni] {
191 visited[ni] = true;
192 parent[ni] = Some(current);
193 queue.push_back(neighbor);
194 }
195 }
196 }
197 }
198
199 (components, parent)
200}
201
202fn fundamental_cycle(
212 mol: &Molecule,
213 u: AtomIdx,
214 v: AtomIdx,
215 bidx: BondIdx,
216 parent: &[Option<AtomIdx>],
217) -> Option<(Vec<BondIdx>, Vec<AtomIdx>)> {
218 let (path_u, path_v) = paths_to_lca(u, v, parent);
222
223 if path_u.is_empty() || path_v.is_empty() {
224 return None;
225 }
226
227 let mut ring_atoms: Vec<AtomIdx> = path_u.clone();
230 for &a in path_v.iter().rev().skip(1) {
232 ring_atoms.push(a);
233 }
234
235 let mut bond_set: Vec<BondIdx> = Vec::new();
237
238 for i in 0..path_u.len().saturating_sub(1) {
240 if let Some((b, _)) = mol.bond_between(path_u[i], path_u[i + 1]) {
241 bond_set.push(b);
242 } else {
243 return None;
244 }
245 }
246 for i in 0..path_v.len().saturating_sub(1) {
248 if let Some((b, _)) = mol.bond_between(path_v[i], path_v[i + 1]) {
249 bond_set.push(b);
250 } else {
251 return None;
252 }
253 }
254 bond_set.push(bidx);
256
257 bond_set.sort();
260 bond_set.dedup();
261
262 Some((bond_set, ring_atoms))
263}
264
265fn paths_to_lca(
270 u: AtomIdx,
271 v: AtomIdx,
272 parent: &[Option<AtomIdx>],
273) -> (Vec<AtomIdx>, Vec<AtomIdx>) {
274 let ancestors_u = ancestors(u, parent);
276 let ancestors_v = ancestors(v, parent);
277
278 let set_u: FxHashMap<AtomIdx, usize> = ancestors_u
279 .iter()
280 .enumerate()
281 .map(|(i, &a)| (a, i))
282 .collect();
283
284 let Some((idx_in_v, lca)) = ancestors_v
286 .iter()
287 .enumerate()
288 .find_map(|(i, a)| set_u.contains_key(a).then_some((i, *a)))
289 else {
290 return (Vec::new(), Vec::new());
293 };
294
295 let idx_in_u = set_u[&lca];
296 let path_u = ancestors_u[..=idx_in_u].to_vec();
297 let path_v = ancestors_v[..=idx_in_v].to_vec();
298 (path_u, path_v)
299}
300
301fn ancestors(start: AtomIdx, parent: &[Option<AtomIdx>]) -> Vec<AtomIdx> {
304 let mut chain = Vec::new();
305 let mut current = start;
306 loop {
307 chain.push(current);
308 match parent[current.0 as usize] {
309 Some(p) => current = p,
310 None => break,
311 }
312 }
313 chain
314}
315
316fn gf2_reduce(cycle: &[BondIdx], basis: &FxHashMap<BondIdx, Vec<BondIdx>>) -> Vec<BondIdx> {
327 let mut current: Vec<BondIdx> = cycle.to_vec();
328 while let Some(&pivot) = current.iter().min() {
329 match basis.get(&pivot) {
330 None => return current, Some(basis_row) => current = sym_diff(¤t, basis_row),
333 }
334 }
335 current
336}
337
338fn sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
340 let mut result = Vec::new();
341 let mut i = 0;
342 let mut j = 0;
343 while i < a.len() && j < b.len() {
344 match a[i].cmp(&b[j]) {
345 std::cmp::Ordering::Less => {
346 result.push(a[i]);
347 i += 1;
348 }
349 std::cmp::Ordering::Greater => {
350 result.push(b[j]);
351 j += 1;
352 }
353 std::cmp::Ordering::Equal => {
354 i += 1;
356 j += 1;
357 }
358 }
359 }
360 result.extend_from_slice(&a[i..]);
361 result.extend_from_slice(&b[j..]);
362 result
363}
364
365#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::aromaticity::augmented_ring_set;
373 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
374
375 fn cyclohexane() -> chematic_core::Molecule {
377 let mut b = MoleculeBuilder::new();
378 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
379 for i in 0..6 {
380 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
381 .unwrap();
382 }
383 b.build()
384 }
385
386 fn benzene() -> chematic_core::Molecule {
388 let mut b = MoleculeBuilder::new();
389 let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
390 for i in 0..6 {
391 b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
392 .unwrap();
393 }
394 b.build()
395 }
396
397 fn naphthalene() -> chematic_core::Molecule {
402 let mut b = MoleculeBuilder::new();
403 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
404 let ring1 = [0usize, 1, 2, 3, 4, 9];
406 for i in 0..6 {
407 b.add_bond(
408 atoms[ring1[i]],
409 atoms[ring1[(i + 1) % 6]],
410 BondOrder::Single,
411 )
412 .unwrap();
413 }
414 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
417 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
418 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
419 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
420 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
421 b.build()
422 }
423
424 fn norbornane() -> chematic_core::Molecule {
431 let mut b = MoleculeBuilder::new();
432 let atoms: Vec<_> = (0..7).map(|_| b.add_atom(Atom::new(Element::C))).collect();
433 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
435 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
436 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
437 b.add_bond(atoms[0], atoms[4], BondOrder::Single).unwrap();
439 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
440 b.add_bond(atoms[5], atoms[3], BondOrder::Single).unwrap();
441 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
443 b.add_bond(atoms[6], atoms[3], BondOrder::Single).unwrap();
444 b.build()
445 }
446
447 #[test]
448 fn test_cyclohexane_sssr() {
449 let mol = cyclohexane();
450 let rings = find_sssr(&mol);
451 assert_eq!(rings.ring_count(), 1, "cyclohexane has exactly 1 ring");
452 assert_eq!(rings.rings()[0].len(), 6, "cyclohexane ring has 6 atoms");
453 }
454
455 #[test]
456 fn test_benzene_sssr() {
457 let mol = benzene();
458 let rings = find_sssr(&mol);
459 assert_eq!(rings.ring_count(), 1, "benzene has exactly 1 ring");
460 assert_eq!(rings.rings()[0].len(), 6, "benzene ring has 6 atoms");
461 }
462
463 #[test]
464 fn test_naphthalene_sssr() {
465 let mol = naphthalene();
466 let rings = find_sssr(&mol);
467 assert_eq!(rings.ring_count(), 2, "naphthalene SSSR has 2 rings");
470 for ring in rings.rings() {
471 assert_eq!(ring.len(), 6, "each naphthalene SSSR ring has 6 atoms");
472 }
473 }
474
475 #[test]
476 fn test_norbornane_sssr() {
477 let mol = norbornane();
478 let rings = find_sssr(&mol);
479 assert_eq!(rings.ring_count(), 2, "norbornane SSSR has 2 rings");
481 for ring in rings.rings() {
483 assert_eq!(ring.len(), 5, "each norbornane SSSR ring has 5 atoms");
484 }
485 }
486
487 #[test]
488 fn test_acyclic_molecule() {
489 let mut b = MoleculeBuilder::new();
491 let c1 = b.add_atom(Atom::new(Element::C));
492 let c2 = b.add_atom(Atom::new(Element::C));
493 b.add_bond(c1, c2, BondOrder::Single).unwrap();
494 let mol = b.build();
495 let rings = find_sssr(&mol);
496 assert_eq!(rings.ring_count(), 0);
497 }
498
499 #[test]
500 fn test_contains_atom() {
501 let mol = cyclohexane();
502 let rings = find_sssr(&mol);
503 for i in 0..6u32 {
504 assert!(
505 rings.contains_atom(AtomIdx(i)),
506 "atom {} should be in a ring",
507 i
508 );
509 }
510 }
511
512 #[test]
513 fn test_atoms_in_ring_count_benzene() {
514 let mol = benzene();
515 let rings = find_sssr(&mol);
516 for i in 0..6u32 {
517 assert_eq!(
518 rings.atoms_in_ring_count(AtomIdx(i)),
519 1,
520 "each benzene atom is in exactly 1 ring"
521 );
522 }
523 }
524
525 fn anthracene() -> chematic_core::Molecule {
528 let mut b = MoleculeBuilder::new();
529 let atoms: Vec<_> = (0..14).map(|_| b.add_atom(Atom::new(Element::C))).collect();
530 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
532 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
533 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
534 b.add_bond(atoms[3], atoms[8], BondOrder::Single).unwrap();
535 b.add_bond(atoms[8], atoms[9], BondOrder::Single).unwrap();
536 b.add_bond(atoms[9], atoms[0], BondOrder::Single).unwrap();
537 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
539 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
540 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
541 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
542 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
543 b.add_bond(atoms[7], atoms[10], BondOrder::Single).unwrap();
545 b.add_bond(atoms[10], atoms[11], BondOrder::Single).unwrap();
546 b.add_bond(atoms[11], atoms[12], BondOrder::Single).unwrap();
547 b.add_bond(atoms[12], atoms[13], BondOrder::Single).unwrap();
548 b.add_bond(atoms[13], atoms[6], BondOrder::Single).unwrap();
549 b.build()
550 }
551
552 fn spiro_nonane() -> chematic_core::Molecule {
555 let mut b = MoleculeBuilder::new();
556 let atoms: Vec<_> = (0..9).map(|_| b.add_atom(Atom::new(Element::C))).collect();
557 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
560 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
561 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
562 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
563 b.add_bond(atoms[4], atoms[0], BondOrder::Single).unwrap();
564 b.add_bond(atoms[0], atoms[5], BondOrder::Single).unwrap();
566 b.add_bond(atoms[5], atoms[6], BondOrder::Single).unwrap();
567 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
568 b.add_bond(atoms[7], atoms[8], BondOrder::Single).unwrap();
569 b.add_bond(atoms[8], atoms[0], BondOrder::Single).unwrap();
570 b.build()
571 }
572
573 fn dodecane_ring() -> chematic_core::Molecule {
575 let mut b = MoleculeBuilder::new();
576 let atoms: Vec<_> = (0..12).map(|_| b.add_atom(Atom::new(Element::C))).collect();
577 for i in 0..12 {
578 b.add_bond(atoms[i], atoms[(i + 1) % 12], BondOrder::Single)
579 .unwrap();
580 }
581 b.build()
582 }
583
584 fn disconnected_rings() -> chematic_core::Molecule {
586 let mut b = MoleculeBuilder::new();
587 let benzene_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
589 for i in 0..6 {
590 b.add_bond(
591 benzene_atoms[i],
592 benzene_atoms[(i + 1) % 6],
593 BondOrder::Single,
594 )
595 .unwrap();
596 }
597 let hexane_atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
599 for i in 0..6 {
600 b.add_bond(
601 hexane_atoms[i],
602 hexane_atoms[(i + 1) % 6],
603 BondOrder::Single,
604 )
605 .unwrap();
606 }
607 b.build()
608 }
609
610 fn adamantane() -> chematic_core::Molecule {
613 let mut b = MoleculeBuilder::new();
614 let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
615 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
618 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
619 b.add_bond(atoms[2], atoms[5], BondOrder::Single).unwrap();
620 b.add_bond(atoms[0], atoms[3], BondOrder::Single).unwrap();
622 b.add_bond(atoms[3], atoms[4], BondOrder::Single).unwrap();
623 b.add_bond(atoms[4], atoms[5], BondOrder::Single).unwrap();
624 b.add_bond(atoms[0], atoms[6], BondOrder::Single).unwrap();
626 b.add_bond(atoms[6], atoms[7], BondOrder::Single).unwrap();
627 b.add_bond(atoms[7], atoms[5], BondOrder::Single).unwrap();
628 b.add_bond(atoms[1], atoms[3], BondOrder::Single).unwrap();
631 b.add_bond(atoms[2], atoms[4], BondOrder::Single).unwrap();
632 b.build()
633 }
634
635 #[test]
636 fn test_anthracene_sssr() {
637 let mol = anthracene();
638 let rings = find_sssr(&mol);
639 assert_eq!(rings.ring_count(), 3, "anthracene SSSR has 3 rings");
641 let all_ring_atoms: std::collections::HashSet<_> = rings
644 .rings()
645 .iter()
646 .flat_map(|r| r.iter().copied())
647 .collect();
648 assert!(
649 all_ring_atoms.len() >= 10,
650 "anthracene SSSR atoms cover most of the structure"
651 );
652 }
653
654 #[test]
655 fn test_spiro_nonane_sssr() {
656 let mol = spiro_nonane();
657 let rings = find_sssr(&mol);
658 assert_eq!(rings.ring_count(), 2, "spiro[4.4]nonane SSSR has 2 rings");
662 for ring in rings.rings() {
663 assert_eq!(ring.len(), 5, "each spiro nonane SSSR ring is 5-membered");
664 }
665 }
666
667 #[test]
668 fn test_dodecane_ring_sssr() {
669 let mol = dodecane_ring();
670 let rings = find_sssr(&mol);
671 assert_eq!(rings.ring_count(), 1, "12-membered ring has 1 SSSR entry");
672 assert_eq!(
673 rings.rings()[0].len(),
674 12,
675 "12-membered ring SSSR has 12 atoms"
676 );
677 }
678
679 #[test]
680 fn test_disconnected_rings_sssr() {
681 let mol = disconnected_rings();
682 let rings = find_sssr(&mol);
683 assert_eq!(
685 rings.ring_count(),
686 2,
687 "two disconnected rings yield 2 SSSR entries"
688 );
689 let sizes: Vec<_> = rings.rings().iter().map(|r| r.len()).collect();
690 assert!(sizes.contains(&6), "one ring should be 6-membered");
691 }
692
693 #[test]
694 fn test_adamantane_sssr() {
695 let mol = adamantane();
696 let rings = find_sssr(&mol);
697 assert!(
701 rings.ring_count() >= 3,
702 "adamantane SSSR has at least 3 rings"
703 );
704 for ring in rings.rings() {
706 assert!(!ring.is_empty(), "each ring should have atoms");
707 assert!(ring.len() <= 10, "ring should not exceed molecule size");
708 }
709 }
710
711 #[test]
712 fn test_macrocycle_atom_in_ring_count() {
713 let mol = dodecane_ring();
714 let rings = find_sssr(&mol);
715 for i in 0..12u32 {
716 assert_eq!(
717 rings.atoms_in_ring_count(AtomIdx(i)),
718 1,
719 "each dodecane atom is in exactly 1 ring"
720 );
721 }
722 }
723
724 #[test]
725 fn test_cubane_sssr() {
726 let mol = chematic_smiles::parse("C12C3C4C1C5C4C3C25").expect("cubane SMILES");
737 let sssr = find_sssr(&mol);
738
739 assert_eq!(
741 sssr.rings().len(),
742 5,
743 "cubane must have exactly 5 SSSR rings (cycle rank 12−8+1=5)"
744 );
745 for ring in sssr.rings() {
747 assert!(
748 ring.len() <= 6,
749 "cubane SSSR rings must be ≤ 6-membered, got {}",
750 ring.len()
751 );
752 }
753 let four_membered = sssr.rings().iter().filter(|r| r.len() == 4).count();
755 assert!(
756 four_membered >= 4,
757 "at least 4 of the 5 cubane SSSR rings must be 4-membered, got {four_membered}"
758 );
759
760 let aug = augmented_ring_set(&mol, sssr.rings());
766 let four_membered_aug = aug.iter().filter(|r| r.len() == 4).count();
767 assert_eq!(
768 four_membered_aug, 5,
769 "augmented_ring_set (pairwise XOR) recovers 5 of 6 cubane square faces; \
770 6th requires 3-way XOR — got {four_membered_aug}"
771 );
772 }
773
774 #[test]
777 fn sssr_ignores_zero_order_bonds() {
778 let mut b = MoleculeBuilder::new();
781 let mut a_atom = Atom::new(chematic_core::Element::C);
782 a_atom.hydrogen_count = Some(3);
783 let mut b_atom = Atom::new(chematic_core::Element::C);
784 b_atom.hydrogen_count = Some(3);
785 let a = b.add_atom(a_atom);
786 let bb = b.add_atom(b_atom);
787 b.add_bond(a, bb, BondOrder::Single).unwrap();
788 b.add_bond(a, bb, BondOrder::Zero)
789 .expect_err("duplicate bond — MoleculeBuilder should reject or ignore it");
790 let mol = b.build();
793 let sssr = find_sssr(&mol);
794 assert_eq!(
795 sssr.rings().len(),
796 0,
797 "single bond between two atoms → no ring"
798 );
799 }
800
801 #[test]
802 fn sssr_ignores_zero_order_bond_as_third_bond() {
803 let mut b = MoleculeBuilder::new();
808 let atoms: Vec<_> = (0..4)
809 .map(|_| {
810 let mut a = Atom::new(chematic_core::Element::C);
811 a.hydrogen_count = Some(2);
812 b.add_atom(a)
813 })
814 .collect();
815 b.add_bond(atoms[0], atoms[1], BondOrder::Single).unwrap();
817 b.add_bond(atoms[1], atoms[2], BondOrder::Single).unwrap();
818 b.add_bond(atoms[2], atoms[3], BondOrder::Single).unwrap();
819 b.add_bond(atoms[3], atoms[0], BondOrder::Single).unwrap();
820 let _ = b.add_bond(atoms[0], atoms[2], BondOrder::Zero);
823 let mol = b.build();
824 let sssr = find_sssr(&mol);
825 assert_eq!(
827 sssr.rings().len(),
828 1,
829 "zero-order diagonal bond must not create extra rings: found {:?}",
830 sssr.rings().iter().map(|r| r.len()).collect::<Vec<_>>()
831 );
832 }
833}