Skip to main content

chematic_fp/
hdf.rs

1//! Hyper-Dimensional Fingerprints (HDF) for molecules.
2//!
3//! HDF encodes a molecule as a unit-norm float32 vector in a high-dimensional
4//! space using Hyperdimensional Computing (HDC) operations.  Unlike hash-based
5//! fingerprints (ECFP), there is no collision: every distinct atom environment
6//! maps to a unique HD vector whose similarity can be measured with cosine dot.
7//!
8//! ## Algorithm
9//!
10//! 1. **Basis**: each (element, charge) pair → deterministic d-dim bipolar vector (±1).
11//! 2. **Neighborhood**: for atom *i* at hop *h*, bind (element-wise multiply)
12//!    the cyclic-shifted basis of *i* with the bases of *h*-hop neighbors.
13//! 3. **Superposition**: sum all contributions across all atoms and hops.
14//! 4. **Normalize** to unit sphere → `Vec<f32>` of length `dim`.
15//!
16//! Cosine similarity between two normalized HDF vectors equals their dot product,
17//! making nearest-neighbor search trivial.
18//!
19//! ## References
20//! - "Hyper-Dimensional Fingerprints as Molecular Representations" (arXiv 2026)
21//! - Kanerva, P. "Hyperdimensional Computing" (Cognitive Computation 2009)
22
23#![forbid(unsafe_code)]
24
25use chematic_core::{AtomIdx, Molecule};
26
27// ─── Config & Output ─────────────────────────────────────────────────────────
28
29/// Configuration for HDF fingerprint generation.
30#[derive(Debug, Clone)]
31pub struct HdfConfig {
32    /// Vector dimension. Higher → less collision, more memory. Default: 1024.
33    pub dim: usize,
34    /// Neighborhood radius (hops). Default: 2.
35    pub radius: usize,
36    /// Reproducibility seed. Default: 42.
37    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/// A normalized HDF fingerprint vector (unit-norm f32 slice).
51///
52/// Use [`cosine_hdf`] for similarity between two HDF fingerprints of the same config.
53#[derive(Debug, Clone)]
54pub struct HdfFp(pub Vec<f32>);
55
56// ─── Public API ──────────────────────────────────────────────────────────────
57
58/// Compute an HDF fingerprint with default config (dim=1024, radius=2, seed=42).
59pub fn hdf_default(mol: &Molecule) -> HdfFp {
60    hdf(mol, &HdfConfig::default())
61}
62
63/// Compute an HDF fingerprint for `mol` using the given `config`.
64pub 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    // Pre-compute per-atom basis vectors (±1 floats, deterministic)
73    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        // Hop 0: atom's own basis
87        add_scaled(&mut mol_vec, &bases[i], 1.0);
88
89        if config.radius == 0 {
90            continue;
91        }
92
93        // BFS expansion up to radius
94        let mut visited = vec![false; n];
95        visited[i] = true;
96
97        // frontier = immediate neighbors (hop 1)
98        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        // accumulated_binding starts as the center atom's basis
108        let mut binding = bases[i].clone();
109
110        for hop in 1..=config.radius {
111            if frontier.is_empty() {
112                break;
113            }
114
115            // Permute (cyclic-shift) the accumulated binding vector by `hop` positions
116            // so each hop level produces a distinct encoding.
117            let shifted = cyclic_shift(&binding, hop * 7 + 3); // prime-ish stride
118
119            for &j in &frontier {
120                // Bind: element-wise multiply shifted center with neighbor basis
121                let bound = elementwise_product(&shifted, &bases[j]);
122                add_scaled(&mut mol_vec, &bound, 1.0);
123            }
124
125            // Expand frontier one more hop
126            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            // Accumulate the frontier atoms into binding for the next hop
138            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
150/// Cosine similarity between two HDF fingerprints (both must be normalized).
151///
152/// Returns a value in [-1, 1] where 1 = identical, 0 = orthogonal.
153/// Panics in debug if the two vectors have different lengths.
154pub 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
159// ─── HD operations ───────────────────────────────────────────────────────────
160
161/// Generate a d-dimensional bipolar (±1) vector seeded deterministically.
162fn 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        // xorshift64
167        state ^= state << 13;
168        state ^= state >> 7;
169        state ^= state << 17;
170        // Each u64 gives 64 bits
171        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
186/// Cyclic-shift a vector left by `k % d` positions.
187fn 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
199/// Element-wise product (binding) of two equal-length vectors.
200fn elementwise_product(a: &[f32], b: &[f32]) -> Vec<f32> {
201    a.iter().zip(b).map(|(x, y)| x * y).collect()
202}
203
204/// Bind `binding` in-place: element-wise multiply by `factor`.
205fn elementwise_multiply_into(binding: &mut [f32], factor: &[f32]) {
206    for (b, &f) in binding.iter_mut().zip(factor) {
207        *b *= f;
208    }
209}
210
211/// Add `scale * src` into `dst`.
212fn 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
218/// Normalize `v` to unit length in-place; no-op if length is ~0.
219fn 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
228/// Deterministic seed for an atom given its element, charge, and global seed.
229fn 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// ─── Tests ───────────────────────────────────────────────────────────────────
238
239#[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(&eth, &ben);
275        let cos_ep = cosine_hdf(&eth, &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        // [NH4+] vs NH3: different charge → different basis → different HDF
352        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}