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