Skip to main content

chematic_perception/
aromaticity.rs

1//! Hückel aromaticity perception with antiaromaticity detection.
2//!
3//! Works on kekulized molecules (no `Aromatic` bond orders) **or** on molecules
4//! that retain `Aromatic` bond orders from the SMILES parser (pre-kekulization).
5//! Call `kekulize` + `apply_kekule` from `chematic-core` before calling
6//! `assign_aromaticity` if you need the explicit double-bond form.
7//!
8//! Algorithm:
9//! 1. Find all SSSR rings via `find_sssr`.
10//! 2. **Pass 1**: evaluate each ring independently using Hückel electron counting.
11//!    Aromatic (`BondOrder::Aromatic`) bonds are treated equivalently to double bonds
12//!    so that pre-kekulization input is handled correctly.
13//!    A special "bridgehead N" rule covers fused-ring N atoms whose entire valence
14//!    is satisfied by single σ-bonds (like indolizine's junction nitrogen).
15//! 3. **Pass 2**: iterative propagation. Rings that were `NonAromatic` or
16//!    indeterminate in Pass 1 are re-evaluated using the already-aromatic atom set
17//!    as context: confirmed-aromatic atoms contribute 1π unconditionally, allowing
18//!    fused rings to be recognised bottom-up (e.g. the 6-ring of indolizine).
19//! 4. Classify rings by electron count:
20//!    - 4n+2 electrons (n >= 0): aromatic (favorable)
21//!    - 4n electrons (n > 0): antiaromatic (unfavorable, strongly disfavored)
22//!    - Other: non-aromatic
23//! 5. Record all aromatic atoms, bonds, and antiaromatic rings in an `AromaticityModel`.
24
25// ---------------------------------------------------------------------------
26// Algorithm selector
27// ---------------------------------------------------------------------------
28
29/// Algorithm used to classify ring aromaticity.
30///
31/// Passed to [`assign_aromaticity_ex`] and [`apply_aromaticity_ex`].
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum AromaticityAlgorithm {
34    /// Strict Hückel 4n+2 rule (default). Supports C, N, O, S.
35    #[default]
36    Huckel,
37    /// RDKit-compatible extension. Adds Se (34) and Te (52) as chalcogen lone-pair
38    /// donors (2π), matching the RDKit DEFAULT aromaticity model for common
39    /// organic and chalcogen heteroaromatics.
40    ///
41    /// P-containing aromatic rings are NOT supported in this mode (separate sprint).
42    /// Keto-lactam aromaticity is NOT included (TautomerMode, separate sprint).
43    RdkitLike,
44}
45
46use rustc_hash::{FxHashMap, FxHashSet};
47
48use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, implicit_hcount};
49
50use crate::ring_family::RingFamily;
51use crate::sssr::find_sssr;
52
53// ---------------------------------------------------------------------------
54// Public types
55// ---------------------------------------------------------------------------
56
57/// Ring aromaticity classification.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum RingAromaticity {
60    /// 4n+2 electrons: aromatic (favorable)
61    Aromatic,
62    /// 4n electrons (n > 0): antiaromatic (unfavorable)
63    Antiaromatic,
64    /// Any other electron count: non-aromatic
65    NonAromatic,
66}
67
68/// Aromaticity assignment for a molecule.
69///
70/// Records which atoms and bonds belong to aromatic rings according to
71/// the Hückel 4n+2 rule applied to SSSR rings (with fused-ring propagation).
72/// Also tracks antiaromatic rings (4n electrons) for chemical accuracy.
73#[derive(Debug, Clone)]
74pub struct AromaticityModel {
75    aromatic_atoms: FxHashSet<AtomIdx>,
76    aromatic_bonds: FxHashSet<BondIdx>,
77    antiaromatic_rings: Vec<Vec<AtomIdx>>,
78    ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)>,
79}
80
81impl AromaticityModel {
82    /// Whether atom `idx` is part of an aromatic ring.
83    pub fn is_atom_aromatic(&self, idx: AtomIdx) -> bool {
84        self.aromatic_atoms.contains(&idx)
85    }
86
87    /// Whether bond `idx` is part of an aromatic ring.
88    pub fn is_bond_aromatic(&self, idx: BondIdx) -> bool {
89        self.aromatic_bonds.contains(&idx)
90    }
91
92    /// Total number of atoms flagged as aromatic.
93    pub fn aromatic_atom_count(&self) -> usize {
94        self.aromatic_atoms.len()
95    }
96
97    /// Get all rings and their classification with electron counts.
98    ///
99    /// Each entry is `(ring_atoms, classification, π_electron_count)`.
100    /// Rings that could not be evaluated (sp3 atoms, unsupported elements) are omitted.
101    pub fn ring_classifications(&self) -> &[(Vec<AtomIdx>, RingAromaticity, u32)] {
102        &self.ring_classifications
103    }
104
105    /// Get all antiaromatic rings (4n electrons, n > 0).
106    pub fn antiaromatic_rings(&self) -> &[Vec<AtomIdx>] {
107        &self.antiaromatic_rings
108    }
109
110    /// Check if any atom belongs to an antiaromatic ring.
111    pub fn has_antiaromaticity(&self) -> bool {
112        !self.antiaromatic_rings.is_empty()
113    }
114
115    /// Build a model directly from an aromatic atom/bond set, with no ring
116    /// classification or antiaromaticity data.
117    ///
118    /// Used by engines (e.g. `rdkit_parity`'s experimental production API)
119    /// that determine an aromatic atom/bond set directly rather than via
120    /// this module's own per-ring Hückel passes -- `ring_classifications()`
121    /// and `antiaromatic_rings()` are empty on the result.
122    pub(crate) fn from_atom_bond_sets(
123        aromatic_atoms: FxHashSet<AtomIdx>,
124        aromatic_bonds: FxHashSet<BondIdx>,
125    ) -> Self {
126        AromaticityModel {
127            aromatic_atoms,
128            aromatic_bonds,
129            antiaromatic_rings: Vec::new(),
130            ring_classifications: Vec::new(),
131        }
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Main entry points
137// ---------------------------------------------------------------------------
138
139/// Classify a ring by its pi electron count using Hückel and antiaromaticity rules.
140#[allow(clippy::manual_is_multiple_of)]
141fn classify_ring_aromaticity(pi_electrons: u32) -> (RingAromaticity, u32) {
142    if pi_electrons >= 2 && (pi_electrons - 2) % 4 == 0 {
143        (RingAromaticity::Aromatic, pi_electrons)
144    } else if pi_electrons > 0 && pi_electrons % 4 == 0 {
145        (RingAromaticity::Antiaromatic, pi_electrons)
146    } else {
147        (RingAromaticity::NonAromatic, pi_electrons)
148    }
149}
150
151/// Mark all atoms and bonds in `ring` as aromatic in the provided sets.
152fn mark_ring_aromatic(
153    mol: &Molecule,
154    ring: &[AtomIdx],
155    aromatic_atoms: &mut FxHashSet<AtomIdx>,
156    aromatic_bonds: &mut FxHashSet<BondIdx>,
157) {
158    for &atom in ring {
159        aromatic_atoms.insert(atom);
160    }
161    for i in 0..ring.len() {
162        let a = ring[i];
163        let b = ring[(i + 1) % ring.len()];
164        if let Some((bidx, _)) = mol.bond_between(a, b) {
165            aromatic_bonds.insert(bidx);
166        }
167    }
168}
169
170/// Assign aromaticity to a molecule using the Hückel 4n+2 rule with fused-ring
171/// propagation (Pass 2) and antiaromaticity detection (4n electrons).
172///
173/// The molecule may be kekulized (`Single`/`Double` bonds) **or** may retain
174/// `BondOrder::Aromatic` bonds from the SMILES parser.  In the latter case,
175/// aromatic bonds are treated as equivalent to double bonds for electron
176/// counting, allowing correct detection without an explicit kekulization step.
177///
178/// For kekulized input from aromatic SMILES, call `chematic_core::kekulize`
179/// then `chematic_core::apply_kekule` first.
180///
181/// Uses [`AromaticityAlgorithm::Huckel`] (default). See [`assign_aromaticity_ex`]
182/// for the RdkitLike variant.
183pub fn assign_aromaticity(mol: &Molecule) -> AromaticityModel {
184    assign_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
185}
186
187/// Assign aromaticity using the specified algorithm.
188///
189/// The default ([`assign_aromaticity`]) uses [`AromaticityAlgorithm::Huckel`].
190/// Pass [`AromaticityAlgorithm::RdkitLike`] to additionally recognise Se/Te
191/// as lone-pair donors in aromatic rings.
192pub fn assign_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> AromaticityModel {
193    let ring_set = find_sssr(mol);
194    let sssr_rings = ring_set.rings();
195
196    // Augment SSSR rings with smaller XOR sub-rings (GF(2) differences between pairs).
197    // This corrects the case where the SSSR algorithm stores a large fundamental cycle
198    // instead of its smaller GF(2)-reduced equivalent (e.g. the 5-ring of indolizine).
199    let rings: Vec<Vec<AtomIdx>> = augmented_ring_set(mol, sssr_rings);
200
201    let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
202    let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
203    let mut antiaromatic_rings: Vec<Vec<AtomIdx>> = Vec::new();
204
205    // Per-ring classification: None means "not yet evaluated / indeterminate".
206    let mut classifications: Vec<Option<(RingAromaticity, u32)>> = vec![None; rings.len()];
207
208    // Indices of rings that are candidates for Pass 2 re-evaluation
209    // (returned None or NonAromatic in Pass 1).
210    let mut pass2_candidates: Vec<usize> = Vec::new();
211
212    // ----- Pass 1: independent Hückel per ring -----
213    let empty_context = FxHashSet::default();
214    for (ring_idx, ring) in rings.iter().enumerate() {
215        match ring_pi_electrons(mol, ring, &empty_context, algo) {
216            Some(pi) => {
217                let (cls, count) = classify_ring_aromaticity(pi);
218                classifications[ring_idx] = Some((cls, count));
219                match cls {
220                    RingAromaticity::Aromatic => {
221                        mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
222                    }
223                    RingAromaticity::Antiaromatic => {
224                        antiaromatic_rings.push(ring.to_vec());
225                        // Antiaromatic is definitive — do not retry in Pass 2.
226                    }
227                    RingAromaticity::NonAromatic => {
228                        pass2_candidates.push(ring_idx);
229                    }
230                }
231            }
232            None => {
233                // Indeterminate (sp3 atoms, unsupported elements, etc.).
234                pass2_candidates.push(ring_idx);
235            }
236        }
237    }
238
239    // ----- Pass 2: propagate through fused ring systems -----
240    // Re-evaluate rings adjacent to already-aromatic rings.  Repeat until
241    // convergence (no newly aromatic ring found in the last iteration).
242    loop {
243        let mut any_new = false;
244        let mut still_pending: Vec<usize> = Vec::new();
245
246        for ring_idx in pass2_candidates {
247            let ring = &rings[ring_idx];
248            // Only rings that share an atom with an already-aromatic ring qualify.
249            if !ring.iter().any(|a| aromatic_atoms.contains(a)) {
250                still_pending.push(ring_idx);
251                continue;
252            }
253            match ring_pi_electrons(mol, ring, &aromatic_atoms, algo) {
254                Some(pi) => {
255                    let (cls, count) = classify_ring_aromaticity(pi);
256                    classifications[ring_idx] = Some((cls, count));
257                    if matches!(cls, RingAromaticity::Aromatic) {
258                        mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
259                        any_new = true;
260                    }
261                    // NonAromatic even in Pass 2 context: do not retry further.
262                }
263                None => {
264                    still_pending.push(ring_idx);
265                }
266            }
267        }
268
269        pass2_candidates = still_pending;
270        if !any_new {
271            break;
272        }
273    }
274
275    // Build the public ring_classifications list (SSSR rings only, omitting augmented/indeterminate).
276    let ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)> = rings
277        .iter()
278        .take(sssr_rings.len()) // only expose SSSR rings in the public API
279        .enumerate()
280        .filter_map(|(i, ring)| classifications[i].map(|(cls, count)| (ring.to_vec(), cls, count)))
281        .collect();
282
283    AromaticityModel {
284        aromatic_atoms,
285        aromatic_bonds,
286        antiaromatic_rings,
287        ring_classifications,
288    }
289}
290
291/// Apply aromaticity perception to a molecule.
292///
293/// Returns a new [`Molecule`] where atoms in Hückel-aromatic rings have
294/// `atom.aromatic = true` and their bonds carry [`BondOrder::Aromatic`].
295/// Non-aromatic atoms and bonds are unchanged.
296///
297/// The input may be kekulized (no `Aromatic` bond orders) or may retain
298/// aromatic bond orders from the SMILES parser.
299///
300/// Uses [`AromaticityAlgorithm::Huckel`] (default). See [`apply_aromaticity_ex`]
301/// for the RdkitLike variant.
302pub fn apply_aromaticity(mol: &Molecule) -> Molecule {
303    apply_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
304}
305
306/// Apply aromaticity using the specified algorithm.
307///
308/// Returns a new [`Molecule`] with aromatic flags set according to `algo`.
309pub fn apply_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> Molecule {
310    let model = assign_aromaticity_ex(mol, algo);
311    build_molecule_from_model(mol, &model)
312}
313
314/// Build a new [`Molecule`] from `mol` with atom/bond aromaticity flags set
315/// according to an already-computed `model`.
316///
317/// Shared by [`apply_aromaticity_ex`] and the `rdkit_parity` experimental
318/// production API (`apply_aromaticity_rdkit_parity_experimental`) so both
319/// get the same implicit-H preservation, bond-direction stashing, and
320/// stereo-metadata copying -- this is the same "Kekule-then-perceive"
321/// normalization either caller needs, not something specific to one
322/// algorithm.
323pub(crate) fn build_molecule_from_model(mol: &Molecule, model: &AromaticityModel) -> Molecule {
324    use chematic_core::{BondOrder, MoleculeBuilder, implicit_hcount};
325
326    // Implicit-H counts computed BEFORE bond orders are normalized below, for
327    // organic-subset atoms without an explicit bracket H count. Needed because
328    // normalizing every aromatic-model bond to `BondOrder::Aromatic` (below)
329    // discards the Kekule Single/Double pattern that distinguishes a
330    // lone-pair-donating "pyrrole-type" heteroatom (2 ring single bonds pre-
331    // normalization, needs 1 implicit H) from a "pyridine-type" one (1 ring
332    // single + 1 ring double, needs 0) -- post-normalization both look
333    // identical (aromatic, 2 aromatic-order ring bonds, no substituent), so
334    // `implicit_hcount`'s aromatic-path heuristic (correct for SMILES that
335    // was aromatic-written from the start, per OpenSMILES convention: bare
336    // aromatic `n` is pyridine-type, pyrrole-type is always `[nH]`) silently
337    // returns the wrong value for atoms that reach this function via
338    // Kekule-then-perceive instead. This under-counts molecular weight and
339    // formula, not just fingerprints/canonical SMILES.
340    let pre_h: Vec<Option<u8>> = mol
341        .atoms()
342        .map(|(idx, atom)| {
343            if atom.hydrogen_count.is_some() {
344                None // already explicit; nothing to preserve
345            } else {
346                Some(implicit_hcount(mol, idx))
347            }
348        })
349        .collect();
350
351    let mut builder = MoleculeBuilder::new();
352    for (idx, atom) in mol.atoms() {
353        let mut a = atom.clone();
354        if model.is_atom_aromatic(idx) {
355            a.aromatic = true;
356        }
357        builder.add_atom(a);
358    }
359    for (bidx, bond) in mol.bonds() {
360        let order = if model.is_bond_aromatic(bidx) {
361            BondOrder::Aromatic
362        } else {
363            bond.order
364        };
365        if let Ok(new_bidx) = builder.add_bond(bond.atom1, bond.atom2, order)
366            && order == BondOrder::Aromatic
367            && matches!(bond.order, BondOrder::Up | BondOrder::Down)
368        {
369            // Kekule input promoted to Aromatic here loses its E/Z direction
370            // the same way the SMILES parser's aromatic-aromatic coercion
371            // does — stash it so an exocyclic double bond anchored on this
372            // ring bond still round-trips through the canonical writer.
373            builder.set_bond_direction(new_bidx, bond.order);
374        }
375    }
376    // Atoms/bonds above are re-added in `mol`'s own enumeration order with
377    // none skipped, so indices line up 1:1 — safe to copy side-channel
378    // metadata wholesale. (This rebuild previously dropped stereo_groups and
379    // stereo_neighbor_order silently; closing that here too.)
380    builder.copy_stereo_groups_from(mol);
381    builder.copy_stereo_from(mol);
382    builder.copy_bond_directions_from(mol);
383    let normalized = builder.build();
384
385    // Compare the pre-normalization implicit H against what the same
386    // (already-tested, unmodified) `implicit_hcount` computes on the
387    // normalized bonds; only atoms where normalization actually changed the
388    // answer get an explicit H frozen in. Benzene CH and pyridine-type N
389    // (heuristic already agrees) are left untouched -- no spurious bracket
390    // notation for atoms that didn't need it.
391    let needs_patch: Vec<(chematic_core::AtomIdx, u8)> = normalized
392        .atoms()
393        .filter_map(|(idx, _)| {
394            let pre = pre_h[idx.0 as usize]?;
395            let post = implicit_hcount(&normalized, idx);
396            (pre != post).then_some((idx, pre))
397        })
398        .collect();
399    if needs_patch.is_empty() {
400        return normalized;
401    }
402
403    let mut patched = MoleculeBuilder::new();
404    for (idx, atom) in normalized.atoms() {
405        let mut a = atom.clone();
406        if let Some(&(_, h)) = needs_patch.iter().find(|(pidx, _)| *pidx == idx) {
407            a.hydrogen_count = Some(h);
408        }
409        patched.add_atom(a);
410    }
411    for (_bond_idx, bond) in normalized.bonds() {
412        let _ = patched.add_bond(bond.atom1, bond.atom2, bond.order);
413    }
414    patched.copy_stereo_groups_from(&normalized);
415    patched.copy_stereo_from(&normalized);
416    patched.copy_bond_directions_from(&normalized);
417    patched.build()
418}
419
420// ---------------------------------------------------------------------------
421// Ring augmentation (XOR sub-rings)
422// ---------------------------------------------------------------------------
423
424/// Return the sorted set of bond indices that form `ring`.
425fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> Vec<BondIdx> {
426    let n = ring.len();
427    let mut bonds: Vec<BondIdx> = (0..n)
428        .filter_map(|i| {
429            let a = ring[i];
430            let b = ring[(i + 1) % n];
431            mol.bond_between(a, b).map(|(bidx, _)| bidx)
432        })
433        .collect();
434    bonds.sort();
435    bonds
436}
437
438/// Sorted symmetric difference of two sorted slices.
439fn bond_sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
440    let mut result: Vec<BondIdx> = Vec::new();
441    let mut i = 0;
442    let mut j = 0;
443    while i < a.len() && j < b.len() {
444        match a[i].cmp(&b[j]) {
445            std::cmp::Ordering::Less => {
446                result.push(a[i]);
447                i += 1;
448            }
449            std::cmp::Ordering::Greater => {
450                result.push(b[j]);
451                j += 1;
452            }
453            std::cmp::Ordering::Equal => {
454                i += 1;
455                j += 1;
456            }
457        }
458    }
459    result.extend_from_slice(&a[i..]);
460    result.extend_from_slice(&b[j..]);
461    result
462}
463
464/// Reconstruct an ordered atom sequence from a set of bond indices forming a simple cycle.
465/// Returns `None` if the bonds do not form a valid simple cycle.
466fn ring_atoms_from_bond_set(mol: &Molecule, bonds: &[BondIdx]) -> Option<Vec<AtomIdx>> {
467    if bonds.is_empty() {
468        return None;
469    }
470    let mut adj: FxHashMap<AtomIdx, [Option<AtomIdx>; 2]> = FxHashMap::default();
471    for &bidx in bonds {
472        let bond = mol.bond(bidx);
473        for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
474            let e = adj.entry(a).or_insert([None; 2]);
475            if e[0].is_none() {
476                e[0] = Some(b);
477            } else if e[1].is_none() {
478                e[1] = Some(b);
479            } else {
480                return None; // degree > 2 — not a simple ring
481            }
482        }
483    }
484    // All atoms must have exactly 2 neighbours.
485    if adj.values().any(|e| e[1].is_none()) {
486        return None;
487    }
488    let start = *adj.keys().next()?;
489    let mut path = vec![start];
490    let mut prev = start;
491    let mut current = adj[&start][0]?;
492    while current != start {
493        path.push(current);
494        let [n0, n1] = adj[&current];
495        let next = if n0 == Some(prev) { n1? } else { n0? };
496        prev = current;
497        current = next;
498    }
499    if path.len() != bonds.len() {
500        return None;
501    }
502    Some(path)
503}
504
505/// Augment the SSSR ring list with smaller XOR sub-rings found by pairwise GF(2)
506/// differences between SSSR rings that share atoms.
507///
508/// The standard SSSR algorithm sometimes stores a large fundamental cycle rather
509/// than its smaller GF(2)-reduced equivalent (e.g. the 5-ring of indolizine is
510/// the XOR of the 6-ring and the 9-ring the algorithm reports).
511/// This augmentation adds such missing smaller rings so that aromaticity
512/// perception works on the correct smallest rings without modifying the SSSR.
513///
514/// The returned `Vec` starts with all SSSR rings in their original order; any
515/// additional sub-rings derived by GF(2) pairwise XOR follow.  The function
516/// only adds a ring if it is strictly smaller than *both* parents, ensuring
517/// that envelope rings (e.g. the 10-membered perimeter of naphthalene) are
518/// never introduced.
519pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
520    let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
521
522    // Track which atom-sets we already have (as sorted atom lists).
523    let mut known: FxHashSet<Vec<AtomIdx>> = sssr_rings
524        .iter()
525        .map(|r| {
526            let mut s = r.clone();
527            s.sort();
528            s
529        })
530        .collect();
531
532    // Iterative pairwise XOR until convergence.
533    //
534    // A single pass only finds rings that are the XOR of two SSSR rings.
535    // Iterating also finds rings that require XOR of 3+ SSSR rings
536    // (e.g. the inner hexagon of coronene, or sub-rings in multi-step
537    // fused PAHs where the SSSR chose large perimeter cycles).
538    // Termination is guaranteed because each new ring is strictly smaller
539    // than both of its parents, so ring size can only decrease.
540    loop {
541        let mut changed = false;
542        let n = rings.len();
543        let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
544
545        for i in 0..n {
546            for j in (i + 1)..n {
547                // Only consider pairs that share atoms (fused rings).
548                let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
549                if !shares_atom {
550                    continue;
551                }
552                let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
553                if xor_bonds.is_empty() {
554                    continue;
555                }
556                // Only interesting if the XOR ring is not larger than the larger
557                // parent.  Using max() recovers cases where SSSR chose a large
558                // cycle (e.g. 10-ring macro vs 6-ring benzene twin).
559                // Using `>` (not `>=`) also allows same-size XOR rings, which
560                // handles bridged bicyclics (e.g. tropane or dioxolane spirocycles)
561                // where both parent rings are 6-membered and the missing bridge
562                // ring is also 6-membered.  Termination is still guaranteed:
563                // the `known` set prevents duplicates, and a finite molecule has
564                // finitely many valid cycles.
565                if xor_bonds.len() > rings[i].len().max(rings[j].len()) {
566                    continue;
567                }
568                if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
569                    let mut key = new_ring.clone();
570                    key.sort();
571                    if known.insert(key) {
572                        rings.push(new_ring);
573                        changed = true;
574                    }
575                }
576            }
577        }
578
579        // 3-ring XOR: catches small rings that require XOR of 3 SSSR rings
580        // when no intermediate 2-ring XOR produces a valid smaller ring.
581        for i in 0..n {
582            for j in (i + 1)..n {
583                let shares_ij = rings[i].iter().any(|a| rings[j].contains(a));
584                if !shares_ij {
585                    continue;
586                }
587                let xor_ij = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
588                if xor_ij.is_empty() {
589                    continue;
590                }
591                for k in (j + 1)..n {
592                    let shares_k = rings[k]
593                        .iter()
594                        .any(|a| rings[i].contains(a) || rings[j].contains(a));
595                    if !shares_k {
596                        continue;
597                    }
598                    let xor_ijk = bond_sym_diff(&xor_ij, &bond_sets[k]);
599                    let max_size = rings[i].len().max(rings[j].len()).max(rings[k].len());
600                    if xor_ijk.is_empty() || xor_ijk.len() > max_size {
601                        continue;
602                    }
603                    if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_ijk) {
604                        let mut key = new_ring.clone();
605                        key.sort();
606                        if known.insert(key) {
607                            rings.push(new_ring);
608                            changed = true;
609                        }
610                    }
611                }
612            }
613        }
614
615        if !changed {
616            break;
617        }
618    }
619
620    rings
621}
622
623/// Shared inner: SSSR → augmented_ring_set → strip_envelope_rings, no aromaticity filter.
624fn all_ring_list_inner(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
625    let sssr = crate::sssr::find_sssr(mol);
626    let aug = augmented_ring_set(mol, sssr.rings());
627    if aug.len() <= 1 {
628        return aug;
629    }
630    let bond_sets: Vec<Vec<BondIdx>> = aug.iter().map(|r| ring_bond_set(mol, r)).collect();
631    let mut is_envelope = vec![false; aug.len()];
632    strip_envelope_rings(&aug, &bond_sets, &mut is_envelope);
633    aug.into_iter()
634        .zip(is_envelope)
635        .filter(|(_, e)| !e)
636        .map(|(r, _)| r)
637        .collect()
638}
639
640/// Return all rings after augmented-ring-set expansion and envelope stripping.
641///
642/// Same pipeline as [`aromatic_ring_list`] but with no aromaticity filter — useful
643/// for aliphatic/saturated ring counting and bridgehead detection where SSSR
644/// envelope rings cause over-counting.
645pub fn all_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
646    all_ring_list_inner(mol)
647}
648
649/// True when all ring bonds between ring atoms are `BondOrder::Aromatic`.
650///
651/// Rings written with aromatic-SMILES notation but containing an explicit single
652/// bond (`c-n`, `nc-2`, etc.) are NOT truly aromatic.  RDKit canonicalises such
653/// SMILES with lowercase atoms and a `-` bond, which the parser stores as
654/// `BondOrder::Single` between two aromatic-flagged atoms.  Returning `false`
655/// here lets callers exclude them from the aromatic ring count.
656pub fn ring_bonds_all_aromatic(mol: &Molecule, ring: &[AtomIdx]) -> bool {
657    let n = ring.len();
658    (0..n).all(|i| {
659        let a = ring[i];
660        let b = ring[(i + 1) % n];
661        mol.bond_between(a, b)
662            .map(|(bidx, _)| mol.bond(bidx).order == BondOrder::Aromatic)
663            .unwrap_or(true)
664    })
665}
666
667/// Return the de-duplicated list of aromatic rings after augmented-ring-set expansion
668/// and envelope stripping.  Useful for filtering (e.g. counting only aromatic heterocycles).
669pub fn aromatic_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
670    let mol_with_arom;
671    let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
672        mol
673    } else {
674        mol_with_arom = apply_aromaticity(mol);
675        &mol_with_arom
676    };
677    all_ring_list_inner(mol)
678        .into_iter()
679        .filter(|ring| {
680            ring.iter().all(|&idx| mol.atom(idx).aromatic) && ring_bonds_all_aromatic(mol, ring)
681        })
682        .collect()
683}
684
685/// Mark which rings in `aromatic` are GF(2) sums (bond-XOR) of 2–4 smaller rings.
686fn strip_envelope_rings(
687    aromatic: &[Vec<AtomIdx>],
688    bond_sets: &[Vec<BondIdx>],
689    is_envelope: &mut [bool],
690) {
691    let n = aromatic.len();
692    for i in 0..n {
693        let si = aromatic[i].len();
694        'jk: for j in 0..n {
695            if j == i || aromatic[j].len() >= si {
696                continue;
697            }
698            for k in (j + 1)..n {
699                if k == i || aromatic[k].len() >= si {
700                    continue;
701                }
702                if bond_sym_diff(&bond_sets[j], &bond_sets[k]) == bond_sets[i] {
703                    is_envelope[i] = true;
704                    break 'jk;
705                }
706            }
707        }
708        if !is_envelope[i] {
709            'jkl: for j in 0..n {
710                if j == i || aromatic[j].len() >= si {
711                    continue;
712                }
713                for k in (j + 1)..n {
714                    if k == i || aromatic[k].len() >= si {
715                        continue;
716                    }
717                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
718                    for l in (k + 1)..n {
719                        if l == i || aromatic[l].len() >= si {
720                            continue;
721                        }
722                        if bond_sym_diff(&xor_jk, &bond_sets[l]) == bond_sets[i] {
723                            is_envelope[i] = true;
724                            break 'jkl;
725                        }
726                    }
727                }
728            }
729        }
730        if !is_envelope[i] {
731            'jklm: for j in 0..n {
732                if j == i || aromatic[j].len() >= si {
733                    continue;
734                }
735                for k in (j + 1)..n {
736                    if k == i || aromatic[k].len() >= si {
737                        continue;
738                    }
739                    let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
740                    for l in (k + 1)..n {
741                        if l == i || aromatic[l].len() >= si {
742                            continue;
743                        }
744                        let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
745                        for m in (l + 1)..n {
746                            if m == i || aromatic[m].len() >= si {
747                                continue;
748                            }
749                            if bond_sym_diff(&xor_jkl, &bond_sets[m]) == bond_sets[i] {
750                                is_envelope[i] = true;
751                                break 'jklm;
752                            }
753                        }
754                    }
755                }
756            }
757        }
758    }
759}
760
761pub fn count_aromatic_rings(mol: &Molecule) -> usize {
762    // For Kekulé-form input (uppercase atoms, no aromatic flags yet), run Hückel
763    // perception first so ring detection works correctly (RDKit #9271).
764    let mol_with_arom;
765    let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
766        mol // aromatic SMILES — flags already set during parsing
767    } else {
768        mol_with_arom = apply_aromaticity(mol);
769        &mol_with_arom
770    };
771
772    let sssr = crate::sssr::find_sssr(mol);
773    let aug = augmented_ring_set(mol, sssr.rings());
774
775    // Keep only rings where every atom carries the aromatic flag.
776    let aromatic: Vec<Vec<AtomIdx>> = aug
777        .into_iter()
778        .filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
779        .collect();
780
781    if aromatic.len() <= 1 {
782        return aromatic.len();
783    }
784
785    // Build sorted bond-index sets for each aromatic ring.
786    let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
787
788    // Mark rings that are the GF(2) sum (bond-XOR) of 2, 3, or 4 strictly
789    // smaller aromatic rings.  Such rings are "envelope" cycles introduced
790    // when the SSSR chose a large fundamental cycle instead of its smaller
791    // GF(2) components.
792    // 2-ring XOR: handles linear/angular fused systems (naphthalene, indolizine…).
793    // 3-ring XOR: handles compact PAHs like pyrene.
794    // 4-ring XOR: handles coronene-class PAHs where the outer perimeter is the
795    //   GF(2) sum of four inner hexagons.
796    let n = aromatic.len();
797    let mut is_envelope = vec![false; n];
798    strip_envelope_rings(&aromatic, &bond_sets, &mut is_envelope);
799    is_envelope.iter().filter(|&&e| !e).count()
800}
801
802// ---------------------------------------------------------------------------
803// Per-ring pi electron count
804// ---------------------------------------------------------------------------
805
806/// Count pi electrons for a ring atom, returning `None` if the atom is
807/// incompatible with aromaticity (e.g. sp3 carbon).
808///
809/// `aromatic_context`: atoms already confirmed aromatic (from Pass 1 or a
810/// previous Pass 2 iteration).  Such atoms contribute 1π unconditionally,
811/// without requiring an explicit double bond.
812///
813/// Rules:
814/// - **C**: if already in `aromatic_context` → 1π (confirmed sp2).
815///   1. No double bond anywhere: carbanion (`charge == -1`) → 2π (lone pair,
816///      e.g. cyclopentadienyl anion); otherwise sp3 → None.
817///   2. Has a double bond, but only exocyclic and to a more electronegative
818///      atom (O/N/S) → 0π (its p-orbital electrons are in the exocyclic π
819///      bond, e.g. the carbonyl carbon in tropone/pyridone/pyranone).
820///   3. Otherwise (has an endocyclic Double/Aromatic bond) → 1π.
821/// - **N**:
822///   1. Has H → 2π (pyrrole-type lone pair).
823///   2. Has an explicit `Double` bond → 1π (pyridine-type).
824///   3. total_degree == 3 AND ring_degree < total_degree AND no explicit
825///      double bond → 2π (lone pair in p orbital): covers both a bridgehead
826///      N shared by two fused rings (indolizine) and a substituted
827///      pyrrole-type N (N-methylpyrrole, N-glycosylated purine); the overall
828///      4n+2 sum, not the substituent, decides ring aromaticity.
829///   4. Has in-ring `Aromatic` bond → 1π (pyridine-like aromatic N).
830///   5. Already in `aromatic_context` → 1π.
831///   6. Otherwise → None.
832/// - **O/S**: ring_degree must be 2; contributes 2π (lone pair).
833/// - **Se (34) / Te (52)**: analogous to S; only in [`AromaticityAlgorithm::RdkitLike`] mode.
834/// - **Other elements**: None (unsupported).
835fn ring_pi_electrons(
836    mol: &Molecule,
837    ring: &[AtomIdx],
838    aromatic_context: &FxHashSet<AtomIdx>,
839    algo: AromaticityAlgorithm,
840) -> Option<u32> {
841    let ring_atom_set: FxHashSet<AtomIdx> = ring.iter().copied().collect();
842    let mut total_pi: u32 = 0;
843
844    for &atom_idx in ring {
845        // Atoms already confirmed aromatic in an adjacent ring contribute 1π.
846        if aromatic_context.contains(&atom_idx) {
847            total_pi += 1;
848            continue;
849        }
850
851        let atom = mol.atom(atom_idx);
852        let an = atom.element.atomic_number();
853
854        let ring_degree = mol
855            .neighbors(atom_idx)
856            .filter(|(nb, _)| ring_atom_set.contains(nb))
857            .count();
858
859        let total_degree = mol.degree(atom_idx);
860
861        // Explicit Double bond anywhere (not counting Aromatic).
862        let has_explicit_double = mol
863            .neighbors(atom_idx)
864            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
865
866        // Double OR Aromatic bond anywhere (for C sp2 check).
867        let has_double_any = has_explicit_double
868            || mol
869                .neighbors(atom_idx)
870                .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
871
872        // Aromatic bond within the ring (for pyridine-like N in aromatic SMILES).
873        let has_aromatic_in_ring = mol
874            .neighbors(atom_idx)
875            .filter(|(nb, _)| ring_atom_set.contains(nb))
876            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
877
878        let pi = match an {
879            // Carbon: must be sp2 (has a double or aromatic bond somewhere).
880            6 => {
881                if atom.charge > 0 {
882                    // Cationic ring carbon (tropylium's `[cH+]`): empty
883                    // p-orbital electron acceptor, 0π, regardless of
884                    // representation -- mirrors RDKit's carbon-specific
885                    // charge-sign flip (see `kekulization.rs`'s
886                    // `atom_must_be_matched` doc comment for the same rule
887                    // in the Kekule-matching layer) and this function's own
888                    // symmetric anion rule below (charge == -1 => 2π).
889                    0
890                } else if !has_double_any {
891                    // No double bond: a ring carbanion still donates its lone
892                    // pair (e.g. cyclopentadienyl anion), otherwise sp3.
893                    if atom.charge == -1 {
894                        2
895                    } else {
896                        return None; // sp3 carbon — ring cannot be aromatic
897                    }
898                } else if has_explicit_double
899                    && !has_aromatic_in_ring
900                    && !mol.neighbors(atom_idx).any(|(nb, bidx)| {
901                        ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
902                    })
903                    && mol.neighbors(atom_idx).any(|(nb, bidx)| {
904                        !ring_atom_set.contains(&nb)
905                            && mol.bond(bidx).order == BondOrder::Double
906                            && matches!(mol.atom(nb).element.atomic_number(), 7 | 8 | 16)
907                    })
908                {
909                    // Only double bond is exocyclic, to a more electronegative
910                    // atom (O/N/S): p-orbital electrons sit in that exocyclic π
911                    // bond, contributing 0π to the ring (e.g. carbonyl carbon
912                    // in tropone/pyridone/pyranone).
913                    0
914                } else {
915                    1
916                }
917            }
918
919            // Nitrogen
920            7 => {
921                if implicit_hcount(mol, atom_idx) > 0 && atom.charge <= 0 {
922                    // Pyrrole-type N with H, neutral or anionic: lone pair → 2π.
923                    2
924                } else if has_explicit_double {
925                    // Pyridine-type N with an explicit double bond → 1π. Also
926                    // catches a protonated ring N (pyridinium's `[nH+]`): the
927                    // added proton consumes the lone pair the H-count check
928                    // above would otherwise have claimed, and
929                    // `chematic_core::kekulize` (charge-aware per K1) routes
930                    // such an atom to a real Kekule double bond, exactly like
931                    // neutral pyridine's bare N -- so this branch is reached
932                    // instead of the one above once `atom.charge <= 0` fails.
933                    1
934                } else if total_degree == 3 && ring_degree < total_degree && atom.charge <= 0 {
935                    // N with no H, no explicit double bond, all three σ-bonds
936                    // exactly filling its valence (3), and neutral/anionic: a
937                    // bridgehead N shared by two fused rings (e.g. indolizine)
938                    // and a substituted pyrrole-type N (e.g. N-methylpyrrole,
939                    // N-glycosylated purine/pyrimidine) have the identical
940                    // local shape — the lone pair occupies the p orbital → 2π
941                    // either way. Whether the ring this atom sits in is
942                    // actually aromatic is decided by the overall 4n+2 sum below, not
943                    // by inspecting the substituent: an imide N (phthalimide) still
944                    // correctly comes out non-aromatic because its ring's carbonyl
945                    // carbons contribute 0π each (exocyclic C=O rule above), giving
946                    // 4π total, not 4n+2. The `charge <= 0` guard keeps a charged
947                    // N with an H (pyridinium's `[nH+]`, degree 3 = 2 ring + 1 H)
948                    // from being wrongly routed here in the aromatic-bond
949                    // (pre-Kekulization) representation, where it has no
950                    // explicit double bond to be caught by the branch above —
951                    // it falls through to the pyridine-type branch below instead.
952                    2
953                } else if has_aromatic_in_ring {
954                    // N in an aromatic ring (pre-kekulization input) without an
955                    // explicit double bond and not a bridgehead → pyridine-like
956                    // → 1π. Also the protonated-N fallback for the aromatic-bond
957                    // representation (see the guards above).
958                    1
959                } else {
960                    // Cannot determine pi contribution.
961                    return None;
962                }
963            }
964
965            // Oxygen / sulfur: lone-pair donor, must be 2-connected in the ring
966            // -- *unless* a positive charge (pyrylium's `[o+]`) has consumed
967            // the lone pair, in which case it needs pyridine-type treatment
968            // (1π via its own ring double/aromatic bond) instead, mirroring
969            // `kekulization.rs`'s charge-aware donor-exemption rule (K1).
970            8 | 16 => {
971                if atom.charge > 0 {
972                    if has_explicit_double || has_aromatic_in_ring {
973                        1
974                    } else {
975                        return None;
976                    }
977                } else {
978                    if ring_degree != 2 {
979                        return None;
980                    }
981                    // Sulfoxide/sulfone: exocyclic S=O ties up the lone pair; cannot donate 2π
982                    if an == 16
983                        && mol.neighbors(atom_idx).any(|(nb, bidx)| {
984                            !ring_atom_set.contains(&nb)
985                                && mol.bond(bidx).order == BondOrder::Double
986                        })
987                    {
988                        return None;
989                    }
990                    2
991                }
992            }
993
994            // Se (34) / Te (52): chalcogen lone-pair donors (2π), analogous to S.
995            // Only recognised in RdkitLike mode.
996            34 | 52 => {
997                if algo != AromaticityAlgorithm::RdkitLike {
998                    return None;
999                }
1000                if ring_degree != 2 {
1001                    return None;
1002                }
1003                // Exocyclic Se=O / Te=O ties up the lone pair.
1004                if mol.neighbors(atom_idx).any(|(nb, bidx)| {
1005                    !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
1006                }) {
1007                    return None;
1008                }
1009                2
1010            }
1011
1012            // Unsupported element.
1013            _ => return None,
1014        };
1015
1016        total_pi += pi;
1017    }
1018
1019    Some(total_pi)
1020}
1021
1022// ---------------------------------------------------------------------------
1023// Diagnostic trace (Aromaticity-A1-0) — observational only, no production
1024// behavior change. `ring_pi_electrons` above is untouched and remains the
1025// single source of truth for `assign_aromaticity_ex`'s actual decisions;
1026// this is a parallel, read-only explanation layer for `component/atom/reason`
1027// tracing, used by `aromaticity_a1_0_report` and the corpus diagnostics in
1028// `validation/aromaticity_a1_0_corpus.jsonl`. See `docs/aromaticity_a1_rfc.md`.
1029// ---------------------------------------------------------------------------
1030
1031/// Reason a ring atom contributes (or fails to contribute) pi electrons,
1032/// mirroring `ring_pi_electrons`'s branches one-to-one. Purely diagnostic.
1033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1034pub enum ContributionReason {
1035    /// Already aromatic from a previous Pass 1/Pass 2 ring: contributes 1π unconditionally.
1036    AlreadyAromaticContext,
1037    /// Carbon with an endocyclic double/aromatic bond: 1π.
1038    CarbonEndocyclicDouble,
1039    /// Carbon whose only double bond is exocyclic to O/N/S: 0π (e.g. a carbonyl carbon).
1040    CarbonExocyclicHeteroatomDouble,
1041    /// Carbanion with no double bond: 2π (lone pair).
1042    CarbonCarbanionLonePair,
1043    /// Cationic ring carbon (e.g. tropylium's `[cH+]`): empty p-orbital
1044    /// electron acceptor, 0π, regardless of representation (Kekule or
1045    /// aromatic-bond) -- mirrors `CarbonCarbanionLonePair`'s anion rule at
1046    /// the opposite electron-count extreme.
1047    CarbonCationVacant,
1048    /// sp3 carbon (no double bond, not a carbanion): ineligible.
1049    CarbonSp3Ineligible,
1050    /// Pyrrole-type N with an H, neutral or anionic: 2π.
1051    NitrogenPyrroleTypeH,
1052    /// Pyridine-type N with an explicit double bond (bare, or protonated
1053    /// N-H+ once it has a Kekule double bond): 1π.
1054    NitrogenPyridineTypeExplicitDouble,
1055    /// Bridgehead N (or N-substituted azole N), neutral or anionic:
1056    /// all-sigma valence, lone pair in p orbital: 2π.
1057    NitrogenBridgeheadOrSubstitutedLonePair,
1058    /// N with an in-ring aromatic bond, not a bridgehead (pyridine-type
1059    /// notation, or a charged N-H+ in aromatic-bond representation): 1π.
1060    NitrogenAromaticInRing,
1061    /// N matching none of the above rules: ineligible.
1062    NitrogenIneligible,
1063    /// O/S/Se/Te lone-pair donor, neutral or anionic, ring-degree 2: 2π.
1064    ChalcogenLonePair,
1065    /// Charged O/S (e.g. pyrylium's `[o+]`): the positive charge consumes
1066    /// the lone pair, so this atom needs pyridine-type treatment (1π via
1067    /// its own ring double/aromatic bond) instead of donating 2π.
1068    ChalcogenCationPyridineType,
1069    /// O/S/Se/Te with the wrong ring degree, an exocyclic X=O, (Se/Te)
1070    /// non-RdkitLike mode, or a charged O/S with no ring double/aromatic
1071    /// bond to fall back on: ineligible.
1072    ChalcogenIneligible,
1073    /// Element not supported by the model: ineligible.
1074    UnsupportedElement,
1075}
1076
1077impl ContributionReason {
1078    /// Whether this reason is an eligible contribution (matches
1079    /// `ring_pi_electrons` returning `Some`) rather than one that disqualifies
1080    /// the whole ring (matches it returning `None`).
1081    pub fn is_eligible(self) -> bool {
1082        !matches!(
1083            self,
1084            ContributionReason::CarbonSp3Ineligible
1085                | ContributionReason::NitrogenIneligible
1086                | ContributionReason::ChalcogenIneligible
1087                | ContributionReason::UnsupportedElement
1088        )
1089    }
1090
1091    /// Coarse `PiEligibility` bucket for this fine-grained reason
1092    /// (Aromaticity-A1-1a). `AlreadyAromaticContext` has no single fixed
1093    /// bucket -- it always carries exactly 1π, so it maps to `OneElectron`.
1094    pub fn eligibility(self) -> PiEligibility {
1095        use ContributionReason::*;
1096        match self {
1097            AlreadyAromaticContext
1098            | CarbonEndocyclicDouble
1099            | NitrogenPyridineTypeExplicitDouble
1100            | NitrogenAromaticInRing
1101            | ChalcogenCationPyridineType => PiEligibility::OneElectron,
1102            CarbonCarbanionLonePair
1103            | NitrogenPyrroleTypeH
1104            | NitrogenBridgeheadOrSubstitutedLonePair
1105            | ChalcogenLonePair => PiEligibility::LonePairDonor,
1106            CarbonExocyclicHeteroatomDouble | CarbonCationVacant => PiEligibility::ZeroElectron,
1107            CarbonSp3Ineligible | NitrogenIneligible | ChalcogenIneligible | UnsupportedElement => {
1108                PiEligibility::Ineligible
1109            }
1110        }
1111    }
1112}
1113
1114/// Coarse per-atom pi-eligibility bucket (Aromaticity-A1-1a). A summary view
1115/// over [`ContributionReason`]'s finer-grained rules -- `electrons()` gives
1116/// the electron count implied by each bucket.
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1118pub enum PiEligibility {
1119    /// Contributes exactly 1π (e.g. an endocyclic double/aromatic bond).
1120    OneElectron,
1121    /// Contributes 2π (a lone pair: pyrrole-type N, chalcogen, bridgehead N, carbanion).
1122    LonePairDonor,
1123    /// Contributes 0π but is still sp2 (p-orbital spent on an exocyclic multiple bond).
1124    ZeroElectron,
1125    /// Not eligible to be part of any conjugated system (e.g. sp3).
1126    Ineligible,
1127}
1128
1129impl PiEligibility {
1130    /// Electron count implied by this bucket, or `None` for `Ineligible`.
1131    pub fn electrons(self) -> Option<u8> {
1132        match self {
1133            PiEligibility::OneElectron => Some(1),
1134            PiEligibility::LonePairDonor => Some(2),
1135            PiEligibility::ZeroElectron => Some(0),
1136            PiEligibility::Ineligible => None,
1137        }
1138    }
1139}
1140
1141/// A candidate conjugated system: some atoms/bonds evaluated together as one
1142/// pi-electron-counting problem (Aromaticity-A1-1a). Two distinct uses:
1143/// - a single SSSR/augmented ring, reinterpreted as a trivial one-ring
1144///   candidate (what `trace_ring_pi_electrons` builds today);
1145/// - a genuine multi-ring fused envelope, built by
1146///   [`build_conjugated_components`] as a connected component of the
1147///   "conjugation graph" (double/aromatic-bonded atoms, plus lone-pair-donor
1148///   atoms bridging across single bonds) -- the azulene-class candidate
1149///   `augmented_ring_set`'s own docstring already named as future work
1150///   ("candidate rings = SSSR ∪ fused envelopes").
1151#[derive(Debug, Clone)]
1152pub struct ConjugatedComponent {
1153    pub atoms: Vec<AtomIdx>,
1154    pub bonds: Vec<BondIdx>,
1155    /// Ring indices (into whatever ring list the caller built this from) this
1156    /// candidate derives from -- one entry for a plain single-ring candidate,
1157    /// 2+ for a fused envelope spanning multiple rings.
1158    pub source_rings: Vec<usize>,
1159}
1160
1161impl ConjugatedComponent {
1162    /// Build a trivial single-ring candidate from one ring's atom list (no
1163    /// bond list needed by [`evaluate_atom_pi_contribution`], which only
1164    /// consults `atoms` membership).
1165    fn from_ring(ring: &[AtomIdx], ring_idx: usize) -> Self {
1166        ConjugatedComponent {
1167            atoms: ring.to_vec(),
1168            bonds: Vec::new(),
1169            source_rings: vec![ring_idx],
1170        }
1171    }
1172}
1173
1174/// The full per-atom decision from [`evaluate_atom_pi_contribution`]: the
1175/// coarse eligibility bucket plus the specific rule that produced it.
1176#[derive(Debug, Clone, Copy)]
1177pub struct ContributionDecision {
1178    pub eligibility: PiEligibility,
1179    pub reason: ContributionReason,
1180}
1181
1182impl ContributionDecision {
1183    pub fn electrons(&self) -> Option<u8> {
1184        self.eligibility.electrons()
1185    }
1186}
1187
1188/// Per-atom trace entry from [`trace_ring_pi_electrons`].
1189#[derive(Debug, Clone, Copy)]
1190pub struct AtomElectronTrace {
1191    pub atom_idx: AtomIdx,
1192    /// `None` iff `reason.is_eligible()` is false.
1193    pub contribution: Option<u8>,
1194    pub reason: ContributionReason,
1195}
1196
1197/// Full per-atom pi-electron trace for one ring — the diagnostic twin of
1198/// [`ring_pi_electrons`]. Unlike `ring_pi_electrons` (which returns `None` at
1199/// the first ineligible atom), this always scans every atom so a caller can
1200/// see exactly which atom(s) disqualify a ring, not just that one did.
1201#[derive(Debug, Clone)]
1202pub struct RingElectronTrace {
1203    pub atoms: Vec<AtomElectronTrace>,
1204    /// `Some(sum)` iff every atom was eligible — must equal
1205    /// `ring_pi_electrons(mol, ring, aromatic_context, algo)` for the same
1206    /// inputs (checked by `trace_matches_ring_pi_electrons_on_corpus` below).
1207    pub total: Option<u32>,
1208}
1209
1210/// Diagnostic twin of [`ring_pi_electrons`]: identical per-atom rules
1211/// (delegating to [`evaluate_atom_pi_contribution`], the single source of
1212/// truth for both this trace and any future experimental production path —
1213/// see `docs/aromaticity_a1_rfc.md`'s A1-1a section), but returns a full
1214/// trace instead of a single early-exiting `Option<u32>`. Does not call,
1215/// wrap, or change `ring_pi_electrons` itself — zero effect on
1216/// `assign_aromaticity_ex`'s behavior. `trace_matches_ring_pi_electrons_on_corpus`
1217/// is the anti-drift guard that keeps this and `ring_pi_electrons` in sync.
1218pub fn trace_ring_pi_electrons(
1219    mol: &Molecule,
1220    ring: &[AtomIdx],
1221    aromatic_context: &FxHashSet<AtomIdx>,
1222    algo: AromaticityAlgorithm,
1223) -> RingElectronTrace {
1224    let component = ConjugatedComponent::from_ring(ring, 0);
1225    let mut atoms = Vec::with_capacity(ring.len());
1226    let mut total: Option<u32> = Some(0);
1227
1228    for &atom_idx in ring {
1229        let (contribution, reason) = if aromatic_context.contains(&atom_idx) {
1230            (Some(1u8), ContributionReason::AlreadyAromaticContext)
1231        } else {
1232            let decision = evaluate_atom_pi_contribution(mol, atom_idx, &component, algo);
1233            (decision.electrons(), decision.reason)
1234        };
1235
1236        total = match (total, contribution) {
1237            (Some(t), Some(c)) => Some(t + c as u32),
1238            _ => None,
1239        };
1240
1241        atoms.push(AtomElectronTrace {
1242            atom_idx,
1243            contribution,
1244            reason,
1245        });
1246    }
1247
1248    RingElectronTrace { atoms, total }
1249}
1250
1251/// Single source of truth for per-atom pi-electron contribution
1252/// (Aromaticity-A1-1a): identical rules to `ring_pi_electrons`'s match arms,
1253/// condition-for-condition, parameterized by an arbitrary candidate
1254/// [`ConjugatedComponent`] instead of one fixed SSSR ring — the same
1255/// function evaluates a plain single-ring candidate (via
1256/// `ConjugatedComponent::from_ring`) or a genuine multi-ring fused envelope
1257/// (via `build_conjugated_components`) identically. Currently called by
1258/// `trace_ring_pi_electrons` only — NOT wired into `ring_pi_electrons` or
1259/// `assign_aromaticity_ex` (that wiring, behind a new opt-in
1260/// `AromaticityAlgorithm` variant, is Aromaticity-A1-1b, not this round).
1261pub fn evaluate_atom_pi_contribution(
1262    mol: &Molecule,
1263    atom_idx: AtomIdx,
1264    component: &ConjugatedComponent,
1265    algo: AromaticityAlgorithm,
1266) -> ContributionDecision {
1267    let component_atoms: FxHashSet<AtomIdx> = component.atoms.iter().copied().collect();
1268    let (_electrons, reason) =
1269        evaluate_atom_pi_contribution_inner(mol, atom_idx, &component_atoms, algo);
1270    // `reason.eligibility().electrons()` is asserted equal to `_electrons`
1271    // for every branch by `contribution_decision_electrons_match_inner_on_corpus`.
1272    ContributionDecision {
1273        eligibility: reason.eligibility(),
1274        reason,
1275    }
1276}
1277
1278/// Per-atom contribution logic, mirroring `ring_pi_electrons`'s match arms
1279/// condition-for-condition, but returning a reason alongside the
1280/// contribution instead of returning early on `None`.
1281fn evaluate_atom_pi_contribution_inner(
1282    mol: &Molecule,
1283    atom_idx: AtomIdx,
1284    ring_atom_set: &FxHashSet<AtomIdx>,
1285    algo: AromaticityAlgorithm,
1286) -> (Option<u8>, ContributionReason) {
1287    let atom = mol.atom(atom_idx);
1288    let an = atom.element.atomic_number();
1289
1290    let ring_degree = mol
1291        .neighbors(atom_idx)
1292        .filter(|(nb, _)| ring_atom_set.contains(nb))
1293        .count();
1294    let total_degree = mol.degree(atom_idx);
1295
1296    let has_explicit_double = mol
1297        .neighbors(atom_idx)
1298        .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
1299    let has_double_any = has_explicit_double
1300        || mol
1301            .neighbors(atom_idx)
1302            .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
1303    let has_aromatic_in_ring = mol
1304        .neighbors(atom_idx)
1305        .filter(|(nb, _)| ring_atom_set.contains(nb))
1306        .any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
1307
1308    match an {
1309        6 => {
1310            if atom.charge > 0 {
1311                (Some(0), ContributionReason::CarbonCationVacant)
1312            } else if !has_double_any {
1313                if atom.charge == -1 {
1314                    (Some(2), ContributionReason::CarbonCarbanionLonePair)
1315                } else {
1316                    (None, ContributionReason::CarbonSp3Ineligible)
1317                }
1318            } else if has_explicit_double
1319                && !has_aromatic_in_ring
1320                && !mol.neighbors(atom_idx).any(|(nb, bidx)| {
1321                    ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
1322                })
1323                && mol.neighbors(atom_idx).any(|(nb, bidx)| {
1324                    !ring_atom_set.contains(&nb)
1325                        && mol.bond(bidx).order == BondOrder::Double
1326                        && matches!(mol.atom(nb).element.atomic_number(), 7 | 8 | 16)
1327                })
1328            {
1329                (Some(0), ContributionReason::CarbonExocyclicHeteroatomDouble)
1330            } else {
1331                (Some(1), ContributionReason::CarbonEndocyclicDouble)
1332            }
1333        }
1334        7 => {
1335            if implicit_hcount(mol, atom_idx) > 0 && atom.charge <= 0 {
1336                (Some(2), ContributionReason::NitrogenPyrroleTypeH)
1337            } else if has_explicit_double {
1338                (
1339                    Some(1),
1340                    ContributionReason::NitrogenPyridineTypeExplicitDouble,
1341                )
1342            } else if total_degree == 3 && ring_degree < total_degree && atom.charge <= 0 {
1343                (
1344                    Some(2),
1345                    ContributionReason::NitrogenBridgeheadOrSubstitutedLonePair,
1346                )
1347            } else if has_aromatic_in_ring {
1348                (Some(1), ContributionReason::NitrogenAromaticInRing)
1349            } else {
1350                (None, ContributionReason::NitrogenIneligible)
1351            }
1352        }
1353        8 | 16 => {
1354            if atom.charge > 0 {
1355                if has_explicit_double || has_aromatic_in_ring {
1356                    (Some(1), ContributionReason::ChalcogenCationPyridineType)
1357                } else {
1358                    (None, ContributionReason::ChalcogenIneligible)
1359                }
1360            } else {
1361                let exocyclic_double = an == 16
1362                    && mol.neighbors(atom_idx).any(|(nb, bidx)| {
1363                        !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
1364                    });
1365                if ring_degree != 2 || exocyclic_double {
1366                    (None, ContributionReason::ChalcogenIneligible)
1367                } else {
1368                    (Some(2), ContributionReason::ChalcogenLonePair)
1369                }
1370            }
1371        }
1372        34 | 52 => {
1373            let exocyclic_double = mol.neighbors(atom_idx).any(|(nb, bidx)| {
1374                !ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
1375            });
1376            if algo != AromaticityAlgorithm::RdkitLike || ring_degree != 2 || exocyclic_double {
1377                (None, ContributionReason::ChalcogenIneligible)
1378            } else {
1379                (Some(2), ContributionReason::ChalcogenLonePair)
1380            }
1381        }
1382        _ => (None, ContributionReason::UnsupportedElement),
1383    }
1384}
1385
1386/// Evaluate an atom's pi contribution using its "home ring" within a
1387/// (possibly multi-ring) candidate, instead of the candidate's flattened
1388/// atom set directly: tries each of `candidate.source_rings` that actually
1389/// contains the atom, evaluating against *that one ring's own* atom set, and
1390/// returns the first eligible result found. Falls back to evaluating
1391/// directly against the flattened `candidate` if `source_rings` is empty or
1392/// none of them contain the atom (shouldn't happen for well-formed
1393/// candidates, but keeps this total rather than panicking).
1394///
1395/// Needed because degree-sensitive rules (the N bridgehead/substituted-azole
1396/// rule, `total_degree == 3 && ring_degree < total_degree`) test "does this
1397/// atom have a bond that points outside THIS ring" -- a genuine multi-ring
1398/// bridgehead's every bond is "in-family" once the evaluation context is the
1399/// flattened whole envelope (every neighbor is, by construction, some other
1400/// family member), which silently defeats that test and makes a real
1401/// bridgehead N (e.g. indolizine's) look `Ineligible`. Evaluating against
1402/// one constituent ring at a time preserves the rule's original, correct,
1403/// per-ring meaning even when the candidate spans multiple rings. This does
1404/// **not** attempt to resolve whether a bridgehead's lone-pair credit is
1405/// *legitimately shared* between two rings that are both otherwise valid vs.
1406/// wrongly borrowed by one ring from another that's actually broken (e.g.
1407/// by an sp3 atom) -- that is a distinct, harder, open question, deliberately
1408/// left to Aromaticity-A1-1b (see `docs/aromaticity_a1_rfc.md`).
1409fn evaluate_atom_via_home_ring(
1410    mol: &Molecule,
1411    atom_idx: AtomIdx,
1412    candidate: &ConjugatedComponent,
1413    rings: &[Vec<AtomIdx>],
1414    algo: AromaticityAlgorithm,
1415) -> ContributionDecision {
1416    let mut last = None;
1417    for &ri in &candidate.source_rings {
1418        if !rings[ri].contains(&atom_idx) {
1419            continue;
1420        }
1421        let home = ConjugatedComponent::from_ring(&rings[ri], ri);
1422        let decision = evaluate_atom_pi_contribution(mol, atom_idx, &home, algo);
1423        if decision.electrons().is_some() {
1424            return decision;
1425        }
1426        last = Some(decision);
1427    }
1428    last.unwrap_or_else(|| evaluate_atom_pi_contribution(mol, atom_idx, candidate, algo))
1429}
1430
1431/// Build genuine multi-ring conjugated-system candidates (Aromaticity-A1-1a):
1432/// connected components of the "conjugation graph" over each ring family's
1433/// atoms -- nodes are atoms whose eligibility (evaluated per-atom against its
1434/// own home ring, via `evaluate_atom_via_home_ring` -- not the flattened
1435/// family) is not `Ineligible`; edges are any bond (single, double, or
1436/// aromatic) between two independently-eligible family atoms: ordinary
1437/// carbon-carbon single-bond conjugation (butadiene's C=C-C=C middle bond,
1438/// styrene's vinyl-to-phenyl bond) connects just as directly as a
1439/// lone-pair-donor heteroatom bridging a sigma bond.
1440///
1441/// A pure candidate *generator* -- callers (currently only
1442/// `exhaustive_aromaticity_oracle`) still run full 4n+2 electron counting on
1443/// each result. Only components spanning 2+ of a family's rings are
1444/// returned: a single unfused ring is already covered by
1445/// `ConjugatedComponent::from_ring`, so this only adds the fused-envelope
1446/// candidates `augmented_ring_set`'s docstring named as future work
1447/// ("candidate rings = SSSR ∪ fused envelopes").
1448pub fn build_conjugated_components(
1449    mol: &Molecule,
1450    rings: &[Vec<AtomIdx>],
1451    ring_families: &[RingFamily],
1452    algo: AromaticityAlgorithm,
1453) -> Vec<ConjugatedComponent> {
1454    let mut out = Vec::new();
1455
1456    for family in ring_families {
1457        if family.ring_indices.len() < 2 {
1458            continue; // single-ring families add nothing beyond from_ring.
1459        }
1460        let family_component = ConjugatedComponent {
1461            atoms: family.atoms.clone(),
1462            bonds: Vec::new(),
1463            source_rings: family.ring_indices.clone(),
1464        };
1465
1466        // Eligibility per atom, evaluated against its *home* constituent
1467        // ring (not the flattened family) -- see `evaluate_atom_via_home_ring`'s
1468        // doc comment for why the flattened version breaks degree-sensitive
1469        // rules (bridgehead N) for any atom whose every bond happens to be
1470        // "in-family" once the family itself is the context.
1471        let eligible: FxHashMap<AtomIdx, bool> = family
1472            .atoms
1473            .iter()
1474            .map(|&a| {
1475                let decision = evaluate_atom_via_home_ring(mol, a, &family_component, rings, algo);
1476                (a, decision.electrons().is_some())
1477            })
1478            .collect();
1479        // Union-find over eligible family atoms, connected by conjugation edges.
1480        let atoms: Vec<AtomIdx> = family.atoms.clone();
1481        let index_of: FxHashMap<AtomIdx, usize> =
1482            atoms.iter().enumerate().map(|(i, &a)| (a, i)).collect();
1483        let mut parent: Vec<usize> = (0..atoms.len()).collect();
1484        fn find(parent: &mut [usize], x: usize) -> usize {
1485            if parent[x] != x {
1486                parent[x] = find(parent, parent[x]);
1487            }
1488            parent[x]
1489        }
1490        fn union(parent: &mut [usize], x: usize, y: usize) {
1491            let (px, py) = (find(parent, x), find(parent, y));
1492            if px != py {
1493                parent[px] = py;
1494            }
1495        }
1496
1497        // Any bond (single, double, or aromatic) between two independently
1498        // eligible atoms conjugation-connects them: two sp2 atoms bridge
1499        // across a single bond exactly like butadiene's C=C-C=C middle bond
1500        // or styrene's vinyl-to-phenyl bond -- ordinary carbon-carbon
1501        // conjugation, not just lone-pair-donor bridging. (First version of
1502        // this rule only bridged single bonds via a `LonePairDonor`
1503        // endpoint, which is too narrow: it left azulene's all-carbon
1504        // alternating single/double perimeter as 5 disconnected 2-atom
1505        // pairs, never forming the one 10-atom fused-envelope candidate it
1506        // needs -- caught by `exhaustive_aromaticity_oracle` returning an
1507        // empty set for azulene instead of the whole ring.) The
1508        // `is_lone_pair_donor` check is now unused for connectivity, kept
1509        // only where a NON-eligible atom's neighbor still needs distinguishing
1510        // (none currently) -- eligibility alone (both endpoints not
1511        // `Ineligible`) is the connectivity condition; bond order still fully
1512        // determines each atom's *electron count* via
1513        // `evaluate_atom_pi_contribution`, just not graph connectivity.
1514        let family_atom_set: FxHashSet<AtomIdx> = family.atoms.iter().copied().collect();
1515        let mut conjugation_bonds: Vec<BondIdx> = Vec::new();
1516        for &a in &atoms {
1517            if !eligible[&a] {
1518                continue;
1519            }
1520            for (nb, bidx) in mol.neighbors(a) {
1521                if !family_atom_set.contains(&nb) || !eligible.get(&nb).copied().unwrap_or(false) {
1522                    continue;
1523                }
1524                // Both endpoints eligible -> connected (see comment above).
1525                union(&mut parent, index_of[&a], index_of[&nb]);
1526                conjugation_bonds.push(bidx);
1527            }
1528        }
1529
1530        let mut groups: FxHashMap<usize, Vec<AtomIdx>> = FxHashMap::default();
1531        for &a in &atoms {
1532            if !eligible[&a] {
1533                continue;
1534            }
1535            let root = find(&mut parent, index_of[&a]);
1536            groups.entry(root).or_default().push(a);
1537        }
1538
1539        for group_atoms in groups.into_values() {
1540            let group_set: FxHashSet<AtomIdx> = group_atoms.iter().copied().collect();
1541            let source_rings: Vec<usize> = family
1542                .ring_indices
1543                .iter()
1544                .copied()
1545                .filter(|&ri| rings[ri].iter().all(|a| group_set.contains(a)))
1546                .collect();
1547            if source_rings.len() < 2 {
1548                continue; // doesn't actually span multiple full rings.
1549            }
1550            let group_bonds: Vec<BondIdx> = conjugation_bonds
1551                .iter()
1552                .copied()
1553                .filter(|&bidx| {
1554                    let b = mol.bond(bidx);
1555                    group_set.contains(&b.atom1) && group_set.contains(&b.atom2)
1556                })
1557                .collect();
1558            out.push(ConjugatedComponent {
1559                atoms: group_atoms,
1560                bonds: group_bonds,
1561                source_rings,
1562            });
1563        }
1564    }
1565
1566    out
1567}
1568
1569/// Test/diagnostic-only exhaustive-candidate reference oracle
1570/// (Aromaticity-A1-1a) — **not** used by production or by
1571/// `trace_ring_pi_electrons`. Evaluates every SSSR/augmented ring AND every
1572/// multi-ring fused-envelope candidate from `build_conjugated_components`,
1573/// marking an atom/bond aromatic if ANY candidate containing it
1574/// independently satisfies 4n+2 via `evaluate_atom_pi_contribution`'s
1575/// per-atom rules — every candidate is evaluated from a clean slate, with NO
1576/// `aromatic_context` bootstrapping at all (unlike `assign_aromaticity_ex`'s
1577/// production Pass 1/Pass 2). Exists to cross-check hypotheses about which
1578/// per-atom rule needs to change, per the MANCUDE-style bounded-enumeration
1579/// precedent — see `docs/aromaticity_a1_rfc.md`'s A1-1a section.
1580/// Deliberately simple/slow: O(rings + fused envelopes) candidates, no
1581/// attempt at Pass-2-style iteration, memoization, or performance tuning.
1582pub fn exhaustive_aromaticity_oracle(
1583    mol: &Molecule,
1584    algo: AromaticityAlgorithm,
1585) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
1586    let sssr = find_sssr(mol);
1587    let rings = augmented_ring_set(mol, sssr.rings());
1588    let families = crate::ring_family::find_ring_families_over(mol, &rings);
1589
1590    let mut candidates: Vec<ConjugatedComponent> = rings
1591        .iter()
1592        .enumerate()
1593        .map(|(i, r)| ConjugatedComponent::from_ring(r, i))
1594        .collect();
1595    candidates.extend(build_conjugated_components(mol, &rings, &families, algo));
1596
1597    let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
1598    let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
1599
1600    for candidate in &candidates {
1601        let mut total: Option<u32> = Some(0);
1602        for &atom_idx in &candidate.atoms {
1603            // Multi-ring candidates evaluate each atom against its home ring
1604            // (see `evaluate_atom_via_home_ring`'s doc comment); single-ring
1605            // candidates fall through to the same code path with exactly one
1606            // source ring, unchanged from evaluating against `candidate` directly.
1607            let decision = evaluate_atom_via_home_ring(mol, atom_idx, candidate, &rings, algo);
1608            total = match (total, decision.electrons()) {
1609                (Some(t), Some(e)) => Some(t + e as u32),
1610                _ => None,
1611            };
1612        }
1613        let Some(pi) = total else { continue };
1614        let (cls, _) = classify_ring_aromaticity(pi);
1615        if !matches!(cls, RingAromaticity::Aromatic) {
1616            continue;
1617        }
1618        for &a in &candidate.atoms {
1619            aromatic_atoms.insert(a);
1620        }
1621        for &a in &candidate.atoms {
1622            for (nb, bidx) in mol.neighbors(a) {
1623                if candidate.atoms.contains(&nb)
1624                    && matches!(
1625                        mol.bond(bidx).order,
1626                        BondOrder::Double | BondOrder::Aromatic
1627                    )
1628                {
1629                    aromatic_bonds.insert(bidx);
1630                }
1631            }
1632        }
1633    }
1634
1635    (aromatic_atoms, aromatic_bonds)
1636}
1637
1638// ---------------------------------------------------------------------------
1639// Tests
1640// ---------------------------------------------------------------------------
1641
1642#[cfg(test)]
1643mod tests {
1644    use super::*;
1645    use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
1646
1647    // =========================================================================
1648    // Molecule builder helpers (kekulized, manually constructed)
1649    // =========================================================================
1650
1651    fn benzene_kekule() -> chematic_core::Molecule {
1652        let mut b = MoleculeBuilder::new();
1653        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1654        for i in 0..6 {
1655            let order = if i % 2 == 0 {
1656                BondOrder::Double
1657            } else {
1658                BondOrder::Single
1659            };
1660            b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
1661        }
1662        b.build()
1663    }
1664
1665    fn cyclohexane() -> chematic_core::Molecule {
1666        let mut b = MoleculeBuilder::new();
1667        let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1668        for i in 0..6 {
1669            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
1670                .unwrap();
1671        }
1672        b.build()
1673    }
1674
1675    fn pyridine_kekule() -> chematic_core::Molecule {
1676        let mut b = MoleculeBuilder::new();
1677        let n = b.add_atom(Atom::new(Element::N));
1678        let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1679        let ring = [
1680            n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
1681        ];
1682        for i in 0..6 {
1683            let order = if i % 2 == 0 {
1684                BondOrder::Double
1685            } else {
1686                BondOrder::Single
1687            };
1688            b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
1689        }
1690        b.build()
1691    }
1692
1693    fn furan_kekule() -> chematic_core::Molecule {
1694        let mut b = MoleculeBuilder::new();
1695        let o = b.add_atom(Atom::new(Element::O));
1696        let c1 = b.add_atom(Atom::new(Element::C));
1697        let c2 = b.add_atom(Atom::new(Element::C));
1698        let c3 = b.add_atom(Atom::new(Element::C));
1699        let c4 = b.add_atom(Atom::new(Element::C));
1700        let ring = [o, c1, c2, c3, c4];
1701        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1702        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1703        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1704        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1705        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1706        b.build()
1707    }
1708
1709    fn pyrrole_kekule() -> chematic_core::Molecule {
1710        let mut b = MoleculeBuilder::new();
1711        let mut n_atom = Atom::new(Element::N);
1712        n_atom.hydrogen_count = Some(1);
1713        let n = b.add_atom(n_atom);
1714        let c1 = b.add_atom(Atom::new(Element::C));
1715        let c2 = b.add_atom(Atom::new(Element::C));
1716        let c3 = b.add_atom(Atom::new(Element::C));
1717        let c4 = b.add_atom(Atom::new(Element::C));
1718        let ring = [n, c1, c2, c3, c4];
1719        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1720        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1721        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1722        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1723        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1724        b.build()
1725    }
1726
1727    /// Same ring as `pyrrole_kekule()`, but the N has NO explicit
1728    /// `hydrogen_count` — matching how the SMILES parser actually builds a
1729    /// bare, non-bracket `N` (e.g. from `Chem.Kekulize` + non-canonical
1730    /// `MolToSmiles(kekuleSmiles=True)` round-tripping an `[nH]`-written
1731    /// pyrrole/imidazole/purine nitrogen). `pyrrole_kekule()` above sidesteps
1732    /// the bug this reproduces by setting `hydrogen_count` manually.
1733    fn pyrrole_kekule_implicit_h() -> chematic_core::Molecule {
1734        let mut b = MoleculeBuilder::new();
1735        let n = b.add_atom(Atom::new(Element::N));
1736        let c1 = b.add_atom(Atom::new(Element::C));
1737        let c2 = b.add_atom(Atom::new(Element::C));
1738        let c3 = b.add_atom(Atom::new(Element::C));
1739        let c4 = b.add_atom(Atom::new(Element::C));
1740        let ring = [n, c1, c2, c3, c4];
1741        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
1742        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
1743        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
1744        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
1745        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
1746        b.build()
1747    }
1748
1749    fn naphthalene_kekule() -> chematic_core::Molecule {
1750        let mut b = MoleculeBuilder::new();
1751        let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1752        let ring1 = [0usize, 1, 2, 3, 4, 9];
1753        let orders1 = [
1754            BondOrder::Double,
1755            BondOrder::Single,
1756            BondOrder::Double,
1757            BondOrder::Single,
1758            BondOrder::Double,
1759            BondOrder::Single,
1760        ];
1761        for i in 0..6 {
1762            b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
1763                .unwrap();
1764        }
1765        let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
1766        let orders2 = [
1767            BondOrder::Single,
1768            BondOrder::Double,
1769            BondOrder::Single,
1770            BondOrder::Double,
1771            BondOrder::Single,
1772        ];
1773        for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
1774            b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
1775        }
1776        b.build()
1777    }
1778
1779    fn cyclobutadiene_kekule() -> chematic_core::Molecule {
1780        let mut b = MoleculeBuilder::new();
1781        let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1782        for i in 0..4 {
1783            let order = if i % 2 == 0 {
1784                BondOrder::Double
1785            } else {
1786                BondOrder::Single
1787            };
1788            b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
1789        }
1790        b.build()
1791    }
1792
1793    fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
1794        let mut b = MoleculeBuilder::new();
1795        let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
1796        for i in 0..8 {
1797            let order = if i % 2 == 0 {
1798                BondOrder::Double
1799            } else {
1800                BondOrder::Single
1801            };
1802            b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
1803        }
1804        b.build()
1805    }
1806
1807    /// Helper: parse an aromatic SMILES and return the molecule with aromatic bonds
1808    /// (no kekulization).  Use for compounds where kekulization is unsupported.
1809    #[cfg(test)]
1810    fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
1811        chematic_smiles::parse(smiles).expect("valid SMILES")
1812    }
1813
1814    /// Helper: parse SMILES and kekulize.  Panics if kekulization fails.
1815    #[cfg(test)]
1816    fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
1817        let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
1818        let k = chematic_core::kekulize(&mol).expect("kekulizable");
1819        chematic_core::apply_kekule(&mol, &k)
1820    }
1821
1822    // =========================================================================
1823    // Regression: kekulized single-ring aromatics (Pass 1 only, no context)
1824    // =========================================================================
1825
1826    #[test]
1827    fn test_benzene_is_aromatic() {
1828        let mol = benzene_kekule();
1829        let model = assign_aromaticity(&mol);
1830        assert_eq!(
1831            model.aromatic_atom_count(),
1832            6,
1833            "all 6 benzene atoms aromatic"
1834        );
1835        for i in 0..6u32 {
1836            assert!(model.is_atom_aromatic(AtomIdx(i)));
1837        }
1838    }
1839
1840    #[test]
1841    fn test_cyclohexane_not_aromatic() {
1842        let mol = cyclohexane();
1843        let model = assign_aromaticity(&mol);
1844        assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
1845    }
1846
1847    #[test]
1848    fn test_pyridine_is_aromatic() {
1849        let mol = pyridine_kekule();
1850        let model = assign_aromaticity(&mol);
1851        assert_eq!(model.aromatic_atom_count(), 6);
1852    }
1853
1854    #[test]
1855    fn test_furan_is_aromatic() {
1856        let mol = furan_kekule();
1857        let model = assign_aromaticity(&mol);
1858        assert_eq!(model.aromatic_atom_count(), 5);
1859    }
1860
1861    #[test]
1862    fn test_pyrrole_is_aromatic() {
1863        let mol = pyrrole_kekule();
1864        let model = assign_aromaticity(&mol);
1865        assert_eq!(model.aromatic_atom_count(), 5);
1866    }
1867
1868    #[test]
1869    fn test_apply_aromaticity_preserves_pyrrole_nh_implicit_hydrogen() {
1870        // Regression test: apply_aromaticity_ex() normalizes all aromatic-
1871        // model ring bonds to BondOrder::Aromatic, which discards the
1872        // Kekule Single/Double pattern that distinguishes a pyrrole-type N
1873        // (needs 1 implicit H) from a pyridine-type N (needs 0) once both
1874        // have exactly 2 aromatic-order ring bonds and no explicit bracket
1875        // H count. Without preserving the pre-normalization value,
1876        // implicit_hcount() on the perceived molecule silently returns 0
1877        // instead of 1 for the unsubstituted pyrrole N -- wrong molecular
1878        // formula/weight, and a representation-dependent divergence from
1879        // the same molecule parsed directly from aromatic-written SMILES
1880        // (where `[nH]`'s bracket H count is correct by construction).
1881        let mol = pyrrole_kekule_implicit_h();
1882        let n_idx = AtomIdx(0);
1883        assert_eq!(
1884            implicit_hcount(&mol, n_idx),
1885            1,
1886            "pre-normalization: bare N with 2 single ring bonds must show 1 implicit H"
1887        );
1888
1889        let perceived = apply_aromaticity(&mol);
1890        assert!(perceived.atom(n_idx).aromatic, "ring N must be aromatic");
1891        assert_eq!(
1892            implicit_hcount(&perceived, n_idx),
1893            1,
1894            "post-apply_aromaticity: pyrrole N must still show 1 implicit H, not 0"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_apply_aromaticity_does_not_add_h_to_pyridine_type_n() {
1900        // Sibling check to the pyrrole regression above: a pyridine-type
1901        // ring N (1 ring single + 1 ring double pre-normalization, no H)
1902        // must NOT gain a spurious implicit H from the preservation logic --
1903        // its pre- and post-normalization implicit_hcount already agree
1904        // (both 0), so it must be left untouched.
1905        let mol = pyridine_kekule();
1906        let n_idx = AtomIdx(0);
1907        assert_eq!(implicit_hcount(&mol, n_idx), 0);
1908
1909        let perceived = apply_aromaticity(&mol);
1910        assert!(perceived.atom(n_idx).aromatic);
1911        assert_eq!(implicit_hcount(&perceived, n_idx), 0);
1912        assert_eq!(
1913            perceived.atom(n_idx).hydrogen_count,
1914            None,
1915            "pyridine N must not gain an explicit hydrogen_count -- would force spurious bracket notation"
1916        );
1917    }
1918
1919    #[test]
1920    fn test_naphthalene_both_rings_aromatic() {
1921        let mol = naphthalene_kekule();
1922        let model = assign_aromaticity(&mol);
1923        assert_eq!(
1924            model.aromatic_atom_count(),
1925            10,
1926            "all 10 naphthalene atoms aromatic"
1927        );
1928    }
1929
1930    #[test]
1931    fn test_bond_aromaticity_benzene() {
1932        let mol = benzene_kekule();
1933        let model = assign_aromaticity(&mol);
1934        let count = mol
1935            .bonds()
1936            .filter(|(b, _)| model.is_bond_aromatic(*b))
1937            .count();
1938        assert_eq!(count, 6);
1939    }
1940
1941    #[test]
1942    fn test_apply_aromaticity_benzene() {
1943        let mol = benzene_kekule();
1944        let aromatic = apply_aromaticity(&mol);
1945        for (_, atom) in aromatic.atoms() {
1946            assert!(atom.aromatic, "every benzene carbon should be aromatic");
1947        }
1948        let aromatic_bond_count = aromatic
1949            .bonds()
1950            .filter(|(_, b)| b.order == BondOrder::Aromatic)
1951            .count();
1952        assert_eq!(aromatic_bond_count, 6);
1953    }
1954
1955    #[test]
1956    fn test_apply_aromaticity_cyclohexane_unchanged() {
1957        let mol = cyclohexane();
1958        let result = apply_aromaticity(&mol);
1959        for (_, atom) in result.atoms() {
1960            assert!(!atom.aromatic);
1961        }
1962        for (_, bond) in result.bonds() {
1963            assert_ne!(bond.order, BondOrder::Aromatic);
1964        }
1965    }
1966
1967    // =========================================================================
1968    // Antiaromaticity
1969    // =========================================================================
1970
1971    #[test]
1972    fn test_cyclobutadiene_antiaromatic() {
1973        let mol = cyclobutadiene_kekule();
1974        let model = assign_aromaticity(&mol);
1975        assert_eq!(
1976            model.aromatic_atom_count(),
1977            0,
1978            "cyclobutadiene not aromatic"
1979        );
1980        assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
1981        assert_eq!(model.antiaromatic_rings().len(), 1);
1982        let classifications = model.ring_classifications();
1983        assert_eq!(classifications.len(), 1);
1984        assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
1985        assert_eq!(classifications[0].2, 4);
1986    }
1987
1988    #[test]
1989    fn test_cyclooctatetraene_antiaromatic() {
1990        let mol = cyclooctatetraene_kekule();
1991        let model = assign_aromaticity(&mol);
1992        assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
1993        assert!(model.has_antiaromaticity(), "COT antiaromatic");
1994        assert_eq!(model.antiaromatic_rings().len(), 1);
1995        let cls = &model.ring_classifications()[0];
1996        assert_eq!(cls.1, RingAromaticity::Antiaromatic);
1997        assert_eq!(cls.2, 8);
1998    }
1999
2000    // =========================================================================
2001    // Ring classifications
2002    // =========================================================================
2003
2004    #[test]
2005    fn test_ring_classifications_benzene() {
2006        let mol = benzene_kekule();
2007        let model = assign_aromaticity(&mol);
2008        let classifications = model.ring_classifications();
2009        assert_eq!(classifications.len(), 1);
2010        assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
2011        assert_eq!(classifications[0].2, 6);
2012    }
2013
2014    #[test]
2015    fn test_ring_classifications_naphthalene() {
2016        let mol = naphthalene_kekule();
2017        let model = assign_aromaticity(&mol);
2018        let classifications = model.ring_classifications();
2019        assert_eq!(classifications.len(), 2, "naphthalene has two rings");
2020        for (_, classification, count) in classifications {
2021            assert_eq!(*classification, RingAromaticity::Aromatic);
2022            assert_eq!(*count, 6);
2023        }
2024    }
2025
2026    #[test]
2027    fn test_non_aromatic_cyclohexane() {
2028        let mol = cyclohexane();
2029        let model = assign_aromaticity(&mol);
2030        for (_, classification, _) in model.ring_classifications() {
2031            assert_ne!(*classification, RingAromaticity::Aromatic);
2032            assert_ne!(*classification, RingAromaticity::Antiaromatic);
2033        }
2034    }
2035
2036    // =========================================================================
2037    // Electron distribution
2038    // =========================================================================
2039
2040    #[test]
2041    fn test_thiophene_aromatic() {
2042        let mut b = MoleculeBuilder::new();
2043        let s = b.add_atom(Atom::new(Element::S));
2044        let c1 = b.add_atom(Atom::new(Element::C));
2045        let c2 = b.add_atom(Atom::new(Element::C));
2046        let c3 = b.add_atom(Atom::new(Element::C));
2047        let c4 = b.add_atom(Atom::new(Element::C));
2048        let ring = [s, c1, c2, c3, c4];
2049        b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
2050        b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
2051        b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
2052        b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
2053        b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
2054        let mol = b.build();
2055        let model = assign_aromaticity(&mol);
2056        assert_eq!(model.aromatic_atom_count(), 5);
2057        assert_eq!(model.ring_classifications()[0].2, 6);
2058    }
2059
2060    #[test]
2061    fn test_electron_distribution_tracking() {
2062        let mol = benzene_kekule();
2063        let model = assign_aromaticity(&mol);
2064        assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
2065
2066        let mol = pyrrole_kekule();
2067        let model = assign_aromaticity(&mol);
2068        assert_eq!(
2069            model.ring_classifications()[0].2,
2070            6,
2071            "pyrrole: N(2π) + 4C(1π) = 6"
2072        );
2073
2074        let mol = furan_kekule();
2075        let model = assign_aromaticity(&mol);
2076        assert_eq!(
2077            model.ring_classifications()[0].2,
2078            6,
2079            "furan: O(2π) + 4C(1π) = 6"
2080        );
2081    }
2082
2083    // =========================================================================
2084    // Aromatic-SMILES input (BondOrder::Aromatic, no kekulization)
2085    // Verifies that assign_aromaticity works on pre-kekulization molecules.
2086    // =========================================================================
2087
2088    #[test]
2089    fn test_benzene_aromatic_smiles() {
2090        // c1ccccc1 — parsed with BondOrder::Aromatic bonds
2091        let mol = mol_aromatic("c1ccccc1");
2092        let model = assign_aromaticity(&mol);
2093        assert_eq!(
2094            model.aromatic_atom_count(),
2095            6,
2096            "benzene from aromatic SMILES"
2097        );
2098    }
2099
2100    #[test]
2101    fn test_naphthalene_aromatic_smiles() {
2102        let mol = mol_aromatic("c1ccc2ccccc2c1");
2103        let model = assign_aromaticity(&mol);
2104        assert_eq!(
2105            model.aromatic_atom_count(),
2106            10,
2107            "naphthalene from aromatic SMILES"
2108        );
2109    }
2110
2111    #[test]
2112    fn test_pyridine_aromatic_smiles() {
2113        let mol = mol_aromatic("c1ccncc1");
2114        let model = assign_aromaticity(&mol);
2115        assert_eq!(
2116            model.aromatic_atom_count(),
2117            6,
2118            "pyridine from aromatic SMILES"
2119        );
2120    }
2121
2122    #[test]
2123    fn test_furan_aromatic_smiles() {
2124        let mol = mol_aromatic("c1ccoc1");
2125        let model = assign_aromaticity(&mol);
2126        assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
2127    }
2128
2129    #[test]
2130    fn test_pyrrole_aromatic_smiles() {
2131        // [nH] bracket atom: hydrogen_count = Some(1)
2132        let mol = mol_aromatic("c1cc[nH]c1");
2133        let model = assign_aromaticity(&mol);
2134        assert_eq!(
2135            model.aromatic_atom_count(),
2136            5,
2137            "pyrrole from aromatic SMILES"
2138        );
2139    }
2140
2141    #[test]
2142    fn test_thiophene_aromatic_smiles() {
2143        let mol = mol_aromatic("c1ccsc1");
2144        let model = assign_aromaticity(&mol);
2145        assert_eq!(
2146            model.aromatic_atom_count(),
2147            5,
2148            "thiophene from aromatic SMILES"
2149        );
2150    }
2151
2152    // =========================================================================
2153    // Fused-ring kekulized systems (Pass 2 propagation)
2154    // =========================================================================
2155
2156    #[test]
2157    fn test_indole_aromatic() {
2158        // c1ccc2[nH]ccc2c1 — indole (9 atoms, 5-ring + 6-ring fused)
2159        let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
2160        let model = assign_aromaticity(&mol);
2161        assert_eq!(
2162            model.aromatic_atom_count(),
2163            9,
2164            "all 9 indole atoms aromatic"
2165        );
2166    }
2167
2168    #[test]
2169    fn test_benzimidazole_aromatic() {
2170        // Two N atoms in fused 5+6 ring system
2171        let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
2172        let model = assign_aromaticity(&mol);
2173        assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
2174    }
2175
2176    #[test]
2177    fn test_quinoline_aromatic() {
2178        let mol = mol_kekulized("c1ccc2ncccc2c1");
2179        let model = assign_aromaticity(&mol);
2180        assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
2181    }
2182
2183    #[test]
2184    fn test_acridine_aromatic() {
2185        // 3 fused 6-membered rings, central N: 13 atoms
2186        let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
2187        let model = assign_aromaticity(&mol);
2188        // acridine is C13H9N → 14 heavy atoms (13 C + 1 N), all aromatic
2189        assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
2190    }
2191
2192    // =========================================================================
2193    // Fused-ring aromatic-SMILES input (BondOrder::Aromatic, kekulize fails)
2194    // =========================================================================
2195
2196    #[test]
2197    fn test_indolizine_aromatic() {
2198        // c1ccn2cccc2c1 — indolizine: bridgehead N, kekulization unsupported.
2199        // The SSSR finds a 6-ring and a 9-ring; the 5-ring is recovered via
2200        // augmentation (XOR of 6- and 9-ring).
2201        // Pass 1: 5-ring (augmented) detected via bridgehead-N rule → 6π.
2202        // Pass 2: 6-ring detected using N already aromatic from 5-ring → 6π.
2203        // The 9-ring (SSSR artifact) is NonAromatic (9π ≠ 4n+2), but all
2204        // 9 atoms are correctly flagged aromatic via the 5- and 6-ring.
2205        let mol = mol_aromatic("c1ccn2cccc2c1");
2206        let model = assign_aromaticity(&mol);
2207        assert_eq!(
2208            model.aromatic_atom_count(),
2209            9,
2210            "all 9 indolizine atoms aromatic"
2211        );
2212        // At least the 6-ring should be classified as Aromatic in the SSSR set.
2213        let has_aromatic_ring = model
2214            .ring_classifications()
2215            .iter()
2216            .any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
2217        assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
2218    }
2219
2220    #[test]
2221    #[ignore = "PROVISIONAL: regressed by the Horton SSSR fix, see comment below"]
2222    fn test_purine_aromatic() {
2223        // c1cnc2[nH]cnc2n1 — purine: 9 atoms, kekulizable
2224        //
2225        // Regressed by the Horton SSSR rewrite (confirmed passing on the old
2226        // single-spanning-tree find_sssr, failing only after Horton; see
2227        // debug dump captured during diagnosis). Root cause, empirically
2228        // confirmed: the 6-membered ring (pyrimidine-type) passes Pass 1
2229        // alone (6π) and marks its atoms aromatic. The 5-membered ring
2230        // (imidazole-type) evaluates to 4π in isolation — its two fusion
2231        // carbons each have their only double bond exocyclic to a ring N,
2232        // which the exocyclic-to-heteroatom rule scores as 0π — and 4π trips
2233        // `classify_ring_aromaticity`'s "4n → Antiaromatic" branch. Pass 1
2234        // treats Antiaromatic as definitive and never retries it in Pass 2,
2235        // even though the fusion carbons would each contribute 1π (not 0π)
2236        // once `aromatic_context` recognizes them as already-aromatic — that
2237        // recount gives 6π (aromatic). The old, non-minimal SSSR never hit
2238        // this path because it fed a different (structurally wrong) ring set
2239        // into Pass 1 in the first place.
2240        //
2241        // Fix belongs in the aromatic_context-removal PR (see
2242        // greedy-hopping-crescent.md step 5), not here: retrying
2243        // Antiaromatic rings in Pass 2 is a real fix, but must not be
2244        // bundled into the SSSR PR per the "measure free recoveries with
2245        // zero aromaticity.rs changes" staging requirement.
2246        let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
2247        let model = assign_aromaticity(&mol);
2248        assert_eq!(
2249            model.aromatic_atom_count(),
2250            9,
2251            "all 9 purine atoms aromatic"
2252        );
2253    }
2254
2255    #[test]
2256    fn test_purine_aromatic_from_aromatic_smiles() {
2257        let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
2258        let model = assign_aromaticity(&mol);
2259        assert_eq!(
2260            model.aromatic_atom_count(),
2261            9,
2262            "purine from aromatic SMILES"
2263        );
2264    }
2265
2266    #[test]
2267    fn test_2_pyridinone_aromatic() {
2268        // O=c1ccncc1 — 2-pyridinone (aromatic SMILES, N without H, exo C=O).
2269        // Kekulization fails; tested on the aromatic-bond form directly.
2270        // The exo C=O gives the C atom has_double_any=true → 1π.
2271        // N has Aromatic bonds in ring → 1π (pyridine-like).
2272        // Total: 6 × 1π = 6π → aromatic.
2273        let mol = mol_aromatic("O=c1ccncc1");
2274        let model = assign_aromaticity(&mol);
2275        assert_eq!(
2276            model.aromatic_atom_count(),
2277            6,
2278            "all 6 ring atoms of 2-pyridinone aromatic"
2279        );
2280    }
2281
2282    #[test]
2283    fn test_quinolone_aromatic() {
2284        // O=c1ccc2ncccc2c1 — quinolone: fused 6+6 with exo C=O, kekulize fails
2285        let mol = mol_aromatic("O=c1ccc2ncccc2c1");
2286        let model = assign_aromaticity(&mol);
2287        assert_eq!(
2288            model.aromatic_atom_count(),
2289            10,
2290            "all 10 quinolone ring atoms aromatic"
2291        );
2292        assert_eq!(
2293            model.ring_classifications().len(),
2294            2,
2295            "two rings classified"
2296        );
2297    }
2298
2299    #[test]
2300    fn test_indole_aromatic_smiles() {
2301        let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
2302        let model = assign_aromaticity(&mol);
2303        assert_eq!(
2304            model.aromatic_atom_count(),
2305            9,
2306            "indole from aromatic SMILES"
2307        );
2308    }
2309
2310    // =========================================================================
2311    // Bridgehead N rule: specifically test that the rule fires correctly
2312    // =========================================================================
2313
2314    #[test]
2315    fn test_bridgehead_n_contributes_lone_pair() {
2316        // Indolizine: the bridgehead N (degree 3, no H, no explicit double bond)
2317        // must be detected as a 2π contributor for the 5-membered ring.
2318        // We verify by checking the 5-ring classification (if accessible).
2319        let mol = mol_aromatic("c1ccn2cccc2c1");
2320        let model = assign_aromaticity(&mol);
2321        // All 9 atoms aromatic: both rings must be aromatic.
2322        assert_eq!(model.aromatic_atom_count(), 9);
2323        // The bridgehead N itself must be in the aromatic set.
2324        // In the SMILES c1ccn2cccc2c1, n is atom index 3.
2325        assert!(
2326            model.is_atom_aromatic(AtomIdx(3)),
2327            "bridgehead N must be aromatic"
2328        );
2329    }
2330
2331    #[test]
2332    fn test_non_bridgehead_n_no_false_positive() {
2333        // Pyrimidine: two N atoms in a 6-membered ring, no bridgehead.
2334        // Both N have ring_degree == total_degree == 2.
2335        // Should be detected as aromatic via has_aromatic_in_ring (Aromatic bonds).
2336        let mol = mol_aromatic("c1ccncn1");
2337        let model = assign_aromaticity(&mol);
2338        assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
2339    }
2340
2341    #[test]
2342    fn test_imidazole_aromatic() {
2343        // c1cn[nH]c1 / c1c[nH]cn1 — imidazole: one pyridine-type N, one pyrrole-type N
2344        let mol = mol_aromatic("c1cn[nH]c1");
2345        let model = assign_aromaticity(&mol);
2346        assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
2347    }
2348
2349    // =========================================================================
2350    // Pass 2 specifically: rings that need fused-ring context
2351    // =========================================================================
2352
2353    #[test]
2354    fn test_pass2_needed_for_indolizine_6ring() {
2355        // The augmented 5-ring (XOR of SSSR 6-ring and 9-ring) is detected aromatic in Pass 1.
2356        // The SSSR 6-ring is then detected aromatic in Pass 2 (N already aromatic → 1π).
2357        // The SSSR 9-ring (9π) remains NonAromatic per Hückel.
2358        // Key assertion: all 9 atoms are aromatic (correct overall perception).
2359        let mol = mol_aromatic("c1ccn2cccc2c1");
2360        let model = assign_aromaticity(&mol);
2361        assert_eq!(
2362            model.aromatic_atom_count(),
2363            9,
2364            "all 9 indolizine atoms aromatic"
2365        );
2366        // The bridgehead N must be aromatic.
2367        assert!(
2368            model.is_atom_aromatic(AtomIdx(3)),
2369            "bridgehead N is aromatic"
2370        );
2371        // The 6-ring (SSSR ring, improved by Pass 2) should be classified Aromatic.
2372        let aromatic_count = model
2373            .ring_classifications()
2374            .iter()
2375            .filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
2376            .count();
2377        assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
2378    }
2379
2380    #[test]
2381    fn test_no_pass2_needed_for_naphthalene() {
2382        // Naphthalene: both rings pass independently in Pass 1.
2383        // Verifies Pass 2 doesn't break things that already work.
2384        let mol = naphthalene_kekule();
2385        let model = assign_aromaticity(&mol);
2386        assert_eq!(model.aromatic_atom_count(), 10);
2387        let classes = model.ring_classifications();
2388        assert_eq!(classes.len(), 2);
2389        for (_, cls, _) in classes {
2390            assert_eq!(*cls, RingAromaticity::Aromatic);
2391        }
2392    }
2393
2394    #[test]
2395    fn test_anthracene_aromatic() {
2396        // c1ccc2cc3ccccc3cc2c1 — anthracene: 3 linearly fused 6-rings, 14 atoms
2397        let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
2398        let model = assign_aromaticity(&mol);
2399        assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
2400    }
2401
2402    // =========================================================================
2403    // Regression: aromatic-bond path must not perturb kekulized correctness
2404    // =========================================================================
2405
2406    #[test]
2407    fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
2408        // Kekulized benzene: bonds are Double/Single, not Aromatic.
2409        // The new Aromatic-bond branches must stay dormant.
2410        let mol = benzene_kekule();
2411        // Verify no aromatic bonds in input.
2412        for (_, bond) in mol.bonds() {
2413            assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
2414        }
2415        let model = assign_aromaticity(&mol);
2416        assert_eq!(model.aromatic_atom_count(), 6);
2417        // All 6 bonds in benzene ring should be aromatic.
2418        let aromatic_bonds = mol
2419            .bonds()
2420            .filter(|(b, _)| model.is_bond_aromatic(*b))
2421            .count();
2422        assert_eq!(aromatic_bonds, 6);
2423    }
2424
2425    #[test]
2426    fn test_keto_pyridinone_aromatic() {
2427        // O=C1NC=CC=C1 — 2-pyridinone keto form with N-H.
2428        // π count: C(=O)(0π, exocyclic-only double bond to O) + N-H(2π) +
2429        // 4×C in 2 ring C=C (1π each) = 6π → aromatic. Matches RDKit, which
2430        // marks all 6 ring atoms aromatic (exocyclic O stays non-aromatic).
2431        let mol = mol_kekulized("O=C1NC=CC=C1");
2432        let model = assign_aromaticity(&mol);
2433        assert_eq!(
2434            model.aromatic_atom_count(),
2435            6,
2436            "keto pyridinone ring is Hückel aromatic (6π = 4n+2)"
2437        );
2438    }
2439
2440    #[test]
2441    fn test_tropone_aromatic() {
2442        // O=C1C=CC=CC=C1 — tropone (cycloheptatrienone), Kekulized input.
2443        // Carbonyl C contributes 0π (exocyclic-only double bond to O); the
2444        // other 6 ring carbons contribute 1π each from 3 endocyclic C=C.
2445        // Total 6π → aromatic, matching RDKit (all 7 ring atoms aromatic).
2446        let mol = mol_kekulized("O=C1C=CC=CC=C1");
2447        let model = assign_aromaticity(&mol);
2448        assert_eq!(
2449            model.aromatic_atom_count(),
2450            7,
2451            "all 7 tropone ring atoms aromatic"
2452        );
2453    }
2454
2455    #[test]
2456    fn test_4_pyridone_aromatic() {
2457        // O=C1C=CNC=C1 — 4-pyridone, Kekulized input. Same 6π accounting as
2458        // 2-pyridone, just with N para to the carbonyl. Matches RDKit.
2459        let mol = mol_kekulized("O=C1C=CNC=C1");
2460        let model = assign_aromaticity(&mol);
2461        assert_eq!(
2462            model.aromatic_atom_count(),
2463            6,
2464            "all 6 4-pyridone ring atoms aromatic"
2465        );
2466    }
2467
2468    #[test]
2469    fn test_pyranone_aromatic() {
2470        // O=C1C=COC=C1 — 4H-pyran-4-one, Kekulized input. Ring O contributes
2471        // 2π (lone pair), carbonyl C contributes 0π, remaining 4 ring carbons
2472        // contribute 1π each from 2 endocyclic C=C. Total 6π. Matches RDKit.
2473        let mol = mol_kekulized("O=C1C=COC=C1");
2474        let model = assign_aromaticity(&mol);
2475        assert_eq!(
2476            model.aromatic_atom_count(),
2477            6,
2478            "all 6 pyranone ring atoms aromatic"
2479        );
2480    }
2481
2482    #[test]
2483    fn test_cyclopentadienyl_anion_aromatic() {
2484        // [CH-]1C=CC=C1 — cyclopentadienyl anion. The carbanion carbon has no
2485        // double bond but contributes 2π (lone pair); the other 4 carbons
2486        // contribute 1π each from 2 endocyclic C=C. Total 6π. Matches RDKit
2487        // (all 5 atoms aromatic).
2488        let mol = mol_kekulized("[CH-]1C=CC=C1");
2489        let model = assign_aromaticity(&mol);
2490        assert_eq!(
2491            model.aromatic_atom_count(),
2492            5,
2493            "all 5 cyclopentadienyl anion atoms aromatic"
2494        );
2495    }
2496
2497    // ── K2a: charge-aware ring_pi_electrons -- tropylium/imidazolium/
2498    // pyridinium/pyrylium now genuinely confirmed aromatic by the raw
2499    // Huckel model itself (not just a stale parser flag surviving), under
2500    // BOTH documented calling conventions (`apply_aromaticity`'s own doc
2501    // comment: "may be kekulized... or may retain Aromatic bond orders from
2502    // the SMILES parser"). RDKit-verified: all four are aromatic cations,
2503    // all-atom/all-bond, per rdkit==2026.03.3 (see
2504    // docs/aromaticity_rdkit_parity_rfc.md and the K2a PR description for
2505    // the full 40-fixture oracle re-run against a live RDKit).
2506    //
2507    // K1 (fix/kekulize-charge-aware-k1, already merged) made
2508    // chematic_core::kekulize() succeed for all four; this fix is the
2509    // separate, independent charge-blindness bug in the Huckel
2510    // pi-electron-counting layer (`ring_pi_electrons`) that K1 explicitly
2511    // did not touch. Deliberately does NOT touch `build_molecule_from_model`
2512    // (that promote-only-vs-demote question is tracked separately as K2b) --
2513    // these four fixtures need no demotion at all: their atom flags were
2514    // already `true` from the aromatic-notation parse, and once the model
2515    // itself confirms the ring, the EXISTING promote-only bond loop already
2516    // correctly promotes their bonds to `Aromatic` for the first time. That
2517    // is what actually fixes the pre-existing atom/bond flag inconsistency
2518    // for these four -- no demotion capability required.
2519    fn assert_fully_aromatic(mol: &Molecule, n: usize, label: &str) {
2520        let applied = apply_aromaticity(mol);
2521        for (idx, atom) in applied.atoms() {
2522            assert!(atom.aromatic, "{label}: atom {idx:?} should be aromatic");
2523        }
2524        assert_eq!(applied.atom_count(), n, "{label}: unexpected atom count");
2525        for (_, bond) in applied.bonds() {
2526            assert_eq!(
2527                bond.order,
2528                BondOrder::Aromatic,
2529                "{label}: every ring bond should end up Aromatic order"
2530            );
2531        }
2532    }
2533
2534    #[test]
2535    fn test_tropylium_cation_aromatic_raw_and_kekulized() {
2536        let raw = chematic_smiles::parse("c1ccc[cH+]cc1").expect("valid SMILES");
2537        assert_fully_aromatic(&raw, 7, "tropylium (raw)");
2538        let kek = mol_kekulized("c1ccc[cH+]cc1");
2539        assert_fully_aromatic(&kek, 7, "tropylium (kekulized)");
2540        assert_eq!(
2541            assign_aromaticity(&raw).aromatic_atom_count(),
2542            7,
2543            "tropylium: raw model itself must confirm all 7 atoms, not rely on a stale flag"
2544        );
2545        assert_eq!(
2546            assign_aromaticity(&kek).aromatic_atom_count(),
2547            7,
2548            "tropylium: kekulized model itself must confirm all 7 atoms"
2549        );
2550    }
2551
2552    #[test]
2553    fn test_imidazolium_aromatic_raw_and_kekulized() {
2554        let raw = chematic_smiles::parse("c1c[nH+]c[nH]1").expect("valid SMILES");
2555        assert_fully_aromatic(&raw, 5, "imidazolium (raw)");
2556        let kek = mol_kekulized("c1c[nH+]c[nH]1");
2557        assert_fully_aromatic(&kek, 5, "imidazolium (kekulized)");
2558        assert_eq!(assign_aromaticity(&raw).aromatic_atom_count(), 5);
2559        assert_eq!(assign_aromaticity(&kek).aromatic_atom_count(), 5);
2560    }
2561
2562    #[test]
2563    fn test_pyridinium_aromatic_raw_and_kekulized() {
2564        let raw = chematic_smiles::parse("c1cc[nH+]cc1").expect("valid SMILES");
2565        assert_fully_aromatic(&raw, 6, "pyridinium (raw)");
2566        let kek = mol_kekulized("c1cc[nH+]cc1");
2567        assert_fully_aromatic(&kek, 6, "pyridinium (kekulized)");
2568        assert_eq!(assign_aromaticity(&raw).aromatic_atom_count(), 6);
2569        assert_eq!(assign_aromaticity(&kek).aromatic_atom_count(), 6);
2570    }
2571
2572    #[test]
2573    fn test_pyrylium_aromatic_raw_and_kekulized() {
2574        let raw = chematic_smiles::parse("c1cc[o+]cc1").expect("valid SMILES");
2575        assert_fully_aromatic(&raw, 6, "pyrylium (raw)");
2576        let kek = mol_kekulized("c1cc[o+]cc1");
2577        assert_fully_aromatic(&kek, 6, "pyrylium (kekulized)");
2578        assert_eq!(assign_aromaticity(&raw).aromatic_atom_count(), 6);
2579        assert_eq!(assign_aromaticity(&kek).aromatic_atom_count(), 6);
2580    }
2581
2582    // ── K2a scope guard: tellurophene/phosphole are explicitly NOT fixed by
2583    // the charge-aware change above (they need real Se/Te/P electron-donor
2584    // support in the default Huckel engine, out of scope -- see the K2a/K2b
2585    // PR descriptions). Pin the current (still-gap) count so a future
2586    // change to this area doesn't silently start claiming these are fixed
2587    // without an explicit, source-grounded review.
2588    #[test]
2589    fn test_tellurophene_and_phosphole_still_unsupported_under_default_huckel() {
2590        let te = mol_kekulized("c1cc[te]c1");
2591        assert_eq!(
2592            assign_aromaticity(&te).aromatic_atom_count(),
2593            0,
2594            "tellurophene: still unsupported under default Huckel (K2a does not add Te support)"
2595        );
2596        let p = mol_kekulized("c1cc[pH]c1");
2597        assert_eq!(
2598            assign_aromaticity(&p).aromatic_atom_count(),
2599            0,
2600            "phosphole: still unsupported under default Huckel (K2a does not add P support)"
2601        );
2602    }
2603
2604    // ── N-substituted pyrrole-type N: bridgehead-branch guard removal ────────
2605    //
2606    // The bridgehead-N branch used to require the exocyclic substituent to be
2607    // sp2, to defensively block imide N (phthalimide). That guard also
2608    // blocked the much more common case of a plain alkyl/aryl/sugar
2609    // substituent on an otherwise-aromatic pyrrole-type N. It was removed;
2610    // these tests cover both the newly-fixed cases and the phthalimide
2611    // regression it was guarding against (which stays correct via the
2612    // overall 4n+2 sum, not the substituent).
2613
2614    #[test]
2615    fn test_n_methylpyrrole_aromatic() {
2616        let mol = mol_kekulized("CN1C=CC=C1");
2617        let model = assign_aromaticity(&mol);
2618        assert_eq!(
2619            model.aromatic_atom_count(),
2620            5,
2621            "all 5 N-methylpyrrole ring atoms aromatic"
2622        );
2623    }
2624
2625    #[test]
2626    fn test_n_methylimidazole_aromatic() {
2627        let mol = mol_kekulized("CN1C=CN=C1");
2628        let model = assign_aromaticity(&mol);
2629        assert_eq!(
2630            model.aromatic_atom_count(),
2631            5,
2632            "all 5 N-methylimidazole ring atoms aromatic"
2633        );
2634    }
2635
2636    #[test]
2637    fn test_n_methylindole_aromatic() {
2638        let mol = mol_kekulized("CN1C=CC2=CC=CC=C21");
2639        let model = assign_aromaticity(&mol);
2640        assert_eq!(
2641            model.aromatic_atom_count(),
2642            9,
2643            "all 9 N-methylindole ring atoms aromatic"
2644        );
2645    }
2646
2647    #[test]
2648    fn test_9_methylpurine_aromatic() {
2649        let mol = mol_kekulized("CN1C=NC2=NC=NC=C21");
2650        let model = assign_aromaticity(&mol);
2651        assert_eq!(
2652            model.aromatic_atom_count(),
2653            9,
2654            "all 9 9-methylpurine ring atoms aromatic"
2655        );
2656    }
2657
2658    #[test]
2659    fn test_phthalimide_5ring_not_aromatic() {
2660        // O=C1NC(=O)c2ccccc21 — only the fused benzo ring is aromatic (6
2661        // atoms); the imide 5-ring (2 carbonyl C + N) is not: carbonyl
2662        // carbons contribute 0π each (exocyclic C=O rule), N contributes 2π,
2663        // the two ring-fusion carbons contribute 1π each — 4π total, not
2664        // 4n+2. Regression guard for the bridgehead-N guard removal above.
2665        let mol = mol_kekulized("O=C1NC(=O)c2ccccc21");
2666        let model = assign_aromaticity(&mol);
2667        assert_eq!(
2668            model.aromatic_atom_count(),
2669            6,
2670            "only the 6 benzo atoms of phthalimide are aromatic"
2671        );
2672    }
2673
2674    #[test]
2675    fn test_n_methylphthalimide_5ring_not_aromatic() {
2676        // O=C1N(C)C(=O)c2ccccc21 — same as phthalimide but N-methylated;
2677        // same accounting applies (N still contributes 2π regardless of
2678        // substituent), 5-ring still non-aromatic.
2679        let mol = mol_kekulized("O=C1N(C)C(=O)c2ccccc21");
2680        let model = assign_aromaticity(&mol);
2681        assert_eq!(
2682            model.aromatic_atom_count(),
2683            6,
2684            "only the 6 benzo atoms of N-methylphthalimide are aromatic"
2685        );
2686    }
2687
2688    #[test]
2689    #[ignore = "PROVISIONAL: regressed by the Horton SSSR fix, see comment below"]
2690    fn test_azulene_kekulized_aromatic() {
2691        // C1=CC2=CC=CC=CC2=C1 — non-alternant fused bicyclic, all 10 atoms
2692        // aromatic per RDKit. Regression coverage: this was previously
2693        // (incorrectly) believed to need a ring-system rewrite, based on a
2694        // test that never called apply_aromaticity() on Kekulized input.
2695        //
2696        // Regressed by the Horton SSSR rewrite (confirmed passing on the old
2697        // single-spanning-tree find_sssr, failing only after Horton). Root
2698        // cause, empirically confirmed via debug dump: Horton's correct,
2699        // minimal SSSR is exactly the 5-ring + 7-ring (matches RDKit). Each
2700        // evaluated standalone has an ODD pi-electron count (5-ring: 5pi,
2701        // 7-ring: 7pi — every ring atom contributes 1pi via a double bond,
2702        // whether the double bond is endo- or exocyclic-to-a-carbon), so
2703        // neither passes Pass 1 and neither can seed Pass 2's
2704        // aromatic_context bootstrap. Azulene's aromaticity is a genuinely
2705        // non-alternant, whole-perimeter (10-atom, 10pi) delocalized system
2706        // — it needs the full-ring-system envelope as a Hückel candidate,
2707        // which `augmented_ring_set` deliberately excludes (its docstring
2708        // names naphthalene's spurious 10-ring as the exact case to avoid).
2709        // The old, non-minimal SSSR happened to hand a large fundamental
2710        // cycle straight to Pass 1 that included the whole perimeter,
2711        // papering over this gap by coincidence.
2712        //
2713        // Fix belongs in the aromatic_context-removal PR (see
2714        // greedy-hopping-crescent.md step 5: "candidate rings = SSSR ∪ fused
2715        // envelopes"), not here — adding an envelope-candidate fallback in
2716        // this PR would be compensating code that step 5's fixed-point
2717        // ring-system evaluation subsumes and would need to delete anyway.
2718        let mol = mol_kekulized("C1=CC2=CC=CC=CC2=C1");
2719        let model = assign_aromaticity(&mol);
2720        assert_eq!(
2721            model.aromatic_atom_count(),
2722            10,
2723            "all 10 azulene atoms aromatic"
2724        );
2725    }
2726
2727    // ── RDKit #9271: charged / zwitterionic aromatic systems ─────────────────
2728
2729    #[test]
2730    fn test_fluorescein_dianion_aromatic() {
2731        // Fluorescein dianion: RDKit #9271 incorrectly marked xanthene bonds as
2732        // single instead of aromatic. Verify chematic parses and identifies
2733        // aromatic atoms correctly (two benzene rings + xanthene O-bridge ring).
2734        // Kekulé-form SMILES: all atoms uppercase.
2735        let smi = "C1=CC=C(C(=C1)C2=C3C=CC(=O)C=C3OC4=C2C=CC(=C4)[O-])C(=O)[O-]";
2736        let mol = chematic_smiles::parse(smi).expect("fluorescein dianion should parse");
2737        // The molecule should parse without panic. Verify aromatic ring count:
2738        // fluorescein has 3 aromatic rings (2 benzene + xanthene core).
2739        let arc = count_aromatic_rings(&mol);
2740        assert!(
2741            arc >= 2,
2742            "fluorescein dianion: expected ≥2 aromatic rings, got {arc} \
2743             (RDKit #9271: charged aromatics may be misclassified)"
2744        );
2745    }
2746
2747    #[test]
2748    fn test_rhodamine_zwitterion_parses() {
2749        // Rhodamine-type zwitterion with N+ and bridging O (RDKit #9271).
2750        // Must parse cleanly and produce a valid aromatic ring count.
2751        let smi = "CCN(CC)c1ccc2c(-c3ccccc3C(=O)O)c3ccc(=[N+](CC)CC)cc-3oc2c1";
2752        let mol = chematic_smiles::parse(smi).expect("rhodamine zwitterion should parse");
2753        let arc = count_aromatic_rings(&mol);
2754        assert!(arc >= 3, "rhodamine: expected ≥3 aromatic rings, got {arc}");
2755    }
2756
2757    #[test]
2758    fn test_cyclopentadienyl_not_aromatic_kekulized() {
2759        // C1=CC=CC1 — cyclopentadiene (4 C with doubles + 1 sp3 CH2): not aromatic.
2760        let mut b = MoleculeBuilder::new();
2761        let c0 = b.add_atom(Atom::new(Element::C)); // sp3
2762        let c1 = b.add_atom(Atom::new(Element::C));
2763        let c2 = b.add_atom(Atom::new(Element::C));
2764        let c3 = b.add_atom(Atom::new(Element::C));
2765        let c4 = b.add_atom(Atom::new(Element::C));
2766        b.add_bond(c0, c1, BondOrder::Single).unwrap();
2767        b.add_bond(c1, c2, BondOrder::Double).unwrap();
2768        b.add_bond(c2, c3, BondOrder::Single).unwrap();
2769        b.add_bond(c3, c4, BondOrder::Double).unwrap();
2770        b.add_bond(c4, c0, BondOrder::Single).unwrap();
2771        let mol = b.build();
2772        let model = assign_aromaticity(&mol);
2773        assert_eq!(
2774            model.aromatic_atom_count(),
2775            0,
2776            "cyclopentadiene not aromatic"
2777        );
2778    }
2779
2780    // =========================================================================
2781    // RdkitLike mode: Se/Te chalcogen heteroaromatics
2782    // =========================================================================
2783
2784    #[test]
2785    fn test_selenophene_huckel_not_aromatic() {
2786        // c1cc[se]c1 — in strict Hückel mode, Se is unsupported → 0 aromatic atoms
2787        // (assign_aromaticity_ex re-derives from scratch, ignoring parser's aromatic flags)
2788        let mol = mol_aromatic("c1cc[se]c1");
2789        let m = assign_aromaticity(&mol); // default Hückel
2790        assert_eq!(
2791            m.aromatic_atom_count(),
2792            0,
2793            "selenophene: Se not aromatic in Hückel mode"
2794        );
2795    }
2796
2797    #[test]
2798    fn test_selenophene_rdkit_aromatic() {
2799        // c1cc[se]c1 — in RdkitLike mode, Se donates 2π → 6π total → aromatic
2800        let mol = mol_aromatic("c1cc[se]c1");
2801        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
2802        assert_eq!(
2803            m.aromatic_atom_count(),
2804            5,
2805            "selenophene: all 5 atoms aromatic in RdkitLike"
2806        );
2807    }
2808
2809    #[test]
2810    fn test_tellurophene_rdkit_aromatic() {
2811        // c1cc[te]c1 — Te analogous to Se (2π donor)
2812        let mol = mol_aromatic("c1cc[te]c1");
2813        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
2814        assert_eq!(
2815            m.aromatic_atom_count(),
2816            5,
2817            "tellurophene: all 5 atoms aromatic in RdkitLike"
2818        );
2819    }
2820
2821    #[test]
2822    fn test_benzoselenophene_rdkit() {
2823        // Fused benzene + selenophene
2824        let mol = mol_aromatic("c1ccc2[se]ccc2c1");
2825        let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
2826        assert_eq!(
2827            m.aromatic_atom_count(),
2828            9,
2829            "benzoselenophene: 9 atoms aromatic"
2830        );
2831    }
2832
2833    #[test]
2834    fn test_rdkit_mode_does_not_break_benzene() {
2835        // Benzene must give same result in both modes
2836        let mol = mol_aromatic("c1ccccc1");
2837        let m_h = assign_aromaticity(&mol);
2838        let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
2839        assert_eq!(m_h.aromatic_atom_count(), m_r.aromatic_atom_count());
2840    }
2841
2842    #[test]
2843    fn test_rdkit_mode_does_not_break_thiophene() {
2844        let mol = mol_aromatic("c1ccsc1");
2845        let m_h = assign_aromaticity(&mol);
2846        let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
2847        assert_eq!(
2848            m_h.aromatic_atom_count(),
2849            m_r.aromatic_atom_count(),
2850            "thiophene same in both modes"
2851        );
2852    }
2853
2854    // ── Known regressions from fix #2 (bridgehead-N guard removal) ──────────
2855    //
2856    // Re-measured after the Horton SSSR rewrite landed (find_sssr is now
2857    // minimal and deterministic, 0% self-instability on the 5000-molecule
2858    // corpus): all 32 counts below are UNCHANGED. Zero free recoveries.
2859    // This confirms these regressions are caused entirely by the
2860    // `aromatic_context` bypass, independent of SSSR ring selection -- the
2861    // two bugs don't interact for this molecule class.
2862    //
2863    // These 32 molecules share one root cause: a "fake bridgehead" N (same
2864    // local shape as a genuine bridgehead or N-substituted azole) feeds a
2865    // central ring that only closes via the `aromatic_context` bypass reusing
2866    // an unrelated ring's atoms. Fixing this requires removing the bypass in
2867    // favor of proper ring-system candidate enumeration (see project plan/
2868    // issue tracker). Pinned here as *known-wrong* so the eventual fix is
2869    // measurable by how many of these flip from this assertion to correct,
2870    // not just by an aggregate corpus percentage.
2871    // (kekulized SMILES, current chematic aromatic_atom_count(), RDKit's correct count).
2872    // Named at module level (not a local in the test below) so
2873    // Aromaticity-A1-0's corpus tests, further down this module, can reuse
2874    // the identical pinned data instead of re-deriving a copy that could
2875    // silently drift out of sync with it.
2876    const KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES: &[(&str, usize, usize)] = &[
2877        ("C[Si](C)(C)C1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
2878        (
2879            "C1=C(C2=CC=C(CCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
2880            22,
2881            18,
2882        ),
2883        ("ClC1=CC=C(OCC2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
2884        ("N[C@@H](CC1=CC=CC=C1)C1=CC2=CC=CC=C2C2=NCCCN12", 16, 12),
2885        (
2886            "CC(C)(C)C1=CC=C(C2=C(CC3=CC=CC=C3)C3=CC=CC=C3C3=NCCCN32)C=C1",
2887            22,
2888            18,
2889        ),
2890        (
2891            "C[Si](C)(C)C1=CC=C(C2=C(CC3=CC=CC=C3)C3=CC=CC=C3C3=NCCCN32)C=C1",
2892            22,
2893            18,
2894        ),
2895        (
2896            "C1=C(C2=CC=C(C3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
2897            22,
2898            18,
2899        ),
2900        (
2901            "C1=C(C2=CC=C(OCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
2902            22,
2903            18,
2904        ),
2905        ("COC1=C(OC)C(OC)=CC(C2=CC3=CC=CC=C3C3=NCCCN23)=C1", 16, 12),
2906        ("CC1=CC2=CC=CC=C2C2=NCCCN12", 10, 6),
2907        (
2908            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)NC4CCCCC4)C=C3)C3=NCCCN23)C=C1",
2909            16,
2910            12,
2911        ),
2912        (
2913            "C1=CC=C(CCC2=CC=C(C3=C(CC4=CC=CC=C4)C4=CC=CC=C4C4=NCCCN43)C=C2)C=C1",
2914            28,
2915            24,
2916        ),
2917        (
2918            "CCCCC1=C(C2=CC=C(CCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
2919            22,
2920            18,
2921        ),
2922        (
2923            "CCCCC1=C(C2=CC=C(C(C)(C)C)C=C2)N2CCCN=C2C2=CC=CC=C12",
2924            16,
2925            12,
2926        ),
2927        ("CCCCCCC1=CC2=CC=CC=C2C2=NCCCN12", 10, 6),
2928        (
2929            "CCOC1=CC=C(CC2=C(CCCC3=CC=CC4=CC=CC=C34)N3CCCN=C3C3=CC=CC=C23)C=C1",
2930            26,
2931            22,
2932        ),
2933        (
2934            "CCOC1=CC=C(CC2=C(C3=CC=C(CCC4=CC=CC=C4)C=C3)N3CCCN=C3C3=CC=CC=C23)C=C1",
2935            28,
2936            24,
2937        ),
2938        (
2939            "CN(C)CCC1=C(C2=CC=C(C(C)(C)C)C=C2)N2CCCN=C2C2=CC=CC=C12",
2940            16,
2941            12,
2942        ),
2943        (
2944            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N/C(S)=N/C4CCCCC4)C=C3)C3=NCCCN23)C=C1",
2945            16,
2946            12,
2947        ),
2948        ("C1=C(/C=C/C2=CC=CC=C2)N2CCCN=C2C2=CC=CC=C12", 16, 12),
2949        ("CC(C)(C)C1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
2950        (
2951            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)CC4=CC=CC=N4)C=C3)C3=NCCCN23)C=C1",
2952            22,
2953            18,
2954        ),
2955        (
2956            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)NC4=C(Cl)C=C(Cl)C=C4)C=C3)C3=NCCCN23)C=C1",
2957            22,
2958            18,
2959        ),
2960        ("C1=C(CC2=CC=CC=C2)C2=CC=CC=C2C2=NCCCN12", 16, 12),
2961        ("ClC1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
2962        ("C1=C(C2=CC=CC=C2)N2CCCN=C2C2=CC=CC=C12", 16, 12),
2963        (
2964            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N(CC4=CC=CC=C4)CC4=CC=CC=C4)C=C3)C3=NCCCN23)C=C1",
2965            28,
2966            24,
2967        ),
2968        (
2969            "CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N)C=C3)C3=NCCCN23)C=C1",
2970            16,
2971            12,
2972        ),
2973        ("CC1=C2C(=NC=C1)N(C1CC1)C1=NC=CC=C1C(=O)N2C", 15, 12),
2974        ("CC(=O)N1C2=NC=CC=C2C(=O)N(C)C2=CC=CN=C21", 15, 12),
2975        ("CN1C(=O)C2=CC=CN=C2N(C(C)(C)C)C2=NC=CC=C21", 15, 12),
2976        ("CCCN1C2=NC=CC=C2C(=O)N(C)C2=CC=CN=C21", 15, 12),
2977    ];
2978
2979    #[test]
2980    fn test_known_regressions_from_bridgehead_n_fix() {
2981        for (smi, expected_wrong, rdkit_correct) in KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES {
2982            let mol = mol_kekulized(smi);
2983            let model = assign_aromaticity(&mol);
2984            assert_eq!(
2985                model.aromatic_atom_count(),
2986                *expected_wrong,
2987                "{smi}: expected current (wrong) count {expected_wrong} (RDKit correct: {rdkit_correct})"
2988            );
2989        }
2990    }
2991
2992    // ── Known order-dependence: same molecule, different Kekulized traversal ─
2993    //
2994    // Originally found because these 3 molecules passed with RDKit's
2995    // canonical Kekulized SMILES but failed with at least one other valid
2996    // Kekulized ordering of the identical structure -- confirmed via
2997    // atom-map-number alignment (no substructure matching). Root cause was
2998    // NOT Pass 1/Pass 2 (verified order-invariant by construction) -- it was
2999    // `find_sssr` itself, non-deterministic and non-minimal.
3000    //
3001    // Re-measured after the Horton SSSR rewrite (find_sssr is now
3002    // deterministic and minimal, 0% self-instability on the 5000-molecule
3003    // corpus): the 3 pinned failing-traversal counts below are UNCHANGED.
3004    // The original order-dependence *mechanism* (find_sssr picking a
3005    // different non-minimal ring depending on traversal) is resolved -- but
3006    // these 3 specific SMILES still disagree with RDKit's count, so at least
3007    // one more bug (likely `aromatic_context`, same as the 32-molecule
3008    // corpus above) also affects this molecule class. Not re-diagnosed here;
3009    // a fresh worst-of-N run against the full corpus would confirm whether
3010    // order-dependence itself (canonical vs. this pinned variant disagreeing
3011    // with each other) is now fully gone, separate from RDKit agreement.
3012    // Named at module level for the same reason as
3013    // `KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES` above -- Aromaticity-A1-0's corpus
3014    // tests reuse this exact pinned data instead of a second copy.
3015    const KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES: &[(&str, usize, usize)] = &[
3016        (
3017            "N1=C2C(N(CC(O)=O)C(=O)N=C2N(C2C=C(C(F)(F)F)C=C(C=2)C(F)(F)F)C2C1=CC=CC=2)=O",
3018            16,
3019            20,
3020        ),
3021        (
3022            "[C@H]12N(C([C@H](NC(=O)[C@H]([C@H](OC(=O)[C@@H](N(C)C(CN(C)C1=O)=O)C(C)C)C)NC(=O)C1C=C(OC)C(C)=C3OC4=C(C)C(=O)C(=C(C4=NC=13)C(=O)N[C@H]1C(=O)N[C@@H](C(C)C)C(N3[C@H](C(=O)N(CC(N([C@H](C(C)C)C(O[C@H]1C)=O)C)=O)C)CCC3)=O)N)C(C)C)=O)CCC2",
3023            6,
3024            14,
3025        ),
3026        ("C12N(C3C=CC=CC=3)C3=NC(=O)N(C)C(C3=NC1=CC=CC=2)=O", 16, 20),
3027    ];
3028
3029    #[test]
3030    fn test_known_order_dependent_regressions() {
3031        for (smi, expected_wrong, rdkit_correct) in KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES {
3032            let mol = mol_kekulized(smi);
3033            let model = assign_aromaticity(&mol);
3034            assert_eq!(
3035                model.aromatic_atom_count(),
3036                *expected_wrong,
3037                "{smi}: expected current (wrong) count {expected_wrong} (RDKit correct: {rdkit_correct})"
3038            );
3039        }
3040    }
3041
3042    // ── Aromaticity-A1-0: anti-drift guard for `trace_ring_pi_electrons` ────
3043    //
3044    // `trace_ring_pi_electrons` is a deliberately separate implementation
3045    // from `ring_pi_electrons` (see the doc comment above it) so it can
3046    // report *why* each atom scored what it did. That separateness is a
3047    // drift risk: nothing stops the two from silently diverging as either
3048    // one is edited. This test is the guard -- for every ring in every
3049    // molecule of the known false-positive/false-negative/negative-control
3050    // corpus (the same molecules `docs/aromaticity_a1_rfc.md`'s diagnostic
3051    // corpus uses), both functions must agree exactly, in both an empty
3052    // context (Pass-1-equivalent) and the model's final converged context
3053    // (an upper-bound Pass-2-equivalent). This does not assert anything
3054    // about correctness vs RDKit -- only that the trace and the real engine
3055    // never disagree with each other.
3056    #[test]
3057    fn trace_matches_ring_pi_electrons_on_corpus() {
3058        let smiles: Vec<&str> = KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES
3059            .iter()
3060            .map(|(smi, _, _)| *smi)
3061            .chain(
3062                KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES
3063                    .iter()
3064                    .map(|(smi, _, _)| *smi),
3065            )
3066            .chain([
3067                "C1=CC2=CC=CC=CC2=C1",    // azulene (Kekulized) -- known false negative
3068                "c1cnc2[nH]cnc2n1",       // purine -- known false negative
3069                "C1=Cc2ccccc2C2=NCCCN12", // PR #86 minimal false-positive reproducer
3070                "C1=Cc2ccccc2C2=CCCC12",  // negative control: no bridgehead N
3071                "C1=Cc2ccccc2C2=CCNC12",  // negative control: N not at bridgehead
3072                "C1Cc2ccccc2C2=NCCCN12",  // negative control: bridgehead N, no exocyclic C=C
3073                "c1ccc2[nH]ccc2c1",       // indole -- must stay correct
3074                "c1ccc2ncccc2c1",         // quinoline -- must stay correct
3075                "c1ccc2ccccc2c1",         // naphthalene -- must stay correct
3076            ])
3077            .collect();
3078
3079        for algo in [
3080            AromaticityAlgorithm::Huckel,
3081            AromaticityAlgorithm::RdkitLike,
3082        ] {
3083            for smi in &smiles {
3084                let mol = mol_kekulized(smi);
3085                let model = assign_aromaticity_ex(&mol, algo);
3086                let final_context: FxHashSet<AtomIdx> = mol
3087                    .atoms()
3088                    .map(|(idx, _)| idx)
3089                    .filter(|&idx| model.is_atom_aromatic(idx))
3090                    .collect();
3091
3092                let sssr = find_sssr(&mol);
3093                let rings = augmented_ring_set(&mol, sssr.rings());
3094                let empty_context: FxHashSet<AtomIdx> = FxHashSet::default();
3095
3096                for ring in &rings {
3097                    for ctx in [&empty_context, &final_context] {
3098                        let expected = ring_pi_electrons(&mol, ring, ctx, algo);
3099                        let traced = trace_ring_pi_electrons(&mol, ring, ctx, algo);
3100                        assert_eq!(
3101                            traced.total,
3102                            expected,
3103                            "{smi} (algo={algo:?}, ring={ring:?}, ctx_len={}): \
3104                             trace_ring_pi_electrons diverged from ring_pi_electrons",
3105                            ctx.len()
3106                        );
3107                        // Cross-check the per-atom eligibility bookkeeping too.
3108                        for a in &traced.atoms {
3109                            assert_eq!(
3110                                a.contribution.is_some(),
3111                                a.reason.is_eligible(),
3112                                "{smi}: atom {:?} contribution/reason eligibility mismatch",
3113                                a.atom_idx
3114                            );
3115                        }
3116                    }
3117                }
3118            }
3119        }
3120    }
3121
3122    // ── Aromaticity-A1-0: false-positive/false-negative polarity sanity ────
3123    //
3124    // These are cheap, structural sanity checks that the corpus buckets are
3125    // labeled the direction they claim -- not a re-measurement of the full
3126    // corpus (that's `aromaticity_a1_0_report` + the Python RDKit join, see
3127    // `docs/aromaticity_a1_rfc.md`). Catches an accidental swap or a stale
3128    // pinned count silently going the other way.
3129    #[test]
3130    fn false_positive_corpus_over_counts_vs_rdkit() {
3131        for (smi, expected_wrong, rdkit_correct) in KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES {
3132            assert!(
3133                expected_wrong > rdkit_correct,
3134                "{smi}: false-positive bucket entry should over-count \
3135                 (chematic={expected_wrong} should be > rdkit={rdkit_correct})"
3136            );
3137        }
3138    }
3139
3140    #[test]
3141    fn false_negative_corpus_under_counts_vs_rdkit() {
3142        for (smi, expected_wrong, rdkit_correct) in KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES {
3143            assert!(
3144                expected_wrong < rdkit_correct,
3145                "{smi}: false-negative bucket entry should under-count \
3146                 (chematic={expected_wrong} should be < rdkit={rdkit_correct})"
3147            );
3148        }
3149    }
3150
3151    // ── Aromaticity-A1-1a: exhaustive_aromaticity_oracle pinned cases ──────
3152    //
3153    // The oracle is a discovery tool, not a correct-answer generator: its
3154    // candidates are built from the SAME per-atom local rules
3155    // (`evaluate_atom_pi_contribution`) that are wrong for the false-positive
3156    // family, so it can't independently arbitrate that family. This test
3157    // pins what the oracle DOES get right (RDKit-atom-index-verified, not
3158    // guessed) after two real fixes made during this milestone:
3159    //
3160    // 1. Connectivity: `build_conjugated_components`'s conjugation graph
3161    //    originally only bridged single bonds via a `LonePairDonor` endpoint,
3162    //    leaving azulene's all-carbon alternating perimeter as 5 disconnected
3163    //    2-atom pairs (oracle returned an empty set). Fixed: any bond between
3164    //    two independently-eligible atoms connects (ordinary carbon-carbon
3165    //    single-bond conjugation, ordinary organic chemistry).
3166    // 2. Home-ring evaluation: evaluating a multi-ring candidate's electron
3167    //    sum against its own *flattened* atom set broke the N
3168    //    bridgehead/substituted-azole rule for any TRUE bridgehead (every
3169    //    bond looks "in-family" once the family itself is the context) --
3170    //    indolizine's own bridgehead N came out `Ineligible`, an oracle bug,
3171    //    not a chematic bug. Fixed via `evaluate_atom_via_home_ring`.
3172    //
3173    // Both fixes are confirmed correct AND confirmed NOT to silently
3174    // "fix" the false-positive family by accident (still wrong, on purpose,
3175    // pinned below) -- an oracle that quietly agreed with the bug would be
3176    // worse than no oracle.
3177    //
3178    // purine is a genuinely OPEN finding, not a regression to chase in this
3179    // round: before the home-ring fix, the (buggy, flattened) evaluation
3180    // happened to give all 9 atoms aromatic (matching RDKit) BY ACCIDENT --
3181    // the SAME flattening bug that broke indolizine happened to produce the
3182    // right answer for purine. After the fix, purine's 5-ring fusion carbons
3183    // (whose own `#[ignore]`d production test already documents an
3184    // antiaromatic-lock issue -- each scores 0π alone, exocyclic-to-N rule)
3185    // need cross-ring information neither a single home ring nor the
3186    // flattened family alone provides correctly. Pinning the current
3187    // (still-wrong) oracle answer here so a future A1-1b design has a
3188    // concrete regression check once it actually resolves this, rather than
3189    // silently inheriting whichever answer the oracle happens to produce.
3190    #[test]
3191    fn exhaustive_oracle_pinned_cases() {
3192        let algo = AromaticityAlgorithm::RdkitLike;
3193
3194        // (name, smiles, expected oracle-aromatic atom indices, sorted)
3195        let matches_rdkit: &[(&str, &str, &[u32])] = &[
3196            (
3197                "azulene",
3198                "C1=CC2=CC=CC=CC2=C1",
3199                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
3200            ),
3201            (
3202                "naphthalene",
3203                "c1ccc2ccccc2c1",
3204                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
3205            ),
3206            (
3207                "anthracene",
3208                "c1ccc2cc3ccccc3cc2c1",
3209                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
3210            ),
3211            ("indole", "c1ccc2[nH]ccc2c1", &[0, 1, 2, 3, 4, 5, 6, 7, 8]),
3212            (
3213                "quinoline",
3214                "c1ccc2ncccc2c1",
3215                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
3216            ),
3217            (
3218                "indolizine (bridgehead N, both rings valid)",
3219                "c1ccn2ccccc12",
3220                &[0, 1, 2, 3, 4, 5, 6, 7, 8],
3221            ),
3222            ("tropone", "O=c1cccccc1", &[1, 2, 3, 4, 5, 6, 7]),
3223            ("2-pyridone", "O=c1cccc[nH]1", &[1, 2, 3, 4, 5, 6]),
3224        ];
3225        for (name, smi, expected) in matches_rdkit {
3226            let mol = mol_kekulized(smi);
3227            let (atoms, _bonds) = exhaustive_aromaticity_oracle(&mol, algo);
3228            let mut got: Vec<u32> = atoms.iter().map(|a| a.0).collect();
3229            got.sort();
3230            assert_eq!(&got, expected, "{name} ({smi}): oracle should match RDKit");
3231        }
3232
3233        // Still wrong, on purpose -- the false-positive family isn't fixable
3234        // by candidate generation alone (Issue B, deliberately deferred to
3235        // A1-1b; see docs/aromaticity_a1_rfc.md).
3236        let (fp_atoms, _) =
3237            exhaustive_aromaticity_oracle(&mol_kekulized("C1=Cc2ccccc2C2=NCCCN12"), algo);
3238        let mut fp_got: Vec<u32> = fp_atoms.iter().map(|a| a.0).collect();
3239        fp_got.sort();
3240        assert_eq!(
3241            fp_got,
3242            vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 13],
3243            "false-positive reproducer: still over-aromatized by the oracle, as expected"
3244        );
3245
3246        // Open finding, not a regression -- see this test's doc comment.
3247        let (purine_atoms, _) =
3248            exhaustive_aromaticity_oracle(&mol_kekulized("c1cnc2[nH]cnc2n1"), algo);
3249        let mut purine_got: Vec<u32> = purine_atoms.iter().map(|a| a.0).collect();
3250        purine_got.sort();
3251        assert_eq!(
3252            purine_got,
3253            vec![0, 1, 2, 3, 7, 8],
3254            "purine: oracle still under-counts (RDKit says all 9) -- open A1-1b question, not fixed here"
3255        );
3256    }
3257}