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 Balducci-Pearlman algorithm.
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_local;
19
20pub use aromaticity::{
21    AromaticityAlgorithm, AromaticityModel, AtomElectronTrace, ConjugatedComponent,
22    ContributionDecision, ContributionReason, PiEligibility, RingAromaticity, RingElectronTrace,
23    all_ring_list, apply_aromaticity, apply_aromaticity_ex, aromatic_ring_list, assign_aromaticity,
24    assign_aromaticity_ex, augmented_ring_set, build_conjugated_components, count_aromatic_rings,
25    evaluate_atom_pi_contribution, exhaustive_aromaticity_oracle, ring_bonds_all_aromatic,
26    trace_ring_pi_electrons,
27};
28pub use chematic_core::{ValenceError, validate_valence};
29pub use pharmacophore::{Feature, FeatureType, detect_features, features_to_bitvec};
30pub use rdkit_parity::{
31    AromaticityError, apply_aromaticity_rdkit_parity_experimental,
32    assign_aromaticity_rdkit_parity_experimental,
33};
34pub use ring_family::{RingFamily, RingSystemKind, find_ring_families, find_ring_families_over};
35
36/// Diagnostic-only APIs, not meant for production use — reference-engine
37/// internals kept for cross-checking and corpus benchmarking. Gated behind
38/// the `diagnostics` feature. See `docs/aromaticity_a1_rfc.md`.
39///
40/// The production-facing surface of the RDKit-parity engine is
41/// [`assign_aromaticity_rdkit_parity_experimental`] and
42/// [`apply_aromaticity_rdkit_parity_experimental`], both always available
43/// (no feature flag required).
44#[cfg(feature = "diagnostics")]
45#[doc(hidden)]
46pub mod diagnostics {
47    pub use crate::rdkit_parity::rdkit_parity_aromaticity;
48}
49pub use sssr::{RingSet, find_sssr};
50pub use stereo_validation::{
51    StereoCompleteness, StereoError, StereoErrorKind, stereo_completeness, validate_stereo,
52};
53pub use stereo2d::{
54    StereoAssignment2D, apply_stereo_from_2d, assign_ez_from_2d, assign_stereo_from_2d,
55    cip_ez_descriptor,
56};
57pub use stereo2d_local::{apply_local_parity_from_wedges, local_parity_from_wedges};
58
59use chematic_core::{AtomIdx, Molecule};
60
61// ---------------------------------------------------------------------------
62// Ring system helper API
63// ---------------------------------------------------------------------------
64
65/// For each atom, return the list of SSSR ring indices that contain it.
66///
67/// The outer `Vec` is indexed by atom position; each inner `Vec` contains
68/// 0-based indices into the SSSR ring list (`find_sssr(mol).rings()`).
69/// Atoms that belong to no ring get an empty inner vec.
70pub fn ring_membership(mol: &Molecule) -> Vec<Vec<usize>> {
71    let ring_set = find_sssr(mol);
72    let rings = ring_set.rings();
73    let n = mol.atom_count();
74    let mut membership: Vec<Vec<usize>> = vec![Vec::new(); n];
75    for (ring_idx, ring) in rings.iter().enumerate() {
76        for &atom in ring {
77            membership[atom.0 as usize].push(ring_idx);
78        }
79    }
80    membership
81}
82
83/// Return the sizes of all SSSR rings that contain `atom_idx`.
84///
85/// Returns an empty vec for acyclic atoms.
86pub fn ring_sizes_for_atom(mol: &Molecule, atom_idx: usize) -> Vec<usize> {
87    let ring_set = find_sssr(mol);
88    let target = AtomIdx(atom_idx as u32);
89    ring_set
90        .rings()
91        .iter()
92        .filter(|ring| ring.contains(&target))
93        .map(|ring| ring.len())
94        .collect()
95}
96
97/// Return `true` if the molecule contains a fused ring system.
98///
99/// Two rings are fused when they share at least one bond (i.e. two adjacent
100/// atoms in both rings).  Spiro rings (sharing exactly one atom) return `false`.
101pub fn is_fused_ring_system(mol: &Molecule) -> bool {
102    let ring_set = find_sssr(mol);
103    let rings = ring_set.rings();
104    for i in 0..rings.len() {
105        for j in (i + 1)..rings.len() {
106            // Count shared atoms.
107            let shared = rings[i].iter().filter(|a| rings[j].contains(a)).count();
108            if shared >= 2 {
109                return true; // two rings share an edge → fused
110            }
111        }
112    }
113    false
114}
115
116/// Apply aromaticity to `mol` in-place (wrapper for [`apply_aromaticity`]).
117pub fn aromatize(mol: &mut Molecule) {
118    *mol = apply_aromaticity(mol);
119}
120
121/// Convert `mol` to Kekulé form in-place (wrapper for `kekulize` + `apply_kekule`).
122///
123/// Returns `Err` if kekulization fails (e.g. invalid aromatic system).
124pub fn kekulize_inplace(mol: &mut Molecule) -> Result<(), chematic_core::KekuleError> {
125    use chematic_core::{apply_kekule, kekulize};
126    let result = kekulize(mol)?;
127    *mol = apply_kekule(mol, &result);
128    Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use chematic_smiles::parse;
135
136    fn mol(smiles: &str) -> Molecule {
137        parse(smiles).expect("valid SMILES")
138    }
139
140    #[test]
141    fn test_ring_membership_benzene() {
142        let m = mol("c1ccccc1");
143        let membership = ring_membership(&m);
144        assert_eq!(membership.len(), 6);
145        for atom_membership in membership.iter().take(6) {
146            assert_eq!(
147                atom_membership.len(),
148                1,
149                "each benzene atom in exactly 1 ring"
150            );
151            assert_eq!(atom_membership[0], 0, "all in ring index 0");
152        }
153    }
154
155    #[test]
156    fn test_ring_membership_naphthalene() {
157        let m = mol("c1ccc2ccccc2c1");
158        let membership = ring_membership(&m);
159        assert_eq!(membership.len(), 10);
160        // In naphthalene SSSR, some atoms appear in 2 rings depending on the ring decomposition
161        // Just verify all atoms are in at least 1 ring
162        for mem in &membership {
163            assert!(
164                !mem.is_empty(),
165                "all naphthalene atoms should be in at least 1 ring"
166            );
167        }
168    }
169
170    #[test]
171    fn test_ring_membership_acyclic() {
172        let m = mol("CC");
173        let membership = ring_membership(&m);
174        assert_eq!(membership.len(), 2);
175        for mem in &membership {
176            assert!(mem.is_empty(), "ethane atoms should not be in rings");
177        }
178    }
179
180    #[test]
181    fn test_ring_sizes_for_atom_benzene() {
182        let m = mol("c1ccccc1");
183        let sizes = ring_sizes_for_atom(&m, 0);
184        assert_eq!(sizes, vec![6]);
185    }
186
187    #[test]
188    fn test_ring_sizes_for_atom_naphthalene() {
189        let m = mol("c1ccc2ccccc2c1");
190        // Just verify naphthalene atoms are in rings of size 6
191        let sizes = ring_sizes_for_atom(&m, 0);
192        assert!(!sizes.is_empty());
193        assert!(sizes.contains(&6), "naphthalene has 6-membered rings");
194    }
195
196    #[test]
197    fn test_ring_sizes_for_atom_acyclic() {
198        let m = mol("CC");
199        let sizes = ring_sizes_for_atom(&m, 0);
200        assert!(sizes.is_empty());
201    }
202
203    #[test]
204    fn test_is_fused_ring_naphthalene() {
205        let m = mol("c1ccc2ccccc2c1");
206        assert!(is_fused_ring_system(&m), "naphthalene is fused");
207    }
208
209    #[test]
210    fn test_is_fused_ring_benzene() {
211        let m = mol("c1ccccc1");
212        assert!(
213            !is_fused_ring_system(&m),
214            "single benzene ring is not fused"
215        );
216    }
217
218    #[test]
219    fn test_is_fused_ring_spiro() {
220        // Spiro[4.4]nonane has two rings sharing only 1 atom
221        let m = mol("C1CCC2(C1)CCCC2");
222        assert!(
223            !is_fused_ring_system(&m),
224            "spiro compound shares only 1 atom, not fused"
225        );
226    }
227
228    #[test]
229    fn test_aromatize_benzene() {
230        let mut m = mol("c1ccccc1");
231        aromatize(&mut m);
232        for (_, atom) in m.atoms() {
233            assert!(atom.aromatic, "all benzene atoms should be aromatic");
234        }
235        for (_, bond) in m.bonds() {
236            assert_eq!(
237                bond.order,
238                chematic_core::BondOrder::Aromatic,
239                "all benzene bonds should be aromatic"
240            );
241        }
242    }
243
244    #[test]
245    fn test_kekulize_inplace_benzene() {
246        let mut m = mol("c1ccccc1");
247        kekulize_inplace(&mut m).expect("benzene should kekulize");
248        let mut single_count = 0;
249        let mut double_count = 0;
250        for (_, bond) in m.bonds() {
251            match bond.order {
252                chematic_core::BondOrder::Single => single_count += 1,
253                chematic_core::BondOrder::Double => double_count += 1,
254                _ => panic!("unexpected bond order after kekulization"),
255            }
256        }
257        assert_eq!(single_count, 3);
258        assert_eq!(double_count, 3);
259    }
260}