Skip to main content

chematic_perception/
aromaticity.rs

1//! Hückel aromaticity perception with antiaromaticity detection.
2//!
3//! Works on kekulized molecules (no `Aromatic` bond orders) **or** on molecules
4//! that retain `Aromatic` bond orders from the SMILES parser (pre-kekulization).
5//! Call `kekulize` + `apply_kekule` from `chematic-core` before calling
6//! `assign_aromaticity` if you need the explicit double-bond form.
7//!
8//! Algorithm:
9//! 1. Find all SSSR rings via `find_sssr`.
10//! 2. **Pass 1**: evaluate each ring independently using Hückel electron counting.
11//!    Aromatic (`BondOrder::Aromatic`) bonds are treated equivalently to double bonds
12//!    so that pre-kekulization input is handled correctly.
13//!    A special "bridgehead N" rule covers fused-ring N atoms whose entire valence
14//!    is satisfied by single σ-bonds (like indolizine's junction nitrogen).
15//! 3. **Pass 2**: iterative propagation. Rings that were `NonAromatic` or
16//!    indeterminate in Pass 1 are re-evaluated using the already-aromatic atom set
17//!    as context: confirmed-aromatic atoms contribute 1π unconditionally, allowing
18//!    fused rings to be recognised bottom-up (e.g. the 6-ring of indolizine).
19//! 4. Classify rings by electron count:
20//!    - 4n+2 electrons (n >= 0): aromatic (favorable)
21//!    - 4n electrons (n > 0): antiaromatic (unfavorable, strongly disfavored)
22//!    - Other: non-aromatic
23//! 5. Record all aromatic atoms, bonds, and antiaromatic rings in an `AromaticityModel`.
24
25// ---------------------------------------------------------------------------
26// Algorithm selector
27// ---------------------------------------------------------------------------
28
29/// Algorithm used to classify ring aromaticity.
30///
31/// Passed to [`assign_aromaticity_ex`] and [`apply_aromaticity_ex`].
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum AromaticityAlgorithm {
34    /// Strict Hückel 4n+2 rule (default). Supports C, N, O, S.
35    #[default]
36    Huckel,
37    /// RDKit-compatible extension. Adds Se (34) and Te (52) as chalcogen lone-pair
38    /// donors (2π), matching the RDKit DEFAULT aromaticity model for common
39    /// organic and chalcogen heteroaromatics.
40    ///
41    /// P-containing aromatic rings are NOT supported in this mode (separate sprint).
42    /// Keto-lactam aromaticity is NOT included (TautomerMode, separate sprint).
43    RdkitLike,
44}
45
46use rustc_hash::{FxHashMap, FxHashSet};
47
48use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, implicit_hcount};
49
50use crate::sssr::find_sssr;
51
52// ---------------------------------------------------------------------------
53// Public types
54// ---------------------------------------------------------------------------
55
56/// Ring aromaticity classification.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RingAromaticity {
59    /// 4n+2 electrons: aromatic (favorable)
60    Aromatic,
61    /// 4n electrons (n > 0): antiaromatic (unfavorable)
62    Antiaromatic,
63    /// Any other electron count: non-aromatic
64    NonAromatic,
65}
66
67/// Aromaticity assignment for a molecule.
68///
69/// Records which atoms and bonds belong to aromatic rings according to
70/// the Hückel 4n+2 rule applied to SSSR rings (with fused-ring propagation).
71/// Also tracks antiaromatic rings (4n electrons) for chemical accuracy.
72#[derive(Debug, Clone)]
73pub struct AromaticityModel {
74    aromatic_atoms: FxHashSet<AtomIdx>,
75    aromatic_bonds: FxHashSet<BondIdx>,
76    antiaromatic_rings: Vec<Vec<AtomIdx>>,
77    ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)>,
78}
79
80impl AromaticityModel {
81    /// Whether atom `idx` is part of an aromatic ring.
82    pub fn is_atom_aromatic(&self, idx: AtomIdx) -> bool {
83        self.aromatic_atoms.contains(&idx)
84    }
85
86    /// Whether bond `idx` is part of an aromatic ring.
87    pub fn is_bond_aromatic(&self, idx: BondIdx) -> bool {
88        self.aromatic_bonds.contains(&idx)
89    }
90
91    /// Total number of atoms flagged as aromatic.
92    pub fn aromatic_atom_count(&self) -> usize {
93        self.aromatic_atoms.len()
94    }
95
96    /// Get all rings and their classification with electron counts.
97    ///
98    /// Each entry is `(ring_atoms, classification, π_electron_count)`.
99    /// Rings that could not be evaluated (sp3 atoms, unsupported elements) are omitted.
100    pub fn ring_classifications(&self) -> &[(Vec<AtomIdx>, RingAromaticity, u32)] {
101        &self.ring_classifications
102    }
103
104    /// Get all antiaromatic rings (4n electrons, n > 0).
105    pub fn antiaromatic_rings(&self) -> &[Vec<AtomIdx>] {
106        &self.antiaromatic_rings
107    }
108
109    /// Check if any atom belongs to an antiaromatic ring.
110    pub fn has_antiaromaticity(&self) -> bool {
111        !self.antiaromatic_rings.is_empty()
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Main entry points
117// ---------------------------------------------------------------------------
118
119/// Classify a ring by its pi electron count using Hückel and antiaromaticity rules.
120#[allow(clippy::manual_is_multiple_of)]
121fn classify_ring_aromaticity(pi_electrons: u32) -> (RingAromaticity, u32) {
122    if pi_electrons >= 2 && (pi_electrons - 2) % 4 == 0 {
123        (RingAromaticity::Aromatic, pi_electrons)
124    } else if pi_electrons > 0 && pi_electrons % 4 == 0 {
125        (RingAromaticity::Antiaromatic, pi_electrons)
126    } else {
127        (RingAromaticity::NonAromatic, pi_electrons)
128    }
129}
130
131/// Mark all atoms and bonds in `ring` as aromatic in the provided sets.
132fn mark_ring_aromatic(
133    mol: &Molecule,
134    ring: &[AtomIdx],
135    aromatic_atoms: &mut FxHashSet<AtomIdx>,
136    aromatic_bonds: &mut FxHashSet<BondIdx>,
137) {
138    for &atom in ring {
139        aromatic_atoms.insert(atom);
140    }
141    for i in 0..ring.len() {
142        let a = ring[i];
143        let b = ring[(i + 1) % ring.len()];
144        if let Some((bidx, _)) = mol.bond_between(a, b) {
145            aromatic_bonds.insert(bidx);
146        }
147    }
148}
149
150/// Assign aromaticity to a molecule using the Hückel 4n+2 rule with fused-ring
151/// propagation (Pass 2) and antiaromaticity detection (4n electrons).
152///
153/// The molecule may be kekulized (`Single`/`Double` bonds) **or** may retain
154/// `BondOrder::Aromatic` bonds from the SMILES parser.  In the latter case,
155/// aromatic bonds are treated as equivalent to double bonds for electron
156/// counting, allowing correct detection without an explicit kekulization step.
157///
158/// For kekulized input from aromatic SMILES, call `chematic_core::kekulize`
159/// then `chematic_core::apply_kekule` first.
160///
161/// Uses [`AromaticityAlgorithm::Huckel`] (default). See [`assign_aromaticity_ex`]
162/// for the RdkitLike variant.
163pub fn assign_aromaticity(mol: &Molecule) -> AromaticityModel {
164    assign_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
165}
166
167/// Assign aromaticity using the specified algorithm.
168///
169/// The default ([`assign_aromaticity`]) uses [`AromaticityAlgorithm::Huckel`].
170/// Pass [`AromaticityAlgorithm::RdkitLike`] to additionally recognise Se/Te
171/// as lone-pair donors in aromatic rings.
172pub fn assign_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> AromaticityModel {
173    let ring_set = find_sssr(mol);
174    let sssr_rings = ring_set.rings();
175
176    // Augment SSSR rings with smaller XOR sub-rings (GF(2) differences between pairs).
177    // This corrects the case where the SSSR algorithm stores a large fundamental cycle
178    // instead of its smaller GF(2)-reduced equivalent (e.g. the 5-ring of indolizine).
179    let rings: Vec<Vec<AtomIdx>> = augmented_ring_set(mol, sssr_rings);
180
181    let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
182    let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
183    let mut antiaromatic_rings: Vec<Vec<AtomIdx>> = Vec::new();
184
185    // Per-ring classification: None means "not yet evaluated / indeterminate".
186    let mut classifications: Vec<Option<(RingAromaticity, u32)>> = vec![None; rings.len()];
187
188    // Indices of rings that are candidates for Pass 2 re-evaluation
189    // (returned None or NonAromatic in Pass 1).
190    let mut pass2_candidates: Vec<usize> = Vec::new();
191
192    // ----- Pass 1: independent Hückel per ring -----
193    let empty_context = FxHashSet::default();
194    for (ring_idx, ring) in rings.iter().enumerate() {
195        match ring_pi_electrons(mol, ring, &empty_context, algo) {
196            Some(pi) => {
197                let (cls, count) = classify_ring_aromaticity(pi);
198                classifications[ring_idx] = Some((cls, count));
199                match cls {
200                    RingAromaticity::Aromatic => {
201                        mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
202                    }
203                    RingAromaticity::Antiaromatic => {
204                        antiaromatic_rings.push(ring.to_vec());
205                        // Antiaromatic is definitive — do not retry in Pass 2.
206                    }
207                    RingAromaticity::NonAromatic => {
208                        pass2_candidates.push(ring_idx);
209                    }
210                }
211            }
212            None => {
213                // Indeterminate (sp3 atoms, unsupported elements, etc.).
214                pass2_candidates.push(ring_idx);
215            }
216        }
217    }
218
219    // ----- Pass 2: propagate through fused ring systems -----
220    // Re-evaluate rings adjacent to already-aromatic rings.  Repeat until
221    // convergence (no newly aromatic ring found in the last iteration).
222    loop {
223        let mut any_new = false;
224        let mut still_pending: Vec<usize> = Vec::new();
225
226        for ring_idx in pass2_candidates {
227            let ring = &rings[ring_idx];
228            // Only rings that share an atom with an already-aromatic ring qualify.
229            if !ring.iter().any(|a| aromatic_atoms.contains(a)) {
230                still_pending.push(ring_idx);
231                continue;
232            }
233            match ring_pi_electrons(mol, ring, &aromatic_atoms, algo) {
234                Some(pi) => {
235                    let (cls, count) = classify_ring_aromaticity(pi);
236                    classifications[ring_idx] = Some((cls, count));
237                    if matches!(cls, RingAromaticity::Aromatic) {
238                        mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
239                        any_new = true;
240                    }
241                    // NonAromatic even in Pass 2 context: do not retry further.
242                }
243                None => {
244                    still_pending.push(ring_idx);
245                }
246            }
247        }
248
249        pass2_candidates = still_pending;
250        if !any_new {
251            break;
252        }
253    }
254
255    // Build the public ring_classifications list (SSSR rings only, omitting augmented/indeterminate).
256    let ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)> = rings
257        .iter()
258        .take(sssr_rings.len()) // only expose SSSR rings in the public API
259        .enumerate()
260        .filter_map(|(i, ring)| classifications[i].map(|(cls, count)| (ring.to_vec(), cls, count)))
261        .collect();
262
263    AromaticityModel {
264        aromatic_atoms,
265        aromatic_bonds,
266        antiaromatic_rings,
267        ring_classifications,
268    }
269}
270
271/// Apply aromaticity perception to a molecule.
272///
273/// Returns a new [`Molecule`] where atoms in Hückel-aromatic rings have
274/// `atom.aromatic = true` and their bonds carry [`BondOrder::Aromatic`].
275/// Non-aromatic atoms and bonds are unchanged.
276///
277/// The input may be kekulized (no `Aromatic` bond orders) or may retain
278/// aromatic bond orders from the SMILES parser.
279///
280/// Uses [`AromaticityAlgorithm::Huckel`] (default). See [`apply_aromaticity_ex`]
281/// for the RdkitLike variant.
282pub fn apply_aromaticity(mol: &Molecule) -> Molecule {
283    apply_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
284}
285
286/// Apply aromaticity using the specified algorithm.
287///
288/// Returns a new [`Molecule`] with aromatic flags set according to `algo`.
289pub fn apply_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> Molecule {
290    use chematic_core::{BondOrder, MoleculeBuilder};
291
292    let model = assign_aromaticity_ex(mol, algo);
293    let mut builder = MoleculeBuilder::new();
294
295    for (idx, atom) in mol.atoms() {
296        let mut a = atom.clone();
297        if model.is_atom_aromatic(idx) {
298            a.aromatic = true;
299        }
300        builder.add_atom(a);
301    }
302    for (bidx, bond) in mol.bonds() {
303        let order = if model.is_bond_aromatic(bidx) {
304            BondOrder::Aromatic
305        } else {
306            bond.order
307        };
308        if let Ok(new_bidx) = builder.add_bond(bond.atom1, bond.atom2, order)
309            && order == BondOrder::Aromatic
310            && matches!(bond.order, BondOrder::Up | BondOrder::Down)
311        {
312            // Kekule input promoted to Aromatic here loses its E/Z direction
313            // the same way the SMILES parser's aromatic-aromatic coercion
314            // does — stash it so an exocyclic double bond anchored on this
315            // ring bond still round-trips through the canonical writer.
316            builder.set_bond_direction(new_bidx, bond.order);
317        }
318    }
319    // Atoms/bonds above are re-added in `mol`'s own enumeration order with
320    // none skipped, so indices line up 1:1 — safe to copy side-channel
321    // metadata wholesale. (This rebuild previously dropped stereo_groups and
322    // stereo_neighbor_order silently; closing that here too.)
323    builder.copy_stereo_groups_from(mol);
324    builder.copy_stereo_from(mol);
325    builder.copy_bond_directions_from(mol);
326    builder.build()
327}
328
329// ---------------------------------------------------------------------------
330// Ring augmentation (XOR sub-rings)
331// ---------------------------------------------------------------------------
332
333/// Return the sorted set of bond indices that form `ring`.
334fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> Vec<BondIdx> {
335    let n = ring.len();
336    let mut bonds: Vec<BondIdx> = (0..n)
337        .filter_map(|i| {
338            let a = ring[i];
339            let b = ring[(i + 1) % n];
340            mol.bond_between(a, b).map(|(bidx, _)| bidx)
341        })
342        .collect();
343    bonds.sort();
344    bonds
345}
346
347/// Sorted symmetric difference of two sorted slices.
348fn bond_sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
349    let mut result: Vec<BondIdx> = Vec::new();
350    let mut i = 0;
351    let mut j = 0;
352    while i < a.len() && j < b.len() {
353        match a[i].cmp(&b[j]) {
354            std::cmp::Ordering::Less => {
355                result.push(a[i]);
356                i += 1;
357            }
358            std::cmp::Ordering::Greater => {
359                result.push(b[j]);
360                j += 1;
361            }
362            std::cmp::Ordering::Equal => {
363                i += 1;
364                j += 1;
365            }
366        }
367    }
368    result.extend_from_slice(&a[i..]);
369    result.extend_from_slice(&b[j..]);
370    result
371}
372
373/// Reconstruct an ordered atom sequence from a set of bond indices forming a simple cycle.
374/// Returns `None` if the bonds do not form a valid simple cycle.
375fn ring_atoms_from_bond_set(mol: &Molecule, bonds: &[BondIdx]) -> Option<Vec<AtomIdx>> {
376    if bonds.is_empty() {
377        return None;
378    }
379    let mut adj: FxHashMap<AtomIdx, [Option<AtomIdx>; 2]> = FxHashMap::default();
380    for &bidx in bonds {
381        let bond = mol.bond(bidx);
382        for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
383            let e = adj.entry(a).or_insert([None; 2]);
384            if e[0].is_none() {
385                e[0] = Some(b);
386            } else if e[1].is_none() {
387                e[1] = Some(b);
388            } else {
389                return None; // degree > 2 — not a simple ring
390            }
391        }
392    }
393    // All atoms must have exactly 2 neighbours.
394    if adj.values().any(|e| e[1].is_none()) {
395        return None;
396    }
397    let start = *adj.keys().next()?;
398    let mut path = vec![start];
399    let mut prev = start;
400    let mut current = adj[&start][0]?;
401    while current != start {
402        path.push(current);
403        let [n0, n1] = adj[&current];
404        let next = if n0 == Some(prev) { n1? } else { n0? };
405        prev = current;
406        current = next;
407    }
408    if path.len() != bonds.len() {
409        return None;
410    }
411    Some(path)
412}
413
414/// Augment the SSSR ring list with smaller XOR sub-rings found by pairwise GF(2)
415/// differences between SSSR rings that share atoms.
416///
417/// The standard SSSR algorithm sometimes stores a large fundamental cycle rather
418/// than its smaller GF(2)-reduced equivalent (e.g. the 5-ring of indolizine is
419/// the XOR of the 6-ring and the 9-ring the algorithm reports).
420/// This augmentation adds such missing smaller rings so that aromaticity
421/// perception works on the correct smallest rings without modifying the SSSR.
422///
423/// The returned `Vec` starts with all SSSR rings in their original order; any
424/// additional sub-rings derived by GF(2) pairwise XOR follow.  The function
425/// only adds a ring if it is strictly smaller than *both* parents, ensuring
426/// that envelope rings (e.g. the 10-membered perimeter of naphthalene) are
427/// never introduced.
428pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
429    let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
430
431    // Track which atom-sets we already have (as sorted atom lists).
432    let mut known: FxHashSet<Vec<AtomIdx>> = sssr_rings
433        .iter()
434        .map(|r| {
435            let mut s = r.clone();
436            s.sort();
437            s
438        })
439        .collect();
440
441    // Iterative pairwise XOR until convergence.
442    //
443    // A single pass only finds rings that are the XOR of two SSSR rings.
444    // Iterating also finds rings that require XOR of 3+ SSSR rings
445    // (e.g. the inner hexagon of coronene, or sub-rings in multi-step
446    // fused PAHs where the SSSR chose large perimeter cycles).
447    // Termination is guaranteed because each new ring is strictly smaller
448    // than both of its parents, so ring size can only decrease.
449    loop {
450        let mut changed = false;
451        let n = rings.len();
452        let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
453
454        for i in 0..n {
455            for j in (i + 1)..n {
456                // Only consider pairs that share atoms (fused rings).
457                let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
458                if !shares_atom {
459                    continue;
460                }
461                let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
462                if xor_bonds.is_empty() {
463                    continue;
464                }
465                // Only interesting if the XOR ring is not larger than the larger
466                // parent.  Using max() recovers cases where SSSR chose a large
467                // cycle (e.g. 10-ring macro vs 6-ring benzene twin).
468                // Using `>` (not `>=`) also allows same-size XOR rings, which
469                // handles bridged bicyclics (e.g. tropane or dioxolane spirocycles)
470                // where both parent rings are 6-membered and the missing bridge
471                // ring is also 6-membered.  Termination is still guaranteed:
472                // the `known` set prevents duplicates, and a finite molecule has
473                // finitely many valid cycles.
474                if xor_bonds.len() > rings[i].len().max(rings[j].len()) {
475                    continue;
476                }
477                if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
478                    let mut key = new_ring.clone();
479                    key.sort();
480                    if known.insert(key) {
481                        rings.push(new_ring);
482                        changed = true;
483                    }
484                }
485            }
486        }
487
488        // 3-ring XOR: catches small rings that require XOR of 3 SSSR rings
489        // when no intermediate 2-ring XOR produces a valid smaller ring.
490        for i in 0..n {
491            for j in (i + 1)..n {
492                let shares_ij = rings[i].iter().any(|a| rings[j].contains(a));
493                if !shares_ij {
494                    continue;
495                }
496                let xor_ij = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
497                if xor_ij.is_empty() {
498                    continue;
499                }
500                for k in (j + 1)..n {
501                    let shares_k = rings[k]
502                        .iter()
503                        .any(|a| rings[i].contains(a) || rings[j].contains(a));
504                    if !shares_k {
505                        continue;
506                    }
507                    let xor_ijk = bond_sym_diff(&xor_ij, &bond_sets[k]);
508                    let max_size = rings[i].len().max(rings[j].len()).max(rings[k].len());
509                    if xor_ijk.is_empty() || xor_ijk.len() > max_size {
510                        continue;
511                    }
512                    if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_ijk) {
513                        let mut key = new_ring.clone();
514                        key.sort();
515                        if known.insert(key) {
516                            rings.push(new_ring);
517                            changed = true;
518                        }
519                    }
520                }
521            }
522        }
523
524        if !changed {
525            break;
526        }
527    }
528
529    rings
530}
531
532/// Shared inner: SSSR → augmented_ring_set → strip_envelope_rings, no aromaticity filter.
533fn all_ring_list_inner(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
534    let sssr = crate::sssr::find_sssr(mol);
535    let aug = augmented_ring_set(mol, sssr.rings());
536    if aug.len() <= 1 {
537        return aug;
538    }
539    let bond_sets: Vec<Vec<BondIdx>> = aug.iter().map(|r| ring_bond_set(mol, r)).collect();
540    let mut is_envelope = vec![false; aug.len()];
541    strip_envelope_rings(&aug, &bond_sets, &mut is_envelope);
542    aug.into_iter()
543        .zip(is_envelope)
544        .filter(|(_, e)| !e)
545        .map(|(r, _)| r)
546        .collect()
547}
548
549/// Return all rings after augmented-ring-set expansion and envelope stripping.
550///
551/// Same pipeline as [`aromatic_ring_list`] but with no aromaticity filter — useful
552/// for aliphatic/saturated ring counting and bridgehead detection where SSSR
553/// envelope rings cause over-counting.
554pub fn all_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
555    all_ring_list_inner(mol)
556}
557
558/// True when all ring bonds between ring atoms are `BondOrder::Aromatic`.
559///
560/// Rings written with aromatic-SMILES notation but containing an explicit single
561/// bond (`c-n`, `nc-2`, etc.) are NOT truly aromatic.  RDKit canonicalises such
562/// SMILES with lowercase atoms and a `-` bond, which the parser stores as
563/// `BondOrder::Single` between two aromatic-flagged atoms.  Returning `false`
564/// here lets callers exclude them from the aromatic ring count.
565pub fn ring_bonds_all_aromatic(mol: &Molecule, ring: &[AtomIdx]) -> bool {
566    let n = ring.len();
567    (0..n).all(|i| {
568        let a = ring[i];
569        let b = ring[(i + 1) % n];
570        mol.bond_between(a, b)
571            .map(|(bidx, _)| mol.bond(bidx).order == BondOrder::Aromatic)
572            .unwrap_or(true)
573    })
574}
575
576/// Return the de-duplicated list of aromatic rings after augmented-ring-set expansion
577/// and envelope stripping.  Useful for filtering (e.g. counting only aromatic heterocycles).
578pub fn aromatic_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
579    let mol_with_arom;
580    let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
581        mol
582    } else {
583        mol_with_arom = apply_aromaticity(mol);
584        &mol_with_arom
585    };
586    all_ring_list_inner(mol)
587        .into_iter()
588        .filter(|ring| {
589            ring.iter().all(|&idx| mol.atom(idx).aromatic) && ring_bonds_all_aromatic(mol, ring)
590        })
591        .collect()
592}
593
594/// Mark which rings in `aromatic` are GF(2) sums (bond-XOR) of 2–4 smaller rings.
595fn strip_envelope_rings(
596    aromatic: &[Vec<AtomIdx>],
597    bond_sets: &[Vec<BondIdx>],
598    is_envelope: &mut [bool],
599) {
600    let n = aromatic.len();
601    for i in 0..n {
602        let si = aromatic[i].len();
603        'jk: for j in 0..n {
604            if j == i || aromatic[j].len() >= si {
605                continue;
606            }
607            for k in (j + 1)..n {
608                if k == i || aromatic[k].len() >= si {
609                    continue;
610                }
611                if bond_sym_diff(&bond_sets[j], &bond_sets[k]) == bond_sets[i] {
612                    is_envelope[i] = true;
613                    break 'jk;
614                }
615            }
616        }
617        if !is_envelope[i] {
618            'jkl: for j in 0..n {
619                if j == i || aromatic[j].len() >= si {
620                    continue;
621                }
622                for k in (j + 1)..n {
623                    if k == i || aromatic[k].len() >= si {
624                        continue;
625                    }
626                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
627                    for l in (k + 1)..n {
628                        if l == i || aromatic[l].len() >= si {
629                            continue;
630                        }
631                        if bond_sym_diff(&xor_jk, &bond_sets[l]) == bond_sets[i] {
632                            is_envelope[i] = true;
633                            break 'jkl;
634                        }
635                    }
636                }
637            }
638        }
639        if !is_envelope[i] {
640            'jklm: for j in 0..n {
641                if j == i || aromatic[j].len() >= si {
642                    continue;
643                }
644                for k in (j + 1)..n {
645                    if k == i || aromatic[k].len() >= si {
646                        continue;
647                    }
648                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
649                    for l in (k + 1)..n {
650                        if l == i || aromatic[l].len() >= si {
651                            continue;
652                        }
653                        let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
654                        for m in (l + 1)..n {
655                            if m == i || aromatic[m].len() >= si {
656                                continue;
657                            }
658                            if bond_sym_diff(&xor_jkl, &bond_sets[m]) == bond_sets[i] {
659                                is_envelope[i] = true;
660                                break 'jklm;
661                            }
662                        }
663                    }
664                }
665            }
666        }
667    }
668}
669
670pub fn count_aromatic_rings(mol: &Molecule) -> usize {
671    // For Kekulé-form input (uppercase atoms, no aromatic flags yet), run Hückel
672    // perception first so ring detection works correctly (RDKit #9271).
673    let mol_with_arom;
674    let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
675        mol // aromatic SMILES — flags already set during parsing
676    } else {
677        mol_with_arom = apply_aromaticity(mol);
678        &mol_with_arom
679    };
680
681    let sssr = crate::sssr::find_sssr(mol);
682    let aug = augmented_ring_set(mol, sssr.rings());
683
684    // Keep only rings where every atom carries the aromatic flag.
685    let aromatic: Vec<Vec<AtomIdx>> = aug
686        .into_iter()
687        .filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
688        .collect();
689
690    if aromatic.len() <= 1 {
691        return aromatic.len();
692    }
693
694    // Build sorted bond-index sets for each aromatic ring.
695    let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
696
697    // Mark rings that are the GF(2) sum (bond-XOR) of 2, 3, or 4 strictly
698    // smaller aromatic rings.  Such rings are "envelope" cycles introduced
699    // when the SSSR chose a large fundamental cycle instead of its smaller
700    // GF(2) components.
701    // 2-ring XOR: handles linear/angular fused systems (naphthalene, indolizine…).
702    // 3-ring XOR: handles compact PAHs like pyrene.
703    // 4-ring XOR: handles coronene-class PAHs where the outer perimeter is the
704    //   GF(2) sum of four inner hexagons.
705    let n = aromatic.len();
706    let mut is_envelope = vec![false; n];
707    strip_envelope_rings(&aromatic, &bond_sets, &mut is_envelope);
708    is_envelope.iter().filter(|&&e| !e).count()
709}
710
711// ---------------------------------------------------------------------------
712// Per-ring pi electron count
713// ---------------------------------------------------------------------------
714
715/// Count pi electrons for a ring atom, returning `None` if the atom is
716/// incompatible with aromaticity (e.g. sp3 carbon).
717///
718/// `aromatic_context`: atoms already confirmed aromatic (from Pass 1 or a
719/// previous Pass 2 iteration).  Such atoms contribute 1π unconditionally,
720/// without requiring an explicit double bond.
721///
722/// Rules:
723/// - **C**: `has_double_any` (Double or Aromatic bond anywhere) → 1π, else None.
724///   If already in `aromatic_context` → 1π (confirmed sp2).
725/// - **N**:
726///   1. Has H → 2π (pyrrole-type lone pair).
727///   2. Has an explicit `Double` bond → 1π (pyridine-type).
728///   3. Bridgehead N: total_degree == 3 AND ring_degree < total_degree AND no
729///      explicit double bond → 2π (lone pair in p orbital, like indolizine N).
730///   4. Has in-ring `Aromatic` bond → 1π (pyridine-like aromatic N).
731///   5. Already in `aromatic_context` → 1π.
732///   6. Otherwise → None.
733/// - **O/S**: ring_degree must be 2; contributes 2π (lone pair).
734/// - **Se (34) / Te (52)**: analogous to S; only in [`AromaticityAlgorithm::RdkitLike`] mode.
735/// - **Other elements**: None (unsupported).
736fn ring_pi_electrons(
737    mol: &Molecule,
738    ring: &[AtomIdx],
739    aromatic_context: &FxHashSet<AtomIdx>,
740    algo: AromaticityAlgorithm,
741) -> Option<u32> {
742    let ring_atom_set: FxHashSet<AtomIdx> = ring.iter().copied().collect();
743    let mut total_pi: u32 = 0;
744
745    for &atom_idx in ring {
746        // Atoms already confirmed aromatic in an adjacent ring contribute 1π.
747        if aromatic_context.contains(&atom_idx) {
748            total_pi += 1;
749            continue;
750        }
751
752        let atom = mol.atom(atom_idx);
753        let an = atom.element.atomic_number();
754
755        let ring_degree = mol
756            .neighbors(atom_idx)
757            .filter(|(nb, _)| ring_atom_set.contains(nb))
758            .count();
759
760        let total_degree = mol.degree(atom_idx);
761
762        // Explicit Double bond anywhere (not counting Aromatic).
763        let has_explicit_double = mol
764            .neighbors(atom_idx)
765            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
766
767        // Double OR Aromatic bond anywhere (for C sp2 check).
768        let has_double_any = has_explicit_double
769            || mol
770                .neighbors(atom_idx)
771                .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
772
773        // Aromatic bond within the ring (for pyridine-like N in aromatic SMILES).
774        let has_aromatic_in_ring = mol
775            .neighbors(atom_idx)
776            .filter(|(nb, _)| ring_atom_set.contains(nb))
777            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
778
779        let pi = match an {
780            // Carbon: must be sp2 (has a double or aromatic bond somewhere).
781            6 => {
782                if !has_double_any {
783                    return None; // sp3 carbon — ring cannot be aromatic
784                }
785                1
786            }
787
788            // Nitrogen
789            7 => {
790                if implicit_hcount(mol, atom_idx) > 0 {
791                    // Pyrrole-type N with H: lone pair → 2π.
792                    2
793                } else if has_explicit_double {
794                    // Pyridine-type N with explicit double bond → 1π.
795                    1
796                } else if total_degree == 3 && ring_degree < total_degree {
797                    // Bridgehead N (e.g. indolizine): no H, no explicit double bond,
798                    // and all three σ-bonds exactly fill the N valence (3).
799                    // The lone pair occupies the p orbital → 2π (pyrrole-analogue).
800                    //
801                    // Guard: the exocyclic bond must lead to an sp2/aromatic neighbour
802                    // (another fused ring atom). This prevents imide N (phthalimide)
803                    // from triggering here — phthalimide N's exocyclic bond goes to an
804                    // alkyl chain with no double/aromatic bonds.
805                    let has_sp2_exocyclic = mol
806                        .neighbors(atom_idx)
807                        .filter(|(nb, _)| !ring_atom_set.contains(nb))
808                        .any(|(nb, _)| {
809                            mol.neighbors(nb).any(|(_, b2)| {
810                                matches!(
811                                    mol.bond(b2).order,
812                                    BondOrder::Double | BondOrder::Aromatic
813                                )
814                            })
815                        });
816                    if has_sp2_exocyclic {
817                        2
818                    } else {
819                        return None;
820                    }
821                } else if has_aromatic_in_ring {
822                    // N in an aromatic ring (pre-kekulization input) without an
823                    // explicit double bond and not a bridgehead → pyridine-like → 1π.
824                    1
825                } else {
826                    // Cannot determine pi contribution.
827                    return None;
828                }
829            }
830
831            // Oxygen / sulfur: lone-pair donor, must be 2-connected in the ring.
832            8 | 16 => {
833                if ring_degree != 2 {
834                    return None;
835                }
836                // Sulfoxide/sulfone: exocyclic S=O ties up the lone pair; cannot donate 2π
837                if an == 16
838                    && mol.neighbors(atom_idx).any(|(nb, bidx)| {
839                        !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
840                    })
841                {
842                    return None;
843                }
844                2
845            }
846
847            // Se (34) / Te (52): chalcogen lone-pair donors (2π), analogous to S.
848            // Only recognised in RdkitLike mode.
849            34 | 52 => {
850                if algo != AromaticityAlgorithm::RdkitLike {
851                    return None;
852                }
853                if ring_degree != 2 {
854                    return None;
855                }
856                // Exocyclic Se=O / Te=O ties up the lone pair.
857                if mol.neighbors(atom_idx).any(|(nb, bidx)| {
858                    !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
859                }) {
860                    return None;
861                }
862                2
863            }
864
865            // Unsupported element.
866            _ => return None,
867        };
868
869        total_pi += pi;
870    }
871
872    Some(total_pi)
873}
874
875// ---------------------------------------------------------------------------
876// Tests
877// ---------------------------------------------------------------------------
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
883
884    // =========================================================================
885    // Molecule builder helpers (kekulized, manually constructed)
886    // =========================================================================
887
888    fn benzene_kekule() -> chematic_core::Molecule {
889        let mut b = MoleculeBuilder::new();
890        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
891        for i in 0..6 {
892            let order = if i % 2 == 0 {
893                BondOrder::Double
894            } else {
895                BondOrder::Single
896            };
897            b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
898        }
899        b.build()
900    }
901
902    fn cyclohexane() -> chematic_core::Molecule {
903        let mut b = MoleculeBuilder::new();
904        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
905        for i in 0..6 {
906            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
907                .unwrap();
908        }
909        b.build()
910    }
911
912    fn pyridine_kekule() -> chematic_core::Molecule {
913        let mut b = MoleculeBuilder::new();
914        let n = b.add_atom(Atom::new(Element::N));
915        let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
916        let ring = [
917            n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
918        ];
919        for i in 0..6 {
920            let order = if i % 2 == 0 {
921                BondOrder::Double
922            } else {
923                BondOrder::Single
924            };
925            b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
926        }
927        b.build()
928    }
929
930    fn furan_kekule() -> chematic_core::Molecule {
931        let mut b = MoleculeBuilder::new();
932        let o = b.add_atom(Atom::new(Element::O));
933        let c1 = b.add_atom(Atom::new(Element::C));
934        let c2 = b.add_atom(Atom::new(Element::C));
935        let c3 = b.add_atom(Atom::new(Element::C));
936        let c4 = b.add_atom(Atom::new(Element::C));
937        let ring = [o, c1, c2, c3, c4];
938        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
939        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
940        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
941        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
942        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
943        b.build()
944    }
945
946    fn pyrrole_kekule() -> chematic_core::Molecule {
947        let mut b = MoleculeBuilder::new();
948        let mut n_atom = Atom::new(Element::N);
949        n_atom.hydrogen_count = Some(1);
950        let n = b.add_atom(n_atom);
951        let c1 = b.add_atom(Atom::new(Element::C));
952        let c2 = b.add_atom(Atom::new(Element::C));
953        let c3 = b.add_atom(Atom::new(Element::C));
954        let c4 = b.add_atom(Atom::new(Element::C));
955        let ring = [n, c1, c2, c3, c4];
956        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
957        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
958        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
959        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
960        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
961        b.build()
962    }
963
964    fn naphthalene_kekule() -> chematic_core::Molecule {
965        let mut b = MoleculeBuilder::new();
966        let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
967        let ring1 = [0usize, 1, 2, 3, 4, 9];
968        let orders1 = [
969            BondOrder::Double,
970            BondOrder::Single,
971            BondOrder::Double,
972            BondOrder::Single,
973            BondOrder::Double,
974            BondOrder::Single,
975        ];
976        for i in 0..6 {
977            b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
978                .unwrap();
979        }
980        let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
981        let orders2 = [
982            BondOrder::Single,
983            BondOrder::Double,
984            BondOrder::Single,
985            BondOrder::Double,
986            BondOrder::Single,
987        ];
988        for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
989            b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
990        }
991        b.build()
992    }
993
994    fn cyclobutadiene_kekule() -> chematic_core::Molecule {
995        let mut b = MoleculeBuilder::new();
996        let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
997        for i in 0..4 {
998            let order = if i % 2 == 0 {
999                BondOrder::Double
1000            } else {
1001                BondOrder::Single
1002            };
1003            b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
1004        }
1005        b.build()
1006    }
1007
1008    fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
1009        let mut b = MoleculeBuilder::new();
1010        let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1011        for i in 0..8 {
1012            let order = if i % 2 == 0 {
1013                BondOrder::Double
1014            } else {
1015                BondOrder::Single
1016            };
1017            b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
1018        }
1019        b.build()
1020    }
1021
1022    /// Helper: parse an aromatic SMILES and return the molecule with aromatic bonds
1023    /// (no kekulization).  Use for compounds where kekulization is unsupported.
1024    #[cfg(test)]
1025    fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
1026        chematic_smiles::parse(smiles).expect("valid SMILES")
1027    }
1028
1029    /// Helper: parse SMILES and kekulize.  Panics if kekulization fails.
1030    #[cfg(test)]
1031    fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
1032        let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
1033        let k = chematic_core::kekulize(&mol).expect("kekulizable");
1034        chematic_core::apply_kekule(&mol, &k)
1035    }
1036
1037    // =========================================================================
1038    // Regression: kekulized single-ring aromatics (Pass 1 only, no context)
1039    // =========================================================================
1040
1041    #[test]
1042    fn test_benzene_is_aromatic() {
1043        let mol = benzene_kekule();
1044        let model = assign_aromaticity(&mol);
1045        assert_eq!(
1046            model.aromatic_atom_count(),
1047            6,
1048            "all 6 benzene atoms aromatic"
1049        );
1050        for i in 0..6u32 {
1051            assert!(model.is_atom_aromatic(AtomIdx(i)));
1052        }
1053    }
1054
1055    #[test]
1056    fn test_cyclohexane_not_aromatic() {
1057        let mol = cyclohexane();
1058        let model = assign_aromaticity(&mol);
1059        assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
1060    }
1061
1062    #[test]
1063    fn test_pyridine_is_aromatic() {
1064        let mol = pyridine_kekule();
1065        let model = assign_aromaticity(&mol);
1066        assert_eq!(model.aromatic_atom_count(), 6);
1067    }
1068
1069    #[test]
1070    fn test_furan_is_aromatic() {
1071        let mol = furan_kekule();
1072        let model = assign_aromaticity(&mol);
1073        assert_eq!(model.aromatic_atom_count(), 5);
1074    }
1075
1076    #[test]
1077    fn test_pyrrole_is_aromatic() {
1078        let mol = pyrrole_kekule();
1079        let model = assign_aromaticity(&mol);
1080        assert_eq!(model.aromatic_atom_count(), 5);
1081    }
1082
1083    #[test]
1084    fn test_naphthalene_both_rings_aromatic() {
1085        let mol = naphthalene_kekule();
1086        let model = assign_aromaticity(&mol);
1087        assert_eq!(
1088            model.aromatic_atom_count(),
1089            10,
1090            "all 10 naphthalene atoms aromatic"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_bond_aromaticity_benzene() {
1096        let mol = benzene_kekule();
1097        let model = assign_aromaticity(&mol);
1098        let count = mol
1099            .bonds()
1100            .filter(|(b, _)| model.is_bond_aromatic(*b))
1101            .count();
1102        assert_eq!(count, 6);
1103    }
1104
1105    #[test]
1106    fn test_apply_aromaticity_benzene() {
1107        let mol = benzene_kekule();
1108        let aromatic = apply_aromaticity(&mol);
1109        for (_, atom) in aromatic.atoms() {
1110            assert!(atom.aromatic, "every benzene carbon should be aromatic");
1111        }
1112        let aromatic_bond_count = aromatic
1113            .bonds()
1114            .filter(|(_, b)| b.order == BondOrder::Aromatic)
1115            .count();
1116        assert_eq!(aromatic_bond_count, 6);
1117    }
1118
1119    #[test]
1120    fn test_apply_aromaticity_cyclohexane_unchanged() {
1121        let mol = cyclohexane();
1122        let result = apply_aromaticity(&mol);
1123        for (_, atom) in result.atoms() {
1124            assert!(!atom.aromatic);
1125        }
1126        for (_, bond) in result.bonds() {
1127            assert_ne!(bond.order, BondOrder::Aromatic);
1128        }
1129    }
1130
1131    // =========================================================================
1132    // Antiaromaticity
1133    // =========================================================================
1134
1135    #[test]
1136    fn test_cyclobutadiene_antiaromatic() {
1137        let mol = cyclobutadiene_kekule();
1138        let model = assign_aromaticity(&mol);
1139        assert_eq!(
1140            model.aromatic_atom_count(),
1141            0,
1142            "cyclobutadiene not aromatic"
1143        );
1144        assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
1145        assert_eq!(model.antiaromatic_rings().len(), 1);
1146        let classifications = model.ring_classifications();
1147        assert_eq!(classifications.len(), 1);
1148        assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
1149        assert_eq!(classifications[0].2, 4);
1150    }
1151
1152    #[test]
1153    fn test_cyclooctatetraene_antiaromatic() {
1154        let mol = cyclooctatetraene_kekule();
1155        let model = assign_aromaticity(&mol);
1156        assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
1157        assert!(model.has_antiaromaticity(), "COT antiaromatic");
1158        assert_eq!(model.antiaromatic_rings().len(), 1);
1159        let cls = &model.ring_classifications()[0];
1160        assert_eq!(cls.1, RingAromaticity::Antiaromatic);
1161        assert_eq!(cls.2, 8);
1162    }
1163
1164    // =========================================================================
1165    // Ring classifications
1166    // =========================================================================
1167
1168    #[test]
1169    fn test_ring_classifications_benzene() {
1170        let mol = benzene_kekule();
1171        let model = assign_aromaticity(&mol);
1172        let classifications = model.ring_classifications();
1173        assert_eq!(classifications.len(), 1);
1174        assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
1175        assert_eq!(classifications[0].2, 6);
1176    }
1177
1178    #[test]
1179    fn test_ring_classifications_naphthalene() {
1180        let mol = naphthalene_kekule();
1181        let model = assign_aromaticity(&mol);
1182        let classifications = model.ring_classifications();
1183        assert_eq!(classifications.len(), 2, "naphthalene has two rings");
1184        for (_, classification, count) in classifications {
1185            assert_eq!(*classification, RingAromaticity::Aromatic);
1186            assert_eq!(*count, 6);
1187        }
1188    }
1189
1190    #[test]
1191    fn test_non_aromatic_cyclohexane() {
1192        let mol = cyclohexane();
1193        let model = assign_aromaticity(&mol);
1194        for (_, classification, _) in model.ring_classifications() {
1195            assert_ne!(*classification, RingAromaticity::Aromatic);
1196            assert_ne!(*classification, RingAromaticity::Antiaromatic);
1197        }
1198    }
1199
1200    // =========================================================================
1201    // Electron distribution
1202    // =========================================================================
1203
1204    #[test]
1205    fn test_thiophene_aromatic() {
1206        let mut b = MoleculeBuilder::new();
1207        let s = b.add_atom(Atom::new(Element::S));
1208        let c1 = b.add_atom(Atom::new(Element::C));
1209        let c2 = b.add_atom(Atom::new(Element::C));
1210        let c3 = b.add_atom(Atom::new(Element::C));
1211        let c4 = b.add_atom(Atom::new(Element::C));
1212        let ring = [s, c1, c2, c3, c4];
1213        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1214        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1215        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1216        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1217        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1218        let mol = b.build();
1219        let model = assign_aromaticity(&mol);
1220        assert_eq!(model.aromatic_atom_count(), 5);
1221        assert_eq!(model.ring_classifications()[0].2, 6);
1222    }
1223
1224    #[test]
1225    fn test_electron_distribution_tracking() {
1226        let mol = benzene_kekule();
1227        let model = assign_aromaticity(&mol);
1228        assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
1229
1230        let mol = pyrrole_kekule();
1231        let model = assign_aromaticity(&mol);
1232        assert_eq!(
1233            model.ring_classifications()[0].2,
1234            6,
1235            "pyrrole: N(2π) + 4C(1π) = 6"
1236        );
1237
1238        let mol = furan_kekule();
1239        let model = assign_aromaticity(&mol);
1240        assert_eq!(
1241            model.ring_classifications()[0].2,
1242            6,
1243            "furan: O(2π) + 4C(1π) = 6"
1244        );
1245    }
1246
1247    // =========================================================================
1248    // Aromatic-SMILES input (BondOrder::Aromatic, no kekulization)
1249    // Verifies that assign_aromaticity works on pre-kekulization molecules.
1250    // =========================================================================
1251
1252    #[test]
1253    fn test_benzene_aromatic_smiles() {
1254        // c1ccccc1 — parsed with BondOrder::Aromatic bonds
1255        let mol = mol_aromatic("c1ccccc1");
1256        let model = assign_aromaticity(&mol);
1257        assert_eq!(
1258            model.aromatic_atom_count(),
1259            6,
1260            "benzene from aromatic SMILES"
1261        );
1262    }
1263
1264    #[test]
1265    fn test_naphthalene_aromatic_smiles() {
1266        let mol = mol_aromatic("c1ccc2ccccc2c1");
1267        let model = assign_aromaticity(&mol);
1268        assert_eq!(
1269            model.aromatic_atom_count(),
1270            10,
1271            "naphthalene from aromatic SMILES"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_pyridine_aromatic_smiles() {
1277        let mol = mol_aromatic("c1ccncc1");
1278        let model = assign_aromaticity(&mol);
1279        assert_eq!(
1280            model.aromatic_atom_count(),
1281            6,
1282            "pyridine from aromatic SMILES"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_furan_aromatic_smiles() {
1288        let mol = mol_aromatic("c1ccoc1");
1289        let model = assign_aromaticity(&mol);
1290        assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
1291    }
1292
1293    #[test]
1294    fn test_pyrrole_aromatic_smiles() {
1295        // [nH] bracket atom: hydrogen_count = Some(1)
1296        let mol = mol_aromatic("c1cc[nH]c1");
1297        let model = assign_aromaticity(&mol);
1298        assert_eq!(
1299            model.aromatic_atom_count(),
1300            5,
1301            "pyrrole from aromatic SMILES"
1302        );
1303    }
1304
1305    #[test]
1306    fn test_thiophene_aromatic_smiles() {
1307        let mol = mol_aromatic("c1ccsc1");
1308        let model = assign_aromaticity(&mol);
1309        assert_eq!(
1310            model.aromatic_atom_count(),
1311            5,
1312            "thiophene from aromatic SMILES"
1313        );
1314    }
1315
1316    // =========================================================================
1317    // Fused-ring kekulized systems (Pass 2 propagation)
1318    // =========================================================================
1319
1320    #[test]
1321    fn test_indole_aromatic() {
1322        // c1ccc2[nH]ccc2c1 — indole (9 atoms, 5-ring + 6-ring fused)
1323        let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
1324        let model = assign_aromaticity(&mol);
1325        assert_eq!(
1326            model.aromatic_atom_count(),
1327            9,
1328            "all 9 indole atoms aromatic"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_benzimidazole_aromatic() {
1334        // Two N atoms in fused 5+6 ring system
1335        let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
1336        let model = assign_aromaticity(&mol);
1337        assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
1338    }
1339
1340    #[test]
1341    fn test_quinoline_aromatic() {
1342        let mol = mol_kekulized("c1ccc2ncccc2c1");
1343        let model = assign_aromaticity(&mol);
1344        assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
1345    }
1346
1347    #[test]
1348    fn test_acridine_aromatic() {
1349        // 3 fused 6-membered rings, central N: 13 atoms
1350        let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
1351        let model = assign_aromaticity(&mol);
1352        // acridine is C13H9N → 14 heavy atoms (13 C + 1 N), all aromatic
1353        assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
1354    }
1355
1356    // =========================================================================
1357    // Fused-ring aromatic-SMILES input (BondOrder::Aromatic, kekulize fails)
1358    // =========================================================================
1359
1360    #[test]
1361    fn test_indolizine_aromatic() {
1362        // c1ccn2cccc2c1 — indolizine: bridgehead N, kekulization unsupported.
1363        // The SSSR finds a 6-ring and a 9-ring; the 5-ring is recovered via
1364        // augmentation (XOR of 6- and 9-ring).
1365        // Pass 1: 5-ring (augmented) detected via bridgehead-N rule → 6π.
1366        // Pass 2: 6-ring detected using N already aromatic from 5-ring → 6π.
1367        // The 9-ring (SSSR artifact) is NonAromatic (9π ≠ 4n+2), but all
1368        // 9 atoms are correctly flagged aromatic via the 5- and 6-ring.
1369        let mol = mol_aromatic("c1ccn2cccc2c1");
1370        let model = assign_aromaticity(&mol);
1371        assert_eq!(
1372            model.aromatic_atom_count(),
1373            9,
1374            "all 9 indolizine atoms aromatic"
1375        );
1376        // At least the 6-ring should be classified as Aromatic in the SSSR set.
1377        let has_aromatic_ring = model
1378            .ring_classifications()
1379            .iter()
1380            .any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
1381        assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
1382    }
1383
1384    #[test]
1385    fn test_purine_aromatic() {
1386        // c1cnc2[nH]cnc2n1 — purine: 9 atoms, kekulizable
1387        let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
1388        let model = assign_aromaticity(&mol);
1389        assert_eq!(
1390            model.aromatic_atom_count(),
1391            9,
1392            "all 9 purine atoms aromatic"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_purine_aromatic_from_aromatic_smiles() {
1398        let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
1399        let model = assign_aromaticity(&mol);
1400        assert_eq!(
1401            model.aromatic_atom_count(),
1402            9,
1403            "purine from aromatic SMILES"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_2_pyridinone_aromatic() {
1409        // O=c1ccncc1 — 2-pyridinone (aromatic SMILES, N without H, exo C=O).
1410        // Kekulization fails; tested on the aromatic-bond form directly.
1411        // The exo C=O gives the C atom has_double_any=true → 1π.
1412        // N has Aromatic bonds in ring → 1π (pyridine-like).
1413        // Total: 6 × 1π = 6π → aromatic.
1414        let mol = mol_aromatic("O=c1ccncc1");
1415        let model = assign_aromaticity(&mol);
1416        assert_eq!(
1417            model.aromatic_atom_count(),
1418            6,
1419            "all 6 ring atoms of 2-pyridinone aromatic"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_quinolone_aromatic() {
1425        // O=c1ccc2ncccc2c1 — quinolone: fused 6+6 with exo C=O, kekulize fails
1426        let mol = mol_aromatic("O=c1ccc2ncccc2c1");
1427        let model = assign_aromaticity(&mol);
1428        assert_eq!(
1429            model.aromatic_atom_count(),
1430            10,
1431            "all 10 quinolone ring atoms aromatic"
1432        );
1433        assert_eq!(
1434            model.ring_classifications().len(),
1435            2,
1436            "two rings classified"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_indole_aromatic_smiles() {
1442        let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
1443        let model = assign_aromaticity(&mol);
1444        assert_eq!(
1445            model.aromatic_atom_count(),
1446            9,
1447            "indole from aromatic SMILES"
1448        );
1449    }
1450
1451    // =========================================================================
1452    // Bridgehead N rule: specifically test that the rule fires correctly
1453    // =========================================================================
1454
1455    #[test]
1456    fn test_bridgehead_n_contributes_lone_pair() {
1457        // Indolizine: the bridgehead N (degree 3, no H, no explicit double bond)
1458        // must be detected as a 2π contributor for the 5-membered ring.
1459        // We verify by checking the 5-ring classification (if accessible).
1460        let mol = mol_aromatic("c1ccn2cccc2c1");
1461        let model = assign_aromaticity(&mol);
1462        // All 9 atoms aromatic: both rings must be aromatic.
1463        assert_eq!(model.aromatic_atom_count(), 9);
1464        // The bridgehead N itself must be in the aromatic set.
1465        // In the SMILES c1ccn2cccc2c1, n is atom index 3.
1466        assert!(
1467            model.is_atom_aromatic(AtomIdx(3)),
1468            "bridgehead N must be aromatic"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_non_bridgehead_n_no_false_positive() {
1474        // Pyrimidine: two N atoms in a 6-membered ring, no bridgehead.
1475        // Both N have ring_degree == total_degree == 2.
1476        // Should be detected as aromatic via has_aromatic_in_ring (Aromatic bonds).
1477        let mol = mol_aromatic("c1ccncn1");
1478        let model = assign_aromaticity(&mol);
1479        assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
1480    }
1481
1482    #[test]
1483    fn test_imidazole_aromatic() {
1484        // c1cn[nH]c1 / c1c[nH]cn1 — imidazole: one pyridine-type N, one pyrrole-type N
1485        let mol = mol_aromatic("c1cn[nH]c1");
1486        let model = assign_aromaticity(&mol);
1487        assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
1488    }
1489
1490    // =========================================================================
1491    // Pass 2 specifically: rings that need fused-ring context
1492    // =========================================================================
1493
1494    #[test]
1495    fn test_pass2_needed_for_indolizine_6ring() {
1496        // The augmented 5-ring (XOR of SSSR 6-ring and 9-ring) is detected aromatic in Pass 1.
1497        // The SSSR 6-ring is then detected aromatic in Pass 2 (N already aromatic → 1π).
1498        // The SSSR 9-ring (9π) remains NonAromatic per Hückel.
1499        // Key assertion: all 9 atoms are aromatic (correct overall perception).
1500        let mol = mol_aromatic("c1ccn2cccc2c1");
1501        let model = assign_aromaticity(&mol);
1502        assert_eq!(
1503            model.aromatic_atom_count(),
1504            9,
1505            "all 9 indolizine atoms aromatic"
1506        );
1507        // The bridgehead N must be aromatic.
1508        assert!(
1509            model.is_atom_aromatic(AtomIdx(3)),
1510            "bridgehead N is aromatic"
1511        );
1512        // The 6-ring (SSSR ring, improved by Pass 2) should be classified Aromatic.
1513        let aromatic_count = model
1514            .ring_classifications()
1515            .iter()
1516            .filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
1517            .count();
1518        assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
1519    }
1520
1521    #[test]
1522    fn test_no_pass2_needed_for_naphthalene() {
1523        // Naphthalene: both rings pass independently in Pass 1.
1524        // Verifies Pass 2 doesn't break things that already work.
1525        let mol = naphthalene_kekule();
1526        let model = assign_aromaticity(&mol);
1527        assert_eq!(model.aromatic_atom_count(), 10);
1528        let classes = model.ring_classifications();
1529        assert_eq!(classes.len(), 2);
1530        for (_, cls, _) in classes {
1531            assert_eq!(*cls, RingAromaticity::Aromatic);
1532        }
1533    }
1534
1535    #[test]
1536    fn test_anthracene_aromatic() {
1537        // c1ccc2cc3ccccc3cc2c1 — anthracene: 3 linearly fused 6-rings, 14 atoms
1538        let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
1539        let model = assign_aromaticity(&mol);
1540        assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
1541    }
1542
1543    // =========================================================================
1544    // Regression: aromatic-bond path must not perturb kekulized correctness
1545    // =========================================================================
1546
1547    #[test]
1548    fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
1549        // Kekulized benzene: bonds are Double/Single, not Aromatic.
1550        // The new Aromatic-bond branches must stay dormant.
1551        let mol = benzene_kekule();
1552        // Verify no aromatic bonds in input.
1553        for (_, bond) in mol.bonds() {
1554            assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
1555        }
1556        let model = assign_aromaticity(&mol);
1557        assert_eq!(model.aromatic_atom_count(), 6);
1558        // All 6 bonds in benzene ring should be aromatic.
1559        let aromatic_bonds = mol
1560            .bonds()
1561            .filter(|(b, _)| model.is_bond_aromatic(*b))
1562            .count();
1563        assert_eq!(aromatic_bonds, 6);
1564    }
1565
1566    #[test]
1567    fn test_keto_pyridinone_not_huckel_aromatic() {
1568        // O=C1NC=CC=C1 — 2-pyridinone keto form with N-H.
1569        // π count: C(=O)(1π) + N-H(2π) + 3×C(1π each) + C6(1π) = 7π → not 4n+2.
1570        // This is a known scope boundary: keto pyridinone has partial aromatic
1571        // character by resonance but is NOT Hückel 4n+2 aromatic.
1572        // RDKit classifies it aromatic using an extended model; our strict Hückel
1573        // implementation correctly returns 0 aromatic atoms.
1574        let mol = mol_kekulized("O=C1NC=CC=C1");
1575        let model = assign_aromaticity(&mol);
1576        assert_eq!(
1577            model.aromatic_atom_count(),
1578            0,
1579            "keto pyridinone is not Hückel aromatic (7π ≠ 4n+2)"
1580        );
1581    }
1582
1583    // ── RDKit #9271: charged / zwitterionic aromatic systems ─────────────────
1584
1585    #[test]
1586    fn test_fluorescein_dianion_aromatic() {
1587        // Fluorescein dianion: RDKit #9271 incorrectly marked xanthene bonds as
1588        // single instead of aromatic. Verify chematic parses and identifies
1589        // aromatic atoms correctly (two benzene rings + xanthene O-bridge ring).
1590        // Kekulé-form SMILES: all atoms uppercase.
1591        let smi = "C1=CC=C(C(=C1)C2=C3C=CC(=O)C=C3OC4=C2C=CC(=C4)[O-])C(=O)[O-]";
1592        let mol = chematic_smiles::parse(smi).expect("fluorescein dianion should parse");
1593        // The molecule should parse without panic. Verify aromatic ring count:
1594        // fluorescein has 3 aromatic rings (2 benzene + xanthene core).
1595        let arc = count_aromatic_rings(&mol);
1596        assert!(
1597            arc >= 2,
1598            "fluorescein dianion: expected ≥2 aromatic rings, got {arc} \
1599             (RDKit #9271: charged aromatics may be misclassified)"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_rhodamine_zwitterion_parses() {
1605        // Rhodamine-type zwitterion with N+ and bridging O (RDKit #9271).
1606        // Must parse cleanly and produce a valid aromatic ring count.
1607        let smi = "CCN(CC)c1ccc2c(-c3ccccc3C(=O)O)c3ccc(=[N+](CC)CC)cc-3oc2c1";
1608        let mol = chematic_smiles::parse(smi).expect("rhodamine zwitterion should parse");
1609        let arc = count_aromatic_rings(&mol);
1610        assert!(arc >= 3, "rhodamine: expected ≥3 aromatic rings, got {arc}");
1611    }
1612
1613    #[test]
1614    fn test_cyclopentadienyl_not_aromatic_kekulized() {
1615        // C1=CC=CC1 — cyclopentadiene (4 C with doubles + 1 sp3 CH2): not aromatic.
1616        let mut b = MoleculeBuilder::new();
1617        let c0 = b.add_atom(Atom::new(Element::C)); // sp3
1618        let c1 = b.add_atom(Atom::new(Element::C));
1619        let c2 = b.add_atom(Atom::new(Element::C));
1620        let c3 = b.add_atom(Atom::new(Element::C));
1621        let c4 = b.add_atom(Atom::new(Element::C));
1622        b.add_bond(c0, c1, BondOrder::Single).unwrap();
1623        b.add_bond(c1, c2, BondOrder::Double).unwrap();
1624        b.add_bond(c2, c3, BondOrder::Single).unwrap();
1625        b.add_bond(c3, c4, BondOrder::Double).unwrap();
1626        b.add_bond(c4, c0, BondOrder::Single).unwrap();
1627        let mol = b.build();
1628        let model = assign_aromaticity(&mol);
1629        assert_eq!(
1630            model.aromatic_atom_count(),
1631            0,
1632            "cyclopentadiene not aromatic"
1633        );
1634    }
1635
1636    // =========================================================================
1637    // RdkitLike mode: Se/Te chalcogen heteroaromatics
1638    // =========================================================================
1639
1640    #[test]
1641    fn test_selenophene_huckel_not_aromatic() {
1642        // c1cc[se]c1 — in strict Hückel mode, Se is unsupported → 0 aromatic atoms
1643        // (assign_aromaticity_ex re-derives from scratch, ignoring parser's aromatic flags)
1644        let mol = mol_aromatic("c1cc[se]c1");
1645        let m = assign_aromaticity(&mol); // default Hückel
1646        assert_eq!(
1647            m.aromatic_atom_count(),
1648            0,
1649            "selenophene: Se not aromatic in Hückel mode"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_selenophene_rdkit_aromatic() {
1655        // c1cc[se]c1 — in RdkitLike mode, Se donates 2π → 6π total → aromatic
1656        let mol = mol_aromatic("c1cc[se]c1");
1657        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1658        assert_eq!(
1659            m.aromatic_atom_count(),
1660            5,
1661            "selenophene: all 5 atoms aromatic in RdkitLike"
1662        );
1663    }
1664
1665    #[test]
1666    fn test_tellurophene_rdkit_aromatic() {
1667        // c1cc[te]c1 — Te analogous to Se (2π donor)
1668        let mol = mol_aromatic("c1cc[te]c1");
1669        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1670        assert_eq!(
1671            m.aromatic_atom_count(),
1672            5,
1673            "tellurophene: all 5 atoms aromatic in RdkitLike"
1674        );
1675    }
1676
1677    #[test]
1678    fn test_benzoselenophene_rdkit() {
1679        // Fused benzene + selenophene
1680        let mol = mol_aromatic("c1ccc2[se]ccc2c1");
1681        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1682        assert_eq!(
1683            m.aromatic_atom_count(),
1684            9,
1685            "benzoselenophene: 9 atoms aromatic"
1686        );
1687    }
1688
1689    #[test]
1690    fn test_rdkit_mode_does_not_break_benzene() {
1691        // Benzene must give same result in both modes
1692        let mol = mol_aromatic("c1ccccc1");
1693        let m_h = assign_aromaticity(&mol);
1694        let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1695        assert_eq!(m_h.aromatic_atom_count(), m_r.aromatic_atom_count());
1696    }
1697
1698    #[test]
1699    fn test_rdkit_mode_does_not_break_thiophene() {
1700        let mol = mol_aromatic("c1ccsc1");
1701        let m_h = assign_aromaticity(&mol);
1702        let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
1703        assert_eq!(
1704            m_h.aromatic_atom_count(),
1705            m_r.aromatic_atom_count(),
1706            "thiophene same in both modes"
1707        );
1708    }
1709}