Skip to main content

chematic_fp/
map4.rs

1//! MAP4 — MinHashed Atom-Pair Fingerprint (Minervini et al. 2020)
2//!
3//! MAP4 encodes circular atom environments as SMILES "shingles" and hashes them
4//! using the MinHash scheme. Unlike MHFP (which uses 3-hop paths), MAP4 uses
5//! all atom-pair combinations at increasing topological radius.
6//!
7//! Reference: Minervini et al., *J. Cheminform.* 2020, 12, 26.
8//! "One molecular fingerprint to rule them all"
9//!
10//! ## Algorithm
11//! 1. For each atom pair (a, b) and radii (r_a, r_b) from 0 to max_radius:
12//!    - Extract the circular SMILES environment of atom a at radius r_a
13//!    - Extract the circular SMILES environment of atom b at radius r_b
14//!    - Encode the pair as `"env_a|env_b|distance"` (sorted for symmetry)
15//! 2. Collect all shingles into a set
16//! 3. Apply MinHash with `n_permutations` hash functions → fixed-size signature
17//!
18//! ## Comparison to RDKit
19//! RDKit does **not** include MAP4 in its main distribution — users must install
20//! the separate `map4` package. chematic implements it natively in pure Rust.
21
22use crate::ecfp::fnv1a;
23use chematic_core::{AtomIdx, Molecule};
24
25/// MAP4 fingerprint configuration.
26#[derive(Debug, Clone)]
27pub struct Map4Config {
28    /// Maximum circular environment radius (default 2, like ECFP4)
29    pub max_radius: usize,
30    /// Number of MinHash permutations (default 1024)
31    pub n_permutations: usize,
32}
33
34impl Default for Map4Config {
35    fn default() -> Self {
36        Self {
37            max_radius: 2,
38            n_permutations: 1024,
39        }
40    }
41}
42
43/// Compute the MAP4 fingerprint as a MinHash signature vector.
44///
45/// Returns a `Vec<u32>` of length `config.n_permutations`. Two molecules
46/// with Jaccard similarity J have expected Hamming similarity J over this vector.
47///
48/// Tanimoto similarity can be estimated as:
49/// `(matching_positions as f64) / config.n_permutations as f64`
50pub fn map4(mol: &Molecule, config: &Map4Config) -> Vec<u32> {
51    let shingles = collect_shingles(mol, config.max_radius);
52    minhash(&shingles, config.n_permutations)
53}
54
55/// Compute MAP4 with default configuration (radius=2, 1024 permutations).
56pub fn map4_default(mol: &Molecule) -> Vec<u32> {
57    map4(mol, &Map4Config::default())
58}
59
60/// Estimate Tanimoto similarity between two MAP4 signatures.
61///
62/// Returns a value in [0, 1]. Exact for Jaccard similarity of the shingle sets
63/// in the limit of infinite permutations; approximate with finite n_permutations.
64pub fn tanimoto_map4(a: &[u32], b: &[u32]) -> f64 {
65    if a.len() != b.len() || a.is_empty() {
66        return 0.0;
67    }
68    let matches = a.iter().zip(b.iter()).filter(|(x, y)| x == y).count();
69    matches as f64 / a.len() as f64
70}
71
72// ─── Shingle extraction ───────────────────────────────────────────────────────
73
74fn collect_shingles(mol: &Molecule, max_radius: usize) -> Vec<u64> {
75    let n = mol.atom_count();
76    // Precompute BFS distances between all pairs (Floyd-Warshall on adjacency)
77    let dist = bfs_all_pairs(mol);
78
79    let mut hashes = Vec::new();
80
81    for a in 0..n {
82        for b in a..n {
83            let d = dist[a][b];
84            if d == usize::MAX {
85                continue; // disconnected
86            }
87            for r_a in 0..=max_radius {
88                for r_b in 0..=max_radius {
89                    let env_a = circular_env_hash(mol, a, r_a, &dist);
90                    let env_b = circular_env_hash(mol, b, r_b, &dist);
91                    // Canonical: sort envs so order of (a,b) doesn't matter
92                    let (ea, eb) = if env_a <= env_b {
93                        (env_a, env_b)
94                    } else {
95                        (env_b, env_a)
96                    };
97                    // Encode: hash(env_a || env_b || distance)
98                    let mut h = fnv1a(ea.to_le_bytes().as_ref());
99                    h = fnv1a_extend(h, eb.to_le_bytes().as_ref());
100                    h = fnv1a_extend(h, &(d as u64).to_le_bytes());
101                    hashes.push(h);
102                }
103            }
104        }
105    }
106    hashes.sort_unstable();
107    hashes.dedup();
108    hashes
109}
110
111/// Hash for the circular environment of `center` at `radius`.
112/// Encodes atom types of all atoms within `radius` bonds, together with the
113/// topological distances from the center.
114fn circular_env_hash(mol: &Molecule, center: usize, radius: usize, dist: &[Vec<usize>]) -> u64 {
115    let mut contributions: Vec<(usize, u64)> = mol
116        .atoms()
117        .filter_map(|(idx, atom)| {
118            let d = dist[center][idx.0 as usize];
119            if d <= radius {
120                let atom_hash = fnv1a(&[
121                    atom.element.atomic_number(),
122                    atom.charge.unsigned_abs(),
123                    atom.element.atomic_number().wrapping_add(d as u8),
124                ]);
125                Some((d, atom_hash))
126            } else {
127                None
128            }
129        })
130        .collect();
131    // Sort by (distance, hash) for determinism
132    contributions.sort_unstable();
133    let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis
134    for (d, ah) in contributions {
135        h = fnv1a_extend(h, &(d as u64).to_le_bytes());
136        h = fnv1a_extend(h, &ah.to_le_bytes());
137    }
138    h
139}
140
141// ─── MinHash ─────────────────────────────────────────────────────────────────
142
143fn minhash(shingles: &[u64], n_permutations: usize) -> Vec<u32> {
144    let mut sig = vec![u32::MAX; n_permutations];
145    for &h in shingles {
146        for (i, slot) in sig.iter_mut().enumerate().take(n_permutations) {
147            // Deterministic hash family: H_i(x) = (a_i * x + b_i) mod p
148            // We use FNV-based mixing for speed (no true universal hash needed)
149            let mixed = fnv1a_mix(h, i as u64);
150            let v = (mixed >> 32) as u32;
151            if v < *slot {
152                *slot = v;
153            }
154        }
155    }
156    sig
157}
158
159// ─── Graph distance ───────────────────────────────────────────────────────────
160
161fn bfs_all_pairs(mol: &Molecule) -> Vec<Vec<usize>> {
162    let n = mol.atom_count();
163    (0..n).map(|src| bfs_from(mol, src, n)).collect()
164}
165
166fn bfs_from(mol: &Molecule, src: usize, n: usize) -> Vec<usize> {
167    let mut dist = vec![usize::MAX; n];
168    dist[src] = 0;
169    let mut queue = std::collections::VecDeque::new();
170    queue.push_back(src);
171    while let Some(cur) = queue.pop_front() {
172        let d = dist[cur];
173        for (nb, _) in mol.neighbors(AtomIdx(cur as u32)) {
174            let nbi = nb.0 as usize;
175            if dist[nbi] == usize::MAX {
176                dist[nbi] = d + 1;
177                queue.push_back(nbi);
178            }
179        }
180    }
181    dist
182}
183
184// ─── Hash utilities ───────────────────────────────────────────────────────────
185
186#[inline]
187fn fnv1a_extend(mut h: u64, bytes: &[u8]) -> u64 {
188    for &b in bytes {
189        h ^= b as u64;
190        h = h.wrapping_mul(0x00000100000001b3);
191    }
192    h
193}
194
195#[inline]
196fn fnv1a_mix(h: u64, seed: u64) -> u64 {
197    let mut v = h.wrapping_add(seed.wrapping_mul(0x9e3779b97f4a7c15));
198    v ^= v >> 30;
199    v = v.wrapping_mul(0xbf58476d1ce4e5b9);
200    v ^= v >> 27;
201    v = v.wrapping_mul(0x94d049bb133111eb);
202    v ^= v >> 31;
203    v
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use chematic_smiles::parse;
210
211    #[test]
212    fn map4_same_molecule_identical() {
213        let mol = parse("CC").expect("parse");
214        let a = map4_default(&mol);
215        let b = map4_default(&mol);
216        assert_eq!(a, b, "MAP4 must be deterministic");
217    }
218
219    #[test]
220    fn map4_self_similarity_is_one() {
221        let mol = parse("c1ccccc1").expect("parse benzene");
222        let fp = map4_default(&mol);
223        assert!((tanimoto_map4(&fp, &fp) - 1.0).abs() < 1e-9);
224    }
225
226    #[test]
227    fn map4_similar_molecules_higher_than_dissimilar() {
228        let benzene = parse("c1ccccc1").expect("benzene");
229        let toluene = parse("Cc1ccccc1").expect("toluene");
230        let methane = parse("C").expect("methane");
231        let fp_benz = map4_default(&benzene);
232        let fp_tol = map4_default(&toluene);
233        let fp_meth = map4_default(&methane);
234        let sim_close = tanimoto_map4(&fp_benz, &fp_tol);
235        let sim_far = tanimoto_map4(&fp_benz, &fp_meth);
236        assert!(
237            sim_close > sim_far,
238            "benzene-toluene ({:.3}) should be > benzene-methane ({:.3})",
239            sim_close,
240            sim_far
241        );
242    }
243
244    #[test]
245    fn map4_length_equals_n_permutations() {
246        let mol = parse("CCO").expect("parse");
247        let cfg = Map4Config {
248            max_radius: 1,
249            n_permutations: 512,
250        };
251        let fp = map4(&mol, &cfg);
252        assert_eq!(fp.len(), 512);
253    }
254
255    #[test]
256    fn tanimoto_bounds() {
257        let mol1 = parse("CC").expect("parse");
258        let mol2 = parse("CCC").expect("parse");
259        let fp1 = map4_default(&mol1);
260        let fp2 = map4_default(&mol2);
261        let t = tanimoto_map4(&fp1, &fp2);
262        assert!((0.0..=1.0).contains(&t), "Tanimoto out of range: {}", t);
263    }
264}