Skip to main content

hermes_core/segment/builder/
mod.rs

1//! Streaming segment builder with optimized memory usage
2//!
3//! Key optimizations:
4//! - **String interning**: Terms are interned using `lasso` to avoid repeated allocations
5//! - **hashbrown HashMap**: O(1) average insertion instead of BTreeMap's O(log n)
6//! - **Streaming document store**: Documents written to disk immediately
7//! - **Zero-copy store build**: Pre-serialized doc bytes passed directly to compressor
8//! - **Parallel posting serialization**: Rayon parallel sort + serialize
9//! - **Inline posting fast path**: Small terms skip PostingList/BlockPostingList entirely
10
11#[cfg_attr(not(feature = "native"), allow(dead_code))]
12pub(crate) mod bmp;
13mod config;
14mod dense;
15#[cfg(feature = "diagnostics")]
16mod diagnostics;
17#[cfg_attr(not(feature = "native"), allow(dead_code))]
18pub(crate) mod graph_bisection;
19pub use graph_bisection::BpBudget;
20mod postings;
21mod sparse;
22mod store;
23
24pub use config::{MemoryBreakdown, SegmentBuilderConfig, SegmentBuilderStats};
25
26#[cfg(feature = "native")]
27use std::fs::{File, OpenOptions};
28#[cfg(feature = "native")]
29use std::io::BufWriter;
30use std::io::Write;
31use std::mem::size_of;
32#[cfg(feature = "native")]
33use std::path::PathBuf;
34
35use hashbrown::HashMap;
36use rustc_hash::FxHashMap;
37
38// String interning: lasso on native (fast arena), HashMap on WASM (no C deps)
39#[cfg(feature = "native")]
40use lasso::{Rodeo, Spur};
41
42#[cfg(not(feature = "native"))]
43pub(crate) mod simple_interner {
44    use hashbrown::HashMap;
45
46    #[derive(Clone, Copy, PartialEq, Eq, Hash)]
47    pub struct Spur(u32);
48
49    /// Simple string interner for WASM (replaces lasso::Rodeo).
50    /// Stores each string once in a Vec; HashMap maps &str → index.
51    pub struct Rodeo {
52        /// Canonical storage — each string lives here exactly once.
53        strings: Vec<Box<str>>,
54        /// Maps borrowed string slices (pointing into `strings`) to their index.
55        /// Safety: entries are never removed and Box<str> has a stable address.
56        map: HashMap<&'static str, u32>,
57    }
58
59    impl Rodeo {
60        pub fn new() -> Self {
61            Self {
62                strings: Vec::new(),
63                map: HashMap::new(),
64            }
65        }
66
67        pub fn get(&self, key: &str) -> Option<Spur> {
68            self.map.get(key).map(|&id| Spur(id))
69        }
70
71        pub fn get_or_intern(&mut self, key: &str) -> Spur {
72            if let Some(&id) = self.map.get(key) {
73                return Spur(id);
74            }
75            let id = self.strings.len() as u32;
76            let boxed: Box<str> = key.into();
77            // Safety: the Box<str> is stored in self.strings (append-only Vec)
78            // and never moved or freed while the Rodeo is alive.
79            let static_ref: &'static str = unsafe { &*(boxed.as_ref() as *const str) };
80            self.strings.push(boxed);
81            self.map.insert(static_ref, id);
82            Spur(id)
83        }
84
85        pub fn resolve(&self, spur: &Spur) -> &str {
86            &self.strings[spur.0 as usize]
87        }
88
89        pub fn len(&self) -> usize {
90            self.strings.len()
91        }
92    }
93}
94
95#[cfg(not(feature = "native"))]
96use simple_interner::{Rodeo, Spur};
97
98use super::types::{FieldStats, SegmentFiles, SegmentId, SegmentMeta};
99use std::sync::Arc;
100
101use crate::directories::{Directory, DirectoryWriter};
102use crate::dsl::{Document, Field, FieldType, FieldValue, Schema};
103use crate::tokenizer::BoxedTokenizer;
104use crate::{DocId, Result};
105
106use dense::{BinaryDenseVectorBuilder, DenseVectorBuilder};
107use postings::{CompactPosting, PositionPostingListBuilder, PostingListBuilder, TermKey};
108use sparse::SparseVectorBuilder;
109
110/// Size of the document store buffer before writing to disk
111const STORE_BUFFER_SIZE: usize = 16 * 1024 * 1024; // 16MB
112
113/// Memory overhead per new term in the inverted index:
114/// HashMap entry control byte + padding + TermKey + PostingListBuilder + Vec header
115const NEW_TERM_OVERHEAD: usize = size_of::<TermKey>() + size_of::<PostingListBuilder>() + 24;
116
117/// Memory overhead per newly interned string: Spur + arena pointers (2 × usize)
118const INTERN_OVERHEAD: usize = size_of::<Spur>() + 2 * size_of::<usize>();
119
120/// Memory overhead per new term in the position index
121const NEW_POS_TERM_OVERHEAD: usize =
122    size_of::<TermKey>() + size_of::<PositionPostingListBuilder>() + 24;
123
124/// Packed position encoding is `(element_ordinal << 20) | token_position`:
125/// 12 bits of element ordinal, 20 bits of token position. Values beyond these
126/// maxima must saturate — a plain shift/or silently corrupts the neighboring
127/// bit field (ordinal 4096 wraps to 0 and aliases element 0; token positions
128/// >= 2^20 bleed into the ordinal bits).
129const MAX_POSITION_ELEMENT_ORDINAL: u32 = (1 << 12) - 1;
130const MAX_TOKEN_POSITION: u32 = (1 << 20) - 1;
131
132/// Default BMP vocabulary size when `dims` is unset in the sparse vector
133/// config (SPLADE unigram vocabulary). Must match the build-time defaults in
134/// `builder/sparse.rs` and `merger/sparse.rs`.
135const DEFAULT_BMP_SPARSE_DIMS: u32 = 105879;
136
137/// Human-readable name of a schema field type (matches the SDL/serde names).
138fn field_type_name(field_type: &FieldType) -> &'static str {
139    match field_type {
140        FieldType::Text => "text",
141        FieldType::U64 => "u64",
142        FieldType::I64 => "i64",
143        FieldType::F64 => "f64",
144        FieldType::Bytes => "bytes",
145        FieldType::SparseVector => "sparse_vector",
146        FieldType::DenseVector => "dense_vector",
147        FieldType::Json => "json",
148        FieldType::BinaryDenseVector => "binary_dense_vector",
149    }
150}
151
152/// Human-readable name of a document field value's type (matches SDL names).
153fn field_value_type_name(value: &FieldValue) -> &'static str {
154    match value {
155        FieldValue::Text(_) => "text",
156        FieldValue::U64(_) => "u64",
157        FieldValue::I64(_) => "i64",
158        FieldValue::F64(_) => "f64",
159        FieldValue::Bytes(_) => "bytes",
160        FieldValue::SparseVector(_) => "sparse_vector",
161        FieldValue::DenseVector(_) => "dense_vector",
162        FieldValue::Json(_) => "json",
163        FieldValue::BinaryDenseVector(_) => "binary_dense_vector",
164    }
165}
166
167/// Segment builder with optimized memory usage
168///
169/// Features:
170/// - Streams documents to disk immediately (no in-memory document storage)
171/// - Uses string interning for terms (reduced allocations)
172/// - Uses hashbrown HashMap (faster than BTreeMap)
173pub struct SegmentBuilder {
174    schema: Arc<Schema>,
175    config: SegmentBuilderConfig,
176    tokenizers: FxHashMap<Field, BoxedTokenizer>,
177
178    /// String interner for terms - O(1) lookup and deduplication
179    term_interner: Rodeo,
180
181    /// Inverted index: term key -> posting list
182    inverted_index: HashMap<TermKey, PostingListBuilder>,
183
184    /// Spill file for high-frequency posting lists (lazily created on first spill).
185    #[cfg(feature = "native")]
186    posting_spill_file: Option<BufWriter<File>>,
187    #[cfg(feature = "native")]
188    posting_spill_path: PathBuf,
189    /// Tracks spilled ranges per term key: (file_offset, posting_count).
190    #[cfg(feature = "native")]
191    posting_spill_index: HashMap<TermKey, Vec<(u64, u32)>>,
192    #[cfg(feature = "native")]
193    posting_spill_offset: u64,
194
195    /// Streaming document store writer (native: temp file on disk, WASM: in-memory buffer)
196    #[cfg(feature = "native")]
197    store_file: BufWriter<File>,
198    #[cfg(feature = "native")]
199    store_path: PathBuf,
200    #[cfg(not(feature = "native"))]
201    store_buffer: Vec<u8>,
202
203    /// Document count
204    next_doc_id: DocId,
205
206    /// Per-field statistics for BM25F
207    field_stats: FxHashMap<u32, FieldStats>,
208
209    /// Per-document field lengths stored compactly
210    /// Uses a flat `Vec` instead of `Vec<HashMap>` for better cache locality
211    /// Layout: [doc0_field0_len, doc0_field1_len, ..., doc1_field0_len, ...]
212    doc_field_lengths: Vec<u32>,
213    num_indexed_fields: usize,
214    field_to_slot: FxHashMap<u32, usize>,
215
216    /// Reusable buffer for per-document term frequency aggregation
217    /// Avoids allocating a new hashmap for each document
218    local_tf_buffer: FxHashMap<Spur, u32>,
219
220    /// Reusable buffer for per-document position tracking (when positions enabled)
221    /// Avoids allocating a new hashmap for each text field per document
222    local_positions: FxHashMap<Spur, Vec<u32>>,
223
224    /// Reusable buffer for tokenization to avoid per-token String allocations
225    token_buffer: String,
226
227    /// Reusable buffer for numeric field term encoding (avoids format!() alloc per call)
228    numeric_buffer: String,
229
230    /// Dense vector storage per field: field -> (doc_ids, vectors)
231    /// Vectors are stored as flat f32 arrays for global IVF-PQ indexing.
232    dense_vectors: FxHashMap<u32, DenseVectorBuilder>,
233
234    /// Binary dense vector storage per field: field -> packed-bit vectors
235    binary_dense_vectors: FxHashMap<u32, BinaryDenseVectorBuilder>,
236
237    /// Sparse vector storage per field: field -> SparseVectorBuilder
238    /// Uses proper BlockSparsePostingList with configurable quantization
239    sparse_vectors: FxHashMap<u32, SparseVectorBuilder>,
240
241    /// Position index for fields with positions enabled
242    /// term key -> position posting list
243    position_index: HashMap<TermKey, PositionPostingListBuilder>,
244
245    /// Fields that have position tracking enabled, with their mode
246    position_enabled_fields: FxHashMap<u32, Option<crate::dsl::PositionMode>>,
247
248    /// Current element ordinal for multi-valued fields (reset per document)
249    current_element_ordinal: FxHashMap<u32, u32>,
250
251    /// Whether the once-per-segment position-encoding saturation warning
252    /// has already been emitted (see MAX_POSITION_ELEMENT_ORDINAL).
253    position_saturation_warned: bool,
254
255    /// Incrementally tracked memory estimate (avoids expensive stats() calls)
256    estimated_memory: usize,
257
258    /// Reusable buffer for document serialization (avoids per-document allocation)
259    doc_serialize_buffer: Vec<u8>,
260
261    /// Fast-field columnar writers per field_id (only for fields with fast=true)
262    fast_fields: FxHashMap<u32, crate::structures::fast_field::FastFieldWriter>,
263}
264
265impl SegmentBuilder {
266    /// Create a new segment builder
267    pub fn new(schema: Arc<Schema>, config: SegmentBuilderConfig) -> Result<Self> {
268        #[cfg(feature = "native")]
269        let (store_file, store_path, spill_path) = {
270            let segment_id = uuid::Uuid::new_v4();
271            let store_path = config
272                .temp_dir
273                .join(format!("hermes_store_{}.tmp", segment_id));
274            let store_file = BufWriter::with_capacity(
275                STORE_BUFFER_SIZE,
276                OpenOptions::new()
277                    .create(true)
278                    .write(true)
279                    .truncate(true)
280                    .open(&store_path)?,
281            );
282            let spill_path = config
283                .temp_dir
284                .join(format!("hermes_spill_{}.tmp", segment_id));
285            (store_file, store_path, spill_path)
286        };
287
288        // Count indexed fields, track positions, and auto-configure tokenizers
289        let registry = crate::tokenizer::TokenizerRegistry::new();
290        let mut num_indexed_fields = 0;
291        let mut field_to_slot = FxHashMap::default();
292        let mut position_enabled_fields = FxHashMap::default();
293        let mut tokenizers = FxHashMap::default();
294        for (field, entry) in schema.fields() {
295            if entry.indexed && matches!(entry.field_type, FieldType::Text) {
296                field_to_slot.insert(field.0, num_indexed_fields);
297                num_indexed_fields += 1;
298                if entry.positions.is_some() {
299                    position_enabled_fields.insert(field.0, entry.positions);
300                }
301                if let Some(ref tok_name) = entry.tokenizer
302                    && let Some(tokenizer) = registry.get(tok_name)
303                {
304                    tokenizers.insert(field, tokenizer);
305                }
306            }
307        }
308
309        // Initialize fast-field writers for fields with fast=true
310        use crate::structures::fast_field::{FastFieldColumnType, FastFieldWriter};
311        let mut fast_fields = FxHashMap::default();
312        for (field, entry) in schema.fields() {
313            if entry.fast {
314                let writer = if entry.multi {
315                    match entry.field_type {
316                        FieldType::U64 => {
317                            FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64)
318                        }
319                        FieldType::I64 => {
320                            FastFieldWriter::new_numeric_multi(FastFieldColumnType::I64)
321                        }
322                        FieldType::F64 => {
323                            FastFieldWriter::new_numeric_multi(FastFieldColumnType::F64)
324                        }
325                        FieldType::Text => FastFieldWriter::new_text_multi(),
326                        _ => continue,
327                    }
328                } else {
329                    match entry.field_type {
330                        FieldType::U64 => FastFieldWriter::new_numeric(FastFieldColumnType::U64),
331                        FieldType::I64 => FastFieldWriter::new_numeric(FastFieldColumnType::I64),
332                        FieldType::F64 => FastFieldWriter::new_numeric(FastFieldColumnType::F64),
333                        FieldType::Text => FastFieldWriter::new_text(),
334                        _ => continue,
335                    }
336                };
337                fast_fields.insert(field.0, writer);
338            }
339        }
340
341        Ok(Self {
342            schema,
343            tokenizers,
344            term_interner: Rodeo::new(),
345            inverted_index: HashMap::with_capacity(config.posting_map_capacity),
346            #[cfg(feature = "native")]
347            posting_spill_file: None,
348            #[cfg(feature = "native")]
349            posting_spill_path: spill_path,
350            #[cfg(feature = "native")]
351            posting_spill_index: HashMap::new(),
352            #[cfg(feature = "native")]
353            posting_spill_offset: 0,
354            #[cfg(feature = "native")]
355            store_file,
356            #[cfg(feature = "native")]
357            store_path,
358            #[cfg(not(feature = "native"))]
359            store_buffer: Vec::with_capacity(STORE_BUFFER_SIZE),
360            next_doc_id: 0,
361            field_stats: FxHashMap::default(),
362            doc_field_lengths: Vec::new(),
363            num_indexed_fields,
364            field_to_slot,
365            local_tf_buffer: FxHashMap::default(),
366            local_positions: FxHashMap::default(),
367            token_buffer: String::with_capacity(64),
368            numeric_buffer: String::with_capacity(32),
369            config,
370            dense_vectors: FxHashMap::default(),
371            binary_dense_vectors: FxHashMap::default(),
372            sparse_vectors: FxHashMap::default(),
373            position_index: HashMap::new(),
374            position_enabled_fields,
375            current_element_ordinal: FxHashMap::default(),
376            position_saturation_warned: false,
377            estimated_memory: 0,
378            doc_serialize_buffer: Vec::with_capacity(256),
379            fast_fields,
380        })
381    }
382
383    pub fn set_tokenizer(&mut self, field: Field, tokenizer: BoxedTokenizer) {
384        self.tokenizers.insert(field, tokenizer);
385    }
386
387    /// Get the current element ordinal for a field and increment it.
388    /// Used for multi-valued fields (text, dense_vector, sparse_vector).
389    fn next_element_ordinal(&mut self, field_id: u32) -> u32 {
390        let ordinal = *self.current_element_ordinal.get(&field_id).unwrap_or(&0);
391        *self.current_element_ordinal.entry(field_id).or_insert(0) += 1;
392        ordinal
393    }
394
395    fn next_vector_ordinal(&mut self, field_id: u32) -> Result<u16> {
396        let ordinal = self.next_element_ordinal(field_id);
397        u16::try_from(ordinal).map_err(|_| {
398            crate::Error::Document(format!(
399                "field {field_id} has more than {} vector values in one document",
400                u16::MAX as usize + 1
401            ))
402        })
403    }
404
405    pub fn num_docs(&self) -> u32 {
406        self.next_doc_id
407    }
408
409    /// Fast O(1) memory estimate - updated incrementally during indexing
410    #[inline]
411    pub fn estimated_memory_bytes(&self) -> usize {
412        self.estimated_memory
413    }
414
415    /// Count total unique sparse dimensions across all fields
416    pub fn sparse_dim_count(&self) -> usize {
417        self.sparse_vectors.values().map(|b| b.postings.len()).sum()
418    }
419
420    /// Get current statistics for debugging performance (expensive - iterates all data)
421    pub fn stats(&self) -> SegmentBuilderStats {
422        use std::mem::size_of;
423
424        let postings_in_memory: usize =
425            self.inverted_index.values().map(|p| p.postings.len()).sum();
426
427        // Size constants computed from actual types
428        let compact_posting_size = size_of::<CompactPosting>();
429        let vec_overhead = size_of::<Vec<u8>>(); // Vec header: ptr + len + cap = 24 bytes on 64-bit
430        let term_key_size = size_of::<TermKey>();
431        let posting_builder_size = size_of::<PostingListBuilder>();
432        let spur_size = size_of::<Spur>();
433        let sparse_entry_size = size_of::<(DocId, u16, f32)>();
434
435        // hashbrown HashMap entry overhead: key + value + 1 byte control + padding
436        // Measured: ~(key_size + value_size + 8) per entry on average
437        let hashmap_entry_base_overhead = 8usize;
438
439        // FxHashMap uses same layout as hashbrown
440        let fxhashmap_entry_overhead = hashmap_entry_base_overhead;
441
442        // Postings memory
443        let postings_bytes: usize = self
444            .inverted_index
445            .values()
446            .map(|p| p.postings.capacity() * compact_posting_size + vec_overhead)
447            .sum();
448
449        // Inverted index overhead
450        let index_overhead_bytes = self.inverted_index.len()
451            * (term_key_size + posting_builder_size + hashmap_entry_base_overhead);
452
453        // Term interner: Rodeo stores strings + metadata
454        // Rodeo internal: string bytes + Spur + arena overhead (~2 pointers per string)
455        let interner_arena_overhead = 2 * size_of::<usize>();
456        let avg_term_len = 8; // Estimated average term length
457        let interner_bytes =
458            self.term_interner.len() * (avg_term_len + spur_size + interner_arena_overhead);
459
460        // Doc field lengths
461        let field_lengths_bytes =
462            self.doc_field_lengths.capacity() * size_of::<u32>() + vec_overhead;
463
464        // Dense vectors
465        let mut dense_vectors_bytes: usize = 0;
466        let mut dense_vector_count: usize = 0;
467        let doc_id_ordinal_size = size_of::<(DocId, u16)>();
468        for b in self.dense_vectors.values() {
469            dense_vectors_bytes += b.vectors.capacity() * size_of::<f32>()
470                + b.doc_ids.capacity() * doc_id_ordinal_size
471                + 2 * vec_overhead; // Two Vecs
472            dense_vector_count += b.doc_ids.len();
473        }
474        // Binary dense vectors
475        for b in self.binary_dense_vectors.values() {
476            dense_vectors_bytes += b.vectors.capacity()
477                + b.doc_ids.capacity() * doc_id_ordinal_size
478                + 2 * vec_overhead;
479            dense_vector_count += b.doc_ids.len();
480        }
481
482        // Local buffers
483        let local_tf_entry_size = spur_size + size_of::<u32>() + fxhashmap_entry_overhead;
484        let local_tf_buffer_bytes = self.local_tf_buffer.capacity() * local_tf_entry_size;
485
486        // Sparse vectors
487        let mut sparse_vectors_bytes: usize = 0;
488        for builder in self.sparse_vectors.values() {
489            for postings in builder.postings.values() {
490                sparse_vectors_bytes += postings.capacity() * sparse_entry_size + vec_overhead;
491            }
492            // Inner FxHashMap overhead: u32 key + Vec value ptr + overhead
493            let inner_entry_size = size_of::<u32>() + vec_overhead + fxhashmap_entry_overhead;
494            sparse_vectors_bytes += builder.postings.len() * inner_entry_size;
495        }
496        // Outer FxHashMap overhead
497        let outer_sparse_entry_size =
498            size_of::<u32>() + size_of::<SparseVectorBuilder>() + fxhashmap_entry_overhead;
499        sparse_vectors_bytes += self.sparse_vectors.len() * outer_sparse_entry_size;
500
501        // Position index
502        let mut position_index_bytes: usize = 0;
503        for pos_builder in self.position_index.values() {
504            for (_, positions) in &pos_builder.postings {
505                position_index_bytes += positions.capacity() * size_of::<u32>() + vec_overhead;
506            }
507            // Vec<(DocId, Vec<u32>)> entry size
508            let pos_entry_size = size_of::<DocId>() + vec_overhead;
509            position_index_bytes += pos_builder.postings.capacity() * pos_entry_size;
510        }
511        // HashMap overhead for position_index
512        let pos_index_entry_size =
513            term_key_size + size_of::<PositionPostingListBuilder>() + hashmap_entry_base_overhead;
514        position_index_bytes += self.position_index.len() * pos_index_entry_size;
515
516        let estimated_memory_bytes = postings_bytes
517            + index_overhead_bytes
518            + interner_bytes
519            + field_lengths_bytes
520            + dense_vectors_bytes
521            + local_tf_buffer_bytes
522            + sparse_vectors_bytes
523            + position_index_bytes;
524
525        let memory_breakdown = MemoryBreakdown {
526            postings_bytes,
527            index_overhead_bytes,
528            interner_bytes,
529            field_lengths_bytes,
530            dense_vectors_bytes,
531            dense_vector_count,
532            sparse_vectors_bytes,
533            position_index_bytes,
534        };
535
536        SegmentBuilderStats {
537            num_docs: self.next_doc_id,
538            unique_terms: self.inverted_index.len(),
539            postings_in_memory,
540            interned_strings: self.term_interner.len(),
541            doc_field_lengths_size: self.doc_field_lengths.len(),
542            estimated_memory_bytes,
543            memory_breakdown,
544        }
545    }
546
547    /// Fail-loud pre-validation of a document's field values against the
548    /// schema. Runs BEFORE any builder state is mutated, so a rejected
549    /// document never poisons the builder (doc id advanced, postings written,
550    /// store write skipped).
551    ///
552    /// - A value whose runtime type does not match the schema field type
553    ///   would previously fall through `add_document`'s match silently: the
554    ///   value was stored but never indexed, so queries on the field could
555    ///   never match the document. Reject it loudly instead.
556    /// - Sparse entries destined for a BMP-format field must fit the
557    ///   configured `dims`: the block-max grid only has rows for
558    ///   `dim_id < dims`, so out-of-range entries would be silently dropped
559    ///   from the grid and silently filtered from queries — permanently
560    ///   unsearchable.
561    fn validate_document_against_schema(&self, doc: &Document) -> Result<()> {
562        for (field, value) in doc.field_values() {
563            let Some(entry) = self.schema.get_field_entry(*field) else {
564                continue;
565            };
566
567            // Mirror the indexing skip below: values that are neither indexed
568            // nor fast (and are not vector types) are only stored verbatim.
569            if !matches!(
570                &entry.field_type,
571                FieldType::DenseVector | FieldType::BinaryDenseVector
572            ) && !entry.indexed
573                && !entry.fast
574            {
575                continue;
576            }
577
578            match (&entry.field_type, value) {
579                (FieldType::SparseVector, FieldValue::SparseVector(entries)) => {
580                    if let Some(config) = entry.sparse_vector_config.as_ref()
581                        && config.format == crate::structures::SparseFormat::Bmp
582                    {
583                        let dims = config.dims.unwrap_or(DEFAULT_BMP_SPARSE_DIMS);
584                        if let Some(&(dim_id, _)) =
585                            entries.iter().find(|&&(dim_id, _)| dim_id >= dims)
586                        {
587                            return Err(crate::Error::Schema(format!(
588                                "sparse vector for field '{}' contains dim_id {} out of \
589                                 range for the configured BMP dims={}: dimensions >= dims \
590                                 are never written to the block-max grid and can never \
591                                 match a query; raise `dims` in the field's sparse_vector \
592                                 config or fix the embedding model",
593                                entry.name, dim_id, dims
594                            )));
595                        }
596                    }
597                }
598                // Matching (type, value) pairs — indexed by `add_document`.
599                (FieldType::Text, FieldValue::Text(_))
600                | (FieldType::U64, FieldValue::U64(_))
601                | (FieldType::I64, FieldValue::I64(_))
602                | (FieldType::F64, FieldValue::F64(_))
603                | (FieldType::DenseVector, FieldValue::DenseVector(_))
604                | (FieldType::BinaryDenseVector, FieldValue::BinaryDenseVector(_))
605                // Stored-only types: no indexing support, value stored verbatim.
606                | (FieldType::Bytes, FieldValue::Bytes(_))
607                | (FieldType::Json, FieldValue::Json(_)) => {}
608                (expected, got) => {
609                    return Err(crate::Error::Schema(format!(
610                        "type mismatch for field '{}': schema expects a {} value, got {}; \
611                         the value would be stored but never indexed, so queries on this \
612                         field could never match the document — fix the document or the \
613                         schema",
614                        entry.name,
615                        field_type_name(expected),
616                        field_value_type_name(got),
617                    )));
618                }
619            }
620        }
621        Ok(())
622    }
623
624    /// Add a document - streams to disk immediately
625    pub fn add_document(&mut self, doc: Document) -> Result<DocId> {
626        // Reject schema-mismatched values before mutating any builder state.
627        self.validate_document_against_schema(&doc)?;
628
629        let doc_id = self.next_doc_id;
630        self.next_doc_id += 1;
631
632        // Initialize field lengths for this document
633        let base_idx = self.doc_field_lengths.len();
634        self.doc_field_lengths
635            .resize(base_idx + self.num_indexed_fields, 0);
636        self.estimated_memory += self.num_indexed_fields * std::mem::size_of::<u32>();
637
638        // Reset element ordinals for this document (for multi-valued fields)
639        self.current_element_ordinal.clear();
640
641        for (field, value) in doc.field_values() {
642            let Some(entry) = self.schema.get_field_entry(*field) else {
643                continue;
644            };
645
646            // Dense/binary vectors are written to .vectors when indexed || stored
647            // Other field types require indexed or fast
648            if !matches!(
649                &entry.field_type,
650                FieldType::DenseVector | FieldType::BinaryDenseVector
651            ) && !entry.indexed
652                && !entry.fast
653            {
654                continue;
655            }
656
657            match (&entry.field_type, value) {
658                (FieldType::Text, FieldValue::Text(text)) => {
659                    if entry.indexed {
660                        let element_ordinal = self.next_element_ordinal(field.0);
661                        let token_count =
662                            self.index_text_field(*field, doc_id, text, element_ordinal)?;
663
664                        let stats = self.field_stats.entry(field.0).or_default();
665                        stats.total_tokens += token_count as u64;
666                        if element_ordinal == 0 {
667                            stats.doc_count += 1;
668                        }
669
670                        if let Some(&slot) = self.field_to_slot.get(&field.0) {
671                            self.doc_field_lengths[base_idx + slot] = token_count;
672                        }
673                    }
674
675                    // Fast-field: store raw text for text ordinal column
676                    if let Some(ff) = self.fast_fields.get_mut(&field.0) {
677                        ff.add_text(doc_id, text);
678                    }
679                }
680                (FieldType::U64, FieldValue::U64(v)) => {
681                    if entry.indexed {
682                        self.index_numeric_field(*field, doc_id, *v)?;
683                    }
684                    if let Some(ff) = self.fast_fields.get_mut(&field.0) {
685                        ff.add_u64(doc_id, *v);
686                    }
687                }
688                (FieldType::I64, FieldValue::I64(v)) => {
689                    if entry.indexed {
690                        self.index_numeric_field(*field, doc_id, *v as u64)?;
691                    }
692                    if let Some(ff) = self.fast_fields.get_mut(&field.0) {
693                        ff.add_i64(doc_id, *v);
694                    }
695                }
696                (FieldType::F64, FieldValue::F64(v)) => {
697                    if entry.indexed {
698                        self.index_numeric_field(*field, doc_id, v.to_bits())?;
699                    }
700                    if let Some(ff) = self.fast_fields.get_mut(&field.0) {
701                        ff.add_f64(doc_id, *v);
702                    }
703                }
704                (FieldType::DenseVector, FieldValue::DenseVector(vec))
705                    if entry.indexed || entry.stored =>
706                {
707                    let ordinal = self.next_vector_ordinal(field.0)?;
708                    self.index_dense_vector_field(*field, doc_id, ordinal, vec)?;
709                }
710                (FieldType::BinaryDenseVector, FieldValue::BinaryDenseVector(bytes))
711                    if entry.indexed || entry.stored =>
712                {
713                    let ordinal = self.next_vector_ordinal(field.0)?;
714                    self.index_binary_dense_vector_field(*field, doc_id, ordinal, bytes)?;
715                }
716                (FieldType::SparseVector, FieldValue::SparseVector(entries)) => {
717                    let ordinal = self.next_vector_ordinal(field.0)?;
718                    self.index_sparse_vector_field(*field, doc_id, ordinal, entries)?;
719                }
720                // Only reachable for stored-only types (bytes/json) and for
721                // vector values on fields that are neither indexed nor
722                // stored: type-mismatched values are rejected loudly by
723                // `validate_document_against_schema` before this loop.
724                _ => {}
725            }
726        }
727
728        // Stream document to disk immediately
729        self.write_document_to_store(&doc)?;
730
731        Ok(doc_id)
732    }
733
734    /// Index a text field using interned terms
735    ///
736    /// Uses a custom tokenizer when set for the field (via `set_tokenizer`),
737    /// otherwise falls back to an inline zero-allocation path (split_whitespace
738    /// + lowercase + strip non-alphanumeric).
739    ///
740    /// If position recording is enabled for this field, also records token positions
741    /// encoded as (element_ordinal << 20) | token_position.
742    fn index_text_field(
743        &mut self,
744        field: Field,
745        doc_id: DocId,
746        text: &str,
747        element_ordinal: u32,
748    ) -> Result<u32> {
749        use crate::dsl::PositionMode;
750
751        let field_id = field.0;
752        let position_mode = self
753            .position_enabled_fields
754            .get(&field_id)
755            .copied()
756            .flatten();
757
758        // Saturate the packed 12-bit ordinal field instead of letting the
759        // shift silently wrap (ordinal 4096 << 20 == 0, aliasing element 0).
760        let encoded_ordinal = if position_mode.is_some_and(|m| m.tracks_ordinal())
761            && element_ordinal > MAX_POSITION_ELEMENT_ORDINAL
762        {
763            self.warn_position_saturation(
764                "element ordinal",
765                element_ordinal,
766                MAX_POSITION_ELEMENT_ORDINAL,
767            );
768            MAX_POSITION_ELEMENT_ORDINAL
769        } else {
770            element_ordinal
771        };
772
773        // Phase 1: Aggregate term frequencies within this document
774        // Also collect positions if enabled
775        // Reuse buffers to avoid allocations
776        self.local_tf_buffer.clear();
777        // Clear position Vecs in-place (keeps allocated capacity for reuse)
778        for v in self.local_positions.values_mut() {
779            v.clear();
780        }
781
782        let mut token_position = 0u32;
783
784        // Tokenize: use custom tokenizer if set, else inline zero-alloc path.
785        // The owned Vec<Token> is computed first so the immutable borrow of
786        // self.tokenizers ends before we mutate other fields.
787        let custom_tokens = self.tokenizers.get(&field).map(|t| t.tokenize(text));
788
789        if let Some(tokens) = custom_tokens {
790            // Custom tokenizer path
791            for token in &tokens {
792                let term_spur = if let Some(spur) = self.term_interner.get(&token.text) {
793                    spur
794                } else {
795                    let spur = self.term_interner.get_or_intern(&token.text);
796                    self.estimated_memory += token.text.len() + INTERN_OVERHEAD;
797                    spur
798                };
799                *self.local_tf_buffer.entry(term_spur).or_insert(0) += 1;
800
801                if let Some(mode) = position_mode {
802                    let encoded_pos = match mode {
803                        PositionMode::Ordinal => encoded_ordinal << 20,
804                        PositionMode::TokenPosition => token.position,
805                        PositionMode::Full => {
806                            (encoded_ordinal << 20) | self.saturate_token_position(token.position)
807                        }
808                    };
809                    self.local_positions
810                        .entry(term_spur)
811                        .or_default()
812                        .push(encoded_pos);
813                }
814            }
815            token_position = tokens.len() as u32;
816        } else {
817            // Inline zero-allocation path: split_whitespace + lowercase + strip non-alphanumeric
818            for word in text.split_whitespace() {
819                self.token_buffer.clear();
820                for c in word.chars() {
821                    if c.is_alphanumeric() {
822                        for lc in c.to_lowercase() {
823                            self.token_buffer.push(lc);
824                        }
825                    }
826                }
827
828                if self.token_buffer.is_empty() {
829                    continue;
830                }
831
832                let term_spur = if let Some(spur) = self.term_interner.get(&self.token_buffer) {
833                    spur
834                } else {
835                    let spur = self.term_interner.get_or_intern(&self.token_buffer);
836                    self.estimated_memory += self.token_buffer.len() + INTERN_OVERHEAD;
837                    spur
838                };
839                *self.local_tf_buffer.entry(term_spur).or_insert(0) += 1;
840
841                if let Some(mode) = position_mode {
842                    let encoded_pos = match mode {
843                        PositionMode::Ordinal => encoded_ordinal << 20,
844                        PositionMode::TokenPosition => token_position,
845                        PositionMode::Full => {
846                            (encoded_ordinal << 20) | self.saturate_token_position(token_position)
847                        }
848                    };
849                    self.local_positions
850                        .entry(term_spur)
851                        .or_default()
852                        .push(encoded_pos);
853                }
854
855                token_position += 1;
856            }
857        }
858
859        // Phase 2: Insert aggregated terms into inverted index
860        // Now we only do one inverted_index lookup per unique term in doc
861        for (&term_spur, &tf) in &self.local_tf_buffer {
862            let term_key = TermKey {
863                field: field_id,
864                term: term_spur,
865            };
866
867            match self.inverted_index.entry(term_key) {
868                hashbrown::hash_map::Entry::Occupied(mut o) => {
869                    o.get_mut().add(doc_id, tf);
870                    self.estimated_memory += size_of::<CompactPosting>();
871                    // Spill large posting lists to disk to reduce peak memory
872                    #[cfg(feature = "native")]
873                    if o.get().should_spill() {
874                        use byteorder::{LittleEndian, WriteBytesExt};
875
876                        let builder = o.get_mut();
877                        let count = builder.postings.len() as u32;
878                        let offset = self.posting_spill_offset;
879
880                        // Lazily create the spill file on first spill
881                        let spill_file = if let Some(ref mut f) = self.posting_spill_file {
882                            f
883                        } else {
884                            self.posting_spill_file = Some(BufWriter::with_capacity(
885                                256 * 1024,
886                                OpenOptions::new()
887                                    .create(true)
888                                    .write(true)
889                                    .truncate(true)
890                                    .open(&self.posting_spill_path)?,
891                            ));
892                            self.posting_spill_file.as_mut().unwrap()
893                        };
894                        for p in &builder.postings {
895                            spill_file.write_u32::<LittleEndian>(p.doc_id)?;
896                            spill_file.write_u16::<LittleEndian>(p.term_freq)?;
897                        }
898                        self.posting_spill_offset += count as u64 * 6;
899                        self.posting_spill_index
900                            .entry(term_key)
901                            .or_default()
902                            .push((offset, count));
903
904                        let freed = builder.postings.len() * size_of::<CompactPosting>();
905                        builder.spilled_count += count;
906                        builder.postings.clear();
907                        self.estimated_memory -= freed;
908                    }
909                }
910                hashbrown::hash_map::Entry::Vacant(v) => {
911                    let mut posting = PostingListBuilder::new();
912                    posting.add(doc_id, tf);
913                    v.insert(posting);
914                    self.estimated_memory += size_of::<CompactPosting>() + NEW_TERM_OVERHEAD;
915                }
916            }
917
918            if position_mode.is_some()
919                && let Some(positions) = self.local_positions.get(&term_spur)
920            {
921                match self.position_index.entry(term_key) {
922                    hashbrown::hash_map::Entry::Occupied(mut o) => {
923                        for &pos in positions {
924                            o.get_mut().add_position(doc_id, pos);
925                        }
926                        self.estimated_memory += positions.len() * size_of::<u32>();
927                    }
928                    hashbrown::hash_map::Entry::Vacant(v) => {
929                        let mut pos_posting = PositionPostingListBuilder::new();
930                        for &pos in positions {
931                            pos_posting.add_position(doc_id, pos);
932                        }
933                        self.estimated_memory +=
934                            positions.len() * size_of::<u32>() + NEW_POS_TERM_OVERHEAD;
935                        v.insert(pos_posting);
936                    }
937                }
938            }
939        }
940
941        Ok(token_position)
942    }
943
944    /// Saturate a token position at the 20-bit packed-encoding maximum so it
945    /// cannot bleed into the element-ordinal bits.
946    #[inline]
947    fn saturate_token_position(&mut self, token_position: u32) -> u32 {
948        if token_position > MAX_TOKEN_POSITION {
949            self.warn_position_saturation("token position", token_position, MAX_TOKEN_POSITION);
950            MAX_TOKEN_POSITION
951        } else {
952            token_position
953        }
954    }
955
956    /// Warn once per segment when the packed position encoding saturates.
957    #[cold]
958    fn warn_position_saturation(&mut self, what: &str, value: u32, max: u32) {
959        if !self.position_saturation_warned {
960            self.position_saturation_warned = true;
961            log::warn!(
962                "[segment_builder] index={} {what} {value} exceeds the position-encoding limit {max}; \
963                 saturating — phrase/ordinal matching degrades for the overflowing \
964                 elements/tokens instead of corrupting other documents' matches \
965                 (further occurrences in this segment are not logged)",
966                self.schema.index_label()
967            );
968        }
969    }
970
971    fn index_numeric_field(&mut self, field: Field, doc_id: DocId, value: u64) -> Result<()> {
972        use std::fmt::Write;
973
974        self.numeric_buffer.clear();
975        write!(self.numeric_buffer, "__num_{}", value).unwrap();
976        let term_spur = if let Some(spur) = self.term_interner.get(&self.numeric_buffer) {
977            spur
978        } else {
979            let spur = self.term_interner.get_or_intern(&self.numeric_buffer);
980            self.estimated_memory += self.numeric_buffer.len() + INTERN_OVERHEAD;
981            spur
982        };
983
984        let term_key = TermKey {
985            field: field.0,
986            term: term_spur,
987        };
988
989        match self.inverted_index.entry(term_key) {
990            hashbrown::hash_map::Entry::Occupied(mut o) => {
991                o.get_mut().add(doc_id, 1);
992                self.estimated_memory += size_of::<CompactPosting>();
993            }
994            hashbrown::hash_map::Entry::Vacant(v) => {
995                let mut posting = PostingListBuilder::new();
996                posting.add(doc_id, 1);
997                v.insert(posting);
998                self.estimated_memory += size_of::<CompactPosting>() + NEW_TERM_OVERHEAD;
999            }
1000        }
1001
1002        Ok(())
1003    }
1004
1005    /// Index a dense vector field with ordinal tracking
1006    fn index_dense_vector_field(
1007        &mut self,
1008        field: Field,
1009        doc_id: DocId,
1010        ordinal: u16,
1011        vector: &[f32],
1012    ) -> Result<()> {
1013        let dim = vector.len();
1014        let expected_dim = self
1015            .schema
1016            .get_field_entry(field)
1017            .and_then(|entry| entry.dense_vector_config.as_ref())
1018            .map(|config| config.dim)
1019            .ok_or_else(|| crate::Error::Schema("DenseVector field missing config".to_string()))?;
1020        if dim != expected_dim {
1021            return Err(crate::Error::Schema(format!(
1022                "Dense vector dimension mismatch: schema expects {}, got {}",
1023                expected_dim, dim
1024            )));
1025        }
1026        if let Some((index, value)) = vector
1027            .iter()
1028            .enumerate()
1029            .find(|(_, value)| !value.is_finite())
1030        {
1031            return Err(crate::Error::Document(format!(
1032                "dense vector contains non-finite value {value} at index {index}"
1033            )));
1034        }
1035
1036        let builder = self
1037            .dense_vectors
1038            .entry(field.0)
1039            .or_insert_with(|| DenseVectorBuilder::new(dim));
1040
1041        // Verify dimension consistency
1042        if builder.dim != dim && builder.len() > 0 {
1043            return Err(crate::Error::Schema(format!(
1044                "Dense vector dimension mismatch: expected {}, got {}",
1045                builder.dim, dim
1046            )));
1047        }
1048
1049        builder.add(doc_id, ordinal, vector);
1050
1051        self.estimated_memory += std::mem::size_of_val(vector) + size_of::<(DocId, u16)>();
1052
1053        Ok(())
1054    }
1055
1056    /// Index a binary dense vector field with ordinal tracking
1057    fn index_binary_dense_vector_field(
1058        &mut self,
1059        field: Field,
1060        doc_id: DocId,
1061        ordinal: u16,
1062        bytes: &[u8],
1063    ) -> Result<()> {
1064        let dim_bits = self
1065            .schema
1066            .get_field_entry(field)
1067            .and_then(|e| e.binary_dense_vector_config.as_ref())
1068            .map(|c| c.dim)
1069            .ok_or_else(|| {
1070                crate::Error::Schema("BinaryDenseVector field missing config".to_string())
1071            })?;
1072
1073        let expected_byte_len = dim_bits.div_ceil(8);
1074        if dim_bits == 0 || !dim_bits.is_multiple_of(8) {
1075            return Err(crate::Error::Schema(format!(
1076                "Binary vector dimension must be a positive multiple of 8, got {dim_bits}"
1077            )));
1078        }
1079        if bytes.len() != expected_byte_len {
1080            return Err(crate::Error::Schema(format!(
1081                "Binary vector byte length mismatch: expected {} (dim={}), got {}",
1082                expected_byte_len,
1083                dim_bits,
1084                bytes.len()
1085            )));
1086        }
1087
1088        let builder = self
1089            .binary_dense_vectors
1090            .entry(field.0)
1091            .or_insert_with(|| BinaryDenseVectorBuilder::new(dim_bits));
1092
1093        builder.add(doc_id, ordinal, bytes);
1094        self.estimated_memory += bytes.len() + size_of::<(DocId, u16)>();
1095
1096        Ok(())
1097    }
1098
1099    /// Index a sparse vector field using dedicated sparse posting lists
1100    ///
1101    /// Collects (doc_id, ordinal, weight) postings per dimension. During commit, these are
1102    /// converted to BlockSparsePostingList with proper quantization from SparseVectorConfig.
1103    ///
1104    /// Weights below the configured `weight_threshold` are not indexed. When
1105    /// `doc_mass` is configured, only the top-|weight| entries covering that
1106    /// fraction of the vector's total |weight| mass are kept (the excessive
1107    /// tail of SPLADE-style vectors is cropped).
1108    fn index_sparse_vector_field(
1109        &mut self,
1110        field: Field,
1111        doc_id: DocId,
1112        ordinal: u16,
1113        entries: &[(u32, f32)],
1114    ) -> Result<()> {
1115        if let Some((index, (_, weight))) = entries
1116            .iter()
1117            .enumerate()
1118            .find(|(_, (_, weight))| !weight.is_finite())
1119        {
1120            return Err(crate::Error::Document(format!(
1121                "sparse vector contains non-finite weight {weight} at index {index}"
1122            )));
1123        }
1124        let (weight_threshold, doc_mass, min_terms) = self
1125            .schema
1126            .get_field_entry(field)
1127            .and_then(|entry| entry.sparse_vector_config.as_ref())
1128            .map(|config| (config.weight_threshold, config.doc_mass, config.min_terms))
1129            .unwrap_or((0.0, None, 0));
1130
1131        let builder = self
1132            .sparse_vectors
1133            .entry(field.0)
1134            .or_insert_with(SparseVectorBuilder::new);
1135
1136        builder.inc_vector_count();
1137
1138        // Document-side mass cropping: determine the per-vector weight cutoff
1139        // below which entries fall outside the doc_mass fraction of total mass.
1140        // Short vectors (<= min_terms entries) are never cropped.
1141        let mass_cutoff = match doc_mass {
1142            Some(mass) if mass < 1.0 && entries.len() > min_terms => {
1143                let mut weights: Vec<f32> = entries
1144                    .iter()
1145                    .map(|&(_, w)| w.abs())
1146                    .filter(|w| *w >= weight_threshold)
1147                    .collect();
1148                weights.sort_unstable_by(|a, b| b.total_cmp(a));
1149                let total: f64 = weights.iter().map(|&w| w as f64).sum();
1150                let target = total * mass as f64;
1151                let mut cumulative = 0.0f64;
1152                let mut cutoff = 0.0f32;
1153                for &w in &weights {
1154                    if cumulative >= target {
1155                        break;
1156                    }
1157                    cumulative += w as f64;
1158                    cutoff = w;
1159                }
1160                cutoff
1161            }
1162            _ => 0.0,
1163        };
1164
1165        for &(dim_id, weight) in entries {
1166            // Skip weights below threshold or outside the doc_mass prefix
1167            if weight.abs() < weight_threshold || weight.abs() < mass_cutoff {
1168                continue;
1169            }
1170
1171            let is_new_dim = !builder.postings.contains_key(&dim_id);
1172            builder.add(dim_id, doc_id, ordinal, weight);
1173            self.estimated_memory += size_of::<(DocId, u16, f32)>();
1174            if is_new_dim {
1175                // HashMap entry overhead + Vec header
1176                self.estimated_memory += size_of::<u32>() + size_of::<Vec<(DocId, u16, f32)>>() + 8; // 8 = hashmap control byte + padding
1177            }
1178        }
1179
1180        Ok(())
1181    }
1182
1183    /// Write document to streaming store (reuses internal buffer to avoid per-doc allocation)
1184    fn write_document_to_store(&mut self, doc: &Document) -> Result<()> {
1185        use byteorder::{LittleEndian, WriteBytesExt};
1186
1187        super::store::serialize_document_into(doc, &self.schema, &mut self.doc_serialize_buffer)?;
1188
1189        #[cfg(feature = "native")]
1190        {
1191            self.store_file
1192                .write_u32::<LittleEndian>(self.doc_serialize_buffer.len() as u32)?;
1193            self.store_file.write_all(&self.doc_serialize_buffer)?;
1194        }
1195        #[cfg(not(feature = "native"))]
1196        {
1197            self.store_buffer
1198                .write_u32::<LittleEndian>(self.doc_serialize_buffer.len() as u32)?;
1199            self.store_buffer.write_all(&self.doc_serialize_buffer)?;
1200            // The in-memory store buffer is often the largest allocation on
1201            // the wasm branch (native streams docs to a temp file instead).
1202            // Count it so the memory-budget flush check can see it.
1203            self.estimated_memory += size_of::<u32>() + self.doc_serialize_buffer.len();
1204        }
1205
1206        Ok(())
1207    }
1208
1209    /// Build the final segment
1210    ///
1211    /// Streams all data directly to disk via StreamingWriter to avoid buffering
1212    /// entire serialized outputs in memory. Each phase consumes and drops its
1213    /// source data before the next phase begins.
1214    pub async fn build<D: Directory + DirectoryWriter>(
1215        mut self,
1216        dir: &D,
1217        segment_id: SegmentId,
1218        trained: Option<&super::TrainedVectorStructures>,
1219    ) -> Result<SegmentMeta> {
1220        // Flush any buffered data
1221        #[cfg(feature = "native")]
1222        self.store_file.flush()?;
1223
1224        let files = SegmentFiles::new(segment_id.0);
1225
1226        // Phase 1: Stream positions directly to disk (consumes position_index)
1227        let position_index = std::mem::take(&mut self.position_index);
1228        let position_offsets = if !position_index.is_empty() {
1229            let mut pos_writer = dir.streaming_writer(&files.positions).await?;
1230            let offsets = postings::build_positions_streaming(
1231                position_index,
1232                &self.term_interner,
1233                &mut *pos_writer,
1234            )?;
1235            pos_writer.finish()?;
1236            offsets
1237        } else {
1238            FxHashMap::default()
1239        };
1240
1241        // Phase 2: 4-way parallel build — postings, store, dense vectors, sparse vectors
1242        // These are fully independent: different source data, different output files.
1243        let inverted_index = std::mem::take(&mut self.inverted_index);
1244        let term_interner = std::mem::replace(&mut self.term_interner, Rodeo::new());
1245        #[cfg(feature = "native")]
1246        let store_path = self.store_path.clone();
1247        #[cfg(feature = "native")]
1248        let num_compression_threads = self.config.num_compression_threads;
1249        let compression_level = self.config.compression_level;
1250        let dense_vectors = std::mem::take(&mut self.dense_vectors);
1251        let binary_dense_vectors = std::mem::take(&mut self.binary_dense_vectors);
1252        let mut sparse_vectors = std::mem::take(&mut self.sparse_vectors);
1253        let schema = &self.schema;
1254
1255        // Pre-create all streaming writers (async) before entering sync rayon scope
1256        // Wrapped in OffsetWriter to track bytes written per phase.
1257        let mut term_dict_writer =
1258            super::OffsetWriter::new(dir.streaming_writer(&files.term_dict).await?);
1259        let mut postings_writer =
1260            super::OffsetWriter::new(dir.streaming_writer(&files.postings).await?);
1261        let mut store_writer = super::OffsetWriter::new(dir.streaming_writer(&files.store).await?);
1262        let mut vectors_writer = if !dense_vectors.is_empty() || !binary_dense_vectors.is_empty() {
1263            Some(super::OffsetWriter::new(
1264                dir.streaming_writer(&files.vectors).await?,
1265            ))
1266        } else {
1267            None
1268        };
1269        let mut sparse_writer = if !sparse_vectors.is_empty() {
1270            Some(super::OffsetWriter::new(
1271                dir.streaming_writer(&files.sparse).await?,
1272            ))
1273        } else {
1274            None
1275        };
1276        let mut fast_fields = std::mem::take(&mut self.fast_fields);
1277        let num_docs = self.next_doc_id;
1278        let mut fast_writer = if !fast_fields.is_empty() {
1279            Some(super::OffsetWriter::new(
1280                dir.streaming_writer(&files.fast).await?,
1281            ))
1282        } else {
1283            None
1284        };
1285
1286        #[cfg(feature = "native")]
1287        {
1288            if let Some(ref mut f) = self.posting_spill_file {
1289                f.flush()?;
1290            }
1291            let posting_spill_index = std::mem::take(&mut self.posting_spill_index);
1292            let mut spill_reader_opt = if !posting_spill_index.is_empty() {
1293                let spill_file = std::fs::File::open(&self.posting_spill_path)?;
1294                Some((std::io::BufReader::new(spill_file), posting_spill_index))
1295            } else {
1296                None
1297            };
1298
1299            let ((postings_result, store_result), ((vectors_result, sparse_result), fast_result)) =
1300                rayon::join(
1301                    || {
1302                        rayon::join(
1303                            || {
1304                                let spill_arg = spill_reader_opt.as_mut().map(|(r, idx)| {
1305                                    (
1306                                        r as &mut std::io::BufReader<std::fs::File>,
1307                                        idx as &postings::SpillIndex,
1308                                    )
1309                                });
1310                                postings::build_postings_streaming(
1311                                    inverted_index,
1312                                    term_interner,
1313                                    &position_offsets,
1314                                    &mut term_dict_writer,
1315                                    &mut postings_writer,
1316                                    spill_arg,
1317                                )
1318                            },
1319                            || {
1320                                store::build_store_streaming(
1321                                    &store_path,
1322                                    num_compression_threads,
1323                                    compression_level,
1324                                    &mut store_writer,
1325                                    num_docs,
1326                                )
1327                            },
1328                        )
1329                    },
1330                    || {
1331                        rayon::join(
1332                            || {
1333                                rayon::join(
1334                                    || -> Result<()> {
1335                                        if let Some(ref mut w) = vectors_writer {
1336                                            dense::build_vectors_streaming(
1337                                                dense_vectors,
1338                                                binary_dense_vectors,
1339                                                schema,
1340                                                trained,
1341                                                w,
1342                                            )?;
1343                                        }
1344                                        Ok(())
1345                                    },
1346                                    || -> Result<()> {
1347                                        if let Some(ref mut w) = sparse_writer {
1348                                            sparse::build_sparse_streaming(
1349                                                &mut sparse_vectors,
1350                                                schema,
1351                                                w,
1352                                            )?;
1353                                        }
1354                                        Ok(())
1355                                    },
1356                                )
1357                            },
1358                            || -> Result<()> {
1359                                if let Some(ref mut w) = fast_writer {
1360                                    build_fast_fields_streaming(&mut fast_fields, num_docs, w)?;
1361                                }
1362                                Ok(())
1363                            },
1364                        )
1365                    },
1366                );
1367            postings_result?;
1368            store_result?;
1369            vectors_result?;
1370            sparse_result?;
1371            fast_result?;
1372        }
1373
1374        #[cfg(not(feature = "native"))]
1375        {
1376            postings::build_postings_streaming(
1377                inverted_index,
1378                term_interner,
1379                &position_offsets,
1380                &mut term_dict_writer,
1381                &mut postings_writer,
1382            )?;
1383            store::build_store_streaming_from_buffer(
1384                &self.store_buffer,
1385                compression_level,
1386                &mut store_writer,
1387                num_docs,
1388            )?;
1389            if let Some(ref mut w) = vectors_writer {
1390                dense::build_vectors_streaming(
1391                    dense_vectors,
1392                    binary_dense_vectors,
1393                    schema,
1394                    trained,
1395                    w,
1396                )?;
1397            }
1398            if let Some(ref mut w) = sparse_writer {
1399                sparse::build_sparse_streaming(&mut sparse_vectors, schema, w)?;
1400            }
1401            if let Some(ref mut w) = fast_writer {
1402                build_fast_fields_streaming(&mut fast_fields, num_docs, w)?;
1403            }
1404        }
1405
1406        let term_dict_bytes = term_dict_writer.offset() as usize;
1407        let postings_bytes = postings_writer.offset() as usize;
1408        let store_bytes = store_writer.offset() as usize;
1409        let vectors_bytes = vectors_writer.as_ref().map_or(0, |w| w.offset() as usize);
1410        let sparse_bytes = sparse_writer.as_ref().map_or(0, |w| w.offset() as usize);
1411        let fast_bytes = fast_writer.as_ref().map_or(0, |w| w.offset() as usize);
1412
1413        term_dict_writer.finish()?;
1414        postings_writer.finish()?;
1415        store_writer.finish()?;
1416        if let Some(w) = vectors_writer {
1417            w.finish()?;
1418        }
1419        if let Some(w) = sparse_writer {
1420            w.finish()?;
1421        }
1422        if let Some(w) = fast_writer {
1423            w.finish()?;
1424        }
1425        drop(position_offsets);
1426        drop(sparse_vectors);
1427
1428        log::info!(
1429            "[segment_build] index={} docs={}: term_dict={}, postings={}, store={}, dense_vectors={}, sparse_vectors={}, fast_fields={}",
1430            self.schema.index_label(),
1431            num_docs,
1432            crate::format_bytes(term_dict_bytes as u64),
1433            crate::format_bytes(postings_bytes as u64),
1434            crate::format_bytes(store_bytes as u64),
1435            crate::format_bytes(vectors_bytes as u64),
1436            crate::format_bytes(sparse_bytes as u64),
1437            crate::format_bytes(fast_bytes as u64),
1438        );
1439
1440        let meta = SegmentMeta {
1441            id: segment_id.0,
1442            num_docs: self.next_doc_id,
1443            field_stats: self.field_stats.clone(),
1444        };
1445
1446        // Durable: committed metadata.json will reference this segment, so a
1447        // torn/unsynced .meta after power loss would make the commit
1448        // unreadable (every other segment file is fsynced by its streaming
1449        // writer's finish()).
1450        dir.write_durable(&files.meta, &meta.serialize()?).await?;
1451
1452        // Cleanup temp files
1453        #[cfg(feature = "native")]
1454        {
1455            let _ = std::fs::remove_file(&self.store_path);
1456        }
1457
1458        Ok(meta)
1459    }
1460}
1461
1462/// Serialize all fast-field columns to a `.fast` file.
1463fn build_fast_fields_streaming(
1464    fast_fields: &mut FxHashMap<u32, crate::structures::fast_field::FastFieldWriter>,
1465    num_docs: u32,
1466    writer: &mut dyn Write,
1467) -> Result<()> {
1468    use crate::structures::fast_field::{FastFieldTocEntry, write_fast_field_toc_and_footer};
1469
1470    if fast_fields.is_empty() {
1471        return Ok(());
1472    }
1473
1474    // Sort fields by id for deterministic output
1475    let mut field_ids: Vec<u32> = fast_fields.keys().copied().collect();
1476    field_ids.sort_unstable();
1477
1478    let mut toc_entries: Vec<FastFieldTocEntry> = Vec::with_capacity(field_ids.len());
1479    let mut current_offset = 0u64;
1480
1481    for &field_id in &field_ids {
1482        let ff = fast_fields.get_mut(&field_id).unwrap();
1483        ff.pad_to(num_docs);
1484
1485        let (mut toc, bytes_written) = ff.serialize(writer, current_offset)?;
1486        toc.field_id = field_id;
1487        current_offset += bytes_written;
1488        toc_entries.push(toc);
1489    }
1490
1491    // Write TOC + footer
1492    let toc_offset = current_offset;
1493    write_fast_field_toc_and_footer(writer, toc_offset, &toc_entries)?;
1494
1495    Ok(())
1496}
1497
1498#[cfg(feature = "native")]
1499impl Drop for SegmentBuilder {
1500    fn drop(&mut self) {
1501        let _ = std::fs::remove_file(&self.store_path);
1502        if self.posting_spill_file.is_some() {
1503            let _ = std::fs::remove_file(&self.posting_spill_path);
1504        }
1505    }
1506}
1507
1508#[cfg(test)]
1509impl SegmentBuilder {
1510    /// Test helper: all encoded positions recorded for `(field, term)`.
1511    fn positions_for_term(&self, field: Field, term: &str) -> Vec<u32> {
1512        let Some(spur) = self.term_interner.get(term) else {
1513            return Vec::new();
1514        };
1515        let key = TermKey {
1516            field: field.0,
1517            term: spur,
1518        };
1519        self.position_index
1520            .get(&key)
1521            .map(|b| {
1522                b.postings
1523                    .iter()
1524                    .flat_map(|(_, ps)| ps.iter().copied())
1525                    .collect()
1526            })
1527            .unwrap_or_default()
1528    }
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533    use super::*;
1534    use crate::dsl::SchemaBuilder;
1535
1536    fn builder_for(schema: Schema) -> SegmentBuilder {
1537        SegmentBuilder::new(Arc::new(schema), SegmentBuilderConfig::default()).unwrap()
1538    }
1539
1540    // ------------------------------------------------------------------
1541    // Finding: field values whose runtime type does not match the schema
1542    // field type fell into `_ => {}` and were silently not indexed while
1543    // still being stored — queries could never match the document.
1544    // ------------------------------------------------------------------
1545    #[test]
1546    fn test_add_document_rejects_type_mismatched_field_value() {
1547        let mut sb = SchemaBuilder::default();
1548        let views = sb.add_u64_field("views", true, true);
1549        let mut builder = builder_for(sb.build());
1550
1551        let mut doc = Document::new();
1552        doc.add_text(views, "123");
1553        let err = builder
1554            .add_document(doc)
1555            .expect_err("schema-mismatched value must be rejected loudly, not silently unindexed");
1556        let msg = err.to_string();
1557        assert!(msg.contains("views"), "error must name the field: {msg}");
1558        assert!(
1559            msg.contains("u64"),
1560            "error must name the expected type: {msg}"
1561        );
1562        assert!(msg.contains("text"), "error must name the got type: {msg}");
1563
1564        // The rejected document must not have consumed a doc id (no poisoning).
1565        assert_eq!(builder.num_docs(), 0);
1566
1567        // A well-typed document still indexes fine afterwards.
1568        let mut doc = Document::new();
1569        doc.add_u64(views, 123);
1570        builder.add_document(doc).unwrap();
1571        assert_eq!(builder.num_docs(), 1);
1572    }
1573
1574    // ------------------------------------------------------------------
1575    // Finding: sparse entries with dim_id >= the configured BMP `dims`
1576    // were accepted at index time but silently dropped from the BMP grid
1577    // and silently filtered from queries — permanently unsearchable.
1578    // ------------------------------------------------------------------
1579    #[test]
1580    fn test_add_document_rejects_bmp_sparse_dim_out_of_range() {
1581        use crate::structures::{SparseFormat, SparseVectorConfig};
1582
1583        let mut sb = SchemaBuilder::default();
1584        let config = SparseVectorConfig {
1585            format: SparseFormat::Bmp,
1586            dims: Some(100),
1587            ..Default::default()
1588        };
1589        let spv = sb.add_sparse_vector_field_with_config("spv", true, false, config);
1590        let mut builder = builder_for(sb.build());
1591
1592        // In-range dims are accepted.
1593        let mut doc = Document::new();
1594        doc.add_sparse_vector(spv, vec![(50, 1.0)]);
1595        builder.add_document(doc).unwrap();
1596
1597        // dim_id >= dims must be rejected with an actionable error.
1598        let mut doc = Document::new();
1599        doc.add_sparse_vector(spv, vec![(50, 1.0), (150, 2.0)]);
1600        let err = builder
1601            .add_document(doc)
1602            .expect_err("out-of-range BMP dim must be rejected, not silently unsearchable");
1603        let msg = err.to_string();
1604        assert!(msg.contains("spv"), "error must name the field: {msg}");
1605        assert!(msg.contains("150"), "error must name the dim_id: {msg}");
1606        assert!(
1607            msg.contains("100"),
1608            "error must name the configured dims: {msg}"
1609        );
1610        assert_eq!(
1611            builder.num_docs(),
1612            1,
1613            "rejected doc must not consume a doc id"
1614        );
1615    }
1616
1617    #[test]
1618    fn test_add_document_maxscore_sparse_dims_unbounded() {
1619        // MaxScore-format sparse fields have no dims bound — large dim ids
1620        // stay legal (the per-dim TOC addresses any u32 dimension).
1621        let mut sb = SchemaBuilder::default();
1622        let spv = sb.add_sparse_vector_field("spv", true, false);
1623        let mut builder = builder_for(sb.build());
1624
1625        let mut doc = Document::new();
1626        doc.add_sparse_vector(spv, vec![(3_000_000, 1.0)]);
1627        builder.add_document(doc).unwrap();
1628    }
1629
1630    // ------------------------------------------------------------------
1631    // Finding: `(element_ordinal << 20) | token_position` silently
1632    // corrupted when element_ordinal >= 4096 (shifted out of the u32,
1633    // aliasing element 0) or token_position >= 2^20 (bleeding into the
1634    // ordinal bits). Both must saturate at their field maxima.
1635    // ------------------------------------------------------------------
1636    #[test]
1637    fn test_position_element_ordinal_overflow_saturates_instead_of_wrapping() {
1638        use crate::dsl::PositionMode;
1639
1640        let mut sb = SchemaBuilder::default();
1641        let body = sb.add_text_field("body", true, false);
1642        sb.set_positions(body, PositionMode::Full);
1643        let mut builder = builder_for(sb.build());
1644
1645        // 4097 values: element ordinal 4096 does not fit the 12-bit ordinal
1646        // field ((4096u32 << 20) wraps to 0, colliding with element 0).
1647        let mut doc = Document::new();
1648        doc.add_text(body, "anchor");
1649        for _ in 0..4095 {
1650            doc.add_text(body, "filler");
1651        }
1652        doc.add_text(body, "needle");
1653        builder.add_document(doc).unwrap();
1654
1655        let positions = builder.positions_for_term(body, "needle");
1656        assert_eq!(positions.len(), 1);
1657        let encoded = positions[0];
1658        assert_ne!(
1659            encoded >> 20,
1660            0,
1661            "element ordinal 4096 must not alias element 0"
1662        );
1663        assert_eq!(
1664            encoded >> 20,
1665            4095,
1666            "overflowing element ordinal must saturate at 4095"
1667        );
1668    }
1669
1670    #[test]
1671    fn test_position_token_position_overflow_saturates_instead_of_bleeding() {
1672        use crate::dsl::PositionMode;
1673
1674        let mut sb = SchemaBuilder::default();
1675        let body = sb.add_text_field("body", true, false);
1676        sb.set_positions(body, PositionMode::Full);
1677        let mut builder = builder_for(sb.build());
1678
1679        // One value with 2^20 + 1 tokens: the last token's position does not
1680        // fit the 20-bit position field and would bleed into ordinal bit 0.
1681        let mut text = "w ".repeat(1 << 20);
1682        text.push_str("needle");
1683        let mut doc = Document::new();
1684        doc.add_text(body, text);
1685        builder.add_document(doc).unwrap();
1686
1687        let positions = builder.positions_for_term(body, "needle");
1688        assert_eq!(positions.len(), 1);
1689        let encoded = positions[0];
1690        assert_eq!(
1691            encoded >> 20,
1692            0,
1693            "token position overflow must not decode as a different element ordinal"
1694        );
1695        assert_eq!(
1696            encoded & 0xFFFFF,
1697            0xFFFFF,
1698            "overflowing token position must saturate at 2^20 - 1"
1699        );
1700    }
1701
1702    // ------------------------------------------------------------------
1703    // Finding: a posting-list spill firing between two values of the same
1704    // document split that document's postings across the spilled range and
1705    // the in-memory tail; the build-time merge concatenated them without
1706    // deduplication (inflated doc_freq, doc visited twice, split tf).
1707    // ------------------------------------------------------------------
1708    #[cfg(feature = "native")]
1709    #[tokio::test]
1710    async fn test_spill_mid_document_does_not_duplicate_postings() {
1711        use crate::directories::RamDirectory;
1712        use crate::structures::TERMINATED;
1713
1714        let mut sb = SchemaBuilder::default();
1715        let body = sb.add_text_field("body", true, false);
1716        let schema = Arc::new(sb.build());
1717        let mut builder =
1718            SegmentBuilder::new(Arc::clone(&schema), SegmentBuilderConfig::default()).unwrap();
1719
1720        // Docs 0..16382 each contribute one posting for "hot", leaving the
1721        // in-memory posting list one entry short of SPILL_THRESHOLD (16384).
1722        for _ in 0..16383 {
1723            let mut doc = Document::new();
1724            doc.add_text(body, "hot");
1725            builder.add_document(doc).unwrap();
1726        }
1727
1728        // Doc 16383 has TWO values containing "hot": indexing the first value
1729        // reaches the spill threshold and spills the list INCLUDING this doc's
1730        // entry; the second value then re-adds the same doc to the now-empty
1731        // in-memory tail.
1732        let mut doc = Document::new();
1733        doc.add_text(body, "hot");
1734        doc.add_text(body, "hot");
1735        let boundary_doc = builder.add_document(doc).unwrap();
1736        assert_eq!(boundary_doc, 16383);
1737
1738        let dir = RamDirectory::new();
1739        let segment_id = crate::segment::SegmentId::new();
1740        builder.build(&dir, segment_id, None).await.unwrap();
1741
1742        let reader = crate::segment::SegmentReader::open(&dir, segment_id, schema, 16)
1743            .await
1744            .unwrap();
1745        let postings = reader
1746            .get_postings(body, b"hot")
1747            .await
1748            .unwrap()
1749            .expect("postings for 'hot'");
1750        assert_eq!(
1751            postings.doc_count(),
1752            16384,
1753            "each document must appear exactly once per term (spill-boundary duplicate)"
1754        );
1755
1756        // Doc ids must be strictly increasing and the boundary document's
1757        // split term frequency must be merged into a single posting.
1758        let mut it = postings.iterator();
1759        let mut prev: Option<DocId> = None;
1760        let mut boundary_tf = 0u32;
1761        let mut d = it.doc();
1762        while d != TERMINATED {
1763            if let Some(p) = prev {
1764                assert!(p < d, "duplicate/unordered doc id {d} after {p}");
1765            }
1766            if d == boundary_doc {
1767                boundary_tf = it.term_freq();
1768            }
1769            prev = Some(d);
1770            d = it.advance();
1771        }
1772        assert_eq!(prev, Some(boundary_doc));
1773        assert_eq!(
1774            boundary_tf, 2,
1775            "boundary doc's term frequency must combine both values"
1776        );
1777    }
1778}