1use crate::bitvec::BitVec2048;
20use crate::ecfp::fnv1a;
21use chematic_core::{Atom, AtomIdx, BondOrder, Molecule, implicit_hcount};
22use rustc_hash::{FxHashMap, FxHashSet};
23use std::collections::VecDeque;
24
25fn identify_functional_groups(mol: &Molecule) -> Vec<Vec<usize>> {
38 let n = mol.atom_count();
39 if n == 0 {
40 return Vec::new();
41 }
42
43 let mut is_hetero = vec![false; n];
45 for (idx, atom) in mol.atoms() {
46 let an = atom.element.atomic_number();
47 if an != 1 && an != 6 {
48 is_hetero[idx.0 as usize] = true;
49 }
50 }
51 let hetero_idxs: Vec<usize> = (0..n).filter(|&i| is_hetero[i]).collect();
52 if hetero_idxs.is_empty() {
53 return Vec::new();
54 }
55
56 let mut parent: Vec<usize> = (0..n).collect();
58
59 for &hi in &hetero_idxs {
61 for (nb, _) in mol.neighbors(AtomIdx(hi as u32)) {
62 let nbi = nb.0 as usize;
63 if is_hetero[nbi] {
64 uf_union(&mut parent, hi, nbi);
65 }
66 }
67 }
68
69 for ci in 0..n {
71 if mol.atom(AtomIdx(ci as u32)).element.atomic_number() != 6 {
72 continue;
73 }
74 let hetero_nbs: Vec<usize> = mol
75 .neighbors(AtomIdx(ci as u32))
76 .filter(|(nb, _)| is_hetero[nb.0 as usize])
77 .map(|(nb, _)| nb.0 as usize)
78 .collect();
79 if hetero_nbs.len() >= 2 {
80 for &hi in &hetero_nbs[1..] {
81 uf_union(&mut parent, hetero_nbs[0], hi);
82 }
83 }
84 }
85
86 let mut cluster_map: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
88 for &hi in &hetero_idxs {
89 let root = uf_find(&mut parent, hi);
90 cluster_map.entry(root).or_default().push(hi);
91 }
92
93 cluster_map
94 .into_values()
95 .map(|mut atoms| {
96 let hetero_set: FxHashSet<usize> = atoms.iter().copied().collect();
97 let snapshot = atoms.clone();
98 for &ai in &snapshot {
99 for (nb, _) in mol.neighbors(AtomIdx(ai as u32)) {
100 let nbi = nb.0 as usize;
101 let nb_an = mol.atom(nb).element.atomic_number();
102 if nb_an == 6 && !hetero_set.contains(&nbi) {
103 atoms.push(nbi);
104 }
105 }
106 }
107 atoms.sort_unstable();
108 atoms.dedup();
109 atoms
110 })
111 .collect()
112}
113
114fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
115 while parent[x] != x {
116 parent[x] = parent[parent[x]]; x = parent[x];
118 }
119 x
120}
121
122fn uf_union(parent: &mut [usize], a: usize, b: usize) {
123 let ra = uf_find(parent, a);
124 let rb = uf_find(parent, b);
125 if ra != rb {
126 parent[rb] = ra;
127 }
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
132pub struct ErgNodeType(pub u8);
133
134impl ErgNodeType {
135 const AROMATIC: u8 = 1;
136 const DONOR: u8 = 2;
137 const ACCEPTOR: u8 = 4;
138 const HYDROPHOBIC: u8 = 8;
139 const POSITIVE: u8 = 16;
140 const NEGATIVE: u8 = 32;
141
142 pub fn new() -> Self {
143 ErgNodeType(0)
144 }
145}
146
147impl Default for ErgNodeType {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl ErgNodeType {
154 pub fn with_aromatic(mut self) -> Self {
155 self.0 |= Self::AROMATIC;
156 self
157 }
158
159 pub fn with_donor(mut self) -> Self {
160 self.0 |= Self::DONOR;
161 self
162 }
163
164 pub fn with_acceptor(mut self) -> Self {
165 self.0 |= Self::ACCEPTOR;
166 self
167 }
168
169 pub fn with_hydrophobic(mut self) -> Self {
170 self.0 |= Self::HYDROPHOBIC;
171 self
172 }
173
174 pub fn with_positive(mut self) -> Self {
175 self.0 |= Self::POSITIVE;
176 self
177 }
178
179 pub fn with_negative(mut self) -> Self {
180 self.0 |= Self::NEGATIVE;
181 self
182 }
183}
184
185#[derive(Clone, Debug)]
187pub struct ErgNode {
188 pub ntype: ErgNodeType,
189 pub atom_indices: Vec<usize>,
190}
191
192#[derive(Clone, Debug)]
194pub struct ErgEdge {
195 pub node_a: usize,
196 pub node_b: usize,
197 pub linker_len: u32,
198}
199
200fn is_amide_like_nitrogen(mol: &Molecule, n_idx: AtomIdx) -> bool {
206 mol.neighbors(n_idx).any(|(nb, _)| {
207 let nb_an = mol.atom(nb).element.atomic_number();
208 match nb_an {
209 6 => mol.neighbors(nb).any(|(c_nb, bond_idx)| {
210 mol.atom(c_nb).element.atomic_number() == 8
211 && mol.bond(bond_idx).order == BondOrder::Double
212 }),
213 16 => mol.neighbors(nb).any(|(s_nb, bond_idx)| {
214 mol.atom(s_nb).element.atomic_number() == 8
215 && mol.bond(bond_idx).order == BondOrder::Double
216 }),
217 _ => false,
218 }
219 })
220}
221
222fn assign_pharmacophore_features(mol: &Molecule, atom_indices: &[usize]) -> ErgNodeType {
235 let mut ntype = ErgNodeType::new();
236
237 for &i in atom_indices {
238 let idx = AtomIdx(i as u32);
239 let atom = mol.atom(idx);
240 let an = atom.element.atomic_number();
241
242 if atom.aromatic {
243 ntype = ntype.with_aromatic();
244 }
245
246 let explicit_h: usize = mol
248 .neighbors(idx)
249 .filter(|(nb, _)| mol.atom(*nb).element.atomic_number() == 1)
250 .count();
251 let impl_h = implicit_hcount(mol, idx) as usize;
252 let total_h = explicit_h + impl_h;
253
254 if total_h > 0 && ((an == 8 || an == 16) || (an == 7 && !is_amide_like_nitrogen(mol, idx)))
258 {
259 ntype = ntype.with_donor();
260 }
261
262 match an {
264 7 if !atom.aromatic => ntype = ntype.with_acceptor(),
266 7 if atom.aromatic && total_h == 0 => ntype = ntype.with_acceptor(),
268 8 | 9 => ntype = ntype.with_acceptor(),
270 _ => {}
271 }
272
273 if atom.charge > 0 {
275 ntype = ntype.with_positive();
276 }
277 if atom.charge < 0 {
278 ntype = ntype.with_negative();
279 }
280 }
281
282 let all_c_or_h = atom_indices.iter().all(|&i| {
284 let an = mol.atom(AtomIdx(i as u32)).element.atomic_number();
285 an == 6 || an == 1
286 });
287 if all_c_or_h && ntype.0 == 0 {
288 ntype = ntype.with_hydrophobic();
289 }
290
291 ntype
292}
293
294fn build_reduced_graph(mol: &Molecule) -> (Vec<ErgNode>, Vec<ErgEdge>) {
297 let fg_groups = identify_functional_groups(mol);
298
299 let mut nodes: Vec<ErgNode> = fg_groups
301 .into_iter()
302 .map(|atom_indices| {
303 let ntype = assign_pharmacophore_features(mol, &atom_indices);
304 ErgNode {
305 ntype,
306 atom_indices,
307 }
308 })
309 .collect();
310
311 if nodes.is_empty() {
313 let all_atoms: Vec<usize> = (0..mol.atom_count()).collect();
314 let ntype = assign_pharmacophore_features(mol, &all_atoms);
315 nodes.push(ErgNode {
316 ntype,
317 atom_indices: all_atoms,
318 });
319 }
320
321 const MAX_ERG_NODES: usize = 128;
323 if nodes.len() > MAX_ERG_NODES {
324 nodes.truncate(MAX_ERG_NODES);
325 }
326
327 let mut edges = Vec::new();
329 let fg_set: FxHashSet<usize> = nodes.iter().flat_map(|n| n.atom_indices.clone()).collect();
330
331 for i in 0..nodes.len() {
332 for j in (i + 1)..nodes.len() {
333 let linker_len = shortest_path_linker(mol, &nodes[i], &nodes[j], &fg_set);
334 edges.push(ErgEdge {
335 node_a: i,
336 node_b: j,
337 linker_len,
338 });
339 }
340 }
341
342 (nodes, edges)
343}
344
345fn shortest_path_linker(
347 mol: &Molecule,
348 node_a: &ErgNode,
349 node_b: &ErgNode,
350 fg_set: &FxHashSet<usize>,
351) -> u32 {
352 let mut dist = vec![u32::MAX; mol.atom_count()];
353
354 let mut queue = VecDeque::new();
356 for &i in &node_a.atom_indices {
357 dist[i] = 0;
358 queue.push_back(i);
359 }
360
361 while let Some(cur) = queue.pop_front() {
362 for (nb, _) in mol.neighbors(AtomIdx(cur as u32)) {
363 let nbi = nb.0 as usize;
364
365 if node_b.atom_indices.contains(&nbi) {
368 return dist[cur] + 1;
369 }
370
371 if dist[nbi] == u32::MAX {
372 let new_dist = dist[cur] + if fg_set.contains(&nbi) { 0 } else { 1 };
374 dist[nbi] = new_dist;
375 queue.push_back(nbi);
376 }
377 }
378 }
379
380 u32::MAX
381}
382
383#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
385pub enum ErgAtomType {
386 CAliphatic = 0,
388 CAromatic = 1,
390 N = 2,
392 O = 3,
394 S = 4,
396 Halogen = 5,
398 Other = 6,
400}
401
402impl ErgAtomType {
403 pub fn from_atom(atom: &Atom) -> Self {
405 let an = atom.element.atomic_number();
406 let aromatic = atom.aromatic;
407
408 match an {
409 6 => {
410 if aromatic {
411 ErgAtomType::CAromatic
412 } else {
413 ErgAtomType::CAliphatic
414 }
415 }
416 7 => ErgAtomType::N,
417 8 => ErgAtomType::O,
418 16 => ErgAtomType::S,
419 9 | 17 | 35 | 53 => ErgAtomType::Halogen,
420 _ => ErgAtomType::Other,
421 }
422 }
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
427pub enum ErgBondType {
428 Single = 0,
430 Double = 1,
432 Triple = 2,
434 Aromatic = 3,
436}
437
438impl ErgBondType {
439 pub fn from_bond(order: BondOrder) -> Self {
441 match order {
442 BondOrder::Single => ErgBondType::Single,
443 BondOrder::Double => ErgBondType::Double,
444 BondOrder::Triple => ErgBondType::Triple,
445 BondOrder::Aromatic => ErgBondType::Aromatic,
446 _ => ErgBondType::Single,
447 }
448 }
449}
450
451#[derive(Clone, Debug)]
453pub struct ErgConfig {
454 pub use_atom_counts: bool,
456 pub use_bond_types: bool,
458}
459
460impl Default for ErgConfig {
461 fn default() -> Self {
462 ErgConfig {
463 use_atom_counts: true,
464 use_bond_types: true,
465 }
466 }
467}
468
469#[derive(Clone, Debug)]
471pub struct ErgFingerprint {
472 pub bits: BitVec2048,
474 pub atom_counts: [u32; 7],
476 pub bond_counts: [u32; 4],
478}
479
480impl ErgFingerprint {
481 pub fn tanimoto(&self, other: &ErgFingerprint) -> f64 {
483 self.bits.tanimoto(&other.bits)
484 }
485}
486
487pub fn erg(mol: &chematic_core::Molecule) -> ErgFingerprint {
493 erg_with_config(mol, &ErgConfig::default())
494}
495
496pub fn erg_with_config(mol: &chematic_core::Molecule, config: &ErgConfig) -> ErgFingerprint {
498 let mut bits = BitVec2048::new();
499 let mut atom_counts = [0u32; 7];
500 let mut bond_counts = [0u32; 4];
501
502 for (idx, atom) in mol.atoms() {
504 let erg_type = ErgAtomType::from_atom(atom);
505 atom_counts[erg_type as usize] += 1;
506
507 let bit_pos = (erg_type as usize) * 16;
509 if bit_pos < 2048 {
510 bits.set(bit_pos);
511 }
512
513 let degree = mol.neighbors(idx).count();
515 let degree_bits = (degree.min(4) << 2) + (erg_type as usize);
516 let degree_bit_pos = 512 + degree_bits.min(127);
517 if degree_bit_pos < 2048 {
518 bits.set(degree_bit_pos);
519 }
520 }
521
522 for (_, bond) in mol.bonds() {
524 let erg_type = ErgBondType::from_bond(bond.order);
525 bond_counts[erg_type as usize] += 1;
526
527 let bit_pos = 112 + (erg_type as usize) * 16;
529 if bit_pos < 2048 {
530 bits.set(bit_pos);
531 }
532 }
533
534 if config.use_atom_counts {
536 for (i, &count) in atom_counts.iter().enumerate() {
537 for j in 0..4 {
538 if ((count >> j) & 1) != 0 {
539 let bit_pos = 200 + i * 4 + j;
540 if bit_pos < 2048 {
541 bits.set(bit_pos);
542 }
543 }
544 }
545 }
546 }
547
548 if config.use_bond_types {
549 for (i, &count) in bond_counts.iter().enumerate() {
550 for j in 0..4 {
551 if ((count >> j) & 1) != 0 {
552 let bit_pos = 228 + i * 4 + j;
553 if bit_pos < 2048 {
554 bits.set(bit_pos);
555 }
556 }
557 }
558 }
559 }
560
561 let (nodes, edges) = build_reduced_graph(mol);
569
570 for edge in &edges {
571 if edge.linker_len == u32::MAX {
572 continue; }
574 let ta = nodes[edge.node_a].ntype.0;
575 let tb = nodes[edge.node_b].ntype.0;
576 let bin: u8 = match edge.linker_len {
577 0 => 0,
578 1..=2 => 1,
579 3..=5 => 2,
580 _ => 3,
581 };
582 let (t_lo, t_hi) = if ta <= tb { (ta, tb) } else { (tb, ta) };
583 let h = fnv1a(&[t_lo, t_hi, bin, 0xE7]) as usize;
585 bits.set(259 + h % (2048 - 259));
586 }
587
588 if nodes.iter().any(|n| n.ntype.0 & ErgNodeType::AROMATIC != 0) {
590 bits.set(256);
591 }
592 if nodes.iter().any(|n| n.ntype.0 != 0) {
593 bits.set(257);
594 }
595 if !nodes.iter().any(|n| n.ntype.0 & ErgNodeType::AROMATIC != 0)
596 && atom_counts[ErgAtomType::CAliphatic as usize] > 0
597 {
598 bits.set(258);
599 }
600
601 ErgFingerprint {
602 bits,
603 atom_counts,
604 bond_counts,
605 }
606}
607
608pub fn erg_extended(mol: &chematic_core::Molecule) -> ErgFingerprint {
610 erg(mol)
611}
612
613pub fn tanimoto_erg(mol1: &chematic_core::Molecule, mol2: &chematic_core::Molecule) -> f64 {
615 let fp1 = erg(mol1);
616 let fp2 = erg(mol2);
617 fp1.tanimoto(&fp2)
618}
619
620pub const ERG_VEC_LEN: usize = 315;
624
625const N_FEAT: usize = 6;
627
628const N_BINS: usize = 15;
630
631const FUZZ: f64 = 0.3;
634
635const FEATURE_BITS: [(u8, usize); N_FEAT] = [
644 (ErgNodeType::AROMATIC, 0),
645 (ErgNodeType::DONOR, 1),
646 (ErgNodeType::ACCEPTOR, 2),
647 (ErgNodeType::POSITIVE, 3),
648 (ErgNodeType::NEGATIVE, 4),
649 (ErgNodeType::HYDROPHOBIC, 5),
650];
651
652#[inline]
657fn pair_idx(a: usize, b: usize) -> usize {
658 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
659 lo * N_FEAT - lo * (lo.wrapping_sub(1)) / 2 + (hi - lo)
660}
661
662#[inline]
664fn fuzz_add(vec: &mut [f64; ERG_VEC_LEN], base: usize, d: usize, weight: f64) {
665 vec[base + d] += weight;
666 if d > 0 {
667 vec[base + d - 1] += weight * FUZZ;
668 }
669 if d + 1 < N_BINS {
670 vec[base + d + 1] += weight * FUZZ;
671 }
672}
673
674pub fn erg_vec(mol: &Molecule) -> [f64; ERG_VEC_LEN] {
686 let mut vec = [0.0f64; ERG_VEC_LEN];
687 let (nodes, edges) = build_reduced_graph(mol);
688
689 for node in &nodes {
693 let ntype = node.ntype.0;
694 for &(bit, fi) in &FEATURE_BITS {
695 if ntype & bit == 0 {
696 continue;
697 }
698 let base = pair_idx(fi, fi) * N_BINS;
699 fuzz_add(&mut vec, base, 0, 1.0);
700 }
701 }
702
703 for edge in &edges {
705 let dist = edge.linker_len;
706 if dist == u32::MAX {
707 continue;
708 }
709 let bin = (dist as usize).min(N_BINS - 1);
710 let ntype_a = nodes[edge.node_a].ntype.0;
711 let ntype_b = nodes[edge.node_b].ntype.0;
712
713 for &(bit_a, fi) in &FEATURE_BITS {
714 if ntype_a & bit_a == 0 {
715 continue;
716 }
717 for &(bit_b, fj) in &FEATURE_BITS {
718 if ntype_b & bit_b == 0 {
719 continue;
720 }
721 let base = pair_idx(fi, fj) * N_BINS;
722 fuzz_add(&mut vec, base, bin, 1.0);
723 }
724 }
725 }
726
727 vec
728}
729
730pub fn cosine_erg_vec(v1: &[f64; ERG_VEC_LEN], v2: &[f64; ERG_VEC_LEN]) -> f64 {
732 let dot: f64 = v1.iter().zip(v2.iter()).map(|(a, b)| a * b).sum();
733 let n1: f64 = v1.iter().map(|x| x * x).sum::<f64>().sqrt();
734 let n2: f64 = v2.iter().map(|x| x * x).sum::<f64>().sqrt();
735 if n1 < 1e-12 || n2 < 1e-12 {
736 return 0.0;
737 }
738 (dot / (n1 * n2)).min(1.0)
739}
740
741pub fn tanimoto_erg_vec(v1: &[f64; ERG_VEC_LEN], v2: &[f64; ERG_VEC_LEN]) -> f64 {
743 let dot: f64 = v1.iter().zip(v2.iter()).map(|(a, b)| a * b).sum();
744 let n1: f64 = v1.iter().map(|x| x * x).sum();
745 let n2: f64 = v2.iter().map(|x| x * x).sum();
746 let denom = n1 + n2 - dot;
747 if denom < 1e-12 {
748 return 1.0;
749 }
750 (dot / denom).clamp(0.0, 1.0)
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use chematic_smiles::parse;
757
758 #[test]
759 fn test_erg_simple() {
760 let mol = parse("CC").unwrap();
761 let fp = erg(&mol);
762
763 assert_eq!(fp.atom_counts[ErgAtomType::CAliphatic as usize], 2);
764 assert!(fp.bits.popcount() > 0);
765 }
766
767 #[test]
768 fn test_erg_identical() {
769 let mol = parse("CC").unwrap();
770 let fp1 = erg(&mol);
771 let fp2 = erg(&mol);
772
773 assert_eq!(fp1.bits.tanimoto(&fp2.bits), 1.0);
775 assert_eq!(fp1.atom_counts, fp2.atom_counts);
776 }
777
778 #[test]
779 fn test_erg_different_molecules() {
780 let mol1 = parse("CC").unwrap();
781 let mol2 = parse("c1ccccc1").unwrap();
782
783 let fp1 = erg(&mol1);
784 let fp2 = erg(&mol2);
785
786 assert!(fp1.atom_counts[ErgAtomType::CAromatic as usize] == 0);
788 assert!(fp2.atom_counts[ErgAtomType::CAromatic as usize] > 0);
789 }
790
791 #[test]
792 fn test_erg_symmetry() {
793 let mol1 = parse("CC").unwrap();
794 let mol2 = parse("c1ccccc1").unwrap();
795
796 let sim12 = tanimoto_erg(&mol1, &mol2);
797 let sim21 = tanimoto_erg(&mol2, &mol1);
798
799 assert!((sim12 - sim21).abs() < 1e-10);
801 }
802
803 #[test]
804 fn test_erg_heteroatom_detection() {
805 let mol = parse("CCO").unwrap();
806 let fp = erg(&mol);
807
808 assert!(fp.atom_counts[ErgAtomType::O as usize] > 0);
809 }
810
811 #[test]
812 fn test_erg_config() {
813 let mol = parse("CC").unwrap();
814 let config = ErgConfig {
815 use_atom_counts: false,
816 use_bond_types: true,
817 };
818
819 let fp = erg_with_config(&mol, &config);
820 assert!(fp.bits.popcount() > 0);
821 }
822
823 #[test]
824 fn test_erg_aromatic_vs_aliphatic() {
825 let aliphatic = parse("CCCC").unwrap();
826 let aromatic = parse("c1ccccc1").unwrap();
827
828 let fp_aliphatic = erg(&aliphatic);
829 let fp_aromatic = erg(&aromatic);
830
831 assert_eq!(fp_aliphatic.atom_counts[ErgAtomType::CAromatic as usize], 0);
833 assert!(fp_aromatic.atom_counts[ErgAtomType::CAromatic as usize] > 0);
834 }
835
836 #[test]
837 fn test_erg_bond_counting() {
838 let single_bond = parse("CC").unwrap();
839 let double_bond = parse("C=C").unwrap();
840
841 let fp_single = erg(&single_bond);
842 let fp_double = erg(&double_bond);
843
844 assert!(fp_single.bond_counts[ErgBondType::Single as usize] > 0);
846 assert!(fp_double.bond_counts[ErgBondType::Double as usize] > 0);
847 }
848
849 #[test]
850 fn test_erg_functional_group_aromatic_bit() {
851 let aliphatic = parse("CCCC").unwrap();
852 let aromatic = parse("c1ccccc1").unwrap();
853
854 let fp_aliphatic = erg(&aliphatic);
855 let fp_aromatic = erg(&aromatic);
856
857 assert!(
859 !fp_aliphatic.bits.get(256),
860 "aliphatic should not have aromatic bit"
861 );
862 assert!(
863 fp_aromatic.bits.get(256),
864 "aromatic should have aromatic bit"
865 );
866 }
867
868 #[test]
869 fn test_erg_functional_group_heteroatom_bit() {
870 let alkane = parse("CC").unwrap();
871 let alcohol = parse("CCO").unwrap();
872 let amine = parse("CCN").unwrap();
873
874 let fp_alkane = erg(&alkane);
875 let fp_alcohol = erg(&alcohol);
876 let fp_amine = erg(&amine);
877
878 assert!(
881 fp_alkane.bits.get(257),
882 "alkane gets HYDROPHOBIC → bit 257 set"
883 );
884 assert!(
885 fp_alcohol.bits.get(257),
886 "alcohol gets ACCEPTOR/DONOR → bit 257 set"
887 );
888 assert!(
889 fp_amine.bits.get(257),
890 "amine gets ACCEPTOR/DONOR → bit 257 set"
891 );
892
893 let topo_alkane: usize = (259..2048).filter(|&b| fp_alkane.bits.get(b)).count();
895 let topo_alcohol: usize = (259..2048).filter(|&b| fp_alcohol.bits.get(b)).count();
896 let topo_amine: usize = (259..2048).filter(|&b| fp_amine.bits.get(b)).count();
897 let _ = (topo_alkane, topo_alcohol, topo_amine);
899
900 assert!(
902 fp_alkane.tanimoto(&fp_alcohol) < 1.0,
903 "alkane vs alcohol should differ"
904 );
905 assert!(
906 fp_alkane.tanimoto(&fp_amine) < 1.0,
907 "alkane vs amine should differ"
908 );
909 }
910
911 #[test]
912 fn test_erg_functional_group_improved_discrimination() {
913 let methane = parse("C").unwrap();
914 let ethanol = parse("CCO").unwrap();
915 let pyridine = parse("c1ccncc1").unwrap();
916
917 let fp_methane = erg(&methane);
918 let fp_ethanol = erg(ðanol);
919 let fp_pyridine = erg(&pyridine);
920
921 let sim_methane_ethanol = fp_methane.tanimoto(&fp_ethanol);
923 let sim_methane_pyridine = fp_methane.tanimoto(&fp_pyridine);
924 let sim_ethanol_pyridine = fp_ethanol.tanimoto(&fp_pyridine);
925
926 assert!((0.0..=1.0).contains(&sim_methane_ethanol));
928 assert!((0.0..=1.0).contains(&sim_methane_pyridine));
929 assert!((0.0..=1.0).contains(&sim_ethanol_pyridine));
930 }
931
932 #[test]
933 fn test_erg_linker_distance_changes_fingerprint() {
934 let short = parse("NCC(=O)O").unwrap(); let long = parse("NCCCCCC(=O)O").unwrap(); let v_short = erg_vec(&short);
942 let v_long = erg_vec(&long);
943
944 assert_ne!(
946 v_short, v_long,
947 "different linker lengths must produce different erg_vec entries"
948 );
949
950 let sim = tanimoto_erg_vec(&v_short, &v_long);
952 assert!(
953 sim < 1.0,
954 "different linker lengths should give Tanimoto < 1.0, got {sim:.4}"
955 );
956 }
957
958 #[test]
961 fn test_erg_nh_donor() {
962 let aniline = parse("c1ccccc1N").unwrap();
964 let fp = erg(&aniline);
965 let benzene = parse("c1ccccc1").unwrap();
967 let fp_benz = erg(&benzene);
968 assert!(
969 fp.tanimoto(&fp_benz) < 1.0,
970 "aniline should differ from benzene"
971 );
972 assert!(fp.bits.popcount() > 0);
973 }
974
975 #[test]
976 fn test_erg_carbonyl_acceptor() {
977 let acetone = parse("CC(C)=O").unwrap();
979 let propane = parse("CCC").unwrap();
980 let fp_acetone = erg(&acetone);
981 let fp_propane = erg(&propane);
982 assert!(
984 fp_acetone.tanimoto(&fp_propane) < 1.0,
985 "acetone vs propane should differ due to acceptor O"
986 );
987 }
988
989 #[test]
990 fn test_erg_carboxylate_negative() {
991 let acetate = parse("CC(=O)[O-]").unwrap();
995 let groups = identify_functional_groups(&acetate);
996 assert!(!groups.is_empty(), "acetate should have a functional group");
997 let ntype = assign_pharmacophore_features(&acetate, &groups[0]);
998 assert!(
999 ntype.0 & ErgNodeType::NEGATIVE != 0,
1000 "acetate O- should produce NEGATIVE pharmacophore feature, got ntype={}",
1001 ntype.0
1002 );
1003
1004 let acid = parse("CC(=O)O").unwrap();
1006 let acid_groups = identify_functional_groups(&acid);
1007 assert!(!acid_groups.is_empty());
1008 let acid_ntype = assign_pharmacophore_features(&acid, &acid_groups[0]);
1009 assert_eq!(
1010 acid_ntype.0 & ErgNodeType::NEGATIVE,
1011 0,
1012 "acetic acid should NOT have NEGATIVE feature"
1013 );
1014 assert!(
1015 acid_ntype.0 & ErgNodeType::DONOR != 0,
1016 "acetic acid O-H should have DONOR feature"
1017 );
1018 }
1019
1020 #[test]
1021 fn test_erg_hydrophobic_chain() {
1022 let hexane = parse("CCCCCC").unwrap();
1024 let fp = erg(&hexane);
1025 assert!(
1027 fp.bits.get(257),
1028 "hexane should have a pharmacophore feature (HYDROPHOBIC)"
1029 );
1030 assert!(!fp.bits.get(256), "hexane should not have aromatic bit");
1032 }
1033
1034 #[test]
1035 fn test_erg_pyridine_vs_pyrrole_acceptor() {
1036 let pyridine = parse("c1ccncc1").unwrap();
1039 let pyrrole = parse("c1cc[nH]c1").unwrap();
1040 let fp_pyr = erg(&pyridine);
1041 let fp_rol = erg(&pyrrole);
1042 assert!(
1044 fp_pyr.tanimoto(&fp_rol) < 1.0,
1045 "pyridine (N acceptor) vs pyrrole (N-H donor) should differ"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_erg_adjacent_groups_bin0() {
1051 let direct = parse("NO").unwrap(); let indirect = parse("NCCCO").unwrap(); let fp_direct = erg(&direct);
1058 let fp_indirect = erg(&indirect);
1059
1060 assert!(
1061 fp_direct.tanimoto(&fp_indirect) < 1.0,
1062 "adjacent vs. 3-linker groups should differ"
1063 );
1064 }
1065
1066 #[test]
1068 fn test_erg_amide_n_is_acceptor_not_donor() {
1069 use super::{assign_pharmacophore_features, identify_functional_groups};
1070
1071 let acetamide = parse("CC(=O)N").unwrap(); let groups = identify_functional_groups(&acetamide);
1073
1074 let n_group = groups
1076 .iter()
1077 .find(|g| {
1078 g.iter()
1079 .any(|&i| acetamide.atom(AtomIdx(i as u32)).element.atomic_number() == 7)
1080 })
1081 .expect("should find N-containing group");
1082
1083 let ntype = assign_pharmacophore_features(&acetamide, n_group);
1084
1085 assert!(
1087 ntype.0 & ErgNodeType::ACCEPTOR != 0,
1088 "amide N should be ACCEPTOR (bits={:#010b})",
1089 ntype.0
1090 );
1091
1092 assert!(
1094 ntype.0 & ErgNodeType::DONOR == 0,
1095 "amide N should NOT be DONOR (bits={:#010b})",
1096 ntype.0
1097 );
1098 }
1099
1100 #[test]
1102 fn test_erg_amine_n_is_donor_and_acceptor() {
1103 use super::{assign_pharmacophore_features, identify_functional_groups};
1104
1105 let ethylamine = parse("CCN").unwrap(); let groups = identify_functional_groups(ðylamine);
1107
1108 let n_group = groups
1109 .iter()
1110 .find(|g| {
1111 g.iter()
1112 .any(|&i| ethylamine.atom(AtomIdx(i as u32)).element.atomic_number() == 7)
1113 })
1114 .expect("should find N-containing group");
1115
1116 let ntype = assign_pharmacophore_features(ðylamine, n_group);
1117
1118 assert!(
1120 ntype.0 & ErgNodeType::DONOR != 0,
1121 "amine N should be DONOR (bits={:#010b})",
1122 ntype.0
1123 );
1124 assert!(
1125 ntype.0 & ErgNodeType::ACCEPTOR != 0,
1126 "amine N should be ACCEPTOR (bits={:#010b})",
1127 ntype.0
1128 );
1129 }
1130
1131 #[test]
1134 fn test_erg_vec_length() {
1135 let mol = parse("CC").unwrap();
1136 let v = erg_vec(&mol);
1137 assert_eq!(
1138 v.len(),
1139 ERG_VEC_LEN,
1140 "erg_vec must return exactly 315 floats"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_erg_vec_consistency() {
1146 let mol = parse("c1ccccc1N").unwrap();
1147 let v1 = erg_vec(&mol);
1148 let v2 = erg_vec(&mol);
1149 assert_eq!(v1, v2, "erg_vec must be deterministic");
1150 }
1151
1152 #[test]
1153 fn test_erg_vec_nonnegative() {
1154 for smi in &["CC", "CCO", "c1ccccc1", "c1ccncc1", "CC(=O)N", "c1ccccc1N"] {
1155 let mol = parse(smi).unwrap();
1156 let v = erg_vec(&mol);
1157 for (i, &x) in v.iter().enumerate() {
1158 assert!(x >= 0.0, "erg_vec[{i}] negative for {smi}: {x}");
1159 }
1160 }
1161 }
1162
1163 #[test]
1164 fn test_erg_vec_same_mol_self_similarity() {
1165 let mol = parse("c1ccccc1").unwrap();
1166 let v = erg_vec(&mol);
1167 let sim = tanimoto_erg_vec(&v, &v);
1168 assert!(
1169 (sim - 1.0).abs() < 1e-9,
1170 "self-similarity must be 1.0 (got {sim})"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_erg_vec_different_molecules() {
1176 let mol1 = parse("CC").unwrap();
1177 let mol2 = parse("c1ccccc1N").unwrap();
1178 let v1 = erg_vec(&mol1);
1179 let v2 = erg_vec(&mol2);
1180 let sim = tanimoto_erg_vec(&v1, &v2);
1181 assert!(
1182 (0.0..=1.0).contains(&sim),
1183 "Tanimoto must be in [0,1] (got {sim})"
1184 );
1185 assert!(sim < 1.0, "ethane vs aniline must not be identical");
1186 }
1187
1188 #[test]
1189 fn test_erg_vec_donor_acceptor_linker_distance() {
1190 let gaba = parse("NCCCC(=O)O").unwrap();
1194 let glycine = parse("NCC(=O)O").unwrap();
1195 let v_gaba = erg_vec(&gaba);
1196 let v_glycine = erg_vec(&glycine);
1197 let sim = tanimoto_erg_vec(&v_gaba, &v_glycine);
1198 assert!(
1199 sim < 1.0,
1200 "GABA vs glycine differ only in linker length — Tanimoto must be < 1 (got {sim:.3})"
1201 );
1202 }
1203
1204 #[test]
1205 fn test_erg_vec_fuzz_spreads_adjacent_bins() {
1206 let mol = parse("NCC(=O)O").unwrap(); let v = erg_vec(&mol);
1209 let da_pair = pair_idx(1, 2) * N_BINS;
1211 let sum: f64 = v[da_pair..da_pair + N_BINS].iter().sum();
1212 assert!(
1213 sum > 0.0,
1214 "Donor–Acceptor bins must be non-zero for glycine analogue"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_pair_idx_all_21() {
1220 let mut seen = FxHashSet::default();
1221 for a in 0..N_FEAT {
1222 for b in a..N_FEAT {
1223 let idx = pair_idx(a, b);
1224 assert!(idx < 21, "pair_idx({a},{b}) = {idx} out of range");
1225 assert!(
1226 seen.insert(idx),
1227 "pair_idx({a},{b}) = {idx} duplicates earlier pair"
1228 );
1229 }
1230 }
1231 assert_eq!(seen.len(), 21);
1232 }
1233
1234 #[test]
1236 fn test_erg_sulfonamide_n_is_acceptor_not_donor() {
1237 use super::{assign_pharmacophore_features, identify_functional_groups};
1238
1239 let sulfonamide = parse("CS(=O)(=O)N").unwrap(); let groups = identify_functional_groups(&sulfonamide);
1241
1242 let n_group = groups
1243 .iter()
1244 .find(|g| {
1245 g.iter()
1246 .any(|&i| sulfonamide.atom(AtomIdx(i as u32)).element.atomic_number() == 7)
1247 })
1248 .expect("should find N-containing group");
1249
1250 let ntype = assign_pharmacophore_features(&sulfonamide, n_group);
1251
1252 assert!(
1253 ntype.0 & ErgNodeType::ACCEPTOR != 0,
1254 "sulfonamide N should be ACCEPTOR (bits={:#010b})",
1255 ntype.0
1256 );
1257 assert!(
1258 ntype.0 & ErgNodeType::DONOR == 0,
1259 "sulfonamide N should NOT be DONOR (bits={:#010b})",
1260 ntype.0
1261 );
1262 }
1263}