Skip to main content

chematic_fp/
ecfp.rs

1//! ECFP (Extended Connectivity Fingerprints) based on the Morgan algorithm.
2//!
3//! Uses FNV-1a 64-bit hashing for reproducibility and WASM-compatibility.
4
5use chematic_core::{AtomIdx, BondOrder, Molecule, implicit_hcount};
6use chematic_perception::find_sssr;
7use rustc_hash::FxHashMap;
8use smallvec::SmallVec;
9
10use crate::bitvec::BitVec2048;
11
12const FNV_OFFSET: u64 = 14695981039346656037;
13const FNV_PRIME: u64 = 1099511628211;
14
15/// Compute the FNV-1a 64-bit hash of `bytes`.
16pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
17    let mut h = FNV_OFFSET;
18    for &b in bytes {
19        h ^= b as u64;
20        h = h.wrapping_mul(FNV_PRIME);
21    }
22    h
23}
24
25/// Hash one atom's neighbourhood at iteration `r` of the Morgan expansion.
26///
27/// Byte layout: `[r as u8, self_id (8 bytes), (bond_type (1) ++ nb_id (8))*]`
28/// Neighbours are sorted before hashing to make the result order-independent.
29fn expand_atom_id(mol: &Molecule, i: usize, r: u32, ids: &[u64]) -> u64 {
30    let idx = AtomIdx(i as u32);
31    // SmallVec<6>: typical atoms have ≤4 heavy neighbors; avoids heap alloc for ~95% of calls.
32    let mut neighbor_info: SmallVec<[(u8, u64); 6]> = mol
33        .neighbors(idx)
34        .map(|(nb_idx, bond_idx)| {
35            (
36                bond_type_int(mol.bond(bond_idx).order),
37                ids[nb_idx.0 as usize],
38            )
39        })
40        .collect();
41    neighbor_info.sort_unstable();
42
43    // 1 (radius) + 8 (self id) + up to 6 × 9 (bond_type + nb_id) = 63 bytes max on stack.
44    let mut bytes: SmallVec<[u8; 64]> = SmallVec::new();
45    bytes.push(r as u8);
46    bytes.extend_from_slice(&ids[i].to_le_bytes());
47    for (btype, nb_id) in &neighbor_info {
48        bytes.push(*btype);
49        bytes.extend_from_slice(&nb_id.to_le_bytes());
50    }
51    fnv1a(&bytes)
52}
53
54/// Compute the FNV-1a atom identifier for iteration 0 of the Morgan algorithm.
55///
56/// The six-byte invariant covers: atomic number, degree, implicit H count, formal
57/// charge (clamped to byte range), ring membership, and aromaticity.  When
58/// `use_chirality` is true an extra chirality byte is appended; this preserves
59/// bit-compatibility with the default (`use_chirality=false`) fingerprints.
60pub(crate) fn initial_atom_id(
61    mol: &Molecule,
62    idx: AtomIdx,
63    ring_set: &chematic_perception::RingSet,
64    use_chirality: bool,
65) -> u64 {
66    let atom = mol.atom(idx);
67    let charge_adjusted = (atom.charge as i16 + 8).clamp(0, 255) as u8;
68    let base_bytes = [
69        atom.element.atomic_number(),
70        mol.neighbors(idx).count().min(255) as u8,
71        implicit_hcount(mol, idx),
72        charge_adjusted,
73        ring_set.contains_atom(idx) as u8,
74        atom.aromatic as u8,
75    ];
76    if use_chirality {
77        use chematic_core::Chirality;
78        let chirality_byte = match atom.chirality {
79            Chirality::None => 0u8,
80            Chirality::CounterClockwise => 1u8,
81            Chirality::Clockwise => 2u8,
82        };
83        let mut chiral_bytes = base_bytes.to_vec();
84        chiral_bytes.push(chirality_byte);
85        fnv1a(&chiral_bytes)
86    } else {
87        fnv1a(&base_bytes)
88    }
89}
90
91/// Configuration for ECFP computation.
92#[derive(Debug, Clone)]
93pub struct EcfpConfig {
94    /// Number of iterations (radius). ECFP4 = 2, ECFP6 = 3.
95    pub radius: u32,
96    /// Output bitvector size (default 2048).
97    pub nbits: usize,
98    /// When `true`, include tetrahedral chirality in the initial atom hash so
99    /// that R and S enantiomers produce different fingerprints.
100    ///
101    /// Defaults to `false` (chirality ignored, matching RDKit's `useChirality=False`).
102    pub use_chirality: bool,
103    /// When `true`, each hash sets two bit positions (using single and double-folded hash)
104    /// to reduce bitvector collisions. This reduces collision probability but changes
105    /// fingerprint values — **not backwards-compatible** with stored fingerprints.
106    ///
107    /// Defaults to `false` (single-bit folding, current behavior preserved).
108    pub use_double_fold: bool,
109}
110
111impl Default for EcfpConfig {
112    fn default() -> Self {
113        Self {
114            radius: 2,
115            nbits: 2048,
116            use_chirality: false,
117            use_double_fold: false,
118        }
119    }
120}
121
122/// Map a `BondOrder` to the integer code used in the ECFP hash.
123///
124/// - Single / Up / Down → 1
125/// - Double            → 2
126/// - Triple            → 3
127/// - Aromatic          → 4
128/// - Quadruple         → 5  (not in standard ECFP; assigned a distinct value)
129#[inline]
130pub(crate) fn bond_type_int(order: BondOrder) -> u8 {
131    match order {
132        BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => 1,
133        BondOrder::Double => 2,
134        BondOrder::Triple => 3,
135        BondOrder::Aromatic => 4,
136        BondOrder::Quadruple => 5,
137        BondOrder::Zero => 0,
138        BondOrder::QueryAny => 6,
139        BondOrder::QuerySingleOrDouble => 7,
140        BondOrder::QuerySingleOrAromatic => 8,
141        BondOrder::QueryDoubleOrAromatic => 9,
142    }
143}
144
145/// Compute an ECFP fingerprint for `mol` using the given configuration.
146///
147/// # Algorithm overview
148/// 1. Compute initial atom identifiers from atomic properties.
149/// 2. Iteratively expand each identifier by incorporating neighbour identifiers
150///    (with their bond types) for `config.radius` rounds.
151/// 3. After each iteration (including iteration 0), map every identifier to a
152///    bit in the output bitvector.
153///
154/// Maximum supported radius for `ecfp`.  Matches the cap in `morgan_fp_counts`.
155/// Beyond this, `r as u8` would silently truncate, producing hash collisions.
156pub const MAX_ECFP_RADIUS: u32 = 20;
157
158pub fn ecfp(mol: &Molecule, config: &EcfpConfig) -> BitVec2048 {
159    let n = mol.atom_count();
160    let nbits = config.nbits;
161    // Cap radius to prevent `r as u8` truncation at r > 255 (hash collision bug).
162    let config = &EcfpConfig {
163        radius: config.radius.min(MAX_ECFP_RADIUS),
164        ..*config
165    };
166    let mut fp = BitVec2048::new();
167
168    if n == 0 {
169        return fp;
170    }
171
172    let ring_set = find_sssr(mol);
173
174    // Step 1: initial atom identifiers (iteration 0).
175    let mut ids: Vec<u64> = Vec::with_capacity(n);
176    for i in 0..n {
177        let idx = AtomIdx(i as u32);
178        let id = initial_atom_id(mol, idx, &ring_set, config.use_chirality);
179        fp.set((id % nbits as u64) as usize);
180        if config.use_double_fold {
181            fp.set(((id >> 11) % nbits as u64) as usize);
182        }
183        ids.push(id);
184    }
185
186    // Step 2: iterative expansion.
187    let mut new_ids: Vec<u64> = vec![0u64; n];
188    for r in 1..=config.radius {
189        for (i, slot) in new_ids.iter_mut().enumerate() {
190            let new_id = expand_atom_id(mol, i, r, &ids);
191            *slot = new_id;
192            fp.set((new_id % nbits as u64) as usize);
193            if config.use_double_fold {
194                fp.set(((new_id >> 11) % nbits as u64) as usize);
195            }
196        }
197        core::mem::swap(&mut ids, &mut new_ids);
198    }
199
200    fp
201}
202
203/// Count-based Morgan fingerprint: returns a map of `hash → count` for all
204/// atom environments up to `radius` iterations.
205///
206/// Each (atom, iteration) pair contributes its hash to the map.  Unlike the
207/// default RDKit behavior, redundant (duplicate) environments are **not**
208/// suppressed — every atom contributes at every iteration level.
209///
210/// This corresponds to `GetMorganFingerprint(mol, radius,
211/// useFeatures=False, includeRedundantEnvironments=True)` in RDKit.
212pub fn morgan_fp_counts(mol: &Molecule, radius: u32) -> FxHashMap<u64, u32> {
213    const MAX_RADIUS: u32 = 20;
214    let radius = radius.min(MAX_RADIUS);
215
216    let n = mol.atom_count();
217    let mut counts: FxHashMap<u64, u32> = FxHashMap::default();
218
219    if n == 0 {
220        return counts;
221    }
222
223    let ring_set = find_sssr(mol);
224
225    // Radius-0: initial atom identifiers.
226    let mut ids: Vec<u64> = (0..n)
227        .map(|i| initial_atom_id(mol, AtomIdx(i as u32), &ring_set, false))
228        .collect();
229
230    for &id in &ids {
231        *counts.entry(id).or_insert(0) += 1;
232    }
233
234    // Radius 1..=radius: iterative expansion (same hash scheme as ecfp).
235    let mut new_ids = vec![0u64; n];
236    for r in 1..=radius {
237        for (i, slot) in new_ids.iter_mut().enumerate() {
238            let new_id = expand_atom_id(mol, i, r, &ids);
239            *slot = new_id;
240            *counts.entry(new_id).or_insert(0) += 1;
241        }
242        core::mem::swap(&mut ids, &mut new_ids);
243    }
244
245    counts
246}
247
248/// ECFP4 fingerprint (radius = 2, 2048 bits).
249pub fn ecfp4(mol: &Molecule) -> BitVec2048 {
250    ecfp(mol, &EcfpConfig::default())
251}
252
253/// ECFP6 fingerprint (radius = 3, 2048 bits).
254pub fn ecfp6(mol: &Molecule) -> BitVec2048 {
255    ecfp(
256        mol,
257        &EcfpConfig {
258            radius: 3,
259            ..EcfpConfig::default()
260        },
261    )
262}
263
264/// Tanimoto similarity between two molecules using ECFP4.
265pub fn tanimoto_ecfp4(a: &Molecule, b: &Molecule) -> f64 {
266    ecfp4(a).tanimoto(&ecfp4(b))
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use chematic_smiles::parse;
273
274    fn benzene() -> Molecule {
275        parse("c1ccccc1").unwrap()
276    }
277
278    fn ethane() -> Molecule {
279        parse("CC").unwrap()
280    }
281
282    fn toluene() -> Molecule {
283        parse("Cc1ccccc1").unwrap()
284    }
285
286    fn aspirin() -> Molecule {
287        // Acetylsalicylic acid
288        parse("CC(=O)Oc1ccccc1C(=O)O").unwrap()
289    }
290
291    fn methane() -> Molecule {
292        parse("C").unwrap()
293    }
294
295    fn water() -> Molecule {
296        parse("O").unwrap()
297    }
298
299    #[test]
300    fn benzene_ecfp4_nonzero() {
301        let fp = ecfp4(&benzene());
302        assert!(fp.popcount() > 0, "benzene ECFP4 must be non-zero");
303    }
304
305    #[test]
306    fn benzene_ecfp4_deterministic() {
307        let fp1 = ecfp4(&benzene());
308        let fp2 = ecfp4(&benzene());
309        assert_eq!(fp1, fp2, "ECFP4 must be deterministic");
310    }
311
312    #[test]
313    fn ethane_vs_benzene_tanimoto_lt1() {
314        let t = tanimoto_ecfp4(&ethane(), &benzene());
315        assert!(t < 1.0, "ethane and benzene must differ (tanimoto={t})");
316    }
317
318    #[test]
319    fn benzene_vs_benzene_tanimoto_eq1() {
320        let t = tanimoto_ecfp4(&benzene(), &benzene());
321        assert_eq!(t, 1.0, "identical molecules must have tanimoto == 1.0");
322    }
323
324    #[test]
325    fn benzene_vs_toluene_tanimoto_between() {
326        let t = tanimoto_ecfp4(&benzene(), &toluene());
327        assert!(t > 0.0, "benzene and toluene share bits (tanimoto={t})");
328        assert!(
329            t < 1.0,
330            "benzene and toluene are not identical (tanimoto={t})"
331        );
332    }
333
334    #[test]
335    fn aspirin_ecfp4_many_bits() {
336        let fp = ecfp4(&aspirin());
337        assert!(
338            fp.popcount() > 5,
339            "aspirin ECFP4 must have more than 5 bits set (got {})",
340            fp.popcount()
341        );
342    }
343
344    #[test]
345    fn ecfp6_vs_ecfp4_benzene_differ() {
346        let fp4 = ecfp4(&benzene());
347        let fp6 = ecfp6(&benzene());
348        // Larger radius explores more environment — the bit counts should differ
349        // because radius-3 adds new hash values not present at radius-2.
350        assert_ne!(
351            fp4.popcount(),
352            fp6.popcount(),
353            "ECFP6 and ECFP4 should produce different bit counts for benzene"
354        );
355    }
356
357    #[test]
358    fn methane_ecfp4_nonzero() {
359        let fp = ecfp4(&methane());
360        assert!(fp.popcount() > 0, "methane ECFP4 must be non-zero");
361    }
362
363    #[test]
364    fn water_ecfp4_nonzero() {
365        let fp = ecfp4(&water());
366        assert!(fp.popcount() > 0, "water ECFP4 must be non-zero");
367    }
368
369    #[test]
370    fn tanimoto_ecfp4_benzene_self_is_one() {
371        let t = tanimoto_ecfp4(&benzene(), &benzene());
372        assert_eq!(t, 1.0, "tanimoto_ecfp4 of identical molecules must be 1.0");
373    }
374
375    #[test]
376    fn tanimoto_ecfp4_methane_vs_benzene_lt_half() {
377        let t = tanimoto_ecfp4(&methane(), &benzene());
378        assert!(
379            t < 0.5,
380            "methane and benzene should be very dissimilar (tanimoto={t})"
381        );
382    }
383
384    // ── morgan_fp_counts ─────────────────────────────────────────────────────
385
386    #[test]
387    fn morgan_counts_radius0_atom_count() {
388        // At radius 0, one hash per atom.
389        let m = benzene();
390        let counts = morgan_fp_counts(&m, 0);
391        let total: u32 = counts.values().sum();
392        assert_eq!(
393            total,
394            m.atom_count() as u32,
395            "radius-0 total count == atom_count"
396        );
397    }
398
399    #[test]
400    fn morgan_counts_radius2_total_grows() {
401        // Each additional radius adds one hash per atom → total = n * (radius+1).
402        let m = methane();
403        let n = m.atom_count() as u32;
404        let r = 2u32;
405        let counts = morgan_fp_counts(&m, r);
406        let total: u32 = counts.values().sum();
407        assert_eq!(
408            total,
409            n * (r + 1),
410            "methane total = atom_count * (radius+1)"
411        );
412    }
413
414    #[test]
415    fn morgan_counts_benzene_symmetry() {
416        // All 6 benzene C atoms are equivalent → radius-0 yields 1 unique hash.
417        let m = benzene();
418        let counts = morgan_fp_counts(&m, 0);
419        assert_eq!(
420            counts.len(),
421            1,
422            "benzene has one unique radius-0 environment"
423        );
424        assert_eq!(
425            *counts.values().next().unwrap(),
426            6,
427            "that environment appears 6 times"
428        );
429    }
430
431    #[test]
432    fn morgan_counts_empty_mol_is_empty() {
433        use chematic_core::MoleculeBuilder;
434        let m = MoleculeBuilder::new().build();
435        let counts = morgan_fp_counts(&m, 2);
436        assert!(counts.is_empty(), "empty molecule yields empty count map");
437    }
438
439    #[test]
440    fn morgan_counts_deterministic() {
441        let m = aspirin();
442        let c1 = morgan_fp_counts(&m, 2);
443        let c2 = morgan_fp_counts(&m, 2);
444        assert_eq!(c1, c2, "morgan_fp_counts must be deterministic");
445    }
446
447    #[test]
448    fn morgan_counts_consistent_with_ecfp_bits() {
449        // Every hash in the count map should be reachable from the ecfp bit set
450        // (after folding to 2048 bits).  This checks the same hash scheme.
451        let m = toluene();
452        let fp = ecfp(
453            &m,
454            &EcfpConfig {
455                radius: 2,
456                nbits: 2048,
457                use_chirality: false,
458                use_double_fold: false,
459            },
460        );
461        let counts = morgan_fp_counts(&m, 2);
462        for &hash in counts.keys() {
463            let bit = (hash % 2048) as usize;
464            assert!(
465                fp.get(bit),
466                "bit {bit} from count map not set in ECFP bitvec"
467            );
468        }
469    }
470
471    // -- Chirality tests ------------------------------------------------------
472
473    #[test]
474    fn ecfp4_ignores_chirality_by_default() {
475        // L-alanine and D-alanine should produce the same ECFP4 when
476        // use_chirality=false (default), since chirality is not in the hash.
477        let l_ala = parse("N[C@@H](C)C(=O)O").unwrap();
478        let d_ala = parse("N[C@H](C)C(=O)O").unwrap();
479        let fp_l = ecfp4(&l_ala);
480        let fp_d = ecfp4(&d_ala);
481        assert_eq!(
482            fp_l, fp_d,
483            "L/D-alanine ECFP4 should be identical when use_chirality=false"
484        );
485    }
486
487    #[test]
488    fn ecfp4_distinguishes_enantiomers_with_chirality() {
489        // With use_chirality=true, L-alanine and D-alanine must have different FPs.
490        let l_ala = parse("N[C@@H](C)C(=O)O").unwrap();
491        let d_ala = parse("N[C@H](C)C(=O)O").unwrap();
492        let config = EcfpConfig {
493            radius: 2,
494            nbits: 2048,
495            use_chirality: true,
496            use_double_fold: false,
497        };
498        let fp_l = ecfp(&l_ala, &config);
499        let fp_d = ecfp(&d_ala, &config);
500        assert_ne!(
501            fp_l, fp_d,
502            "L/D-alanine ECFP4 must differ when use_chirality=true"
503        );
504        // Tanimoto < 1.0 confirms they are not identical.
505        assert!(
506            fp_l.tanimoto(&fp_d) < 1.0,
507            "Tanimoto of L/D-alanine must be < 1.0 with use_chirality"
508        );
509    }
510
511    #[test]
512    fn ecfp4_non_chiral_generates_with_chirality_flag() {
513        // Non-chiral molecules like benzene should generate valid ECFP with use_chirality flag.
514        // This test verifies that use_chirality=true doesn't break on achiral molecules.
515        let mol = parse("c1ccccc1").unwrap(); // Benzene — no stereo centers.
516        let config = EcfpConfig {
517            radius: 2,
518            nbits: 2048,
519            use_chirality: true,
520            use_double_fold: false,
521        };
522        let fp = ecfp(&mol, &config);
523        assert!(
524            fp.popcount() > 0,
525            "Benzene should generate non-empty ECFP4 with use_chirality=true"
526        );
527    }
528
529    // -----------------------------------------------------------------------
530    // Implicit vs. explicit hydrogen representation (CDK issue #1084 pattern)
531    // -----------------------------------------------------------------------
532    //
533    // chematic's molecule model stores only heavy atoms; implicit H counts are
534    // computed on demand via `implicit_hcount()`.  When a SMILES contains
535    // explicit H atoms (e.g. `[H]O` or `[OH2]`), those atoms ARE stored in
536    // the molecular graph and WILL change the ECFP invariant for the heavy
537    // atom they're bonded to (its degree increases and its implicit_hcount
538    // decreases).  This is the expected behaviour for a graph-based fingerprint
539    // and mirrors CDK / RDKit behaviour.  These tests document it explicitly.
540
541    #[test]
542    fn ecfp4_implicit_h_water_vs_no_atoms() {
543        // "O" has 1 heavy atom (O) with 2 implicit H.
544        // "[OH2]" is the same molecule: O with explicit H count = 2 but still
545        // only 1 heavy atom (implicit H also = 0 because OH2 bracket sets it).
546        // Both should produce the same fingerprint.
547        let implicit = parse("O").unwrap();
548        let bracketed = parse("[OH2]").unwrap();
549        assert_eq!(
550            ecfp4(&implicit),
551            ecfp4(&bracketed),
552            "[OH2] and O should give the same ECFP4 (same heavy-atom graph)"
553        );
554    }
555
556    #[test]
557    fn ecfp4_explicit_h_atom_changes_fingerprint() {
558        // "[H]O[H]" parses as 3 atoms: H-O-H.  The O atom now has degree=2
559        // and implicit_hcount=0, so its Morgan invariant differs from the
560        // single-atom "O" (degree=0, implicit_hcount=2).  The fingerprint
561        // must differ — this documents the expected behaviour when explicit
562        // H atoms are present in the molecular graph.
563        let implicit = parse("O").unwrap();
564        let explicit_h = parse("[H]O[H]").unwrap();
565        assert_ne!(
566            ecfp4(&implicit),
567            ecfp4(&explicit_h),
568            "explicit H atoms in the graph change the ECFP4"
569        );
570    }
571
572    #[test]
573    fn ecfp4_implicit_vs_explicit_h_in_organic_molecule() {
574        // Methanol "CO" vs "C([H])([H])([H])O" — the second form has 3
575        // explicit H atoms on C, which changes C's degree and implicit_hcount.
576        // These are different molecular graphs → different ECFP4.
577        let implicit = parse("CO").unwrap();
578        let explicit_h = parse("C([H])([H])([H])O").unwrap();
579        assert_ne!(
580            ecfp4(&implicit),
581            ecfp4(&explicit_h),
582            "methanol with explicit H atoms has a different heavy-atom neighbourhood"
583        );
584    }
585}