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 std::collections::HashSet;
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: HashSet<AtomIdx>,
54    aromatic_bonds: HashSet<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 HashSet<AtomIdx>,
115    aromatic_bonds: &mut HashSet<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: HashSet<AtomIdx> = HashSet::new();
149    let mut aromatic_bonds: HashSet<BondIdx> = HashSet::new();
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 = HashSet::new();
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: std::collections::HashMap<AtomIdx, [Option<AtomIdx>; 2]> =
321        std::collections::HashMap::new();
322    for &bidx in bonds {
323        let bond = mol.bond(bidx);
324        for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
325            let e = adj.entry(a).or_insert([None; 2]);
326            if e[0].is_none() {
327                e[0] = Some(b);
328            } else if e[1].is_none() {
329                e[1] = Some(b);
330            } else {
331                return None; // degree > 2 — not a simple ring
332            }
333        }
334    }
335    // All atoms must have exactly 2 neighbours.
336    if adj.values().any(|e| e[1].is_none()) {
337        return None;
338    }
339    let start = *adj.keys().next()?;
340    let mut path = vec![start];
341    let mut prev = start;
342    let mut current = adj[&start][0]?;
343    while current != start {
344        path.push(current);
345        let [n0, n1] = adj[&current];
346        let next = if n0 == Some(prev) { n1? } else { n0? };
347        prev = current;
348        current = next;
349    }
350    if path.len() != bonds.len() {
351        return None;
352    }
353    Some(path)
354}
355
356/// Augment the SSSR ring list with smaller XOR sub-rings found by pairwise GF(2)
357/// differences between SSSR rings that share atoms.
358///
359/// The standard SSSR algorithm sometimes stores a large fundamental cycle rather
360/// than its smaller GF(2)-reduced equivalent (e.g. the 5-ring of indolizine is
361/// the XOR of the 6-ring and the 9-ring the algorithm reports).
362/// This augmentation adds such missing smaller rings so that aromaticity
363/// perception works on the correct smallest rings without modifying the SSSR.
364///
365/// The returned `Vec` starts with all SSSR rings in their original order; any
366/// additional sub-rings derived by GF(2) pairwise XOR follow.  The function
367/// only adds a ring if it is strictly smaller than *both* parents, ensuring
368/// that envelope rings (e.g. the 10-membered perimeter of naphthalene) are
369/// never introduced.
370pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
371    let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
372
373    // Track which atom-sets we already have (as sorted atom lists).
374    let mut known: std::collections::HashSet<Vec<AtomIdx>> = sssr_rings
375        .iter()
376        .map(|r| {
377            let mut s = r.clone();
378            s.sort();
379            s
380        })
381        .collect();
382
383    // Iterative pairwise XOR until convergence.
384    //
385    // A single pass only finds rings that are the XOR of two SSSR rings.
386    // Iterating also finds rings that require XOR of 3+ SSSR rings
387    // (e.g. the inner hexagon of coronene, or sub-rings in multi-step
388    // fused PAHs where the SSSR chose large perimeter cycles).
389    // Termination is guaranteed because each new ring is strictly smaller
390    // than both of its parents, so ring size can only decrease.
391    loop {
392        let mut changed = false;
393        let n = rings.len();
394        let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
395
396        for i in 0..n {
397            for j in (i + 1)..n {
398                // Only consider pairs that share atoms (fused rings).
399                let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
400                if !shares_atom {
401                    continue;
402                }
403                let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
404                if xor_bonds.is_empty() {
405                    continue;
406                }
407                // Only interesting if the XOR ring is strictly smaller than the
408                // larger parent. Using max() instead of min() recovers missing
409                // rings when the SSSR chose a large cycle over a same-size one
410                // (e.g. SSSR returns a 10-bond macro ring instead of the 6-bond
411                // benzene twin; the missing benzene equals the XOR of the
412                // 6-bond lactone and the 10-bond macro, and is not strictly
413                // smaller than the lactone but IS strictly smaller than the macro).
414                if xor_bonds.len() >= rings[i].len().max(rings[j].len()) {
415                    continue;
416                }
417                if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
418                    let mut key = new_ring.clone();
419                    key.sort();
420                    if known.insert(key) {
421                        rings.push(new_ring);
422                        changed = true;
423                    }
424                }
425            }
426        }
427
428        if !changed {
429            break;
430        }
431    }
432
433    rings
434}
435
436/// Count aromatic rings in `mol`.
437///
438/// Uses the augmented ring set so that fused systems where the SSSR stores a
439/// large fundamental cycle (e.g. a 9-ring for indolizine) still report the
440/// correct small-ring count.  After building the augmented set, any "envelope"
441/// ring — one that equals the bond-symmetric-difference of two smaller aromatic
442/// rings — is excluded, preventing double-counting.
443pub fn count_aromatic_rings(mol: &Molecule) -> usize {
444    let sssr = crate::sssr::find_sssr(mol);
445    let aug = augmented_ring_set(mol, sssr.rings());
446
447    // Keep only rings where every atom carries the aromatic flag.
448    let aromatic: Vec<Vec<AtomIdx>> = aug
449        .into_iter()
450        .filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
451        .collect();
452
453    if aromatic.len() <= 1 {
454        return aromatic.len();
455    }
456
457    // Build sorted bond-index sets for each aromatic ring.
458    let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
459
460    // Mark rings that are the GF(2) sum (bond-XOR) of 2, 3, or 4 strictly
461    // smaller aromatic rings.  Such rings are "envelope" cycles introduced
462    // when the SSSR chose a large fundamental cycle instead of its smaller
463    // GF(2) components.
464    // 2-ring XOR: handles linear/angular fused systems (naphthalene, indolizine…).
465    // 3-ring XOR: handles compact PAHs like pyrene.
466    // 4-ring XOR: handles coronene-class PAHs where the outer perimeter is the
467    //   GF(2) sum of four inner hexagons.
468    let n = aromatic.len();
469    let mut is_envelope = vec![false; n];
470    for i in 0..n {
471        let si = aromatic[i].len();
472
473        // Check pair XOR first (most common case, O(n²)).
474        'jk: for j in 0..n {
475            if j == i || aromatic[j].len() >= si {
476                continue;
477            }
478            for k in (j + 1)..n {
479                if k == i || aromatic[k].len() >= si {
480                    continue;
481                }
482                let xor = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
483                if xor == bond_sets[i] {
484                    is_envelope[i] = true;
485                    break 'jk;
486                }
487            }
488        }
489
490        // If not resolved by pair XOR, try triple XOR (O(n³)).
491        if !is_envelope[i] {
492            'jkl: for j in 0..n {
493                if j == i || aromatic[j].len() >= si {
494                    continue;
495                }
496                for k in (j + 1)..n {
497                    if k == i || aromatic[k].len() >= si {
498                        continue;
499                    }
500                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
501                    for l in (k + 1)..n {
502                        if l == i || aromatic[l].len() >= si {
503                            continue;
504                        }
505                        let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
506                        if xor_jkl == bond_sets[i] {
507                            is_envelope[i] = true;
508                            break 'jkl;
509                        }
510                    }
511                }
512            }
513        }
514
515        // If still not resolved, try quadruple XOR (O(n⁴)).
516        // Handles coronene-class PAHs where the perimeter is the GF(2) sum of
517        // four inner hexagons.
518        if !is_envelope[i] {
519            'jklm: for j in 0..n {
520                if j == i || aromatic[j].len() >= si { continue; }
521                for k in (j + 1)..n {
522                    if k == i || aromatic[k].len() >= si { continue; }
523                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
524                    for l in (k + 1)..n {
525                        if l == i || aromatic[l].len() >= si { continue; }
526                        let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
527                        for m in (l + 1)..n {
528                            if m == i || aromatic[m].len() >= si { continue; }
529                            let xor_jklm = bond_sym_diff(&xor_jkl, &bond_sets[m]);
530                            if xor_jklm == bond_sets[i] {
531                                is_envelope[i] = true;
532                                break 'jklm;
533                            }
534                        }
535                    }
536                }
537            }
538        }
539    }
540
541    is_envelope.iter().filter(|&&e| !e).count()
542}
543
544// ---------------------------------------------------------------------------
545// Per-ring pi electron count
546// ---------------------------------------------------------------------------
547
548/// Count pi electrons for a ring atom, returning `None` if the atom is
549/// incompatible with aromaticity (e.g. sp3 carbon).
550///
551/// `aromatic_context`: atoms already confirmed aromatic (from Pass 1 or a
552/// previous Pass 2 iteration).  Such atoms contribute 1π unconditionally,
553/// without requiring an explicit double bond.
554///
555/// Rules:
556/// - **C**: `has_double_any` (Double or Aromatic bond anywhere) → 1π, else None.
557///   If already in `aromatic_context` → 1π (confirmed sp2).
558/// - **N**:
559///   1. Has H → 2π (pyrrole-type lone pair).
560///   2. Has an explicit `Double` bond → 1π (pyridine-type).
561///   3. Bridgehead N: total_degree == 3 AND ring_degree < total_degree AND no
562///      explicit double bond → 2π (lone pair in p orbital, like indolizine N).
563///   4. Has in-ring `Aromatic` bond → 1π (pyridine-like aromatic N).
564///   5. Already in `aromatic_context` → 1π.
565///   6. Otherwise → None.
566/// - **O/S**: ring_degree must be 2; contributes 2π (lone pair).
567/// - **Other elements**: None (unsupported).
568fn ring_pi_electrons(
569    mol: &Molecule,
570    ring: &[AtomIdx],
571    aromatic_context: &HashSet<AtomIdx>,
572) -> Option<u32> {
573    let ring_atom_set: HashSet<AtomIdx> = ring.iter().copied().collect();
574    let mut total_pi: u32 = 0;
575
576    for &atom_idx in ring {
577        // Atoms already confirmed aromatic in an adjacent ring contribute 1π.
578        if aromatic_context.contains(&atom_idx) {
579            total_pi += 1;
580            continue;
581        }
582
583        let atom = mol.atom(atom_idx);
584        let an = atom.element.atomic_number();
585
586        let ring_degree = mol
587            .neighbors(atom_idx)
588            .filter(|(nb, _)| ring_atom_set.contains(nb))
589            .count();
590
591        let total_degree = mol.degree(atom_idx);
592
593        // Explicit Double bond anywhere (not counting Aromatic).
594        let has_explicit_double = mol
595            .neighbors(atom_idx)
596            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
597
598        // Double OR Aromatic bond anywhere (for C sp2 check).
599        let has_double_any = has_explicit_double
600            || mol
601                .neighbors(atom_idx)
602                .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
603
604        // Aromatic bond within the ring (for pyridine-like N in aromatic SMILES).
605        let has_aromatic_in_ring = mol
606            .neighbors(atom_idx)
607            .filter(|(nb, _)| ring_atom_set.contains(nb))
608            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
609
610        let pi = match an {
611            // Carbon: must be sp2 (has a double or aromatic bond somewhere).
612            6 => {
613                if !has_double_any {
614                    return None; // sp3 carbon — ring cannot be aromatic
615                }
616                1
617            }
618
619            // Nitrogen
620            7 => {
621                if implicit_hcount(mol, atom_idx) > 0 {
622                    // Pyrrole-type N with H: lone pair → 2π.
623                    2
624                } else if has_explicit_double {
625                    // Pyridine-type N with explicit double bond → 1π.
626                    1
627                } else if total_degree == 3 && ring_degree < total_degree {
628                    // Bridgehead N (e.g. indolizine): no H, no explicit double bond,
629                    // and all three σ-bonds exactly fill the N valence (3).
630                    // The lone pair occupies the p orbital → 2π (pyrrole-analogue).
631                    2
632                } else if has_aromatic_in_ring {
633                    // N in an aromatic ring (pre-kekulization input) without an
634                    // explicit double bond and not a bridgehead → pyridine-like → 1π.
635                    1
636                } else {
637                    // Cannot determine pi contribution.
638                    return None;
639                }
640            }
641
642            // Oxygen / sulfur: lone-pair donor, must be 2-connected in the ring.
643            8 | 16 => {
644                if ring_degree != 2 {
645                    return None;
646                }
647                2
648            }
649
650            // Unsupported element.
651            _ => return None,
652        };
653
654        total_pi += pi;
655    }
656
657    Some(total_pi)
658}
659
660// ---------------------------------------------------------------------------
661// Tests
662// ---------------------------------------------------------------------------
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
668
669    // =========================================================================
670    // Molecule builder helpers (kekulized, manually constructed)
671    // =========================================================================
672
673    fn benzene_kekule() -> chematic_core::Molecule {
674        let mut b = MoleculeBuilder::new();
675        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
676        for i in 0..6 {
677            let order = if i % 2 == 0 {
678                BondOrder::Double
679            } else {
680                BondOrder::Single
681            };
682            b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
683        }
684        b.build()
685    }
686
687    fn cyclohexane() -> chematic_core::Molecule {
688        let mut b = MoleculeBuilder::new();
689        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
690        for i in 0..6 {
691            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
692                .unwrap();
693        }
694        b.build()
695    }
696
697    fn pyridine_kekule() -> chematic_core::Molecule {
698        let mut b = MoleculeBuilder::new();
699        let n = b.add_atom(Atom::new(Element::N));
700        let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
701        let ring = [
702            n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
703        ];
704        for i in 0..6 {
705            let order = if i % 2 == 0 {
706                BondOrder::Double
707            } else {
708                BondOrder::Single
709            };
710            b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
711        }
712        b.build()
713    }
714
715    fn furan_kekule() -> chematic_core::Molecule {
716        let mut b = MoleculeBuilder::new();
717        let o = b.add_atom(Atom::new(Element::O));
718        let c1 = b.add_atom(Atom::new(Element::C));
719        let c2 = b.add_atom(Atom::new(Element::C));
720        let c3 = b.add_atom(Atom::new(Element::C));
721        let c4 = b.add_atom(Atom::new(Element::C));
722        let ring = [o, c1, c2, c3, c4];
723        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
724        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
725        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
726        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
727        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
728        b.build()
729    }
730
731    fn pyrrole_kekule() -> chematic_core::Molecule {
732        let mut b = MoleculeBuilder::new();
733        let mut n_atom = Atom::new(Element::N);
734        n_atom.hydrogen_count = Some(1);
735        let n = b.add_atom(n_atom);
736        let c1 = b.add_atom(Atom::new(Element::C));
737        let c2 = b.add_atom(Atom::new(Element::C));
738        let c3 = b.add_atom(Atom::new(Element::C));
739        let c4 = b.add_atom(Atom::new(Element::C));
740        let ring = [n, c1, c2, c3, c4];
741        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
742        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
743        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
744        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
745        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
746        b.build()
747    }
748
749    fn naphthalene_kekule() -> chematic_core::Molecule {
750        let mut b = MoleculeBuilder::new();
751        let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
752        let ring1 = [0usize, 1, 2, 3, 4, 9];
753        let orders1 = [
754            BondOrder::Double,
755            BondOrder::Single,
756            BondOrder::Double,
757            BondOrder::Single,
758            BondOrder::Double,
759            BondOrder::Single,
760        ];
761        for i in 0..6 {
762            b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
763                .unwrap();
764        }
765        let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
766        let orders2 = [
767            BondOrder::Single,
768            BondOrder::Double,
769            BondOrder::Single,
770            BondOrder::Double,
771            BondOrder::Single,
772        ];
773        for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
774            b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
775        }
776        b.build()
777    }
778
779    fn cyclobutadiene_kekule() -> chematic_core::Molecule {
780        let mut b = MoleculeBuilder::new();
781        let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
782        for i in 0..4 {
783            let order = if i % 2 == 0 {
784                BondOrder::Double
785            } else {
786                BondOrder::Single
787            };
788            b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
789        }
790        b.build()
791    }
792
793    fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
794        let mut b = MoleculeBuilder::new();
795        let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
796        for i in 0..8 {
797            let order = if i % 2 == 0 {
798                BondOrder::Double
799            } else {
800                BondOrder::Single
801            };
802            b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
803        }
804        b.build()
805    }
806
807    /// Helper: parse an aromatic SMILES and return the molecule with aromatic bonds
808    /// (no kekulization).  Use for compounds where kekulization is unsupported.
809    #[cfg(test)]
810    fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
811        chematic_smiles::parse(smiles).expect("valid SMILES")
812    }
813
814    /// Helper: parse SMILES and kekulize.  Panics if kekulization fails.
815    #[cfg(test)]
816    fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
817        let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
818        let k = chematic_core::kekulize(&mol).expect("kekulizable");
819        chematic_core::apply_kekule(&mol, &k)
820    }
821
822    // =========================================================================
823    // Regression: kekulized single-ring aromatics (Pass 1 only, no context)
824    // =========================================================================
825
826    #[test]
827    fn test_benzene_is_aromatic() {
828        let mol = benzene_kekule();
829        let model = assign_aromaticity(&mol);
830        assert_eq!(
831            model.aromatic_atom_count(),
832            6,
833            "all 6 benzene atoms aromatic"
834        );
835        for i in 0..6u32 {
836            assert!(model.is_atom_aromatic(AtomIdx(i)));
837        }
838    }
839
840    #[test]
841    fn test_cyclohexane_not_aromatic() {
842        let mol = cyclohexane();
843        let model = assign_aromaticity(&mol);
844        assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
845    }
846
847    #[test]
848    fn test_pyridine_is_aromatic() {
849        let mol = pyridine_kekule();
850        let model = assign_aromaticity(&mol);
851        assert_eq!(model.aromatic_atom_count(), 6);
852    }
853
854    #[test]
855    fn test_furan_is_aromatic() {
856        let mol = furan_kekule();
857        let model = assign_aromaticity(&mol);
858        assert_eq!(model.aromatic_atom_count(), 5);
859    }
860
861    #[test]
862    fn test_pyrrole_is_aromatic() {
863        let mol = pyrrole_kekule();
864        let model = assign_aromaticity(&mol);
865        assert_eq!(model.aromatic_atom_count(), 5);
866    }
867
868    #[test]
869    fn test_naphthalene_both_rings_aromatic() {
870        let mol = naphthalene_kekule();
871        let model = assign_aromaticity(&mol);
872        assert_eq!(
873            model.aromatic_atom_count(),
874            10,
875            "all 10 naphthalene atoms aromatic"
876        );
877    }
878
879    #[test]
880    fn test_bond_aromaticity_benzene() {
881        let mol = benzene_kekule();
882        let model = assign_aromaticity(&mol);
883        let count = mol
884            .bonds()
885            .filter(|(b, _)| model.is_bond_aromatic(*b))
886            .count();
887        assert_eq!(count, 6);
888    }
889
890    #[test]
891    fn test_apply_aromaticity_benzene() {
892        let mol = benzene_kekule();
893        let aromatic = apply_aromaticity(&mol);
894        for (_, atom) in aromatic.atoms() {
895            assert!(atom.aromatic, "every benzene carbon should be aromatic");
896        }
897        let aromatic_bond_count = aromatic
898            .bonds()
899            .filter(|(_, b)| b.order == BondOrder::Aromatic)
900            .count();
901        assert_eq!(aromatic_bond_count, 6);
902    }
903
904    #[test]
905    fn test_apply_aromaticity_cyclohexane_unchanged() {
906        let mol = cyclohexane();
907        let result = apply_aromaticity(&mol);
908        for (_, atom) in result.atoms() {
909            assert!(!atom.aromatic);
910        }
911        for (_, bond) in result.bonds() {
912            assert_ne!(bond.order, BondOrder::Aromatic);
913        }
914    }
915
916    // =========================================================================
917    // Antiaromaticity
918    // =========================================================================
919
920    #[test]
921    fn test_cyclobutadiene_antiaromatic() {
922        let mol = cyclobutadiene_kekule();
923        let model = assign_aromaticity(&mol);
924        assert_eq!(
925            model.aromatic_atom_count(),
926            0,
927            "cyclobutadiene not aromatic"
928        );
929        assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
930        assert_eq!(model.antiaromatic_rings().len(), 1);
931        let classifications = model.ring_classifications();
932        assert_eq!(classifications.len(), 1);
933        assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
934        assert_eq!(classifications[0].2, 4);
935    }
936
937    #[test]
938    fn test_cyclooctatetraene_antiaromatic() {
939        let mol = cyclooctatetraene_kekule();
940        let model = assign_aromaticity(&mol);
941        assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
942        assert!(model.has_antiaromaticity(), "COT antiaromatic");
943        assert_eq!(model.antiaromatic_rings().len(), 1);
944        let cls = &model.ring_classifications()[0];
945        assert_eq!(cls.1, RingAromaticity::Antiaromatic);
946        assert_eq!(cls.2, 8);
947    }
948
949    // =========================================================================
950    // Ring classifications
951    // =========================================================================
952
953    #[test]
954    fn test_ring_classifications_benzene() {
955        let mol = benzene_kekule();
956        let model = assign_aromaticity(&mol);
957        let classifications = model.ring_classifications();
958        assert_eq!(classifications.len(), 1);
959        assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
960        assert_eq!(classifications[0].2, 6);
961    }
962
963    #[test]
964    fn test_ring_classifications_naphthalene() {
965        let mol = naphthalene_kekule();
966        let model = assign_aromaticity(&mol);
967        let classifications = model.ring_classifications();
968        assert_eq!(classifications.len(), 2, "naphthalene has two rings");
969        for (_, classification, count) in classifications {
970            assert_eq!(*classification, RingAromaticity::Aromatic);
971            assert_eq!(*count, 6);
972        }
973    }
974
975    #[test]
976    fn test_non_aromatic_cyclohexane() {
977        let mol = cyclohexane();
978        let model = assign_aromaticity(&mol);
979        for (_, classification, _) in model.ring_classifications() {
980            assert_ne!(*classification, RingAromaticity::Aromatic);
981            assert_ne!(*classification, RingAromaticity::Antiaromatic);
982        }
983    }
984
985    // =========================================================================
986    // Electron distribution
987    // =========================================================================
988
989    #[test]
990    fn test_thiophene_aromatic() {
991        let mut b = MoleculeBuilder::new();
992        let s = b.add_atom(Atom::new(Element::S));
993        let c1 = b.add_atom(Atom::new(Element::C));
994        let c2 = b.add_atom(Atom::new(Element::C));
995        let c3 = b.add_atom(Atom::new(Element::C));
996        let c4 = b.add_atom(Atom::new(Element::C));
997        let ring = [s, c1, c2, c3, c4];
998        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
999        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1000        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1001        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1002        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1003        let mol = b.build();
1004        let model = assign_aromaticity(&mol);
1005        assert_eq!(model.aromatic_atom_count(), 5);
1006        assert_eq!(model.ring_classifications()[0].2, 6);
1007    }
1008
1009    #[test]
1010    fn test_electron_distribution_tracking() {
1011        let mol = benzene_kekule();
1012        let model = assign_aromaticity(&mol);
1013        assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
1014
1015        let mol = pyrrole_kekule();
1016        let model = assign_aromaticity(&mol);
1017        assert_eq!(
1018            model.ring_classifications()[0].2,
1019            6,
1020            "pyrrole: N(2π) + 4C(1π) = 6"
1021        );
1022
1023        let mol = furan_kekule();
1024        let model = assign_aromaticity(&mol);
1025        assert_eq!(
1026            model.ring_classifications()[0].2,
1027            6,
1028            "furan: O(2π) + 4C(1π) = 6"
1029        );
1030    }
1031
1032    // =========================================================================
1033    // Aromatic-SMILES input (BondOrder::Aromatic, no kekulization)
1034    // Verifies that assign_aromaticity works on pre-kekulization molecules.
1035    // =========================================================================
1036
1037    #[test]
1038    fn test_benzene_aromatic_smiles() {
1039        // c1ccccc1 — parsed with BondOrder::Aromatic bonds
1040        let mol = mol_aromatic("c1ccccc1");
1041        let model = assign_aromaticity(&mol);
1042        assert_eq!(
1043            model.aromatic_atom_count(),
1044            6,
1045            "benzene from aromatic SMILES"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_naphthalene_aromatic_smiles() {
1051        let mol = mol_aromatic("c1ccc2ccccc2c1");
1052        let model = assign_aromaticity(&mol);
1053        assert_eq!(
1054            model.aromatic_atom_count(),
1055            10,
1056            "naphthalene from aromatic SMILES"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_pyridine_aromatic_smiles() {
1062        let mol = mol_aromatic("c1ccncc1");
1063        let model = assign_aromaticity(&mol);
1064        assert_eq!(
1065            model.aromatic_atom_count(),
1066            6,
1067            "pyridine from aromatic SMILES"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_furan_aromatic_smiles() {
1073        let mol = mol_aromatic("c1ccoc1");
1074        let model = assign_aromaticity(&mol);
1075        assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
1076    }
1077
1078    #[test]
1079    fn test_pyrrole_aromatic_smiles() {
1080        // [nH] bracket atom: hydrogen_count = Some(1)
1081        let mol = mol_aromatic("c1cc[nH]c1");
1082        let model = assign_aromaticity(&mol);
1083        assert_eq!(
1084            model.aromatic_atom_count(),
1085            5,
1086            "pyrrole from aromatic SMILES"
1087        );
1088    }
1089
1090    #[test]
1091    fn test_thiophene_aromatic_smiles() {
1092        let mol = mol_aromatic("c1ccsc1");
1093        let model = assign_aromaticity(&mol);
1094        assert_eq!(
1095            model.aromatic_atom_count(),
1096            5,
1097            "thiophene from aromatic SMILES"
1098        );
1099    }
1100
1101    // =========================================================================
1102    // Fused-ring kekulized systems (Pass 2 propagation)
1103    // =========================================================================
1104
1105    #[test]
1106    fn test_indole_aromatic() {
1107        // c1ccc2[nH]ccc2c1 — indole (9 atoms, 5-ring + 6-ring fused)
1108        let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
1109        let model = assign_aromaticity(&mol);
1110        assert_eq!(
1111            model.aromatic_atom_count(),
1112            9,
1113            "all 9 indole atoms aromatic"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_benzimidazole_aromatic() {
1119        // Two N atoms in fused 5+6 ring system
1120        let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
1121        let model = assign_aromaticity(&mol);
1122        assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
1123    }
1124
1125    #[test]
1126    fn test_quinoline_aromatic() {
1127        let mol = mol_kekulized("c1ccc2ncccc2c1");
1128        let model = assign_aromaticity(&mol);
1129        assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
1130    }
1131
1132    #[test]
1133    fn test_acridine_aromatic() {
1134        // 3 fused 6-membered rings, central N: 13 atoms
1135        let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
1136        let model = assign_aromaticity(&mol);
1137        // acridine is C13H9N → 14 heavy atoms (13 C + 1 N), all aromatic
1138        assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
1139    }
1140
1141    // =========================================================================
1142    // Fused-ring aromatic-SMILES input (BondOrder::Aromatic, kekulize fails)
1143    // =========================================================================
1144
1145    #[test]
1146    fn test_indolizine_aromatic() {
1147        // c1ccn2cccc2c1 — indolizine: bridgehead N, kekulization unsupported.
1148        // The SSSR finds a 6-ring and a 9-ring; the 5-ring is recovered via
1149        // augmentation (XOR of 6- and 9-ring).
1150        // Pass 1: 5-ring (augmented) detected via bridgehead-N rule → 6π.
1151        // Pass 2: 6-ring detected using N already aromatic from 5-ring → 6π.
1152        // The 9-ring (SSSR artifact) is NonAromatic (9π ≠ 4n+2), but all
1153        // 9 atoms are correctly flagged aromatic via the 5- and 6-ring.
1154        let mol = mol_aromatic("c1ccn2cccc2c1");
1155        let model = assign_aromaticity(&mol);
1156        assert_eq!(
1157            model.aromatic_atom_count(),
1158            9,
1159            "all 9 indolizine atoms aromatic"
1160        );
1161        // At least the 6-ring should be classified as Aromatic in the SSSR set.
1162        let has_aromatic_ring = model
1163            .ring_classifications()
1164            .iter()
1165            .any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
1166        assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
1167    }
1168
1169    #[test]
1170    fn test_purine_aromatic() {
1171        // c1cnc2[nH]cnc2n1 — purine: 9 atoms, kekulizable
1172        let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
1173        let model = assign_aromaticity(&mol);
1174        assert_eq!(
1175            model.aromatic_atom_count(),
1176            9,
1177            "all 9 purine atoms aromatic"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_purine_aromatic_from_aromatic_smiles() {
1183        let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
1184        let model = assign_aromaticity(&mol);
1185        assert_eq!(
1186            model.aromatic_atom_count(),
1187            9,
1188            "purine from aromatic SMILES"
1189        );
1190    }
1191
1192    #[test]
1193    fn test_2_pyridinone_aromatic() {
1194        // O=c1ccncc1 — 2-pyridinone (aromatic SMILES, N without H, exo C=O).
1195        // Kekulization fails; tested on the aromatic-bond form directly.
1196        // The exo C=O gives the C atom has_double_any=true → 1π.
1197        // N has Aromatic bonds in ring → 1π (pyridine-like).
1198        // Total: 6 × 1π = 6π → aromatic.
1199        let mol = mol_aromatic("O=c1ccncc1");
1200        let model = assign_aromaticity(&mol);
1201        assert_eq!(
1202            model.aromatic_atom_count(),
1203            6,
1204            "all 6 ring atoms of 2-pyridinone aromatic"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_quinolone_aromatic() {
1210        // O=c1ccc2ncccc2c1 — quinolone: fused 6+6 with exo C=O, kekulize fails
1211        let mol = mol_aromatic("O=c1ccc2ncccc2c1");
1212        let model = assign_aromaticity(&mol);
1213        assert_eq!(
1214            model.aromatic_atom_count(),
1215            10,
1216            "all 10 quinolone ring atoms aromatic"
1217        );
1218        assert_eq!(
1219            model.ring_classifications().len(),
1220            2,
1221            "two rings classified"
1222        );
1223    }
1224
1225    #[test]
1226    fn test_indole_aromatic_smiles() {
1227        let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
1228        let model = assign_aromaticity(&mol);
1229        assert_eq!(
1230            model.aromatic_atom_count(),
1231            9,
1232            "indole from aromatic SMILES"
1233        );
1234    }
1235
1236    // =========================================================================
1237    // Bridgehead N rule: specifically test that the rule fires correctly
1238    // =========================================================================
1239
1240    #[test]
1241    fn test_bridgehead_n_contributes_lone_pair() {
1242        // Indolizine: the bridgehead N (degree 3, no H, no explicit double bond)
1243        // must be detected as a 2π contributor for the 5-membered ring.
1244        // We verify by checking the 5-ring classification (if accessible).
1245        let mol = mol_aromatic("c1ccn2cccc2c1");
1246        let model = assign_aromaticity(&mol);
1247        // All 9 atoms aromatic: both rings must be aromatic.
1248        assert_eq!(model.aromatic_atom_count(), 9);
1249        // The bridgehead N itself must be in the aromatic set.
1250        // In the SMILES c1ccn2cccc2c1, n is atom index 3.
1251        assert!(
1252            model.is_atom_aromatic(AtomIdx(3)),
1253            "bridgehead N must be aromatic"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_non_bridgehead_n_no_false_positive() {
1259        // Pyrimidine: two N atoms in a 6-membered ring, no bridgehead.
1260        // Both N have ring_degree == total_degree == 2.
1261        // Should be detected as aromatic via has_aromatic_in_ring (Aromatic bonds).
1262        let mol = mol_aromatic("c1ccncn1");
1263        let model = assign_aromaticity(&mol);
1264        assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
1265    }
1266
1267    #[test]
1268    fn test_imidazole_aromatic() {
1269        // c1cn[nH]c1 / c1c[nH]cn1 — imidazole: one pyridine-type N, one pyrrole-type N
1270        let mol = mol_aromatic("c1cn[nH]c1");
1271        let model = assign_aromaticity(&mol);
1272        assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
1273    }
1274
1275    // =========================================================================
1276    // Pass 2 specifically: rings that need fused-ring context
1277    // =========================================================================
1278
1279    #[test]
1280    fn test_pass2_needed_for_indolizine_6ring() {
1281        // The augmented 5-ring (XOR of SSSR 6-ring and 9-ring) is detected aromatic in Pass 1.
1282        // The SSSR 6-ring is then detected aromatic in Pass 2 (N already aromatic → 1π).
1283        // The SSSR 9-ring (9π) remains NonAromatic per Hückel.
1284        // Key assertion: all 9 atoms are aromatic (correct overall perception).
1285        let mol = mol_aromatic("c1ccn2cccc2c1");
1286        let model = assign_aromaticity(&mol);
1287        assert_eq!(
1288            model.aromatic_atom_count(),
1289            9,
1290            "all 9 indolizine atoms aromatic"
1291        );
1292        // The bridgehead N must be aromatic.
1293        assert!(
1294            model.is_atom_aromatic(AtomIdx(3)),
1295            "bridgehead N is aromatic"
1296        );
1297        // The 6-ring (SSSR ring, improved by Pass 2) should be classified Aromatic.
1298        let aromatic_count = model
1299            .ring_classifications()
1300            .iter()
1301            .filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
1302            .count();
1303        assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
1304    }
1305
1306    #[test]
1307    fn test_no_pass2_needed_for_naphthalene() {
1308        // Naphthalene: both rings pass independently in Pass 1.
1309        // Verifies Pass 2 doesn't break things that already work.
1310        let mol = naphthalene_kekule();
1311        let model = assign_aromaticity(&mol);
1312        assert_eq!(model.aromatic_atom_count(), 10);
1313        let classes = model.ring_classifications();
1314        assert_eq!(classes.len(), 2);
1315        for (_, cls, _) in classes {
1316            assert_eq!(*cls, RingAromaticity::Aromatic);
1317        }
1318    }
1319
1320    #[test]
1321    fn test_anthracene_aromatic() {
1322        // c1ccc2cc3ccccc3cc2c1 — anthracene: 3 linearly fused 6-rings, 14 atoms
1323        let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
1324        let model = assign_aromaticity(&mol);
1325        assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
1326    }
1327
1328    // =========================================================================
1329    // Regression: aromatic-bond path must not perturb kekulized correctness
1330    // =========================================================================
1331
1332    #[test]
1333    fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
1334        // Kekulized benzene: bonds are Double/Single, not Aromatic.
1335        // The new Aromatic-bond branches must stay dormant.
1336        let mol = benzene_kekule();
1337        // Verify no aromatic bonds in input.
1338        for (_, bond) in mol.bonds() {
1339            assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
1340        }
1341        let model = assign_aromaticity(&mol);
1342        assert_eq!(model.aromatic_atom_count(), 6);
1343        // All 6 bonds in benzene ring should be aromatic.
1344        let aromatic_bonds = mol
1345            .bonds()
1346            .filter(|(b, _)| model.is_bond_aromatic(*b))
1347            .count();
1348        assert_eq!(aromatic_bonds, 6);
1349    }
1350
1351    #[test]
1352    fn test_keto_pyridinone_not_huckel_aromatic() {
1353        // O=C1NC=CC=C1 — 2-pyridinone keto form with N-H.
1354        // π count: C(=O)(1π) + N-H(2π) + 3×C(1π each) + C6(1π) = 7π → not 4n+2.
1355        // This is a known scope boundary: keto pyridinone has partial aromatic
1356        // character by resonance but is NOT Hückel 4n+2 aromatic.
1357        // RDKit classifies it aromatic using an extended model; our strict Hückel
1358        // implementation correctly returns 0 aromatic atoms.
1359        let mol = mol_kekulized("O=C1NC=CC=C1");
1360        let model = assign_aromaticity(&mol);
1361        assert_eq!(
1362            model.aromatic_atom_count(),
1363            0,
1364            "keto pyridinone is not Hückel aromatic (7π ≠ 4n+2)"
1365        );
1366    }
1367
1368    #[test]
1369    fn test_cyclopentadienyl_not_aromatic_kekulized() {
1370        // C1=CC=CC1 — cyclopentadiene (4 C with doubles + 1 sp3 CH2): not aromatic.
1371        let mut b = MoleculeBuilder::new();
1372        let c0 = b.add_atom(Atom::new(Element::C)); // sp3
1373        let c1 = b.add_atom(Atom::new(Element::C));
1374        let c2 = b.add_atom(Atom::new(Element::C));
1375        let c3 = b.add_atom(Atom::new(Element::C));
1376        let c4 = b.add_atom(Atom::new(Element::C));
1377        b.add_bond(c0, c1, BondOrder::Single).unwrap();
1378        b.add_bond(c1, c2, BondOrder::Double).unwrap();
1379        b.add_bond(c2, c3, BondOrder::Single).unwrap();
1380        b.add_bond(c3, c4, BondOrder::Double).unwrap();
1381        b.add_bond(c4, c0, BondOrder::Single).unwrap();
1382        let mol = b.build();
1383        let model = assign_aromaticity(&mol);
1384        assert_eq!(
1385            model.aromatic_atom_count(),
1386            0,
1387            "cyclopentadiene not aromatic"
1388        );
1389    }
1390}