#![forbid(unsafe_code)]
use chematic_core::{AtomIdx, Molecule};
#[derive(Debug, Clone)]
pub struct HdfConfig {
pub dim: usize,
pub radius: usize,
pub seed: u64,
}
impl Default for HdfConfig {
fn default() -> Self {
HdfConfig {
dim: 1024,
radius: 2,
seed: 42,
}
}
}
#[derive(Debug, Clone)]
pub struct HdfFp(pub Vec<f32>);
pub fn hdf_default(mol: &Molecule) -> HdfFp {
hdf(mol, &HdfConfig::default())
}
pub fn hdf(mol: &Molecule, config: &HdfConfig) -> HdfFp {
let d = config.dim;
let n = mol.atom_count();
if n == 0 {
return HdfFp(vec![0.0f32; d]);
}
let bases: Vec<Vec<f32>> = (0..n)
.map(|i| {
let atom = mol.atom(AtomIdx(i as u32));
random_bipolar(
d,
atom_seed(atom.element.atomic_number(), atom.charge, config.seed),
)
})
.collect();
let mut mol_vec = vec![0.0f32; d];
for i in 0..n {
add_scaled(&mut mol_vec, &bases[i], 1.0);
if config.radius == 0 {
continue;
}
let mut visited = vec![false; n];
visited[i] = true;
let mut frontier: Vec<usize> = mol
.neighbors(AtomIdx(i as u32))
.map(|(nb, _)| {
let j = nb.0 as usize;
visited[j] = true;
j
})
.collect();
let mut binding = bases[i].clone();
for hop in 1..=config.radius {
if frontier.is_empty() {
break;
}
let shifted = cyclic_shift(&binding, hop * 7 + 3);
for &j in &frontier {
let bound = elementwise_product(&shifted, &bases[j]);
add_scaled(&mut mol_vec, &bound, 1.0);
}
let mut next: Vec<usize> = Vec::new();
for &j in &frontier {
for (nb, _) in mol.neighbors(AtomIdx(j as u32)) {
let k = nb.0 as usize;
if !visited[k] {
visited[k] = true;
next.push(k);
}
}
}
for &j in &frontier {
elementwise_multiply_into(&mut binding, &bases[j]);
}
frontier = next;
}
}
normalize(&mut mol_vec);
HdfFp(mol_vec)
}
pub fn cosine_hdf(a: &HdfFp, b: &HdfFp) -> f32 {
debug_assert_eq!(a.0.len(), b.0.len(), "HdfFp dimensions must match");
a.0.iter().zip(&b.0).map(|(x, y)| x * y).sum()
}
fn random_bipolar(d: usize, seed: u64) -> Vec<f32> {
let mut state = if seed == 0 { 1 } else { seed };
let mut out = Vec::with_capacity(d);
while out.len() < d {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let bits = state;
for b in 0..64 {
if out.len() == d {
break;
}
out.push(if (bits >> b) & 1 == 0 {
-1.0f32
} else {
1.0f32
});
}
}
out
}
fn cyclic_shift(v: &[f32], k: usize) -> Vec<f32> {
let d = v.len();
if d == 0 {
return Vec::new();
}
let k = k % d;
let mut out = Vec::with_capacity(d);
out.extend_from_slice(&v[k..]);
out.extend_from_slice(&v[..k]);
out
}
fn elementwise_product(a: &[f32], b: &[f32]) -> Vec<f32> {
a.iter().zip(b).map(|(x, y)| x * y).collect()
}
fn elementwise_multiply_into(binding: &mut [f32], factor: &[f32]) {
for (b, &f) in binding.iter_mut().zip(factor) {
*b *= f;
}
}
fn add_scaled(dst: &mut [f32], src: &[f32], scale: f32) {
for (d, &s) in dst.iter_mut().zip(src) {
*d += scale * s;
}
}
fn normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-9 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
fn atom_seed(atomic_num: u8, charge: i8, global_seed: u64) -> u64 {
global_seed
.wrapping_mul(6364136223846793005)
.wrapping_add(atomic_num as u64)
.wrapping_add((charge as i64 + 128) as u64)
.wrapping_mul(2654435761)
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(smi: &str) -> chematic_core::Molecule {
parse(smi).expect("parse SMILES")
}
fn hdf_default_mol(smi: &str) -> HdfFp {
hdf_default(&mol(smi))
}
#[test]
fn same_molecule_cosine_one() {
let fp = hdf_default_mol("CCO");
let fp2 = hdf_default_mol("CCO");
let cos = cosine_hdf(&fp, &fp2);
assert!((cos - 1.0).abs() < 1e-5, "same molecule cosine={cos}");
}
#[test]
fn unit_norm() {
for smi in ["CCO", "c1ccccc1", "CC(=O)Oc1ccccc1C(=O)O"] {
let fp = hdf_default_mol(smi);
let norm: f32 = fp.0.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "{smi}: norm={norm}");
}
}
#[test]
fn ethanol_vs_benzene_less_similar_than_propanol() {
let eth = hdf_default_mol("CCO");
let ben = hdf_default_mol("c1ccccc1");
let pro = hdf_default_mol("CCCO");
let cos_eb = cosine_hdf(ð, &ben);
let cos_ep = cosine_hdf(ð, &pro);
assert!(
cos_ep > cos_eb,
"propanol ({cos_ep:.3}) should be more similar to ethanol than benzene ({cos_eb:.3})"
);
}
#[test]
fn different_dims_work() {
for dim in [64, 256, 512, 1024, 2048] {
let config = HdfConfig {
dim,
radius: 2,
seed: 42,
};
let fp = hdf(&mol("CCO"), &config);
assert_eq!(fp.0.len(), dim);
let norm: f32 = fp.0.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-4, "dim={dim} norm={norm}");
}
}
#[test]
fn different_radius_gives_different_vectors() {
let mol = mol("c1ccccc1");
let r0 = hdf(
&mol,
&HdfConfig {
dim: 256,
radius: 0,
seed: 1,
},
);
let r2 = hdf(
&mol,
&HdfConfig {
dim: 256,
radius: 2,
seed: 1,
},
);
let cos = cosine_hdf(&r0, &r2);
assert!(
cos < 0.999,
"radius-0 and radius-2 should differ; cosine={cos}"
);
}
#[test]
fn different_seeds_give_different_vectors() {
let m = mol("CCO");
let fp1 = hdf(
&m,
&HdfConfig {
dim: 256,
radius: 2,
seed: 1,
},
);
let fp2 = hdf(
&m,
&HdfConfig {
dim: 256,
radius: 2,
seed: 2,
},
);
let cos = cosine_hdf(&fp1, &fp2);
assert!(
cos < 0.999,
"different seeds should give different vectors; cosine={cos}"
);
}
#[test]
fn charged_atom_differs_from_neutral() {
let charged = hdf_default_mol("[NH4+]");
let neutral = hdf_default_mol("N");
let cos = cosine_hdf(&charged, &neutral);
assert!(cos < 0.99, "charged vs neutral too similar: cosine={cos}");
}
#[test]
fn empty_molecule_returns_zero_vector() {
let empty = chematic_core::MoleculeBuilder::new().build();
let fp = hdf_default(&empty);
assert!(fp.0.iter().all(|&x| x == 0.0));
}
}