1use std::path::Path;
4
5use super::vector_index::{SearchHit, VectorIndex};
6use crate::error::{KernelError, Result};
7
8pub struct TurbovecIndex {
14 inner: turbovec::IdMapIndex,
15 dim: usize,
16 bit_width: u8,
17}
18
19impl std::fmt::Debug for TurbovecIndex {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 f.debug_struct("TurbovecIndex")
22 .field("dim", &self.dim)
23 .field("bit_width", &self.bit_width)
24 .field("len", &self.inner.len())
25 .finish()
26 }
27}
28
29impl TurbovecIndex {
30 pub fn new(dim: usize, bit_width: u8) -> Result<Self> {
36 if bit_width != 2 && bit_width != 4 {
37 return Err(KernelError::Embedding(format!(
38 "bit_width must be 2 or 4, got {bit_width}"
39 )));
40 }
41 let inner = turbovec::IdMapIndex::new(dim, bit_width as usize)
42 .map_err(|e| KernelError::Embedding(format!("failed to create index: {e}")))?;
43 Ok(Self {
44 inner,
45 dim,
46 bit_width,
47 })
48 }
49
50 pub fn bit_width(&self) -> u8 {
52 self.bit_width
53 }
54
55 pub fn load(path: &Path) -> Result<Self> {
61 let inner = turbovec::IdMapIndex::load(path)
62 .map_err(|e| KernelError::Embedding(format!("failed to load vector index: {e}")))?;
63 let meta_path = path.with_extension("meta.json");
64 let meta: IndexMeta = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)
65 .map_err(KernelError::embedding)?;
66 if meta.bit_width != 2 && meta.bit_width != 4 {
67 return Err(KernelError::Embedding(format!(
68 "corrupted index meta: bit_width must be 2 or 4, got {}",
69 meta.bit_width
70 )));
71 }
72 if meta.dim == 0 {
73 return Err(KernelError::Embedding(
74 "corrupted index meta: dim must be positive, got 0".into(),
75 ));
76 }
77
78 let inner_dim = inner.dim();
80 if inner_dim != 0 && inner_dim != meta.dim {
81 return Err(KernelError::Embedding(format!(
82 "index-meta mismatch: index dim={inner_dim}, meta dim={}",
83 meta.dim
84 )));
85 }
86 let inner_bw = inner.bit_width();
87 if inner_bw != meta.bit_width as usize {
88 return Err(KernelError::Embedding(format!(
89 "index-meta mismatch: index bit_width={inner_bw}, meta bit_width={}",
90 meta.bit_width
91 )));
92 }
93
94 Ok(Self {
95 inner,
96 dim: meta.dim,
97 bit_width: meta.bit_width,
98 })
99 }
100
101 fn validate_dim(&self, v: &[f32]) -> Result<()> {
102 if v.len() != self.dim {
103 return Err(KernelError::Embedding(format!(
104 "vector dimension mismatch: expected {}, got {}",
105 self.dim,
106 v.len()
107 )));
108 }
109 Ok(())
110 }
111
112 fn validate_dims(&self, vectors: &[Vec<f32>]) -> Result<()> {
113 for v in vectors {
114 self.validate_dim(v)?;
115 }
116 Ok(())
117 }
118}
119
120impl VectorIndex for TurbovecIndex {
121 fn add(&mut self, vectors: &[Vec<f32>]) -> Result<()> {
122 if vectors.is_empty() {
123 return Ok(());
124 }
125 self.validate_dims(vectors)?;
126 let start_id = self.inner.len() as u64;
127 let ids: Vec<u64> = (start_id..start_id + vectors.len() as u64).collect();
128 let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
130 self.inner
131 .add_with_ids_2d(&flat, self.dim, &ids)
132 .map_err(|e| KernelError::Embedding(format!("add failed: {e}")))?;
133 Ok(())
134 }
135
136 fn add_with_ids(&mut self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
137 if vectors.len() != ids.len() {
138 return Err(KernelError::Embedding(format!(
139 "vectors ({} entries) and ids ({} entries) must have the same length",
140 vectors.len(),
141 ids.len()
142 )));
143 }
144 self.validate_dims(vectors)?;
145 let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
146 self.inner
147 .add_with_ids_2d(&flat, self.dim, ids)
148 .map_err(|e| KernelError::Embedding(format!("add failed: {e}")))?;
149 Ok(())
150 }
151
152 fn remove(&mut self, ids: &[u64]) -> Result<()> {
153 for &id in ids {
154 self.inner.remove(id);
155 }
156 Ok(())
157 }
158
159 fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
160 self.validate_dim(query)?;
161 if self.inner.is_empty() {
162 return Ok(vec![]);
163 }
164 let (scores, ids) = self.inner.search(query, k);
165 Ok(scores
166 .into_iter()
167 .zip(ids)
168 .map(|(score, id)| SearchHit { id, score })
169 .collect())
170 }
171
172 fn search_filtered(
173 &self,
174 query: &[f32],
175 k: usize,
176 allowlist: &[u64],
177 ) -> Result<Vec<SearchHit>> {
178 self.validate_dim(query)?;
179 if self.inner.is_empty() || allowlist.is_empty() {
180 return Ok(vec![]);
181 }
182 let (scores, ids) = self.inner.search_with_allowlist(query, k, Some(allowlist));
183 Ok(scores
184 .into_iter()
185 .zip(ids)
186 .map(|(score, id)| SearchHit { id, score })
187 .collect())
188 }
189
190 fn len(&self) -> usize {
191 self.inner.len()
192 }
193
194 fn is_empty(&self) -> bool {
195 self.inner.is_empty()
196 }
197
198 fn dim(&self) -> usize {
199 self.dim
200 }
201
202 fn save(&self, path: &Path) -> Result<()> {
203 let tmp_index = path.with_extension("tvim.tmp");
205 let tmp_meta = path.with_extension("meta.tmp");
206
207 self.inner
208 .write(&tmp_index)
209 .map_err(|e| KernelError::Embedding(format!("failed to write vector index: {e}")))?;
210
211 let meta = IndexMeta {
212 dim: self.dim,
213 bit_width: self.bit_width,
214 };
215 let json = serde_json::to_string_pretty(&meta).map_err(KernelError::embedding)?;
216 std::fs::write(&tmp_meta, &json)?;
217
218 if let Ok(f) = std::fs::File::open(&tmp_index) {
220 let _ = f.sync_all();
221 }
222 if let Ok(f) = std::fs::File::open(&tmp_meta) {
223 let _ = f.sync_all();
224 }
225
226 std::fs::rename(&tmp_meta, path.with_extension("meta.json"))?;
228 std::fs::rename(&tmp_index, path)?;
229
230 Ok(())
231 }
232}
233
234#[derive(serde::Serialize, serde::Deserialize)]
235struct IndexMeta {
236 dim: usize,
237 bit_width: u8,
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use tempfile::TempDir;
244
245 fn make_index(dim: usize, bit_width: u8) -> TurbovecIndex {
246 TurbovecIndex::new(dim, bit_width).unwrap()
247 }
248
249 fn random_vector(dim: usize, seed: f32) -> Vec<f32> {
250 (0..dim).map(|i| (seed + i as f32 * 0.001).sin()).collect()
251 }
252
253 #[test]
254 fn new_valid_bit_widths() {
255 assert!(TurbovecIndex::new(128, 2).is_ok());
256 assert!(TurbovecIndex::new(128, 4).is_ok());
257 }
258
259 #[test]
260 fn new_invalid_bit_width() {
261 assert!(TurbovecIndex::new(128, 3).is_err());
262 assert!(TurbovecIndex::new(128, 8).is_err());
263 assert!(TurbovecIndex::new(128, 1).is_err());
264 }
265
266 #[test]
267 fn add_and_len() {
268 let mut idx = make_index(64, 4);
269 assert!(idx.is_empty());
270 idx.add(&[random_vector(64, 1.0), random_vector(64, 2.0)])
271 .unwrap();
272 assert_eq!(idx.len(), 2);
273 }
274
275 #[test]
276 fn add_empty() {
277 let mut idx = make_index(64, 4);
278 idx.add(&[]).unwrap();
279 assert!(idx.is_empty());
280 }
281
282 #[test]
283 fn add_with_explicit_ids() {
284 let mut idx = make_index(64, 4);
285 idx.add_with_ids(&[random_vector(64, 1.0)], &[42u64])
286 .unwrap();
287 assert_eq!(idx.len(), 1);
288 }
289
290 #[test]
291 fn add_dimension_mismatch() {
292 let mut idx = make_index(64, 4);
293 let result = idx.add(&[vec![0.0; 32]]);
294 assert!(result.is_err());
295 assert!(
296 result
297 .unwrap_err()
298 .to_string()
299 .contains("dimension mismatch")
300 );
301 }
302
303 #[test]
304 fn add_with_ids_length_mismatch() {
305 let mut idx = make_index(64, 4);
306 let result = idx.add_with_ids(&[random_vector(64, 1.0), random_vector(64, 2.0)], &[1u64]);
307 assert!(result.is_err());
308 assert!(result.unwrap_err().to_string().contains("same length"));
309 }
310
311 #[test]
312 fn search_empty_index() {
313 let idx = make_index(64, 4);
314 let hits = idx.search(&random_vector(64, 1.0), 5).unwrap();
315 assert!(hits.is_empty());
316 }
317
318 #[test]
319 fn search_returns_nearest() {
320 let mut idx = make_index(64, 4);
321 let target = random_vector(64, 3.0);
322 idx.add_with_ids(
323 &[
324 random_vector(64, 100.0),
325 target.clone(),
326 random_vector(64, 200.0),
327 ],
328 &[0u64, 1u64, 2u64],
329 )
330 .unwrap();
331 let hits = idx.search(&target, 1).unwrap();
332 assert_eq!(hits.len(), 1);
333 assert_eq!(hits[0].id, 1);
334 }
335
336 #[test]
337 fn search_dimension_mismatch() {
338 let mut idx = make_index(64, 4);
339 idx.add(&[random_vector(64, 1.0)]).unwrap();
340 let result = idx.search(&[0.0; 32], 1);
341 assert!(result.is_err());
342 }
343
344 #[test]
345 fn search_filtered_with_allowlist() {
346 let mut idx = make_index(64, 4);
347 idx.add_with_ids(
348 &[
349 random_vector(64, 1.0),
350 random_vector(64, 2.0),
351 random_vector(64, 3.0),
352 ],
353 &[10u64, 20u64, 30u64],
354 )
355 .unwrap();
356 let hits = idx
357 .search_filtered(&random_vector(64, 1.0), 10, &[20u64, 30u64])
358 .unwrap();
359 let ids: Vec<u64> = hits.iter().map(|h| h.id).collect();
360 assert!(ids.contains(&20));
361 assert!(ids.contains(&30));
362 assert!(!ids.contains(&10));
363 }
364
365 #[test]
366 fn search_filtered_empty_allowlist() {
367 let mut idx = make_index(64, 4);
368 idx.add(&[random_vector(64, 1.0)]).unwrap();
369 let hits = idx
370 .search_filtered(&random_vector(64, 1.0), 5, &[])
371 .unwrap();
372 assert!(hits.is_empty());
373 }
374
375 #[test]
376 fn save_load_roundtrip() {
377 let dir = TempDir::new().unwrap();
378 let path = dir.path().join("test.tvim");
379 let mut idx = make_index(64, 4);
380 idx.add_with_ids(
381 &[random_vector(64, 1.0), random_vector(64, 2.0)],
382 &[100u64, 200u64],
383 )
384 .unwrap();
385 idx.save(&path).unwrap();
386 let loaded = TurbovecIndex::load(&path).unwrap();
387 assert_eq!(loaded.dim(), 64);
388 assert_eq!(loaded.bit_width(), 4);
389 assert_eq!(loaded.len(), 2);
390 }
391
392 #[test]
393 fn load_rejects_corrupted_meta() {
394 let dir = TempDir::new().unwrap();
395 let path = dir.path().join("corrupt.tvim");
396 let mut idx = make_index(64, 4);
397 idx.add(&[random_vector(64, 1.0)]).unwrap();
398 idx.save(&path).unwrap();
399 let meta_path = path.with_extension("meta.json");
400 std::fs::write(&meta_path, r#"{"dim": 64, "bit_width": 7}"#).unwrap();
401 let result = TurbovecIndex::load(&path);
402 assert!(result.is_err());
403 assert!(result.unwrap_err().to_string().contains("bit_width"));
404 }
405
406 #[test]
407 fn load_rejects_zero_dim() {
408 let dir = TempDir::new().unwrap();
409 let path = dir.path().join("zero.tvim");
410 let mut idx = make_index(64, 4);
411 idx.add(&[random_vector(64, 1.0)]).unwrap();
412 idx.save(&path).unwrap();
413 let meta_path = path.with_extension("meta.json");
414 std::fs::write(&meta_path, r#"{"dim": 0, "bit_width": 4}"#).unwrap();
415 let result = TurbovecIndex::load(&path);
416 assert!(result.is_err());
417 assert!(result.unwrap_err().to_string().contains("dim"));
418 }
419
420 #[test]
421 fn dim_and_bit_width_accessors() {
422 let idx = make_index(128, 2);
423 assert_eq!(idx.dim(), 128);
424 assert_eq!(idx.bit_width(), 2);
425 }
426
427 #[test]
428 fn trait_object_compatibility() {
429 let mut idx: Box<dyn VectorIndex> = Box::new(make_index(64, 4));
430 idx.add(&[random_vector(64, 1.0)]).unwrap();
431 assert_eq!(idx.len(), 1);
432 assert!(!idx.is_empty());
433 }
434
435 #[test]
436 fn remove_existing_id() {
437 let mut idx = make_index(64, 4);
438 idx.add_with_ids(
439 &[
440 random_vector(64, 1.0),
441 random_vector(64, 2.0),
442 random_vector(64, 3.0),
443 ],
444 &[10u64, 20u64, 30u64],
445 )
446 .unwrap();
447 assert_eq!(idx.len(), 3);
448 idx.remove(&[20u64]).unwrap();
449 assert_eq!(idx.len(), 2);
450 let hits = idx.search(&random_vector(64, 2.0), 10).unwrap();
451 let ids: Vec<u64> = hits.iter().map(|h| h.id).collect();
452 assert!(!ids.contains(&20));
453 }
454
455 #[test]
456 fn remove_nonexistent_id() {
457 let mut idx = make_index(64, 4);
458 idx.add_with_ids(&[random_vector(64, 1.0)], &[1u64])
459 .unwrap();
460 idx.remove(&[999u64]).unwrap();
461 assert_eq!(idx.len(), 1);
462 }
463
464 #[test]
465 fn remove_empty_ids() {
466 let mut idx = make_index(64, 4);
467 idx.add(&[random_vector(64, 1.0)]).unwrap();
468 idx.remove(&[]).unwrap();
469 assert_eq!(idx.len(), 1);
470 }
471
472 #[test]
473 fn remove_via_trait_object() {
474 let mut idx: Box<dyn VectorIndex> = Box::new(make_index(64, 4));
475 idx.add_with_ids(&[random_vector(64, 1.0)], &[42u64])
476 .unwrap();
477 idx.remove(&[42u64]).unwrap();
478 assert!(idx.is_empty());
479 }
480
481 #[test]
482 fn load_detects_dim_mismatch() {
483 let dir = TempDir::new().unwrap();
484 let path = dir.path().join("mismatch.tvim");
485 let mut idx = make_index(64, 4);
486 idx.add(&[random_vector(64, 1.0)]).unwrap();
487 idx.save(&path).unwrap();
488 let meta_path = path.with_extension("meta.json");
489 std::fs::write(&meta_path, r#"{"dim": 128, "bit_width": 4}"#).unwrap();
490 let result = TurbovecIndex::load(&path);
491 assert!(result.is_err());
492 let msg = result.unwrap_err().to_string();
493 assert!(
494 msg.contains("mismatch") || msg.contains("dim"),
495 "expected mismatch error, got: {msg}"
496 );
497 }
498}