1mod groups;
15mod impacts;
16mod reader;
17mod validation;
18use groups::GroupWords;
19use impacts::{ImpactBuilder, ImpactTable};
20
21pub(crate) use reader::{DeferredPosting, PostingListReader};
22
23#[cfg(feature = "native")]
24mod compact;
25#[cfg(feature = "native")]
26pub(crate) use compact::{PostingBlockSource, PostingStreamWriter};
27
28use byteorder::{LittleEndian, WriteBytesExt};
29use std::io::{self, Write};
30
31use super::bitpacking4x;
32use super::horizontal_bp128::{pack_block_n as pack_bits, unpack_block_n as unpack_bits};
33use super::opt_p4d::{find_optimal_bit_width, pack_with_exceptions};
34use crate::DocId;
35use crate::directories::OwnedBytes;
36use crate::structures::simd;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PostingCodec {
42 #[default]
45 Rounded = 0,
46 Packed = 1,
49 Pfor = 2,
52 Simd4x = 3,
55}
56
57impl PostingCodec {
58 const HEADER_SHIFT: u32 = 6;
60 const WIDTH_MASK: u8 = 0x3F;
61
62 fn from_header_byte(doc_bits: u8) -> io::Result<(Self, u8)> {
66 let width = doc_bits & Self::WIDTH_MASK;
67 let codec = match doc_bits >> Self::HEADER_SHIFT {
68 0 => PostingCodec::Rounded,
69 1 => PostingCodec::Packed,
70 2 => PostingCodec::Pfor,
71 _ => PostingCodec::Simd4x,
72 };
73 if width > 32 {
74 return Err(io::Error::new(
75 io::ErrorKind::InvalidData,
76 format!("posting block doc-id width {width} exceeds 32 bits"),
77 ));
78 }
79 Ok((codec, width))
80 }
81
82 pub(super) fn for_count(self, count: usize) -> Self {
85 if self == Self::Simd4x && count < BLOCK_SIZE {
86 Self::Rounded
87 } else {
88 self
89 }
90 }
91
92 fn header_byte(self, width: u8) -> u8 {
93 ((self as u8) << Self::HEADER_SHIFT) | width
94 }
95
96 pub fn parse(s: &str) -> Option<Self> {
97 match s.to_ascii_lowercase().as_str() {
98 "rounded" | "default" => Some(PostingCodec::Rounded),
99 "packed" | "bp128" | "exact" => Some(PostingCodec::Packed),
100 "pfor" | "optp4d" | "patched" => Some(PostingCodec::Pfor),
101 "simd4x" => Some(PostingCodec::Simd4x),
102 _ => None,
103 }
104 }
105}
106
107impl std::fmt::Display for PostingCodec {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.write_str(match self {
110 PostingCodec::Rounded => "rounded",
111 PostingCodec::Packed => "packed",
112 PostingCodec::Pfor => "pfor",
113 PostingCodec::Simd4x => "simd4x",
114 })
115 }
116}
117
118#[inline]
122fn packed_bytes(count: usize, width: u8) -> usize {
123 (count * width as usize).div_ceil(8)
124}
125
126fn pack_pfor(values: &[u32], out: &mut Vec<u8>) -> u8 {
130 let (width, _, _) = find_optimal_bit_width(values);
131 let (packed, exceptions) = pack_with_exceptions(values, width);
132 out.push(exceptions.len() as u8);
133 out.extend_from_slice(&packed);
134 for (pos, high) in exceptions {
135 out.push(pos);
136 out.extend_from_slice(&high.to_le_bytes());
137 }
138 width
139}
140
141fn pfor_payload_len(input: &[u8], count: usize, width: u8) -> io::Result<usize> {
143 let n_exceptions = *input
144 .first()
145 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "posting block truncated"))?
146 as usize;
147 Ok(1 + packed_bytes(count, width) + n_exceptions * 5)
148}
149
150fn unpack_pfor(input: &[u8], width: u8, out: &mut [u32], count: usize) -> io::Result<()> {
153 let n_exceptions = *input
154 .first()
155 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "posting block truncated"))?
156 as usize;
157 let table_at = 1 + packed_bytes(count, width);
158 let table_end = table_at + n_exceptions * 5;
159 if input.len() < table_end {
160 return Err(io::Error::new(
161 io::ErrorKind::InvalidData,
162 "posting block exception table truncated",
163 ));
164 }
165 let out = &mut out[..count];
166 unpack_bits(&input[1..table_at], width, out, count);
167 if width < 32 {
168 for entry in input[table_at..table_end].chunks_exact(5) {
169 let pos = entry[0] as usize;
170 let high = u32::from_le_bytes([entry[1], entry[2], entry[3], entry[4]]);
171 if pos < count {
172 out[pos] |= high << width;
173 }
174 }
175 }
176 Ok(())
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct Posting {
182 pub doc_id: DocId,
183 pub term_freq: u32,
184}
185
186#[derive(Debug, Clone, Default)]
188pub struct PostingList {
189 postings: Vec<Posting>,
190}
191
192impl PostingList {
193 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn with_capacity(capacity: usize) -> Self {
198 Self {
199 postings: Vec::with_capacity(capacity),
200 }
201 }
202
203 pub fn push(&mut self, doc_id: DocId, term_freq: u32) {
205 debug_assert!(
206 self.postings.is_empty() || self.postings.last().unwrap().doc_id < doc_id,
207 "Postings must be added in sorted order"
208 );
209 self.postings.push(Posting { doc_id, term_freq });
210 }
211
212 pub fn add(&mut self, doc_id: DocId, term_freq: u32) {
214 if let Some(last) = self.postings.last_mut()
215 && last.doc_id == doc_id
216 {
217 last.term_freq += term_freq;
218 return;
219 }
220 self.postings.push(Posting { doc_id, term_freq });
221 }
222
223 pub fn doc_count(&self) -> u32 {
225 self.postings.len() as u32
226 }
227
228 pub fn len(&self) -> usize {
229 self.postings.len()
230 }
231
232 pub fn is_empty(&self) -> bool {
233 self.postings.is_empty()
234 }
235
236 pub fn iter(&self) -> impl Iterator<Item = &Posting> {
237 self.postings.iter()
238 }
239}
240
241pub struct PostingListIterator<'a> {
243 postings: &'a [Posting],
244 position: usize,
245}
246
247impl<'a> PostingListIterator<'a> {
248 pub fn new(posting_list: &'a PostingList) -> Self {
249 Self {
250 postings: &posting_list.postings,
251 position: 0,
252 }
253 }
254
255 pub fn doc(&self) -> DocId {
257 if self.position < self.postings.len() {
258 self.postings[self.position].doc_id
259 } else {
260 TERMINATED
261 }
262 }
263
264 pub fn term_freq(&self) -> u32 {
266 if self.position < self.postings.len() {
267 self.postings[self.position].term_freq
268 } else {
269 0
270 }
271 }
272
273 pub fn advance(&mut self) -> DocId {
275 self.position += 1;
276 self.doc()
277 }
278
279 pub fn seek(&mut self, target: DocId) -> DocId {
281 crate::observe::search_work!(posting_seeks += 1);
282 let remaining = &self.postings[self.position..];
283 let offset = remaining.partition_point(|p| p.doc_id < target);
284 self.position += offset;
285 self.doc()
286 }
287
288 pub fn size_hint(&self) -> usize {
290 self.postings.len().saturating_sub(self.position)
291 }
292}
293
294pub const TERMINATED: DocId = DocId::MAX;
296
297pub const BLOCK_SIZE: usize = 128;
306
307const L1_INTERVAL: usize = 8;
309
310const L0_SIZE: usize = 16;
313
314const L1_SIZE: usize = 4;
316
317const FOOTER_SIZE: usize = 24;
319
320const FOOTER_V2_SIZE: usize = FOOTER_SIZE + 20;
326
327const FOOTER_MAGIC: u32 = 0x324C_5042;
329
330const FLAG_POS_CURSORS: u32 = 1;
332
333const FLAG_LEN_BOUNDS: u32 = 2;
337
338const FLAG_L1_BOUNDS: u32 = 4;
342
343const FLAG_RATIO_BOUNDS: u32 = 8;
345
346const FLAG_IMPACT_BOUNDS: u32 = 16;
348const FLAG_GROUP_IMPACT_BOUNDS: u32 = 32;
350const FLAG_COMPACT_HEADERS: u32 = 64;
351const FLAG_SHORT_CURSORS: u32 = 128;
352
353fn append_ratio_groups(ratios: &mut Vec<u8>, blocks: usize) {
354 for start in (0..blocks).step_by(L1_INTERVAL) {
355 let ratio = (start..(start + L1_INTERVAL).min(blocks))
356 .map(|i| read_ratio(ratios, i))
357 .fold(f32::INFINITY, f32::min);
358 ratios.extend_from_slice(&ratio.to_le_bytes());
359 }
360}
361
362#[inline]
363fn read_ratio(bytes: &[u8], index: usize) -> f32 {
364 let at = index * 4;
365 f32::from_le_bytes(bytes[at..at + 4].try_into().unwrap())
366}
367
368fn validate_ratios(bytes: &[u8]) -> io::Result<()> {
369 if bytes.chunks_exact(4).any(|value| {
370 let ratio = f32::from_le_bytes(value.try_into().unwrap());
371 !ratio.is_finite() || ratio < 0.0
372 }) {
373 return Err(io::Error::new(
374 io::ErrorKind::InvalidData,
375 "invalid posting ratio bound",
376 ));
377 }
378 Ok(())
379}
380
381fn lower_length_ratio(length: u32, tf: u32) -> f32 {
382 if tf == 0 {
383 return 0.0;
384 }
385 ((length as f64 / tf as f64) as f32).next_down().max(0.0)
388}
389
390fn group_bounds_from_l0(l0: &[u8], l0_count: usize) -> Vec<u32> {
393 let mut groups = Vec::with_capacity(l0_count.div_ceil(L1_INTERVAL));
394 let mut idx = 0;
395 while idx < l0_count {
396 let end = (idx + L1_INTERVAL).min(l0_count);
397 let mut max_tf = 0u32;
398 let mut min_len = u32::MAX;
399 for block in idx..end {
400 let (_, _, _, word) = read_l0(l0, block);
401 let (tf, len) = unpack_bounds(word, true);
402 max_tf = max_tf.max(tf);
403 min_len = min_len.min(len.unwrap_or(1));
404 }
405 groups.push(pack_bounds(max_tf, min_len));
406 idx = end;
407 }
408 groups
409}
410
411#[inline]
413fn pack_bounds(max_tf: u32, min_len: u32) -> u32 {
414 max_tf.min(u16::MAX as u32) | (min_len.min(u16::MAX as u32) << 16)
415}
416
417#[inline]
420fn unpack_bounds(word: u32, packed: bool) -> (u32, Option<u32>) {
421 if packed {
422 (word & 0xFFFF, Some(word >> 16))
423 } else {
424 (f32::from_bits(word) as u32, None)
425 }
426}
427
428const CURSOR_SIZE: usize = 8;
431
432#[derive(Debug, Clone, Copy)]
434struct Footer {
435 compact_headers: bool,
436 short_cursors: bool,
437 stream_len: usize,
438 l0_count: usize,
439 l1_count: usize,
440 doc_count: u32,
441 max_tf: u32,
442 total_positions: u64,
443 has_cursors: bool,
444 len_bounds: bool,
445 l1_bounds: bool,
446 ratio_bounds: bool,
447 impact_bounds: bool,
448 group_impact_bounds: bool,
449 min_len: u32,
450}
451
452impl Footer {
453 fn parse(raw: &[u8]) -> io::Result<Self> {
454 Self::parse_tail(raw, raw.len())
455 }
456
457 fn parse_tail(raw: &[u8], total_len: usize) -> io::Result<Self> {
458 if raw.len() < FOOTER_SIZE {
459 return Err(io::Error::new(
460 io::ErrorKind::InvalidData,
461 "posting data too short",
462 ));
463 }
464 let extended = raw.len() >= FOOTER_V2_SIZE
465 && u32::from_le_bytes(raw[raw.len() - 4..].try_into().unwrap()) == FOOTER_MAGIC;
466 let f = raw.len()
467 - if extended {
468 FOOTER_V2_SIZE
469 } else {
470 FOOTER_SIZE
471 };
472 let stream_len = usize::try_from(u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()))
473 .map_err(|_| {
474 io::Error::new(
475 io::ErrorKind::InvalidData,
476 "posting stream exceeds address space",
477 )
478 })?;
479 let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
480 let l1_count = u32::from_le_bytes(raw[f + 12..f + 16].try_into().unwrap()) as usize;
481 let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
482 let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
483 let (total_positions, flags, min_len) = if extended {
484 let total = u64::from_le_bytes(raw[f + 24..f + 32].try_into().unwrap());
485 let flags = u32::from_le_bytes(raw[f + 32..f + 36].try_into().unwrap());
486 let min_len = u32::from_le_bytes(raw[f + 36..f + 40].try_into().unwrap());
487 (total, flags, min_len)
488 } else {
489 (0, 0, 0)
490 };
491 if flags
492 & !(FLAG_POS_CURSORS
493 | FLAG_LEN_BOUNDS
494 | FLAG_L1_BOUNDS
495 | FLAG_RATIO_BOUNDS
496 | FLAG_IMPACT_BOUNDS
497 | FLAG_GROUP_IMPACT_BOUNDS
498 | FLAG_COMPACT_HEADERS
499 | FLAG_SHORT_CURSORS)
500 != 0
501 {
502 return Err(io::Error::new(
503 io::ErrorKind::InvalidData,
504 "unknown posting footer flags",
505 ));
506 }
507 let footer = Self {
508 compact_headers: flags & FLAG_COMPACT_HEADERS != 0,
509 short_cursors: flags & FLAG_SHORT_CURSORS != 0,
510 stream_len,
511 l0_count,
512 l1_count,
513 doc_count,
514 max_tf,
515 total_positions,
516 has_cursors: flags & FLAG_POS_CURSORS != 0,
517 len_bounds: flags & FLAG_LEN_BOUNDS != 0,
518 l1_bounds: flags & FLAG_L1_BOUNDS != 0,
519 ratio_bounds: flags & FLAG_RATIO_BOUNDS != 0,
520 impact_bounds: flags & FLAG_IMPACT_BOUNDS != 0,
521 group_impact_bounds: flags & FLAG_GROUP_IMPACT_BOUNDS != 0,
522 min_len,
523 };
524 if footer.short_cursors && (!footer.has_cursors || footer.total_positions > u32::MAX as u64)
525 {
526 return Err(io::Error::new(
527 io::ErrorKind::InvalidData,
528 "invalid short position cursors",
529 ));
530 }
531 if footer.group_impact_bounds && (!footer.impact_bounds || !footer.l1_bounds) {
532 return Err(io::Error::new(
533 io::ErrorKind::InvalidData,
534 "group impacts require L0 impacts and L1 bounds",
535 ));
536 }
537 let end = l0_count
538 .checked_mul(L0_SIZE + if footer.compact_headers { 4 } else { 0 })
539 .and_then(|n| {
540 l1_count
541 .checked_mul(L1_SIZE + if footer.l1_bounds { 4 } else { 0 })
542 .and_then(|m| n.checked_add(m))
543 })
544 .and_then(|n| {
545 l0_count
546 .checked_mul(if footer.has_cursors {
547 footer.cursor_size()
548 } else {
549 0
550 })
551 .and_then(|m| n.checked_add(m))
552 })
553 .and_then(|n| {
554 if footer.ratio_bounds {
555 l0_count
556 .checked_add(l1_count)
557 .and_then(|m| m.checked_mul(4))
558 .and_then(|m| n.checked_add(m))
559 } else {
560 Some(n)
561 }
562 })
563 .and_then(|n| n.checked_add(stream_len));
564 let footer_offset = total_len.saturating_sub(raw.len() - f);
565 if end.is_none_or(|end| {
566 if footer.impact_bounds {
567 !footer.len_bounds
568 || !footer.ratio_bounds
569 || l0_count
570 .checked_add(if footer.group_impact_bounds {
571 l1_count
572 } else {
573 0
574 })
575 .and_then(|n| n.checked_add(1))
576 .and_then(|n| n.checked_mul(4))
577 .and_then(|n| n.checked_add(end))
578 .is_none_or(|minimum| minimum > footer_offset)
579 } else {
580 end != footer_offset
581 }
582 }) {
583 return Err(io::Error::new(
584 io::ErrorKind::InvalidData,
585 "posting list sections do not match the footer offset",
586 ));
587 }
588 Ok(footer)
589 }
590
591 fn l0_start(&self) -> usize {
592 self.stream_len
593 }
594 fn impact_record_count(&self) -> usize {
595 self.l0_count
596 + if self.group_impact_bounds {
597 self.l1_count
598 } else {
599 0
600 }
601 }
602 fn l0_end(&self) -> usize {
603 self.l0_start() + self.l0_count * L0_SIZE
604 }
605 fn l1_start(&self) -> usize {
606 self.l0_end()
607 + if self.compact_headers {
608 self.l0_count * 4
609 } else {
610 0
611 }
612 }
613 fn cursor_size(&self) -> usize {
614 if self.short_cursors { 4 } else { CURSOR_SIZE }
615 }
616 fn l1_end(&self) -> usize {
617 self.l1_start() + self.l1_count * L1_SIZE
618 }
619 fn l1_bounds_end(&self) -> usize {
620 self.l1_end() + if self.l1_bounds { self.l1_count * 4 } else { 0 }
621 }
622 fn ratios_end(&self) -> usize {
623 self.cursors_end()
624 + if self.ratio_bounds {
625 (self.l0_count + self.l1_count) * 4
626 } else {
627 0
628 }
629 }
630 fn cursors_end(&self) -> usize {
631 self.l1_bounds_end()
632 + if self.has_cursors {
633 self.l0_count * self.cursor_size()
634 } else {
635 0
636 }
637 }
638}
639
640#[inline]
647fn read_l0(bytes: &[u8], idx: usize) -> (u32, u32, u32, u32) {
648 let b = &bytes[idx * L0_SIZE..][..L0_SIZE];
649 let first_doc = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
650 let last_doc = u32::from_le_bytes([b[4], b[5], b[6], b[7]]);
651 let offset = u32::from_le_bytes([b[8], b[9], b[10], b[11]]);
652 let bounds = u32::from_le_bytes([b[12], b[13], b[14], b[15]]);
653 (first_doc, last_doc, offset, bounds)
654}
655
656#[inline]
660fn verify_block_docs(
661 docs: &[u32],
662 first: u32,
663 last: u32,
664 byte_gaps: Option<&[u8]>,
665 strict_gap_width: Option<u8>,
666) -> bool {
667 let ordered = if strict_gap_width.is_some_and(|width| width <= 25) {
668 debug_assert!(docs.len() <= BLOCK_SIZE);
673 true
674 } else if let Some(gaps) = byte_gaps {
675 debug_assert_eq!(gaps.len() + 1, docs.len());
679 let mut nonzero = true;
680 for &gap in gaps {
681 nonzero &= gap != 0;
682 }
683 nonzero
684 } else {
685 let mut ordered = true;
686 for pair in docs.windows(2) {
687 ordered &= pair[0] < pair[1];
688 }
689 ordered
690 };
691 ordered
692 && first <= last
693 && last != TERMINATED
694 && docs.first() == Some(&first)
695 && docs.last() == Some(&last)
696}
697
698#[inline]
700fn write_l0(buf: &mut Vec<u8>, first_doc: u32, last_doc: u32, offset: u32, bounds: u32) {
701 buf.extend_from_slice(&first_doc.to_le_bytes());
702 buf.extend_from_slice(&last_doc.to_le_bytes());
703 buf.extend_from_slice(&offset.to_le_bytes());
704 buf.extend_from_slice(&bounds.to_le_bytes());
705}
706
707#[inline]
711fn block_len_from_l0(l0_bytes: &[u8], l0_count: usize, stream_len: usize, idx: usize) -> usize {
712 let (_, _, offset, _) = read_l0(l0_bytes, idx);
713 let end = if idx + 1 < l0_count {
714 read_l0(l0_bytes, idx + 1).2 as usize
715 } else {
716 stream_len
717 };
718 end.saturating_sub(offset as usize)
719}
720
721struct EncodedBlock {
724 doc_bits: u8,
725 tf_bits: u8,
726}
727
728fn encode_block_arrays(
730 codec: PostingCodec,
731 deltas: &[u32],
732 tfs: &[u32],
733 stream: &mut Vec<u8>,
734) -> EncodedBlock {
735 let codec = codec.for_count(tfs.len());
736 match codec {
737 PostingCodec::Simd4x => EncodedBlock {
738 doc_bits: codec.header_byte(bitpacking4x::encode_gaps(deltas, stream)),
739 tf_bits: bitpacking4x::encode(tfs, stream),
740 },
741 PostingCodec::Rounded => {
742 let max_delta = deltas.iter().copied().max().unwrap_or(0);
743 let doc_bits = simd::round_bit_width(simd::bits_needed(max_delta));
744 let max_tf = tfs.iter().copied().max().unwrap_or(0);
745 let tf_bits = simd::round_bit_width(simd::bits_needed(max_tf));
746 if !deltas.is_empty() {
747 let rounded = simd::RoundedBitWidth::from_u8(doc_bits);
748 let start = stream.len();
749 stream.resize(start + deltas.len() * rounded.bytes_per_value(), 0);
750 simd::pack_rounded(deltas, rounded, &mut stream[start..]);
751 }
752 {
753 let rounded = simd::RoundedBitWidth::from_u8(tf_bits);
754 let start = stream.len();
755 stream.resize(start + tfs.len() * rounded.bytes_per_value(), 0);
756 simd::pack_rounded(tfs, rounded, &mut stream[start..]);
757 }
758 EncodedBlock {
759 doc_bits: codec.header_byte(doc_bits),
760 tf_bits,
761 }
762 }
763 PostingCodec::Packed => {
764 let max_delta = deltas.iter().copied().max().unwrap_or(0);
765 let doc_bits = simd::bits_needed(max_delta);
766 let max_tf = tfs.iter().copied().max().unwrap_or(0);
767 let tf_bits = simd::bits_needed(max_tf);
768 pack_bits(deltas, doc_bits, stream);
769 pack_bits(tfs, tf_bits, stream);
770 EncodedBlock {
771 doc_bits: codec.header_byte(doc_bits),
772 tf_bits,
773 }
774 }
775 PostingCodec::Pfor => {
776 let doc_bits = if deltas.is_empty() {
777 0
778 } else {
779 pack_pfor(deltas, stream)
780 };
781 let tf_bits = pack_pfor(tfs, stream);
782 EncodedBlock {
783 doc_bits: codec.header_byte(doc_bits),
784 tf_bits,
785 }
786 }
787 }
788}
789
790#[derive(Debug, Clone)]
791pub struct BlockPostingList {
792 compact_headers: bool,
793 short_cursors: bool,
794 verify_content: bool,
796 content_error: Option<std::sync::Arc<reader::PostingIntegrity>>,
798 stream: OwnedBytes,
800 l0_bytes: OwnedBytes,
804 l0_count: usize,
806 l1_docs: GroupWords,
809 l1_bounds: GroupWords,
812 ratios: Option<OwnedBytes>,
814 impacts: Option<ImpactTable>,
816 doc_count: u32,
818 max_tf: u32,
820 pos_cursors: Option<OwnedBytes>,
824 total_positions: u64,
827 len_bounds: bool,
829 min_len: u32,
831}
832
833impl BlockPostingList {
834 #[inline]
836 fn read_l0_entry(&self, idx: usize) -> (u32, u32, u32, u32) {
837 read_l0(&self.l0_bytes, idx)
838 }
839
840 pub fn from_posting_list(list: &PostingList) -> io::Result<Self> {
849 Self::build(list, false, None, PostingCodec::Rounded, false, false)
850 }
851
852 pub fn from_posting_list_with_codec(
854 list: &PostingList,
855 codec: PostingCodec,
856 ) -> io::Result<Self> {
857 Self::build(list, false, None, codec, false, false)
858 }
859
860 pub fn from_posting_list_with(
865 list: &PostingList,
866 with_positions: bool,
867 length_of: Option<&dyn Fn(DocId) -> u32>,
868 ) -> io::Result<Self> {
869 Self::build(
870 list,
871 with_positions,
872 length_of,
873 PostingCodec::Rounded,
874 false,
875 false,
876 )
877 }
878
879 pub fn from_posting_list_with_options(
881 list: &PostingList,
882 with_positions: bool,
883 length_of: Option<&dyn Fn(DocId) -> u32>,
884 codec: PostingCodec,
885 ) -> io::Result<Self> {
886 Self::build(list, with_positions, length_of, codec, false, false)
887 }
888
889 pub fn from_posting_list_with_ratio_bounds(
891 list: &PostingList,
892 with_positions: bool,
893 length_of: Option<&dyn Fn(DocId) -> u32>,
894 codec: PostingCodec,
895 ) -> io::Result<Self> {
896 Self::build(list, with_positions, length_of, codec, true, false)
897 }
898
899 pub fn from_posting_list_with_impact_bounds(
902 list: &PostingList,
903 with_positions: bool,
904 length_of: Option<&dyn Fn(DocId) -> u32>,
905 codec: PostingCodec,
906 ) -> io::Result<Self> {
907 if length_of.is_none() {
908 return Err(io::Error::new(
909 io::ErrorKind::InvalidInput,
910 "impact bounds require scoring lengths",
911 ));
912 }
913 Self::build(list, with_positions, length_of, codec, true, true)
914 }
915
916 fn build(
917 list: &PostingList,
918 with_positions: bool,
919 length_of: Option<&dyn Fn(DocId) -> u32>,
920 codec: PostingCodec,
921 ratio_bounds: bool,
922 impact_bounds: bool,
923 ) -> io::Result<Self> {
924 let capped = |id: DocId| {
928 length_of
929 .map_or(0, |length_of| length_of(id))
930 .min(crate::segment::chunk_map::MAX_CHUNK_LENGTH)
931 };
932 let length_of: Option<&dyn Fn(DocId) -> u32> = if ratio_bounds {
933 length_of.map(|_| &capped as &dyn Fn(DocId) -> u32)
934 } else {
935 length_of
936 };
937 let mut ratios = (ratio_bounds && length_of.is_some()).then(Vec::new);
938 let mut impacts = (impact_bounds && length_of.is_some() && list.len() > BLOCK_SIZE)
939 .then(|| ImpactBuilder::with_groups(list.len().div_ceil(BLOCK_SIZE)))
940 .transpose()?;
941 let mut points = [(0u32, 0u32); BLOCK_SIZE];
942 let mut stream: Vec<u8> = Vec::new();
943 let mut l0_buf: Vec<u8> = Vec::new();
944 let mut l1_docs: Vec<u32> = Vec::new();
945 let mut cursors: Vec<u8> = Vec::new();
946 let mut positions_so_far = 0u64;
947 let mut l0_count = 0usize;
948 let mut max_tf = 0u32;
949 let mut list_min_len = u32::MAX;
950
951 let postings = &list.postings;
952 let mut i = 0;
953
954 let mut deltas = Vec::with_capacity(BLOCK_SIZE);
956 let mut tf_buf = Vec::with_capacity(BLOCK_SIZE);
957
958 while i < postings.len() {
959 if stream.len() > u32::MAX as usize {
960 return Err(io::Error::new(
961 io::ErrorKind::InvalidData,
962 "posting list stream exceeds u32::MAX bytes",
963 ));
964 }
965 let block_start = stream.len() as u32;
966 let block_end = (i + BLOCK_SIZE).min(postings.len());
967 let block = &postings[i..block_end];
968 let count = block.len();
969
970 let block_max_tf = block.iter().map(|p| p.term_freq).max().unwrap_or(0);
972 max_tf = max_tf.max(block_max_tf);
973
974 let base_doc_id = block.first().unwrap().doc_id;
975 let last_doc_id = block.last().unwrap().doc_id;
976
977 deltas.clear();
979 let mut prev = base_doc_id;
980 for posting in block.iter().skip(1) {
981 deltas.push(posting.doc_id - prev);
982 prev = posting.doc_id;
983 }
984
985 tf_buf.clear();
987 tf_buf.extend(block.iter().map(|p| p.term_freq));
988
989 stream.write_u16::<LittleEndian>(count as u16)?;
993 stream.write_u32::<LittleEndian>(base_doc_id)?;
994 let header_at = stream.len();
995 stream.push(0);
996 stream.push(0);
997 let encoded = encode_block_arrays(codec, &deltas, &tf_buf, &mut stream);
998 stream[header_at] = encoded.doc_bits;
999 stream[header_at + 1] = encoded.tf_bits;
1000
1001 let block_min_len = length_of.map_or(1, |length_of| {
1003 block
1004 .iter()
1005 .map(|p| length_of(p.doc_id).max(1))
1006 .min()
1007 .unwrap_or(1)
1008 });
1009 list_min_len = list_min_len.min(block_min_len);
1010 if let Some(ratios) = &mut ratios {
1011 let ratio = block
1012 .iter()
1013 .map(|p| lower_length_ratio(length_of.unwrap()(p.doc_id).max(1), p.term_freq))
1014 .fold(f32::INFINITY, f32::min);
1015 ratios.extend_from_slice(&ratio.to_le_bytes());
1016 }
1017 if let Some(impacts) = &mut impacts {
1018 for (point, posting) in points.iter_mut().zip(block) {
1019 let length = length_of.unwrap()(posting.doc_id);
1020 *point = (
1021 posting.term_freq,
1022 if length == 0 {
1023 posting.term_freq
1024 } else {
1025 length
1026 },
1027 );
1028 }
1029 impacts.append_points(&mut points[..count])?;
1030 }
1031 write_l0(
1032 &mut l0_buf,
1033 base_doc_id,
1034 last_doc_id,
1035 block_start,
1036 pack_bounds(block_max_tf, block_min_len),
1037 );
1038 l0_count += 1;
1039 if with_positions {
1040 cursors.extend_from_slice(&positions_so_far.to_le_bytes());
1041 positions_so_far += block.iter().map(|p| p.term_freq as u64).sum::<u64>();
1042 }
1043
1044 if l0_count.is_multiple_of(L1_INTERVAL) {
1046 l1_docs.push(last_doc_id);
1047 }
1048
1049 i = block_end;
1050 }
1051
1052 if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
1054 let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
1055 l1_docs.push(last_doc);
1056 }
1057 let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
1058 if let Some(ratios) = &mut ratios {
1059 append_ratio_groups(ratios, l0_count);
1060 }
1061
1062 Ok(Self {
1063 compact_headers: false,
1064 short_cursors: false,
1065 verify_content: true,
1066 content_error: None,
1067 stream: OwnedBytes::new(stream),
1068 l0_bytes: OwnedBytes::new(l0_buf),
1069 l0_count,
1070 l1_docs: l1_docs.into(),
1071 l1_bounds: l1_bounds.into(),
1072 ratios: ratios.map(OwnedBytes::new),
1073 impacts: if let Some(mut impacts) = impacts {
1074 impacts.append_groups_with(l0_count, |_| Ok(None))?;
1075 impacts.finish()
1076 } else {
1077 None
1078 },
1079 doc_count: postings.len() as u32,
1080 max_tf,
1081 pos_cursors: with_positions.then(|| OwnedBytes::new(cursors)),
1082 total_positions: positions_so_far,
1083 len_bounds: true,
1084 min_len: if list_min_len == u32::MAX {
1085 1
1086 } else {
1087 list_min_len
1088 },
1089 })
1090 }
1091
1092 pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
1105 self.serialize_layout(writer, self.compact_headers)
1106 }
1107
1108 #[cfg(all(feature = "native", test))]
1110 pub(crate) fn has_compact_headers(&self) -> bool {
1111 self.compact_headers
1112 }
1113
1114 pub fn serialize_compact<W: Write>(&self, writer: &mut W) -> io::Result<()> {
1117 let compact =
1118 (0..self.num_blocks()).all(|i| self.block_codec(i) != Some(PostingCodec::Pfor));
1119 self.serialize_layout(writer, compact)
1120 }
1121
1122 fn serialize_layout<W: Write>(&self, writer: &mut W, compact: bool) -> io::Result<()> {
1123 let source_header = if self.compact_headers { 0 } else { 8 };
1124 let output_header = if compact { 0 } else { 8 };
1125 let output_offset =
1126 |offset: usize, block: usize| offset - block * source_header + block * output_header;
1127 let stream_len = output_offset(self.stream.len(), self.num_blocks());
1128 if compact == self.compact_headers {
1129 writer.write_all(&self.stream)?;
1130 } else {
1131 for i in 0..self.num_blocks() {
1132 if !compact {
1133 writer.write_all(&self.block_header(i))?;
1134 }
1135 writer.write_all(self.block_payload(i))?;
1136 }
1137 }
1138 for i in 0..self.num_blocks() {
1139 let (first, last, offset, bounds) = self.read_l0_entry(i);
1140 writer.write_u32::<LittleEndian>(first)?;
1141 writer.write_u32::<LittleEndian>(last)?;
1142 writer.write_u32::<LittleEndian>(
1143 u32::try_from(output_offset(offset as usize, i))
1144 .map_err(|_| io::Error::other("posting stream offset overflow"))?,
1145 )?;
1146 writer.write_u32::<LittleEndian>(bounds)?;
1147 }
1148 if compact {
1149 for i in 0..self.num_blocks() {
1150 let header = self.block_header(i);
1151 writer.write_all(&header[..2])?;
1152 writer.write_all(&header[6..])?;
1153 }
1154 }
1155 writer.write_all(self.l1_docs.bytes())?;
1156 writer.write_all(self.l1_bounds.bytes())?;
1157 let short_cursors =
1158 compact && self.pos_cursors.is_some() && self.total_positions <= u32::MAX as u64;
1159 if self.pos_cursors.is_some() {
1160 for i in 0..self.num_blocks() {
1161 let cursor = self.pos_cursor(i).unwrap();
1162 if short_cursors {
1163 writer.write_u32::<LittleEndian>(cursor as u32)?;
1164 } else {
1165 writer.write_u64::<LittleEndian>(cursor)?;
1166 }
1167 }
1168 }
1169 if let Some(ratios) = &self.ratios {
1170 writer.write_all(ratios)?;
1171 }
1172 if let Some(impacts) = &self.impacts {
1173 writer.write_all(impacts.bytes())?;
1174 }
1175 Self::write_footer(
1176 writer,
1177 stream_len as u64,
1178 self.l0_count,
1179 self.l1_docs.len(),
1180 self.doc_count,
1181 self.max_tf,
1182 self.total_positions,
1183 self.pos_cursors.is_some(),
1184 self.len_bounds.then_some(self.min_len),
1185 !self.l1_bounds.is_empty(),
1186 self.ratios.is_some(),
1187 self.impacts.is_some(),
1188 self.has_group_impact_bounds(),
1189 if compact { FLAG_COMPACT_HEADERS } else { 0 }
1190 | if short_cursors { FLAG_SHORT_CURSORS } else { 0 },
1191 )
1192 }
1193
1194 #[allow(clippy::too_many_arguments)]
1195 fn write_footer<W: Write>(
1196 writer: &mut W,
1197 stream_len: u64,
1198 l0_count: usize,
1199 l1_count: usize,
1200 doc_count: u32,
1201 max_tf: u32,
1202 total_positions: u64,
1203 has_cursors: bool,
1204 min_len: Option<u32>,
1205 l1_bounds: bool,
1206 ratio_bounds: bool,
1207 impact_bounds: bool,
1208 group_impact_bounds: bool,
1209 layout_flags: u32,
1210 ) -> io::Result<()> {
1211 writer.write_u64::<LittleEndian>(stream_len)?;
1212 writer.write_u32::<LittleEndian>(l0_count as u32)?;
1213 writer.write_u32::<LittleEndian>(l1_count as u32)?;
1214 writer.write_u32::<LittleEndian>(doc_count)?;
1215 writer.write_u32::<LittleEndian>(max_tf)?;
1216 writer.write_u64::<LittleEndian>(total_positions)?;
1217 let mut flags = layout_flags;
1218 if has_cursors {
1219 flags |= FLAG_POS_CURSORS;
1220 }
1221 if min_len.is_some() {
1222 flags |= FLAG_LEN_BOUNDS;
1223 }
1224 if l1_bounds {
1225 flags |= FLAG_L1_BOUNDS;
1226 }
1227 if ratio_bounds {
1228 flags |= FLAG_RATIO_BOUNDS;
1229 }
1230 if impact_bounds {
1231 flags |= FLAG_IMPACT_BOUNDS;
1232 }
1233 if group_impact_bounds {
1234 flags |= FLAG_GROUP_IMPACT_BOUNDS;
1235 }
1236 writer.write_u32::<LittleEndian>(flags)?;
1237 writer.write_u32::<LittleEndian>(min_len.unwrap_or(0))?;
1238 writer.write_u32::<LittleEndian>(FOOTER_MAGIC)?;
1239 Ok(())
1240 }
1241
1242 pub fn deserialize(raw: &[u8]) -> io::Result<Self> {
1244 Self::deserialize_zero_copy(OwnedBytes::new(raw.to_vec()))
1245 }
1246
1247 pub fn deserialize_zero_copy(raw: OwnedBytes) -> io::Result<Self> {
1250 let footer = Self::validate_bytes(&raw)?;
1251 Ok(Self::from_layout(raw, footer))
1252 }
1253
1254 fn validate_bytes(raw: &[u8]) -> io::Result<Footer> {
1255 let footer = Footer::parse(raw)?;
1256 validation::validate_list(raw, &footer)?;
1257 if footer.ratio_bounds {
1258 validate_ratios(&raw[footer.cursors_end()..footer.ratios_end()])?;
1259 }
1260 if footer.impact_bounds {
1261 ImpactTable::validate(
1262 &raw[footer.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1263 footer.impact_record_count(),
1264 )?;
1265 }
1266 Ok(footer)
1267 }
1268
1269 fn from_layout(raw: OwnedBytes, footer: Footer) -> Self {
1272 let ratios = footer
1273 .ratio_bounds
1274 .then(|| raw.slice(footer.cursors_end()..footer.ratios_end()));
1275 let l1_docs = GroupWords::borrowed(raw.slice(footer.l1_start()..footer.l1_end()));
1276 let l1_bounds = GroupWords::borrowed(raw.slice(footer.l1_end()..footer.l1_bounds_end()));
1277 let pos_cursors = footer
1278 .has_cursors
1279 .then(|| raw.slice(footer.l1_bounds_end()..footer.cursors_end()));
1280
1281 Self {
1282 compact_headers: footer.compact_headers,
1283 short_cursors: footer.short_cursors,
1284 verify_content: true,
1285 content_error: None,
1286 stream: raw.slice(0..footer.stream_len),
1287 l0_bytes: raw.slice(footer.l0_start()..footer.l1_start()),
1288 l0_count: footer.l0_count,
1289 l1_docs,
1290 l1_bounds,
1291 ratios,
1292 impacts: footer.impact_bounds.then(|| {
1293 ImpactTable::from_validated(
1294 raw.slice(footer.ratios_end()..raw.len() - FOOTER_V2_SIZE),
1295 footer.impact_record_count(),
1296 )
1297 }),
1298 doc_count: footer.doc_count,
1299 max_tf: footer.max_tf,
1300 pos_cursors,
1301 total_positions: footer.total_positions,
1302 len_bounds: footer.len_bounds,
1303 min_len: footer.min_len,
1304 }
1305 }
1306
1307 pub fn min_len(&self) -> Option<u32> {
1310 self.len_bounds.then_some(self.min_len)
1311 }
1312
1313 pub fn has_ratio_bounds(&self) -> bool {
1315 self.ratios.is_some()
1316 }
1317
1318 pub fn has_impact_bounds(&self) -> bool {
1320 self.impacts.is_some()
1321 }
1322
1323 pub fn block_impact_point_count(&self, block: usize) -> Option<usize> {
1326 if block >= self.l0_count {
1327 return None;
1328 }
1329 self.impacts
1330 .as_ref()?
1331 .record(block)
1332 .map(|r| r.first().copied().unwrap_or(0) as usize)
1333 }
1334
1335 pub fn has_group_impact_bounds(&self) -> bool {
1337 self.impacts
1338 .as_ref()
1339 .is_some_and(|t| t.record_count() > self.l0_count)
1340 }
1341
1342 #[cfg(test)]
1344 pub fn group_impact_point_count(&self, block: usize) -> Option<usize> {
1345 if block >= self.l0_count {
1346 return None;
1347 }
1348 self.impacts
1349 .as_ref()?
1350 .record(self.l0_count + block / L1_INTERVAL)
1351 .map(|r| r.first().copied().unwrap_or(0) as usize)
1352 }
1353
1354 pub(crate) fn group_impact_minimum(
1355 &self,
1356 block: usize,
1357 reciprocal: f64,
1358 ratio: f64,
1359 ) -> Option<f64> {
1360 if block >= self.l0_count {
1361 return None;
1362 }
1363 self.impacts
1364 .as_ref()?
1365 .minimum(self.l0_count + block / L1_INTERVAL, reciprocal, ratio)
1366 }
1367
1368 pub(crate) fn block_impact_minimum(
1369 &self,
1370 block: usize,
1371 reciprocal: f64,
1372 ratio: f64,
1373 ) -> Option<f64> {
1374 if block >= self.l0_count {
1375 return None;
1376 }
1377 self.impacts.as_ref()?.minimum(block, reciprocal, ratio)
1378 }
1379
1380 pub(crate) fn block_length_ratio(&self, block: usize) -> f32 {
1382 self.ratios
1383 .as_ref()
1384 .map_or(0.0, |ratios| read_ratio(ratios, block))
1385 }
1386
1387 pub(crate) fn group_length_ratio(&self, block: usize) -> f32 {
1388 self.ratios.as_ref().map_or(0.0, |ratios| {
1389 read_ratio(ratios, self.l0_count + block / L1_INTERVAL)
1390 })
1391 }
1392
1393 #[inline]
1395 pub fn block_bounds(&self, block_idx: usize) -> Option<(u32, Option<u32>)> {
1396 if block_idx >= self.l0_count {
1397 return None;
1398 }
1399 let (_, _, _, word) = self.read_l0_entry(block_idx);
1400 let (max_tf, min_len) = unpack_bounds(word, self.len_bounds);
1401 let max_tf = if self.len_bounds && max_tf == u16::MAX as u32 {
1404 max_tf.max(self.max_tf)
1405 } else {
1406 max_tf
1407 };
1408 Some((max_tf, min_len))
1409 }
1410
1411 #[inline]
1414 pub fn group_bounds(&self, block_idx: usize) -> Option<(u32, u32)> {
1415 if block_idx >= self.l0_count {
1416 return None;
1417 }
1418 let word = self.l1_bounds.get(block_idx / L1_INTERVAL)?;
1419 let (max_tf, min_len) = unpack_bounds(word, true);
1420 let max_tf = if max_tf == u16::MAX as u32 {
1421 max_tf.max(self.max_tf)
1422 } else {
1423 max_tf
1424 };
1425 Some((max_tf, min_len.unwrap_or(1)))
1426 }
1427
1428 #[inline]
1430 pub fn group_last_doc(&self, block_idx: usize) -> Option<DocId> {
1431 self.l1_docs.get(block_idx / L1_INTERVAL)
1432 }
1433
1434 #[inline]
1436 pub fn is_group_start(&self, block_idx: usize) -> bool {
1437 block_idx.is_multiple_of(L1_INTERVAL)
1438 }
1439
1440 #[inline]
1443 pub fn next_group_block(&self, block_idx: usize) -> usize {
1444 ((block_idx / L1_INTERVAL + 1) * L1_INTERVAL).min(self.l0_count)
1445 }
1446
1447 pub fn has_cursors_bytes(raw: &[u8]) -> bool {
1449 Footer::parse(raw).is_ok_and(|footer| footer.has_cursors)
1450 }
1451
1452 pub fn has_position_cursors(&self) -> bool {
1454 self.pos_cursors.is_some()
1455 }
1456
1457 #[inline]
1458 fn block_header(&self, block: usize) -> [u8; 8] {
1459 let (first, _, offset, _) = self.read_l0_entry(block);
1460 if self.compact_headers {
1461 let headers = &self.l0_bytes[self.l0_count * L0_SIZE..];
1462 let mut header = [0; 8];
1463 header[..2].copy_from_slice(&headers[block * 4..block * 4 + 2]);
1464 header[2..6].copy_from_slice(&first.to_le_bytes());
1465 header[6..].copy_from_slice(&headers[block * 4 + 2..block * 4 + 4]);
1466 header
1467 } else {
1468 self.stream[offset as usize..offset as usize + 8]
1469 .try_into()
1470 .unwrap()
1471 }
1472 }
1473
1474 #[inline]
1475 fn block_payload(&self, block: usize) -> &[u8] {
1476 let offset = self.read_l0_entry(block).2 as usize;
1477 let header = if self.compact_headers { 0 } else { 8 };
1478 &self.stream[offset + header..offset + self.block_len(block)]
1479 }
1480
1481 pub fn total_positions(&self) -> u64 {
1483 self.total_positions
1484 }
1485
1486 #[inline]
1488 pub fn pos_cursor(&self, block_idx: usize) -> Option<u64> {
1489 let cursors = self.pos_cursors.as_ref()?;
1490 let size = if self.short_cursors { 4 } else { CURSOR_SIZE };
1491 let p = block_idx * size;
1492 cursors.get(p..p + size).map(|b| {
1493 if self.short_cursors {
1494 u64::from(u32::from_le_bytes(b.try_into().unwrap()))
1495 } else {
1496 u64::from_le_bytes(b.try_into().unwrap())
1497 }
1498 })
1499 }
1500
1501 pub fn doc_count(&self) -> u32 {
1502 self.doc_count
1503 }
1504
1505 pub fn max_tf(&self) -> u32 {
1507 self.max_tf
1508 }
1509
1510 pub fn num_blocks(&self) -> usize {
1512 self.l0_count
1513 }
1514
1515 pub fn block_max_tf(&self, block_idx: usize) -> Option<u32> {
1517 self.block_bounds(block_idx).map(|(max_tf, _)| max_tf)
1518 }
1519
1520 pub fn concatenate_blocks(sources: &[(BlockPostingList, u32)]) -> io::Result<Self> {
1523 let mut previous_last = None;
1526 let mut total_docs = 0u32;
1527 let mut total_positions = 0u64;
1528 for (source, offset) in sources {
1529 validation::validate_remap(
1530 &source.l0_bytes,
1531 source.l0_count,
1532 *offset,
1533 &mut previous_last,
1534 )?;
1535 total_docs = total_docs.checked_add(source.doc_count).ok_or_else(|| {
1536 io::Error::new(io::ErrorKind::InvalidData, "merged posting count overflow")
1537 })?;
1538 total_positions = total_positions
1539 .checked_add(source.total_positions)
1540 .ok_or_else(|| {
1541 io::Error::new(
1542 io::ErrorKind::InvalidData,
1543 "merged position cursor overflow",
1544 )
1545 })?;
1546 }
1547 let mut stream: Vec<u8> = Vec::new();
1548 let mut l0_buf: Vec<u8> = Vec::new();
1549 let mut l1_docs: Vec<u32> = Vec::new();
1550 let mut l0_count = 0usize;
1551 let mut ratios = sources
1552 .iter()
1553 .any(|(s, _)| s.has_ratio_bounds())
1554 .then(Vec::new);
1555 let mut impacts = if sources.iter().any(|(s, _)| s.has_impact_bounds()) {
1556 let blocks = sources
1557 .iter()
1558 .try_fold(0usize, |n, (s, _)| n.checked_add(s.num_blocks()))
1559 .ok_or_else(|| {
1560 io::Error::new(io::ErrorKind::InvalidData, "impact block count overflow")
1561 })?;
1562 Some(ImpactBuilder::with_groups(blocks)?)
1563 } else {
1564 None
1565 };
1566 let mut max_tf = 0u32;
1567 let all_cursors = sources.iter().all(|(s, _)| s.has_position_cursors());
1568 if !all_cursors && sources.iter().any(|(s, _)| s.has_position_cursors()) {
1569 return Err(io::Error::new(
1570 io::ErrorKind::InvalidData,
1571 "cannot concatenate posting lists with and without position cursors",
1572 ));
1573 }
1574 let mut cursors: Vec<u8> = Vec::new();
1575 let mut positions_before = 0u64;
1576 let mut min_len = u32::MAX;
1577
1578 for (source, doc_offset) in sources {
1579 max_tf = max_tf.max(source.max_tf);
1580 min_len = min_len.min(source.min_len().unwrap_or(1));
1581 for block_idx in 0..source.num_blocks() {
1582 if let Some(impacts) = &mut impacts {
1583 impacts.append(
1584 source
1585 .impacts
1586 .as_ref()
1587 .and_then(|t| t.record(block_idx))
1588 .unwrap_or(&[]),
1589 )?;
1590 }
1591 if let Some(ratios) = &mut ratios {
1592 ratios.extend_from_slice(&source.block_length_ratio(block_idx).to_le_bytes());
1593 }
1594 if all_cursors {
1595 let cursor = source.pos_cursor(block_idx).unwrap_or(0) + positions_before;
1596 cursors.extend_from_slice(&cursor.to_le_bytes());
1597 }
1598 let (first_doc, last_doc, _, word) = source.read_l0_entry(block_idx);
1599 let (block_max_tf, block_min_len) = unpack_bounds(word, source.len_bounds);
1600 let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
1601 let header = source.block_header(block_idx);
1602 let count = u16::from_le_bytes(header[..2].try_into().unwrap());
1603 if stream.len() > u32::MAX as usize {
1604 return Err(io::Error::new(
1605 io::ErrorKind::InvalidData,
1606 "posting list stream exceeds u32::MAX bytes during concatenation",
1607 ));
1608 }
1609 let new_offset = stream.len() as u32;
1610
1611 stream.write_u16::<LittleEndian>(count)?;
1613 stream.write_u32::<LittleEndian>(first_doc + doc_offset)?;
1614 stream.extend_from_slice(&header[6..]);
1615 stream.extend_from_slice(source.block_payload(block_idx));
1616
1617 let new_last = last_doc + doc_offset;
1618 write_l0(
1619 &mut l0_buf,
1620 first_doc + doc_offset,
1621 new_last,
1622 new_offset,
1623 bounds,
1624 );
1625 l0_count += 1;
1626
1627 if l0_count.is_multiple_of(L1_INTERVAL) {
1628 l1_docs.push(new_last);
1629 }
1630 }
1631 positions_before += source.total_positions;
1632 }
1633
1634 if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
1636 let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
1637 l1_docs.push(last_doc);
1638 }
1639 let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
1640 if let Some(ratios) = &mut ratios {
1641 append_ratio_groups(ratios, l0_count);
1642 }
1643
1644 Ok(Self {
1645 compact_headers: false,
1646 short_cursors: false,
1647 verify_content: true,
1648 content_error: None,
1649 stream: OwnedBytes::new(stream),
1650 l0_bytes: OwnedBytes::new(l0_buf),
1651 l0_count,
1652 l1_docs: l1_docs.into(),
1653 l1_bounds: l1_bounds.into(),
1654 ratios: ratios.map(OwnedBytes::new),
1655 impacts: if let Some(mut impacts) = impacts {
1656 let mut source = 0;
1657 let mut base = 0;
1658 impacts.append_groups_with(l0_count, |range| {
1659 while base + sources[source].0.num_blocks() <= range.start {
1660 base += sources[source].0.num_blocks();
1661 source += 1;
1662 }
1663 let list = &sources[source].0;
1664 let local = range.start - base;
1665 let record = if local.is_multiple_of(L1_INTERVAL)
1666 && range.end - base == (local + L1_INTERVAL).min(list.num_blocks())
1667 {
1668 list.impacts
1669 .as_ref()
1670 .and_then(|t| t.record(list.l0_count + local / L1_INTERVAL))
1671 } else {
1672 None
1673 };
1674 Ok(record)
1675 })?;
1676 impacts.finish()
1677 } else {
1678 None
1679 },
1680 doc_count: total_docs,
1681 max_tf,
1682 pos_cursors: all_cursors.then(|| OwnedBytes::new(cursors)),
1683 total_positions: if all_cursors { total_positions } else { 0 },
1684 len_bounds: true,
1685 min_len: if min_len == u32::MAX { 1 } else { min_len },
1686 })
1687 }
1688
1689 pub fn concatenate_streaming<W: Write>(
1704 sources: &[(&[u8], u32)], writer: &mut W,
1706 ) -> crate::Result<(u32, usize)> {
1707 let mut metas: Vec<Footer> = Vec::with_capacity(sources.len());
1708 let mut total_docs = 0u32;
1709 let mut merged_max_tf = 0u32;
1710 let mut merged_min_len = u32::MAX;
1711 let mut previous_last = None;
1712 let mut total_positions = 0u64;
1713
1714 for (source_index, (raw, offset)) in sources.iter().enumerate() {
1715 let invalid_source = |error: io::Error| {
1716 crate::Error::Corruption(format!(
1717 "posting list source {source_index} is invalid: {error}"
1718 ))
1719 };
1720 let footer = Self::validate_bytes(raw).map_err(invalid_source)?;
1723 validation::validate_remap(
1724 &raw[footer.l0_start()..footer.l0_end()],
1725 footer.l0_count,
1726 *offset,
1727 &mut previous_last,
1728 )
1729 .map_err(invalid_source)?;
1730 total_docs = total_docs
1731 .checked_add(footer.doc_count)
1732 .ok_or_else(|| crate::Error::Corruption("merged posting count overflow".into()))?;
1733 total_positions = total_positions
1734 .checked_add(footer.total_positions)
1735 .ok_or_else(|| {
1736 crate::Error::Corruption("merged position cursor overflow".into())
1737 })?;
1738 merged_max_tf = merged_max_tf.max(footer.max_tf);
1739 merged_min_len = merged_min_len.min(if footer.len_bounds { footer.min_len } else { 1 });
1740 metas.push(footer);
1741 }
1742
1743 if sources.len() == 1 && sources[0].1 == 0 {
1746 writer.write_all(sources[0].0)?;
1747 return Ok((metas[0].doc_count, sources[0].0.len()));
1748 }
1749
1750 let compact_output = !metas.is_empty() && metas.iter().all(|meta| meta.compact_headers);
1751 let short_cursors = compact_output && total_positions <= u32::MAX as u64;
1752 let mut out_headers = Vec::new();
1753 let all_cursors = metas.iter().all(|m| m.has_cursors);
1754 if !all_cursors && metas.iter().any(|m| m.has_cursors) {
1755 return Err(crate::Error::Corruption(
1756 "cannot concatenate posting lists with and without position cursors".into(),
1757 ));
1758 }
1759
1760 let mut out_impacts = if metas.iter().any(|m| m.impact_bounds) {
1763 let blocks = metas
1764 .iter()
1765 .try_fold(0usize, |n, m| n.checked_add(m.l0_count))
1766 .ok_or_else(|| crate::Error::Corruption("impact block count overflow".into()))?;
1767 Some(ImpactBuilder::with_groups(blocks)?)
1768 } else {
1769 None
1770 };
1771 let mut out_ratios = metas.iter().any(|m| m.ratio_bounds).then(Vec::new);
1772 let mut out_l0: Vec<u8> = Vec::new();
1773 let mut out_l1_docs: Vec<u32> = Vec::new();
1774 let mut out_cursors: Vec<u8> = Vec::new();
1775 let mut positions_before = 0u64;
1776 let mut out_l0_count = 0usize;
1777 let mut stream_written = 0u64;
1778 let mut patch_buf = [0u8; 8];
1779
1780 for (src_idx, meta) in metas.iter().enumerate() {
1781 let (raw, doc_offset) = &sources[src_idx];
1782 let l0_base = meta.l0_start(); let src_stream = &raw[..meta.stream_len];
1784 let cursors_base = meta.l1_bounds_end();
1785
1786 for i in 0..meta.l0_count {
1787 if let Some(impacts) = &mut out_impacts {
1788 let record = if meta.impact_bounds {
1789 ImpactTable::record_from_validated(
1790 &raw[meta.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1791 meta.impact_record_count(),
1792 i,
1793 )
1794 } else {
1795 &[]
1796 };
1797 impacts.append(record)?;
1798 }
1799 if let Some(ratios) = &mut out_ratios {
1800 let ratio = if meta.ratio_bounds {
1801 read_ratio(&raw[meta.cursors_end()..], i)
1802 } else {
1803 0.0
1804 };
1805 ratios.extend_from_slice(&ratio.to_le_bytes());
1806 }
1807 let (first_doc, last_doc, offset, word) = read_l0(&raw[l0_base..], i);
1809 let (block_max_tf, block_min_len) = unpack_bounds(word, meta.len_bounds);
1810 let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
1811 if all_cursors {
1812 let size = meta.cursor_size();
1813 let p = cursors_base + i * size;
1814 let cursor = if meta.short_cursors {
1815 u64::from(u32::from_le_bytes(raw[p..p + size].try_into().unwrap()))
1816 } else {
1817 u64::from_le_bytes(raw[p..p + size].try_into().unwrap())
1818 };
1819 if short_cursors {
1820 out_cursors
1821 .extend_from_slice(&((cursor + positions_before) as u32).to_le_bytes());
1822 } else {
1823 out_cursors.extend_from_slice(&(cursor + positions_before).to_le_bytes());
1824 }
1825 }
1826
1827 let blk_size =
1829 block_len_from_l0(&raw[l0_base..], meta.l0_count, meta.stream_len, i);
1830 let block = &src_stream[offset as usize..offset as usize + blk_size];
1831
1832 let new_last = last_doc + doc_offset;
1834 if stream_written > u32::MAX as u64 {
1835 return Err(io::Error::new(
1836 io::ErrorKind::InvalidData,
1837 "posting list stream exceeds u32::MAX bytes during streaming merge",
1838 )
1839 .into());
1840 }
1841 write_l0(
1842 &mut out_l0,
1843 first_doc + doc_offset,
1844 new_last,
1845 stream_written as u32,
1846 bounds,
1847 );
1848 out_l0_count += 1;
1849
1850 if out_l0_count.is_multiple_of(L1_INTERVAL) {
1852 out_l1_docs.push(new_last);
1853 }
1854
1855 let payload = if meta.compact_headers {
1857 let header = &raw[meta.l0_end() + i * 4..meta.l0_end() + i * 4 + 4];
1858 patch_buf[..2].copy_from_slice(&header[..2]);
1859 patch_buf[6..].copy_from_slice(&header[2..]);
1860 block
1861 } else {
1862 patch_buf.copy_from_slice(&block[..8]);
1863 &block[8..]
1864 };
1865 patch_buf[2..6].copy_from_slice(&(first_doc + doc_offset).to_le_bytes());
1866 if compact_output {
1867 out_headers.extend_from_slice(&patch_buf[..2]);
1868 out_headers.extend_from_slice(&patch_buf[6..]);
1869 } else {
1870 writer.write_all(&patch_buf)?;
1871 }
1872 writer.write_all(payload)?;
1873 stream_written += (if compact_output { 0 } else { 8 } + payload.len()) as u64;
1874 }
1875 positions_before += meta.total_positions;
1876 }
1877
1878 if !out_l0_count.is_multiple_of(L1_INTERVAL) && out_l0_count > 0 {
1880 let (_, last_doc, _, _) = read_l0(&out_l0, out_l0_count - 1);
1881 out_l1_docs.push(last_doc);
1882 }
1883
1884 let out_l1_bounds = group_bounds_from_l0(&out_l0, out_l0_count);
1886 writer.write_all(&out_l0)?;
1887 writer.write_all(&out_headers)?;
1888 for &doc in &out_l1_docs {
1889 writer.write_u32::<LittleEndian>(doc)?;
1890 }
1891 for &bounds in &out_l1_bounds {
1892 writer.write_u32::<LittleEndian>(bounds)?;
1893 }
1894 writer.write_all(&out_cursors)?;
1895 if let Some(ratios) = &mut out_ratios {
1896 append_ratio_groups(ratios, out_l0_count);
1897 writer.write_all(ratios)?;
1898 }
1899 let out_impacts = if let Some(mut impacts) = out_impacts {
1900 let mut source = 0;
1901 let mut base = 0;
1902 impacts.append_groups_with(out_l0_count, |range| {
1903 while base + metas[source].l0_count <= range.start {
1904 base += metas[source].l0_count;
1905 source += 1;
1906 }
1907 let meta = &metas[source];
1908 let local = range.start - base;
1909 let record = if meta.group_impact_bounds
1910 && local.is_multiple_of(L1_INTERVAL)
1911 && range.end - base == (local + L1_INTERVAL).min(meta.l0_count)
1912 {
1913 let raw = sources[source].0;
1914 Some(ImpactTable::record_from_validated(
1915 &raw[meta.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1916 meta.impact_record_count(),
1917 meta.l0_count + local / L1_INTERVAL,
1918 ))
1919 } else {
1920 None
1921 };
1922 Ok(record)
1923 })?;
1924 impacts.finish()
1925 } else {
1926 None
1927 };
1928 if let Some(impacts) = &out_impacts {
1929 writer.write_all(impacts.bytes())?;
1930 }
1931 Self::write_footer(
1932 writer,
1933 stream_written,
1934 out_l0_count,
1935 out_l1_docs.len(),
1936 total_docs,
1937 merged_max_tf,
1938 if all_cursors { total_positions } else { 0 },
1939 all_cursors,
1940 Some(if merged_min_len == u32::MAX {
1941 1
1942 } else {
1943 merged_min_len
1944 }),
1945 true,
1946 out_ratios.is_some(),
1947 out_impacts.is_some(),
1948 out_impacts.is_some(),
1949 if compact_output {
1950 FLAG_COMPACT_HEADERS
1951 } else {
1952 0
1953 } | if short_cursors && all_cursors {
1954 FLAG_SHORT_CURSORS
1955 } else {
1956 0
1957 },
1958 )?;
1959
1960 let l1_bytes_len = out_l1_docs.len() * L1_SIZE + out_l1_bounds.len() * 4;
1961 let total_bytes = stream_written as usize
1962 + out_l0.len()
1963 + out_headers.len()
1964 + l1_bytes_len
1965 + out_cursors.len()
1966 + out_ratios.as_ref().map_or(0, Vec::len)
1967 + out_impacts.as_ref().map_or(0, |t| t.bytes().len())
1968 + FOOTER_V2_SIZE;
1969 Ok((total_docs, total_bytes))
1970 }
1971
1972 pub fn decode_block_into(
1979 &self,
1980 block_idx: usize,
1981 doc_ids: &mut Vec<u32>,
1982 tfs: &mut Vec<u32>,
1983 ) -> bool {
1984 if let Some((offset, tf_start, count)) = self.decode_block_doc_ids_only(block_idx, doc_ids)
1985 {
1986 self.decode_block_tfs_deferred(offset, tf_start, count, tfs);
1987 true
1988 } else {
1989 false
1990 }
1991 }
1992
1993 pub fn decode_block_doc_ids_only(
2001 &self,
2002 block_idx: usize,
2003 doc_ids: &mut Vec<u32>,
2004 ) -> Option<(usize, usize, usize)> {
2005 match self.decode_block_doc_ids_checked(block_idx, doc_ids) {
2006 Ok(state) => state,
2007 Err(error) => {
2008 log::error!(
2009 "posting block {block_idx} of {} is corrupt; the cursor ends here: {error}",
2010 self.l0_count
2011 );
2012 None
2013 }
2014 }
2015 }
2016
2017 fn decode_block_doc_ids_checked(
2020 &self,
2021 block_idx: usize,
2022 doc_ids: &mut Vec<u32>,
2023 ) -> io::Result<Option<(usize, usize, usize)>> {
2024 if block_idx >= self.l0_count {
2025 return Ok(None);
2026 }
2027 let decoded = (|| {
2028 let (first, last, offset, _) = self.read_l0_entry(block_idx);
2029 let header = self.block_header(block_idx);
2030 let payload = self.block_payload(block_idx);
2031 let state = self.decode_block_doc_ids_unchecked(
2032 offset as usize,
2033 block_idx,
2034 header,
2035 payload,
2036 doc_ids,
2037 )?;
2038 if self.verify_content {
2039 let byte_gaps = (header[6] == 8).then(|| &payload[..doc_ids.len() - 1]);
2041 if header[6] >> PostingCodec::HEADER_SHIFT == PostingCodec::Simd4x as u8
2042 && doc_ids.len() == BLOCK_SIZE
2043 && !bitpacking4x::first_gap_is_zero(
2044 payload,
2045 header[6] & PostingCodec::WIDTH_MASK,
2046 )
2047 {
2048 return Err(io::Error::new(
2049 io::ErrorKind::InvalidData,
2050 "SIMD posting first gap must be zero",
2051 ));
2052 }
2053 let strict_gap_width = (header[6] >> PostingCodec::HEADER_SHIFT
2054 == PostingCodec::Simd4x as u8)
2055 .then_some(header[6] & PostingCodec::WIDTH_MASK);
2056 if !verify_block_docs(doc_ids, first, last, byte_gaps, strict_gap_width) {
2057 return Err(io::Error::new(
2058 io::ErrorKind::InvalidData,
2059 format!("decoded doc ids leave the directory range {first}..={last}"),
2060 ));
2061 }
2062 }
2063 crate::observe::search_work!(
2064 doc_blocks += 1,
2065 doc_values += doc_ids.len(),
2066 doc_payload_bytes += state.1 - if self.compact_headers { 0 } else { 8 }
2067 );
2068 Ok(Some(state))
2069 })();
2070 if decoded.is_err() {
2071 doc_ids.clear();
2072 if let Some(error) = &self.content_error {
2073 error.record(block_idx);
2075 }
2076 }
2077 decoded
2078 }
2079
2080 fn decode_block_doc_ids_unchecked(
2081 &self,
2082 pos: usize,
2083 block_idx: usize,
2084 header: [u8; 8],
2085 payload: &[u8],
2086 doc_ids: &mut Vec<u32>,
2087 ) -> io::Result<(usize, usize, usize)> {
2088 let invalid = |message: &str| io::Error::new(io::ErrorKind::InvalidData, message);
2089 let count = u16::from_le_bytes(header[..2].try_into().unwrap()) as usize;
2090 let first_doc = u32::from_le_bytes(header[2..6].try_into().unwrap());
2091 let (codec, doc_width) = PostingCodec::from_header_byte(header[6])?;
2092 let header_len = if self.compact_headers { 0 } else { 8 };
2093 let state = if self.compact_headers { block_idx } else { pos };
2094 if count == 0 || count > BLOCK_SIZE {
2095 return Err(invalid("invalid posting block count"));
2096 }
2097
2098 doc_ids.resize(count, 0);
2101 doc_ids[0] = first_doc;
2102
2103 if codec == PostingCodec::Simd4x {
2104 let values = if count == BLOCK_SIZE {
2105 count
2106 } else {
2107 count - 1
2108 };
2109 let bytes = bitpacking4x::encoded_len(values, doc_width);
2110 bitpacking4x::decode_docs(&payload[..bytes], doc_width, first_doc, doc_ids);
2111 return Ok((state, header_len + bytes, count));
2112 }
2113 let deltas_bytes = if count > 1 {
2114 match codec {
2115 PostingCodec::Rounded => {
2116 let rounded = simd::RoundedBitWidth::try_from_u8(doc_width)
2117 .ok_or_else(|| invalid("invalid rounded posting width"))?;
2118 let bytes = (count - 1) * rounded.bytes_per_value();
2119 simd::unpack_rounded_raw_delta_decode(
2120 &payload[..bytes],
2121 rounded,
2122 doc_ids,
2123 first_doc,
2124 count,
2125 );
2126 return Ok((state, header_len + bytes, count));
2127 }
2128 PostingCodec::Packed => {
2129 let bytes = packed_bytes(count - 1, doc_width);
2130 unpack_bits(&payload[..bytes], doc_width, &mut doc_ids[1..], count - 1);
2131 bytes
2132 }
2133 PostingCodec::Pfor => {
2134 let bytes = pfor_payload_len(payload, count - 1, doc_width)?;
2135 unpack_pfor(&payload[..bytes], doc_width, &mut doc_ids[1..], count - 1)?;
2136 bytes
2137 }
2138 PostingCodec::Simd4x => unreachable!("SIMD documents were decoded above"),
2139 }
2140 } else {
2141 0
2142 };
2143 for i in 1..count {
2144 doc_ids[i] = doc_ids[i].wrapping_add(doc_ids[i - 1]);
2145 }
2146
2147 let tfs_start = header_len + deltas_bytes;
2148 Ok((state, tfs_start, count))
2149 }
2150
2151 pub fn decode_block_tfs_deferred(
2155 &self,
2156 block_offset: usize,
2157 tf_start: usize,
2158 count: usize,
2159 tfs: &mut Vec<u32>,
2160 ) {
2161 tfs.resize(count, 0);
2162 self.decode_block_tfs_slice(block_offset, tf_start, tfs);
2163 }
2164
2165 fn decode_block_tfs_slice(&self, block_offset: usize, tf_start: usize, tfs: &mut [u32]) {
2167 let count = tfs.len();
2168 let (header, block_data) = if self.compact_headers {
2169 (
2170 self.block_header(block_offset),
2171 self.block_payload(block_offset),
2172 )
2173 } else {
2174 (
2175 self.stream[block_offset..block_offset + 8]
2176 .try_into()
2177 .unwrap(),
2178 &self.stream[block_offset..],
2179 )
2180 };
2181 let (codec, _) = PostingCodec::from_header_byte(header[6]).expect("admitted posting codec");
2182 let tf_bits = header[7];
2183 let payload = &block_data[tf_start..];
2184 crate::observe::search_work!(
2185 tf_blocks += 1,
2186 tf_values += count,
2187 tf_payload_bytes += match codec {
2188 PostingCodec::Pfor =>
2189 pfor_payload_len(payload, count, tf_bits).expect("admitted frequency payload"),
2190 _ => packed_bytes(count, tf_bits),
2191 }
2192 );
2193 match codec {
2194 PostingCodec::Simd4x => {
2195 bitpacking4x::decode(&payload[..packed_bytes(count, tf_bits)], tf_bits, tfs);
2196 }
2197 PostingCodec::Rounded => {
2198 let rounded = simd::RoundedBitWidth::try_from_u8(tf_bits)
2199 .expect("invalid rounded posting frequency width");
2200 simd::unpack_rounded(
2201 &payload[..count * rounded.bytes_per_value()],
2202 rounded,
2203 tfs,
2204 count,
2205 );
2206 }
2207 PostingCodec::Packed => {
2208 unpack_bits(
2209 &payload[..packed_bytes(count, tf_bits)],
2210 tf_bits,
2211 tfs,
2212 count,
2213 );
2214 }
2215 PostingCodec::Pfor => {
2216 let len = pfor_payload_len(payload, count, tf_bits)
2217 .expect("invalid patched posting frequency payload");
2218 unpack_pfor(&payload[..len], tf_bits, tfs, count)
2219 .expect("invalid patched posting frequency table");
2220 }
2221 }
2222 }
2223
2224 #[inline]
2226 fn block_len(&self, block_idx: usize) -> usize {
2227 block_len_from_l0(&self.l0_bytes, self.l0_count, self.stream.len(), block_idx)
2228 }
2229
2230 pub fn block_codec(&self, block_idx: usize) -> Option<PostingCodec> {
2232 if block_idx >= self.l0_count {
2233 return None;
2234 }
2235 PostingCodec::from_header_byte(self.block_header(block_idx)[6])
2236 .ok()
2237 .map(|(codec, _)| codec)
2238 }
2239
2240 #[inline]
2242 pub fn block_first_doc(&self, block_idx: usize) -> Option<DocId> {
2243 if block_idx >= self.l0_count {
2244 return None;
2245 }
2246 let (first_doc, _, _, _) = self.read_l0_entry(block_idx);
2247 Some(first_doc)
2248 }
2249
2250 #[inline]
2252 pub fn block_last_doc(&self, block_idx: usize) -> Option<DocId> {
2253 if block_idx >= self.l0_count {
2254 return None;
2255 }
2256 let (_, last_doc, _, _) = self.read_l0_entry(block_idx);
2257 Some(last_doc)
2258 }
2259
2260 pub fn seek_block(&self, target: DocId, from_block: usize) -> Option<usize> {
2268 if from_block >= self.l0_count {
2269 return None;
2270 }
2271
2272 if self
2273 .block_last_doc(from_block)
2274 .is_some_and(|last| last >= target)
2275 {
2276 return Some(from_block);
2277 }
2278 let from_l1 = from_block / L1_INTERVAL;
2279 let groups = self.l1_docs.words().get(from_l1..)?;
2280 let first = u32::from_le_bytes(*groups.first()?);
2281 let offset = if first >= target {
2282 0
2283 } else {
2284 let mut bound = 1usize;
2285 while bound < groups.len() && u32::from_le_bytes(groups[bound]) < target {
2286 bound = bound.saturating_mul(2);
2287 }
2288 let lo = bound / 2;
2289 let hi = bound.saturating_add(1).min(groups.len());
2290 lo + groups[lo..hi].partition_point(|&last| u32::from_le_bytes(last) < target)
2291 };
2292 let l1_idx = from_l1 + offset;
2293 if l1_idx >= self.l1_docs.len() {
2294 return None;
2295 }
2296
2297 let start = (l1_idx * L1_INTERVAL).max(from_block + 1);
2300 let end = ((l1_idx + 1) * L1_INTERVAL).min(self.l0_count);
2301 let entries = self.l0_bytes.as_slice().as_chunks::<L0_SIZE>().0;
2302 let within = entries[start..end].partition_point(|entry| {
2303 u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]) < target
2304 });
2305 let block_idx = start + within;
2306
2307 if block_idx < self.l0_count {
2308 Some(block_idx)
2309 } else {
2310 None
2311 }
2312 }
2313
2314 pub fn iterator(&self) -> BlockPostingIterator<'_> {
2316 BlockPostingIterator::new(self)
2317 }
2318
2319 pub fn into_iterator(self) -> BlockPostingIterator<'static> {
2321 BlockPostingIterator::owned(self)
2322 }
2323
2324 pub(crate) fn into_candidate_iterator(
2327 self,
2328 first_target: DocId,
2329 scratch: &mut PostingDecodeScratch,
2330 ) -> BlockPostingIterator<'static> {
2331 let first_block = self.seek_block(first_target, 0);
2332 let PostingDecodeScratch {
2333 doc_ids,
2334 term_freqs,
2335 } = std::mem::take(scratch);
2336 let mut block_tfs = term_freqs.unwrap_or_default();
2337 block_tfs.take();
2338 let mut iterator = BlockPostingIterator {
2339 block_list: std::borrow::Cow::Owned(self),
2340 current_block: first_block.unwrap_or(0),
2341 block_doc_ids: doc_ids,
2342 block_tfs,
2343 tf_state: (0, 0, 0),
2344 position_in_block: 0,
2345 position_offsets: None,
2346 position_offsets_ready: false,
2347 block_position_cursor: 0,
2348 exhausted: first_block.is_none(),
2349 };
2350 if let Some(block) = first_block {
2351 iterator.load_block(block);
2352 }
2353 iterator
2354 }
2355}
2356
2357#[derive(Default)]
2358pub(crate) struct PostingDecodeScratch {
2359 doc_ids: Vec<u32>,
2360 term_freqs: Option<std::sync::OnceLock<[u32; BLOCK_SIZE]>>,
2361}
2362
2363pub struct BlockPostingIterator<'a> {
2370 block_list: std::borrow::Cow<'a, BlockPostingList>,
2371 current_block: usize,
2372 block_doc_ids: Vec<u32>,
2373 block_tfs: std::sync::OnceLock<[u32; BLOCK_SIZE]>,
2378 tf_state: (usize, usize, usize),
2379 position_in_block: usize,
2380 position_offsets: Option<Box<[u64; BLOCK_SIZE + 1]>>,
2383 position_offsets_ready: bool,
2384 block_position_cursor: u64,
2385 exhausted: bool,
2386}
2387
2388impl<'a> BlockPostingIterator<'a> {
2389 pub(crate) fn recycle(self, scratch: &mut PostingDecodeScratch) {
2390 *scratch = PostingDecodeScratch {
2391 doc_ids: self.block_doc_ids,
2392 term_freqs: Some(self.block_tfs),
2393 };
2394 }
2395
2396 fn new(block_list: &'a BlockPostingList) -> Self {
2397 let exhausted = block_list.l0_count == 0;
2398 let mut iter = Self {
2399 block_list: std::borrow::Cow::Borrowed(block_list),
2400 current_block: 0,
2401 block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
2402 block_tfs: std::sync::OnceLock::new(),
2403 tf_state: (0, 0, 0),
2404 position_in_block: 0,
2405 position_offsets: None,
2406 position_offsets_ready: false,
2407 block_position_cursor: 0,
2408 exhausted,
2409 };
2410 if !iter.exhausted {
2411 iter.load_block(0);
2412 }
2413 iter
2414 }
2415
2416 fn owned(block_list: BlockPostingList) -> BlockPostingIterator<'static> {
2417 let exhausted = block_list.l0_count == 0;
2418 let mut iter = BlockPostingIterator {
2419 block_list: std::borrow::Cow::Owned(block_list),
2420 current_block: 0,
2421 block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
2422 block_tfs: std::sync::OnceLock::new(),
2423 tf_state: (0, 0, 0),
2424 position_in_block: 0,
2425 position_offsets: None,
2426 position_offsets_ready: false,
2427 block_position_cursor: 0,
2428 exhausted,
2429 };
2430 if !iter.exhausted {
2431 iter.load_block(0);
2432 }
2433 iter
2434 }
2435
2436 fn load_block(&mut self, block_idx: usize) {
2437 if block_idx >= self.block_list.l0_count {
2438 self.exhausted = true;
2439 return;
2440 }
2441
2442 self.current_block = block_idx;
2443 self.position_in_block = 0;
2444 self.position_offsets_ready = false;
2445 self.block_position_cursor = self.block_list.pos_cursor(block_idx).unwrap_or(0);
2446
2447 self.block_tfs.take();
2448 match self
2449 .block_list
2450 .decode_block_doc_ids_checked(block_idx, &mut self.block_doc_ids)
2451 {
2452 Ok(Some(state)) => self.tf_state = state,
2453 Ok(None) => unreachable!("block index was range-checked above"),
2454 Err(error) => {
2455 log::error!(
2458 "posting block {block_idx} of {} is corrupt; the cursor ends here: {error}",
2459 self.block_list.l0_count
2460 );
2461 self.block_doc_ids.clear();
2462 self.tf_state = (0, 0, 0);
2463 self.exhausted = true;
2464 }
2465 }
2466 }
2467
2468 fn frequencies(&self) -> &[u32] {
2469 let (offset, start, count) = self.tf_state;
2470 if count == 0 {
2471 return &[];
2472 }
2473 &self.block_tfs.get_or_init(|| {
2474 let mut tfs = [0; BLOCK_SIZE];
2475 self.block_list
2476 .decode_block_tfs_slice(offset, start, &mut tfs[..count]);
2477 tfs
2478 })[..count]
2479 }
2480
2481 #[inline]
2486 pub fn position_cursor(&self) -> u64 {
2487 if self.position_offsets_ready {
2488 self.position_offsets.as_ref().unwrap()[self.position_in_block]
2489 } else {
2490 self.block_position_cursor
2491 + self.frequencies()[..self.position_in_block]
2492 .iter()
2493 .map(|&tf| u64::from(tf))
2494 .sum::<u64>()
2495 }
2496 }
2497
2498 pub(crate) fn position_cursor_mut(&mut self) -> u64 {
2499 if !self.position_offsets_ready {
2500 self.initialize_position_offsets();
2501 }
2502 self.position_offsets.as_ref().unwrap()[self.position_in_block]
2503 }
2504
2505 #[inline]
2508 pub(crate) fn position_range(&mut self) -> (u64, u32) {
2509 let cursor = self.position_cursor_mut();
2510 (cursor, self.frequencies()[self.position_in_block])
2511 }
2512
2513 fn initialize_position_offsets(&mut self) {
2514 self.frequencies();
2515 let offsets = self
2516 .position_offsets
2517 .get_or_insert_with(|| Box::new([0; BLOCK_SIZE + 1]));
2518 let mut total = self.block_position_cursor;
2519 offsets[0] = total;
2520 if let Some(frequencies) = self.block_tfs.get() {
2521 for (offset, &tf) in offsets[1..].iter_mut().zip(&frequencies[..self.tf_state.2]) {
2522 total += u64::from(tf);
2523 *offset = total;
2524 }
2525 }
2526 self.position_offsets_ready = true;
2527 }
2528
2529 pub fn doc(&self) -> DocId {
2530 if self.exhausted {
2531 TERMINATED
2532 } else if self.position_in_block < self.block_doc_ids.len() {
2533 self.block_doc_ids[self.position_in_block]
2534 } else {
2535 TERMINATED
2536 }
2537 }
2538
2539 pub fn term_freq(&self) -> u32 {
2540 if self.exhausted || self.position_in_block >= self.block_doc_ids.len() {
2541 0
2542 } else {
2543 self.frequencies()[self.position_in_block]
2544 }
2545 }
2546
2547 pub fn advance(&mut self) -> DocId {
2548 if self.exhausted {
2549 return TERMINATED;
2550 }
2551
2552 self.position_in_block += 1;
2553 if self.position_in_block >= self.block_doc_ids.len() {
2554 self.load_block(self.current_block + 1);
2555 }
2556 self.doc()
2557 }
2558
2559 #[inline]
2560 pub fn seek(&mut self, target: DocId) -> DocId {
2561 crate::observe::search_work!(posting_seeks += 1);
2562 if self.exhausted {
2563 return TERMINATED;
2564 }
2565 let current = self.block_doc_ids[self.position_in_block];
2566 if target <= current {
2567 return current;
2568 }
2569 if target > *self.block_doc_ids.last().unwrap() {
2570 return self.seek_later_block(target);
2571 }
2572 let next = self.position_in_block + 1;
2575 self.position_in_block = if self.block_doc_ids[next] >= target {
2576 next
2577 } else {
2578 let remaining = &self.block_doc_ids[next + 1..];
2579 let mut bound = 1;
2580 while bound < remaining.len() && remaining[bound] < target {
2581 bound *= 2;
2582 }
2583 let lo = bound / 2;
2584 let hi = (bound + 1).min(remaining.len());
2585 next + 1 + lo + remaining[lo..hi].partition_point(|&doc| doc < target)
2586 };
2587 self.block_doc_ids[self.position_in_block]
2588 }
2589
2590 pub(crate) fn find_in_block(
2593 &mut self,
2594 mut find: impl FnMut(&[u32], &[u32]) -> Option<usize>,
2595 ) -> Option<DocId> {
2596 if self.exhausted {
2597 return Some(TERMINATED);
2598 }
2599 let start = self.position_in_block;
2600 if let Some(offset) = find(&self.block_doc_ids[start..], &self.frequencies()[start..]) {
2601 self.position_in_block += offset;
2602 return Some(self.block_doc_ids[self.position_in_block]);
2603 }
2604 self.load_block(self.current_block + 1);
2605 None
2606 }
2607
2608 pub(crate) fn seek_physical(&mut self, target: DocId) -> DocId {
2611 if !self.exhausted && target >= self.doc() {
2612 return self.seek(target);
2613 }
2614 let Some(block) = self.block_list.seek_block(target, 0) else {
2615 self.exhausted = true;
2616 return TERMINATED;
2617 };
2618 self.exhausted = false;
2619 if block != self.current_block || self.block_doc_ids.is_empty() {
2620 self.load_block(block);
2621 }
2622 if self.exhausted {
2623 return TERMINATED;
2624 }
2625 self.position_in_block = self.block_doc_ids.partition_point(|&doc| doc < target);
2626 self.doc()
2627 }
2628
2629 fn seek_later_block(&mut self, target: DocId) -> DocId {
2630 let Some(block_idx) = self.block_list.seek_block(target, self.current_block + 1) else {
2631 self.exhausted = true;
2632 return TERMINATED;
2633 };
2634 self.load_block(block_idx);
2635 if self.exhausted {
2636 return TERMINATED;
2637 }
2638 self.position_in_block = self.block_doc_ids.partition_point(|&doc| doc < target);
2640 self.block_doc_ids[self.position_in_block]
2641 }
2642
2643 pub(crate) fn fill_doc_batch(&mut self, docs: &mut [DocId]) -> usize {
2645 self.fill_batch::<false>(docs, &mut [])
2646 }
2647
2648 pub(crate) fn fill_scored_doc_batch(&mut self, docs: &mut [DocId], tfs: &mut [u32]) -> usize {
2649 assert!(tfs.len() >= docs.len());
2650 self.fill_batch::<true>(docs, tfs)
2651 }
2652
2653 fn fill_batch<const WITH_FREQUENCIES: bool>(
2654 &mut self,
2655 docs: &mut [DocId],
2656 tfs: &mut [u32],
2657 ) -> usize {
2658 assert!(docs.len() <= BLOCK_SIZE);
2659 let mut count = 0;
2660 while count < docs.len() && !self.exhausted {
2661 let remaining = &self.block_doc_ids[self.position_in_block..];
2662 let take = remaining.len().min(docs.len() - count);
2663 docs[count..count + take].copy_from_slice(&remaining[..take]);
2664 if WITH_FREQUENCIES {
2665 tfs[count..count + take].copy_from_slice(
2666 &self.frequencies()[self.position_in_block..self.position_in_block + take],
2667 );
2668 }
2669 count += take;
2670 self.position_in_block += take;
2671 if self.position_in_block == self.block_doc_ids.len() {
2672 self.load_block(self.current_block + 1);
2673 }
2674 }
2675 count
2676 }
2677
2678 pub(crate) fn retain_doc_batch(&mut self, docs: &mut [DocId]) -> usize {
2680 self.retain_batch::<false>(docs, &mut [])
2681 }
2682
2683 pub(crate) fn retain_scored_doc_batch(&mut self, docs: &mut [DocId], tfs: &mut [u32]) -> usize {
2684 assert!(tfs.len() >= docs.len());
2685 self.retain_batch::<true>(docs, tfs)
2686 }
2687
2688 fn retain_batch<const WITH_FREQUENCIES: bool>(
2689 &mut self,
2690 docs: &mut [DocId],
2691 tfs: &mut [u32],
2692 ) -> usize {
2693 assert!(docs.len() <= BLOCK_SIZE);
2694 let mut input = 0;
2695 let mut kept = 0;
2696 while input < docs.len() {
2697 if self.seek(docs[input]) == TERMINATED {
2698 break;
2699 }
2700 let last = *self.block_doc_ids.last().unwrap();
2701 while input < docs.len() && docs[input] <= last {
2702 let doc = docs[input];
2703 self.position_in_block = simd::find_first_ge_block_from(
2704 &self.block_doc_ids,
2705 self.position_in_block,
2706 doc,
2707 );
2708 if self.block_doc_ids[self.position_in_block] == doc {
2709 docs[kept] = doc;
2710 if WITH_FREQUENCIES {
2711 tfs[kept] = self.frequencies()[self.position_in_block];
2712 }
2713 kept += 1;
2714 }
2715 input += 1;
2716 }
2717 }
2718 kept
2719 }
2720
2721 pub(crate) fn fill_doc_window(&mut self, base: DocId, bits: &mut [u64]) {
2724 let span = u32::try_from(bits.len()).unwrap().checked_mul(64).unwrap();
2725 let end = base.saturating_add(span);
2726 bits.fill(0);
2727 self.seek(base);
2728 let list = self.block_list.as_ref();
2729 let dense = list.doc_count() >= 16
2730 && list
2731 .block_first_doc(0)
2732 .zip(list.block_last_doc(list.num_blocks().saturating_sub(1)))
2733 .is_some_and(|(first, last)| {
2734 u64::from(last)
2735 .checked_sub(u64::from(first))
2736 .is_some_and(|span| span < u64::from(list.doc_count()) * 2)
2737 });
2738 if dense {
2739 self.fill_doc_words::<true>(base, end, bits);
2740 } else {
2741 self.fill_doc_words::<false>(base, end, bits);
2742 }
2743 }
2744
2745 fn fill_doc_words<const GROUPED: bool>(&mut self, base: DocId, end: DocId, bits: &mut [u64]) {
2746 self.visit_until::<false>(end, |docs, _| {
2747 if !GROUPED {
2749 for &doc in docs {
2750 let offset = (doc - base) as usize;
2751 bits[offset / 64] |= 1u64 << (offset % 64);
2752 }
2753 return true;
2754 }
2755 let mut index = 0;
2756 while index < docs.len() {
2757 let offset = (docs[index] - base) as usize;
2758 let word_index = offset / 64;
2759 let mut mask = 1u64 << (offset % 64);
2760 index += 1;
2761 while index < docs.len() {
2762 let offset = (docs[index] - base) as usize;
2763 if offset / 64 != word_index {
2764 break;
2765 }
2766 mask |= 1u64 << (offset % 64);
2767 index += 1;
2768 }
2769 bits[word_index] |= mask;
2770 }
2771 true
2772 });
2773 }
2774
2775 pub(crate) fn visit_postings_until(
2780 &mut self,
2781 end: DocId,
2782 mut visit: impl FnMut(&[u32], &[u32]) -> bool,
2783 ) {
2784 self.visit_until::<true>(end, |docs, tfs| visit(docs, tfs.unwrap()));
2785 }
2786
2787 pub(crate) fn visit_doc_ids_until(
2789 &mut self,
2790 end: DocId,
2791 mut visit: impl FnMut(&[u32]) -> bool,
2792 ) {
2793 self.visit_until::<false>(end, |docs, _| visit(docs));
2794 }
2795
2796 fn visit_until<const WITH_FREQUENCIES: bool>(
2797 &mut self,
2798 end: DocId,
2799 mut visit: impl FnMut(&[u32], Option<&[u32]>) -> bool,
2800 ) {
2801 while self.doc() < end {
2802 let start = self.position_in_block;
2803 let count = self.block_doc_ids[start..].partition_point(|&doc| doc < end);
2804 let tfs = WITH_FREQUENCIES.then(|| &self.frequencies()[start..start + count]);
2805 if !visit(&self.block_doc_ids[start..start + count], tfs) {
2806 return;
2807 }
2808 self.position_in_block += count;
2809 if self.position_in_block == self.block_doc_ids.len() {
2810 self.load_block(self.current_block + 1);
2811 }
2812 }
2813 }
2814
2815 pub fn skip_to_next_block(&mut self) -> DocId {
2819 if self.exhausted {
2820 return TERMINATED;
2821 }
2822 self.load_block(self.current_block + 1);
2823 self.doc()
2824 }
2825
2826 #[inline]
2828 pub fn current_block_idx(&self) -> usize {
2829 self.current_block
2830 }
2831
2832 #[inline]
2834 pub fn num_blocks(&self) -> usize {
2835 self.block_list.l0_count
2836 }
2837
2838 pub(crate) fn current_block_metadata(&self) -> Option<(&BlockPostingList, usize)> {
2840 (!self.exhausted).then_some((&self.block_list, self.current_block))
2841 }
2842
2843 #[inline]
2845 pub fn current_block_max_tf(&self) -> u32 {
2846 if self.exhausted || self.current_block >= self.block_list.l0_count {
2847 0
2848 } else {
2849 self.block_list
2850 .block_max_tf(self.current_block)
2851 .unwrap_or(0)
2852 }
2853 }
2854}
2855
2856pub(crate) struct PostingIntersection {
2860 seek_driven: bool,
2861 left_is_rare: bool,
2862 blocks: (usize, usize),
2863 pairs: [(u8, u8); BLOCK_SIZE],
2864 next: usize,
2865 count: usize,
2866 ends: (usize, usize),
2867}
2868
2869impl Default for PostingIntersection {
2870 fn default() -> Self {
2871 Self {
2872 seek_driven: false,
2873 left_is_rare: true,
2874 blocks: (usize::MAX, usize::MAX),
2875 pairs: [(0, 0); BLOCK_SIZE],
2876 next: 0,
2877 count: 0,
2878 ends: (0, 0),
2879 }
2880 }
2881}
2882
2883impl PostingIntersection {
2884 pub(crate) fn with_costs(left: u32, right: u32) -> Self {
2885 Self {
2886 seek_driven: left.min(right).saturating_mul(4) < left.max(right),
2887 left_is_rare: left <= right,
2888 ..Self::default()
2889 }
2890 }
2891
2892 pub(crate) fn reset(&mut self) {
2894 self.blocks = (usize::MAX, usize::MAX);
2895 }
2896
2897 pub(crate) fn intersect_block(
2899 &mut self,
2900 left: &mut BlockPostingIterator<'_>,
2901 right: &mut BlockPostingIterator<'_>,
2902 ) -> Option<DocId> {
2903 if left.exhausted || right.exhausted {
2904 return Some(TERMINATED);
2905 }
2906 if left.doc() == right.doc() {
2907 return Some(left.doc());
2908 }
2909 if self.seek_driven {
2910 fn align(
2911 lead: &mut BlockPostingIterator<'_>,
2912 other: &mut BlockPostingIterator<'_>,
2913 ) -> Option<DocId> {
2914 let candidate = lead.doc();
2915 let next = other.seek(candidate);
2916 if next == candidate {
2917 return Some(candidate);
2918 }
2919 lead.seek(next);
2920 None
2921 }
2922 return if self.left_is_rare {
2923 align(left, right)
2924 } else {
2925 align(right, left)
2926 };
2927 }
2928 if *left.block_doc_ids.last().unwrap() < right.doc() {
2929 left.seek_later_block(right.doc());
2930 return None;
2931 }
2932 if *right.block_doc_ids.last().unwrap() < left.doc() {
2933 right.seek_later_block(left.doc());
2934 return None;
2935 }
2936 let blocks = (left.current_block, right.current_block);
2937 if self.blocks != blocks {
2938 let mut a = left.position_in_block;
2939 let mut b = right.position_in_block;
2940 self.count = simd::intersect_posting_blocks(
2941 &left.block_doc_ids,
2942 &mut a,
2943 &right.block_doc_ids,
2944 &mut b,
2945 &mut self.pairs,
2946 );
2947 self.blocks = blocks;
2948 self.ends = (a, b);
2949 self.next = 0;
2950 }
2951 while self.next < self.count {
2952 let (a, b) = self.pairs[self.next];
2953 self.next += 1;
2954 let (a, b) = (usize::from(a), usize::from(b));
2955 if a >= left.position_in_block && b >= right.position_in_block {
2956 left.position_in_block = a;
2957 right.position_in_block = b;
2958 return Some(left.doc());
2959 }
2960 }
2961 left.position_in_block = left.position_in_block.max(self.ends.0);
2962 right.position_in_block = right.position_in_block.max(self.ends.1);
2963 if left.position_in_block == left.block_doc_ids.len() {
2964 left.load_block(left.current_block + 1);
2965 }
2966 if right.position_in_block == right.block_doc_ids.len() {
2967 right.load_block(right.current_block + 1);
2968 }
2969 None
2970 }
2971}
2972
2973#[cfg(test)]
2974mod compact_layout_tests {
2975 use super::*;
2976 #[test]
2977 fn batched_intersection_preserves_monotone_seeks_frequencies_and_position_offsets() {
2978 for codec in [
2979 PostingCodec::Rounded,
2980 PostingCodec::Packed,
2981 PostingCodec::Pfor,
2982 PostingCodec::Simd4x,
2983 ] {
2984 let make = |divisor| {
2985 let mut postings = PostingList::new();
2986 for doc in 0..3001 {
2987 if doc % divisor != 1 {
2988 postings.push(doc, 1 + doc % 7);
2989 }
2990 }
2991 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
2992 .unwrap()
2993 };
2994 let a = make(3);
2995 let b = make(5);
2996 for (seek_driven, left_is_rare) in [(false, true), (true, true), (true, false)] {
2997 let mut left = a.iterator();
2998 let mut right = b.iterator();
2999 let mut reference_left = a.iterator();
3000 let mut reference_right = b.iterator();
3001 let mut intersection = PostingIntersection {
3002 seek_driven,
3003 left_is_rare,
3004 ..Default::default()
3005 };
3006 let mut target = 0;
3007 loop {
3008 left.seek(target);
3009 right.seek(target);
3010 let doc = loop {
3011 if let Some(doc) = intersection.intersect_block(&mut left, &mut right) {
3012 break doc;
3013 }
3014 };
3015 let expected = (target..3001)
3016 .find(|doc| doc % 3 != 1 && doc % 5 != 1)
3017 .unwrap_or(TERMINATED);
3018 assert_eq!(doc, expected);
3019 assert_eq!(
3020 intersection.intersect_block(&mut left, &mut right),
3021 Some(doc)
3022 );
3023 if doc == TERMINATED {
3024 break;
3025 }
3026 reference_left.seek(doc);
3027 reference_right.seek(doc);
3028 assert_eq!(left.term_freq(), reference_left.term_freq());
3029 assert_eq!(right.term_freq(), reference_right.term_freq());
3030 assert_eq!(left.position_cursor(), reference_left.position_cursor());
3031 assert_eq!(right.position_cursor(), reference_right.position_cursor());
3032 target = doc + if doc % 7 == 0 { 19 } else { 1 };
3033 }
3034 for target in [0, 200, 129, 3, 2048, 3000, 2] {
3035 intersection.reset();
3036 left.seek_physical(target);
3037 right.seek_physical(target);
3038 let doc = loop {
3039 if let Some(doc) = intersection.intersect_block(&mut left, &mut right) {
3040 break doc;
3041 }
3042 };
3043 let expected = (target..3001)
3044 .find(|doc| doc % 3 != 1 && doc % 5 != 1)
3045 .unwrap_or(TERMINATED);
3046 assert_eq!(doc, expected);
3047 reference_left.seek_physical(doc);
3048 reference_right.seek_physical(doc);
3049 assert_eq!(left.position_cursor(), reference_left.position_cursor());
3050 assert_eq!(right.position_cursor(), reference_right.position_cursor());
3051 }
3052 }
3053 }
3054 }
3055
3056 #[test]
3057 fn compact_postings_preserve_payload_scores_and_copy_merges() {
3058 for codec in [
3059 PostingCodec::Rounded,
3060 PostingCodec::Packed,
3061 PostingCodec::Simd4x,
3062 PostingCodec::Pfor,
3063 ] {
3064 for count in [1, 2, 127, 128, 129, 1025] {
3065 let mut postings = PostingList::new();
3066 for doc in 0..count {
3067 postings.push(doc * 3, doc % 13);
3068 }
3069 let list = BlockPostingList::from_posting_list_with_ratio_bounds(
3070 &postings,
3071 true,
3072 Some(&|doc| doc % 100 + 1),
3073 codec,
3074 )
3075 .unwrap();
3076 let mut old = Vec::new();
3077 list.serialize(&mut old).unwrap();
3078 let mut bytes = Vec::new();
3079 list.serialize_compact(&mut bytes).unwrap();
3080 let compact = BlockPostingList::deserialize(&bytes).unwrap();
3081 assert_eq!(compact.compact_headers, codec != PostingCodec::Pfor);
3082 if codec != PostingCodec::Pfor {
3083 assert_eq!(old.len() - bytes.len(), list.num_blocks() * 8);
3084 }
3085 let mut a = Vec::new();
3086 let mut b = Vec::new();
3087 for i in 0..list.num_blocks() {
3088 assert_eq!(list.block_payload(i), compact.block_payload(i));
3089 assert_eq!(list.pos_cursor(i), compact.pos_cursor(i));
3090 assert!(compact.decode_block_into(i, &mut a, &mut b));
3091 assert_eq!(
3092 a,
3093 postings.postings
3094 [i * BLOCK_SIZE..(i * BLOCK_SIZE + BLOCK_SIZE).min(count as usize)]
3095 .iter()
3096 .map(|p| p.doc_id)
3097 .collect::<Vec<_>>()
3098 );
3099 assert_eq!(b, a.iter().map(|doc| (doc / 3) % 13).collect::<Vec<_>>());
3100 }
3101 let mut repeated = Vec::new();
3102 compact.serialize(&mut repeated).unwrap();
3103 assert_eq!(bytes, repeated);
3104 for second in [&old, &bytes] {
3105 let mut merged = Vec::new();
3106 let (docs, len) = BlockPostingList::concatenate_streaming(
3107 &[(&bytes, 0), (second, count * 3)],
3108 &mut merged,
3109 )
3110 .unwrap();
3111 assert_eq!(len, merged.len());
3112 assert_eq!(docs, count * 2);
3113 let merged = BlockPostingList::deserialize(&merged).unwrap();
3114 for block in 0..merged.num_blocks() {
3115 assert_eq!(
3116 merged.block_payload(block),
3117 list.block_payload(block % list.num_blocks())
3118 );
3119 assert!(merged.decode_block_into(block, &mut a, &mut b));
3120 }
3121 }
3122 }
3123 }
3124 }
3125
3126 #[test]
3127 fn compact_posting_metadata_rejects_bad_descriptors_and_content_checks_remain_observable() {
3128 let mut postings = PostingList::new();
3129 for doc in 0..257 {
3130 postings.push(doc * 2, 1);
3131 }
3132 let list = BlockPostingList::from_posting_list_with_options(
3133 &postings,
3134 true,
3135 None,
3136 PostingCodec::Rounded,
3137 )
3138 .unwrap();
3139 let mut bytes = Vec::new();
3140 list.serialize_compact(&mut bytes).unwrap();
3141 let footer = Footer::parse(&bytes).unwrap();
3142 for (at, value) in [
3143 (footer.l0_end(), 0),
3144 (footer.l0_end() + 1, 1),
3145 (footer.l0_end() + 2, 33),
3146 (footer.l0_end() + 3, 33),
3147 (footer.l1_bounds_end(), 1),
3148 ] {
3149 let mut corrupt = bytes.clone();
3150 corrupt[at] = value;
3151 assert!(
3152 BlockPostingList::deserialize(&corrupt).is_err(),
3153 "byte {at}"
3154 );
3155 }
3156 let mut corrupt = bytes.clone();
3157 corrupt[0] = 0;
3158 let compact = BlockPostingList::deserialize(&corrupt).unwrap();
3159 let mut docs = Vec::new();
3160 assert!(compact.decode_block_doc_ids_checked(0, &mut docs).is_err());
3161 assert!(docs.is_empty());
3162 }
3163}
3164
3165#[cfg(test)]
3166mod tests {
3167 use super::*;
3168
3169 #[test]
3170 fn block_decoding_overwrites_stale_values_across_lengths_and_codecs() {
3171 let mut docs = vec![u32::MAX; BLOCK_SIZE * 2];
3172 let mut tfs = docs.clone();
3173 for codec in [
3174 PostingCodec::Rounded,
3175 PostingCodec::Packed,
3176 PostingCodec::Pfor,
3177 PostingCodec::Simd4x,
3178 ] {
3179 for count in [128, 1, 127, 128, 17, 256, 257] {
3180 for freq in [0, 1, 255, 65536] {
3181 let mut postings = PostingList::new();
3182 for i in 0..count {
3183 postings.push(i * 3 + 7, freq);
3184 }
3185 let list = BlockPostingList::from_posting_list_with_options(
3186 &postings, true, None, codec,
3187 )
3188 .unwrap();
3189 for compact in [false, true] {
3190 let mut bytes = Vec::new();
3191 if compact {
3192 list.serialize_compact(&mut bytes).unwrap();
3193 } else {
3194 list.serialize(&mut bytes).unwrap();
3195 }
3196 let list = BlockPostingList::deserialize(&bytes).unwrap();
3197 for block in (0..list.num_blocks()).rev().chain(0..list.num_blocks()) {
3198 docs.fill(u32::MAX);
3199 tfs.fill(u32::MAX);
3200 assert!(list.decode_block_into(block, &mut docs, &mut tfs));
3201 let expected: Vec<_> = postings.postings[block * BLOCK_SIZE
3202 ..postings.postings.len().min((block + 1) * BLOCK_SIZE)]
3203 .iter()
3204 .map(|p| p.doc_id)
3205 .collect();
3206 assert_eq!(docs, expected);
3207 assert_eq!(tfs, vec![freq; expected.len()]);
3208 }
3209 }
3210 }
3211 }
3212 }
3213 }
3214
3215 #[test]
3216 fn compact_membership_batches_preserve_resume_frequencies_and_position_prefixes() {
3217 for codec in [
3218 PostingCodec::Rounded,
3219 PostingCodec::Packed,
3220 PostingCodec::Pfor,
3221 PostingCodec::Simd4x,
3222 ] {
3223 let mut postings = PostingList::new();
3224 let mut expected = Vec::new();
3225 let mut prefix = 0u64;
3226 for i in 0..701u32 {
3227 let tf = i % 19 + 1;
3228 let doc = i * 13 + 5;
3229 postings.push(doc, tf);
3230 expected.push((doc, tf, prefix));
3231 prefix += u64::from(tf);
3232 }
3233 let list =
3234 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3235 .unwrap();
3236 let bytes = serialize_bpl(&list);
3237 let mut cursor = list.iterator();
3238 let mut docs = [0; BLOCK_SIZE];
3239 let mut consumed = 0;
3240 while cursor.doc() != TERMINATED {
3241 let count = cursor.fill_doc_batch(&mut docs);
3242 assert_eq!(
3243 &docs[..count],
3244 &expected[consumed..consumed + count]
3245 .iter()
3246 .map(|e| e.0)
3247 .collect::<Vec<_>>()
3248 );
3249 consumed += count;
3250 if consumed < expected.len() {
3251 assert!(cursor.block_tfs.get().is_none(), "copy initialized TFs");
3252 assert_eq!(cursor.doc(), expected[consumed].0);
3253 assert_eq!(cursor.term_freq(), expected[consumed].1);
3254 assert_eq!(cursor.position_cursor_mut(), expected[consumed].2);
3255 }
3256 }
3257 assert_eq!(consumed, expected.len());
3258 assert_eq!(cursor.fill_doc_batch(&mut docs), 0);
3259 let mut cursor = list.iterator();
3260 for start in (0..10_000u32).step_by(BLOCK_SIZE) {
3261 let mut candidates: Vec<_> = (start..start + BLOCK_SIZE as u32).collect();
3262 let wanted: Vec<_> = candidates
3263 .iter()
3264 .copied()
3265 .filter(|doc| expected.iter().any(|e| e.0 == *doc))
3266 .collect();
3267 let kept = cursor.retain_doc_batch(&mut candidates);
3268 assert_eq!(&candidates[..kept], wanted, "{codec:?} start={start}");
3269 if cursor.doc() != TERMINATED {
3270 let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3271 assert_eq!(cursor.term_freq(), entry.1);
3272 assert_eq!(cursor.position_cursor_mut(), entry.2);
3273 }
3274 }
3275 assert_eq!(cursor.doc(), TERMINATED);
3276 assert_eq!(serialize_bpl(&list), bytes);
3277 }
3278 }
3279
3280 #[test]
3281 fn membership_words_preserve_unaligned_dense_sparse_windows_and_resume() {
3282 for codec in [
3283 PostingCodec::Rounded,
3284 PostingCodec::Packed,
3285 PostingCodec::Pfor,
3286 PostingCodec::Simd4x,
3287 ] {
3288 for stride in [1, 2, 3, 67, 129] {
3289 let docs: Vec<u32> = (0..10_000).step_by(stride).collect();
3290 let mut postings = PostingList::new();
3291 for &doc in &docs {
3292 postings.push(doc, 1 + doc % 7);
3293 }
3294 let list =
3295 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3296 .unwrap();
3297 for start in [0, 1, 17, 63, 64, 127, 511] {
3298 let mut cursor = list.iterator();
3299 for base in [start, start + 4096, start + 8192] {
3300 let mut bits = [u64::MAX; 64];
3301 cursor.fill_doc_window(base, &mut bits);
3302 let mut expected = [0u64; 64];
3303 for &doc in &docs {
3304 if (base..base + 4096).contains(&doc) {
3305 let offset = (doc - base) as usize;
3306 expected[offset / 64] |= 1u64 << (offset % 64);
3307 }
3308 }
3309 assert_eq!(bits, expected, "{codec:?}, stride={stride}, base={base}");
3310 let next = docs
3311 .iter()
3312 .copied()
3313 .find(|&doc| doc >= base + 4096)
3314 .unwrap_or(TERMINATED);
3315 assert_eq!(cursor.doc(), next);
3316 if next != TERMINATED {
3317 assert_eq!(cursor.term_freq(), 1 + next % 7);
3318 }
3319 }
3320 }
3321 }
3322 }
3323 }
3324
3325 #[test]
3326 fn document_only_navigation_defers_frequencies_and_resumes_exact_position_reads() {
3327 for codec in [
3328 PostingCodec::Rounded,
3329 PostingCodec::Packed,
3330 PostingCodec::Pfor,
3331 PostingCodec::Simd4x,
3332 ] {
3333 let mut postings = PostingList::new();
3334 let mut expected = Vec::new();
3335 let mut prefix = 0u64;
3336 for i in 0..701u32 {
3337 let tf = if i % 37 == 0 { u32::MAX } else { i % 23 + 1 };
3338 let doc = i * 13 + 5;
3339 postings.push(doc, tf);
3340 expected.push((doc, tf, prefix));
3341 prefix += u64::from(tf);
3342 }
3343 let list =
3344 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3345 .unwrap();
3346 let bytes = serialize_bpl(&list);
3347 for owned in [false, true] {
3348 let mut cursor = if owned {
3349 list.clone().into_iterator()
3350 } else {
3351 list.iterator()
3352 };
3353 assert!(
3354 cursor.block_tfs.get().is_none(),
3355 "{codec:?}: ID-only open decoded frequencies"
3356 );
3357 for target in [20, 205, 1800, 3900] {
3358 let entry = expected.iter().find(|e| e.0 >= target).unwrap();
3359 assert_eq!(cursor.seek(target), entry.0);
3360 assert!(
3361 cursor.block_tfs.get().is_none(),
3362 "ID-only seek decoded frequencies"
3363 );
3364 }
3365 let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3366 #[cfg(feature = "native")]
3367 std::thread::scope(|scope| {
3368 for _ in 0..4 {
3369 let shared = &cursor;
3370 scope.spawn(move || {
3371 assert_eq!(shared.term_freq(), entry.1);
3372 assert_eq!(shared.position_cursor(), entry.2);
3373 });
3374 }
3375 });
3376 assert_eq!(cursor.position_cursor(), entry.2);
3377 assert_eq!(cursor.term_freq(), entry.1);
3378 assert_eq!(cursor.position_cursor_mut(), entry.2);
3379 let mut bits = [0u64; 64];
3380 cursor.fill_doc_window(4096, &mut bits);
3381 for &(doc, _, _) in &expected {
3382 if (4096..8192).contains(&doc) {
3383 assert_ne!(bits[(doc as usize - 4096) / 64] & (1 << (doc % 64)), 0);
3384 }
3385 }
3386 assert_eq!(
3387 bits.iter().map(|word| word.count_ones()).sum::<u32>(),
3388 expected
3389 .iter()
3390 .filter(|e| (4096..8192).contains(&e.0))
3391 .count() as u32
3392 );
3393 assert!(
3394 cursor.block_tfs.get().is_none(),
3395 "membership windows decoded frequencies"
3396 );
3397 let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3398 assert_eq!(cursor.position_cursor_mut(), entry.2);
3399 assert_eq!(cursor.term_freq(), entry.1);
3400 let parked = cursor.doc();
3401 cursor.visit_postings_until(TERMINATED, |docs, tfs| {
3402 for (&doc, &tf) in docs.iter().zip(tfs) {
3403 assert_eq!(tf, expected.iter().find(|e| e.0 == doc).unwrap().1);
3404 }
3405 false
3406 });
3407 assert_eq!(cursor.doc(), parked);
3408 assert_eq!(cursor.position_cursor(), entry.2);
3409 let mut seen = Vec::new();
3410 cursor.visit_postings_until(TERMINATED, |docs, tfs| {
3411 seen.extend(docs.iter().copied().zip(tfs.iter().copied()));
3412 true
3413 });
3414 assert_eq!(
3415 seen,
3416 expected
3417 .iter()
3418 .filter(|e| e.0 >= parked)
3419 .map(|e| (e.0, e.1))
3420 .collect::<Vec<_>>()
3421 );
3422 assert_eq!(cursor.doc(), TERMINATED);
3423 assert_eq!(cursor.term_freq(), 0);
3424 assert_eq!(cursor.seek(0), TERMINATED);
3425 }
3426 assert_eq!(serialize_bpl(&list), bytes);
3427 }
3428 }
3429
3430 #[test]
3431 fn document_navigation_defers_position_accounting_without_changing_cursors() {
3432 for codec in [
3433 PostingCodec::Rounded,
3434 PostingCodec::Packed,
3435 PostingCodec::Pfor,
3436 PostingCodec::Simd4x,
3437 ] {
3438 let mut postings = PostingList::new();
3439 let mut expected = Vec::new();
3440 let mut total = 0u64;
3441 for doc in 0..701u32 {
3442 expected.push(total);
3443 let tf = doc % 23 + 1;
3444 postings.push(doc * 7, tf);
3445 total += u64::from(tf);
3446 }
3447 let list =
3448 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3449 .unwrap();
3450 let mut cursor = list.iterator();
3451 for target in [7, 70, 777, 896, 1400, 3500, 4900] {
3452 assert_eq!(cursor.seek(target), target);
3453 assert!(
3454 cursor.position_offsets.is_none(),
3455 "navigation must not prepare unused position offsets"
3456 );
3457 assert_eq!(cursor.position_cursor(), expected[target as usize / 7]);
3458 assert_eq!(cursor.term_freq(), target / 7 % 23 + 1);
3459 assert_eq!(cursor.seek(target - 1), target);
3460 }
3461 assert_eq!(cursor.advance(), TERMINATED);
3462 }
3463 }
3464
3465 #[test]
3466 fn deferred_position_accounting_resumes_after_reads_windows_and_stopped_runs() {
3467 for codec in [
3468 PostingCodec::Rounded,
3469 PostingCodec::Packed,
3470 PostingCodec::Pfor,
3471 PostingCodec::Simd4x,
3472 ] {
3473 let docs: Vec<_> = (0..600u32).map(|doc| (doc * 11, u32::MAX - doc)).collect();
3474 let prefixes = expected_cursors(&docs);
3475 let mut input = PostingList::new();
3476 for &(doc, tf) in &docs {
3477 input.push(doc, tf);
3478 }
3479 let list = BlockPostingList::build(&input, true, None, codec, false, false).unwrap();
3480 let before = serialize_bpl(&list);
3481 let mut cursor = list.iterator();
3482 for at in [13, 25, 127, 128, 199] {
3483 cursor.seek(docs[at].0);
3484 assert_eq!(cursor.position_range(), (prefixes[at], docs[at].1));
3485 assert_eq!(cursor.position_range(), (prefixes[at], docs[at].1));
3486 assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3487 assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3488 assert_eq!(cursor.position_cursor(), prefixes[at]);
3489 cursor.seek(docs[at].0 - 1);
3490 assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3491 }
3492 cursor.visit_postings_until(docs[220].0, |_, _| true);
3493 assert_eq!(cursor.position_cursor_mut(), prefixes[220]);
3494 cursor.visit_postings_until(docs[250].0, |_, _| false);
3495 assert_eq!(cursor.position_cursor_mut(), prefixes[220]);
3496 let mut bits = [0u64; 4];
3497 cursor.fill_doc_window(docs[220].0, &mut bits);
3498 let at = docs.partition_point(|&(doc, _)| doc < docs[220].0 + 256);
3499 assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3500 cursor.skip_to_next_block();
3501 assert_eq!(cursor.position_cursor_mut(), prefixes[256]);
3502 assert_eq!(serialize_bpl(&list), before);
3503 }
3504 }
3505
3506 #[test]
3507 fn posting_runs_preserve_frequencies_positions_and_resumption_after_stop() {
3508 let docs: Vec<_> = (0..1027u32).map(|i| (i * 7, i % 31 + 1)).collect();
3509 let prefixes = expected_cursors(&docs);
3510 let mut list = PostingList::new();
3511 for &(doc, tf) in &docs {
3512 list.push(doc, tf);
3513 }
3514 for codec in [
3515 PostingCodec::Rounded,
3516 PostingCodec::Packed,
3517 PostingCodec::Pfor,
3518 PostingCodec::Simd4x,
3519 ] {
3520 let postings = BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3521 let bytes = serialize_bpl(&postings);
3522 let mut cursor = postings.iterator();
3523 cursor.seek(docs[17].0);
3524 let mut actual = Vec::new();
3525 let mut calls = 0;
3526 cursor.visit_postings_until(TERMINATED, |ids, tfs| {
3527 calls += 1;
3528 assert_eq!(ids.len(), tfs.len());
3529 assert!(ids.len() <= BLOCK_SIZE);
3530 if calls == 3 {
3531 return false;
3532 }
3533 actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3534 true
3535 });
3536 assert_eq!(actual, docs[17..256]);
3537 assert_eq!(cursor.doc(), docs[256].0);
3538 assert_eq!(cursor.position_cursor(), prefixes[256]);
3539 cursor.visit_postings_until(docs[331].0, |ids, tfs| {
3540 actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3541 true
3542 });
3543 assert_eq!(actual, docs[17..331]);
3544 assert_eq!(cursor.doc(), docs[331].0);
3545 assert_eq!(cursor.term_freq(), docs[331].1);
3546 assert_eq!(cursor.position_cursor(), prefixes[331]);
3547 cursor.visit_postings_until(TERMINATED, |ids, tfs| {
3548 actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3549 true
3550 });
3551 assert_eq!(actual, docs[17..]);
3552 cursor.visit_postings_until(TERMINATED, |_, _| panic!("already exhausted"));
3553 assert_eq!(cursor.doc(), TERMINATED);
3554 assert_eq!(serialize_bpl(&postings), bytes);
3555 }
3556 }
3557
3558 #[test]
3559 fn document_windows_preserve_posting_frequencies_positions_and_bytes() {
3560 for near_end in [false, true] {
3561 let base = if near_end { TERMINATED - 30_000 } else { 0 };
3562 let docs: Vec<_> = (0..5000u32).map(|i| (base + i * 5, i % 31 + 1)).collect();
3563 let prefixes = expected_cursors(&docs);
3564 let mut list = PostingList::new();
3565 for &(doc, tf) in &docs {
3566 list.push(doc, tf);
3567 }
3568 for codec in [
3569 PostingCodec::Rounded,
3570 PostingCodec::Packed,
3571 PostingCodec::Pfor,
3572 PostingCodec::Simd4x,
3573 ] {
3574 let postings =
3575 BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3576 let bytes = serialize_bpl(&postings);
3577 let mut cursor = postings.iterator();
3578 let mut actual = Vec::new();
3579 while cursor.doc() != TERMINATED {
3580 let base = cursor.doc();
3581 let mut bits = [u64::MAX; 64];
3582 cursor.fill_doc_window(base, &mut bits);
3583 for (index, word) in bits.into_iter().enumerate() {
3584 for bit in 0..64 {
3585 if word & (1 << bit) != 0 {
3586 actual.push(base + index as u32 * 64 + bit);
3587 }
3588 }
3589 }
3590 let at = docs.partition_point(|&(doc, _)| doc < base.saturating_add(4096));
3591 assert_eq!(cursor.doc(), docs.get(at).map_or(TERMINATED, |p| p.0));
3592 if at < docs.len() {
3593 assert_eq!(cursor.term_freq(), docs[at].1);
3594 assert_eq!(cursor.position_cursor(), prefixes[at]);
3595 }
3596 }
3597 assert_eq!(actual, docs.iter().map(|p| p.0).collect::<Vec<_>>());
3598 assert_eq!(serialize_bpl(&postings), bytes);
3599 }
3600 }
3601 }
3602
3603 #[test]
3604 fn nearby_and_distant_seeks_preserve_postings_position_cursors_and_bytes() {
3605 let docs: Vec<_> = (0..32_769u32)
3606 .map(|i| (i * 5 + i % 3, i % 31 + 1))
3607 .collect();
3608 let prefixes = expected_cursors(&docs);
3609 let mut list = PostingList::new();
3610 for &(doc, tf) in &docs {
3611 list.push(doc, tf);
3612 }
3613 for codec in [
3614 PostingCodec::Rounded,
3615 PostingCodec::Packed,
3616 PostingCodec::Pfor,
3617 PostingCodec::Simd4x,
3618 ] {
3619 let postings = BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3620 let bytes = serialize_bpl(&postings);
3621 let lasts: Vec<_> = (0..postings.num_blocks())
3622 .map(|i| postings.block_last_doc(i).unwrap())
3623 .collect();
3624 for from in [0, 1, 7, 8, 64, 200, 256, 257] {
3625 for target in [0, 1, 100, 639, 640, 641, 700, 160_000, 163_840, TERMINATED] {
3626 let expected = (from..lasts.len()).find(|&i| lasts[i] >= target);
3627 assert_eq!(
3628 postings.seek_block(target, from),
3629 expected,
3630 "{codec:?} from={from} target={target}"
3631 );
3632 }
3633 }
3634 let mut cursor = postings.iterator();
3635 let mut at = 0;
3636 for target in [
3637 0,
3638 1,
3639 20,
3640 19,
3641 639,
3642 640,
3643 641,
3644 700,
3645 160_000,
3646 161_000,
3647 160_000,
3648 docs.last().unwrap().0 - 1,
3649 docs.last().unwrap().0,
3650 TERMINATED,
3651 0,
3652 ] {
3653 while at < docs.len() && docs[at].0 < target {
3654 at += 1;
3655 }
3656 let expected = docs.get(at).map_or(TERMINATED, |&(doc, _)| doc);
3657 assert_eq!(cursor.seek(target), expected, "{codec:?} target={target}");
3658 if at < docs.len() {
3659 assert_eq!(cursor.term_freq(), docs[at].1);
3660 assert_eq!(cursor.position_cursor(), prefixes[at]);
3661 }
3662 }
3663 assert_eq!(serialize_bpl(&postings), bytes);
3664 }
3665 }
3666
3667 #[test]
3668 fn candidate_posting_probes_reuse_buffers_and_preserve_seek_and_position_cursors() {
3669 let mut list = PostingList::new();
3670 for i in 0..700 {
3671 list.push(i * 3, i % 7 + 1);
3672 }
3673 let postings = BlockPostingList::from_posting_list(&list).unwrap();
3674 let mut scratch = PostingDecodeScratch::default();
3675 let mut doc_buffer = None;
3676 for first in [0, 385, 900, 1800, 2100, 100, 2097] {
3677 let mut reference = postings.clone().into_iterator();
3678 let mut selected = postings
3679 .clone()
3680 .into_candidate_iterator(first, &mut scratch);
3681 assert_eq!(
3682 selected.current_block_idx(),
3683 postings.seek_block(first, 0).unwrap_or(0)
3684 );
3685 for target in (first..2200).step_by(17) {
3686 assert_eq!(selected.seek(target), reference.seek(target));
3687 assert_eq!(selected.term_freq(), reference.term_freq());
3688 if selected.doc() != TERMINATED {
3689 assert_eq!(selected.position_cursor(), reference.position_cursor());
3690 }
3691 }
3692 selected.recycle(&mut scratch);
3693 assert!(scratch.doc_ids.capacity() >= BLOCK_SIZE);
3694 assert!(scratch.term_freqs.is_some());
3697 let address = scratch.doc_ids.as_ptr() as usize;
3698 if let Some(previous) = doc_buffer {
3699 assert_eq!(
3700 address, previous,
3701 "document scratch allocation must be reused"
3702 );
3703 }
3704 doc_buffer = Some(address);
3705 }
3706 }
3707
3708 #[test]
3709 fn test_posting_list_basic() {
3710 let mut list = PostingList::new();
3711 list.push(1, 2);
3712 list.push(5, 1);
3713 list.push(10, 3);
3714
3715 assert_eq!(list.len(), 3);
3716
3717 let mut iter = PostingListIterator::new(&list);
3718 assert_eq!(iter.doc(), 1);
3719 assert_eq!(iter.term_freq(), 2);
3720
3721 assert_eq!(iter.advance(), 5);
3722 assert_eq!(iter.term_freq(), 1);
3723
3724 assert_eq!(iter.advance(), 10);
3725 assert_eq!(iter.term_freq(), 3);
3726
3727 assert_eq!(iter.advance(), TERMINATED);
3728 }
3729
3730 #[test]
3731 fn test_posting_list_seek() {
3732 let mut list = PostingList::new();
3733 for i in 0..100 {
3734 list.push(i * 2, 1);
3735 }
3736
3737 let mut iter = PostingListIterator::new(&list);
3738
3739 assert_eq!(iter.seek(50), 50);
3740 assert_eq!(iter.seek(51), 52);
3741 assert_eq!(iter.seek(200), TERMINATED);
3742 }
3743
3744 #[test]
3745 fn test_block_posting_list() {
3746 let mut list = PostingList::new();
3747 for i in 0..500 {
3748 list.push(i * 2, (i % 10) + 1);
3749 }
3750
3751 let block_list = BlockPostingList::from_posting_list(&list).unwrap();
3752 assert_eq!(block_list.doc_count(), 500);
3753
3754 let mut iter = block_list.iterator();
3755 assert_eq!(iter.doc(), 0);
3756 assert_eq!(iter.term_freq(), 1);
3757
3758 assert_eq!(iter.seek(500), 500);
3760 assert_eq!(iter.seek(998), 998);
3761 assert_eq!(iter.seek(1000), TERMINATED);
3762 }
3763
3764 #[test]
3765 fn test_block_posting_list_serialization() {
3766 let mut list = PostingList::new();
3767 for i in 0..300 {
3768 list.push(i * 3, i + 1);
3769 }
3770
3771 let block_list = BlockPostingList::from_posting_list(&list).unwrap();
3772
3773 let mut buffer = Vec::new();
3774 block_list.serialize(&mut buffer).unwrap();
3775
3776 let deserialized = BlockPostingList::deserialize(&buffer[..]).unwrap();
3777 assert_eq!(deserialized.doc_count(), block_list.doc_count());
3778
3779 let mut iter1 = block_list.iterator();
3781 let mut iter2 = deserialized.iterator();
3782
3783 while iter1.doc() != TERMINATED {
3784 assert_eq!(iter1.doc(), iter2.doc());
3785 assert_eq!(iter1.term_freq(), iter2.term_freq());
3786 iter1.advance();
3787 iter2.advance();
3788 }
3789 assert_eq!(iter2.doc(), TERMINATED);
3790 }
3791
3792 fn collect_postings(bpl: &BlockPostingList) -> Vec<(u32, u32)> {
3794 let mut result = Vec::new();
3795 let mut it = bpl.iterator();
3796 while it.doc() != TERMINATED {
3797 result.push((it.doc(), it.term_freq()));
3798 it.advance();
3799 }
3800 result
3801 }
3802
3803 #[test]
3805 fn deserialization_rejects_unsupported_width_instead_of_empty_results() {
3806 let list = build_bpl(&[(0, 1), (1, 2)]);
3807 let mut bytes = Vec::new();
3808 list.serialize(&mut bytes).unwrap();
3809 bytes[6] = 0xe1;
3810 let error = BlockPostingList::deserialize(&bytes).unwrap_err();
3811 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
3812 assert!(error.to_string().contains("exceeds 32 bits"));
3813 }
3814
3815 fn build_bpl(postings: &[(u32, u32)]) -> BlockPostingList {
3816 let mut pl = PostingList::new();
3817 for &(doc_id, tf) in postings {
3818 pl.push(doc_id, tf);
3819 }
3820 BlockPostingList::from_posting_list(&pl).unwrap()
3821 }
3822
3823 fn serialize_bpl(bpl: &BlockPostingList) -> Vec<u8> {
3825 let mut buf = Vec::new();
3826 bpl.serialize(&mut buf).unwrap();
3827 buf
3828 }
3829
3830 #[test]
3831 fn test_concatenate_blocks_two_segments() {
3832 let a: Vec<(u32, u32)> = (0..100).map(|i| (i * 2, i + 1)).collect();
3834 let bpl_a = build_bpl(&a);
3835
3836 let b: Vec<(u32, u32)> = (0..100).map(|i| (i * 3, i + 2)).collect();
3838 let bpl_b = build_bpl(&b);
3839
3840 let merged =
3842 BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 200)])
3843 .unwrap();
3844
3845 assert_eq!(merged.doc_count(), 200);
3846
3847 let postings = collect_postings(&merged);
3848 assert_eq!(postings.len(), 200);
3849
3850 for (i, p) in postings.iter().enumerate().take(100) {
3852 assert_eq!(*p, (i as u32 * 2, i as u32 + 1));
3853 }
3854 for i in 0..100 {
3856 assert_eq!(postings[100 + i], (i as u32 * 3 + 200, i as u32 + 2));
3857 }
3858 }
3859
3860 #[test]
3861 fn test_concatenate_streaming_matches_blocks() {
3862 let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
3864 let seg_b: Vec<(u32, u32)> = (0..180).map(|i| (i * 5, (i % 3) + 1)).collect();
3865 let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
3866
3867 let bpl_a = build_bpl(&seg_a);
3868 let bpl_b = build_bpl(&seg_b);
3869 let bpl_c = build_bpl(&seg_c);
3870
3871 let offset_b = 1000u32;
3872 let offset_c = 2000u32;
3873
3874 let ref_merged = BlockPostingList::concatenate_blocks(&[
3876 (bpl_a.clone(), 0),
3877 (bpl_b.clone(), offset_b),
3878 (bpl_c.clone(), offset_c),
3879 ])
3880 .unwrap();
3881 let mut ref_buf = Vec::new();
3882 ref_merged.serialize(&mut ref_buf).unwrap();
3883
3884 let bytes_a = serialize_bpl(&bpl_a);
3886 let bytes_b = serialize_bpl(&bpl_b);
3887 let bytes_c = serialize_bpl(&bpl_c);
3888
3889 let sources: Vec<(&[u8], u32)> =
3890 vec![(&bytes_a, 0), (&bytes_b, offset_b), (&bytes_c, offset_c)];
3891 let mut stream_buf = Vec::new();
3892 let (doc_count, bytes_written) =
3893 BlockPostingList::concatenate_streaming(&sources, &mut stream_buf).unwrap();
3894
3895 assert_eq!(doc_count, 520); assert_eq!(bytes_written, stream_buf.len());
3897
3898 let ref_postings = collect_postings(&BlockPostingList::deserialize(&ref_buf).unwrap());
3900 let stream_postings =
3901 collect_postings(&BlockPostingList::deserialize(&stream_buf).unwrap());
3902
3903 assert_eq!(ref_postings.len(), stream_postings.len());
3904 for (i, (r, s)) in ref_postings.iter().zip(stream_postings.iter()).enumerate() {
3905 assert_eq!(r, s, "mismatch at posting {}", i);
3906 }
3907 }
3908
3909 #[test]
3910 fn test_concatenate_streaming_short_source_returns_corruption() {
3911 let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
3916 let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
3917 let bytes_a = serialize_bpl(&build_bpl(&seg_a));
3918 let bytes_c = serialize_bpl(&build_bpl(&seg_c));
3919 let short = vec![0u8; FOOTER_SIZE - 1]; let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&short, 1000), (&bytes_c, 2000)];
3922 let mut out = Vec::new();
3923 let result = BlockPostingList::concatenate_streaming(&sources, &mut out);
3924 assert!(
3925 matches!(result, Err(crate::Error::Corruption(_))),
3926 "short/corrupt source must be a Corruption error, not silently skipped: {:?}",
3927 result.map(|r| r.0)
3928 );
3929 }
3930
3931 #[test]
3932 fn test_multi_round_merge() {
3933 let segments: Vec<Vec<(u32, u32)>> = (0..4)
3940 .map(|seg| (0..200).map(|i| (i * 3, (i + seg * 7) % 10 + 1)).collect())
3941 .collect();
3942
3943 let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
3944 let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
3945
3946 let mut merged_01 = Vec::new();
3948 let sources_01: Vec<(&[u8], u32)> = vec![(&serialized[0], 0), (&serialized[1], 600)];
3949 let (dc_01, _) =
3950 BlockPostingList::concatenate_streaming(&sources_01, &mut merged_01).unwrap();
3951 assert_eq!(dc_01, 400);
3952
3953 let mut merged_23 = Vec::new();
3954 let sources_23: Vec<(&[u8], u32)> = vec![(&serialized[2], 0), (&serialized[3], 600)];
3955 let (dc_23, _) =
3956 BlockPostingList::concatenate_streaming(&sources_23, &mut merged_23).unwrap();
3957 assert_eq!(dc_23, 400);
3958
3959 let mut final_merged = Vec::new();
3961 let sources_final: Vec<(&[u8], u32)> = vec![(&merged_01, 0), (&merged_23, 1200)];
3962 let (dc_final, _) =
3963 BlockPostingList::concatenate_streaming(&sources_final, &mut final_merged).unwrap();
3964 assert_eq!(dc_final, 800);
3965
3966 let final_bpl = BlockPostingList::deserialize(&final_merged).unwrap();
3968 let postings = collect_postings(&final_bpl);
3969 assert_eq!(postings.len(), 800);
3970
3971 assert_eq!(postings[0].0, 0); assert_eq!(postings[199].0, 597); assert_eq!(postings[200].0, 600); assert_eq!(postings[399].0, 1197); assert_eq!(postings[400].0, 1200); assert_eq!(postings[799].0, 2397); for seg in 0u32..4 {
3984 for i in 0u32..200 {
3985 let idx = (seg * 200 + i) as usize;
3986 assert_eq!(
3987 postings[idx].1,
3988 (i + seg * 7) % 10 + 1,
3989 "seg{} tf[{}]",
3990 seg,
3991 i
3992 );
3993 }
3994 }
3995
3996 let mut it = final_bpl.iterator();
3998 assert_eq!(it.seek(600), 600);
3999 assert_eq!(it.seek(1200), 1200);
4000 assert_eq!(it.seek(2397), 2397);
4001 assert_eq!(it.seek(2398), TERMINATED);
4002 }
4003
4004 #[test]
4005 fn test_large_scale_merge() {
4006 let num_segments = 5;
4009 let docs_per_segment = 2000;
4010 let docs_gap = 3; let segments: Vec<Vec<(u32, u32)>> = (0..num_segments)
4013 .map(|seg| {
4014 (0..docs_per_segment)
4015 .map(|i| (i as u32 * docs_gap, (i as u32 + seg as u32) % 20 + 1))
4016 .collect()
4017 })
4018 .collect();
4019
4020 let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
4021
4022 for bpl in &bpls {
4024 assert!(
4025 bpl.num_blocks() >= 15,
4026 "expected >=15 blocks, got {}",
4027 bpl.num_blocks()
4028 );
4029 }
4030
4031 let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
4032
4033 let max_doc_per_seg = (docs_per_segment as u32 - 1) * docs_gap;
4035 let offsets: Vec<u32> = (0..num_segments)
4036 .map(|i| i as u32 * (max_doc_per_seg + 1))
4037 .collect();
4038
4039 let sources: Vec<(&[u8], u32)> = serialized
4040 .iter()
4041 .zip(offsets.iter())
4042 .map(|(b, o)| (b.as_slice(), *o))
4043 .collect();
4044
4045 let mut merged = Vec::new();
4046 let (doc_count, _) =
4047 BlockPostingList::concatenate_streaming(&sources, &mut merged).unwrap();
4048 assert_eq!(doc_count, (num_segments * docs_per_segment) as u32);
4049
4050 let merged_bpl = BlockPostingList::deserialize(&merged).unwrap();
4052 let postings = collect_postings(&merged_bpl);
4053 assert_eq!(postings.len(), num_segments * docs_per_segment);
4054
4055 for i in 1..postings.len() {
4057 assert!(
4058 postings[i].0 > postings[i - 1].0 || (i % docs_per_segment == 0), "doc_id not increasing at {}: {} vs {}",
4060 i,
4061 postings[i - 1].0,
4062 postings[i].0,
4063 );
4064 }
4065
4066 let mut it = merged_bpl.iterator();
4068 for (seg, &expected_first) in offsets.iter().enumerate() {
4069 assert_eq!(
4070 it.seek(expected_first),
4071 expected_first,
4072 "seek to segment {} start",
4073 seg
4074 );
4075 }
4076 }
4077
4078 #[test]
4079 fn test_merge_edge_cases() {
4080 let bpl_a = build_bpl(&[(0, 5)]);
4082 let bpl_b = build_bpl(&[(0, 3)]);
4083
4084 let merged =
4085 BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 1)])
4086 .unwrap();
4087 assert_eq!(merged.doc_count(), 2);
4088 let p = collect_postings(&merged);
4089 assert_eq!(p, vec![(0, 5), (1, 3)]);
4090
4091 let exact_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32).map(|i| (i, i % 5 + 1)).collect();
4093 let bpl_exact = build_bpl(&exact_block);
4094 assert_eq!(bpl_exact.num_blocks(), 1);
4095
4096 let bytes = serialize_bpl(&bpl_exact);
4097 let mut out = Vec::new();
4098 let sources: Vec<(&[u8], u32)> = vec![(&bytes, 0), (&bytes, BLOCK_SIZE as u32)];
4099 let (dc, _) = BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4100 assert_eq!(dc, BLOCK_SIZE as u32 * 2);
4101
4102 let merged = BlockPostingList::deserialize(&out).unwrap();
4103 let postings = collect_postings(&merged);
4104 assert_eq!(postings.len(), BLOCK_SIZE * 2);
4105 assert_eq!(postings[BLOCK_SIZE].0, BLOCK_SIZE as u32);
4107
4108 let over_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32 + 1).map(|i| (i * 2, 1)).collect();
4110 let bpl_over = build_bpl(&over_block);
4111 assert_eq!(bpl_over.num_blocks(), 2);
4112 }
4113
4114 #[test]
4115 fn test_streaming_roundtrip_single_source() {
4116 let docs: Vec<(u32, u32)> = (0..500).map(|i| (i * 7, i % 15 + 1)).collect();
4118 let bpl = build_bpl(&docs);
4119 let direct = serialize_bpl(&bpl);
4120
4121 let sources: Vec<(&[u8], u32)> = vec![(&direct, 0)];
4122 let mut streamed = Vec::new();
4123 BlockPostingList::concatenate_streaming(&sources, &mut streamed).unwrap();
4124
4125 let p1 = collect_postings(&BlockPostingList::deserialize(&direct).unwrap());
4127 let p2 = collect_postings(&BlockPostingList::deserialize(&streamed).unwrap());
4128 assert_eq!(p1, p2);
4129 }
4130
4131 #[test]
4132 fn test_max_tf_preserved_through_merge() {
4133 let mut a = Vec::new();
4135 for i in 0..200 {
4136 a.push((i * 2, if i == 100 { 50 } else { 1 }));
4137 }
4138 let bpl_a = build_bpl(&a);
4139 assert_eq!(bpl_a.max_tf(), 50);
4140
4141 let mut b = Vec::new();
4143 for i in 0..200 {
4144 b.push((i * 2, if i == 50 { 30 } else { 2 }));
4145 }
4146 let bpl_b = build_bpl(&b);
4147 assert_eq!(bpl_b.max_tf(), 30);
4148
4149 let bytes_a = serialize_bpl(&bpl_a);
4151 let bytes_b = serialize_bpl(&bpl_b);
4152 let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 1000)];
4153 let mut out = Vec::new();
4154 BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4155
4156 let merged = BlockPostingList::deserialize(&out).unwrap();
4157 assert_eq!(merged.max_tf(), 50);
4158 assert_eq!(merged.doc_count(), 400);
4159 }
4160
4161 #[test]
4164 fn test_l0_l1_counts() {
4165 let bpl = build_bpl(&(0..50u32).map(|i| (i, 1)).collect::<Vec<_>>());
4167 assert_eq!(bpl.num_blocks(), 1);
4168 assert_eq!(bpl.l1_docs.len(), 1);
4169
4170 let n = BLOCK_SIZE * L1_INTERVAL;
4172 let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4173 assert_eq!(bpl.num_blocks(), L1_INTERVAL);
4174 assert_eq!(bpl.l1_docs.len(), 1);
4175
4176 let n = BLOCK_SIZE * L1_INTERVAL + 1;
4178 let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4179 assert_eq!(bpl.num_blocks(), L1_INTERVAL + 1);
4180 assert_eq!(bpl.l1_docs.len(), 2);
4181
4182 let n = BLOCK_SIZE * L1_INTERVAL * 3;
4184 let bpl = build_bpl(&(0..n as u32).map(|i| (i, 1)).collect::<Vec<_>>());
4185 assert_eq!(bpl.num_blocks(), L1_INTERVAL * 3);
4186 assert_eq!(bpl.l1_docs.len(), 3);
4187 }
4188
4189 #[test]
4190 fn test_l1_last_doc_values() {
4191 let n = BLOCK_SIZE * 20;
4193 let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
4194 let bpl = build_bpl(&docs);
4195 assert_eq!(bpl.num_blocks(), 20);
4196 assert_eq!(bpl.l1_docs.len(), 3); let expected_l1_0 = bpl.block_last_doc(7).unwrap();
4200 assert_eq!(bpl.l1_docs.get(0).unwrap(), expected_l1_0);
4201
4202 let expected_l1_1 = bpl.block_last_doc(15).unwrap();
4204 assert_eq!(bpl.l1_docs.get(1).unwrap(), expected_l1_1);
4205
4206 let expected_l1_2 = bpl.block_last_doc(19).unwrap();
4208 assert_eq!(bpl.l1_docs.get(2).unwrap(), expected_l1_2);
4209 }
4210
4211 #[test]
4212 fn test_seek_block_basic() {
4213 let n = BLOCK_SIZE * 20;
4215 let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 10, 1)).collect();
4216 let bpl = build_bpl(&docs);
4217
4218 assert_eq!(bpl.seek_block(0, 0), Some(0));
4220
4221 for blk in 0..20 {
4223 let first = bpl.block_first_doc(blk).unwrap();
4224 assert_eq!(
4225 bpl.seek_block(first, 0),
4226 Some(blk),
4227 "seek to block {} first_doc",
4228 blk
4229 );
4230 }
4231
4232 for blk in 0..20 {
4234 let last = bpl.block_last_doc(blk).unwrap();
4235 assert_eq!(
4236 bpl.seek_block(last, 0),
4237 Some(blk),
4238 "seek to block {} last_doc",
4239 blk
4240 );
4241 }
4242
4243 let max_doc = bpl.block_last_doc(19).unwrap();
4245 assert_eq!(bpl.seek_block(max_doc + 1, 0), None);
4246
4247 let mid_doc = bpl.block_first_doc(10).unwrap();
4249 assert_eq!(bpl.seek_block(mid_doc, 10), Some(10));
4250 assert_eq!(
4251 bpl.seek_block(mid_doc, 11),
4252 Some(11).or(bpl.seek_block(mid_doc, 11))
4253 );
4254 }
4255
4256 #[test]
4257 fn test_seek_block_across_l1_boundaries() {
4258 let n = BLOCK_SIZE * 24;
4260 let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 5, 1)).collect();
4261 let bpl = build_bpl(&docs);
4262 assert_eq!(bpl.l1_docs.len(), 3);
4263
4264 for group in 0..3 {
4266 let blk = group * L1_INTERVAL;
4267 let target = bpl.block_first_doc(blk).unwrap();
4268 assert_eq!(
4269 bpl.seek_block(target, 0),
4270 Some(blk),
4271 "seek to group {} block {}",
4272 group,
4273 blk
4274 );
4275 }
4276
4277 let target = bpl.block_first_doc(20).unwrap() + 1;
4279 assert_eq!(bpl.seek_block(target, 0), Some(20));
4280 }
4281
4282 #[test]
4283 fn block_len_matches_l0_offsets() {
4284 let bpl = build_bpl(&(0..1000).map(|i| (i * 3, 1 + i % 4)).collect::<Vec<_>>());
4286 let mut total = 0usize;
4287 for b in 0..bpl.num_blocks() {
4288 let (_, _, offset, _) = bpl.read_l0_entry(b);
4289 assert_eq!(offset as usize, total, "block {b} offset");
4290 total += bpl.block_len(b);
4291 }
4292 assert_eq!(total, bpl.stream.len());
4293 }
4294
4295 #[test]
4299 fn every_codec_round_trips_and_seeks() {
4300 let mut postings: Vec<(u32, u32)> = Vec::new();
4301 let mut doc = 0u32;
4302 for i in 0..5000u32 {
4303 doc += if i % 97 == 0 { 100_000 } else { 1 + i % 7 };
4306 let tf = if i % 131 == 0 { 5000 } else { 1 + i % 3 };
4307 postings.push((doc, tf));
4308 }
4309 let mut list = PostingList::new();
4310 for &(d, tf) in &postings {
4311 list.push(d, tf);
4312 }
4313 let rounded = BlockPostingList::from_posting_list(&list).unwrap();
4314 let mut sizes = Vec::new();
4315 for codec in [
4316 PostingCodec::Rounded,
4317 PostingCodec::Packed,
4318 PostingCodec::Pfor,
4319 PostingCodec::Simd4x,
4320 ] {
4321 let bpl = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
4322 assert_eq!(collect_postings(&bpl), postings, "{codec}");
4323 for b in 0..bpl.num_blocks() {
4324 let count = (postings.len() - b * BLOCK_SIZE).min(BLOCK_SIZE);
4325 let expected_codec = if codec == PostingCodec::Simd4x && count < BLOCK_SIZE {
4326 PostingCodec::Rounded
4327 } else {
4328 codec
4329 };
4330 assert_eq!(bpl.block_codec(b), Some(expected_codec));
4331 assert_eq!(bpl.block_max_tf(b), rounded.block_max_tf(b));
4332 }
4333 let bytes = serialize_bpl(&bpl);
4335 let back = BlockPostingList::deserialize(&bytes).unwrap();
4336 assert_eq!(collect_postings(&back), postings, "{codec} deserialize");
4337 let back =
4338 BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4339 assert_eq!(collect_postings(&back), postings, "{codec} zero-copy");
4340 let mut a = rounded.iterator();
4342 let mut b = back.iterator();
4343 for target in (0..postings.last().unwrap().0 + 10).step_by(2_003) {
4344 assert_eq!(a.seek(target), b.seek(target), "{codec} seek {target}");
4345 assert_eq!(a.term_freq(), b.term_freq());
4346 }
4347 sizes.push((codec, bytes.len()));
4348 }
4349 let rounded_bytes = serialize_bpl(&rounded);
4350 assert_eq!(
4351 serialize_bpl(
4352 &BlockPostingList::from_posting_list_with_codec(&list, PostingCodec::Rounded)
4353 .unwrap()
4354 ),
4355 rounded_bytes,
4356 "Rounded must stay byte-identical"
4357 );
4358 assert!(matches!(rounded.stream[6], 0 | 8 | 16 | 32));
4360 let size = |c: PostingCodec| sizes.iter().find(|(k, _)| *k == c).unwrap().1;
4361 assert!(size(PostingCodec::Packed) < size(PostingCodec::Rounded));
4362 assert!(size(PostingCodec::Pfor) < size(PostingCodec::Packed));
4363 }
4364
4365 #[test]
4367 fn mixed_codec_sources_concatenate() {
4368 let a: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 5, 1 + i % 9)).collect();
4369 let b: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 11 + 3, 2 + i % 5)).collect();
4370 let list_a = {
4371 let mut l = PostingList::new();
4372 a.iter().for_each(|&(d, t)| l.push(d, t));
4373 BlockPostingList::from_posting_list_with_codec(&l, PostingCodec::Pfor).unwrap()
4374 };
4375 let list_b = {
4376 let mut l = PostingList::new();
4377 b.iter().for_each(|&(d, t)| l.push(d, t));
4378 BlockPostingList::from_posting_list_with_codec(&l, PostingCodec::Packed).unwrap()
4379 };
4380 let offset_b = a.last().unwrap().0 + 1;
4381 let expected: Vec<(u32, u32)> = a
4382 .iter()
4383 .copied()
4384 .chain(b.iter().map(|&(d, t)| (d + offset_b, t)))
4385 .collect();
4386
4387 let merged = BlockPostingList::concatenate_blocks(&[
4388 (list_a.clone(), 0),
4389 (list_b.clone(), offset_b),
4390 ])
4391 .unwrap();
4392 assert_eq!(collect_postings(&merged), expected);
4393
4394 let bytes_a = serialize_bpl(&list_a);
4395 let bytes_b = serialize_bpl(&list_b);
4396 let mut out = Vec::new();
4397 let (docs, written) = BlockPostingList::concatenate_streaming(
4398 &[(bytes_a.as_slice(), 0), (bytes_b.as_slice(), offset_b)],
4399 &mut out,
4400 )
4401 .unwrap();
4402 assert_eq!(docs, 600);
4403 assert_eq!(written, out.len());
4404 let streamed = BlockPostingList::deserialize(&out).unwrap();
4405 assert_eq!(collect_postings(&streamed), expected);
4406 assert_eq!(streamed.block_codec(0), Some(PostingCodec::Pfor));
4407 assert_eq!(
4408 streamed.block_codec(streamed.num_blocks() - 1),
4409 Some(PostingCodec::Packed)
4410 );
4411 }
4412
4413 #[test]
4414 fn test_l0_entry_roundtrip() {
4415 let docs: Vec<(u32, u32)> = (0..1000u32).map(|i| (i * 3, (i % 10) + 1)).collect();
4417 let bpl = build_bpl(&docs);
4418
4419 let bytes = serialize_bpl(&bpl);
4420 let bpl2 = BlockPostingList::deserialize(&bytes).unwrap();
4421
4422 assert_eq!(bpl.num_blocks(), bpl2.num_blocks());
4423 for blk in 0..bpl.num_blocks() {
4424 assert_eq!(
4425 bpl.read_l0_entry(blk),
4426 bpl2.read_l0_entry(blk),
4427 "L0 entry mismatch at block {}",
4428 blk
4429 );
4430 }
4431
4432 assert_eq!(bpl.l1_docs.bytes(), bpl2.l1_docs.bytes());
4434 }
4435
4436 #[test]
4437 fn posting_open_borrows_unaligned_group_directories_and_preserves_bytes_and_seeks() {
4438 for codec in [
4439 PostingCodec::Rounded,
4440 PostingCodec::Packed,
4441 PostingCodec::Pfor,
4442 PostingCodec::Simd4x,
4443 ] {
4444 let mut postings = PostingList::new();
4445 for i in 0..3457u32 {
4446 postings.push(i * 7 + 3, i % 13 + 1);
4447 }
4448 let built = BlockPostingList::from_posting_list_with_codec(&postings, codec).unwrap();
4449 let encoded = serialize_bpl(&built);
4450 for prefix in [1, 3, 7] {
4451 let mut padded = vec![0; prefix];
4452 padded.extend_from_slice(&encoded);
4453 let owner = OwnedBytes::new(padded);
4454 let raw = owner.slice(prefix..owner.len());
4455 let footer = Footer::parse(&raw).unwrap();
4456 let docs_start = raw[footer.l1_start()..].as_ptr();
4457 let bounds_start = raw[footer.l1_end()..].as_ptr();
4458 let opened = BlockPostingList::deserialize_zero_copy(raw).unwrap();
4459 assert_eq!(
4460 opened.l1_docs.bytes().as_ptr(),
4461 docs_start,
4462 "opening must borrow group document metadata"
4463 );
4464 assert_eq!(
4465 opened.l1_bounds.bytes().as_ptr(),
4466 bounds_start,
4467 "opening must borrow group bound metadata"
4468 );
4469 assert_eq!(serialize_bpl(&opened), encoded);
4470 for from in 0..=built.num_blocks() {
4471 for target in (0..=3457 * 7 + 4).step_by(37) {
4472 let expected = (from..built.num_blocks())
4473 .find(|&block| built.block_last_doc(block).unwrap() >= target);
4474 assert_eq!(opened.seek_block(target, from), expected);
4475 }
4476 }
4477 assert_eq!(collect_postings(&opened), collect_postings(&built));
4478 }
4479 }
4480 }
4481
4482 #[test]
4483 fn test_zero_copy_deserialize_matches() {
4484 let docs: Vec<(u32, u32)> = (0..2000u32).map(|i| (i * 2, (i % 5) + 1)).collect();
4485 let bpl = build_bpl(&docs);
4486 let bytes = serialize_bpl(&bpl);
4487
4488 let copied = BlockPostingList::deserialize(&bytes).unwrap();
4489 let zero_copy =
4490 BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4491
4492 assert_eq!(copied.l0_count, zero_copy.l0_count);
4494 assert_eq!(copied.l1_docs.bytes(), zero_copy.l1_docs.bytes());
4495 assert_eq!(copied.doc_count, zero_copy.doc_count);
4496 assert_eq!(copied.max_tf, zero_copy.max_tf);
4497
4498 let p1 = collect_postings(&copied);
4500 let p2 = collect_postings(&zero_copy);
4501 assert_eq!(p1, p2);
4502 }
4503
4504 #[test]
4505 fn test_l1_preserved_through_streaming_merge() {
4506 let seg_a = build_bpl(&(0..1000u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4508 let seg_b = build_bpl(&(0..800u32).map(|i| (i * 3, 2)).collect::<Vec<_>>());
4509 let seg_c = build_bpl(&(0..500u32).map(|i| (i * 5, 3)).collect::<Vec<_>>());
4510
4511 let bytes_a = serialize_bpl(&seg_a);
4512 let bytes_b = serialize_bpl(&seg_b);
4513 let bytes_c = serialize_bpl(&seg_c);
4514
4515 let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 10000), (&bytes_c, 20000)];
4516 let mut out = Vec::new();
4517 BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4518
4519 let merged = BlockPostingList::deserialize(&out).unwrap();
4520 let expected_l1_count = merged.num_blocks().div_ceil(L1_INTERVAL);
4521 assert_eq!(merged.l1_docs.len(), expected_l1_count);
4522
4523 for (i, word) in merged.l1_docs.words().iter().enumerate() {
4525 let l1_doc = u32::from_le_bytes(*word);
4526 let last_block_in_group = ((i + 1) * L1_INTERVAL - 1).min(merged.num_blocks() - 1);
4527 let expected = merged.block_last_doc(last_block_in_group).unwrap();
4528 assert_eq!(l1_doc, expected, "L1[{}] mismatch", i);
4529 }
4530
4531 for blk in 0..merged.num_blocks() {
4533 let first = merged.block_first_doc(blk).unwrap();
4534 assert_eq!(merged.seek_block(first, 0), Some(blk));
4535 }
4536 }
4537
4538 #[test]
4539 fn test_seek_block_single_block() {
4540 let bpl = build_bpl(&[(0, 1), (10, 2), (20, 3)]);
4542 assert_eq!(bpl.num_blocks(), 1);
4543 assert_eq!(bpl.l1_docs.len(), 1);
4544
4545 assert_eq!(bpl.seek_block(0, 0), Some(0));
4546 assert_eq!(bpl.seek_block(10, 0), Some(0));
4547 assert_eq!(bpl.seek_block(20, 0), Some(0));
4548 assert_eq!(bpl.seek_block(21, 0), None);
4549 }
4550
4551 #[test]
4552 fn test_footer_size() {
4553 let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 2, 1)).collect();
4555 let bpl = build_bpl(&docs);
4556 let bytes = serialize_bpl(&bpl);
4557
4558 let expected = bpl.stream.len()
4559 + bpl.l0_count * L0_SIZE
4560 + bpl.l1_docs.len() * (L1_SIZE + 4)
4561 + FOOTER_V2_SIZE;
4562 assert_eq!(bytes.len(), expected);
4563 }
4564
4565 fn build_bpl_with_positions(postings: &[(u32, u32)]) -> BlockPostingList {
4566 let mut list = PostingList::new();
4567 for &(doc, tf) in postings {
4568 list.push(doc, tf);
4569 }
4570 BlockPostingList::from_posting_list_with(&list, true, None).unwrap()
4571 }
4572
4573 fn expected_cursors(postings: &[(u32, u32)]) -> Vec<u64> {
4575 let mut acc = 0u64;
4576 postings
4577 .iter()
4578 .map(|&(_, tf)| {
4579 let c = acc;
4580 acc += tf as u64;
4581 c
4582 })
4583 .collect()
4584 }
4585
4586 fn iterator_cursors(bpl: &BlockPostingList) -> Vec<u64> {
4587 let mut it = bpl.iterator();
4588 let mut out = Vec::new();
4589 while it.doc() != TERMINATED {
4590 out.push(it.position_cursor());
4591 it.advance();
4592 }
4593 out
4594 }
4595
4596 #[test]
4597 fn position_cursors_survive_serialization_and_seeks() {
4598 let docs: Vec<(u32, u32)> = (0..700u32).map(|i| (i * 3, i % 5 + 1)).collect();
4599 let bpl = build_bpl_with_positions(&docs);
4600 assert!(bpl.has_position_cursors());
4601 assert_eq!(
4602 bpl.total_positions(),
4603 docs.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
4604 );
4605 assert_eq!(bpl.pos_cursor(0), Some(0));
4606 assert_eq!(
4607 bpl.pos_cursor(1),
4608 Some(docs[..128].iter().map(|&(_, tf)| tf as u64).sum::<u64>())
4609 );
4610 assert_eq!(iterator_cursors(&bpl), expected_cursors(&docs));
4611
4612 let bytes = serialize_bpl(&bpl);
4613 assert_eq!(
4614 bytes.len(),
4615 bpl.stream.len()
4616 + bpl.l0_count * (L0_SIZE + CURSOR_SIZE)
4617 + bpl.l1_docs.len() * (L1_SIZE + 4)
4618 + FOOTER_V2_SIZE
4619 );
4620 assert!(BlockPostingList::has_cursors_bytes(&bytes));
4621 let decoded =
4622 BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4623 assert_eq!(iterator_cursors(&decoded), expected_cursors(&docs));
4624 assert_eq!(decoded.total_positions(), bpl.total_positions());
4625
4626 let mut it = decoded.iterator();
4628 let expected = expected_cursors(&docs);
4629 for (i, &(doc, _)) in docs.iter().enumerate().step_by(37) {
4630 assert_eq!(it.seek(doc), doc);
4631 assert_eq!(it.position_cursor(), expected[i], "cursor at doc {doc}");
4632 }
4633 let mut it = decoded.iterator();
4634 assert_eq!(it.seek(docs[600].0 + 1), docs[601].0);
4635 assert_eq!(it.position_cursor(), expected[601]);
4636
4637 let plain = build_bpl(&docs);
4640 assert!(!plain.has_position_cursors());
4641 assert_eq!(plain.pos_cursor(0), None);
4642 assert_eq!(plain.total_positions(), 0);
4643 }
4644
4645 #[test]
4646 fn length_bounds_are_packed_per_block_and_survive_merges() {
4647 let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i, i % 3 + 1)).collect();
4648 let length_of = |doc: u32| 10 + (doc % 50) * 7;
4649 let mut list = PostingList::new();
4650 for &(doc, tf) in &docs {
4651 list.push(doc, tf);
4652 }
4653 let bpl = BlockPostingList::from_posting_list_with(&list, true, Some(&length_of)).unwrap();
4654 assert_eq!(bpl.min_len(), Some(10));
4655 assert_eq!(bpl.block_bounds(0), Some((3, Some(10))));
4656 assert_eq!(bpl.block_bounds(2), Some((3, Some(52))));
4658 assert_eq!(bpl.block_max_tf(2), Some(3));
4659
4660 let bytes = serialize_bpl(&bpl);
4661 let decoded = BlockPostingList::deserialize(&bytes).unwrap();
4662 assert_eq!(decoded.min_len(), Some(10));
4663 assert_eq!(decoded.block_bounds(2), Some((3, Some(52))));
4664 assert_eq!(decoded.group_bounds(0), Some((3, 10)));
4667 assert_eq!(decoded.group_bounds(2), Some((3, 10)));
4668 assert_eq!(decoded.group_bounds(3), None);
4669 assert_eq!(decoded.group_last_doc(1), Some(299));
4670 assert_eq!(decoded.next_group_block(1), 3);
4671
4672 let plain = build_bpl(&docs);
4674 assert_eq!(plain.min_len(), Some(1));
4675 assert_eq!(plain.block_bounds(0), Some((3, Some(1))));
4676
4677 let mut out = Vec::new();
4679 BlockPostingList::concatenate_streaming(&[(&bytes, 0), (&bytes, 1000)], &mut out).unwrap();
4680 let merged = BlockPostingList::deserialize(&out).unwrap();
4681 assert_eq!(merged.min_len(), Some(10));
4682 assert_eq!(merged.block_bounds(2), Some((3, Some(52))));
4683 assert_eq!(merged.block_bounds(3), Some((3, Some(10))));
4684 assert_eq!(merged.block_max_tf(5), Some(3));
4685 assert_eq!(merged.group_bounds(5), Some((3, 10)));
4688 assert_eq!(merged.group_last_doc(5), Some(1299));
4689 assert_eq!(merged.next_group_block(5), 6);
4690 }
4691
4692 #[test]
4693 fn legacy_footer_without_magic_still_deserializes() {
4694 let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 2, 1 + i % 3)).collect();
4695 let bpl = build_bpl(&docs);
4696 let bytes = serialize_bpl(&bpl);
4697 let footer = Footer::parse(&bytes).unwrap();
4700 let mut legacy = bytes[..footer.l1_end()].to_vec();
4701 for block in 0..bpl.num_blocks() {
4702 let at = footer.l0_start() + block * L0_SIZE + 12;
4703 legacy[at..at + 4]
4704 .copy_from_slice(&(bpl.block_max_tf(block).unwrap() as f32).to_le_bytes());
4705 }
4706 legacy.extend_from_slice(
4707 &bytes[bytes.len() - FOOTER_V2_SIZE..bytes.len() - (FOOTER_V2_SIZE - FOOTER_SIZE)],
4708 );
4709 assert!(!BlockPostingList::has_cursors_bytes(&legacy));
4710 let decoded = BlockPostingList::deserialize(&legacy).unwrap();
4712 assert_eq!(collect_postings(&decoded), docs);
4713 assert_eq!(decoded.max_tf(), 3);
4714 assert!(!decoded.has_position_cursors());
4715 assert_eq!(decoded.min_len(), None);
4716 assert_eq!(decoded.group_bounds(0), None);
4717 let mut out = Vec::new();
4719 let (count, written) =
4720 BlockPostingList::concatenate_streaming(&[(&legacy, 0), (&legacy, 1000)], &mut out)
4721 .unwrap();
4722 assert_eq!(count, 600);
4723 assert_eq!(written, out.len());
4724 let merged = BlockPostingList::deserialize(&out).unwrap();
4725 assert_eq!(merged.doc_count(), 600);
4726 assert!(!merged.has_position_cursors());
4727 }
4728
4729 #[test]
4730 fn streaming_merge_rebases_position_cursors() {
4731 let a: Vec<(u32, u32)> = (0..200u32).map(|i| (i, i % 4 + 1)).collect();
4732 let b: Vec<(u32, u32)> = (0..150u32).map(|i| (i * 2, 2)).collect();
4733 let bytes_a = serialize_bpl(&build_bpl_with_positions(&a));
4734 let bytes_b = serialize_bpl(&build_bpl_with_positions(&b));
4735 let mut out = Vec::new();
4736 let (count, written) =
4737 BlockPostingList::concatenate_streaming(&[(&bytes_a, 0), (&bytes_b, 1000)], &mut out)
4738 .unwrap();
4739 assert_eq!(count, 350);
4740 assert_eq!(written, out.len());
4741 let merged = BlockPostingList::deserialize(&out).unwrap();
4742 assert!(merged.has_position_cursors());
4743 let all: Vec<(u32, u32)> = a
4744 .iter()
4745 .copied()
4746 .chain(b.iter().map(|&(d, tf)| (d + 1000, tf)))
4747 .collect();
4748 assert_eq!(collect_postings(&merged), all);
4749 assert_eq!(iterator_cursors(&merged), expected_cursors(&all));
4750 assert_eq!(
4751 merged.total_positions(),
4752 all.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
4753 );
4754 let reference = BlockPostingList::concatenate_blocks(&[
4756 (build_bpl_with_positions(&a), 0),
4757 (build_bpl_with_positions(&b), 1000),
4758 ])
4759 .unwrap();
4760 assert_eq!(iterator_cursors(&reference), expected_cursors(&all));
4761 let plain = serialize_bpl(&build_bpl(&b));
4763 assert!(
4764 BlockPostingList::concatenate_streaming(
4765 &[(&bytes_a, 0), (&plain, 1000)],
4766 &mut Vec::new()
4767 )
4768 .is_err()
4769 );
4770 }
4771
4772 #[test]
4773 fn test_seek_block_from_block_skips_earlier() {
4774 let n = BLOCK_SIZE * 16;
4776 let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
4777 let bpl = build_bpl(&docs);
4778
4779 let target_in_5 = bpl.block_first_doc(5).unwrap() + 1;
4781 let result = bpl.seek_block(target_in_5, 8);
4784 assert!(result.is_some());
4785 assert!(result.unwrap() >= 8);
4786 }
4787 #[test]
4788 fn saturated_tf_bounds_remain_upper_bounds_without_changing_encoded_bytes() {
4789 let docs: Vec<_> = (0..(BLOCK_SIZE * 18) as u32)
4790 .map(|doc| {
4791 (
4792 doc,
4793 if doc == (BLOCK_SIZE * 17) as u32 {
4794 100_000
4795 } else {
4796 1
4797 },
4798 )
4799 })
4800 .collect();
4801 let list = build_bpl(&docs);
4802 let bytes = serialize_bpl(&list);
4803 assert_eq!(list.max_tf(), 100_000);
4804 assert!(list.block_bounds(17).unwrap().0 >= 100_000);
4805 assert!(list.group_bounds(17).unwrap().0 >= 100_000);
4806 assert_eq!(list.block_bounds(0).unwrap().0, 1);
4807 let decoded = BlockPostingList::deserialize(&bytes).unwrap();
4808 assert!(decoded.block_bounds(17).unwrap().0 >= 100_000);
4809 assert!(decoded.group_bounds(17).unwrap().0 >= 100_000);
4810 assert_eq!(serialize_bpl(&decoded), bytes);
4811 assert_eq!(collect_postings(&decoded), docs);
4812 }
4813 #[test]
4814 fn ratio_bounds_preserve_payload_bytes_and_copy_merge_with_legacy_blocks() {
4815 for codec in [
4816 PostingCodec::Rounded,
4817 PostingCodec::Packed,
4818 PostingCodec::Pfor,
4819 PostingCodec::Simd4x,
4820 ] {
4821 let mut postings = PostingList::new();
4822 for i in 0..1100 {
4823 postings.push(i * 3, 1 + i % 97);
4824 }
4825 let length = |id| 10 + id % 997;
4826 let plain = BlockPostingList::from_posting_list_with_options(
4827 &postings,
4828 true,
4829 Some(&length),
4830 codec,
4831 )
4832 .unwrap();
4833 let tight = BlockPostingList::from_posting_list_with_ratio_bounds(
4834 &postings,
4835 true,
4836 Some(&length),
4837 codec,
4838 )
4839 .unwrap();
4840 assert_eq!(plain.stream.as_slice(), tight.stream.as_slice());
4841 assert_eq!(plain.l0_bytes.as_slice(), tight.l0_bytes.as_slice());
4842 assert_eq!(
4843 plain.pos_cursors.as_ref().map(OwnedBytes::as_slice),
4844 tight.pos_cursors.as_ref().map(OwnedBytes::as_slice)
4845 );
4846 let mut raw = Vec::new();
4847 tight.serialize(&mut raw).unwrap();
4848 let decoded = BlockPostingList::deserialize(&raw).unwrap();
4849 assert!(decoded.has_ratio_bounds());
4850 for block in 0..tight.num_blocks() {
4851 assert!(decoded.block_length_ratio(block) > 0.0);
4852 for posting in &postings.postings
4853 [block * BLOCK_SIZE..((block + 1) * BLOCK_SIZE).min(postings.len())]
4854 {
4855 assert!(
4856 decoded.block_length_ratio(block) as f64
4857 <= length(posting.doc_id) as f64 / posting.term_freq as f64
4858 );
4859 assert!(decoded.group_length_ratio(block) <= decoded.block_length_ratio(block));
4860 }
4861 }
4862 let merged = BlockPostingList::concatenate_blocks(&[
4863 (tight.clone(), 0),
4864 (plain.clone(), 10_000),
4865 ])
4866 .unwrap();
4867 let mut plain_raw = Vec::new();
4868 plain.serialize(&mut plain_raw).unwrap();
4869 let mut streaming = Vec::new();
4870 let (_, size) = BlockPostingList::concatenate_streaming(
4871 &[(&raw, 0), (&plain_raw, 10_000)],
4872 &mut streaming,
4873 )
4874 .unwrap();
4875 let mut expected = Vec::new();
4876 merged.serialize(&mut expected).unwrap();
4877 assert_eq!(streaming, expected);
4878 assert_eq!(streaming.len(), size);
4879 for block in 0..tight.num_blocks() {
4880 assert_eq!(
4881 merged.block_length_ratio(block),
4882 tight.block_length_ratio(block)
4883 );
4884 assert_eq!(merged.block_length_ratio(block + tight.num_blocks()), 0.0);
4885 let mut before = Vec::new();
4886 let mut after = Vec::new();
4887 let mut tfs_before = Vec::new();
4888 let mut tfs_after = Vec::new();
4889 tight.decode_block_into(block, &mut before, &mut tfs_before);
4890 merged.decode_block_into(block, &mut after, &mut tfs_after);
4891 assert_eq!((before, tfs_before), (after, tfs_after));
4892 }
4893 let footer = Footer::parse(&raw).unwrap();
4896 raw.drain(footer.cursors_end()..footer.ratios_end());
4897 let flags = raw.len() - 12;
4898 raw[flags] &= !(FLAG_RATIO_BOUNDS as u8);
4899 let legacy_view = BlockPostingList::deserialize(&raw).unwrap();
4900 assert!(!legacy_view.has_ratio_bounds());
4901 assert_eq!(legacy_view.stream.as_slice(), tight.stream.as_slice());
4902 }
4903 }
4904
4905 #[test]
4906 fn ratio_bounds_reject_truncation_nonfinite_negative_and_unknown_flags() {
4907 let mut postings = PostingList::new();
4908 postings.push(1, 3);
4909 let list = BlockPostingList::from_posting_list_with_ratio_bounds(
4910 &postings,
4911 false,
4912 Some(&|_| 7),
4913 PostingCodec::Rounded,
4914 )
4915 .unwrap();
4916 let mut raw = Vec::new();
4917 list.serialize(&mut raw).unwrap();
4918 let footer = Footer::parse(&raw).unwrap();
4919 for bad in [f32::NAN, f32::INFINITY, -1.0] {
4920 let mut corrupt = raw.clone();
4921 corrupt[footer.cursors_end()..footer.cursors_end() + 4]
4922 .copy_from_slice(&bad.to_le_bytes());
4923 assert!(BlockPostingList::deserialize(&corrupt).is_err());
4924 assert!(
4925 BlockPostingList::concatenate_streaming(&[(&corrupt, 0)], &mut Vec::new()).is_err()
4926 );
4927 }
4928 let mut short = raw.clone();
4929 short.remove(footer.cursors_end());
4930 assert!(BlockPostingList::deserialize(&short).is_err());
4931 let at = raw.len() - 12;
4932 raw[at] |= 128;
4933 assert!(BlockPostingList::deserialize(&raw).is_err());
4934 }
4935
4936 const ALL_CODECS: [PostingCodec; 4] = [
4937 PostingCodec::Rounded,
4938 PostingCodec::Packed,
4939 PostingCodec::Pfor,
4940 PostingCodec::Simd4x,
4941 ];
4942
4943 fn first_delta_byte(list: &BlockPostingList, block: usize) -> usize {
4945 let (_, _, offset, _) = list.read_l0_entry(block);
4946 let header = offset as usize;
4947 header + 8 + usize::from(list.block_codec(block) == Some(PostingCodec::Pfor))
4949 }
4950
4951 #[test]
4954 fn content_corrupt_block_never_panics_or_truncates_silently() {
4955 for codec in ALL_CODECS {
4956 let mut postings = PostingList::new();
4959 for doc in 0..128 {
4960 postings.push(doc, doc % 5 + 1);
4961 }
4962 postings.push(200, 2);
4963 postings.push(300, 3);
4964 let list =
4965 BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
4966 .unwrap();
4967 let bytes = serialize_bpl(&list);
4968 let at = first_delta_byte(&list, 1);
4969 assert_eq!(
4970 bytes[at], 100,
4971 "{codec}: delta 300-200 at the first payload byte"
4972 );
4973 let mut corrupt = bytes.clone();
4974 corrupt[at] = 1; let admitted = BlockPostingList::deserialize(&corrupt)
4976 .unwrap_or_else(|e| panic!("{codec}: structural admission must pass: {e}"));
4977 let mut cursor = admitted.iterator();
4979 assert_eq!(cursor.seek(250), TERMINATED, "{codec}");
4980 assert_eq!(cursor.term_freq(), 0);
4981 assert_eq!(cursor.advance(), TERMINATED);
4982 let mut decoded = Vec::new();
4983 assert!(
4984 admitted
4985 .decode_block_doc_ids_only(0, &mut decoded)
4986 .is_some()
4987 );
4988 assert_eq!(decoded.len(), 128);
4989 assert!(
4990 admitted
4991 .decode_block_doc_ids_only(1, &mut decoded)
4992 .is_none(),
4993 "{codec}: a content-corrupt block must be reported, not decoded"
4994 );
4995 assert!(decoded.is_empty(), "no ids escape a corrupt block");
4996 assert!(!admitted.decode_block_into(1, &mut decoded, &mut Vec::new()));
4997 assert_eq!(
5000 collect_postings(&admitted),
5001 collect_postings(&list)[..128],
5002 "{codec}"
5003 );
5004 let mut window = admitted.iterator();
5005 let mut bits = [0u64; 8];
5006 window.fill_doc_window(0, &mut bits);
5007 assert_eq!(bits[..2], [u64::MAX, u64::MAX]);
5008 assert_eq!(window.doc(), TERMINATED);
5009 assert_eq!(
5011 collect_postings(&BlockPostingList::deserialize(&bytes).unwrap()).len(),
5012 130
5013 );
5014 }
5015 let mut postings = PostingList::new();
5018 for doc in 0..128 {
5019 postings.push(doc, 1);
5020 }
5021 for i in 0..128 {
5022 postings.push(200 + i * 2, 1);
5023 }
5024 let list = BlockPostingList::from_posting_list_with_codec(&postings, PostingCodec::Simd4x)
5025 .unwrap();
5026 assert_eq!(list.block_codec(1), Some(PostingCodec::Simd4x));
5027 let mut bytes = serialize_bpl(&list);
5028 let at = first_delta_byte(&list, 1) + 1;
5029 assert_ne!(bytes[at], 0);
5030 bytes[at] = 0;
5031 let admitted = BlockPostingList::deserialize(&bytes).unwrap();
5032 let mut cursor = admitted.iterator();
5033 assert_eq!(cursor.seek(450), TERMINATED);
5034 assert!(
5035 admitted
5036 .decode_block_doc_ids_only(1, &mut Vec::new())
5037 .is_none()
5038 );
5039 assert_eq!(collect_postings(&admitted).len(), 128);
5040 }
5041
5042 fn simd_exact_tail_bytes(docs: &[(u32, u32)]) -> Vec<u8> {
5046 assert!(!docs.is_empty() && docs.len() < BLOCK_SIZE);
5047 let count = docs.len();
5048 let (first, last) = (docs[0].0, docs[count - 1].0);
5049 let deltas: Vec<u32> = docs.windows(2).map(|w| w[1].0 - w[0].0).collect();
5050 let tfs: Vec<u32> = docs.iter().map(|d| d.1).collect();
5051 let max_tf = tfs.iter().copied().max().unwrap();
5052 let mut stream = Vec::new();
5053 stream.write_u16::<LittleEndian>(count as u16).unwrap();
5054 stream.write_u32::<LittleEndian>(first).unwrap();
5055 let header_at = stream.len();
5056 stream.extend_from_slice(&[0, 0]);
5057 let doc_bits = bitpacking4x::encode_gaps(&deltas, &mut stream);
5058 let tf_bits = bitpacking4x::encode(&tfs, &mut stream);
5059 stream[header_at] = PostingCodec::Simd4x.header_byte(doc_bits);
5060 stream[header_at + 1] = tf_bits;
5061 let mut bytes = stream.clone();
5062 write_l0(&mut bytes, first, last, 0, pack_bounds(max_tf, 1));
5063 bytes.extend_from_slice(&last.to_le_bytes());
5064 bytes.extend_from_slice(&pack_bounds(max_tf, 1).to_le_bytes());
5065 BlockPostingList::write_footer(
5066 &mut bytes,
5067 stream.len() as u64,
5068 1,
5069 1,
5070 count as u32,
5071 max_tf,
5072 0,
5073 false,
5074 Some(1),
5075 true,
5076 false,
5077 false,
5078 false,
5079 0,
5080 )
5081 .unwrap();
5082 bytes
5083 }
5084
5085 #[test]
5086 fn simd4x_exact_tail_blocks_decode_seek_and_copy_through_merge() {
5087 for count in [1usize, 2, 3, 17, 127] {
5088 let docs: Vec<(u32, u32)> = (0..count as u32)
5089 .map(|i| (i * 3 + 7, i % 4 + 1 + (i == 5) as u32 * 900))
5090 .collect();
5091 let bytes = simd_exact_tail_bytes(&docs);
5092 let list = BlockPostingList::deserialize(&bytes).unwrap();
5093 assert_eq!(
5094 list.block_codec(0),
5095 Some(PostingCodec::Simd4x),
5096 "count={count}"
5097 );
5098 assert_eq!(collect_postings(&list), docs, "count={count}");
5099 let mut cursor = list.iterator();
5100 for &(doc, tf) in &docs {
5101 assert_eq!(cursor.seek(doc.saturating_sub(1)), doc);
5102 assert_eq!(cursor.term_freq(), tf);
5103 }
5104 assert_eq!(cursor.seek(docs[count - 1].0 + 1), TERMINATED);
5105 assert_eq!(serialize_bpl(&list), bytes, "byte-identical round trip");
5106 let mut out = Vec::new();
5108 let (merged_count, written) =
5109 BlockPostingList::concatenate_streaming(&[(&bytes, 0), (&bytes, 1000)], &mut out)
5110 .unwrap();
5111 assert_eq!((merged_count as usize, written), (2 * count, out.len()));
5112 let merged = BlockPostingList::deserialize(&out).unwrap();
5113 assert_eq!(merged.block_codec(1), Some(PostingCodec::Simd4x));
5114 let expected: Vec<(u32, u32)> = docs
5115 .iter()
5116 .copied()
5117 .chain(docs.iter().map(|&(d, t)| (d + 1000, t)))
5118 .collect();
5119 assert_eq!(collect_postings(&merged), expected, "count={count}");
5120 let typed =
5121 BlockPostingList::concatenate_blocks(&[(list.clone(), 0), (list.clone(), 1000)])
5122 .unwrap();
5123 assert_eq!(serialize_bpl(&typed), out);
5124 let mut short = bytes.clone();
5126 short[0] = (count + 1) as u8;
5127 assert!(BlockPostingList::deserialize(&short).is_err());
5128 }
5129 }
5130
5131 #[test]
5135 fn legacy_footer_whose_max_tf_equals_the_magic_is_rejected_not_misread() {
5136 for count in [1u32, 2, 130, 700] {
5137 let mut postings = PostingList::new();
5138 for i in 0..count {
5139 postings.push(i * 2, if i == 0 { FOOTER_MAGIC } else { 1 });
5140 }
5141 let bpl = BlockPostingList::from_posting_list(&postings).unwrap();
5142 assert_eq!(bpl.max_tf(), FOOTER_MAGIC);
5143 let bytes = serialize_bpl(&bpl);
5144 let footer = Footer::parse(&bytes).unwrap();
5145 let mut legacy = bytes[..footer.l1_end()].to_vec();
5146 for block in 0..bpl.num_blocks() {
5147 let at = footer.l0_start() + block * L0_SIZE + 12;
5148 legacy[at..at + 4]
5149 .copy_from_slice(&(bpl.block_max_tf(block).unwrap() as f32).to_le_bytes());
5150 }
5151 legacy.extend_from_slice(
5152 &bytes[bytes.len() - FOOTER_V2_SIZE..bytes.len() - (FOOTER_V2_SIZE - FOOTER_SIZE)],
5153 );
5154 assert_eq!(
5155 u32::from_le_bytes(legacy[legacy.len() - 4..].try_into().unwrap()),
5156 FOOTER_MAGIC
5157 );
5158 assert!(
5159 BlockPostingList::deserialize(&legacy).is_err(),
5160 "count={count}: ambiguous footer must not be read as either layout"
5161 );
5162 assert!(!BlockPostingList::has_cursors_bytes(&legacy));
5163 let mut out = vec![0xab];
5164 assert!(BlockPostingList::concatenate_streaming(&[(&legacy, 0)], &mut out).is_err());
5165 assert_eq!(out, [0xab]);
5166 assert_eq!(
5168 BlockPostingList::deserialize(&bytes).unwrap().max_tf(),
5169 FOOTER_MAGIC
5170 );
5171 }
5172 }
5173
5174 #[test]
5175 fn mixed_cursor_and_cursorless_sources_fail_loudly_before_output() {
5176 let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 2, i % 3 + 1)).collect();
5177 let with = build_bpl_with_positions(&docs);
5178 let without = build_bpl(&docs);
5179 assert!(with.has_position_cursors() && !without.has_position_cursors());
5180 for sources in [
5181 [(with.clone(), 0), (without.clone(), 1000)],
5182 [(without.clone(), 0), (with.clone(), 1000)],
5183 ] {
5184 let error = BlockPostingList::concatenate_blocks(&sources).unwrap_err();
5185 assert!(
5186 error
5187 .to_string()
5188 .contains("with and without position cursors"),
5189 "{error}"
5190 );
5191 let bytes: Vec<Vec<u8>> = sources.iter().map(|(s, _)| serialize_bpl(s)).collect();
5192 let mut out = vec![0xab];
5193 let result = BlockPostingList::concatenate_streaming(
5194 &[(&bytes[0], 0), (&bytes[1], 1000)],
5195 &mut out,
5196 );
5197 match result {
5198 Err(crate::Error::Corruption(message)) => {
5199 assert!(
5200 message.contains("with and without position cursors"),
5201 "{message}"
5202 )
5203 }
5204 other => panic!("expected a loud corruption error, got {other:?}"),
5205 }
5206 assert_eq!(out, [0xab], "nothing is written for a rejected source set");
5207 }
5208 }
5209}
5210
5211#[cfg(test)]
5212#[path = "posting/merge_admission_tests.rs"]
5213mod merge_admission_tests;
5214
5215#[cfg(test)]
5216mod byte_gap_validation_tests {
5217 use super::*;
5218
5219 #[test]
5220 fn strict_gap_validation_agrees_with_full_scan_at_width_and_wrap_boundaries() {
5221 for count in 1..=BLOCK_SIZE {
5222 for width in 0..=32 {
5223 let mask = if width == 0 {
5224 0
5225 } else {
5226 u32::MAX >> (32 - width)
5227 };
5228 for first in [0u32, 17, u32::MAX / 2, u32::MAX - 1] {
5229 for pattern in 0..3 {
5230 let mut docs = vec![first];
5231 for i in 1..count {
5232 let encoded = match pattern {
5233 0 => mask,
5234 1 => 0,
5235 _ => (i as u32).wrapping_mul(0x9e3779b9) & mask,
5236 };
5237 docs.push(docs.last().unwrap().wrapping_add(encoded).wrapping_add(1));
5238 }
5239 let end = *docs.last().unwrap();
5240 for last in [end, end.wrapping_add(1), end.wrapping_sub(1)] {
5241 assert_eq!(
5242 verify_block_docs(&docs, first, last, None, Some(width)),
5243 verify_block_docs(&docs, first, last, None, None),
5244 "count={count}, width={width}, first={first}, pattern={pattern}, last={last}"
5245 );
5246 }
5247 }
5248 }
5249 }
5250 }
5251 }
5252
5253 #[test]
5254 fn byte_gap_validation_agrees_with_decoded_order_for_tails_and_unsigned_wraps() {
5255 for count in 1..=BLOCK_SIZE {
5256 for first in [0u32, 17, u32::MAX - 400, u32::MAX - 1] {
5257 for pattern in 0..5 {
5258 let gaps: Vec<u8> = (1..count)
5259 .map(|i| match pattern {
5260 0 => 1,
5261 1 => 255,
5262 2 => {
5263 if i == count / 2 {
5264 0
5265 } else {
5266 1
5267 }
5268 }
5269 3 => (i * 71) as u8,
5270 _ => ((i * 71) as u8).max(1),
5271 })
5272 .collect();
5273 let mut docs = vec![first];
5274 for &gap in &gaps {
5275 docs.push(docs.last().unwrap().wrapping_add(u32::from(gap)));
5276 }
5277 let end = *docs.last().unwrap();
5278 for last in [end, end.wrapping_add(1), end.wrapping_sub(1)] {
5279 assert_eq!(
5280 verify_block_docs(&docs, first, last, Some(&gaps), None),
5281 verify_block_docs(&docs, first, last, None, None),
5282 "count={count}, first={first}, pattern={pattern}, last={last}"
5283 );
5284 }
5285 }
5286 }
5287 }
5288 }
5289}