#![forbid(unsafe_code)]
use std::collections::{HashMap, HashSet};
use chematic_core::{Atom, AtomIdx, BondIdx, BondOrder, Element, Molecule, MoleculeBuilder};
use chematic_perception::find_sssr;
use chematic_smiles::canonical_smiles;
pub struct ScaffoldNetwork {
pub scaffolds: Vec<Molecule>,
pub counts: Vec<usize>,
pub parents: Vec<Option<usize>>,
}
pub fn murcko_scaffold(mol: &Molecule) -> Molecule {
let rings = find_sssr(mol);
if rings.ring_count() == 0 {
return MoleculeBuilder::new().build();
}
let mut scaffold_atoms: HashSet<AtomIdx> = rings
.rings()
.iter()
.flat_map(|r| r.iter().copied())
.collect();
loop {
let mut changed = false;
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
if scaffold_atoms.contains(&idx) {
continue;
}
let scaffold_neighbors = mol
.neighbors(idx)
.filter(|(nb, _)| scaffold_atoms.contains(nb))
.count();
if scaffold_neighbors >= 2 {
scaffold_atoms.insert(idx);
changed = true;
}
}
if !changed {
break;
}
}
build_subgraph(mol, &scaffold_atoms)
}
pub fn generic_murcko_scaffold(mol: &Molecule) -> Molecule {
let scaffold = murcko_scaffold(mol);
if scaffold.atom_count() == 0 {
return scaffold;
}
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..scaffold.atom_count() {
let new_idx = builder.add_atom(Atom::organic(Element::C));
remap.insert(AtomIdx(i as u32), new_idx);
}
for i in 0..scaffold.bond_count() {
let bond = scaffold.bond(BondIdx(i as u32));
if let (Some(&new_a), Some(&new_b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(new_a, new_b, BondOrder::Single);
}
}
builder.build()
}
fn build_subgraph(mol: &Molecule, atom_set: &HashSet<AtomIdx>) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let old_idx = AtomIdx(i as u32);
if atom_set.contains(&old_idx) {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
}
for i in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(i as u32));
if let (Some(&new_a), Some(&new_b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(new_a, new_b, bond.order);
}
}
builder.build()
}
pub fn scaffold_network(mol: &Molecule) -> Vec<Molecule> {
let start = murcko_scaffold(mol);
if start.atom_count() == 0 {
return Vec::new();
}
let mut network: Vec<Molecule> = Vec::new();
let mut current = start;
loop {
let snapshot: HashSet<AtomIdx> = (0..current.atom_count())
.map(|i| AtomIdx(i as u32))
.collect();
network.push(build_subgraph(¤t, &snapshot));
let rings = find_sssr(¤t);
if rings.ring_count() <= 1 {
break; }
match schuffenhauer_remove_ring(¤t, &rings) {
Some(next) => {
current = next;
}
None => break,
}
}
network
}
pub fn schuffenhauer_parents(mol: &Molecule) -> Vec<Molecule> {
let start = murcko_scaffold(mol);
let rings = find_sssr(&start);
if rings.ring_count() <= 1 {
return Vec::new();
}
schuffenhauer_remove_ring(&start, &rings)
.into_iter()
.collect()
}
fn schuffenhauer_remove_ring(
mol: &Molecule,
rings: &chematic_perception::RingSet,
) -> Option<Molecule> {
let n_rings = rings.ring_count();
if n_rings == 0 {
return None;
}
let all_rings: Vec<Vec<AtomIdx>> = rings.rings().to_vec();
let mut candidates: Vec<usize> = (0..n_rings)
.filter(|&ri| {
let exclusive: usize = all_rings[ri]
.iter()
.filter(|&&atom| {
all_rings
.iter()
.enumerate()
.filter(|(j, _)| *j != ri)
.all(|(_, other)| !other.contains(&atom))
})
.count();
exclusive > 0
})
.collect();
if candidates.is_empty() {
candidates = (0..n_rings).collect();
}
let carbon_only: Vec<usize> = candidates
.iter()
.copied()
.filter(|&ri| {
all_rings[ri]
.iter()
.all(|&a| mol.atom(a).element.atomic_number() == 6)
})
.collect();
if !carbon_only.is_empty() {
candidates = carbon_only;
}
let min_size = candidates
.iter()
.map(|&ri| all_rings[ri].len())
.min()
.unwrap();
candidates.retain(|&ri| all_rings[ri].len() == min_size);
let count_heteroatoms = |ri: usize| {
all_rings[ri]
.iter()
.filter(|&&a| mol.atom(a).element.atomic_number() == 7)
.count()
};
let min_nitrogen = candidates
.iter()
.map(|&ri| count_heteroatoms(ri))
.min()
.unwrap();
candidates.retain(|&ri| count_heteroatoms(ri) == min_nitrogen);
let count_substituents = |ri: usize| {
all_rings[ri]
.iter()
.filter(|&&atom| {
mol.neighbors(atom).any(|(nb, _)| {
!all_rings[ri].contains(&nb) && mol.atom(nb).element.atomic_number() != 1
})
})
.count()
};
let min_subs = candidates
.iter()
.map(|&ri| count_substituents(ri))
.min()
.unwrap();
candidates.retain(|&ri| count_substituents(ri) == min_subs);
let prefer_5ring: Vec<usize> = candidates
.iter()
.copied()
.filter(|&ri| all_rings[ri].len() == 5)
.collect();
if !prefer_5ring.is_empty() {
candidates = prefer_5ring;
}
let count_linker_bonds = |ri: usize| {
all_rings[ri]
.iter()
.filter(|&&atom| {
all_rings
.iter()
.enumerate()
.any(|(j, other)| j != ri && other.contains(&atom))
})
.count()
};
let min_linker = candidates
.iter()
.map(|&ri| count_linker_bonds(ri))
.min()
.unwrap();
candidates.retain(|&ri| count_linker_bonds(ri) == min_linker);
candidates.sort_by_key(|&ri| all_rings[ri].iter().map(|a| a.0).min().unwrap_or(0));
let chosen_ring = candidates[0];
let to_delete: HashSet<AtomIdx> = all_rings[chosen_ring]
.iter()
.copied()
.filter(|&atom| {
all_rings
.iter()
.enumerate()
.filter(|(j, _)| *j != chosen_ring)
.all(|(_, other)| !other.contains(&atom))
})
.collect();
if to_delete.is_empty() {
return None; }
let keep: HashSet<AtomIdx> = (0..mol.atom_count())
.map(|i| AtomIdx(i as u32))
.filter(|a| !to_delete.contains(a))
.collect();
if keep.is_empty() {
return None;
}
Some(build_subgraph(mol, &keep))
}
pub fn scaffold_network_with_counts(mols: &[Molecule]) -> ScaffoldNetwork {
let mut smi_counts: HashMap<String, usize> = HashMap::new();
let mut smi_parents: HashMap<String, Option<String>> = HashMap::new();
let mut scaffolds_list: Vec<(String, Molecule)> = Vec::new(); let mut seen_smiles: HashSet<String> = HashSet::new();
for mol in mols {
let network = scaffold_network(mol);
for (i, scaffold) in network.iter().enumerate() {
let smiles = canonical_smiles(scaffold);
smi_counts
.entry(smiles.clone())
.and_modify(|c| *c += 1)
.or_insert(1);
if i > 0 {
let parent_smiles = canonical_smiles(&network[i - 1]);
smi_parents
.entry(smiles.clone())
.or_insert(Some(parent_smiles));
} else {
smi_parents.entry(smiles.clone()).or_insert(None);
}
if !seen_smiles.contains(&smiles) {
seen_smiles.insert(smiles.clone());
let atom_set: HashSet<AtomIdx> = (0..scaffold.atom_count())
.map(|i| AtomIdx(i as u32))
.collect();
let rebuilt = build_subgraph(scaffold, &atom_set);
scaffolds_list.push((smiles, rebuilt));
}
}
}
scaffolds_list.sort_by(|a, b| a.0.cmp(&b.0));
let smiles_vec: Vec<String> = scaffolds_list.iter().map(|(smi, _)| smi.clone()).collect();
let smi_to_idx: HashMap<String, usize> = smiles_vec
.iter()
.enumerate()
.map(|(idx, smi)| (smi.clone(), idx))
.collect();
let (_, scaffolds_vec): (Vec<_>, Vec<_>) = scaffolds_list.into_iter().unzip();
let counts: Vec<usize> = smiles_vec
.iter()
.map(|smi| smi_counts.get(smi).copied().unwrap_or(0))
.collect();
let parents: Vec<Option<usize>> = smiles_vec
.iter()
.map(|smi| {
smi_parents
.get(smi)
.cloned()
.flatten()
.and_then(|parent_smi| smi_to_idx.get(&parent_smi).copied())
})
.collect();
ScaffoldNetwork {
scaffolds: scaffolds_vec,
counts,
parents,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn murcko_benzene_preserves_all_atoms() {
let mol = parse("c1ccccc1").unwrap();
let scaffold = murcko_scaffold(&mol);
assert_eq!(scaffold.atom_count(), 6);
}
#[test]
fn murcko_toluene_removes_methyl() {
let mol = parse("Cc1ccccc1").unwrap();
let scaffold = murcko_scaffold(&mol);
assert_eq!(scaffold.atom_count(), 6, "methyl group should be removed");
}
#[test]
fn murcko_ethylbenzene_removes_chain() {
let mol = parse("CCc1ccccc1").unwrap();
let scaffold = murcko_scaffold(&mol);
assert_eq!(scaffold.atom_count(), 6, "ethyl chain should be removed");
}
#[test]
fn murcko_acyclic_returns_empty() {
let mol = parse("CC").unwrap();
let scaffold = murcko_scaffold(&mol);
assert_eq!(scaffold.atom_count(), 0);
}
#[test]
fn generic_murcko_benzene_all_carbon_single() {
let mol = parse("c1ccccc1").unwrap();
let generic = generic_murcko_scaffold(&mol);
assert_eq!(generic.atom_count(), 6);
for i in 0..generic.atom_count() {
let atom = generic.atom(AtomIdx(i as u32));
assert_eq!(atom.element, Element::C, "all atoms should be carbon");
}
for i in 0..generic.bond_count() {
let bond = generic.bond(chematic_core::BondIdx(i as u32));
assert_eq!(bond.order, BondOrder::Single, "all bonds should be Single");
}
}
#[test]
fn murcko_biphenyl_keeps_all_ring_atoms() {
let mol = parse("c1ccccc1c1ccccc1").unwrap();
let scaffold = murcko_scaffold(&mol);
assert!(
scaffold.atom_count() >= 12,
"biphenyl scaffold should have at least 12 atoms, got {}",
scaffold.atom_count()
);
}
#[test]
fn scaffold_network_with_counts_empty_input() {
let network = scaffold_network_with_counts(&[]);
assert!(
network.scaffolds.is_empty(),
"empty input should yield empty network"
);
}
#[test]
fn scaffold_network_with_counts_single_molecule() {
let mol = parse("c1ccc2ccccc2c1").unwrap();
let network = scaffold_network_with_counts(&[mol]);
assert!(
!network.scaffolds.is_empty(),
"naphthalene should yield at least one scaffold"
);
assert_eq!(
network.scaffolds.len(),
network.counts.len(),
"scaffolds and counts must have same length"
);
assert_eq!(
network.scaffolds.len(),
network.parents.len(),
"scaffolds and parents must have same length"
);
}
#[test]
fn scaffold_network_with_counts_duplicate_scaffolds() {
let mol1 = parse("c1ccc2c(c1)ccc1ccccc12").unwrap(); let mol2 = parse("c1ccc2c(c1)ccc1ccccc12").unwrap(); let network = scaffold_network_with_counts(&[mol1, mol2]);
for count in &network.counts {
assert!(
*count >= 1,
"each scaffold should appear at least once (but may appear multiple times)"
);
}
let has_count_2_or_more = network.counts.iter().any(|&c| c >= 2);
assert!(
has_count_2_or_more,
"at least one scaffold should have count >= 2 from duplicate molecules"
);
}
#[test]
fn scaffold_network_with_counts_parent_relationships() {
let mol = parse("c1ccc2c(c1)ccc1ccccc12").unwrap(); let network = scaffold_network_with_counts(&[mol]);
assert!(
network.scaffolds.len() > 1,
"Anthracene should yield multiple scaffold layers"
);
let root_count = network.parents.iter().filter(|p| p.is_none()).count();
assert_eq!(root_count, 1, "should have exactly one root scaffold");
}
#[test]
fn scaffold_network_with_counts_multiple_molecules() {
let benzene = parse("c1ccccc1").unwrap();
let naphthalene = parse("c1ccc2ccccc2c1").unwrap();
let network = scaffold_network_with_counts(&[benzene, naphthalene]);
assert!(
network.scaffolds.len() >= 2,
"network should include scaffolds from both benzene and naphthalene"
);
assert_eq!(
network.scaffolds.len(),
network.counts.len(),
"lengths must match"
);
}
}