1use 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
15pub(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
25fn expand_atom_id(mol: &Molecule, i: usize, r: u32, ids: &[u64]) -> u64 {
30 let idx = AtomIdx(i as u32);
31 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 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
54pub(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#[derive(Debug, Clone)]
93pub struct EcfpConfig {
94 pub radius: u32,
96 pub nbits: usize,
98 pub use_chirality: bool,
103 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#[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
145pub 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 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 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 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
203pub 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 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 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
248pub fn ecfp4(mol: &Molecule) -> BitVec2048 {
250 ecfp(mol, &EcfpConfig::default())
251}
252
253pub fn ecfp6(mol: &Molecule) -> BitVec2048 {
255 ecfp(
256 mol,
257 &EcfpConfig {
258 radius: 3,
259 ..EcfpConfig::default()
260 },
261 )
262}
263
264pub 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 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(ðane(), &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 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 #[test]
387 fn morgan_counts_radius0_atom_count() {
388 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 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 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 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 #[test]
474 fn ecfp4_ignores_chirality_by_default() {
475 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 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 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 let mol = parse("c1ccccc1").unwrap(); 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 #[test]
542 fn ecfp4_implicit_h_water_vs_no_atoms() {
543 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 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 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}