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