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.
238fn 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).
271pub fn apply_kekule(mol: &Molecule, kekule: &KekuleResult) -> Molecule {
272    use crate::molecule::MoleculeBuilder;
273
274    let mut builder = MoleculeBuilder::new();
275
276    // Re-add all atoms.
277    for (_, atom) in mol.atoms() {
278        builder.add_atom(atom.clone());
279    }
280
281    // Re-add bonds, substituting updated orders for aromatic bonds.
282    for (bidx, bond) in mol.bonds() {
283        let order = kekule.get(&bidx).copied().unwrap_or(bond.order);
284        builder
285            .add_bond(bond.atom1, bond.atom2, order)
286            .expect("duplicate bond during apply_kekule");
287    }
288
289    builder.build()
290}
291
292/// Attempt to find an augmenting path starting from `start` and update `matching`.
293///
294/// Uses iterative BFS with parent-pointer path reconstruction to avoid stack
295/// overflow on large aromatic systems (wasm32 default stack is ~1 MB).
296/// `visited` must already contain `start` (prevents root re-entry in odd cycles).
297///
298/// Returns true if an augmenting path was found and the matching was updated.
299fn augment(
300    start: AtomIdx,
301    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
302    matching: &mut HashMap<AtomIdx, AtomIdx>,
303    visited: &mut HashSet<AtomIdx>,
304) -> bool {
305    // parent[u] = v means "u was reached from v in the BFS tree"
306    let mut parent: HashMap<AtomIdx, AtomIdx> = HashMap::new();
307    let mut queue: std::collections::VecDeque<AtomIdx> = std::collections::VecDeque::new();
308    queue.push_back(start);
309
310    'bfs: while let Some(v) = queue.pop_front() {
311        let Some(neighbors) = adj.get(&v) else {
312            continue;
313        };
314        for &(u, _) in neighbors {
315            if !visited.insert(u) {
316                continue;
317            }
318            parent.insert(u, v);
319
320            match matching.get(&u).copied() {
321                None => {
322                    // Found a free vertex — trace back through parent pointers
323                    // and flip every edge along the augmenting path.
324                    let mut cur = u;
325                    loop {
326                        let prev = parent[&cur];
327                        let prev_old_match = matching.get(&prev).copied();
328                        matching.insert(prev, cur);
329                        matching.insert(cur, prev);
330                        match prev_old_match {
331                            None | Some(_) if prev == start => break,
332                            Some(m) => cur = m,
333                            None => break,
334                        }
335                    }
336                    break 'bfs;
337                }
338                Some(partner) => {
339                    // u is matched to partner — explore further from partner.
340                    if visited.insert(partner) {
341                        parent.insert(partner, u);
342                        queue.push_back(partner);
343                    }
344                }
345            }
346        }
347    }
348
349    // Augmentation succeeded iff start is now matched.
350    matching.contains_key(&start)
351}
352
353/// Run a single augmenting-path pass over `atoms` and update `matching`.
354///
355/// For each unmatched atom in `atoms`, attempts to find an augmenting path using
356/// the BFS-based `augment()` function. Extracted so that `kekulize()` can try
357/// multiple orderings (ascending → descending) without code duplication.
358fn run_matching_pass(
359    atoms: &[AtomIdx],
360    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
361    matching: &mut HashMap<AtomIdx, AtomIdx>,
362) {
363    for &start in atoms {
364        if matching.contains_key(&start) {
365            continue;
366        }
367        let mut visited: HashSet<AtomIdx> = HashSet::new();
368        visited.insert(start);
369        augment(start, adj, matching, &mut visited);
370    }
371}
372
373// ─── Edmonds' blossom maximum matching ───────────────────────────────────────
374//
375// General (non-bipartite) maximum matching via Gabow's blossom formulation.
376// Vertices are integers 0..n; the returned `mate[i] = j` if matched, else NONE.
377//
378// `NONE` sentinel: usize::MAX — safe because `n` is always < 32 k for drug-like
379// molecules (InChI library limit) and we never index at NONE.
380
381const NONE: usize = usize::MAX;
382
383/// Find a maximum matching in a general graph (Edmonds' blossom, O(n²m)).
384fn blossom_max_matching(n: usize, adj: &[Vec<usize>]) -> Vec<usize> {
385    let mut mate = vec![NONE; n];
386    for v in 0..n {
387        if mate[v] == NONE {
388            blossom_augment(v, n, adj, &mut mate);
389        }
390    }
391    mate
392}
393
394/// Attempt to augment the matching from free vertex `root`.
395fn blossom_augment(root: usize, n: usize, adj: &[Vec<usize>], mate: &mut [usize]) {
396    // base[v]: representative of the blossom containing v.
397    let mut base: Vec<usize> = (0..n).collect();
398    // parent[v]: predecessor of v on the augmenting path (NONE = unlabeled).
399    let mut parent: Vec<usize> = vec![NONE; n];
400    // is_outer[v]: true if v is an outer (even-level) vertex in the BFS forest.
401    let mut is_outer: Vec<bool> = vec![false; n];
402
403    is_outer[root] = true;
404    let mut queue: VecDeque<usize> = VecDeque::new();
405    queue.push_back(root);
406
407    'bfs: while let Some(v) = queue.pop_front() {
408        for &w in &adj[v] {
409            if base[v] == base[w] {
410                continue;
411            } // same blossom
412            if mate[v] == w {
413                continue;
414            } // already-matched edge, skip
415
416            if is_outer[w] {
417                // Both v and w are outer → odd cycle (blossom).
418                let b = blossom_lca(v, w, &base, &parent, mate, n);
419                blossom_mark_path(
420                    v,
421                    b,
422                    w,
423                    &mut base,
424                    &mut parent,
425                    &mut is_outer,
426                    &mut queue,
427                    mate,
428                    n,
429                );
430                blossom_mark_path(
431                    w,
432                    b,
433                    v,
434                    &mut base,
435                    &mut parent,
436                    &mut is_outer,
437                    &mut queue,
438                    mate,
439                    n,
440                );
441            } else if parent[w] == NONE {
442                // w is unlabeled.
443                parent[w] = v;
444                if mate[w] == NONE {
445                    // Augmenting path ends at w.  Trace parent[] and flip matching.
446                    let mut cur = w;
447                    while cur != NONE {
448                        let prev = parent[cur];
449                        let prev_old = mate[prev];
450                        mate[cur] = prev;
451                        mate[prev] = cur;
452                        cur = prev_old;
453                    }
454                    break 'bfs;
455                }
456                // w is matched — add mate[w] as the next outer vertex.
457                let u = mate[w];
458                if !is_outer[u] {
459                    is_outer[u] = true;
460                    parent[u] = w;
461                    queue.push_back(u);
462                }
463            }
464            // else: w is inner (labeled but not outer) → skip.
465        }
466    }
467}
468
469/// Lowest common ancestor of `a` and `b` in the alternating BFS tree.
470///
471/// Traces both paths toward `root` (following outer→matched-inner→outer chains)
472/// and returns the first vertex visited by both traces.
473fn blossom_lca(
474    mut a: usize,
475    mut b: usize,
476    base: &[usize],
477    parent: &[usize],
478    mate: &[usize],
479    n: usize,
480) -> usize {
481    let mut visited = vec![false; n];
482    loop {
483        a = base[a];
484        visited[a] = true;
485        if mate[a] == NONE {
486            break;
487        } // reached a free vertex (root or its base)
488        a = parent[mate[a]]; // hop: outer→matched inner→outer parent
489    }
490    loop {
491        b = base[b];
492        if visited[b] {
493            return b;
494        } // first vertex seen by both traces
495        b = parent[mate[b]];
496    }
497}
498
499/// Walk from `x` toward blossom base `b`, updating `base[]`, `parent[]`,
500/// and promoting inner vertices to outer so the BFS can traverse the blossom.
501#[allow(clippy::too_many_arguments)]
502fn blossom_mark_path(
503    mut x: usize,
504    b: usize,
505    child: usize,
506    base: &mut [usize],
507    parent: &mut [usize],
508    is_outer: &mut [bool],
509    queue: &mut VecDeque<usize>,
510    mate: &[usize],
511    n: usize,
512) {
513    let mut ch = child;
514    while base[x] != b {
515        let bx = base[x];
516        let bmx = base[mate[x]];
517        // Merge blossom: all vertices in bx or bmx become part of base b.
518        for slot in base.iter_mut().take(n) {
519            if *slot == bx || *slot == bmx {
520                *slot = b;
521            }
522        }
523        // Update augmenting-path parent pointers inside the blossom.
524        parent[x] = ch;
525        // Promote mate[x] to outer so the BFS can continue through the blossom.
526        let mx = mate[x];
527        if !is_outer[mx] {
528            is_outer[mx] = true;
529            queue.push_back(mx);
530        }
531        ch = mx;
532        x = parent[mx];
533    }
534}
535
536// ─────────────────────────────────────────────────────────────────────────────
537
538/// Determine whether an aromatic atom *must* appear in the matching
539/// (i.e. requires a double bond for a valid Kekulé form).
540///
541/// An atom can be unmatched if it contributes a lone pair to the aromatic system:
542/// - O (furan-type oxygen)
543/// - S (thiophene-type sulfur)
544/// - N with an H (pyrrole-type nitrogen: [nH])
545/// - Se, As aromatic analogs
546/// - Any aromatic atom that already has an exocyclic double bond (e.g. the
547///   carbonyl carbon in coumarin/warfarin `c=O` fused into an aromatic ring).
548///   Such an atom's pi contribution comes from conjugation with the exocyclic
549///   bond; no additional ring double bond is needed or possible.
550///
551/// Everything else (C, N without H like pyridine) must be matched.
552fn atom_must_be_matched(mol: &Molecule, idx: AtomIdx) -> bool {
553    let atom = mol.atom(idx);
554    match atom.element.atomic_number() {
555        // O, S, Se always donate a lone pair → don't need a double bond.
556        8 | 16 | 34 => false,
557        // Aromatic B contributes an empty p orbital (electron acceptor), not a lone pair.
558        // It must appear in a double bond in the Kekulé form, just like aromatic C.
559        5 => true,
560        // N with explicit H ([nH]) is a lone-pair donor.
561        7 if matches!(atom.hydrogen_count, Some(h) if h > 0) => false,
562        // Anionic aromatic N ([n-]) also donates its lone pair; the extra electron
563        // occupies the lone-pair slot rather than a ring π bond (same as [nH]).
564        7 if atom.charge < 0 => false,
565        // Neutral N with a non-aromatic substituent (e.g. N-methyl in caffeine) is a lone-pair
566        // donor: the substituent "replaces" the H, and the N contributes its lone pair to
567        // aromaticity rather than a π bond (same as [nH] in pyrrole).
568        // Charged N (pyridinium [n+], N-oxide) must still be matched even with a substituent.
569        7 if atom.charge == 0
570            && mol
571                .neighbors(idx)
572                .any(|(_, bidx)| mol.bond(bidx).order != BondOrder::Aromatic) =>
573        {
574            false
575        }
576        // Bare aromatic N with only aromatic bonds (pyridine-type): must be matched.
577        7 => true,
578        // Any anionic aromatic atom (e.g. cyclopentadienyl [cH-]) donates its lone pair.
579        _ if atom.charge < 0 => false,
580        // Any atom (typically C) that already has an exocyclic π bond (e.g. `c(=O)` in
581        // a heterocyclic carbonyl) cannot also carry a ring double bond — its π-slot is
582        // occupied by the exocyclic bond. Such atoms contribute via conjugation, like
583        // lone-pair donors, and should not be forced into the matching.
584        _ if mol.neighbors(idx).any(|(_, bidx)| {
585            let o = mol.bond(bidx).order;
586            o == BondOrder::Double || o == BondOrder::Triple
587        }) =>
588        {
589            false
590        }
591        // All other atoms must appear in the matching.
592        _ => true,
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use crate::atom::Atom;
600    use crate::element::Element;
601    use crate::molecule::MoleculeBuilder;
602
603    /// Build benzene as a fully aromatic SMILES-style molecule.
604    fn benzene() -> Molecule {
605        let mut b = MoleculeBuilder::new();
606        let atoms: Vec<_> = (0..6)
607            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
608            .collect();
609        for i in 0..6 {
610            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
611                .unwrap();
612        }
613        b.build()
614    }
615
616    /// Build pyridine (5 aromatic C + 1 aromatic N, ring).
617    fn pyridine() -> Molecule {
618        let mut b = MoleculeBuilder::new();
619        let c1 = b.add_atom(Atom::aromatic(Element::C));
620        let c2 = b.add_atom(Atom::aromatic(Element::C));
621        let c3 = b.add_atom(Atom::aromatic(Element::C));
622        let n = b.add_atom(Atom::aromatic(Element::N));
623        let c4 = b.add_atom(Atom::aromatic(Element::C));
624        let c5 = b.add_atom(Atom::aromatic(Element::C));
625        let atoms = [c1, c2, c3, n, c4, c5];
626        for i in 0..6 {
627            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
628                .unwrap();
629        }
630        b.build()
631    }
632
633    /// Build furan (4 aromatic C + 1 aromatic O, ring).
634    fn furan() -> Molecule {
635        let mut b = MoleculeBuilder::new();
636        let o = b.add_atom(Atom::aromatic(Element::O));
637        let c1 = b.add_atom(Atom::aromatic(Element::C));
638        let c2 = b.add_atom(Atom::aromatic(Element::C));
639        let c3 = b.add_atom(Atom::aromatic(Element::C));
640        let c4 = b.add_atom(Atom::aromatic(Element::C));
641        let atoms = [o, c1, c2, c3, c4];
642        for i in 0..5 {
643            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
644                .unwrap();
645        }
646        b.build()
647    }
648
649    /// Build pyrrole ([nH] + 4 aromatic C, ring).
650    fn pyrrole() -> Molecule {
651        let mut b = MoleculeBuilder::new();
652        // N with 1 H — bracket-style (hydrogen_count = Some(1))
653        let mut n_atom = Atom::aromatic(Element::N);
654        n_atom.hydrogen_count = Some(1);
655        let n = b.add_atom(n_atom);
656        let c1 = b.add_atom(Atom::aromatic(Element::C));
657        let c2 = b.add_atom(Atom::aromatic(Element::C));
658        let c3 = b.add_atom(Atom::aromatic(Element::C));
659        let c4 = b.add_atom(Atom::aromatic(Element::C));
660        let atoms = [n, c1, c2, c3, c4];
661        for i in 0..5 {
662            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
663                .unwrap();
664        }
665        b.build()
666    }
667
668    #[test]
669    fn test_kekulize_benzene() {
670        let mol = benzene();
671        let result = kekulize(&mol).expect("benzene kekulization failed");
672        assert_eq!(result.len(), 6); // all 6 aromatic bonds assigned
673
674        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
675        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
676        assert_eq!(doubles, 3, "benzene must have 3 double bonds");
677        assert_eq!(singles, 3, "benzene must have 3 single bonds");
678    }
679
680    #[test]
681    fn test_kekulize_pyridine() {
682        let mol = pyridine();
683        let result = kekulize(&mol).expect("pyridine kekulization failed");
684        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
685        assert_eq!(doubles, 3, "pyridine must have 3 double bonds");
686    }
687
688    #[test]
689    fn test_kekulize_furan() {
690        let mol = furan();
691        let result = kekulize(&mol).expect("furan kekulization failed");
692        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
693        assert_eq!(doubles, 2, "furan must have 2 double bonds");
694    }
695
696    #[test]
697    fn test_kekulize_pyrrole() {
698        let mol = pyrrole();
699        let result = kekulize(&mol).expect("pyrrole kekulization failed");
700        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
701        assert_eq!(doubles, 2, "pyrrole must have 2 double bonds");
702    }
703
704    #[test]
705    fn test_kekulize_naphthalene() {
706        // 10 aromatic C, 11 aromatic bonds (fused bicyclic)
707        let mut b = MoleculeBuilder::new();
708        let atoms: Vec<_> = (0..10)
709            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
710            .collect();
711        // Ring 1: 0-1-2-3-4-9
712        let ring1 = [0, 1, 2, 3, 4, 9];
713        for i in 0..ring1.len() {
714            b.add_bond(
715                atoms[ring1[i]],
716                atoms[ring1[(i + 1) % ring1.len()]],
717                BondOrder::Aromatic,
718            )
719            .unwrap();
720        }
721        // Ring 2: 4-5-6-7-8-9 (shares bond 4-9)
722        let ring2 = [4, 5, 6, 7, 8, 9];
723        for i in 0..ring2.len() {
724            let a = atoms[ring2[i]];
725            let bb = atoms[ring2[(i + 1) % ring2.len()]];
726            // Skip already-added bond (4-9)
727            if mol_has_no_bond_yet(&b, a, bb) {
728                b.add_bond(a, bb, BondOrder::Aromatic).unwrap();
729            }
730        }
731        let mol = b.build();
732        let result = kekulize(&mol).expect("naphthalene kekulization failed");
733        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
734        assert_eq!(doubles, 5, "naphthalene must have 5 double bonds");
735    }
736
737    #[test]
738    fn test_apply_kekule() {
739        let mol = benzene();
740        let kekule = kekulize(&mol).unwrap();
741        let kekule_mol = apply_kekule(&mol, &kekule);
742
743        // After applying, no aromatic bonds should remain.
744        for (_, bond) in kekule_mol.bonds() {
745            assert_ne!(
746                bond.order,
747                BondOrder::Aromatic,
748                "apply_kekule should remove all aromatic bonds"
749            );
750        }
751    }
752
753    #[test]
754    fn test_no_aromatic_bonds_noop() {
755        // A molecule with no aromatic bonds should return empty result.
756        let mut b = MoleculeBuilder::new();
757        let c1 = b.add_atom(Atom::new(Element::C));
758        let c2 = b.add_atom(Atom::new(Element::C));
759        b.add_bond(c1, c2, BondOrder::Single).unwrap();
760        let mol = b.build();
761        let result = kekulize(&mol).unwrap();
762        assert!(result.is_empty());
763    }
764
765    // Helper: check that the builder does not yet have a bond between a and b.
766    fn mol_has_no_bond_yet(b: &MoleculeBuilder, a: AtomIdx, bb: AtomIdx) -> bool {
767        for (_, partner) in b.atom_neighbors(a) {
768            if partner == bb {
769                return false;
770            }
771        }
772        true
773    }
774
775    // B6 coverage: exotic ring systems that require correct matching on non-bipartite graphs.
776
777    /// Azulene: fused 5+7 bicyclic, 10 aromatic C, 11 bonds.
778    /// Valid Kekulé form exists → must yield 5 double bonds.
779    #[test]
780    fn test_kekulize_azulene() {
781        let mut b = MoleculeBuilder::new();
782        let a: Vec<_> = (0..10)
783            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
784            .collect();
785        // 5-ring: 0-1-2-3-4-0
786        for i in 0..5 {
787            b.add_bond(a[i], a[(i + 1) % 5], BondOrder::Aromatic)
788                .unwrap();
789        }
790        // 7-ring extras (shares bond 0-4): 4-5-6-7-8-9-0
791        for (x, y) in [(4usize, 5usize), (5, 6), (6, 7), (7, 8), (8, 9), (9, 0)] {
792            if mol_has_no_bond_yet(&b, a[x], a[y]) {
793                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
794            }
795        }
796        let mol = b.build();
797        let result = kekulize(&mol).expect("azulene kekulization failed");
798        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
799        assert_eq!(doubles, 5, "azulene needs 5 double bonds");
800    }
801
802    /// Acenaphthylene: naphthalene + fused cyclopentadiene bridge, 12 aromatic C.
803    #[test]
804    fn test_kekulize_acenaphthylene() {
805        // Connectivity: naphthalene core (atoms 0-9) + bridge atoms 10,11
806        // Naphthalene: ring1=0-1-2-3-4-9, ring2=4-5-6-7-8-9
807        // Bridge: 0-11-10-1 (the 5-ring across the 1,8 positions of naphthalene)
808        let mut b = MoleculeBuilder::new();
809        let a: Vec<_> = (0..12)
810            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
811            .collect();
812        // Naphthalene ring 1: 0-1-2-3-4-9-0
813        for (x, y) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 9), (9, 0)] {
814            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
815        }
816        // Naphthalene ring 2: 4-5-6-7-8-9 (4-9 shared)
817        for (x, y) in [(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)] {
818            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
819        }
820        // Bridge: 0-11-10-1
821        for (x, y) in [(0, 11), (11, 10), (10, 1)] {
822            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
823        }
824        let mol = b.build();
825        let result = kekulize(&mol).expect("acenaphthylene kekulization failed");
826        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
827        assert_eq!(doubles, 6, "acenaphthylene needs 6 double bonds");
828    }
829
830    // ---- Edge-case tests added v0.1.100 ----
831    //
832    // Build complex PAH manually to test edge cases in the matching algorithm.
833
834    /// Biphenylene: two benzene rings connected by a cyclobutadiene bridge.
835    ///
836    /// Topology: Ring A (0–5), Ring B (6–11), bridge bonds (0–11) and (5–6).
837    /// The 4-membered ring 0-5-6-11-0 is the challenging part.
838    fn biphenylene() -> Molecule {
839        let mut b = MoleculeBuilder::new();
840        let a: Vec<_> = (0..12)
841            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
842            .collect();
843        // Ring A (6): 0-1-2-3-4-5-0
844        for i in 0..6 {
845            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
846                .unwrap();
847        }
848        // Ring B (6): 6-7-8-9-10-11-6
849        for i in 0..6 {
850            b.add_bond(a[6 + i], a[6 + (i + 1) % 6], BondOrder::Aromatic)
851                .unwrap();
852        }
853        // 4-membered bridge: closes ring 0-5-6-11-0
854        b.add_bond(a[5], a[6], BondOrder::Aromatic).unwrap();
855        b.add_bond(a[0], a[11], BondOrder::Aromatic).unwrap();
856        b.build()
857    }
858
859    /// Naphtho-4-ring: three fused 6-membered rings sharing a common bond sequence.
860    /// Linear anthracene topology (0-1-2-3-4-5, 5-6-7-8-9-4, 6-10-11-12-13-7).
861    fn anthracene() -> Molecule {
862        let mut b = MoleculeBuilder::new();
863        let a: Vec<_> = (0..14)
864            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
865            .collect();
866        // Ring A: 0-1-2-3-4-5-0
867        for i in 0..6 {
868            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
869                .unwrap();
870        }
871        // Ring B: 5-4-9-8-7-6-5 (shares bond 4-5 with Ring A)
872        for (x, y) in [(4, 9), (9, 8), (8, 7), (7, 6), (6, 5)] {
873            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
874        }
875        // Ring C: 6-7-13-12-11-10-6 (shares bond 6-7 with Ring B)
876        for (x, y) in [(7, 13), (13, 12), (12, 11), (11, 10), (10, 6)] {
877            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
878        }
879        b.build()
880    }
881
882    #[test]
883    fn test_kekulize_biphenylene() {
884        // Biphenylene: 4-membered cyclobutadiene bridge between two benzenes.
885        // 12 aromatic C → 6 double bonds.
886        let mol = biphenylene();
887        let result = kekulize(&mol).expect("biphenylene kekulization should succeed");
888        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
889        assert_eq!(doubles, 6, "biphenylene needs 6 double bonds");
890    }
891
892    #[test]
893    fn test_kekulize_anthracene() {
894        // Anthracene (C14H10): 3 linearly fused 6-membered rings.  7 double bonds.
895        let mol = anthracene();
896        let result = kekulize(&mol).expect("anthracene kekulization should succeed");
897        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
898        assert_eq!(doubles, 7, "anthracene needs 7 double bonds");
899    }
900
901    #[test]
902    fn test_kekulize_biphenylene_double_bond_count() {
903        // Cross-check: all 14 bonds should be assigned single or double.
904        let mol = biphenylene();
905        let result = kekulize(&mol).expect("biphenylene kekulization");
906        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
907        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
908        assert_eq!(singles + doubles, 14, "biphenylene has 14 aromatic bonds");
909    }
910
911    #[test]
912    fn test_kekulize_large_fused_6rings() {
913        // 4 fused 6-membered rings (pyrene-like) in a simple linear topology.
914        // Simulates a large all-even-ring PAH that the BFS should handle easily.
915        let mol = {
916            let mut b = MoleculeBuilder::new();
917            let a: Vec<_> = (0..16)
918                .map(|_| b.add_atom(Atom::aromatic(Element::C)))
919                .collect();
920            // Ring 0-1-2-3-4-5
921            for i in 0..6 {
922                b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
923                    .unwrap();
924            }
925            // Ring 5-4-9-8-7-6
926            for (x, y) in [(4, 9), (9, 8), (8, 7), (7, 6), (6, 5)] {
927                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
928            }
929            // Ring 1-2-11-10-13-12
930            for (x, y) in [(2, 11), (11, 10), (10, 13), (13, 12), (12, 1)] {
931                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
932            }
933            // Ring 6-7-15-14-11-2 (closed)
934            for (x, y) in [(7, 15), (15, 14), (14, 11)] {
935                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
936            }
937            b.build()
938        };
939        let result = kekulize(&mol).expect("4-ring PAH kekulization should succeed");
940        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
941        assert!(
942            doubles >= 6,
943            "4-ring PAH needs at least 6 double bonds, got {doubles}"
944        );
945    }
946
947    #[test]
948    fn test_kekulize_deterministic() {
949        // kekulize() is deterministic: rebuilding the same molecule gives the same count.
950        let mol1 = biphenylene();
951        let mol2 = biphenylene();
952        let r1 = kekulize(&mol1).expect("pass1");
953        let r2 = kekulize(&mol2).expect("pass2");
954        assert_eq!(
955            r1.values().filter(|&&o| o == BondOrder::Double).count(),
956            r2.values().filter(|&&o| o == BondOrder::Double).count(),
957            "kekulization must be deterministic"
958        );
959    }
960
961    /// Fluoranthene: 16 aromatic C in a fused 5+6+6+6 system.
962    #[test]
963    fn test_kekulize_fluoranthene() {
964        // Simplified: 3 fused 6-rings + 1 fused 5-ring sharing atoms
965        // Build as corannulene-like: two naphthalene units bridged by a 5-ring
966        // Atoms 0-15 (16 aromatic C), total bonds = 19 (for fluoranthene)
967        // Use a simplified topology that gives the right ring count
968        // 6-ring A: 0-1-2-3-4-5
969        // 6-ring B: 0-5-6-7-8-9
970        // 6-ring C: 2-3-10-11-12-13
971        // 5-ring D: 0-9-14-15-1 (bridge)
972        let mut b = MoleculeBuilder::new();
973        let a: Vec<_> = (0..16)
974            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
975            .collect();
976        // Ring A (6-ring): 0-1-2-3-4-5-0
977        for i in 0..6 {
978            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic)
979                .unwrap();
980        }
981        // Ring B (6-ring): 0-5-6-7-8-9-0
982        for (x, y) in [(5, 6), (6, 7), (7, 8), (8, 9), (9, 0)] {
983            if mol_has_no_bond_yet(&b, a[x], a[y]) {
984                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
985            }
986        }
987        // Ring C (6-ring): 1-2-10-11-12-13-1
988        for (x, y) in [(2, 10), (10, 11), (11, 12), (12, 13), (13, 1)] {
989            if mol_has_no_bond_yet(&b, a[x], a[y]) {
990                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
991            }
992        }
993        // Ring D (5-ring): 9-8-14-15-13-9
994        for (x, y) in [(8, 14), (14, 15), (15, 13), (13, 9)] {
995            if mol_has_no_bond_yet(&b, a[x], a[y]) {
996                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
997            }
998        }
999        let mol = b.build();
1000        let result = kekulize(&mol).expect("fluoranthene-like kekulization failed");
1001        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
1002        assert_eq!(
1003            doubles, 8,
1004            "fluoranthene-like structure needs 8 double bonds"
1005        );
1006    }
1007
1008    /// Indolizine (`c1ccn2cccc2c1`) — bridgehead N at C9a (aromatic degree 3).
1009    /// 9 atoms: N contributes lone pair, 8 C atoms form 4 double bonds.
1010    /// Requires Pass 3 (bridgehead-N exclusion).
1011    #[test]
1012    fn kekulize_indolizine() {
1013        // Bonds: 6-ring (0-1-2-3-7-8-0) ∪ 5-ring (3-4-5-6-7-3), fused at edge 3-7.
1014        let mut b = MoleculeBuilder::new();
1015        let c: Vec<_> = (0..9)
1016            .map(|i| {
1017                if i == 3 {
1018                    b.add_atom(Atom::aromatic(Element::N))
1019                } else {
1020                    b.add_atom(Atom::aromatic(Element::C))
1021                }
1022            })
1023            .collect();
1024        for (x, y) in [
1025            (0, 1),
1026            (1, 2),
1027            (2, 3),
1028            (3, 4),
1029            (4, 5),
1030            (5, 6),
1031            (6, 7),
1032            (7, 3),
1033            (7, 8),
1034            (8, 0),
1035        ] {
1036            b.add_bond(c[x], c[y], BondOrder::Aromatic).unwrap();
1037        }
1038        let mol = b.build();
1039        let result = kekulize(&mol);
1040        assert!(
1041            result.is_ok(),
1042            "indolizine kekulization failed: {:?}",
1043            result.err()
1044        );
1045        let doubles = result
1046            .unwrap()
1047            .values()
1048            .filter(|&&o| o == BondOrder::Double)
1049            .count();
1050        assert_eq!(doubles, 4, "indolizine: 4 double bonds (N lone-pair donor)");
1051    }
1052
1053    /// Quinolizine (`c1ccn2ccccc2c1`) — bridgehead N in two 6-membered rings.
1054    /// 10 atoms (even), bipartite graph → passes 1/2 handle it; Pass 3 must not break it.
1055    #[test]
1056    fn kekulize_quinolizine() {
1057        // 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.
1058        let mut b = MoleculeBuilder::new();
1059        let c: Vec<_> = (0..10)
1060            .map(|i| {
1061                if i == 3 {
1062                    b.add_atom(Atom::aromatic(Element::N))
1063                } else {
1064                    b.add_atom(Atom::aromatic(Element::C))
1065                }
1066            })
1067            .collect();
1068        for (x, y) in [
1069            (0, 1),
1070            (1, 2),
1071            (2, 3),
1072            (3, 4),
1073            (4, 5),
1074            (5, 6),
1075            (6, 7),
1076            (7, 8),
1077            (8, 3),
1078            (8, 9),
1079            (9, 0),
1080        ] {
1081            b.add_bond(c[x], c[y], BondOrder::Aromatic).unwrap();
1082        }
1083        let mol = b.build();
1084        let result = kekulize(&mol);
1085        assert!(
1086            result.is_ok(),
1087            "quinolizine kekulization failed: {:?}",
1088            result.err()
1089        );
1090        let doubles = result
1091            .unwrap()
1092            .values()
1093            .filter(|&&o| o == BondOrder::Double)
1094            .count();
1095        assert_eq!(doubles, 5, "quinolizine: 5 double bonds");
1096    }
1097
1098    /// Corannulene (C₂₀H₁₀) — bowl-shaped PAH with five 5-membered rings fused to
1099    /// five 6-membered rings.  The C-only aromatic subgraph is non-bipartite (five odd
1100    /// cycles), so passes 1–3 fail; requires Pass 4 (Edmonds' blossom).
1101    #[test]
1102    fn kekulize_corannulene() {
1103        // Inner 5-ring hub: 0-1-2-3-4-0
1104        // Spokes to outer ring: 0-5, 1-7, 2-9, 3-11, 4-13
1105        // Outer 10-ring: 5-6-7-8-9-10-11-12-13-14-5
1106        // Outer 5 "cap" pairs: (5,15),(6,15),(7,16),(8,16),(9,17),(10,17),(11,18),(12,18),(13,19),(14,19)
1107        // 20 vertices, 25 edges, 10 double bonds expected.
1108        let mut b = MoleculeBuilder::new();
1109        let a: Vec<_> = (0..20)
1110            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
1111            .collect();
1112        let edges: &[(usize, usize)] = &[
1113            // inner 5-ring
1114            (0, 1),
1115            (1, 2),
1116            (2, 3),
1117            (3, 4),
1118            (4, 0),
1119            // spokes
1120            (0, 5),
1121            (1, 7),
1122            (2, 9),
1123            (3, 11),
1124            (4, 13),
1125            // outer 10-ring
1126            (5, 6),
1127            (6, 7),
1128            (7, 8),
1129            (8, 9),
1130            (9, 10),
1131            (10, 11),
1132            (11, 12),
1133            (12, 13),
1134            (13, 14),
1135            (14, 5),
1136            // outer "cap" bonds
1137            (5, 15),
1138            (6, 15),
1139            (7, 16),
1140            (8, 16),
1141            (9, 17),
1142            (10, 17),
1143            (11, 18),
1144            (12, 18),
1145            (13, 19),
1146            (14, 19),
1147        ];
1148        for &(x, y) in edges {
1149            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
1150        }
1151        let mol = b.build();
1152        let result = kekulize(&mol);
1153        assert!(
1154            result.is_ok(),
1155            "corannulene kekulization failed: {:?}",
1156            result.err()
1157        );
1158        let doubles = result
1159            .unwrap()
1160            .values()
1161            .filter(|&&o| o == BondOrder::Double)
1162            .count();
1163        assert_eq!(doubles, 10, "corannulene: 10 double bonds");
1164    }
1165
1166    /// 1-borazarobenzene (`b1ccccn1`) — aromatic B has an empty p orbital
1167    /// (electron acceptor), not a lone pair. B must be in a double bond.
1168    /// This was the last remaining kekulization failure in the 5000-molecule corpus.
1169    #[test]
1170    fn kekulize_boron_azine() {
1171        // 6-ring: B(0)-C(1)-C(2)-C(3)-C(4)-N(5)-B(0)
1172        // Valid Kekulé: B=C, C=C, C=N  (3 double bonds)
1173        let mut b = MoleculeBuilder::new();
1174        let atoms: Vec<_> = (0..6)
1175            .map(|i| {
1176                if i == 0 {
1177                    b.add_atom(Atom::aromatic(Element::B))
1178                } else if i == 5 {
1179                    b.add_atom(Atom::aromatic(Element::N))
1180                } else {
1181                    b.add_atom(Atom::aromatic(Element::C))
1182                }
1183            })
1184            .collect();
1185        for i in 0..6 {
1186            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
1187                .unwrap();
1188        }
1189        let mol = b.build();
1190        let result = kekulize(&mol);
1191        assert!(
1192            result.is_ok(),
1193            "b1ccccn1 kekulization failed: {:?}",
1194            result.err()
1195        );
1196        let doubles = result
1197            .unwrap()
1198            .values()
1199            .filter(|&&o| o == BondOrder::Double)
1200            .count();
1201        assert_eq!(doubles, 3, "b1ccccn1: 3 double bonds");
1202    }
1203}