use std::collections::{HashMap, HashSet};
use chematic_core::{Atom, AtomIdx, BondOrder, Molecule, MoleculeBuilder};
use chematic_smiles::canonical_smiles;
use crate::brics::brics_bonds;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MmpPair {
pub mol_a: String,
pub mol_b: String,
pub core: String,
pub fragment_a: String,
pub fragment_b: String,
}
pub fn find_mmp(mols: &[&Molecule]) -> Vec<MmpPair> {
let mol_smiles: Vec<String> = mols.iter().map(|m| canonical_smiles(m)).collect();
let mut index: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (mol_idx, mol) in mols.iter().enumerate() {
for (core_smi, sub_smi) in all_cuts(mol) {
index.entry(core_smi).or_default().push((mol_idx, sub_smi));
}
}
let mut seen: HashSet<(usize, usize, String, String, String)> = HashSet::new();
let mut pairs: Vec<MmpPair> = Vec::new();
for (core_smi, entries) in &index {
if entries.len() < 2 {
continue;
}
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let (mi, sub_i) = &entries[i];
let (mj, sub_j) = &entries[j];
if mi == mj || sub_i == sub_j {
continue; }
let (lo, hi, sub_lo, sub_hi) = if mi < mj {
(*mi, *mj, sub_i.clone(), sub_j.clone())
} else {
(*mj, *mi, sub_j.clone(), sub_i.clone())
};
let key = (lo, hi, core_smi.clone(), sub_lo.clone(), sub_hi.clone());
if seen.insert(key) {
pairs.push(MmpPair {
mol_a: mol_smiles[lo].clone(),
mol_b: mol_smiles[hi].clone(),
core: core_smi.clone(),
fragment_a: sub_lo,
fragment_b: sub_hi,
});
}
}
}
}
pairs.sort_by(|a, b| {
a.mol_a
.cmp(&b.mol_a)
.then(a.mol_b.cmp(&b.mol_b))
.then(a.core.cmp(&b.core))
});
pairs
}
fn all_cuts(mol: &Molecule) -> Vec<(String, String)> {
let mut result = Vec::new();
for (a1, a2) in brics_bonds(mol) {
let side1 = atoms_on_side(mol, a1, a2);
let side2 = atoms_on_side(mol, a2, a1);
let (sub, core, at_sub, at_core) = if side1.len() <= side2.len() {
(side1, side2, a1, a2)
} else {
(side2, side1, a2, a1)
};
let core_smi = fragment_smiles(mol, &core, at_core);
let sub_smi = fragment_smiles(mol, &sub, at_sub);
result.push((core_smi, sub_smi));
}
result
}
fn atoms_on_side(mol: &Molecule, from: AtomIdx, not_via: AtomIdx) -> HashSet<AtomIdx> {
let mut visited = HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back(from);
while let Some(idx) = queue.pop_front() {
if visited.contains(&idx) {
continue;
}
visited.insert(idx);
for (nb, _) in mol.neighbors(idx) {
if nb != not_via && !visited.contains(&nb) {
queue.push_back(nb);
}
}
}
visited
}
fn build_fragment_mol(
mol: &Molecule,
side: &HashSet<AtomIdx>,
attach: AtomIdx,
) -> chematic_core::Molecule {
let mut builder = MoleculeBuilder::new();
let mut idx_map: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut wc = Atom::new(chematic_core::Element::C);
wc.wildcard = true;
let wc_idx = builder.add_atom(wc);
for &orig in side {
let atom = mol.atom(orig);
let mut a = Atom::new(atom.element);
a.charge = atom.charge;
a.isotope = atom.isotope;
a.aromatic = atom.aromatic;
a.chirality = atom.chirality;
a.hydrogen_count = atom.hydrogen_count;
a.atom_map = atom.atom_map;
let new_idx = builder.add_atom(a);
idx_map.insert(orig, new_idx);
}
let _ = builder.add_bond(wc_idx, *idx_map.get(&attach).unwrap(), BondOrder::Single);
for (_, bond) in mol.bonds() {
if side.contains(&bond.atom1)
&& side.contains(&bond.atom2)
&& let (Some(&n1), Some(&n2)) = (idx_map.get(&bond.atom1), idx_map.get(&bond.atom2))
{
let _ = builder.add_bond(n1, n2, bond.order);
}
}
builder.build()
}
fn fragment_smiles(mol: &Molecule, side: &HashSet<AtomIdx>, attach: AtomIdx) -> String {
canonical_smiles(&build_fragment_mol(mol, side, attach))
}
#[derive(Debug, Clone)]
pub struct MmsMember {
pub smiles: String,
pub fragment: String,
pub mw: f64,
}
#[derive(Debug, Clone)]
pub struct MmsSeries {
pub core: String,
pub members: Vec<MmsMember>,
}
pub fn find_mms(mols: &[&Molecule]) -> Vec<MmsSeries> {
let mol_smiles: Vec<String> = mols.iter().map(|m| canonical_smiles(m)).collect();
let mut index: HashMap<String, Vec<(usize, String, f64)>> = HashMap::new();
for (mol_idx, mol) in mols.iter().enumerate() {
let mut seen: HashSet<(String, String)> = HashSet::new();
for (core_smi, sub_smi, sub_mw) in all_cuts_with_mw(mol) {
if seen.insert((core_smi.clone(), sub_smi.clone())) {
index
.entry(core_smi)
.or_default()
.push((mol_idx, sub_smi, sub_mw));
}
}
}
let mut series_list: Vec<MmsSeries> = Vec::new();
for (core_smi, entries) in &index {
let distinct_mols: HashSet<usize> = entries.iter().map(|(i, _, _)| *i).collect();
if distinct_mols.len() < 3 {
continue;
}
let mut members: Vec<MmsMember> = entries
.iter()
.map(|(mol_idx, sub_smi, sub_mw)| MmsMember {
smiles: mol_smiles[*mol_idx].clone(),
fragment: sub_smi.clone(),
mw: *sub_mw,
})
.collect();
members.sort_by(|a, b| a.mw.partial_cmp(&b.mw).unwrap_or(std::cmp::Ordering::Equal));
series_list.push(MmsSeries {
core: core_smi.clone(),
members,
});
}
series_list.sort_by(|a, b| a.core.cmp(&b.core));
series_list
}
fn all_cuts_with_mw(mol: &Molecule) -> Vec<(String, String, f64)> {
let mut result = Vec::new();
for (a1, a2) in brics_bonds(mol) {
let side1 = atoms_on_side(mol, a1, a2);
let side2 = atoms_on_side(mol, a2, a1);
let (sub, core, at_sub, at_core) = if side1.len() <= side2.len() {
(side1, side2, a1, a2)
} else {
(side2, side1, a2, a1)
};
let core_smi = fragment_smiles(mol, &core, at_core);
let (sub_smi, sub_mw) = fragment_smiles_with_mw(mol, &sub, at_sub);
result.push((core_smi, sub_smi, sub_mw));
}
result
}
fn fragment_smiles_with_mw(
mol: &Molecule,
side: &HashSet<AtomIdx>,
attach: AtomIdx,
) -> (String, f64) {
let frag_mol = build_fragment_mol(mol, side, attach);
let mw = crate::descriptors::molecular_weight(&frag_mol);
let smi = canonical_smiles(&frag_mol);
(smi, mw)
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
#[test]
fn mmp_ethylbenzene_propylbenzene() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let pairs = find_mmp(&[&a, &b]);
let matching: Vec<_> = pairs.iter().filter(|p| p.core == "c1c(cccc1)[*]").collect();
assert_eq!(
matching.len(),
1,
"expected 1 pair with benzene core, got: {pairs:?}"
);
let pair = &matching[0];
assert_eq!(
pair.fragment_a, "C(C)[*]",
"ethylbenzene substituent should be C(C)[*]: {pair:?}"
);
assert_eq!(
pair.fragment_b, "[*]CCC",
"propylbenzene substituent should be [*]CCC: {pair:?}"
);
}
#[test]
fn mmp_no_pairs_for_single_molecule() {
let a = mol("CCc1ccccc1");
let pairs = find_mmp(&[&a]);
assert!(pairs.is_empty(), "single molecule has no MMP pairs");
}
#[test]
fn mmp_no_pairs_when_no_brics_bonds() {
let a = mol("c1ccccc1");
let b = mol("c1ccncc1");
let pairs = find_mmp(&[&a, &b]);
assert!(
pairs.is_empty(),
"benzene/pyridine have no BRICS bonds, expect 0 pairs: {pairs:?}"
);
}
#[test]
fn mmp_dedup_direction() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let pairs = find_mmp(&[&a, &b]);
let n = pairs.len();
let pairs2 = find_mmp(&[&b, &a]);
let n2 = pairs2.len();
assert_eq!(n, n2, "pair count must be order-independent: {n} vs {n2}");
}
#[test]
fn mmp_three_molecules_correct_count() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let c = mol("CCCCc1ccccc1"); let pairs = find_mmp(&[&a, &b, &c]);
let benzene_pairs: Vec<_> = pairs.iter().filter(|p| p.core == "c1c(cccc1)[*]").collect();
assert_eq!(
benzene_pairs.len(),
3,
"3 molecules → 3 benzene-core MMP pairs: {pairs:?}"
);
}
#[test]
fn mms_three_alkylbenzenes_one_series() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let c = mol("CCCCc1ccccc1");
let series = find_mms(&[&a, &b, &c]);
let benzene_series: Vec<_> = series
.iter()
.filter(|s| s.core == "c1c(cccc1)[*]")
.collect();
assert_eq!(
benzene_series.len(),
1,
"expected 1 MMS series with benzene core: {series:?}"
);
let s = &benzene_series[0];
assert_eq!(s.members.len(), 3, "series should have 3 members");
let mws: Vec<f64> = s.members.iter().map(|m| m.mw).collect();
assert!(
mws.windows(2).all(|w| w[0] <= w[1] + 1e-6),
"members not sorted by MW: {mws:?}"
);
}
#[test]
fn mms_two_molecules_no_series() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let series = find_mms(&[&a, &b]);
assert!(
series.is_empty(),
"2 molecules cannot form a MMS (need ≥3): {series:?}"
);
}
#[test]
fn mms_member_mw_excludes_wildcard() {
let a = mol("CCc1ccccc1");
let b = mol("CCCc1ccccc1");
let c = mol("CCCCc1ccccc1");
let series = find_mms(&[&a, &b, &c]);
let s = series
.iter()
.find(|s| s.core == "c1c(cccc1)[*]")
.expect("benzene-core series");
let ethyl_mw = s.members[0].mw;
assert!(
(ethyl_mw - 29.06).abs() < 0.5,
"ethyl MW should be ~29.06, got {ethyl_mw} (wildcard atom leaked?)"
);
let propyl_mw = s.members[1].mw;
assert!(propyl_mw > ethyl_mw);
assert!(s.members[2].mw < 70.0, "butyl MW should be < 70");
}
#[test]
fn mms_no_brics_bonds_no_series() {
let a = mol("c1ccccc1");
let b = mol("c1ccncc1");
let c = mol("c1cccnc1");
let series = find_mms(&[&a, &b, &c]);
assert!(
series.is_empty(),
"aromatic rings without BRICS bonds → no series: {series:?}"
);
}
}