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}