Skip to main content

chematic_perception/
rdkit_parity.rs

1//! Aromaticity-A1-1b-0: a faithful, independent reproduction of RDKit's
2//! *default* aromaticity model (`AROMATICITY_RDKIT`/`AROMATICITY_DEFAULT`),
3//! ported directly from RDKit's own source
4//! (`Code/GraphMol/Aromaticity.cpp`, functions `getAtomDonorTypeArom`,
5//! `countAtomElec`, `isAtomCandForArom`, `applyHuckel`, `applyHuckelToFused`,
6//! `aromaticityHelper`'s `includeFused` branch — the exact path
7//! `setAromaticity(mol, AROMATICITY_RDKIT, ...)` calls).
8//!
9//! `AromaticityAlgorithm::RdkitLike` now routes through this engine when the
10//! input can be kekulized; the historical per-ring implementation remains as
11//! an infallible fallback for inputs that cannot satisfy the parity engine's
12//! precondition. `Huckel` remains the default and is unchanged. This module
13//! also backs a separate, explicitly opt-in, fallible production API:
14//! [`assign_aromaticity_rdkit_parity_experimental`] and
15//! [`apply_aromaticity_rdkit_parity_experimental`], re-exported from the
16//! crate root. Every other item in this module (the low-level donor-type/
17//! Hückel machinery) is crate-private; the only way to reach it from
18//! outside the crate is through those two functions, or, for diagnostics,
19//! `diagnostics::rdkit_parity_aromaticity` behind the `diagnostics` feature.
20//! See `docs/rfcs/aromaticity_a1_rfc.md`'s "A1-1b-0"/"A1-1b-1" sections for the
21//! full design writeup, the calibration battery, and the corpus gate.
22//!
23//! Ported from RDKit commit `e89c9f656a694fab4105139844cba88d2e013354`, an
24//! ancestor of release tag `Release_2026_03_4` (which resolves to
25//! `8afba32ec539dcb2369bc84549d802aca3f7eb39`, independently verified via
26//! the GitHub tags API during Morgan M4-A0). `Code/GraphMol/Aromaticity.cpp`
27//! is byte-identical between the two commits (independently diffed during
28//! M4-A0's provenance audit — the 130 commits between them never touch this
29//! file), so the 5 functions cited above are unaffected either way. See
30//! `THIRD_PARTY_NOTICES.md` at the repo root for the required BSD 3-Clause
31//! attribution and license text.
32//!
33//! Unlike this crate's own `ring_pi_electrons`/`evaluate_atom_pi_contribution`
34//! (which evaluate an atom's contribution *per candidate ring/component*),
35//! RDKit computes each atom's [`ElectronDonorType`] **once, globally, per
36//! molecule** — whether a multiple bond "counts" for aromaticity purposes
37//! depends on whether that bond is part of *any* SSSR ring in the whole
38//! molecule (`RingInfo::numBondRings(bond) > 0`), not on whether it's inside
39//! the *specific* candidate ring currently being evaluated. This is the
40//! precise, source-verified point where this crate's own `ring_pi_electrons`
41//! diverges from RDKit for the SMARTS-A0/PR #86 false-positive family: its
42//! `CarbonExocyclicHeteroatomDouble` rule checks "is the double-bond partner
43//! outside *this ring's* atom set" where RDKit checks "is this bond outside
44//! *every* ring in the molecule" — an exocyclic-to-the-candidate-ring double
45//! bond whose partner is itself a *different* ring's atom (e.g. this crate's
46//! reproducer's atom 8, `C=N` where the N is in a second fused ring) still
47//! counts as a normal one-electron donor under RDKit's rule, not a
48//! zero-electron "spent on the exocyclic bond" donor.
49//!
50//! Requires pre-kekulized input (no `BondOrder::Aromatic`), matching RDKit's
51//! own pipeline (`Kekulize` always runs before `setAromaticity`).
52
53use rustc_hash::{FxHashMap, FxHashSet};
54
55use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
56
57use crate::aromaticity::AromaticityModel;
58use crate::sssr::find_sssr;
59
60// ---------------------------------------------------------------------------
61// Electron donor type (ported from RDKit's `ElectronDonorType`)
62// ---------------------------------------------------------------------------
63
64/// Per-atom pi-electron donor classification, computed once per molecule
65/// (not per candidate ring). Direct port of RDKit's `ElectronDonorType`.
66///
67/// Crate-internal: not part of the public API. The only supported entry
68/// points are [`assign_aromaticity_rdkit_parity_experimental`] and
69/// [`apply_aromaticity_rdkit_parity_experimental`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub(crate) enum ElectronDonorType {
72    /// No electrons to spare, but an empty p-orbital (e.g. tropylium-type carbocation).
73    Vacant,
74    /// Exactly 1 electron (a normal sp2 atom with one endocyclic pi bond).
75    OneElectron,
76    /// Exactly 2 electrons (a lone pair, unconditionally).
77    TwoElectron,
78    /// Either 1 or 2, ambiguous until a specific candidate ring/subset is evaluated
79    /// (RDKit tries every value in this range when checking Hückel's rule).
80    ///
81    /// Kept for shape-fidelity with RDKit's own `ElectronDonorType` enum
82    /// (this port's `get_atom_electron_donor_type` doesn't currently
83    /// construct this variant on any input the calibration battery or the
84    /// 5,000-molecule corpus exercises -- was previously masked by this
85    /// enum being `pub`, which suppresses rustc's dead-code analysis for
86    /// externally-constructible items). Not a behavior change to fix here.
87    #[allow(dead_code)]
88    OneOrTwo,
89    /// Dummy-atom wildcard (1 or 2, but at most one such atom per evaluated ring).
90    Any,
91    /// Not eligible to donate at all (disqualifies any ring it's part of).
92    None,
93}
94
95/// RDKit's main-group "number of outer-shell (valence) electrons" per
96/// element, used by `count_atom_pi_electrons` exactly as
97/// `PeriodicTable::getNouterElecs` is used in the source. Small, stable
98/// chemistry fact table — not exposed from `chematic-core` since this is the
99/// only consumer.
100fn outer_shell_electrons(atomic_number: u8) -> Option<u8> {
101    match atomic_number {
102        1 => Some(1),  // H
103        5 => Some(3),  // B
104        6 => Some(4),  // C
105        7 => Some(5),  // N
106        8 => Some(6),  // O
107        9 => Some(7),  // F
108        14 => Some(4), // Si
109        15 => Some(5), // P
110        16 => Some(6), // S
111        17 => Some(7), // Cl
112        33 => Some(5), // As
113        34 => Some(6), // Se
114        35 => Some(7), // Br
115        52 => Some(6), // Te
116        53 => Some(7), // I
117        _ => None,
118    }
119}
120
121fn default_valence(atomic_number: u8) -> Option<u8> {
122    chematic_core::Element::from_atomic_number(atomic_number)
123        .and_then(|e| e.normal_valences().first().copied())
124}
125
126fn bond_order_contrib(order: BondOrder) -> f32 {
127    match order {
128        BondOrder::Single | BondOrder::Up | BondOrder::Down => 1.0,
129        BondOrder::Double => 2.0,
130        BondOrder::Triple => 3.0,
131        BondOrder::Quadruple => 4.0,
132        // None of these should occur on pre-kekulized organic input (this
133        // module's precondition) -- fall back to a single-bond-equivalent
134        // rather than panicking.
135        BondOrder::Aromatic
136        | BondOrder::Zero
137        | BondOrder::Dative
138        | BondOrder::QueryAny
139        | BondOrder::QuerySingleOrDouble
140        | BondOrder::QuerySingleOrAromatic
141        | BondOrder::QueryDoubleOrAromatic => 1.0,
142    }
143}
144
145/// Port of `countAtomElec`: pi electrons available for donation into an
146/// aromatic system, from generic valence-shell arithmetic — NOT
147/// element-specific branching (RDKit's model is deliberately generic here).
148/// Returns `None` for atoms that can never be aromatic (univalent elements,
149/// degree > 3, multiple unsaturations already ruled out upstream).
150fn count_atom_pi_electrons(mol: &Molecule, atom_idx: AtomIdx) -> Option<i32> {
151    let atom = mol.atom(atom_idx);
152    let an = atom.element.atomic_number();
153    let dv = default_valence(an)?;
154    if dv <= 1 {
155        return None; // univalent elements can't be aromatic or conjugated
156    }
157
158    let implicit_h = chematic_core::implicit_hcount(mol, atom_idx);
159    let degree = mol.degree(atom_idx) + implicit_h as usize;
160    if degree > 3 {
161        return None;
162    }
163
164    let nlp_raw = outer_shell_electrons(an)? as i32 - dv as i32;
165    let nlp = (nlp_raw - atom.charge as i32).max(0);
166    let n_radicals = 0i32; // radicals aren't modeled in chematic-core's Atom
167
168    let mut res = (dv as i32 - degree as i32) + nlp - n_radicals;
169
170    if res > 1 {
171        let explicit_valence: f32 = mol
172            .neighbors(atom_idx)
173            .map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
174            .sum();
175        let n_unsaturations = explicit_valence - mol.degree(atom_idx) as f32;
176        if n_unsaturations > 1.0 {
177            res = 1;
178        }
179    }
180
181    Some(res)
182}
183
184fn incident_non_cyclic_multiple_bond(
185    mol: &Molecule,
186    atom_idx: AtomIdx,
187    ring_bonds: &FxHashSet<BondIdx>,
188) -> Option<AtomIdx> {
189    mol.neighbors(atom_idx)
190        .find(|&(_, bidx)| {
191            !ring_bonds.contains(&bidx) && bond_order_contrib(mol.bond(bidx).order) >= 2.0
192        })
193        .map(|(nb, _)| nb)
194}
195
196fn incident_cyclic_multiple_bond(
197    mol: &Molecule,
198    atom_idx: AtomIdx,
199    ring_bonds: &FxHashSet<BondIdx>,
200) -> bool {
201    mol.neighbors(atom_idx).any(|(_, bidx)| {
202        ring_bonds.contains(&bidx) && bond_order_contrib(mol.bond(bidx).order) >= 2.0
203    })
204}
205
206fn incident_multiple_bond(mol: &Molecule, atom_idx: AtomIdx) -> bool {
207    let explicit_valence: f32 = mol
208        .neighbors(atom_idx)
209        .map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
210        .sum();
211    (explicit_valence - mol.degree(atom_idx) as f32).abs() > 1e-6
212}
213
214fn more_electronegative(a: u8, b: u8) -> bool {
215    // RDKit's PeriodicTable::moreElectroNegative is Pauling-scale; restricted
216    // here to the elements this model's callers actually compare against
217    // (the exocyclic-multiple-bond partner check), which are always N/O/S
218    // relative to C -- matches every case in `isAtomCandForArom`'s callers.
219    fn electronegativity(an: u8) -> f32 {
220        match an {
221            1 => 2.20,
222            5 => 2.04,
223            6 => 2.55,
224            7 => 3.04,
225            8 => 3.44,
226            9 => 3.98,
227            14 => 1.90,
228            15 => 2.19,
229            16 => 2.58,
230            17 => 3.16,
231            34 => 2.55,
232            35 => 2.96,
233            52 => 2.10,
234            53 => 2.66,
235            _ => 2.20,
236        }
237    }
238    electronegativity(a) > electronegativity(b)
239}
240
241/// Port of `getAtomDonorTypeArom` (default params: `exocyclicBondsStealElectrons = true`).
242/// `ring_bonds` = the set of bond indices that are part of *any* SSSR ring in
243/// the whole molecule (global, not scoped to one candidate ring/subset).
244pub(crate) fn get_atom_electron_donor_type(
245    mol: &Molecule,
246    atom_idx: AtomIdx,
247    ring_bonds: &FxHashSet<BondIdx>,
248) -> ElectronDonorType {
249    let atom = mol.atom(atom_idx);
250    let an = atom.element.atomic_number();
251
252    let Some(nelec) = count_atom_pi_electrons(mol, atom_idx) else {
253        return ElectronDonorType::None;
254    };
255
256    if nelec < 0 {
257        ElectronDonorType::None
258    } else if nelec == 0 {
259        if let Some(_who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
260            ElectronDonorType::Vacant
261        } else if incident_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
262            ElectronDonorType::OneElectron
263        } else {
264            ElectronDonorType::None
265        }
266    } else if nelec == 1 {
267        if let Some(who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
268            let other_an = mol.atom(who).element.atomic_number();
269            if more_electronegative(other_an, an) {
270                ElectronDonorType::Vacant
271            } else {
272                ElectronDonorType::OneElectron
273            }
274        } else if incident_multiple_bond(mol, atom_idx) {
275            ElectronDonorType::OneElectron
276        } else if atom.charge == 1 {
277            // tropylium / cyclopropenyl cation
278            ElectronDonorType::Vacant
279        } else {
280            ElectronDonorType::None
281        }
282    } else {
283        let mut nelec = nelec;
284        if let Some(who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
285            let other_an = mol.atom(who).element.atomic_number();
286            if more_electronegative(other_an, an) {
287                nelec -= 1;
288            }
289        }
290        if nelec % 2 == 1 {
291            ElectronDonorType::OneElectron
292        } else {
293            ElectronDonorType::TwoElectron
294        }
295    }
296}
297
298/// Port of `isAtomCandForArom` with the DEFAULT model's parameters
299/// (`allowThirdRow=true, allowTripleBonds=true, allowHigherExceptions=true,
300/// onlyCorN=false, allowExocyclicMultipleBonds=true`).
301pub(crate) fn is_atom_candidate_for_aromaticity(
302    mol: &Molecule,
303    atom_idx: AtomIdx,
304    donor_type: ElectronDonorType,
305) -> bool {
306    let atom = mol.atom(atom_idx);
307    let an = atom.element.atomic_number();
308
309    // First two rows, plus Se/Te (allowHigherExceptions).
310    if an > 18 && an != 34 && an != 52 {
311        return false;
312    }
313
314    if matches!(donor_type, ElectronDonorType::None) {
315        return false;
316    }
317
318    // Atoms not in their default valence state are shut out.
319    if let Some(dv) = default_valence(an) {
320        let total_valence: f32 = mol
321            .neighbors(atom_idx)
322            .map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
323            .sum::<f32>()
324            + chematic_core::implicit_hcount(mol, atom_idx) as f32;
325        let an_neutral = (an as i32 - atom.charge as i32).max(0) as u8;
326        if let Some(dv_neutral) = default_valence(an_neutral)
327            && total_valence.round() as i32 > dv_neutral as i32
328        {
329            return false;
330        }
331        let _ = dv;
332    }
333
334    // No more than one double/triple bond (rules out cumulated dienes like C=C=N).
335    let explicit_valence: f32 = mol
336        .neighbors(atom_idx)
337        .map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
338        .sum();
339    let n_unsaturations = explicit_valence - mol.degree(atom_idx) as f32;
340    if n_unsaturations > 1.0 {
341        let n_mult = mol
342            .neighbors(atom_idx)
343            .filter(|(_, bidx)| {
344                matches!(mol.bond(*bidx).order, BondOrder::Double | BondOrder::Triple)
345            })
346            .count();
347        if n_mult > 1 {
348            return false;
349        }
350    }
351
352    true
353}
354
355// ---------------------------------------------------------------------------
356// Hückel evaluation (ported from `applyHuckel` / `applyHuckelToFused`)
357// ---------------------------------------------------------------------------
358
359fn min_max_atom_electrons(dtype: ElectronDonorType) -> (i32, i32) {
360    match dtype {
361        ElectronDonorType::Any | ElectronDonorType::OneOrTwo => (1, 2),
362        ElectronDonorType::OneElectron => (1, 1),
363        ElectronDonorType::TwoElectron => (2, 2),
364        ElectronDonorType::None | ElectronDonorType::Vacant => (0, 0),
365    }
366}
367
368/// Port of `applyHuckel`: given a candidate atom union, checks whether ANY
369/// electron count in `[sum_of_lower_bounds, sum_of_upper_bounds]` satisfies
370/// 4n+2 -- or the `rup == 2` special case for tiny rings (e.g. cyclopropenyl
371/// cation).
372pub(crate) fn apply_huckel(
373    mol: &Molecule,
374    atoms: &[AtomIdx],
375    donor: &FxHashMap<AtomIdx, ElectronDonorType>,
376) -> bool {
377    let _ = mol;
378    let mut rlw = 0i32;
379    let mut rup = 0i32;
380    let mut n_any = 0u32;
381    for &a in atoms {
382        let dtype = donor[&a];
383        if dtype == ElectronDonorType::Any {
384            n_any += 1;
385            if n_any > 1 {
386                return false;
387            }
388        }
389        let (lo, hi) = min_max_atom_electrons(dtype);
390        rlw += lo;
391        rup += hi;
392    }
393
394    if rup >= 6 {
395        (rlw..=rup).any(|rie| (rie - 2).rem_euclid(4) == 0)
396    } else {
397        rup == 2
398    }
399}
400
401/// One connected group of candidate rings, adjacent when they share ≥1 bond
402/// (RDKit's `makeRingNeighborMap`).
403fn fused_ring_groups(ring_bond_ids: &[Vec<BondIdx>]) -> Vec<Vec<usize>> {
404    let n = ring_bond_ids.len();
405    let mut parent: Vec<usize> = (0..n).collect();
406    fn find(parent: &mut [usize], x: usize) -> usize {
407        if parent[x] != x {
408            parent[x] = find(parent, parent[x]);
409        }
410        parent[x]
411    }
412    for i in 0..n {
413        for j in (i + 1)..n {
414            if ring_bond_ids[i]
415                .iter()
416                .any(|b| ring_bond_ids[j].contains(b))
417            {
418                let (pi, pj) = (find(&mut parent, i), find(&mut parent, j));
419                if pi != pj {
420                    parent[pi] = pj;
421                }
422            }
423        }
424    }
425    let mut groups: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
426    for i in 0..n {
427        groups.entry(find(&mut parent, i)).or_default().push(i);
428    }
429    let mut out: Vec<Vec<usize>> = groups.into_values().collect();
430    out.sort_by_key(|g| g[0]);
431    out
432}
433
434/// All `k`-combinations of `0..n`, in RDKit's `nextCombination` order
435/// (ascending indices, lexicographic).
436fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
437    if k == 0 || k > n {
438        return vec![];
439    }
440    let mut result = Vec::new();
441    let mut combo: Vec<usize> = (0..k).collect();
442    loop {
443        result.push(combo.clone());
444        let mut i = k;
445        loop {
446            if i == 0 {
447                return result;
448            }
449            i -= 1;
450            if combo[i] != i + n - k {
451                break;
452            }
453        }
454        combo[i] += 1;
455        for j in (i + 1)..k {
456            combo[j] = combo[j - 1] + 1;
457        }
458    }
459}
460
461/// Port of `applyHuckelToFused`: within one fused ring group, tries every
462/// connected subset of rings (size 1, then 2, ... up to `max_num_fused_rings`),
463/// unions each subset's atoms (RDKit's #2895 rule: an atom counts only if it
464/// appears in exactly 1 or 2 of the subset's rings), and marks the subset's
465/// *outer perimeter* bonds/atoms aromatic if `apply_huckel` accepts. Stops
466/// once every bond in the fused group has been assigned a verdict.
467/// Candidate rings, bundled so `apply_huckel_to_fused` stays under clippy's
468/// too-many-arguments limit -- `atoms[i]`/`bonds[i]` describe the same ring.
469struct CandidateRings<'a> {
470    atoms: &'a [Vec<AtomIdx>],
471    bonds: &'a [Vec<BondIdx>],
472}
473
474fn apply_huckel_to_fused(
475    mol: &Molecule,
476    rings: &CandidateRings<'_>,
477    group: &[usize],
478    donor: &FxHashMap<AtomIdx, ElectronDonorType>,
479    max_num_fused_rings: usize,
480    aromatic_atoms: &mut FxHashSet<AtomIdx>,
481    aromatic_bonds: &mut FxHashSet<BondIdx>,
482) {
483    let ring_atoms = rings.atoms;
484    let ring_bond_ids = rings.bonds;
485    let n_ring_bonds: usize = {
486        let mut all: FxHashSet<BondIdx> = FxHashSet::default();
487        for &ri in group {
488            all.extend(ring_bond_ids[ri].iter().copied());
489        }
490        all.len()
491    };
492    let mut done_bonds: FxHashSet<BondIdx> = FxHashSet::default();
493
494    for size in 1..=group.len().min(max_num_fused_rings) {
495        if done_bonds.len() >= n_ring_bonds {
496            break;
497        }
498        for combo in combinations(group.len(), size) {
499            let cur_rings: Vec<usize> = combo.iter().map(|&i| group[i]).collect();
500
501            // Subset must itself be connected (share bonds pairwise-reachable).
502            if size > 1 {
503                let sub_bond_ids: Vec<Vec<BondIdx>> = cur_rings
504                    .iter()
505                    .map(|&ri| ring_bond_ids[ri].clone())
506                    .collect();
507                if fused_ring_groups(&sub_bond_ids).len() != 1 {
508                    continue;
509                }
510            }
511
512            let mut membership_count: FxHashMap<AtomIdx, u32> = FxHashMap::default();
513            for &ri in &cur_rings {
514                for &a in &ring_atoms[ri] {
515                    *membership_count.entry(a).or_insert(0) += 1;
516                }
517            }
518            let union: Vec<AtomIdx> = membership_count
519                .iter()
520                .filter(|&(_, &c)| c == 1 || c == 2)
521                .map(|(&a, _)| a)
522                .collect();
523
524            if apply_huckel(mol, &union, donor) {
525                // Mark only the outer-perimeter bonds (appear in exactly one
526                // of this subset's rings), matching `markAtomsBondsArom`.
527                let mut bond_count: FxHashMap<BondIdx, u32> = FxHashMap::default();
528                for &ri in &cur_rings {
529                    for &b in &ring_bond_ids[ri] {
530                        *bond_count.entry(b).or_insert(0) += 1;
531                    }
532                }
533                for (&b, &c) in &bond_count {
534                    if c == 1 {
535                        aromatic_bonds.insert(b);
536                        let bond = mol.bond(b);
537                        aromatic_atoms.insert(bond.atom1);
538                        aromatic_atoms.insert(bond.atom2);
539                        done_bonds.insert(b);
540                    }
541                }
542            }
543        }
544    }
545}
546
547/// Top-level driver, matching `aromaticityHelper(mol, srings, 0, 0,
548/// includeFused=true)` -- the exact function `AROMATICITY_RDKIT`/
549/// `AROMATICITY_DEFAULT` call. `maxNumFusedRings` is RDKit's own hardcoded
550/// default (`6`), left as a parameter for the calibration battery.
551///
552/// Requires pre-kekulized `mol` (see module doc comment).
553pub fn rdkit_parity_aromaticity(mol: &Molecule) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
554    rdkit_parity_aromaticity_ex(mol, 6)
555}
556
557pub(crate) fn rdkit_parity_aromaticity_ex(
558    mol: &Molecule,
559    max_num_fused_rings: usize,
560) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
561    let sssr = find_sssr(mol);
562    let srings = sssr.rings();
563
564    let all_ring_bonds: FxHashSet<BondIdx> = srings
565        .iter()
566        .flat_map(|ring| {
567            (0..ring.len()).filter_map(move |i| {
568                mol.bond_between(ring[i], ring[(i + 1) % ring.len()])
569                    .map(|(bidx, _)| bidx)
570            })
571        })
572        .collect();
573
574    let mut donor: FxHashMap<AtomIdx, ElectronDonorType> = FxHashMap::default();
575    let mut candidate: FxHashMap<AtomIdx, bool> = FxHashMap::default();
576    for ring in srings {
577        for &a in ring {
578            donor
579                .entry(a)
580                .or_insert_with(|| get_atom_electron_donor_type(mol, a, &all_ring_bonds));
581            let d = donor[&a];
582            candidate
583                .entry(a)
584                .or_insert_with(|| is_atom_candidate_for_aromaticity(mol, a, d));
585        }
586    }
587
588    let candidate_rings: Vec<&Vec<AtomIdx>> = srings
589        .iter()
590        .filter(|ring| {
591            ring.iter()
592                .all(|a| candidate.get(a).copied().unwrap_or(false))
593        })
594        .collect();
595
596    let ring_atoms: Vec<Vec<AtomIdx>> = candidate_rings.iter().map(|r| (*r).clone()).collect();
597    let ring_bond_ids: Vec<Vec<BondIdx>> = ring_atoms
598        .iter()
599        .map(|ring| {
600            (0..ring.len())
601                .filter_map(|i| {
602                    mol.bond_between(ring[i], ring[(i + 1) % ring.len()])
603                        .map(|(bidx, _)| bidx)
604                })
605                .collect()
606        })
607        .collect();
608
609    let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
610    let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
611    let rings = CandidateRings {
612        atoms: &ring_atoms,
613        bonds: &ring_bond_ids,
614    };
615
616    for group in fused_ring_groups(&ring_bond_ids) {
617        apply_huckel_to_fused(
618            mol,
619            &rings,
620            &group,
621            &donor,
622            max_num_fused_rings,
623            &mut aromatic_atoms,
624            &mut aromatic_bonds,
625        );
626    }
627
628    (aromatic_atoms, aromatic_bonds)
629}
630
631// ---------------------------------------------------------------------------
632// Production entry points (A1-1b-1): fallible opt-in API
633// ---------------------------------------------------------------------------
634
635/// Error from the RDKit-parity experimental aromaticity API.
636///
637/// Unlike [`assign_aromaticity_ex`](crate::assign_aromaticity_ex)/
638/// [`apply_aromaticity_ex`](crate::apply_aromaticity_ex) (infallible, and
639/// unchanged by this addition), this engine requires an explicit
640/// kekulization step it does not control the success of, so its entry
641/// points return `Result` rather than silently falling back to another
642/// algorithm or panicking.
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub enum AromaticityError {
645    /// The input could not be reduced to a Kekulé form (no `BondOrder::Aromatic`
646    /// bonds), which this engine requires as a precondition -- mirrors RDKit's
647    /// own pipeline, where `Kekulize` always runs before `setAromaticity`.
648    KekulizationFailed {
649        /// Human-readable detail from the underlying `chematic_core::KekuleError`.
650        reason: String,
651    },
652    /// A post-computation sanity check failed (e.g. an aromatic bond with a
653    /// non-aromatic endpoint atom) -- should never happen for chemically
654    /// valid input; surfaced as an error rather than a panic or a silently
655    /// wrong result.
656    InternalInvariantViolation {
657        /// Human-readable detail of which invariant failed.
658        reason: String,
659    },
660}
661
662impl std::fmt::Display for AromaticityError {
663    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
664        match self {
665            AromaticityError::KekulizationFailed { reason } => {
666                write!(f, "rdkit-parity aromaticity: kekulization failed: {reason}")
667            }
668            AromaticityError::InternalInvariantViolation { reason } => {
669                write!(
670                    f,
671                    "rdkit-parity aromaticity: internal invariant violation: {reason}"
672                )
673            }
674        }
675    }
676}
677
678impl std::error::Error for AromaticityError {}
679
680/// Clone `mol` with every atom's `aromatic` flag reset to `false`.
681///
682/// Bond orders (including any `BondOrder::Aromatic`) are copied unchanged --
683/// only the atom-level annotation is cleared. This engine derives
684/// aromaticity purely from element/charge/bond-order structure (never reads
685/// `atom.aromatic`), so clearing stale flags here has no effect on the
686/// computation itself; it only ensures the *output* molecule's flags come
687/// entirely from this engine's own verdict, never from whatever annotation
688/// the caller's input happened to carry in.
689fn clear_aromatic_flags(mol: &Molecule) -> Molecule {
690    use chematic_core::MoleculeBuilder;
691    let mut builder = MoleculeBuilder::new();
692    for (_, atom) in mol.atoms() {
693        let mut a = atom.clone();
694        a.aromatic = false;
695        builder.add_atom(a);
696    }
697    for (_, bond) in mol.bonds() {
698        let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
699    }
700    builder.copy_stereo_groups_from(mol);
701    builder.copy_stereo_from(mol);
702    builder.copy_bond_directions_from(mol);
703    builder.build()
704}
705
706/// Normalize `mol` to pure Kekulé form (this engine's precondition): clear
707/// stale aromatic flags, then kekulize. Returns an explicit error instead of
708/// falling back to another algorithm or leaving a partially-rewritten
709/// molecule behind -- `mol` itself is never mutated, only read.
710fn kekulize_for_rdkit_parity(mol: &Molecule) -> Result<Molecule, AromaticityError> {
711    let cleared = clear_aromatic_flags(mol);
712    match chematic_core::kekulize(&cleared) {
713        Ok(k) => Ok(chematic_core::apply_kekule(&cleared, &k)),
714        Err(e) => Err(AromaticityError::KekulizationFailed { reason: e.detail }),
715    }
716}
717
718/// Every aromatic bond's two endpoint atoms must themselves be in the
719/// aromatic atom set -- a basic well-formedness property of any Hückel
720/// verdict. Cheap to check, catches a class of bug that would otherwise
721/// surface downstream as a confusing SMILES/valence inconsistency instead
722/// of a clear error at the source.
723fn validate_aromaticity_invariants(
724    mol: &Molecule,
725    atoms: &FxHashSet<AtomIdx>,
726    bonds: &FxHashSet<BondIdx>,
727) -> Result<(), AromaticityError> {
728    for &bidx in bonds {
729        let bond = mol.bond(bidx);
730        if !atoms.contains(&bond.atom1) || !atoms.contains(&bond.atom2) {
731            return Err(AromaticityError::InternalInvariantViolation {
732                reason: format!(
733                    "aromatic bond {bidx:?} ({:?}-{:?}) has a non-aromatic endpoint atom",
734                    bond.atom1, bond.atom2
735                ),
736            });
737        }
738    }
739    Ok(())
740}
741
742fn assign_from_kekulized(kekulized: &Molecule) -> Result<AromaticityModel, AromaticityError> {
743    let (atoms, bonds) = rdkit_parity_aromaticity(kekulized);
744    validate_aromaticity_invariants(kekulized, &atoms, &bonds)?;
745    Ok(AromaticityModel::from_atom_bond_sets(atoms, bonds))
746}
747
748/// Assign aromaticity using the RDKit-parity reference engine
749/// (`rdkit_parity_aromaticity`, see the module doc comment).
750///
751/// Explicitly opt-in and separate from [`assign_aromaticity_ex`]/
752/// [`AromaticityAlgorithm`] -- those remain infallible and unchanged. This
753/// function is fallible because it performs its own kekulization
754/// internally (this engine requires pre-kekulized input); on failure,
755/// `mol` is never touched and no partial result is produced.
756///
757/// The returned model's atom/bond indices correspond 1:1 with `mol`'s own
758/// indices (kekulization here is index-preserving: it only clears stale
759/// aromatic flags and normalizes bond orders, never adds/removes/reorders
760/// atoms or bonds).
761///
762/// [`ring_classifications`](AromaticityModel::ring_classifications) and
763/// [`antiaromatic_rings`](AromaticityModel::antiaromatic_rings) are always
764/// empty on the returned model -- this engine (like RDKit's own) determines
765/// only the aromatic atom/bond sets, not a per-ring classification or
766/// antiaromaticity verdict.
767///
768/// [`assign_aromaticity_ex`]: crate::assign_aromaticity_ex
769/// [`AromaticityAlgorithm`]: crate::AromaticityAlgorithm
770pub fn assign_aromaticity_rdkit_parity_experimental(
771    mol: &Molecule,
772) -> Result<AromaticityModel, AromaticityError> {
773    let kekulized = kekulize_for_rdkit_parity(mol)?;
774    assign_from_kekulized(&kekulized)
775}
776
777/// Apply aromaticity using the RDKit-parity reference engine, returning a
778/// new [`Molecule`] with atom/bond flags set according to the computed
779/// model.
780///
781/// See [`assign_aromaticity_rdkit_parity_experimental`] for the fallibility
782/// contract (kekulization failure is reported, never silently substituted
783/// or partially applied) and the index-correspondence guarantee.
784pub fn apply_aromaticity_rdkit_parity_experimental(
785    mol: &Molecule,
786) -> Result<Molecule, AromaticityError> {
787    let kekulized = kekulize_for_rdkit_parity(mol)?;
788    let model = assign_from_kekulized(&kekulized)?;
789    Ok(crate::aromaticity::build_molecule_from_model(
790        &kekulized, &model,
791    ))
792}
793
794// ---------------------------------------------------------------------------
795// Tests
796// ---------------------------------------------------------------------------
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    fn mol_kekulized(smiles: &str) -> Molecule {
803        let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
804        let k = chematic_core::kekulize(&mol).expect("kekulizable");
805        chematic_core::apply_kekule(&mol, &k)
806    }
807
808    // Calibration battery, RDKit-atom-index-verified (not guessed): every
809    // entry here was checked against a live `rdkit.Chem.MolFromSmiles(...)`
810    // atom-aromaticity dump before being pinned. Covers the exact cases that
811    // motivated this module: simple monocyclics (benzene/pyrrole/furan/
812    // thiophene), the exocyclic-carbonyl-in-ring rule (tropone/2-pyridone/
813    // 4-pyranone), a genuine bridgehead spanning two valid rings
814    // (indolizine), a non-alternant fused bicyclic needing the whole-perimeter
815    // candidate (azulene), plain fused benzenoids (naphthalene/anthracene),
816    // fused heteroaromatics (indole/quinoline/purine), and both open findings
817    // from Aromaticity-A1-1a (the false-positive reproducer, purine).
818    #[test]
819    fn calibration_battery_matches_rdkit() {
820        let cases: &[(&str, &str, &[u32])] = &[
821            ("benzene", "c1ccccc1", &[0, 1, 2, 3, 4, 5]),
822            ("pyrrole", "c1cc[nH]c1", &[0, 1, 2, 3, 4]),
823            ("furan", "c1ccoc1", &[0, 1, 2, 3, 4]),
824            ("thiophene", "c1ccsc1", &[0, 1, 2, 3, 4]),
825            (
826                "selenophene (Se analog control, pre-Kekulized input)",
827                "C1=C[Se]C=C1",
828                &[0, 1, 2, 3, 4],
829            ),
830            (
831                "tellurophene (pre-Kekulized input; regression test for the Te \
832                 normal_valences() gap fixed in chematic-core's element.rs — Te previously \
833                 had no valence-table entry, so default_valence/count_atom_pi_electrons/\
834                 get_atom_electron_donor_type all returned None and \
835                 is_atom_candidate_for_aromaticity rejected it outright)",
836                "C1=C[Te]C=C1",
837                &[0, 1, 2, 3, 4],
838            ),
839            ("tropone", "O=c1cccccc1", &[1, 2, 3, 4, 5, 6, 7]),
840            ("2-pyridone", "O=c1cccc[nH]1", &[1, 2, 3, 4, 5, 6]),
841            ("4-pyranone", "O=c1ccocc1", &[1, 2, 3, 4, 5, 6]),
842            (
843                "indolizine (true bridgehead, both rings valid)",
844                "c1ccn2ccccc12",
845                &[0, 1, 2, 3, 4, 5, 6, 7, 8],
846            ),
847            (
848                "azulene (non-alternant, needs whole-perimeter candidate)",
849                "C1=CC2=CC=CC=CC2=C1",
850                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
851            ),
852            (
853                "naphthalene",
854                "c1ccc2ccccc2c1",
855                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
856            ),
857            (
858                "anthracene",
859                "c1ccc2cc3ccccc3cc2c1",
860                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
861            ),
862            ("indole", "c1ccc2[nH]ccc2c1", &[0, 1, 2, 3, 4, 5, 6, 7, 8]),
863            (
864                "quinoline",
865                "c1ccc2ncccc2c1",
866                &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
867            ),
868            (
869                "purine (Aromaticity-A1-1a open finding, fixed here)",
870                "c1cnc2[nH]cnc2n1",
871                &[0, 1, 2, 3, 4, 5, 6, 7, 8],
872            ),
873            (
874                "PR #86 false-positive reproducer (Aromaticity-A1-1a open finding, fixed here)",
875                "C1=Cc2ccccc2C2=NCCCN12",
876                &[2, 3, 4, 5, 6, 7],
877            ),
878        ];
879
880        for (name, smi, expected) in cases {
881            let mol = mol_kekulized(smi);
882            let (atoms, _bonds) = rdkit_parity_aromaticity(&mol);
883            let mut got: Vec<u32> = atoms.iter().map(|a| a.0).collect();
884            got.sort();
885            assert_eq!(&got, expected, "{name} ({smi}): should match RDKit exactly");
886        }
887    }
888
889    #[test]
890    fn te_default_valence_and_aromatic_bond_flags() {
891        // Direct check of the dependent-path fix: default_valence(52) must now
892        // resolve to Some(2) (was None before the chematic-core element.rs
893        // Te normal_valences() fix).
894        assert_eq!(default_valence(52), Some(2));
895
896        // The calibration battery above only checks the aromatic ATOM set for
897        // tellurophene. RDKit also reports tellurophene's ring BONDS as
898        // aromatic (bond order 1.5 on every ring bond, oracle-verified via
899        // rdkit.Chem.MolFromSmiles("C1=C[Te]C=C1")); confirm chematic's
900        // aromatic bond set matches too, not just the atom set.
901        let mol = mol_kekulized("C1=C[Te]C=C1");
902        let (atoms, bonds) = rdkit_parity_aromaticity(&mol);
903        assert_eq!(
904            atoms.len(),
905            5,
906            "all 5 tellurophene ring atoms must be aromatic"
907        );
908        assert_eq!(
909            bonds.len(),
910            5,
911            "all 5 tellurophene ring bonds must be aromatic"
912        );
913    }
914
915    // Purine's Aromaticity-A1-0 finding was that production's answer depends
916    // on whether the input was Kekulized before `apply_aromaticity` ran.
917    // rdkit_parity_aromaticity must NOT reintroduce that: both a raw
918    // aromatic-lowercase parse (kekulized here identically to every other
919    // corpus entry, so this mostly re-confirms `mol_kekulized`'s own
920    // determinism) and chematic's own `kekulize()` choice must agree with
921    // each other and with RDKit.
922    #[test]
923    fn purine_representation_stable() {
924        let smi = "c1cnc2[nH]cnc2n1";
925        let raw = chematic_smiles::parse(smi).expect("valid SMILES");
926        let k = chematic_core::kekulize(&raw).expect("purine should kekulize");
927        let via_own_kekulize = chematic_core::apply_kekule(&raw, &k);
928
929        let (atoms_a, _) = rdkit_parity_aromaticity(&mol_kekulized(smi));
930        let (atoms_b, _) = rdkit_parity_aromaticity(&via_own_kekulize);
931
932        let mut a: Vec<u32> = atoms_a.iter().map(|x| x.0).collect();
933        let mut b: Vec<u32> = atoms_b.iter().map(|x| x.0).collect();
934        a.sort();
935        b.sort();
936        assert_eq!(a, b, "purine: two Kekulization paths disagree");
937        assert_eq!(
938            a,
939            vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
940            "purine: should match RDKit (all 9 atoms aromatic)"
941        );
942    }
943
944    #[test]
945    fn production_api_assign_matches_engine_on_benzene() {
946        let mol = chematic_smiles::parse("c1ccccc1").expect("valid SMILES");
947        let model = assign_aromaticity_rdkit_parity_experimental(&mol).expect("benzene kekulizes");
948        assert_eq!(model.aromatic_atom_count(), 6);
949        for (idx, _) in mol.atoms() {
950            assert!(
951                model.is_atom_aromatic(idx),
952                "atom {idx:?} should be aromatic"
953            );
954        }
955        // This engine only determines atom/bond sets, not per-ring
956        // classification or antiaromaticity -- both must be empty.
957        assert!(model.ring_classifications().is_empty());
958        assert!(model.antiaromatic_rings().is_empty());
959    }
960
961    #[test]
962    fn production_api_apply_sets_aromatic_flags_and_bond_orders() {
963        let mol = chematic_smiles::parse("C1=CC=CC=C1").expect("valid SMILES"); // Kekule benzene
964        let applied = apply_aromaticity_rdkit_parity_experimental(&mol).expect("benzene kekulizes");
965        assert_eq!(applied.atom_count(), mol.atom_count());
966        for (_, atom) in applied.atoms() {
967            assert!(atom.aromatic, "every benzene atom should end up aromatic");
968        }
969        for (_, bond) in applied.bonds() {
970            assert_eq!(bond.order, BondOrder::Aromatic);
971        }
972    }
973
974    #[test]
975    fn production_api_reports_kekulize_failure_not_panic() {
976        // The one known-gap molecule from the full-corpus gate: RDKit itself
977        // parses this fine, but chematic's own `kekulize()` rejects a
978        // bridgehead N in this fused purine-like system. Must surface as
979        // `AromaticityError::KekulizationFailed`, not a panic and not a
980        // silent fallback to another algorithm.
981        let smi = "Cc1cn2c(=O)c3ncn(COCCO)c3nc2n1C";
982        let mol = chematic_smiles::parse(smi).expect("valid SMILES");
983
984        match assign_aromaticity_rdkit_parity_experimental(&mol) {
985            Err(AromaticityError::KekulizationFailed { .. }) => {}
986            other => panic!("expected KekulizationFailed, got {other:?}"),
987        }
988        // `Molecule` has no `Debug` impl, so match on the error shape only
989        // (discarding the `Ok(Molecule)` payload) rather than formatting
990        // the whole `Result` on failure.
991        match apply_aromaticity_rdkit_parity_experimental(&mol).map(|_| ()) {
992            Err(AromaticityError::KekulizationFailed { .. }) => {}
993            other => panic!("expected KekulizationFailed, got {other:?}"),
994        }
995    }
996
997    #[test]
998    fn production_api_does_not_mutate_input_on_failure() {
999        // "元の分子を途中まで書き換えてから失敗する経路は作らないでください" --
1000        // `mol` is only ever taken by `&Molecule` throughout the fallible
1001        // path (`clear_aromatic_flags`/`kekulize`/`apply_kekule` all build
1002        // new molecules rather than mutating in place), so this is enforced
1003        // by the type system. Pin it as an explicit regression: the input's
1004        // own atom/bond flags and counts are unchanged after a failed call.
1005        let smi = "Cc1cn2c(=O)c3ncn(COCCO)c3nc2n1C";
1006        let mol = chematic_smiles::parse(smi).expect("valid SMILES");
1007        let atom_count_before = mol.atom_count();
1008        let bond_count_before = mol.bond_count();
1009        let aromatic_before: Vec<bool> = mol.atoms().map(|(_, a)| a.aromatic).collect();
1010
1011        let result = assign_aromaticity_rdkit_parity_experimental(&mol);
1012        assert!(
1013            result.is_err(),
1014            "this molecule is a known kekulize-gap case"
1015        );
1016
1017        assert_eq!(mol.atom_count(), atom_count_before);
1018        assert_eq!(mol.bond_count(), bond_count_before);
1019        let aromatic_after: Vec<bool> = mol.atoms().map(|(_, a)| a.aromatic).collect();
1020        assert_eq!(aromatic_before, aromatic_after);
1021    }
1022
1023    /// Was a known kekulize-gap case (found during Morgan M4-A0, `chematic-fp`'s
1024    /// `rdkit_morgan_hash.rs`, full-corpus validation): pyridinium's protonated
1025    /// `[nH+]` used to make `chematic_core::kekulize()` hard-fail because
1026    /// `atom_must_be_matched`'s N-with-H lone-pair-donor rule was charge-blind
1027    /// (docs/rfcs/aromaticity_rdkit_parity_rfc.md §1, root cause A). Fixed by
1028    /// `fix/kekulize-charge-aware-k1` (see
1029    /// docs/rfcs/kekulize_charge_aware_rdkit_parity.md): the rule now requires
1030    /// `atom.charge <= 0`, so a protonated ring N routes back to "must be
1031    /// matched" -- same as neutral pyridine's bare N -- instead of being
1032    /// wrongly treated like neutral pyrrole's `[nH]`. Kept as a regression
1033    /// test (not deleted) so a future re-introduction of the charge-blind rule
1034    /// is caught here, not just in the 40-fixture diagnosis corpus.
1035    ///
1036    /// NOTE for whoever picks up the companion fp-side fix: `chematic-fp`'s
1037    /// `rdkit_morgan_ecfp4.rs` has two tests
1038    /// (`kekule_pyridinium_reports_kekulization_failed_not_a_fallback_result`,
1039    /// `hueckel_fallback_would_be_detectable_if_silently_reintroduced`) that
1040    /// also used this exact SMILES as their "kekulize fails" positive control
1041    /// for a *different* invariant (the fallible ECFP4 path must not silently
1042    /// fall back to Hueckel) -- those are out of scope for K1 (chematic-fp is
1043    /// off limits for this fix) and still fail as of this commit. They need
1044    /// the same swap this test got: same invariant, a still-failing molecule
1045    /// as the example (`Cc1cn2c(=O)c3ncn(COCCO)c3nc2n1C`, see
1046    /// `production_api_reports_kekulize_failure_not_panic` above), not a
1047    /// deletion. `validation/README.md`'s Morgan M4-A0 section and several
1048    /// `validation/*.json` artifacts also cite a now-stale "2 of 5,048"
1049    /// preprocessing-failure count (only the purine molecule remains).
1050    #[test]
1051    fn kekulize_charge_aware_k1_fixes_protonated_pyridinium() {
1052        let smi = "c1cc[nH+]cc1";
1053        let mol = chematic_smiles::parse(smi).expect("valid SMILES");
1054        let result = apply_aromaticity_rdkit_parity_experimental(&mol);
1055        match result {
1056            Ok(applied) => {
1057                for (_, atom) in applied.atoms() {
1058                    assert!(
1059                        atom.aromatic,
1060                        "pyridinium's ring must end up fully aromatic"
1061                    );
1062                }
1063            }
1064            Err(other) => {
1065                panic!("expected pyridinium to kekulize successfully post-K1, got {other:?}")
1066            }
1067        }
1068    }
1069
1070    #[test]
1071    fn production_api_stale_aromatic_flag_is_overridden_not_leaked() {
1072        // A non-aromatic atom that happens to carry a stale `aromatic=true`
1073        // flag on input must not leak that flag into the output -- the
1074        // engine's own verdict is the sole source of truth for the result.
1075        use chematic_core::MoleculeBuilder;
1076
1077        let mut base = chematic_smiles::parse("CC").expect("valid SMILES"); // ethane, acyclic
1078        let mut builder = MoleculeBuilder::new();
1079        for (_, atom) in base.atoms() {
1080            let mut a = atom.clone();
1081            a.aromatic = true; // stale/bogus annotation
1082            builder.add_atom(a);
1083        }
1084        for (_, bond) in base.bonds() {
1085            let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
1086        }
1087        base = builder.build();
1088        assert!(base.atoms().all(|(_, a)| a.aromatic), "test setup sanity");
1089
1090        let applied = apply_aromaticity_rdkit_parity_experimental(&base)
1091            .expect("acyclic molecule kekulizes trivially (no-op)");
1092        assert!(
1093            applied.atoms().all(|(_, a)| !a.aromatic),
1094            "stale aromatic=true on an acyclic atom must not survive"
1095        );
1096    }
1097}