Skip to main content

hermes_core/index/
metadata.rs

1//! Unified index metadata - segments list + vector index state
2//!
3//! This module manages all index-level metadata in a single `metadata.json` file:
4//! - List of committed segments
5//! - Vector index state per field (Flat/Built)
6//! - Trained centroids/codebooks paths
7//!
8//! The workflow is:
9//! 1. During accumulation: segments store Flat vectors, state is Flat
10//! 2. When threshold crossed: train ONCE, update state to Built
11//! 3. On index open: load metadata, skip re-training if already built
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::io::Write;
16use std::path::Path;
17
18use crate::dsl::{Schema, VectorIndexType};
19use crate::error::{Error, Result};
20
21/// Metadata file name at index level
22pub const INDEX_META_FILENAME: &str = "metadata.json";
23/// Temp file for atomic writes (write here, then rename to INDEX_META_FILENAME)
24const INDEX_META_TMP_FILENAME: &str = "metadata.json.tmp";
25
26/// Index-level centroids/codebooks are deliberately bounded before they are
27/// read or decoded. Besides limiting ordinary corruption damage, the matching
28/// bincode limit prevents a tiny forged collection length from requesting an
29/// effectively unbounded allocation.
30pub(crate) const MAX_TRAINED_ARTIFACT_BYTES: usize = 512 * 1024 * 1024;
31
32/// State of vector index for a field
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34pub enum VectorIndexState {
35    /// Accumulating vectors - using Flat (brute-force) search
36    #[default]
37    Flat,
38    /// Index structures built - using ANN search
39    Built {
40        /// Total vector count when training happened
41        vector_count: usize,
42        /// Number of clusters used
43        num_clusters: usize,
44    },
45}
46
47fn default_true() -> bool {
48    true
49}
50
51/// Per-segment metadata stored in index metadata
52/// This allows merge decisions without loading segment files
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SegmentMetaInfo {
55    /// Number of documents in this segment
56    pub num_docs: u32,
57    /// Parent segment IDs that were merged to produce this segment (empty for fresh segments)
58    pub ancestors: Vec<String>,
59    /// Merge generation: 0 for fresh segments, max(parent generations) + 1 for merged segments
60    pub generation: u32,
61    /// Whether this segment has been reordered via Recursive Graph Bisection (BP).
62    /// Fresh segments and block-copy merges are not reordered. Only segments that have
63    /// been explicitly reordered (via background optimizer or reorder command) are marked true.
64    #[serde(default)]
65    pub reordered: bool,
66    /// Whether the last BP reorder pass ran to natural convergence. False when
67    /// a wall-clock BP budget ended the pass early — the segment is ordered
68    /// better than before, and a later warm-started pass can deepen it.
69    /// Old metadata (field absent) deserializes as converged.
70    #[serde(default = "default_true")]
71    pub bp_converged: bool,
72    /// Number of consecutive budget-exhausted BP rewrites in this segment's
73    /// current reordered lineage. Carried across replacement IDs so the
74    /// optimizer can impose a hard follow-up bound instead of rewriting forever.
75    #[serde(default)]
76    pub bp_unconverged_passes: u32,
77}
78
79/// Per-field vector index metadata
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FieldVectorMeta {
82    /// Field ID
83    pub field_id: u32,
84    /// Configured index type (target type when built)
85    pub index_type: VectorIndexType,
86    /// Current state
87    pub state: VectorIndexState,
88    /// Path to centroids file (relative to index dir)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub centroids_file: Option<String>,
91    /// Path to codebook file (relative to index dir, for ScaNN)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub codebook_file: Option<String>,
94}
95
96/// Unified index metadata - single source of truth for index state
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct IndexMetadata {
99    /// Version for compatibility
100    pub version: u32,
101    /// Index schema
102    pub schema: Schema,
103    /// Segment metadata: segment_id -> info (doc count, etc.)
104    /// Using HashMap allows O(1) lookup and stores doc counts for merge decisions
105    #[serde(default)]
106    pub segment_metas: HashMap<String, SegmentMetaInfo>,
107    /// Per-field vector index metadata
108    #[serde(default)]
109    pub vector_fields: HashMap<u32, FieldVectorMeta>,
110    /// Aggregate vector count recorded by all built vector fields.
111    ///
112    /// The per-field `VectorIndexState::Built::vector_count` values are the
113    /// source of truth. This cached aggregate is refreshed whenever a field is
114    /// marked built, rather than being overwritten with whichever field was
115    /// trained last.
116    #[serde(default)]
117    pub total_vectors: usize,
118}
119
120impl IndexMetadata {
121    /// Create new metadata with schema
122    pub fn new(schema: Schema) -> Self {
123        Self {
124            version: 1,
125            schema,
126            segment_metas: HashMap::new(),
127            vector_fields: HashMap::new(),
128            total_vectors: 0,
129        }
130    }
131
132    /// Get segment IDs as a sorted Vec (deterministic ordering)
133    pub fn segment_ids(&self) -> Vec<String> {
134        let mut ids: Vec<String> = self.segment_metas.keys().cloned().collect();
135        ids.sort();
136        ids
137    }
138
139    /// Add a fresh segment (gen=0, no ancestors, not reordered)
140    pub fn add_segment(&mut self, segment_id: String, num_docs: u32) {
141        self.segment_metas.insert(
142            segment_id,
143            SegmentMetaInfo {
144                num_docs,
145                ancestors: Vec::new(),
146                generation: 0,
147                reordered: false,
148                bp_converged: true,
149                bp_unconverged_passes: 0,
150            },
151        );
152    }
153
154    /// Add a merged segment with lineage info
155    pub fn add_merged_segment(
156        &mut self,
157        segment_id: String,
158        num_docs: u32,
159        ancestors: Vec<String>,
160        generation: u32,
161        reordered: bool,
162        bp_converged: bool,
163    ) {
164        self.add_segment_meta(
165            segment_id,
166            SegmentMetaInfo {
167                num_docs,
168                ancestors,
169                generation,
170                reordered,
171                bp_converged,
172                bp_unconverged_passes: 0,
173            },
174        );
175    }
176
177    /// Insert fully constructed lifecycle metadata. Merge/reorder code uses
178    /// this to carry bounded BP lineage; ordinary callers use the safer
179    /// constructors above, which start a fresh lineage.
180    pub(crate) fn add_segment_meta(&mut self, segment_id: String, info: SegmentMetaInfo) {
181        self.segment_metas.insert(segment_id, info);
182    }
183
184    /// Remove a segment
185    pub fn remove_segment(&mut self, segment_id: &str) {
186        self.segment_metas.remove(segment_id);
187    }
188
189    /// Check if segment exists
190    pub fn has_segment(&self, segment_id: &str) -> bool {
191        self.segment_metas.contains_key(segment_id)
192    }
193
194    /// Get segment doc count
195    pub fn segment_doc_count(&self, segment_id: &str) -> Option<u32> {
196        self.segment_metas.get(segment_id).map(|m| m.num_docs)
197    }
198
199    /// Check if a field has been built
200    pub fn is_field_built(&self, field_id: u32) -> bool {
201        self.vector_fields
202            .get(&field_id)
203            .map(|f| matches!(f.state, VectorIndexState::Built { .. }))
204            .unwrap_or(false)
205    }
206
207    /// Get field metadata
208    pub fn get_field_meta(&self, field_id: u32) -> Option<&FieldVectorMeta> {
209        self.vector_fields.get(&field_id)
210    }
211
212    /// Initialize field metadata (called when field is first seen)
213    pub fn init_field(&mut self, field_id: u32, index_type: VectorIndexType) {
214        self.vector_fields
215            .entry(field_id)
216            .or_insert(FieldVectorMeta {
217                field_id,
218                index_type,
219                state: VectorIndexState::Flat,
220                centroids_file: None,
221                codebook_file: None,
222            });
223    }
224
225    /// Mark field as built with trained structures
226    pub fn mark_field_built(
227        &mut self,
228        field_id: u32,
229        vector_count: usize,
230        num_clusters: usize,
231        centroids_file: String,
232        codebook_file: Option<String>,
233    ) {
234        if let Some(field) = self.vector_fields.get_mut(&field_id) {
235            field.state = VectorIndexState::Built {
236                vector_count,
237                num_clusters,
238            };
239            field.centroids_file = Some(centroids_file);
240            field.codebook_file = codebook_file;
241            self.refresh_total_vectors();
242        }
243    }
244
245    /// Refresh the cached aggregate from the authoritative per-field states.
246    ///
247    /// Saturation keeps this infallible metadata helper safe even if it is
248    /// called after loading externally modified metadata with impossible
249    /// counts.
250    pub(crate) fn refresh_total_vectors(&mut self) {
251        self.total_vectors = self
252            .vector_fields
253            .values()
254            .filter_map(|field| match field.state {
255                VectorIndexState::Built { vector_count, .. } => Some(vector_count),
256                VectorIndexState::Flat => None,
257            })
258            .fold(0usize, usize::saturating_add);
259    }
260
261    /// Check if field should be built based on threshold
262    pub fn should_build_field(&self, field_id: u32, threshold: usize) -> bool {
263        // Don't build if already built
264        if self.is_field_built(field_id) {
265            return false;
266        }
267        // Build if we have enough vectors
268        self.total_vectors >= threshold
269    }
270
271    /// Load from directory
272    ///
273    /// If `metadata.json` is missing but `metadata.json.tmp` exists (crash
274    /// between write and rename), recovers from the temp file.
275    pub async fn load<D: crate::directories::Directory>(dir: &D) -> Result<Self> {
276        let path = Path::new(INDEX_META_FILENAME);
277        match dir.open_read(path).await {
278            Ok(slice) => {
279                let bytes = slice.read_bytes().await?;
280                serde_json::from_slice(bytes.as_slice())
281                    .map_err(|e| Error::Serialization(e.to_string()))
282            }
283            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
284                // Try recovering from temp file (crash between write and rename)
285                let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
286                let slice = dir.open_read(tmp_path).await?;
287                let bytes = slice.read_bytes().await?;
288                let meta: Self = serde_json::from_slice(bytes.as_slice())
289                    .map_err(|e| Error::Serialization(e.to_string()))?;
290                log::warn!("Recovered metadata from temp file (previous crash during save)");
291                Ok(meta)
292            }
293            Err(e) => Err(Error::Io(e)),
294        }
295    }
296
297    /// Save to directory (atomic: write temp file, then rename)
298    ///
299    /// Uses write-then-rename so a crash mid-write won't corrupt the
300    /// existing metadata file. On POSIX, rename is atomic.
301    pub async fn save<D: crate::directories::DirectoryWriter>(&self, dir: &D) -> Result<()> {
302        let bytes = self.serialize_to_bytes()?;
303        Self::save_bytes(dir, &bytes).await
304    }
305
306    /// Serialize metadata to bytes (cheap, no I/O).
307    /// Useful when you need to release a lock before doing disk I/O.
308    pub fn serialize_to_bytes(&self) -> Result<Vec<u8>> {
309        serde_json::to_vec_pretty(self).map_err(|e| Error::Serialization(e.to_string()))
310    }
311
312    /// Write pre-serialized metadata bytes to directory (atomic rename + fsync).
313    ///
314    /// The fsync ensures durability: without it, a power failure after rename
315    /// could lose the metadata update on systems with volatile write caches.
316    pub async fn save_bytes<D: crate::directories::DirectoryWriter>(
317        dir: &D,
318        bytes: &[u8],
319    ) -> Result<()> {
320        let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
321        let final_path = Path::new(INDEX_META_FILENAME);
322        // Metadata is tiny, but `DirectoryWriter::write` does not guarantee
323        // the file contents themselves are fsynced. Finish the streaming
324        // writer first (filesystem implementations call `File::sync_all`),
325        // then atomically publish the durable temp file by rename.
326        let mut writer = dir.streaming_writer(tmp_path).await.map_err(Error::Io)?;
327        writer.write_all(bytes).map_err(Error::Io)?;
328        writer.finish().map_err(Error::Io)?;
329        // Rename is the logical commit point: after it succeeds, readers can
330        // observe the new generation and callers must publish the matching
331        // in-memory/tracker state. Directory fsync only strengthens crash
332        // durability. It cannot safely turn an already-visible rename into a
333        // reported pre-commit failure, because cleanup could then delete files
334        // referenced by the metadata now on disk.
335        dir.rename(tmp_path, final_path).await.map_err(Error::Io)?;
336        if let Err(error) = dir.sync().await {
337            log::error!(
338                "[metadata] directory fsync failed after committed rename: {}. \
339                 Continuing with the renamed generation; crash durability is not guaranteed",
340                error,
341            );
342        }
343        Ok(())
344    }
345
346    /// Compatibility loader for callers that only have the persisted field
347    /// map. Invalid/incomplete state is logged and returns `None` rather than a
348    /// partial set.
349    ///
350    /// Index open/build paths use the fallible, schema-aware
351    /// [`Self::try_load_trained_from_fields`] method below.
352    pub async fn load_trained_from_fields<D: crate::directories::Directory>(
353        vector_fields: &HashMap<u32, FieldVectorMeta>,
354        dir: &D,
355    ) -> Option<crate::segment::TrainedVectorStructures> {
356        match Self::load_trained_from_fields_impl(vector_fields, None, dir).await {
357            Ok(trained) => trained,
358            Err(error) => {
359                log::error!("[trained] refusing incomplete/corrupt artifact set: {error}");
360                None
361            }
362        }
363    }
364
365    /// Fallible schema-aware loader used for lifecycle publication.
366    #[cfg_attr(not(feature = "native"), allow(dead_code))]
367    pub(crate) async fn try_load_trained_from_fields<D: crate::directories::Directory>(
368        vector_fields: &HashMap<u32, FieldVectorMeta>,
369        schema: &Schema,
370        dir: &D,
371    ) -> Result<Option<crate::segment::TrainedVectorStructures>> {
372        Self::load_trained_from_fields_impl(vector_fields, Some(schema), dir).await
373    }
374
375    /// Load and validate the complete trained-artifact set described by a
376    /// `vector_fields` snapshot.
377    ///
378    /// This is intentionally all-or-nothing. A `Built` field is a durable
379    /// promise that every artifact required by its configured index exists and
380    /// is compatible with the schema. Returning a partial map would let some
381    /// segment builders publish ANN data while another field was silently
382    /// unusable, and would make the same index behave differently after a
383    /// restart.
384    async fn load_trained_from_fields_impl<D: crate::directories::Directory>(
385        vector_fields: &HashMap<u32, FieldVectorMeta>,
386        schema: Option<&Schema>,
387        dir: &D,
388    ) -> Result<Option<crate::segment::TrainedVectorStructures>> {
389        use std::sync::Arc;
390
391        let mut centroids = rustc_hash::FxHashMap::default();
392        let mut codebooks = rustc_hash::FxHashMap::default();
393        let mut built_fields: Vec<_> = vector_fields
394            .iter()
395            .filter(|(_, meta)| matches!(meta.state, VectorIndexState::Built { .. }))
396            .collect();
397        built_fields.sort_unstable_by_key(|(field_id, _)| **field_id);
398
399        log::debug!(
400            "[trained] loading trained structures, vector_fields={:?}",
401            vector_fields.keys().collect::<Vec<_>>()
402        );
403
404        for (field_id, field_meta) in built_fields {
405            log::debug!(
406                "[trained] field {} state={:?} centroids_file={:?} codebook_file={:?}",
407                field_id,
408                field_meta.state,
409                field_meta.centroids_file,
410                field_meta.codebook_file,
411            );
412            if field_meta.field_id != *field_id {
413                return Err(Error::Corruption(format!(
414                    "trained vector metadata key {field_id} contains field_id {}",
415                    field_meta.field_id
416                )));
417            }
418
419            let schema_config = match schema {
420                None => None,
421                Some(schema) => {
422                    let entry = schema
423                        .get_field_entry(crate::dsl::Field(*field_id))
424                        .ok_or_else(|| {
425                            Error::Corruption(format!(
426                                "trained vector metadata references missing field {field_id}"
427                            ))
428                        })?;
429                    if entry.field_type != crate::dsl::FieldType::DenseVector {
430                        return Err(Error::Corruption(format!(
431                            "trained vector metadata field {field_id} has non-dense schema type {:?}",
432                            entry.field_type
433                        )));
434                    }
435                    let config = entry.dense_vector_config.as_ref().ok_or_else(|| {
436                        Error::Corruption(format!(
437                            "trained vector metadata field {field_id} has no dense-vector configuration"
438                        ))
439                    })?;
440                    if field_meta.index_type != config.index_type {
441                        return Err(Error::Corruption(format!(
442                            "trained vector metadata field {field_id} uses {:?}, schema requires {:?}",
443                            field_meta.index_type, config.index_type
444                        )));
445                    }
446                    Some(config)
447                }
448            };
449            if !matches!(
450                field_meta.index_type,
451                VectorIndexType::IvfRaBitQ | VectorIndexType::ScaNN
452            ) {
453                return Err(Error::Corruption(format!(
454                    "field {field_id} is Built for {:?}, which has no index-level trained artifacts",
455                    field_meta.index_type
456                )));
457            }
458
459            let expected_clusters = match field_meta.state {
460                VectorIndexState::Built { num_clusters, .. } if num_clusters > 0 => num_clusters,
461                VectorIndexState::Built { .. } => {
462                    return Err(Error::Corruption(format!(
463                        "trained vector metadata field {field_id} has zero clusters"
464                    )));
465                }
466                VectorIndexState::Flat => unreachable!("built_fields contains only Built entries"),
467            };
468
469            let centroids_file = field_meta.centroids_file.as_deref().ok_or_else(|| {
470                Error::Corruption(format!(
471                    "trained vector metadata field {field_id} is Built but has no centroids_file"
472                ))
473            })?;
474            let c: crate::structures::CoarseCentroids =
475                load_trained_artifact(dir, *field_id, "centroids", centroids_file).await?;
476            let expected_dim = schema_config.map_or(c.dim, |config| config.dim);
477            let actual_clusters = c.num_clusters as usize;
478            let expected_values = actual_clusters.checked_mul(expected_dim).ok_or_else(|| {
479                Error::Corruption(format!(
480                    "trained centroid dimensions overflow for field {field_id}"
481                ))
482            })?;
483            if actual_clusters == 0
484                || actual_clusters > expected_clusters
485                || c.dim == 0
486                || c.dim != expected_dim
487                || c.centroids.len() != expected_values
488                || c.centroids.iter().any(|value| !value.is_finite())
489            {
490                return Err(Error::Corruption(format!(
491                    "trained centroids for field {field_id} do not match metadata/schema: \
492                     clusters={} (metadata maximum {expected_clusters}), dim={} (expected {}), \
493                     values={} (expected {expected_values})",
494                    c.num_clusters,
495                    c.dim,
496                    expected_dim,
497                    c.centroids.len(),
498                )));
499            }
500            if actual_clusters < expected_clusters {
501                // Older writers persisted the requested cluster count even
502                // though the trainer clamps it to the available sample. This
503                // shape is safe and self-describing in the artifact; accepting
504                // it keeps pre-fix indexes openable. New writers persist the
505                // actual count, so no new mismatch is produced.
506                log::warn!(
507                    "[trained] field {} legacy cluster-count clamp: metadata={}, artifact={}",
508                    field_id,
509                    expected_clusters,
510                    actual_clusters,
511                );
512            }
513            log::debug!(
514                "[trained] field {} loaded centroids ({} clusters)",
515                field_id,
516                c.num_clusters
517            );
518
519            if field_meta.index_type == VectorIndexType::ScaNN {
520                let codebook_file = field_meta.codebook_file.as_deref().ok_or_else(|| {
521                    Error::Corruption(format!(
522                        "trained vector metadata field {field_id} is ScaNN Built but has no codebook_file"
523                    ))
524                })?;
525                let codebook: crate::structures::PQCodebook =
526                    load_trained_artifact(dir, *field_id, "codebook", codebook_file).await?;
527                codebook.validate().map_err(|error| {
528                    Error::Corruption(format!(
529                        "invalid trained codebook for field {field_id}: {error}"
530                    ))
531                })?;
532                if codebook.config.dim != expected_dim {
533                    return Err(Error::Corruption(format!(
534                        "trained codebook for field {field_id} has dimension {}, expected {}",
535                        codebook.config.dim, expected_dim
536                    )));
537                }
538                log::debug!("[trained] field {} loaded codebook", field_id);
539                codebooks.insert(*field_id, Arc::new(codebook));
540            }
541            centroids.insert(*field_id, Arc::new(c));
542        }
543
544        if centroids.is_empty() {
545            Ok(None)
546        } else {
547            Ok(Some(crate::segment::TrainedVectorStructures {
548                centroids,
549                codebooks,
550            }))
551        }
552    }
553}
554
555fn validate_trained_artifact_path(field_id: u32, kind: &str, filename: &str) -> Result<()> {
556    use std::path::Component;
557
558    let path = Path::new(filename);
559    if filename.is_empty()
560        || path.is_absolute()
561        || path.components().any(|component| {
562            matches!(
563                component,
564                Component::ParentDir | Component::RootDir | Component::Prefix(_)
565            )
566        })
567    {
568        return Err(Error::Corruption(format!(
569            "trained {kind} path for field {field_id} is not a safe relative path: '{filename}'"
570        )));
571    }
572    Ok(())
573}
574
575async fn load_trained_artifact<T, D>(
576    dir: &D,
577    field_id: u32,
578    kind: &str,
579    filename: &str,
580) -> Result<T>
581where
582    T: serde::de::DeserializeOwned,
583    D: crate::directories::Directory,
584{
585    validate_trained_artifact_path(field_id, kind, filename)?;
586    let path = Path::new(filename);
587    let file_size = dir.file_size(path).await.map_err(|error| {
588        Error::Corruption(format!(
589            "failed to stat trained {kind} '{filename}' for field {field_id}: {error}"
590        ))
591    })?;
592    validate_trained_artifact_size(field_id, kind, filename, file_size)?;
593    let slice = dir.open_read(path).await.map_err(|error| {
594        Error::Corruption(format!(
595            "failed to open trained {kind} '{filename}' for field {field_id}: {error}"
596        ))
597    })?;
598    validate_trained_artifact_size(field_id, kind, filename, slice.len())?;
599    let bytes = slice.read_bytes().await.map_err(|error| {
600        Error::Corruption(format!(
601            "failed to read trained {kind} '{filename}' for field {field_id}: {error}"
602        ))
603    })?;
604    let (artifact, consumed) = bincode::serde::decode_from_slice::<T, _>(
605        bytes.as_slice(),
606        bincode::config::standard().with_limit::<MAX_TRAINED_ARTIFACT_BYTES>(),
607    )
608    .map_err(|error| {
609        Error::Corruption(format!(
610            "failed to deserialize trained {kind} '{filename}' for field {field_id}: {error}"
611        ))
612    })?;
613    if consumed != bytes.len() {
614        return Err(Error::Corruption(format!(
615            "trained {kind} '{filename}' for field {field_id} has {} trailing bytes",
616            bytes.len() - consumed
617        )));
618    }
619    Ok(artifact)
620}
621
622fn validate_trained_artifact_size(
623    field_id: u32,
624    kind: &str,
625    filename: &str,
626    file_size: u64,
627) -> Result<()> {
628    if file_size > MAX_TRAINED_ARTIFACT_BYTES as u64 {
629        return Err(Error::Corruption(format!(
630            "trained {kind} '{filename}' for field {field_id} is {file_size} bytes, \
631             exceeding the {MAX_TRAINED_ARTIFACT_BYTES}-byte safety limit"
632        )));
633    }
634    Ok(())
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::directories::DirectoryWriter;
641
642    #[derive(Clone, Default)]
643    struct SyncFailDirectory(crate::directories::RamDirectory);
644
645    #[async_trait::async_trait]
646    impl crate::directories::Directory for SyncFailDirectory {
647        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
648            self.0.exists(path).await
649        }
650
651        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
652            self.0.file_size(path).await
653        }
654
655        async fn open_read(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
656            self.0.open_read(path).await
657        }
658
659        async fn read_range(
660            &self,
661            path: &Path,
662            range: std::ops::Range<u64>,
663        ) -> std::io::Result<crate::directories::OwnedBytes> {
664            self.0.read_range(path, range).await
665        }
666
667        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
668            self.0.list_files(prefix).await
669        }
670
671        async fn open_lazy(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
672            self.0.open_lazy(path).await
673        }
674    }
675
676    #[async_trait::async_trait]
677    impl crate::directories::DirectoryWriter for SyncFailDirectory {
678        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
679            self.0.write(path, data).await
680        }
681
682        async fn delete(&self, path: &Path) -> std::io::Result<()> {
683            self.0.delete(path).await
684        }
685
686        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
687            self.0.rename(from, to).await
688        }
689
690        async fn sync(&self) -> std::io::Result<()> {
691            Err(std::io::Error::other("injected directory fsync failure"))
692        }
693
694        async fn streaming_writer(
695            &self,
696            path: &Path,
697        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
698            self.0.streaming_writer(path).await
699        }
700    }
701
702    fn test_schema() -> Schema {
703        Schema::default()
704    }
705
706    fn dense_schema(index_type: VectorIndexType) -> (Schema, crate::dsl::Field) {
707        let mut builder = crate::dsl::SchemaBuilder::default();
708        let config = match index_type {
709            VectorIndexType::IvfRaBitQ => crate::dsl::DenseVectorConfig::with_ivf(2, Some(1), 1),
710            VectorIndexType::ScaNN => crate::dsl::DenseVectorConfig::with_scann(2, Some(1), 1),
711            other => panic!("unsupported trained test index type: {other:?}"),
712        };
713        let field = builder.add_dense_vector_field_with_config("embedding", true, true, config);
714        (builder.build(), field)
715    }
716
717    fn test_centroids() -> crate::structures::CoarseCentroids {
718        crate::structures::CoarseCentroids {
719            num_clusters: 1,
720            dim: 2,
721            centroids: vec![0.25, 0.75],
722            version: 7,
723            soar_config: None,
724        }
725    }
726
727    async fn write_bincode(
728        directory: &crate::directories::RamDirectory,
729        filename: &str,
730        value: &impl serde::Serialize,
731    ) {
732        let bytes = bincode::serde::encode_to_vec(value, bincode::config::standard()).unwrap();
733        directory.write(Path::new(filename), &bytes).await.unwrap();
734    }
735
736    #[test]
737    fn test_metadata_init() {
738        let mut meta = IndexMetadata::new(test_schema());
739        assert_eq!(meta.total_vectors, 0);
740        assert!(meta.segment_metas.is_empty());
741        assert!(!meta.is_field_built(0));
742
743        meta.init_field(0, VectorIndexType::IvfRaBitQ);
744        assert!(!meta.is_field_built(0));
745        assert!(meta.vector_fields.contains_key(&0));
746    }
747
748    #[tokio::test]
749    async fn save_treats_post_rename_sync_failure_as_committed() {
750        let directory = SyncFailDirectory::default();
751        let mut metadata = IndexMetadata::new(test_schema());
752        metadata.add_segment("committed".to_string(), 7);
753
754        metadata.save(&directory).await.unwrap();
755
756        let loaded = IndexMetadata::load(&directory).await.unwrap();
757        assert_eq!(loaded.segment_doc_count("committed"), Some(7));
758    }
759
760    #[tokio::test]
761    async fn trained_artifacts_load_only_when_the_complete_built_set_is_valid() {
762        let mut builder = crate::dsl::SchemaBuilder::default();
763        let config = crate::dsl::DenseVectorConfig::with_ivf(2, Some(1), 1);
764        let first = builder.add_dense_vector_field_with_config(
765            "first_embedding",
766            true,
767            true,
768            config.clone(),
769        );
770        let second =
771            builder.add_dense_vector_field_with_config("second_embedding", true, true, config);
772        let schema = builder.build();
773        let directory = crate::directories::RamDirectory::new();
774        let mut metadata = IndexMetadata::new(schema.clone());
775        metadata.init_field(first.0, VectorIndexType::IvfRaBitQ);
776        metadata.init_field(second.0, VectorIndexType::IvfRaBitQ);
777        metadata.mark_field_built(first.0, 10, 1, "field_0_centroids.bin".into(), None);
778        metadata.mark_field_built(second.0, 10, 1, "field_1_centroids.bin".into(), None);
779        write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
780
781        let error = IndexMetadata::try_load_trained_from_fields(
782            &metadata.vector_fields,
783            &schema,
784            &directory,
785        )
786        .await
787        .err()
788        .expect("missing artifact must fail the complete load")
789        .to_string();
790        assert!(error.contains("field_1_centroids.bin"), "{error}");
791        assert!(error.contains("field 1"), "{error}");
792        assert!(
793            IndexMetadata::load_trained_from_fields(&metadata.vector_fields, &directory)
794                .await
795                .is_none(),
796            "the compatibility API must also fail closed instead of returning the valid subset"
797        );
798    }
799
800    #[tokio::test]
801    async fn index_open_fails_closed_when_built_artifact_is_missing() {
802        let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
803        let directory = crate::directories::RamDirectory::new();
804        let mut metadata = IndexMetadata::new(schema);
805        metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
806        metadata.mark_field_built(field.0, 10, 1, "missing_centroids.bin".into(), None);
807        metadata.save(&directory).await.unwrap();
808
809        let error = match crate::index::Index::open(directory, crate::index::IndexConfig::default())
810            .await
811        {
812            Ok(_) => panic!("Index::open accepted a Built field with no artifact"),
813            Err(error) => error.to_string(),
814        };
815        assert!(error.contains("missing_centroids.bin"), "{error}");
816    }
817
818    #[tokio::test]
819    async fn scann_built_state_requires_a_codebook() {
820        let (schema, field) = dense_schema(VectorIndexType::ScaNN);
821        let directory = crate::directories::RamDirectory::new();
822        let mut metadata = IndexMetadata::new(schema.clone());
823        metadata.init_field(field.0, VectorIndexType::ScaNN);
824        metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
825        write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
826
827        let error = IndexMetadata::try_load_trained_from_fields(
828            &metadata.vector_fields,
829            &schema,
830            &directory,
831        )
832        .await
833        .err()
834        .expect("ScaNN Built state without a codebook must fail")
835        .to_string();
836        assert!(error.contains("has no codebook_file"), "{error}");
837    }
838
839    #[tokio::test]
840    async fn legacy_requested_cluster_count_accepts_a_clamped_artifact() {
841        let mut builder = crate::dsl::SchemaBuilder::default();
842        let field = builder.add_dense_vector_field_with_config(
843            "embedding",
844            true,
845            true,
846            crate::dsl::DenseVectorConfig::with_ivf(2, Some(4), 1),
847        );
848        let schema = builder.build();
849        let directory = crate::directories::RamDirectory::new();
850        let mut metadata = IndexMetadata::new(schema.clone());
851        metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
852        metadata.mark_field_built(field.0, 1, 4, "field_0_centroids.bin".into(), None);
853        write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
854
855        let trained = IndexMetadata::try_load_trained_from_fields(
856            &metadata.vector_fields,
857            &schema,
858            &directory,
859        )
860        .await
861        .unwrap()
862        .unwrap();
863        assert_eq!(trained.centroids[&field.0].num_clusters, 1);
864    }
865
866    #[tokio::test]
867    async fn trained_artifact_loader_rejects_trailing_data() {
868        let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
869        let directory = crate::directories::RamDirectory::new();
870        let mut metadata = IndexMetadata::new(schema.clone());
871        metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
872        metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
873        let mut bytes =
874            bincode::serde::encode_to_vec(test_centroids(), bincode::config::standard()).unwrap();
875        bytes.extend_from_slice(&[0xaa, 0xbb]);
876        directory
877            .write(Path::new("field_0_centroids.bin"), &bytes)
878            .await
879            .unwrap();
880
881        let error = IndexMetadata::try_load_trained_from_fields(
882            &metadata.vector_fields,
883            &schema,
884            &directory,
885        )
886        .await
887        .err()
888        .expect("trailing artifact bytes must fail validation")
889        .to_string();
890        assert!(error.contains("trailing bytes"), "{error}");
891    }
892
893    #[test]
894    fn trained_artifact_size_limit_rejects_before_reading() {
895        let error = validate_trained_artifact_size(
896            3,
897            "centroids",
898            "field_3_centroids.bin",
899            MAX_TRAINED_ARTIFACT_BYTES as u64 + 1,
900        )
901        .unwrap_err()
902        .to_string();
903        assert!(error.contains("exceeding"), "{error}");
904        assert!(error.contains("field 3"), "{error}");
905    }
906
907    #[tokio::test]
908    async fn trained_artifact_decode_limit_rejects_forged_collection_length() {
909        let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
910        let directory = crate::directories::RamDirectory::new();
911        let mut metadata = IndexMetadata::new(schema.clone());
912        metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
913        metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
914
915        // CoarseCentroids begins with num_clusters=1, dim=2, then the Vec
916        // length. Bincode's standard varint marker 253 introduces a u64; this
917        // tiny payload claims an impossible f32 vector and must hit the decode
918        // limit before any large allocation is attempted.
919        let mut bytes = vec![1, 2, 253];
920        bytes.extend_from_slice(&u64::MAX.to_le_bytes());
921        directory
922            .write(Path::new("field_0_centroids.bin"), &bytes)
923            .await
924            .unwrap();
925
926        let error = IndexMetadata::try_load_trained_from_fields(
927            &metadata.vector_fields,
928            &schema,
929            &directory,
930        )
931        .await
932        .err()
933        .expect("forged collection length must fail the bounded decoder")
934        .to_string();
935        assert!(error.contains("failed to deserialize"), "{error}");
936    }
937
938    #[test]
939    fn test_metadata_segments() {
940        let mut meta = IndexMetadata::new(test_schema());
941        meta.add_segment("abc123".to_string(), 50);
942        meta.add_segment("def456".to_string(), 100);
943        assert_eq!(meta.segment_metas.len(), 2);
944        assert_eq!(meta.segment_doc_count("abc123"), Some(50));
945        assert_eq!(meta.segment_doc_count("def456"), Some(100));
946
947        // Overwrites existing
948        meta.add_segment("abc123".to_string(), 75);
949        assert_eq!(meta.segment_metas.len(), 2);
950        assert_eq!(meta.segment_doc_count("abc123"), Some(75));
951
952        meta.remove_segment("abc123");
953        assert_eq!(meta.segment_metas.len(), 1);
954        assert!(meta.has_segment("def456"));
955        assert!(!meta.has_segment("abc123"));
956    }
957
958    #[test]
959    fn test_mark_field_built() {
960        let mut meta = IndexMetadata::new(test_schema());
961        meta.init_field(0, VectorIndexType::IvfRaBitQ);
962        meta.total_vectors = 10000;
963
964        assert!(!meta.is_field_built(0));
965
966        meta.mark_field_built(0, 10000, 256, "field_0_centroids.bin".to_string(), None);
967
968        assert!(meta.is_field_built(0));
969        let field = meta.get_field_meta(0).unwrap();
970        assert_eq!(
971            field.centroids_file.as_deref(),
972            Some("field_0_centroids.bin")
973        );
974    }
975
976    #[test]
977    fn total_vectors_is_aggregate_of_built_field_counts() {
978        let mut meta = IndexMetadata::new(test_schema());
979        meta.init_field(7, VectorIndexType::IvfRaBitQ);
980        meta.init_field(3, VectorIndexType::ScaNN);
981
982        // Build in reverse field-id order to ensure the result is not tied to
983        // HashMap or training iteration order.
984        meta.mark_field_built(7, 400, 20, "field_7_centroids.bin".to_string(), None);
985        assert_eq!(meta.total_vectors, 400);
986        meta.mark_field_built(
987            3,
988            250,
989            15,
990            "field_3_centroids.bin".to_string(),
991            Some("field_3_codebook.bin".to_string()),
992        );
993        assert_eq!(meta.total_vectors, 650);
994
995        // Rebuilding a field replaces its contribution; it does not add a
996        // duplicate training snapshot.
997        meta.mark_field_built(7, 425, 20, "field_7_centroids.bin".to_string(), None);
998        assert_eq!(meta.total_vectors, 675);
999    }
1000
1001    #[test]
1002    fn test_should_build_field() {
1003        let mut meta = IndexMetadata::new(test_schema());
1004        meta.init_field(0, VectorIndexType::IvfRaBitQ);
1005
1006        // Below threshold
1007        meta.total_vectors = 500;
1008        assert!(!meta.should_build_field(0, 1000));
1009
1010        // Above threshold
1011        meta.total_vectors = 1500;
1012        assert!(meta.should_build_field(0, 1000));
1013
1014        // Already built - should not build again
1015        meta.mark_field_built(0, 1500, 256, "centroids.bin".to_string(), None);
1016        assert!(!meta.should_build_field(0, 1000));
1017    }
1018
1019    #[test]
1020    fn test_serialization() {
1021        let mut meta = IndexMetadata::new(test_schema());
1022        meta.add_segment("seg1".to_string(), 100);
1023        meta.init_field(0, VectorIndexType::IvfRaBitQ);
1024        meta.total_vectors = 5000;
1025
1026        let json = serde_json::to_string_pretty(&meta).unwrap();
1027        let loaded: IndexMetadata = serde_json::from_str(&json).unwrap();
1028
1029        assert_eq!(loaded.segment_ids().len(), meta.segment_ids().len());
1030        assert_eq!(loaded.segment_doc_count("seg1"), Some(100));
1031        assert_eq!(loaded.total_vectors, meta.total_vectors);
1032        assert!(loaded.vector_fields.contains_key(&0));
1033    }
1034
1035    #[test]
1036    fn old_metadata_defaults_the_bp_retry_counter() {
1037        let mut meta = IndexMetadata::new(test_schema());
1038        meta.add_segment("legacy".to_string(), 10);
1039        let mut json = serde_json::to_value(&meta).unwrap();
1040        json["segment_metas"]["legacy"]
1041            .as_object_mut()
1042            .unwrap()
1043            .remove("bp_unconverged_passes");
1044
1045        let loaded: IndexMetadata = serde_json::from_value(json).unwrap();
1046        assert_eq!(loaded.segment_metas["legacy"].bp_unconverged_passes, 0);
1047    }
1048
1049    #[test]
1050    fn test_merged_segment_lineage() {
1051        let mut meta = IndexMetadata::new(test_schema());
1052        meta.add_segment("a".to_string(), 50);
1053        meta.add_segment("b".to_string(), 75);
1054
1055        // Fresh segments: gen=0, no ancestors
1056        assert_eq!(meta.segment_metas["a"].generation, 0);
1057        assert!(meta.segment_metas["a"].ancestors.is_empty());
1058
1059        // Merge a+b → c
1060        meta.add_merged_segment(
1061            "c".to_string(),
1062            125,
1063            vec!["a".to_string(), "b".to_string()],
1064            1,
1065            false,
1066            true,
1067        );
1068        assert_eq!(meta.segment_metas["c"].generation, 1);
1069        assert_eq!(meta.segment_metas["c"].ancestors, vec!["a", "b"]);
1070        assert_eq!(meta.segment_doc_count("c"), Some(125));
1071
1072        // Merge c+d → e (gen should be 2)
1073        meta.add_segment("d".to_string(), 30);
1074        meta.add_merged_segment(
1075            "e".to_string(),
1076            155,
1077            vec!["c".to_string(), "d".to_string()],
1078            2,
1079            false,
1080            true,
1081        );
1082        assert_eq!(meta.segment_metas["e"].generation, 2);
1083    }
1084}