Skip to main content

multivector/
engine.rs

1#[path = "retrieval.rs"]
2mod retrieval;
3use crate::{
4    analyzer::{Analyzer, TextAnalyzer},
5    fde::{Vector, dot, maxsim_flat, normalize},
6    muvera::FdeEncoder,
7    storage::{
8        CompressedVectorStore, FixedVectorStore, ObjectLocation, atomic_write, commit_boundary,
9        record_bytes, verify_record,
10    },
11};
12use annex::{
13    utils::types::DistanceMetric,
14    vector::hnsw::{HNSWIndex, SearchRuntimeOptions},
15};
16use rayon::prelude::*;
17pub use retrieval::{
18    AdaptiveRerank, Channel, Chunk, ContextHit, ContextOptions, Fusion, Predicate, Representation,
19    Rerank, RetrievalDocument, RetrievalResponse, RetrievalTrace, RetrieveRequest,
20};
21use retrieval::{FieldSchema, Fields, RetrievalState};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::sync::Arc;
25use std::{
26    collections::{BTreeMap, HashMap, HashSet},
27    fs::{self, File, OpenOptions},
28    io,
29    path::{Path, PathBuf},
30    sync::{Mutex, RwLock},
31};
32use thiserror::Error;
33
34#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
35#[serde(deny_unknown_fields)]
36pub struct IndexConfig {
37    pub dimension: usize,
38    #[serde(default = "default_centroids")]
39    pub centroids: usize,
40    #[serde(default = "default_bits")]
41    pub residual_bits: u8,
42    #[serde(default = "default_probes")]
43    pub probes: usize,
44    #[serde(default = "default_fde_repetitions")]
45    pub fde_repetitions: usize,
46    #[serde(default = "default_fde_ksim")]
47    pub fde_ksim: usize,
48    #[serde(default = "default_fde_projected")]
49    pub fde_projected: usize,
50    /// Text analysis policy for the lexical field. Persisted with the
51    /// collection; reopening with a different policy is a configuration error.
52    #[serde(default)]
53    pub analyzer: TextAnalyzer,
54}
55fn default_centroids() -> usize {
56    64
57}
58fn default_bits() -> u8 {
59    2
60}
61fn default_probes() -> usize {
62    4
63}
64fn default_fde_repetitions() -> usize {
65    20
66}
67fn default_fde_ksim() -> usize {
68    4
69}
70fn default_fde_projected() -> usize {
71    8
72}
73impl IndexConfig {
74    pub fn new(dimension: usize) -> Self {
75        Self {
76            dimension,
77            centroids: 64,
78            residual_bits: 2,
79            probes: 4,
80            fde_repetitions: 20,
81            fde_ksim: 4,
82            fde_projected: 8,
83            analyzer: TextAnalyzer::plain(),
84        }
85    }
86}
87
88#[derive(Clone, Debug, Deserialize, Serialize)]
89struct DocumentRecord {
90    centroid_ids: Vec<u32>,
91    unique_centroids: Vec<u32>,
92    location: ObjectLocation,
93    fde_location: ObjectLocation,
94    metadata: Value,
95    tokens: usize,
96    compressed_bytes: u64,
97    #[serde(default)]
98    fields: Arc<Fields>,
99    #[serde(default)]
100    storage_id: u64,
101}
102/// Persisted manifest header. `format_version` lets us evolve the on-disk
103/// layout later without silently accepting mismatched files. `generation`
104/// is bumped on every mutation and lets any derived structure (HNSW-over-
105/// FDE in particular) prove it was built against the current document set.
106///
107/// Format changes: bump FORMAT_VERSION and add a From<oldManifest> path.
108const FORMAT_VERSION: u32 = 3;
109
110/// Fsync is the default: acknowledge only after segment data, the manifest,
111/// and its directory entry are synced. Buffered retains atomic visibility,
112/// but does not promise power-loss durability. There is no background flusher.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum Durability {
115    #[default]
116    Fsync,
117    Buffered,
118}
119
120#[derive(Clone, Copy, Deserialize, Serialize)]
121struct SegmentBoundaries {
122    objects: u64,
123    fde: u64,
124}
125
126/// Digest and exact manifest bytes are replaced in ONE rename, eliminating
127/// the torn sidecar/manifest pair in format 1. The digest is BLAKE3, not SHA256.
128#[derive(Deserialize, Serialize)]
129struct ManifestEnvelope {
130    format_version: u32,
131    checksum_blake3: String,
132    manifest: String,
133}
134
135#[derive(Deserialize, Serialize)]
136struct Manifest {
137    #[serde(default = "current_format_version")]
138    format_version: u32,
139    #[serde(default)]
140    generation: u64,
141    #[serde(default = "legacy_fde_encoding_version")]
142    fde_encoding_version: u32,
143    config: IndexConfig,
144    codebook: Vec<Vector>,
145    residual_codebook: Vec<f32>,
146    documents: HashMap<String, DocumentRecord>,
147    /// Immutable kind/dimension contract for every named representation ever
148    /// committed in this collection. It outlives the last document using a field.
149    #[serde(default)]
150    representation_schema: BTreeMap<String, FieldSchema>,
151    #[serde(default)]
152    segments: Option<SegmentBoundaries>,
153    #[serde(default)]
154    storage_generation: Option<u64>,
155    #[serde(default)]
156    sealed: Vec<(u64, SegmentBoundaries)>,
157}
158
159fn legacy_fde_encoding_version() -> u32 {
160    1
161}
162
163fn current_format_version() -> u32 {
164    1 // legacy manifests omitted this field
165}
166
167/// Verifies the legacy BLAKE3 sidecar (historically misnamed sha256).
168/// The sidecar is optional (older indexes were written without one), so we
169/// only enforce when the file exists. New commits use a self-contained envelope.
170fn verify_manifest_checksum(manifest_bytes: &[u8], sidecar_path: &Path) -> Result<(), IndexError> {
171    if !sidecar_path.exists() {
172        return Ok(());
173    }
174    let expected = fs::read_to_string(sidecar_path)?;
175    let expected = expected.trim();
176    let actual = blake3::hash(manifest_bytes);
177    if actual.to_hex().as_str() != expected {
178        return Err(IndexError::Invalid(format!(
179            "manifest.sha256 does not match manifest.json — index may be corrupt or torn: expected={expected}, actual={}",
180            actual.to_hex(),
181        )));
182    }
183    Ok(())
184}
185#[derive(Clone, Debug, Serialize, PartialEq)]
186pub struct Hit {
187    pub id: String,
188    pub score: f32,
189    /// FDE score this hit had before the compressed-MaxSim rescore. Exposed
190    /// so callers can compute per-query FDE-vs-MaxSim rank disagreement —
191    /// a signal for the confidence-output / adaptive-escalation primitive.
192    /// Skipped from JSON for centroid-only probing, which has no FDE stage.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub fde_score: Option<f32>,
195    pub metadata: Value,
196}
197#[derive(Clone, Debug, Serialize, PartialEq)]
198pub struct CandidateHit {
199    pub id: String,
200    pub score: f32,
201}
202#[derive(Clone, Debug)]
203pub struct UpsertDocument {
204    pub id: String,
205    pub vectors: Vec<Vector>,
206    pub metadata: Value,
207}
208#[derive(Clone, Debug, Serialize, PartialEq)]
209pub struct IndexStats {
210    pub documents: usize,
211    pub generation: u64,
212    pub token_vectors: usize,
213    pub compressed_bytes: u64,
214    pub centroids: usize,
215    pub residual_bits: u8,
216    pub trained: bool,
217    pub fde_dimension: usize,
218    pub fde_ann_nodes: usize,
219    pub fde_ann_base_nodes: usize,
220    pub fde_ann_delta_documents: usize,
221    pub fde_ann_tombstones: usize,
222    pub fde_encoding_version: u32,
223    pub storage_segments: usize,
224    pub dense_ann_fields: HashMap<String, usize>,
225}
226#[derive(Debug, Error)]
227pub enum IndexError {
228    #[error("{0}")]
229    Invalid(String),
230    #[error("index configuration is {actual:?}, requested {requested:?}")]
231    Config {
232        actual: Box<IndexConfig>,
233        requested: Box<IndexConfig>,
234    },
235    #[error(transparent)]
236    Io(#[from] io::Error),
237    #[error(transparent)]
238    Json(#[from] serde_json::Error),
239    #[error(
240        "generation published, but durable commit is uncertain; reopen or retry idempotently: {0}"
241    )]
242    CommitUncertain(io::Error),
243}
244/// Immutable graph and dense external-ID mapping, shared by staged generations.
245struct FdeAnnBase {
246    index: HNSWIndex,
247    ids: Vec<String>,
248    by_id: HashMap<String, u64>,
249    field: Option<String>,
250}
251
252#[derive(Clone)]
253struct FdeAnn {
254    base: Arc<FdeAnnBase>,
255    // Updated IDs are scanned exactly from the append-only FDE store; a
256    // rebuild folds them into a new graph without duplicating vectors in RAM.
257    delta: HashSet<String>,
258    tombstones: HashSet<u64>,
259    generation: u64,
260}
261
262#[derive(Clone)]
263struct State {
264    generation: u64,
265    codebook: Vec<Vector>,
266    residual_codebook: Vec<f32>,
267    documents: HashMap<String, DocumentRecord>,
268    postings: Vec<HashSet<String>>,
269    fde_ann: Option<FdeAnn>,
270    named_ann: HashMap<String, FdeAnn>,
271    stores: Arc<SegmentStores>,
272    retrieval: Arc<RetrievalState>,
273    objects_map: Option<Arc<memmap2::Mmap>>,
274    fde_map: Option<Arc<memmap2::Mmap>>,
275    sealed: HashMap<u64, Arc<SegmentSnapshot>>,
276}
277struct SegmentSnapshot {
278    stores: Arc<SegmentStores>,
279    objects: Option<Arc<memmap2::Mmap>>,
280    fde: Option<Arc<memmap2::Mmap>>,
281    bounds: SegmentBoundaries,
282}
283impl SegmentSnapshot {
284    fn open(root: &Path, id: u64, bounds: SegmentBoundaries) -> Result<Self, IndexError> {
285        let path = if id == 0 {
286            root.to_owned()
287        } else {
288            root.join("segments").join(id.to_string())
289        };
290        if !path.is_dir() {
291            return Err(IndexError::Invalid("missing sealed segment".into()));
292        }
293        let stores = Arc::new(SegmentStores {
294            objects: CompressedVectorStore::new(path.join("objects"))?,
295            fde: FixedVectorStore::new(path.join("fde"))?,
296            root: path,
297            id: if id == 0 { None } else { Some(id) },
298            retired: std::sync::atomic::AtomicBool::new(false),
299        });
300        if stores.objects.len()? < bounds.objects || stores.fde.len()? < bounds.fde {
301            return Err(IndexError::Invalid(
302                "sealed segment shorter than committed boundary".into(),
303            ));
304        }
305        Ok(Self {
306            objects: if bounds.objects > 0 {
307                Some(Arc::new(stores.objects.map()?))
308            } else {
309                None
310            },
311            fde: if bounds.fde > 0 {
312                Some(Arc::new(stores.fde.map()?))
313            } else {
314                None
315            },
316            stores,
317            bounds,
318        })
319    }
320}
321impl State {
322    fn record_fde(&self, record: &DocumentRecord) -> &[u8] {
323        if record.storage_id == self.stores.id.unwrap_or(0) {
324            self.fde_bytes()
325        } else {
326            self.sealed[&record.storage_id]
327                .fde
328                .as_deref()
329                .map(|m| &m[..self.sealed[&record.storage_id].bounds.fde as usize])
330                .unwrap_or(&[])
331        }
332    }
333    fn record_objects(&self, record: &DocumentRecord) -> &[u8] {
334        if record.storage_id == self.stores.id.unwrap_or(0) {
335            self.object_bytes()
336        } else {
337            self.sealed[&record.storage_id]
338                .objects
339                .as_deref()
340                .map(|m| &m[..self.sealed[&record.storage_id].bounds.objects as usize])
341                .unwrap_or(&[])
342        }
343    }
344    fn object_bytes(&self) -> &[u8] {
345        self.objects_map.as_deref().map(|m| &m[..]).unwrap_or(&[])
346    }
347    fn fde_bytes(&self) -> &[u8] {
348        self.fde_map.as_deref().map(|m| &m[..]).unwrap_or(&[])
349    }
350}
351
352struct SegmentStores {
353    objects: CompressedVectorStore,
354    fde: FixedVectorStore,
355    root: PathBuf,
356    id: Option<u64>,
357    retired: std::sync::atomic::AtomicBool,
358}
359
360impl Drop for SegmentStores {
361    fn drop(&mut self) {
362        if self.retired.load(std::sync::atomic::Ordering::Relaxed) {
363            if self.id.is_some() {
364                let _ = fs::remove_dir_all(&self.root);
365            } else {
366                let _ = fs::remove_dir_all(self.root.join("objects"));
367                let _ = fs::remove_dir_all(self.root.join("fde"));
368            }
369        }
370    }
371}
372
373struct DirectoryLock(File);
374
375impl Drop for DirectoryLock {
376    fn drop(&mut self) {
377        // Closing one descriptor does not release flock while a fork/dup copy
378        // survives. Release logical ownership explicitly before closing ours.
379        let _ = fs2::FileExt::unlock(&self.0);
380    }
381}
382
383pub struct MultiVectorIndex {
384    root: PathBuf,
385    config: IndexConfig,
386    fde: FdeEncoder,
387    state: RwLock<Arc<State>>,
388    writer: Mutex<()>,
389    durability: Durability,
390    // One process/handle owns append offsets and manifest publication at a time.
391    _directory_lock: DirectoryLock,
392}
393
394impl MultiVectorIndex {
395    pub(crate) fn initialize(&self) -> Result<(), IndexError> {
396        let _writer = self.writer.lock().unwrap();
397        self.persist(&self.snapshot())
398    }
399    pub fn open_existing(
400        path: impl AsRef<Path>,
401        durability: Durability,
402    ) -> Result<Self, IndexError> {
403        let bytes = fs::read(path.as_ref().join("manifest.json"))?;
404        let value: Value = serde_json::from_slice(&bytes)?;
405        let manifest: Manifest = if value.get("manifest").is_some() {
406            let envelope: ManifestEnvelope = serde_json::from_value(value)?;
407            if blake3::hash(envelope.manifest.as_bytes()).to_hex().as_str()
408                != envelope.checksum_blake3
409            {
410                return Err(IndexError::Invalid("manifest checksum mismatch".into()));
411            }
412            serde_json::from_str(&envelope.manifest)?
413        } else {
414            serde_json::from_slice(&bytes)?
415        };
416        Self::open_with_durability(path, manifest.config, durability)
417    }
418    pub fn open(path: impl AsRef<Path>, config: IndexConfig) -> Result<Self, IndexError> {
419        Self::open_with_durability(path, config, Durability::Fsync)
420    }
421
422    pub fn open_with_durability(
423        path: impl AsRef<Path>,
424        config: IndexConfig,
425        durability: Durability,
426    ) -> Result<Self, IndexError> {
427        if config.dimension == 0
428            || config.centroids == 0
429            || config.probes == 0
430            || !(1..=8).contains(&config.residual_bits)
431            || config.fde_repetitions == 0
432            || config.fde_ksim == 0
433            || config.fde_ksim > 12
434            || config.fde_projected == 0
435        {
436            return Err(IndexError::Invalid(
437                "dimension, centroids, probes, and residual_bits (1..=8) must be valid".into(),
438            ));
439        }
440        validate_config_size(&config)?;
441        config.analyzer.validate().map_err(IndexError::Invalid)?;
442        let root = path.as_ref().to_owned();
443        let mut created_parents = Vec::new();
444        let mut missing = root.as_path();
445        while !missing.exists() {
446            let parent = missing
447                .parent()
448                .filter(|p| !p.as_os_str().is_empty())
449                .unwrap_or(Path::new("."));
450            created_parents.push(parent.to_owned());
451            missing = parent;
452        }
453        fs::create_dir_all(&root)?;
454        let directory_lock = OpenOptions::new()
455            .create(true)
456            .truncate(false)
457            .read(true)
458            .write(true)
459            .open(root.join("index.lock"))?;
460        fs2::FileExt::try_lock_exclusive(&directory_lock).map_err(|e| {
461            IndexError::Invalid(format!("index already open or cannot lock directory: {e}"))
462        })?;
463        let directory_lock = DirectoryLock(directory_lock);
464        let manifest_path = root.join("manifest.json");
465        let (
466            generation,
467            fde_encoding_version,
468            codebook,
469            residual_codebook,
470            mut documents,
471            representation_schema,
472            segments,
473            storage_generation,
474            sealed_bounds,
475            checksummed,
476        ) = if manifest_path.exists() {
477            let bytes = fs::read(&manifest_path)?;
478            let header: Value = serde_json::from_slice(&bytes)?;
479            let (m, checksummed): (Manifest, bool) = if header.get("manifest").is_some() {
480                let envelope: ManifestEnvelope = serde_json::from_slice(&bytes)?;
481                if !matches!(envelope.format_version, 2 | FORMAT_VERSION)
482                    || blake3::hash(envelope.manifest.as_bytes()).to_hex().as_str()
483                        != envelope.checksum_blake3
484                {
485                    return Err(IndexError::Invalid(
486                        "manifest checksum or envelope version mismatch".into(),
487                    ));
488                }
489                (serde_json::from_str(&envelope.manifest)?, true)
490            } else {
491                verify_manifest_checksum(&bytes, &root.join("manifest.sha256"))?;
492                (serde_json::from_slice(&bytes)?, false)
493            };
494            if !(if checksummed {
495                matches!(m.format_version, 2 | FORMAT_VERSION)
496            } else {
497                m.format_version == 1
498            }) || (checksummed && m.segments.is_none())
499            {
500                return Err(IndexError::Invalid(
501                    "unsupported manifest version or missing committed boundaries".into(),
502                ));
503            }
504            if m.config != config {
505                return Err(IndexError::Config {
506                    actual: Box::new(m.config),
507                    requested: Box::new(config),
508                });
509            }
510            (
511                m.generation,
512                m.fde_encoding_version,
513                m.codebook,
514                m.residual_codebook,
515                m.documents,
516                m.representation_schema,
517                m.segments,
518                m.storage_generation,
519                m.sealed,
520                checksummed,
521            )
522        } else {
523            // Without a committed manifest all segment bytes are uncommitted.
524            (
525                0,
526                2,
527                vec![],
528                vec![],
529                HashMap::new(),
530                BTreeMap::new(),
531                Some(SegmentBoundaries { objects: 0, fde: 0 }),
532                None,
533                Vec::new(),
534                false,
535            )
536        };
537        if !matches!(fde_encoding_version, 1 | 2) {
538            return Err(IndexError::Invalid(format!(
539                "unsupported FDE encoding version {fde_encoding_version}"
540            )));
541        }
542        validate_codebooks(
543            &config,
544            &codebook,
545            &residual_codebook,
546            documents.values().any(|d| d.tokens > 0),
547        )?;
548        let store_root = storage_generation
549            .map(|id| root.join("segments").join(id.to_string()))
550            .unwrap_or_else(|| root.clone());
551        if storage_generation.is_some() && !store_root.is_dir() {
552            return Err(IndexError::Invalid(
553                "missing committed segment directory".into(),
554            ));
555        }
556        let objects = CompressedVectorStore::new(store_root.join("objects"))?;
557        let fde_store = FixedVectorStore::new(store_root.join("fde"))?;
558        if !manifest_path.exists()
559            && (objects.len()? != 0
560                || fde_store.len()? != 0
561                || root
562                    .join("segments")
563                    .read_dir()
564                    .is_ok_and(|mut entries| entries.next().is_some()))
565        {
566            return Err(IndexError::Invalid(
567                "missing manifest for non-empty segments".into(),
568            ));
569        }
570        let boundaries = segments.unwrap_or(SegmentBoundaries {
571            objects: objects.len()?,
572            fde: fde_store.len()?,
573        });
574        if objects.len()? < boundaries.objects || fde_store.len()? < boundaries.fde {
575            return Err(IndexError::Invalid(
576                "segment shorter than committed boundary".into(),
577            ));
578        }
579        let mut sealed = HashMap::new();
580        for (id, bounds) in sealed_bounds {
581            if id == storage_generation.unwrap_or(0) || sealed.contains_key(&id) {
582                return Err(IndexError::Invalid("duplicate storage segment".into()));
583            }
584            sealed.insert(id, Arc::new(SegmentSnapshot::open(&root, id, bounds)?));
585        }
586        if !documents.is_empty() {
587            let objects_map = if objects.len()? > 0 {
588                Some(objects.map()?)
589            } else {
590                None
591            };
592            let fde_map = if fde_store.len()? > 0 {
593                Some(fde_store.map()?)
594            } else {
595                None
596            };
597            let object_bytes = objects_map
598                .as_deref()
599                .unwrap_or(&[])
600                .get(
601                    ..usize::try_from(boundaries.objects)
602                        .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?,
603                )
604                .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?;
605            let fde_bytes = fde_map
606                .as_deref()
607                .unwrap_or(&[])
608                .get(
609                    ..usize::try_from(boundaries.fde)
610                        .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?,
611                )
612                .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?;
613            let fde_dimension =
614                (1usize << config.fde_ksim) * config.fde_projected * config.fde_repetitions;
615            for d in documents.values_mut() {
616                let (object_bytes, fde_bytes) = if d.storage_id == storage_generation.unwrap_or(0) {
617                    (object_bytes, fde_bytes)
618                } else {
619                    let segment = sealed.get(&d.storage_id).ok_or_else(|| {
620                        IndexError::Invalid("document references missing segment".into())
621                    })?;
622                    (
623                        segment
624                            .objects
625                            .as_deref()
626                            .map(|m| &m[..segment.bounds.objects as usize])
627                            .unwrap_or(&[]),
628                        segment
629                            .fde
630                            .as_deref()
631                            .map(|m| &m[..segment.bounds.fde as usize])
632                            .unwrap_or(&[]),
633                    )
634                };
635                d.fields.verify(fde_bytes)?;
636                if d.tokens == 0 {
637                    if !d.centroid_ids.is_empty()
638                        || !d.unique_centroids.is_empty()
639                        || d.location.length != 0
640                        || d.fde_location.length != 0
641                    {
642                        return Err(IndexError::Invalid("invalid empty multivector".into()));
643                    }
644                    continue;
645                }
646                verify_record(object_bytes, d.location, checksummed)?;
647                verify_record(fde_bytes, d.fde_location, checksummed)?;
648                let decoded = CompressedVectorStore::decode(
649                    object_bytes,
650                    d.location,
651                    &codebook,
652                    &residual_codebook,
653                )?;
654                if decoded.dimension != config.dimension
655                    || d.tokens == 0
656                    || decoded.values.len() / config.dimension != d.tokens
657                    || d.centroid_ids.len() != d.tokens
658                    || d.compressed_bytes != d.location.length
659                    || d.centroid_ids.iter().any(|&c| c as usize >= codebook.len())
660                    || decoded.values.iter().any(|v| !v.is_finite())
661                {
662                    return Err(IndexError::Invalid(
663                        "invalid document shape or centroid IDs".into(),
664                    ));
665                }
666                let bytes = record_bytes(object_bytes, d.location)?;
667                let stored_ids = bytes[16..16 + d.tokens * 4]
668                    .chunks_exact(4)
669                    .map(|v| u32::from_le_bytes(v.try_into().unwrap()));
670                if !stored_ids.eq(d.centroid_ids.iter().copied()) {
671                    return Err(IndexError::Invalid(
672                        "manifest/record centroid mismatch".into(),
673                    ));
674                }
675                let mut unique = d.centroid_ids.clone();
676                unique.sort_unstable();
677                unique.dedup();
678                if unique != d.unique_centroids {
679                    return Err(IndexError::Invalid("invalid document posting list".into()));
680                }
681                if FixedVectorStore::get(fde_bytes, d.fde_location, fde_dimension)?
682                    .iter()
683                    .any(|v| !v.is_finite())
684                {
685                    return Err(IndexError::Invalid("non-finite FDE record".into()));
686                }
687                // Upgrade legacy records in memory; the next commit writes their digests.
688                d.location.checksum =
689                    Some(*blake3::hash(record_bytes(object_bytes, d.location)?).as_bytes());
690                d.fde_location.checksum =
691                    Some(*blake3::hash(record_bytes(fde_bytes, d.fde_location)?).as_bytes());
692            }
693        }
694        // No mappings survive this point: discard only the uncommitted tail.
695        for segment in sealed.values() {
696            segment.stores.objects.recover(segment.bounds.objects)?;
697            segment.stores.fde.recover(segment.bounds.fde)?;
698        }
699        objects.recover(boundaries.objects)?;
700        fde_store.recover(boundaries.fde)?;
701        if durability == Durability::Fsync {
702            File::open(store_root.join("objects"))?.sync_all()?;
703            File::open(store_root.join("fde"))?.sync_all()?;
704            File::open(&root)?.sync_all()?;
705            for parent in created_parents {
706                File::open(parent)?.sync_all()?;
707            }
708        }
709        if manifest_path.exists() {
710            if let Ok(entries) = fs::read_dir(root.join("segments")) {
711                for entry in entries {
712                    let entry = entry?;
713                    if !entry.file_type()?.is_dir() {
714                        continue;
715                    }
716                    if let Some(id) = entry
717                        .file_name()
718                        .to_str()
719                        .and_then(|s| s.parse::<u64>().ok())
720                    {
721                        if Some(id) != storage_generation && !sealed.contains_key(&id) {
722                            fs::remove_dir_all(entry.path())?;
723                        }
724                    }
725                }
726            }
727            if storage_generation.is_some() && !sealed.contains_key(&0) {
728                for name in ["objects", "fde"] {
729                    let path = root.join(name);
730                    if path.exists() {
731                        fs::remove_dir_all(path)?;
732                    }
733                }
734            }
735        }
736        let mut postings = vec![HashSet::new(); codebook.len()];
737        for (id, d) in &documents {
738            for &c in &d.centroid_ids {
739                postings[c as usize].insert(id.clone());
740            }
741        }
742        // New manifests preserve the collection's immutable named-field
743        // contract even when no live document still uses a field. Legacy
744        // manifests default to an empty map and infer it while replaying docs;
745        // the next mutation persists the inferred schema.
746        let mut retrieval = RetrievalState::from_schema(
747            representation_schema,
748            Analyzer::new(config.analyzer.clone()),
749        )?;
750        let mut ordered: Vec<_> = documents.iter().collect();
751        ordered.sort_by(|a, b| a.0.cmp(b.0));
752        for (id, d) in ordered {
753            retrieval.insert(id, &d.fields)?;
754        }
755        Ok(Self {
756            fde: if fde_encoding_version == 2 {
757                FdeEncoder::new(
758                    config.dimension,
759                    config.fde_ksim,
760                    config.fde_projected,
761                    config.fde_repetitions,
762                    0x4d55_5645_5241,
763                )
764            } else {
765                FdeEncoder::with_version(
766                    config.dimension,
767                    config.fde_ksim,
768                    config.fde_projected,
769                    config.fde_repetitions,
770                    0x4d55_5645_5241,
771                    fde_encoding_version,
772                )
773            },
774            writer: Mutex::new(()),
775            durability,
776            _directory_lock: directory_lock,
777            state: RwLock::new(Arc::new(State {
778                generation,
779                codebook,
780                residual_codebook,
781                documents,
782                postings,
783                fde_ann: None,
784                named_ann: HashMap::new(),
785                objects_map: if objects.len()? > 0 {
786                    Some(Arc::new(objects.map()?))
787                } else {
788                    None
789                },
790                fde_map: if fde_store.len()? > 0 {
791                    Some(Arc::new(fde_store.map()?))
792                } else {
793                    None
794                },
795                stores: Arc::new(SegmentStores {
796                    objects,
797                    fde: fde_store,
798                    root: store_root,
799                    id: storage_generation,
800                    retired: std::sync::atomic::AtomicBool::new(false),
801                }),
802                retrieval: Arc::new(retrieval),
803                sealed,
804            })),
805            root,
806            config,
807        })
808    }
809    fn snapshot(&self) -> Arc<State> {
810        Arc::clone(&self.state.read().unwrap())
811    }
812
813    fn validate(&self, v: &[Vector]) -> Result<(), IndexError> {
814        if v.is_empty()
815            || v.iter()
816                .any(|x| x.len() != self.config.dimension || x.iter().any(|n| !n.is_finite()))
817        {
818            Err(IndexError::Invalid(format!(
819                "vectors must be a non-empty matrix of {} finite values",
820                self.config.dimension
821            )))
822        } else {
823            Ok(())
824        }
825    }
826    fn persist(&self, s: &State) -> Result<(), IndexError> {
827        if self.durability == Durability::Fsync {
828            s.stores.objects.sync()?;
829            commit_boundary("objects_synced")?;
830            s.stores.fde.sync()?;
831            commit_boundary("fde_synced")?;
832        }
833        let manifest = serde_json::to_string(&Manifest {
834            format_version: FORMAT_VERSION,
835            generation: s.generation,
836            fde_encoding_version: self.fde.encoding_version(),
837            config: self.config.clone(),
838            codebook: s.codebook.clone(),
839            residual_codebook: s.residual_codebook.clone(),
840            documents: s.documents.clone(),
841            representation_schema: s.retrieval.schema().clone(),
842            storage_generation: s.stores.id,
843            sealed: s
844                .sealed
845                .iter()
846                .map(|(&id, segment)| (id, segment.bounds))
847                .collect(),
848            segments: Some(SegmentBoundaries {
849                objects: s.stores.objects.len()?,
850                fde: s.stores.fde.len()?,
851            }),
852        })?;
853        let envelope = ManifestEnvelope {
854            format_version: FORMAT_VERSION,
855            checksum_blake3: blake3::hash(manifest.as_bytes()).to_hex().to_string(),
856            manifest,
857        };
858        let bytes = serde_json::to_vec(&envelope)?;
859        atomic_write(
860            &self.root.join("manifest.json"),
861            &bytes,
862            self.durability == Durability::Fsync,
863        )
864        .map_err(|e| {
865            if e.published {
866                IndexError::CommitUncertain(e.source)
867            } else {
868                IndexError::Io(e.source)
869            }
870        })
871    }
872
873    fn commit(&self, current: &State, mut next: State) -> Result<(), IndexError> {
874        next.generation = current
875            .generation
876            .checked_add(1)
877            .ok_or_else(|| IndexError::Invalid("generation exhausted".into()))?;
878        // Every derived change is staged before persistence, then published
879        // together with its documents, including a post-rename uncertain commit.
880        if let Some(ann) = next.fde_ann.as_mut() {
881            ann.generation = next.generation;
882        }
883        for ann in next.named_ann.values_mut() {
884            ann.generation = next.generation;
885        }
886        if next.stores.objects.len()? as usize != next.object_bytes().len() {
887            next.objects_map = Some(Arc::new(next.stores.objects.map()?));
888        }
889        if next.stores.fde.len()? as usize != next.fde_bytes().len() {
890            next.fde_map = Some(Arc::new(next.stores.fde.map()?));
891        }
892        let result = self.persist(&next);
893        if result.is_ok() || matches!(result, Err(IndexError::CommitUncertain(_))) {
894            next.stores
895                .retired
896                .store(false, std::sync::atomic::Ordering::Relaxed);
897            *self.state.write().unwrap() = Arc::new(next);
898        }
899        result
900    }
901    fn invalidate_fde_ann(next: &mut State) {
902        next.fde_ann = None;
903    }
904    /// Train PLAID's coarse k-means codebook. Must happen before ingestion.
905    pub fn train(&self, samples: &[Vector], iterations: usize) -> Result<(), IndexError> {
906        self.validate(samples)?;
907        if samples.len() < self.config.centroids {
908            return Err(IndexError::Invalid(
909                "training samples must be >= centroid count".into(),
910            ));
911        }
912        let _writer = self.writer.lock().unwrap();
913        let s = self.snapshot();
914        if s.documents.values().any(|d| d.tokens > 0) {
915            return Err(IndexError::Invalid(
916                "cannot retrain an index containing token vectors".into(),
917            ));
918        }
919        let samples: Vec<_> = samples.iter().map(|sample| normalize(sample)).collect();
920        let mut centers: Vec<_> = (0..self.config.centroids)
921            .map(|i| samples[i * samples.len() / self.config.centroids].clone())
922            .collect();
923        for _ in 0..iterations.max(1) {
924            let mut sums = vec![vec![0.; self.config.dimension]; centers.len()];
925            let mut counts = vec![0usize; centers.len()];
926            for v in &samples {
927                let c = nearest(v, &centers);
928                counts[c] += 1;
929                for (i, x) in v.iter().enumerate() {
930                    sums[c][i] += x;
931                }
932            }
933            for c in 0..centers.len() {
934                if counts[c] > 0 {
935                    for x in &mut sums[c] {
936                        *x /= counts[c] as f32;
937                    }
938                    centers[c] = normalize(&sums[c]);
939                }
940            }
941        }
942        let mut next = (*s).clone();
943        next.codebook = centers;
944        let residuals: Vec<f32> = samples
945            .iter()
946            .flat_map(|vector| {
947                let center = &next.codebook[nearest(vector, &next.codebook)];
948                vector
949                    .iter()
950                    .zip(center)
951                    .map(|(value, centroid)| value - centroid)
952                    .collect::<Vec<_>>()
953            })
954            .collect();
955        next.residual_codebook =
956            train_scalar_codebook(&residuals, 1usize << self.config.residual_bits, 12);
957        next.postings = vec![HashSet::new(); next.codebook.len()];
958        // Retraining an empty collection resets its derived graph/overlay.
959        Self::invalidate_fde_ann(&mut next);
960        self.commit(&s, next)
961    }
962    pub fn upsert(
963        &self,
964        id: impl Into<String>,
965        vectors: Vec<Vector>,
966        metadata: Value,
967    ) -> Result<(), IndexError> {
968        self.upsert_batch(vec![UpsertDocument {
969            id: id.into(),
970            vectors,
971            metadata,
972        }])
973    }
974    /// Atomically replace the whole batch. Duplicate IDs use the last value.
975    /// Empty batches are no-ops. On a pre-publication error no document,
976    /// posting, generation, or existing ANN changes; unreachable bytes may
977    /// remain in the append-only segments until recovery/compaction.
978    pub fn upsert_batch(&self, batch: Vec<UpsertDocument>) -> Result<(), IndexError> {
979        self.upsert_records(
980            batch
981                .into_iter()
982                .map(|d| RetrievalDocument {
983                    id: d.id,
984                    vectors: d.vectors,
985                    metadata: d.metadata,
986                    ..RetrievalDocument::default()
987                })
988                .collect(),
989        )
990    }
991    pub fn upsert_records(&self, batch: Vec<RetrievalDocument>) -> Result<(), IndexError> {
992        if batch.is_empty() {
993            return Ok(());
994        }
995        for document in &batch {
996            if !document.vectors.is_empty() {
997                self.validate(&document.vectors)?;
998            }
999        }
1000        let _writer = self.writer.lock().unwrap();
1001        let s = self.snapshot();
1002        if s.codebook.is_empty() && batch.iter().any(|d| !d.vectors.is_empty()) {
1003            return Err(IndexError::Invalid(
1004                "index is untrained; call train first".into(),
1005            ));
1006        }
1007        let mut next = (*s).clone();
1008        if next.stores.objects.len()? + next.stores.fde.len()? >= 64 * 1024 * 1024 {
1009            self.rotate_segment(&mut next)?;
1010        }
1011        for document in batch {
1012            let fields = Arc::new(Fields::prepare(&document, &next.stores)?);
1013            let id = document.id;
1014            Arc::make_mut(&mut next.retrieval).insert(&id, &fields)?;
1015            if let Some(old_ids) = next
1016                .documents
1017                .get(&id)
1018                .map(|old| old.unique_centroids.clone())
1019            {
1020                for c in old_ids {
1021                    next.postings[c as usize].remove(&id);
1022                }
1023            }
1024            let vectors: Vec<_> = document
1025                .vectors
1026                .iter()
1027                .map(|vector| normalize(vector))
1028                .collect();
1029            let ids: Vec<u32> = vectors
1030                .iter()
1031                .map(|v| nearest(v, &next.codebook) as u32)
1032                .collect();
1033            let mut unique_centroids = ids.clone();
1034            unique_centroids.sort_unstable();
1035            unique_centroids.dedup();
1036            let (location, size) = if vectors.is_empty() {
1037                (
1038                    ObjectLocation {
1039                        offset: 0,
1040                        length: 0,
1041                        checksum: None,
1042                    },
1043                    0,
1044                )
1045            } else {
1046                next.stores.objects.put(
1047                    &vectors,
1048                    &ids,
1049                    &next.codebook,
1050                    &next.residual_codebook,
1051                    self.config.residual_bits,
1052                )?
1053            };
1054            commit_boundary("object_appended")?;
1055            let fde_location = if vectors.is_empty() {
1056                ObjectLocation {
1057                    offset: 0,
1058                    length: 0,
1059                    checksum: None,
1060                }
1061            } else {
1062                next.stores.fde.put(&self.fde.encode_document(&vectors))?
1063            };
1064            commit_boundary("fde_appended")?;
1065            for &c in &unique_centroids {
1066                next.postings[c as usize].insert(id.clone());
1067            }
1068            if let Some(ann) = next.fde_ann.as_mut() {
1069                if let Some(&point_id) = ann.base.by_id.get(&id) {
1070                    ann.tombstones.insert(point_id);
1071                }
1072                if vectors.is_empty() {
1073                    ann.delta.remove(&id);
1074                } else {
1075                    ann.delta.insert(id.clone());
1076                }
1077            }
1078            for (field, ann) in &mut next.named_ann {
1079                if let Some(&point) = ann.base.by_id.get(&id) {
1080                    ann.tombstones.insert(point);
1081                }
1082                if fields.has_dense(field) {
1083                    ann.delta.insert(id.clone());
1084                } else {
1085                    ann.delta.remove(&id);
1086                }
1087            }
1088            next.documents.insert(
1089                id,
1090                DocumentRecord {
1091                    centroid_ids: ids,
1092                    unique_centroids,
1093                    location,
1094                    fde_location,
1095                    metadata: document.metadata,
1096                    tokens: vectors.len(),
1097                    compressed_bytes: size,
1098                    fields,
1099                    storage_id: next.stores.id.unwrap_or(0),
1100                },
1101            );
1102        }
1103        self.commit(&s, next)
1104    }
1105    pub fn delete(&self, id: &str) -> Result<bool, IndexError> {
1106        let _writer = self.writer.lock().unwrap();
1107        let s = self.snapshot();
1108        if !s.documents.contains_key(id) {
1109            return Ok(false);
1110        }
1111        let mut next = (*s).clone();
1112        let d = next.documents.remove(id).unwrap();
1113        Arc::make_mut(&mut next.retrieval).remove(id);
1114        for c in d.unique_centroids {
1115            next.postings[c as usize].remove(id);
1116        }
1117        if let Some(ann) = next.fde_ann.as_mut() {
1118            if let Some(&point_id) = ann.base.by_id.get(id) {
1119                ann.tombstones.insert(point_id);
1120            }
1121            ann.delta.remove(id);
1122        }
1123        for ann in next.named_ann.values_mut() {
1124            if let Some(&point) = ann.base.by_id.get(id) {
1125                ann.tombstones.insert(point);
1126            }
1127            ann.delta.remove(id);
1128        }
1129        self.commit(&s, next)?;
1130        Ok(true)
1131    }
1132    /// Exact MUVERA candidates followed by compressed MaxSim rescoring.
1133    pub fn query(
1134        &self,
1135        vectors: &[Vector],
1136        top_k: usize,
1137        candidates: Option<usize>,
1138    ) -> Result<Vec<Hit>, IndexError> {
1139        self.validate(vectors)?;
1140        if top_k == 0 {
1141            return Err(IndexError::Invalid("top_k must be positive".into()));
1142        }
1143        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1144        // Rescore only ever reads the top `count` — mirror the same cap here
1145        // so exact_fde_scores can partial-sort instead of fully sorting the
1146        // 10K+ pool it just scored.
1147        let cap = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1148        let s = self.snapshot();
1149        let approximate = self.exact_fde_scores_capped(&s, &normalized, Some(cap))?;
1150        self.rescore(&s, &normalized, approximate, top_k, candidates)
1151    }
1152    /// Whether the base plus mutable overlay covers the current generation.
1153    pub fn hnsw_ready(&self) -> bool {
1154        let s = self.snapshot();
1155        s.fde_ann
1156            .as_ref()
1157            .is_some_and(|ann| ann.generation == s.generation)
1158    }
1159
1160    /// Use a fresh FDE-HNSW base plus exact delta when available; otherwise
1161    /// use exact FDE. Selection and rescoring share one generation read guard.
1162    pub fn query_auto(
1163        &self,
1164        vectors: &[Vector],
1165        top_k: usize,
1166        candidates: Option<usize>,
1167        ef_search: usize,
1168    ) -> Result<Vec<Hit>, IndexError> {
1169        self.query_auto_with_backend(vectors, top_k, candidates, ef_search)
1170            .map(|(hits, _)| hits)
1171    }
1172
1173    /// Return the actual backend used under the same snapshot as the results.
1174    pub fn query_auto_with_backend(
1175        &self,
1176        vectors: &[Vector],
1177        top_k: usize,
1178        candidates: Option<usize>,
1179        ef_search: usize,
1180    ) -> Result<(Vec<Hit>, &'static str), IndexError> {
1181        self.validate(vectors)?;
1182        if top_k == 0 || ef_search == 0 {
1183            return Err(IndexError::Invalid(
1184                "top_k and ef_search must be positive".into(),
1185            ));
1186        }
1187        let normalized: Vec<_> = vectors.iter().map(|v| normalize(v)).collect();
1188        let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1189        let s = self.snapshot();
1190        let (approximate, backend) = if s
1191            .fde_ann
1192            .as_ref()
1193            .is_some_and(|ann| ann.generation == s.generation)
1194        {
1195            (
1196                self.ann_fde_scores(&s, &self.fde.encode_query(&normalized), count, ef_search)?,
1197                "hnsw",
1198            )
1199        } else {
1200            (
1201                self.exact_fde_scores_capped(&s, &normalized, Some(count))?,
1202                "muvera",
1203            )
1204        };
1205        Ok((
1206            self.rescore(&s, &normalized, approximate, top_k, candidates)?,
1207            backend,
1208        ))
1209    }
1210    /// Generate broad FDE candidates, prune with centroid-only MaxSim, then
1211    /// decode residuals only for the surviving documents.
1212    pub fn query_with_centroid_pruning(
1213        &self,
1214        vectors: &[Vector],
1215        top_k: usize,
1216        candidates: usize,
1217        rerank_candidates: usize,
1218    ) -> Result<Vec<Hit>, IndexError> {
1219        self.validate(vectors)?;
1220        check_pruning_shape(top_k, candidates, rerank_candidates)?;
1221        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1222        let s = self.snapshot();
1223        let approximate = self.exact_fde_scores_capped(&s, &normalized, Some(candidates))?;
1224        self.prune_and_rescore(
1225            &s,
1226            &normalized,
1227            approximate,
1228            top_k,
1229            candidates,
1230            rerank_candidates,
1231        )
1232    }
1233    /// Same pipeline as `query_with_centroid_pruning`, but pulls the broad
1234    /// candidate set from the FDE HNSW graph instead of the exact FDE scan.
1235    pub fn query_with_fde_ann_and_pruning(
1236        &self,
1237        vectors: &[Vector],
1238        top_k: usize,
1239        candidates: usize,
1240        rerank_candidates: usize,
1241        ef_search: usize,
1242    ) -> Result<Vec<Hit>, IndexError> {
1243        self.validate(vectors)?;
1244        if ef_search == 0 {
1245            return Err(IndexError::Invalid("ef_search must be positive".into()));
1246        }
1247        check_pruning_shape(top_k, candidates, rerank_candidates)?;
1248        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1249        let query_fde = self.fde.encode_query(&normalized);
1250        let s = self.snapshot();
1251        let approximate = self.ann_fde_scores(&s, &query_fde, candidates, ef_search)?;
1252        self.prune_and_rescore(
1253            &s,
1254            &normalized,
1255            approximate,
1256            top_k,
1257            candidates,
1258            rerank_candidates,
1259        )
1260    }
1261    fn prune_and_rescore(
1262        &self,
1263        s: &State,
1264        normalized: &[Vector],
1265        approximate: Vec<(String, f32)>,
1266        top_k: usize,
1267        candidates: usize,
1268        rerank_candidates: usize,
1269    ) -> Result<Vec<Hit>, IndexError> {
1270        let broad: Vec<_> = approximate.into_iter().take(candidates).collect();
1271        let pruned = centroid_prune(s, normalized, broad, rerank_candidates);
1272        self.rescore(s, normalized, pruned, top_k, Some(rerank_candidates))
1273    }
1274    fn exact_fde_scores(&self, normalized: &[Vector]) -> Result<Vec<(String, f32)>, IndexError> {
1275        let s = self.snapshot();
1276        self.exact_fde_scores_capped(&s, normalized, None)
1277    }
1278
1279    /// FDE exhaustive scan, optionally returning only the top `cap` results.
1280    /// When capped, partitions the top-cap with `select_nth_unstable_by`
1281    /// (O(n) expected) then fully sorts only the surviving prefix
1282    /// (O(cap log cap)), instead of paying O(n log n) to sort a 10K+
1283    /// candidate pool whose tail is discarded by rescore anyway.
1284    fn exact_fde_scores_capped(
1285        &self,
1286        s: &State,
1287        normalized: &[Vector],
1288        cap: Option<usize>,
1289    ) -> Result<Vec<(String, f32)>, IndexError> {
1290        self.exact_fde_scores_filtered(s, normalized, cap, None)
1291    }
1292    fn exact_fde_scores_filtered(
1293        &self,
1294        s: &State,
1295        normalized: &[Vector],
1296        cap: Option<usize>,
1297        eligible: Option<&HashSet<&str>>,
1298    ) -> Result<Vec<(String, f32)>, IndexError> {
1299        if !s.documents.values().any(|d| d.tokens > 0) {
1300            return Ok(Vec::new());
1301        }
1302        let query_fde = self.fde.encode_query(normalized);
1303        let fde_dimension = self.fde.output_dimension();
1304        let approximate_results: Result<Vec<_>, io::Error> = s
1305            .documents
1306            .par_iter()
1307            .filter(|(id, d)| d.tokens > 0 && eligible.is_none_or(|ids| ids.contains(id.as_str())))
1308            .map(|(id, record)| {
1309                Ok((
1310                    id.clone(),
1311                    dot(
1312                        &query_fde,
1313                        FixedVectorStore::get(
1314                            s.record_fde(record),
1315                            record.fde_location,
1316                            fde_dimension,
1317                        )?,
1318                    ),
1319                ))
1320            })
1321            .collect();
1322        let mut approximate = approximate_results?;
1323        let n = approximate.len();
1324        let by_desc =
1325            |a: &(String, f32), b: &(String, f32)| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0));
1326        match cap {
1327            Some(k) if k < n => {
1328                approximate.select_nth_unstable_by(k, by_desc);
1329                approximate.truncate(k);
1330                approximate.par_sort_unstable_by(by_desc);
1331            }
1332            _ => {
1333                approximate.par_sort_unstable_by(by_desc);
1334            }
1335        }
1336        Ok(approximate)
1337    }
1338    /// Return exact FDE candidates before compressed MaxSim reranking.
1339    pub fn exact_fde_candidates(
1340        &self,
1341        vectors: &[Vector],
1342        count: usize,
1343    ) -> Result<Vec<CandidateHit>, IndexError> {
1344        self.validate(vectors)?;
1345        if count == 0 {
1346            return Err(IndexError::Invalid(
1347                "candidate count must be positive".into(),
1348            ));
1349        }
1350        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1351        Ok(self
1352            .exact_fde_scores(&normalized)?
1353            .into_iter()
1354            .take(count)
1355            .map(|(id, score)| CandidateHit { id, score })
1356            .collect())
1357    }
1358    /// Build an HNSW index over persisted FDEs. Exact FDE scan remains available as an oracle.
1359    pub fn build_fde_ann(&self, m: usize, ef_construct: usize) -> Result<usize, IndexError> {
1360        self.build_ann(None, m, ef_construct)
1361    }
1362    pub fn build_dense_ann(
1363        &self,
1364        field: &str,
1365        m: usize,
1366        ef_construct: usize,
1367    ) -> Result<usize, IndexError> {
1368        self.build_ann(Some(field), m, ef_construct)
1369    }
1370    fn build_ann(
1371        &self,
1372        field: Option<&str>,
1373        m: usize,
1374        ef_construct: usize,
1375    ) -> Result<usize, IndexError> {
1376        if !(1..=128).contains(&m) || !(1..=65_536).contains(&ef_construct) {
1377            return Err(IndexError::Invalid(
1378                "HNSW m must be in 1..=128 and ef_construct in 1..=65536".into(),
1379            ));
1380        }
1381        let s = self.snapshot();
1382        let dimension = if let Some(field) = field {
1383            s.retrieval.dense_dimension(field)?
1384        } else {
1385            self.fde.output_dimension()
1386        };
1387        let mut ids: Vec<_> = s
1388            .documents
1389            .iter()
1390            .filter(|(_, d)| field.map_or(d.tokens > 0, |f| d.fields.has_dense(f)))
1391            .map(|(id, _)| id.clone())
1392            .collect();
1393        ids.sort();
1394        let mut hnsw = HNSWIndex::new(DistanceMetric::Dot, m, ef_construct, 16, dimension);
1395        // Build in chunks so peak memory stays bounded regardless of corpus
1396        // size (each chunk holds only its own decoded vectors). Each chunk is
1397        // handed to par_insert_batch, which uses annex-core's concurrent
1398        // &self insert path to parallelise linking across threads.
1399        const CHUNK: usize = 4096;
1400        for (chunk_idx, chunk_ids) in ids.chunks(CHUNK).enumerate() {
1401            let base = chunk_idx * CHUNK;
1402            let entries: Vec<(u64, Vec<f32>)> = chunk_ids
1403                .iter()
1404                .enumerate()
1405                .map(|(offset, id)| {
1406                    let vector = if let Some(field) = field {
1407                        self.dense_vector(&s, id, field)?
1408                    } else {
1409                        FixedVectorStore::get(
1410                            s.record_fde(&s.documents[id]),
1411                            s.documents[id].fde_location,
1412                            dimension,
1413                        )?
1414                    }
1415                    .to_vec();
1416                    Ok::<_, IndexError>(((base + offset) as u64, vector))
1417                })
1418                .collect::<Result<Vec<_>, _>>()?;
1419            hnsw.par_insert_batch(&entries).map_err(|error| {
1420                IndexError::Invalid(format!("HNSW par_insert_batch failed: {error}"))
1421            })?;
1422        }
1423        // Reorder only. SQ8 screening currently supports Cosine, whereas
1424        // FDE uses Dot; allocating codes here would add cost without benefit.
1425        if !ids.is_empty() {
1426            hnsw.reorder_rcm();
1427        }
1428        let built_generation = s.generation;
1429        drop(s);
1430        commit_boundary("ann_built_before_publish")?;
1431        #[cfg(test)]
1432        transaction_tests::before_ann_publish();
1433        let _writer = self.writer.lock().unwrap();
1434        let s = self.snapshot();
1435        if s.generation != built_generation {
1436            return Err(IndexError::Invalid(format!(
1437                "index generation moved during HNSW build ({} -> {}); retry",
1438                built_generation, s.generation,
1439            )));
1440        }
1441        let count = ids.len();
1442        let by_id = ids
1443            .iter()
1444            .enumerate()
1445            .map(|(idx, id)| (id.clone(), idx as u64))
1446            .collect();
1447        let mut next = (*s).clone();
1448        let ann = FdeAnn {
1449            base: Arc::new(FdeAnnBase {
1450                index: hnsw,
1451                ids,
1452                by_id,
1453                field: field.map(str::to_owned),
1454            }),
1455            delta: HashSet::new(),
1456            tombstones: HashSet::new(),
1457            generation: built_generation,
1458        };
1459        if let Some(field) = field {
1460            next.named_ann.insert(field.to_owned(), ann);
1461        } else {
1462            next.fde_ann = Some(ann);
1463        }
1464        *self.state.write().unwrap() = Arc::new(next);
1465        Ok(count)
1466    }
1467    pub fn query_with_fde_ann(
1468        &self,
1469        vectors: &[Vector],
1470        top_k: usize,
1471        candidates: Option<usize>,
1472        ef_search: usize,
1473    ) -> Result<Vec<Hit>, IndexError> {
1474        self.validate(vectors)?;
1475        if top_k == 0 || ef_search == 0 {
1476            return Err(IndexError::Invalid(
1477                "top_k and ef_search must be positive".into(),
1478            ));
1479        }
1480        let normalized: Vec<_> = vectors.iter().map(|v| normalize(v)).collect();
1481        let query_fde = self.fde.encode_query(&normalized);
1482        let s = self.snapshot();
1483        let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1484        let approximate = self.ann_fde_scores(&s, &query_fde, count, ef_search)?;
1485        self.rescore(&s, &normalized, approximate, top_k, candidates)
1486    }
1487    fn ann_fde_scores(
1488        &self,
1489        s: &State,
1490        query_fde: &Vector,
1491        count: usize,
1492        ef_search: usize,
1493    ) -> Result<Vec<(String, f32)>, IndexError> {
1494        let ann = s.fde_ann.as_ref().ok_or_else(|| {
1495            IndexError::Invalid("FDE ANN is not built; call /v1/fde/index".into())
1496        })?;
1497        self.ann_scores(s, ann, query_fde, count, ef_search)
1498    }
1499    fn ann_scores(
1500        &self,
1501        s: &State,
1502        ann: &FdeAnn,
1503        query_fde: &Vector,
1504        count: usize,
1505        ef_search: usize,
1506    ) -> Result<Vec<(String, f32)>, IndexError> {
1507        if ann.generation != s.generation {
1508            return Err(IndexError::Invalid(
1509                "FDE ANN generation is stale; rebuild".into(),
1510            ));
1511        }
1512        let mut scores = Vec::new();
1513        if ann.base.ids.len() > ann.tombstones.len() {
1514            // Account for masked base hits before asking the graph for candidates.
1515            let base_count = count
1516                .saturating_add(ann.tombstones.len())
1517                .min(ann.base.ids.len());
1518            let options = SearchRuntimeOptions {
1519                ef_search: Some(ef_search.max(base_count)),
1520                ..SearchRuntimeOptions::default()
1521            };
1522            let points = ann
1523                .base
1524                .index
1525                .search_with_options(query_fde, base_count, &options)
1526                .map_err(|error| IndexError::Invalid(format!("HNSW search failed: {error}")))?;
1527            for point in points {
1528                if ann.tombstones.contains(&point.id) {
1529                    continue;
1530                }
1531                let id = ann
1532                    .base
1533                    .ids
1534                    .get(point.id as usize)
1535                    .ok_or_else(|| IndexError::Invalid("invalid HNSW point id".into()))?;
1536                if !s.documents.contains_key(id) || ann.delta.contains(id) {
1537                    return Err(IndexError::Invalid(
1538                        "FDE ANN overlay is inconsistent; rebuild".into(),
1539                    ));
1540                }
1541                scores.push((id.clone(), -point.sort_key));
1542            }
1543        }
1544        if !ann.delta.is_empty() {
1545            for id in &ann.delta {
1546                let record = s
1547                    .documents
1548                    .get(id)
1549                    .ok_or_else(|| IndexError::Invalid("missing delta document".into()))?;
1550                let vector = if let Some(field) = &ann.base.field {
1551                    self.dense_vector(s, id, field)?
1552                } else {
1553                    FixedVectorStore::get(
1554                        s.record_fde(record),
1555                        record.fde_location,
1556                        self.fde.output_dimension(),
1557                    )?
1558                };
1559                scores.push((id.clone(), dot(query_fde, vector)));
1560            }
1561        }
1562        let by_score =
1563            |a: &(String, f32), b: &(String, f32)| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0));
1564        if scores.len() > count {
1565            scores.select_nth_unstable_by(count, by_score);
1566            scores.truncate(count);
1567        }
1568        scores.sort_unstable_by(by_score);
1569        Ok(scores)
1570    }
1571    /// Return HNSW FDE candidates before compressed MaxSim reranking.
1572    pub fn ann_fde_candidates(
1573        &self,
1574        vectors: &[Vector],
1575        count: usize,
1576        ef_search: usize,
1577    ) -> Result<Vec<CandidateHit>, IndexError> {
1578        self.validate(vectors)?;
1579        if count == 0 || ef_search == 0 {
1580            return Err(IndexError::Invalid(
1581                "candidate count and ef_search must be positive".into(),
1582            ));
1583        }
1584        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1585        let query_fde = self.fde.encode_query(&normalized);
1586        let s = self.snapshot();
1587        Ok(self
1588            .ann_fde_scores(&s, &query_fde, count, ef_search)?
1589            .into_iter()
1590            .map(|(id, score)| CandidateHit { id, score })
1591            .collect())
1592    }
1593    pub fn query_with_probes(
1594        &self,
1595        vectors: &[Vector],
1596        top_k: usize,
1597        candidates: Option<usize>,
1598        probes: usize,
1599    ) -> Result<Vec<Hit>, IndexError> {
1600        self.validate(vectors)?;
1601        if top_k == 0 {
1602            return Err(IndexError::Invalid("top_k must be positive".into()));
1603        }
1604        let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1605        let s = self.snapshot();
1606        if s.codebook.is_empty() {
1607            return Err(IndexError::Invalid("index is untrained".into()));
1608        }
1609        // Compute Q x C once; all later centroid interaction is a table lookup.
1610        let interaction: Vec<Vec<f32>> = normalized
1611            .iter()
1612            .map(|q| s.codebook.iter().map(|c| dot(q, c)).collect())
1613            .collect();
1614        let mut selected = HashSet::new();
1615        for row in &interaction {
1616            let mut scored: Vec<_> = row.iter().copied().enumerate().collect();
1617            scored.sort_by(|a, b| b.1.total_cmp(&a.1));
1618            for &(c, _) in scored.iter().take(probes.max(1)) {
1619                selected.insert(c);
1620            }
1621        }
1622        let mut candidate_ids = HashSet::new();
1623        for c in selected {
1624            candidate_ids.extend(s.postings[c].iter().cloned());
1625        }
1626        let mut approx: Vec<_> = candidate_ids
1627            .into_iter()
1628            .map(|id| {
1629                let d = &s.documents[&id];
1630                let score = interaction
1631                    .iter()
1632                    .map(|row| {
1633                        d.unique_centroids
1634                            .iter()
1635                            .map(|&c| row[c as usize])
1636                            .fold(f32::NEG_INFINITY, f32::max)
1637                    })
1638                    .sum::<f32>();
1639                (id, score)
1640            })
1641            .collect();
1642        approx.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1643        let mut hits = self.rescore(&s, &normalized, approx, top_k, candidates)?;
1644        for hit in &mut hits {
1645            hit.fde_score = None;
1646        }
1647        Ok(hits)
1648    }
1649    fn rescore(
1650        &self,
1651        s: &State,
1652        normalized: &[Vector],
1653        approximate: Vec<(String, f32)>,
1654        top_k: usize,
1655        candidates: Option<usize>,
1656    ) -> Result<Vec<Hit>, IndexError> {
1657        if approximate.is_empty() {
1658            return Ok(Vec::new());
1659        }
1660        let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1661        // Per-stage timing accumulators, gated by MULTIVECTOR_TIMING. Cached
1662        // in a OnceLock so a live server pays the env::var HashMap lookup
1663        // exactly once, not per rescoring call.
1664        use std::sync::OnceLock;
1665        static TIMING: OnceLock<bool> = OnceLock::new();
1666        let timing = *TIMING.get_or_init(|| std::env::var("MULTIVECTOR_TIMING").is_ok());
1667        let decode_ns = std::sync::atomic::AtomicU64::new(0);
1668        let maxsim_ns = std::sync::atomic::AtomicU64::new(0);
1669        // Per-worker scratch buffer for compressed decode. Reused across every
1670        // candidate this thread scores in this rescoring call — turns
1671        // (candidates x per-doc) Vec::with_capacity(count*dim) allocations
1672        // into one grow-once-per-thread. Concretely on a FiQA-shaped query
1673        // with 250 candidates x 200 tokens x 128 dims that removes about
1674        // 25 MB of scratch f32 allocs per query.
1675        thread_local! {
1676            static DECODE_SCRATCH: std::cell::RefCell<Vec<f32>> =
1677                const { std::cell::RefCell::new(Vec::new()) };
1678        }
1679        // Two-phase: (1) score every candidate producing only (idx, score),
1680        // then (2) pick the top_k and materialise Hit structs only for the
1681        // survivors. This avoids cloning id + metadata for the (count -
1682        // top_k) candidates that get thrown away after the sort — on a
1683        // typical query with candidates=500 and top_k=100 that saves 400
1684        // String + Value clones per query.
1685        let approximate_slice: &[(String, f32)] = approximate.as_slice();
1686        let scored: Vec<(usize, f32)> = approximate_slice
1687            .par_iter()
1688            .take(count)
1689            .enumerate()
1690            .map(|(idx, (id, _))| -> Result<(usize, f32), io::Error> {
1691                let record = &s.documents[id];
1692                let t0 = if timing {
1693                    Some(std::time::Instant::now())
1694                } else {
1695                    None
1696                };
1697                let (score, t1) = DECODE_SCRATCH.with(
1698                    |cell| -> Result<(f32, Option<std::time::Instant>), io::Error> {
1699                        let mut scratch = cell.borrow_mut();
1700                        let dim = CompressedVectorStore::decode_into(
1701                            s.record_objects(record),
1702                            record.location,
1703                            &s.codebook,
1704                            &s.residual_codebook,
1705                            &mut scratch,
1706                        )?;
1707                        let t1 = if timing {
1708                            Some(std::time::Instant::now())
1709                        } else {
1710                            None
1711                        };
1712                        Ok((maxsim_flat(normalized, &scratch, dim), t1))
1713                    },
1714                )?;
1715                let t2 = if timing {
1716                    Some(std::time::Instant::now())
1717                } else {
1718                    None
1719                };
1720                if let (Some(t0), Some(t1), Some(t2)) = (t0, t1, t2) {
1721                    decode_ns.fetch_add(
1722                        t1.duration_since(t0).as_nanos() as u64,
1723                        std::sync::atomic::Ordering::Relaxed,
1724                    );
1725                    maxsim_ns.fetch_add(
1726                        t2.duration_since(t1).as_nanos() as u64,
1727                        std::sync::atomic::Ordering::Relaxed,
1728                    );
1729                }
1730                Ok((idx, score))
1731            })
1732            .collect::<Result<Vec<_>, io::Error>>()?;
1733        if timing {
1734            let d = decode_ns.load(std::sync::atomic::Ordering::Relaxed);
1735            let m = maxsim_ns.load(std::sync::atomic::Ordering::Relaxed);
1736            eprintln!(
1737                "[timing] candidates={count} decode_us={} maxsim_us={} (per-thread aggregate)",
1738                d / 1000,
1739                m / 1000
1740            );
1741        }
1742        // Partition + sort only the top_k survivors, then materialise the
1743        // Hit structs — avoids id/metadata clones for candidates outside
1744        // top_k. select_nth_unstable_by would be O(n) but we still need a
1745        // sorted top_k, so partition then sort the small prefix.
1746        let mut scored = scored;
1747        let n = scored.len();
1748        let by_score_desc = |a: &(usize, f32), b: &(usize, f32)| {
1749            b.1.total_cmp(&a.1)
1750                .then_with(|| approximate_slice[a.0].0.cmp(&approximate_slice[b.0].0))
1751        };
1752        if top_k < n {
1753            scored.select_nth_unstable_by(top_k, by_score_desc);
1754            scored.truncate(top_k);
1755        }
1756        scored.sort_unstable_by(by_score_desc);
1757        let hits: Vec<Hit> = scored
1758            .into_iter()
1759            .map(|(idx, score)| {
1760                let (id, fde) = &approximate_slice[idx];
1761                let record = &s.documents[id];
1762                Hit {
1763                    id: id.clone(),
1764                    score,
1765                    fde_score: Some(*fde),
1766                    metadata: record.metadata.clone(),
1767                }
1768            })
1769            .collect();
1770        Ok(hits)
1771    }
1772    fn new_segment(&self, generation: u64) -> Result<Arc<SegmentStores>, IndexError> {
1773        let mut id = generation
1774            .checked_add(1)
1775            .ok_or_else(|| IndexError::Invalid("generation exhausted".into()))?;
1776        let parent = self.root.join("segments");
1777        fs::create_dir_all(&parent)?;
1778        let root = loop {
1779            let path = parent.join(id.to_string());
1780            match fs::create_dir(&path) {
1781                Ok(()) => break path,
1782                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
1783                    id = id
1784                        .checked_add(1)
1785                        .ok_or_else(|| IndexError::Invalid("segment IDs exhausted".into()))?;
1786                }
1787                Err(e) => return Err(e.into()),
1788            }
1789        };
1790        let stores = Arc::new(SegmentStores {
1791            objects: CompressedVectorStore::new(root.join("objects"))?,
1792            fde: FixedVectorStore::new(root.join("fde"))?,
1793            root,
1794            id: Some(id),
1795            retired: std::sync::atomic::AtomicBool::new(true),
1796        });
1797        Ok(stores)
1798    }
1799    fn rotate_segment(&self, next: &mut State) -> Result<(), IndexError> {
1800        let stores = self.new_segment(next.generation)?;
1801        // A failed append can leave a tail beyond the published mappings.
1802        // Sealing preserves the committed prefix, never the physical file size.
1803        let bounds = SegmentBoundaries {
1804            objects: next.object_bytes().len() as u64,
1805            fde: next.fde_bytes().len() as u64,
1806        };
1807        next.sealed.insert(
1808            next.stores.id.unwrap_or(0),
1809            Arc::new(SegmentSnapshot {
1810                stores: Arc::clone(&next.stores),
1811                objects: next.objects_map.take(),
1812                fde: next.fde_map.take(),
1813                bounds,
1814            }),
1815        );
1816        if self.durability == Durability::Fsync {
1817            for path in [
1818                stores.root.join("objects"),
1819                stores.root.join("fde"),
1820                stores.root.clone(),
1821                self.root.join("segments"),
1822            ] {
1823                File::open(path)?.sync_all()?;
1824            }
1825            File::open(&self.root)?.sync_all()?;
1826        }
1827        next.stores = stores;
1828        Ok(())
1829    }
1830    pub fn seal(&self) -> Result<(), IndexError> {
1831        let _writer = self.writer.lock().unwrap();
1832        let s = self.snapshot();
1833        let mut next = (*s).clone();
1834        self.rotate_segment(&mut next)?;
1835        self.commit(&s, next)
1836    }
1837    /// Copy live records into a new append segment and atomically publish its
1838    /// locations. Existing readers retain their old files until they finish.
1839    pub fn compact(&self) -> Result<serde_json::Value, IndexError> {
1840        let s = self.snapshot();
1841        let before = s.stores.objects.len()?
1842            + s.stores.fde.len()?
1843            + s.sealed
1844                .values()
1845                .map(|v| Ok::<_, io::Error>(v.stores.objects.len()? + v.stores.fde.len()?))
1846                .collect::<Result<Vec<_>, _>>()?
1847                .iter()
1848                .sum::<u64>();
1849        let stores = self.new_segment(s.generation)?;
1850        let mut next = (*s).clone();
1851        next.stores = Arc::clone(&stores);
1852        next.objects_map = None;
1853        next.fde_map = None;
1854        next.sealed.clear();
1855        for document in next.documents.values_mut() {
1856            if document.tokens > 0 {
1857                document.location = stores
1858                    .objects
1859                    .copy_record(s.record_objects(document), document.location)?;
1860                document.fde_location = stores
1861                    .fde
1862                    .copy_record(s.record_fde(document), document.fde_location)?;
1863            }
1864            let source = s.record_fde(document);
1865            Arc::make_mut(&mut document.fields).relocate(source, &stores.fde)?;
1866            document.storage_id = stores.id.unwrap();
1867        }
1868        if self.durability == Durability::Fsync {
1869            for path in [
1870                stores.root.join("objects"),
1871                stores.root.join("fde"),
1872                stores.root.clone(),
1873                self.root.join("segments"),
1874            ] {
1875                File::open(path)?.sync_all()?;
1876            }
1877            File::open(&self.root)?.sync_all()?;
1878        }
1879        commit_boundary("compaction_copied")?;
1880        let _writer = self.writer.lock().unwrap();
1881        if self.snapshot().generation != s.generation {
1882            return Err(IndexError::Invalid(
1883                "generation changed during compaction; retry".into(),
1884            ));
1885        }
1886        let result = self.commit(&s, next);
1887        if result.is_ok() || matches!(result, Err(IndexError::CommitUncertain(_))) {
1888            stores
1889                .retired
1890                .store(false, std::sync::atomic::Ordering::Relaxed);
1891        }
1892        if result.is_ok() {
1893            s.stores
1894                .retired
1895                .store(true, std::sync::atomic::Ordering::Relaxed);
1896            for old in s.sealed.values() {
1897                old.stores
1898                    .retired
1899                    .store(true, std::sync::atomic::Ordering::Relaxed);
1900            }
1901        }
1902        result?;
1903        let after = stores.objects.len()? + stores.fde.len()?;
1904        Ok(
1905            serde_json::json!({"generation":s.generation+1,"bytes_before":before,"bytes_after":after,"bytes_reclaimed":before.saturating_sub(after)}),
1906        )
1907    }
1908
1909    pub fn stats(&self) -> IndexStats {
1910        let s = self.snapshot();
1911        IndexStats {
1912            documents: s.documents.len(),
1913            generation: s.generation,
1914            token_vectors: s.documents.values().map(|d| d.tokens).sum(),
1915            compressed_bytes: s.documents.values().map(|d| d.compressed_bytes).sum(),
1916            centroids: s.codebook.len(),
1917            residual_bits: self.config.residual_bits,
1918            trained: !s.codebook.is_empty(),
1919            fde_dimension: self.fde.output_dimension(),
1920            fde_ann_nodes: s.fde_ann.as_ref().map_or(0, |ann| {
1921                ann.base.ids.len() - ann.tombstones.len() + ann.delta.len()
1922            }),
1923            fde_ann_base_nodes: s.fde_ann.as_ref().map_or(0, |ann| ann.base.ids.len()),
1924            fde_ann_delta_documents: s.fde_ann.as_ref().map_or(0, |ann| ann.delta.len()),
1925            fde_ann_tombstones: s.fde_ann.as_ref().map_or(0, |ann| ann.tombstones.len()),
1926            fde_encoding_version: self.fde.encoding_version(),
1927            storage_segments: s.sealed.len() + 1,
1928            dense_ann_fields: s
1929                .named_ann
1930                .iter()
1931                .map(|(field, ann)| {
1932                    (
1933                        field.clone(),
1934                        ann.base.ids.len() - ann.tombstones.len() + ann.delta.len(),
1935                    )
1936                })
1937                .collect(),
1938        }
1939    }
1940    /// Diagnostic score over caller-provided vectors; used to verify scorer parity.
1941    pub fn score_uncompressed(
1942        &self,
1943        query: &[Vector],
1944        document: &[Vector],
1945    ) -> Result<f32, IndexError> {
1946        self.validate(query)?;
1947        self.validate(document)?;
1948        let query: Vec<_> = query.iter().map(|v| normalize(v)).collect();
1949        let document: Vec<_> = document.iter().map(|v| normalize(v)).collect();
1950        let flat: Vec<_> = document.into_iter().flatten().collect();
1951        Ok(maxsim_flat(&query, &flat, self.config.dimension))
1952    }
1953    /// Diagnostic score over the actual compressed bytes stored for a document.
1954    pub fn score_compressed(&self, query: &[Vector], id: &str) -> Result<f32, IndexError> {
1955        self.validate(query)?;
1956        let query: Vec<_> = query.iter().map(|v| normalize(v)).collect();
1957        let s = self.snapshot();
1958        let record = s
1959            .documents
1960            .get(id)
1961            .ok_or_else(|| IndexError::Invalid(format!("unknown document: {id}")))?;
1962        let document = CompressedVectorStore::decode(
1963            s.record_objects(record),
1964            record.location,
1965            &s.codebook,
1966            &s.residual_codebook,
1967        )?;
1968        Ok(maxsim_flat(&query, &document.values, document.dimension))
1969    }
1970}
1971
1972fn validate_config_size(c: &IndexConfig) -> Result<(), IndexError> {
1973    // Upper bound on any individual encoder/codebook workspace (64 MiB f32).
1974    const MAX_VALUES: usize = 16 * 1024 * 1024;
1975    let bounded = |n: Option<usize>| n.is_some_and(|n| n <= MAX_VALUES);
1976    let buckets = 1usize << c.fde_ksim;
1977    if c.centroids > u32::MAX as usize
1978        || !bounded(c.dimension.checked_mul(c.centroids))
1979        || !bounded(
1980            buckets
1981                .checked_mul(c.fde_projected)
1982                .and_then(|n| n.checked_mul(c.fde_repetitions)),
1983        )
1984        || !bounded(
1985            c.fde_projected
1986                .checked_add(c.fde_ksim)
1987                .and_then(|n| n.checked_mul(c.dimension))
1988                .and_then(|n| n.checked_mul(c.fde_repetitions)),
1989        )
1990        || !bounded(buckets.checked_mul(c.dimension))
1991    {
1992        return Err(IndexError::Invalid(
1993            "configuration exceeds checked 64 MiB workspace limit".into(),
1994        ));
1995    }
1996    Ok(())
1997}
1998
1999fn validate_codebooks(
2000    c: &IndexConfig,
2001    centers: &[Vector],
2002    residuals: &[f32],
2003    has_docs: bool,
2004) -> Result<(), IndexError> {
2005    if centers.is_empty() && residuals.is_empty() && !has_docs {
2006        return Ok(());
2007    }
2008    if centers.len() != c.centroids
2009        || residuals.len() != 1usize << c.residual_bits
2010        || centers
2011            .iter()
2012            .any(|v| v.len() != c.dimension || v.iter().any(|x| !x.is_finite()))
2013        || residuals.iter().any(|x| !x.is_finite())
2014    {
2015        return Err(IndexError::Invalid("invalid persisted codebook".into()));
2016    }
2017    Ok(())
2018}
2019
2020fn check_pruning_shape(
2021    top_k: usize,
2022    candidates: usize,
2023    rerank_candidates: usize,
2024) -> Result<(), IndexError> {
2025    if top_k == 0 || rerank_candidates < top_k || candidates < rerank_candidates {
2026        return Err(IndexError::Invalid(
2027            "require top_k > 0 and candidates >= rerank_candidates >= top_k".into(),
2028        ));
2029    }
2030    Ok(())
2031}
2032fn centroid_prune(
2033    s: &State,
2034    query: &[Vector],
2035    candidates: Vec<(String, f32)>,
2036    survivors: usize,
2037) -> Vec<(String, f32)> {
2038    let interaction: Vec<Vec<f32>> = query
2039        .iter()
2040        .map(|q| s.codebook.iter().map(|centroid| dot(q, centroid)).collect())
2041        .collect();
2042    let mut scored: Vec<_> = candidates
2043        .into_par_iter()
2044        .map(|(id, fde_score)| {
2045            let document = &s.documents[&id];
2046            let score = interaction
2047                .iter()
2048                .map(|row| {
2049                    document
2050                        .unique_centroids
2051                        .iter()
2052                        .map(|&centroid| row[centroid as usize])
2053                        .fold(f32::NEG_INFINITY, f32::max)
2054                })
2055                .sum::<f32>();
2056            (id, fde_score, score)
2057        })
2058        .collect();
2059    scored.par_sort_unstable_by(|a, b| b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
2060    scored.truncate(survivors);
2061    scored
2062        .into_iter()
2063        .map(|(id, fde_score, _)| (id, fde_score))
2064        .collect()
2065}
2066fn nearest(vector: &Vector, centroids: &[Vector]) -> usize {
2067    centroids
2068        .iter()
2069        .enumerate()
2070        .min_by(|(_, a), (_, b)| distance(vector, a).total_cmp(&distance(vector, b)))
2071        .unwrap()
2072        .0
2073}
2074fn distance(a: &[f32], b: &[f32]) -> f32 {
2075    a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
2076}
2077fn train_scalar_codebook(values: &[f32], levels: usize, iterations: usize) -> Vec<f32> {
2078    let mut sorted = values.to_vec();
2079    sorted.sort_unstable_by(|a, b| a.total_cmp(b));
2080    let mut centers: Vec<_> = (0..levels)
2081        .map(|i| sorted[((2 * i + 1) * sorted.len() / (2 * levels)).min(sorted.len() - 1)])
2082        .collect();
2083    for _ in 0..iterations {
2084        let mut sums = vec![0.; levels];
2085        let mut counts = vec![0usize; levels];
2086        for &value in values {
2087            let index = centers
2088                .iter()
2089                .enumerate()
2090                .min_by(|(_, a), (_, b)| (value - **a).abs().total_cmp(&(value - **b).abs()))
2091                .unwrap()
2092                .0;
2093            sums[index] += value;
2094            counts[index] += 1;
2095        }
2096        for i in 0..levels {
2097            if counts[i] > 0 {
2098                centers[i] = sums[i] / counts[i] as f32;
2099            }
2100        }
2101    }
2102    centers.sort_unstable_by(|a, b| a.total_cmp(b));
2103    centers
2104}
2105
2106#[cfg(test)]
2107#[path = "transaction_tests.rs"]
2108mod transaction_tests;