use std::collections::{HashMap, HashSet, VecDeque};
use chematic_core::{Atom, AtomIdx, BondIdx, BondOrder, Molecule, MoleculeBuilder};
use chematic_perception::find_sssr;
pub fn brics_bonds(mol: &Molecule) -> Vec<(AtomIdx, AtomIdx)> {
let ring_bond_set = ring_bond_keys(mol);
let mut result = Vec::new();
for bidx in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(bidx as u32));
let (a, b) = (bond.atom1, bond.atom2);
if !matches!(bond.order, BondOrder::Single | BondOrder::Up | BondOrder::Down) {
continue;
}
if ring_bond_set.contains(&bond_key(a, b)) {
continue;
}
if is_brics_breakable(mol, a, b) {
result.push((a, b));
}
}
result
}
fn bond_key(a: AtomIdx, b: AtomIdx) -> (u32, u32) {
(a.0.min(b.0), a.0.max(b.0))
}
fn ring_bond_keys(mol: &Molecule) -> HashSet<(u32, u32)> {
let mut set = HashSet::new();
for ring in find_sssr(mol).rings() {
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
set.insert(bond_key(a, b));
}
}
set
}
#[derive(Debug, Clone)]
pub struct BricsConfig {
pub min_fragment_size: usize,
}
impl Default for BricsConfig {
fn default() -> Self {
Self { min_fragment_size: 1 }
}
}
pub fn brics_fragments_with_config(mol: &Molecule, config: &BricsConfig) -> Vec<Molecule> {
brics_fragments(mol)
.into_iter()
.filter(|frag| {
frag.atoms().filter(|(_, a)| !a.wildcard).count() >= config.min_fragment_size
})
.collect()
}
pub fn brics_fragments(mol: &Molecule) -> Vec<Molecule> {
let bonds: Vec<(AtomIdx, AtomIdx)> = brics_bonds(mol);
if bonds.is_empty() {
return vec![copy_molecule(mol)];
}
let break_set: HashSet<(u32, u32)> = bonds.iter().map(|&(a, b)| bond_key(a, b)).collect();
let mut builder = MoleculeBuilder::new();
let mut old_to_new: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for (old_idx, atom) in mol.atoms() {
let new_idx = builder.add_atom(atom.clone());
old_to_new.insert(old_idx, new_idx);
}
for bidx in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(bidx as u32));
let (a, b) = (bond.atom1, bond.atom2);
let new_a = old_to_new[&a];
let new_b = old_to_new[&b];
if break_set.contains(&bond_key(a, b)) {
let wa = builder.add_atom(Atom::wildcard());
let wb = builder.add_atom(Atom::wildcard());
let _ = builder.add_bond(new_a, wa, BondOrder::Single);
let _ = builder.add_bond(new_b, wb, BondOrder::Single);
} else {
let _ = builder.add_bond(new_a, new_b, bond.order);
}
}
split_into_components(&builder.build())
}
fn is_carbonyl_c(mol: &Molecule, idx: AtomIdx) -> bool {
let at = mol.atom(idx);
at.element.atomic_number() == 6
&& !at.aromatic
&& mol.neighbors(idx).any(|(nb, bid)| {
mol.atom(nb).element.atomic_number() == 8
&& mol.bond(bid).order == BondOrder::Double
})
}
fn is_brics_breakable(mol: &Molecule, a: AtomIdx, b: AtomIdx) -> bool {
let atom_a = mol.atom(a);
let atom_b = mol.atom(b);
let an_a = atom_a.element.atomic_number();
let an_b = atom_b.element.atomic_number();
let deg_a = mol.degree(a);
let deg_b = mol.degree(b);
let arom_a = atom_a.aromatic;
let arom_b = atom_b.aromatic;
let carb_a = is_carbonyl_c(mol, a);
let carb_b = is_carbonyl_c(mol, b);
let is_amide_ester_c =
|an: u8, arom: bool, deg: usize, carb: bool| an == 6 && !arom && deg == 3 && carb;
let is_ali_c_internal =
|an: u8, arom: bool, deg: usize, carb: bool| an == 6 && !arom && deg > 1 && !carb;
let is_ali_n = |an: u8, arom: bool, deg: usize| an == 7 && !arom && deg > 1;
let is_thioether_s = |an: u8, arom: bool, deg: usize| an == 16 && !arom && deg == 2;
let is_aromatic_c = |an: u8, arom: bool| an == 6 && arom;
let is_aromatic_n = |an: u8, arom: bool| an == 7 && arom;
let l1_partner = |an: u8, arom: bool, deg: usize| {
(an == 6 && !arom && deg > 1) || (an == 7 && !arom && deg > 1) || is_thioether_s(an, arom, deg) };
if is_amide_ester_c(an_a, arom_a, deg_a, carb_a) && l1_partner(an_b, arom_b, deg_b) {
return true;
}
if is_amide_ester_c(an_b, arom_b, deg_b, carb_b) && l1_partner(an_a, arom_a, deg_a) {
return true;
}
let is_ether_o = |an: u8, arom: bool, deg: usize| an == 8 && !arom && deg == 2;
if (is_ether_o(an_a, arom_a, deg_a) && is_aromatic_c(an_b, arom_b))
|| (is_ether_o(an_b, arom_b, deg_b) && is_aromatic_c(an_a, arom_a))
{
return true;
}
if (is_ali_c_internal(an_a, arom_a, deg_a, carb_a) && is_aromatic_c(an_b, arom_b))
|| (is_ali_c_internal(an_b, arom_b, deg_b, carb_b) && is_aromatic_c(an_a, arom_a))
{
return true;
}
if (is_ali_c_internal(an_a, arom_a, deg_a, carb_a) && is_ali_n(an_b, arom_b, deg_b))
|| (is_ali_c_internal(an_b, arom_b, deg_b, carb_b) && is_ali_n(an_a, arom_a, deg_a))
{
return true;
}
if (is_ali_c_internal(an_a, arom_a, deg_a, carb_a) && is_aromatic_n(an_b, arom_b))
|| (is_ali_c_internal(an_b, arom_b, deg_b, carb_b) && is_aromatic_n(an_a, arom_a))
{
return true;
}
if (is_aromatic_c(an_a, arom_a) && is_ali_n(an_b, arom_b, deg_b))
|| (is_aromatic_c(an_b, arom_b) && is_ali_n(an_a, arom_a, deg_a))
{
return true;
}
if is_ali_c_internal(an_a, arom_a, deg_a, carb_a)
&& is_ali_c_internal(an_b, arom_b, deg_b, carb_b)
{
return true;
}
if (is_thioether_s(an_a, arom_a, deg_a) && is_aromatic_c(an_b, arom_b))
|| (is_thioether_s(an_b, arom_b, deg_b) && is_aromatic_c(an_a, arom_a))
{
return true;
}
let is_ali_c_d_gt1 = |an: u8, arom: bool, deg: usize| an == 6 && !arom && deg > 1;
if (is_ali_c_d_gt1(an_a, arom_a, deg_a) && is_thioether_s(an_b, arom_b, deg_b))
|| (is_ali_c_d_gt1(an_b, arom_b, deg_b) && is_thioether_s(an_a, arom_a, deg_a))
{
return true;
}
if is_aromatic_c(an_a, arom_a) && is_aromatic_c(an_b, arom_b) && !atoms_share_ring(mol, a, b) {
return true;
}
if is_aromatic_n(an_a, arom_a) && is_aromatic_n(an_b, arom_b) {
return true;
}
false
}
fn atoms_share_ring(mol: &Molecule, a: AtomIdx, b: AtomIdx) -> bool {
find_sssr(mol)
.rings()
.iter()
.any(|ring| ring.contains(&a) && ring.contains(&b))
}
fn split_into_components(mol: &Molecule) -> Vec<Molecule> {
let n = mol.atom_count();
let mut visited = vec![false; n];
let mut components = Vec::new();
for start in 0..n {
if visited[start] {
continue;
}
let mut component: HashSet<AtomIdx> = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(AtomIdx(start as u32));
visited[start] = true;
while let Some(cur) = queue.pop_front() {
component.insert(cur);
for (nb, _) in mol.neighbors(cur) {
let ni = nb.0 as usize;
if !visited[ni] {
visited[ni] = true;
queue.push_back(nb);
}
}
}
components.push(build_subgraph(mol, &component));
}
components
}
fn build_subgraph(mol: &Molecule, atom_set: &HashSet<AtomIdx>) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut sorted: Vec<AtomIdx> = atom_set.iter().copied().collect();
sorted.sort_by_key(|a| a.0);
for &old_idx in &sorted {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
for bidx in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(bidx 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()
}
fn copy_molecule(mol: &Molecule) -> Molecule {
build_subgraph(mol, &(0..mol.atom_count()).map(|i| AtomIdx(i as u32)).collect())
}
#[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 test_brics_bonds_benzene_zero() {
assert_eq!(brics_bonds(&mol("c1ccccc1")).len(), 0);
}
#[test]
fn test_brics_bonds_ethane_zero() {
assert_eq!(brics_bonds(&mol("CC")).len(), 0);
}
#[test]
fn test_brics_bonds_propane_zero() {
assert_eq!(brics_bonds(&mol("CCC")).len(), 0);
}
#[test]
fn test_brics_bonds_butane_one() {
let bonds = brics_bonds(&mol("CCCC"));
assert_eq!(bonds.len(), 1, "butane central C-C is BRICS breakable");
}
#[test]
fn test_brics_bonds_toluene_one() {
let bonds = brics_bonds(&mol("Cc1ccccc1"));
assert_eq!(bonds.len(), 0, "toluene has no BRICS bonds (methyl C is D1)");
}
#[test]
fn test_brics_bonds_ethylbenzene_one() {
let bonds = brics_bonds(&mol("CCc1ccccc1"));
assert_eq!(bonds.len(), 1, "ethylbenzene has 1 BRICS bond (alkyl-aryl C-c)");
}
#[test]
fn test_brics_bonds_amide() {
let bonds = brics_bonds(&mol("CC(=O)NC"));
assert!(bonds.len() >= 1, "amide C-N should be BRICS breakable");
}
#[test]
fn test_brics_bonds_ester() {
let bonds = brics_bonds(&mol("CC(=O)OC"));
assert!(bonds.len() <= 3, "methyl acetate should have at most 3 BRICS bonds");
}
#[test]
fn test_brics_bonds_aspirin() {
let bonds = brics_bonds(&mol("CC(=O)Oc1ccccc1C(=O)O"));
assert!(bonds.len() >= 1, "aspirin should have at least 1 BRICS bond, got {}", bonds.len());
}
#[test]
fn test_brics_fragments_benzene_no_cut() {
let frags = brics_fragments(&mol("c1ccccc1"));
assert_eq!(frags.len(), 1);
assert_eq!(frags[0].atom_count(), 6);
}
#[test]
fn test_brics_fragments_butane_two_pieces() {
let frags = brics_fragments(&mol("CCCC"));
assert_eq!(frags.len(), 2, "butane should split into 2 fragments");
for frag in &frags {
assert!(
frag.atoms().any(|(_, a)| a.wildcard),
"each fragment should have a [*] attachment point"
);
}
}
#[test]
fn test_brics_fragments_aspirin_multiple() {
let frags = brics_fragments(&mol("CC(=O)Oc1ccccc1C(=O)O"));
assert!(frags.len() >= 2, "aspirin should fragment into ≥ 2 pieces, got {}", frags.len());
}
#[test]
fn test_brics_fragments_atom_count_conservation() {
let mol = mol("CC(=O)Nc1ccccc1"); let n_cuts = brics_bonds(&mol).len();
let frags = brics_fragments(&mol);
let total_atoms: usize = frags.iter().map(|f| f.atom_count()).sum();
let expected = mol.atom_count() + 2 * n_cuts;
assert_eq!(total_atoms, expected, "atom count should be conserved (original + 2 per cut)");
}
#[test]
fn test_brics_fragments_all_valid_range() {
for smiles in &[
"CC(=O)Oc1ccccc1C(=O)O", "Cn1cnc2c1c(=O)n(c(=O)n2C)C", "CC(C)Cc1ccc(cc1)C(C)C(=O)O", "c1ccccc1", "CC", "CCCC", ] {
let m = mol(smiles);
let frags = brics_fragments(&m);
assert!(
!frags.is_empty() && frags.len() <= 20,
"'{smiles}' should give 1-20 fragments, got {}", frags.len()
);
}
}
}
#[cfg(test)]
mod mmp_probe {
use super::*;
use chematic_core::{Atom, AtomIdx, BondOrder, MoleculeBuilder};
use chematic_smiles::{canonical_smiles, parse};
use std::collections::{HashMap, HashSet, VecDeque};
fn atoms_on_side(mol: &chematic_core::Molecule, from: AtomIdx, not_via: AtomIdx) -> HashSet<AtomIdx> {
let mut vis = HashSet::new();
let mut q = VecDeque::new();
q.push_back(from);
while let Some(idx) = q.pop_front() {
if vis.contains(&idx) { continue; }
vis.insert(idx);
for (nb, _) in mol.neighbors(idx) {
if nb != not_via && !vis.contains(&nb) { q.push_back(nb); }
}
}
vis
}
fn frag_smiles(mol: &chematic_core::Molecule, side: &HashSet<AtomIdx>, attach: AtomIdx) -> String {
let mut b = MoleculeBuilder::new();
let mut idx_map = HashMap::new();
let mut wc = Atom::new(chematic_core::Element::C);
wc.wildcard = true;
let wc_new = b.add_atom(wc);
for &a in side {
let orig = mol.atom(a);
let mut na = Atom::new(orig.element);
na.charge = orig.charge; na.isotope = orig.isotope;
na.aromatic = orig.aromatic; na.chirality = orig.chirality;
na.hydrogen_count = orig.hydrogen_count;
idx_map.insert(a, b.add_atom(na));
}
b.add_bond(wc_new, *idx_map.get(&attach).unwrap(), BondOrder::Single).unwrap();
for (_, bond) in mol.bonds() {
if side.contains(&bond.atom1) && side.contains(&bond.atom2) {
let n1 = *idx_map.get(&bond.atom1).unwrap();
let n2 = *idx_map.get(&bond.atom2).unwrap();
let _ = b.add_bond(n1, n2, bond.order);
}
}
canonical_smiles(&b.build())
}
fn cut_into_parts(mol: &chematic_core::Molecule, a1: AtomIdx, a2: AtomIdx)
-> (HashSet<AtomIdx>, HashSet<AtomIdx>, AtomIdx, AtomIdx)
{
let s1 = atoms_on_side(mol, a1, a2);
let s2 = atoms_on_side(mol, a2, a1);
if s1.len() <= s2.len() { (s1, s2, a1, a2) } else { (s2, s1, a2, a1) }
}
fn all_cut_pairs(mol: &chematic_core::Molecule) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for (a1, a2) in brics_bonds(mol) {
let (sub, core, at_sub, at_core) = cut_into_parts(mol, a1, a2);
let core_smi = frag_smiles(mol, &core, at_core);
let sub_smi = frag_smiles(mol, &sub, at_sub);
pairs.push((core_smi, sub_smi));
}
pairs
}
#[test]
fn core_smiles_equal_for_ethylbenzene_and_propylbenzene() {
let ethylbenz = parse("CCc1ccccc1").unwrap();
let propylbenz = parse("CCCc1ccccc1").unwrap();
let eb_pairs = all_cut_pairs(ðylbenz);
let pb_pairs = all_cut_pairs(&propylbenz);
eprintln!("ethylbenzene cuts: {eb_pairs:?}");
eprintln!("propylbenzene cuts: {pb_pairs:?}");
let eb_cores: std::collections::HashSet<&str> =
eb_pairs.iter().map(|(c, _)| c.as_str()).collect();
let shared_cores: Vec<_> = pb_pairs.iter()
.filter(|(c, _)| eb_cores.contains(c.as_str()))
.collect();
assert!(!shared_cores.is_empty(),
"ethylbenzene and propylbenzene must share at least one core SMILES");
let (shared_core, pb_sub) = &shared_cores[0];
let eb_sub = eb_pairs.iter()
.find(|(c, _)| c == shared_core).map(|(_, s)| s.as_str()).unwrap();
eprintln!("shared core={shared_core} eb_sub={eb_sub} pb_sub={pb_sub}");
assert_ne!(eb_sub, pb_sub.as_str(),
"substituents must differ: got identical '{eb_sub}'");
}
}