Skip to main content

chematic_fp/
topo_path.rs

1//! Topological path fingerprints.
2//!
3//! Enumerates all simple paths of length 1..=`max_len` bonds and hashes
4//! them with FNV-1a. Paths are canonicalised (forward vs. reverse) so that
5//! each unique path is hashed only once.
6
7use chematic_core::{AtomIdx, BondIdx, Molecule};
8
9use crate::bitvec::BitVec2048;
10use crate::ecfp::{bond_type_int as bond_byte, fnv1a};
11
12/// Configuration for topological path fingerprints.
13#[derive(Debug, Clone)]
14pub struct TopoPathConfig {
15    /// Maximum number of atoms in a path (default: 7).
16    pub max_len: usize,
17    /// Bitvector size in bits (default: 2048).
18    pub nbits: usize,
19}
20
21impl Default for TopoPathConfig {
22    fn default() -> Self {
23        Self {
24            max_len: 7,
25            nbits: 2048,
26        }
27    }
28}
29
30/// Compute a topological path fingerprint for `mol`.
31///
32/// All simple paths of 2..=`config.max_len` atoms are enumerated,
33/// encoded as interleaved `[atomic_num, bond_order, atomic_num, ...]` bytes,
34/// canonicalised (min of forward/reverse encoding), hashed with FNV-1a, and
35/// folded into a `BitVec2048` via `hash % nbits`.
36pub fn topo_path(mol: &Molecule, config: &TopoPathConfig) -> BitVec2048 {
37    let mut fp = BitVec2048::new();
38    if mol.atom_count() == 0 {
39        return fp;
40    }
41    let n = mol.atom_count();
42    let mut path_atoms: Vec<u8> = Vec::with_capacity(config.max_len);
43    let mut path_bonds: Vec<u8> = Vec::with_capacity(config.max_len.saturating_sub(1));
44    let mut visited: Vec<bool> = vec![false; n];
45
46    for start in 0..n {
47        let start_idx = AtomIdx(start as u32);
48        path_atoms.push(mol.atom(start_idx).element.atomic_number());
49        visited[start] = true;
50        dfs(
51            mol,
52            start_idx,
53            None,
54            &mut path_atoms,
55            &mut path_bonds,
56            &mut visited,
57            &mut fp,
58            config,
59        );
60        path_atoms.pop();
61        visited[start] = false;
62    }
63    fp
64}
65
66#[allow(clippy::too_many_arguments)]
67fn dfs(
68    mol: &Molecule,
69    atom: AtomIdx,
70    from_bond: Option<BondIdx>,
71    path_atoms: &mut Vec<u8>,
72    path_bonds: &mut Vec<u8>,
73    visited: &mut Vec<bool>,
74    fp: &mut BitVec2048,
75    config: &TopoPathConfig,
76) {
77    // Record path when it has at least 2 atoms (1 bond)
78    if path_atoms.len() >= 2 {
79        hash_path(path_atoms, path_bonds, fp, config.nbits);
80    }
81    if path_atoms.len() >= config.max_len {
82        return;
83    }
84    for (nbr, bid) in mol.neighbors(atom) {
85        if Some(bid) == from_bond {
86            continue;
87        }
88        if visited[nbr.0 as usize] {
89            continue;
90        }
91        let order = mol.bond(bid).order;
92        path_bonds.push(bond_byte(order));
93        path_atoms.push(mol.atom(nbr).element.atomic_number());
94        visited[nbr.0 as usize] = true;
95        dfs(
96            mol,
97            nbr,
98            Some(bid),
99            path_atoms,
100            path_bonds,
101            visited,
102            fp,
103            config,
104        );
105        visited[nbr.0 as usize] = false;
106        path_atoms.pop();
107        path_bonds.pop();
108    }
109}
110
111fn hash_path(atoms: &[u8], bonds: &[u8], fp: &mut BitVec2048, nbits: usize) {
112    // Interleaved encoding: [a0, b0, a1, b1, ..., a_n-1]
113    let len = atoms.len() * 2 - 1;
114    let mut fwd = Vec::with_capacity(len);
115    let mut rev = Vec::with_capacity(len);
116    for i in 0..atoms.len() {
117        fwd.push(atoms[i]);
118        if i + 1 < atoms.len() {
119            fwd.push(bonds[i]);
120        }
121    }
122    for i in (0..atoms.len()).rev() {
123        rev.push(atoms[i]);
124        if i > 0 {
125            rev.push(bonds[i - 1]);
126        }
127    }
128    let canonical = if fwd <= rev { fwd } else { rev };
129    let hash = fnv1a(&canonical);
130    fp.set((hash % nbits as u64) as usize);
131}
132
133/// Tanimoto similarity between two molecules using topological path fingerprints.
134pub fn tanimoto_topo_path(a: &Molecule, b: &Molecule) -> f64 {
135    let cfg = TopoPathConfig::default();
136    topo_path(a, &cfg).tanimoto(&topo_path(b, &cfg))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use chematic_smiles::parse;
143
144    fn cfg() -> TopoPathConfig {
145        TopoPathConfig::default()
146    }
147
148    #[test]
149    fn ethane_nonzero() {
150        let mol = parse("CC").unwrap();
151        assert!(topo_path(&mol, &cfg()).popcount() > 0);
152    }
153
154    #[test]
155    fn benzene_nonzero() {
156        let mol = parse("c1ccccc1").unwrap();
157        assert!(topo_path(&mol, &cfg()).popcount() > 0);
158    }
159
160    #[test]
161    fn aspirin_many_bits() {
162        let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
163        let fp = topo_path(&mol, &cfg());
164        assert!(
165            fp.popcount() > 10,
166            "aspirin topo_path got {}",
167            fp.popcount()
168        );
169    }
170
171    #[test]
172    fn deterministic() {
173        let mol = parse("c1ccccc1").unwrap();
174        assert_eq!(topo_path(&mol, &cfg()), topo_path(&mol, &cfg()));
175    }
176
177    #[test]
178    fn longer_max_len_more_bits() {
179        let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
180        let fp2 = topo_path(
181            &mol,
182            &TopoPathConfig {
183                max_len: 2,
184                nbits: 2048,
185            },
186        );
187        let fp7 = topo_path(&mol, &cfg());
188        assert!(
189            fp2.popcount() <= fp7.popcount(),
190            "max_len=2 ({}) should have <= bits than max_len=7 ({})",
191            fp2.popcount(),
192            fp7.popcount()
193        );
194    }
195
196    #[test]
197    fn ethane_vs_benzene_differ() {
198        let e = parse("CC").unwrap();
199        let b = parse("c1ccccc1").unwrap();
200        let t = topo_path(&e, &cfg()).tanimoto(&topo_path(&b, &cfg()));
201        assert!(t < 1.0, "ethane and benzene must differ (tanimoto={t})");
202    }
203}