#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AromaticityAlgorithm {
#[default]
Huckel,
RdkitLike,
}
use rustc_hash::{FxHashMap, FxHashSet};
use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, implicit_hcount};
use crate::ring_family::RingFamily;
use crate::sssr::find_sssr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RingAromaticity {
Aromatic,
Antiaromatic,
NonAromatic,
}
#[derive(Debug, Clone)]
pub struct AromaticityModel {
aromatic_atoms: FxHashSet<AtomIdx>,
aromatic_bonds: FxHashSet<BondIdx>,
antiaromatic_rings: Vec<Vec<AtomIdx>>,
ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)>,
}
impl AromaticityModel {
pub fn is_atom_aromatic(&self, idx: AtomIdx) -> bool {
self.aromatic_atoms.contains(&idx)
}
pub fn is_bond_aromatic(&self, idx: BondIdx) -> bool {
self.aromatic_bonds.contains(&idx)
}
pub fn aromatic_atom_count(&self) -> usize {
self.aromatic_atoms.len()
}
pub fn ring_classifications(&self) -> &[(Vec<AtomIdx>, RingAromaticity, u32)] {
&self.ring_classifications
}
pub fn antiaromatic_rings(&self) -> &[Vec<AtomIdx>] {
&self.antiaromatic_rings
}
pub fn has_antiaromaticity(&self) -> bool {
!self.antiaromatic_rings.is_empty()
}
pub(crate) fn from_atom_bond_sets(
aromatic_atoms: FxHashSet<AtomIdx>,
aromatic_bonds: FxHashSet<BondIdx>,
) -> Self {
AromaticityModel {
aromatic_atoms,
aromatic_bonds,
antiaromatic_rings: Vec::new(),
ring_classifications: Vec::new(),
}
}
}
#[allow(clippy::manual_is_multiple_of)]
fn classify_ring_aromaticity(pi_electrons: u32) -> (RingAromaticity, u32) {
if pi_electrons >= 2 && (pi_electrons - 2) % 4 == 0 {
(RingAromaticity::Aromatic, pi_electrons)
} else if pi_electrons > 0 && pi_electrons % 4 == 0 {
(RingAromaticity::Antiaromatic, pi_electrons)
} else {
(RingAromaticity::NonAromatic, pi_electrons)
}
}
fn mark_ring_aromatic(
mol: &Molecule,
ring: &[AtomIdx],
aromatic_atoms: &mut FxHashSet<AtomIdx>,
aromatic_bonds: &mut FxHashSet<BondIdx>,
) {
for &atom in ring {
aromatic_atoms.insert(atom);
}
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
if let Some((bidx, _)) = mol.bond_between(a, b) {
aromatic_bonds.insert(bidx);
}
}
}
pub fn assign_aromaticity(mol: &Molecule) -> AromaticityModel {
assign_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
}
pub fn assign_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> AromaticityModel {
let ring_set = find_sssr(mol);
let sssr_rings = ring_set.rings();
let rings: Vec<Vec<AtomIdx>> = augmented_ring_set(mol, sssr_rings);
let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
let mut antiaromatic_rings: Vec<Vec<AtomIdx>> = Vec::new();
let mut classifications: Vec<Option<(RingAromaticity, u32)>> = vec![None; rings.len()];
let mut pass2_candidates: Vec<usize> = Vec::new();
let empty_context = FxHashSet::default();
for (ring_idx, ring) in rings.iter().enumerate() {
match ring_pi_electrons(mol, ring, &empty_context, algo) {
Some(pi) => {
let (cls, count) = classify_ring_aromaticity(pi);
classifications[ring_idx] = Some((cls, count));
match cls {
RingAromaticity::Aromatic => {
mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
}
RingAromaticity::Antiaromatic => {
antiaromatic_rings.push(ring.to_vec());
}
RingAromaticity::NonAromatic => {
pass2_candidates.push(ring_idx);
}
}
}
None => {
pass2_candidates.push(ring_idx);
}
}
}
loop {
let mut any_new = false;
let mut still_pending: Vec<usize> = Vec::new();
for ring_idx in pass2_candidates {
let ring = &rings[ring_idx];
if !ring.iter().any(|a| aromatic_atoms.contains(a)) {
still_pending.push(ring_idx);
continue;
}
match ring_pi_electrons(mol, ring, &aromatic_atoms, algo) {
Some(pi) => {
let (cls, count) = classify_ring_aromaticity(pi);
classifications[ring_idx] = Some((cls, count));
if matches!(cls, RingAromaticity::Aromatic) {
mark_ring_aromatic(mol, ring, &mut aromatic_atoms, &mut aromatic_bonds);
any_new = true;
}
}
None => {
still_pending.push(ring_idx);
}
}
}
pass2_candidates = still_pending;
if !any_new {
break;
}
}
let ring_classifications: Vec<(Vec<AtomIdx>, RingAromaticity, u32)> = rings
.iter()
.take(sssr_rings.len()) .enumerate()
.filter_map(|(i, ring)| classifications[i].map(|(cls, count)| (ring.to_vec(), cls, count)))
.collect();
AromaticityModel {
aromatic_atoms,
aromatic_bonds,
antiaromatic_rings,
ring_classifications,
}
}
pub fn apply_aromaticity(mol: &Molecule) -> Molecule {
apply_aromaticity_ex(mol, AromaticityAlgorithm::Huckel)
}
pub fn apply_aromaticity_ex(mol: &Molecule, algo: AromaticityAlgorithm) -> Molecule {
let model = assign_aromaticity_ex(mol, algo);
build_molecule_from_model(mol, &model)
}
pub(crate) fn build_molecule_from_model(mol: &Molecule, model: &AromaticityModel) -> Molecule {
use chematic_core::{BondOrder, MoleculeBuilder, implicit_hcount};
let pre_h: Vec<Option<u8>> = mol
.atoms()
.map(|(idx, atom)| {
if atom.hydrogen_count.is_some() {
None } else {
Some(implicit_hcount(mol, idx))
}
})
.collect();
let mut builder = MoleculeBuilder::new();
for (idx, atom) in mol.atoms() {
let mut a = atom.clone();
if model.is_atom_aromatic(idx) {
a.aromatic = true;
}
builder.add_atom(a);
}
for (bidx, bond) in mol.bonds() {
let order = if model.is_bond_aromatic(bidx) {
BondOrder::Aromatic
} else {
bond.order
};
if let Ok(new_bidx) = builder.add_bond(bond.atom1, bond.atom2, order)
&& order == BondOrder::Aromatic
&& matches!(bond.order, BondOrder::Up | BondOrder::Down)
{
builder.set_bond_direction(new_bidx, bond.order);
}
}
builder.copy_stereo_groups_from(mol);
builder.copy_stereo_from(mol);
builder.copy_bond_directions_from(mol);
let normalized = builder.build();
let needs_patch: Vec<(chematic_core::AtomIdx, u8)> = normalized
.atoms()
.filter_map(|(idx, _)| {
let pre = pre_h[idx.0 as usize]?;
let post = implicit_hcount(&normalized, idx);
(pre != post).then_some((idx, pre))
})
.collect();
if needs_patch.is_empty() {
return normalized;
}
let mut patched = MoleculeBuilder::new();
for (idx, atom) in normalized.atoms() {
let mut a = atom.clone();
if let Some(&(_, h)) = needs_patch.iter().find(|(pidx, _)| *pidx == idx) {
a.hydrogen_count = Some(h);
}
patched.add_atom(a);
}
for (_bond_idx, bond) in normalized.bonds() {
let _ = patched.add_bond(bond.atom1, bond.atom2, bond.order);
}
patched.copy_stereo_groups_from(&normalized);
patched.copy_stereo_from(&normalized);
patched.copy_bond_directions_from(&normalized);
patched.build()
}
fn ring_bond_set(mol: &Molecule, ring: &[AtomIdx]) -> Vec<BondIdx> {
let n = ring.len();
let mut bonds: Vec<BondIdx> = (0..n)
.filter_map(|i| {
let a = ring[i];
let b = ring[(i + 1) % n];
mol.bond_between(a, b).map(|(bidx, _)| bidx)
})
.collect();
bonds.sort();
bonds
}
fn bond_sym_diff(a: &[BondIdx], b: &[BondIdx]) -> Vec<BondIdx> {
let mut result: Vec<BondIdx> = Vec::new();
let mut i = 0;
let mut j = 0;
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
std::cmp::Ordering::Less => {
result.push(a[i]);
i += 1;
}
std::cmp::Ordering::Greater => {
result.push(b[j]);
j += 1;
}
std::cmp::Ordering::Equal => {
i += 1;
j += 1;
}
}
}
result.extend_from_slice(&a[i..]);
result.extend_from_slice(&b[j..]);
result
}
fn ring_atoms_from_bond_set(mol: &Molecule, bonds: &[BondIdx]) -> Option<Vec<AtomIdx>> {
if bonds.is_empty() {
return None;
}
let mut adj: FxHashMap<AtomIdx, [Option<AtomIdx>; 2]> = FxHashMap::default();
for &bidx in bonds {
let bond = mol.bond(bidx);
for (a, b) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
let e = adj.entry(a).or_insert([None; 2]);
if e[0].is_none() {
e[0] = Some(b);
} else if e[1].is_none() {
e[1] = Some(b);
} else {
return None; }
}
}
if adj.values().any(|e| e[1].is_none()) {
return None;
}
let start = *adj.keys().next()?;
let mut path = vec![start];
let mut prev = start;
let mut current = adj[&start][0]?;
while current != start {
path.push(current);
let [n0, n1] = adj[¤t];
let next = if n0 == Some(prev) { n1? } else { n0? };
prev = current;
current = next;
}
if path.len() != bonds.len() {
return None;
}
Some(path)
}
pub fn augmented_ring_set(mol: &Molecule, sssr_rings: &[Vec<AtomIdx>]) -> Vec<Vec<AtomIdx>> {
let mut rings: Vec<Vec<AtomIdx>> = sssr_rings.to_vec();
let mut known: FxHashSet<Vec<AtomIdx>> = sssr_rings
.iter()
.map(|r| {
let mut s = r.clone();
s.sort();
s
})
.collect();
loop {
let mut changed = false;
let n = rings.len();
let bond_sets: Vec<Vec<BondIdx>> = rings.iter().map(|r| ring_bond_set(mol, r)).collect();
for i in 0..n {
for j in (i + 1)..n {
let shares_atom = rings[i].iter().any(|a| rings[j].contains(a));
if !shares_atom {
continue;
}
let xor_bonds = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
if xor_bonds.is_empty() {
continue;
}
if xor_bonds.len() > rings[i].len().max(rings[j].len()) {
continue;
}
if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_bonds) {
let mut key = new_ring.clone();
key.sort();
if known.insert(key) {
rings.push(new_ring);
changed = true;
}
}
}
}
for i in 0..n {
for j in (i + 1)..n {
let shares_ij = rings[i].iter().any(|a| rings[j].contains(a));
if !shares_ij {
continue;
}
let xor_ij = bond_sym_diff(&bond_sets[i], &bond_sets[j]);
if xor_ij.is_empty() {
continue;
}
for k in (j + 1)..n {
let shares_k = rings[k]
.iter()
.any(|a| rings[i].contains(a) || rings[j].contains(a));
if !shares_k {
continue;
}
let xor_ijk = bond_sym_diff(&xor_ij, &bond_sets[k]);
let max_size = rings[i].len().max(rings[j].len()).max(rings[k].len());
if xor_ijk.is_empty() || xor_ijk.len() > max_size {
continue;
}
if let Some(new_ring) = ring_atoms_from_bond_set(mol, &xor_ijk) {
let mut key = new_ring.clone();
key.sort();
if known.insert(key) {
rings.push(new_ring);
changed = true;
}
}
}
}
}
if !changed {
break;
}
}
rings
}
fn all_ring_list_inner(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
let sssr = crate::sssr::find_sssr(mol);
let aug = augmented_ring_set(mol, sssr.rings());
if aug.len() <= 1 {
return aug;
}
let bond_sets: Vec<Vec<BondIdx>> = aug.iter().map(|r| ring_bond_set(mol, r)).collect();
let mut is_envelope = vec![false; aug.len()];
strip_envelope_rings(&aug, &bond_sets, &mut is_envelope);
aug.into_iter()
.zip(is_envelope)
.filter(|(_, e)| !e)
.map(|(r, _)| r)
.collect()
}
pub fn all_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
all_ring_list_inner(mol)
}
pub fn ring_bonds_all_aromatic(mol: &Molecule, ring: &[AtomIdx]) -> bool {
let n = ring.len();
(0..n).all(|i| {
let a = ring[i];
let b = ring[(i + 1) % n];
mol.bond_between(a, b)
.map(|(bidx, _)| mol.bond(bidx).order == BondOrder::Aromatic)
.unwrap_or(true)
})
}
pub fn aromatic_ring_list(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
let mol_with_arom;
let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
mol
} else {
mol_with_arom = apply_aromaticity(mol);
&mol_with_arom
};
all_ring_list_inner(mol)
.into_iter()
.filter(|ring| {
ring.iter().all(|&idx| mol.atom(idx).aromatic) && ring_bonds_all_aromatic(mol, ring)
})
.collect()
}
fn strip_envelope_rings(
aromatic: &[Vec<AtomIdx>],
bond_sets: &[Vec<BondIdx>],
is_envelope: &mut [bool],
) {
let n = aromatic.len();
for i in 0..n {
let si = aromatic[i].len();
'jk: for j in 0..n {
if j == i || aromatic[j].len() >= si {
continue;
}
for k in (j + 1)..n {
if k == i || aromatic[k].len() >= si {
continue;
}
if bond_sym_diff(&bond_sets[j], &bond_sets[k]) == bond_sets[i] {
is_envelope[i] = true;
break 'jk;
}
}
}
if !is_envelope[i] {
'jkl: for j in 0..n {
if j == i || aromatic[j].len() >= si {
continue;
}
for k in (j + 1)..n {
if k == i || aromatic[k].len() >= si {
continue;
}
let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
for l in (k + 1)..n {
if l == i || aromatic[l].len() >= si {
continue;
}
if bond_sym_diff(&xor_jk, &bond_sets[l]) == bond_sets[i] {
is_envelope[i] = true;
break 'jkl;
}
}
}
}
}
if !is_envelope[i] {
'jklm: for j in 0..n {
if j == i || aromatic[j].len() >= si {
continue;
}
for k in (j + 1)..n {
if k == i || aromatic[k].len() >= si {
continue;
}
let xor_jk = bond_sym_diff(&bond_sets[j], &bond_sets[k]);
for l in (k + 1)..n {
if l == i || aromatic[l].len() >= si {
continue;
}
let xor_jkl = bond_sym_diff(&xor_jk, &bond_sets[l]);
for m in (l + 1)..n {
if m == i || aromatic[m].len() >= si {
continue;
}
if bond_sym_diff(&xor_jkl, &bond_sets[m]) == bond_sets[i] {
is_envelope[i] = true;
break 'jklm;
}
}
}
}
}
}
}
}
pub fn count_aromatic_rings(mol: &Molecule) -> usize {
let mol_with_arom;
let mol = if mol.atoms().any(|(_, a)| a.aromatic) {
mol } else {
mol_with_arom = apply_aromaticity(mol);
&mol_with_arom
};
let sssr = crate::sssr::find_sssr(mol);
let aug = augmented_ring_set(mol, sssr.rings());
let aromatic: Vec<Vec<AtomIdx>> = aug
.into_iter()
.filter(|ring| ring.iter().all(|&idx| mol.atom(idx).aromatic))
.collect();
if aromatic.len() <= 1 {
return aromatic.len();
}
let bond_sets: Vec<Vec<BondIdx>> = aromatic.iter().map(|r| ring_bond_set(mol, r)).collect();
let n = aromatic.len();
let mut is_envelope = vec![false; n];
strip_envelope_rings(&aromatic, &bond_sets, &mut is_envelope);
is_envelope.iter().filter(|&&e| !e).count()
}
fn ring_pi_electrons(
mol: &Molecule,
ring: &[AtomIdx],
aromatic_context: &FxHashSet<AtomIdx>,
algo: AromaticityAlgorithm,
) -> Option<u32> {
let ring_atom_set: FxHashSet<AtomIdx> = ring.iter().copied().collect();
let mut total_pi: u32 = 0;
for &atom_idx in ring {
if aromatic_context.contains(&atom_idx) {
total_pi += 1;
continue;
}
let atom = mol.atom(atom_idx);
let an = atom.element.atomic_number();
let ring_degree = mol
.neighbors(atom_idx)
.filter(|(nb, _)| ring_atom_set.contains(nb))
.count();
let total_degree = mol.degree(atom_idx);
let has_explicit_double = mol
.neighbors(atom_idx)
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
let has_double_any = has_explicit_double
|| mol
.neighbors(atom_idx)
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
let has_aromatic_in_ring = mol
.neighbors(atom_idx)
.filter(|(nb, _)| ring_atom_set.contains(nb))
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
let pi = match an {
6 => {
if !has_double_any {
if atom.charge == -1 {
2
} else {
return None; }
} else if has_explicit_double
&& !has_aromatic_in_ring
&& !mol.neighbors(atom_idx).any(|(nb, bidx)| {
ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
})
&& mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb)
&& mol.bond(bidx).order == BondOrder::Double
&& matches!(mol.atom(nb).element.atomic_number(), 7 | 8 | 16)
})
{
0
} else {
1
}
}
7 => {
if implicit_hcount(mol, atom_idx) > 0 {
2
} else if has_explicit_double {
1
} else if total_degree == 3 && ring_degree < total_degree {
2
} else if has_aromatic_in_ring {
1
} else {
return None;
}
}
8 | 16 => {
if ring_degree != 2 {
return None;
}
if an == 16
&& mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
})
{
return None;
}
2
}
34 | 52 => {
if algo != AromaticityAlgorithm::RdkitLike {
return None;
}
if ring_degree != 2 {
return None;
}
if mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
}) {
return None;
}
2
}
_ => return None,
};
total_pi += pi;
}
Some(total_pi)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContributionReason {
AlreadyAromaticContext,
CarbonEndocyclicDouble,
CarbonExocyclicHeteroatomDouble,
CarbonCarbanionLonePair,
CarbonSp3Ineligible,
NitrogenPyrroleTypeH,
NitrogenPyridineTypeExplicitDouble,
NitrogenBridgeheadOrSubstitutedLonePair,
NitrogenAromaticInRing,
NitrogenIneligible,
ChalcogenLonePair,
ChalcogenIneligible,
UnsupportedElement,
}
impl ContributionReason {
pub fn is_eligible(self) -> bool {
!matches!(
self,
ContributionReason::CarbonSp3Ineligible
| ContributionReason::NitrogenIneligible
| ContributionReason::ChalcogenIneligible
| ContributionReason::UnsupportedElement
)
}
pub fn eligibility(self) -> PiEligibility {
use ContributionReason::*;
match self {
AlreadyAromaticContext
| CarbonEndocyclicDouble
| NitrogenPyridineTypeExplicitDouble
| NitrogenAromaticInRing => PiEligibility::OneElectron,
CarbonCarbanionLonePair
| NitrogenPyrroleTypeH
| NitrogenBridgeheadOrSubstitutedLonePair
| ChalcogenLonePair => PiEligibility::LonePairDonor,
CarbonExocyclicHeteroatomDouble => PiEligibility::ZeroElectron,
CarbonSp3Ineligible | NitrogenIneligible | ChalcogenIneligible | UnsupportedElement => {
PiEligibility::Ineligible
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PiEligibility {
OneElectron,
LonePairDonor,
ZeroElectron,
Ineligible,
}
impl PiEligibility {
pub fn electrons(self) -> Option<u8> {
match self {
PiEligibility::OneElectron => Some(1),
PiEligibility::LonePairDonor => Some(2),
PiEligibility::ZeroElectron => Some(0),
PiEligibility::Ineligible => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ConjugatedComponent {
pub atoms: Vec<AtomIdx>,
pub bonds: Vec<BondIdx>,
pub source_rings: Vec<usize>,
}
impl ConjugatedComponent {
fn from_ring(ring: &[AtomIdx], ring_idx: usize) -> Self {
ConjugatedComponent {
atoms: ring.to_vec(),
bonds: Vec::new(),
source_rings: vec![ring_idx],
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ContributionDecision {
pub eligibility: PiEligibility,
pub reason: ContributionReason,
}
impl ContributionDecision {
pub fn electrons(&self) -> Option<u8> {
self.eligibility.electrons()
}
}
#[derive(Debug, Clone, Copy)]
pub struct AtomElectronTrace {
pub atom_idx: AtomIdx,
pub contribution: Option<u8>,
pub reason: ContributionReason,
}
#[derive(Debug, Clone)]
pub struct RingElectronTrace {
pub atoms: Vec<AtomElectronTrace>,
pub total: Option<u32>,
}
pub fn trace_ring_pi_electrons(
mol: &Molecule,
ring: &[AtomIdx],
aromatic_context: &FxHashSet<AtomIdx>,
algo: AromaticityAlgorithm,
) -> RingElectronTrace {
let component = ConjugatedComponent::from_ring(ring, 0);
let mut atoms = Vec::with_capacity(ring.len());
let mut total: Option<u32> = Some(0);
for &atom_idx in ring {
let (contribution, reason) = if aromatic_context.contains(&atom_idx) {
(Some(1u8), ContributionReason::AlreadyAromaticContext)
} else {
let decision = evaluate_atom_pi_contribution(mol, atom_idx, &component, algo);
(decision.electrons(), decision.reason)
};
total = match (total, contribution) {
(Some(t), Some(c)) => Some(t + c as u32),
_ => None,
};
atoms.push(AtomElectronTrace {
atom_idx,
contribution,
reason,
});
}
RingElectronTrace { atoms, total }
}
pub fn evaluate_atom_pi_contribution(
mol: &Molecule,
atom_idx: AtomIdx,
component: &ConjugatedComponent,
algo: AromaticityAlgorithm,
) -> ContributionDecision {
let component_atoms: FxHashSet<AtomIdx> = component.atoms.iter().copied().collect();
let (_electrons, reason) =
evaluate_atom_pi_contribution_inner(mol, atom_idx, &component_atoms, algo);
ContributionDecision {
eligibility: reason.eligibility(),
reason,
}
}
fn evaluate_atom_pi_contribution_inner(
mol: &Molecule,
atom_idx: AtomIdx,
ring_atom_set: &FxHashSet<AtomIdx>,
algo: AromaticityAlgorithm,
) -> (Option<u8>, ContributionReason) {
let atom = mol.atom(atom_idx);
let an = atom.element.atomic_number();
let ring_degree = mol
.neighbors(atom_idx)
.filter(|(nb, _)| ring_atom_set.contains(nb))
.count();
let total_degree = mol.degree(atom_idx);
let has_explicit_double = mol
.neighbors(atom_idx)
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Double);
let has_double_any = has_explicit_double
|| mol
.neighbors(atom_idx)
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
let has_aromatic_in_ring = mol
.neighbors(atom_idx)
.filter(|(nb, _)| ring_atom_set.contains(nb))
.any(|(_, bidx)| mol.bond(bidx).order == BondOrder::Aromatic);
match an {
6 => {
if !has_double_any {
if atom.charge == -1 {
(Some(2), ContributionReason::CarbonCarbanionLonePair)
} else {
(None, ContributionReason::CarbonSp3Ineligible)
}
} else if has_explicit_double
&& !has_aromatic_in_ring
&& !mol.neighbors(atom_idx).any(|(nb, bidx)| {
ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
})
&& mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb)
&& mol.bond(bidx).order == BondOrder::Double
&& matches!(mol.atom(nb).element.atomic_number(), 7 | 8 | 16)
})
{
(Some(0), ContributionReason::CarbonExocyclicHeteroatomDouble)
} else {
(Some(1), ContributionReason::CarbonEndocyclicDouble)
}
}
7 => {
if implicit_hcount(mol, atom_idx) > 0 {
(Some(2), ContributionReason::NitrogenPyrroleTypeH)
} else if has_explicit_double {
(
Some(1),
ContributionReason::NitrogenPyridineTypeExplicitDouble,
)
} else if total_degree == 3 && ring_degree < total_degree {
(
Some(2),
ContributionReason::NitrogenBridgeheadOrSubstitutedLonePair,
)
} else if has_aromatic_in_ring {
(Some(1), ContributionReason::NitrogenAromaticInRing)
} else {
(None, ContributionReason::NitrogenIneligible)
}
}
8 | 16 => {
let exocyclic_double = an == 16
&& mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
});
if ring_degree != 2 || exocyclic_double {
(None, ContributionReason::ChalcogenIneligible)
} else {
(Some(2), ContributionReason::ChalcogenLonePair)
}
}
34 | 52 => {
let exocyclic_double = mol.neighbors(atom_idx).any(|(nb, bidx)| {
!ring_atom_set.contains(&nb) && mol.bond(bidx).order == BondOrder::Double
});
if algo != AromaticityAlgorithm::RdkitLike || ring_degree != 2 || exocyclic_double {
(None, ContributionReason::ChalcogenIneligible)
} else {
(Some(2), ContributionReason::ChalcogenLonePair)
}
}
_ => (None, ContributionReason::UnsupportedElement),
}
}
fn evaluate_atom_via_home_ring(
mol: &Molecule,
atom_idx: AtomIdx,
candidate: &ConjugatedComponent,
rings: &[Vec<AtomIdx>],
algo: AromaticityAlgorithm,
) -> ContributionDecision {
let mut last = None;
for &ri in &candidate.source_rings {
if !rings[ri].contains(&atom_idx) {
continue;
}
let home = ConjugatedComponent::from_ring(&rings[ri], ri);
let decision = evaluate_atom_pi_contribution(mol, atom_idx, &home, algo);
if decision.electrons().is_some() {
return decision;
}
last = Some(decision);
}
last.unwrap_or_else(|| evaluate_atom_pi_contribution(mol, atom_idx, candidate, algo))
}
pub fn build_conjugated_components(
mol: &Molecule,
rings: &[Vec<AtomIdx>],
ring_families: &[RingFamily],
algo: AromaticityAlgorithm,
) -> Vec<ConjugatedComponent> {
let mut out = Vec::new();
for family in ring_families {
if family.ring_indices.len() < 2 {
continue; }
let family_component = ConjugatedComponent {
atoms: family.atoms.clone(),
bonds: Vec::new(),
source_rings: family.ring_indices.clone(),
};
let eligible: FxHashMap<AtomIdx, bool> = family
.atoms
.iter()
.map(|&a| {
let decision = evaluate_atom_via_home_ring(mol, a, &family_component, rings, algo);
(a, decision.electrons().is_some())
})
.collect();
let atoms: Vec<AtomIdx> = family.atoms.clone();
let index_of: FxHashMap<AtomIdx, usize> =
atoms.iter().enumerate().map(|(i, &a)| (a, i)).collect();
let mut parent: Vec<usize> = (0..atoms.len()).collect();
fn find(parent: &mut [usize], x: usize) -> usize {
if parent[x] != x {
parent[x] = find(parent, parent[x]);
}
parent[x]
}
fn union(parent: &mut [usize], x: usize, y: usize) {
let (px, py) = (find(parent, x), find(parent, y));
if px != py {
parent[px] = py;
}
}
let family_atom_set: FxHashSet<AtomIdx> = family.atoms.iter().copied().collect();
let mut conjugation_bonds: Vec<BondIdx> = Vec::new();
for &a in &atoms {
if !eligible[&a] {
continue;
}
for (nb, bidx) in mol.neighbors(a) {
if !family_atom_set.contains(&nb) || !eligible.get(&nb).copied().unwrap_or(false) {
continue;
}
union(&mut parent, index_of[&a], index_of[&nb]);
conjugation_bonds.push(bidx);
}
}
let mut groups: FxHashMap<usize, Vec<AtomIdx>> = FxHashMap::default();
for &a in &atoms {
if !eligible[&a] {
continue;
}
let root = find(&mut parent, index_of[&a]);
groups.entry(root).or_default().push(a);
}
for group_atoms in groups.into_values() {
let group_set: FxHashSet<AtomIdx> = group_atoms.iter().copied().collect();
let source_rings: Vec<usize> = family
.ring_indices
.iter()
.copied()
.filter(|&ri| rings[ri].iter().all(|a| group_set.contains(a)))
.collect();
if source_rings.len() < 2 {
continue; }
let group_bonds: Vec<BondIdx> = conjugation_bonds
.iter()
.copied()
.filter(|&bidx| {
let b = mol.bond(bidx);
group_set.contains(&b.atom1) && group_set.contains(&b.atom2)
})
.collect();
out.push(ConjugatedComponent {
atoms: group_atoms,
bonds: group_bonds,
source_rings,
});
}
}
out
}
pub fn exhaustive_aromaticity_oracle(
mol: &Molecule,
algo: AromaticityAlgorithm,
) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
let sssr = find_sssr(mol);
let rings = augmented_ring_set(mol, sssr.rings());
let families = crate::ring_family::find_ring_families_over(mol, &rings);
let mut candidates: Vec<ConjugatedComponent> = rings
.iter()
.enumerate()
.map(|(i, r)| ConjugatedComponent::from_ring(r, i))
.collect();
candidates.extend(build_conjugated_components(mol, &rings, &families, algo));
let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
for candidate in &candidates {
let mut total: Option<u32> = Some(0);
for &atom_idx in &candidate.atoms {
let decision = evaluate_atom_via_home_ring(mol, atom_idx, candidate, &rings, algo);
total = match (total, decision.electrons()) {
(Some(t), Some(e)) => Some(t + e as u32),
_ => None,
};
}
let Some(pi) = total else { continue };
let (cls, _) = classify_ring_aromaticity(pi);
if !matches!(cls, RingAromaticity::Aromatic) {
continue;
}
for &a in &candidate.atoms {
aromatic_atoms.insert(a);
}
for &a in &candidate.atoms {
for (nb, bidx) in mol.neighbors(a) {
if candidate.atoms.contains(&nb)
&& matches!(
mol.bond(bidx).order,
BondOrder::Double | BondOrder::Aromatic
)
{
aromatic_bonds.insert(bidx);
}
}
}
}
(aromatic_atoms, aromatic_bonds)
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
fn benzene_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
for i in 0..6 {
let order = if i % 2 == 0 {
BondOrder::Double
} else {
BondOrder::Single
};
b.add_bond(atoms[i], atoms[(i + 1) % 6], order).unwrap();
}
b.build()
}
fn cyclohexane() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let atoms: Vec<_> = (0..6).map(|_| b.add_atom(Atom::new(Element::C))).collect();
for i in 0..6 {
b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Single)
.unwrap();
}
b.build()
}
fn pyridine_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let n = b.add_atom(Atom::new(Element::N));
let atoms_c: Vec<_> = (0..5).map(|_| b.add_atom(Atom::new(Element::C))).collect();
let ring = [
n, atoms_c[0], atoms_c[1], atoms_c[2], atoms_c[3], atoms_c[4],
];
for i in 0..6 {
let order = if i % 2 == 0 {
BondOrder::Double
} else {
BondOrder::Single
};
b.add_bond(ring[i], ring[(i + 1) % 6], order).unwrap();
}
b.build()
}
fn furan_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let o = b.add_atom(Atom::new(Element::O));
let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
let ring = [o, c1, c2, c3, c4];
b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
b.build()
}
fn pyrrole_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let mut n_atom = Atom::new(Element::N);
n_atom.hydrogen_count = Some(1);
let n = b.add_atom(n_atom);
let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
let ring = [n, c1, c2, c3, c4];
b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
b.build()
}
fn pyrrole_kekule_implicit_h() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let n = b.add_atom(Atom::new(Element::N));
let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
let ring = [n, c1, c2, c3, c4];
b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
b.build()
}
fn naphthalene_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let atoms: Vec<_> = (0..10).map(|_| b.add_atom(Atom::new(Element::C))).collect();
let ring1 = [0usize, 1, 2, 3, 4, 9];
let orders1 = [
BondOrder::Double,
BondOrder::Single,
BondOrder::Double,
BondOrder::Single,
BondOrder::Double,
BondOrder::Single,
];
for i in 0..6 {
b.add_bond(atoms[ring1[i]], atoms[ring1[(i + 1) % 6]], orders1[i])
.unwrap();
}
let ring2_extra = [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)];
let orders2 = [
BondOrder::Single,
BondOrder::Double,
BondOrder::Single,
BondOrder::Double,
BondOrder::Single,
];
for (i, &(a, bb)) in ring2_extra.iter().enumerate() {
b.add_bond(atoms[a], atoms[bb], orders2[i]).unwrap();
}
b.build()
}
fn cyclobutadiene_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let atoms: Vec<_> = (0..4).map(|_| b.add_atom(Atom::new(Element::C))).collect();
for i in 0..4 {
let order = if i % 2 == 0 {
BondOrder::Double
} else {
BondOrder::Single
};
b.add_bond(atoms[i], atoms[(i + 1) % 4], order).unwrap();
}
b.build()
}
fn cyclooctatetraene_kekule() -> chematic_core::Molecule {
let mut b = MoleculeBuilder::new();
let atoms: Vec<_> = (0..8).map(|_| b.add_atom(Atom::new(Element::C))).collect();
for i in 0..8 {
let order = if i % 2 == 0 {
BondOrder::Double
} else {
BondOrder::Single
};
b.add_bond(atoms[i], atoms[(i + 1) % 8], order).unwrap();
}
b.build()
}
#[cfg(test)]
fn mol_aromatic(smiles: &str) -> chematic_core::Molecule {
chematic_smiles::parse(smiles).expect("valid SMILES")
}
#[cfg(test)]
fn mol_kekulized(smiles: &str) -> chematic_core::Molecule {
let mol = chematic_smiles::parse(smiles).expect("valid SMILES");
let k = chematic_core::kekulize(&mol).expect("kekulizable");
chematic_core::apply_kekule(&mol, &k)
}
#[test]
fn test_benzene_is_aromatic() {
let mol = benzene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"all 6 benzene atoms aromatic"
);
for i in 0..6u32 {
assert!(model.is_atom_aromatic(AtomIdx(i)));
}
}
#[test]
fn test_cyclohexane_not_aromatic() {
let mol = cyclohexane();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 0, "cyclohexane not aromatic");
}
#[test]
fn test_pyridine_is_aromatic() {
let mol = pyridine_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 6);
}
#[test]
fn test_furan_is_aromatic() {
let mol = furan_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 5);
}
#[test]
fn test_pyrrole_is_aromatic() {
let mol = pyrrole_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 5);
}
#[test]
fn test_apply_aromaticity_preserves_pyrrole_nh_implicit_hydrogen() {
let mol = pyrrole_kekule_implicit_h();
let n_idx = AtomIdx(0);
assert_eq!(
implicit_hcount(&mol, n_idx),
1,
"pre-normalization: bare N with 2 single ring bonds must show 1 implicit H"
);
let perceived = apply_aromaticity(&mol);
assert!(perceived.atom(n_idx).aromatic, "ring N must be aromatic");
assert_eq!(
implicit_hcount(&perceived, n_idx),
1,
"post-apply_aromaticity: pyrrole N must still show 1 implicit H, not 0"
);
}
#[test]
fn test_apply_aromaticity_does_not_add_h_to_pyridine_type_n() {
let mol = pyridine_kekule();
let n_idx = AtomIdx(0);
assert_eq!(implicit_hcount(&mol, n_idx), 0);
let perceived = apply_aromaticity(&mol);
assert!(perceived.atom(n_idx).aromatic);
assert_eq!(implicit_hcount(&perceived, n_idx), 0);
assert_eq!(
perceived.atom(n_idx).hydrogen_count,
None,
"pyridine N must not gain an explicit hydrogen_count -- would force spurious bracket notation"
);
}
#[test]
fn test_naphthalene_both_rings_aromatic() {
let mol = naphthalene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
10,
"all 10 naphthalene atoms aromatic"
);
}
#[test]
fn test_bond_aromaticity_benzene() {
let mol = benzene_kekule();
let model = assign_aromaticity(&mol);
let count = mol
.bonds()
.filter(|(b, _)| model.is_bond_aromatic(*b))
.count();
assert_eq!(count, 6);
}
#[test]
fn test_apply_aromaticity_benzene() {
let mol = benzene_kekule();
let aromatic = apply_aromaticity(&mol);
for (_, atom) in aromatic.atoms() {
assert!(atom.aromatic, "every benzene carbon should be aromatic");
}
let aromatic_bond_count = aromatic
.bonds()
.filter(|(_, b)| b.order == BondOrder::Aromatic)
.count();
assert_eq!(aromatic_bond_count, 6);
}
#[test]
fn test_apply_aromaticity_cyclohexane_unchanged() {
let mol = cyclohexane();
let result = apply_aromaticity(&mol);
for (_, atom) in result.atoms() {
assert!(!atom.aromatic);
}
for (_, bond) in result.bonds() {
assert_ne!(bond.order, BondOrder::Aromatic);
}
}
#[test]
fn test_cyclobutadiene_antiaromatic() {
let mol = cyclobutadiene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
0,
"cyclobutadiene not aromatic"
);
assert!(model.has_antiaromaticity(), "cyclobutadiene antiaromatic");
assert_eq!(model.antiaromatic_rings().len(), 1);
let classifications = model.ring_classifications();
assert_eq!(classifications.len(), 1);
assert_eq!(classifications[0].1, RingAromaticity::Antiaromatic);
assert_eq!(classifications[0].2, 4);
}
#[test]
fn test_cyclooctatetraene_antiaromatic() {
let mol = cyclooctatetraene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 0, "COT not aromatic");
assert!(model.has_antiaromaticity(), "COT antiaromatic");
assert_eq!(model.antiaromatic_rings().len(), 1);
let cls = &model.ring_classifications()[0];
assert_eq!(cls.1, RingAromaticity::Antiaromatic);
assert_eq!(cls.2, 8);
}
#[test]
fn test_ring_classifications_benzene() {
let mol = benzene_kekule();
let model = assign_aromaticity(&mol);
let classifications = model.ring_classifications();
assert_eq!(classifications.len(), 1);
assert_eq!(classifications[0].1, RingAromaticity::Aromatic);
assert_eq!(classifications[0].2, 6);
}
#[test]
fn test_ring_classifications_naphthalene() {
let mol = naphthalene_kekule();
let model = assign_aromaticity(&mol);
let classifications = model.ring_classifications();
assert_eq!(classifications.len(), 2, "naphthalene has two rings");
for (_, classification, count) in classifications {
assert_eq!(*classification, RingAromaticity::Aromatic);
assert_eq!(*count, 6);
}
}
#[test]
fn test_non_aromatic_cyclohexane() {
let mol = cyclohexane();
let model = assign_aromaticity(&mol);
for (_, classification, _) in model.ring_classifications() {
assert_ne!(*classification, RingAromaticity::Aromatic);
assert_ne!(*classification, RingAromaticity::Antiaromatic);
}
}
#[test]
fn test_thiophene_aromatic() {
let mut b = MoleculeBuilder::new();
let s = b.add_atom(Atom::new(Element::S));
let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
let ring = [s, c1, c2, c3, c4];
b.add_bond(ring[0], ring[1], BondOrder::Single).unwrap();
b.add_bond(ring[1], ring[2], BondOrder::Double).unwrap();
b.add_bond(ring[2], ring[3], BondOrder::Single).unwrap();
b.add_bond(ring[3], ring[4], BondOrder::Double).unwrap();
b.add_bond(ring[4], ring[0], BondOrder::Single).unwrap();
let mol = b.build();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 5);
assert_eq!(model.ring_classifications()[0].2, 6);
}
#[test]
fn test_electron_distribution_tracking() {
let mol = benzene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.ring_classifications()[0].2, 6, "benzene: 6 × 1π = 6");
let mol = pyrrole_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(
model.ring_classifications()[0].2,
6,
"pyrrole: N(2π) + 4C(1π) = 6"
);
let mol = furan_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(
model.ring_classifications()[0].2,
6,
"furan: O(2π) + 4C(1π) = 6"
);
}
#[test]
fn test_benzene_aromatic_smiles() {
let mol = mol_aromatic("c1ccccc1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"benzene from aromatic SMILES"
);
}
#[test]
fn test_naphthalene_aromatic_smiles() {
let mol = mol_aromatic("c1ccc2ccccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
10,
"naphthalene from aromatic SMILES"
);
}
#[test]
fn test_pyridine_aromatic_smiles() {
let mol = mol_aromatic("c1ccncc1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"pyridine from aromatic SMILES"
);
}
#[test]
fn test_furan_aromatic_smiles() {
let mol = mol_aromatic("c1ccoc1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 5, "furan from aromatic SMILES");
}
#[test]
fn test_pyrrole_aromatic_smiles() {
let mol = mol_aromatic("c1cc[nH]c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
5,
"pyrrole from aromatic SMILES"
);
}
#[test]
fn test_thiophene_aromatic_smiles() {
let mol = mol_aromatic("c1ccsc1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
5,
"thiophene from aromatic SMILES"
);
}
#[test]
fn test_indole_aromatic() {
let mol = mol_kekulized("c1ccc2[nH]ccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 indole atoms aromatic"
);
}
#[test]
fn test_benzimidazole_aromatic() {
let mol = mol_kekulized("c1ccc2[nH]cnc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 9, "all 9 benzimidazole atoms");
}
#[test]
fn test_quinoline_aromatic() {
let mol = mol_kekulized("c1ccc2ncccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 10, "all 10 quinoline atoms");
}
#[test]
fn test_acridine_aromatic() {
let mol = mol_kekulized("c1ccc2nc3ccccc3cc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 14, "all 14 acridine atoms");
}
#[test]
fn test_indolizine_aromatic() {
let mol = mol_aromatic("c1ccn2cccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 indolizine atoms aromatic"
);
let has_aromatic_ring = model
.ring_classifications()
.iter()
.any(|(_, cls, _)| *cls == RingAromaticity::Aromatic);
assert!(has_aromatic_ring, "at least one SSSR ring aromatic");
}
#[test]
#[ignore = "PROVISIONAL: regressed by the Horton SSSR fix, see comment below"]
fn test_purine_aromatic() {
let mol = mol_kekulized("c1cnc2[nH]cnc2n1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 purine atoms aromatic"
);
}
#[test]
fn test_purine_aromatic_from_aromatic_smiles() {
let mol = mol_aromatic("c1cnc2[nH]cnc2n1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"purine from aromatic SMILES"
);
}
#[test]
fn test_2_pyridinone_aromatic() {
let mol = mol_aromatic("O=c1ccncc1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"all 6 ring atoms of 2-pyridinone aromatic"
);
}
#[test]
fn test_quinolone_aromatic() {
let mol = mol_aromatic("O=c1ccc2ncccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
10,
"all 10 quinolone ring atoms aromatic"
);
assert_eq!(
model.ring_classifications().len(),
2,
"two rings classified"
);
}
#[test]
fn test_indole_aromatic_smiles() {
let mol = mol_aromatic("c1ccc2[nH]ccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"indole from aromatic SMILES"
);
}
#[test]
fn test_bridgehead_n_contributes_lone_pair() {
let mol = mol_aromatic("c1ccn2cccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 9);
assert!(
model.is_atom_aromatic(AtomIdx(3)),
"bridgehead N must be aromatic"
);
}
#[test]
fn test_non_bridgehead_n_no_false_positive() {
let mol = mol_aromatic("c1ccncn1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 6, "pyrimidine is aromatic");
}
#[test]
fn test_imidazole_aromatic() {
let mol = mol_aromatic("c1cn[nH]c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 5, "imidazole is aromatic");
}
#[test]
fn test_pass2_needed_for_indolizine_6ring() {
let mol = mol_aromatic("c1ccn2cccc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 indolizine atoms aromatic"
);
assert!(
model.is_atom_aromatic(AtomIdx(3)),
"bridgehead N is aromatic"
);
let aromatic_count = model
.ring_classifications()
.iter()
.filter(|(_, cls, _)| *cls == RingAromaticity::Aromatic)
.count();
assert!(aromatic_count >= 1, "at least one SSSR ring is aromatic");
}
#[test]
fn test_no_pass2_needed_for_naphthalene() {
let mol = naphthalene_kekule();
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 10);
let classes = model.ring_classifications();
assert_eq!(classes.len(), 2);
for (_, cls, _) in classes {
assert_eq!(*cls, RingAromaticity::Aromatic);
}
}
#[test]
fn test_anthracene_aromatic() {
let mol = mol_kekulized("c1ccc2cc3ccccc3cc2c1");
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 14, "all 14 anthracene atoms");
}
#[test]
fn test_kekulized_path_unaffected_by_aromatic_bond_changes() {
let mol = benzene_kekule();
for (_, bond) in mol.bonds() {
assert_ne!(bond.order, BondOrder::Aromatic, "input must be kekulized");
}
let model = assign_aromaticity(&mol);
assert_eq!(model.aromatic_atom_count(), 6);
let aromatic_bonds = mol
.bonds()
.filter(|(b, _)| model.is_bond_aromatic(*b))
.count();
assert_eq!(aromatic_bonds, 6);
}
#[test]
fn test_keto_pyridinone_aromatic() {
let mol = mol_kekulized("O=C1NC=CC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"keto pyridinone ring is Hückel aromatic (6π = 4n+2)"
);
}
#[test]
fn test_tropone_aromatic() {
let mol = mol_kekulized("O=C1C=CC=CC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
7,
"all 7 tropone ring atoms aromatic"
);
}
#[test]
fn test_4_pyridone_aromatic() {
let mol = mol_kekulized("O=C1C=CNC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"all 6 4-pyridone ring atoms aromatic"
);
}
#[test]
fn test_pyranone_aromatic() {
let mol = mol_kekulized("O=C1C=COC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"all 6 pyranone ring atoms aromatic"
);
}
#[test]
fn test_cyclopentadienyl_anion_aromatic() {
let mol = mol_kekulized("[CH-]1C=CC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
5,
"all 5 cyclopentadienyl anion atoms aromatic"
);
}
#[test]
fn test_n_methylpyrrole_aromatic() {
let mol = mol_kekulized("CN1C=CC=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
5,
"all 5 N-methylpyrrole ring atoms aromatic"
);
}
#[test]
fn test_n_methylimidazole_aromatic() {
let mol = mol_kekulized("CN1C=CN=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
5,
"all 5 N-methylimidazole ring atoms aromatic"
);
}
#[test]
fn test_n_methylindole_aromatic() {
let mol = mol_kekulized("CN1C=CC2=CC=CC=C21");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 N-methylindole ring atoms aromatic"
);
}
#[test]
fn test_9_methylpurine_aromatic() {
let mol = mol_kekulized("CN1C=NC2=NC=NC=C21");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
9,
"all 9 9-methylpurine ring atoms aromatic"
);
}
#[test]
fn test_phthalimide_5ring_not_aromatic() {
let mol = mol_kekulized("O=C1NC(=O)c2ccccc21");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"only the 6 benzo atoms of phthalimide are aromatic"
);
}
#[test]
fn test_n_methylphthalimide_5ring_not_aromatic() {
let mol = mol_kekulized("O=C1N(C)C(=O)c2ccccc21");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
6,
"only the 6 benzo atoms of N-methylphthalimide are aromatic"
);
}
#[test]
#[ignore = "PROVISIONAL: regressed by the Horton SSSR fix, see comment below"]
fn test_azulene_kekulized_aromatic() {
let mol = mol_kekulized("C1=CC2=CC=CC=CC2=C1");
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
10,
"all 10 azulene atoms aromatic"
);
}
#[test]
fn test_fluorescein_dianion_aromatic() {
let smi = "C1=CC=C(C(=C1)C2=C3C=CC(=O)C=C3OC4=C2C=CC(=C4)[O-])C(=O)[O-]";
let mol = chematic_smiles::parse(smi).expect("fluorescein dianion should parse");
let arc = count_aromatic_rings(&mol);
assert!(
arc >= 2,
"fluorescein dianion: expected ≥2 aromatic rings, got {arc} \
(RDKit #9271: charged aromatics may be misclassified)"
);
}
#[test]
fn test_rhodamine_zwitterion_parses() {
let smi = "CCN(CC)c1ccc2c(-c3ccccc3C(=O)O)c3ccc(=[N+](CC)CC)cc-3oc2c1";
let mol = chematic_smiles::parse(smi).expect("rhodamine zwitterion should parse");
let arc = count_aromatic_rings(&mol);
assert!(arc >= 3, "rhodamine: expected ≥3 aromatic rings, got {arc}");
}
#[test]
fn test_cyclopentadienyl_not_aromatic_kekulized() {
let mut b = MoleculeBuilder::new();
let c0 = b.add_atom(Atom::new(Element::C)); let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C));
let c3 = b.add_atom(Atom::new(Element::C));
let c4 = b.add_atom(Atom::new(Element::C));
b.add_bond(c0, c1, BondOrder::Single).unwrap();
b.add_bond(c1, c2, BondOrder::Double).unwrap();
b.add_bond(c2, c3, BondOrder::Single).unwrap();
b.add_bond(c3, c4, BondOrder::Double).unwrap();
b.add_bond(c4, c0, BondOrder::Single).unwrap();
let mol = b.build();
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
0,
"cyclopentadiene not aromatic"
);
}
#[test]
fn test_selenophene_huckel_not_aromatic() {
let mol = mol_aromatic("c1cc[se]c1");
let m = assign_aromaticity(&mol); assert_eq!(
m.aromatic_atom_count(),
0,
"selenophene: Se not aromatic in Hückel mode"
);
}
#[test]
fn test_selenophene_rdkit_aromatic() {
let mol = mol_aromatic("c1cc[se]c1");
let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
assert_eq!(
m.aromatic_atom_count(),
5,
"selenophene: all 5 atoms aromatic in RdkitLike"
);
}
#[test]
fn test_tellurophene_rdkit_aromatic() {
let mol = mol_aromatic("c1cc[te]c1");
let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
assert_eq!(
m.aromatic_atom_count(),
5,
"tellurophene: all 5 atoms aromatic in RdkitLike"
);
}
#[test]
fn test_benzoselenophene_rdkit() {
let mol = mol_aromatic("c1ccc2[se]ccc2c1");
let m = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
assert_eq!(
m.aromatic_atom_count(),
9,
"benzoselenophene: 9 atoms aromatic"
);
}
#[test]
fn test_rdkit_mode_does_not_break_benzene() {
let mol = mol_aromatic("c1ccccc1");
let m_h = assign_aromaticity(&mol);
let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
assert_eq!(m_h.aromatic_atom_count(), m_r.aromatic_atom_count());
}
#[test]
fn test_rdkit_mode_does_not_break_thiophene() {
let mol = mol_aromatic("c1ccsc1");
let m_h = assign_aromaticity(&mol);
let m_r = assign_aromaticity_ex(&mol, AromaticityAlgorithm::RdkitLike);
assert_eq!(
m_h.aromatic_atom_count(),
m_r.aromatic_atom_count(),
"thiophene same in both modes"
);
}
const KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES: &[(&str, usize, usize)] = &[
("C[Si](C)(C)C1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
(
"C1=C(C2=CC=C(CCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
22,
18,
),
("ClC1=CC=C(OCC2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
("N[C@@H](CC1=CC=CC=C1)C1=CC2=CC=CC=C2C2=NCCCN12", 16, 12),
(
"CC(C)(C)C1=CC=C(C2=C(CC3=CC=CC=C3)C3=CC=CC=C3C3=NCCCN32)C=C1",
22,
18,
),
(
"C[Si](C)(C)C1=CC=C(C2=C(CC3=CC=CC=C3)C3=CC=CC=C3C3=NCCCN32)C=C1",
22,
18,
),
(
"C1=C(C2=CC=C(C3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
22,
18,
),
(
"C1=C(C2=CC=C(OCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
22,
18,
),
("COC1=C(OC)C(OC)=CC(C2=CC3=CC=CC=C3C3=NCCCN23)=C1", 16, 12),
("CC1=CC2=CC=CC=C2C2=NCCCN12", 10, 6),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)NC4CCCCC4)C=C3)C3=NCCCN23)C=C1",
16,
12,
),
(
"C1=CC=C(CCC2=CC=C(C3=C(CC4=CC=CC=C4)C4=CC=CC=C4C4=NCCCN43)C=C2)C=C1",
28,
24,
),
(
"CCCCC1=C(C2=CC=C(CCC3=CC=CC=C3)C=C2)N2CCCN=C2C2=CC=CC=C12",
22,
18,
),
(
"CCCCC1=C(C2=CC=C(C(C)(C)C)C=C2)N2CCCN=C2C2=CC=CC=C12",
16,
12,
),
("CCCCCCC1=CC2=CC=CC=C2C2=NCCCN12", 10, 6),
(
"CCOC1=CC=C(CC2=C(CCCC3=CC=CC4=CC=CC=C34)N3CCCN=C3C3=CC=CC=C23)C=C1",
26,
22,
),
(
"CCOC1=CC=C(CC2=C(C3=CC=C(CCC4=CC=CC=C4)C=C3)N3CCCN=C3C3=CC=CC=C23)C=C1",
28,
24,
),
(
"CN(C)CCC1=C(C2=CC=C(C(C)(C)C)C=C2)N2CCCN=C2C2=CC=CC=C12",
16,
12,
),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N/C(S)=N/C4CCCCC4)C=C3)C3=NCCCN23)C=C1",
16,
12,
),
("C1=C(/C=C/C2=CC=CC=C2)N2CCCN=C2C2=CC=CC=C12", 16, 12),
("CC(C)(C)C1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)CC4=CC=CC=N4)C=C3)C3=NCCCN23)C=C1",
22,
18,
),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(NC(=O)NC4=C(Cl)C=C(Cl)C=C4)C=C3)C3=NCCCN23)C=C1",
22,
18,
),
("C1=C(CC2=CC=CC=C2)C2=CC=CC=C2C2=NCCCN12", 16, 12),
("ClC1=CC=C(C2=CC3=CC=CC=C3C3=NCCCN23)C=C1", 16, 12),
("C1=C(C2=CC=CC=C2)N2CCCN=C2C2=CC=CC=C12", 16, 12),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N(CC4=CC=CC=C4)CC4=CC=CC=C4)C=C3)C3=NCCCN23)C=C1",
28,
24,
),
(
"CC(C)(C)C1=CC=C(C2=CC3=C(C=C(N)C=C3)C3=NCCCN23)C=C1",
16,
12,
),
("CC1=C2C(=NC=C1)N(C1CC1)C1=NC=CC=C1C(=O)N2C", 15, 12),
("CC(=O)N1C2=NC=CC=C2C(=O)N(C)C2=CC=CN=C21", 15, 12),
("CN1C(=O)C2=CC=CN=C2N(C(C)(C)C)C2=NC=CC=C21", 15, 12),
("CCCN1C2=NC=CC=C2C(=O)N(C)C2=CC=CN=C21", 15, 12),
];
#[test]
fn test_known_regressions_from_bridgehead_n_fix() {
for (smi, expected_wrong, rdkit_correct) in KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES {
let mol = mol_kekulized(smi);
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
*expected_wrong,
"{smi}: expected current (wrong) count {expected_wrong} (RDKit correct: {rdkit_correct})"
);
}
}
const KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES: &[(&str, usize, usize)] = &[
(
"N1=C2C(N(CC(O)=O)C(=O)N=C2N(C2C=C(C(F)(F)F)C=C(C=2)C(F)(F)F)C2C1=CC=CC=2)=O",
16,
20,
),
(
"[C@H]12N(C([C@H](NC(=O)[C@H]([C@H](OC(=O)[C@@H](N(C)C(CN(C)C1=O)=O)C(C)C)C)NC(=O)C1C=C(OC)C(C)=C3OC4=C(C)C(=O)C(=C(C4=NC=13)C(=O)N[C@H]1C(=O)N[C@@H](C(C)C)C(N3[C@H](C(=O)N(CC(N([C@H](C(C)C)C(O[C@H]1C)=O)C)=O)C)CCC3)=O)N)C(C)C)=O)CCC2",
6,
14,
),
("C12N(C3C=CC=CC=3)C3=NC(=O)N(C)C(C3=NC1=CC=CC=2)=O", 16, 20),
];
#[test]
fn test_known_order_dependent_regressions() {
for (smi, expected_wrong, rdkit_correct) in KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES {
let mol = mol_kekulized(smi);
let model = assign_aromaticity(&mol);
assert_eq!(
model.aromatic_atom_count(),
*expected_wrong,
"{smi}: expected current (wrong) count {expected_wrong} (RDKit correct: {rdkit_correct})"
);
}
}
#[test]
fn trace_matches_ring_pi_electrons_on_corpus() {
let smiles: Vec<&str> = KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES
.iter()
.map(|(smi, _, _)| *smi)
.chain(
KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES
.iter()
.map(|(smi, _, _)| *smi),
)
.chain([
"C1=CC2=CC=CC=CC2=C1", "c1cnc2[nH]cnc2n1", "C1=Cc2ccccc2C2=NCCCN12", "C1=Cc2ccccc2C2=CCCC12", "C1=Cc2ccccc2C2=CCNC12", "C1Cc2ccccc2C2=NCCCN12", "c1ccc2[nH]ccc2c1", "c1ccc2ncccc2c1", "c1ccc2ccccc2c1", ])
.collect();
for algo in [
AromaticityAlgorithm::Huckel,
AromaticityAlgorithm::RdkitLike,
] {
for smi in &smiles {
let mol = mol_kekulized(smi);
let model = assign_aromaticity_ex(&mol, algo);
let final_context: FxHashSet<AtomIdx> = mol
.atoms()
.map(|(idx, _)| idx)
.filter(|&idx| model.is_atom_aromatic(idx))
.collect();
let sssr = find_sssr(&mol);
let rings = augmented_ring_set(&mol, sssr.rings());
let empty_context: FxHashSet<AtomIdx> = FxHashSet::default();
for ring in &rings {
for ctx in [&empty_context, &final_context] {
let expected = ring_pi_electrons(&mol, ring, ctx, algo);
let traced = trace_ring_pi_electrons(&mol, ring, ctx, algo);
assert_eq!(
traced.total,
expected,
"{smi} (algo={algo:?}, ring={ring:?}, ctx_len={}): \
trace_ring_pi_electrons diverged from ring_pi_electrons",
ctx.len()
);
for a in &traced.atoms {
assert_eq!(
a.contribution.is_some(),
a.reason.is_eligible(),
"{smi}: atom {:?} contribution/reason eligibility mismatch",
a.atom_idx
);
}
}
}
}
}
}
#[test]
fn false_positive_corpus_over_counts_vs_rdkit() {
for (smi, expected_wrong, rdkit_correct) in KNOWN_BRIDGEHEAD_N_FALSE_POSITIVES {
assert!(
expected_wrong > rdkit_correct,
"{smi}: false-positive bucket entry should over-count \
(chematic={expected_wrong} should be > rdkit={rdkit_correct})"
);
}
}
#[test]
fn false_negative_corpus_under_counts_vs_rdkit() {
for (smi, expected_wrong, rdkit_correct) in KNOWN_ORDER_DEPENDENT_FALSE_NEGATIVES {
assert!(
expected_wrong < rdkit_correct,
"{smi}: false-negative bucket entry should under-count \
(chematic={expected_wrong} should be < rdkit={rdkit_correct})"
);
}
}
#[test]
fn exhaustive_oracle_pinned_cases() {
let algo = AromaticityAlgorithm::RdkitLike;
let matches_rdkit: &[(&str, &str, &[u32])] = &[
(
"azulene",
"C1=CC2=CC=CC=CC2=C1",
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
),
(
"naphthalene",
"c1ccc2ccccc2c1",
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
),
(
"anthracene",
"c1ccc2cc3ccccc3cc2c1",
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
),
("indole", "c1ccc2[nH]ccc2c1", &[0, 1, 2, 3, 4, 5, 6, 7, 8]),
(
"quinoline",
"c1ccc2ncccc2c1",
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
),
(
"indolizine (bridgehead N, both rings valid)",
"c1ccn2ccccc12",
&[0, 1, 2, 3, 4, 5, 6, 7, 8],
),
("tropone", "O=c1cccccc1", &[1, 2, 3, 4, 5, 6, 7]),
("2-pyridone", "O=c1cccc[nH]1", &[1, 2, 3, 4, 5, 6]),
];
for (name, smi, expected) in matches_rdkit {
let mol = mol_kekulized(smi);
let (atoms, _bonds) = exhaustive_aromaticity_oracle(&mol, algo);
let mut got: Vec<u32> = atoms.iter().map(|a| a.0).collect();
got.sort();
assert_eq!(&got, expected, "{name} ({smi}): oracle should match RDKit");
}
let (fp_atoms, _) =
exhaustive_aromaticity_oracle(&mol_kekulized("C1=Cc2ccccc2C2=NCCCN12"), algo);
let mut fp_got: Vec<u32> = fp_atoms.iter().map(|a| a.0).collect();
fp_got.sort();
assert_eq!(
fp_got,
vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 13],
"false-positive reproducer: still over-aromatized by the oracle, as expected"
);
let (purine_atoms, _) =
exhaustive_aromaticity_oracle(&mol_kekulized("c1cnc2[nH]cnc2n1"), algo);
let mut purine_got: Vec<u32> = purine_atoms.iter().map(|a| a.0).collect();
purine_got.sort();
assert_eq!(
purine_got,
vec![0, 1, 2, 3, 7, 8],
"purine: oracle still under-counts (RDKit says all 9) -- open A1-1b question, not fixed here"
);
}
}