1use crate::db;
7use crate::error::MemoryError;
8use hnsw_rs::prelude::*;
9use rusqlite::params;
10use serde::{Deserialize, Serialize};
11use stack_ids::ContentDigest;
12use std::collections::{HashMap, HashSet};
13use std::fs::{self, File};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
17use std::sync::{Arc, RwLock};
18
19const HNSW_DATA_MAGIC: u32 = 0x534d_4844; const HNSW_GRAPH_MAGIC: u32 = 0x534d_4847; const HNSW_SIDECAR_VERSION: u16 = 1;
22const HNSW_SIDECAR_HEADER_LEN: u16 = 24;
23const HNSW_MANIFEST_SCHEMA_VERSION: u32 = 1;
24
25#[derive(Debug, Clone)]
27pub struct HnswConfig {
28 pub m: usize,
29 pub ef_construction: usize,
30 pub ef_search: usize,
31 pub dimensions: usize,
32 pub max_elements: usize,
33 pub compaction_threshold: f32,
34 pub flush_interval_secs: Option<u64>,
35}
36
37impl Default for HnswConfig {
38 fn default() -> Self {
39 Self {
40 m: 16,
41 ef_construction: 200,
42 ef_search: 50,
43 dimensions: 768,
44 max_elements: 100_000,
45 compaction_threshold: 0.3,
46 flush_interval_secs: None,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct HnswHit {
54 pub key: String,
55 pub distance: f32,
56}
57
58impl HnswHit {
59 pub fn similarity(&self) -> f32 {
60 (1.0 - self.distance).max(0.0)
61 }
62
63 pub fn parse_key(&self) -> Result<(&str, &str), MemoryError> {
65 self.key
66 .split_once(':')
67 .ok_or_else(|| MemoryError::InvalidKey(self.key.clone()))
68 }
69}
70
71struct HnswIndexInner {
72 graph: Hnsw<'static, f32, DistCosine>,
73 keymap: RwLock<KeyMapState>,
74 next_id: AtomicUsize,
75 keymap_dirty: AtomicBool,
76 last_flush_epoch: AtomicU64,
77 config: HnswConfig,
78}
79
80#[derive(Debug, Default, Clone)]
81struct KeyMapState {
82 key_to_id: HashMap<String, usize>,
84 id_to_key: HashMap<usize, String>,
86 id_to_vector: HashMap<usize, Vec<f32>>,
88 deleted_ids: HashSet<usize>,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92struct SidecarHeader {
93 magic: u32,
94 version: u16,
95 header_len: u16,
96 dim: u32,
97 vector_count: u64,
98 flags: u32,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102struct HnswSidecarManifestV1 {
103 schema_version: u32,
104 generation_id: String,
105 basename: String,
106 graph_file_name: String,
107 data_file_name: String,
108 graph_digest: String,
109 data_digest: String,
110 dimensions: usize,
111 vector_count: u64,
112 hnsw_sidecar_format_version: u16,
113 source_sqlite_epoch: Option<u64>,
114 created_at: String,
115}
116
117fn current_epoch_secs() -> u64 {
118 std::time::SystemTime::now()
119 .duration_since(std::time::UNIX_EPOCH)
120 .unwrap_or_default()
121 .as_secs()
122}
123
124#[derive(Clone)]
125pub struct HnswIndex {
126 inner: Arc<HnswIndexInner>,
127}
128
129impl HnswIndex {
130 pub fn new(config: HnswConfig) -> Result<Self, MemoryError> {
131 let graph: Hnsw<'static, f32, DistCosine> = Hnsw::new(
132 config.m,
133 config.max_elements,
134 16,
135 config.ef_construction,
136 DistCosine {},
137 );
138
139 Ok(Self {
140 inner: Arc::new(HnswIndexInner {
141 graph,
142 keymap: RwLock::new(KeyMapState::default()),
143 next_id: AtomicUsize::new(0),
144 keymap_dirty: AtomicBool::new(false),
145 last_flush_epoch: AtomicU64::new(current_epoch_secs()),
146 config,
147 }),
148 })
149 }
150
151 pub fn load(dir: &Path, basename: &str, config: HnswConfig) -> Result<Self, MemoryError> {
157 let data_path = dir.join(format!("{}.hnsw.data", basename));
158 let graph_path = dir.join(format!("{}.hnsw.graph", basename));
159 if !data_path.exists() || !graph_path.exists() {
160 return Err(MemoryError::HnswError(format!(
161 "missing HNSW sidecar files under {}",
162 dir.display()
163 )));
164 }
165
166 let index = Self::new(config)?;
167 let manifest = validate_hnsw_manifest(dir, basename, &index.inner.config)?;
168 let graph_header = validate_graph_sidecar(&graph_path)?;
169 if let Some(manifest) = &manifest {
170 validate_manifest_against_header(manifest, &graph_header)?;
171 }
172 let max_id = load_vectors_from_sidecar(&index, &data_path, &graph_header)?;
173 index
174 .inner
175 .next_id
176 .store(max_id.saturating_add(1), Ordering::SeqCst);
177 Ok(index)
178 }
179
180 pub fn save(&self, dir: &Path, basename: &str) -> Result<(), MemoryError> {
181 std::fs::create_dir_all(dir).map_err(|e| {
182 MemoryError::HnswError(format!(
183 "failed to create HNSW dir {}: {}",
184 dir.display(),
185 e
186 ))
187 })?;
188 let tmp_basename = format!(".{}.tmp-{}", basename, std::process::id());
189
190 let vectors = self.sidecar_vectors()?;
191 let vector_count = u64::try_from(vectors.len()).map_err(|_| {
192 MemoryError::HnswError("HNSW sidecar vector count exceeds u64".to_string())
193 })?;
194
195 let graph_tmp = dir.join(format!("{}.hnsw.graph", tmp_basename));
196 let data_tmp = dir.join(format!("{}.hnsw.data", tmp_basename));
197 let manifest_tmp = dir.join(format!("{}.hnsw.manifest.json", tmp_basename));
198 let graph_final = dir.join(format!("{}.hnsw.graph", basename));
199 let data_final = dir.join(format!("{}.hnsw.data", basename));
200 let manifest_final = hnsw_manifest_path(dir, basename);
201 write_graph_sidecar(&graph_tmp, self.inner.config.dimensions, vector_count)?;
202 write_data_sidecar(
203 &data_tmp,
204 self.inner.config.dimensions,
205 vector_count,
206 &vectors,
207 )?;
208
209 let graph_digest = file_digest(&graph_tmp)?;
210 let data_digest = file_digest(&data_tmp)?;
211 atomically_replace_sidecar(&graph_tmp, &graph_final)?;
212 atomically_replace_sidecar(&data_tmp, &data_final)?;
213 write_hnsw_manifest(
214 &manifest_tmp,
215 HnswSidecarManifestV1 {
216 schema_version: HNSW_MANIFEST_SCHEMA_VERSION,
217 generation_id: uuid::Uuid::new_v4().to_string(),
218 basename: basename.to_string(),
219 graph_file_name: hnsw_graph_file_name(basename),
220 data_file_name: hnsw_data_file_name(basename),
221 graph_digest,
222 data_digest,
223 dimensions: self.inner.config.dimensions,
224 vector_count,
225 hnsw_sidecar_format_version: HNSW_SIDECAR_VERSION,
226 source_sqlite_epoch: Some(current_epoch_secs()),
227 created_at: chrono::Utc::now().to_rfc3339(),
228 },
229 )?;
230 atomically_replace_sidecar(&manifest_tmp, &manifest_final)?;
231 if let Ok(dir_file) = File::open(dir) {
232 let _ = dir_file.sync_all();
233 }
234 Ok(())
235 }
236
237 pub fn insert(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
238 let id = self.allocate_id()?;
239 self.insert_with_id(Some(key), id, vector)
240 }
241
242 pub fn delete(&self, key: &str) -> Result<(), MemoryError> {
243 let mut keymap = self.inner.keymap.write().unwrap_or_else(|e| e.into_inner());
244
245 if let Some(id) = keymap.key_to_id.remove(key) {
246 keymap.id_to_key.remove(&id);
247 keymap.deleted_ids.insert(id);
248 self.inner.keymap_dirty.store(true, Ordering::SeqCst);
249 }
250 Ok(())
251 }
252
253 pub fn update(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
254 self.delete(&key)?;
255 self.insert(key, vector)
256 }
257
258 pub fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<HnswHit>, MemoryError> {
259 validate_dimensions(query, self.inner.config.dimensions)?;
260
261 if self.is_empty() || top_k == 0 {
262 return Ok(Vec::new());
263 }
264
265 let keymap_snapshot = self
266 .inner
267 .keymap
268 .read()
269 .unwrap_or_else(|e| e.into_inner())
270 .clone();
271 let total_points = self.inner.graph.get_nb_point();
272 let fetch_count = top_k
273 .saturating_add(keymap_snapshot.deleted_ids.len())
274 .min(total_points);
275
276 let neighbors = self
277 .inner
278 .graph
279 .search(query, fetch_count, self.inner.config.ef_search);
280
281 let mut hits: Vec<HnswHit> = neighbors
282 .into_iter()
283 .filter(|neighbor| !keymap_snapshot.deleted_ids.contains(&neighbor.d_id))
284 .filter_map(|neighbor| {
285 keymap_snapshot
286 .id_to_key
287 .get(&neighbor.d_id)
288 .map(|key| HnswHit {
289 key: key.clone(),
290 distance: neighbor.distance,
291 })
292 })
293 .take(top_k)
294 .collect();
295
296 if hits.len() < top_k && keymap_snapshot.key_to_id.len() >= top_k {
297 tracing::warn!(
298 requested = top_k,
299 returned = hits.len(),
300 active_keys = keymap_snapshot.key_to_id.len(),
301 "HNSW filtered under-return detected; caller should fall back to exact vector search"
302 );
303 return Ok(Vec::new());
304 }
305
306 hits.sort_by(|a, b| {
307 a.distance.partial_cmp(&b.distance).unwrap_or_else(|| {
308 if a.distance.is_nan() {
310 std::cmp::Ordering::Greater
311 } else {
312 std::cmp::Ordering::Less
313 }
314 })
315 });
316 Ok(hits)
317 }
318
319 pub fn len(&self) -> usize {
320 let total = self.inner.graph.get_nb_point();
321 let deleted = self
322 .inner
323 .keymap
324 .read()
325 .unwrap_or_else(|e| e.into_inner())
326 .deleted_ids
327 .len();
328 total.saturating_sub(deleted)
329 }
330
331 pub fn is_empty(&self) -> bool {
332 self.len() == 0
333 }
334
335 pub fn deleted_ratio(&self) -> f32 {
336 let total = self.inner.graph.get_nb_point();
337 if total == 0 {
338 return 0.0;
339 }
340 let deleted = self
341 .inner
342 .keymap
343 .read()
344 .unwrap_or_else(|e| e.into_inner())
345 .deleted_ids
346 .len();
347 deleted as f32 / total as f32
348 }
349
350 pub fn needs_compaction(&self) -> bool {
351 self.deleted_ratio() > self.inner.config.compaction_threshold
352 }
353
354 pub fn config(&self) -> &HnswConfig {
355 &self.inner.config
356 }
357
358 pub fn is_keymap_dirty(&self) -> bool {
359 self.inner.keymap_dirty.load(Ordering::SeqCst)
360 }
361
362 pub fn should_flush(&self, interval_secs: u64) -> bool {
363 let last = self.inner.last_flush_epoch.load(Ordering::SeqCst);
364 current_epoch_secs().saturating_sub(last) >= interval_secs
365 }
366
367 pub fn update_last_flush_epoch(&self) {
368 self.inner
369 .last_flush_epoch
370 .store(current_epoch_secs(), Ordering::SeqCst);
371 }
372
373 pub fn flush_keymap(&self, conn: &rusqlite::Connection) -> Result<(), MemoryError> {
374 if !self.is_keymap_dirty() {
375 return Ok(());
376 }
377
378 let keymap = self.inner.keymap.read().unwrap_or_else(|e| e.into_inner());
379 let next_id = self.inner.next_id.load(Ordering::SeqCst);
380
381 db::with_transaction(conn, |tx| {
382 tx.execute("DELETE FROM hnsw_keymap", [])?;
383 let mut insert_stmt = tx.prepare(
384 "INSERT INTO hnsw_keymap (node_id, item_key, deleted) VALUES (?1, ?2, ?3)",
385 )?;
386
387 for (key, id) in keymap.key_to_id.iter() {
388 insert_stmt.execute(params![*id as i64, key, 0])?;
389 }
390 for id in keymap.deleted_ids.iter() {
391 insert_stmt.execute(params![*id as i64, format!("_deleted:{}", id), 1])?;
392 }
393 drop(insert_stmt);
394
395 tx.execute(
396 "INSERT INTO hnsw_metadata (key, value) VALUES ('next_id', ?1)
397 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
398 params![next_id.to_string()],
399 )?;
400 Ok(())
401 })?;
402
403 self.inner.keymap_dirty.store(false, Ordering::SeqCst);
404 Ok(())
405 }
406
407 pub fn load_keymap(&self, conn: &rusqlite::Connection) -> Result<(), MemoryError> {
408 let table_exists: bool = conn.query_row(
409 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='hnsw_keymap'",
410 [],
411 |row| row.get(0),
412 )?;
413 if !table_exists {
414 return Err(MemoryError::HnswError(
415 "hnsw_keymap table missing while HNSW sidecar exists".to_string(),
416 ));
417 }
418
419 let mut key_to_id = HashMap::new();
421 let mut id_to_key = HashMap::new();
423 let mut deleted_ids = HashSet::new();
424
425 let mut stmt = conn.prepare("SELECT node_id, item_key, deleted FROM hnsw_keymap")?;
426 let rows = stmt.query_map([], |row| {
427 Ok((
428 usize::try_from(row.get::<_, i64>(0)?).map_err(|e| {
429 rusqlite::Error::FromSqlConversionFailure(
430 0,
431 rusqlite::types::Type::Integer,
432 Box::new(e),
433 )
434 })?,
435 row.get::<_, String>(1)?,
436 row.get::<_, bool>(2)?,
437 ))
438 })?;
439
440 for row in rows {
441 let (node_id, item_key, deleted) = row?;
442 if !deleted && node_id >= self.inner.next_id.load(Ordering::SeqCst) {
443 return Err(MemoryError::HnswError(format!(
444 "hnsw_keymap node_id {node_id} is outside loaded HNSW sidecar bounds"
445 )));
446 }
447 if deleted {
448 deleted_ids.insert(node_id);
449 } else {
450 let has_vector = self
451 .inner
452 .keymap
453 .read()
454 .unwrap_or_else(|e| e.into_inner())
455 .id_to_vector
456 .contains_key(&node_id);
457 if !has_vector {
458 return Err(MemoryError::HnswError(format!(
459 "hnsw_keymap node_id {node_id} has no vector in loaded sidecar"
460 )));
461 }
462 key_to_id.insert(item_key.clone(), node_id);
463 id_to_key.insert(node_id, item_key);
464 }
465 }
466
467 let next_id = match conn.query_row(
468 "SELECT value FROM hnsw_metadata WHERE key = 'next_id'",
469 [],
470 |row| row.get::<_, String>(0),
471 ) {
472 Ok(value) => value.parse::<usize>().map_err(|e| {
473 MemoryError::HnswError(format!("malformed hnsw next_id metadata '{value}': {e}"))
474 })?,
475 Err(rusqlite::Error::QueryReturnedNoRows) => self.inner.graph.get_nb_point(),
476 Err(error) => return Err(error.into()),
477 };
478
479 let id_to_vector = self
480 .inner
481 .keymap
482 .read()
483 .unwrap_or_else(|e| e.into_inner())
484 .id_to_vector
485 .clone();
486 *self.inner.keymap.write().unwrap_or_else(|e| e.into_inner()) = KeyMapState {
487 key_to_id,
488 id_to_key,
489 id_to_vector,
490 deleted_ids,
491 };
492 self.inner.next_id.store(next_id, Ordering::SeqCst);
493 self.inner.keymap_dirty.store(false, Ordering::SeqCst);
494
495 Ok(())
496 }
497
498 fn insert_with_id(
499 &self,
500 key: Option<String>,
501 id: usize,
502 vector: &[f32],
503 ) -> Result<(), MemoryError> {
504 validate_dimensions(vector, self.inner.config.dimensions)?;
505
506 if let Some(key) = key {
507 self.inner.graph.insert((vector, id));
508
509 let mut keymap = self.inner.keymap.write().unwrap_or_else(|e| e.into_inner());
510
511 if let Some(old_id) = keymap.key_to_id.insert(key.clone(), id) {
512 keymap.id_to_key.remove(&old_id);
513 keymap.id_to_vector.remove(&old_id);
514 keymap.deleted_ids.insert(old_id);
515 }
516 keymap.id_to_key.insert(id, key);
517 keymap.id_to_vector.insert(id, vector.to_vec());
518 self.inner.keymap_dirty.store(true, Ordering::SeqCst);
519 } else {
520 self.inner.graph.insert((vector, id));
521 self.inner
522 .keymap
523 .write()
524 .unwrap_or_else(|e| e.into_inner())
525 .id_to_vector
526 .insert(id, vector.to_vec());
527 }
528 Ok(())
529 }
530
531 fn allocate_id(&self) -> Result<usize, MemoryError> {
532 let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst);
533 if id >= self.inner.config.max_elements {
534 self.inner.next_id.fetch_sub(1, Ordering::SeqCst);
535 return Err(MemoryError::HnswError(format!(
536 "HNSW id space exhausted at max_elements={}; compact or rebuild sidecar before inserting more vectors",
537 self.inner.config.max_elements
538 )));
539 }
540 Ok(id)
541 }
542
543 pub(crate) fn vector_snapshot(&self) -> HashMap<usize, Vec<f32>> {
544 self.inner
545 .keymap
546 .read()
547 .unwrap_or_else(|e| e.into_inner())
548 .id_to_vector
549 .clone()
550 }
551
552 fn sidecar_vectors(&self) -> Result<Vec<(usize, Vec<f32>)>, MemoryError> {
553 let keymap = self.inner.keymap.read().unwrap_or_else(|e| e.into_inner());
554 let mut vectors = Vec::with_capacity(keymap.id_to_key.len());
555 for id in keymap.id_to_key.keys() {
556 if keymap.deleted_ids.contains(id) {
557 continue;
558 }
559 let vector = keymap.id_to_vector.get(id).ok_or_else(|| {
560 MemoryError::HnswError(format!(
561 "HNSW node {id} has an active key but no retained vector for sidecar save"
562 ))
563 })?;
564 db::validate_embedding(vector, self.inner.config.dimensions)?;
565 vectors.push((*id, vector.clone()));
566 }
567 vectors.sort_by_key(|(id, _)| *id);
568 Ok(vectors)
569 }
570}
571
572fn validate_dimensions(vector: &[f32], expected: usize) -> Result<(), MemoryError> {
573 if vector.len() != expected {
574 return Err(MemoryError::HnswError(format!(
575 "expected {} dimensions, got {}",
576 expected,
577 vector.len()
578 )));
579 }
580 if vector.iter().any(|v| !v.is_finite()) {
582 return Err(MemoryError::HnswError(
583 "embedding contains NaN or infinity values".into(),
584 ));
585 }
586 Ok(())
587}
588
589fn hnsw_graph_file_name(basename: &str) -> String {
590 format!("{basename}.hnsw.graph")
591}
592
593fn hnsw_data_file_name(basename: &str) -> String {
594 format!("{basename}.hnsw.data")
595}
596
597fn hnsw_manifest_path(dir: &Path, basename: &str) -> PathBuf {
598 dir.join(format!("{basename}.hnsw.manifest.json"))
599}
600
601fn file_digest(path: &Path) -> Result<String, MemoryError> {
602 let bytes = fs::read(path).map_err(|e| {
603 MemoryError::HnswError(format!(
604 "failed to read HNSW sidecar {} for digest: {}",
605 path.display(),
606 e
607 ))
608 })?;
609 Ok(format!("blake3:{}", ContentDigest::compute(&bytes).hex()))
610}
611
612fn write_hnsw_manifest(path: &Path, manifest: HnswSidecarManifestV1) -> Result<(), MemoryError> {
613 let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| {
614 MemoryError::HnswError(format!("failed to serialize HNSW sidecar manifest: {e}"))
615 })?;
616 let mut file = File::create(path).map_err(|e| {
617 MemoryError::HnswError(format!(
618 "failed to create HNSW sidecar manifest {}: {}",
619 path.display(),
620 e
621 ))
622 })?;
623 file.write_all(&bytes).map_err(|e| {
624 MemoryError::HnswError(format!(
625 "failed to write HNSW sidecar manifest {}: {}",
626 path.display(),
627 e
628 ))
629 })?;
630 file.sync_all().map_err(|e| {
631 MemoryError::HnswError(format!(
632 "failed to fsync HNSW sidecar manifest {}: {}",
633 path.display(),
634 e
635 ))
636 })?;
637 Ok(())
638}
639
640fn validate_hnsw_manifest(
641 dir: &Path,
642 basename: &str,
643 config: &HnswConfig,
644) -> Result<Option<HnswSidecarManifestV1>, MemoryError> {
645 let manifest_path = hnsw_manifest_path(dir, basename);
646 if !manifest_path.exists() {
647 tracing::warn!(
648 path = %manifest_path.display(),
649 "HNSW sidecar manifest missing; legacy graph/data sidecar load is allowed"
650 );
651 return Ok(None);
652 }
653 let bytes = fs::read(&manifest_path).map_err(|e| {
654 MemoryError::HnswError(format!(
655 "failed to read HNSW sidecar manifest {}: {}",
656 manifest_path.display(),
657 e
658 ))
659 })?;
660 let manifest: HnswSidecarManifestV1 = serde_json::from_slice(&bytes).map_err(|e| {
661 MemoryError::HnswError(format!(
662 "failed to parse HNSW sidecar manifest {}: {}",
663 manifest_path.display(),
664 e
665 ))
666 })?;
667 if manifest.schema_version != HNSW_MANIFEST_SCHEMA_VERSION {
668 return Err(MemoryError::HnswError(format!(
669 "unsupported HNSW sidecar manifest schema {}; supported schema is {}",
670 manifest.schema_version, HNSW_MANIFEST_SCHEMA_VERSION
671 )));
672 }
673 if manifest.basename != basename {
674 return Err(MemoryError::HnswError(format!(
675 "HNSW sidecar manifest basename mismatch: manifest={}, expected={basename}",
676 manifest.basename
677 )));
678 }
679 let expected_graph = hnsw_graph_file_name(basename);
680 let expected_data = hnsw_data_file_name(basename);
681 if manifest.graph_file_name != expected_graph || manifest.data_file_name != expected_data {
682 return Err(MemoryError::HnswError(format!(
683 "HNSW sidecar manifest file mismatch: graph={}, data={}, expected graph={}, data={}",
684 manifest.graph_file_name, manifest.data_file_name, expected_graph, expected_data
685 )));
686 }
687 if manifest.dimensions != config.dimensions {
688 return Err(MemoryError::HnswError(format!(
689 "HNSW sidecar manifest dimensions {} do not match configured {}",
690 manifest.dimensions, config.dimensions
691 )));
692 }
693 if manifest.hnsw_sidecar_format_version != HNSW_SIDECAR_VERSION {
694 return Err(MemoryError::HnswError(format!(
695 "HNSW sidecar manifest format version {} does not match supported {}",
696 manifest.hnsw_sidecar_format_version, HNSW_SIDECAR_VERSION
697 )));
698 }
699
700 let graph_path = dir.join(&manifest.graph_file_name);
701 let data_path = dir.join(&manifest.data_file_name);
702 if !graph_path.exists() || !data_path.exists() {
703 return Err(MemoryError::HnswError(format!(
704 "HNSW sidecar manifest points to missing files: graph_exists={}, data_exists={}",
705 graph_path.exists(),
706 data_path.exists()
707 )));
708 }
709 let graph_digest = file_digest(&graph_path)?;
710 let data_digest = file_digest(&data_path)?;
711 if graph_digest != manifest.graph_digest {
712 return Err(MemoryError::HnswError(format!(
713 "HNSW sidecar graph digest mismatch: manifest={}, actual={graph_digest}",
714 manifest.graph_digest
715 )));
716 }
717 if data_digest != manifest.data_digest {
718 return Err(MemoryError::HnswError(format!(
719 "HNSW sidecar data digest mismatch: manifest={}, actual={data_digest}",
720 manifest.data_digest
721 )));
722 }
723 Ok(Some(manifest))
724}
725
726fn validate_manifest_against_header(
727 manifest: &HnswSidecarManifestV1,
728 graph_header: &SidecarHeader,
729) -> Result<(), MemoryError> {
730 if graph_header.dim as usize != manifest.dimensions
731 || graph_header.vector_count != manifest.vector_count
732 {
733 return Err(MemoryError::HnswError(format!(
734 "HNSW manifest/header mismatch: manifest dim/count={}/{}, graph dim/count={}/{}",
735 manifest.dimensions, manifest.vector_count, graph_header.dim, graph_header.vector_count
736 )));
737 }
738 Ok(())
739}
740
741fn validate_graph_sidecar(graph_path: &Path) -> Result<SidecarHeader, MemoryError> {
742 let header = read_sidecar_header(graph_path, HNSW_GRAPH_MAGIC)?;
743 validate_sidecar_header(&header)?;
744 Ok(header)
745}
746
747fn load_vectors_from_sidecar(
748 index: &HnswIndex,
749 data_path: &Path,
750 graph_header: &SidecarHeader,
751) -> Result<usize, MemoryError> {
752 let mut file = File::open(data_path).map_err(|e| {
753 MemoryError::HnswError(format!("failed to open {}: {}", data_path.display(), e))
754 })?;
755
756 let header = read_sidecar_header_from_reader(&mut file, data_path, HNSW_DATA_MAGIC)?;
757 validate_sidecar_header(&header)?;
758 if graph_header.dim != header.dim || graph_header.vector_count != header.vector_count {
759 return Err(MemoryError::HnswError(format!(
760 "HNSW graph/data sidecar header mismatch: graph dim/count={}/{}, data dim/count={}/{}",
761 graph_header.dim, graph_header.vector_count, header.dim, header.vector_count
762 )));
763 }
764 let dims = usize::try_from(header.dim).map_err(|_| {
765 MemoryError::HnswError(format!(
766 "HNSW sidecar dimensions {} exceed this platform's usize range",
767 header.dim
768 ))
769 })?;
770 if dims != index.inner.config.dimensions {
771 return Err(MemoryError::HnswError(format!(
772 "HNSW sidecar dimensions {} do not match configured {}",
773 dims, index.inner.config.dimensions
774 )));
775 }
776
777 let mut max_id = 0usize;
778 let mut loaded = 0u64;
779
780 while loaded < header.vector_count {
781 let mut u64_buf = [0u8; 8];
782 file.read_exact(&mut u64_buf).map_err(|e| {
783 MemoryError::HnswError(format!("failed to read HNSW sidecar node id: {}", e))
784 })?;
785 let id_u64 = u64::from_le_bytes(u64_buf);
786 let id = usize::try_from(id_u64).map_err(|_| {
787 MemoryError::HnswError(format!(
788 "HNSW sidecar node id {id_u64} exceeds this platform's usize range"
789 ))
790 })?;
791 if id >= index.inner.config.max_elements {
792 return Err(MemoryError::HnswError(format!(
793 "HNSW sidecar node id {id} exceeds configured max_elements {}",
794 index.inner.config.max_elements
795 )));
796 }
797
798 file.read_exact(&mut u64_buf).map_err(|e| {
799 MemoryError::HnswError(format!("failed to read HNSW sidecar vector length: {}", e))
800 })?;
801 let byte_len_u64 = u64::from_le_bytes(u64_buf);
802 let expected_byte_len = index
803 .inner
804 .config
805 .dimensions
806 .checked_mul(4)
807 .ok_or_else(|| MemoryError::HnswError("HNSW dimension byte length overflow".into()))?;
808 let byte_len = usize::try_from(byte_len_u64).map_err(|_| {
809 MemoryError::HnswError(format!(
810 "HNSW sidecar vector byte length {byte_len_u64} exceeds this platform's usize range"
811 ))
812 })?;
813 if byte_len != expected_byte_len {
814 return Err(MemoryError::HnswError(format!(
815 "HNSW sidecar vector byte length {byte_len} does not match configured dimensions {} (expected {expected_byte_len} bytes)",
816 index.inner.config.dimensions
817 )));
818 }
819 let mut raw = vec![0u8; byte_len];
820 file.read_exact(&mut raw).map_err(|e| {
821 MemoryError::HnswError(format!("failed to read HNSW sidecar payload: {}", e))
822 })?;
823
824 let vector = db::decode_f32_le(&raw, index.inner.config.dimensions)?;
825 index.insert_with_id(None, id, &vector)?;
826 max_id = max_id.max(id);
827 loaded += 1;
828 }
829
830 let mut trailing = [0u8; 1];
831 if file.read(&mut trailing).map_err(|e| {
832 MemoryError::HnswError(format!("failed to inspect HNSW sidecar trailer: {}", e))
833 })? != 0
834 {
835 return Err(MemoryError::HnswError(
836 "HNSW data sidecar has trailing bytes beyond declared vector_count".to_string(),
837 ));
838 }
839
840 Ok(max_id)
841}
842
843fn atomically_replace_sidecar(tmp_path: &Path, final_path: &Path) -> Result<(), MemoryError> {
844 if let Ok(file) = File::open(tmp_path) {
845 file.sync_all().map_err(|e| {
846 MemoryError::HnswError(format!(
847 "failed to fsync temporary HNSW sidecar {}: {}",
848 tmp_path.display(),
849 e
850 ))
851 })?;
852 }
853 std::fs::rename(tmp_path, final_path).map_err(|e| {
854 MemoryError::HnswError(format!(
855 "failed to atomically replace HNSW sidecar {}: {}",
856 final_path.display(),
857 e
858 ))
859 })?;
860 Ok(())
861}
862
863fn write_graph_sidecar(
864 path: &Path,
865 dimensions: usize,
866 vector_count: u64,
867) -> Result<(), MemoryError> {
868 let mut file = File::create(path).map_err(|e| {
869 MemoryError::HnswError(format!("failed to create {}: {}", path.display(), e))
870 })?;
871 write_sidecar_header(&mut file, HNSW_GRAPH_MAGIC, dimensions, vector_count)?;
872 Ok(())
873}
874
875fn write_data_sidecar(
876 path: &Path,
877 dimensions: usize,
878 vector_count: u64,
879 vectors: &[(usize, Vec<f32>)],
880) -> Result<(), MemoryError> {
881 let mut file = File::create(path).map_err(|e| {
882 MemoryError::HnswError(format!("failed to create {}: {}", path.display(), e))
883 })?;
884 write_sidecar_header(&mut file, HNSW_DATA_MAGIC, dimensions, vector_count)?;
885 for (id, vector) in vectors {
886 db::validate_embedding(vector, dimensions)?;
887 let id = u64::try_from(*id).map_err(|_| {
888 MemoryError::HnswError(format!("HNSW node id {id} exceeds u64 sidecar range"))
889 })?;
890 let bytes = db::encode_f32_le(vector);
891 let byte_len = u64::try_from(bytes.len()).map_err(|_| {
892 MemoryError::HnswError("HNSW vector byte length exceeds u64 sidecar range".to_string())
893 })?;
894 file.write_all(&id.to_le_bytes()).map_err(|e| {
895 MemoryError::HnswError(format!("failed to write HNSW sidecar node id: {}", e))
896 })?;
897 file.write_all(&byte_len.to_le_bytes()).map_err(|e| {
898 MemoryError::HnswError(format!("failed to write HNSW sidecar vector length: {}", e))
899 })?;
900 file.write_all(&bytes).map_err(|e| {
901 MemoryError::HnswError(format!("failed to write HNSW sidecar vector: {}", e))
902 })?;
903 }
904 Ok(())
905}
906
907fn write_sidecar_header<W: Write>(
908 writer: &mut W,
909 magic: u32,
910 dimensions: usize,
911 vector_count: u64,
912) -> Result<(), MemoryError> {
913 let dim = u32::try_from(dimensions).map_err(|_| {
914 MemoryError::HnswError(format!(
915 "HNSW dimensions {dimensions} exceed u32 sidecar header range"
916 ))
917 })?;
918 writer
919 .write_all(&magic.to_le_bytes())
920 .and_then(|_| writer.write_all(&HNSW_SIDECAR_VERSION.to_le_bytes()))
921 .and_then(|_| writer.write_all(&HNSW_SIDECAR_HEADER_LEN.to_le_bytes()))
922 .and_then(|_| writer.write_all(&dim.to_le_bytes()))
923 .and_then(|_| writer.write_all(&vector_count.to_le_bytes()))
924 .and_then(|_| writer.write_all(&0u32.to_le_bytes()))
925 .map_err(|e| MemoryError::HnswError(format!("failed to write HNSW sidecar header: {}", e)))
926}
927
928fn read_sidecar_header(path: &Path, expected_magic: u32) -> Result<SidecarHeader, MemoryError> {
929 let mut file = File::open(path)
930 .map_err(|e| MemoryError::HnswError(format!("failed to open {}: {}", path.display(), e)))?;
931 read_sidecar_header_from_reader(&mut file, path, expected_magic)
932}
933
934fn read_sidecar_header_from_reader<R: Read>(
935 reader: &mut R,
936 path: &Path,
937 expected_magic: u32,
938) -> Result<SidecarHeader, MemoryError> {
939 let mut header = [0u8; HNSW_SIDECAR_HEADER_LEN as usize];
940 reader.read_exact(&mut header).map_err(|e| {
941 if e.kind() == std::io::ErrorKind::UnexpectedEof {
942 MemoryError::HnswError(format!(
943 "empty or truncated HNSW sidecar: {}",
944 path.display()
945 ))
946 } else {
947 MemoryError::HnswError(format!("failed to read HNSW sidecar header: {}", e))
948 }
949 })?;
950 let parsed = SidecarHeader {
951 magic: u32::from_le_bytes([header[0], header[1], header[2], header[3]]),
952 version: u16::from_le_bytes([header[4], header[5]]),
953 header_len: u16::from_le_bytes([header[6], header[7]]),
954 dim: u32::from_le_bytes([header[8], header[9], header[10], header[11]]),
955 vector_count: u64::from_le_bytes([
956 header[12], header[13], header[14], header[15], header[16], header[17], header[18],
957 header[19],
958 ]),
959 flags: u32::from_le_bytes([header[20], header[21], header[22], header[23]]),
960 };
961 if parsed.magic != expected_magic {
962 return Err(MemoryError::HnswError(format!(
963 "unsupported HNSW sidecar magic 0x{:08x}; expected 0x{:08x}",
964 parsed.magic, expected_magic
965 )));
966 }
967 Ok(parsed)
968}
969
970fn validate_sidecar_header(header: &SidecarHeader) -> Result<(), MemoryError> {
971 if header.version != HNSW_SIDECAR_VERSION {
972 return Err(MemoryError::HnswError(format!(
973 "unsupported HNSW sidecar version {}; supported version is {}",
974 header.version, HNSW_SIDECAR_VERSION
975 )));
976 }
977 if header.header_len != HNSW_SIDECAR_HEADER_LEN {
978 return Err(MemoryError::HnswError(format!(
979 "unsupported HNSW sidecar header length {}; expected {}",
980 header.header_len, HNSW_SIDECAR_HEADER_LEN
981 )));
982 }
983 if header.flags != 0 {
984 return Err(MemoryError::HnswError(format!(
985 "unsupported HNSW sidecar flags 0x{:08x}",
986 header.flags
987 )));
988 }
989 Ok(())
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995 use std::sync::Arc;
996
997 fn test_config(max_elements: usize) -> HnswConfig {
998 HnswConfig {
999 dimensions: 2,
1000 max_elements,
1001 ..HnswConfig::default()
1002 }
1003 }
1004
1005 #[test]
1006 fn hnsw_keymap_updates_are_single_lock_consistent_under_delete_search_race() {
1007 let index = Arc::new(HnswIndex::new(test_config(256)).unwrap());
1008 for i in 0..64 {
1009 index
1010 .insert(format!("fact:{i}"), &[1.0, i as f32 / 100.0])
1011 .unwrap();
1012 }
1013
1014 std::thread::scope(|scope| {
1015 for worker in 0..4 {
1016 let index = Arc::clone(&index);
1017 scope.spawn(move || {
1018 for i in 0..64 {
1019 if i % 4 == worker {
1020 index.delete(&format!("fact:{i}")).unwrap();
1021 }
1022 }
1023 });
1024 }
1025 for _ in 0..4 {
1026 let index = Arc::clone(&index);
1027 scope.spawn(move || {
1028 for _ in 0..128 {
1029 let hits = index.search(&[1.0, 0.0], 16).unwrap();
1030 for hit in hits {
1031 assert!(hit.key.starts_with("fact:"));
1032 }
1033 }
1034 });
1035 }
1036 });
1037 }
1038
1039 #[test]
1040 fn dirty_flag_and_flush_epoch_use_seqcst_visible_state() {
1041 let index = HnswIndex::new(test_config(8)).unwrap();
1042 assert!(!index.is_keymap_dirty());
1043 index.insert("fact:a".into(), &[1.0, 0.0]).unwrap();
1044 assert!(index.is_keymap_dirty());
1045 assert!(index.should_flush(0));
1046 index.update_last_flush_epoch();
1047 assert!(!index.should_flush(u64::MAX));
1048 }
1049
1050 #[test]
1051 fn id_exhaustion_is_explicit_until_compaction_rebuilds_sidecar() {
1052 let index = HnswIndex::new(test_config(1)).unwrap();
1053 index.insert("fact:a".into(), &[1.0, 0.0]).unwrap();
1054 index.delete("fact:a").unwrap();
1055
1056 let error = index.insert("fact:b".into(), &[0.0, 1.0]).unwrap_err();
1057 assert!(error.to_string().contains("HNSW id space exhausted"));
1058 assert!(error.to_string().contains("compact or rebuild"));
1059 }
1060}