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