1use chematic_core::{AtomIdx, BondIdx, Molecule};
8
9use crate::bitvec::BitVec2048;
10use crate::ecfp::{bond_type_int as bond_byte, fnv1a};
11
12#[derive(Debug, Clone)]
14pub struct TopoPathConfig {
15 pub max_len: usize,
17 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
30pub 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 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 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
133pub 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}