use std::collections::HashMap;
use std::time::Instant;
use chematic_core::{AtomIdx, BondOrder, Molecule};
use chematic_perception::{RingSet, find_sssr};
use crate::query::{AtomPrimitive, AtomQuery, BondPrimitive, BondQuery, QueryMolecule};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AtomCompare {
#[default]
Elements,
AnyHeavyAtom,
Any,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum BondCompare {
#[default]
OrderOrAromatic,
Any,
}
#[derive(Debug, Clone)]
pub struct McsConfig {
pub match_bonds: bool,
pub min_atoms: usize,
pub timeout_ms: Option<u64>,
pub ring_matches_ring_only: bool,
pub complete_rings_only: bool,
pub atom_compare: AtomCompare,
pub bond_compare: BondCompare,
pub match_chiral_tag: bool,
pub maximize_bonds: bool,
}
impl Default for McsConfig {
fn default() -> Self {
Self {
match_bonds: true,
min_atoms: 1,
timeout_ms: None,
ring_matches_ring_only: false,
complete_rings_only: false,
atom_compare: AtomCompare::Elements,
bond_compare: BondCompare::OrderOrAromatic,
match_chiral_tag: false,
maximize_bonds: true,
}
}
}
pub fn find_mcs(mols: &[&Molecule]) -> QueryMolecule {
find_mcs_with_config(mols, &McsConfig::default())
}
pub fn find_mcs_with_config(mols: &[&Molecule], config: &McsConfig) -> QueryMolecule {
if mols.is_empty() {
return QueryMolecule::new();
}
if mols.len() == 1 {
return molecule_to_query(mols[0]);
}
let deadline = config
.timeout_ms
.map(|ms| Instant::now() + std::time::Duration::from_millis(ms));
let ring_sets: Vec<RingSet> = if config.ring_matches_ring_only || config.complete_rings_only {
mols.iter().map(|m| find_sssr(m)).collect()
} else {
Vec::new()
};
let mut state = McsState {
mols,
config,
best: PartialMapping::empty(mols.len()),
deadline,
timed_out: false,
ring_sets,
};
let n0 = mols[0].atom_count();
'outer: for a0 in 0..n0 {
if state.timed_out {
break;
}
if let Some(d) = state.deadline
&& Instant::now() >= d
{
state.timed_out = true;
break;
}
let seed_candidates =
collect_seed_candidates(mols, AtomIdx(a0 as u32), config, &state.ring_sets);
for seed in CartesianProduct::new(&seed_candidates) {
if state.timed_out {
break 'outer;
}
if let Some(d) = state.deadline
&& Instant::now() >= d
{
state.timed_out = true;
break 'outer;
}
let ub = upper_bound_for_seeds(mols, AtomIdx(a0 as u32), &seed);
if ub <= state.best.size {
continue;
}
let mut mapping = PartialMapping::from_seed(mols, AtomIdx(a0 as u32), &seed);
grow(&mut state, &mut mapping);
}
}
if config.complete_rings_only && !state.ring_sets.is_empty() {
prune_partial_rings(mols, &mut state.best, &state.ring_sets);
}
if state.best.size < config.min_atoms {
return QueryMolecule::new();
}
build_query(mols[0], &state.best, config)
}
#[derive(Clone)]
struct PartialMapping {
mol_map: Vec<Vec<Option<usize>>>,
query_to_mol: Vec<Vec<AtomIdx>>,
size: usize,
bond_count: usize,
}
impl PartialMapping {
fn empty(n_mols: usize) -> Self {
Self {
mol_map: vec![Vec::new(); n_mols],
query_to_mol: Vec::new(),
size: 0,
bond_count: 0,
}
}
fn from_seed(mols: &[&Molecule], a0: AtomIdx, seed: &[AtomIdx]) -> Self {
let mut mol_map: Vec<Vec<Option<usize>>> =
mols.iter().map(|m| vec![None; m.atom_count()]).collect();
let mut query_to_mol: Vec<Vec<AtomIdx>> = Vec::new();
let mut row = vec![a0];
row.extend_from_slice(seed);
for (mi, &ai) in row.iter().enumerate() {
mol_map[mi][ai.0 as usize] = Some(0);
}
query_to_mol.push(row);
Self {
mol_map,
query_to_mol,
size: 1,
bond_count: 0,
}
}
fn is_mapped(&self, mol_idx: usize, atom_idx: AtomIdx) -> bool {
let v = &self.mol_map[mol_idx];
let idx = atom_idx.0 as usize;
idx < v.len() && v[idx].is_some()
}
fn query_idx_of(&self, mol_idx: usize, atom_idx: AtomIdx) -> Option<usize> {
let v = &self.mol_map[mol_idx];
let idx = atom_idx.0 as usize;
if idx < v.len() { v[idx] } else { None }
}
fn extend(&mut self, atoms: &[AtomIdx], extra_bonds: usize) -> usize {
let q = self.query_to_mol.len();
for (mi, &ai) in atoms.iter().enumerate() {
self.mol_map[mi][ai.0 as usize] = Some(q);
}
self.query_to_mol.push(atoms.to_vec());
self.size += 1;
self.bond_count += extra_bonds;
q
}
fn retract(&mut self, atoms: &[AtomIdx], extra_bonds: usize) {
for (mi, &ai) in atoms.iter().enumerate() {
self.mol_map[mi][ai.0 as usize] = None;
}
self.query_to_mol.pop();
self.size -= 1;
self.bond_count -= extra_bonds;
}
}
struct McsState<'a> {
mols: &'a [&'a Molecule],
config: &'a McsConfig,
best: PartialMapping,
deadline: Option<Instant>,
timed_out: bool,
ring_sets: Vec<RingSet>,
}
fn grow(state: &mut McsState<'_>, mapping: &mut PartialMapping) {
let is_better = mapping.size > state.best.size
|| (state.config.maximize_bonds
&& mapping.size == state.best.size
&& mapping.bond_count > state.best.bond_count);
if is_better {
state.best = mapping.clone();
}
if let Some(d) = state.deadline
&& Instant::now() >= d
{
state.timed_out = true;
return;
}
if state.timed_out {
return;
}
let additional_ub = upper_bound_additional(state.mols, mapping);
if mapping.size + additional_ub <= state.best.size {
return;
}
let mol0 = state.mols[0];
let mut frontier: Vec<AtomIdx> = Vec::new();
for row in &mapping.query_to_mol {
let a0 = row[0];
for (nb, _) in mol0.neighbors(a0) {
if !mapping.is_mapped(0, nb) && !frontier.contains(&nb) {
frontier.push(nb);
}
}
}
if frontier.is_empty() {
return;
}
let n0 = frontier[0];
let candidates_per_mol =
build_frontier_candidates(state.mols, mapping, n0, state.config, &state.ring_sets);
for tuple in CartesianProduct::new(&candidates_per_mol) {
if state.timed_out {
return;
}
let mut atoms: Vec<AtomIdx> = Vec::with_capacity(state.mols.len());
atoms.push(n0);
atoms.extend_from_slice(&tuple);
let extra_bonds = count_new_bonds(mol0, mapping, n0);
mapping.extend(&atoms, extra_bonds);
grow(state, mapping);
mapping.retract(&atoms, extra_bonds);
}
}
fn build_frontier_candidates(
mols: &[&Molecule],
mapping: &PartialMapping,
n0: AtomIdx,
config: &McsConfig,
ring_sets: &[RingSet],
) -> Vec<Vec<AtomIdx>> {
let mol0 = mols[0];
let atom0 = mol0.atom(n0);
let n0_in_ring = !ring_sets.is_empty() && ring_sets[0].contains_atom(n0);
let mut mapped_neighbors: Vec<(usize, AtomIdx, BondOrder)> = Vec::new();
for (nb, bidx) in mol0.neighbors(n0) {
if let Some(q) = mapping.query_idx_of(0, nb) {
let bond = mol0.bond(bidx);
mapped_neighbors.push((q, nb, bond.order));
}
}
let mut result: Vec<Vec<AtomIdx>> = Vec::with_capacity(mols.len() - 1);
for mi in 1..mols.len() {
let mol_i = mols[mi];
let mut candidates: Vec<AtomIdx> = Vec::new();
'atom: for (ai, atom_i) in mol_i.atoms() {
if mapping.is_mapped(mi, ai) {
continue;
}
if !atoms_compatible(atom0, atom_i, &config.atom_compare, config.match_chiral_tag) {
continue;
}
if config.ring_matches_ring_only {
let ai_in_ring = ring_sets[mi].contains_atom(ai);
if n0_in_ring != ai_in_ring {
continue;
}
}
for &(q, _m0, bond_order_0) in &mapped_neighbors {
let m_i = mapping.query_to_mol[q][mi];
match mol_i.bond_between(ai, m_i) {
None => continue 'atom,
Some((_bidx, bond_entry)) => {
if config.match_bonds
&& !bonds_compatible(
bond_order_0,
bond_entry.order,
&config.bond_compare,
)
{
continue 'atom;
}
}
}
}
candidates.push(ai);
}
result.push(candidates);
}
result
}
fn collect_seed_candidates(
mols: &[&Molecule],
a0: AtomIdx,
config: &McsConfig,
ring_sets: &[RingSet],
) -> Vec<Vec<AtomIdx>> {
let atom0 = mols[0].atom(a0);
let a0_in_ring = !ring_sets.is_empty() && ring_sets[0].contains_atom(a0);
let mut result = Vec::with_capacity(mols.len() - 1);
for mi in 1..mols.len() {
let cands: Vec<AtomIdx> = mols[mi]
.atoms()
.filter(|(ai, a)| {
if !atoms_compatible(atom0, a, &config.atom_compare, config.match_chiral_tag) {
return false;
}
if config.ring_matches_ring_only {
let ai_in_ring = ring_sets[mi].contains_atom(*ai);
if a0_in_ring != ai_in_ring {
return false;
}
}
true
})
.map(|(idx, _)| idx)
.collect();
result.push(cands);
}
result
}
fn upper_bound_for_seeds(mols: &[&Molecule], a0: AtomIdx, seed: &[AtomIdx]) -> usize {
let mut counts: HashMap<u8, Vec<usize>> = HashMap::new();
for (mi, mol) in mols.iter().enumerate() {
let exclude = if mi == 0 { a0 } else { seed[mi - 1] };
for (ai, atom) in mol.atoms() {
if ai == exclude {
continue; }
let en = atom.element.atomic_number();
counts.entry(en).or_insert_with(|| vec![0; mols.len()])[mi] += 1;
}
}
let ub: usize = counts.values().map(|v| *v.iter().min().unwrap_or(&0)).sum();
ub + 1 }
fn upper_bound_additional(mols: &[&Molecule], mapping: &PartialMapping) -> usize {
let mut counts: HashMap<u8, Vec<usize>> = HashMap::new();
for (mi, mol) in mols.iter().enumerate() {
for (ai, atom) in mol.atoms() {
if mapping.is_mapped(mi, ai) {
continue;
}
let en = atom.element.atomic_number();
let entry = counts.entry(en).or_insert_with(|| vec![0; mols.len()]);
entry[mi] += 1;
}
}
counts.values().map(|v| *v.iter().min().unwrap_or(&0)).sum()
}
fn atoms_compatible(
a: &chematic_core::Atom,
b: &chematic_core::Atom,
compare: &AtomCompare,
match_chiral: bool,
) -> bool {
let base = match compare {
AtomCompare::Elements => {
a.element.atomic_number() == b.element.atomic_number() && a.aromatic == b.aromatic
}
AtomCompare::AnyHeavyAtom => a.element.atomic_number() > 1 && b.element.atomic_number() > 1,
AtomCompare::Any => true,
};
if !base {
return false;
}
if match_chiral && a.chirality != b.chirality {
return false;
}
true
}
fn bonds_compatible(a: BondOrder, b: BondOrder, compare: &BondCompare) -> bool {
match compare {
BondCompare::OrderOrAromatic => normalize_bond(a) == normalize_bond(b),
BondCompare::Any => true,
}
}
fn normalize_bond(o: BondOrder) -> BondOrder {
match o {
BondOrder::Up | BondOrder::Down => BondOrder::Single,
other => other,
}
}
fn count_new_bonds(mol0: &Molecule, mapping: &PartialMapping, n0: AtomIdx) -> usize {
mol0.neighbors(n0)
.filter(|(nb, _)| mapping.is_mapped(0, *nb))
.count()
}
fn prune_partial_rings(mols: &[&Molecule], mapping: &mut PartialMapping, ring_sets: &[RingSet]) {
loop {
let mut to_remove: Vec<usize> = Vec::new();
let ring_set = &ring_sets[0];
for ring in ring_set.rings() {
let mapped_in_ring: Vec<AtomIdx> = ring
.iter()
.copied()
.filter(|&ai| mapping.is_mapped(0, ai))
.collect();
if !mapped_in_ring.is_empty() && mapped_in_ring.len() < ring.len() {
for ai in mapped_in_ring {
if let Some(qi) = mapping.query_idx_of(0, ai)
&& !to_remove.contains(&qi)
{
to_remove.push(qi);
}
}
}
}
if to_remove.is_empty() {
break;
}
let n_mols = mols.len();
let mut new_query_to_mol: Vec<Vec<AtomIdx>> = Vec::new();
let mut new_mol_map: Vec<Vec<Option<usize>>> =
mols.iter().map(|m| vec![None; m.atom_count()]).collect();
for (old_qi, row) in mapping.query_to_mol.iter().enumerate() {
if to_remove.contains(&old_qi) {
continue;
}
let new_qi = new_query_to_mol.len();
new_query_to_mol.push(row.clone());
for mi in 0..n_mols {
let ai = row[mi];
new_mol_map[mi][ai.0 as usize] = Some(new_qi);
}
}
mapping.query_to_mol = new_query_to_mol;
mapping.mol_map = new_mol_map;
mapping.size = mapping.query_to_mol.len();
}
}
fn build_query(mol0: &Molecule, mapping: &PartialMapping, config: &McsConfig) -> QueryMolecule {
let mut qmol = QueryMolecule::new();
for row in &mapping.query_to_mol {
let a0 = row[0];
let atom = mol0.atom(a0);
let an = atom.element.atomic_number();
let arom = atom.aromatic;
let query = AtomQuery::And(
Box::new(AtomQuery::Primitive(AtomPrimitive::AtomicNum(an))),
Box::new(AtomQuery::Primitive(AtomPrimitive::Aromatic(arom))),
);
qmol.add_atom(query);
}
for (q1, row1) in mapping.query_to_mol.iter().enumerate() {
let a1 = row1[0];
for (q2, row2) in mapping.query_to_mol.iter().enumerate() {
if q2 <= q1 {
continue;
}
let a2 = row2[0];
if let Some((_bidx, bond_entry)) = mol0.bond_between(a1, a2) {
let bq = bond_order_to_query(bond_entry.order, config.match_bonds);
qmol.add_bond(q1, q2, bq);
}
}
}
qmol
}
fn bond_order_to_query(order: BondOrder, match_bonds: bool) -> BondQuery {
if !match_bonds {
return BondQuery::Primitive(BondPrimitive::Any);
}
match normalize_bond(order) {
BondOrder::Single | BondOrder::Dative => BondQuery::Primitive(BondPrimitive::Single),
BondOrder::Double => BondQuery::Primitive(BondPrimitive::Double),
BondOrder::Triple => BondQuery::Primitive(BondPrimitive::Triple),
BondOrder::Aromatic => BondQuery::Primitive(BondPrimitive::Aromatic),
BondOrder::Quadruple | BondOrder::Zero | BondOrder::QueryAny => {
BondQuery::Primitive(BondPrimitive::Any)
}
BondOrder::QuerySingleOrDouble => BondQuery::Or(
Box::new(BondQuery::Primitive(BondPrimitive::Single)),
Box::new(BondQuery::Primitive(BondPrimitive::Double)),
),
BondOrder::QuerySingleOrAromatic => BondQuery::Or(
Box::new(BondQuery::Primitive(BondPrimitive::Single)),
Box::new(BondQuery::Primitive(BondPrimitive::Aromatic)),
),
BondOrder::QueryDoubleOrAromatic => BondQuery::Or(
Box::new(BondQuery::Primitive(BondPrimitive::Double)),
Box::new(BondQuery::Primitive(BondPrimitive::Aromatic)),
),
BondOrder::Up | BondOrder::Down => BondQuery::Primitive(BondPrimitive::Single),
}
}
fn molecule_to_query(mol: &Molecule) -> QueryMolecule {
let mut qmol = QueryMolecule::new();
for (_, atom) in mol.atoms() {
let an = atom.element.atomic_number();
let arom = atom.aromatic;
let query = AtomQuery::And(
Box::new(AtomQuery::Primitive(AtomPrimitive::AtomicNum(an))),
Box::new(AtomQuery::Primitive(AtomPrimitive::Aromatic(arom))),
);
qmol.add_atom(query);
}
for (_, bond) in mol.bonds() {
let bq = bond_order_to_query(bond.order, true);
qmol.add_bond(bond.atom1.0 as usize, bond.atom2.0 as usize, bq);
}
qmol
}
struct CartesianProduct<'a, T> {
lists: &'a [Vec<T>],
indices: Vec<usize>,
done: bool,
}
impl<'a, T: Copy> CartesianProduct<'a, T> {
fn new(lists: &'a [Vec<T>]) -> Self {
let done = lists.iter().any(|l| l.is_empty());
let indices = vec![0; lists.len()];
Self {
lists,
indices,
done,
}
}
}
impl<'a, T: Copy> Iterator for CartesianProduct<'a, T> {
type Item = Vec<T>;
fn next(&mut self) -> Option<Vec<T>> {
if self.done {
return None;
}
let item: Vec<T> = self
.lists
.iter()
.zip(self.indices.iter())
.map(|(l, &i)| l[i])
.collect();
let mut carry = true;
for k in (0..self.lists.len()).rev() {
if carry {
self.indices[k] += 1;
if self.indices[k] < self.lists[k].len() {
carry = false;
} else {
self.indices[k] = 0;
}
}
}
if carry {
self.done = true;
}
Some(item)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn test_empty_input() {
let result = find_mcs(&[]);
assert_eq!(result.atom_count(), 0);
}
#[test]
fn test_single_molecule() {
let benzene = parse("c1ccccc1").unwrap();
let result = find_mcs(&[&benzene]);
assert_eq!(result.atom_count(), 6);
}
#[test]
fn test_ethane_propane() {
let a = parse("CC").unwrap();
let b = parse("CCC").unwrap();
let result = find_mcs(&[&a, &b]);
assert!(
result.atom_count() >= 2,
"Expected >= 2 atoms, got {}",
result.atom_count()
);
}
#[test]
fn test_methane_ethanol() {
let a = parse("C").unwrap();
let b = parse("CCO").unwrap();
let result = find_mcs(&[&a, &b]);
assert!(result.atom_count() >= 1);
}
#[test]
fn test_benzene_toluene() {
let a = parse("c1ccccc1").unwrap();
let b = parse("Cc1ccccc1").unwrap();
let result = find_mcs(&[&a, &b]);
assert_eq!(
result.atom_count(),
6,
"Expected benzene ring (6 atoms), got {}",
result.atom_count()
);
}
#[test]
fn test_no_common_atoms() {
let a = parse("N").unwrap(); let b = parse("O").unwrap(); let result = find_mcs(&[&a, &b]);
assert_eq!(result.atom_count(), 0);
}
#[test]
fn test_identical_molecules() {
let a = parse("CC").unwrap();
let b = parse("CC").unwrap();
let result = find_mcs(&[&a, &b]);
assert_eq!(result.atom_count(), 2);
}
#[test]
fn test_match_bonds_false() {
let config = McsConfig {
match_bonds: false,
..McsConfig::default()
};
let a = parse("CC").unwrap();
let b = parse("C=C").unwrap();
let result = find_mcs_with_config(&[&a, &b], &config);
assert!(result.atom_count() >= 2);
}
#[test]
fn test_min_atoms_filter() {
let config = McsConfig {
min_atoms: 5,
..McsConfig::default()
};
let a = parse("CC").unwrap();
let b = parse("CCC").unwrap();
let result = find_mcs_with_config(&[&a, &b], &config);
assert_eq!(result.atom_count(), 0);
}
#[test]
fn test_timeout_does_not_panic() {
let config = McsConfig {
timeout_ms: Some(1),
..McsConfig::default()
}; let a = parse("c1ccccc1").unwrap();
let b = parse("Cc1ccccc1").unwrap();
let _ = find_mcs_with_config(&[&a, &b], &config);
}
#[test]
fn test_result_matches_all_inputs() {
use crate::find_matches;
let a = parse("c1ccccc1").unwrap();
let b = parse("Cc1ccccc1").unwrap();
let result = find_mcs(&[&a, &b]);
if result.atom_count() > 0 {
assert!(!find_matches(&result, &a).is_empty());
assert!(!find_matches(&result, &b).is_empty());
}
}
#[test]
fn test_aspirin_benzoic_acid() {
let a = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
let b = parse("OC(=O)c1ccccc1").unwrap();
let result = find_mcs(&[&a, &b]);
assert!(
result.atom_count() >= 7,
"Expected >= 7 atoms for aspirin/benzoic acid MCS, got {}",
result.atom_count()
);
}
#[test]
fn test_ring_matches_ring_only_blocks_ring_to_nonring() {
let a = parse("C1CCCCC1").unwrap();
let b = parse("CCCCCC").unwrap();
let default_result = find_mcs(&[&a, &b]);
assert!(
default_result.atom_count() > 0,
"Default MCS should be non-zero"
);
let config = McsConfig {
ring_matches_ring_only: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&a, &b], &config);
assert_eq!(
result.atom_count(),
0,
"ring_matches_ring_only: ring C must not match chain C, expected 0 got {}",
result.atom_count()
);
}
#[test]
fn test_ring_matches_ring_only_preserves_ring_only_mcs() {
let a = parse("c1ccccc1").unwrap();
let b = parse("Cc1ccccc1").unwrap();
let config = McsConfig {
ring_matches_ring_only: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&a, &b], &config);
assert_eq!(
result.atom_count(),
6,
"benzene ring should still be the MCS"
);
}
#[test]
fn test_complete_rings_only_quinoline_series() {
let a = parse("c1ccc2nc(CC)ccc2c1").unwrap();
let b = parse("c1ccc2nc(CO)ccc2c1").unwrap();
let c = parse("c1ccc2nc(CN)ccc2c1").unwrap();
let config = McsConfig {
complete_rings_only: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&a, &b, &c], &config);
assert!(
result.atom_count() >= 10,
"Expected at least quinoline scaffold (10) with complete_rings_only, got {}",
result.atom_count()
);
}
#[test]
fn test_complete_rings_only_does_not_break_full_ring_mcs() {
let a = parse("c1ccccc1").unwrap();
let b = parse("Cc1ccccc1").unwrap();
let config = McsConfig {
complete_rings_only: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&a, &b], &config);
assert_eq!(
result.atom_count(),
6,
"Full benzene ring should be preserved"
);
}
#[test]
fn test_complete_rings_only_pruning_fires() {
let a = parse("c1ccccc1").unwrap();
let b = parse("c1ccc2ccccc2c1").unwrap();
let default_result = find_mcs(&[&a, &b]);
let config = McsConfig {
complete_rings_only: true,
..McsConfig::default()
};
let pruned_result = find_mcs_with_config(&[&a, &b], &config);
eprintln!(
"benzene vs naphthalene — raw MCS: {}, complete_rings_only: {}",
default_result.atom_count(),
pruned_result.atom_count()
);
assert_eq!(
default_result.atom_count(),
6,
"Default MCS should be benzene ring (6 atoms)"
);
assert_eq!(
pruned_result.atom_count(),
6,
"complete_rings_only should preserve the full 6-ring (no partial ring)"
);
}
#[test]
fn test_complete_rings_only_prunes_partial_fused_ring() {
let benzocyclobutene = parse("C1Cc2ccccc21").unwrap();
let toluene = parse("Cc1ccccc1").unwrap();
let default_result = find_mcs(&[&benzocyclobutene, &toluene]);
let config = McsConfig {
complete_rings_only: true,
..McsConfig::default()
};
let pruned_result = find_mcs_with_config(&[&benzocyclobutene, &toluene], &config);
assert!(
default_result.atom_count() > 0,
"default MCS should be non-empty"
);
assert_eq!(
pruned_result.atom_count(),
0,
"complete_rings_only must prune to 0 when partial ring cascades"
);
}
#[test]
fn test_complete_rings_only_quinoline_raw_vs_pruned() {
let a = parse("c1ccc2nc(CC)ccc2c1").unwrap();
let b = parse("c1ccc2nc(CO)ccc2c1").unwrap();
let c = parse("c1ccc2nc(CN)ccc2c1").unwrap();
let default_result = find_mcs(&[&a, &b, &c]);
let config = McsConfig {
complete_rings_only: true,
..McsConfig::default()
};
let pruned_result = find_mcs_with_config(&[&a, &b, &c], &config);
eprintln!(
"quinoline series raw MCS: {}, with complete_rings_only: {}",
default_result.atom_count(),
pruned_result.atom_count()
);
}
#[test]
fn test_ring_matches_ring_only_and_complete_rings_only_combined() {
let a = parse("c1ccc2nc(CC)ccc2c1").unwrap();
let b = parse("c1ccc2nc(CO)ccc2c1").unwrap();
let config = McsConfig {
ring_matches_ring_only: true,
complete_rings_only: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&a, &b], &config);
assert!(
result.atom_count() >= 10,
"Combined flags should yield at least the quinoline scaffold, got {}",
result.atom_count()
);
}
#[test]
fn test_match_chiral_tag_false_matches_enantiomers() {
let r_ala = parse("[C@H](C)(N)C(=O)O").unwrap();
let s_ala = parse("[C@@H](C)(N)C(=O)O").unwrap();
let default_result = find_mcs(&[&r_ala, &s_ala]);
assert_eq!(
default_result.atom_count(),
r_ala.atom_count(),
"Default (match_chiral_tag=false) should match all atoms of enantiomers"
);
}
#[test]
fn test_match_chiral_tag_true_blocks_stereocenters() {
let r_ala = parse("[C@H](C)(N)C(=O)O").unwrap();
let s_ala = parse("[C@@H](C)(N)C(=O)O").unwrap();
let default_result = find_mcs(&[&r_ala, &s_ala]);
let config = McsConfig {
match_chiral_tag: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&r_ala, &s_ala], &config);
assert!(
result.atom_count() < default_result.atom_count(),
"match_chiral_tag=true should find smaller MCS than default: {} vs {}",
result.atom_count(),
default_result.atom_count()
);
}
#[test]
fn test_match_chiral_tag_true_matches_same_stereoisomer() {
let r_ala1 = parse("[C@H](C)(N)C(=O)O").unwrap();
let r_ala2 = parse("[C@H](C)(N)C(=O)O").unwrap();
let config = McsConfig {
match_chiral_tag: true,
..McsConfig::default()
};
let result = find_mcs_with_config(&[&r_ala1, &r_ala2], &config);
assert_eq!(
result.atom_count(),
r_ala1.atom_count(),
"match_chiral_tag=true should still match molecules with same stereochemistry"
);
}
}