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