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