Skip to main content

chematic_fp/
search.rs

1//! Similarity-based nearest-neighbour search over a molecule database.
2//!
3//! [`nearest_neighbors`] computes fingerprints for every molecule in `db`,
4//! measures Tanimoto similarity against a query fingerprint, and returns the
5//! top-k results sorted by descending similarity.
6
7use chematic_core::Molecule;
8
9use crate::bitvec::BitVec2048;
10use crate::ecfp::{EcfpConfig, ecfp};
11
12// ---------------------------------------------------------------------------
13// Fingerprint type selector
14// ---------------------------------------------------------------------------
15
16/// Which fingerprint type to use for the similarity search.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum FpType {
19    /// ECFP4 (Morgan radius=2, 2048 bits). Default.
20    #[default]
21    Ecfp4,
22    /// ECFP6 (Morgan radius=3, 2048 bits).
23    Ecfp6,
24    /// ECFP4 with chirality encoding enabled.
25    Ecfp4Chiral,
26    /// FCFP4 (feature-based circular FP).
27    Fcfp4,
28    /// MACCS 166-bit structural keys.
29    Maccs,
30    /// Topological path FP (max_len=7, 2048 bits).
31    TopoPath,
32}
33
34fn compute_fp(mol: &Molecule, fp_type: FpType) -> BitVec2048 {
35    match fp_type {
36        FpType::Ecfp4 => ecfp(mol, &EcfpConfig::default()),
37        FpType::Ecfp6 => ecfp(
38            mol,
39            &EcfpConfig {
40                radius: 3,
41                nbits: 2048,
42                use_chirality: false,
43                use_double_fold: false,
44            },
45        ),
46        FpType::Ecfp4Chiral => ecfp(
47            mol,
48            &EcfpConfig {
49                radius: 2,
50                nbits: 2048,
51                use_chirality: true,
52                use_double_fold: false,
53            },
54        ),
55        FpType::Fcfp4 => crate::fcfp::fcfp4(mol),
56        FpType::Maccs => crate::maccs::maccs(mol),
57        FpType::TopoPath => {
58            crate::topo_path::topo_path(mol, &crate::topo_path::TopoPathConfig::default())
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Public API
65// ---------------------------------------------------------------------------
66
67/// Return the `k` most similar molecules in `db` to `query`, sorted by
68/// descending Tanimoto similarity.
69///
70/// Returns `Vec<(db_index, tanimoto)>` where `db_index` is the 0-based
71/// position in `db`.  If `db` has fewer than `k` molecules, all are returned.
72/// Molecules with Tanimoto = 0.0 are excluded unless every molecule scores 0.
73pub fn nearest_neighbors(
74    query: &Molecule,
75    db: &[Molecule],
76    k: usize,
77    fp_type: FpType,
78) -> Vec<(usize, f64)> {
79    if k == 0 || db.is_empty() {
80        return vec![];
81    }
82
83    let query_fp = compute_fp(query, fp_type);
84
85    let mut scores: Vec<(usize, f64)> = db
86        .iter()
87        .enumerate()
88        .map(|(i, mol)| {
89            let fp = compute_fp(mol, fp_type);
90            (i, query_fp.tanimoto(&fp))
91        })
92        .filter(|(_, t)| *t > 0.0)
93        .collect();
94
95    scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
96    scores.truncate(k);
97    scores
98}
99
100/// Like [`nearest_neighbors`] but accepts a pre-computed query fingerprint.
101///
102/// Use this when you need to search multiple query fingerprints against the
103/// same database to avoid recomputing database fingerprints each time.
104pub fn nearest_neighbors_from_fp(
105    query_fp: &BitVec2048,
106    db_fps: &[BitVec2048],
107    k: usize,
108) -> Vec<(usize, f64)> {
109    if k == 0 || db_fps.is_empty() {
110        return vec![];
111    }
112
113    let mut scores: Vec<(usize, f64)> = db_fps
114        .iter()
115        .enumerate()
116        .map(|(i, fp)| (i, query_fp.tanimoto(fp)))
117        .filter(|(_, t)| *t > 0.0)
118        .collect();
119
120    scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
121    scores.truncate(k);
122    scores
123}
124
125// ---------------------------------------------------------------------------
126// Tests
127// ---------------------------------------------------------------------------
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use chematic_smiles::parse;
133
134    fn benzene() -> Molecule {
135        parse("c1ccccc1").unwrap()
136    }
137    fn toluene() -> Molecule {
138        parse("Cc1ccccc1").unwrap()
139    }
140    fn naphthalene() -> Molecule {
141        parse("c1ccc2ccccc2c1").unwrap()
142    }
143    fn ethane() -> Molecule {
144        parse("CC").unwrap()
145    }
146
147    #[test]
148    fn test_nn_self_is_first() {
149        let query = benzene();
150        let db = vec![ethane(), toluene(), benzene(), naphthalene()];
151        let results = nearest_neighbors(&query, &db, 3, FpType::Ecfp4);
152        assert!(!results.is_empty());
153        // benzene (index 2) should be the closest match to itself
154        assert_eq!(results[0].0, 2, "benzene should match itself first");
155        assert!(
156            (results[0].1 - 1.0).abs() < 1e-9,
157            "self-similarity should be 1.0"
158        );
159    }
160
161    #[test]
162    fn test_nn_returns_k_results() {
163        let query = benzene();
164        let db = vec![ethane(), toluene(), benzene(), naphthalene()];
165        let results = nearest_neighbors(&query, &db, 2, FpType::Ecfp4);
166        assert!(results.len() <= 2, "should return at most k results");
167    }
168
169    #[test]
170    fn test_nn_sorted_descending() {
171        let query = benzene();
172        let db = vec![ethane(), toluene(), benzene(), naphthalene()];
173        let results = nearest_neighbors(&query, &db, 4, FpType::Ecfp4);
174        for w in results.windows(2) {
175            assert!(
176                w[0].1 >= w[1].1,
177                "results should be sorted by descending Tanimoto"
178            );
179        }
180    }
181
182    #[test]
183    fn test_nn_empty_db() {
184        let query = benzene();
185        let results = nearest_neighbors(&query, &[], 5, FpType::Ecfp4);
186        assert!(results.is_empty());
187    }
188
189    #[test]
190    fn test_nn_k_zero() {
191        let query = benzene();
192        let db = vec![benzene()];
193        let results = nearest_neighbors(&query, &db, 0, FpType::Ecfp4);
194        assert!(results.is_empty());
195    }
196
197    #[test]
198    fn test_nn_from_fp() {
199        let query = benzene();
200        let db = [ethane(), toluene(), benzene()];
201        let query_fp = crate::ecfp::ecfp4(&query);
202        let db_fps: Vec<_> = db.iter().map(crate::ecfp::ecfp4).collect();
203        let results = nearest_neighbors_from_fp(&query_fp, &db_fps, 3);
204        assert!(!results.is_empty());
205        assert_eq!(results[0].0, 2, "benzene fp should match itself");
206    }
207
208    #[test]
209    fn test_nn_maccs_type() {
210        // With MACCS keys, benzene and toluene may score identically against
211        // a benzene query (methyl adds no unique key). Just verify the top result
212        // has a high score and ethane (index 2) scores lower than the aromatic mols.
213        let query = benzene();
214        let db = vec![toluene(), benzene(), ethane()];
215        let results = nearest_neighbors(&query, &db, 3, FpType::Maccs);
216        assert!(!results.is_empty());
217        // Ethane (index 2) should not be the top hit
218        assert_ne!(
219            results[0].0, 2,
220            "ethane should not be the top MACCS hit for benzene"
221        );
222        // Top hit should have Tanimoto > 0.5
223        assert!(
224            results[0].1 > 0.5,
225            "top MACCS hit should have Tanimoto > 0.5"
226        );
227    }
228}