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/// Find the smallest simple rings containing `root` using the Figueras-style
256/// root-neighbor BFS primitive.
257///
258/// A ring through `root` is formed by two distinct ring-eligible neighbors of
259/// the root plus a shortest path between those neighbors with the root
260/// removed. Every neighbor pair is searched, and all pairs producing the
261/// minimum ring size for this root are returned. The result is a local
262/// primitive for the future symmetrized-ring model; it is deliberately not
263/// substituted for [`find_sssr`], whose output is a linearly independent
264/// Horton basis.
265///
266/// The search enumerates every shortest path for each neighbor pair. A later
267/// symmetrization layer is responsible for applying RDKit's duplicate-ring
268/// acceptance rules.
269pub 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
273/// Find the smallest root-centered rings while temporarily ignoring a set of
274/// bonds. This is the bounded re-search primitive used by the RDKit-compatible
275/// duplicate-D2 candidate pass; the molecule itself is never mutated.
276pub 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
355/// Find smallest root-centered rings after applying RDKit-style leaf trimming
356/// to the temporary bond mask. Removing a bond can expose degree-0/1 atoms;
357/// those atoms cannot participate in a cycle, so their remaining active bonds
358/// are removed transitively before the BFS is run. The molecule is unchanged.
359pub 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
368/// Compute the active-bond mask after repeatedly removing bonds incident to
369/// degree-0/1 atoms. This is the non-mutating equivalent of RDKit's
370/// `trimBonds` queue and is useful when several rooted searches share a
371/// progressively reduced graph.
372pub 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
411/// Find smallest rings from the BFS tree used by RDKit's Figueras pass.
412/// Unlike [`find_smallest_rings_bfs_with_blocked_bonds`], this intentionally
413/// keeps one parent tree and derives cycles from non-tree edges. It is exposed
414/// separately so the bounded pair-shortest-path primitive remains available
415/// for callers that need all shortest paths.
416pub 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
493/// Select one root from each connected component of ring-eligible degree-2
494/// atoms, matching RDKit's `pickD2Nodes`/`markUselessD2s` pass. A degree-2
495/// chain is represented by its first atom in molecule order; this avoids
496/// treating every atom along the same chain as an independent root.
497pub 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
531/// Build the symmetrized smallest-ring set from the Horton basis and the
532/// root-centered Figueras candidates.
533///
534/// A candidate is accepted only when it has the same size as a basis ring,
535/// shares a bond with that ring, and does not omit a bond that is unique to
536/// the basis ring. These are RDKit's duplicate-ring acceptance conditions.
537/// The existing [`find_sssr`] result remains the base and this function is a
538/// separate opt-in model for consumers that need symmetry-equivalent rings.
539pub 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    // Keep every independently verified minimum replacement. RDKit's
667    // symmetrized SSSR intentionally retains multiple overlapping rings in a
668    // degenerate fused/bridged system; collapsing them to one representative
669    // loses the active ring context needed by MMFF94 aromaticity.
670    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
695/// Stable tie-break key for a candidate ring based on a GF(2)-valid basis
696/// exchange. The candidate replaces each same-sized Horton basis ring in
697/// turn; only replacements that remain linearly independent are considered.
698/// This preserves the minimum-cycle-basis contract while making the choice
699/// depend on the resulting basis rather than raw bond numbering alone.
700fn 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
780/// Enumerate shortest paths in an already-computed BFS distance field.
781/// Distances strictly increase at each step, so every emitted path is simple
782/// and cannot revisit the excluded root.
783fn 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
818/// Form the Horton candidate cycle `SP(root,x) + edge(x,y) + SP(y,root)`.
819///
820/// Returns `None` if the two root-rooted shortest paths share any vertex
821/// other than `root` itself — in that case the paths actually meet at some
822/// closer common ancestor, so this (root, edge) pair doesn't yield a *simple*
823/// cycle (the true minimal cycle through that closer ancestor is correctly
824/// picked up when *it* is tried as root instead).
825fn 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); // [x, ..., root]
834    let path_y = path_to_root(y, parent); // [y, ..., root]
835    debug_assert_eq!(*path_x.last().unwrap(), root);
836    debug_assert_eq!(*path_y.last().unwrap(), root);
837
838    // Simplicity check: the two paths must share only `root`.
839    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    // Ordered ring atoms: x ... root ... y (then the edge x-y closes the cycle).
848    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
869/// Walk parent pointers from `start` to `root`, returning the chain
870/// including `start` (first) and the root (last).
871fn 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
884// ---------------------------------------------------------------------------
885// Canonical tie-break (determinism, independent of atom-numbering)
886// ---------------------------------------------------------------------------
887
888/// A cheap, self-contained (no cross-crate dependency) approximation of
889/// canonical atom ranking, used only to make candidate-cycle tie-breaking
890/// deterministic: the same molecular graph always produces the same SSSR,
891/// regardless of how its atoms happen to be numbered by the parser (SMILES
892/// traversal order, SDF atom-block order, etc). This is a local
893/// Weisfeiler-Leman-style refinement (seed on (element, degree, charge,
894/// aromatic), then repeatedly fold in each atom's sorted neighbor keys) —
895/// it does not aim for full canonical-labeling discriminating power (ties
896/// among genuinely symmetric atoms are expected and fine; the point is
897/// input-order-independence, not maximal refinement).
898fn 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; // shift to non-negative
907            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
932/// Deterministic sort key for a candidate cycle: the sorted multiset of its
933/// atoms' canonical ranks, combined order-independently — isomorphic cycles
934/// (same molecule, different traversal) always collapse to the same key.
935fn 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
945// ---------------------------------------------------------------------------
946// GF(2) Gaussian elimination
947// ---------------------------------------------------------------------------
948
949/// Reduce `cycle` over GF(2) against the current `basis`.
950///
951/// Each basis entry maps a pivot bond (the minimum BondIdx in that row)
952/// to the full row (sorted Vec<BondIdx>).
953///
954/// Returns the reduced cycle (empty if dependent on existing basis).
955fn 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, // independent
960            // XOR: symmetric difference of the two sorted sets.
961            Some(basis_row) => current = sym_diff(&current, basis_row),
962        }
963    }
964    current
965}
966
967/// Symmetric difference of two sorted slices (GF(2) addition / XOR for sets).
968fn 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                // Both contain this element — XOR removes it.
984                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// ---------------------------------------------------------------------------
995// Tests
996// ---------------------------------------------------------------------------
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
1002
1003    // Build a cyclohexane molecule (6 carbons, 6 single bonds).
1004    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    // Build a benzene molecule (6 aromatic carbons, 6 single bonds for topology).
1015    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    // Build naphthalene: 10 atoms, 11 bonds (two fused 6-membered rings).
1026    // Atom numbering:
1027    //   0-1-2-3-4-5-0  (ring 1 perimeter, 6 atoms)
1028    //   4-6-7-8-9-5    (ring 2, sharing bond 4-5)
1029    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        // Ring 1: 0-1-2-3-4-9-0
1033        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        // Ring 2: 4-5-6-7-8-9 (shares bond 4-9)
1043        // Bonds to add: 4-5, 5-6, 6-7, 7-8, 8-9
1044        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    // Build norbornane (bicyclo[2.2.1]heptane): 7 carbons, 8 bonds, 2 rings.
1053    // Numbering:
1054    //   bridgehead atoms: 0, 3
1055    //   bridge 1: 0-1-2-3
1056    //   bridge 2: 0-4-5-3
1057    //   bridge 3: 0-6-3  (one-carbon bridge)
1058    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        // Bridge 1: 0-1-2-3
1062        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        // Bridge 2: 0-4-5-3
1066        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        // Bridge 3: 0-6-3
1070        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        // Azulene: cyclopentadiene fused to cycloheptatriene, sharing one
1078        // bond. RDKit's GetSymmSSSR ring-size multiset is [5, 7]; the old
1079        // single-spanning-tree find_sssr previously returned a non-minimal
1080        // basis for this topology (see aromaticity.rs's PROVISIONAL-tagged
1081        // azulene regression test for the downstream effect on Pass 1/2).
1082        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        // Indolizine: pyrrole fused to pyridine sharing a bridgehead N.
1092        // RDKit's GetSymmSSSR ring-size multiset is [5, 6] — a fused
1093        // heterocycle oracle case distinct from azulene's all-carbon,
1094        // odd/odd-sized ring pair.
1095        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        // Cycle rank: 11 bonds - 10 atoms + 1 component = 2
1139        // SSSR should have 2 rings, both 6-membered.
1140        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        // Cycle rank: 8 bonds - 7 atoms + 1 component = 2
1151        assert_eq!(rings.ring_count(), 2, "norbornane SSSR has 2 rings");
1152        // The two smallest rings are both 5-membered.
1153        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        // Ethane: no rings.
1161        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    // Anthracene: 14 atoms, 16 bonds (3 fused 6-membered rings).
1197    // Linear fusion: central ring shares edges with two outer rings.
1198    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        // Ring 1 (left): 0-1-2-3-8-9-0
1202        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        // Ring 2 (center): 3-4-5-6-7-8-3
1209        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        // Ring 3 (right): 7-10-11-12-13-6-7
1215        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    // Spiro[4.4]nonane: two 5-membered rings sharing a single bridgehead atom.
1224    // 9 atoms total, cycle rank 2.
1225    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        // Bridgehead: atom 0
1229        // Ring 1: 0-1-2-3-4-0
1230        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        // Ring 2: 0-5-6-7-8-0
1236        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    // 12-membered macrocycle (1 ring, 12 atoms).
1245    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    // Two disconnected rings (two components).
1256    fn disconnected_rings() -> chematic_core::Molecule {
1257        let mut b = MoleculeBuilder::new();
1258        // Benzene ring: atoms 0-5
1259        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        // Separate cyclohexane ring: atoms 6-11
1269        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    // Adamantane-like tricyclic structure (simplified):
1282    // 10 atoms, 3 bridges between 2 bridgeheads
1283    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        // Bridgehead atoms: 0, 5
1287        // Bridge 1: 0-1-2-5 (3 bonds in chain)
1288        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        // Bridge 2: 0-3-4-5 (3 bonds in chain)
1292        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        // Bridge 3: 0-6-7-5 (3 bonds in chain)
1296        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        // Cross-link bonds to connect bridges (forming tertiary center)
1300        // 1-3, 2-4, 6-? to complete cage
1301        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        // Cycle rank: 16 bonds - 14 atoms + 1 component = 3.
1311        // RDKit's GetSymmSSSR gives three 6-membered rings (linear acene) —
1312        // Horton's minimum-weight basis must match this exactly, not just
1313        // "3 rings covering most atoms" (the old spanning-tree algorithm
1314        // could substitute a larger non-minimal ring for one of these).
1315        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        // Cycle rank: 8 bonds - 9 atoms + 1 component = 0... wait, let me recalculate
1336        // Actually: two 5-membered rings sharing 1 atom = 4 + 4 + 2 (bridge) = 10 bonds
1337        // 10 bonds - 9 atoms + 1 = 2 rings
1338        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        // Cycle rank: 12 bonds - 12 atoms + 2 components = 2 rings
1361        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        // Simplified adamantane: 10 atoms, 12 bonds, 1 component
1375        // Cycle rank: 12 - 10 + 1 = 3
1376        // (May be 3-4 depending on cross-link structure and Gaussian elimination)
1377        assert!(
1378            rings.ring_count() >= 3,
1379            "adamantane SSSR has at least 3 rings"
1380        );
1381        // Each ring should be reasonable size
1382        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        // Cubane C8H8 — a cage molecule with 12 C-C bonds and 8 vertices.
1457        // Cycle rank = E - V + 1 = 12 - 8 + 1 = 5.
1458        // Cubane has 6 square (4-membered) faces but only 5 are linearly
1459        // independent in GF(2) — the 6th is the XOR of the other 5 (not just
1460        // any pair of them, since Sum-of-all-6-faces = 0 in GF(2): each edge
1461        // is shared by exactly 2 faces).
1462        //
1463        // Horton's candidate generation (O(V*E) candidates, guaranteed to
1464        // contain a minimum-weight basis) finds a truly minimal SSSR here:
1465        // all 5 basis rings are 4-membered (weight 20), strictly better than
1466        // the old single-spanning-tree algorithm's typical "4 four-membered +
1467        // 1 six-membered diagonal" (weight 22) — see module doc / project
1468        // history for why the old algorithm couldn't guarantee this.
1469        //
1470        // Recovering the 6th (symmetry-equivalent) face requires XOR-ing all
1471        // 5 basis rings together, not a pairwise XOR — augmented_ring_set
1472        // only does pairwise XOR, so it cannot find it from an already-fully-
1473        // 4-membered basis (two same-size adjacent cube faces XOR to a
1474        // 6-membered "belt", not another 4-ring). This is expected: full
1475        // symmetrization (all 6 symmetry-equivalent minimal rings, matching
1476        // RDKit's GetSymmSSSR on cubane) is out of scope for Horton alone and
1477        // deferred to a later Vismara "relevant cycles" pass (see project
1478        // plan) — not a regression, since Horton's SSSR is still a strict
1479        // minimality improvement over the previous algorithm.
1480        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    /// Rebuild `mol` with atoms relabeled by `perm` (perm[new_idx] = old_idx),
1503    /// preserving the same graph but a different atom insertion order.
1504    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    /// find_sssr's ring-size multiset must not depend on atom insertion order.
1522    /// Probes the fused/bridged/cage systems this project has already
1523    /// identified as the hard cases for canonical_atom_ranks' 3-round
1524    /// (not-fixpoint) Weisfeiler-Leman tie-break -- unlike
1525    /// `canonical_atom_order` (chematic-smiles), this function's doc comment
1526    /// does not claim full canonical-labeling power, and find_sssr's
1527    /// self-stability was already measured at 0% on a 5000-molecule corpus
1528    /// during the Horton rewrite (698ba3f); this is a permanent regression
1529    /// guard for that claim, not a first-time probe.
1530    #[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    // ── RDKit PR #9118: SSSR excludes Zero-order and Dative bonds ────────────
1569
1570    #[test]
1571    fn sssr_ignores_zero_order_bonds() {
1572        // A--B via a single bond PLUS a Zero-order bond between the same atoms
1573        // must NOT create a ring. Zero-order bonds are non-valence connections.
1574        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        // Build a proper molecule: just two atoms with a single bond.
1585        // The zero-order bond attempt is rejected, so the molecule is acyclic.
1586        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        // Benzene ring (6 aromatic bonds) PLUS one Zero-order bond closing an
1598        // extra connection should NOT add a phantom 2-membered ring.
1599        // We build cyclohexane (all single bonds) and verify no extra ring from
1600        // a Zero-order bond added between two non-adjacent atoms.
1601        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        // Square ring: 0-1-2-3-0
1610        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        // Zero-order bond between non-adjacent atoms: should NOT create a new ring.
1615        // Note: builder may reject parallel bonds; if so the test still passes.
1616        let _ = b.add_bond(atoms[0], atoms[2], BondOrder::Zero);
1617        let mol = b.build();
1618        let sssr = find_sssr(&mol);
1619        // Should find exactly 1 ring (the 4-membered ring), NOT 2 or 3.
1620        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}