Skip to main content

hermes_core/segment/
chunk_map.rs

1//! Virtual-id maps of chunked text fields (`seg_<id>.chunks`).
2//!
3//! A text field declared `chunked` indexes every value as its own scoring
4//! unit: term postings and positions are keyed by a dense, segment-local
5//! **virtual id** instead of the document id. This file maps each virtual id
6//! back to `(doc_id, ordinal)` and records the chunk's token count for BM25
7//! length normalisation. See `docs/chunked-text-fields.md`.
8//!
9//! ```text
10//! [magic "CHNK"][version u32 = 2][num_sections u32]
11//! TOC × num_sections: [field_id u32][kind u32][count u32][total_tokens u64][data_offset u64]
12//! kind 0 (chunk map):   doc_ids u32 × n | ordinals u16 × n | lengths u16 × n
13//! kind 1 (doc lengths): lengths u16 × num_docs        (norms of a plain text field)
14//! ```
15//!
16//! Version 1 files have 24-byte entries without `kind` and hold chunk maps
17//! only; they are still read.
18//!
19//! Virtual ids are assigned in indexing order, and documents are indexed in
20//! doc-id order, so `doc_ids` starts out non-decreasing. A reorder pass on a
21//! field with the `reorder` attribute permutes the virtual ids (BP over the
22//! field's postings, `segment/text_reorder.rs`). Readers verify doc-id order
23//! at open before enabling ordered query paths. Merges concatenate sections and add the document offset to
24//! `doc_ids`; ordinals and lengths are copied verbatim.
25//!
26//! A doc-length section stores the token count of the field in every
27//! document of the segment (0 when the document has no value), so BM25 can
28//! normalise plain fields by their real length instead of `tf`.
29
30use std::io::{self, Write};
31
32use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
33use rustc_hash::FxHashMap;
34
35use crate::DocId;
36use crate::directories::OwnedBytes;
37
38const MAGIC: u32 = 0x4B4E_4843; // "CHNK"
39const VERSION: u32 = 2;
40const HEADER_SIZE: usize = 12;
41const TOC_ENTRY_SIZE_V1: usize = 24;
42const TOC_ENTRY_SIZE: usize = 28;
43const KIND_CHUNK_MAP: u32 = 0;
44const KIND_DOC_LENGTHS: u32 = 1;
45
46/// Token count stored per chunk; longer chunks saturate.
47pub const MAX_CHUNK_LENGTH: u32 = u16::MAX as u32;
48
49/// In-memory map of one chunked field while a segment is being built.
50#[derive(Debug, Default, Clone)]
51pub struct ChunkMapBuilder {
52    doc_ids: Vec<DocId>,
53    ordinals: Vec<u16>,
54    lengths: Vec<u16>,
55    total_tokens: u64,
56}
57
58impl ChunkMapBuilder {
59    /// Number of chunks so far (the next virtual id).
60    pub fn len(&self) -> usize {
61        self.doc_ids.len()
62    }
63
64    pub fn is_empty(&self) -> bool {
65        self.doc_ids.is_empty()
66    }
67
68    /// Register the next chunk. Returns its virtual id.
69    pub fn push(&mut self, doc_id: DocId, ordinal: u16, token_count: u32) -> io::Result<u32> {
70        let vid = u32::try_from(self.doc_ids.len()).map_err(|_| {
71            io::Error::new(
72                io::ErrorKind::InvalidData,
73                "chunked text field exceeds u32::MAX chunks in one segment",
74            )
75        })?;
76        self.doc_ids.push(doc_id);
77        self.ordinals.push(ordinal);
78        self.lengths.push(token_count.min(MAX_CHUNK_LENGTH) as u16);
79        self.total_tokens += u64::from(token_count);
80        Ok(vid)
81    }
82
83    /// Heap bytes held by this builder (memory-budget accounting).
84    pub fn estimated_bytes(&self) -> usize {
85        self.doc_ids.capacity() * 4 + self.ordinals.capacity() * 2 + self.lengths.capacity() * 2
86    }
87
88    fn section_bytes(&self) -> u64 {
89        self.doc_ids.len() as u64 * 8
90    }
91
92    /// Token count of virtual id `vid` (saturated at `MAX_CHUNK_LENGTH`).
93    pub fn length(&self, vid: u32) -> u32 {
94        self.lengths
95            .get(vid as usize)
96            .map_or(0, |len| u32::from(*len))
97    }
98}
99
100/// Per-document token counts of one plain text field, ready to be written.
101pub struct DocLengthsColumn<'a> {
102    pub field_id: u32,
103    /// One entry per document of the segment (0 = no value).
104    pub lengths: &'a [u16],
105    /// Sum of the unsaturated token counts.
106    pub total_tokens: u64,
107}
108
109/// Write every chunked field's map and every plain field's length column as
110/// one `.chunks` file.
111///
112/// `fields` must be sorted by field id and contain only non-empty builders;
113/// `norms` likewise sorted, one column per field.
114pub fn write_chunk_maps<W: Write + ?Sized>(
115    writer: &mut W,
116    fields: &[(u32, &ChunkMapBuilder)],
117    norms: &[DocLengthsColumn<'_>],
118) -> io::Result<u64> {
119    let sections = fields.len() + norms.len();
120    let mut offset = (HEADER_SIZE + TOC_ENTRY_SIZE * sections) as u64;
121    writer.write_u32::<LittleEndian>(MAGIC)?;
122    writer.write_u32::<LittleEndian>(VERSION)?;
123    writer.write_u32::<LittleEndian>(sections as u32)?;
124    for (field_id, map) in fields {
125        writer.write_u32::<LittleEndian>(*field_id)?;
126        writer.write_u32::<LittleEndian>(KIND_CHUNK_MAP)?;
127        writer.write_u32::<LittleEndian>(map.len() as u32)?;
128        writer.write_u64::<LittleEndian>(map.total_tokens)?;
129        writer.write_u64::<LittleEndian>(offset)?;
130        offset += map.section_bytes();
131    }
132    for column in norms {
133        writer.write_u32::<LittleEndian>(column.field_id)?;
134        writer.write_u32::<LittleEndian>(KIND_DOC_LENGTHS)?;
135        writer.write_u32::<LittleEndian>(column.lengths.len() as u32)?;
136        writer.write_u64::<LittleEndian>(column.total_tokens)?;
137        writer.write_u64::<LittleEndian>(offset)?;
138        offset += column.lengths.len() as u64 * 2;
139    }
140    for (_, map) in fields {
141        for doc_id in &map.doc_ids {
142            writer.write_u32::<LittleEndian>(*doc_id)?;
143        }
144        for ordinal in &map.ordinals {
145            writer.write_u16::<LittleEndian>(*ordinal)?;
146        }
147        for length in &map.lengths {
148            writer.write_u16::<LittleEndian>(*length)?;
149        }
150    }
151    for column in norms {
152        for length in column.lengths {
153            writer.write_u16::<LittleEndian>(*length)?;
154        }
155    }
156    Ok(offset)
157}
158
159/// Read-only per-document lengths of one plain text field, backed by the
160/// mapped `.chunks` file.
161#[derive(Debug, Clone)]
162pub struct DocLengths {
163    lengths: OwnedBytes,
164    num_docs: u32,
165    total_tokens: u64,
166}
167
168impl DocLengths {
169    /// In-memory lengths column (tests).
170    #[cfg(test)]
171    pub(crate) fn from_lengths(lengths: &[u16]) -> Self {
172        let mut bytes = Vec::with_capacity(lengths.len() * 2);
173        for len in lengths {
174            bytes.extend_from_slice(&len.to_le_bytes());
175        }
176        Self {
177            lengths: OwnedBytes::new(bytes),
178            num_docs: lengths.len() as u32,
179            total_tokens: lengths.iter().map(|&l| u64::from(l)).sum(),
180        }
181    }
182
183    pub fn num_docs(&self) -> u32 {
184        self.num_docs
185    }
186
187    pub fn total_tokens(&self) -> u64 {
188        self.total_tokens
189    }
190
191    /// Average length over documents that have the field (1.0 when none).
192    pub fn avg_len(&self) -> f32 {
193        let with_value = self
194            .lengths
195            .as_slice()
196            .chunks_exact(2)
197            .filter(|b| b[0] != 0 || b[1] != 0)
198            .count();
199        if with_value == 0 {
200            1.0
201        } else {
202            (self.total_tokens as f64 / with_value as f64) as f32
203        }
204    }
205
206    /// Token count of the field in `doc_id` (0 when absent or out of range,
207    /// saturated at `MAX_CHUNK_LENGTH`).
208    #[inline]
209    pub fn length(&self, doc_id: DocId) -> u32 {
210        let at = doc_id as usize * 2;
211        self.lengths
212            .as_slice()
213            .get(at..at + 2)
214            .map_or(0, |b| u32::from(u16::from_le_bytes([b[0], b[1]])))
215    }
216
217    pub(crate) fn length_bytes(&self) -> &[u8] {
218        self.lengths.as_slice()
219    }
220}
221
222/// Everything a `.chunks` file holds.
223#[derive(Debug, Default)]
224pub struct ChunkMapFile {
225    pub chunk_maps: FxHashMap<u32, ChunkMap>,
226    pub doc_lengths: FxHashMap<u32, DocLengths>,
227}
228
229/// Read-only chunk map of one field, backed by the mapped `.chunks` file.
230#[derive(Debug, Clone)]
231pub struct ChunkMap {
232    doc_ids: OwnedBytes,
233    ordinals: OwnedBytes,
234    lengths: OwnedBytes,
235    num_chunks: u32,
236    total_tokens: u64,
237    /// Nominal chunk length: the 90th-percentile chunk length of the field in
238    /// this segment. BM25 floors every chunk length at this value so a short
239    /// tail chunk is not rewarded for being short (`docs/chunked-bm25.md`).
240    length_floor: u32,
241    /// Derived once at open; never persisted or assumed from schema flags.
242    doc_ids_monotonic: bool,
243}
244
245/// 90th-percentile of a little-endian `u16` length column (0 when empty).
246fn nominal_chunk_length(lengths: &[u8]) -> u32 {
247    let n = lengths.len() / 2;
248    if n == 0 {
249        return 0;
250    }
251    let mut histogram = vec![0u32; u16::MAX as usize + 1];
252    for pair in lengths.chunks_exact(2) {
253        histogram[u16::from_le_bytes([pair[0], pair[1]]) as usize] += 1;
254    }
255    // Smallest length such that at least 90 % of the chunks are ≤ it.
256    let target = (n as u64 * 9).div_ceil(10);
257    let mut seen = 0u64;
258    for (len, &count) in histogram.iter().enumerate() {
259        seen += u64::from(count);
260        if seen >= target {
261            return len as u32;
262        }
263    }
264    u16::MAX as u32
265}
266
267impl ChunkMap {
268    pub(crate) fn is_doc_ordered(&self) -> bool {
269        self.doc_ids_monotonic
270    }
271
272    /// First virtual id owned by a document at or after `target`.
273    /// Only valid for a verified doc-ordered map; num_chunks means exhausted.
274    pub(crate) fn lower_bound_doc(&self, target: DocId) -> u32 {
275        debug_assert!(self.is_doc_ordered());
276        let (mut lo, mut hi) = (0, self.num_chunks);
277        while lo < hi {
278            let mid = lo + (hi - lo) / 2;
279            if self.doc_id(mid) < target {
280                lo = mid + 1;
281            } else {
282                hi = mid;
283            }
284        }
285        lo
286    }
287
288    /// Number of chunks (virtual ids) in this segment.
289    #[inline]
290    pub fn num_chunks(&self) -> u32 {
291        self.num_chunks
292    }
293
294    /// Sum of all chunk token counts.
295    pub fn total_tokens(&self) -> u64 {
296        self.total_tokens
297    }
298
299    /// Average chunk length in tokens (1.0 when empty).
300    pub fn avg_len(&self) -> f32 {
301        if self.num_chunks == 0 {
302            1.0
303        } else {
304            (self.total_tokens as f64 / f64::from(self.num_chunks)) as f32
305        }
306    }
307
308    /// Nominal chunk length (90th percentile), the BM25 length floor.
309    #[inline]
310    pub fn length_floor(&self) -> u32 {
311        self.length_floor
312    }
313
314    /// BM25 length of virtual id `vid`: its token count floored at the
315    /// nominal chunk length.
316    #[inline]
317    pub fn bm25_length(&self, vid: u32) -> u32 {
318        self.length(vid).max(self.length_floor)
319    }
320
321    /// Document owning virtual id `vid`.
322    #[inline]
323    pub fn doc_id(&self, vid: u32) -> DocId {
324        let at = vid as usize * 4;
325        let b = &self.doc_ids.as_slice()[at..at + 4];
326        u32::from_le_bytes([b[0], b[1], b[2], b[3]])
327    }
328
329    /// Ordinal (value index within the document) of virtual id `vid`.
330    #[inline]
331    pub fn ordinal(&self, vid: u32) -> u16 {
332        let at = vid as usize * 2;
333        let b = &self.ordinals.as_slice()[at..at + 2];
334        u16::from_le_bytes([b[0], b[1]])
335    }
336
337    /// Token count of virtual id `vid` (saturated at `MAX_CHUNK_LENGTH`).
338    #[inline]
339    pub fn length(&self, vid: u32) -> u32 {
340        let at = vid as usize * 2;
341        let b = &self.lengths.as_slice()[at..at + 2];
342        u32::from(u16::from_le_bytes([b[0], b[1]]))
343    }
344
345    /// `(doc_id, ordinal)` of virtual id `vid`.
346    #[inline]
347    pub fn resolve(&self, vid: u32) -> (DocId, u16) {
348        (self.doc_id(vid), self.ordinal(vid))
349    }
350
351    /// Raw little-endian document-id column (merge copy).
352    pub(crate) fn doc_id_bytes(&self) -> &[u8] {
353        self.doc_ids.as_slice()
354    }
355
356    /// Raw little-endian ordinal column (merge copy).
357    pub(crate) fn ordinal_bytes(&self) -> &[u8] {
358        self.ordinals.as_slice()
359    }
360
361    /// Raw little-endian length column (merge copy).
362    pub(crate) fn length_bytes(&self) -> &[u8] {
363        self.lengths.as_slice()
364    }
365}
366
367/// Parse a `.chunks` file into per-field chunk maps and length columns.
368pub fn read_chunk_maps(bytes: OwnedBytes) -> io::Result<ChunkMapFile> {
369    let data = bytes.as_slice();
370    if data.len() < HEADER_SIZE {
371        return Err(io::Error::new(
372            io::ErrorKind::InvalidData,
373            "chunk map file shorter than its header",
374        ));
375    }
376    let mut cursor = io::Cursor::new(data);
377    let magic = cursor.read_u32::<LittleEndian>()?;
378    if magic != MAGIC {
379        return Err(io::Error::new(
380            io::ErrorKind::InvalidData,
381            format!("chunk map magic mismatch: {magic:#x}"),
382        ));
383    }
384    let version = cursor.read_u32::<LittleEndian>()?;
385    let entry_size = match version {
386        1 => TOC_ENTRY_SIZE_V1,
387        VERSION => TOC_ENTRY_SIZE,
388        other => {
389            return Err(io::Error::new(
390                io::ErrorKind::InvalidData,
391                format!("unsupported chunk map version {other} (expected {VERSION})"),
392            ));
393        }
394    };
395    let num_sections = cursor.read_u32::<LittleEndian>()? as usize;
396    if data.len() < HEADER_SIZE + entry_size * num_sections {
397        return Err(io::Error::new(
398            io::ErrorKind::InvalidData,
399            "chunk map table of contents truncated",
400        ));
401    }
402    let overflow = || io::Error::new(io::ErrorKind::InvalidData, "chunk map size overflow");
403    let mut file = ChunkMapFile::default();
404    for _ in 0..num_sections {
405        let field_id = cursor.read_u32::<LittleEndian>()?;
406        let kind = if version == 1 {
407            KIND_CHUNK_MAP
408        } else {
409            cursor.read_u32::<LittleEndian>()?
410        };
411        let count = cursor.read_u32::<LittleEndian>()?;
412        let total_tokens = cursor.read_u64::<LittleEndian>()?;
413        let offset = cursor.read_u64::<LittleEndian>()? as usize;
414        let n = count as usize;
415        let bytes_per_entry = match kind {
416            KIND_CHUNK_MAP => 8,
417            KIND_DOC_LENGTHS => 2,
418            other => {
419                return Err(io::Error::new(
420                    io::ErrorKind::InvalidData,
421                    format!("unknown chunk map section kind {other} for field {field_id}"),
422                ));
423            }
424        };
425        let end = offset
426            .checked_add(n.checked_mul(bytes_per_entry).ok_or_else(overflow)?)
427            .ok_or_else(overflow)?;
428        if end > data.len() {
429            return Err(io::Error::new(
430                io::ErrorKind::InvalidData,
431                format!("chunk map section of field {field_id} exceeds file length"),
432            ));
433        }
434        match kind {
435            KIND_CHUNK_MAP => {
436                let doc_ids = bytes.slice(offset..offset + n * 4);
437                let ordinals = bytes.slice(offset + n * 4..offset + n * 6);
438                let lengths = bytes.slice(offset + n * 6..end);
439                let length_floor = nominal_chunk_length(lengths.as_slice());
440                let doc_ids_monotonic = doc_ids
441                    .as_slice()
442                    .chunks_exact(4)
443                    .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
444                    .is_sorted();
445                file.chunk_maps.insert(
446                    field_id,
447                    ChunkMap {
448                        doc_ids,
449                        ordinals,
450                        lengths,
451                        num_chunks: count,
452                        total_tokens,
453                        length_floor,
454                        doc_ids_monotonic,
455                    },
456                );
457            }
458            _ => {
459                file.doc_lengths.insert(
460                    field_id,
461                    DocLengths {
462                        lengths: bytes.slice(offset..end),
463                        num_docs: count,
464                        total_tokens,
465                    },
466                );
467            }
468        }
469    }
470    Ok(file)
471}
472
473/// One source section of a merged chunk map.
474pub struct ChunkMapSource<'a> {
475    pub map: &'a ChunkMap,
476    /// Added to every document id of the source.
477    pub doc_offset: u32,
478}
479
480/// One source of a merged length column: the source segment's column when it
481/// has one, and its document count (zeros are written for a missing column).
482pub struct DocLengthsSource<'a> {
483    pub lengths: Option<&'a DocLengths>,
484    pub num_docs: u32,
485}
486
487/// Write the merged `.chunks` file: per field, the sources' sections are
488/// concatenated in order (virtual ids of a later source are offset by the
489/// chunk counts of the earlier ones, matching the posting merge; length
490/// columns follow the document order of the merge).
491///
492/// `fields` and `norms` must be sorted by field id; a field with zero total
493/// chunks is skipped.
494pub fn write_merged_chunk_maps<W: Write + ?Sized>(
495    writer: &mut W,
496    fields: &[(u32, Vec<ChunkMapSource<'_>>)],
497    norms: &[(u32, Vec<DocLengthsSource<'_>>)],
498) -> io::Result<u64> {
499    let live: Vec<&(u32, Vec<ChunkMapSource<'_>>)> = fields
500        .iter()
501        .filter(|(_, sources)| sources.iter().any(|s| s.map.num_chunks() > 0))
502        .collect();
503    let sections = live.len() + norms.len();
504    let mut offset = (HEADER_SIZE + TOC_ENTRY_SIZE * sections) as u64;
505    writer.write_u32::<LittleEndian>(MAGIC)?;
506    writer.write_u32::<LittleEndian>(VERSION)?;
507    writer.write_u32::<LittleEndian>(sections as u32)?;
508    for (field_id, sources) in &live {
509        let mut num_chunks = 0u64;
510        let mut total_tokens = 0u64;
511        for source in sources {
512            num_chunks += u64::from(source.map.num_chunks());
513            total_tokens += source.map.total_tokens();
514        }
515        let num_chunks = u32::try_from(num_chunks).map_err(|_| {
516            io::Error::new(
517                io::ErrorKind::InvalidData,
518                format!("chunked field {field_id} exceeds u32::MAX chunks after merge"),
519            )
520        })?;
521        writer.write_u32::<LittleEndian>(*field_id)?;
522        writer.write_u32::<LittleEndian>(KIND_CHUNK_MAP)?;
523        writer.write_u32::<LittleEndian>(num_chunks)?;
524        writer.write_u64::<LittleEndian>(total_tokens)?;
525        writer.write_u64::<LittleEndian>(offset)?;
526        offset += u64::from(num_chunks) * 8;
527    }
528    for (field_id, sources) in norms {
529        let num_docs: u64 = sources.iter().map(|s| u64::from(s.num_docs)).sum();
530        let num_docs = u32::try_from(num_docs).map_err(|_| {
531            io::Error::new(
532                io::ErrorKind::InvalidData,
533                format!("field {field_id} exceeds u32::MAX documents after merge"),
534            )
535        })?;
536        let total_tokens: u64 = sources
537            .iter()
538            .filter_map(|s| s.lengths.map(DocLengths::total_tokens))
539            .sum();
540        writer.write_u32::<LittleEndian>(*field_id)?;
541        writer.write_u32::<LittleEndian>(KIND_DOC_LENGTHS)?;
542        writer.write_u32::<LittleEndian>(num_docs)?;
543        writer.write_u64::<LittleEndian>(total_tokens)?;
544        writer.write_u64::<LittleEndian>(offset)?;
545        offset += u64::from(num_docs) * 2;
546    }
547    let mut patched: Vec<u8> = Vec::new();
548    for (_, sources) in &live {
549        for source in sources {
550            if source.doc_offset == 0 {
551                writer.write_all(source.map.doc_id_bytes())?;
552                continue;
553            }
554            patched.clear();
555            patched.reserve(source.map.doc_id_bytes().len());
556            for chunk in source.map.doc_id_bytes().chunks_exact(4) {
557                let doc = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
558                let remapped = doc.checked_add(source.doc_offset).ok_or_else(|| {
559                    io::Error::new(
560                        io::ErrorKind::InvalidData,
561                        "document id overflow while merging chunk maps",
562                    )
563                })?;
564                patched.extend_from_slice(&remapped.to_le_bytes());
565            }
566            writer.write_all(&patched)?;
567        }
568        for source in sources {
569            writer.write_all(source.map.ordinal_bytes())?;
570        }
571        for source in sources {
572            writer.write_all(source.map.length_bytes())?;
573        }
574    }
575    let zeros = [0u8; 2 * 1024];
576    for (_, sources) in norms {
577        for source in sources {
578            match source.lengths {
579                Some(lengths) if lengths.num_docs() == source.num_docs => {
580                    writer.write_all(lengths.length_bytes())?;
581                }
582                Some(lengths) => {
583                    return Err(io::Error::new(
584                        io::ErrorKind::InvalidData,
585                        format!(
586                            "length column covers {} documents, segment has {}",
587                            lengths.num_docs(),
588                            source.num_docs
589                        ),
590                    ));
591                }
592                None => {
593                    let mut remaining = source.num_docs as usize * 2;
594                    while remaining > 0 {
595                        let take = remaining.min(zeros.len());
596                        writer.write_all(&zeros[..take])?;
597                        remaining -= take;
598                    }
599                }
600            }
601        }
602    }
603    Ok(offset)
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn build(entries: &[(u32, u16, u32)]) -> ChunkMapBuilder {
611        let mut builder = ChunkMapBuilder::default();
612        for &(doc, ord, len) in entries {
613            builder.push(doc, ord, len).unwrap();
614        }
615        builder
616    }
617
618    #[test]
619    fn round_trips_two_fields() {
620        let a = build(&[(0, 0, 10), (0, 1, 20), (3, 0, 70_000)]);
621        let b = build(&[(1, 0, 5)]);
622        let mut out = Vec::new();
623        write_chunk_maps(&mut out, &[(2, &a), (7, &b)], &[]).unwrap();
624        let maps = read_chunk_maps(OwnedBytes::new(out)).unwrap().chunk_maps;
625        let a = &maps[&2];
626        assert_eq!(a.num_chunks(), 3);
627        assert_eq!(a.resolve(0), (0, 0));
628        assert_eq!(a.resolve(1), (0, 1));
629        assert_eq!(a.resolve(2), (3, 0));
630        assert_eq!(a.length(1), 20);
631        assert_eq!(a.length(2), MAX_CHUNK_LENGTH, "lengths saturate at u16");
632        assert_eq!(a.total_tokens(), 70_030);
633        assert_eq!(maps[&7].resolve(0), (1, 0));
634        assert_eq!(maps[&7].avg_len(), 5.0);
635    }
636
637    #[test]
638    fn merged_maps_offset_doc_ids_and_keep_ordinals() {
639        let first = build(&[(0, 0, 10), (1, 0, 11), (1, 1, 12)]);
640        let second = build(&[(0, 0, 20), (0, 1, 21)]);
641        let mut raw_first = Vec::new();
642        write_chunk_maps(&mut raw_first, &[(4, &first)], &[]).unwrap();
643        let mut raw_second = Vec::new();
644        write_chunk_maps(&mut raw_second, &[(4, &second)], &[]).unwrap();
645        let first = read_chunk_maps(OwnedBytes::new(raw_first))
646            .unwrap()
647            .chunk_maps;
648        let second = read_chunk_maps(OwnedBytes::new(raw_second))
649            .unwrap()
650            .chunk_maps;
651
652        let mut merged = Vec::new();
653        write_merged_chunk_maps(
654            &mut merged,
655            &[(
656                4,
657                vec![
658                    ChunkMapSource {
659                        map: &first[&4],
660                        doc_offset: 0,
661                    },
662                    ChunkMapSource {
663                        map: &second[&4],
664                        doc_offset: 2,
665                    },
666                ],
667            )],
668            &[],
669        )
670        .unwrap();
671        let merged = read_chunk_maps(OwnedBytes::new(merged)).unwrap().chunk_maps;
672        let map = &merged[&4];
673        assert_eq!(map.num_chunks(), 5);
674        assert_eq!(map.total_tokens(), 74);
675        assert_eq!(
676            (0..5).map(|v| map.resolve(v)).collect::<Vec<_>>(),
677            vec![(0, 0), (1, 0), (1, 1), (2, 0), (2, 1)]
678        );
679        assert_eq!(
680            (0..5).map(|v| map.length(v)).collect::<Vec<_>>(),
681            vec![10, 11, 12, 20, 21]
682        );
683    }
684
685    #[test]
686    fn rejects_foreign_or_truncated_files() {
687        assert!(read_chunk_maps(OwnedBytes::new(vec![0u8; 4])).is_err());
688        let mut bad_magic = Vec::new();
689        bad_magic.write_u32::<LittleEndian>(0xDEAD_BEEF).unwrap();
690        bad_magic.write_u32::<LittleEndian>(VERSION).unwrap();
691        bad_magic.write_u32::<LittleEndian>(0).unwrap();
692        assert!(read_chunk_maps(OwnedBytes::new(bad_magic)).is_err());
693
694        let a = build(&[(0, 0, 10)]);
695        let mut out = Vec::new();
696        write_chunk_maps(&mut out, &[(1, &a)], &[]).unwrap();
697        out.truncate(out.len() - 1);
698        assert!(read_chunk_maps(OwnedBytes::new(out)).is_err());
699    }
700
701    #[test]
702    fn doc_length_columns_round_trip_and_merge_with_zero_fill() {
703        let a = build(&[(0, 0, 10)]);
704        let column = [7u16, 0, 300];
705        let mut out = Vec::new();
706        write_chunk_maps(
707            &mut out,
708            &[(1, &a)],
709            &[DocLengthsColumn {
710                field_id: 5,
711                lengths: &column,
712                total_tokens: 307,
713            }],
714        )
715        .unwrap();
716        let file = read_chunk_maps(OwnedBytes::new(out)).unwrap();
717        assert_eq!(file.chunk_maps[&1].num_chunks(), 1);
718        let norms = &file.doc_lengths[&5];
719        assert_eq!(norms.num_docs(), 3);
720        assert_eq!(
721            (0..4).map(|d| norms.length(d)).collect::<Vec<_>>(),
722            vec![7, 0, 300, 0]
723        );
724        assert_eq!(norms.total_tokens(), 307);
725        assert!(
726            (norms.avg_len() - 153.5).abs() < 1e-3,
727            "{}",
728            norms.avg_len()
729        );
730
731        // Merge: a source without the column contributes zeros for its docs.
732        let mut merged = Vec::new();
733        write_merged_chunk_maps(
734            &mut merged,
735            &[],
736            &[(
737                5,
738                vec![
739                    DocLengthsSource {
740                        lengths: None,
741                        num_docs: 2,
742                    },
743                    DocLengthsSource {
744                        lengths: Some(norms),
745                        num_docs: 3,
746                    },
747                ],
748            )],
749        )
750        .unwrap();
751        let merged = read_chunk_maps(OwnedBytes::new(merged)).unwrap();
752        assert!(merged.chunk_maps.is_empty());
753        let norms = &merged.doc_lengths[&5];
754        assert_eq!(norms.num_docs(), 5);
755        assert_eq!(
756            (0..5).map(|d| norms.length(d)).collect::<Vec<_>>(),
757            vec![0, 0, 7, 0, 300]
758        );
759        assert_eq!(norms.total_tokens(), 307);
760    }
761
762    #[test]
763    fn version_one_files_still_read() {
764        let a = build(&[(0, 0, 10), (2, 0, 4)]);
765        let mut out = Vec::new();
766        out.write_u32::<LittleEndian>(MAGIC).unwrap();
767        out.write_u32::<LittleEndian>(1).unwrap();
768        out.write_u32::<LittleEndian>(1).unwrap();
769        out.write_u32::<LittleEndian>(9).unwrap();
770        out.write_u32::<LittleEndian>(2).unwrap();
771        out.write_u64::<LittleEndian>(14).unwrap();
772        out.write_u64::<LittleEndian>((HEADER_SIZE + TOC_ENTRY_SIZE_V1) as u64)
773            .unwrap();
774        for doc in &a.doc_ids {
775            out.write_u32::<LittleEndian>(*doc).unwrap();
776        }
777        for ord in &a.ordinals {
778            out.write_u16::<LittleEndian>(*ord).unwrap();
779        }
780        for len in &a.lengths {
781            out.write_u16::<LittleEndian>(*len).unwrap();
782        }
783        let file = read_chunk_maps(OwnedBytes::new(out)).unwrap();
784        assert!(file.doc_lengths.is_empty());
785        let map = &file.chunk_maps[&9];
786        assert_eq!(map.resolve(1), (2, 0));
787        assert_eq!(map.length(1), 4);
788    }
789}