#![forbid(unsafe_code)]
use std::collections::HashSet;
use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule, MoleculeBuilder, implicit_hcount};
#[derive(Clone, Copy, PartialEq, Eq)]
enum BondOrderMatch {
Single,
Double,
#[allow(dead_code)]
Any,
}
impl BondOrderMatch {
fn matches(self, order: BondOrder) -> bool {
match self {
BondOrderMatch::Single => {
matches!(order, BondOrder::Single | BondOrder::Up | BondOrder::Down)
}
BondOrderMatch::Double => matches!(order, BondOrder::Double),
BondOrderMatch::Any => true,
}
}
}
struct TautomerRule {
#[allow(dead_code)]
name: &'static str,
donor_elem: u8,
bridge_elem: Option<u8>,
acceptor_elem: u8,
donor_bridge_order: BondOrderMatch,
bridge_acceptor_order: BondOrderMatch,
prefer_forward: bool,
path_len: usize,
}
static RULES: &[TautomerRule] = &[
TautomerRule {
name: "keto-enol",
donor_elem: 8,
bridge_elem: Some(6),
acceptor_elem: 6,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "amide-iminol",
donor_elem: 7,
bridge_elem: Some(6),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "iminol-amide",
donor_elem: 8,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "imine-enamine",
donor_elem: 7,
bridge_elem: Some(6),
acceptor_elem: 6,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "1,3-N-to-O",
donor_elem: 7,
bridge_elem: None,
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-N-to-N",
donor_elem: 7,
bridge_elem: None,
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "thioamide",
donor_elem: 7,
bridge_elem: Some(6),
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "thio-iminol-amide",
donor_elem: 16,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "thio-keto-enol",
donor_elem: 16,
bridge_elem: Some(6),
acceptor_elem: 6,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "thio-enol-ketone",
donor_elem: 8,
bridge_elem: Some(6),
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-N-to-S",
donor_elem: 7,
bridge_elem: None,
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-S-to-O",
donor_elem: 16,
bridge_elem: None,
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-S-to-N",
donor_elem: 16,
bridge_elem: None,
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-O-to-S",
donor_elem: 8,
bridge_elem: None,
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-S-to-S",
donor_elem: 16,
bridge_elem: None,
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-O-to-N-any-bridge",
donor_elem: 8,
bridge_elem: None,
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "1,3-O-to-O-any-bridge",
donor_elem: 8,
bridge_elem: None,
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-N-to-C-any-bridge",
donor_elem: 7,
bridge_elem: None,
acceptor_elem: 6,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: true,
path_len: 3,
},
TautomerRule {
name: "1,3-C-to-O-any-bridge",
donor_elem: 6,
bridge_elem: None,
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,3-C-to-N-any-bridge",
donor_elem: 6,
bridge_elem: None,
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 3,
},
TautomerRule {
name: "1,5-O-to-O-beta-diketone",
donor_elem: 8,
bridge_elem: Some(6), acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-O-to-N",
donor_elem: 8,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-N",
donor_elem: 7,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-O",
donor_elem: 7,
bridge_elem: Some(6),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-O",
donor_elem: 6,
bridge_elem: Some(6),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-O-to-O-N-bridge",
donor_elem: 8,
bridge_elem: Some(7),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-O-to-O-S-bridge",
donor_elem: 8,
bridge_elem: Some(16),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-O-N-bridge",
donor_elem: 7,
bridge_elem: Some(7),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-O-S-bridge",
donor_elem: 7,
bridge_elem: Some(16),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-N-N-bridge",
donor_elem: 7,
bridge_elem: Some(7),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-N-to-N-S-bridge",
donor_elem: 7,
bridge_elem: Some(16),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-O-to-N-N-bridge",
donor_elem: 8,
bridge_elem: Some(7),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-O-to-N-S-bridge",
donor_elem: 8,
bridge_elem: Some(16),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-S-to-O-N-bridge",
donor_elem: 16,
bridge_elem: Some(7),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-S-to-O-S-bridge",
donor_elem: 16,
bridge_elem: Some(16),
acceptor_elem: 8,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-S-to-N-C-bridge",
donor_elem: 16,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-S-to-N-N-bridge",
donor_elem: 16,
bridge_elem: Some(7),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-N-C-bridge",
donor_elem: 6,
bridge_elem: Some(6),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-N-N-bridge",
donor_elem: 6,
bridge_elem: Some(7),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-N-S-bridge",
donor_elem: 6,
bridge_elem: Some(16),
acceptor_elem: 7,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-S-N-bridge",
donor_elem: 6,
bridge_elem: Some(7),
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
TautomerRule {
name: "1,5-C-to-S-S-bridge",
donor_elem: 6,
bridge_elem: Some(16),
acceptor_elem: 16,
donor_bridge_order: BondOrderMatch::Single,
bridge_acceptor_order: BondOrderMatch::Double,
prefer_forward: false,
path_len: 5,
},
];
fn h_assignment(mol: &Molecule) -> Vec<Option<u32>> {
(0..mol.atom_count())
.map(|i| mol.atom(AtomIdx(i as u32)).hydrogen_count.map(|h| h as u32))
.collect()
}
fn enumerate_direct_aromatic_forms(
start: &Molecule,
blocked: &HashSet<AtomIdx>,
max: usize,
) -> Vec<Molecule> {
let mut result = Vec::new();
let mut seen: HashSet<Vec<Option<u32>>> = HashSet::new();
seen.insert(h_assignment(start));
let mut frontier = vec![start.clone()];
while !frontier.is_empty() && result.len() < max {
let current = frontier.remove(0);
for (d, a) in find_direct_aromatic_matches(¤t) {
if let Some(next) = transfer_hydrogen_aromatic(¤t, d, a, blocked) {
let ha = h_assignment(&next);
if !seen.contains(&ha) {
seen.insert(ha);
frontier.push(next.clone());
result.push(next);
}
}
}
}
result
}
fn find_direct_aromatic_matches(mol: &Molecule) -> Vec<(AtomIdx, AtomIdx)> {
let mut pairs = Vec::new();
for (d, _) in mol.atoms() {
let da = mol.atom(d);
if !da.aromatic || da.element.atomic_number() != 7 {
continue;
}
if da.hydrogen_count.is_none_or(|h| h == 0) {
continue;
}
for (a, _) in mol.neighbors(d) {
let aa = mol.atom(a);
if aa.aromatic && (aa.element.atomic_number() == 7 || aa.element.atomic_number() == 8) {
pairs.push((d, a));
}
}
}
pairs
}
fn transfer_hydrogen_aromatic(
mol: &Molecule,
donor: AtomIdx,
acceptor: AtomIdx,
blocked_atoms: &HashSet<AtomIdx>,
) -> Option<Molecule> {
if blocked_atoms.contains(&donor) || blocked_atoms.contains(&acceptor) {
return None;
}
let donor_h = mol.atom(donor).hydrogen_count?;
if donor_h == 0 {
return None;
}
let acceptor_h = mol.atom(acceptor).hydrogen_count.unwrap_or(0);
let mut builder = MoleculeBuilder::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let mut atom = mol.atom(idx).clone();
if idx == donor {
atom.hydrogen_count = donor_h.checked_sub(1).filter(|&h| h > 0);
} else if idx == acceptor {
atom.hydrogen_count = Some(acceptor_h.saturating_add(1));
}
builder.add_atom(atom);
}
for i in 0..mol.bond_count() {
let bidx = BondIdx(i as u32);
let b = mol.bond(bidx);
builder
.add_bond(b.atom1, b.atom2, b.order)
.expect("transfer_hydrogen_aromatic: bond from a valid molecule must be re-addable");
}
builder.copy_stereo_groups_from(mol);
builder.copy_stereo_from(mol);
builder.copy_bond_directions_from(mol);
Some(builder.build())
}
use crate::hash::{FNV1A_OFFSET, FNV1A_PRIME};
fn tautomer_score(mol: &Molecule) -> i32 {
let mut score = 0i32;
let mut has_aromatic = false;
for (_, atom) in mol.atoms() {
if atom.aromatic {
has_aromatic = true;
}
let h_count = atom.hydrogen_count.unwrap_or(0) as i32;
if h_count > 0 {
match atom.element.atomic_number() {
8 => score += h_count * 100, 7 => score += h_count * 50, 16 => score += h_count * 25, _ => {}
}
}
}
if has_aromatic {
score += 1000;
}
score
}
fn mol_fingerprint(mol: &Molecule) -> u64 {
let mut atoms: Vec<(u8, i8, u32)> = (0..mol.atom_count())
.map(|i| {
let idx = AtomIdx(i as u32);
let a = mol.atom(idx);
let bos: u32 = mol
.neighbors(idx)
.map(|(_, bidx)| mol.bond(bidx).order.order_int() as u32)
.sum();
(a.element.atomic_number(), a.charge, bos)
})
.collect();
atoms.sort();
let mut hash = FNV1A_OFFSET;
for (an, ch, bos) in atoms {
hash ^= an as u64;
hash = hash.wrapping_mul(FNV1A_PRIME);
hash ^= (ch as u8 as u64).wrapping_add(128);
hash = hash.wrapping_mul(FNV1A_PRIME);
hash ^= bos as u64;
hash = hash.wrapping_mul(FNV1A_PRIME);
}
hash
}
fn find_matches(mol: &Molecule, rule: &TautomerRule) -> Vec<(AtomIdx, AtomIdx, AtomIdx)> {
let mut matches = Vec::new();
if rule.path_len == 5 {
for i in 0..mol.atom_count() {
let d = AtomIdx(i as u32);
let donor_atom = mol.atom(d);
if donor_atom.element.atomic_number() != rule.donor_elem {
continue;
}
if implicit_hcount(mol, d) == 0 {
continue;
}
for (b1, db_bidx) in mol.neighbors(d) {
if !rule.donor_bridge_order.matches(mol.bond(db_bidx).order) {
continue;
}
if mol.atom(b1).element.atomic_number() != 6 {
continue;
}
for (b2, _) in mol.neighbors(b1) {
if b2 == d {
continue;
}
if let Some(br_elem) = rule.bridge_elem
&& mol.atom(b2).element.atomic_number() != br_elem
{
continue;
}
for (b3, _) in mol.neighbors(b2) {
if b3 == b1 {
continue;
}
if mol.atom(b3).element.atomic_number() != 6 {
continue;
}
for (a, ba_bidx) in mol.neighbors(b3) {
if a == b2 {
continue;
}
if !rule.bridge_acceptor_order.matches(mol.bond(ba_bidx).order) {
continue;
}
if mol.atom(a).element.atomic_number() != rule.acceptor_elem {
continue;
}
matches.push((d, b2, a));
}
}
}
}
}
} else {
for i in 0..mol.atom_count() {
let d = AtomIdx(i as u32);
let donor_atom = mol.atom(d);
if donor_atom.element.atomic_number() != rule.donor_elem {
continue;
}
if implicit_hcount(mol, d) == 0 {
continue;
}
for (b, db_bidx) in mol.neighbors(d) {
if !rule.donor_bridge_order.matches(mol.bond(db_bidx).order) {
continue;
}
if let Some(br_elem) = rule.bridge_elem
&& mol.atom(b).element.atomic_number() != br_elem
{
continue;
}
for (a, ba_bidx) in mol.neighbors(b) {
if a == d {
continue;
}
if !rule.bridge_acceptor_order.matches(mol.bond(ba_bidx).order) {
continue;
}
if mol.atom(a).element.atomic_number() != rule.acceptor_elem {
continue;
}
matches.push((d, b, a));
}
}
}
}
matches
}
fn transfer_hydrogen(
mol: &Molecule,
donor: AtomIdx,
bridge: AtomIdx,
acceptor: AtomIdx,
blocked_atoms: &HashSet<AtomIdx>,
blocked_bonds: &HashSet<BondIdx>,
) -> Option<Molecule> {
if blocked_atoms.contains(&donor)
|| blocked_atoms.contains(&bridge)
|| blocked_atoms.contains(&acceptor)
{
return None;
}
let (db_bidx, _) = mol.bond_between(donor, bridge)?;
let (ba_bidx, _) = mol.bond_between(bridge, acceptor)?;
if blocked_bonds.contains(&db_bidx) || blocked_bonds.contains(&ba_bidx) {
return None;
}
let mut builder = MoleculeBuilder::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let mut atom = mol.atom(idx).clone();
if let Some(h) = atom.hydrogen_count {
if idx == donor {
if h == 0 {
return None;
}
atom.hydrogen_count = Some(h - 1);
} else if idx == acceptor {
atom.hydrogen_count = Some(h.saturating_add(1));
}
}
builder.add_atom(atom);
}
for i in 0..mol.bond_count() {
let bidx = BondIdx(i as u32);
let b = mol.bond(bidx);
let order = match bidx {
x if x == db_bidx => BondOrder::Double,
x if x == ba_bidx => BondOrder::Single,
_ => b.order,
};
builder.add_bond(b.atom1, b.atom2, order).ok()?;
}
builder.copy_stereo_groups_from(mol);
builder.copy_stereo_from(mol);
builder.copy_bond_directions_from(mol);
Some(builder.build())
}
fn apply_first_match(
mol: &Molecule,
rule: &TautomerRule,
config: &TautomerConfig,
) -> Option<Molecule> {
find_matches(mol, rule).into_iter().find_map(|(d, b, a)| {
transfer_hydrogen(mol, d, b, a, &config.blocked_atoms, &config.blocked_bonds)
})
}
fn apply_all_matches(
mol: &Molecule,
rule: &TautomerRule,
config: &TautomerConfig,
) -> Vec<Molecule> {
find_matches(mol, rule)
.into_iter()
.filter_map(|(d, b, a)| {
transfer_hydrogen(mol, d, b, a, &config.blocked_atoms, &config.blocked_bonds)
})
.collect()
}
#[derive(Debug, Clone)]
pub struct TautomerConfig {
pub max_iter: usize,
pub max_tautomers: usize,
pub enabled_rules: Vec<usize>,
pub blocked_atoms: HashSet<AtomIdx>,
pub blocked_bonds: HashSet<BondIdx>,
}
impl Default for TautomerConfig {
fn default() -> Self {
Self {
max_iter: 16,
max_tautomers: 32,
enabled_rules: Vec::new(),
blocked_atoms: HashSet::new(),
blocked_bonds: HashSet::new(),
}
}
}
impl TautomerConfig {
pub fn rule_count() -> usize {
RULES.len()
}
pub fn rule_names() -> Vec<&'static str> {
RULES.iter().map(|r| r.name).collect()
}
pub fn keto_enol_only() -> Self {
Self {
enabled_rules: vec![0],
..Self::default()
}
}
}
fn active_rules(config: &TautomerConfig) -> Vec<&'static TautomerRule> {
RULES
.iter()
.enumerate()
.filter_map(|(i, r)| {
if config.enabled_rules.is_empty() || config.enabled_rules.contains(&i) {
Some(r)
} else {
None
}
})
.collect()
}
pub fn canonical_tautomer(mol: &Molecule) -> Molecule {
canonical_tautomer_with_config(mol, &TautomerConfig::default())
}
pub fn canonical_tautomer_with_config(mol: &Molecule, config: &TautomerConfig) -> Molecule {
let mut current = mol.clone();
let mut seen = HashSet::new();
seen.insert(mol_fingerprint(¤t));
for _ in 0..config.max_iter {
let mut changed = false;
for rule in active_rules(config)
.into_iter()
.filter(|r| r.prefer_forward)
{
if let Some(next) = apply_first_match(¤t, rule, config) {
let fp = mol_fingerprint(&next);
if !seen.contains(&fp) {
seen.insert(fp);
current = next;
changed = true;
break;
}
}
}
if !changed {
break;
}
}
let mut candidates: Vec<Molecule> = vec![current.clone()];
candidates.extend(enumerate_direct_aromatic_forms(
¤t,
&config.blocked_atoms,
16,
));
if candidates.len() > 1 {
candidates.sort_by(|a, b| {
tautomer_score(b).cmp(&tautomer_score(a)).then_with(|| {
chematic_smiles::canonical_smiles(a).cmp(&chematic_smiles::canonical_smiles(b))
})
});
current = candidates.into_iter().next().unwrap();
}
current
}
pub fn enumerate_tautomers(mol: &Molecule) -> Vec<Molecule> {
enumerate_tautomers_with_config(mol, &TautomerConfig::default())
}
pub fn enumerate_tautomers_with_config(mol: &Molecule, config: &TautomerConfig) -> Vec<Molecule> {
let mut result = vec![mol.clone()];
let mut seen = HashSet::new();
seen.insert(mol_fingerprint(mol));
let mut h_seen: HashSet<Vec<Option<u32>>> = HashSet::new();
h_seen.insert(h_assignment(mol));
let mut frontier = vec![mol.clone()];
while !frontier.is_empty() && result.len() < config.max_tautomers {
let current = frontier.remove(0);
for rule in active_rules(config).into_iter() {
for next in apply_all_matches(¤t, rule, config) {
let fp = mol_fingerprint(&next);
if !seen.contains(&fp) {
seen.insert(fp);
h_seen.insert(h_assignment(&next));
frontier.push(next.clone());
result.push(next);
if result.len() >= config.max_tautomers {
break;
}
}
}
if result.len() >= config.max_tautomers {
break;
}
}
for (d, a) in find_direct_aromatic_matches(¤t) {
if result.len() >= config.max_tautomers {
break;
}
if let Some(next) = transfer_hydrogen_aromatic(¤t, d, a, &config.blocked_atoms) {
let ha = h_assignment(&next);
if !h_seen.contains(&ha) {
h_seen.insert(ha);
seen.insert(mol_fingerprint(&next));
frontier.push(next.clone());
result.push(next);
}
}
}
}
result
}
#[cfg(test)]
mod tests {
#![allow(dead_code)]
use super::*;
use chematic_core::{AtomIdx, Chirality};
use chematic_smiles::{canonical_smiles, parse};
fn build_comb(n: usize, reverse_arms: bool, keto: bool) -> Molecule {
use chematic_core::{Atom, Element};
let mut builder = MoleculeBuilder::new();
let cap = builder.add_atom(Atom::new(Element::C));
let mut backbone = Vec::new();
for _ in 0..n {
backbone.push(builder.add_atom(Atom::new(Element::C)));
}
builder
.add_bond(cap, backbone[0], BondOrder::Single)
.unwrap();
for i in 0..n - 1 {
builder
.add_bond(backbone[i], backbone[i + 1], BondOrder::Single)
.unwrap();
}
let arm_order: Vec<usize> = if reverse_arms {
(0..n).rev().collect()
} else {
(0..n).collect()
};
for &i in &arm_order {
if keto {
let carbonyl = builder.add_atom(Atom::new(Element::C));
let mut o = Atom::new(Element::O);
o.hydrogen_count = Some(0);
let oxy = builder.add_atom(o);
let methyl = builder.add_atom(Atom::new(Element::C));
builder
.add_bond(backbone[i], carbonyl, BondOrder::Single)
.unwrap();
builder.add_bond(carbonyl, oxy, BondOrder::Double).unwrap();
builder
.add_bond(carbonyl, methyl, BondOrder::Single)
.unwrap();
} else {
let bridge = builder.add_atom(Atom::new(Element::C));
let acceptor = builder.add_atom(Atom::new(Element::C));
let mut o = Atom::new(Element::O);
o.hydrogen_count = Some(1);
let donor = builder.add_atom(o);
builder
.add_bond(backbone[i], bridge, BondOrder::Single)
.unwrap();
builder
.add_bond(bridge, acceptor, BondOrder::Double)
.unwrap();
builder.add_bond(bridge, donor, BondOrder::Single).unwrap();
}
}
builder.build()
}
#[test]
#[ignore = "confirmed real, root-caused, and deliberately not fixed this \
round: canonical_tautomer_with_config's greedy loop applies \
one transform per outer iteration via apply_first_match \
(atom-index order, see find_matches), bounded by \
config.max_iter (default 16). A molecule with MORE \
independent same-rule tautomerizable sites than max_iter \
allows has its FINAL result depend on atom insertion order: \
which subset of sites got converted before the budget ran \
out differs. Verified NOT a deeper tie-break flaw -- with \
max_iter=1000 (room to fully process all 25 sites) both \
atom orderings converge to the byte-identical correct \
answer (see test_max_iter_1000_resolves_the_divergence); \
verified NOT a canonical_smiles bug either (see \
test_canonical_smiles_alone_is_order_independent_on_comb). \
The trigger (>16 independent, same-rule, non-automorphic \
tautomerizable sites in one molecule) is an extreme edge \
case with no known real-molecule instance -- a proper fix \
needs batching all of a rule's non-conflicting matches per \
iteration instead of raising the constant, an architectural \
change with its own conflict-resolution complexity, \
correctly left out of scope for this round rather than \
rushed. See TautomerConfig::max_iter's doc comment."]
fn test_max_iter_default_diverges_on_many_independent_sites() {
let forward = build_comb(25, false, false);
let reversed = build_comb(25, true, false);
assert_eq!(
canonical_smiles(&forward),
canonical_smiles(&reversed),
"sanity check: build_comb(reverse_arms) must build the SAME molecule"
);
assert_eq!(
canonical_smiles(&canonical_tautomer(&forward)),
canonical_smiles(&canonical_tautomer(&reversed)),
);
}
#[test]
fn test_max_iter_1000_resolves_the_divergence() {
let forward = build_comb(25, false, false);
let reversed = build_comb(25, true, false);
let big_config = TautomerConfig {
max_iter: 1000,
..TautomerConfig::default()
};
let ta = canonical_tautomer_with_config(&forward, &big_config);
let tb = canonical_tautomer_with_config(&reversed, &big_config);
assert_eq!(canonical_smiles(&ta), canonical_smiles(&tb));
assert!(!canonical_smiles(&ta).contains("[OH]"));
}
#[test]
fn test_canonical_smiles_alone_is_order_independent_on_comb() {
let forward = build_comb(25, false, true);
let reversed = build_comb(25, true, true);
assert_eq!(canonical_smiles(&forward), canonical_smiles(&reversed));
}
#[test]
fn test_bis_enol_independent_sites_order_independent() {
let a = parse("OC=CCC=C(O)C").unwrap();
let b = parse("CC(O)=CCC=CO").unwrap();
assert_eq!(
canonical_smiles(&canonical_tautomer(&a)),
canonical_smiles(&canonical_tautomer(&b)),
);
}
#[test]
fn test_canonical_no_tautomers() {
let mol = parse("CCO").unwrap();
let t = canonical_tautomer(&mol);
assert_eq!(t.atom_count(), mol.atom_count());
}
#[test]
fn test_canonical_idempotent() {
let mol = parse("CC=O").unwrap(); let t1 = canonical_tautomer(&mol);
let t2 = canonical_tautomer(&t1);
assert_eq!(mol_fingerprint(&t1), mol_fingerprint(&t2));
}
#[test]
fn test_enumerate_single_no_match() {
let mol = parse("C").unwrap(); let tautomers = enumerate_tautomers(&mol);
assert_eq!(tautomers.len(), 1); }
#[test]
fn test_enumerate_cap() {
let mol = parse("CC(=O)CC(=O)C").unwrap(); let tautomers = enumerate_tautomers(&mol);
assert!(tautomers.len() <= 32);
assert!(!tautomers.is_empty());
}
#[test]
fn test_enumerate_vinyl_alcohol() {
let mol = parse("OC=C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for vinyl alcohol, got {}",
tautomers.len()
);
}
#[test]
fn test_canonical_amide_unchanged() {
let mol = parse("CC(=O)N").unwrap();
let t = canonical_tautomer(&mol);
assert_eq!(mol_fingerprint(&t), mol_fingerprint(&mol));
}
#[test]
fn test_canonical_acetylacetone_stable() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let t = canonical_tautomer(&mol);
assert!(t.atom_count() > 0);
}
#[test]
fn test_enumerate_includes_original() {
let mol = parse("CC=O").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert_eq!(mol_fingerprint(&tautomers[0]), mol_fingerprint(&mol));
}
#[test]
fn test_canonical_keto_unchanged() {
let mol = parse("CC=O").unwrap();
let t = canonical_tautomer(&mol);
assert_eq!(mol_fingerprint(&t), mol_fingerprint(&mol));
}
#[test]
fn test_canonical_acetylacetone_enol() {
let enol = parse("CC(O)=CC(=O)C").unwrap();
let keto = parse("CC(=O)CC(=O)C").unwrap();
let t_enol = canonical_tautomer(&enol);
let t_keto = canonical_tautomer(&keto);
assert!(t_enol.atom_count() > 0);
assert!(t_keto.atom_count() > 0);
}
#[test]
fn test_enumerate_pyrazole_12_shift() {
let mol = parse("c1cc[nH]n1").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for pyrazole, got {}",
tautomers.len()
);
}
#[test]
fn test_canonical_pyrazole_normalization() {
let n1h = parse("c1cc[nH]n1").unwrap();
let tautomers = enumerate_tautomers(&n1h);
let n2h = tautomers
.iter()
.find(|t| h_assignment(t) != h_assignment(&n1h))
.expect("enumerate_tautomers should produce N2H tautomer of pyrazole");
assert_eq!(
canonical_smiles(&canonical_tautomer(&n1h)),
canonical_smiles(&canonical_tautomer(n2h)),
"canonical_tautomer should normalize N1H and N2H to the same form"
);
}
#[test]
fn test_config_default_same_as_no_config() {
let mol = parse("OC=C").unwrap(); let a = canonical_tautomer(&mol);
let b = canonical_tautomer_with_config(&mol, &TautomerConfig::default());
assert_eq!(
canonical_smiles(&a),
canonical_smiles(&b),
"default config should match canonical_tautomer"
);
}
#[test]
fn test_config_max_iter_one_limits_convergence() {
let mol = parse("OC=C").unwrap();
let config = TautomerConfig {
max_iter: 1,
..TautomerConfig::default()
};
let _ = canonical_tautomer_with_config(&mol, &config);
}
#[test]
fn test_config_max_tautomers_caps_enumerate() {
let mol = parse("CC(=O)CC(=O)C").unwrap(); let config = TautomerConfig {
max_tautomers: 2,
..TautomerConfig::default()
};
let tautomers = enumerate_tautomers_with_config(&mol, &config);
assert_eq!(
tautomers.len(),
2,
"max_tautomers=2 should return exactly 2"
);
}
#[test]
fn test_config_enabled_rules_subset() {
let mol = parse("OC=C").unwrap();
let config = TautomerConfig::keto_enol_only();
let result = canonical_tautomer_with_config(&mol, &config);
assert!(result.atom_count() > 0);
}
#[test]
fn test_config_empty_enabled_rules_equals_all() {
let mol = parse("OC=C").unwrap();
let all = canonical_tautomer_with_config(&mol, &TautomerConfig::default());
let explicit_empty = canonical_tautomer_with_config(
&mol,
&TautomerConfig {
enabled_rules: vec![],
..TautomerConfig::default()
},
);
assert_eq!(
canonical_smiles(&all),
canonical_smiles(&explicit_empty),
"empty enabled_rules should equal all rules"
);
}
#[test]
fn test_rule_count_and_names() {
let count = TautomerConfig::rule_count();
let names = TautomerConfig::rule_names();
assert!(count > 0);
assert_eq!(names.len(), count);
assert!(!names[0].is_empty());
}
#[test]
fn test_15_shift_beta_diketone() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for β-diketone"
);
}
#[test]
fn test_15_shift_enol_imine() {
let mol = parse("CC(=N)CC(=O)C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_n_bridge_diketone() {
let mol = parse("OC1=C(O)C(=O)C(=O)C=C1").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_s_bridge() {
let mol = parse("CC(=O)CS(=O)C(=O)C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_n_to_n_with_bridge() {
let mol = parse("NC(=N)NC(=N)N").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_canonical_idempotent() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let t1 = canonical_tautomer(&mol);
let t2 = canonical_tautomer(&t1);
assert_eq!(mol_fingerprint(&t1), mol_fingerprint(&t2));
}
#[test]
fn test_15_shift_heteroatom_enumeration() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for acetylacetone with new 1,5-shift rules"
);
}
#[test]
fn test_15_shift_c_to_o_with_heteroatom() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let config = TautomerConfig {
max_tautomers: 64,
..TautomerConfig::default()
};
let tautomers = enumerate_tautomers_with_config(&mol, &config);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_no_false_positives() {
let mol = parse("CC(=O)C").unwrap(); let tautomers = enumerate_tautomers(&mol);
assert!(!tautomers.is_empty());
}
#[test]
fn test_15_shift_multiple_donors_acceptors() {
let mol = parse("CC(=O)CC(=O)CC(=O)C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected multiple tautomers from multiple 1,5-shift sites"
);
}
#[test]
fn test_15_shift_config_selectivity() {
let mol = parse("CC(=O)CC(=O)C").unwrap();
let config = TautomerConfig {
max_tautomers: 32,
enabled_rules: (20..43).collect(), ..TautomerConfig::default()
};
let tautomers = enumerate_tautomers_with_config(&mol, &config);
assert!(!tautomers.is_empty());
}
fn canonical_smi(smi: &str) -> String {
let mol = parse(smi).unwrap();
canonical_smiles(&canonical_tautomer(&mol))
}
fn blocked_smi(smi: &str, blocked: &[u32]) -> String {
let mol = parse(smi).unwrap();
let config = TautomerConfig {
blocked_atoms: blocked.iter().map(|&i| AtomIdx(i)).collect(),
..TautomerConfig::default()
};
canonical_smiles(&canonical_tautomer_with_config(&mol, &config))
}
#[test]
fn test_blocking_donor_suppresses_keto_enol() {
let mol = parse("OC=C").unwrap();
let default_config = TautomerConfig::default();
let blocked_config = TautomerConfig {
blocked_atoms: [AtomIdx(0)].into_iter().collect(), ..TautomerConfig::default()
};
let default_result = canonical_tautomer_with_config(&mol, &default_config);
let blocked_result = canonical_tautomer_with_config(&mol, &blocked_config);
assert!(blocked_result.atom_count() > 0);
let _ = (default_result, blocked_result); }
#[test]
fn test_empty_blocked_sets_identical_to_default() {
for smi in &["OC=C", "CC(=O)CC", "c1cc[nH]c1", "CN=C"] {
let mol = parse(smi).unwrap();
let default = canonical_tautomer(&mol);
let empty_config = TautomerConfig {
blocked_atoms: HashSet::new(),
blocked_bonds: HashSet::new(),
..TautomerConfig::default()
};
let explicit_empty = canonical_tautomer_with_config(&mol, &empty_config);
assert_eq!(
canonical_smiles(&default),
canonical_smiles(&explicit_empty),
"empty blocked sets must give same result as default for {smi}"
);
}
}
#[test]
fn test_enumerate_with_blocking_leq_enumerate_without() {
let mol = parse("CC(=O)CC(=O)C").unwrap(); let all = enumerate_tautomers(&mol);
let config = TautomerConfig {
blocked_atoms: [AtomIdx(3)].into_iter().collect(),
..TautomerConfig::default()
};
let blocked = enumerate_tautomers_with_config(&mol, &config);
assert!(
blocked.len() <= all.len(),
"blocking must not increase tautomer count: {} > {}",
blocked.len(),
all.len()
);
}
#[test]
fn test_blocking_all_atoms_preserves_input() {
let mol = parse("OC=C").unwrap();
let n = mol.atom_count();
let config = TautomerConfig {
blocked_atoms: (0..n as u32).map(AtomIdx).collect(),
..TautomerConfig::default()
};
let result = canonical_tautomer_with_config(&mol, &config);
assert_eq!(
canonical_smiles(&result),
canonical_smiles(&mol),
"all atoms blocked → input unchanged"
);
}
#[test]
fn test_enumerate_all_atoms_blocked_returns_singleton() {
let mol = parse("OC=C").unwrap();
let n = mol.atom_count();
let config = TautomerConfig {
blocked_atoms: (0..n as u32).map(AtomIdx).collect(),
..TautomerConfig::default()
};
let tautomers = enumerate_tautomers_with_config(&mol, &config);
assert_eq!(
tautomers.len(),
1,
"all atoms blocked → only the original is returned"
);
}
#[test]
fn test_out_of_range_atom_index_is_safe() {
let mol = parse("OC=C").unwrap();
let n = mol.atom_count();
let config = TautomerConfig {
blocked_atoms: [AtomIdx(n as u32 + 100)].into_iter().collect(),
..TautomerConfig::default()
};
let result = canonical_tautomer_with_config(&mol, &config);
assert!(result.atom_count() > 0);
}
#[test]
fn test_remote_stereo_preserved_keto_enol() {
let mol = parse("C[C@H](O)CC(=O)C").unwrap();
let before_chirality = mol.atom(AtomIdx(1)).chirality;
assert_ne!(
before_chirality,
Chirality::None,
"test setup: atom 1 must be chiral"
);
let t = canonical_tautomer(&mol);
let after_chirality = t.atom(AtomIdx(1)).chirality;
assert_ne!(
after_chirality,
Chirality::None,
"Remote [C@H] chirality erased by canonical_tautomer (RDKit #7969 regression)"
);
let smi = canonical_smiles(&t);
assert!(
smi.contains('@'),
"Canonical SMILES lost chirality marker: '{}'",
smi
);
}
#[test]
fn test_alanine_stereo_trivially_preserved() {
let mol = parse("[C@@H](N)(C(=O)O)C").unwrap();
let before = mol.atom(AtomIdx(0)).chirality;
let t = canonical_tautomer(&mol);
let after = t.atom(AtomIdx(0)).chirality;
assert_eq!(
before, after,
"Alanine chirality changed; was {:?}, got {:?}",
before, after
);
}
#[test]
fn test_glucose_all_stereocenters_preserved() {
let mol = parse("OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O").unwrap();
let before: Vec<Chirality> = mol.atoms().map(|(_, a)| a.chirality).collect();
let t = canonical_tautomer(&mol);
let after: Vec<Chirality> = t.atoms().map(|(_, a)| a.chirality).collect();
assert_eq!(
before, after,
"Glucose: stereocenters changed through canonical_tautomer"
);
}
#[test]
fn test_pyrazole_no_phantom_chirality() {
let mol = parse("c1cc[nH]n1").unwrap();
let t = canonical_tautomer(&mol);
let chiral_count = t
.atoms()
.filter(|(_, a)| a.chirality != Chirality::None)
.count();
assert_eq!(
chiral_count, 0,
"Phantom chirality introduced by pyrazole tautomerism"
);
}
#[test]
fn test_stereo_at_donor_does_not_panic() {
let mol = parse("[C@@H](O)(C)C(=O)O").unwrap();
let t = canonical_tautomer(&mol);
assert!(t.atom_count() > 0);
let smi = canonical_smiles(&t);
assert!(!smi.is_empty());
}
#[test]
fn test_enumerate_tautomers_remote_stereo_preserved() {
let mol = parse("C[C@H](O)CC(=O)C").unwrap();
let original_chirality = mol.atom(AtomIdx(1)).chirality;
let tautomers = enumerate_tautomers(&mol);
for (i, t) in tautomers.iter().enumerate() {
if t.atom_count() == mol.atom_count() {
let ch = t.atom(AtomIdx(1)).chirality;
assert_eq!(
ch, original_chirality,
"Tautomer #{}: chirality at atom 1 changed ({:?} → {:?})",
i, original_chirality, ch
);
}
}
}
const AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER: &str = "C[C@H](O)c1cc[nH]n1";
#[test]
fn transfer_hydrogen_aromatic_preserves_remote_stereo_neighbor_order() {
let mol = parse(AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER).unwrap();
let stereocenter = AtomIdx(1);
let original_order = mol.stereo_neighbor_order(stereocenter).map(|s| s.to_vec());
assert!(
original_order.is_some(),
"test setup sanity: atom 1 must be chiral"
);
let (donor, acceptor) = find_direct_aromatic_matches(&mol)
.into_iter()
.next()
.expect("test setup sanity: pyrazole should have a direct aromatic N-H match");
let result = transfer_hydrogen_aromatic(&mol, donor, acceptor, &HashSet::new())
.expect("pyrazole N-H transfer should succeed");
assert_eq!(
result.atom(stereocenter).chirality,
mol.atom(stereocenter).chirality,
"remote stereocenter's chirality must be unchanged"
);
assert_eq!(
result
.stereo_neighbor_order(stereocenter)
.map(|s| s.to_vec()),
original_order,
"remote stereocenter's stereo_neighbor_order must survive transfer_hydrogen_aromatic \
verbatim -- chirality alone surviving is not enough to keep it interpretable"
);
}
#[test]
fn transfer_hydrogen_aromatic_preserves_stereo_groups_and_bond_directions() {
let mut mol = parse(AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER).unwrap();
let stereocenter = AtomIdx(1);
mol.add_stereo_group(chematic_core::StereoGroup::new(
chematic_core::StereoGroupKind::Absolute,
vec![stereocenter],
));
let (co_bond, _) = mol
.bond_between(AtomIdx(1), AtomIdx(2))
.expect("C-O bond must exist");
mol.set_bond_direction(co_bond, BondOrder::Up);
let (donor, acceptor) = find_direct_aromatic_matches(&mol)
.into_iter()
.next()
.expect("test setup sanity: pyrazole should have a direct aromatic N-H match");
let result = transfer_hydrogen_aromatic(&mol, donor, acceptor, &HashSet::new())
.expect("pyrazole N-H transfer should succeed");
assert_eq!(
result.stereo_groups(),
mol.stereo_groups(),
"stereo_groups must survive transfer_hydrogen_aromatic verbatim"
);
assert_eq!(
result.bond_direction(co_bond),
mol.bond_direction(co_bond),
"bond_directions for a bond uninvolved in the H transfer must be unchanged"
);
}
const NON_AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER: &str = "C[C@H](Cl)CC(O)=C";
fn keto_enol_match(mol: &Molecule) -> (AtomIdx, AtomIdx, AtomIdx) {
let rule = RULES
.iter()
.find(|r| r.name == "keto-enol")
.expect("keto-enol rule must exist");
find_matches(mol, rule)
.into_iter()
.next()
.expect("test setup sanity: molecule should have a keto-enol match")
}
#[test]
fn transfer_hydrogen_preserves_remote_stereo_neighbor_order() {
let mol = parse(NON_AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER).unwrap();
let stereocenter = AtomIdx(1);
let original_order = mol.stereo_neighbor_order(stereocenter).map(|s| s.to_vec());
assert!(
original_order.is_some(),
"test setup sanity: atom 1 must be chiral"
);
let (donor, bridge, acceptor) = keto_enol_match(&mol);
let result = transfer_hydrogen(
&mol,
donor,
bridge,
acceptor,
&HashSet::new(),
&HashSet::new(),
)
.expect("keto-enol transfer should succeed");
assert_eq!(
result.atom(stereocenter).chirality,
mol.atom(stereocenter).chirality,
"remote stereocenter's chirality must be unchanged"
);
assert_eq!(
result
.stereo_neighbor_order(stereocenter)
.map(|s| s.to_vec()),
original_order,
"remote stereocenter's stereo_neighbor_order must survive transfer_hydrogen \
verbatim -- chirality alone surviving is not enough to keep it interpretable"
);
}
#[test]
fn transfer_hydrogen_preserves_stereo_groups_and_bond_directions() {
let mut mol = parse(NON_AROMATIC_TAUTOMER_WITH_REMOTE_STEREOCENTER).unwrap();
let stereocenter = AtomIdx(1);
mol.add_stereo_group(chematic_core::StereoGroup::new(
chematic_core::StereoGroupKind::Absolute,
vec![stereocenter],
));
let (c_cl_bond, _) = mol
.bond_between(AtomIdx(1), AtomIdx(2))
.expect("C-Cl bond must exist");
mol.set_bond_direction(c_cl_bond, BondOrder::Up);
let (donor, bridge, acceptor) = keto_enol_match(&mol);
let result = transfer_hydrogen(
&mol,
donor,
bridge,
acceptor,
&HashSet::new(),
&HashSet::new(),
)
.expect("keto-enol transfer should succeed");
assert_eq!(
result.stereo_groups(),
mol.stereo_groups(),
"stereo_groups must survive transfer_hydrogen verbatim"
);
assert_eq!(
result.bond_direction(c_cl_bond),
mol.bond_direction(c_cl_bond),
"bond_directions for a bond uninvolved in the H transfer must be unchanged"
);
}
#[test]
fn transfer_hydrogen_cip_of_uninvolved_stereocenter_survives_canonical_round_trip() {
let mol = parse("C[C@H:9](Cl)CC(O)=C").unwrap();
let stereocenter = mol
.atoms()
.find(|(_, a)| a.atom_map == Some(9))
.map(|(idx, _)| idx)
.expect("atom map tag not found");
let before_cip = crate::assign_cip(&mol).get(stereocenter);
assert!(
before_cip.is_some(),
"test setup sanity: must be resolvable"
);
let (donor, bridge, acceptor) = keto_enol_match(&mol);
let result = transfer_hydrogen(
&mol,
donor,
bridge,
acceptor,
&HashSet::new(),
&HashSet::new(),
)
.expect("keto-enol transfer should succeed");
let smi = canonical_smiles(&result);
let reparsed = parse(&smi).expect("valid canonical SMILES");
let reparsed_center = reparsed
.atoms()
.find(|(_, a)| a.atom_map == Some(9))
.map(|(idx, _)| idx)
.expect("atom map tag not found after round trip");
let after_cip = crate::assign_cip(&reparsed).get(reparsed_center);
assert_eq!(
before_cip, after_cip,
"remote stereocenter's CIP code must survive transfer_hydrogen + a canonical round trip"
);
}
#[test]
fn enumerate_tautomers_keto_enol_count_and_canonical_form_unaffected_by_metadata_fix() {
let mol = parse("CC(O)=C").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert_eq!(
tautomers.len(),
2,
"propan-2-enol should enumerate to exactly 2 forms (enol + acetone)"
);
assert_eq!(
canonical_smiles(&canonical_tautomer(&mol)),
"C(C)(C)=O",
"the canonical tautomer form must be unchanged (prefers the keto form)"
);
}
#[test]
fn enumerate_tautomers_count_and_canonical_form_unaffected_by_metadata_fix() {
let mol = parse("c1cc[nH]n1").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert_eq!(
tautomers.len(),
2,
"pyrazole should enumerate to exactly 2 forms"
);
assert_eq!(
canonical_smiles(&canonical_tautomer(&mol)),
"c1[nH]ncc1",
"pyrazole's canonical tautomer form must be unchanged"
);
}
#[test]
fn test_blocked_stereo_preserved_with_zone_blocking() {
let mol = parse("O[C@@H](F)C(=O)C").unwrap();
let config = TautomerConfig {
blocked_atoms: [AtomIdx(0)].into_iter().collect(),
..TautomerConfig::default()
};
let t = canonical_tautomer_with_config(&mol, &config);
let chirality_after = t.atom(AtomIdx(1)).chirality;
assert_ne!(
chirality_after,
Chirality::None,
"Chirality erased from blocked stereocentre"
);
}
#[test]
#[ignore = "known: canonical_tautomer loses E/Z stereo on hydrazones/imines (RDKit PR #9128). \
mol_fingerprint() does not include Up/Down bond orders so both E and Z forms hash \
identically; the canonical tautomer selection then returns the same SMILES for both. \
Fix requires either including stereo in mol_fingerprint or re-applying input E/Z stereo \
to the canonical tautomer output after selection."]
fn test_hydrazone_ez_stereo_preserved_in_canonical_tautomer() {
let e_hydrazone = parse("C/C=N/N").expect("E-hydrazone");
let z_hydrazone = parse("C/C=N\\N").expect("Z-hydrazone");
let e_can = canonical_tautomer(&e_hydrazone);
let z_can = canonical_tautomer(&z_hydrazone);
let e_smi = canonical_smiles(&e_can);
let z_smi = canonical_smiles(&z_can);
assert_ne!(
e_smi, z_smi,
"E and Z hydrazone must remain distinct after canonical_tautomer (RDKit PR #9128): \
E={e_smi} Z={z_smi}"
);
}
#[test]
fn test_tetrazole_1h_enumerates_two_forms() {
let mol = parse("c1nnn[nH]1").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for 1H-tetrazole, got {}",
tautomers.len()
);
}
#[test]
fn test_tetrazole_2h_enumerates_two_forms() {
let mol = parse("c1n[nH]nn1").unwrap();
let tautomers = enumerate_tautomers(&mol);
assert!(
tautomers.len() >= 2,
"Expected >= 2 tautomers for 2H-tetrazole, got {}",
tautomers.len()
);
}
#[test]
fn test_tetrazole_canonical_from_1h_and_2h_agrees() {
let mol_1h = parse("c1nnn[nH]1").unwrap();
let mol_2h = parse("c1n[nH]nn1").unwrap();
let can_1h = canonical_smiles(&canonical_tautomer(&mol_1h));
let can_2h = canonical_smiles(&canonical_tautomer(&mol_2h));
assert_eq!(
can_1h, can_2h,
"canonical_tautomer must agree for 1H ({can_1h}) and 2H ({can_2h}) tetrazole"
);
}
#[test]
fn test_tetrazole_canonical_preserves_aromaticity() {
let mol = parse("c1nnn[nH]1").unwrap();
let t = canonical_tautomer(&mol);
let all_aromatic = t.atoms().all(|(_, a)| a.aromatic);
assert!(
all_aromatic,
"all atoms in canonical tetrazole must be aromatic"
);
}
#[test]
fn imine_ez_stereo_preserved_in_tautomer_enumeration() {
let e_imine = parse("C/C=N/C").expect("E-imine");
let tautomers = enumerate_tautomers(&e_imine);
assert!(
!tautomers.is_empty(),
"E-imine must enumerate at least one tautomer"
);
for (i, t) in tautomers.iter().enumerate() {
let smi = canonical_smiles(t);
assert!(
!smi.is_empty(),
"tautomer {i} must produce valid canonical SMILES"
);
}
}
}