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