1use std::collections::HashMap;
2
3pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
4 let n = a.len().min(b.len());
5 let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
6 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
7 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
8 if na == 0.0 || nb == 0.0 {
9 0.0
10 } else {
11 dot / (na * nb)
12 }
13}
14
15#[derive(Debug, Clone)]
16pub struct Hit {
17 pub tool_id: String,
18 pub score: f32,
19}
20
21pub fn top_k(query: &[f32], catalog: &[(String, Vec<f32>)], k: usize) -> Vec<Hit> {
22 let mut hits: Vec<Hit> = catalog
23 .iter()
24 .map(|(id, v)| Hit {
25 tool_id: id.clone(),
26 score: cosine(query, v),
27 })
28 .collect();
29 hits.sort_by(|a, b| {
30 b.score
31 .partial_cmp(&a.score)
32 .unwrap_or(std::cmp::Ordering::Equal)
33 });
34 hits.truncate(k);
35 hits
36}
37
38pub fn hybrid_top_k(
46 query_emb: &[f32],
47 catalog: &[(String, Vec<f32>)],
48 fts_hits: &HashMap<String, f32>,
49 k: usize,
50 cos_w: f32,
51 fts_w: f32,
52) -> Vec<Hit> {
53 if catalog.is_empty() {
54 return Vec::new();
55 }
56
57 let cosines: Vec<f32> = catalog.iter().map(|(_, v)| cosine(query_emb, v)).collect();
58 let (cmin, cmax) = min_max(&cosines);
59 let cspan = (cmax - cmin).max(1e-6);
60
61 let fts_sims: Vec<f32> = catalog
63 .iter()
64 .map(|(id, _)| fts_hits.get(id).map(|b| -b).unwrap_or(0.0))
65 .collect();
66 let present: Vec<f32> = catalog
67 .iter()
68 .filter_map(|(id, _)| fts_hits.get(id).map(|b| -b))
69 .collect();
70 let (fmin, fmax) = if present.is_empty() {
71 (0.0, 1.0)
72 } else {
73 min_max(&present)
74 };
75 let fspan = (fmax - fmin).max(1e-6);
76
77 let mut hits: Vec<Hit> = catalog
78 .iter()
79 .enumerate()
80 .map(|(i, (id, _))| {
81 let cos_n = (cosines[i] - cmin) / cspan;
82 let fts_n = if fts_hits.contains_key(id) {
83 (fts_sims[i] - fmin) / fspan
84 } else {
85 0.0
86 };
87 Hit {
88 tool_id: id.clone(),
89 score: cos_w * cos_n + fts_w * fts_n,
90 }
91 })
92 .collect();
93
94 hits.sort_by(|a, b| {
95 b.score
96 .partial_cmp(&a.score)
97 .unwrap_or(std::cmp::Ordering::Equal)
98 });
99 hits.truncate(k);
100 hits
101}
102
103pub fn hybrid_from_score_maps(
109 vec_sims: &HashMap<String, f32>,
110 fts_hits: &HashMap<String, f32>,
111 k: usize,
112 cos_w: f32,
113 fts_w: f32,
114) -> Vec<Hit> {
115 let mut all_ids: std::collections::HashSet<String> = vec_sims.keys().cloned().collect();
116 all_ids.extend(fts_hits.keys().cloned());
117
118 let vec_vals: Vec<f32> = vec_sims.values().copied().collect();
119 let (vmin, vmax) = if vec_vals.is_empty() {
120 (0.0, 1.0)
121 } else {
122 min_max(&vec_vals)
123 };
124 let vspan = (vmax - vmin).max(1e-6);
125
126 let fts_sims: Vec<f32> = fts_hits.values().map(|b| -b).collect();
127 let (fmin, fmax) = if fts_sims.is_empty() {
128 (0.0, 1.0)
129 } else {
130 min_max(&fts_sims)
131 };
132 let fspan = (fmax - fmin).max(1e-6);
133
134 let mut hits: Vec<Hit> = all_ids
135 .into_iter()
136 .map(|id| {
137 let vec_n = vec_sims.get(&id).map(|v| (v - vmin) / vspan).unwrap_or(0.0);
138 let fts_n = fts_hits
139 .get(&id)
140 .map(|b| (-b - fmin) / fspan)
141 .unwrap_or(0.0);
142 Hit {
143 tool_id: id,
144 score: cos_w * vec_n + fts_w * fts_n,
145 }
146 })
147 .collect();
148
149 hits.sort_by(|a, b| {
150 b.score
151 .partial_cmp(&a.score)
152 .unwrap_or(std::cmp::Ordering::Equal)
153 });
154 hits.truncate(k);
155 hits
156}
157
158fn min_max(xs: &[f32]) -> (f32, f32) {
159 let mut lo = f32::INFINITY;
160 let mut hi = f32::NEG_INFINITY;
161 for x in xs {
162 if *x < lo {
163 lo = *x;
164 }
165 if *x > hi {
166 hi = *x;
167 }
168 }
169 (lo, hi)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn cosine_orthogonal_is_zero() {
178 let a = [1.0, 0.0, 0.0];
179 let b = [0.0, 1.0, 0.0];
180 assert!(cosine(&a, &b).abs() < 1e-6);
181 }
182
183 #[test]
184 fn cosine_identical_is_one() {
185 let a = [1.0, 2.0, 3.0];
186 assert!((cosine(&a, &a) - 1.0).abs() < 1e-6);
187 }
188
189 #[test]
190 fn cosine_opposite_is_negative_one() {
191 let a = [1.0, 0.0];
192 let b = [-1.0, 0.0];
193 assert!((cosine(&a, &b) + 1.0).abs() < 1e-6);
194 }
195
196 #[test]
197 fn top_k_orders_by_descending_score_and_truncates() {
198 let q = vec![1.0, 0.0];
199 let catalog = vec![
200 ("c".into(), vec![0.5, 0.5]),
201 ("a".into(), vec![1.0, 0.0]),
202 ("b".into(), vec![0.0, 1.0]),
203 ("d".into(), vec![0.9, 0.05]),
204 ];
205 let hits = top_k(&q, &catalog, 2);
206 assert_eq!(hits.len(), 2);
207 assert_eq!(hits[0].tool_id, "a");
208 assert_eq!(hits[1].tool_id, "d");
209 }
210
211 #[test]
212 fn hybrid_promotes_tool_present_in_both_signals() {
213 let q = vec![1.0, 0.0];
215 let catalog = vec![
216 ("a".into(), vec![1.0, 0.0]),
217 ("b".into(), vec![0.95, 0.31]),
218 ("c".into(), vec![0.0, 1.0]),
219 ];
220 let mut fts = HashMap::new();
223 fts.insert("b".into(), -10.0); fts.insert("c".into(), -2.0); let hits = hybrid_top_k(&q, &catalog, &fts, 3, 0.6, 0.4);
226 assert_eq!(hits.len(), 3);
227 assert_eq!(hits[0].tool_id, "b");
229 }
230
231 #[test]
232 fn hybrid_from_score_maps_combines_both_signals() {
233 let mut vec_sims = HashMap::new();
234 vec_sims.insert("a".into(), 1.00);
235 vec_sims.insert("b".into(), 0.95);
236 vec_sims.insert("c".into(), 0.10);
237 let mut fts_hits = HashMap::new();
238 fts_hits.insert("b".into(), -10.0); fts_hits.insert("c".into(), -2.0);
240 let hits = hybrid_from_score_maps(&vec_sims, &fts_hits, 3, 0.6, 0.4);
241 assert_eq!(hits.len(), 3);
242 assert_eq!(hits[0].tool_id, "b"); }
244
245 #[test]
246 fn hybrid_falls_back_to_cosine_when_fts_empty() {
247 let q = vec![1.0, 0.0];
248 let catalog = vec![("a".into(), vec![1.0, 0.0]), ("b".into(), vec![0.0, 1.0])];
249 let fts = HashMap::new();
250 let hits = hybrid_top_k(&q, &catalog, &fts, 2, 0.6, 0.4);
251 assert_eq!(hits[0].tool_id, "a");
252 }
253}