Skip to main content

chematic_core/
kekulization.rs

1//! Kekulization: assign alternating single/double bonds to aromatic systems.
2//!
3//! Algorithm:
4//! 1. Collect all aromatic atoms and aromatic bonds.
5//! 2. Build an adjacency structure restricted to aromatic bonds.
6//! 3. Find a maximum matching on the aromatic subgraph using
7//!    augmenting paths (DFS-based, O(V*E)).
8//! 4. Matched edges → Double bond; unmatched edges → Single bond.
9//! 5. Return the updated bond order map, or an error if no valid
10//!    Kekulé form exists (i.e. some atom that *must* be doubled is unmatched).
11//!
12//! Scope: handles all standard aromatic systems (benzene, naphthalene,
13//! pyridine, pyrrole, furan, thiophene, indole, imidazole, …).
14//! Edmond's blossom algorithm for exotic odd-member cycles is a future todo.
15
16use std::collections::{HashMap, HashSet};
17
18use crate::bond::BondOrder;
19use crate::molecule::{AtomIdx, BondIdx, Molecule};
20
21/// Error returned when no valid Kekulé form can be found.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct KekuleError {
24    pub detail: String,
25}
26
27impl core::fmt::Display for KekuleError {
28    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29        write!(f, "kekulization failed: {}", self.detail)
30    }
31}
32
33impl std::error::Error for KekuleError {}
34
35/// Result of kekulization: a map from BondIdx to the new BondOrder.
36///
37/// Bonds NOT in this map are unchanged (non-aromatic bonds).
38pub type KekuleResult = HashMap<BondIdx, BondOrder>;
39
40/// Kekulize a molecule that contains aromatic bonds.
41///
42/// Returns a map of aromatic bond indices to their new (Single or Double) orders.
43/// All non-aromatic bonds are unchanged and not included in the result.
44///
45/// If the molecule has no aromatic bonds the result is empty (success, no-op).
46pub fn kekulize(mol: &Molecule) -> Result<KekuleResult, KekuleError> {
47    // Collect aromatic bonds and the atoms they touch.
48    let mut aromatic_bonds: Vec<BondIdx> = Vec::new();
49    let mut aromatic_atoms: HashSet<AtomIdx> = HashSet::new();
50
51    for (bidx, bond) in mol.bonds() {
52        if bond.order == BondOrder::Aromatic {
53            aromatic_bonds.push(bidx);
54            aromatic_atoms.insert(bond.atom1);
55            aromatic_atoms.insert(bond.atom2);
56        }
57    }
58
59    if aromatic_bonds.is_empty() {
60        return Ok(HashMap::new());
61    }
62
63    // Determine which aromatic atoms *must* be in a double bond.
64    // An aromatic atom must be double-bonded if it has no lone pair to donate.
65    // Heuristic: if the atom has no explicit/implicit H and is carbon or nitrogen-imine,
66    // it must be matched.
67    // For simplicity: atoms that must_match = those with no spare lone pair =
68    //   carbon (C), nitrogen-imine (N in pyridine — has no H when in 6-membered ring).
69    // Atoms that can be unmatched (lone-pair donors): O, S, N with H (pyrrole N).
70    //
71    // Practical rule used here: an atom can be *unmatched* iff it has an explicit
72    // H count > 0 OR it is O or S (lone-pair donors).
73    // All others must appear in the matching.
74    let must_match: HashSet<AtomIdx> = aromatic_atoms
75        .iter()
76        .copied()
77        .filter(|&idx| atom_must_be_matched(mol, idx))
78        .collect();
79
80    // Build adjacency list restricted to aromatic bonds BETWEEN must-match atoms.
81    //
82    // Lone-pair donors (O, S, [nH]) contribute their pi electrons via the lone pair,
83    // NOT via a double bond.  Including them in the matching adjacency causes the
84    // augmenting-path algorithm to assign double bonds to e.g. [nH]=C in pyrrole or
85    // indole, which is chemically wrong and produces incorrect implicit-H counts.
86    //
87    // Only bonds where BOTH endpoints are in must_match are valid double-bond
88    // candidates.  Lone-pair donors remain in aromatic_atoms (so their aromatic bonds
89    // become Single in the result) but are excluded from the matching graph.
90    let mut adj: HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>> = HashMap::new();
91    for &bidx in &aromatic_bonds {
92        let bond = mol.bond(bidx);
93        if must_match.contains(&bond.atom1) && must_match.contains(&bond.atom2) {
94            adj.entry(bond.atom1).or_default().push((bond.atom2, bidx));
95            adj.entry(bond.atom2).or_default().push((bond.atom1, bidx));
96        }
97    }
98
99    // Run maximum matching via augmenting paths.
100    let mut matching: HashMap<AtomIdx, AtomIdx> = HashMap::new(); // atom -> matched_partner
101
102    // Process must-match atoms in a deterministic order (by index) for reproducibility.
103    // Non-must-match atoms (lone-pair donors) are skipped — they never initiate
104    // augmenting paths and are never placed in the matching.
105    let mut sorted_atoms: Vec<AtomIdx> = must_match.iter().copied().collect();
106    sorted_atoms.sort();
107
108    // Pass 1: ascending order (primary).
109    run_matching_pass(&sorted_atoms, &adj, &mut matching);
110
111    // Pass 2 (fallback): descending order — avoids order-dependent dead-ends.
112    //
113    // A greedy ascending pass can get stuck on certain ring topologies: the first
114    // matched edge blocks an augmenting path that a different starting order would
115    // find.  Reversing the order is O(V·E) overhead but resolves many such cases
116    // without requiring the full Edmonds blossom algorithm.
117    if must_match.iter().any(|&idx| !matching.contains_key(&idx)) {
118        matching.clear();
119        let mut rev = sorted_atoms.clone();
120        rev.reverse();
121        run_matching_pass(&rev, &adj, &mut matching);
122    }
123
124    // Verify that all must_match atoms are matched.
125    for &idx in &must_match {
126        if !matching.contains_key(&idx) {
127            return Err(KekuleError {
128                detail: format!(
129                    "atom {} ({}) cannot be assigned a double bond",
130                    idx.0,
131                    mol.atom(idx).element.symbol()
132                ),
133            });
134        }
135    }
136
137    // Build the result map: matched aromatic bonds → Double, rest → Single.
138    let mut double_bonds: HashSet<BondIdx> = HashSet::new();
139    for (&atom, &partner) in &matching {
140        if atom >= partner {
141            continue; // each pair shows up twice in `matching`; visit one orientation
142        }
143        if let Some((bidx, _)) = mol.bond_between(atom, partner)
144            && mol.bond(bidx).order == BondOrder::Aromatic
145        {
146            double_bonds.insert(bidx);
147        }
148    }
149
150    let result: KekuleResult = aromatic_bonds
151        .iter()
152        .map(|&bidx| {
153            let order = if double_bonds.contains(&bidx) {
154                BondOrder::Double
155            } else {
156                BondOrder::Single
157            };
158            (bidx, order)
159        })
160        .collect();
161    Ok(result)
162}
163
164/// Apply a Kekulé result to a molecule, returning a new Molecule with updated bond orders.
165///
166/// Aromatic flags on atoms are *not* cleared (the molecule retains the aromaticity
167/// annotation; only bond orders change).
168pub fn apply_kekule(mol: &Molecule, kekule: &KekuleResult) -> Molecule {
169    use crate::molecule::MoleculeBuilder;
170
171    let mut builder = MoleculeBuilder::new();
172
173    // Re-add all atoms.
174    for (_, atom) in mol.atoms() {
175        builder.add_atom(atom.clone());
176    }
177
178    // Re-add bonds, substituting updated orders for aromatic bonds.
179    for (bidx, bond) in mol.bonds() {
180        let order = kekule.get(&bidx).copied().unwrap_or(bond.order);
181        builder
182            .add_bond(bond.atom1, bond.atom2, order)
183            .expect("duplicate bond during apply_kekule");
184    }
185
186    builder.build()
187}
188
189/// Attempt to find an augmenting path starting from `start` and update `matching`.
190///
191/// Uses iterative BFS with parent-pointer path reconstruction to avoid stack
192/// overflow on large aromatic systems (wasm32 default stack is ~1 MB).
193/// `visited` must already contain `start` (prevents root re-entry in odd cycles).
194///
195/// Returns true if an augmenting path was found and the matching was updated.
196fn augment(
197    start: AtomIdx,
198    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
199    matching: &mut HashMap<AtomIdx, AtomIdx>,
200    visited: &mut HashSet<AtomIdx>,
201) -> bool {
202    // parent[u] = v means "u was reached from v in the BFS tree"
203    let mut parent: HashMap<AtomIdx, AtomIdx> = HashMap::new();
204    let mut queue: std::collections::VecDeque<AtomIdx> = std::collections::VecDeque::new();
205    queue.push_back(start);
206
207    'bfs: while let Some(v) = queue.pop_front() {
208        let Some(neighbors) = adj.get(&v) else {
209            continue;
210        };
211        for &(u, _) in neighbors {
212            if !visited.insert(u) {
213                continue;
214            }
215            parent.insert(u, v);
216
217            match matching.get(&u).copied() {
218                None => {
219                    // Found a free vertex — trace back through parent pointers
220                    // and flip every edge along the augmenting path.
221                    let mut cur = u;
222                    loop {
223                        let prev = parent[&cur];
224                        let prev_old_match = matching.get(&prev).copied();
225                        matching.insert(prev, cur);
226                        matching.insert(cur, prev);
227                        match prev_old_match {
228                            None | Some(_) if prev == start => break,
229                            Some(m) => cur = m,
230                            None => break,
231                        }
232                    }
233                    break 'bfs;
234                }
235                Some(partner) => {
236                    // u is matched to partner — explore further from partner.
237                    if visited.insert(partner) {
238                        parent.insert(partner, u);
239                        queue.push_back(partner);
240                    }
241                }
242            }
243        }
244    }
245
246    // Augmentation succeeded iff start is now matched.
247    matching.contains_key(&start)
248}
249
250/// Run a single augmenting-path pass over `atoms` and update `matching`.
251///
252/// For each unmatched atom in `atoms`, attempts to find an augmenting path using
253/// the BFS-based `augment()` function. Extracted so that `kekulize()` can try
254/// multiple orderings (ascending → descending) without code duplication.
255fn run_matching_pass(
256    atoms: &[AtomIdx],
257    adj: &HashMap<AtomIdx, Vec<(AtomIdx, BondIdx)>>,
258    matching: &mut HashMap<AtomIdx, AtomIdx>,
259) {
260    for &start in atoms {
261        if matching.contains_key(&start) {
262            continue;
263        }
264        let mut visited: HashSet<AtomIdx> = HashSet::new();
265        visited.insert(start);
266        augment(start, adj, matching, &mut visited);
267    }
268}
269
270/// Determine whether an aromatic atom *must* appear in the matching
271/// (i.e. requires a double bond for a valid Kekulé form).
272///
273/// An atom can be unmatched if it contributes a lone pair to the aromatic system:
274/// - O (furan-type oxygen)
275/// - S (thiophene-type sulfur)
276/// - N with an H (pyrrole-type nitrogen: [nH])
277/// - Se, As aromatic analogs
278/// - Any aromatic atom that already has an exocyclic double bond (e.g. the
279///   carbonyl carbon in coumarin/warfarin `c=O` fused into an aromatic ring).
280///   Such an atom's pi contribution comes from conjugation with the exocyclic
281///   bond; no additional ring double bond is needed or possible.
282///
283/// Everything else (C, N without H like pyridine) must be matched.
284fn atom_must_be_matched(mol: &Molecule, idx: AtomIdx) -> bool {
285    let atom = mol.atom(idx);
286    match atom.element.atomic_number() {
287        // O, S, Se always donate a lone pair → don't need a double bond.
288        8 | 16 | 34 => false,
289        // Boron aromatic (rare) — can donate lone pair.
290        5 => false,
291        // N with explicit H ([nH]) is a lone-pair donor; bare aromatic N (pyridine) must match.
292        7 => !matches!(atom.hydrogen_count, Some(h) if h > 0),
293        // Carbon and everything else: must be in the matching.
294        _ => true,
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::atom::Atom;
302    use crate::element::Element;
303    use crate::molecule::MoleculeBuilder;
304
305    /// Build benzene as a fully aromatic SMILES-style molecule.
306    fn benzene() -> Molecule {
307        let mut b = MoleculeBuilder::new();
308        let atoms: Vec<_> = (0..6)
309            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
310            .collect();
311        for i in 0..6 {
312            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
313                .unwrap();
314        }
315        b.build()
316    }
317
318    /// Build pyridine (5 aromatic C + 1 aromatic N, ring).
319    fn pyridine() -> Molecule {
320        let mut b = MoleculeBuilder::new();
321        let c1 = b.add_atom(Atom::aromatic(Element::C));
322        let c2 = b.add_atom(Atom::aromatic(Element::C));
323        let c3 = b.add_atom(Atom::aromatic(Element::C));
324        let n = b.add_atom(Atom::aromatic(Element::N));
325        let c4 = b.add_atom(Atom::aromatic(Element::C));
326        let c5 = b.add_atom(Atom::aromatic(Element::C));
327        let atoms = [c1, c2, c3, n, c4, c5];
328        for i in 0..6 {
329            b.add_bond(atoms[i], atoms[(i + 1) % 6], BondOrder::Aromatic)
330                .unwrap();
331        }
332        b.build()
333    }
334
335    /// Build furan (4 aromatic C + 1 aromatic O, ring).
336    fn furan() -> Molecule {
337        let mut b = MoleculeBuilder::new();
338        let o = b.add_atom(Atom::aromatic(Element::O));
339        let c1 = b.add_atom(Atom::aromatic(Element::C));
340        let c2 = b.add_atom(Atom::aromatic(Element::C));
341        let c3 = b.add_atom(Atom::aromatic(Element::C));
342        let c4 = b.add_atom(Atom::aromatic(Element::C));
343        let atoms = [o, c1, c2, c3, c4];
344        for i in 0..5 {
345            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
346                .unwrap();
347        }
348        b.build()
349    }
350
351    /// Build pyrrole ([nH] + 4 aromatic C, ring).
352    fn pyrrole() -> Molecule {
353        let mut b = MoleculeBuilder::new();
354        // N with 1 H — bracket-style (hydrogen_count = Some(1))
355        let mut n_atom = Atom::aromatic(Element::N);
356        n_atom.hydrogen_count = Some(1);
357        let n = b.add_atom(n_atom);
358        let c1 = b.add_atom(Atom::aromatic(Element::C));
359        let c2 = b.add_atom(Atom::aromatic(Element::C));
360        let c3 = b.add_atom(Atom::aromatic(Element::C));
361        let c4 = b.add_atom(Atom::aromatic(Element::C));
362        let atoms = [n, c1, c2, c3, c4];
363        for i in 0..5 {
364            b.add_bond(atoms[i], atoms[(i + 1) % 5], BondOrder::Aromatic)
365                .unwrap();
366        }
367        b.build()
368    }
369
370    #[test]
371    fn test_kekulize_benzene() {
372        let mol = benzene();
373        let result = kekulize(&mol).expect("benzene kekulization failed");
374        assert_eq!(result.len(), 6); // all 6 aromatic bonds assigned
375
376        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
377        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
378        assert_eq!(doubles, 3, "benzene must have 3 double bonds");
379        assert_eq!(singles, 3, "benzene must have 3 single bonds");
380    }
381
382    #[test]
383    fn test_kekulize_pyridine() {
384        let mol = pyridine();
385        let result = kekulize(&mol).expect("pyridine kekulization failed");
386        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
387        assert_eq!(doubles, 3, "pyridine must have 3 double bonds");
388    }
389
390    #[test]
391    fn test_kekulize_furan() {
392        let mol = furan();
393        let result = kekulize(&mol).expect("furan kekulization failed");
394        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
395        assert_eq!(doubles, 2, "furan must have 2 double bonds");
396    }
397
398    #[test]
399    fn test_kekulize_pyrrole() {
400        let mol = pyrrole();
401        let result = kekulize(&mol).expect("pyrrole kekulization failed");
402        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
403        assert_eq!(doubles, 2, "pyrrole must have 2 double bonds");
404    }
405
406    #[test]
407    fn test_kekulize_naphthalene() {
408        // 10 aromatic C, 11 aromatic bonds (fused bicyclic)
409        let mut b = MoleculeBuilder::new();
410        let atoms: Vec<_> = (0..10)
411            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
412            .collect();
413        // Ring 1: 0-1-2-3-4-9
414        let ring1 = [0, 1, 2, 3, 4, 9];
415        for i in 0..ring1.len() {
416            b.add_bond(
417                atoms[ring1[i]],
418                atoms[ring1[(i + 1) % ring1.len()]],
419                BondOrder::Aromatic,
420            )
421            .unwrap();
422        }
423        // Ring 2: 4-5-6-7-8-9 (shares bond 4-9)
424        let ring2 = [4, 5, 6, 7, 8, 9];
425        for i in 0..ring2.len() {
426            let a = atoms[ring2[i]];
427            let bb = atoms[ring2[(i + 1) % ring2.len()]];
428            // Skip already-added bond (4-9)
429            if mol_has_no_bond_yet(&b, a, bb) {
430                b.add_bond(a, bb, BondOrder::Aromatic).unwrap();
431            }
432        }
433        let mol = b.build();
434        let result = kekulize(&mol).expect("naphthalene kekulization failed");
435        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
436        assert_eq!(doubles, 5, "naphthalene must have 5 double bonds");
437    }
438
439    #[test]
440    fn test_apply_kekule() {
441        let mol = benzene();
442        let kekule = kekulize(&mol).unwrap();
443        let kekule_mol = apply_kekule(&mol, &kekule);
444
445        // After applying, no aromatic bonds should remain.
446        for (_, bond) in kekule_mol.bonds() {
447            assert_ne!(
448                bond.order,
449                BondOrder::Aromatic,
450                "apply_kekule should remove all aromatic bonds"
451            );
452        }
453    }
454
455    #[test]
456    fn test_no_aromatic_bonds_noop() {
457        // A molecule with no aromatic bonds should return empty result.
458        let mut b = MoleculeBuilder::new();
459        let c1 = b.add_atom(Atom::new(Element::C));
460        let c2 = b.add_atom(Atom::new(Element::C));
461        b.add_bond(c1, c2, BondOrder::Single).unwrap();
462        let mol = b.build();
463        let result = kekulize(&mol).unwrap();
464        assert!(result.is_empty());
465    }
466
467    // Helper: check that the builder does not yet have a bond between a and b.
468    fn mol_has_no_bond_yet(b: &MoleculeBuilder, a: AtomIdx, bb: AtomIdx) -> bool {
469        for (_, partner) in b.atom_neighbors(a) {
470            if partner == bb {
471                return false;
472            }
473        }
474        true
475    }
476
477    // B6 coverage: exotic ring systems that require correct matching on non-bipartite graphs.
478
479    /// Azulene: fused 5+7 bicyclic, 10 aromatic C, 11 bonds.
480    /// Valid Kekulé form exists → must yield 5 double bonds.
481    #[test]
482    fn test_kekulize_azulene() {
483        let mut b = MoleculeBuilder::new();
484        let a: Vec<_> = (0..10)
485            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
486            .collect();
487        // 5-ring: 0-1-2-3-4-0
488        for i in 0..5 {
489            b.add_bond(a[i], a[(i + 1) % 5], BondOrder::Aromatic).unwrap();
490        }
491        // 7-ring extras (shares bond 0-4): 4-5-6-7-8-9-0
492        for (x, y) in [(4usize, 5usize), (5, 6), (6, 7), (7, 8), (8, 9), (9, 0)] {
493            if mol_has_no_bond_yet(&b, a[x], a[y]) {
494                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
495            }
496        }
497        let mol = b.build();
498        let result = kekulize(&mol).expect("azulene kekulization failed");
499        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
500        assert_eq!(doubles, 5, "azulene needs 5 double bonds");
501    }
502
503    /// Acenaphthylene: naphthalene + fused cyclopentadiene bridge, 12 aromatic C.
504    #[test]
505    fn test_kekulize_acenaphthylene() {
506        // Connectivity: naphthalene core (atoms 0-9) + bridge atoms 10,11
507        // Naphthalene: ring1=0-1-2-3-4-9, ring2=4-5-6-7-8-9
508        // Bridge: 0-11-10-1 (the 5-ring across the 1,8 positions of naphthalene)
509        let mut b = MoleculeBuilder::new();
510        let a: Vec<_> = (0..12)
511            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
512            .collect();
513        // Naphthalene ring 1: 0-1-2-3-4-9-0
514        for (x, y) in [(0,1),(1,2),(2,3),(3,4),(4,9),(9,0)] {
515            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
516        }
517        // Naphthalene ring 2: 4-5-6-7-8-9 (4-9 shared)
518        for (x, y) in [(4,5),(5,6),(6,7),(7,8),(8,9)] {
519            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
520        }
521        // Bridge: 0-11-10-1
522        for (x, y) in [(0,11),(11,10),(10,1)] {
523            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
524        }
525        let mol = b.build();
526        let result = kekulize(&mol).expect("acenaphthylene kekulization failed");
527        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
528        assert_eq!(doubles, 6, "acenaphthylene needs 6 double bonds");
529    }
530
531    // ---- Edge-case tests added v0.1.100 ----
532    //
533    // Build complex PAH manually to test edge cases in the matching algorithm.
534
535    /// Biphenylene: two benzene rings connected by a cyclobutadiene bridge.
536    ///
537    /// Topology: Ring A (0–5), Ring B (6–11), bridge bonds (0–11) and (5–6).
538    /// The 4-membered ring 0-5-6-11-0 is the challenging part.
539    fn biphenylene() -> Molecule {
540        let mut b = MoleculeBuilder::new();
541        let a: Vec<_> = (0..12)
542            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
543            .collect();
544        // Ring A (6): 0-1-2-3-4-5-0
545        for i in 0..6 {
546            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic).unwrap();
547        }
548        // Ring B (6): 6-7-8-9-10-11-6
549        for i in 0..6 {
550            b.add_bond(a[6 + i], a[6 + (i + 1) % 6], BondOrder::Aromatic).unwrap();
551        }
552        // 4-membered bridge: closes ring 0-5-6-11-0
553        b.add_bond(a[5], a[6], BondOrder::Aromatic).unwrap();
554        b.add_bond(a[0], a[11], BondOrder::Aromatic).unwrap();
555        b.build()
556    }
557
558    /// Naphtho-4-ring: three fused 6-membered rings sharing a common bond sequence.
559    /// Linear anthracene topology (0-1-2-3-4-5, 5-6-7-8-9-4, 6-10-11-12-13-7).
560    fn anthracene() -> Molecule {
561        let mut b = MoleculeBuilder::new();
562        let a: Vec<_> = (0..14)
563            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
564            .collect();
565        // Ring A: 0-1-2-3-4-5-0
566        for i in 0..6 {
567            b.add_bond(a[i], a[(i + 1) % 6], BondOrder::Aromatic).unwrap();
568        }
569        // Ring B: 5-4-9-8-7-6-5 (shares bond 4-5 with Ring A)
570        for (x, y) in [(4,9),(9,8),(8,7),(7,6),(6,5)] {
571            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
572        }
573        // Ring C: 6-7-13-12-11-10-6 (shares bond 6-7 with Ring B)
574        for (x, y) in [(7,13),(13,12),(12,11),(11,10),(10,6)] {
575            b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
576        }
577        b.build()
578    }
579
580    #[test]
581    fn test_kekulize_biphenylene() {
582        // Biphenylene: 4-membered cyclobutadiene bridge between two benzenes.
583        // 12 aromatic C → 6 double bonds.
584        let mol = biphenylene();
585        let result = kekulize(&mol).expect("biphenylene kekulization should succeed");
586        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
587        assert_eq!(doubles, 6, "biphenylene needs 6 double bonds");
588    }
589
590    #[test]
591    fn test_kekulize_anthracene() {
592        // Anthracene (C14H10): 3 linearly fused 6-membered rings.  7 double bonds.
593        let mol = anthracene();
594        let result = kekulize(&mol).expect("anthracene kekulization should succeed");
595        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
596        assert_eq!(doubles, 7, "anthracene needs 7 double bonds");
597    }
598
599    #[test]
600    fn test_kekulize_biphenylene_double_bond_count() {
601        // Cross-check: all 14 bonds should be assigned single or double.
602        let mol = biphenylene();
603        let result = kekulize(&mol).expect("biphenylene kekulization");
604        let singles = result.values().filter(|&&o| o == BondOrder::Single).count();
605        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
606        assert_eq!(singles + doubles, 14, "biphenylene has 14 aromatic bonds");
607    }
608
609    #[test]
610    fn test_kekulize_large_fused_6rings() {
611        // 4 fused 6-membered rings (pyrene-like) in a simple linear topology.
612        // Simulates a large all-even-ring PAH that the BFS should handle easily.
613        let mol = {
614            let mut b = MoleculeBuilder::new();
615            let a: Vec<_> = (0..16)
616                .map(|_| b.add_atom(Atom::aromatic(Element::C)))
617                .collect();
618            // Ring 0-1-2-3-4-5
619            for i in 0..6 { b.add_bond(a[i], a[(i+1)%6], BondOrder::Aromatic).unwrap(); }
620            // Ring 5-4-9-8-7-6
621            for (x,y) in [(4,9),(9,8),(8,7),(7,6),(6,5)] {
622                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
623            }
624            // Ring 1-2-11-10-13-12
625            for (x,y) in [(2,11),(11,10),(10,13),(13,12),(12,1)] {
626                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
627            }
628            // Ring 6-7-15-14-11-2 (closed)
629            for (x,y) in [(7,15),(15,14),(14,11)] {
630                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
631            }
632            b.build()
633        };
634        let result = kekulize(&mol).expect("4-ring PAH kekulization should succeed");
635        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
636        assert!(doubles >= 6, "4-ring PAH needs at least 6 double bonds, got {doubles}");
637    }
638
639    #[test]
640    fn test_kekulize_deterministic() {
641        // kekulize() is deterministic: rebuilding the same molecule gives the same count.
642        let mol1 = biphenylene();
643        let mol2 = biphenylene();
644        let r1 = kekulize(&mol1).expect("pass1");
645        let r2 = kekulize(&mol2).expect("pass2");
646        assert_eq!(
647            r1.values().filter(|&&o| o == BondOrder::Double).count(),
648            r2.values().filter(|&&o| o == BondOrder::Double).count(),
649            "kekulization must be deterministic"
650        );
651    }
652
653    /// Fluoranthene: 16 aromatic C in a fused 5+6+6+6 system.
654    #[test]
655    fn test_kekulize_fluoranthene() {
656        // Simplified: 3 fused 6-rings + 1 fused 5-ring sharing atoms
657        // Build as corannulene-like: two naphthalene units bridged by a 5-ring
658        // Atoms 0-15 (16 aromatic C), total bonds = 19 (for fluoranthene)
659        // Use a simplified topology that gives the right ring count
660        // 6-ring A: 0-1-2-3-4-5
661        // 6-ring B: 0-5-6-7-8-9
662        // 6-ring C: 2-3-10-11-12-13
663        // 5-ring D: 0-9-14-15-1 (bridge)
664        let mut b = MoleculeBuilder::new();
665        let a: Vec<_> = (0..16)
666            .map(|_| b.add_atom(Atom::aromatic(Element::C)))
667            .collect();
668        // Ring A (6-ring): 0-1-2-3-4-5-0
669        for i in 0..6 { b.add_bond(a[i], a[(i+1)%6], BondOrder::Aromatic).unwrap(); }
670        // Ring B (6-ring): 0-5-6-7-8-9-0
671        for (x,y) in [(5,6),(6,7),(7,8),(8,9),(9,0)] {
672            if mol_has_no_bond_yet(&b, a[x], a[y]) {
673                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
674            }
675        }
676        // Ring C (6-ring): 1-2-10-11-12-13-1
677        for (x,y) in [(2,10),(10,11),(11,12),(12,13),(13,1)] {
678            if mol_has_no_bond_yet(&b, a[x], a[y]) {
679                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
680            }
681        }
682        // Ring D (5-ring): 9-8-14-15-13-9
683        for (x,y) in [(8,14),(14,15),(15,13),(13,9)] {
684            if mol_has_no_bond_yet(&b, a[x], a[y]) {
685                b.add_bond(a[x], a[y], BondOrder::Aromatic).unwrap();
686            }
687        }
688        let mol = b.build();
689        let result = kekulize(&mol).expect("fluoranthene-like kekulization failed");
690        let doubles = result.values().filter(|&&o| o == BondOrder::Double).count();
691        assert_eq!(doubles, 8, "fluoranthene-like structure needs 8 double bonds");
692    }
693}