1use crate::ecfp::fnv1a;
23use chematic_core::{AtomIdx, Molecule};
24
25#[derive(Debug, Clone)]
27pub struct Map4Config {
28 pub max_radius: usize,
30 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
43pub 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
55pub fn map4_default(mol: &Molecule) -> Vec<u32> {
57 map4(mol, &Map4Config::default())
58}
59
60pub 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
72fn collect_shingles(mol: &Molecule, max_radius: usize) -> Vec<u64> {
75 let n = mol.atom_count();
76 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; }
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 let (ea, eb) = if env_a <= env_b {
93 (env_a, env_b)
94 } else {
95 (env_b, env_a)
96 };
97 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
111fn 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 contributions.sort_unstable();
133 let mut h: u64 = 0xcbf29ce484222325; 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
141fn 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 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
159fn 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#[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}