Skip to main content

chematic_fp/
erg.rs

1//! ERG (Extended Reduced Graph) fingerprints — bespoke 2048-bit and 315-dim float variants.
2//!
3//! Implements ERG algorithm (Sheridan 1996 / Rarey & Dixon 1998) with Ertl 2017 FG detection:
4//! 1. Identify functional group clusters (Ertl 2017)
5//! 2. Collapse each cluster into a single reduced node with pharmacophore type
6//! 3. Build reduced graph with linker bond counts between nodes
7//! 4. Generate fingerprint from reduced graph topology
8//!
9//! Two fingerprint formats are provided:
10//! - `erg()`: 2048-bit bespoke bitvector (original chematic format, fast hashing)
11//! - `erg_vec()`: 315-float histogram (Rarey & Dixon 1998 structure: 21 node-type pairs
12//!   × 15 distance bins, with Gaussian fuzzing ±1 bin at weight 0.3)
13//!   NOTE: numerical parity with RDKit GetErGFingerprint is unverified (no fixture data).
14//!
15//! Reference: Rarey & Dixon 1998 J.Comput.-Aided Mol.Des. 12:471–490
16//!            Sheridan 1996 J.Chem.Inf.Comput.Sci. 36:128–136
17//!            Ertl 2017 J.Cheminf. 9:36
18
19use 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
25/// Functional group identification for ERG reduced graph construction.
26///
27/// Algorithm (Union-Find over heteroatoms):
28/// 1. Identify all heteroatoms (non-C, non-H).
29/// 2. Merge heteroatoms that share a common C neighbor (e.g. C=O and OH on same
30///    carbonyl C → carboxylate/ester one node; amide C bonded to N and O → one node).
31/// 3. Merge heteroatoms directly bonded to each other (e.g. N-O in nitro groups).
32/// 4. Expand each cluster by including all directly bonded C atoms.
33///
34/// This keeps amine and carboxylate as *separate* nodes in amino acids, because
35/// the only carbon shared between them (α-C) is bonded to only ONE heteroatom cluster
36/// at a time. C–C bonds between FG clusters become linker atoms for edge encoding.
37fn 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    // --- Phase 1: collect heteroatom indices ---
44    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    // --- Phase 2: Union-Find ---
57    let mut parent: Vec<usize> = (0..n).collect();
58
59    // Merge heteroatoms bonded directly to each other (nitro N-O, N-N hydrazine, etc.)
60    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    // Merge heteroatoms that share a C neighbor (same FG context)
70    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    // --- Phase 3: collect clusters and expand with bonded C atoms ---
87    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]]; // path halving
117        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/// Node type for reduced graph: pharmacophore feature bits.
131#[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/// Reduced graph node representing a functional group or backbone segment.
186#[derive(Clone, Debug)]
187pub struct ErgNode {
188    pub ntype: ErgNodeType,
189    pub atom_indices: Vec<usize>,
190}
191
192/// Reduced graph edge with linker information.
193#[derive(Clone, Debug)]
194pub struct ErgEdge {
195    pub node_a: usize,
196    pub node_b: usize,
197    pub linker_len: u32,
198}
199
200/// Return true if atom `n_idx` (nitrogen) is in an amide or sulfonamide context.
201///
202/// Amide: N directly bonded to C=O.
203/// Sulfonamide: N directly bonded to S(=O).
204/// In both cases the N lone pair is delocalized → acts as ACCEPTOR, not DONOR.
205fn 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
222/// Assign pharmacophore-based node type for a functional group cluster.
223///
224/// Rules (per atom in the group):
225/// - AROMATIC: any aromatic atom
226/// - DONOR: N-H (non-amide/sulfonamide), O-H, or S-H
227/// - ACCEPTOR: N (non-amide unless aromatic), amide/sulfonamide N, any O, any F
228/// - POSITIVE: formal charge > 0
229/// - NEGATIVE: formal charge < 0
230/// - HYDROPHOBIC: group with only C/H atoms and no other pharmacophore feature
231///
232/// Key fix vs prior version: amide/sulfonamide N is ACCEPTOR-only even when it
233/// carries an H, because the lone pair is delocalized into the C=O or S=O π system.
234fn 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        // Count H attached to this atom (explicit H neighbors)
247        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        // H-bond donor:
255        //   - O-H and S-H: always donors when H present
256        //   - N-H: donor ONLY when NOT in amide/sulfonamide context
257        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        // H-bond acceptor
263        match an {
264            // Non-aromatic N: acceptor (including amide N — delocalized lone pair)
265            7 if !atom.aromatic => ntype = ntype.with_acceptor(),
266            // Aromatic N: acceptor only if no H (pyridine-like), not if has H (pyrrole)
267            7 if atom.aromatic && total_h == 0 => ntype = ntype.with_acceptor(),
268            // O and F: always acceptor
269            8 | 9 => ntype = ntype.with_acceptor(),
270            _ => {}
271        }
272
273        // Formal charge
274        if atom.charge > 0 {
275            ntype = ntype.with_positive();
276        }
277        if atom.charge < 0 {
278            ntype = ntype.with_negative();
279        }
280    }
281
282    // Hydrophobic: all atoms are C or H and no other pharmacophore bit set
283    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
294/// Build reduced graph from molecule using functional group detection.
295/// If no functional groups found, treats entire molecule as one backbone node.
296fn build_reduced_graph(mol: &Molecule) -> (Vec<ErgNode>, Vec<ErgEdge>) {
297    let fg_groups = identify_functional_groups(mol);
298
299    // Create nodes from functional groups using pharmacophore feature assignment
300    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 no FGs found, treat entire molecule as one backbone node
312    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    // Cap node count to prevent O(k²·n) blow-up on pathological all-heteroatom inputs.
322    const MAX_ERG_NODES: usize = 128;
323    if nodes.len() > MAX_ERG_NODES {
324        nodes.truncate(MAX_ERG_NODES);
325    }
326
327    // Create edges: find shortest paths between node pairs
328    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
345/// Compute shortest path length between two functional groups (linker atoms only).
346fn 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    // BFS from node_a atoms
355    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            // Stop if we reach node_b.
366            // linker = total bond count from any node_a atom to this node_b atom.
367            if node_b.atom_indices.contains(&nbi) {
368                return dist[cur] + 1;
369            }
370
371            if dist[nbi] == u32::MAX {
372                // Only count non-fg atoms as linker
373                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/// Atom property encoding for ERG nodes.
384#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
385pub enum ErgAtomType {
386    /// Aliphatic carbon
387    CAliphatic = 0,
388    /// Aromatic carbon
389    CAromatic = 1,
390    /// Nitrogen (any)
391    N = 2,
392    /// Oxygen (any)
393    O = 3,
394    /// Sulfur (any)
395    S = 4,
396    /// Halogen (F, Cl, Br, I)
397    Halogen = 5,
398    /// Other heteroatom
399    Other = 6,
400}
401
402impl ErgAtomType {
403    /// Get atom type from Atom and aromaticity.
404    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/// Bond type encoding for ERG edges.
426#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
427pub enum ErgBondType {
428    /// Single bond
429    Single = 0,
430    /// Double bond
431    Double = 1,
432    /// Triple bond
433    Triple = 2,
434    /// Aromatic bond
435    Aromatic = 3,
436}
437
438impl ErgBondType {
439    /// Get bond type from BondOrder.
440    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/// Configuration for ERG fingerprint generation.
452#[derive(Clone, Debug)]
453pub struct ErgConfig {
454    /// Use atom counts in fingerprint
455    pub use_atom_counts: bool,
456    /// Use bond types
457    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/// Extended Reduced Graph fingerprint.
470#[derive(Clone, Debug)]
471pub struct ErgFingerprint {
472    /// Fingerprint bits (2048-bit)
473    pub bits: BitVec2048,
474    /// Atom type counts
475    pub atom_counts: [u32; 7],
476    /// Bond type counts
477    pub bond_counts: [u32; 4],
478}
479
480impl ErgFingerprint {
481    /// Calculate Tanimoto similarity between ERG fingerprints.
482    pub fn tanimoto(&self, other: &ErgFingerprint) -> f64 {
483        self.bits.tanimoto(&other.bits)
484    }
485}
486
487/// Generate True ERG fingerprint from a molecule.
488///
489/// v0.1.91: Uses Ertl 2017 functional group detection and reduced graph topology.
490/// Identifies functional group clusters, builds a reduced graph, and generates
491/// a fingerprint from the reduced graph structure.
492pub fn erg(mol: &chematic_core::Molecule) -> ErgFingerprint {
493    erg_with_config(mol, &ErgConfig::default())
494}
495
496/// Generate ERG fingerprint with custom configuration.
497pub 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    // Count atom/bond types for backward compatibility
503    for (idx, atom) in mol.atoms() {
504        let erg_type = ErgAtomType::from_atom(atom);
505        atom_counts[erg_type as usize] += 1;
506
507        // Set bits based on atom type
508        let bit_pos = (erg_type as usize) * 16;
509        if bit_pos < 2048 {
510            bits.set(bit_pos);
511        }
512
513        // Add degree-based bits for structural context
514        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    // Count bond types
523    for (_, bond) in mol.bonds() {
524        let erg_type = ErgBondType::from_bond(bond.order);
525        bond_counts[erg_type as usize] += 1;
526
527        // Set bits based on bond type
528        let bit_pos = 112 + (erg_type as usize) * 16;
529        if bit_pos < 2048 {
530            bits.set(bit_pos);
531        }
532    }
533
534    // Encode atom/bond counts
535    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    // Reduced graph topology encoding.
562    //
563    // For each edge (i, j) in the reduced graph, encode:
564    //   (sorted node types, binned linker length) → bit in [259, 2047]
565    //
566    // Linker bins: 0=adjacent, 1=short(1-2), 2=medium(3-5), 3=long(6+)
567    // This makes the fingerprint sensitive to inter-group distance.
568    let (nodes, edges) = build_reduced_graph(mol);
569
570    for edge in &edges {
571        if edge.linker_len == u32::MAX {
572            continue; // no path between groups — skip
573        }
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        // Hash the triplet into [259, 2047] for topology bits.
584        let h = fnv1a(&[t_lo, t_hi, bin, 0xE7]) as usize;
585        bits.set(259 + h % (2048 - 259));
586    }
587
588    // Global node-level flags (bits 256-258)
589    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
608/// Convenience function for standard ERG generation.
609pub fn erg_extended(mol: &chematic_core::Molecule) -> ErgFingerprint {
610    erg(mol)
611}
612
613/// Calculate Tanimoto similarity between two molecules using ERG.
614pub 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
620// ─── ERG 315-dim float histogram (Rarey & Dixon 1998 structure) ──────────────
621
622/// Length of the ERG float vector: 21 node-type pairs × 15 distance bins.
623pub const ERG_VEC_LEN: usize = 315;
624
625/// Number of pharmacophore feature types encoded in the float vector.
626const N_FEAT: usize = 6;
627
628/// Number of distance bins (distances 0..=14+ linker atoms).
629const N_BINS: usize = 15;
630
631/// Gaussian fuzzing weight applied to the adjacent distance bins.
632/// Mirrors the `fuzzIncrement` parameter used in RDKit's ErG implementation.
633const FUZZ: f64 = 0.3;
634
635/// Feature type index ordering for the 315-dim vector.
636/// Mapping from bit position (as bit-shift in ErgNodeType.0) to feature index 0-5:
637///   0 = AROMATIC (bit 1 = 1<<0)
638///   1 = DONOR    (bit 2 = 1<<1)
639///   2 = ACCEPTOR (bit 4 = 1<<2)
640///   3 = POSITIVE (bit 16 = 1<<4)
641///   4 = NEGATIVE (bit 32 = 1<<5)
642///   5 = HYDROPHOBIC (bit 8 = 1<<3)
643const 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/// Compute index into the 21 unique unordered pairs for feature types (a, b).
653///
654/// Lower-triangular enumeration (including diagonal):
655/// (0,0)=0 (0,1)=1 … (0,5)=5 | (1,1)=6 … (1,5)=10 | … | (5,5)=20
656#[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/// Add `weight` to `vec` at distance bin `d` with Gaussian fuzzing (±1 at `FUZZ`).
663#[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
674/// Generate an ERG-style 315-element float histogram.
675///
676/// Format: `vec[pair_index * 15 + distance_bin]` where
677/// - `pair_index` ∈ 0..21 (unique unordered pair of 6 pharmacophore feature types)
678/// - `distance_bin` ∈ 0..15 (linker bond count capped at 14)
679///
680/// Adjacent bins receive a `FUZZ = 0.3` weight (Gaussian smearing).
681///
682/// **NOTE**: numerical parity with RDKit `GetErGFingerprint` is unverified.
683/// Use as a richer alternative to the 2048-bit bespoke FP when float vectors
684/// are needed (e.g. cosine similarity, PCA).
685pub 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    // Self-pair entries: each node contributes its feature types at bin 0.
690    // This encodes node composition independent of topology, so single-node
691    // molecules (pure alkanes, single-FG molecules) are still distinguishable.
692    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    // Inter-node pair entries: each edge encodes (feature_a, feature_b, distance).
704    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
730/// Cosine similarity between two ERG float vectors.
731pub 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
741/// Tanimoto similarity on float vectors: T = dot / (|v1|² + |v2|² − dot).
742pub 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        // Identical molecules should have identical fingerprints
774        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        // Aliphatic vs aromatic should differ
787        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        // Similarity should be symmetric
800        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        // Aromatic should have aromatic carbon counts
832        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        // Different bond types
845        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        // Aromatic bit (256) should be set for benzene, not for alkane
858        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        // Bit 257: set when any pharmacophore feature is present.
879        // With pharmacophore assignment, HYDROPHOBIC is set for alkane too.
880        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        // Alcohol and amine should differ from alkane in topology bits (259+)
894        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        // All should be valid (we just verify topo bits exist, not exact values)
898        let _ = (topo_alkane, topo_alcohol, topo_amine);
899
900        // Alkane FP should differ from alcohol and amine FPs
901        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(&ethanol);
919        let fp_pyridine = erg(&pyridine);
920
921        // Functional group bits should improve discrimination
922        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        // All similarities should be in valid range
927        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        // Same terminal functional groups (NH2 and COOH) but different linker lengths.
935        // erg_vec (315-dim float histogram) encodes distances in distinct bins,
936        // so the vectors must differ. The bespoke 2048-bit erg() uses FNV hashing
937        // which can have bin collisions for this specific pair — use erg_vec instead.
938        let short = parse("NCC(=O)O").unwrap(); // 1 linker C
939        let long = parse("NCCCCCC(=O)O").unwrap(); // 5 linker Cs
940
941        let v_short = erg_vec(&short);
942        let v_long = erg_vec(&long);
943
944        // Vectors must not be identical (different linker bin → different histogram).
945        assert_ne!(
946            v_short, v_long,
947            "different linker lengths must produce different erg_vec entries"
948        );
949
950        // Similarity must be strictly < 1.0.
951        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    // ---- New pharmacophore feature tests ----
959
960    #[test]
961    fn test_erg_nh_donor() {
962        // Aniline: c1ccccc1N — the N group should be a DONOR (N-H present)
963        let aniline = parse("c1ccccc1N").unwrap();
964        let fp = erg(&aniline);
965        // Should differ from benzene (no N)
966        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        // Acetone C(C)(C)=O — the C=O group should be ACCEPTOR
978        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        // Acetone (has O acceptor) should differ from propane (pure hydrocarbon)
983        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        // Acetate anion CC(=O)[O-]: O- has formal charge -1 → NEGATIVE feature.
992        // Test the pharmacophore assignment directly (fingerprint-level may
993        // collapse single-node molecules to identical atom-count bits).
994        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        // Acetic acid CC(=O)O: no negative charge → DONOR (O-H) instead
1005        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        // Hexane CCCCCC: all C → HYDROPHOBIC node type
1023        let hexane = parse("CCCCCC").unwrap();
1024        let fp = erg(&hexane);
1025        // Hydrophobic molecules should have bit 257 set (any feature including HYDROPHOBIC)
1026        assert!(
1027            fp.bits.get(257),
1028            "hexane should have a pharmacophore feature (HYDROPHOBIC)"
1029        );
1030        // No aromatic (bit 256)
1031        assert!(!fp.bits.get(256), "hexane should not have aromatic bit");
1032    }
1033
1034    #[test]
1035    fn test_erg_pyridine_vs_pyrrole_acceptor() {
1036        // Pyridine: aromatic N without H → ACCEPTOR only
1037        // Pyrrole: aromatic N-H → DONOR only
1038        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        // They should have different fingerprints (one is acceptor, other is donor)
1043        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        // Two functional groups with 0 linker atoms (directly bonded): bin=0.
1052        // OCCO: O and O separated by 2 C linker atoms → bin=1 (short).
1053        // ON: O and N directly bonded → linker_len should be small.
1054        let direct = parse("NO").unwrap(); // N-O: effectively adjacent FGs
1055        let indirect = parse("NCCCO").unwrap(); // N-CCC-O: 3-linker
1056
1057        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    /// Amide N: ACCEPTOR only, NOT donor (lone pair delocalized into C=O).
1067    #[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(); // CH3-C(=O)-NH2
1072        let groups = identify_functional_groups(&acetamide);
1073
1074        // Find the group containing N (atomic number 7)
1075        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        // Amide N should be ACCEPTOR (bit 4 set)
1086        assert!(
1087            ntype.0 & ErgNodeType::ACCEPTOR != 0,
1088            "amide N should be ACCEPTOR (bits={:#010b})",
1089            ntype.0
1090        );
1091
1092        // Amide N should NOT be DONOR (bit 2) — lone pair delocalized into C=O
1093        assert!(
1094            ntype.0 & ErgNodeType::DONOR == 0,
1095            "amide N should NOT be DONOR (bits={:#010b})",
1096            ntype.0
1097        );
1098    }
1099
1100    /// Ethylamine N: both DONOR and ACCEPTOR (free lone pair + N-H).
1101    #[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(); // CH3CH2-NH2
1106        let groups = identify_functional_groups(&ethylamine);
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(&ethylamine, n_group);
1117
1118        // Amine N should be both DONOR and ACCEPTOR
1119        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    // ── erg_vec (315-dim float histogram) tests ──────────────────────────────
1132
1133    #[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        // GABA (H2N-CH2-CH2-CH2-COOH): donor (NH2) and acceptor (C=O, OH) with 3 linker Cs
1191        // Glycine (H2N-CH2-COOH): 1 linker C
1192        // The DA pair at different distance bins must give different vectors.
1193        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        // A node pair at distance bin d must also populate bin d±1 via FUZZ.
1207        let mol = parse("NCC(=O)O").unwrap(); // donor-linker-acceptor
1208        let v = erg_vec(&mol);
1209        // Some bin must be > 0 in the Donor–Acceptor (fi=1, fj=2) pair region
1210        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    /// Sulfonamide N: ACCEPTOR only (same logic as amide N).
1235    #[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(); // methanesulfonamide
1240        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}