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