Skip to main content

hermes_core/structures/fast_field/
mod.rs

1//! Fast field columnar storage for efficient filtering and sorting.
2//!
3//! Stores one column per fast-field, indexed by doc_id for O(1) access.
4//! Supports u64, i64, f64, and text (dictionary-encoded ordinal) columns.
5//! Both single-valued and multi-valued columns are supported.
6//!
7//! ## File format (`.fast` — version FST2)
8//!
9//! ```text
10//! [column 0 blocked data] [column 1 blocked data] ... [column N blocked data]
11//! [TOC: FastFieldTocEntry × num_columns]
12//! [footer: toc_offset(8) + num_columns(4) + magic(4)]  = 16 bytes
13//! ```
14//!
15//! ## Blocked column format
16//!
17//! Each column's data region is a sequence of independently-decodable blocks:
18//!
19//! ```text
20//! [num_blocks: u32]
21//! [block_index: BlockIndexEntry × num_blocks]   (16 bytes each)
22//! [block_0 data] [block_0 dict?] [block_1 data] [block_1 dict?] ...
23//! ```
24//!
25//! `BlockIndexEntry`: num_docs(4) + data_len(4) + dict_count(4) + dict_len(4)
26//!
27//! Fresh segments produce a single block. Merges stack blocks from source
28//! segments via raw byte copy (memcpy) — no per-value decode/re-encode.
29//!
30//! ## Codecs (auto-selected per block at build time)
31//!
32//! | ID | Codec           | Description                               |
33//! |----|-----------------|-------------------------------------------|
34//! |  0 | Constant        | All values identical — 0 data bytes       |
35//! |  1 | Bitpacked       | min-subtract + global bitpack             |
36//! |  2 | Linear          | Regression line + bitpacked residuals     |
37//! |  3 | BlockwiseLinear | Per-512-block linear + residuals          |
38
39pub mod codec;
40
41use std::collections::BTreeMap;
42use std::io::{self, Read, Write};
43use std::sync::OnceLock;
44
45use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
46
47// ── Constants ─────────────────────────────────────────────────────────────
48
49/// Magic number for `.fast` file footer — FST2 (auto-codec + multi-value)
50pub const FAST_FIELD_MAGIC: u32 = 0x32545346;
51
52/// Footer size: toc_offset(8) + num_columns(4) + magic(4) = 16
53pub const FAST_FIELD_FOOTER_SIZE: u64 = 16;
54
55/// Sentinel for missing / absent values in any fast-field column type.
56///
57/// - **Text**: document has no value → ordinal stored as `u64::MAX`
58/// - **Numeric (u64/i64/f64)**: document has no value → raw stored as `u64::MAX`
59///
60/// Callers should check `raw != FAST_FIELD_MISSING` before interpreting
61/// the value as a real number or ordinal.
62pub const FAST_FIELD_MISSING: u64 = u64::MAX;
63
64// ── Column type ───────────────────────────────────────────────────────────
65
66/// Type of a fast-field column (stored in TOC).
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[repr(u8)]
69pub enum FastFieldColumnType {
70    U64 = 0,
71    I64 = 1,
72    F64 = 2,
73    TextOrdinal = 3,
74}
75
76impl FastFieldColumnType {
77    pub fn from_u8(v: u8) -> Option<Self> {
78        match v {
79            0 => Some(Self::U64),
80            1 => Some(Self::I64),
81            2 => Some(Self::F64),
82            3 => Some(Self::TextOrdinal),
83            _ => None,
84        }
85    }
86}
87
88// ── Encoding helpers ──────────────────────────────────────────────────────
89
90/// Zigzag-encode an i64 to u64 (small absolute values → small u64).
91#[inline]
92pub fn zigzag_encode(v: i64) -> u64 {
93    ((v << 1) ^ (v >> 63)) as u64
94}
95
96/// Zigzag-decode a u64 back to i64.
97#[inline]
98pub fn zigzag_decode(v: u64) -> i64 {
99    ((v >> 1) as i64) ^ -((v & 1) as i64)
100}
101
102/// Encode f64 to u64 preserving total order.
103/// Positive floats: flip sign bit (so they sort above negatives).
104/// Negative floats: flip all bits (so they sort in reverse magnitude).
105#[inline]
106pub fn f64_to_sortable_u64(f: f64) -> u64 {
107    let bits = f.to_bits();
108    if (bits >> 63) == 0 {
109        bits ^ (1u64 << 63) // positive: flip sign bit
110    } else {
111        !bits // negative: flip all bits
112    }
113}
114
115/// Decode sortable u64 back to f64.
116#[inline]
117pub fn sortable_u64_to_f64(v: u64) -> f64 {
118    let bits = if (v >> 63) != 0 {
119        v ^ (1u64 << 63) // was positive: unflip sign bit
120    } else {
121        !v // was negative: unflip all bits
122    };
123    f64::from_bits(bits)
124}
125
126/// Minimum number of bits needed to represent `val`.
127#[inline]
128pub fn bits_needed_u64(val: u64) -> u8 {
129    if val == 0 {
130        0
131    } else {
132        64 - val.leading_zeros() as u8
133    }
134}
135
136// ── Bit-packing ───────────────────────────────────────────────────────────
137
138/// Pack `values` at `bits_per_value` bits each into `out`.
139/// `out` must be large enough: `ceil(values.len() * bits_per_value / 8)` bytes.
140pub fn bitpack_write(values: &[u64], bits_per_value: u8, out: &mut Vec<u8>) {
141    if bits_per_value == 0 {
142        return; // all values are the same (constant column)
143    }
144    let bpv = bits_per_value as usize;
145    let total_bits = values.len() * bpv;
146    let total_bytes = total_bits.div_ceil(8);
147    out.reserve(total_bytes);
148
149    let start = out.len();
150    out.resize(start + total_bytes, 0);
151    let buf = &mut out[start..];
152
153    for (i, &val) in values.iter().enumerate() {
154        let bit_offset = i * bpv;
155        let byte_offset = bit_offset / 8;
156        let bit_shift = bit_offset % 8;
157
158        // Write across byte boundaries (up to 9 bytes for 64-bit values)
159        let mut remaining_bits = bpv;
160        let mut v = val;
161        let mut bo = byte_offset;
162        let mut bs = bit_shift;
163
164        while remaining_bits > 0 {
165            let can_write = (8 - bs).min(remaining_bits);
166            let mask = (1u64 << can_write) - 1;
167            buf[bo] |= ((v & mask) << bs) as u8;
168            v >>= can_write;
169            remaining_bits -= can_write;
170            bo += 1;
171            bs = 0;
172        }
173    }
174}
175
176/// Read value at `index` from bit-packed data.
177///
178/// Fast path: reads a single unaligned u64 (LE) covering the target bits,
179/// shifts and masks. This compiles to ~4 instructions on x86/ARM and avoids
180/// the per-byte loop entirely for bpv ≤ 56.
181#[inline]
182pub fn bitpack_read(data: &[u8], bits_per_value: u8, index: usize) -> u64 {
183    if bits_per_value == 0 {
184        return 0;
185    }
186    let bpv = bits_per_value as usize;
187    let bit_offset = index * bpv;
188    let byte_offset = bit_offset / 8;
189    let bit_shift = bit_offset % 8;
190
191    // Fast path: single unaligned LE u64 load, shift, and mask.
192    // Valid when all needed bits fit within 8 bytes: bit_shift + bpv ≤ 64.
193    if bit_shift + bpv <= 64 && byte_offset + 8 <= data.len() {
194        let raw = u64::from_le_bytes(data[byte_offset..byte_offset + 8].try_into().unwrap());
195        let mask = if bpv >= 64 {
196            u64::MAX
197        } else {
198            (1u64 << bpv) - 1
199        };
200        return (raw >> bit_shift) & mask;
201    }
202
203    // Slow path for the last few values near the end of the buffer
204    let mut result: u64 = 0;
205    let mut remaining_bits = bpv;
206    let mut bo = byte_offset;
207    let mut bs = bit_shift;
208    let mut out_shift = 0;
209
210    while remaining_bits > 0 {
211        let can_read = (8 - bs).min(remaining_bits);
212        let mask = ((1u64 << can_read) - 1) as u8;
213        let byte_val = if bo < data.len() { data[bo] } else { 0 };
214        result |= (((byte_val >> bs) & mask) as u64) << out_shift;
215        remaining_bits -= can_read;
216        out_shift += can_read;
217        bo += 1;
218        bs = 0;
219    }
220
221    result
222}
223
224// ── TOC entry ─────────────────────────────────────────────────────────────
225
226/// On-disk TOC entry for a fast-field column (FST2 format).
227///
228/// Wire: field_id(4) + column_type(1) + flags(1) + data_offset(8) + data_len(8) +
229///       num_docs(4) + dict_offset(8) + dict_count(4) = 38 bytes
230///
231/// The `flags` byte encodes:
232///   bit 0: multi-valued column (offset+value sub-columns)
233///
234/// For multi-valued columns, the data region contains:
235///   [offset column (auto-codec)] [value column (auto-codec)]
236///   with a 4-byte length prefix for the offset column so the reader knows where
237///   the value column starts.
238#[derive(Debug, Clone)]
239pub struct FastFieldTocEntry {
240    pub field_id: u32,
241    pub column_type: FastFieldColumnType,
242    pub multi: bool,
243    pub data_offset: u64,
244    pub data_len: u64,
245    pub num_docs: u32,
246    /// Byte offset of the text dictionary section (0 for numeric columns).
247    pub dict_offset: u64,
248    /// Number of entries in the text dictionary (0 for numeric columns).
249    pub dict_count: u32,
250}
251
252/// FST2 TOC entry size: field_id(4)+column_type(1)+flags(1)+data_offset(8)+data_len(8)+num_docs(4)+dict_offset(8)+dict_count(4) = 38
253pub const FAST_FIELD_TOC_ENTRY_SIZE: usize = 4 + 1 + 1 + 8 + 8 + 4 + 8 + 4; // 38
254
255// ── Block index entry ─────────────────────────────────────────────────────
256
257/// On-disk index entry for one block within a blocked column.
258///
259/// Wire: num_docs(4) + data_len(4) + dict_count(4) + dict_len(4) = 16 bytes
260#[derive(Debug, Clone)]
261pub struct BlockIndexEntry {
262    pub num_docs: u32,
263    pub data_len: u32,
264    pub dict_count: u32,
265    pub dict_len: u32,
266}
267
268pub const BLOCK_INDEX_ENTRY_SIZE: usize = 16;
269
270impl BlockIndexEntry {
271    pub fn write_to(&self, w: &mut dyn Write) -> io::Result<()> {
272        w.write_u32::<LittleEndian>(self.num_docs)?;
273        w.write_u32::<LittleEndian>(self.data_len)?;
274        w.write_u32::<LittleEndian>(self.dict_count)?;
275        w.write_u32::<LittleEndian>(self.dict_len)?;
276        Ok(())
277    }
278
279    pub fn read_from(r: &mut dyn Read) -> io::Result<Self> {
280        let num_docs = r.read_u32::<LittleEndian>()?;
281        let data_len = r.read_u32::<LittleEndian>()?;
282        let dict_count = r.read_u32::<LittleEndian>()?;
283        let dict_len = r.read_u32::<LittleEndian>()?;
284        Ok(Self {
285            num_docs,
286            data_len,
287            dict_count,
288            dict_len,
289        })
290    }
291}
292
293impl FastFieldTocEntry {
294    pub fn write_to(&self, w: &mut dyn Write) -> io::Result<()> {
295        w.write_u32::<LittleEndian>(self.field_id)?;
296        w.write_u8(self.column_type as u8)?;
297        let flags: u8 = if self.multi { 1 } else { 0 };
298        w.write_u8(flags)?;
299        w.write_u64::<LittleEndian>(self.data_offset)?;
300        w.write_u64::<LittleEndian>(self.data_len)?;
301        w.write_u32::<LittleEndian>(self.num_docs)?;
302        w.write_u64::<LittleEndian>(self.dict_offset)?;
303        w.write_u32::<LittleEndian>(self.dict_count)?;
304        Ok(())
305    }
306
307    pub fn read_from(r: &mut dyn Read) -> io::Result<Self> {
308        let field_id = r.read_u32::<LittleEndian>()?;
309        let ct = r.read_u8()?;
310        let column_type = FastFieldColumnType::from_u8(ct)
311            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad column type"))?;
312        let flags = r.read_u8()?;
313        if flags & !1 != 0 {
314            return Err(io::Error::new(
315                io::ErrorKind::InvalidData,
316                "unknown fast field flags",
317            ));
318        }
319        let multi = (flags & 1) != 0;
320        let data_offset = r.read_u64::<LittleEndian>()?;
321        let data_len = r.read_u64::<LittleEndian>()?;
322        let num_docs = r.read_u32::<LittleEndian>()?;
323        let dict_offset = r.read_u64::<LittleEndian>()?;
324        let dict_count = r.read_u32::<LittleEndian>()?;
325        Ok(Self {
326            field_id,
327            column_type,
328            multi,
329            data_offset,
330            data_len,
331            num_docs,
332            dict_offset,
333            dict_count,
334        })
335    }
336}
337
338// ── Writer ────────────────────────────────────────────────────────────────
339
340/// Collects values during indexing and serializes a single fast-field column.
341///
342/// Supports both single-valued and multi-valued columns.
343/// For multi-valued columns, values are stored in a flat array with an
344/// offset column that maps doc_id → value range.
345pub struct FastFieldWriter {
346    pub column_type: FastFieldColumnType,
347    /// Whether this is a multi-valued column.
348    pub multi: bool,
349
350    // ── Single-valued state ──
351    /// Raw u64 values indexed by local doc_id (single-value mode).
352    values: Vec<u64>,
353
354    // ── Multi-valued state ──
355    /// Flat list of all values (multi-value mode).
356    multi_values: Vec<u64>,
357    /// Per-doc cumulative offset into `multi_values`. Length = num_docs + 1.
358    /// offsets[doc_id]..offsets[doc_id+1] is the value range for doc_id.
359    multi_offsets: Vec<u32>,
360    /// Current doc_id being filled (for multi-value sequential writes).
361    multi_current_doc: u32,
362
363    // ── Text state (shared) ──
364    /// For TextOrdinal: maps original string → insertion order.
365    text_values: Option<BTreeMap<String, u32>>,
366    /// For TextOrdinal single-value: per-doc string values (parallel to `values`).
367    text_per_doc: Option<Vec<Option<String>>>,
368    /// For TextOrdinal multi-value: per-value strings (parallel to `multi_values`).
369    text_multi_values: Option<Vec<String>>,
370}
371
372impl FastFieldWriter {
373    /// Create a writer for a single-valued numeric column (u64/i64/f64).
374    pub fn new_numeric(column_type: FastFieldColumnType) -> Self {
375        debug_assert!(matches!(
376            column_type,
377            FastFieldColumnType::U64 | FastFieldColumnType::I64 | FastFieldColumnType::F64
378        ));
379        Self {
380            column_type,
381            multi: false,
382            values: Vec::new(),
383            multi_values: Vec::new(),
384            multi_offsets: vec![0],
385            multi_current_doc: 0,
386            text_values: None,
387            text_per_doc: None,
388            text_multi_values: None,
389        }
390    }
391
392    /// Create a writer for a multi-valued numeric column.
393    pub fn new_numeric_multi(column_type: FastFieldColumnType) -> Self {
394        debug_assert!(matches!(
395            column_type,
396            FastFieldColumnType::U64 | FastFieldColumnType::I64 | FastFieldColumnType::F64
397        ));
398        Self {
399            column_type,
400            multi: true,
401            values: Vec::new(),
402            multi_values: Vec::new(),
403            multi_offsets: vec![0],
404            multi_current_doc: 0,
405            text_values: None,
406            text_per_doc: None,
407            text_multi_values: None,
408        }
409    }
410
411    /// Create a writer for a single-valued text ordinal column.
412    pub fn new_text() -> Self {
413        Self {
414            column_type: FastFieldColumnType::TextOrdinal,
415            multi: false,
416            values: Vec::new(),
417            multi_values: Vec::new(),
418            multi_offsets: vec![0],
419            multi_current_doc: 0,
420            text_values: Some(BTreeMap::new()),
421            text_per_doc: Some(Vec::new()),
422            text_multi_values: None,
423        }
424    }
425
426    /// Create a writer for a multi-valued text ordinal column.
427    pub fn new_text_multi() -> Self {
428        Self {
429            column_type: FastFieldColumnType::TextOrdinal,
430            multi: true,
431            values: Vec::new(),
432            multi_values: Vec::new(),
433            multi_offsets: vec![0],
434            multi_current_doc: 0,
435            text_values: Some(BTreeMap::new()),
436            text_per_doc: None,
437            text_multi_values: Some(Vec::new()),
438        }
439    }
440
441    /// Record a numeric value for `doc_id`. Fills gaps with 0.
442    /// For single-value mode only.
443    pub fn add_u64(&mut self, doc_id: u32, value: u64) {
444        if self.multi {
445            self.add_multi_u64(doc_id, value);
446            return;
447        }
448        let idx = doc_id as usize;
449        if idx >= self.values.len() {
450            self.values.resize(idx + 1, FAST_FIELD_MISSING);
451            if let Some(ref mut tpd) = self.text_per_doc {
452                tpd.resize(idx + 1, None);
453            }
454        }
455        self.values[idx] = value;
456    }
457
458    /// Record a value in multi-value mode.
459    fn add_multi_u64(&mut self, doc_id: u32, value: u64) {
460        // Pad offsets for any skipped doc_ids
461        while self.multi_current_doc < doc_id {
462            self.multi_current_doc += 1;
463            self.multi_offsets.push(self.multi_values.len() as u32);
464        }
465        // Ensure offset exists for current doc
466        if self.multi_current_doc == doc_id && self.multi_offsets.len() == doc_id as usize + 1 {
467            // offset for doc_id already exists as the last entry
468        }
469        self.multi_values.push(value);
470    }
471
472    /// Record an i64 value (zigzag-encoded).
473    pub fn add_i64(&mut self, doc_id: u32, value: i64) {
474        self.add_u64(doc_id, zigzag_encode(value));
475    }
476
477    /// Record an f64 value (sortable-encoded).
478    pub fn add_f64(&mut self, doc_id: u32, value: f64) {
479        self.add_u64(doc_id, f64_to_sortable_u64(value));
480    }
481
482    /// Record a text value (dictionary-encoded at build time).
483    pub fn add_text(&mut self, doc_id: u32, value: &str) {
484        if let Some(ref mut dict) = self.text_values {
485            let next_id = dict.len() as u32;
486            dict.entry(value.to_string()).or_insert(next_id);
487        }
488
489        if self.multi {
490            if let Some(ref mut tmv) = self.text_multi_values {
491                // Pad offsets for skipped docs
492                while self.multi_current_doc < doc_id {
493                    self.multi_current_doc += 1;
494                    self.multi_offsets.push(self.multi_values.len() as u32);
495                }
496                if self.multi_current_doc == doc_id
497                    && self.multi_offsets.len() == doc_id as usize + 1
498                {
499                    // offset already exists
500                }
501                self.multi_values.push(0); // placeholder, resolved later
502                tmv.push(value.to_string());
503            }
504        } else {
505            let idx = doc_id as usize;
506            if idx >= self.values.len() {
507                self.values.resize(idx + 1, FAST_FIELD_MISSING);
508            }
509            if let Some(ref mut tpd) = self.text_per_doc {
510                if idx >= tpd.len() {
511                    tpd.resize(idx + 1, None);
512                }
513                tpd[idx] = Some(value.to_string());
514            }
515        }
516    }
517
518    /// Ensure the column covers `num_docs` entries.
519    ///
520    /// Absent entries are filled with [`FAST_FIELD_MISSING`] for single-value
521    /// columns, or with empty offset ranges for multi-value columns.
522    pub fn pad_to(&mut self, num_docs: u32) {
523        let n = num_docs as usize;
524        if self.multi {
525            while (self.multi_offsets.len() as u32) <= num_docs {
526                self.multi_offsets.push(self.multi_values.len() as u32);
527            }
528            self.multi_current_doc = num_docs;
529        } else {
530            if self.values.len() < n {
531                self.values.resize(n, FAST_FIELD_MISSING);
532                if let Some(ref mut tpd) = self.text_per_doc {
533                    tpd.resize(n, None);
534                }
535            }
536        }
537    }
538
539    /// Number of documents in this column.
540    pub fn num_docs(&self) -> u32 {
541        if self.multi {
542            // offsets has num_docs+1 entries
543            (self.multi_offsets.len() as u32).saturating_sub(1)
544        } else {
545            self.values.len() as u32
546        }
547    }
548
549    /// Serialize column data using blocked format with auto-selecting codec.
550    ///
551    /// Writes a single block: [num_blocks(4)] [BlockIndexEntry] [block_data] [block_dict?]
552    /// Returns `(toc_entry, total_bytes_written)`.
553    pub fn serialize(
554        &mut self,
555        writer: &mut dyn Write,
556        data_offset: u64,
557    ) -> io::Result<(FastFieldTocEntry, u64)> {
558        // For text ordinal: resolve strings to sorted ordinals
559        if self.column_type == FastFieldColumnType::TextOrdinal {
560            self.resolve_text_ordinals();
561        }
562
563        let num_docs = self.num_docs();
564
565        // Serialize block data into a temp buffer to measure lengths
566        let mut block_data = Vec::new();
567        if self.multi {
568            // Multi-value: write [offset_col_len(4)] [offset_col] [value_col]
569            let offsets_u64: Vec<u64> = self.multi_offsets.iter().map(|&v| v as u64).collect();
570            let mut offset_buf = Vec::new();
571            codec::serialize_auto(&offsets_u64, &mut offset_buf)?;
572
573            block_data.write_u32::<LittleEndian>(offset_buf.len() as u32)?;
574            block_data.write_all(&offset_buf)?;
575
576            codec::serialize_auto(&self.multi_values, &mut block_data)?;
577        } else {
578            codec::serialize_auto(&self.values, &mut block_data)?;
579        }
580
581        // Serialize text dictionary into temp buffer
582        let mut dict_buf = Vec::new();
583        let dict_count = if self.column_type == FastFieldColumnType::TextOrdinal {
584            let (count, _) = self.write_text_dictionary(&mut dict_buf)?;
585            count
586        } else {
587            0u32
588        };
589
590        // Build block index entry
591        let block_entry = BlockIndexEntry {
592            num_docs,
593            data_len: block_data.len() as u32,
594            dict_count,
595            dict_len: dict_buf.len() as u32,
596        };
597
598        // Write: num_blocks + block_index + block_data + block_dict
599        let mut total_bytes = 0u64;
600
601        writer.write_u32::<LittleEndian>(1u32)?; // num_blocks
602        total_bytes += 4;
603
604        block_entry.write_to(writer)?;
605        total_bytes += BLOCK_INDEX_ENTRY_SIZE as u64;
606
607        writer.write_all(&block_data)?;
608        total_bytes += block_data.len() as u64;
609
610        writer.write_all(&dict_buf)?;
611        total_bytes += dict_buf.len() as u64;
612
613        let toc = FastFieldTocEntry {
614            field_id: 0, // set by caller
615            column_type: self.column_type,
616            multi: self.multi,
617            data_offset,
618            data_len: total_bytes,
619            num_docs,
620            dict_offset: 0, // no longer used at TOC level (per-block dicts)
621            dict_count: 0,
622        };
623
624        Ok((toc, total_bytes))
625    }
626
627    /// Resolve text per-doc values to sorted ordinals.
628    fn resolve_text_ordinals(&mut self) {
629        let dict = self.text_values.as_ref().expect("text_values required");
630
631        // Build sorted ordinal map: BTreeMap iterates in sorted order
632        let sorted_ordinals: BTreeMap<&str, u64> = dict
633            .keys()
634            .enumerate()
635            .map(|(ord, key)| (key.as_str(), ord as u64))
636            .collect();
637
638        if self.multi {
639            // Multi-value: resolve multi_values via text_multi_values
640            if let Some(ref tmv) = self.text_multi_values {
641                for (i, text) in tmv.iter().enumerate() {
642                    self.multi_values[i] = sorted_ordinals[text.as_str()];
643                }
644            }
645        } else {
646            // Single-value: resolve values via text_per_doc
647            let tpd = self.text_per_doc.as_ref().expect("text_per_doc required");
648            for (i, doc_text) in tpd.iter().enumerate() {
649                match doc_text {
650                    Some(text) => {
651                        self.values[i] = sorted_ordinals[text.as_str()];
652                    }
653                    None => {
654                        self.values[i] = FAST_FIELD_MISSING;
655                    }
656                }
657            }
658        }
659    }
660
661    /// Write len-prefixed sorted strings. Returns (dict_count, bytes_written).
662    fn write_text_dictionary(&self, writer: &mut dyn Write) -> io::Result<(u32, u64)> {
663        let dict = self.text_values.as_ref().expect("text_values required");
664        let mut bytes_written = 0u64;
665
666        // BTreeMap keys are already sorted
667        let count = dict.len() as u32;
668        for key in dict.keys() {
669            let key_bytes = key.as_bytes();
670            writer.write_u32::<LittleEndian>(key_bytes.len() as u32)?;
671            writer.write_all(key_bytes)?;
672            bytes_written += 4 + key_bytes.len() as u64;
673        }
674
675        Ok((count, bytes_written))
676    }
677}
678
679// ── Reader ────────────────────────────────────────────────────────────────
680
681use crate::directories::OwnedBytes;
682
683/// One independently-decodable block within a blocked column.
684///
685/// All byte slices are zero-copy borrows from the mmap'd `.fast` file.
686pub struct ColumnBlock {
687    /// Number of docs before this block (for doc_id → block lookup).
688    pub cumulative_docs: u32,
689    /// Number of docs in this block.
690    pub num_docs: u32,
691    /// Auto-codec encoded data for this block (single-value or raw multi-value region).
692    pub data: OwnedBytes,
693    /// For multi-value blocks: offset sub-column.
694    pub offset_data: OwnedBytes,
695    /// For multi-value blocks: value sub-column.
696    pub value_data: OwnedBytes,
697    /// Per-block text dictionary (text columns only). Lazy — offsets built on first access.
698    pub dict: Option<TextDictReader>,
699    /// Raw dictionary bytes for this block (for merge: memcpy).
700    pub raw_dict: OwnedBytes,
701}
702
703/// Reads a single fast-field column from mmap/buffer with O(1) doc_id access.
704///
705/// A column is a sequence of independently-decodable blocks. Fresh segments
706/// have one block; merged segments may have multiple (one per source segment).
707///
708/// **Zero-copy**: all data is borrowed from the underlying mmap / `OwnedBytes`.
709///
710/// **Lazy text state**: for text-ordinal columns, the global merged dictionary
711/// and per-block ordinal maps are built lazily on first access (not at load time).
712/// This avoids scanning all dictionary pages from mmap during segment loading.
713pub struct FastFieldReader {
714    pub column_type: FastFieldColumnType,
715    pub num_docs: u32,
716    pub multi: bool,
717
718    /// Blocks in doc_id order.
719    blocks: Vec<ColumnBlock>,
720
721    /// Lazy-initialized text state (global dict + ordinal maps).
722    /// Built on first text-related access, not at load time.
723    text_state: OnceLock<TextState>,
724}
725
726/// Lazily-built state for text-ordinal columns.
727struct TextState {
728    /// Global merged dictionary across all blocks.
729    global_dict: TextDictReader,
730    /// Per-block ordinal maps: `ordinal_maps[block_idx][local_ord] → global_ord`.
731    /// Empty Vec for blocks without dicts or single-block columns (identity mapping).
732    ordinal_maps: Vec<Vec<u32>>,
733}
734
735impl FastFieldReader {
736    /// Open a blocked column from an `OwnedBytes` file buffer using a TOC entry.
737    ///
738    /// For text-ordinal columns, dictionary scanning and global dict merging are
739    /// deferred to first access — no mmap pages are touched for dict data here.
740    pub fn open(file_data: &OwnedBytes, toc: &FastFieldTocEntry) -> io::Result<Self> {
741        let region_start = usize::try_from(toc.data_offset).map_err(|_| {
742            io::Error::new(
743                io::ErrorKind::InvalidData,
744                "fast field data offset exceeds address space",
745            )
746        })?;
747        let region_len = usize::try_from(toc.data_len).map_err(|_| {
748            io::Error::new(
749                io::ErrorKind::InvalidData,
750                "fast field data length exceeds address space",
751            )
752        })?;
753        let region_end = region_start.checked_add(region_len).ok_or_else(|| {
754            io::Error::new(io::ErrorKind::InvalidData, "fast field data range overflow")
755        })?;
756
757        if region_end > file_data.len() {
758            return Err(io::Error::new(
759                io::ErrorKind::UnexpectedEof,
760                "fast field data out of bounds",
761            ));
762        }
763
764        let raw = file_data.as_slice();
765
766        // Read num_blocks
767        let mut pos = region_start;
768        if pos.checked_add(4).is_none_or(|end| end > region_end) {
769            return Err(io::Error::new(
770                io::ErrorKind::UnexpectedEof,
771                "fast field: missing num_blocks",
772            ));
773        }
774        let num_blocks = u32::from_le_bytes(raw[pos..pos + 4].try_into().unwrap());
775        pos += 4;
776
777        // Read block index
778        let idx_size = (num_blocks as usize)
779            .checked_mul(BLOCK_INDEX_ENTRY_SIZE)
780            .ok_or_else(|| {
781                io::Error::new(
782                    io::ErrorKind::InvalidData,
783                    "fast field block index overflow",
784                )
785            })?;
786        let index_end = pos.checked_add(idx_size).ok_or_else(|| {
787            io::Error::new(
788                io::ErrorKind::InvalidData,
789                "fast field block index overflow",
790            )
791        })?;
792        if index_end > region_end {
793            return Err(io::Error::new(
794                io::ErrorKind::UnexpectedEof,
795                "fast field: block index truncated",
796            ));
797        }
798        let mut block_entries = Vec::new();
799        block_entries
800            .try_reserve_exact(num_blocks as usize)
801            .map_err(|_| {
802                io::Error::new(io::ErrorKind::InvalidData, "too many fast field blocks")
803            })?;
804        {
805            let mut cursor = std::io::Cursor::new(&raw[pos..index_end]);
806            for _ in 0..num_blocks {
807                block_entries.push(BlockIndexEntry::read_from(&mut cursor)?);
808            }
809        }
810        pos = index_end;
811
812        let empty = OwnedBytes::new(Vec::new());
813
814        // Parse each block's data + dict slices
815        let mut blocks = Vec::new();
816        blocks.try_reserve_exact(num_blocks as usize).map_err(|_| {
817            io::Error::new(io::ErrorKind::InvalidData, "too many fast field blocks")
818        })?;
819        let mut cumulative = 0u32;
820
821        for entry in &block_entries {
822            let data_start = pos;
823            let data_end = data_start
824                .checked_add(entry.data_len as usize)
825                .ok_or_else(|| {
826                    io::Error::new(
827                        io::ErrorKind::InvalidData,
828                        "fast field block range overflow",
829                    )
830                })?;
831            let dict_start = data_end;
832            let dict_end = dict_start
833                .checked_add(entry.dict_len as usize)
834                .ok_or_else(|| {
835                    io::Error::new(io::ErrorKind::InvalidData, "fast field dict range overflow")
836                })?;
837
838            if dict_end > region_end {
839                return Err(io::Error::new(
840                    io::ErrorKind::UnexpectedEof,
841                    "fast field: block data/dict truncated",
842                ));
843            }
844
845            // Parse multi-value sub-columns from block data
846            let (block_data, offset_data, value_data) = if toc.multi {
847                let block_raw = &raw[data_start..data_end];
848                if block_raw.len() < 4 {
849                    return Err(io::Error::new(
850                        io::ErrorKind::UnexpectedEof,
851                        "fast field multi-value header is truncated",
852                    ));
853                }
854                let offset_col_len =
855                    u32::from_le_bytes(block_raw[0..4].try_into().unwrap()) as usize;
856                let o_start = data_start + 4;
857                let o_end = o_start.checked_add(offset_col_len).ok_or_else(|| {
858                    io::Error::new(
859                        io::ErrorKind::InvalidData,
860                        "fast field offset column range overflow",
861                    )
862                })?;
863                if o_end > data_end {
864                    return Err(io::Error::new(
865                        io::ErrorKind::UnexpectedEof,
866                        "fast field offset column is truncated",
867                    ));
868                }
869                let v_start = o_end;
870                let v_end = data_end;
871                let offset_data = file_data.slice(o_start..o_end);
872                let value_data = file_data.slice(v_start..v_end);
873                let offset_count = (entry.num_docs as usize).checked_add(1).ok_or_else(|| {
874                    io::Error::new(io::ErrorKind::InvalidData, "fast field doc count overflow")
875                })?;
876                codec::validate_auto(offset_data.as_slice(), offset_count)?;
877
878                let mut previous = 0u64;
879                for index in 0..offset_count {
880                    let offset = codec::auto_read(offset_data.as_slice(), index);
881                    if offset > u32::MAX as u64 || (index == 0 && offset != 0) || offset < previous
882                    {
883                        return Err(io::Error::new(
884                            io::ErrorKind::InvalidData,
885                            "fast field value offsets are invalid",
886                        ));
887                    }
888                    previous = offset;
889                }
890                codec::validate_auto(value_data.as_slice(), previous as usize)?;
891
892                (
893                    file_data.slice(data_start..data_end),
894                    offset_data,
895                    value_data,
896                )
897            } else {
898                let block_data = file_data.slice(data_start..data_end);
899                codec::validate_auto(block_data.as_slice(), entry.num_docs as usize)?;
900                (block_data, empty.clone(), empty.clone())
901            };
902
903            if toc.column_type == FastFieldColumnType::TextOrdinal {
904                if entry.dict_count == 0 && entry.dict_len != 0 {
905                    return Err(io::Error::new(
906                        io::ErrorKind::InvalidData,
907                        "empty fast field dictionary has data",
908                    ));
909                }
910                validate_text_dict_bytes(&raw[dict_start..dict_end], entry.dict_count)?;
911            } else if entry.dict_count != 0 || entry.dict_len != 0 {
912                return Err(io::Error::new(
913                    io::ErrorKind::InvalidData,
914                    "numeric fast field contains a text dictionary",
915                ));
916            }
917
918            // Create lazy block dict — no scanning, just stores the data slice + count
919            let dict = if entry.dict_count > 0 {
920                Some(TextDictReader::new_lazy(
921                    file_data.slice(dict_start..dict_end),
922                    entry.dict_count,
923                ))
924            } else {
925                None
926            };
927
928            let raw_dict = if entry.dict_len > 0 {
929                file_data.slice(dict_start..dict_end)
930            } else {
931                empty.clone()
932            };
933
934            blocks.push(ColumnBlock {
935                cumulative_docs: cumulative,
936                num_docs: entry.num_docs,
937                data: block_data,
938                offset_data,
939                value_data,
940                dict,
941                raw_dict,
942            });
943
944            cumulative = cumulative.checked_add(entry.num_docs).ok_or_else(|| {
945                io::Error::new(io::ErrorKind::InvalidData, "fast field doc count overflow")
946            })?;
947            pos = dict_end;
948        }
949
950        if pos != region_end || cumulative != toc.num_docs {
951            return Err(io::Error::new(
952                io::ErrorKind::InvalidData,
953                "fast field block totals are inconsistent with the TOC",
954            ));
955        }
956        if toc.num_docs > 0 && blocks.is_empty() {
957            return Err(io::Error::new(
958                io::ErrorKind::InvalidData,
959                "non-empty fast field has no blocks",
960            ));
961        }
962
963        Ok(Self {
964            column_type: toc.column_type,
965            num_docs: toc.num_docs,
966            multi: toc.multi,
967            blocks,
968            text_state: OnceLock::new(),
969        })
970    }
971
972    /// Lazily initialize and return the text state (global dict + ordinal maps).
973    /// Only called for text-ordinal columns.
974    fn ensure_text_state(&self) -> &TextState {
975        self.text_state
976            .get_or_init(|| Self::build_text_state(&self.blocks))
977    }
978
979    /// Build text state: global merged dictionary + per-block ordinal maps.
980    /// Called lazily on first text-related access (not at segment load time).
981    fn build_text_state(blocks: &[ColumnBlock]) -> TextState {
982        // Fast path: single block → block-local ordinals ARE global ordinals.
983        // No merging, no cloning, no ordinal map needed.
984        let blocks_with_dict = blocks.iter().filter(|b| b.dict.is_some()).count();
985        if blocks_with_dict <= 1 {
986            for block in blocks.iter() {
987                if let Some(ref dict) = block.dict {
988                    // Re-use the existing dict — no ordinal_map needed (identity mapping)
989                    return TextState {
990                        global_dict: TextDictReader::new_lazy(block.raw_dict.clone(), dict.len()),
991                        ordinal_maps: vec![Vec::new(); blocks.len()],
992                    };
993                }
994            }
995            // No blocks have dicts — return empty
996            return TextState {
997                global_dict: TextDictReader::new_lazy(OwnedBytes::new(Vec::new()), 0),
998                ordinal_maps: vec![Vec::new(); blocks.len()],
999            };
1000        }
1001
1002        // Multi-block: merge sorted block dictionaries.
1003        // Each block dict is already sorted, so we k-way merge in O(total_entries).
1004        // Uses a BTreeMap to deduplicate and assign global ordinals.
1005
1006        // Phase 1: Collect unique strings → assign global ordinals.
1007        //
1008        // BTreeMap is sorted by key, so ordinals assigned by iterating values_mut()
1009        // match the order that Phase 3 writes the dictionary (also key-sorted).
1010        // This is critical: TextDictReader::ordinal() does binary search by position,
1011        // so the ordinal_map values MUST equal the sorted position, not insertion order.
1012        let mut unique_map: BTreeMap<String, u32> = BTreeMap::new();
1013        for block in blocks.iter() {
1014            if let Some(ref dict) = block.dict {
1015                for ord in 0..dict.len() {
1016                    if let Some(text) = dict.get(ord) {
1017                        unique_map.entry(text.to_string()).or_insert(0);
1018                    }
1019                }
1020            }
1021        }
1022        // Assign ordinals by sorted position (BTreeMap iterates keys in order).
1023        for (i, value) in unique_map.values_mut().enumerate() {
1024            *value = i as u32;
1025        }
1026
1027        // Phase 2: Build per-block ordinal maps
1028        let mut ordinal_maps = Vec::with_capacity(blocks.len());
1029        for block in blocks.iter() {
1030            if let Some(ref dict) = block.dict {
1031                let mut map = Vec::with_capacity(dict.len() as usize);
1032                for local_ord in 0..dict.len() {
1033                    let text = dict
1034                        .get(local_ord)
1035                        .expect("block dict ordinal out of range");
1036                    let global_ord = *unique_map
1037                        .get(text)
1038                        .expect("block dict entry not found in merged global dict");
1039                    map.push(global_ord);
1040                }
1041                ordinal_maps.push(map);
1042            } else {
1043                ordinal_maps.push(Vec::new());
1044            }
1045        }
1046
1047        // Phase 3: Serialize global dict (sorted) into a buffer
1048        let mut dict_buf = Vec::new();
1049        let count = unique_map.len() as u32;
1050        for s in unique_map.keys() {
1051            let bytes = s.as_bytes();
1052            dict_buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1053            dict_buf.extend_from_slice(bytes);
1054        }
1055
1056        TextState {
1057            global_dict: TextDictReader::new_lazy(OwnedBytes::new(dict_buf), count),
1058            ordinal_maps,
1059        }
1060    }
1061
1062    /// Remap a block-local raw ordinal to a global ordinal using the ordinal map.
1063    /// Returns raw unchanged for non-text columns, single-block columns, or missing ordinals.
1064    #[inline]
1065    fn remap_ordinal(&self, block_idx: usize, raw: u64) -> u64 {
1066        if self.column_type == FastFieldColumnType::TextOrdinal
1067            && raw != FAST_FIELD_MISSING
1068            && self.blocks.len() > 1
1069        {
1070            let state = self.ensure_text_state();
1071            let map = &state.ordinal_maps[block_idx];
1072            if !map.is_empty() {
1073                let idx = raw as usize;
1074                if idx < map.len() {
1075                    map[idx] as u64
1076                } else {
1077                    FAST_FIELD_MISSING
1078                }
1079            } else {
1080                raw
1081            }
1082        } else {
1083            raw
1084        }
1085    }
1086
1087    /// Find the block containing `doc_id`. Returns (block_index, local_doc_id).
1088    #[inline]
1089    fn find_block(&self, doc_id: u32) -> (usize, u32) {
1090        debug_assert!(!self.blocks.is_empty());
1091        // Single block fast path (common: fresh segments)
1092        if self.blocks.len() == 1 {
1093            return (0, doc_id);
1094        }
1095        // Binary search: find the last block whose cumulative_docs <= doc_id
1096        let bi = self
1097            .blocks
1098            .partition_point(|b| b.cumulative_docs <= doc_id)
1099            .saturating_sub(1);
1100        (bi, doc_id - self.blocks[bi].cumulative_docs)
1101    }
1102
1103    /// Get raw u64 value for a doc_id.
1104    ///
1105    /// Returns [`FAST_FIELD_MISSING`] for out-of-range doc_ids **and** for docs
1106    /// that were never assigned a value (absent docs).
1107    ///
1108    /// For text columns, returns the global ordinal (remapped from block-local).
1109    /// For multi-valued columns, returns the first value (or `FAST_FIELD_MISSING` if empty).
1110    #[inline]
1111    pub fn get_u64(&self, doc_id: u32) -> u64 {
1112        if doc_id >= self.num_docs {
1113            return FAST_FIELD_MISSING;
1114        }
1115        let (bi, local) = self.find_block(doc_id);
1116        let block = &self.blocks[bi];
1117
1118        if self.multi {
1119            let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1120            let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1121            if start >= end {
1122                return FAST_FIELD_MISSING;
1123            }
1124            let raw = codec::auto_read(block.value_data.as_slice(), start as usize);
1125            return self.remap_ordinal(bi, raw);
1126        }
1127
1128        let raw = codec::auto_read(block.data.as_slice(), local as usize);
1129        self.remap_ordinal(bi, raw)
1130    }
1131
1132    /// Get the value range for a multi-valued column within its block.
1133    /// Returns (block_index, start_index, end_index) into the block's flat value array.
1134    #[inline]
1135    fn block_value_range(&self, doc_id: u32) -> (usize, u32, u32) {
1136        if !self.multi || doc_id >= self.num_docs {
1137            return (0, 0, 0);
1138        }
1139        let (bi, local) = self.find_block(doc_id);
1140        let block = &self.blocks[bi];
1141        let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1142        let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1143        (bi, start, end)
1144    }
1145
1146    /// Get the value range for a multi-valued column.
1147    /// Returns (start_index, end_index) — for single-block columns these are
1148    /// direct indices; for multi-block, use `get_multi_values` instead.
1149    #[inline]
1150    pub fn value_range(&self, doc_id: u32) -> (u32, u32) {
1151        let (_, start, end) = self.block_value_range(doc_id);
1152        (start, end)
1153    }
1154
1155    /// Get a specific value from the flat value array (multi-value mode).
1156    /// For single-block columns only. For multi-block, use `get_multi_values`.
1157    #[inline]
1158    pub fn get_value_at(&self, index: u32) -> u64 {
1159        // For single-block (common case), delegate directly
1160        if self.blocks.len() == 1 {
1161            let raw = codec::auto_read(self.blocks[0].value_data.as_slice(), index as usize);
1162            return self.remap_ordinal(0, raw);
1163        }
1164        // Multi-block fallback — index is block-local, caller should use get_multi_values
1165        0
1166    }
1167
1168    /// Get all values for a multi-valued doc_id. Handles multi-block correctly.
1169    pub fn get_multi_values(&self, doc_id: u32) -> Vec<u64> {
1170        let (bi, start, end) = self.block_value_range(doc_id);
1171        if start >= end {
1172            return Vec::new();
1173        }
1174        let block = &self.blocks[bi];
1175        (start..end)
1176            .map(|idx| {
1177                let raw = codec::auto_read(block.value_data.as_slice(), idx as usize);
1178                self.remap_ordinal(bi, raw)
1179            })
1180            .collect()
1181    }
1182
1183    /// Iterate multi-values for a doc, calling `f` for each. Returns true if `f` ever returns true (short-circuit).
1184    /// Handles multi-block columns correctly by finding the right block.
1185    #[inline]
1186    pub fn for_each_multi_value(&self, doc_id: u32, mut f: impl FnMut(u64) -> bool) -> bool {
1187        let (bi, start, end) = self.block_value_range(doc_id);
1188        if start >= end {
1189            return false;
1190        }
1191        let block = &self.blocks[bi];
1192        for idx in start..end {
1193            let raw = codec::auto_read(block.value_data.as_slice(), idx as usize);
1194            if f(self.remap_ordinal(bi, raw)) {
1195                return true;
1196            }
1197        }
1198        false
1199    }
1200
1201    /// Batch-scan all values in a single-value column, calling `f(doc_id, raw_value)` for each.
1202    ///
1203    /// Uses `auto_read_batch` internally (one codec dispatch per block, not per value),
1204    /// enabling compiler auto-vectorization for byte-aligned bitpacked columns.
1205    /// For text columns, returned values are global ordinals (remapped).
1206    /// For multi-value columns, use `for_each_multi_value` instead.
1207    pub fn scan_single_values(&self, mut f: impl FnMut(u32, u64)) {
1208        if self.multi {
1209            return;
1210        }
1211        const BATCH: usize = 256;
1212        let mut buf = [0u64; BATCH];
1213        let needs_remap =
1214            self.column_type == FastFieldColumnType::TextOrdinal && self.blocks.len() > 1;
1215
1216        // Pre-fetch ordinal maps once (only for multi-block text columns)
1217        let ordinal_maps = if needs_remap {
1218            Some(&self.ensure_text_state().ordinal_maps)
1219        } else {
1220            None
1221        };
1222
1223        for (block_idx, block) in self.blocks.iter().enumerate() {
1224            let n = block.num_docs as usize;
1225            let mut pos = 0;
1226
1227            let map = ordinal_maps.map(|maps| &maps[block_idx]);
1228            let has_map = map.is_some_and(|m| !m.is_empty());
1229
1230            while pos < n {
1231                let chunk = (n - pos).min(BATCH);
1232                codec::auto_read_batch(block.data.as_slice(), pos, &mut buf[..chunk]);
1233
1234                if has_map {
1235                    let map = map.unwrap();
1236                    for (i, &raw) in buf[..chunk].iter().enumerate() {
1237                        let val = if raw != FAST_FIELD_MISSING {
1238                            let idx = raw as usize;
1239                            if idx < map.len() {
1240                                map[idx] as u64
1241                            } else {
1242                                FAST_FIELD_MISSING
1243                            }
1244                        } else {
1245                            raw
1246                        };
1247                        f(block.cumulative_docs + pos as u32 + i as u32, val);
1248                    }
1249                } else {
1250                    for (i, &val) in buf[..chunk].iter().enumerate() {
1251                        f(block.cumulative_docs + pos as u32 + i as u32, val);
1252                    }
1253                }
1254                pos += chunk;
1255            }
1256        }
1257    }
1258
1259    /// Check if this doc has a value (not [`FAST_FIELD_MISSING`]).
1260    ///
1261    /// For single-value columns, checks the raw sentinel.
1262    /// For multi-value columns, checks if the offset range is non-empty.
1263    #[inline]
1264    pub fn has_value(&self, doc_id: u32) -> bool {
1265        if !self.multi {
1266            return doc_id < self.num_docs && self.get_u64(doc_id) != FAST_FIELD_MISSING;
1267        }
1268        let (_, start, end) = self.block_value_range(doc_id);
1269        start < end
1270    }
1271
1272    /// Get decoded i64 value (zigzag-decoded).
1273    ///
1274    /// Returns `i64::MIN` for absent docs (zigzag_decode of `FAST_FIELD_MISSING`).
1275    /// Use [`has_value`](Self::has_value) to distinguish absent from real values.
1276    #[inline]
1277    pub fn get_i64(&self, doc_id: u32) -> i64 {
1278        zigzag_decode(self.get_u64(doc_id))
1279    }
1280
1281    /// Get decoded f64 value (sortable-decoded).
1282    ///
1283    /// Returns `NaN` for absent docs (`sortable_u64_to_f64(FAST_FIELD_MISSING)`).
1284    /// Use [`has_value`](Self::has_value) to distinguish absent from real values.
1285    #[inline]
1286    pub fn get_f64(&self, doc_id: u32) -> f64 {
1287        sortable_u64_to_f64(self.get_u64(doc_id))
1288    }
1289
1290    /// Get the text ordinal for a doc_id. Returns FAST_FIELD_MISSING if missing.
1291    #[inline]
1292    pub fn get_ordinal(&self, doc_id: u32) -> u64 {
1293        self.get_u64(doc_id)
1294    }
1295
1296    /// Get the text string for a doc_id (looks up ordinal in block-local dictionary).
1297    /// Returns None if the doc has no value or ordinal is missing.
1298    pub fn get_text(&self, doc_id: u32) -> Option<&str> {
1299        if doc_id >= self.num_docs {
1300            return None;
1301        }
1302        let (bi, local) = self.find_block(doc_id);
1303        let block = &self.blocks[bi];
1304        let raw_ordinal = if self.multi {
1305            let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1306            let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1307            if start >= end {
1308                return None;
1309            }
1310            codec::auto_read(block.value_data.as_slice(), start as usize)
1311        } else {
1312            codec::auto_read(block.data.as_slice(), local as usize)
1313        };
1314        if raw_ordinal == FAST_FIELD_MISSING {
1315            return None;
1316        }
1317        block.dict.as_ref().and_then(|d| d.get(raw_ordinal as u32))
1318    }
1319
1320    /// Look up text string → global ordinal. Returns None if not found.
1321    pub fn text_ordinal(&self, text: &str) -> Option<u64> {
1322        if self.column_type != FastFieldColumnType::TextOrdinal {
1323            return None;
1324        }
1325        self.ensure_text_state().global_dict.ordinal(text)
1326    }
1327
1328    /// Access the global text dictionary reader (if this is a text column).
1329    pub fn text_dict(&self) -> Option<&TextDictReader> {
1330        if self.column_type != FastFieldColumnType::TextOrdinal {
1331            return None;
1332        }
1333        Some(&self.ensure_text_state().global_dict)
1334    }
1335
1336    /// Number of blocks in this column.
1337    pub fn num_blocks(&self) -> usize {
1338        self.blocks.len()
1339    }
1340
1341    /// Access blocks for raw stacking during merge.
1342    pub fn blocks(&self) -> &[ColumnBlock] {
1343        &self.blocks
1344    }
1345}
1346
1347// ── Text dictionary ───────────────────────────────────────────────────────
1348
1349/// Sorted dictionary for text ordinal columns.
1350///
1351/// **Zero-copy**: the dictionary data is a shared slice of the `.fast` file.
1352/// **Lazy**: the offset table is built on first access (not at load time),
1353/// avoiding mmap page faults during segment loading.
1354pub struct TextDictReader {
1355    /// The raw dictionary bytes from the `.fast` file (zero-copy).
1356    data: OwnedBytes,
1357    /// Number of entries in this dictionary.
1358    count: u32,
1359    /// Per-entry (offset, len) pairs into `data` — built lazily on first access.
1360    offsets: OnceLock<Vec<(u32, u32)>>,
1361}
1362
1363impl TextDictReader {
1364    /// Create a lazy text dictionary from pre-sliced data.
1365    /// No scanning is performed — offsets are built on first `get()`/`ordinal()` call.
1366    fn new_lazy(data: OwnedBytes, count: u32) -> Self {
1367        Self {
1368            data,
1369            count,
1370            offsets: OnceLock::new(),
1371        }
1372    }
1373
1374    /// Open a zero-copy text dictionary from `file_data` starting at `dict_start`.
1375    /// Scans to find the dict end position for slicing, but defers offset building.
1376    pub fn open(file_data: &OwnedBytes, dict_start: usize, count: u32) -> io::Result<Self> {
1377        if count == 0 {
1378            return Ok(Self::new_lazy(OwnedBytes::new(Vec::new()), 0));
1379        }
1380        // Scan to find end position (need to know the slice range)
1381        let dict_slice = file_data.as_slice();
1382        if dict_start > dict_slice.len() {
1383            return Err(io::Error::new(
1384                io::ErrorKind::UnexpectedEof,
1385                "text dict offset out of bounds",
1386            ));
1387        }
1388        let mut pos = dict_start;
1389        for _ in 0..count {
1390            if pos.checked_add(4).is_none_or(|end| end > dict_slice.len()) {
1391                return Err(io::Error::new(
1392                    io::ErrorKind::UnexpectedEof,
1393                    "text dict truncated",
1394                ));
1395            }
1396            let len = u32::from_le_bytes(dict_slice[pos..pos + 4].try_into().unwrap()) as usize;
1397            pos += 4;
1398            if pos
1399                .checked_add(len)
1400                .is_none_or(|end| end > dict_slice.len())
1401            {
1402                return Err(io::Error::new(
1403                    io::ErrorKind::UnexpectedEof,
1404                    "text dict entry truncated",
1405                ));
1406            }
1407            std::str::from_utf8(&dict_slice[pos..pos + len])
1408                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1409            pos += len;
1410        }
1411        let data = file_data.slice(dict_start..pos);
1412        Ok(Self::new_lazy(data, count))
1413    }
1414
1415    /// Open from raw dict bytes (already length-prefixed entries).
1416    pub fn open_from_raw(raw_dict: &OwnedBytes, count: u32) -> io::Result<Self> {
1417        validate_text_dict_bytes(raw_dict.as_slice(), count)?;
1418        Ok(Self::new_lazy(raw_dict.clone(), count))
1419    }
1420
1421    /// Build offset table lazily on first access.
1422    #[inline]
1423    fn ensure_offsets(&self) -> &[(u32, u32)] {
1424        self.offsets.get_or_init(|| {
1425            let dict_slice = self.data.as_slice();
1426            let mut pos = 0usize;
1427            let mut offsets = Vec::with_capacity(self.count as usize);
1428            for _ in 0..self.count {
1429                debug_assert!(
1430                    pos + 4 <= dict_slice.len(),
1431                    "text dict truncated during lazy init"
1432                );
1433                let len = u32::from_le_bytes(dict_slice[pos..pos + 4].try_into().unwrap()) as usize;
1434                pos += 4;
1435                debug_assert!(
1436                    pos + len <= dict_slice.len(),
1437                    "text dict entry truncated during lazy init"
1438                );
1439                offsets.push((pos as u32, len as u32));
1440                pos += len;
1441            }
1442            offsets
1443        })
1444    }
1445
1446    /// Get string by ordinal — zero-copy borrow from the underlying file data.
1447    pub fn get(&self, ordinal: u32) -> Option<&str> {
1448        let offsets = self.ensure_offsets();
1449        let &(off, len) = offsets.get(ordinal as usize)?;
1450        let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1451        std::str::from_utf8(slice).ok()
1452    }
1453
1454    /// Binary search for a string → ordinal.
1455    pub fn ordinal(&self, text: &str) -> Option<u64> {
1456        let offsets = self.ensure_offsets();
1457        offsets
1458            .binary_search_by(|&(off, len)| {
1459                let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1460                std::str::from_utf8(slice).unwrap_or("").cmp(text)
1461            })
1462            .ok()
1463            .map(|i| i as u64)
1464    }
1465
1466    /// Number of entries in the dictionary.
1467    pub fn len(&self) -> u32 {
1468        self.count
1469    }
1470
1471    /// Whether the dictionary is empty.
1472    pub fn is_empty(&self) -> bool {
1473        self.count == 0
1474    }
1475
1476    /// Iterate all entries.
1477    pub fn iter(&self) -> impl Iterator<Item = &str> {
1478        let offsets = self.ensure_offsets();
1479        offsets.iter().map(|&(off, len)| {
1480            let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1481            std::str::from_utf8(slice).unwrap_or("")
1482        })
1483    }
1484}
1485
1486fn validate_text_dict_bytes(data: &[u8], count: u32) -> io::Result<()> {
1487    let minimum = (count as usize).checked_mul(4).ok_or_else(|| {
1488        io::Error::new(io::ErrorKind::InvalidData, "text dictionary size overflow")
1489    })?;
1490    if minimum > data.len() {
1491        return Err(io::Error::new(
1492            io::ErrorKind::UnexpectedEof,
1493            "text dictionary entry table is truncated",
1494        ));
1495    }
1496
1497    let mut pos = 0usize;
1498    let mut previous: Option<&str> = None;
1499    for _ in 0..count {
1500        let len_end = pos.checked_add(4).ok_or_else(|| {
1501            io::Error::new(
1502                io::ErrorKind::InvalidData,
1503                "text dictionary offset overflow",
1504            )
1505        })?;
1506        let len = u32::from_le_bytes(data[pos..len_end].try_into().unwrap()) as usize;
1507        pos = len_end;
1508        let end = pos.checked_add(len).ok_or_else(|| {
1509            io::Error::new(
1510                io::ErrorKind::InvalidData,
1511                "text dictionary offset overflow",
1512            )
1513        })?;
1514        if end > data.len() {
1515            return Err(io::Error::new(
1516                io::ErrorKind::UnexpectedEof,
1517                "text dictionary entry is truncated",
1518            ));
1519        }
1520        let value = std::str::from_utf8(&data[pos..end])
1521            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1522        if previous.is_some_and(|previous| previous >= value) {
1523            return Err(io::Error::new(
1524                io::ErrorKind::InvalidData,
1525                "text dictionary entries are not strictly increasing",
1526            ));
1527        }
1528        previous = Some(value);
1529        pos = end;
1530    }
1531    if pos != data.len() {
1532        return Err(io::Error::new(
1533            io::ErrorKind::InvalidData,
1534            "text dictionary contains trailing data",
1535        ));
1536    }
1537    Ok(())
1538}
1539
1540// ── File-level write/read ─────────────────────────────────────────────────
1541
1542/// Write fast-field TOC + footer.
1543pub fn write_fast_field_toc_and_footer(
1544    writer: &mut dyn Write,
1545    toc_offset: u64,
1546    entries: &[FastFieldTocEntry],
1547) -> io::Result<()> {
1548    for e in entries {
1549        e.write_to(writer)?;
1550    }
1551    writer.write_u64::<LittleEndian>(toc_offset)?;
1552    writer.write_u32::<LittleEndian>(entries.len() as u32)?;
1553    writer.write_u32::<LittleEndian>(FAST_FIELD_MAGIC)?;
1554    Ok(())
1555}
1556
1557/// Read fast-field footer from the last 16 bytes.
1558/// Returns (toc_offset, num_columns).
1559pub fn read_fast_field_footer(file_data: &[u8]) -> io::Result<(u64, u32)> {
1560    let len = file_data.len();
1561    if len < FAST_FIELD_FOOTER_SIZE as usize {
1562        return Err(io::Error::new(
1563            io::ErrorKind::UnexpectedEof,
1564            "fast field file too small for footer",
1565        ));
1566    }
1567    let footer = &file_data[len - FAST_FIELD_FOOTER_SIZE as usize..];
1568    let mut cursor = std::io::Cursor::new(footer);
1569    let toc_offset = cursor.read_u64::<LittleEndian>()?;
1570    let num_columns = cursor.read_u32::<LittleEndian>()?;
1571    let magic = cursor.read_u32::<LittleEndian>()?;
1572    if magic != FAST_FIELD_MAGIC {
1573        return Err(io::Error::new(
1574            io::ErrorKind::InvalidData,
1575            format!("bad fast field magic: 0x{:08x}", magic),
1576        ));
1577    }
1578    Ok((toc_offset, num_columns))
1579}
1580
1581/// Read all TOC entries from file data (FST2 format).
1582pub fn read_fast_field_toc(
1583    file_data: &[u8],
1584    toc_offset: u64,
1585    num_columns: u32,
1586) -> io::Result<Vec<FastFieldTocEntry>> {
1587    let start = usize::try_from(toc_offset).map_err(|_| {
1588        io::Error::new(
1589            io::ErrorKind::InvalidData,
1590            "fast field TOC offset exceeds address space",
1591        )
1592    })?;
1593    let expected = (num_columns as usize)
1594        .checked_mul(FAST_FIELD_TOC_ENTRY_SIZE)
1595        .ok_or_else(|| {
1596            io::Error::new(io::ErrorKind::InvalidData, "fast field TOC size overflow")
1597        })?;
1598    let end = start.checked_add(expected).ok_or_else(|| {
1599        io::Error::new(io::ErrorKind::InvalidData, "fast field TOC range overflow")
1600    })?;
1601    if end > file_data.len() {
1602        return Err(io::Error::new(
1603            io::ErrorKind::UnexpectedEof,
1604            "fast field TOC out of bounds",
1605        ));
1606    }
1607    let mut cursor = std::io::Cursor::new(&file_data[start..end]);
1608    let mut entries = Vec::new();
1609    entries
1610        .try_reserve_exact(num_columns as usize)
1611        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "too many fast field columns"))?;
1612    for _ in 0..num_columns {
1613        entries.push(FastFieldTocEntry::read_from(&mut cursor)?);
1614    }
1615    Ok(entries)
1616}
1617
1618// ── Tests ─────────────────────────────────────────────────────────────────
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623
1624    #[test]
1625    fn test_zigzag_roundtrip() {
1626        for v in [0i64, 1, -1, 42, -42, i64::MAX, i64::MIN] {
1627            assert_eq!(zigzag_decode(zigzag_encode(v)), v);
1628        }
1629    }
1630
1631    #[test]
1632    fn test_f64_sortable_roundtrip() {
1633        for v in [0.0f64, 1.0, -1.0, f64::MAX, f64::MIN, f64::MIN_POSITIVE] {
1634            assert_eq!(sortable_u64_to_f64(f64_to_sortable_u64(v)), v);
1635        }
1636    }
1637
1638    #[test]
1639    fn test_f64_sortable_order() {
1640        let values = [-100.0f64, -1.0, -0.0, 0.0, 0.5, 1.0, 100.0];
1641        let encoded: Vec<u64> = values.iter().map(|&v| f64_to_sortable_u64(v)).collect();
1642        for i in 1..encoded.len() {
1643            assert!(
1644                encoded[i] >= encoded[i - 1],
1645                "{} >= {} failed for {} vs {}",
1646                encoded[i],
1647                encoded[i - 1],
1648                values[i],
1649                values[i - 1]
1650            );
1651        }
1652    }
1653
1654    #[test]
1655    fn test_bitpack_roundtrip() {
1656        let values: Vec<u64> = vec![0, 3, 7, 15, 0, 1, 6, 12];
1657        let bpv = 4u8;
1658        let mut packed = Vec::new();
1659        bitpack_write(&values, bpv, &mut packed);
1660
1661        for (i, &expected) in values.iter().enumerate() {
1662            let got = bitpack_read(&packed, bpv, i);
1663            assert_eq!(got, expected, "index {}", i);
1664        }
1665    }
1666
1667    #[test]
1668    fn test_bitpack_high_bpv_regression() {
1669        // Regression: bpv > 56 with non-zero bit_shift used to read wrong bits
1670        // because the old 8-byte fast path didn't check bit_shift + bpv <= 64.
1671        for bpv in [57u8, 58, 59, 60, 63, 64] {
1672            let max_val = if bpv == 64 {
1673                u64::MAX
1674            } else {
1675                (1u64 << bpv) - 1
1676            };
1677            let values: Vec<u64> = (0..32)
1678                .map(|i: u64| {
1679                    if max_val == u64::MAX {
1680                        i * 7
1681                    } else {
1682                        (i * 7) % (max_val + 1)
1683                    }
1684                })
1685                .collect();
1686            let mut packed = Vec::new();
1687            bitpack_write(&values, bpv, &mut packed);
1688            for (i, &expected) in values.iter().enumerate() {
1689                let got = bitpack_read(&packed, bpv, i);
1690                assert_eq!(got, expected, "high bpv={} index={}", bpv, i);
1691            }
1692        }
1693    }
1694
1695    #[test]
1696    fn test_bitpack_various_widths() {
1697        for bpv in [1u8, 2, 3, 5, 7, 8, 13, 16, 32, 64] {
1698            let max_val = if bpv == 64 {
1699                u64::MAX
1700            } else {
1701                (1u64 << bpv) - 1
1702            };
1703            let values: Vec<u64> = (0..100)
1704                .map(|i: u64| {
1705                    if max_val == u64::MAX {
1706                        i
1707                    } else {
1708                        i % (max_val + 1)
1709                    }
1710                })
1711                .collect();
1712            let mut packed = Vec::new();
1713            bitpack_write(&values, bpv, &mut packed);
1714
1715            for (i, &expected) in values.iter().enumerate() {
1716                let got = bitpack_read(&packed, bpv, i);
1717                assert_eq!(got, expected, "bpv={} index={}", bpv, i);
1718            }
1719        }
1720    }
1721
1722    /// Helper: wrap a Vec<u8> in OwnedBytes for tests.
1723    fn owned(buf: Vec<u8>) -> OwnedBytes {
1724        OwnedBytes::new(buf)
1725    }
1726
1727    #[test]
1728    fn test_writer_reader_u64_roundtrip() {
1729        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
1730        writer.add_u64(0, 100);
1731        writer.add_u64(1, 200);
1732        writer.add_u64(2, 150);
1733        writer.add_u64(4, 300); // gap at doc_id=3
1734        writer.pad_to(5);
1735
1736        let mut buf = Vec::new();
1737        let (mut toc, _bytes) = writer.serialize(&mut buf, 0).unwrap();
1738        toc.field_id = 42;
1739
1740        // Write TOC + footer
1741        let toc_offset = buf.len() as u64;
1742        write_fast_field_toc_and_footer(&mut buf, toc_offset, &[toc]).unwrap();
1743
1744        // Read back
1745        let ob = owned(buf);
1746        let (toc_off, num_cols) = read_fast_field_footer(&ob).unwrap();
1747        assert_eq!(num_cols, 1);
1748        let tocs = read_fast_field_toc(&ob, toc_off, num_cols).unwrap();
1749        assert_eq!(tocs.len(), 1);
1750        assert_eq!(tocs[0].field_id, 42);
1751
1752        let reader = FastFieldReader::open(&ob, &tocs[0]).unwrap();
1753        assert_eq!(reader.get_u64(0), 100);
1754        assert_eq!(reader.get_u64(1), 200);
1755        assert_eq!(reader.get_u64(2), 150);
1756        assert_eq!(reader.get_u64(3), FAST_FIELD_MISSING); // gap → absent sentinel
1757        assert_eq!(reader.get_u64(4), 300);
1758    }
1759
1760    #[test]
1761    fn test_writer_reader_i64_roundtrip() {
1762        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
1763        writer.add_i64(0, -100);
1764        writer.add_i64(1, 50);
1765        writer.add_i64(2, 0);
1766        writer.pad_to(3);
1767
1768        let mut buf = Vec::new();
1769        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1770        let ob = owned(buf);
1771        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1772        assert_eq!(reader.get_i64(0), -100);
1773        assert_eq!(reader.get_i64(1), 50);
1774        assert_eq!(reader.get_i64(2), 0);
1775    }
1776
1777    #[test]
1778    fn test_writer_reader_f64_roundtrip() {
1779        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::F64);
1780        writer.add_f64(0, -1.5);
1781        writer.add_f64(1, 3.15);
1782        writer.add_f64(2, 0.0);
1783        writer.pad_to(3);
1784
1785        let mut buf = Vec::new();
1786        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1787        let ob = owned(buf);
1788        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1789        assert_eq!(reader.get_f64(0), -1.5);
1790        assert_eq!(reader.get_f64(1), 3.15);
1791        assert_eq!(reader.get_f64(2), 0.0);
1792    }
1793
1794    #[test]
1795    fn test_writer_reader_text_roundtrip() {
1796        let mut writer = FastFieldWriter::new_text();
1797        writer.add_text(0, "banana");
1798        writer.add_text(1, "apple");
1799        writer.add_text(2, "cherry");
1800        writer.add_text(3, "apple"); // duplicate
1801        // doc_id=4 has no value
1802        writer.pad_to(5);
1803
1804        let mut buf = Vec::new();
1805        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1806        let ob = owned(buf);
1807        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1808
1809        // Dictionary is sorted: apple=0, banana=1, cherry=2
1810        assert_eq!(reader.get_text(0), Some("banana"));
1811        assert_eq!(reader.get_text(1), Some("apple"));
1812        assert_eq!(reader.get_text(2), Some("cherry"));
1813        assert_eq!(reader.get_text(3), Some("apple"));
1814        assert_eq!(reader.get_text(4), None); // missing
1815
1816        // Ordinal lookups
1817        assert_eq!(reader.text_ordinal("apple"), Some(0));
1818        assert_eq!(reader.text_ordinal("banana"), Some(1));
1819        assert_eq!(reader.text_ordinal("cherry"), Some(2));
1820        assert_eq!(reader.text_ordinal("durian"), None);
1821    }
1822
1823    #[test]
1824    fn test_constant_column() {
1825        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
1826        for i in 0..100 {
1827            writer.add_u64(i, 42);
1828        }
1829
1830        let mut buf = Vec::new();
1831        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1832
1833        let ob = owned(buf);
1834        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1835        for i in 0..100 {
1836            assert_eq!(reader.get_u64(i), 42);
1837        }
1838    }
1839
1840    // ── Multi-value tests ──
1841
1842    #[test]
1843    fn test_multi_value_u64_roundtrip() {
1844        let mut writer = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
1845        // doc 0: [10, 20, 30]
1846        writer.add_u64(0, 10);
1847        writer.add_u64(0, 20);
1848        writer.add_u64(0, 30);
1849        // doc 1: [] (empty)
1850        // doc 2: [100]
1851        writer.add_u64(2, 100);
1852        // doc 3: [5, 15]
1853        writer.add_u64(3, 5);
1854        writer.add_u64(3, 15);
1855        writer.pad_to(4);
1856
1857        let mut buf = Vec::new();
1858        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1859        assert!(toc.multi);
1860        assert_eq!(toc.num_docs, 4);
1861
1862        let ob = owned(buf);
1863        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1864        assert!(reader.multi);
1865
1866        // doc 0: first value
1867        assert_eq!(reader.get_u64(0), 10);
1868        let (s, e) = reader.value_range(0);
1869        assert_eq!(e - s, 3);
1870        assert_eq!(reader.get_value_at(s), 10);
1871        assert_eq!(reader.get_value_at(s + 1), 20);
1872        assert_eq!(reader.get_value_at(s + 2), 30);
1873
1874        // doc 1: empty → sentinel
1875        assert_eq!(reader.get_u64(1), FAST_FIELD_MISSING);
1876        let (s, e) = reader.value_range(1);
1877        assert_eq!(s, e);
1878        assert!(!reader.has_value(1));
1879
1880        // doc 2: [100]
1881        assert_eq!(reader.get_u64(2), 100);
1882        assert!(reader.has_value(2));
1883
1884        // doc 3: [5, 15]
1885        assert_eq!(reader.get_u64(3), 5);
1886        let (s, e) = reader.value_range(3);
1887        assert_eq!(e - s, 2);
1888        assert_eq!(reader.get_value_at(s), 5);
1889        assert_eq!(reader.get_value_at(s + 1), 15);
1890    }
1891
1892    #[test]
1893    fn test_multi_value_text_roundtrip() {
1894        let mut writer = FastFieldWriter::new_text_multi();
1895        // doc 0: ["banana", "apple"]
1896        writer.add_text(0, "banana");
1897        writer.add_text(0, "apple");
1898        // doc 1: ["cherry"]
1899        writer.add_text(1, "cherry");
1900        // doc 2: [] empty
1901        writer.pad_to(3);
1902
1903        let mut buf = Vec::new();
1904        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1905        assert!(toc.multi);
1906
1907        let ob = owned(buf);
1908        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1909
1910        // doc 0: first value ordinal → banana is ordinal 1 (apple=0, banana=1, cherry=2)
1911        let (s, e) = reader.value_range(0);
1912        assert_eq!(e - s, 2);
1913        let ord0 = reader.get_value_at(s);
1914        let ord1 = reader.get_value_at(s + 1);
1915        assert_eq!(reader.text_dict().unwrap().get(ord0 as u32), Some("banana"));
1916        assert_eq!(reader.text_dict().unwrap().get(ord1 as u32), Some("apple"));
1917
1918        // doc 1: cherry
1919        let (s, e) = reader.value_range(1);
1920        assert_eq!(e - s, 1);
1921        let ord = reader.get_value_at(s);
1922        assert_eq!(reader.text_dict().unwrap().get(ord as u32), Some("cherry"));
1923
1924        // doc 2: empty
1925        assert!(!reader.has_value(2));
1926    }
1927
1928    #[test]
1929    fn test_multi_value_full_toc_roundtrip() {
1930        let mut writer = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
1931        writer.add_u64(0, 1);
1932        writer.add_u64(0, 2);
1933        writer.add_u64(1, 3);
1934        writer.pad_to(2);
1935
1936        let mut buf = Vec::new();
1937        let (mut toc, _) = writer.serialize(&mut buf, 0).unwrap();
1938        toc.field_id = 7;
1939
1940        let toc_offset = buf.len() as u64;
1941        write_fast_field_toc_and_footer(&mut buf, toc_offset, &[toc]).unwrap();
1942
1943        let ob = owned(buf);
1944        let (toc_off, num_cols) = read_fast_field_footer(&ob).unwrap();
1945        let tocs = read_fast_field_toc(&ob, toc_off, num_cols).unwrap();
1946        assert_eq!(tocs[0].field_id, 7);
1947        assert!(tocs[0].multi);
1948
1949        let reader = FastFieldReader::open(&ob, &tocs[0]).unwrap();
1950        assert_eq!(reader.get_u64(0), 1);
1951        assert_eq!(reader.get_u64(1), 3);
1952    }
1953
1954    /// Helper: serialize a writer into a blocked column, return (block_data, block_dict, block_index_entry)
1955    /// by stripping the blocked header.
1956    fn serialize_single_block(writer: &mut FastFieldWriter) -> (Vec<u8>, Vec<u8>, BlockIndexEntry) {
1957        let mut buf = Vec::new();
1958        let (_toc, _) = writer.serialize(&mut buf, 0).unwrap();
1959        // Strip: [num_blocks(4)] [BlockIndexEntry(16)] [data...] [dict...]
1960        let mut cursor = std::io::Cursor::new(&buf[4..4 + BLOCK_INDEX_ENTRY_SIZE]);
1961        let entry = BlockIndexEntry::read_from(&mut cursor).unwrap();
1962        let data_start = 4 + BLOCK_INDEX_ENTRY_SIZE;
1963        let data_end = data_start + entry.data_len as usize;
1964        let dict_end = data_end + entry.dict_len as usize;
1965        let data = buf[data_start..data_end].to_vec();
1966        let dict = if dict_end > data_end {
1967            buf[data_end..dict_end].to_vec()
1968        } else {
1969            Vec::new()
1970        };
1971        (data, dict, entry)
1972    }
1973
1974    /// Manually assemble a multi-block column from individual block payloads.
1975    fn assemble_blocked_column(
1976        field_id: u32,
1977        column_type: FastFieldColumnType,
1978        multi: bool,
1979        blocks: &[(u32, &[u8], u32, &[u8])], // (num_docs, data, dict_count, dict)
1980    ) -> (Vec<u8>, FastFieldTocEntry) {
1981        use byteorder::{LittleEndian, WriteBytesExt};
1982
1983        let mut buf = Vec::new();
1984        let num_blocks = blocks.len() as u32;
1985
1986        // num_blocks
1987        buf.write_u32::<LittleEndian>(num_blocks).unwrap();
1988
1989        // block index
1990        for &(num_docs, data, dict_count, dict) in blocks {
1991            let entry = BlockIndexEntry {
1992                num_docs,
1993                data_len: data.len() as u32,
1994                dict_count,
1995                dict_len: dict.len() as u32,
1996            };
1997            entry.write_to(&mut buf).unwrap();
1998        }
1999
2000        // block data + dicts
2001        let mut total_docs = 0u32;
2002        for &(num_docs, data, _, dict) in blocks {
2003            buf.extend_from_slice(data);
2004            buf.extend_from_slice(dict);
2005            total_docs += num_docs;
2006        }
2007
2008        let data_len = buf.len() as u64;
2009
2010        // Write TOC + footer
2011        let toc = FastFieldTocEntry {
2012            field_id,
2013            column_type,
2014            multi,
2015            data_offset: 0,
2016            data_len,
2017            num_docs: total_docs,
2018            dict_offset: 0,
2019            dict_count: 0,
2020        };
2021
2022        let toc_offset = buf.len() as u64;
2023        write_fast_field_toc_and_footer(&mut buf, toc_offset, std::slice::from_ref(&toc)).unwrap();
2024
2025        (buf, toc)
2026    }
2027
2028    #[test]
2029    fn test_multi_block_numeric_roundtrip() {
2030        // Block A: 3 docs [10, 20, 30]
2031        let mut wa = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
2032        wa.add_u64(0, 10);
2033        wa.add_u64(1, 20);
2034        wa.add_u64(2, 30);
2035        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2036
2037        // Block B: 2 docs [40, 50]
2038        let mut wb = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
2039        wb.add_u64(0, 40);
2040        wb.add_u64(1, 50);
2041        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2042
2043        let (buf, toc) = assemble_blocked_column(
2044            1,
2045            FastFieldColumnType::U64,
2046            false,
2047            &[
2048                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2049                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2050            ],
2051        );
2052
2053        let ob = owned(buf);
2054        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2055
2056        assert_eq!(reader.num_docs, 5);
2057        assert_eq!(reader.num_blocks(), 2);
2058        assert_eq!(reader.get_u64(0), 10);
2059        assert_eq!(reader.get_u64(1), 20);
2060        assert_eq!(reader.get_u64(2), 30);
2061        assert_eq!(reader.get_u64(3), 40);
2062        assert_eq!(reader.get_u64(4), 50);
2063    }
2064
2065    #[test]
2066    fn test_multi_block_text_roundtrip() {
2067        // Block A: 2 docs ["alpha", "beta"]
2068        let mut wa = FastFieldWriter::new_text();
2069        wa.add_text(0, "alpha");
2070        wa.add_text(1, "beta");
2071        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2072
2073        // Block B: 2 docs ["gamma", "alpha"]  (alpha shared with block A)
2074        let mut wb = FastFieldWriter::new_text();
2075        wb.add_text(0, "gamma");
2076        wb.add_text(1, "alpha");
2077        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2078
2079        let (buf, toc) = assemble_blocked_column(
2080            2,
2081            FastFieldColumnType::TextOrdinal,
2082            false,
2083            &[
2084                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2085                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2086            ],
2087        );
2088
2089        let ob = owned(buf);
2090        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2091
2092        assert_eq!(reader.num_docs, 4);
2093        assert_eq!(reader.num_blocks(), 2);
2094
2095        // Global dict should be: alpha(0), beta(1), gamma(2)
2096        assert_eq!(reader.text_dict().unwrap().len(), 3);
2097
2098        // Block A: alpha=local0→global0, beta=local1→global1
2099        assert_eq!(reader.get_text(0), Some("alpha"));
2100        assert_eq!(reader.get_text(1), Some("beta"));
2101
2102        // Block B: gamma=local1→global2, alpha=local0→global0
2103        assert_eq!(reader.get_text(2), Some("gamma"));
2104        assert_eq!(reader.get_text(3), Some("alpha"));
2105
2106        // Global ordinal lookups
2107        assert_eq!(reader.text_ordinal("alpha"), Some(0));
2108        assert_eq!(reader.text_ordinal("beta"), Some(1));
2109        assert_eq!(reader.text_ordinal("gamma"), Some(2));
2110
2111        // get_u64 returns global ordinals
2112        assert_eq!(reader.get_u64(0), 0); // alpha
2113        assert_eq!(reader.get_u64(1), 1); // beta
2114        assert_eq!(reader.get_u64(2), 2); // gamma
2115        assert_eq!(reader.get_u64(3), 0); // alpha
2116    }
2117
2118    /// Regression test: ordinal mismatch when blocks have disjoint dicts
2119    /// that arrive in non-sorted order.
2120    ///
2121    /// Block A has ["book","wiki"], Block B has ["apple","wiki"].
2122    /// "apple" < "book" < "wiki" alphabetically, but "book" is encountered
2123    /// first. Before the fix, insertion-order ordinals were used instead of
2124    /// sorted-position ordinals, causing text_ordinal() and get_u64() to
2125    /// disagree — wrong documents would pass fast-field predicates.
2126    #[test]
2127    fn test_multi_block_text_ordinal_mismatch_regression() {
2128        // Block A: 2 docs ["book", "wiki"]
2129        let mut wa = FastFieldWriter::new_text();
2130        wa.add_text(0, "book");
2131        wa.add_text(1, "wiki");
2132        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2133
2134        // Block B: 2 docs ["apple", "wiki"]  ("apple" < "book" alphabetically)
2135        let mut wb = FastFieldWriter::new_text();
2136        wb.add_text(0, "apple");
2137        wb.add_text(1, "wiki");
2138        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2139
2140        let (buf, toc) = assemble_blocked_column(
2141            2,
2142            FastFieldColumnType::TextOrdinal,
2143            false,
2144            &[
2145                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2146                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2147            ],
2148        );
2149
2150        let ob = owned(buf);
2151        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2152
2153        // Global dict should be sorted: apple(0), book(1), wiki(2)
2154        assert_eq!(reader.text_dict().unwrap().len(), 3);
2155        assert_eq!(reader.text_ordinal("apple"), Some(0));
2156        assert_eq!(reader.text_ordinal("book"), Some(1));
2157        assert_eq!(reader.text_ordinal("wiki"), Some(2));
2158
2159        // get_u64 must return the SAME global ordinals that text_ordinal returns
2160        assert_eq!(reader.get_u64(0), 1); // doc0 in block A = "book" → global 1
2161        assert_eq!(reader.get_u64(1), 2); // doc1 in block A = "wiki" → global 2
2162        assert_eq!(reader.get_u64(2), 0); // doc0 in block B = "apple" → global 0
2163        assert_eq!(reader.get_u64(3), 2); // doc1 in block B = "wiki" → global 2
2164
2165        // Simulate TermQuery predicate: text_ordinal("wiki") == get_u64(doc_id)
2166        let wiki_ord = reader.text_ordinal("wiki").unwrap();
2167        assert_eq!(reader.get_u64(1), wiki_ord, "wiki doc should match");
2168        assert_eq!(reader.get_u64(3), wiki_ord, "wiki doc should match");
2169        assert_ne!(reader.get_u64(0), wiki_ord, "book doc must NOT match wiki");
2170        assert_ne!(reader.get_u64(2), wiki_ord, "apple doc must NOT match wiki");
2171    }
2172
2173    /// Regression: issued_at timestamps stored via add_i64 with gaps
2174    /// should roundtrip correctly through FastFieldWriter → FastFieldReader.
2175    #[test]
2176    fn test_i64_timestamps_with_missing_roundtrip() {
2177        let base_ts = 1724630400i64; // 2024-08-26 epoch seconds
2178        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
2179
2180        // 100 docs, every 5th has no issued_at
2181        let mut expected_values: Vec<Option<i64>> = Vec::new();
2182        for i in 0..100u32 {
2183            if i % 5 == 0 {
2184                expected_values.push(None); // missing
2185            } else {
2186                let ts = base_ts - (i as i64 * 86400);
2187                writer.add_i64(i, ts);
2188                expected_values.push(Some(ts));
2189            }
2190        }
2191        writer.pad_to(100);
2192
2193        let mut buf = Vec::new();
2194        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
2195        let ob = owned(buf);
2196        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2197
2198        for (i, expected) in expected_values.iter().enumerate() {
2199            let raw = reader.get_u64(i as u32);
2200            match expected {
2201                None => {
2202                    assert_eq!(
2203                        raw, FAST_FIELD_MISSING,
2204                        "doc {}: expected MISSING, got raw {}",
2205                        i, raw
2206                    );
2207                }
2208                Some(ts) => {
2209                    assert_ne!(
2210                        raw, FAST_FIELD_MISSING,
2211                        "doc {}: expected timestamp {}, got MISSING",
2212                        i, ts
2213                    );
2214                    let decoded = zigzag_decode(raw);
2215                    assert_eq!(
2216                        decoded,
2217                        *ts,
2218                        "doc {}: expected i64 {}, got i64 {} (raw zigzag: {}, expected zigzag: {})",
2219                        i,
2220                        ts,
2221                        decoded,
2222                        raw,
2223                        zigzag_encode(*ts)
2224                    );
2225                }
2226            }
2227        }
2228    }
2229
2230    /// Regression: specific value 1724630400 that was corrupted in production.
2231    /// Test with varying column sizes to exercise different codec selections.
2232    #[test]
2233    fn test_issued_at_1724630400_various_sizes() {
2234        let target_ts = 1724630400i64;
2235        let target_zigzag = zigzag_encode(target_ts);
2236
2237        for num_docs in [2, 5, 10, 50, 100, 500, 1000, 2000] {
2238            let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
2239            let target_doc = num_docs / 3;
2240
2241            for i in 0..num_docs as u32 {
2242                if i == target_doc as u32 {
2243                    writer.add_i64(i, target_ts);
2244                } else if i % 3 == 0 {
2245                    // missing
2246                } else {
2247                    let ts = 1700000000i64 + (i as i64 * 86400);
2248                    writer.add_i64(i, ts);
2249                }
2250            }
2251            writer.pad_to(num_docs as u32);
2252
2253            let mut buf = Vec::new();
2254            let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
2255            let ob = owned(buf);
2256            let reader = FastFieldReader::open(&ob, &toc).unwrap();
2257
2258            let raw = reader.get_u64(target_doc as u32);
2259            assert_eq!(
2260                raw,
2261                target_zigzag,
2262                "num_docs={}: doc {} expected zigzag {} (ts {}), got {} (decoded i64: {})",
2263                num_docs,
2264                target_doc,
2265                target_zigzag,
2266                target_ts,
2267                raw,
2268                zigzag_decode(raw)
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn test_multi_block_multi_value_numeric() {
2275        // Block A: doc0=[1,2], doc1=[3]
2276        let mut wa = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
2277        wa.add_u64(0, 1);
2278        wa.add_u64(0, 2);
2279        wa.add_u64(1, 3);
2280        wa.pad_to(2);
2281        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2282
2283        // Block B: doc0=[4,5,6], doc1=[]
2284        let mut wb = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
2285        wb.add_u64(0, 4);
2286        wb.add_u64(0, 5);
2287        wb.add_u64(0, 6);
2288        wb.pad_to(2);
2289        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2290
2291        let (buf, toc) = assemble_blocked_column(
2292            3,
2293            FastFieldColumnType::U64,
2294            true,
2295            &[
2296                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2297                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2298            ],
2299        );
2300
2301        let ob = owned(buf);
2302        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2303
2304        assert_eq!(reader.num_docs, 4);
2305        assert_eq!(reader.num_blocks(), 2);
2306
2307        // doc0 (block A): [1, 2]
2308        assert_eq!(reader.get_multi_values(0), vec![1, 2]);
2309        // doc1 (block A): [3]
2310        assert_eq!(reader.get_multi_values(1), vec![3]);
2311        // doc2 (block B, local 0): [4, 5, 6]
2312        assert_eq!(reader.get_multi_values(2), vec![4, 5, 6]);
2313        // doc3 (block B, local 1): []
2314        assert_eq!(reader.get_multi_values(3), Vec::<u64>::new());
2315    }
2316}