Skip to main content

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