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