Skip to main content

chematic_perception/
sssr.rs

1//! Smallest Set of Smallest Rings (SSSR) via Horton's algorithm.
2//!
3//! Algorithm overview:
4//! 1. Compute the cycle rank r = E - V + C (Euler characteristic),
5//!    where C is the number of connected components.
6//! 2. For every vertex v (as a candidate root) and every ring-eligible edge
7//!    (x, y), form the candidate cycle SP(v, x) + edge(x, y) + SP(y, v),
8//!    where SP is the shortest path in v's BFS tree. Keep it only if it's a
9//!    genuine simple cycle (the two paths share no vertex other than v).
10//!    This produces O(V*E) candidates and is guaranteed (Horton, 1987) to
11//!    contain a minimum-weight cycle basis — unlike a single spanning tree's
12//!    fundamental-cycle set (exactly r candidates, no redundancy), which can
13//!    only ever report *a* valid basis, never guaranteed to be minimal.
14//! 3. Represent each cycle as a set of bond indices (for GF(2) XOR independence
15//!    testing) and sort candidates by (length, canonical tie-break) — the
16//!    tie-break uses a local Weisfeiler-Leman-style atom ranking (see
17//!    `canonical_atom_ranks`) so ring *selection* doesn't depend on input
18//!    atom-numbering (i.e. SMILES parse/traversal order), only on molecular
19//!    graph structure.
20//! 4. Use Gaussian elimination over GF(2) to greedily build an independent
21//!    basis of r cycles from that sorted candidate list.
22//! 5. Convert the chosen bond-sets back to ordered atom sequences for the public API.
23
24use rustc_hash::{FxHashMap, FxHashSet};
25use std::collections::VecDeque;
26
27use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
28
29/// Returns `true` if the bond order is eligible for ring perception.
30///
31/// Zero-order and Dative bonds are coordinate/non-valence connections that must
32/// not form ring closures in the SSSR (RDKit PR #9118). Query bond types are
33/// also excluded since they only appear in SMARTS patterns, never in real molecules.
34fn 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// ---------------------------------------------------------------------------
48// Public types
49// ---------------------------------------------------------------------------
50
51/// The Smallest Set of Smallest Rings for a molecule.
52///
53/// Each ring is stored as a sequence of `AtomIdx` values listed in ring order.
54/// The first atom is not repeated at the end.
55#[derive(Debug, Clone)]
56pub struct RingSet(Vec<Vec<AtomIdx>>);
57
58impl RingSet {
59    /// All rings as slices of atom indices.
60    pub fn rings(&self) -> &[Vec<AtomIdx>] {
61        &self.0
62    }
63
64    /// Number of rings in the SSSR.
65    pub fn ring_count(&self) -> usize {
66        self.0.len()
67    }
68
69    /// Whether atom `atom` is a member of at least one ring.
70    pub fn contains_atom(&self, atom: AtomIdx) -> bool {
71        self.0.iter().any(|ring| ring.contains(&atom))
72    }
73
74    /// Number of rings that atom `atom` belongs to.
75    pub fn atoms_in_ring_count(&self, atom: AtomIdx) -> usize {
76        self.0.iter().filter(|ring| ring.contains(&atom)).count()
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Main entry point
82// ---------------------------------------------------------------------------
83
84/// Compute the Smallest Set of Smallest Rings for `mol`.
85///
86/// Returns a [`RingSet`] whose ring count equals the cycle rank r = E - V + C.
87/// For acyclic molecules (r = 0) the returned set is empty.
88pub fn find_sssr(mol: &Molecule) -> RingSet {
89    let v = mol.atom_count();
90    // Count only ring-eligible bonds for the cycle rank (E - V + C).
91    // Zero-order and Dative bonds are excluded (RDKit PR #9118).
92    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    // Component count only (the parent tree itself isn't used any more —
102    // candidate generation below builds its own BFS tree per root).
103    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    // Horton candidate generation: BFS from every vertex, then for every
118    // ring-eligible edge form the candidate SP(root,x)+edge(x,y)+SP(y,root).
119    // O(V*E) candidates total — enough redundancy to guarantee a
120    // minimum-weight basis is representable in the pool (see module doc).
121    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; // degenerate: edge touches the root itself
128            }
129            if dist[x.0 as usize] == usize::MAX || dist[y.0 as usize] == usize::MAX {
130                continue; // x or y unreachable from this root (different component)
131            }
132            if let Some(candidate) = horton_candidate(mol, root, x, y, bidx, &parent) {
133                candidates.push(candidate);
134            }
135        }
136    }
137
138    // Deterministic ordering: shortest first, then a canonical (input-order-
139    // independent) tie-break so ring *selection* doesn't depend on how the
140    // molecule happened to be numbered by the parser.
141    let ranks = canonical_atom_ranks(mol);
142    candidates.sort_by_cached_key(|c| (c.0.len(), canonical_cycle_key(&c.1, &ranks)));
143    // The same geometric cycle can be generated from multiple roots; collapse
144    // duplicates (bond_set is already sorted, so identical cycles are equal).
145    candidates.dedup_by(|a, b| a.0 == b.0);
146
147    // Gaussian elimination over GF(2) to select r linearly independent cycles.
148    // The basis maps a pivot BondIdx to the full bond-set of that basis row.
149    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        // Reduce this cycle against the current basis.
154        let reduced = gf2_reduce(&bond_set, &basis);
155
156        if !reduced.is_empty() {
157            // This cycle is independent — add it to the basis.
158            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    // Sort output rings by length for output consistency.
169    selected_atoms.sort_by_key(|ring| ring.len());
170    RingSet(selected_atoms)
171}
172
173// ---------------------------------------------------------------------------
174// BFS spanning forest
175// ---------------------------------------------------------------------------
176
177/// Build a BFS spanning forest over the entire molecule.
178///
179/// Returns:
180/// - `components`: number of connected components.
181/// - `parent`: for each atom, the atom from which it was first discovered
182///   (None for BFS roots).
183fn 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                // Skip non-ring-eligible bonds (Zero, Dative, Query*) — they
202                // must not form ring closures in the spanning forest (RDKit PR #9118).
203                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
219// ---------------------------------------------------------------------------
220// Horton candidate generation
221// ---------------------------------------------------------------------------
222
223/// BFS shortest-path tree from `root`, restricted to ring-eligible bonds.
224///
225/// Returns `(dist, parent)`: `dist[i]` is the shortest-path distance from
226/// `root` to atom `i` (`usize::MAX` if unreachable), `parent[i]` is the
227/// preceding atom on that shortest path (`None` for `root` and unreachable
228/// atoms).
229fn 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
255/// Form the Horton candidate cycle `SP(root,x) + edge(x,y) + SP(y,root)`.
256///
257/// Returns `None` if the two root-rooted shortest paths share any vertex
258/// other than `root` itself — in that case the paths actually meet at some
259/// closer common ancestor, so this (root, edge) pair doesn't yield a *simple*
260/// cycle (the true minimal cycle through that closer ancestor is correctly
261/// picked up when *it* is tried as root instead).
262fn 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); // [x, ..., root]
271    let path_y = path_to_root(y, parent); // [y, ..., root]
272    debug_assert_eq!(*path_x.last().unwrap(), root);
273    debug_assert_eq!(*path_y.last().unwrap(), root);
274
275    // Simplicity check: the two paths must share only `root`.
276    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    // Ordered ring atoms: x ... root ... y (then the edge x-y closes the cycle).
285    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
306/// Walk parent pointers from `start` to `root`, returning the chain
307/// including `start` (first) and the root (last).
308fn 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
321// ---------------------------------------------------------------------------
322// Canonical tie-break (determinism, independent of atom-numbering)
323// ---------------------------------------------------------------------------
324
325/// A cheap, self-contained (no cross-crate dependency) approximation of
326/// canonical atom ranking, used only to make candidate-cycle tie-breaking
327/// deterministic: the same molecular graph always produces the same SSSR,
328/// regardless of how its atoms happen to be numbered by the parser (SMILES
329/// traversal order, SDF atom-block order, etc). This is a local
330/// Weisfeiler-Leman-style refinement (seed on (element, degree, charge,
331/// aromatic), then repeatedly fold in each atom's sorted neighbor keys) —
332/// it does not aim for full canonical-labeling discriminating power (ties
333/// among genuinely symmetric atoms are expected and fine; the point is
334/// input-order-independence, not maximal refinement).
335fn 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; // shift to non-negative
344            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
369/// Deterministic sort key for a candidate cycle: the sorted multiset of its
370/// atoms' canonical ranks, combined order-independently — isomorphic cycles
371/// (same molecule, different traversal) always collapse to the same key.
372fn 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
382// ---------------------------------------------------------------------------
383// GF(2) Gaussian elimination
384// ---------------------------------------------------------------------------
385
386/// Reduce `cycle` over GF(2) against the current `basis`.
387///
388/// Each basis entry maps a pivot bond (the minimum BondIdx in that row)
389/// to the full row (sorted Vec<BondIdx>).
390///
391/// Returns the reduced cycle (empty if dependent on existing basis).
392fn 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, // independent
397            // XOR: symmetric difference of the two sorted sets.
398            Some(basis_row) => current = sym_diff(&current, basis_row),
399        }
400    }
401    current
402}
403
404/// Symmetric difference of two sorted slices (GF(2) addition / XOR for sets).
405fn 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                // Both contain this element — XOR removes it.
421                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// ---------------------------------------------------------------------------
432// Tests
433// ---------------------------------------------------------------------------
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
439
440    // Build a cyclohexane molecule (6 carbons, 6 single bonds).
441    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    // Build a benzene molecule (6 aromatic carbons, 6 single bonds for topology).
452    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    // Build naphthalene: 10 atoms, 11 bonds (two fused 6-membered rings).
463    // Atom numbering:
464    //   0-1-2-3-4-5-0  (ring 1 perimeter, 6 atoms)
465    //   4-6-7-8-9-5    (ring 2, sharing bond 4-5)
466    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        // Ring 1: 0-1-2-3-4-9-0
470        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        // Ring 2: 4-5-6-7-8-9 (shares bond 4-9)
480        // Bonds to add: 4-5, 5-6, 6-7, 7-8, 8-9
481        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    // Build norbornane (bicyclo[2.2.1]heptane): 7 carbons, 8 bonds, 2 rings.
490    // Numbering:
491    //   bridgehead atoms: 0, 3
492    //   bridge 1: 0-1-2-3
493    //   bridge 2: 0-4-5-3
494    //   bridge 3: 0-6-3  (one-carbon bridge)
495    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        // Bridge 1: 0-1-2-3
499        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        // Bridge 2: 0-4-5-3
503        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        // Bridge 3: 0-6-3
507        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        // Azulene: cyclopentadiene fused to cycloheptatriene, sharing one
515        // bond. RDKit's GetSymmSSSR ring-size multiset is [5, 7]; the old
516        // single-spanning-tree find_sssr previously returned a non-minimal
517        // basis for this topology (see aromaticity.rs's PROVISIONAL-tagged
518        // azulene regression test for the downstream effect on Pass 1/2).
519        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        // Indolizine: pyrrole fused to pyridine sharing a bridgehead N.
529        // RDKit's GetSymmSSSR ring-size multiset is [5, 6] — a fused
530        // heterocycle oracle case distinct from azulene's all-carbon,
531        // odd/odd-sized ring pair.
532        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        // Cycle rank: 11 bonds - 10 atoms + 1 component = 2
560        // SSSR should have 2 rings, both 6-membered.
561        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        // Cycle rank: 8 bonds - 7 atoms + 1 component = 2
572        assert_eq!(rings.ring_count(), 2, "norbornane SSSR has 2 rings");
573        // The two smallest rings are both 5-membered.
574        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        // Ethane: no rings.
582        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    // Anthracene: 14 atoms, 16 bonds (3 fused 6-membered rings).
618    // Linear fusion: central ring shares edges with two outer rings.
619    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        // Ring 1 (left): 0-1-2-3-8-9-0
623        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        // Ring 2 (center): 3-4-5-6-7-8-3
630        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        // Ring 3 (right): 7-10-11-12-13-6-7
636        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    // Spiro[4.4]nonane: two 5-membered rings sharing a single bridgehead atom.
645    // 9 atoms total, cycle rank 2.
646    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        // Bridgehead: atom 0
650        // Ring 1: 0-1-2-3-4-0
651        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        // Ring 2: 0-5-6-7-8-0
657        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    // 12-membered macrocycle (1 ring, 12 atoms).
666    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    // Two disconnected rings (two components).
677    fn disconnected_rings() -> chematic_core::Molecule {
678        let mut b = MoleculeBuilder::new();
679        // Benzene ring: atoms 0-5
680        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        // Separate cyclohexane ring: atoms 6-11
690        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    // Adamantane-like tricyclic structure (simplified):
703    // 10 atoms, 3 bridges between 2 bridgeheads
704    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        // Bridgehead atoms: 0, 5
708        // Bridge 1: 0-1-2-5 (3 bonds in chain)
709        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        // Bridge 2: 0-3-4-5 (3 bonds in chain)
713        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        // Bridge 3: 0-6-7-5 (3 bonds in chain)
717        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        // Cross-link bonds to connect bridges (forming tertiary center)
721        // 1-3, 2-4, 6-? to complete cage
722        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        // Cycle rank: 16 bonds - 14 atoms + 1 component = 3.
732        // RDKit's GetSymmSSSR gives three 6-membered rings (linear acene) —
733        // Horton's minimum-weight basis must match this exactly, not just
734        // "3 rings covering most atoms" (the old spanning-tree algorithm
735        // could substitute a larger non-minimal ring for one of these).
736        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        // Cycle rank: 8 bonds - 9 atoms + 1 component = 0... wait, let me recalculate
757        // Actually: two 5-membered rings sharing 1 atom = 4 + 4 + 2 (bridge) = 10 bonds
758        // 10 bonds - 9 atoms + 1 = 2 rings
759        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        // Cycle rank: 12 bonds - 12 atoms + 2 components = 2 rings
782        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        // Simplified adamantane: 10 atoms, 12 bonds, 1 component
796        // Cycle rank: 12 - 10 + 1 = 3
797        // (May be 3-4 depending on cross-link structure and Gaussian elimination)
798        assert!(
799            rings.ring_count() >= 3,
800            "adamantane SSSR has at least 3 rings"
801        );
802        // Each ring should be reasonable size
803        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        // Cubane C8H8 — a cage molecule with 12 C-C bonds and 8 vertices.
825        // Cycle rank = E - V + 1 = 12 - 8 + 1 = 5.
826        // Cubane has 6 square (4-membered) faces but only 5 are linearly
827        // independent in GF(2) — the 6th is the XOR of the other 5 (not just
828        // any pair of them, since Sum-of-all-6-faces = 0 in GF(2): each edge
829        // is shared by exactly 2 faces).
830        //
831        // Horton's candidate generation (O(V*E) candidates, guaranteed to
832        // contain a minimum-weight basis) finds a truly minimal SSSR here:
833        // all 5 basis rings are 4-membered (weight 20), strictly better than
834        // the old single-spanning-tree algorithm's typical "4 four-membered +
835        // 1 six-membered diagonal" (weight 22) — see module doc / project
836        // history for why the old algorithm couldn't guarantee this.
837        //
838        // Recovering the 6th (symmetry-equivalent) face requires XOR-ing all
839        // 5 basis rings together, not a pairwise XOR — augmented_ring_set
840        // only does pairwise XOR, so it cannot find it from an already-fully-
841        // 4-membered basis (two same-size adjacent cube faces XOR to a
842        // 6-membered "belt", not another 4-ring). This is expected: full
843        // symmetrization (all 6 symmetry-equivalent minimal rings, matching
844        // RDKit's GetSymmSSSR on cubane) is out of scope for Horton alone and
845        // deferred to a later Vismara "relevant cycles" pass (see project
846        // plan) — not a regression, since Horton's SSSR is still a strict
847        // minimality improvement over the previous algorithm.
848        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    /// Rebuild `mol` with atoms relabeled by `perm` (perm[new_idx] = old_idx),
871    /// preserving the same graph but a different atom insertion order.
872    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    /// find_sssr's ring-size multiset must not depend on atom insertion order.
890    /// Probes the fused/bridged/cage systems this project has already
891    /// identified as the hard cases for canonical_atom_ranks' 3-round
892    /// (not-fixpoint) Weisfeiler-Leman tie-break -- unlike
893    /// `canonical_atom_order` (chematic-smiles), this function's doc comment
894    /// does not claim full canonical-labeling power, and find_sssr's
895    /// self-stability was already measured at 0% on a 5000-molecule corpus
896    /// during the Horton rewrite (698ba3f); this is a permanent regression
897    /// guard for that claim, not a first-time probe.
898    #[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    // ── RDKit PR #9118: SSSR excludes Zero-order and Dative bonds ────────────
937
938    #[test]
939    fn sssr_ignores_zero_order_bonds() {
940        // A--B via a single bond PLUS a Zero-order bond between the same atoms
941        // must NOT create a ring. Zero-order bonds are non-valence connections.
942        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        // Build a proper molecule: just two atoms with a single bond.
953        // The zero-order bond attempt is rejected, so the molecule is acyclic.
954        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        // Benzene ring (6 aromatic bonds) PLUS one Zero-order bond closing an
966        // extra connection should NOT add a phantom 2-membered ring.
967        // We build cyclohexane (all single bonds) and verify no extra ring from
968        // a Zero-order bond added between two non-adjacent atoms.
969        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        // Square ring: 0-1-2-3-0
978        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        // Zero-order bond between non-adjacent atoms: should NOT create a new ring.
983        // Note: builder may reject parallel bonds; if so the test still passes.
984        let _ = b.add_bond(atoms[0], atoms[2], BondOrder::Zero);
985        let mol = b.build();
986        let sssr = find_sssr(&mol);
987        // Should find exactly 1 ring (the 4-membered ring), NOT 2 or 3.
988        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}