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:
552    /// `[num_blocks(4)] [BlockIndexEntry] [block_data] [block_dict?]`.
553    /// Returns `(toc_entry, total_bytes_written)`.
554    pub fn serialize(
555        &mut self,
556        writer: &mut dyn Write,
557        data_offset: u64,
558    ) -> io::Result<(FastFieldTocEntry, u64)> {
559        // For text ordinal: resolve strings to sorted ordinals
560        if self.column_type == FastFieldColumnType::TextOrdinal {
561            self.resolve_text_ordinals();
562        }
563
564        let num_docs = self.num_docs();
565
566        // Serialize block data into a temp buffer to measure lengths
567        let mut block_data = Vec::new();
568        if self.multi {
569            // Multi-value: write [offset_col_len(4)] [offset_col] [value_col]
570            let offsets_u64: Vec<u64> = self.multi_offsets.iter().map(|&v| v as u64).collect();
571            let mut offset_buf = Vec::new();
572            codec::serialize_auto(&offsets_u64, &mut offset_buf)?;
573
574            block_data.write_u32::<LittleEndian>(offset_buf.len() as u32)?;
575            block_data.write_all(&offset_buf)?;
576
577            codec::serialize_auto(&self.multi_values, &mut block_data)?;
578        } else {
579            codec::serialize_auto(&self.values, &mut block_data)?;
580        }
581
582        // Serialize text dictionary into temp buffer
583        let mut dict_buf = Vec::new();
584        let dict_count = if self.column_type == FastFieldColumnType::TextOrdinal {
585            let (count, _) = self.write_text_dictionary(&mut dict_buf)?;
586            count
587        } else {
588            0u32
589        };
590
591        // Build block index entry
592        let block_entry = BlockIndexEntry {
593            num_docs,
594            data_len: block_data.len() as u32,
595            dict_count,
596            dict_len: dict_buf.len() as u32,
597        };
598
599        // Write: num_blocks + block_index + block_data + block_dict
600        let mut total_bytes = 0u64;
601
602        writer.write_u32::<LittleEndian>(1u32)?; // num_blocks
603        total_bytes += 4;
604
605        block_entry.write_to(writer)?;
606        total_bytes += BLOCK_INDEX_ENTRY_SIZE as u64;
607
608        writer.write_all(&block_data)?;
609        total_bytes += block_data.len() as u64;
610
611        writer.write_all(&dict_buf)?;
612        total_bytes += dict_buf.len() as u64;
613
614        let toc = FastFieldTocEntry {
615            field_id: 0, // set by caller
616            column_type: self.column_type,
617            multi: self.multi,
618            data_offset,
619            data_len: total_bytes,
620            num_docs,
621            dict_offset: 0, // no longer used at TOC level (per-block dicts)
622            dict_count: 0,
623        };
624
625        Ok((toc, total_bytes))
626    }
627
628    /// Resolve text per-doc values to sorted ordinals.
629    fn resolve_text_ordinals(&mut self) {
630        let dict = self.text_values.as_ref().expect("text_values required");
631
632        // Build sorted ordinal map: BTreeMap iterates in sorted order
633        let sorted_ordinals: BTreeMap<&str, u64> = dict
634            .keys()
635            .enumerate()
636            .map(|(ord, key)| (key.as_str(), ord as u64))
637            .collect();
638
639        if self.multi {
640            // Multi-value: resolve multi_values via text_multi_values
641            if let Some(ref tmv) = self.text_multi_values {
642                for (i, text) in tmv.iter().enumerate() {
643                    self.multi_values[i] = sorted_ordinals[text.as_str()];
644                }
645            }
646        } else {
647            // Single-value: resolve values via text_per_doc
648            let tpd = self.text_per_doc.as_ref().expect("text_per_doc required");
649            for (i, doc_text) in tpd.iter().enumerate() {
650                match doc_text {
651                    Some(text) => {
652                        self.values[i] = sorted_ordinals[text.as_str()];
653                    }
654                    None => {
655                        self.values[i] = FAST_FIELD_MISSING;
656                    }
657                }
658            }
659        }
660    }
661
662    /// Write len-prefixed sorted strings. Returns (dict_count, bytes_written).
663    fn write_text_dictionary(&self, writer: &mut dyn Write) -> io::Result<(u32, u64)> {
664        let dict = self.text_values.as_ref().expect("text_values required");
665        let mut bytes_written = 0u64;
666
667        // BTreeMap keys are already sorted
668        let count = dict.len() as u32;
669        for key in dict.keys() {
670            let key_bytes = key.as_bytes();
671            writer.write_u32::<LittleEndian>(key_bytes.len() as u32)?;
672            writer.write_all(key_bytes)?;
673            bytes_written += 4 + key_bytes.len() as u64;
674        }
675
676        Ok((count, bytes_written))
677    }
678}
679
680// ── Reader ────────────────────────────────────────────────────────────────
681
682use crate::directories::OwnedBytes;
683
684/// One independently-decodable block within a blocked column.
685///
686/// All byte slices are zero-copy borrows from the mmap'd `.fast` file.
687pub struct ColumnBlock {
688    /// Number of docs before this block (for doc_id → block lookup).
689    pub cumulative_docs: u32,
690    /// Number of docs in this block.
691    pub num_docs: u32,
692    /// Auto-codec encoded data for this block (single-value or raw multi-value region).
693    pub data: OwnedBytes,
694    /// For multi-value blocks: offset sub-column.
695    pub offset_data: OwnedBytes,
696    /// For multi-value blocks: value sub-column.
697    pub value_data: OwnedBytes,
698    /// Per-block text dictionary (text columns only). Lazy — offsets built on first access.
699    pub dict: Option<TextDictReader>,
700    /// Raw dictionary bytes for this block (for merge: memcpy).
701    pub raw_dict: OwnedBytes,
702}
703
704/// Reads a single fast-field column from mmap/buffer with O(1) doc_id access.
705///
706/// A column is a sequence of independently-decodable blocks. Fresh segments
707/// have one block; merged segments may have multiple (one per source segment).
708///
709/// **Zero-copy**: all data is borrowed from the underlying mmap / `OwnedBytes`.
710///
711/// **Lazy text state**: for text-ordinal columns, the global merged dictionary
712/// and per-block ordinal maps are built lazily on first access (not at load time).
713/// This avoids scanning all dictionary pages from mmap during segment loading.
714pub struct FastFieldReader {
715    pub column_type: FastFieldColumnType,
716    pub num_docs: u32,
717    pub multi: bool,
718
719    /// Blocks in doc_id order.
720    blocks: Vec<ColumnBlock>,
721
722    /// Lazy-initialized text state (global dict + ordinal maps).
723    /// Built on first text-related access, not at load time.
724    text_state: OnceLock<TextState>,
725}
726
727/// Lazily-built state for text-ordinal columns.
728struct TextState {
729    /// Global merged dictionary across all blocks.
730    global_dict: TextDictReader,
731    /// Per-block ordinal maps: `ordinal_maps[block_idx][local_ord] → global_ord`.
732    /// Empty Vec for blocks without dicts or single-block columns (identity mapping).
733    ordinal_maps: Vec<Vec<u32>>,
734}
735
736impl FastFieldReader {
737    /// Bytes of column data backing this reader (values, offsets, dicts).
738    pub fn disk_bytes(&self) -> u64 {
739        self.blocks
740            .iter()
741            .map(|block| {
742                (block.data.len()
743                    + block.offset_data.len()
744                    + block.value_data.len()
745                    + block.raw_dict.len()) as u64
746            })
747            .sum()
748    }
749
750    /// Open a blocked column from an `OwnedBytes` file buffer using a TOC entry.
751    ///
752    /// For text-ordinal columns, dictionary scanning and global dict merging are
753    /// deferred to first access — no mmap pages are touched for dict data here.
754    pub fn open(file_data: &OwnedBytes, toc: &FastFieldTocEntry) -> io::Result<Self> {
755        let region_start = usize::try_from(toc.data_offset).map_err(|_| {
756            io::Error::new(
757                io::ErrorKind::InvalidData,
758                "fast field data offset exceeds address space",
759            )
760        })?;
761        let region_len = usize::try_from(toc.data_len).map_err(|_| {
762            io::Error::new(
763                io::ErrorKind::InvalidData,
764                "fast field data length exceeds address space",
765            )
766        })?;
767        let region_end = region_start.checked_add(region_len).ok_or_else(|| {
768            io::Error::new(io::ErrorKind::InvalidData, "fast field data range overflow")
769        })?;
770
771        if region_end > file_data.len() {
772            return Err(io::Error::new(
773                io::ErrorKind::UnexpectedEof,
774                "fast field data out of bounds",
775            ));
776        }
777
778        let raw = file_data.as_slice();
779
780        // Read num_blocks
781        let mut pos = region_start;
782        if pos.checked_add(4).is_none_or(|end| end > region_end) {
783            return Err(io::Error::new(
784                io::ErrorKind::UnexpectedEof,
785                "fast field: missing num_blocks",
786            ));
787        }
788        let num_blocks = u32::from_le_bytes(raw[pos..pos + 4].try_into().unwrap());
789        pos += 4;
790
791        // Read block index
792        let idx_size = (num_blocks as usize)
793            .checked_mul(BLOCK_INDEX_ENTRY_SIZE)
794            .ok_or_else(|| {
795                io::Error::new(
796                    io::ErrorKind::InvalidData,
797                    "fast field block index overflow",
798                )
799            })?;
800        let index_end = pos.checked_add(idx_size).ok_or_else(|| {
801            io::Error::new(
802                io::ErrorKind::InvalidData,
803                "fast field block index overflow",
804            )
805        })?;
806        if index_end > region_end {
807            return Err(io::Error::new(
808                io::ErrorKind::UnexpectedEof,
809                "fast field: block index truncated",
810            ));
811        }
812        let mut block_entries = Vec::new();
813        block_entries
814            .try_reserve_exact(num_blocks as usize)
815            .map_err(|_| {
816                io::Error::new(io::ErrorKind::InvalidData, "too many fast field blocks")
817            })?;
818        {
819            let mut cursor = std::io::Cursor::new(&raw[pos..index_end]);
820            for _ in 0..num_blocks {
821                block_entries.push(BlockIndexEntry::read_from(&mut cursor)?);
822            }
823        }
824        pos = index_end;
825
826        let empty = OwnedBytes::new(Vec::new());
827
828        // Parse each block's data + dict slices
829        let mut blocks = Vec::new();
830        blocks.try_reserve_exact(num_blocks as usize).map_err(|_| {
831            io::Error::new(io::ErrorKind::InvalidData, "too many fast field blocks")
832        })?;
833        let mut cumulative = 0u32;
834
835        for entry in &block_entries {
836            let data_start = pos;
837            let data_end = data_start
838                .checked_add(entry.data_len as usize)
839                .ok_or_else(|| {
840                    io::Error::new(
841                        io::ErrorKind::InvalidData,
842                        "fast field block range overflow",
843                    )
844                })?;
845            let dict_start = data_end;
846            let dict_end = dict_start
847                .checked_add(entry.dict_len as usize)
848                .ok_or_else(|| {
849                    io::Error::new(io::ErrorKind::InvalidData, "fast field dict range overflow")
850                })?;
851
852            if dict_end > region_end {
853                return Err(io::Error::new(
854                    io::ErrorKind::UnexpectedEof,
855                    "fast field: block data/dict truncated",
856                ));
857            }
858
859            // Parse multi-value sub-columns from block data
860            let (block_data, offset_data, value_data) = if toc.multi {
861                let block_raw = &raw[data_start..data_end];
862                if block_raw.len() < 4 {
863                    return Err(io::Error::new(
864                        io::ErrorKind::UnexpectedEof,
865                        "fast field multi-value header is truncated",
866                    ));
867                }
868                let offset_col_len =
869                    u32::from_le_bytes(block_raw[0..4].try_into().unwrap()) as usize;
870                let o_start = data_start + 4;
871                let o_end = o_start.checked_add(offset_col_len).ok_or_else(|| {
872                    io::Error::new(
873                        io::ErrorKind::InvalidData,
874                        "fast field offset column range overflow",
875                    )
876                })?;
877                if o_end > data_end {
878                    return Err(io::Error::new(
879                        io::ErrorKind::UnexpectedEof,
880                        "fast field offset column is truncated",
881                    ));
882                }
883                let v_start = o_end;
884                let v_end = data_end;
885                let offset_data = file_data.slice(o_start..o_end);
886                let value_data = file_data.slice(v_start..v_end);
887                let offset_count = (entry.num_docs as usize).checked_add(1).ok_or_else(|| {
888                    io::Error::new(io::ErrorKind::InvalidData, "fast field doc count overflow")
889                })?;
890                codec::validate_auto(offset_data.as_slice(), offset_count)?;
891
892                let mut previous = 0u64;
893                for index in 0..offset_count {
894                    let offset = codec::auto_read(offset_data.as_slice(), index);
895                    if offset > u32::MAX as u64 || (index == 0 && offset != 0) || offset < previous
896                    {
897                        return Err(io::Error::new(
898                            io::ErrorKind::InvalidData,
899                            "fast field value offsets are invalid",
900                        ));
901                    }
902                    previous = offset;
903                }
904                codec::validate_auto(value_data.as_slice(), previous as usize)?;
905
906                (
907                    file_data.slice(data_start..data_end),
908                    offset_data,
909                    value_data,
910                )
911            } else {
912                let block_data = file_data.slice(data_start..data_end);
913                codec::validate_auto(block_data.as_slice(), entry.num_docs as usize)?;
914                (block_data, empty.clone(), empty.clone())
915            };
916
917            if toc.column_type == FastFieldColumnType::TextOrdinal {
918                if entry.dict_count == 0 && entry.dict_len != 0 {
919                    return Err(io::Error::new(
920                        io::ErrorKind::InvalidData,
921                        "empty fast field dictionary has data",
922                    ));
923                }
924                validate_text_dict_bytes(&raw[dict_start..dict_end], entry.dict_count)?;
925            } else if entry.dict_count != 0 || entry.dict_len != 0 {
926                return Err(io::Error::new(
927                    io::ErrorKind::InvalidData,
928                    "numeric fast field contains a text dictionary",
929                ));
930            }
931
932            // Create lazy block dict — no scanning, just stores the data slice + count
933            let dict = if entry.dict_count > 0 {
934                Some(TextDictReader::new_lazy(
935                    file_data.slice(dict_start..dict_end),
936                    entry.dict_count,
937                ))
938            } else {
939                None
940            };
941
942            let raw_dict = if entry.dict_len > 0 {
943                file_data.slice(dict_start..dict_end)
944            } else {
945                empty.clone()
946            };
947
948            blocks.push(ColumnBlock {
949                cumulative_docs: cumulative,
950                num_docs: entry.num_docs,
951                data: block_data,
952                offset_data,
953                value_data,
954                dict,
955                raw_dict,
956            });
957
958            cumulative = cumulative.checked_add(entry.num_docs).ok_or_else(|| {
959                io::Error::new(io::ErrorKind::InvalidData, "fast field doc count overflow")
960            })?;
961            pos = dict_end;
962        }
963
964        if pos != region_end || cumulative != toc.num_docs {
965            return Err(io::Error::new(
966                io::ErrorKind::InvalidData,
967                "fast field block totals are inconsistent with the TOC",
968            ));
969        }
970        if toc.num_docs > 0 && blocks.is_empty() {
971            return Err(io::Error::new(
972                io::ErrorKind::InvalidData,
973                "non-empty fast field has no blocks",
974            ));
975        }
976
977        Ok(Self {
978            column_type: toc.column_type,
979            num_docs: toc.num_docs,
980            multi: toc.multi,
981            blocks,
982            text_state: OnceLock::new(),
983        })
984    }
985
986    /// Lazily initialize and return the text state (global dict + ordinal maps).
987    /// Only called for text-ordinal columns.
988    fn ensure_text_state(&self) -> &TextState {
989        self.text_state
990            .get_or_init(|| Self::build_text_state(&self.blocks))
991    }
992
993    /// Build text state: global merged dictionary + per-block ordinal maps.
994    /// Called lazily on first text-related access (not at segment load time).
995    fn build_text_state(blocks: &[ColumnBlock]) -> TextState {
996        // Fast path: single block → block-local ordinals ARE global ordinals.
997        // No merging, no cloning, no ordinal map needed.
998        let blocks_with_dict = blocks.iter().filter(|b| b.dict.is_some()).count();
999        if blocks_with_dict <= 1 {
1000            for block in blocks.iter() {
1001                if let Some(ref dict) = block.dict {
1002                    // Re-use the existing dict — no ordinal_map needed (identity mapping)
1003                    return TextState {
1004                        global_dict: TextDictReader::new_lazy(block.raw_dict.clone(), dict.len()),
1005                        ordinal_maps: vec![Vec::new(); blocks.len()],
1006                    };
1007                }
1008            }
1009            // No blocks have dicts — return empty
1010            return TextState {
1011                global_dict: TextDictReader::new_lazy(OwnedBytes::new(Vec::new()), 0),
1012                ordinal_maps: vec![Vec::new(); blocks.len()],
1013            };
1014        }
1015
1016        // Multi-block: merge sorted block dictionaries.
1017        // Each block dict is already sorted, so we k-way merge in O(total_entries).
1018        // Uses a BTreeMap to deduplicate and assign global ordinals.
1019
1020        // Phase 1: Collect unique strings → assign global ordinals.
1021        //
1022        // BTreeMap is sorted by key, so ordinals assigned by iterating values_mut()
1023        // match the order that Phase 3 writes the dictionary (also key-sorted).
1024        // This is critical: TextDictReader::ordinal() does binary search by position,
1025        // so the ordinal_map values MUST equal the sorted position, not insertion order.
1026        let mut unique_map: BTreeMap<String, u32> = BTreeMap::new();
1027        for block in blocks.iter() {
1028            if let Some(ref dict) = block.dict {
1029                for ord in 0..dict.len() {
1030                    if let Some(text) = dict.get(ord) {
1031                        unique_map.entry(text.to_string()).or_insert(0);
1032                    }
1033                }
1034            }
1035        }
1036        // Assign ordinals by sorted position (BTreeMap iterates keys in order).
1037        for (i, value) in unique_map.values_mut().enumerate() {
1038            *value = i as u32;
1039        }
1040
1041        // Phase 2: Build per-block ordinal maps
1042        let mut ordinal_maps = Vec::with_capacity(blocks.len());
1043        for block in blocks.iter() {
1044            if let Some(ref dict) = block.dict {
1045                let mut map = Vec::with_capacity(dict.len() as usize);
1046                for local_ord in 0..dict.len() {
1047                    let text = dict
1048                        .get(local_ord)
1049                        .expect("block dict ordinal out of range");
1050                    let global_ord = *unique_map
1051                        .get(text)
1052                        .expect("block dict entry not found in merged global dict");
1053                    map.push(global_ord);
1054                }
1055                ordinal_maps.push(map);
1056            } else {
1057                ordinal_maps.push(Vec::new());
1058            }
1059        }
1060
1061        // Phase 3: Serialize global dict (sorted) into a buffer
1062        let mut dict_buf = Vec::new();
1063        let count = unique_map.len() as u32;
1064        for s in unique_map.keys() {
1065            let bytes = s.as_bytes();
1066            dict_buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1067            dict_buf.extend_from_slice(bytes);
1068        }
1069
1070        TextState {
1071            global_dict: TextDictReader::new_lazy(OwnedBytes::new(dict_buf), count),
1072            ordinal_maps,
1073        }
1074    }
1075
1076    /// Remap a block-local raw ordinal to a global ordinal using the ordinal map.
1077    /// Returns raw unchanged for non-text columns, single-block columns, or missing ordinals.
1078    #[inline]
1079    fn remap_ordinal(&self, block_idx: usize, raw: u64) -> u64 {
1080        if self.column_type == FastFieldColumnType::TextOrdinal
1081            && raw != FAST_FIELD_MISSING
1082            && self.blocks.len() > 1
1083        {
1084            let state = self.ensure_text_state();
1085            let map = &state.ordinal_maps[block_idx];
1086            if !map.is_empty() {
1087                let idx = raw as usize;
1088                if idx < map.len() {
1089                    map[idx] as u64
1090                } else {
1091                    FAST_FIELD_MISSING
1092                }
1093            } else {
1094                raw
1095            }
1096        } else {
1097            raw
1098        }
1099    }
1100
1101    /// Find the block containing `doc_id`. Returns (block_index, local_doc_id).
1102    #[inline]
1103    fn find_block(&self, doc_id: u32) -> (usize, u32) {
1104        debug_assert!(!self.blocks.is_empty());
1105        // Single block fast path (common: fresh segments)
1106        if self.blocks.len() == 1 {
1107            return (0, doc_id);
1108        }
1109        // Binary search: find the last block whose cumulative_docs <= doc_id
1110        let bi = self
1111            .blocks
1112            .partition_point(|b| b.cumulative_docs <= doc_id)
1113            .saturating_sub(1);
1114        (bi, doc_id - self.blocks[bi].cumulative_docs)
1115    }
1116
1117    /// Get raw u64 value for a doc_id.
1118    ///
1119    /// Returns [`FAST_FIELD_MISSING`] for out-of-range doc_ids **and** for docs
1120    /// that were never assigned a value (absent docs).
1121    ///
1122    /// For text columns, returns the global ordinal (remapped from block-local).
1123    /// For multi-valued columns, returns the first value (or `FAST_FIELD_MISSING` if empty).
1124    #[inline]
1125    pub fn get_u64(&self, doc_id: u32) -> u64 {
1126        if doc_id >= self.num_docs {
1127            return FAST_FIELD_MISSING;
1128        }
1129        let (bi, local) = self.find_block(doc_id);
1130        let block = &self.blocks[bi];
1131
1132        if self.multi {
1133            let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1134            let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1135            if start >= end {
1136                return FAST_FIELD_MISSING;
1137            }
1138            let raw = codec::auto_read(block.value_data.as_slice(), start as usize);
1139            return self.remap_ordinal(bi, raw);
1140        }
1141
1142        let raw = codec::auto_read(block.data.as_slice(), local as usize);
1143        self.remap_ordinal(bi, raw)
1144    }
1145
1146    /// Get the value range for a multi-valued column within its block.
1147    /// Returns (block_index, start_index, end_index) into the block's flat value array.
1148    #[inline]
1149    fn block_value_range(&self, doc_id: u32) -> (usize, u32, u32) {
1150        if !self.multi || doc_id >= self.num_docs {
1151            return (0, 0, 0);
1152        }
1153        let (bi, local) = self.find_block(doc_id);
1154        let block = &self.blocks[bi];
1155        let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1156        let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1157        (bi, start, end)
1158    }
1159
1160    /// Get the value range for a multi-valued column.
1161    /// Returns (start_index, end_index) — for single-block columns these are
1162    /// direct indices; for multi-block, use `get_multi_values` instead.
1163    #[inline]
1164    pub fn value_range(&self, doc_id: u32) -> (u32, u32) {
1165        let (_, start, end) = self.block_value_range(doc_id);
1166        (start, end)
1167    }
1168
1169    /// Get a specific value from the flat value array (multi-value mode).
1170    /// For single-block columns only. For multi-block, use `get_multi_values`.
1171    #[inline]
1172    pub fn get_value_at(&self, index: u32) -> u64 {
1173        // For single-block (common case), delegate directly
1174        if self.blocks.len() == 1 {
1175            let raw = codec::auto_read(self.blocks[0].value_data.as_slice(), index as usize);
1176            return self.remap_ordinal(0, raw);
1177        }
1178        // Multi-block fallback — index is block-local, caller should use get_multi_values
1179        0
1180    }
1181
1182    /// Get all values for a multi-valued doc_id. Handles multi-block correctly.
1183    pub fn get_multi_values(&self, doc_id: u32) -> Vec<u64> {
1184        let (bi, start, end) = self.block_value_range(doc_id);
1185        if start >= end {
1186            return Vec::new();
1187        }
1188        let block = &self.blocks[bi];
1189        (start..end)
1190            .map(|idx| {
1191                let raw = codec::auto_read(block.value_data.as_slice(), idx as usize);
1192                self.remap_ordinal(bi, raw)
1193            })
1194            .collect()
1195    }
1196
1197    /// Iterate multi-values for a doc, calling `f` for each. Returns true if `f` ever returns true (short-circuit).
1198    /// Handles multi-block columns correctly by finding the right block.
1199    #[inline]
1200    pub fn for_each_multi_value(&self, doc_id: u32, mut f: impl FnMut(u64) -> bool) -> bool {
1201        let (bi, start, end) = self.block_value_range(doc_id);
1202        if start >= end {
1203            return false;
1204        }
1205        let block = &self.blocks[bi];
1206        for idx in start..end {
1207            let raw = codec::auto_read(block.value_data.as_slice(), idx as usize);
1208            if f(self.remap_ordinal(bi, raw)) {
1209                return true;
1210            }
1211        }
1212        false
1213    }
1214
1215    /// Batch-scan all values in a single-value column, calling `f(doc_id, raw_value)` for each.
1216    ///
1217    /// Uses `auto_read_batch` internally (one codec dispatch per block, not per value),
1218    /// enabling compiler auto-vectorization for byte-aligned bitpacked columns.
1219    /// For text columns, returned values are global ordinals (remapped).
1220    /// For multi-value columns, use `for_each_multi_value` instead.
1221    pub fn scan_single_values(&self, mut f: impl FnMut(u32, u64)) {
1222        if self.multi {
1223            return;
1224        }
1225        const BATCH: usize = 256;
1226        let mut buf = [0u64; BATCH];
1227        let needs_remap =
1228            self.column_type == FastFieldColumnType::TextOrdinal && self.blocks.len() > 1;
1229
1230        // Pre-fetch ordinal maps once (only for multi-block text columns)
1231        let ordinal_maps = if needs_remap {
1232            Some(&self.ensure_text_state().ordinal_maps)
1233        } else {
1234            None
1235        };
1236
1237        for (block_idx, block) in self.blocks.iter().enumerate() {
1238            let n = block.num_docs as usize;
1239            let mut pos = 0;
1240
1241            let map = ordinal_maps.map(|maps| &maps[block_idx]);
1242            let has_map = map.is_some_and(|m| !m.is_empty());
1243
1244            while pos < n {
1245                let chunk = (n - pos).min(BATCH);
1246                codec::auto_read_batch(block.data.as_slice(), pos, &mut buf[..chunk]);
1247
1248                if has_map {
1249                    let map = map.unwrap();
1250                    for (i, &raw) in buf[..chunk].iter().enumerate() {
1251                        let val = if raw != FAST_FIELD_MISSING {
1252                            let idx = raw as usize;
1253                            if idx < map.len() {
1254                                map[idx] as u64
1255                            } else {
1256                                FAST_FIELD_MISSING
1257                            }
1258                        } else {
1259                            raw
1260                        };
1261                        f(block.cumulative_docs + pos as u32 + i as u32, val);
1262                    }
1263                } else {
1264                    for (i, &val) in buf[..chunk].iter().enumerate() {
1265                        f(block.cumulative_docs + pos as u32 + i as u32, val);
1266                    }
1267                }
1268                pos += chunk;
1269            }
1270        }
1271    }
1272
1273    /// Check if this doc has a value (not [`FAST_FIELD_MISSING`]).
1274    ///
1275    /// For single-value columns, checks the raw sentinel.
1276    /// For multi-value columns, checks if the offset range is non-empty.
1277    #[inline]
1278    pub fn has_value(&self, doc_id: u32) -> bool {
1279        if !self.multi {
1280            return doc_id < self.num_docs && self.get_u64(doc_id) != FAST_FIELD_MISSING;
1281        }
1282        let (_, start, end) = self.block_value_range(doc_id);
1283        start < end
1284    }
1285
1286    /// Get decoded i64 value (zigzag-decoded).
1287    ///
1288    /// Returns `i64::MIN` for absent docs (zigzag_decode of `FAST_FIELD_MISSING`).
1289    /// Use [`has_value`](Self::has_value) to distinguish absent from real values.
1290    #[inline]
1291    pub fn get_i64(&self, doc_id: u32) -> i64 {
1292        zigzag_decode(self.get_u64(doc_id))
1293    }
1294
1295    /// Get decoded f64 value (sortable-decoded).
1296    ///
1297    /// Returns `NaN` for absent docs (`sortable_u64_to_f64(FAST_FIELD_MISSING)`).
1298    /// Use [`has_value`](Self::has_value) to distinguish absent from real values.
1299    #[inline]
1300    pub fn get_f64(&self, doc_id: u32) -> f64 {
1301        sortable_u64_to_f64(self.get_u64(doc_id))
1302    }
1303
1304    /// Get the text ordinal for a doc_id. Returns FAST_FIELD_MISSING if missing.
1305    #[inline]
1306    pub fn get_ordinal(&self, doc_id: u32) -> u64 {
1307        self.get_u64(doc_id)
1308    }
1309
1310    /// Get the text string for a doc_id (looks up ordinal in block-local dictionary).
1311    /// Returns None if the doc has no value or ordinal is missing.
1312    pub fn get_text(&self, doc_id: u32) -> Option<&str> {
1313        if doc_id >= self.num_docs {
1314            return None;
1315        }
1316        let (bi, local) = self.find_block(doc_id);
1317        let block = &self.blocks[bi];
1318        let raw_ordinal = if self.multi {
1319            let start = codec::auto_read(block.offset_data.as_slice(), local as usize) as u32;
1320            let end = codec::auto_read(block.offset_data.as_slice(), local as usize + 1) as u32;
1321            if start >= end {
1322                return None;
1323            }
1324            codec::auto_read(block.value_data.as_slice(), start as usize)
1325        } else {
1326            codec::auto_read(block.data.as_slice(), local as usize)
1327        };
1328        if raw_ordinal == FAST_FIELD_MISSING {
1329            return None;
1330        }
1331        block.dict.as_ref().and_then(|d| d.get(raw_ordinal as u32))
1332    }
1333
1334    /// Look up text string → global ordinal. Returns None if not found.
1335    pub fn text_ordinal(&self, text: &str) -> Option<u64> {
1336        if self.column_type != FastFieldColumnType::TextOrdinal {
1337            return None;
1338        }
1339        self.ensure_text_state().global_dict.ordinal(text)
1340    }
1341
1342    /// Access the global text dictionary reader (if this is a text column).
1343    pub fn text_dict(&self) -> Option<&TextDictReader> {
1344        if self.column_type != FastFieldColumnType::TextOrdinal {
1345            return None;
1346        }
1347        Some(&self.ensure_text_state().global_dict)
1348    }
1349
1350    /// Number of blocks in this column.
1351    pub fn num_blocks(&self) -> usize {
1352        self.blocks.len()
1353    }
1354
1355    /// Access blocks for raw stacking during merge.
1356    pub fn blocks(&self) -> &[ColumnBlock] {
1357        &self.blocks
1358    }
1359}
1360
1361// ── Text dictionary ───────────────────────────────────────────────────────
1362
1363/// Sorted dictionary for text ordinal columns.
1364///
1365/// **Zero-copy**: the dictionary data is a shared slice of the `.fast` file.
1366/// **Lazy**: the offset table is built on first access (not at load time),
1367/// avoiding mmap page faults during segment loading.
1368pub struct TextDictReader {
1369    /// The raw dictionary bytes from the `.fast` file (zero-copy).
1370    data: OwnedBytes,
1371    /// Number of entries in this dictionary.
1372    count: u32,
1373    /// Per-entry (offset, len) pairs into `data` — built lazily on first access.
1374    offsets: OnceLock<Vec<(u32, u32)>>,
1375}
1376
1377impl TextDictReader {
1378    /// Create a lazy text dictionary from pre-sliced data.
1379    /// No scanning is performed — offsets are built on first `get()`/`ordinal()` call.
1380    fn new_lazy(data: OwnedBytes, count: u32) -> Self {
1381        Self {
1382            data,
1383            count,
1384            offsets: OnceLock::new(),
1385        }
1386    }
1387
1388    /// Open a zero-copy text dictionary from `file_data` starting at `dict_start`.
1389    /// Scans to find the dict end position for slicing, but defers offset building.
1390    pub fn open(file_data: &OwnedBytes, dict_start: usize, count: u32) -> io::Result<Self> {
1391        if count == 0 {
1392            return Ok(Self::new_lazy(OwnedBytes::new(Vec::new()), 0));
1393        }
1394        // Scan to find end position (need to know the slice range)
1395        let dict_slice = file_data.as_slice();
1396        if dict_start > dict_slice.len() {
1397            return Err(io::Error::new(
1398                io::ErrorKind::UnexpectedEof,
1399                "text dict offset out of bounds",
1400            ));
1401        }
1402        let mut pos = dict_start;
1403        for _ in 0..count {
1404            if pos.checked_add(4).is_none_or(|end| end > dict_slice.len()) {
1405                return Err(io::Error::new(
1406                    io::ErrorKind::UnexpectedEof,
1407                    "text dict truncated",
1408                ));
1409            }
1410            let len = u32::from_le_bytes(dict_slice[pos..pos + 4].try_into().unwrap()) as usize;
1411            pos += 4;
1412            if pos
1413                .checked_add(len)
1414                .is_none_or(|end| end > dict_slice.len())
1415            {
1416                return Err(io::Error::new(
1417                    io::ErrorKind::UnexpectedEof,
1418                    "text dict entry truncated",
1419                ));
1420            }
1421            std::str::from_utf8(&dict_slice[pos..pos + len])
1422                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1423            pos += len;
1424        }
1425        let data = file_data.slice(dict_start..pos);
1426        Ok(Self::new_lazy(data, count))
1427    }
1428
1429    /// Open from raw dict bytes (already length-prefixed entries).
1430    pub fn open_from_raw(raw_dict: &OwnedBytes, count: u32) -> io::Result<Self> {
1431        validate_text_dict_bytes(raw_dict.as_slice(), count)?;
1432        Ok(Self::new_lazy(raw_dict.clone(), count))
1433    }
1434
1435    /// Build offset table lazily on first access.
1436    #[inline]
1437    fn ensure_offsets(&self) -> &[(u32, u32)] {
1438        self.offsets.get_or_init(|| {
1439            let dict_slice = self.data.as_slice();
1440            let mut pos = 0usize;
1441            let mut offsets = Vec::with_capacity(self.count as usize);
1442            for _ in 0..self.count {
1443                debug_assert!(
1444                    pos + 4 <= dict_slice.len(),
1445                    "text dict truncated during lazy init"
1446                );
1447                let len = u32::from_le_bytes(dict_slice[pos..pos + 4].try_into().unwrap()) as usize;
1448                pos += 4;
1449                debug_assert!(
1450                    pos + len <= dict_slice.len(),
1451                    "text dict entry truncated during lazy init"
1452                );
1453                offsets.push((pos as u32, len as u32));
1454                pos += len;
1455            }
1456            offsets
1457        })
1458    }
1459
1460    /// Get string by ordinal — zero-copy borrow from the underlying file data.
1461    pub fn get(&self, ordinal: u32) -> Option<&str> {
1462        let offsets = self.ensure_offsets();
1463        let &(off, len) = offsets.get(ordinal as usize)?;
1464        let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1465        std::str::from_utf8(slice).ok()
1466    }
1467
1468    /// Binary search for a string → ordinal.
1469    pub fn ordinal(&self, text: &str) -> Option<u64> {
1470        let offsets = self.ensure_offsets();
1471        offsets
1472            .binary_search_by(|&(off, len)| {
1473                let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1474                std::str::from_utf8(slice).unwrap_or("").cmp(text)
1475            })
1476            .ok()
1477            .map(|i| i as u64)
1478    }
1479
1480    /// Number of entries in the dictionary.
1481    pub fn len(&self) -> u32 {
1482        self.count
1483    }
1484
1485    /// Whether the dictionary is empty.
1486    pub fn is_empty(&self) -> bool {
1487        self.count == 0
1488    }
1489
1490    /// Iterate all entries.
1491    pub fn iter(&self) -> impl Iterator<Item = &str> {
1492        let offsets = self.ensure_offsets();
1493        offsets.iter().map(|&(off, len)| {
1494            let slice = &self.data.as_slice()[off as usize..off as usize + len as usize];
1495            std::str::from_utf8(slice).unwrap_or("")
1496        })
1497    }
1498}
1499
1500fn validate_text_dict_bytes(data: &[u8], count: u32) -> io::Result<()> {
1501    let minimum = (count as usize).checked_mul(4).ok_or_else(|| {
1502        io::Error::new(io::ErrorKind::InvalidData, "text dictionary size overflow")
1503    })?;
1504    if minimum > data.len() {
1505        return Err(io::Error::new(
1506            io::ErrorKind::UnexpectedEof,
1507            "text dictionary entry table is truncated",
1508        ));
1509    }
1510
1511    let mut pos = 0usize;
1512    let mut previous: Option<&str> = None;
1513    for _ in 0..count {
1514        let len_end = pos.checked_add(4).ok_or_else(|| {
1515            io::Error::new(
1516                io::ErrorKind::InvalidData,
1517                "text dictionary offset overflow",
1518            )
1519        })?;
1520        let len = u32::from_le_bytes(data[pos..len_end].try_into().unwrap()) as usize;
1521        pos = len_end;
1522        let end = pos.checked_add(len).ok_or_else(|| {
1523            io::Error::new(
1524                io::ErrorKind::InvalidData,
1525                "text dictionary offset overflow",
1526            )
1527        })?;
1528        if end > data.len() {
1529            return Err(io::Error::new(
1530                io::ErrorKind::UnexpectedEof,
1531                "text dictionary entry is truncated",
1532            ));
1533        }
1534        let value = std::str::from_utf8(&data[pos..end])
1535            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1536        if previous.is_some_and(|previous| previous >= value) {
1537            return Err(io::Error::new(
1538                io::ErrorKind::InvalidData,
1539                "text dictionary entries are not strictly increasing",
1540            ));
1541        }
1542        previous = Some(value);
1543        pos = end;
1544    }
1545    if pos != data.len() {
1546        return Err(io::Error::new(
1547            io::ErrorKind::InvalidData,
1548            "text dictionary contains trailing data",
1549        ));
1550    }
1551    Ok(())
1552}
1553
1554// ── File-level write/read ─────────────────────────────────────────────────
1555
1556/// Write fast-field TOC + footer.
1557pub fn write_fast_field_toc_and_footer(
1558    writer: &mut dyn Write,
1559    toc_offset: u64,
1560    entries: &[FastFieldTocEntry],
1561) -> io::Result<()> {
1562    for e in entries {
1563        e.write_to(writer)?;
1564    }
1565    writer.write_u64::<LittleEndian>(toc_offset)?;
1566    writer.write_u32::<LittleEndian>(entries.len() as u32)?;
1567    writer.write_u32::<LittleEndian>(FAST_FIELD_MAGIC)?;
1568    Ok(())
1569}
1570
1571/// Read fast-field footer from the last 16 bytes.
1572/// Returns (toc_offset, num_columns).
1573pub fn read_fast_field_footer(file_data: &[u8]) -> io::Result<(u64, u32)> {
1574    let len = file_data.len();
1575    if len < FAST_FIELD_FOOTER_SIZE as usize {
1576        return Err(io::Error::new(
1577            io::ErrorKind::UnexpectedEof,
1578            "fast field file too small for footer",
1579        ));
1580    }
1581    let footer = &file_data[len - FAST_FIELD_FOOTER_SIZE as usize..];
1582    let mut cursor = std::io::Cursor::new(footer);
1583    let toc_offset = cursor.read_u64::<LittleEndian>()?;
1584    let num_columns = cursor.read_u32::<LittleEndian>()?;
1585    let magic = cursor.read_u32::<LittleEndian>()?;
1586    if magic != FAST_FIELD_MAGIC {
1587        return Err(io::Error::new(
1588            io::ErrorKind::InvalidData,
1589            format!("bad fast field magic: 0x{:08x}", magic),
1590        ));
1591    }
1592    Ok((toc_offset, num_columns))
1593}
1594
1595/// Read all TOC entries from file data (FST2 format).
1596pub fn read_fast_field_toc(
1597    file_data: &[u8],
1598    toc_offset: u64,
1599    num_columns: u32,
1600) -> io::Result<Vec<FastFieldTocEntry>> {
1601    let start = usize::try_from(toc_offset).map_err(|_| {
1602        io::Error::new(
1603            io::ErrorKind::InvalidData,
1604            "fast field TOC offset exceeds address space",
1605        )
1606    })?;
1607    let expected = (num_columns as usize)
1608        .checked_mul(FAST_FIELD_TOC_ENTRY_SIZE)
1609        .ok_or_else(|| {
1610            io::Error::new(io::ErrorKind::InvalidData, "fast field TOC size overflow")
1611        })?;
1612    let end = start.checked_add(expected).ok_or_else(|| {
1613        io::Error::new(io::ErrorKind::InvalidData, "fast field TOC range overflow")
1614    })?;
1615    if end > file_data.len() {
1616        return Err(io::Error::new(
1617            io::ErrorKind::UnexpectedEof,
1618            "fast field TOC out of bounds",
1619        ));
1620    }
1621    let mut cursor = std::io::Cursor::new(&file_data[start..end]);
1622    let mut entries = Vec::new();
1623    entries
1624        .try_reserve_exact(num_columns as usize)
1625        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "too many fast field columns"))?;
1626    for _ in 0..num_columns {
1627        entries.push(FastFieldTocEntry::read_from(&mut cursor)?);
1628    }
1629    Ok(entries)
1630}
1631
1632// ── Tests ─────────────────────────────────────────────────────────────────
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637
1638    #[test]
1639    fn test_zigzag_roundtrip() {
1640        for v in [0i64, 1, -1, 42, -42, i64::MAX, i64::MIN] {
1641            assert_eq!(zigzag_decode(zigzag_encode(v)), v);
1642        }
1643    }
1644
1645    #[test]
1646    fn test_f64_sortable_roundtrip() {
1647        for v in [0.0f64, 1.0, -1.0, f64::MAX, f64::MIN, f64::MIN_POSITIVE] {
1648            assert_eq!(sortable_u64_to_f64(f64_to_sortable_u64(v)), v);
1649        }
1650    }
1651
1652    #[test]
1653    fn test_f64_sortable_order() {
1654        let values = [-100.0f64, -1.0, -0.0, 0.0, 0.5, 1.0, 100.0];
1655        let encoded: Vec<u64> = values.iter().map(|&v| f64_to_sortable_u64(v)).collect();
1656        for i in 1..encoded.len() {
1657            assert!(
1658                encoded[i] >= encoded[i - 1],
1659                "{} >= {} failed for {} vs {}",
1660                encoded[i],
1661                encoded[i - 1],
1662                values[i],
1663                values[i - 1]
1664            );
1665        }
1666    }
1667
1668    #[test]
1669    fn test_bitpack_roundtrip() {
1670        let values: Vec<u64> = vec![0, 3, 7, 15, 0, 1, 6, 12];
1671        let bpv = 4u8;
1672        let mut packed = Vec::new();
1673        bitpack_write(&values, bpv, &mut packed);
1674
1675        for (i, &expected) in values.iter().enumerate() {
1676            let got = bitpack_read(&packed, bpv, i);
1677            assert_eq!(got, expected, "index {}", i);
1678        }
1679    }
1680
1681    #[test]
1682    fn test_bitpack_high_bpv_regression() {
1683        // Regression: bpv > 56 with non-zero bit_shift used to read wrong bits
1684        // because the old 8-byte fast path didn't check bit_shift + bpv <= 64.
1685        for bpv in [57u8, 58, 59, 60, 63, 64] {
1686            let max_val = if bpv == 64 {
1687                u64::MAX
1688            } else {
1689                (1u64 << bpv) - 1
1690            };
1691            let values: Vec<u64> = (0..32)
1692                .map(|i: u64| {
1693                    if max_val == u64::MAX {
1694                        i * 7
1695                    } else {
1696                        (i * 7) % (max_val + 1)
1697                    }
1698                })
1699                .collect();
1700            let mut packed = Vec::new();
1701            bitpack_write(&values, bpv, &mut packed);
1702            for (i, &expected) in values.iter().enumerate() {
1703                let got = bitpack_read(&packed, bpv, i);
1704                assert_eq!(got, expected, "high bpv={} index={}", bpv, i);
1705            }
1706        }
1707    }
1708
1709    #[test]
1710    fn test_bitpack_various_widths() {
1711        for bpv in [1u8, 2, 3, 5, 7, 8, 13, 16, 32, 64] {
1712            let max_val = if bpv == 64 {
1713                u64::MAX
1714            } else {
1715                (1u64 << bpv) - 1
1716            };
1717            let values: Vec<u64> = (0..100)
1718                .map(|i: u64| {
1719                    if max_val == u64::MAX {
1720                        i
1721                    } else {
1722                        i % (max_val + 1)
1723                    }
1724                })
1725                .collect();
1726            let mut packed = Vec::new();
1727            bitpack_write(&values, bpv, &mut packed);
1728
1729            for (i, &expected) in values.iter().enumerate() {
1730                let got = bitpack_read(&packed, bpv, i);
1731                assert_eq!(got, expected, "bpv={} index={}", bpv, i);
1732            }
1733        }
1734    }
1735
1736    /// Helper: wrap a Vec<u8> in OwnedBytes for tests.
1737    fn owned(buf: Vec<u8>) -> OwnedBytes {
1738        OwnedBytes::new(buf)
1739    }
1740
1741    #[test]
1742    fn test_writer_reader_u64_roundtrip() {
1743        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
1744        writer.add_u64(0, 100);
1745        writer.add_u64(1, 200);
1746        writer.add_u64(2, 150);
1747        writer.add_u64(4, 300); // gap at doc_id=3
1748        writer.pad_to(5);
1749
1750        let mut buf = Vec::new();
1751        let (mut toc, _bytes) = writer.serialize(&mut buf, 0).unwrap();
1752        toc.field_id = 42;
1753
1754        // Write TOC + footer
1755        let toc_offset = buf.len() as u64;
1756        write_fast_field_toc_and_footer(&mut buf, toc_offset, &[toc]).unwrap();
1757
1758        // Read back
1759        let ob = owned(buf);
1760        let (toc_off, num_cols) = read_fast_field_footer(&ob).unwrap();
1761        assert_eq!(num_cols, 1);
1762        let tocs = read_fast_field_toc(&ob, toc_off, num_cols).unwrap();
1763        assert_eq!(tocs.len(), 1);
1764        assert_eq!(tocs[0].field_id, 42);
1765
1766        let reader = FastFieldReader::open(&ob, &tocs[0]).unwrap();
1767        assert_eq!(reader.get_u64(0), 100);
1768        assert_eq!(reader.get_u64(1), 200);
1769        assert_eq!(reader.get_u64(2), 150);
1770        assert_eq!(reader.get_u64(3), FAST_FIELD_MISSING); // gap → absent sentinel
1771        assert_eq!(reader.get_u64(4), 300);
1772    }
1773
1774    #[test]
1775    fn test_writer_reader_i64_roundtrip() {
1776        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
1777        writer.add_i64(0, -100);
1778        writer.add_i64(1, 50);
1779        writer.add_i64(2, 0);
1780        writer.pad_to(3);
1781
1782        let mut buf = Vec::new();
1783        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1784        let ob = owned(buf);
1785        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1786        assert_eq!(reader.get_i64(0), -100);
1787        assert_eq!(reader.get_i64(1), 50);
1788        assert_eq!(reader.get_i64(2), 0);
1789    }
1790
1791    #[test]
1792    fn test_writer_reader_f64_roundtrip() {
1793        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::F64);
1794        writer.add_f64(0, -1.5);
1795        writer.add_f64(1, 3.15);
1796        writer.add_f64(2, 0.0);
1797        writer.pad_to(3);
1798
1799        let mut buf = Vec::new();
1800        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1801        let ob = owned(buf);
1802        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1803        assert_eq!(reader.get_f64(0), -1.5);
1804        assert_eq!(reader.get_f64(1), 3.15);
1805        assert_eq!(reader.get_f64(2), 0.0);
1806    }
1807
1808    #[test]
1809    fn test_writer_reader_text_roundtrip() {
1810        let mut writer = FastFieldWriter::new_text();
1811        writer.add_text(0, "banana");
1812        writer.add_text(1, "apple");
1813        writer.add_text(2, "cherry");
1814        writer.add_text(3, "apple"); // duplicate
1815        // doc_id=4 has no value
1816        writer.pad_to(5);
1817
1818        let mut buf = Vec::new();
1819        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1820        let ob = owned(buf);
1821        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1822
1823        // Dictionary is sorted: apple=0, banana=1, cherry=2
1824        assert_eq!(reader.get_text(0), Some("banana"));
1825        assert_eq!(reader.get_text(1), Some("apple"));
1826        assert_eq!(reader.get_text(2), Some("cherry"));
1827        assert_eq!(reader.get_text(3), Some("apple"));
1828        assert_eq!(reader.get_text(4), None); // missing
1829
1830        // Ordinal lookups
1831        assert_eq!(reader.text_ordinal("apple"), Some(0));
1832        assert_eq!(reader.text_ordinal("banana"), Some(1));
1833        assert_eq!(reader.text_ordinal("cherry"), Some(2));
1834        assert_eq!(reader.text_ordinal("durian"), None);
1835    }
1836
1837    #[test]
1838    fn test_constant_column() {
1839        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
1840        for i in 0..100 {
1841            writer.add_u64(i, 42);
1842        }
1843
1844        let mut buf = Vec::new();
1845        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1846
1847        let ob = owned(buf);
1848        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1849        for i in 0..100 {
1850            assert_eq!(reader.get_u64(i), 42);
1851        }
1852    }
1853
1854    // ── Multi-value tests ──
1855
1856    #[test]
1857    fn test_multi_value_u64_roundtrip() {
1858        let mut writer = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
1859        // doc 0: [10, 20, 30]
1860        writer.add_u64(0, 10);
1861        writer.add_u64(0, 20);
1862        writer.add_u64(0, 30);
1863        // doc 1: [] (empty)
1864        // doc 2: [100]
1865        writer.add_u64(2, 100);
1866        // doc 3: [5, 15]
1867        writer.add_u64(3, 5);
1868        writer.add_u64(3, 15);
1869        writer.pad_to(4);
1870
1871        let mut buf = Vec::new();
1872        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1873        assert!(toc.multi);
1874        assert_eq!(toc.num_docs, 4);
1875
1876        let ob = owned(buf);
1877        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1878        assert!(reader.multi);
1879
1880        // doc 0: first value
1881        assert_eq!(reader.get_u64(0), 10);
1882        let (s, e) = reader.value_range(0);
1883        assert_eq!(e - s, 3);
1884        assert_eq!(reader.get_value_at(s), 10);
1885        assert_eq!(reader.get_value_at(s + 1), 20);
1886        assert_eq!(reader.get_value_at(s + 2), 30);
1887
1888        // doc 1: empty → sentinel
1889        assert_eq!(reader.get_u64(1), FAST_FIELD_MISSING);
1890        let (s, e) = reader.value_range(1);
1891        assert_eq!(s, e);
1892        assert!(!reader.has_value(1));
1893
1894        // doc 2: [100]
1895        assert_eq!(reader.get_u64(2), 100);
1896        assert!(reader.has_value(2));
1897
1898        // doc 3: [5, 15]
1899        assert_eq!(reader.get_u64(3), 5);
1900        let (s, e) = reader.value_range(3);
1901        assert_eq!(e - s, 2);
1902        assert_eq!(reader.get_value_at(s), 5);
1903        assert_eq!(reader.get_value_at(s + 1), 15);
1904    }
1905
1906    #[test]
1907    fn test_multi_value_text_roundtrip() {
1908        let mut writer = FastFieldWriter::new_text_multi();
1909        // doc 0: ["banana", "apple"]
1910        writer.add_text(0, "banana");
1911        writer.add_text(0, "apple");
1912        // doc 1: ["cherry"]
1913        writer.add_text(1, "cherry");
1914        // doc 2: [] empty
1915        writer.pad_to(3);
1916
1917        let mut buf = Vec::new();
1918        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
1919        assert!(toc.multi);
1920
1921        let ob = owned(buf);
1922        let reader = FastFieldReader::open(&ob, &toc).unwrap();
1923
1924        // doc 0: first value ordinal → banana is ordinal 1 (apple=0, banana=1, cherry=2)
1925        let (s, e) = reader.value_range(0);
1926        assert_eq!(e - s, 2);
1927        let ord0 = reader.get_value_at(s);
1928        let ord1 = reader.get_value_at(s + 1);
1929        assert_eq!(reader.text_dict().unwrap().get(ord0 as u32), Some("banana"));
1930        assert_eq!(reader.text_dict().unwrap().get(ord1 as u32), Some("apple"));
1931
1932        // doc 1: cherry
1933        let (s, e) = reader.value_range(1);
1934        assert_eq!(e - s, 1);
1935        let ord = reader.get_value_at(s);
1936        assert_eq!(reader.text_dict().unwrap().get(ord as u32), Some("cherry"));
1937
1938        // doc 2: empty
1939        assert!(!reader.has_value(2));
1940    }
1941
1942    #[test]
1943    fn test_multi_value_full_toc_roundtrip() {
1944        let mut writer = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
1945        writer.add_u64(0, 1);
1946        writer.add_u64(0, 2);
1947        writer.add_u64(1, 3);
1948        writer.pad_to(2);
1949
1950        let mut buf = Vec::new();
1951        let (mut toc, _) = writer.serialize(&mut buf, 0).unwrap();
1952        toc.field_id = 7;
1953
1954        let toc_offset = buf.len() as u64;
1955        write_fast_field_toc_and_footer(&mut buf, toc_offset, &[toc]).unwrap();
1956
1957        let ob = owned(buf);
1958        let (toc_off, num_cols) = read_fast_field_footer(&ob).unwrap();
1959        let tocs = read_fast_field_toc(&ob, toc_off, num_cols).unwrap();
1960        assert_eq!(tocs[0].field_id, 7);
1961        assert!(tocs[0].multi);
1962
1963        let reader = FastFieldReader::open(&ob, &tocs[0]).unwrap();
1964        assert_eq!(reader.get_u64(0), 1);
1965        assert_eq!(reader.get_u64(1), 3);
1966    }
1967
1968    /// Helper: serialize a writer into a blocked column, return (block_data, block_dict, block_index_entry)
1969    /// by stripping the blocked header.
1970    fn serialize_single_block(writer: &mut FastFieldWriter) -> (Vec<u8>, Vec<u8>, BlockIndexEntry) {
1971        let mut buf = Vec::new();
1972        let (_toc, _) = writer.serialize(&mut buf, 0).unwrap();
1973        // Strip: [num_blocks(4)] [BlockIndexEntry(16)] [data...] [dict...]
1974        let mut cursor = std::io::Cursor::new(&buf[4..4 + BLOCK_INDEX_ENTRY_SIZE]);
1975        let entry = BlockIndexEntry::read_from(&mut cursor).unwrap();
1976        let data_start = 4 + BLOCK_INDEX_ENTRY_SIZE;
1977        let data_end = data_start + entry.data_len as usize;
1978        let dict_end = data_end + entry.dict_len as usize;
1979        let data = buf[data_start..data_end].to_vec();
1980        let dict = if dict_end > data_end {
1981            buf[data_end..dict_end].to_vec()
1982        } else {
1983            Vec::new()
1984        };
1985        (data, dict, entry)
1986    }
1987
1988    /// Manually assemble a multi-block column from individual block payloads.
1989    fn assemble_blocked_column(
1990        field_id: u32,
1991        column_type: FastFieldColumnType,
1992        multi: bool,
1993        blocks: &[(u32, &[u8], u32, &[u8])], // (num_docs, data, dict_count, dict)
1994    ) -> (Vec<u8>, FastFieldTocEntry) {
1995        use byteorder::{LittleEndian, WriteBytesExt};
1996
1997        let mut buf = Vec::new();
1998        let num_blocks = blocks.len() as u32;
1999
2000        // num_blocks
2001        buf.write_u32::<LittleEndian>(num_blocks).unwrap();
2002
2003        // block index
2004        for &(num_docs, data, dict_count, dict) in blocks {
2005            let entry = BlockIndexEntry {
2006                num_docs,
2007                data_len: data.len() as u32,
2008                dict_count,
2009                dict_len: dict.len() as u32,
2010            };
2011            entry.write_to(&mut buf).unwrap();
2012        }
2013
2014        // block data + dicts
2015        let mut total_docs = 0u32;
2016        for &(num_docs, data, _, dict) in blocks {
2017            buf.extend_from_slice(data);
2018            buf.extend_from_slice(dict);
2019            total_docs += num_docs;
2020        }
2021
2022        let data_len = buf.len() as u64;
2023
2024        // Write TOC + footer
2025        let toc = FastFieldTocEntry {
2026            field_id,
2027            column_type,
2028            multi,
2029            data_offset: 0,
2030            data_len,
2031            num_docs: total_docs,
2032            dict_offset: 0,
2033            dict_count: 0,
2034        };
2035
2036        let toc_offset = buf.len() as u64;
2037        write_fast_field_toc_and_footer(&mut buf, toc_offset, std::slice::from_ref(&toc)).unwrap();
2038
2039        (buf, toc)
2040    }
2041
2042    #[test]
2043    fn test_multi_block_numeric_roundtrip() {
2044        // Block A: 3 docs [10, 20, 30]
2045        let mut wa = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
2046        wa.add_u64(0, 10);
2047        wa.add_u64(1, 20);
2048        wa.add_u64(2, 30);
2049        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2050
2051        // Block B: 2 docs [40, 50]
2052        let mut wb = FastFieldWriter::new_numeric(FastFieldColumnType::U64);
2053        wb.add_u64(0, 40);
2054        wb.add_u64(1, 50);
2055        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2056
2057        let (buf, toc) = assemble_blocked_column(
2058            1,
2059            FastFieldColumnType::U64,
2060            false,
2061            &[
2062                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2063                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2064            ],
2065        );
2066
2067        let ob = owned(buf);
2068        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2069
2070        assert_eq!(reader.num_docs, 5);
2071        assert_eq!(reader.num_blocks(), 2);
2072        assert_eq!(reader.get_u64(0), 10);
2073        assert_eq!(reader.get_u64(1), 20);
2074        assert_eq!(reader.get_u64(2), 30);
2075        assert_eq!(reader.get_u64(3), 40);
2076        assert_eq!(reader.get_u64(4), 50);
2077    }
2078
2079    #[test]
2080    fn test_multi_block_text_roundtrip() {
2081        // Block A: 2 docs ["alpha", "beta"]
2082        let mut wa = FastFieldWriter::new_text();
2083        wa.add_text(0, "alpha");
2084        wa.add_text(1, "beta");
2085        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2086
2087        // Block B: 2 docs ["gamma", "alpha"]  (alpha shared with block A)
2088        let mut wb = FastFieldWriter::new_text();
2089        wb.add_text(0, "gamma");
2090        wb.add_text(1, "alpha");
2091        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2092
2093        let (buf, toc) = assemble_blocked_column(
2094            2,
2095            FastFieldColumnType::TextOrdinal,
2096            false,
2097            &[
2098                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2099                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2100            ],
2101        );
2102
2103        let ob = owned(buf);
2104        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2105
2106        assert_eq!(reader.num_docs, 4);
2107        assert_eq!(reader.num_blocks(), 2);
2108
2109        // Global dict should be: alpha(0), beta(1), gamma(2)
2110        assert_eq!(reader.text_dict().unwrap().len(), 3);
2111
2112        // Block A: alpha=local0→global0, beta=local1→global1
2113        assert_eq!(reader.get_text(0), Some("alpha"));
2114        assert_eq!(reader.get_text(1), Some("beta"));
2115
2116        // Block B: gamma=local1→global2, alpha=local0→global0
2117        assert_eq!(reader.get_text(2), Some("gamma"));
2118        assert_eq!(reader.get_text(3), Some("alpha"));
2119
2120        // Global ordinal lookups
2121        assert_eq!(reader.text_ordinal("alpha"), Some(0));
2122        assert_eq!(reader.text_ordinal("beta"), Some(1));
2123        assert_eq!(reader.text_ordinal("gamma"), Some(2));
2124
2125        // get_u64 returns global ordinals
2126        assert_eq!(reader.get_u64(0), 0); // alpha
2127        assert_eq!(reader.get_u64(1), 1); // beta
2128        assert_eq!(reader.get_u64(2), 2); // gamma
2129        assert_eq!(reader.get_u64(3), 0); // alpha
2130    }
2131
2132    /// Regression test: ordinal mismatch when blocks have disjoint dicts
2133    /// that arrive in non-sorted order.
2134    ///
2135    /// Block A has ["book","wiki"], Block B has ["apple","wiki"].
2136    /// "apple" < "book" < "wiki" alphabetically, but "book" is encountered
2137    /// first. Before the fix, insertion-order ordinals were used instead of
2138    /// sorted-position ordinals, causing text_ordinal() and get_u64() to
2139    /// disagree — wrong documents would pass fast-field predicates.
2140    #[test]
2141    fn test_multi_block_text_ordinal_mismatch_regression() {
2142        // Block A: 2 docs ["book", "wiki"]
2143        let mut wa = FastFieldWriter::new_text();
2144        wa.add_text(0, "book");
2145        wa.add_text(1, "wiki");
2146        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2147
2148        // Block B: 2 docs ["apple", "wiki"]  ("apple" < "book" alphabetically)
2149        let mut wb = FastFieldWriter::new_text();
2150        wb.add_text(0, "apple");
2151        wb.add_text(1, "wiki");
2152        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2153
2154        let (buf, toc) = assemble_blocked_column(
2155            2,
2156            FastFieldColumnType::TextOrdinal,
2157            false,
2158            &[
2159                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2160                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2161            ],
2162        );
2163
2164        let ob = owned(buf);
2165        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2166
2167        // Global dict should be sorted: apple(0), book(1), wiki(2)
2168        assert_eq!(reader.text_dict().unwrap().len(), 3);
2169        assert_eq!(reader.text_ordinal("apple"), Some(0));
2170        assert_eq!(reader.text_ordinal("book"), Some(1));
2171        assert_eq!(reader.text_ordinal("wiki"), Some(2));
2172
2173        // get_u64 must return the SAME global ordinals that text_ordinal returns
2174        assert_eq!(reader.get_u64(0), 1); // doc0 in block A = "book" → global 1
2175        assert_eq!(reader.get_u64(1), 2); // doc1 in block A = "wiki" → global 2
2176        assert_eq!(reader.get_u64(2), 0); // doc0 in block B = "apple" → global 0
2177        assert_eq!(reader.get_u64(3), 2); // doc1 in block B = "wiki" → global 2
2178
2179        // Simulate TermQuery predicate: text_ordinal("wiki") == get_u64(doc_id)
2180        let wiki_ord = reader.text_ordinal("wiki").unwrap();
2181        assert_eq!(reader.get_u64(1), wiki_ord, "wiki doc should match");
2182        assert_eq!(reader.get_u64(3), wiki_ord, "wiki doc should match");
2183        assert_ne!(reader.get_u64(0), wiki_ord, "book doc must NOT match wiki");
2184        assert_ne!(reader.get_u64(2), wiki_ord, "apple doc must NOT match wiki");
2185    }
2186
2187    /// Regression: issued_at timestamps stored via add_i64 with gaps
2188    /// should roundtrip correctly through FastFieldWriter → FastFieldReader.
2189    #[test]
2190    fn test_i64_timestamps_with_missing_roundtrip() {
2191        let base_ts = 1724630400i64; // 2024-08-26 epoch seconds
2192        let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
2193
2194        // 100 docs, every 5th has no issued_at
2195        let mut expected_values: Vec<Option<i64>> = Vec::new();
2196        for i in 0..100u32 {
2197            if i % 5 == 0 {
2198                expected_values.push(None); // missing
2199            } else {
2200                let ts = base_ts - (i as i64 * 86400);
2201                writer.add_i64(i, ts);
2202                expected_values.push(Some(ts));
2203            }
2204        }
2205        writer.pad_to(100);
2206
2207        let mut buf = Vec::new();
2208        let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
2209        let ob = owned(buf);
2210        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2211
2212        for (i, expected) in expected_values.iter().enumerate() {
2213            let raw = reader.get_u64(i as u32);
2214            match expected {
2215                None => {
2216                    assert_eq!(
2217                        raw, FAST_FIELD_MISSING,
2218                        "doc {}: expected MISSING, got raw {}",
2219                        i, raw
2220                    );
2221                }
2222                Some(ts) => {
2223                    assert_ne!(
2224                        raw, FAST_FIELD_MISSING,
2225                        "doc {}: expected timestamp {}, got MISSING",
2226                        i, ts
2227                    );
2228                    let decoded = zigzag_decode(raw);
2229                    assert_eq!(
2230                        decoded,
2231                        *ts,
2232                        "doc {}: expected i64 {}, got i64 {} (raw zigzag: {}, expected zigzag: {})",
2233                        i,
2234                        ts,
2235                        decoded,
2236                        raw,
2237                        zigzag_encode(*ts)
2238                    );
2239                }
2240            }
2241        }
2242    }
2243
2244    /// Regression: specific value 1724630400 that was corrupted in production.
2245    /// Test with varying column sizes to exercise different codec selections.
2246    #[test]
2247    fn test_issued_at_1724630400_various_sizes() {
2248        let target_ts = 1724630400i64;
2249        let target_zigzag = zigzag_encode(target_ts);
2250
2251        for num_docs in [2, 5, 10, 50, 100, 500, 1000, 2000] {
2252            let mut writer = FastFieldWriter::new_numeric(FastFieldColumnType::I64);
2253            let target_doc = num_docs / 3;
2254
2255            for i in 0..num_docs as u32 {
2256                if i == target_doc as u32 {
2257                    writer.add_i64(i, target_ts);
2258                } else if i % 3 == 0 {
2259                    // missing
2260                } else {
2261                    let ts = 1700000000i64 + (i as i64 * 86400);
2262                    writer.add_i64(i, ts);
2263                }
2264            }
2265            writer.pad_to(num_docs as u32);
2266
2267            let mut buf = Vec::new();
2268            let (toc, _) = writer.serialize(&mut buf, 0).unwrap();
2269            let ob = owned(buf);
2270            let reader = FastFieldReader::open(&ob, &toc).unwrap();
2271
2272            let raw = reader.get_u64(target_doc as u32);
2273            assert_eq!(
2274                raw,
2275                target_zigzag,
2276                "num_docs={}: doc {} expected zigzag {} (ts {}), got {} (decoded i64: {})",
2277                num_docs,
2278                target_doc,
2279                target_zigzag,
2280                target_ts,
2281                raw,
2282                zigzag_decode(raw)
2283            );
2284        }
2285    }
2286
2287    #[test]
2288    fn test_multi_block_multi_value_numeric() {
2289        // Block A: doc0=[1,2], doc1=[3]
2290        let mut wa = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
2291        wa.add_u64(0, 1);
2292        wa.add_u64(0, 2);
2293        wa.add_u64(1, 3);
2294        wa.pad_to(2);
2295        let (data_a, dict_a, entry_a) = serialize_single_block(&mut wa);
2296
2297        // Block B: doc0=[4,5,6], doc1=[]
2298        let mut wb = FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64);
2299        wb.add_u64(0, 4);
2300        wb.add_u64(0, 5);
2301        wb.add_u64(0, 6);
2302        wb.pad_to(2);
2303        let (data_b, dict_b, entry_b) = serialize_single_block(&mut wb);
2304
2305        let (buf, toc) = assemble_blocked_column(
2306            3,
2307            FastFieldColumnType::U64,
2308            true,
2309            &[
2310                (entry_a.num_docs, &data_a, entry_a.dict_count, &dict_a),
2311                (entry_b.num_docs, &data_b, entry_b.dict_count, &dict_b),
2312            ],
2313        );
2314
2315        let ob = owned(buf);
2316        let reader = FastFieldReader::open(&ob, &toc).unwrap();
2317
2318        assert_eq!(reader.num_docs, 4);
2319        assert_eq!(reader.num_blocks(), 2);
2320
2321        // doc0 (block A): [1, 2]
2322        assert_eq!(reader.get_multi_values(0), vec![1, 2]);
2323        // doc1 (block A): [3]
2324        assert_eq!(reader.get_multi_values(1), vec![3]);
2325        // doc2 (block B, local 0): [4, 5, 6]
2326        assert_eq!(reader.get_multi_values(2), vec![4, 5, 6]);
2327        // doc3 (block B, local 1): []
2328        assert_eq!(reader.get_multi_values(3), Vec::<u64>::new());
2329    }
2330}