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}