Skip to main content

chematic_fp/
path.rs

1//! v0.1.92: RDKit-compatible path fingerprints with bond type inclusion.
2//!
3//! Enumerates all topological paths up to a configurable length,
4//! hashes each path (including bond types), and aggregates into a 2048-bit fingerprint.
5//! Bond types (single/double/triple/aromatic) are interleaved with atomic numbers in hash.
6
7use crate::bitvec::BitVec2048;
8use chematic_core::{AtomIdx, BondOrder, Molecule};
9
10const HASH_MOD: usize = 2048;
11
12/// RDKit path fingerprint configuration.
13#[derive(Clone, Debug)]
14pub struct RdkitPathConfig {
15    /// Maximum path length (default 7, matching RDKit).
16    pub max_path_len: usize,
17    /// Use atom types (default false — just atom index).
18    pub use_atom_types: bool,
19}
20
21impl Default for RdkitPathConfig {
22    fn default() -> Self {
23        Self {
24            max_path_len: 7,
25            use_atom_types: false,
26        }
27    }
28}
29
30/// Compute RDKit path fingerprint (Daylight-like, with bond types).
31///
32/// Enumerates all simple paths up to `max_path_len`, hashes each path
33/// (including bond order types), and sets corresponding bits in a 2048-bit fingerprint.
34pub fn rdkit_path_fp(mol: &Molecule) -> BitVec2048 {
35    rdkit_path_fp_with_config(mol, &RdkitPathConfig::default())
36}
37
38/// RDKit path fingerprint with custom config.
39pub fn rdkit_path_fp_with_config(mol: &Molecule, config: &RdkitPathConfig) -> BitVec2048 {
40    let mut fp = BitVec2048::new();
41
42    if mol.atom_count() == 0 {
43        return fp;
44    }
45
46    for start_idx in 0..mol.atom_count() {
47        let start = AtomIdx(start_idx as u32);
48        let initial_path = PathWithBonds {
49            atoms: vec![start],
50            bonds: vec![],
51        };
52        enumerate_paths_from(
53            mol,
54            start,
55            initial_path,
56            &mut |path| {
57                let hash = hash_path(mol, path);
58                let bit_idx = hash % HASH_MOD;
59                fp.set(bit_idx);
60            },
61            config.max_path_len,
62        );
63    }
64
65    fp
66}
67
68/// Path with atoms and their connecting bond orders.
69struct PathWithBonds {
70    atoms: Vec<AtomIdx>,
71    bonds: Vec<BondOrder>, // len = atoms.len() - 1
72}
73
74/// Recursive DFS path enumeration with bond tracking.
75fn enumerate_paths_from<F>(
76    mol: &Molecule,
77    current: AtomIdx,
78    path: PathWithBonds,
79    callback: &mut F,
80    max_len: usize,
81) where
82    F: FnMut(&PathWithBonds),
83{
84    if path.atoms.len() <= max_len {
85        // Callback for the current path
86        if path.atoms.len() > 1 {
87            callback(&path);
88        }
89
90        // Extend to neighbors
91        if path.atoms.len() < max_len {
92            for (neighbor, bond_idx) in mol.neighbors(current) {
93                // Avoid cycles — don't revisit atoms already in path
94                if !path.atoms.contains(&neighbor) {
95                    let bond_order = mol.bond(bond_idx).order;
96                    let mut new_path = path.clone();
97                    new_path.atoms.push(neighbor);
98                    new_path.bonds.push(bond_order);
99                    enumerate_paths_from(mol, neighbor, new_path, callback, max_len);
100                }
101            }
102        }
103    }
104}
105
106impl Clone for PathWithBonds {
107    fn clone(&self) -> Self {
108        PathWithBonds {
109            atoms: self.atoms.clone(),
110            bonds: self.bonds.clone(),
111        }
112    }
113}
114
115/// Hash a path using FNV-1a, interleaving atoms and bond types.
116fn hash_path(mol: &Molecule, path: &PathWithBonds) -> usize {
117    let mut bytes: Vec<u8> = Vec::with_capacity(path.atoms.len() * 2);
118    for (i, &atom_idx) in path.atoms.iter().enumerate() {
119        if i > 0 {
120            bytes.push(crate::ecfp::bond_type_int(path.bonds[i - 1]));
121        }
122        bytes.push(mol.atom(atom_idx).element.atomic_number());
123    }
124    crate::ecfp::fnv1a(&bytes) as usize
125}
126
127/// Tanimoto similarity between two RDKit path fingerprints.
128pub fn tanimoto_rdkit_path(a: &BitVec2048, b: &BitVec2048) -> f64 {
129    a.tanimoto(b)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use chematic_smiles::parse;
136
137    fn mol(smiles: &str) -> Molecule {
138        parse(smiles).unwrap_or_else(|e| panic!("failed to parse {smiles:?}: {e}"))
139    }
140
141    #[test]
142    fn test_rdkit_path_fp_ethane() {
143        let m = mol("CC");
144        let fp = rdkit_path_fp(&m);
145        assert!(fp.popcount() > 0, "ethane should have non-zero bits");
146    }
147
148    #[test]
149    fn test_rdkit_path_fp_propane() {
150        let m = mol("CCC");
151        let fp = rdkit_path_fp(&m);
152        assert!(fp.popcount() > 0, "propane should have non-zero bits");
153    }
154
155    #[test]
156    fn test_rdkit_path_fp_benzene() {
157        let m = mol("c1ccccc1");
158        let fp = rdkit_path_fp(&m);
159        assert!(fp.popcount() > 0, "benzene should have non-zero bits");
160    }
161
162    #[test]
163    fn test_rdkit_path_fp_identical() {
164        let m1 = mol("CC");
165        let m2 = mol("CC");
166        let fp1 = rdkit_path_fp(&m1);
167        let fp2 = rdkit_path_fp(&m2);
168        assert_eq!(
169            fp1.tanimoto(&fp2),
170            1.0,
171            "identical molecules should have tanimoto=1.0"
172        );
173    }
174
175    #[test]
176    fn test_rdkit_path_fp_single_atom() {
177        let m = mol("C");
178        let fp = rdkit_path_fp(&m);
179        // Single atom has no paths (path length >= 2)
180        assert_eq!(fp.popcount(), 0, "single atom should have zero bits");
181    }
182
183    #[test]
184    fn test_rdkit_path_fp_bond_type_distinction() {
185        // Single vs double bond: same atoms, different bond types → different FP
186        let m_single = mol("CC");
187        let m_double = mol("C=C");
188        let fp_single = rdkit_path_fp(&m_single);
189        let fp_double = rdkit_path_fp(&m_double);
190        // FPs should differ due to bond type
191        assert!(
192            tanimoto_rdkit_path(&fp_single, &fp_double) < 1.0,
193            "single and double bonds should produce different fingerprints"
194        );
195    }
196
197    #[test]
198    fn test_rdkit_path_fp_bond_type_consistency() {
199        // Identical molecules with same bond types should have identical FPs
200        let m1 = mol("C=C");
201        let m2 = mol("C=C");
202        let fp1 = rdkit_path_fp(&m1);
203        let fp2 = rdkit_path_fp(&m2);
204        assert_eq!(
205            tanimoto_rdkit_path(&fp1, &fp2),
206            1.0,
207            "identical bond types should produce identical fingerprints"
208        );
209    }
210
211    #[test]
212    fn test_rdkit_path_fp_triple_bond() {
213        let m = mol("C#C");
214        let fp = rdkit_path_fp(&m);
215        assert!(fp.popcount() > 0, "triple bond should have non-zero bits");
216    }
217
218    #[test]
219    fn test_rdkit_path_fp_aromatic_distinction() {
220        // Benzene (aromatic) vs cyclohexatriene-like (not aromatic)
221        let m_aromatic = mol("c1ccccc1");
222        let m_aliphatic = mol("C1CCCC=CC=C1"); // approximation, just for structure
223        let fp_aromatic = rdkit_path_fp(&m_aromatic);
224        let fp_aliphatic = rdkit_path_fp(&m_aliphatic);
225        // Should differ due to aromatic vs single/double bonds
226        assert!(
227            tanimoto_rdkit_path(&fp_aromatic, &fp_aliphatic) < 1.0,
228            "aromatic and aliphatic should produce different fingerprints"
229        );
230    }
231}