1#![forbid(unsafe_code)]
24
25use chematic_core::{AtomIdx, Molecule};
26
27#[derive(Debug, Clone)]
31pub struct HdfConfig {
32 pub dim: usize,
34 pub radius: usize,
36 pub seed: u64,
38}
39
40impl Default for HdfConfig {
41 fn default() -> Self {
42 HdfConfig {
43 dim: 1024,
44 radius: 2,
45 seed: 42,
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
54pub struct HdfFp(pub Vec<f32>);
55
56pub fn hdf_default(mol: &Molecule) -> HdfFp {
60 hdf(mol, &HdfConfig::default())
61}
62
63pub fn hdf(mol: &Molecule, config: &HdfConfig) -> HdfFp {
65 let d = config.dim;
66 let n = mol.atom_count();
67
68 if n == 0 {
69 return HdfFp(vec![0.0f32; d]);
70 }
71
72 let bases: Vec<Vec<f32>> = (0..n)
74 .map(|i| {
75 let atom = mol.atom(AtomIdx(i as u32));
76 random_bipolar(
77 d,
78 atom_seed(atom.element.atomic_number(), atom.charge, config.seed),
79 )
80 })
81 .collect();
82
83 let mut mol_vec = vec![0.0f32; d];
84
85 for i in 0..n {
86 add_scaled(&mut mol_vec, &bases[i], 1.0);
88
89 if config.radius == 0 {
90 continue;
91 }
92
93 let mut visited = vec![false; n];
95 visited[i] = true;
96
97 let mut frontier: Vec<usize> = mol
99 .neighbors(AtomIdx(i as u32))
100 .map(|(nb, _)| {
101 let j = nb.0 as usize;
102 visited[j] = true;
103 j
104 })
105 .collect();
106
107 let mut binding = bases[i].clone();
109
110 for hop in 1..=config.radius {
111 if frontier.is_empty() {
112 break;
113 }
114
115 let shifted = cyclic_shift(&binding, hop * 7 + 3); for &j in &frontier {
120 let bound = elementwise_product(&shifted, &bases[j]);
122 add_scaled(&mut mol_vec, &bound, 1.0);
123 }
124
125 let mut next: Vec<usize> = Vec::new();
127 for &j in &frontier {
128 for (nb, _) in mol.neighbors(AtomIdx(j as u32)) {
129 let k = nb.0 as usize;
130 if !visited[k] {
131 visited[k] = true;
132 next.push(k);
133 }
134 }
135 }
136
137 for &j in &frontier {
139 elementwise_multiply_into(&mut binding, &bases[j]);
140 }
141
142 frontier = next;
143 }
144 }
145
146 normalize(&mut mol_vec);
147 HdfFp(mol_vec)
148}
149
150pub fn cosine_hdf(a: &HdfFp, b: &HdfFp) -> f32 {
155 debug_assert_eq!(a.0.len(), b.0.len(), "HdfFp dimensions must match");
156 a.0.iter().zip(&b.0).map(|(x, y)| x * y).sum()
157}
158
159fn random_bipolar(d: usize, seed: u64) -> Vec<f32> {
163 let mut state = if seed == 0 { 1 } else { seed };
164 let mut out = Vec::with_capacity(d);
165 while out.len() < d {
166 state ^= state << 13;
168 state ^= state >> 7;
169 state ^= state << 17;
170 let bits = state;
172 for b in 0..64 {
173 if out.len() == d {
174 break;
175 }
176 out.push(if (bits >> b) & 1 == 0 {
177 -1.0f32
178 } else {
179 1.0f32
180 });
181 }
182 }
183 out
184}
185
186fn cyclic_shift(v: &[f32], k: usize) -> Vec<f32> {
188 let d = v.len();
189 if d == 0 {
190 return Vec::new();
191 }
192 let k = k % d;
193 let mut out = Vec::with_capacity(d);
194 out.extend_from_slice(&v[k..]);
195 out.extend_from_slice(&v[..k]);
196 out
197}
198
199fn elementwise_product(a: &[f32], b: &[f32]) -> Vec<f32> {
201 a.iter().zip(b).map(|(x, y)| x * y).collect()
202}
203
204fn elementwise_multiply_into(binding: &mut [f32], factor: &[f32]) {
206 for (b, &f) in binding.iter_mut().zip(factor) {
207 *b *= f;
208 }
209}
210
211fn add_scaled(dst: &mut [f32], src: &[f32], scale: f32) {
213 for (d, &s) in dst.iter_mut().zip(src) {
214 *d += scale * s;
215 }
216}
217
218fn normalize(v: &mut [f32]) {
220 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
221 if norm > 1e-9 {
222 for x in v.iter_mut() {
223 *x /= norm;
224 }
225 }
226}
227
228fn atom_seed(atomic_num: u8, charge: i8, global_seed: u64) -> u64 {
230 global_seed
231 .wrapping_mul(6364136223846793005)
232 .wrapping_add(atomic_num as u64)
233 .wrapping_add((charge as i64 + 128) as u64)
234 .wrapping_mul(2654435761)
235}
236
237#[cfg(test)]
240mod tests {
241 use super::*;
242 use chematic_smiles::parse;
243
244 fn mol(smi: &str) -> chematic_core::Molecule {
245 parse(smi).expect("parse SMILES")
246 }
247
248 fn hdf_default_mol(smi: &str) -> HdfFp {
249 hdf_default(&mol(smi))
250 }
251
252 #[test]
253 fn same_molecule_cosine_one() {
254 let fp = hdf_default_mol("CCO");
255 let fp2 = hdf_default_mol("CCO");
256 let cos = cosine_hdf(&fp, &fp2);
257 assert!((cos - 1.0).abs() < 1e-5, "same molecule cosine={cos}");
258 }
259
260 #[test]
261 fn unit_norm() {
262 for smi in ["CCO", "c1ccccc1", "CC(=O)Oc1ccccc1C(=O)O"] {
263 let fp = hdf_default_mol(smi);
264 let norm: f32 = fp.0.iter().map(|x| x * x).sum::<f32>().sqrt();
265 assert!((norm - 1.0).abs() < 1e-5, "{smi}: norm={norm}");
266 }
267 }
268
269 #[test]
270 fn ethanol_vs_benzene_less_similar_than_propanol() {
271 let eth = hdf_default_mol("CCO");
272 let ben = hdf_default_mol("c1ccccc1");
273 let pro = hdf_default_mol("CCCO");
274 let cos_eb = cosine_hdf(ð, &ben);
275 let cos_ep = cosine_hdf(ð, &pro);
276 assert!(
277 cos_ep > cos_eb,
278 "propanol ({cos_ep:.3}) should be more similar to ethanol than benzene ({cos_eb:.3})"
279 );
280 }
281
282 #[test]
283 fn different_dims_work() {
284 for dim in [64, 256, 512, 1024, 2048] {
285 let config = HdfConfig {
286 dim,
287 radius: 2,
288 seed: 42,
289 };
290 let fp = hdf(&mol("CCO"), &config);
291 assert_eq!(fp.0.len(), dim);
292 let norm: f32 = fp.0.iter().map(|x| x * x).sum::<f32>().sqrt();
293 assert!((norm - 1.0).abs() < 1e-4, "dim={dim} norm={norm}");
294 }
295 }
296
297 #[test]
298 fn different_radius_gives_different_vectors() {
299 let mol = mol("c1ccccc1");
300 let r0 = hdf(
301 &mol,
302 &HdfConfig {
303 dim: 256,
304 radius: 0,
305 seed: 1,
306 },
307 );
308 let r2 = hdf(
309 &mol,
310 &HdfConfig {
311 dim: 256,
312 radius: 2,
313 seed: 1,
314 },
315 );
316 let cos = cosine_hdf(&r0, &r2);
317 assert!(
318 cos < 0.999,
319 "radius-0 and radius-2 should differ; cosine={cos}"
320 );
321 }
322
323 #[test]
324 fn different_seeds_give_different_vectors() {
325 let m = mol("CCO");
326 let fp1 = hdf(
327 &m,
328 &HdfConfig {
329 dim: 256,
330 radius: 2,
331 seed: 1,
332 },
333 );
334 let fp2 = hdf(
335 &m,
336 &HdfConfig {
337 dim: 256,
338 radius: 2,
339 seed: 2,
340 },
341 );
342 let cos = cosine_hdf(&fp1, &fp2);
343 assert!(
344 cos < 0.999,
345 "different seeds should give different vectors; cosine={cos}"
346 );
347 }
348
349 #[test]
350 fn charged_atom_differs_from_neutral() {
351 let charged = hdf_default_mol("[NH4+]");
353 let neutral = hdf_default_mol("N");
354 let cos = cosine_hdf(&charged, &neutral);
355 assert!(cos < 0.99, "charged vs neutral too similar: cosine={cos}");
356 }
357
358 #[test]
359 fn empty_molecule_returns_zero_vector() {
360 let empty = chematic_core::MoleculeBuilder::new().build();
361 let fp = hdf_default(&empty);
362 assert!(fp.0.iter().all(|&x| x == 0.0));
363 }
364}