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::path::Path;
16
17use crate::dsl::{Schema, VectorIndexType};
18use crate::error::{Error, Result};
19
20/// Metadata file name at index level
21pub const INDEX_META_FILENAME: &str = "metadata.json";
22/// Temp file for atomic writes (write here, then rename to INDEX_META_FILENAME)
23const INDEX_META_TMP_FILENAME: &str = "metadata.json.tmp";
24
25/// State of vector index for a field
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
27pub enum VectorIndexState {
28    /// Accumulating vectors - using Flat (brute-force) search
29    #[default]
30    Flat,
31    /// Index structures built - using ANN search
32    Built {
33        /// Total vector count when training happened
34        vector_count: usize,
35        /// Number of clusters used
36        num_clusters: usize,
37    },
38}
39
40fn default_true() -> bool {
41    true
42}
43
44/// Per-segment metadata stored in index metadata
45/// This allows merge decisions without loading segment files
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SegmentMetaInfo {
48    /// Number of documents in this segment
49    pub num_docs: u32,
50    /// Parent segment IDs that were merged to produce this segment (empty for fresh segments)
51    pub ancestors: Vec<String>,
52    /// Merge generation: 0 for fresh segments, max(parent generations) + 1 for merged segments
53    pub generation: u32,
54    /// Whether this segment has been reordered via Recursive Graph Bisection (BP).
55    /// Fresh segments and block-copy merges are not reordered. Only segments that have
56    /// been explicitly reordered (via background optimizer or reorder command) are marked true.
57    #[serde(default)]
58    pub reordered: bool,
59    /// Whether the last BP reorder pass ran to natural convergence. False when
60    /// a wall-clock BP budget ended the pass early — the segment is ordered
61    /// better than before, and a later warm-started pass can deepen it.
62    /// Old metadata (field absent) deserializes as converged.
63    #[serde(default = "default_true")]
64    pub bp_converged: bool,
65}
66
67/// Per-field vector index metadata
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct FieldVectorMeta {
70    /// Field ID
71    pub field_id: u32,
72    /// Configured index type (target type when built)
73    pub index_type: VectorIndexType,
74    /// Current state
75    pub state: VectorIndexState,
76    /// Path to centroids file (relative to index dir)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub centroids_file: Option<String>,
79    /// Path to codebook file (relative to index dir, for ScaNN)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub codebook_file: Option<String>,
82}
83
84/// Unified index metadata - single source of truth for index state
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct IndexMetadata {
87    /// Version for compatibility
88    pub version: u32,
89    /// Index schema
90    pub schema: Schema,
91    /// Segment metadata: segment_id -> info (doc count, etc.)
92    /// Using HashMap allows O(1) lookup and stores doc counts for merge decisions
93    #[serde(default)]
94    pub segment_metas: HashMap<String, SegmentMetaInfo>,
95    /// Per-field vector index metadata
96    #[serde(default)]
97    pub vector_fields: HashMap<u32, FieldVectorMeta>,
98    /// Total vectors across all segments (updated on commit)
99    #[serde(default)]
100    pub total_vectors: usize,
101}
102
103impl IndexMetadata {
104    /// Create new metadata with schema
105    pub fn new(schema: Schema) -> Self {
106        Self {
107            version: 1,
108            schema,
109            segment_metas: HashMap::new(),
110            vector_fields: HashMap::new(),
111            total_vectors: 0,
112        }
113    }
114
115    /// Get segment IDs as a sorted Vec (deterministic ordering)
116    pub fn segment_ids(&self) -> Vec<String> {
117        let mut ids: Vec<String> = self.segment_metas.keys().cloned().collect();
118        ids.sort();
119        ids
120    }
121
122    /// Add a fresh segment (gen=0, no ancestors, not reordered)
123    pub fn add_segment(&mut self, segment_id: String, num_docs: u32) {
124        self.segment_metas.insert(
125            segment_id,
126            SegmentMetaInfo {
127                num_docs,
128                ancestors: Vec::new(),
129                generation: 0,
130                reordered: false,
131                bp_converged: true,
132            },
133        );
134    }
135
136    /// Add a merged segment with lineage info
137    pub fn add_merged_segment(
138        &mut self,
139        segment_id: String,
140        num_docs: u32,
141        ancestors: Vec<String>,
142        generation: u32,
143        reordered: bool,
144        bp_converged: bool,
145    ) {
146        self.segment_metas.insert(
147            segment_id,
148            SegmentMetaInfo {
149                num_docs,
150                ancestors,
151                generation,
152                reordered,
153                bp_converged,
154            },
155        );
156    }
157
158    /// Remove a segment
159    pub fn remove_segment(&mut self, segment_id: &str) {
160        self.segment_metas.remove(segment_id);
161    }
162
163    /// Check if segment exists
164    pub fn has_segment(&self, segment_id: &str) -> bool {
165        self.segment_metas.contains_key(segment_id)
166    }
167
168    /// Get segment doc count
169    pub fn segment_doc_count(&self, segment_id: &str) -> Option<u32> {
170        self.segment_metas.get(segment_id).map(|m| m.num_docs)
171    }
172
173    /// Check if a field has been built
174    pub fn is_field_built(&self, field_id: u32) -> bool {
175        self.vector_fields
176            .get(&field_id)
177            .map(|f| matches!(f.state, VectorIndexState::Built { .. }))
178            .unwrap_or(false)
179    }
180
181    /// Get field metadata
182    pub fn get_field_meta(&self, field_id: u32) -> Option<&FieldVectorMeta> {
183        self.vector_fields.get(&field_id)
184    }
185
186    /// Initialize field metadata (called when field is first seen)
187    pub fn init_field(&mut self, field_id: u32, index_type: VectorIndexType) {
188        self.vector_fields
189            .entry(field_id)
190            .or_insert(FieldVectorMeta {
191                field_id,
192                index_type,
193                state: VectorIndexState::Flat,
194                centroids_file: None,
195                codebook_file: None,
196            });
197    }
198
199    /// Mark field as built with trained structures
200    pub fn mark_field_built(
201        &mut self,
202        field_id: u32,
203        vector_count: usize,
204        num_clusters: usize,
205        centroids_file: String,
206        codebook_file: Option<String>,
207    ) {
208        if let Some(field) = self.vector_fields.get_mut(&field_id) {
209            field.state = VectorIndexState::Built {
210                vector_count,
211                num_clusters,
212            };
213            field.centroids_file = Some(centroids_file);
214            field.codebook_file = codebook_file;
215        }
216    }
217
218    /// Check if field should be built based on threshold
219    pub fn should_build_field(&self, field_id: u32, threshold: usize) -> bool {
220        // Don't build if already built
221        if self.is_field_built(field_id) {
222            return false;
223        }
224        // Build if we have enough vectors
225        self.total_vectors >= threshold
226    }
227
228    /// Load from directory
229    ///
230    /// If `metadata.json` is missing but `metadata.json.tmp` exists (crash
231    /// between write and rename), recovers from the temp file.
232    pub async fn load<D: crate::directories::Directory>(dir: &D) -> Result<Self> {
233        let path = Path::new(INDEX_META_FILENAME);
234        match dir.open_read(path).await {
235            Ok(slice) => {
236                let bytes = slice.read_bytes().await?;
237                serde_json::from_slice(bytes.as_slice())
238                    .map_err(|e| Error::Serialization(e.to_string()))
239            }
240            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
241                // Try recovering from temp file (crash between write and rename)
242                let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
243                let slice = dir.open_read(tmp_path).await?;
244                let bytes = slice.read_bytes().await?;
245                let meta: Self = serde_json::from_slice(bytes.as_slice())
246                    .map_err(|e| Error::Serialization(e.to_string()))?;
247                log::warn!("Recovered metadata from temp file (previous crash during save)");
248                Ok(meta)
249            }
250            Err(e) => Err(Error::Io(e)),
251        }
252    }
253
254    /// Save to directory (atomic: write temp file, then rename)
255    ///
256    /// Uses write-then-rename so a crash mid-write won't corrupt the
257    /// existing metadata file. On POSIX, rename is atomic.
258    pub async fn save<D: crate::directories::DirectoryWriter>(&self, dir: &D) -> Result<()> {
259        let bytes = self.serialize_to_bytes()?;
260        Self::save_bytes(dir, &bytes).await
261    }
262
263    /// Serialize metadata to bytes (cheap, no I/O).
264    /// Useful when you need to release a lock before doing disk I/O.
265    pub fn serialize_to_bytes(&self) -> Result<Vec<u8>> {
266        serde_json::to_vec_pretty(self).map_err(|e| Error::Serialization(e.to_string()))
267    }
268
269    /// Write pre-serialized metadata bytes to directory (atomic rename + fsync).
270    ///
271    /// The fsync ensures durability: without it, a power failure after rename
272    /// could lose the metadata update on systems with volatile write caches.
273    pub async fn save_bytes<D: crate::directories::DirectoryWriter>(
274        dir: &D,
275        bytes: &[u8],
276    ) -> Result<()> {
277        let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
278        let final_path = Path::new(INDEX_META_FILENAME);
279        dir.write(tmp_path, bytes).await.map_err(Error::Io)?;
280        dir.sync().await.map_err(Error::Io)?;
281        dir.rename(tmp_path, final_path).await.map_err(Error::Io)?;
282        dir.sync().await.map_err(Error::Io)?;
283        Ok(())
284    }
285
286    /// Load trained structures from a vector_fields map.
287    /// Accepts a pre-cloned map so callers can release locks before disk I/O.
288    pub async fn load_trained_from_fields<D: crate::directories::Directory>(
289        vector_fields: &HashMap<u32, FieldVectorMeta>,
290        dir: &D,
291    ) -> Option<crate::segment::TrainedVectorStructures> {
292        use std::sync::Arc;
293
294        let mut centroids = rustc_hash::FxHashMap::default();
295        let mut codebooks = rustc_hash::FxHashMap::default();
296
297        log::debug!(
298            "[trained] loading trained structures, vector_fields={:?}",
299            vector_fields.keys().collect::<Vec<_>>()
300        );
301
302        for (field_id, field_meta) in vector_fields {
303            log::debug!(
304                "[trained] field {} state={:?} centroids_file={:?} codebook_file={:?}",
305                field_id,
306                field_meta.state,
307                field_meta.centroids_file,
308                field_meta.codebook_file,
309            );
310            if !matches!(field_meta.state, VectorIndexState::Built { .. }) {
311                log::debug!("[trained] field {} skipped (not Built)", field_id);
312                continue;
313            }
314
315            // Load centroids
316            match &field_meta.centroids_file {
317                None => {
318                    log::warn!(
319                        "[trained] field {} is Built but has no centroids_file",
320                        field_id
321                    );
322                }
323                Some(file) => match dir.open_read(Path::new(file)).await {
324                    Err(e) => {
325                        log::warn!(
326                            "[trained] field {} failed to open centroids file '{}': {}",
327                            field_id,
328                            file,
329                            e
330                        );
331                    }
332                    Ok(slice) => match slice.read_bytes().await {
333                        Err(e) => {
334                            log::warn!(
335                                "[trained] field {} failed to read centroids file '{}': {}",
336                                field_id,
337                                file,
338                                e
339                            );
340                        }
341                        Ok(bytes) => {
342                            match bincode::serde::decode_from_slice::<
343                                crate::structures::CoarseCentroids,
344                                _,
345                            >(
346                                bytes.as_slice(), bincode::config::standard()
347                            )
348                            .map(|(v, _)| v)
349                            {
350                                Err(e) => {
351                                    log::warn!(
352                                        "[trained] field {} failed to deserialize centroids from '{}': {}",
353                                        field_id,
354                                        file,
355                                        e
356                                    );
357                                }
358                                Ok(c) => {
359                                    log::debug!(
360                                        "[trained] field {} loaded centroids ({} clusters)",
361                                        field_id,
362                                        c.num_clusters
363                                    );
364                                    centroids.insert(*field_id, Arc::new(c));
365                                }
366                            }
367                        }
368                    },
369                },
370            }
371
372            // Load codebook (for ScaNN)
373            match &field_meta.codebook_file {
374                None => {} // optional, not all index types use codebooks
375                Some(file) => match dir.open_read(Path::new(file)).await {
376                    Err(e) => {
377                        log::warn!(
378                            "[trained] field {} failed to open codebook file '{}': {}",
379                            field_id,
380                            file,
381                            e
382                        );
383                    }
384                    Ok(slice) => match slice.read_bytes().await {
385                        Err(e) => {
386                            log::warn!(
387                                "[trained] field {} failed to read codebook file '{}': {}",
388                                field_id,
389                                file,
390                                e
391                            );
392                        }
393                        Ok(bytes) => {
394                            match bincode::serde::decode_from_slice::<
395                                crate::structures::PQCodebook,
396                                _,
397                            >(
398                                bytes.as_slice(), bincode::config::standard()
399                            )
400                            .map(|(v, _)| v)
401                            {
402                                Err(e) => {
403                                    log::warn!(
404                                        "[trained] field {} failed to deserialize codebook from '{}': {}",
405                                        field_id,
406                                        file,
407                                        e
408                                    );
409                                }
410                                Ok(c) => {
411                                    log::debug!("[trained] field {} loaded codebook", field_id);
412                                    codebooks.insert(*field_id, Arc::new(c));
413                                }
414                            }
415                        }
416                    },
417                },
418            }
419        }
420
421        if centroids.is_empty() {
422            None
423        } else {
424            Some(crate::segment::TrainedVectorStructures {
425                centroids,
426                codebooks,
427            })
428        }
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn test_schema() -> Schema {
437        Schema::default()
438    }
439
440    #[test]
441    fn test_metadata_init() {
442        let mut meta = IndexMetadata::new(test_schema());
443        assert_eq!(meta.total_vectors, 0);
444        assert!(meta.segment_metas.is_empty());
445        assert!(!meta.is_field_built(0));
446
447        meta.init_field(0, VectorIndexType::IvfRaBitQ);
448        assert!(!meta.is_field_built(0));
449        assert!(meta.vector_fields.contains_key(&0));
450    }
451
452    #[test]
453    fn test_metadata_segments() {
454        let mut meta = IndexMetadata::new(test_schema());
455        meta.add_segment("abc123".to_string(), 50);
456        meta.add_segment("def456".to_string(), 100);
457        assert_eq!(meta.segment_metas.len(), 2);
458        assert_eq!(meta.segment_doc_count("abc123"), Some(50));
459        assert_eq!(meta.segment_doc_count("def456"), Some(100));
460
461        // Overwrites existing
462        meta.add_segment("abc123".to_string(), 75);
463        assert_eq!(meta.segment_metas.len(), 2);
464        assert_eq!(meta.segment_doc_count("abc123"), Some(75));
465
466        meta.remove_segment("abc123");
467        assert_eq!(meta.segment_metas.len(), 1);
468        assert!(meta.has_segment("def456"));
469        assert!(!meta.has_segment("abc123"));
470    }
471
472    #[test]
473    fn test_mark_field_built() {
474        let mut meta = IndexMetadata::new(test_schema());
475        meta.init_field(0, VectorIndexType::IvfRaBitQ);
476        meta.total_vectors = 10000;
477
478        assert!(!meta.is_field_built(0));
479
480        meta.mark_field_built(0, 10000, 256, "field_0_centroids.bin".to_string(), None);
481
482        assert!(meta.is_field_built(0));
483        let field = meta.get_field_meta(0).unwrap();
484        assert_eq!(
485            field.centroids_file.as_deref(),
486            Some("field_0_centroids.bin")
487        );
488    }
489
490    #[test]
491    fn test_should_build_field() {
492        let mut meta = IndexMetadata::new(test_schema());
493        meta.init_field(0, VectorIndexType::IvfRaBitQ);
494
495        // Below threshold
496        meta.total_vectors = 500;
497        assert!(!meta.should_build_field(0, 1000));
498
499        // Above threshold
500        meta.total_vectors = 1500;
501        assert!(meta.should_build_field(0, 1000));
502
503        // Already built - should not build again
504        meta.mark_field_built(0, 1500, 256, "centroids.bin".to_string(), None);
505        assert!(!meta.should_build_field(0, 1000));
506    }
507
508    #[test]
509    fn test_serialization() {
510        let mut meta = IndexMetadata::new(test_schema());
511        meta.add_segment("seg1".to_string(), 100);
512        meta.init_field(0, VectorIndexType::IvfRaBitQ);
513        meta.total_vectors = 5000;
514
515        let json = serde_json::to_string_pretty(&meta).unwrap();
516        let loaded: IndexMetadata = serde_json::from_str(&json).unwrap();
517
518        assert_eq!(loaded.segment_ids().len(), meta.segment_ids().len());
519        assert_eq!(loaded.segment_doc_count("seg1"), Some(100));
520        assert_eq!(loaded.total_vectors, meta.total_vectors);
521        assert!(loaded.vector_fields.contains_key(&0));
522    }
523
524    #[test]
525    fn test_merged_segment_lineage() {
526        let mut meta = IndexMetadata::new(test_schema());
527        meta.add_segment("a".to_string(), 50);
528        meta.add_segment("b".to_string(), 75);
529
530        // Fresh segments: gen=0, no ancestors
531        assert_eq!(meta.segment_metas["a"].generation, 0);
532        assert!(meta.segment_metas["a"].ancestors.is_empty());
533
534        // Merge a+b → c
535        meta.add_merged_segment(
536            "c".to_string(),
537            125,
538            vec!["a".to_string(), "b".to_string()],
539            1,
540            false,
541            true,
542        );
543        assert_eq!(meta.segment_metas["c"].generation, 1);
544        assert_eq!(meta.segment_metas["c"].ancestors, vec!["a", "b"]);
545        assert_eq!(meta.segment_doc_count("c"), Some(125));
546
547        // Merge c+d → e (gen should be 2)
548        meta.add_segment("d".to_string(), 30);
549        meta.add_merged_segment(
550            "e".to_string(),
551            155,
552            vec!["c".to_string(), "d".to_string()],
553            2,
554            false,
555            true,
556        );
557        assert_eq!(meta.segment_metas["e"].generation, 2);
558    }
559}