Skip to main content

multivector/
engine.rs

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