use rustc_hash::{FxHashMap, FxHashSet};
use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
use crate::aromaticity::AromaticityModel;
use crate::sssr::find_sssr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ElectronDonorType {
Vacant,
OneElectron,
TwoElectron,
#[allow(dead_code)]
OneOrTwo,
Any,
None,
}
fn outer_shell_electrons(atomic_number: u8) -> Option<u8> {
match atomic_number {
1 => Some(1), 5 => Some(3), 6 => Some(4), 7 => Some(5), 8 => Some(6), 9 => Some(7), 14 => Some(4), 15 => Some(5), 16 => Some(6), 17 => Some(7), 33 => Some(5), 34 => Some(6), 35 => Some(7), 52 => Some(6), 53 => Some(7), _ => None,
}
}
fn default_valence(atomic_number: u8) -> Option<u8> {
chematic_core::Element::from_atomic_number(atomic_number)
.and_then(|e| e.normal_valences().first().copied())
}
fn bond_order_contrib(order: BondOrder) -> f32 {
match order {
BondOrder::Single | BondOrder::Up | BondOrder::Down => 1.0,
BondOrder::Double => 2.0,
BondOrder::Triple => 3.0,
BondOrder::Quadruple => 4.0,
BondOrder::Aromatic
| BondOrder::Zero
| BondOrder::Dative
| BondOrder::QueryAny
| BondOrder::QuerySingleOrDouble
| BondOrder::QuerySingleOrAromatic
| BondOrder::QueryDoubleOrAromatic => 1.0,
}
}
fn count_atom_pi_electrons(mol: &Molecule, atom_idx: AtomIdx) -> Option<i32> {
let atom = mol.atom(atom_idx);
let an = atom.element.atomic_number();
let dv = default_valence(an)?;
if dv <= 1 {
return None; }
let implicit_h = chematic_core::implicit_hcount(mol, atom_idx);
let degree = mol.degree(atom_idx) + implicit_h as usize;
if degree > 3 {
return None;
}
let nlp_raw = outer_shell_electrons(an)? as i32 - dv as i32;
let nlp = (nlp_raw - atom.charge as i32).max(0);
let n_radicals = 0i32;
let mut res = (dv as i32 - degree as i32) + nlp - n_radicals;
if res > 1 {
let explicit_valence: f32 = mol
.neighbors(atom_idx)
.map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
.sum();
let n_unsaturations = explicit_valence - mol.degree(atom_idx) as f32;
if n_unsaturations > 1.0 {
res = 1;
}
}
Some(res)
}
fn incident_non_cyclic_multiple_bond(
mol: &Molecule,
atom_idx: AtomIdx,
ring_bonds: &FxHashSet<BondIdx>,
) -> Option<AtomIdx> {
mol.neighbors(atom_idx)
.find(|&(_, bidx)| {
!ring_bonds.contains(&bidx) && bond_order_contrib(mol.bond(bidx).order) >= 2.0
})
.map(|(nb, _)| nb)
}
fn incident_cyclic_multiple_bond(
mol: &Molecule,
atom_idx: AtomIdx,
ring_bonds: &FxHashSet<BondIdx>,
) -> bool {
mol.neighbors(atom_idx).any(|(_, bidx)| {
ring_bonds.contains(&bidx) && bond_order_contrib(mol.bond(bidx).order) >= 2.0
})
}
fn incident_multiple_bond(mol: &Molecule, atom_idx: AtomIdx) -> bool {
let explicit_valence: f32 = mol
.neighbors(atom_idx)
.map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
.sum();
(explicit_valence - mol.degree(atom_idx) as f32).abs() > 1e-6
}
fn more_electronegative(a: u8, b: u8) -> bool {
fn electronegativity(an: u8) -> f32 {
match an {
1 => 2.20,
5 => 2.04,
6 => 2.55,
7 => 3.04,
8 => 3.44,
9 => 3.98,
14 => 1.90,
15 => 2.19,
16 => 2.58,
17 => 3.16,
34 => 2.55,
35 => 2.96,
52 => 2.10,
53 => 2.66,
_ => 2.20,
}
}
electronegativity(a) > electronegativity(b)
}
pub(crate) fn get_atom_electron_donor_type(
mol: &Molecule,
atom_idx: AtomIdx,
ring_bonds: &FxHashSet<BondIdx>,
) -> ElectronDonorType {
let atom = mol.atom(atom_idx);
let an = atom.element.atomic_number();
let Some(nelec) = count_atom_pi_electrons(mol, atom_idx) else {
return ElectronDonorType::None;
};
if nelec < 0 {
ElectronDonorType::None
} else if nelec == 0 {
if let Some(_who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
ElectronDonorType::Vacant
} else if incident_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
ElectronDonorType::OneElectron
} else {
ElectronDonorType::None
}
} else if nelec == 1 {
if let Some(who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
let other_an = mol.atom(who).element.atomic_number();
if more_electronegative(other_an, an) {
ElectronDonorType::Vacant
} else {
ElectronDonorType::OneElectron
}
} else if incident_multiple_bond(mol, atom_idx) {
ElectronDonorType::OneElectron
} else if atom.charge == 1 {
ElectronDonorType::Vacant
} else {
ElectronDonorType::None
}
} else {
let mut nelec = nelec;
if let Some(who) = incident_non_cyclic_multiple_bond(mol, atom_idx, ring_bonds) {
let other_an = mol.atom(who).element.atomic_number();
if more_electronegative(other_an, an) {
nelec -= 1;
}
}
if nelec % 2 == 1 {
ElectronDonorType::OneElectron
} else {
ElectronDonorType::TwoElectron
}
}
}
pub(crate) fn is_atom_candidate_for_aromaticity(
mol: &Molecule,
atom_idx: AtomIdx,
donor_type: ElectronDonorType,
) -> bool {
let atom = mol.atom(atom_idx);
let an = atom.element.atomic_number();
if an > 18 && an != 34 && an != 52 {
return false;
}
if matches!(donor_type, ElectronDonorType::None) {
return false;
}
if let Some(dv) = default_valence(an) {
let total_valence: f32 = mol
.neighbors(atom_idx)
.map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
.sum::<f32>()
+ chematic_core::implicit_hcount(mol, atom_idx) as f32;
let an_neutral = (an as i32 - atom.charge as i32).max(0) as u8;
if let Some(dv_neutral) = default_valence(an_neutral)
&& total_valence.round() as i32 > dv_neutral as i32
{
return false;
}
let _ = dv;
}
let explicit_valence: f32 = mol
.neighbors(atom_idx)
.map(|(_, bidx)| bond_order_contrib(mol.bond(bidx).order))
.sum();
let n_unsaturations = explicit_valence - mol.degree(atom_idx) as f32;
if n_unsaturations > 1.0 {
let n_mult = mol
.neighbors(atom_idx)
.filter(|(_, bidx)| {
matches!(mol.bond(*bidx).order, BondOrder::Double | BondOrder::Triple)
})
.count();
if n_mult > 1 {
return false;
}
}
true
}
fn min_max_atom_electrons(dtype: ElectronDonorType) -> (i32, i32) {
match dtype {
ElectronDonorType::Any | ElectronDonorType::OneOrTwo => (1, 2),
ElectronDonorType::OneElectron => (1, 1),
ElectronDonorType::TwoElectron => (2, 2),
ElectronDonorType::None | ElectronDonorType::Vacant => (0, 0),
}
}
pub(crate) fn apply_huckel(
mol: &Molecule,
atoms: &[AtomIdx],
donor: &FxHashMap<AtomIdx, ElectronDonorType>,
) -> bool {
let _ = mol;
let mut rlw = 0i32;
let mut rup = 0i32;
let mut n_any = 0u32;
for &a in atoms {
let dtype = donor[&a];
if dtype == ElectronDonorType::Any {
n_any += 1;
if n_any > 1 {
return false;
}
}
let (lo, hi) = min_max_atom_electrons(dtype);
rlw += lo;
rup += hi;
}
if rup >= 6 {
(rlw..=rup).any(|rie| (rie - 2).rem_euclid(4) == 0)
} else {
rup == 2
}
}
fn fused_ring_groups(ring_bond_ids: &[Vec<BondIdx>]) -> Vec<Vec<usize>> {
let n = ring_bond_ids.len();
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], x: usize) -> usize {
if parent[x] != x {
parent[x] = find(parent, parent[x]);
}
parent[x]
}
for i in 0..n {
for j in (i + 1)..n {
if ring_bond_ids[i]
.iter()
.any(|b| ring_bond_ids[j].contains(b))
{
let (pi, pj) = (find(&mut parent, i), find(&mut parent, j));
if pi != pj {
parent[pi] = pj;
}
}
}
}
let mut groups: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
for i in 0..n {
groups.entry(find(&mut parent, i)).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = groups.into_values().collect();
out.sort_by_key(|g| g[0]);
out
}
fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
if k == 0 || k > n {
return vec![];
}
let mut result = Vec::new();
let mut combo: Vec<usize> = (0..k).collect();
loop {
result.push(combo.clone());
let mut i = k;
loop {
if i == 0 {
return result;
}
i -= 1;
if combo[i] != i + n - k {
break;
}
}
combo[i] += 1;
for j in (i + 1)..k {
combo[j] = combo[j - 1] + 1;
}
}
}
struct CandidateRings<'a> {
atoms: &'a [Vec<AtomIdx>],
bonds: &'a [Vec<BondIdx>],
}
fn apply_huckel_to_fused(
mol: &Molecule,
rings: &CandidateRings<'_>,
group: &[usize],
donor: &FxHashMap<AtomIdx, ElectronDonorType>,
max_num_fused_rings: usize,
aromatic_atoms: &mut FxHashSet<AtomIdx>,
aromatic_bonds: &mut FxHashSet<BondIdx>,
) {
let ring_atoms = rings.atoms;
let ring_bond_ids = rings.bonds;
let n_ring_bonds: usize = {
let mut all: FxHashSet<BondIdx> = FxHashSet::default();
for &ri in group {
all.extend(ring_bond_ids[ri].iter().copied());
}
all.len()
};
let mut done_bonds: FxHashSet<BondIdx> = FxHashSet::default();
for size in 1..=group.len().min(max_num_fused_rings) {
if done_bonds.len() >= n_ring_bonds {
break;
}
for combo in combinations(group.len(), size) {
let cur_rings: Vec<usize> = combo.iter().map(|&i| group[i]).collect();
if size > 1 {
let sub_bond_ids: Vec<Vec<BondIdx>> = cur_rings
.iter()
.map(|&ri| ring_bond_ids[ri].clone())
.collect();
if fused_ring_groups(&sub_bond_ids).len() != 1 {
continue;
}
}
let mut membership_count: FxHashMap<AtomIdx, u32> = FxHashMap::default();
for &ri in &cur_rings {
for &a in &ring_atoms[ri] {
*membership_count.entry(a).or_insert(0) += 1;
}
}
let union: Vec<AtomIdx> = membership_count
.iter()
.filter(|&(_, &c)| c == 1 || c == 2)
.map(|(&a, _)| a)
.collect();
if apply_huckel(mol, &union, donor) {
let mut bond_count: FxHashMap<BondIdx, u32> = FxHashMap::default();
for &ri in &cur_rings {
for &b in &ring_bond_ids[ri] {
*bond_count.entry(b).or_insert(0) += 1;
}
}
for (&b, &c) in &bond_count {
if c == 1 {
aromatic_bonds.insert(b);
let bond = mol.bond(b);
aromatic_atoms.insert(bond.atom1);
aromatic_atoms.insert(bond.atom2);
done_bonds.insert(b);
}
}
}
}
}
}
pub fn rdkit_parity_aromaticity(mol: &Molecule) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
rdkit_parity_aromaticity_ex(mol, 6)
}
pub(crate) fn rdkit_parity_aromaticity_ex(
mol: &Molecule,
max_num_fused_rings: usize,
) -> (FxHashSet<AtomIdx>, FxHashSet<BondIdx>) {
let sssr = find_sssr(mol);
let srings = sssr.rings();
let all_ring_bonds: FxHashSet<BondIdx> = srings
.iter()
.flat_map(|ring| {
(0..ring.len()).filter_map(move |i| {
mol.bond_between(ring[i], ring[(i + 1) % ring.len()])
.map(|(bidx, _)| bidx)
})
})
.collect();
let mut donor: FxHashMap<AtomIdx, ElectronDonorType> = FxHashMap::default();
let mut candidate: FxHashMap<AtomIdx, bool> = FxHashMap::default();
for ring in srings {
for &a in ring {
donor
.entry(a)
.or_insert_with(|| get_atom_electron_donor_type(mol, a, &all_ring_bonds));
let d = donor[&a];
candidate
.entry(a)
.or_insert_with(|| is_atom_candidate_for_aromaticity(mol, a, d));
}
}
let candidate_rings: Vec<&Vec<AtomIdx>> = srings
.iter()
.filter(|ring| {
ring.iter()
.all(|a| candidate.get(a).copied().unwrap_or(false))
})
.collect();
let ring_atoms: Vec<Vec<AtomIdx>> = candidate_rings.iter().map(|r| (*r).clone()).collect();
let ring_bond_ids: Vec<Vec<BondIdx>> = ring_atoms
.iter()
.map(|ring| {
(0..ring.len())
.filter_map(|i| {
mol.bond_between(ring[i], ring[(i + 1) % ring.len()])
.map(|(bidx, _)| bidx)
})
.collect()
})
.collect();
let mut aromatic_atoms: FxHashSet<AtomIdx> = FxHashSet::default();
let mut aromatic_bonds: FxHashSet<BondIdx> = FxHashSet::default();
let rings = CandidateRings {
atoms: &ring_atoms,
bonds: &ring_bond_ids,
};
for group in fused_ring_groups(&ring_bond_ids) {
apply_huckel_to_fused(
mol,
&rings,
&group,
&donor,
max_num_fused_rings,
&mut aromatic_atoms,
&mut aromatic_bonds,
);
}
(aromatic_atoms, aromatic_bonds)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AromaticityError {
KekulizationFailed {
reason: String,
},
InternalInvariantViolation {
reason: String,
},
}
impl std::fmt::Display for AromaticityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AromaticityError::KekulizationFailed { reason } => {
write!(f, "rdkit-parity aromaticity: kekulization failed: {reason}")
}
AromaticityError::InternalInvariantViolation { reason } => {
write!(
f,
"rdkit-parity aromaticity: internal invariant violation: {reason}"
)
}
}
}
}
impl std::error::Error for AromaticityError {}
fn clear_aromatic_flags(mol: &Molecule) -> Molecule {
use chematic_core::MoleculeBuilder;
let mut builder = MoleculeBuilder::new();
for (_, atom) in mol.atoms() {
let mut a = atom.clone();
a.aromatic = false;
builder.add_atom(a);
}
for (_, bond) in mol.bonds() {
let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
}
builder.copy_stereo_groups_from(mol);
builder.copy_stereo_from(mol);
builder.copy_bond_directions_from(mol);
builder.build()
}
fn kekulize_for_rdkit_parity(mol: &Molecule) -> Result<Molecule, AromaticityError> {
let cleared = clear_aromatic_flags(mol);
match chematic_core::kekulize(&cleared) {
Ok(k) => Ok(chematic_core::apply_kekule(&cleared, &k)),
Err(e) => Err(AromaticityError::KekulizationFailed { reason: e.detail }),
}
}
fn validate_aromaticity_invariants(
mol: &Molecule,
atoms: &FxHashSet<AtomIdx>,
bonds: &FxHashSet<BondIdx>,
) -> Result<(), AromaticityError> {
for &bidx in bonds {
let bond = mol.bond(bidx);
if !atoms.contains(&bond.atom1) || !atoms.contains(&bond.atom2) {
return Err(AromaticityError::InternalInvariantViolation {
reason: format!(
"aromatic bond {bidx:?} ({:?}-{:?}) has a non-aromatic endpoint atom",
bond.atom1, bond.atom2
),
});
}
}
Ok(())
}
fn assign_from_kekulized(kekulized: &Molecule) -> Result<AromaticityModel, AromaticityError> {
let (atoms, bonds) = rdkit_parity_aromaticity(kekulized);
validate_aromaticity_invariants(kekulized, &atoms, &bonds)?;
Ok(AromaticityModel::from_atom_bond_sets(atoms, bonds))
}
pub fn assign_aromaticity_rdkit_parity_experimental(
mol: &Molecule,
) -> Result<AromaticityModel, AromaticityError> {
let kekulized = kekulize_for_rdkit_parity(mol)?;
assign_from_kekulized(&kekulized)
}
pub fn apply_aromaticity_rdkit_parity_experimental(
mol: &Molecule,
) -> Result<Molecule, AromaticityError> {
let kekulized = kekulize_for_rdkit_parity(mol)?;
let model = assign_from_kekulized(&kekulized)?;
Ok(crate::aromaticity::build_molecule_from_model(
&kekulized, &model,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn mol_kekulized(smiles: &str) -> 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 calibration_battery_matches_rdkit() {
let cases: &[(&str, &str, &[u32])] = &[
("benzene", "c1ccccc1", &[0, 1, 2, 3, 4, 5]),
("pyrrole", "c1cc[nH]c1", &[0, 1, 2, 3, 4]),
("furan", "c1ccoc1", &[0, 1, 2, 3, 4]),
("thiophene", "c1ccsc1", &[0, 1, 2, 3, 4]),
("tropone", "O=c1cccccc1", &[1, 2, 3, 4, 5, 6, 7]),
("2-pyridone", "O=c1cccc[nH]1", &[1, 2, 3, 4, 5, 6]),
("4-pyranone", "O=c1ccocc1", &[1, 2, 3, 4, 5, 6]),
(
"indolizine (true bridgehead, both rings valid)",
"c1ccn2ccccc12",
&[0, 1, 2, 3, 4, 5, 6, 7, 8],
),
(
"azulene (non-alternant, needs whole-perimeter candidate)",
"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],
),
(
"purine (Aromaticity-A1-1a open finding, fixed here)",
"c1cnc2[nH]cnc2n1",
&[0, 1, 2, 3, 4, 5, 6, 7, 8],
),
(
"PR #86 false-positive reproducer (Aromaticity-A1-1a open finding, fixed here)",
"C1=Cc2ccccc2C2=NCCCN12",
&[2, 3, 4, 5, 6, 7],
),
];
for (name, smi, expected) in cases {
let mol = mol_kekulized(smi);
let (atoms, _bonds) = rdkit_parity_aromaticity(&mol);
let mut got: Vec<u32> = atoms.iter().map(|a| a.0).collect();
got.sort();
assert_eq!(&got, expected, "{name} ({smi}): should match RDKit exactly");
}
}
#[test]
fn purine_representation_stable() {
let smi = "c1cnc2[nH]cnc2n1";
let raw = chematic_smiles::parse(smi).expect("valid SMILES");
let k = chematic_core::kekulize(&raw).expect("purine should kekulize");
let via_own_kekulize = chematic_core::apply_kekule(&raw, &k);
let (atoms_a, _) = rdkit_parity_aromaticity(&mol_kekulized(smi));
let (atoms_b, _) = rdkit_parity_aromaticity(&via_own_kekulize);
let mut a: Vec<u32> = atoms_a.iter().map(|x| x.0).collect();
let mut b: Vec<u32> = atoms_b.iter().map(|x| x.0).collect();
a.sort();
b.sort();
assert_eq!(a, b, "purine: two Kekulization paths disagree");
assert_eq!(
a,
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
"purine: should match RDKit (all 9 atoms aromatic)"
);
}
#[test]
fn production_api_assign_matches_engine_on_benzene() {
let mol = chematic_smiles::parse("c1ccccc1").expect("valid SMILES");
let model = assign_aromaticity_rdkit_parity_experimental(&mol).expect("benzene kekulizes");
assert_eq!(model.aromatic_atom_count(), 6);
for (idx, _) in mol.atoms() {
assert!(
model.is_atom_aromatic(idx),
"atom {idx:?} should be aromatic"
);
}
assert!(model.ring_classifications().is_empty());
assert!(model.antiaromatic_rings().is_empty());
}
#[test]
fn production_api_apply_sets_aromatic_flags_and_bond_orders() {
let mol = chematic_smiles::parse("C1=CC=CC=C1").expect("valid SMILES"); let applied = apply_aromaticity_rdkit_parity_experimental(&mol).expect("benzene kekulizes");
assert_eq!(applied.atom_count(), mol.atom_count());
for (_, atom) in applied.atoms() {
assert!(atom.aromatic, "every benzene atom should end up aromatic");
}
for (_, bond) in applied.bonds() {
assert_eq!(bond.order, BondOrder::Aromatic);
}
}
#[test]
fn production_api_reports_kekulize_failure_not_panic() {
let smi = "Cc1cn2c(=O)c3ncn(COCCO)c3nc2n1C";
let mol = chematic_smiles::parse(smi).expect("valid SMILES");
match assign_aromaticity_rdkit_parity_experimental(&mol) {
Err(AromaticityError::KekulizationFailed { .. }) => {}
other => panic!("expected KekulizationFailed, got {other:?}"),
}
match apply_aromaticity_rdkit_parity_experimental(&mol).map(|_| ()) {
Err(AromaticityError::KekulizationFailed { .. }) => {}
other => panic!("expected KekulizationFailed, got {other:?}"),
}
}
#[test]
fn production_api_does_not_mutate_input_on_failure() {
let smi = "Cc1cn2c(=O)c3ncn(COCCO)c3nc2n1C";
let mol = chematic_smiles::parse(smi).expect("valid SMILES");
let atom_count_before = mol.atom_count();
let bond_count_before = mol.bond_count();
let aromatic_before: Vec<bool> = mol.atoms().map(|(_, a)| a.aromatic).collect();
let result = assign_aromaticity_rdkit_parity_experimental(&mol);
assert!(
result.is_err(),
"this molecule is a known kekulize-gap case"
);
assert_eq!(mol.atom_count(), atom_count_before);
assert_eq!(mol.bond_count(), bond_count_before);
let aromatic_after: Vec<bool> = mol.atoms().map(|(_, a)| a.aromatic).collect();
assert_eq!(aromatic_before, aromatic_after);
}
#[test]
fn production_api_stale_aromatic_flag_is_overridden_not_leaked() {
use chematic_core::MoleculeBuilder;
let mut base = chematic_smiles::parse("CC").expect("valid SMILES"); let mut builder = MoleculeBuilder::new();
for (_, atom) in base.atoms() {
let mut a = atom.clone();
a.aromatic = true; builder.add_atom(a);
}
for (_, bond) in base.bonds() {
let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
}
base = builder.build();
assert!(base.atoms().all(|(_, a)| a.aromatic), "test setup sanity");
let applied = apply_aromaticity_rdkit_parity_experimental(&base)
.expect("acyclic molecule kekulizes trivially (no-op)");
assert!(
applied.atoms().all(|(_, a)| !a.aromatic),
"stale aromatic=true on an acyclic atom must not survive"
);
}
}