Skip to main content

chematic_perception/
lib.rs

1//! `chematic-perception` — molecular perception algorithms.
2//!
3//! Provides:
4//! - [`sssr`]: Smallest Set of Smallest Rings (SSSR) via a Horton minimum cycle basis.
5//! - [`aromaticity`]: Hückel aromaticity perception for kekulized molecules.
6
7#![forbid(unsafe_code)]
8
9pub mod aromaticity;
10pub mod cip_priority;
11pub mod pharmacophore;
12mod rdkit_parity;
13pub mod ring_family;
14pub mod sssr;
15pub mod stereo_validation;
16
17pub mod stereo2d;
18pub mod stereo2d_ez_direction;
19pub mod stereo2d_local;
20
21pub use aromaticity::{
22    AromaticityAlgorithm, AromaticityModel, AtomElectronTrace, ConjugatedComponent,
23    ContributionDecision, ContributionReason, PiEligibility, RingAromaticity, RingElectronTrace,
24    all_ring_list, apply_aromaticity, apply_aromaticity_authoritative_experimental,
25    apply_aromaticity_ex, aromatic_ring_list, assign_aromaticity,
26    assign_aromaticity_authoritative_experimental, assign_aromaticity_ex, augmented_ring_set,
27    build_conjugated_components, count_aromatic_rings, evaluate_atom_pi_contribution,
28    exhaustive_aromaticity_oracle, ring_bonds_all_aromatic, trace_ring_pi_electrons,
29};
30pub use chematic_core::{ValenceError, validate_valence};
31pub use pharmacophore::{Feature, FeatureType, detect_features, features_to_bitvec};
32pub use rdkit_parity::{
33    AromaticityError, apply_aromaticity_rdkit_parity_experimental,
34    assign_aromaticity_rdkit_parity_experimental,
35};
36pub use ring_family::{RingFamily, RingSystemKind, find_ring_families, find_ring_families_over};
37
38/// Diagnostic-only APIs, not meant for production use — reference-engine
39/// internals kept for cross-checking and corpus benchmarking. Gated behind
40/// the `diagnostics` feature. See `docs/rfcs/aromaticity_a1_rfc.md`.
41///
42/// The production-facing surface of the RDKit-parity engine is
43/// [`assign_aromaticity_rdkit_parity_experimental`] and
44/// [`apply_aromaticity_rdkit_parity_experimental`], both always available
45/// (no feature flag required).
46#[cfg(feature = "diagnostics")]
47#[doc(hidden)]
48pub mod diagnostics {
49    pub use crate::rdkit_parity::rdkit_parity_aromaticity;
50}
51pub use sssr::{
52    RingSet, SymmetrizedSssrResult, SymmetrizedSssrStatus, find_smallest_rings_bfs,
53    find_smallest_rings_bfs_with_blocked_bonds, find_smallest_rings_bfs_with_rdkit_tree,
54    find_smallest_rings_bfs_with_trimmed_bonds, find_sssr, find_symmetrized_sssr,
55    find_symmetrized_sssr_with_diagnostics, select_rdkit_d2_roots, trim_ring_bonds,
56};
57pub use stereo_validation::{
58    StereoCompleteness, StereoError, StereoErrorKind, stereo_centers, stereo_completeness,
59    validate_stereo,
60};
61pub use stereo2d::{
62    StereoAssignment2D, apply_stereo_from_2d, assign_ez_from_2d, assign_stereo_from_2d,
63    cip_ez_descriptor,
64};
65pub use stereo2d_ez_direction::{
66    EzDirectionDiagnostic, EzDirectionRejectionReason, apply_ez_directions_from_2d,
67    apply_ez_directions_from_2d_ex, apply_ez_directions_from_2d_with_diagnostics,
68};
69pub use stereo2d_local::{
70    StereoDiagnostic, StereoRejectionReason, apply_local_parity_from_wedges,
71    apply_local_parity_from_wedges_with_diagnostics, local_parity_from_wedges,
72};
73
74use chematic_core::{AtomIdx, Molecule};
75
76// ---------------------------------------------------------------------------
77// Ring system helper API
78// ---------------------------------------------------------------------------
79
80/// For each atom, return the list of SSSR ring indices that contain it.
81///
82/// The outer `Vec` is indexed by atom position; each inner `Vec` contains
83/// 0-based indices into the SSSR ring list (`find_sssr(mol).rings()`).
84/// Atoms that belong to no ring get an empty inner vec.
85pub fn ring_membership(mol: &Molecule) -> Vec<Vec<usize>> {
86    let ring_set = find_sssr(mol);
87    let rings = ring_set.rings();
88    let n = mol.atom_count();
89    let mut membership: Vec<Vec<usize>> = vec![Vec::new(); n];
90    for (ring_idx, ring) in rings.iter().enumerate() {
91        for &atom in ring {
92            membership[atom.0 as usize].push(ring_idx);
93        }
94    }
95    membership
96}
97
98/// Return the sizes of all SSSR rings that contain `atom_idx`.
99///
100/// Returns an empty vec for acyclic atoms.
101pub fn ring_sizes_for_atom(mol: &Molecule, atom_idx: usize) -> Vec<usize> {
102    let ring_set = find_sssr(mol);
103    let target = AtomIdx(atom_idx as u32);
104    ring_set
105        .rings()
106        .iter()
107        .filter(|ring| ring.contains(&target))
108        .map(|ring| ring.len())
109        .collect()
110}
111
112/// Return `true` if the molecule contains a fused ring system.
113///
114/// Two rings are fused when they share at least one bond (i.e. two adjacent
115/// atoms in both rings).  Spiro rings (sharing exactly one atom) return `false`.
116pub fn is_fused_ring_system(mol: &Molecule) -> bool {
117    let ring_set = find_sssr(mol);
118    let rings = ring_set.rings();
119    for i in 0..rings.len() {
120        for j in (i + 1)..rings.len() {
121            // Count shared atoms.
122            let shared = rings[i].iter().filter(|a| rings[j].contains(a)).count();
123            if shared >= 2 {
124                return true; // two rings share an edge → fused
125            }
126        }
127    }
128    false
129}
130
131/// Return `true` if `ring` is a macrocycle (>= 9 atoms).
132///
133/// `9` matches RDKit's own `minMacrocycleRingSize` (the ring size at which
134/// RDKit's ETKDG embedder switches to macrocycle-specific torsion
135/// sampling). This is a pure ring-size classification over the atom list
136/// returned by [`find_sssr`]/[`ring_family::find_ring_families`] — it takes
137/// no bond/force-field context and lives here so callers don't need the
138/// full `chematic-3d` dependency chain just to ask "is this ring a
139/// macrocycle?" (see issue #266).
140///
141/// Note (pre-existing, not touched by this function): `chematic-3d`
142/// independently hardcodes this same threshold twice —
143/// `rdkit_shape_descriptors::MACROCYCLE_RING_THRESHOLD` and
144/// `etkdg_knowledge::classify::MACROCYCLE_MIN`, both `9`, neither shared
145/// with this function or with each other.
146pub fn is_macrocycle(ring: &[AtomIdx]) -> bool {
147    ring.len() >= 9
148}
149
150/// Apply aromaticity to `mol` in-place (wrapper for [`apply_aromaticity`]).
151pub fn aromatize(mol: &mut Molecule) {
152    *mol = apply_aromaticity(mol);
153}
154
155/// Convert `mol` to Kekulé form in-place (wrapper for `kekulize` + `apply_kekule`).
156///
157/// Returns `Err` if kekulization fails (e.g. invalid aromatic system).
158pub fn kekulize_inplace(mol: &mut Molecule) -> Result<(), chematic_core::KekuleError> {
159    use chematic_core::{apply_kekule, kekulize};
160    let result = kekulize(mol)?;
161    *mol = apply_kekule(mol, &result);
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use chematic_smiles::parse;
169
170    fn mol(smiles: &str) -> Molecule {
171        parse(smiles).expect("valid SMILES")
172    }
173
174    #[test]
175    fn test_ring_membership_benzene() {
176        let m = mol("c1ccccc1");
177        let membership = ring_membership(&m);
178        assert_eq!(membership.len(), 6);
179        for atom_membership in membership.iter().take(6) {
180            assert_eq!(
181                atom_membership.len(),
182                1,
183                "each benzene atom in exactly 1 ring"
184            );
185            assert_eq!(atom_membership[0], 0, "all in ring index 0");
186        }
187    }
188
189    #[test]
190    fn test_ring_membership_naphthalene() {
191        let m = mol("c1ccc2ccccc2c1");
192        let membership = ring_membership(&m);
193        assert_eq!(membership.len(), 10);
194        // In naphthalene SSSR, some atoms appear in 2 rings depending on the ring decomposition
195        // Just verify all atoms are in at least 1 ring
196        for mem in &membership {
197            assert!(
198                !mem.is_empty(),
199                "all naphthalene atoms should be in at least 1 ring"
200            );
201        }
202    }
203
204    #[test]
205    fn test_ring_membership_acyclic() {
206        let m = mol("CC");
207        let membership = ring_membership(&m);
208        assert_eq!(membership.len(), 2);
209        for mem in &membership {
210            assert!(mem.is_empty(), "ethane atoms should not be in rings");
211        }
212    }
213
214    #[test]
215    fn test_ring_sizes_for_atom_benzene() {
216        let m = mol("c1ccccc1");
217        let sizes = ring_sizes_for_atom(&m, 0);
218        assert_eq!(sizes, vec![6]);
219    }
220
221    #[test]
222    fn test_ring_sizes_for_atom_naphthalene() {
223        let m = mol("c1ccc2ccccc2c1");
224        // Just verify naphthalene atoms are in rings of size 6
225        let sizes = ring_sizes_for_atom(&m, 0);
226        assert!(!sizes.is_empty());
227        assert!(sizes.contains(&6), "naphthalene has 6-membered rings");
228    }
229
230    #[test]
231    fn test_ring_sizes_for_atom_acyclic() {
232        let m = mol("CC");
233        let sizes = ring_sizes_for_atom(&m, 0);
234        assert!(sizes.is_empty());
235    }
236
237    #[test]
238    fn test_is_fused_ring_naphthalene() {
239        let m = mol("c1ccc2ccccc2c1");
240        assert!(is_fused_ring_system(&m), "naphthalene is fused");
241    }
242
243    #[test]
244    fn test_is_fused_ring_benzene() {
245        let m = mol("c1ccccc1");
246        assert!(
247            !is_fused_ring_system(&m),
248            "single benzene ring is not fused"
249        );
250    }
251
252    #[test]
253    fn test_is_fused_ring_spiro() {
254        // Spiro[4.4]nonane has two rings sharing only 1 atom
255        let m = mol("C1CCC2(C1)CCCC2");
256        assert!(
257            !is_fused_ring_system(&m),
258            "spiro compound shares only 1 atom, not fused"
259        );
260    }
261
262    #[test]
263    fn test_is_macrocycle_boundary() {
264        // Exact >=9 boundary: 8-membered false, 9-membered true.
265        let ring8: Vec<AtomIdx> = (0..8).map(AtomIdx).collect();
266        let ring9: Vec<AtomIdx> = (0..9).map(AtomIdx).collect();
267        assert!(
268            !is_macrocycle(&ring8),
269            "8-membered ring is not a macrocycle"
270        );
271        assert!(is_macrocycle(&ring9), "9-membered ring is a macrocycle");
272    }
273
274    #[test]
275    fn test_is_macrocycle_cyclododecane() {
276        // Cyclododecane: 12-membered ring, well above the threshold.
277        let m = mol("C1CCCCCCCCCCC1");
278        let ring_set = find_sssr(&m);
279        let rings = ring_set.rings();
280        assert_eq!(rings.len(), 1);
281        assert!(is_macrocycle(&rings[0]), "cyclododecane is a macrocycle");
282    }
283
284    #[test]
285    fn test_is_macrocycle_small_rings() {
286        // Benzene (6) and cyclohexane (6) are well under the threshold.
287        let benzene = mol("c1ccccc1");
288        let rings = find_sssr(&benzene);
289        assert!(
290            !is_macrocycle(&rings.rings()[0]),
291            "benzene is not a macrocycle"
292        );
293
294        let cyclohexane = mol("C1CCCCC1");
295        let rings = find_sssr(&cyclohexane);
296        assert!(
297            !is_macrocycle(&rings.rings()[0]),
298            "cyclohexane is not a macrocycle"
299        );
300    }
301
302    #[test]
303    fn test_aromatize_benzene() {
304        let mut m = mol("c1ccccc1");
305        aromatize(&mut m);
306        for (_, atom) in m.atoms() {
307            assert!(atom.aromatic, "all benzene atoms should be aromatic");
308        }
309        for (_, bond) in m.bonds() {
310            assert_eq!(
311                bond.order,
312                chematic_core::BondOrder::Aromatic,
313                "all benzene bonds should be aromatic"
314            );
315        }
316    }
317
318    #[test]
319    fn test_kekulize_inplace_benzene() {
320        let mut m = mol("c1ccccc1");
321        kekulize_inplace(&mut m).expect("benzene should kekulize");
322        let mut single_count = 0;
323        let mut double_count = 0;
324        for (_, bond) in m.bonds() {
325            match bond.order {
326                chematic_core::BondOrder::Single => single_count += 1,
327                chematic_core::BondOrder::Double => double_count += 1,
328                _ => panic!("unexpected bond order after kekulization"),
329            }
330        }
331        assert_eq!(single_count, 3);
332        assert_eq!(double_count, 3);
333    }
334}