Skip to main content

holographic_memory/core/qgraph/
update.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Durable single-writer vector updates; binary contracts are in
4//! `docs/file-format/VECTOR-UPDATES.md`.
5
6use std::cell::RefCell;
7use std::collections::HashSet;
8use std::fs::{File, OpenOptions};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU8, Ordering};
11use std::sync::Arc;
12
13use parking_lot::RwLock;
14
15use super::{
16    kernels::dot, vertex::SearchScratch, BuildParams, Graph, Metric, Residency, VGraph,
17    VSearchParams,
18};
19use crate::core::durable_file::{atomic_write, sync_directory, sync_mapped_file};
20use crate::core::error::HmsError;
21
22mod delta;
23mod format;
24use delta::Delta;
25use format::BaseRows;
26
27const MAX_DIM: usize = 1 << 20;
28const MAX_PAYLOAD: usize = 64 << 20;
29const MAX_OPS: usize = 65536;
30
31/// Failures at the update and persistence boundary.
32#[derive(Debug, thiserror::Error)]
33pub enum UpdateError {
34    #[error("unknown vector id {0}")]
35    UnknownId(u32),
36    #[error("vector id {0} already exists")]
37    AlreadyExists(u32),
38    #[error("id {0} occurs twice in one batch")]
39    DuplicateIdInBatch(u32),
40    #[error("expected dimension {expected}, got {actual}")]
41    DimensionMismatch { expected: usize, actual: usize },
42    #[error("invalid vector: {0}")]
43    InvalidVector(&'static str),
44    #[error("id u32::MAX is reserved")]
45    IdOutOfRange,
46    #[error("vector index allocation or batch limit exceeded")]
47    Capacity,
48    #[error("corrupt vector log at byte {offset}: {reason}")]
49    CorruptLog { offset: u64, reason: String },
50    #[error("corrupt vector base: {0}")]
51    CorruptBase(String),
52    #[error("unsupported vector format: {0}")]
53    UnsupportedFormat(&'static str),
54    #[error("another writer is active")]
55    WriterBusy,
56    #[error("index is already open by a writer")]
57    Locked,
58    #[error("write outcome uncertain; reopen the index before further writes")]
59    RecoveryRequired,
60    #[error(transparent)]
61    Io(#[from] std::io::Error),
62    #[error(transparent)]
63    Persistence(#[from] anyhow::Error),
64    #[error(transparent)]
65    Search(#[from] HmsError),
66}
67
68/// Advisory thresholds; callers schedule consolidation outside their commit latency.
69#[derive(Clone, Copy, Debug)]
70pub struct ConsolidationPolicy {
71    pub delta_ratio: f64,
72    pub tombstone_ratio: f64,
73    pub min_delta: usize,
74}
75impl Default for ConsolidationPolicy {
76    fn default() -> Self {
77        Self {
78            delta_ratio: 0.1,
79            tombstone_ratio: 0.2,
80            min_delta: 1024,
81        }
82    }
83}
84
85#[derive(Clone)]
86pub(super) struct Encoding {
87    pub(super) bits: u8,
88    pub(super) residual_bits: u8,
89    pub(super) align: bool,
90    pub(super) reorder: bool,
91    pub(super) id_bytes: u8,
92    pub(super) screen: bool,
93    pub(super) block_bits: u8,
94}
95impl Default for Encoding {
96    fn default() -> Self {
97        Self {
98            bits: 8,
99            residual_bits: 8,
100            align: true,
101            reorder: false,
102            id_bytes: 4,
103            screen: false,
104            block_bits: 0,
105        }
106    }
107}
108#[derive(Clone)]
109struct Manifest {
110    identity: [u8; 16],
111    dim: usize,
112    sequence: u64,
113    build: BuildParams,
114    encoding: Encoding,
115}
116impl Manifest {
117    fn vertex_path(&self, dir: &Path) -> PathBuf {
118        dir.join(format!("base-{}.vertex", self.sequence))
119    }
120    fn rows_path(&self, dir: &Path) -> PathBuf {
121        dir.join(format!("rows-{}.bin", self.sequence))
122    }
123}
124
125#[derive(Clone)]
126enum Operation {
127    Insert(u32, Vec<f32>),
128    Delete(u32),
129    Replace(u32, Vec<f32>),
130}
131impl Operation {
132    fn id(&self) -> u32 {
133        match self {
134            Self::Insert(id, _) | Self::Delete(id) | Self::Replace(id, _) => *id,
135        }
136    }
137    fn tag(&self) -> u8 {
138        match self {
139            Self::Insert(..) => 1,
140            Self::Delete(..) => 2,
141            Self::Replace(..) => 3,
142        }
143    }
144    fn vector(&self) -> Option<&[f32]> {
145        match self {
146            Self::Insert(_, v) | Self::Replace(_, v) => Some(v),
147            Self::Delete(_) => None,
148        }
149    }
150}
151struct Base {
152    graph: Option<VGraph>,
153    rows: BaseRows,
154    dense_ids: bool,
155}
156impl Base {
157    fn open(dir: &Path, m: &Manifest) -> Result<Self, UpdateError> {
158        let rows = BaseRows::open(&m.rows_path(dir), m)?;
159        let graph = if rows.ids.is_empty() {
160            None
161        } else {
162            let graph = VGraph::open(&m.vertex_path(dir), Residency::Mapped)?;
163            if graph.len() != rows.ids.len()
164                || graph.dim() != m.dim
165                || graph.metric() != m.build.metric
166            {
167                return Err(UpdateError::CorruptBase(
168                    "vertex/rows shape mismatch".into(),
169                ));
170            }
171            Some(graph)
172        };
173        let dense_ids = rows.ids.iter().enumerate().all(|(i, &id)| i == id as usize);
174        Ok(Self {
175            graph,
176            rows,
177            dense_ids,
178        })
179    }
180}
181#[derive(Clone)]
182struct State {
183    base: Arc<Base>,
184    tombstones: Vec<u64>,
185    deleted: usize,
186    delta: Delta,
187    pending: Vec<Operation>,
188    touched: HashSet<u32>,
189    pending_bytes: usize,
190    manifest: Manifest,
191    sequence: u64,
192    log_end: u64,
193}
194impl State {
195    fn new(base: Base, manifest: Manifest) -> Self {
196        Self {
197            tombstones: vec![0; base.rows.ids.len().div_ceil(64)],
198            base: Arc::new(base),
199            deleted: 0,
200            delta: Delta::default(),
201            pending: Vec::new(),
202            touched: HashSet::new(),
203            pending_bytes: 0,
204            sequence: manifest.sequence,
205            manifest,
206            log_end: format::LOG_HEADER,
207        }
208    }
209    fn dead(&self, slot: usize) -> bool {
210        self.tombstones[slot / 64] & (1 << (slot % 64)) != 0
211    }
212    fn base_slot(&self, id: u32) -> Option<usize> {
213        self.base
214            .rows
215            .ids
216            .binary_search(&id)
217            .ok()
218            .filter(|&s| !self.dead(s))
219    }
220    fn contains(&self, id: u32) -> bool {
221        self.delta.live.contains_key(&id) || self.base_slot(id).is_some()
222    }
223    fn len(&self) -> usize {
224        self.base.rows.ids.len() - self.deleted + self.delta.live.len()
225    }
226    fn validate_op(&self, op: &Operation) -> Result<(), UpdateError> {
227        if op.id() == u32::MAX {
228            return Err(UpdateError::IdOutOfRange);
229        }
230        if let Some(v) = op.vector() {
231            validate_vector(v, self.manifest.dim, self.manifest.build.metric)?;
232        }
233        match op {
234            Operation::Insert(id, _) if self.contains(*id) => Err(UpdateError::AlreadyExists(*id)),
235            Operation::Delete(id) | Operation::Replace(id, _) if !self.contains(*id) => {
236                Err(UpdateError::UnknownId(*id))
237            }
238            _ => Ok(()),
239        }
240    }
241    fn apply(&mut self, op: &Operation) {
242        let id = op.id();
243        if !matches!(op, Operation::Insert(..)) {
244            self.delta.live.remove(&id);
245            if let Some(slot) = self.base_slot(id) {
246                self.tombstones[slot / 64] |= 1 << (slot % 64);
247                self.deleted += 1;
248            }
249        }
250        if let Some(row) = op.vector() {
251            let mut row = row.to_vec();
252            super::prepare_rows(&mut row, self.manifest.dim, self.manifest.build.metric);
253            self.delta.insert(id, &row, &self.manifest.build);
254        }
255    }
256    fn replay(&mut self, ops: Vec<Operation>) -> Result<(), UpdateError> {
257        for op in &ops {
258            self.validate_op(op)?;
259        }
260        for op in &ops {
261            self.apply(op);
262        }
263        Ok(())
264    }
265}
266
267thread_local! { static SCRATCH: RefCell<Option<SearchScratch>> = const { RefCell::new(None) }; }
268
269/// A mapped base plus a mutable graph delta, with atomic durable batches.
270/// `insert`, `delete`, and `replace` publish immediately; `commit` makes the batch durable.
271/// Only one process may open a directory, while any number of threads may search it.
272pub struct UpdatableVGraph {
273    dir: PathBuf,
274    _lock: IndexLock,
275    writer: AtomicU8,
276    state: RwLock<State>,
277}
278struct Writer<'a>(&'a AtomicU8);
279impl Writer<'_> {
280    fn uncertain(&self) {
281        self.0.store(2, Ordering::Release);
282    }
283}
284impl Drop for Writer<'_> {
285    fn drop(&mut self) {
286        let _ = self
287            .0
288            .compare_exchange(1, 0, Ordering::Release, Ordering::Relaxed);
289    }
290}
291struct IndexLock(File);
292impl Drop for IndexLock {
293    fn drop(&mut self) {
294        // Explicit unlock also releases descriptors inherited by a concurrent fork.
295        if let Err(error) = fs2::FileExt::unlock(&self.0) {
296            tracing::warn!(%error, "failed to release vector index lock");
297        }
298    }
299}
300fn lock(dir: &Path) -> Result<IndexLock, UpdateError> {
301    let file = OpenOptions::new()
302        .create(true)
303        .truncate(false)
304        .read(true)
305        .write(true)
306        .open(dir.join(".lock"))?;
307    fs2::FileExt::try_lock_exclusive(&file).map_err(|e| {
308        // Windows reports a held lock as ERROR_LOCK_VIOLATION, not WouldBlock.
309        if e.kind() == std::io::ErrorKind::WouldBlock
310            || e.raw_os_error() == fs2::lock_contended_error().raw_os_error()
311        {
312            UpdateError::Locked
313        } else {
314            UpdateError::Io(e)
315        }
316    })?;
317    Ok(IndexLock(file))
318}
319fn validate_vector(row: &[f32], dim: usize, metric: Metric) -> Result<(), UpdateError> {
320    if row.len() != dim {
321        return Err(UpdateError::DimensionMismatch {
322            expected: dim,
323            actual: row.len(),
324        });
325    }
326    if row.iter().any(|v| !v.is_finite()) {
327        return Err(UpdateError::InvalidVector("non-finite coordinate"));
328    }
329    if !dot(row, row).is_finite() {
330        return Err(UpdateError::InvalidVector("squared norm overflows f32"));
331    }
332    if metric == Metric::Angular && row.iter().all(|&v| v == 0.0) {
333        return Err(UpdateError::InvalidVector("zero angular vector"));
334    }
335    Ok(())
336}
337fn distance(q: &[f32], row: &[f32], metric: Metric) -> f32 {
338    match metric {
339        Metric::Angular => -dot(q, row),
340        Metric::Euclidean => dot(row, row) * 0.5 - dot(q, row),
341    }
342}
343
344fn widened_width(requested: usize, allocated: usize, live: usize) -> usize {
345    ((requested.min(allocated) as u128 * allocated as u128).div_ceil(live as u128))
346        .min(allocated as u128) as usize
347}
348
349impl UpdatableVGraph {
350    /// Creates a new directory from row-major vectors with ids `0..n`.
351    pub fn create(
352        path: &Path,
353        data: &[f32],
354        dim: usize,
355        params: &BuildParams,
356    ) -> Result<Self, UpdateError> {
357        Self::create_with_encoding(path, data, dim, params, Encoding::default())
358    }
359    fn create_with_encoding(
360        path: &Path,
361        data: &[f32],
362        dim: usize,
363        params: &BuildParams,
364        encoding: Encoding,
365    ) -> Result<Self, UpdateError> {
366        if dim == 0
367            || dim > MAX_DIM
368            || !data.len().is_multiple_of(dim)
369            || data.len() / dim >= u32::MAX as usize
370        {
371            return Err(UpdateError::Capacity);
372        }
373        let manifest = Manifest {
374            dim,
375            identity: rand::random(),
376            sequence: 0,
377            build: params.clone(),
378            encoding,
379        };
380        Manifest::decode(&manifest.encode())?;
381        for row in data.chunks_exact(dim) {
382            validate_vector(row, dim, params.metric)?;
383        }
384        std::fs::create_dir(path)?;
385        let file = lock(path)?;
386        let mut vectors = data.to_vec();
387        super::prepare_rows(&mut vectors, dim, params.metric);
388        let ids: Vec<u32> = (0..(data.len() / dim) as u32).collect();
389        write_base(path, &manifest, &ids, vectors)?;
390        format::rotate(&path.join("updates.log"), &manifest)?;
391        atomic_write(&path.join("CURRENT"), &manifest.encode())?;
392        sync_directory(
393            path.parent()
394                .filter(|p| !p.as_os_str().is_empty())
395                .unwrap_or_else(|| Path::new(".")),
396        )?;
397        let base = Base::open(path, &manifest)?;
398        Ok(Self {
399            dir: path.to_path_buf(),
400            _lock: file,
401            writer: AtomicU8::new(0),
402            state: RwLock::new(State::new(base, manifest)),
403        })
404    }
405    /// Saves a graph and its matching vertex encoding without rebuilding the graph.
406    pub fn create_from_graph(
407        path: &Path,
408        graph: &Graph,
409        vertex: &VGraph,
410    ) -> Result<Self, UpdateError> {
411        if graph.n != vertex.len() || graph.dim != vertex.dim() || graph.metric != vertex.metric() {
412            return Err(UpdateError::CorruptBase(
413                "graph and vertex encoding differ".into(),
414            ));
415        }
416        let manifest = Manifest {
417            identity: rand::random(),
418            dim: graph.dim,
419            sequence: 0,
420            build: graph.build.clone(),
421            encoding: vertex.update_encoding(),
422        };
423        Manifest::decode(&manifest.encode())?;
424        for row in graph.vectors.chunks_exact(graph.dim) {
425            validate_vector(row, graph.dim, graph.metric)?;
426        }
427        std::fs::create_dir(path)?;
428        let file = lock(path)?;
429        vertex.save(&manifest.vertex_path(path))?;
430        let ids: Vec<_> = (0..graph.n as u32).collect();
431        BaseRows::write(&manifest.rows_path(path), &manifest, &ids, &graph.vectors)?;
432        format::rotate(&path.join("updates.log"), &manifest)?;
433        atomic_write(&path.join("CURRENT"), &manifest.encode())?;
434        sync_directory(
435            path.parent()
436                .filter(|p| !p.as_os_str().is_empty())
437                .unwrap_or_else(|| Path::new(".")),
438        )?;
439        let base = Base::open(path, &manifest)?;
440        Ok(Self {
441            dir: path.to_path_buf(),
442            _lock: file,
443            writer: AtomicU8::new(0),
444            state: RwLock::new(State::new(base, manifest)),
445        })
446    }
447    /// Opens and replays committed batches; truncates only an incomplete final frame.
448    pub fn open(path: &Path) -> Result<Self, UpdateError> {
449        let file = lock(path)?;
450        let manifest = Manifest::read(&path.join("CURRENT"))?;
451        let base = Base::open(path, &manifest)?;
452        let mut state = State::new(base, manifest.clone());
453        let log = path.join("updates.log");
454        let (sequence, end) = format::replay(&log, &manifest, |ops| state.replay(ops))?;
455        let log_file = OpenOptions::new().write(true).open(&log)?;
456        if log_file.metadata()?.len() != end {
457            log_file.set_len(end)?;
458            log_file.sync_all()?;
459            sync_mapped_file(&log_file)?;
460        }
461        state.sequence = sequence;
462        state.log_end = end;
463        cleanup_generations(path, state.manifest.sequence)?;
464        Ok(Self {
465            dir: path.to_path_buf(),
466            _lock: file,
467            writer: AtomicU8::new(0),
468            state: RwLock::new(state),
469        })
470    }
471    fn writer(&self) -> Result<Writer<'_>, UpdateError> {
472        self.writer
473            .compare_exchange(0, 1, Ordering::Acquire, Ordering::Relaxed)
474            .map_err(|v| {
475                if v == 2 {
476                    UpdateError::RecoveryRequired
477                } else {
478                    UpdateError::WriterBusy
479                }
480            })?;
481        Ok(Writer(&self.writer))
482    }
483    pub fn len(&self) -> usize {
484        self.state.read().len()
485    }
486    pub fn is_empty(&self) -> bool {
487        self.len() == 0
488    }
489    pub fn dim(&self) -> usize {
490        self.state.read().manifest.dim
491    }
492    /// Stages a new id in the current batch.
493    pub fn insert(&self, id: u32, vector: &[f32]) -> Result<(), UpdateError> {
494        self.stage(id, Some(vector), 1)
495    }
496    /// Stages deletion of an existing id.
497    pub fn delete(&self, id: u32) -> Result<(), UpdateError> {
498        self.stage(id, None, 2)
499    }
500    /// Atomically invalidates the previous vector and inserts the replacement.
501    pub fn replace(&self, id: u32, vector: &[f32]) -> Result<(), UpdateError> {
502        self.stage(id, Some(vector), 3)
503    }
504    fn stage(&self, id: u32, vector: Option<&[f32]>, tag: u8) -> Result<(), UpdateError> {
505        let _writer = self.writer()?;
506        let mut s = self.state.write();
507        if id == u32::MAX {
508            return Err(UpdateError::IdOutOfRange);
509        }
510        if s.touched.contains(&id) {
511            return Err(UpdateError::DuplicateIdInBatch(id));
512        }
513        if let Some(v) = vector {
514            validate_vector(v, s.manifest.dim, s.manifest.build.metric)?;
515        }
516        let size = 5 + vector.map_or(0, |v| v.len() * 4);
517        if s.pending.len() >= MAX_OPS
518            || s.pending_bytes + size > MAX_PAYLOAD
519            || s.delta.len() >= (u32::MAX - 1) as usize
520        {
521            return Err(UpdateError::Capacity);
522        }
523        let op = match tag {
524            1 => Operation::Insert(id, vector.expect("insert vector").to_vec()),
525            2 => Operation::Delete(id),
526            _ => Operation::Replace(id, vector.expect("replace vector").to_vec()),
527        };
528        s.validate_op(&op)?;
529        s.apply(&op);
530        s.touched.insert(id);
531        s.pending_bytes += size;
532        s.pending.push(op);
533        Ok(())
534    }
535    /// Most operations one batch can stage before `commit` for vectors of this dimension.
536    pub fn batch_capacity(&self) -> usize {
537        MAX_OPS.min(MAX_PAYLOAD / (5 + 4 * self.dim()))
538    }
539    /// Stages new ids for row-major `vectors`, all or none, linking them into the delta in
540    /// parallel. Up to `batch_capacity` operations fit in one batch.
541    pub fn insert_batch(&self, ids: &[u32], vectors: &[f32]) -> Result<(), UpdateError> {
542        let _writer = self.writer()?;
543        let mut s = self.state.write();
544        let dim = s.manifest.dim;
545        if vectors.len() != ids.len() * dim {
546            return Err(UpdateError::DimensionMismatch {
547                expected: ids.len() * dim,
548                actual: vectors.len(),
549            });
550        }
551        let size = (5 + 4 * dim) * ids.len();
552        if s.pending.len() + ids.len() > MAX_OPS
553            || s.pending_bytes + size > MAX_PAYLOAD
554            || s.delta.len() + ids.len() >= (u32::MAX - 1) as usize
555        {
556            return Err(UpdateError::Capacity);
557        }
558        let mut batch = HashSet::with_capacity(ids.len());
559        for (&id, row) in ids.iter().zip(vectors.chunks_exact(dim)) {
560            if id == u32::MAX {
561                return Err(UpdateError::IdOutOfRange);
562            }
563            if s.touched.contains(&id) || !batch.insert(id) {
564                return Err(UpdateError::DuplicateIdInBatch(id));
565            }
566            if s.contains(id) {
567                return Err(UpdateError::AlreadyExists(id));
568            }
569            validate_vector(row, dim, s.manifest.build.metric)?;
570        }
571        let mut rows = vectors.to_vec();
572        super::prepare_rows(&mut rows, dim, s.manifest.build.metric);
573        let build = s.manifest.build.clone();
574        s.delta.insert_batch(ids, &rows, &build);
575        for (&id, row) in ids.iter().zip(vectors.chunks_exact(dim)) {
576            s.pending.push(Operation::Insert(id, row.to_vec()));
577            s.touched.insert(id);
578        }
579        s.pending_bytes += size;
580        Ok(())
581    }
582    /// Appends the whole pending batch and completes its durable sync.
583    pub fn commit(&self) -> Result<(), UpdateError> {
584        let writer = self.writer()?;
585        self.commit_with(&writer)
586    }
587    fn commit_with(&self, writer: &Writer<'_>) -> Result<(), UpdateError> {
588        let (bytes, end, sequence) = {
589            let s = self.state.read();
590            if s.pending.is_empty() {
591                return Ok(());
592            }
593            let sequence = s.sequence.checked_add(1).ok_or(UpdateError::Capacity)?;
594            (
595                format::frame(&s.pending, sequence, s.manifest.dim)?,
596                s.log_end,
597                sequence,
598            )
599        };
600        if let Err(e) = format::append(&self.dir.join("updates.log"), end, &bytes) {
601            writer.uncertain();
602            return Err(e);
603        }
604        let mut s = self.state.write();
605        s.sequence = sequence;
606        s.log_end += bytes.len() as u64;
607        s.pending.clear();
608        s.touched.clear();
609        s.pending_bytes = 0;
610        Ok(())
611    }
612    /// Searches a consistent in-memory view. `k` is clamped to the live count.
613    pub fn search(
614        &self,
615        query: &[f32],
616        k: usize,
617        params: VSearchParams,
618    ) -> Result<Vec<u32>, UpdateError> {
619        let mut out = Vec::new();
620        self.search_into(query, k, params, &mut out)?;
621        Ok(out)
622    }
623    /// Searches with a reusable result buffer, avoiding allocation on clean-base queries.
624    pub fn search_into(
625        &self,
626        query: &[f32],
627        k: usize,
628        params: VSearchParams,
629        out: &mut Vec<u32>,
630    ) -> Result<(), UpdateError> {
631        let s = self.state.read();
632        validate_vector(query, s.manifest.dim, s.manifest.build.metric)?;
633        let k = k.min(s.len());
634        out.clear();
635        if k == 0 {
636            return Ok(());
637        }
638        if s.delta.len() == 0 && s.deleted == 0 {
639            if let Some(graph) = &s.base.graph {
640                SCRATCH.with(|cache| {
641                    graph.search_reusing(&mut cache.borrow_mut(), query, k, params, out)
642                })?;
643                if !s.base.dense_ids {
644                    for slot in out {
645                        *slot = s.base.rows.ids[*slot as usize];
646                    }
647                }
648            }
649            return Ok(());
650        }
651        if let Some(graph) = s
652            .base
653            .graph
654            .as_ref()
655            .filter(|_| s.deleted < s.base.rows.ids.len())
656        {
657            let live = graph.len() - s.deleted;
658            let mut take = widened_width(params.ef.max(params.rerank).max(k), graph.len(), live);
659            loop {
660                let mut widened = params;
661                widened.ef = take;
662                widened.rerank = take;
663                widened.patience = 0;
664                SCRATCH.with(|cache| {
665                    graph.search_reusing(&mut cache.borrow_mut(), query, take, widened, out)
666                })?;
667                if take == graph.len()
668                    || out.iter().filter(|&&slot| !s.dead(slot as usize)).count() >= k.min(live)
669                {
670                    break;
671                }
672                take = take.saturating_mul(2).min(graph.len());
673            }
674        }
675        let metric = s.manifest.build.metric;
676        let mut candidates =
677            Vec::with_capacity(out.len() + s.delta.live.len().min(params.ef.max(k)));
678        for &slot in out.iter() {
679            let slot = slot as usize;
680            if !s.dead(slot) {
681                candidates.push((
682                    distance(query, s.base.rows.row(slot)?, metric),
683                    s.base.rows.ids[slot],
684                ));
685            }
686        }
687        if !s.delta.live.is_empty() {
688            let mut ef = widened_width(
689                params.ef.max(params.rerank).max(k),
690                s.delta.len(),
691                s.delta.live.len(),
692            );
693            loop {
694                let found = s.delta.search(query, ef, metric);
695                if ef == s.delta.len() || found.len() >= k.min(s.delta.live.len()) {
696                    candidates.extend(found);
697                    break;
698                }
699                ef = ef.saturating_mul(2).min(s.delta.len());
700            }
701        }
702        candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
703        candidates.truncate(k);
704        out.clear();
705        out.extend(candidates.into_iter().map(|(_, id)| id));
706        Ok(())
707    }
708    pub fn needs_consolidation(&self, policy: ConsolidationPolicy) -> Result<bool, UpdateError> {
709        if !policy.delta_ratio.is_finite()
710            || !policy.tombstone_ratio.is_finite()
711            || !(0.0..=1.0).contains(&policy.delta_ratio)
712            || !(0.0..=1.0).contains(&policy.tombstone_ratio)
713        {
714            return Err(UpdateError::InvalidVector(
715                "consolidation ratios must be in 0..=1",
716            ));
717        }
718        let s = self.state.read();
719        let base = s.base.rows.ids.len().max(1) as f64;
720        Ok(
721            (s.delta.len() >= policy.min_delta
722                && s.delta.len() as f64 / base >= policy.delta_ratio)
723                || (s.deleted > 0 && s.deleted as f64 / base >= policy.tombstone_ratio),
724        )
725    }
726    /// Commits pending work and publishes a new immutable generation while readers run.
727    pub fn consolidate(&self) -> Result<(), UpdateError> {
728        self.consolidate_inner(|_| Ok(()))
729    }
730    fn consolidate_inner(
731        &self,
732        mut checkpoint: impl FnMut(u8) -> Result<(), UpdateError>,
733    ) -> Result<(), UpdateError> {
734        let writer = self.writer()?;
735        self.commit_with(&writer)?;
736        // The writer flag excludes every other mutation, so a read guard sees the same state
737        // a clone would, without copying the delta.
738        let snapshot = self.state.read();
739        if snapshot.delta.len() == 0 && snapshot.deleted == 0 {
740            return Ok(());
741        }
742        let mut manifest = snapshot.manifest.clone();
743        manifest.sequence = snapshot.sequence;
744        let mut rows = Vec::new();
745        rows.try_reserve_exact(
746            snapshot
747                .len()
748                .checked_mul(manifest.dim)
749                .ok_or(UpdateError::Capacity)?,
750        )
751        .map_err(|_| UpdateError::Capacity)?;
752        let mut ids: Vec<_> = snapshot
753            .base
754            .rows
755            .ids
756            .iter()
757            .enumerate()
758            .filter(|(slot, _)| !snapshot.dead(*slot))
759            .map(|(_, id)| *id)
760            .chain(snapshot.delta.live.keys().copied())
761            .collect();
762        ids.sort_unstable();
763        for &id in &ids {
764            if let Some(&slot) = snapshot.delta.live.get(&id) {
765                rows.extend_from_slice(snapshot.delta.row(slot, manifest.dim));
766            } else {
767                rows.extend_from_slice(
768                    snapshot
769                        .base
770                        .rows
771                        .row(snapshot.base_slot(id).expect("live base id"))?,
772                );
773            }
774        }
775        drop(snapshot);
776        write_base(&self.dir, &manifest, &ids, rows)?;
777        checkpoint(1)?;
778        let base = Base::open(&self.dir, &manifest)?;
779        let publication = (|| {
780            atomic_write(&self.dir.join("CURRENT"), &manifest.encode())?;
781            checkpoint(2)?;
782            checkpoint(3)?;
783            format::rotate(&self.dir.join("updates.log"), &manifest)?;
784            Ok::<_, UpdateError>(())
785        })();
786        if let Err(e) = publication {
787            writer.uncertain();
788            return Err(e);
789        }
790        *self.state.write() = State::new(base, manifest.clone());
791        cleanup_generations(&self.dir, manifest.sequence)?;
792        Ok(())
793    }
794}
795fn cleanup_generations(dir: &Path, active: u64) -> Result<(), UpdateError> {
796    for entry in std::fs::read_dir(dir)? {
797        let entry = entry?;
798        let name = entry.file_name();
799        let Some(name) = name.to_str() else { continue };
800        let sequence = name
801            .strip_prefix("base-")
802            .and_then(|s| s.strip_suffix(".vertex"))
803            .or_else(|| {
804                name.strip_prefix("rows-")
805                    .and_then(|s| s.strip_suffix(".bin"))
806            })
807            .and_then(|s| s.parse::<u64>().ok());
808        if sequence.is_some_and(|s| s != active) {
809            std::fs::remove_file(entry.path())?;
810        }
811    }
812    sync_directory(dir)?;
813    Ok(())
814}
815
816/// Writes the rows file, then builds the graph from the same buffer (`rows` are already
817/// prepared, so the build's in-place preparation leaves them unchanged).
818fn write_base(
819    dir: &Path,
820    manifest: &Manifest,
821    ids: &[u32],
822    rows: Vec<f32>,
823) -> Result<(), UpdateError> {
824    BaseRows::write(&manifest.rows_path(dir), manifest, ids, &rows)?;
825    if !ids.is_empty() {
826        let graph = Graph::build_owned(rows, manifest.dim, &manifest.build)?;
827        let e = &manifest.encoding;
828        let mut vertex = VGraph::from_graph_with(
829            &graph,
830            e.bits,
831            e.residual_bits,
832            e.align,
833            e.reorder,
834            e.id_bytes,
835            e.screen,
836        )?;
837        if e.block_bits != 0 {
838            vertex = vertex.with_blocks(&graph, e.block_bits)?;
839        }
840        vertex.save(&manifest.vertex_path(dir))?;
841    }
842    sync_directory(dir)?;
843    Ok(())
844}
845
846#[cfg(test)]
847mod tests;