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