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