Skip to main content

chematic_core/
kekulization.rs

1//! Kekulization: assign alternating single/double bonds to aromatic systems.
2//!
3//! Algorithm (4 passes):
4//!
5//! 1. Collect all aromatic atoms and bonds.
6//! 2. Determine the must-match set (atoms that need a double bond).
7//! 3. Find a maximum matching:
8//!    - Pass 1: BFS augmenting paths, ascending atom order.
9//!    - Pass 2: BFS augmenting paths, descending order (fallback).
10//!    - Pass 3: Bridgehead-N exclusion (lone-pair donors at ring junctions).
11//!    - Pass 4: Edmonds' blossom for non-bipartite aromatic subgraphs.
12//! 4. Matched edges → Double; unmatched → Single.
13
14use std::collections::{HashMap, HashSet, VecDeque};
15
16use crate::bond::BondOrder;
17use crate::molecule::{AtomIdx, BondIdx, Molecule};
18
19/// Error returned when no valid Kekulé form can be found.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct KekuleError {
22    pub detail: String,
23}
24
25impl core::fmt::Display for KekuleError {
26    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
27        write!(f, "kekulization failed: {}", self.detail)
28    }
29}
30
31impl std::error::Error for KekuleError {}
32
33/// Result of kekulization: a map from BondIdx to the new BondOrder.
34///
35/// Bonds NOT in this map are unchanged (non-aromatic bonds).
36pub type KekuleResult = HashMap<BondIdx, BondOrder>;
37
38/// Kekulize a molecule that contains aromatic bonds.
39///
40/// Returns a map of aromatic bond indices to their new (Single or Double) orders.
41/// All non-aromatic bonds are unchanged and not included in the result.
42///
43/// If the molecule has no aromatic bonds the result is empty (success, no-op).
44pub fn kekulize(mol: &Molecule) -> Result<KekuleResult, KekuleError> {
45    // Collect aromatic bonds and the atoms they touch.
46    let mut aromatic_bonds: Vec<BondIdx> = Vec::new();
47    let mut aromatic_atoms: HashSet<AtomIdx> = HashSet::new();
48
49    for (bidx, bond) in mol.bonds() {
50        if bond.order == BondOrder::Aromatic {
51            aromatic_bonds.push(bidx);
52            aromatic_atoms.insert(bond.atom1);
53            aromatic_atoms.insert(bond.atom2);
54        }
55    }
56
57    if aromatic_bonds.is_empty() {
58        return Ok(HashMap::new());
59    }
60
61    // Determine which aromatic atoms *must* be in a double bond.
62    // An aromatic atom must be double-bonded if it has no lone pair to donate.
63    // Heuristic: if the atom has no explicit/implicit H and is carbon or nitrogen-imine,
64    // it must be matched.
65    // For simplicity: atoms that must_match = those with no spare lone pair =
66    //   carbon (C), nitrogen-imine (N in pyridine — has no H when in 6-membered ring).
67    // Atoms that can be unmatched (lone-pair donors): O, S, N with H (pyrrole N).
68    //
69    // Practical rule used here: an atom can be *unmatched* iff it has an explicit
70    // H count > 0 OR it is O or S (lone-pair donors).
71    // All others must appear in the matching.
72    let must_match: HashSet<AtomIdx> = aromatic_atoms
73        .iter()
74        .copied()
75        .filter(|&idx| atom_must_be_matched(mol, idx))
76        .collect();
77
78    // Build adjacency list restricted to aromatic bonds BETWEEN must-match atoms.
79    //
80    // Lone-pair donors (O, S, [nH]) contribute their pi electrons via the lone pair,
81    // NOT via a double bond.  Including them in the matching adjacency causes the
82    // augmenting-path algorithm to assign double bonds to e.g. [nH]=C in pyrrole or
83    // indole, which is chemically wrong and produces incorrect implicit-H counts.
84    //
85    // Only bonds where BOTH endpoints are in must_match are valid double-bond
86    // candidates.  Lone-pair donors remain in aromatic_atoms (so their aromatic bonds
87    // become Single in the result) but are excluded from the matching graph.
88    let mut adj: HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>> = HashMap::new();
89    for &bidx in &aromatic_bonds {
90        let bond = mol.bond(bidx);
91        if must_match.contains(&bond.atom1) && must_match.contains(&bond.atom2) {
92            adj.entry(bond.atom1).or_default().push((bond.atom2, bidx));
93            adj.entry(bond.atom2).or_default().push((bond.atom1, bidx));
94        }
95    }
96
97    // Run maximum matching via augmenting paths.
98    let mut matching: HashMap<AtomIdx, AtomIdx> = HashMap::new(); // atom -> matched_partner
99
100    // Process must-match atoms in a deterministic order (by index) for reproducibility.
101    // Non-must-match atoms (lone-pair donors) are skipped — they never initiate
102    // augmenting paths and are never placed in the matching.
103    let mut sorted_atoms: Vec<AtomIdx> = must_match.iter().copied().collect();
104    sorted_atoms.sort();
105
106    // Pass 1: ascending order (primary).
107    run_matching_pass(&sorted_atoms, &adj, &mut matching);
108
109    // Pass 2 (fallback): descending order — avoids order-dependent dead-ends.
110    //
111    // A greedy ascending pass can get stuck on certain ring topologies: the first
112    // matched edge blocks an augmenting path that a different starting order would
113    // find.  Reversing the order is O(V·E) overhead but resolves many such cases
114    // without requiring the full Edmonds blossom algorithm.
115    if must_match.iter().any(|&idx| !matching.contains_key(&idx)) {
116        matching.clear();
117        let mut rev = sorted_atoms.clone();
118        rev.reverse();
119        run_matching_pass(&rev, &adj, &mut matching);
120    }
121
122    // --- Pass 3: bridgehead-N exclusion fallback ----------------------------
123    //
124    // N at the junction of two fused aromatic rings (e.g. indolizine C9a-N)
125    // has aromatic degree ≥ 3 and contributes a lone pair to the π system
126    // rather than occupying a double bond.  The `atom_must_be_matched` rule
127    // correctly handles isolated pyridine-N (degree 2) but incorrectly forces
128    // bridgehead-N into the matching, making it impossible to form a perfect
129    // matching in odd-atom-count fused systems (9 atoms in indolizine).
130    //
131    // Strategy: identify must-match N atoms whose degree in `adj` is ≥ 3,
132    // remove them from the matching problem, rebuild adjacency on the remaining
133    // (all-carbon) atoms, and retry.  If those atoms can all be matched, the
134    // bridgehead-N atoms receive only single bonds and donate their lone pair.
135    if must_match.iter().any(|&idx| !matching.contains_key(&idx)) {
136        let bridgehead_n: HashSet<AtomIdx> = must_match
137            .iter()
138            .copied()
139            .filter(|&idx| {
140                mol.atom(idx).element.atomic_number() == 7
141                    && adj.get(&idx).map_or(0, |v| v.len()) >= 3
142            })
143            .collect();
144
145        if !bridgehead_n.is_empty() {
146            let must_match_nb: HashSet<AtomIdx> =
147                must_match.difference(&bridgehead_n).copied().collect();
148
149            let mut adj_nb: HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>> = HashMap::new();
150            for &bidx in &aromatic_bonds {
151                let bond = mol.bond(bidx);
152                if must_match_nb.contains(&bond.atom1) && must_match_nb.contains(&bond.atom2) {
153                    adj_nb
154                        .entry(bond.atom1)
155                        .or_default()
156                        .push((bond.atom2, bidx));
157                    adj_nb
158                        .entry(bond.atom2)
159                        .or_default()
160                        .push((bond.atom1, bidx));
161                }
162            }
163
164            let mut sorted_nb: Vec<AtomIdx> = must_match_nb.iter().copied().collect();
165            sorted_nb.sort();
166
167            matching.clear();
168            run_matching_pass(&sorted_nb, &adj_nb, &mut matching);
169            if must_match_nb
170                .iter()
171                .any(|&idx| !matching.contains_key(&idx))
172            {
173                matching.clear();
174                let rev_nb: Vec<AtomIdx> = sorted_nb.iter().copied().rev().collect();
175                run_matching_pass(&rev_nb, &adj_nb, &mut matching);
176            }
177
178            if must_match_nb.iter().all(|&idx| matching.contains_key(&idx)) {
179                return Ok(build_kekule_result(&aromatic_bonds, mol, &matching));
180            }
181        }
182    }
183
184    // --- Pass 4: Edmonds' blossom (general graph maximum matching) ----------
185    //
186    // Passes 1–3 use BFS augmenting paths which are correct for bipartite graphs
187    // but can miss augmenting paths that traverse odd cycles (blossoms).
188    // Edmonds' blossom algorithm contracts odd cycles into single super-vertices,
189    // allowing the BFS to find augmenting paths through non-bipartite subgraphs.
190    // This fixes molecules where the must-match C subgraph has odd cycles
191    // (e.g. corannulene: 5 five-membered rings, 20 C atoms).
192    if must_match.iter().any(|&idx| !matching.contains_key(&idx)) {
193        let n = sorted_atoms.len();
194        let idx_to_int: HashMap<AtomIdx, usize> = sorted_atoms
195            .iter()
196            .enumerate()
197            .map(|(i, &a)| (a, i))
198            .collect();
199        let int_adj: Vec<Vec<usize>> = sorted_atoms
200            .iter()
201            .map(|&a| {
202                adj.get(&a)
203                    .map(|nbrs| {
204                        nbrs.iter()
205                            .filter_map(|(nb, _)| idx_to_int.get(nb).copied())
206                            .collect()
207                    })
208                    .unwrap_or_default()
209            })
210            .collect();
211
212        matching.clear();
213        let int_mate = blossom_max_matching(n, &int_adj);
214        for (i, &j) in int_mate.iter().enumerate() {
215            if j != usize::MAX {
216                matching.insert(sorted_atoms[i], sorted_atoms[j]);
217            }
218        }
219    }
220
221    // Verify that all must_match atoms are matched.
222    for &idx in &must_match {
223        if !matching.contains_key(&idx) {
224            return Err(KekuleError {
225                detail: format!(
226                    "atom {} ({}) cannot be assigned a double bond",
227                    idx.0,
228                    mol.atom(idx).element.symbol()
229                ),
230            });
231        }
232    }
233
234    Ok(build_kekule_result(&aromatic_bonds, mol, &matching))
235}
236
237/// Build the KekuleResult map from the current matching.
238pub fn build_kekule_result(
239    aromatic_bonds: &[BondIdx],
240    mol: &Molecule,
241    matching: &HashMap<AtomIdx, AtomIdx>,
242) -> KekuleResult {
243    let mut double_bonds: HashSet<BondIdx> = HashSet::new();
244    for (&atom, &partner) in matching {
245        if atom >= partner {
246            continue;
247        }
248        if let Some((bidx, _)) = mol.bond_between(atom, partner)
249            && mol.bond(bidx).order == BondOrder::Aromatic
250        {
251            double_bonds.insert(bidx);
252        }
253    }
254    aromatic_bonds
255        .iter()
256        .map(|&bidx| {
257            let order = if double_bonds.contains(&bidx) {
258                BondOrder::Double
259            } else {
260                BondOrder::Single
261            };
262            (bidx, order)
263        })
264        .collect()
265}
266
267/// Apply a Kekulé result to a molecule, returning a new Molecule with updated bond orders.
268///
269/// Aromatic flags on atoms are *not* cleared (the molecule retains the aromaticity
270/// annotation; only bond orders change).
271///
272/// Preserves every stereo side-channel (`stereo_groups`, `stereo_neighbor_order`,
273/// `bond_directions`) unchanged. This matters even though `atom.chirality` itself is
274/// copied verbatim with each `Atom`: `@`/`@@` is a relative descriptor over the SMILES
275/// neighbor-encounter order recorded in `stereo_neighbor_order`, not an absolute one.
276/// Losing that order and re-deriving `@`/`@@` output from a *different* neighbor
277/// ordering downstream (e.g. canonical atom order) can serialize the *same* tetrahedral
278/// center as the wrong configuration -- silently, with no panic and no error, since
279/// `chirality` alone still round-trips as "some chirality is set". Atom/bond indices are
280/// unchanged (nothing is skipped, added, or reordered), the same precondition the
281/// `copy_*_from` methods themselves require.
282pub fn apply_kekule(mol: &Molecule, kekule: &KekuleResult) -> Molecule {
283    use crate::molecule::MoleculeBuilder;
284
285    if kekule.is_empty() {
286        return mol.clone();
287    }
288
289    let mut builder = MoleculeBuilder::new();
290
291    // Re-add all atoms.
292    for (_, atom) in mol.atoms() {
293        builder.add_atom(atom.clone());
294    }
295
296    // Re-add bonds, substituting updated orders for aromatic bonds.
297    for (bidx, bond) in mol.bonds() {
298        let order = kekule.get(&bidx).copied().unwrap_or(bond.order);
299        builder
300            .add_bond(bond.atom1, bond.atom2, order)
301            .expect("duplicate bond during apply_kekule");
302    }
303
304    builder.copy_stereo_groups_from(mol);
305    builder.copy_stereo_from(mol);
306    builder.copy_bond_directions_from(mol);
307
308    builder.build()
309}
310
311/// Attempt to find an augmenting path starting from `start` and update `matching`.
312///
313/// Uses iterative BFS with parent-pointer path reconstruction to avoid stack
314/// overflow on large aromatic systems (wasm32 default stack is ~1 MB).
315/// `visited` must already contain `start` (prevents root re-entry in odd cycles).
316///
317/// Returns true if an augmenting path was found and the matching was updated.
318fn augment(
319    start: AtomIdx,
320    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
321    matching: &mut HashMap<AtomIdx, AtomIdx>,
322    visited: &mut HashSet<AtomIdx>,
323) -> bool {
324    // parent[u] = v means "u was reached from v in the BFS tree"
325    let mut parent: HashMap<AtomIdx, AtomIdx> = HashMap::new();
326    let mut queue: std::collections::VecDeque<AtomIdx> = std::collections::VecDeque::new();
327    queue.push_back(start);
328
329    'bfs: while let Some(v) = queue.pop_front() {
330        let Some(neighbors) = adj.get(&v) else {
331            continue;
332        };
333        for &(u, _) in neighbors {
334            if !visited.insert(u) {
335                continue;
336            }
337            parent.insert(u, v);
338
339            match matching.get(&u).copied() {
340                None => {
341                    // Found a free vertex — trace back through parent pointers
342                    // and flip every edge along the augmenting path.
343                    let mut cur = u;
344                    loop {
345                        let prev = parent[&cur];
346                        let prev_old_match = matching.get(&prev).copied();
347                        matching.insert(prev, cur);
348                        matching.insert(cur, prev);
349                        match prev_old_match {
350                            None | Some(_) if prev == start => break,
351                            Some(m) => cur = m,
352                            None => break,
353                        }
354                    }
355                    break 'bfs;
356                }
357                Some(partner) => {
358                    // u is matched to partner — explore further from partner.
359                    if visited.insert(partner) {
360                        parent.insert(partner, u);
361                        queue.push_back(partner);
362                    }
363                }
364            }
365        }
366    }
367
368    // Augmentation succeeded iff start is now matched.
369    matching.contains_key(&start)
370}
371
372/// Run a single augmenting-path pass over `atoms` and update `matching`.
373///
374/// For each unmatched atom in `atoms`, attempts to find an augmenting path using
375/// the BFS-based `augment()` function. Extracted so that `kekulize()` can try
376/// multiple orderings (ascending → descending) without code duplication.
377fn run_matching_pass(
378    atoms: &[AtomIdx],
379    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
380    matching: &mut HashMap<AtomIdx, AtomIdx>,
381) {
382    for &start in atoms {
383        if matching.contains_key(&start) {
384            continue;
385        }
386        let mut visited: HashSet<AtomIdx> = HashSet::new();
387        visited.insert(start);
388        augment(start, adj, matching, &mut visited);
389    }
390}
391
392// ─── Edmonds' blossom maximum matching ───────────────────────────────────────
393//
394// General (non-bipartite) maximum matching via Gabow's blossom formulation.
395// Vertices are integers 0..n; the returned `mate[i] = j` if matched, else NONE.
396//
397// `NONE` sentinel: usize::MAX — safe because `n` is always < 32 k for drug-like
398// molecules (InChI library limit) and we never index at NONE.
399
400const NONE: usize = usize::MAX;
401
402/// Find a maximum matching in a general graph (Edmonds' blossom, O(n²m)).
403fn blossom_max_matching(n: usize, adj: &[Vec<usize>]) -> Vec<usize> {
404    let mut mate = vec![NONE; n];
405    for v in 0..n {
406        if mate[v] == NONE {
407            blossom_augment(v, n, adj, &mut mate);
408        }
409    }
410    mate
411}
412
413/// Attempt to augment the matching from free vertex `root`.
414fn blossom_augment(root: usize, n: usize, adj: &[Vec<usize>], mate: &mut [usize]) {
415    // base[v]: representative of the blossom containing v.
416    let mut base: Vec<usize> = (0..n).collect();
417    // parent[v]: predecessor of v on the augmenting path (NONE = unlabeled).
418    let mut parent: Vec<usize> = vec![NONE; n];
419    // is_outer[v]: true if v is an outer (even-level) vertex in the BFS forest.
420    let mut is_outer: Vec<bool> = vec![false; n];
421
422    is_outer[root] = true;
423    let mut queue: VecDeque<usize> = VecDeque::new();
424    queue.push_back(root);
425
426    'bfs: while let Some(v) = queue.pop_front() {
427        for &w in &adj[v] {
428            if base[v] == base[w] {
429                continue;
430            } // same blossom
431            if mate[v] == w {
432                continue;
433            } // already-matched edge, skip
434
435            if is_outer[w] {
436                // Both v and w are outer → odd cycle (blossom).
437                let b = blossom_lca(v, w, &base, &parent, mate, n);
438                blossom_mark_path(
439                    v,
440                    b,
441                    w,
442                    &mut base,
443                    &mut parent,
444                    &mut is_outer,
445                    &mut queue,
446                    mate,
447                    n,
448                );
449                blossom_mark_path(
450                    w,
451                    b,
452                    v,
453                    &mut base,
454                    &mut parent,
455                    &mut is_outer,
456                    &mut queue,
457                    mate,
458                    n,
459                );
460            } else if parent[w] == NONE {
461                // w is unlabeled.
462                parent[w] = v;
463                if mate[w] == NONE {
464                    // Augmenting path ends at w.  Trace parent[] and flip matching.
465                    let mut cur = w;
466                    while cur != NONE {
467                        let prev = parent[cur];
468                        let prev_old = mate[prev];
469                        mate[cur] = prev;
470                        mate[prev] = cur;
471                        cur = prev_old;
472                    }
473                    break 'bfs;
474                }
475                // w is matched — add mate[w] as the next outer vertex.
476                let u = mate[w];
477                if !is_outer[u] {
478                    is_outer[u] = true;
479                    parent[u] = w;
480                    queue.push_back(u);
481                }
482            }
483            // else: w is inner (labeled but not outer) → skip.
484        }
485    }
486}
487
488/// Lowest common ancestor of `a` and `b` in the alternating BFS tree.
489///
490/// Traces both paths toward `root` (following outer→matched-inner→outer chains)
491/// and returns the first vertex visited by both traces.
492fn blossom_lca(
493    mut a: usize,
494    mut b: usize,
495    base: &[usize],
496    parent: &[usize],
497    mate: &[usize],
498    n: usize,
499) -> usize {
500    let mut visited = vec![false; n];
501    loop {
502        a = base[a];
503        visited[a] = true;
504        if mate[a] == NONE {
505            break;
506        } // reached a free vertex (root or its base)
507        a = parent[mate[a]]; // hop: outer→matched inner→outer parent
508    }
509    loop {
510        b = base[b];
511        if visited[b] {
512            return b;
513        } // first vertex seen by both traces
514        b = parent[mate[b]];
515    }
516}
517
518/// Walk from `x` toward blossom base `b`, updating `base[]`, `parent[]`,
519/// and promoting inner vertices to outer so the BFS can traverse the blossom.
520#[allow(clippy::too_many_arguments)]
521fn blossom_mark_path(
522    mut x: usize,
523    b: usize,
524    child: usize,
525    base: &mut [usize],
526    parent: &mut [usize],
527    is_outer: &mut [bool],
528    queue: &mut VecDeque<usize>,
529    mate: &[usize],
530    n: usize,
531) {
532    let mut ch = child;
533    while base[x] != b {
534        let bx = base[x];
535        let bmx = base[mate[x]];
536        // Merge blossom: all vertices in bx or bmx become part of base b.
537        for slot in base.iter_mut().take(n) {
538            if *slot == bx || *slot == bmx {
539                *slot = b;
540            }
541        }
542        // Update augmenting-path parent pointers inside the blossom.
543        parent[x] = ch;
544        // Promote mate[x] to outer so the BFS can continue through the blossom.
545        let mx = mate[x];
546        if !is_outer[mx] {
547            is_outer[mx] = true;
548            queue.push_back(mx);
549        }
550        ch = mx;
551        x = parent[mx];
552    }
553}
554
555// ─────────────────────────────────────────────────────────────────────────────
556
557/// Determine whether an aromatic atom *must* appear in the matching
558/// (i.e. requires a double bond for a valid Kekulé form).
559///
560/// Charge-aware per RDKit's own kekulization (`markDbondCands`,
561/// `Code/GraphMol/Kekulize.cpp` at the pinned commit `8afba32ec539dcb2369bc84549d802aca3f7eb39`):
562/// RDKit computes a per-atom target valence `dv = defaultValence(atomicNum) + chrg`
563/// (formal charge added directly, no sign flip — `isEarlyAtom` is `false` for
564/// C/N/O/P/S/Se/Te, `Code/GraphMol/Atom.cpp`) *except* for carbon, which gets an
565/// explicit sign flip (`chrg = -chrg` when `atomicNum == 6 && chrg > 0`, their own
566/// comment: "special case for carbon - see GitHub #539"). An atom is a double-bond
567/// candidate (`dBndCands[allAtm] = 1`) iff its current bond-order sum `sbo` (ring
568/// bonds + H count, each counted as 1) is exactly `dv - 1`; i.e. it needs exactly one
569/// more bond order to reach `dv`. This flips the "donor" classification for a charged
570/// heteroatom (charge *raises* `dv`, e.g. `[nH+]`/`[o+]` become one bond order short —
571/// a candidate) but *lowers* it for a cationic carbon (a `+1` carbon's `dv` drops from
572/// 4 to 3, which its existing bonds already satisfy — not a candidate). Verified
573/// empirically against `rdkit==2026.03.3` (pinned to the same commit) for every case
574/// below; see `docs/kekulize_charge_aware_rdkit_parity.md`.
575///
576/// An atom can be unmatched (a lone-pair donor) if:
577/// - O, S, Se, Te (furan/thiophene/selenophene/tellurophene-type chalcogen) — but only
578///   while neutral or anionic; a positively-charged chalcogen (pyrylium's `[o+]`) has
579///   consumed the lone pair and needs a double bond instead, like pyridine-type N.
580/// - N or P with an H ([nH] pyrrole-type nitrogen, [pH] phosphole-type phosphorus) —
581///   but only while neutral or anionic; protonation (`[nH+]`, e.g. pyridinium) consumes
582///   the lone pair the same way, so the atom needs a double bond instead.
583/// - Any anionic aromatic atom (cyclopentadienyl-anion-type) donates its lone pair.
584/// - Any aromatic atom that already has an exocyclic double bond (e.g. the
585///   carbonyl carbon in coumarin/warfarin `c=O` fused into an aromatic ring).
586///   Such an atom's pi contribution comes from conjugation with the exocyclic
587///   bond; no additional ring double bond is needed or possible.
588///
589/// A cationic aromatic carbon ([cH+], e.g. tropylium) is a symmetric case: an empty
590/// p-orbital electron *acceptor*, like aromatic B, but — unlike B — needs no double
591/// bond of its own (see the carbon sign-flip above).
592///
593/// Everything else (C, N/P without H like pyridine/phosphole's ring carbons, aromatic
594/// B) must be matched.
595pub fn atom_must_be_matched(mol: &Molecule, idx: AtomIdx) -> bool {
596    let atom = mol.atom(idx);
597    match atom.element.atomic_number() {
598        // O, S, Se, Te donate a lone pair → don't need a double bond — *unless* a
599        // positive charge (pyrylium's [o+]) has consumed it, in which case the atom
600        // needs a double bond exactly like pyridine-type N (falls through to the
601        // catch-all `_ => true` below).
602        8 | 16 | 34 | 52 if atom.charge <= 0 => false,
603        // Aromatic B contributes an empty p orbital (electron acceptor), not a lone pair.
604        // It must appear in a double bond in the Kekulé form, just like aromatic C.
605        5 => true,
606        // N or P with explicit H ([nH], [pH]) is a lone-pair donor — *unless* protonated
607        // (a positive charge consumes the lone pair, e.g. pyridinium's [nH+], which then
608        // needs a double bond exactly like neutral pyridine's bare N).
609        7 | 15 if atom.charge <= 0 && matches!(atom.hydrogen_count, Some(h) if h > 0) => false,
610        // Anionic aromatic N/P ([n-]) also donates its lone pair; the extra electron
611        // occupies the lone-pair slot rather than a ring π bond (same as [nH]).
612        7 | 15 if atom.charge < 0 => false,
613        // Neutral N/P with a non-aromatic substituent (e.g. N-methyl in caffeine) is a lone-pair
614        // donor: the substituent "replaces" the H, and the atom contributes its lone pair to
615        // aromaticity rather than a π bond (same as [nH] in pyrrole).
616        // Charged N/P (pyridinium [n+], N-oxide) must still be matched even with a substituent.
617        7 | 15
618            if atom.charge == 0
619                && mol
620                    .neighbors(idx)
621                    .any(|(_, bidx)| mol.bond(bidx).order != BondOrder::Aromatic) =>
622        {
623            false
624        }
625        // Bare aromatic N/P with only aromatic bonds (pyridine-type), or protonated
626        // ([nH+] pyridinium): must be matched.
627        7 | 15 => true,
628        // A cationic aromatic carbon ([cH+], e.g. tropylium) has an empty p orbital: an
629        // electron acceptor like aromatic B, but *without* B's obligatory double bond.
630        // RDKit's own charge-sign flip (see doc comment above) drops a +1 carbon's
631        // target valence from 4 to 3, which its existing ring bonds + H already
632        // satisfy — no double bond needed or possible.
633        6 if atom.charge > 0 => false,
634        // Any anionic aromatic atom (e.g. cyclopentadienyl [cH-]) donates its lone pair.
635        _ if atom.charge < 0 => false,
636        // Any atom (typically C) that already has an exocyclic π bond (e.g. `c(=O)` in
637        // a heterocyclic carbonyl) cannot also carry a ring double bond — its π-slot is
638        // occupied by the exocyclic bond. Such atoms contribute via conjugation, like
639        // lone-pair donors, and should not be forced into the matching.
640        _ if mol.neighbors(idx).any(|(_, bidx)| {
641            let o = mol.bond(bidx).order;
642            o == BondOrder::Double || o == BondOrder::Triple
643        }) =>
644        {
645            false
646        }
647        // All other atoms must appear in the matching.
648        _ => true,
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::atom::Atom;
656    use crate::element::Element;
657    use crate::molecule::{MoleculeBuilder, STEREO_H_SENTINEL};
658    use crate::stereo_group::StereoGroup;
659
660    /// Build benzene as a fully aromatic SMILES-style molecule.
661    fn benzene() -> Molecule {
662        let mut b = MoleculeBuilder::new();
663        let atoms: Vec<_> = (0..6)
664            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
665            .collect();
666        for i in 0..6 {
667            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
668                .unwrap();
669        }
670        b.build()
671    }
672
673    /// Build pyridine (5 aromatic C + 1 aromatic N, ring).
674    fn pyridine() -> Molecule {
675        let mut b = MoleculeBuilder::new();
676        let c1 = b.add_atom(Atom::aromatic(Element::C));
677        let c2 = b.add_atom(Atom::aromatic(Element::C));
678        let c3 = b.add_atom(Atom::aromatic(Element::C));
679        let n = b.add_atom(Atom::aromatic(Element::N));
680        let c4 = b.add_atom(Atom::aromatic(Element::C));
681        let c5 = b.add_atom(Atom::aromatic(Element::C));
682        let atoms = [c1, c2, c3, n, c4, c5];
683        for i in 0..6 {
684            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
685                .unwrap();
686        }
687        b.build()
688    }
689
690    /// Build furan (4 aromatic C + 1 aromatic O, ring).
691    fn furan() -> Molecule {
692        let mut b = MoleculeBuilder::new();
693        let o = b.add_atom(Atom::aromatic(Element::O));
694        let c1 = b.add_atom(Atom::aromatic(Element::C));
695        let c2 = b.add_atom(Atom::aromatic(Element::C));
696        let c3 = b.add_atom(Atom::aromatic(Element::C));
697        let c4 = b.add_atom(Atom::aromatic(Element::C));
698        let atoms = [o, c1, c2, c3, c4];
699        for i in 0..5 {
700            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
701                .unwrap();
702        }
703        b.build()
704    }
705
706    /// Build pyrrole ([nH] + 4 aromatic C, ring).
707    fn pyrrole() -> Molecule {
708        let mut b = MoleculeBuilder::new();
709        // N with 1 H — bracket-style (hydrogen_count = Some(1))
710        let mut n_atom = Atom::aromatic(Element::N);
711        n_atom.hydrogen_count = Some(1);
712        let n = b.add_atom(n_atom);
713        let c1 = b.add_atom(Atom::aromatic(Element::C));
714        let c2 = b.add_atom(Atom::aromatic(Element::C));
715        let c3 = b.add_atom(Atom::aromatic(Element::C));
716        let c4 = b.add_atom(Atom::aromatic(Element::C));
717        let atoms = [n, c1, c2, c3, c4];
718        for i in 0..5 {
719            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
720                .unwrap();
721        }
722        b.build()
723    }
724
725    #[test]
726    fn test_kekulize_benzene() {
727        let mol = benzene();
728        let result = kekulize(&mol).expect("benzene kekulization failed");
729        assert_eq!(result.len(), 6); // all 6 aromatic bonds assigned
730
731        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
732        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
733        assert_eq!(doubles, 3, "benzene must have 3 double bonds");
734        assert_eq!(singles, 3, "benzene must have 3 single bonds");
735    }
736
737    #[test]
738    fn test_kekulize_pyridine() {
739        let mol = pyridine();
740        let result = kekulize(&mol).expect("pyridine kekulization failed");
741        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
742        assert_eq!(doubles, 3, "pyridine must have 3 double bonds");
743    }
744
745    #[test]
746    fn test_kekulize_furan() {
747        let mol = furan();
748        let result = kekulize(&mol).expect("furan kekulization failed");
749        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
750        assert_eq!(doubles, 2, "furan must have 2 double bonds");
751    }
752
753    #[test]
754    fn test_kekulize_pyrrole() {
755        let mol = pyrrole();
756        let result = kekulize(&mol).expect("pyrrole kekulization failed");
757        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
758        assert_eq!(doubles, 2, "pyrrole must have 2 double bonds");
759    }
760
761    #[test]
762    fn test_kekulize_naphthalene() {
763        // 10 aromatic C, 11 aromatic bonds (fused bicyclic)
764        let mut b = MoleculeBuilder::new();
765        let atoms: Vec<_> = (0..10)
766            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
767            .collect();
768        // Ring 1: 0-1-2-3-4-9
769        let ring1 = [0, 1, 2, 3, 4, 9];
770        for i in 0..ring1.len() {
771            b.add_bond(
772                atoms[ring1[i]],
773                atoms[ring1[(i + 1) % ring1.len()]],
774                BondOrder::Aromatic,
775            )
776            .unwrap();
777        }
778        // Ring 2: 4-5-6-7-8-9 (shares bond 4-9)
779        let ring2 = [4, 5, 6, 7, 8, 9];
780        for i in 0..ring2.len() {
781            let a = atoms[ring2[i]];
782            let bb = atoms[ring2[(i + 1) % ring2.len()]];
783            // Skip already-added bond (4-9)
784            if mol_has_no_bond_yet(&b, a, bb) {
785                b.add_bond(a, bb, BondOrder::Aromatic).unwrap();
786            }
787        }
788        let mol = b.build();
789        let result = kekulize(&mol).expect("naphthalene kekulization failed");
790        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
791        assert_eq!(doubles, 5, "naphthalene must have 5 double bonds");
792    }
793
794    #[test]
795    fn test_apply_kekule() {
796        let mol = benzene();
797        let kekule = kekulize(&mol).unwrap();
798        let kekule_mol = apply_kekule(&mol, &kekule);
799
800        // After applying, no aromatic bonds should remain.
801        for (_, bond) in kekule_mol.bonds() {
802            assert_ne!(
803                bond.order,
804                BondOrder::Aromatic,
805                "apply_kekule should remove all aromatic bonds"
806            );
807        }
808    }
809
810    #[test]
811    fn test_no_aromatic_bonds_noop() {
812        // A molecule with no aromatic bonds should return empty result.
813        let mut b = MoleculeBuilder::new();
814        let c1 = b.add_atom(Atom::new(Element::C));
815        let c2 = b.add_atom(Atom::new(Element::C));
816        b.add_bond(c1, c2, BondOrder::Single).unwrap();
817        let mol = b.build();
818        let result = kekulize(&mol).unwrap();
819        assert!(result.is_empty());
820    }
821
822    // Helper: check that the builder does not yet have a bond between a and b.
823    fn mol_has_no_bond_yet(b: &MoleculeBuilder, a: AtomIdx, bb: AtomIdx) -> bool {
824        for (_, partner) in b.atom_neighbors(a) {
825            if partner == bb {
826                return false;
827            }
828        }
829        true
830    }
831
832    // B6 coverage: exotic ring systems that require correct matching on non-bipartite graphs.
833
834    /// Azulene: fused 5+7 bicyclic, 10 aromatic C, 11 bonds.
835    /// Valid Kekulé form exists → must yield 5 double bonds.
836    #[test]
837    fn test_kekulize_azulene() {
838        let mut b = MoleculeBuilder::new();
839        let a: Vec<_> = (0..10)
840            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
841            .collect();
842        // 5-ring: 0-1-2-3-4-0
843        for i in 0..5 {
844            b.add_bond(a[i], a[(i + 1) % 5], BondOrder::Aromatic)
845                .unwrap();
846        }
847        // 7-ring extras (shares bond 0-4): 4-5-6-7-8-9-0
848        for (x, y) in [(4usize, 5usize), (5, 6), (6, 7), (7, 8), (8, 9), (9, 0)] {
849            if mol_has_no_bond_yet(&b, a[x], a[y]) {
850                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
851            }
852        }
853        let mol = b.build();
854        let result = kekulize(&mol).expect("azulene kekulization failed");
855        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
856        assert_eq!(doubles, 5, "azulene needs 5 double bonds");
857    }
858
859    /// Acenaphthylene: naphthalene + fused cyclopentadiene bridge, 12 aromatic C.
860    #[test]
861    fn test_kekulize_acenaphthylene() {
862        // Connectivity: naphthalene core (atoms 0-9) + bridge atoms 10,11
863        // Naphthalene: ring1=0-1-2-3-4-9, ring2=4-5-6-7-8-9
864        // Bridge: 0-11-10-1 (the 5-ring across the 1,8 positions of naphthalene)
865        let mut b = MoleculeBuilder::new();
866        let a: Vec<_> = (0..12)
867            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
868            .collect();
869        // Naphthalene ring 1: 0-1-2-3-4-9-0
870        for (x, y) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 9), (9, 0)] {
871            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
872        }
873        // Naphthalene ring 2: 4-5-6-7-8-9 (4-9 shared)
874        for (x, y) in [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)] {
875            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
876        }
877        // Bridge: 0-11-10-1
878        for (x, y) in [(0, 11), (11, 10), (10, 1)] {
879            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
880        }
881        let mol = b.build();
882        let result = kekulize(&mol).expect("acenaphthylene kekulization failed");
883        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
884        assert_eq!(doubles, 6, "acenaphthylene needs 6 double bonds");
885    }
886
887    // ---- Edge-case tests added v0.1.100 ----
888    //
889    // Build complex PAH manually to test edge cases in the matching algorithm.
890
891    /// Biphenylene: two benzene rings connected by a cyclobutadiene bridge.
892    ///
893    /// Topology: Ring A (0–5), Ring B (6–11), bridge bonds (0–11) and (5–6).
894    /// The 4-membered ring 0-5-6-11-0 is the challenging part.
895    fn biphenylene() -> Molecule {
896        let mut b = MoleculeBuilder::new();
897        let a: Vec<_> = (0..12)
898            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
899            .collect();
900        // Ring A (6): 0-1-2-3-4-5-0
901        for i in 0..6 {
902            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
903                .unwrap();
904        }
905        // Ring B (6): 6-7-8-9-10-11-6
906        for i in 0..6 {
907            b.add_bond(a[6 + i], a[6 + (i + 1) % 6], BondOrder::Aromatic)
908                .unwrap();
909        }
910        // 4-membered bridge: closes ring 0-5-6-11-0
911        b.add_bond(a[5], a[6], BondOrder::Aromatic).unwrap();
912        b.add_bond(a[0], a[11], BondOrder::Aromatic).unwrap();
913        b.build()
914    }
915
916    /// Naphtho-4-ring: three fused 6-membered rings sharing a common bond sequence.
917    /// Linear anthracene topology (0-1-2-3-4-5, 5-6-7-8-9-4, 6-10-11-12-13-7).
918    fn anthracene() -> Molecule {
919        let mut b = MoleculeBuilder::new();
920        let a: Vec<_> = (0..14)
921            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
922            .collect();
923        // Ring A: 0-1-2-3-4-5-0
924        for i in 0..6 {
925            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
926                .unwrap();
927        }
928        // Ring B: 5-4-9-8-7-6-5 (shares bond 4-5 with Ring A)
929        for (x, y) in [(4, 9), (9, 8), (8, 7), (7, 6), (6, 5)] {
930            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
931        }
932        // Ring C: 6-7-13-12-11-10-6 (shares bond 6-7 with Ring B)
933        for (x, y) in [(7, 13), (13, 12), (12, 11), (11, 10), (10, 6)] {
934            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
935        }
936        b.build()
937    }
938
939    #[test]
940    fn test_kekulize_biphenylene() {
941        // Biphenylene: 4-membered cyclobutadiene bridge between two benzenes.
942        // 12 aromatic C → 6 double bonds.
943        let mol = biphenylene();
944        let result = kekulize(&mol).expect("biphenylene kekulization should succeed");
945        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
946        assert_eq!(doubles, 6, "biphenylene needs 6 double bonds");
947    }
948
949    #[test]
950    fn test_kekulize_anthracene() {
951        // Anthracene (C14H10): 3 linearly fused 6-membered rings.  7 double bonds.
952        let mol = anthracene();
953        let result = kekulize(&mol).expect("anthracene kekulization should succeed");
954        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
955        assert_eq!(doubles, 7, "anthracene needs 7 double bonds");
956    }
957
958    #[test]
959    fn test_kekulize_biphenylene_double_bond_count() {
960        // Cross-check: all 14 bonds should be assigned single or double.
961        let mol = biphenylene();
962        let result = kekulize(&mol).expect("biphenylene kekulization");
963        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
964        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
965        assert_eq!(singles + doubles, 14, "biphenylene has 14 aromatic bonds");
966    }
967
968    #[test]
969    fn test_kekulize_large_fused_6rings() {
970        // 4 fused 6-membered rings (pyrene-like) in a simple linear topology.
971        // Simulates a large all-even-ring PAH that the BFS should handle easily.
972        let mol = {
973            let mut b = MoleculeBuilder::new();
974            let a: Vec<_> = (0..16)
975                .map(|_| b.add_atom(Atom::aromatic(Element::C)))
976                .collect();
977            // Ring 0-1-2-3-4-5
978            for i in 0..6 {
979                b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
980                    .unwrap();
981            }
982            // Ring 5-4-9-8-7-6
983            for (x, y) in [(4, 9), (9, 8), (8, 7), (7, 6), (6, 5)] {
984                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
985            }
986            // Ring 1-2-11-10-13-12
987            for (x, y) in [(2, 11), (11, 10), (10, 13), (13, 12), (12, 1)] {
988                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
989            }
990            // Ring 6-7-15-14-11-2 (closed)
991            for (x, y) in [(7, 15), (15, 14), (14, 11)] {
992                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
993            }
994            b.build()
995        };
996        let result = kekulize(&mol).expect("4-ring PAH kekulization should succeed");
997        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
998        assert!(
999            doubles >= 6,
1000            "4-ring PAH needs at least 6 double bonds, got {doubles}"
1001        );
1002    }
1003
1004    #[test]
1005    fn test_kekulize_deterministic() {
1006        // kekulize() is deterministic: rebuilding the same molecule gives the same count.
1007        let mol1 = biphenylene();
1008        let mol2 = biphenylene();
1009        let r1 = kekulize(&mol1).expect("pass1");
1010        let r2 = kekulize(&mol2).expect("pass2");
1011        assert_eq!(
1012            r1.values().filter(|&&o| o == BondOrder::Double).count(),
1013            r2.values().filter(|&&o| o == BondOrder::Double).count(),
1014            "kekulization must be deterministic"
1015        );
1016    }
1017
1018    /// Fluoranthene: 16 aromatic C in a fused 5+6+6+6 system.
1019    #[test]
1020    fn test_kekulize_fluoranthene() {
1021        // Simplified: 3 fused 6-rings + 1 fused 5-ring sharing atoms
1022        // Build as corannulene-like: two naphthalene units bridged by a 5-ring
1023        // Atoms 0-15 (16 aromatic C), total bonds = 19 (for fluoranthene)
1024        // Use a simplified topology that gives the right ring count
1025        // 6-ring A: 0-1-2-3-4-5
1026        // 6-ring B: 0-5-6-7-8-9
1027        // 6-ring C: 2-3-10-11-12-13
1028        // 5-ring D: 0-9-14-15-1 (bridge)
1029        let mut b = MoleculeBuilder::new();
1030        let a: Vec<_> = (0..16)
1031            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1032            .collect();
1033        // Ring A (6-ring): 0-1-2-3-4-5-0
1034        for i in 0..6 {
1035            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
1036                .unwrap();
1037        }
1038        // Ring B (6-ring): 0-5-6-7-8-9-0
1039        for (x, y) in [(5, 6), (6, 7), (7, 8), (8, 9), (9, 0)] {
1040            if mol_has_no_bond_yet(&b, a[x], a[y]) {
1041                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
1042            }
1043        }
1044        // Ring C (6-ring): 1-2-10-11-12-13-1
1045        for (x, y) in [(2, 10), (10, 11), (11, 12), (12, 13), (13, 1)] {
1046            if mol_has_no_bond_yet(&b, a[x], a[y]) {
1047                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
1048            }
1049        }
1050        // Ring D (5-ring): 9-8-14-15-13-9
1051        for (x, y) in [(8, 14), (14, 15), (15, 13), (13, 9)] {
1052            if mol_has_no_bond_yet(&b, a[x], a[y]) {
1053                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
1054            }
1055        }
1056        let mol = b.build();
1057        let result = kekulize(&mol).expect("fluoranthene-like kekulization failed");
1058        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1059        assert_eq!(
1060            doubles, 8,
1061            "fluoranthene-like structure needs 8 double bonds"
1062        );
1063    }
1064
1065    /// Indolizine (`c1ccn2cccc2c1`) — bridgehead N at C9a (aromatic degree 3).
1066    /// 9 atoms: N contributes lone pair, 8 C atoms form 4 double bonds.
1067    /// Requires Pass 3 (bridgehead-N exclusion).
1068    #[test]
1069    fn kekulize_indolizine() {
1070        // Bonds: 6-ring (0-1-2-3-7-8-0) ∪ 5-ring (3-4-5-6-7-3), fused at edge 3-7.
1071        let mut b = MoleculeBuilder::new();
1072        let c: Vec<_> = (0..9)
1073            .map(|i| {
1074                if i == 3 {
1075                    b.add_atom(Atom::aromatic(Element::N))
1076                } else {
1077                    b.add_atom(Atom::aromatic(Element::C))
1078                }
1079            })
1080            .collect();
1081        for (x, y) in [
1082            (0, 1),
1083            (1, 2),
1084            (2, 3),
1085            (3, 4),
1086            (4, 5),
1087            (5, 6),
1088            (6, 7),
1089            (7, 3),
1090            (7, 8),
1091            (8, 0),
1092        ] {
1093            b.add_bond(c[x], c[y], BondOrder::Aromatic).unwrap();
1094        }
1095        let mol = b.build();
1096        let result = kekulize(&mol);
1097        assert!(
1098            result.is_ok(),
1099            "indolizine kekulization failed: {:?}",
1100            result.err()
1101        );
1102        let doubles = result
1103            .unwrap()
1104            .values()
1105            .filter(|&&o| o == BondOrder::Double)
1106            .count();
1107        assert_eq!(doubles, 4, "indolizine: 4 double bonds (N lone-pair donor)");
1108    }
1109
1110    /// Quinolizine (`c1ccn2ccccc2c1`) — bridgehead N in two 6-membered rings.
1111    /// 10 atoms (even), bipartite graph → passes 1/2 handle it; Pass 3 must not break it.
1112    #[test]
1113    fn kekulize_quinolizine() {
1114        // 6-ring A: 0-1-2-3-8-9-0  6-ring B: 3-4-5-6-7-8-3  fused at edge 3-8.
1115        let mut b = MoleculeBuilder::new();
1116        let c: Vec<_> = (0..10)
1117            .map(|i| {
1118                if i == 3 {
1119                    b.add_atom(Atom::aromatic(Element::N))
1120                } else {
1121                    b.add_atom(Atom::aromatic(Element::C))
1122                }
1123            })
1124            .collect();
1125        for (x, y) in [
1126            (0, 1),
1127            (1, 2),
1128            (2, 3),
1129            (3, 4),
1130            (4, 5),
1131            (5, 6),
1132            (6, 7),
1133            (7, 8),
1134            (8, 3),
1135            (8, 9),
1136            (9, 0),
1137        ] {
1138            b.add_bond(c[x], c[y], BondOrder::Aromatic).unwrap();
1139        }
1140        let mol = b.build();
1141        let result = kekulize(&mol);
1142        assert!(
1143            result.is_ok(),
1144            "quinolizine kekulization failed: {:?}",
1145            result.err()
1146        );
1147        let doubles = result
1148            .unwrap()
1149            .values()
1150            .filter(|&&o| o == BondOrder::Double)
1151            .count();
1152        assert_eq!(doubles, 5, "quinolizine: 5 double bonds");
1153    }
1154
1155    /// Corannulene (C₂₀H₁₀) — bowl-shaped PAH with five 5-membered rings fused to
1156    /// five 6-membered rings.  The C-only aromatic subgraph is non-bipartite (five odd
1157    /// cycles), so passes 1–3 fail; requires Pass 4 (Edmonds' blossom).
1158    #[test]
1159    fn kekulize_corannulene() {
1160        // Inner 5-ring hub: 0-1-2-3-4-0
1161        // Spokes to outer ring: 0-5, 1-7, 2-9, 3-11, 4-13
1162        // Outer 10-ring: 5-6-7-8-9-10-11-12-13-14-5
1163        // Outer 5 "cap" pairs: (5,15),(6,15),(7,16),(8,16),(9,17),(10,17),(11,18),(12,18),(13,19),(14,19)
1164        // 20 vertices, 25 edges, 10 double bonds expected.
1165        let mut b = MoleculeBuilder::new();
1166        let a: Vec<_> = (0..20)
1167            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1168            .collect();
1169        let edges: &[(usize, usize)] = &[
1170            // inner 5-ring
1171            (0, 1),
1172            (1, 2),
1173            (2, 3),
1174            (3, 4),
1175            (4, 0),
1176            // spokes
1177            (0, 5),
1178            (1, 7),
1179            (2, 9),
1180            (3, 11),
1181            (4, 13),
1182            // outer 10-ring
1183            (5, 6),
1184            (6, 7),
1185            (7, 8),
1186            (8, 9),
1187            (9, 10),
1188            (10, 11),
1189            (11, 12),
1190            (12, 13),
1191            (13, 14),
1192            (14, 5),
1193            // outer "cap" bonds
1194            (5, 15),
1195            (6, 15),
1196            (7, 16),
1197            (8, 16),
1198            (9, 17),
1199            (10, 17),
1200            (11, 18),
1201            (12, 18),
1202            (13, 19),
1203            (14, 19),
1204        ];
1205        for &(x, y) in edges {
1206            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
1207        }
1208        let mol = b.build();
1209        let result = kekulize(&mol);
1210        assert!(
1211            result.is_ok(),
1212            "corannulene kekulization failed: {:?}",
1213            result.err()
1214        );
1215        let doubles = result
1216            .unwrap()
1217            .values()
1218            .filter(|&&o| o == BondOrder::Double)
1219            .count();
1220        assert_eq!(doubles, 10, "corannulene: 10 double bonds");
1221    }
1222
1223    /// 1-borazarobenzene (`b1ccccn1`) — aromatic B has an empty p orbital
1224    /// (electron acceptor), not a lone pair. B must be in a double bond.
1225    /// This was the last remaining kekulization failure in the 5000-molecule corpus.
1226    #[test]
1227    fn kekulize_boron_azine() {
1228        // 6-ring: B(0)-C(1)-C(2)-C(3)-C(4)-N(5)-B(0)
1229        // Valid Kekulé: B=C, C=C, C=N  (3 double bonds)
1230        let mut b = MoleculeBuilder::new();
1231        let atoms: Vec<_> = (0..6)
1232            .map(|i| {
1233                if i == 0 {
1234                    b.add_atom(Atom::aromatic(Element::B))
1235                } else if i == 5 {
1236                    b.add_atom(Atom::aromatic(Element::N))
1237                } else {
1238                    b.add_atom(Atom::aromatic(Element::C))
1239                }
1240            })
1241            .collect();
1242        for i in 0..6 {
1243            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
1244                .unwrap();
1245        }
1246        let mol = b.build();
1247        let result = kekulize(&mol);
1248        assert!(
1249            result.is_ok(),
1250            "b1ccccn1 kekulization failed: {:?}",
1251            result.err()
1252        );
1253        let doubles = result
1254            .unwrap()
1255            .values()
1256            .filter(|&&o| o == BondOrder::Double)
1257            .count();
1258        assert_eq!(doubles, 3, "b1ccccn1: 3 double bonds");
1259    }
1260
1261    // -----------------------------------------------------------------
1262    // Kekule-S0: apply_kekule must preserve stereo side channels.
1263    //
1264    // `@`/`@@` (`Atom::chirality`) is a *relative* descriptor over the
1265    // SMILES neighbor-encounter order recorded in `stereo_neighbor_order`
1266    // -- copying `chirality` alone without that order is not enough to
1267    // reproduce the same tetrahedral configuration downstream.
1268    // -----------------------------------------------------------------
1269
1270    /// A chiral aromatic ring (5-membered, one sp3 substituent) with a
1271    /// stereo group, a stashed bond direction, and a `stereo_neighbor_order`
1272    /// entry set on the sp3 atom -- exercises all three side channels
1273    /// `apply_kekule` must preserve.
1274    fn chiral_aromatic_with_stereo_metadata() -> (Molecule, AtomIdx, BondIdx) {
1275        let mut b = MoleculeBuilder::new();
1276        let c1 = b.add_atom(Atom::aromatic(Element::C));
1277        let c2 = b.add_atom(Atom::aromatic(Element::C));
1278        let c3 = b.add_atom(Atom::aromatic(Element::C));
1279        let c4 = b.add_atom(Atom::aromatic(Element::C));
1280        let o = b.add_atom(Atom::aromatic(Element::O));
1281        for i in 0..4 {
1282            let ring = [c1, c2, c3, c4, o];
1283            b.add_bond(ring[i], ring[i + 1], BondOrder::Aromatic)
1284                .unwrap();
1285        }
1286        b.add_bond(o, c1, BondOrder::Aromatic).unwrap();
1287
1288        // sp3 chiral substituent on c1: -CH(F)(Cl), a real stereocenter.
1289        let mut chiral = Atom::new(Element::C);
1290        chiral.chirality = crate::atom::Chirality::CounterClockwise;
1291        let ch = b.add_atom(chiral);
1292        let f = b.add_atom(Atom::new(Element::F));
1293        let cl = b.add_atom(Atom::new(Element::CL));
1294        let exocyclic_bond = b.add_bond(c1, ch, BondOrder::Single).unwrap();
1295        b.add_bond(ch, f, BondOrder::Single).unwrap();
1296        b.add_bond(ch, cl, BondOrder::Single).unwrap();
1297
1298        let mut mol = b.build();
1299        // SMILES-text-order neighbor sequence: c1, implicit-H sentinel, F, Cl.
1300        mol.set_stereo_neighbor_order(ch, vec![c1.0, STEREO_H_SENTINEL, f.0, cl.0]);
1301        mol.add_stereo_group(StereoGroup::new(
1302            crate::stereo_group::StereoGroupKind::Absolute,
1303            vec![ch],
1304        ));
1305        mol.set_bond_direction(exocyclic_bond, BondOrder::Up);
1306
1307        (mol, ch, exocyclic_bond)
1308    }
1309
1310    #[test]
1311    fn apply_kekule_preserves_stereo_neighbor_order() {
1312        let (mol, ch, _) = chiral_aromatic_with_stereo_metadata();
1313        let before = mol.stereo_neighbor_order(ch).map(|s| s.to_vec());
1314        assert!(before.is_some(), "test setup sanity");
1315
1316        let result = kekulize(&mol).expect("kekulizable");
1317        assert!(
1318            !result.is_empty(),
1319            "test setup sanity: ring must need kekulization"
1320        );
1321        let kekulized = apply_kekule(&mol, &result);
1322
1323        assert_eq!(
1324            kekulized.stereo_neighbor_order(ch).map(|s| s.to_vec()),
1325            before,
1326            "stereo_neighbor_order must survive apply_kekule verbatim"
1327        );
1328    }
1329
1330    #[test]
1331    fn apply_kekule_preserves_stereo_groups() {
1332        let (mol, _, _) = chiral_aromatic_with_stereo_metadata();
1333        let before = mol.stereo_groups().to_vec();
1334        assert!(!before.is_empty(), "test setup sanity");
1335
1336        let result = kekulize(&mol).expect("kekulizable");
1337        let kekulized = apply_kekule(&mol, &result);
1338
1339        assert_eq!(
1340            kekulized.stereo_groups(),
1341            before.as_slice(),
1342            "stereo_groups must survive apply_kekule verbatim"
1343        );
1344    }
1345
1346    #[test]
1347    fn apply_kekule_preserves_bond_directions() {
1348        let (mol, _, exocyclic_bond) = chiral_aromatic_with_stereo_metadata();
1349        let before = mol.bond_direction(exocyclic_bond);
1350        assert!(before.is_some(), "test setup sanity");
1351
1352        let result = kekulize(&mol).expect("kekulizable");
1353        let kekulized = apply_kekule(&mol, &result);
1354
1355        assert_eq!(
1356            kekulized.bond_direction(exocyclic_bond),
1357            before,
1358            "bond_directions must survive apply_kekule verbatim"
1359        );
1360    }
1361
1362    #[test]
1363    fn apply_kekule_preserves_atom_and_bond_index_mapping() {
1364        let (mol, _, _) = chiral_aromatic_with_stereo_metadata();
1365        let result = kekulize(&mol).expect("kekulizable");
1366        let kekulized = apply_kekule(&mol, &result);
1367
1368        assert_eq!(kekulized.atom_count(), mol.atom_count());
1369        assert_eq!(kekulized.bond_count(), mol.bond_count());
1370        for (idx, atom) in mol.atoms() {
1371            let after = kekulized.atom(idx);
1372            assert_eq!(atom.element, after.element, "atom {idx:?} element moved");
1373            assert_eq!(
1374                atom.chirality, after.chirality,
1375                "atom {idx:?} chirality moved"
1376            );
1377        }
1378        for (bidx, bond) in mol.bonds() {
1379            let after = kekulized.bond(bidx);
1380            assert_eq!(bond.atom1, after.atom1, "bond {bidx:?} atom1 moved");
1381            assert_eq!(bond.atom2, after.atom2, "bond {bidx:?} atom2 moved");
1382        }
1383    }
1384
1385    #[test]
1386    fn apply_kekule_empty_result_is_full_clone() {
1387        // No aromatic bonds at all -- kekulize() returns an empty map, and
1388        // apply_kekule must take the early-return clone path, which trivially
1389        // preserves every side channel (it's the same molecule).
1390        let mut b = MoleculeBuilder::new();
1391        let mut chiral = Atom::new(Element::C);
1392        chiral.chirality = crate::atom::Chirality::Clockwise;
1393        let c = b.add_atom(chiral);
1394        let f = b.add_atom(Atom::new(Element::F));
1395        let cl = b.add_atom(Atom::new(Element::CL));
1396        let br = b.add_atom(Atom::new(Element::BR));
1397        let h_bond = b.add_atom(Atom::new(Element::I));
1398        b.add_bond(c, f, BondOrder::Single).unwrap();
1399        b.add_bond(c, cl, BondOrder::Single).unwrap();
1400        b.add_bond(c, br, BondOrder::Single).unwrap();
1401        b.add_bond(c, h_bond, BondOrder::Single).unwrap();
1402
1403        let mut mol = b.build();
1404        mol.set_stereo_neighbor_order(c, vec![f.0, cl.0, br.0, h_bond.0]);
1405
1406        let result = kekulize(&mol).expect("no aromatic bonds -- trivially kekulizable");
1407        assert!(result.is_empty(), "test setup sanity: no aromatic bonds");
1408
1409        let kekulized = apply_kekule(&mol, &result);
1410        assert_eq!(
1411            kekulized.stereo_neighbor_order(c).map(|s| s.to_vec()),
1412            mol.stereo_neighbor_order(c).map(|s| s.to_vec())
1413        );
1414        assert_eq!(kekulized.atom_count(), mol.atom_count());
1415        assert_eq!(kekulized.bond_count(), mol.bond_count());
1416    }
1417
1418    // -----------------------------------------------------------------
1419    // K1: charge-aware `atom_must_be_matched` -- fixtures from
1420    // docs/aromaticity_rdkit_parity_rfc.md section 1a, previously hard
1421    // failures. Builders mirror `furan`/`pyrrole` above: an explicit-H
1422    // bracket atom is built via `Atom::aromatic` + a field override, matching
1423    // the SMILES bracket notation named in each doc comment.
1424    // -----------------------------------------------------------------
1425
1426    /// Tropylium cation (`c1ccc[cH+]cc1`): 7-ring, 6 neutral aromatic CH +
1427    /// 1 cationic aromatic CH+. Returns the cation's `AtomIdx` alongside the
1428    /// molecule so tests can check its bonds specifically.
1429    fn tropylium_cation() -> (Molecule, AtomIdx) {
1430        let mut b = MoleculeBuilder::new();
1431        let mut atoms: Vec<AtomIdx> = (0..4)
1432            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1433            .collect();
1434        let mut cation = Atom::aromatic(Element::C);
1435        cation.charge = 1;
1436        cation.hydrogen_count = Some(1);
1437        let cation_idx = b.add_atom(cation); // ring position matches c1ccc[cH+]cc1
1438        atoms.push(cation_idx);
1439        atoms.extend((0..2).map(|_| b.add_atom(Atom::aromatic(Element::C))));
1440        for i in 0..7 {
1441            b.add_bond(atoms[i], atoms[(i + 1) % 7], BondOrder::Aromatic)
1442                .unwrap();
1443        }
1444        (b.build(), cation_idx)
1445    }
1446
1447    /// Imidazolium (`c1c[nH+]c[nH]1`): 5-ring, 3 aromatic C + 1 protonated
1448    /// `[nH+]` + 1 neutral `[nH]`.
1449    fn imidazolium() -> Molecule {
1450        let mut b = MoleculeBuilder::new();
1451        let c0 = b.add_atom(Atom::aromatic(Element::C));
1452        let c1 = b.add_atom(Atom::aromatic(Element::C));
1453        let mut n_plus = Atom::aromatic(Element::N);
1454        n_plus.charge = 1;
1455        n_plus.hydrogen_count = Some(1);
1456        let n2 = b.add_atom(n_plus);
1457        let c3 = b.add_atom(Atom::aromatic(Element::C));
1458        let mut n_neutral = Atom::aromatic(Element::N);
1459        n_neutral.hydrogen_count = Some(1);
1460        let n4 = b.add_atom(n_neutral);
1461        let atoms = [c0, c1, n2, c3, n4];
1462        for i in 0..5 {
1463            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
1464                .unwrap();
1465        }
1466        b.build()
1467    }
1468
1469    /// Pyridinium (`c1cc[nH+]cc1`): 6-ring, 5 aromatic C + 1 protonated `[nH+]`.
1470    fn pyridinium() -> Molecule {
1471        let mut b = MoleculeBuilder::new();
1472        let mut atoms: Vec<AtomIdx> = (0..5)
1473            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1474            .collect();
1475        let mut n_plus = Atom::aromatic(Element::N);
1476        n_plus.charge = 1;
1477        n_plus.hydrogen_count = Some(1);
1478        atoms.insert(3, b.add_atom(n_plus)); // ring position matches c1cc[nH+]cc1
1479        for i in 0..6 {
1480            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
1481                .unwrap();
1482        }
1483        b.build()
1484    }
1485
1486    /// Pyrylium (`c1cc[o+]cc1`): 6-ring, 5 aromatic C + 1 cationic aromatic O+.
1487    fn pyrylium() -> Molecule {
1488        let mut b = MoleculeBuilder::new();
1489        let mut atoms: Vec<AtomIdx> = (0..5)
1490            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1491            .collect();
1492        let mut o_plus = Atom::aromatic(Element::O);
1493        o_plus.charge = 1;
1494        atoms.insert(3, b.add_atom(o_plus)); // ring position matches c1cc[o+]cc1
1495        for i in 0..6 {
1496            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
1497                .unwrap();
1498        }
1499        b.build()
1500    }
1501
1502    /// Tellurophene (`c1cc[te]c1`): 5-ring, 4 aromatic C + 1 neutral aromatic Te.
1503    fn tellurophene() -> Molecule {
1504        let mut b = MoleculeBuilder::new();
1505        let te = b.add_atom(Atom::aromatic(Element::TE));
1506        let c1 = b.add_atom(Atom::aromatic(Element::C));
1507        let c2 = b.add_atom(Atom::aromatic(Element::C));
1508        let c3 = b.add_atom(Atom::aromatic(Element::C));
1509        let c4 = b.add_atom(Atom::aromatic(Element::C));
1510        let ring = [te, c1, c2, c3, c4];
1511        for i in 0..5 {
1512            b.add_bond(ring[i], ring[(i + 1) % 5], BondOrder::Aromatic)
1513                .unwrap();
1514        }
1515        b.build()
1516    }
1517
1518    /// Phosphole (`c1cc[pH]c1`): 5-ring, 4 aromatic C + 1 neutral `[pH]`.
1519    fn phosphole() -> Molecule {
1520        let mut b = MoleculeBuilder::new();
1521        let mut p_atom = Atom::aromatic(Element::P);
1522        p_atom.hydrogen_count = Some(1);
1523        let p = b.add_atom(p_atom);
1524        let c1 = b.add_atom(Atom::aromatic(Element::C));
1525        let c2 = b.add_atom(Atom::aromatic(Element::C));
1526        let c3 = b.add_atom(Atom::aromatic(Element::C));
1527        let c4 = b.add_atom(Atom::aromatic(Element::C));
1528        let ring = [p, c1, c2, c3, c4];
1529        for i in 0..5 {
1530            b.add_bond(ring[i], ring[(i + 1) % 5], BondOrder::Aromatic)
1531                .unwrap();
1532        }
1533        b.build()
1534    }
1535
1536    /// Thiophene (4 aromatic C + 1 aromatic S, ring) -- regression coverage:
1537    /// neutral chalcogen donor rule must still exempt S after the K1 charge-aware fix.
1538    fn thiophene() -> Molecule {
1539        let mut b = MoleculeBuilder::new();
1540        let s = b.add_atom(Atom::aromatic(Element::S));
1541        let c1 = b.add_atom(Atom::aromatic(Element::C));
1542        let c2 = b.add_atom(Atom::aromatic(Element::C));
1543        let c3 = b.add_atom(Atom::aromatic(Element::C));
1544        let c4 = b.add_atom(Atom::aromatic(Element::C));
1545        let ring = [s, c1, c2, c3, c4];
1546        for i in 0..5 {
1547            b.add_bond(ring[i], ring[(i + 1) % 5], BondOrder::Aromatic)
1548                .unwrap();
1549        }
1550        b.build()
1551    }
1552
1553    /// Selenophene (4 aromatic C + 1 aromatic Se, ring) -- regression coverage,
1554    /// same rationale as `thiophene` above.
1555    fn selenophene() -> Molecule {
1556        let mut b = MoleculeBuilder::new();
1557        let se = b.add_atom(Atom::aromatic(Element::SE));
1558        let c1 = b.add_atom(Atom::aromatic(Element::C));
1559        let c2 = b.add_atom(Atom::aromatic(Element::C));
1560        let c3 = b.add_atom(Atom::aromatic(Element::C));
1561        let c4 = b.add_atom(Atom::aromatic(Element::C));
1562        let ring = [se, c1, c2, c3, c4];
1563        for i in 0..5 {
1564            b.add_bond(ring[i], ring[(i + 1) % 5], BondOrder::Aromatic)
1565                .unwrap();
1566        }
1567        b.build()
1568    }
1569
1570    #[test]
1571    fn test_kekulize_tropylium_cation() {
1572        let (mol, cation_idx) = tropylium_cation();
1573        let result = kekulize(&mol).expect("tropylium cation kekulization failed");
1574        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1575        assert_eq!(
1576            doubles, 3,
1577            "tropylium: 6 neutral C alternate into 3 double bonds"
1578        );
1579        // The cationic carbon must end up with only single bonds.
1580        for (bidx, order) in &result {
1581            let bond = mol.bond(*bidx);
1582            if bond.atom1 == cation_idx || bond.atom2 == cation_idx {
1583                assert_eq!(
1584                    *order,
1585                    BondOrder::Single,
1586                    "cationic C must not get a double bond"
1587                );
1588            }
1589        }
1590    }
1591
1592    #[test]
1593    fn test_kekulize_imidazolium() {
1594        let mol = imidazolium();
1595        let result = kekulize(&mol).expect("imidazolium kekulization failed");
1596        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1597        assert_eq!(
1598            doubles, 2,
1599            "imidazolium: 2 double bonds ([nH+] matched, [nH] not)"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_kekulize_pyridinium() {
1605        let mol = pyridinium();
1606        let result = kekulize(&mol).expect("pyridinium kekulization failed");
1607        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1608        assert_eq!(
1609            doubles, 3,
1610            "pyridinium: 3 double bonds, same as neutral pyridine"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_kekulize_pyrylium() {
1616        let mol = pyrylium();
1617        let result = kekulize(&mol).expect("pyrylium kekulization failed");
1618        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1619        assert_eq!(
1620            doubles, 3,
1621            "pyrylium: 3 double bonds, O+ matched like pyridine's N"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_kekulize_tellurophene() {
1627        let mol = tellurophene();
1628        let result = kekulize(&mol).expect("tellurophene kekulization failed");
1629        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1630        assert_eq!(
1631            doubles, 2,
1632            "tellurophene: 2 double bonds, Te donates its lone pair"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_kekulize_phosphole() {
1638        let mol = phosphole();
1639        let result = kekulize(&mol).expect("phosphole kekulization failed");
1640        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1641        assert_eq!(
1642            doubles, 2,
1643            "phosphole: 2 double bonds, [pH] donates its lone pair like [nH]"
1644        );
1645    }
1646
1647    #[test]
1648    fn test_kekulize_thiophene_regression() {
1649        let mol = thiophene();
1650        let result = kekulize(&mol).expect("thiophene kekulization failed");
1651        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1652        assert_eq!(
1653            doubles, 2,
1654            "thiophene must still kekulize after the K1 charge-aware fix"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_kekulize_selenophene_regression() {
1660        let mol = selenophene();
1661        let result = kekulize(&mol).expect("selenophene kekulization failed");
1662        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1663        assert_eq!(
1664            doubles, 2,
1665            "selenophene must still kekulize after the K1 charge-aware fix"
1666        );
1667    }
1668}