Skip to main content

semantic_memory/
hnsw.rs

1//! HNSW approximate nearest-neighbor index wrapper.
2//!
3//! SQLite remains the source of truth. The on-disk HNSW files are a recoverable
4//! acceleration sidecar that can be rebuilt from SQLite whenever needed.
5
6use 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; // "SMHD"
20const HNSW_GRAPH_MAGIC: u32 = 0x534d_4847; // "SMHG"
21const HNSW_SIDECAR_VERSION: u16 = 1;
22const HNSW_SIDECAR_HEADER_LEN: u16 = 24;
23const HNSW_MANIFEST_SCHEMA_VERSION: u32 = 1;
24
25/// Configuration for the HNSW index.
26#[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/// A single hit from HNSW search.
52#[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    /// Split the sidecar key into `(domain, identifier)`.
64    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    // CONVENTION EXCEPTION: O(1) lookup required for HNSW index
83    key_to_id: HashMap<String, usize>,
84    // CONVENTION EXCEPTION: O(1) lookup required for HNSW index
85    id_to_key: HashMap<usize, String>,
86    // CONVENTION EXCEPTION: sidecar persistence needs stable node-id to vector replay
87    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    /// Load a previously flushed HNSW sidecar by replaying the dumped vectors.
152    ///
153    /// This avoids relying on `hnsw_rs`'s borrowing reload API and keeps the safety
154    /// boundary purely in safe Rust. Node IDs are preserved so the SQLite keymap can
155    /// be loaded afterward.
156    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                // LIB-020: NaN distances sort to the end rather than comparing as equal
309                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        // CONVENTION EXCEPTION: O(1) lookup required for HNSW index
420        let mut key_to_id = HashMap::new();
421        // CONVENTION EXCEPTION: O(1) lookup required for HNSW index
422        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    // LIB-LOW-002: Reject NaN/infinity embeddings
581    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}