1use std::collections::HashMap;
32
33use turbovec::TurboQuantIndex;
34
35use crate::distance::Distance;
36use crate::index::{IndexError, NodeId, SearchResult};
37
38pub struct TurboTable {
41 bits: u8,
42 distance: Distance,
43 dim: u16,
44 index: TurboQuantIndex,
48 slots: Vec<Option<NodeId>>,
53 id_to_slot: HashMap<NodeId, usize>,
55}
56
57impl TurboTable {
58 pub fn new(distance: Distance, dim: u16, bits: u8) -> Result<Self, IndexError> {
69 if dim == 0 {
70 return Err(IndexError::Empty);
71 }
72 if !dim.is_multiple_of(8) {
73 return Err(IndexError::DimensionMismatch {
74 expected: ((dim / 8) + 1) * 8,
75 got: dim,
76 });
77 }
78 if !(2..=4).contains(&bits) {
79 return Err(IndexError::Empty);
80 }
81 let index = TurboQuantIndex::new(usize::from(dim), usize::from(bits))
82 .map_err(|_| IndexError::Empty)?;
83 Ok(Self {
84 bits,
85 distance,
86 dim,
87 index,
88 slots: Vec::new(),
89 id_to_slot: HashMap::new(),
90 })
91 }
92
93 #[must_use]
95 pub fn len(&self) -> usize {
96 self.slots.iter().filter(|s| s.is_some()).count()
97 }
98
99 #[must_use]
101 pub fn is_empty(&self) -> bool {
102 self.len() == 0
103 }
104
105 #[must_use]
107 pub fn dim(&self) -> u16 {
108 self.dim
109 }
110
111 #[must_use]
113 pub fn distance(&self) -> Distance {
114 self.distance
115 }
116
117 #[must_use]
119 pub fn bits(&self) -> u8 {
120 self.bits
121 }
122
123 pub fn insert(&mut self, id: NodeId, vector: Vec<f32>) -> Result<(), IndexError> {
139 if vector.is_empty() {
140 return Err(IndexError::Empty);
141 }
142 let got = u16::try_from(vector.len()).unwrap_or(u16::MAX);
143 if got != self.dim {
144 return Err(IndexError::DimensionMismatch {
145 expected: self.dim,
146 got,
147 });
148 }
149 if self.id_to_slot.contains_key(&id) {
150 return Err(IndexError::Duplicate(id));
151 }
152 let prepared = match self.distance {
153 Distance::Cosine | Distance::Euclidean => l2_normalise(&vector),
154 Distance::DotProduct => vector,
155 };
156 self.index
163 .add_2d(&prepared, usize::from(self.dim))
164 .map_err(|_| IndexError::Empty)?;
165 let slot = self.slots.len();
166 self.slots.push(Some(id));
167 self.id_to_slot.insert(id, slot);
168 Ok(())
169 }
170
171 pub fn delete(&mut self, id: NodeId) -> bool {
178 let Some(slot) = self.id_to_slot.remove(&id) else {
179 return false;
180 };
181 if slot < self.slots.len() {
182 self.slots[slot] = None;
183 }
184 true
185 }
186
187 #[must_use]
189 pub fn contains(&self, id: NodeId) -> bool {
190 self.id_to_slot.contains_key(&id)
191 }
192
193 pub fn search(
203 &self,
204 query: &[f32],
205 k: usize,
206 _ef: Option<usize>,
207 ) -> Result<Vec<SearchResult>, IndexError> {
208 if query.is_empty() || self.slots.is_empty() {
209 return Ok(Vec::new());
210 }
211 let got = u16::try_from(query.len()).unwrap_or(u16::MAX);
212 if got != self.dim {
213 return Err(IndexError::DimensionMismatch {
214 expected: self.dim,
215 got,
216 });
217 }
218 let prepared = match self.distance {
219 Distance::Cosine | Distance::Euclidean => l2_normalise(query),
220 Distance::DotProduct => query.to_vec(),
221 };
222 let mask: Vec<bool> = self.slots.iter().map(Option::is_some).collect();
223 let allowed = mask.iter().filter(|b| **b).count();
224 if allowed == 0 {
225 return Ok(Vec::new());
226 }
227 let res = self.index.search_with_mask(&prepared, k, Some(&mask));
228 let mut out = Vec::with_capacity(res.k);
229 for i in 0..res.k {
230 let raw_idx = res.indices[i];
231 if raw_idx < 0 {
232 continue;
235 }
236 let Ok(slot) = usize::try_from(raw_idx) else {
237 continue;
238 };
239 let Some(Some(node_id)) = self.slots.get(slot) else {
240 continue;
241 };
242 let similarity = res.scores[i];
243 let score = match self.distance {
244 Distance::DotProduct => -similarity,
245 Distance::Cosine => 1.0 - similarity,
246 Distance::Euclidean => (2.0 - 2.0 * similarity).max(0.0).sqrt(),
247 };
248 out.push(SearchResult {
249 id: *node_id,
250 score,
251 });
252 }
253 out.sort_by(|a, b| {
259 a.score
260 .partial_cmp(&b.score)
261 .unwrap_or(std::cmp::Ordering::Equal)
262 });
263 out.truncate(k);
264 Ok(out)
265 }
266}
267
268fn l2_normalise(v: &[f32]) -> Vec<f32> {
269 let n2: f32 = v.iter().map(|x| x * x).sum();
270 let n = n2.sqrt();
271 if n <= 0.0 {
272 return v.to_vec();
273 }
274 v.iter().map(|x| x / n).collect()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn rand_vec(seed: u64, dim: usize) -> Vec<f32> {
282 let mut x = seed;
283 let mut v = Vec::with_capacity(dim);
284 for _ in 0..dim {
285 x ^= x << 13;
286 x ^= x >> 7;
287 x ^= x << 17;
288 let bits = (x >> 11) & ((1_u64 << 53) - 1);
289 #[allow(
290 clippy::cast_precision_loss,
291 clippy::cast_possible_truncation,
292 reason = "test fixture: PRNG narrowed to f32"
293 )]
294 let r = (((bits as f64) / ((1_u64 << 53) as f64)) * 2.0 - 1.0) as f32;
295 v.push(r);
296 }
297 v
298 }
299
300 #[test]
301 fn insert_and_search_returns_self_first() {
302 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
303 let target = rand_vec(42, 64);
304 t.insert(0, target.clone()).unwrap();
305 for i in 1..50_u64 {
306 t.insert(i, rand_vec(i.wrapping_mul(1_000_003) + 1, 64))
307 .unwrap();
308 }
309 let res = t.search(&target, 3, None).unwrap();
310 assert!(!res.is_empty());
311 assert_eq!(res[0].id, 0);
312 }
313
314 #[test]
315 fn delete_excludes_from_search() {
316 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
317 for i in 0..30_u64 {
318 t.insert(i, rand_vec(i + 1, 64)).unwrap();
319 }
320 let q = rand_vec(1, 64);
321 let before = t.search(&q, 5, None).unwrap();
322 let target = before[0].id;
323 assert!(t.delete(target));
324 let after = t.search(&q, 5, None).unwrap();
325 assert!(after.iter().all(|r| r.id != target));
326 }
327
328 #[test]
329 fn empty_table_search_is_empty() {
330 let t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
331 assert!(t.search(&rand_vec(0, 64), 5, None).unwrap().is_empty());
332 }
333
334 #[test]
335 fn duplicate_id_rejected() {
336 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
337 t.insert(7, rand_vec(7, 64)).unwrap();
338 assert!(matches!(
339 t.insert(7, rand_vec(8, 64)),
340 Err(IndexError::Duplicate(7))
341 ));
342 }
343
344 #[test]
345 fn dimension_mismatch_rejected() {
346 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
347 assert!(matches!(
348 t.insert(0, vec![0.1; 32]),
349 Err(IndexError::DimensionMismatch { .. })
350 ));
351 }
352
353 #[test]
354 fn new_rejects_bad_parameters() {
355 assert!(matches!(
357 TurboTable::new(Distance::Cosine, 0, 4),
358 Err(IndexError::Empty)
359 ));
360 match TurboTable::new(Distance::Cosine, 60, 4) {
362 Err(IndexError::DimensionMismatch { expected, got }) => {
363 assert_eq!(got, 60);
364 assert_eq!(expected, 64);
365 }
366 Err(other) => panic!("expected DimensionMismatch, got {other:?}"),
367 Ok(_) => panic!("expected DimensionMismatch, got Ok"),
368 }
369 assert!(matches!(
371 TurboTable::new(Distance::Cosine, 64, 1),
372 Err(IndexError::Empty)
373 ));
374 assert!(matches!(
375 TurboTable::new(Distance::Cosine, 64, 5),
376 Err(IndexError::Empty)
377 ));
378 }
379
380 #[test]
381 fn accessors_report_construction_state() {
382 let mut t = TurboTable::new(Distance::Euclidean, 64, 3).unwrap();
383 assert_eq!(t.dim(), 64);
384 assert_eq!(t.distance(), Distance::Euclidean);
385 assert_eq!(t.bits(), 3);
386 assert!(t.is_empty());
387 assert_eq!(t.len(), 0);
388 t.insert(1, rand_vec(1, 64)).unwrap();
389 assert!(!t.is_empty());
390 assert_eq!(t.len(), 1);
391 assert!(t.contains(1));
392 assert!(!t.contains(2));
393 assert!(t.delete(1));
395 assert_eq!(t.len(), 0);
396 assert!(!t.contains(1));
397 assert!(!t.delete(99));
399 }
400
401 #[test]
402 fn insert_rejects_empty_vector() {
403 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
404 assert!(matches!(t.insert(0, Vec::new()), Err(IndexError::Empty)));
405 }
406
407 #[test]
408 fn dot_product_metric_round_trips() {
409 let mut t = TurboTable::new(Distance::DotProduct, 64, 4).unwrap();
412 let target = rand_vec(11, 64);
413 t.insert(0, target.clone()).unwrap();
414 for i in 1..20_u64 {
415 t.insert(i, rand_vec(i.wrapping_mul(7) + 3, 64)).unwrap();
416 }
417 let res = t.search(&target, 3, None).unwrap();
418 assert!(!res.is_empty());
419 for w in res.windows(2) {
421 assert!(w[0].score <= w[1].score);
422 }
423 }
424
425 #[test]
426 fn euclidean_metric_search_maps_score() {
427 let mut t = TurboTable::new(Distance::Euclidean, 64, 4).unwrap();
428 let target = rand_vec(5, 64);
429 t.insert(0, target.clone()).unwrap();
430 for i in 1..20_u64 {
431 t.insert(i, rand_vec(i + 100, 64)).unwrap();
432 }
433 let res = t.search(&target, 3, None).unwrap();
434 assert!(!res.is_empty());
435 assert!(res.iter().all(|r| r.score >= 0.0));
437 assert_eq!(res[0].id, 0);
438 }
439
440 #[test]
441 fn search_with_all_slots_deleted_is_empty() {
442 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
443 for i in 0..5_u64 {
444 t.insert(i, rand_vec(i + 1, 64)).unwrap();
445 }
446 for i in 0..5_u64 {
447 assert!(t.delete(i));
448 }
449 let res = t.search(&rand_vec(1, 64), 3, None).unwrap();
451 assert!(res.is_empty());
452 }
453
454 #[test]
455 fn search_dimension_mismatch_rejected() {
456 let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
457 t.insert(0, rand_vec(1, 64)).unwrap();
458 assert!(matches!(
459 t.search(&[0.1; 32], 3, None),
460 Err(IndexError::DimensionMismatch { .. })
461 ));
462 }
463
464 #[test]
465 fn l2_normalise_zero_vector_is_returned_unchanged() {
466 let zero = vec![0.0_f32; 8];
467 assert_eq!(l2_normalise(&zero), zero);
468 let v = vec![3.0_f32, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
470 let n = l2_normalise(&v);
471 let mag: f32 = n.iter().map(|x| x * x).sum::<f32>().sqrt();
472 assert!((mag - 1.0).abs() < 1e-5, "magnitude {mag}");
473 }
474}