1use super::bm25::{DocLen, Tf};
53
54pub const DEFAULT_POSTINGS_PER_BLOCK: u16 = 64;
56
57pub const BLOCK_META_LEN: usize = 32;
59
60const POSTINGS_MAGIC: [u8; 4] = *b"ZPST";
62
63pub const POSTINGS_VERSION_V1: u16 = 1;
70
71pub const POSTINGS_VERSION: u16 = 2;
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum PostingsError {
81 Truncated {
83 needed: usize,
85 available: usize,
87 },
88 BadMagic,
90 UnsupportedVersion {
92 found: u16,
94 },
95 BitWidthTooLarge {
97 bits: u8,
99 },
100 ZeroBlockSize,
102 DocidsNotAscending {
104 docid: u32,
106 },
107 PositionsNotAscending {
109 position: u32,
111 },
112 ZeroTermFrequency,
114 InconsistentPostingCount {
116 declared: u32,
118 described: u32,
120 },
121}
122
123impl std::fmt::Display for PostingsError {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Self::Truncated { needed, available } => write!(
127 formatter,
128 "posting stream truncated: needed {needed} bytes, had {available}"
129 ),
130 Self::BadMagic => formatter.write_str("posting stream magic did not match"),
131 Self::UnsupportedVersion { found } => {
132 write!(formatter, "posting stream version {found} is not readable")
133 }
134 Self::BitWidthTooLarge { bits } => {
135 write!(formatter, "declared bit width {bits} exceeds 32")
136 }
137 Self::ZeroBlockSize => formatter.write_str("posting block size was zero"),
138 Self::DocidsNotAscending { docid } => {
139 write!(formatter, "document ids are not ascending at {docid}")
140 }
141 Self::PositionsNotAscending { position } => {
142 write!(formatter, "positions are not ascending at {position}")
143 }
144 Self::ZeroTermFrequency => formatter.write_str("a posting had a zero term frequency"),
145 Self::InconsistentPostingCount {
146 declared,
147 described,
148 } => write!(
149 formatter,
150 "header declares {declared} postings, blocks describe {described}"
151 ),
152 }
153 }
154}
155
156impl std::error::Error for PostingsError {}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct Posting {
161 pub docid: u32,
163 pub tf: u32,
165 pub positions: Vec<u32>,
167}
168
169#[cfg(test)]
170thread_local! {
171 static ACCOUNTING_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
172}
173
174#[cfg(test)]
175pub(crate) fn take_accounting_visits() -> usize {
176 ACCOUNTING_VISITS.with(|count| count.replace(0))
177}
178
179#[inline]
180pub(crate) fn count_accounting_visits(amount: usize) {
181 #[cfg(test)]
182 ACCOUNTING_VISITS.with(|count| count.set(count.get() + amount));
183 #[cfg(not(test))]
184 let _ = amount;
185}
186
187#[derive(Clone, Debug, Default, Eq, PartialEq)]
189pub struct PostingList {
190 postings: Vec<Posting>,
191}
192
193impl PostingList {
194 #[must_use]
196 pub const fn new() -> Self {
197 Self {
198 postings: Vec::new(),
199 }
200 }
201
202 #[must_use]
204 pub fn postings(&self) -> &[Posting] {
205 &self.postings
206 }
207
208 pub(crate) fn allocation_bytes(&self) -> usize {
209 self.postings
210 .capacity()
211 .saturating_mul(std::mem::size_of::<Posting>())
212 }
213
214 pub(crate) fn resident_bytes(&self) -> usize {
215 count_accounting_visits(self.postings.len());
216 self.postings
217 .capacity()
218 .saturating_mul(std::mem::size_of::<Posting>())
219 .saturating_add(
220 self.postings
221 .iter()
222 .map(|posting| posting.positions.capacity().saturating_mul(4))
223 .fold(0_usize, usize::saturating_add),
224 )
225 }
226
227 #[must_use]
229 pub fn document_frequency(&self) -> u32 {
230 u32::try_from(self.postings.len()).unwrap_or(u32::MAX)
231 }
232
233 #[must_use]
235 pub fn is_empty(&self) -> bool {
236 self.postings.is_empty()
237 }
238
239 pub fn push(&mut self, posting: Posting) -> Result<(), PostingsError> {
248 if posting.tf == 0 || posting.positions.is_empty() {
249 return Err(PostingsError::ZeroTermFrequency);
250 }
251 if let Some(last) = self.postings.last()
252 && posting.docid <= last.docid
253 {
254 return Err(PostingsError::DocidsNotAscending {
255 docid: posting.docid,
256 });
257 }
258 Self::validate_positions(&posting)?;
259 self.postings.push(posting);
260 Ok(())
261 }
262
263 pub(crate) fn insert(&mut self, posting: Posting) -> Result<(), PostingsError> {
271 if posting.tf == 0 || posting.positions.is_empty() {
272 return Err(PostingsError::ZeroTermFrequency);
273 }
274 let index = self
275 .postings
276 .partition_point(|existing| existing.docid < posting.docid);
277 if self
278 .postings
279 .get(index)
280 .is_some_and(|existing| existing.docid == posting.docid)
281 {
282 return Err(PostingsError::DocidsNotAscending {
283 docid: posting.docid,
284 });
285 }
286 Self::validate_positions(&posting)?;
287 self.postings.insert(index, posting);
288 Ok(())
289 }
290
291 pub(crate) fn remove(&mut self, docid: u32) -> bool {
293 match self
294 .postings
295 .binary_search_by_key(&docid, |posting| posting.docid)
296 {
297 Ok(index) => {
298 self.postings.remove(index);
299 true
300 }
301 Err(_) => false,
302 }
303 }
304
305 fn validate_positions(posting: &Posting) -> Result<(), PostingsError> {
306 let mut previous: Option<u32> = None;
307 for position in &posting.positions {
308 if let Some(earlier) = previous
309 && *position <= earlier
310 {
311 return Err(PostingsError::PositionsNotAscending {
312 position: *position,
313 });
314 }
315 previous = Some(*position);
316 }
317 Ok(())
318 }
319}
320
321fn required_bits_controlled<E>(
323 values: &[u32],
324 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
325) -> Result<u8, E> {
326 let mut maximum = 0;
327 for value in values {
328 work.step()?;
329 #[cfg(test)]
330 ENCODE_BITS_PROBES.with(|value| value.set(value.get() + 1));
331 maximum = maximum.max(*value);
332 }
333 Ok(if maximum == 0 {
334 0
335 } else {
336 u8::try_from(32 - maximum.leading_zeros()).unwrap_or(32)
338 })
339}
340
341fn pack_bits_controlled<E>(
343 values: &[u32],
344 bits: u8,
345 output: &mut Vec<u8>,
346 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
347) -> Result<(), E> {
348 if bits == 0 {
349 return Ok(());
350 }
351 let width = u32::from(bits);
352 let mut accumulator: u64 = 0;
353 let mut filled: u32 = 0;
354 for value in values {
355 work.step()?;
356 #[cfg(test)]
357 ENCODE_PACK_PROBES.with(|value| value.set(value.get() + 1));
358 accumulator |= u64::from(*value) << filled;
359 filled += width;
360 while filled >= 8 {
361 output.push((accumulator & 0xFF) as u8);
362 accumulator >>= 8;
363 filled -= 8;
364 }
365 }
366 if filled > 0 {
367 output.push((accumulator & 0xFF) as u8);
368 }
369 Ok(())
370}
371
372#[cfg(test)]
373fn pack_bits(values: &[u32], bits: u8, output: &mut Vec<u8>) {
374 let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
375 match pack_bits_controlled(values, bits, output, &mut work) {
376 Ok(()) => (),
377 Err(never) => match never {},
378 }
379}
380
381const fn packed_len(count: usize, bits: u8) -> usize {
383 if bits == 0 {
384 return 0;
385 }
386 (count * bits as usize).div_ceil(8)
387}
388
389pub fn unpack_bits(
399 input: &[u8],
400 bits: u8,
401 count: usize,
402 output: &mut [u32],
403) -> Result<usize, PostingsError> {
404 if bits > 32 {
405 return Err(PostingsError::BitWidthTooLarge { bits });
406 }
407 if bits == 0 {
408 for slot in output.iter_mut().take(count) {
409 *slot = 0;
410 }
411 return Ok(0);
412 }
413 let needed = packed_len(count, bits);
414 if input.len() < needed {
415 return Err(PostingsError::Truncated {
416 needed,
417 available: input.len(),
418 });
419 }
420 let width = u32::from(bits);
421 let mask = if bits == 32 {
422 u32::MAX
423 } else {
424 (1_u32 << width) - 1
425 };
426 let mut accumulator: u64 = 0;
427 let mut filled: u32 = 0;
428 let mut cursor = 0_usize;
429 for slot in output.iter_mut().take(count) {
430 while filled < width {
431 let byte = input.get(cursor).copied().unwrap_or(0);
432 cursor += 1;
433 accumulator |= u64::from(byte) << filled;
434 filled += 8;
435 }
436 *slot = (accumulator as u32) & mask;
437 accumulator >>= width;
438 filled -= width;
439 }
440 Ok(needed)
441}
442
443#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
445pub struct BlockMeta {
446 pub last_docid: u32,
448 pub docids_offset: u32,
450 pub tfs_offset: u32,
452 pub positions_offset: u32,
454 pub positions_count: u32,
456 pub count: u16,
458 pub docid_bits: u8,
460 pub tf_bits: u8,
462 pub position_bits: u8,
464 pub block_max: u8,
473 pub max_tf: u32,
475 pub min_len: u16,
480}
481
482impl BlockMeta {
483 fn write(&self, output: &mut Vec<u8>) {
484 output.extend_from_slice(&self.last_docid.to_le_bytes());
485 output.extend_from_slice(&self.docids_offset.to_le_bytes());
486 output.extend_from_slice(&self.tfs_offset.to_le_bytes());
487 output.extend_from_slice(&self.positions_offset.to_le_bytes());
488 output.extend_from_slice(&self.positions_count.to_le_bytes());
489 output.extend_from_slice(&self.count.to_le_bytes());
490 output.push(self.docid_bits);
491 output.push(self.tf_bits);
492 output.push(self.position_bits);
493 output.push(self.block_max);
494 output.extend_from_slice(&self.max_tf.to_le_bytes());
499 output.extend_from_slice(&self.min_len.to_le_bytes());
500 }
501
502 pub fn read(input: &[u8]) -> Result<Self, PostingsError> {
509 let row = input
510 .get(..BLOCK_META_LEN)
511 .ok_or(PostingsError::Truncated {
512 needed: BLOCK_META_LEN,
513 available: input.len(),
514 })?;
515 Ok(Self {
516 last_docid: read_u32(row, 0)?,
517 docids_offset: read_u32(row, 4)?,
518 tfs_offset: read_u32(row, 8)?,
519 positions_offset: read_u32(row, 12)?,
520 positions_count: read_u32(row, 16)?,
521 count: read_u16(row, 20)?,
522 docid_bits: read_u8(row, 22)?,
523 tf_bits: read_u8(row, 23)?,
524 position_bits: read_u8(row, 24)?,
525 block_max: read_u8(row, 25)?,
526 max_tf: read_u32(row, 26)?,
527 min_len: read_u16(row, 30)?,
528 })
529 }
530}
531
532fn read_u8(input: &[u8], at: usize) -> Result<u8, PostingsError> {
533 input.get(at).copied().ok_or(PostingsError::Truncated {
534 needed: at + 1,
535 available: input.len(),
536 })
537}
538
539fn read_u16(input: &[u8], at: usize) -> Result<u16, PostingsError> {
540 let bytes = input.get(at..at + 2).ok_or(PostingsError::Truncated {
541 needed: at + 2,
542 available: input.len(),
543 })?;
544 let mut buffer = [0_u8; 2];
545 buffer.copy_from_slice(bytes);
546 Ok(u16::from_le_bytes(buffer))
547}
548
549fn read_u32(input: &[u8], at: usize) -> Result<u32, PostingsError> {
550 let bytes = input.get(at..at + 4).ok_or(PostingsError::Truncated {
551 needed: at + 4,
552 available: input.len(),
553 })?;
554 let mut buffer = [0_u8; 4];
555 buffer.copy_from_slice(bytes);
556 Ok(u32::from_le_bytes(buffer))
557}
558
559#[derive(Clone, Debug, Default, Eq, PartialEq)]
561pub struct EncodedPostings {
562 bytes: Vec<u8>,
563}
564
565impl EncodedPostings {
566 #[must_use]
568 pub fn as_bytes(&self) -> &[u8] {
569 &self.bytes
570 }
571
572 #[must_use]
574 pub const fn from_bytes(bytes: Vec<u8>) -> Self {
575 Self { bytes }
576 }
577}
578
579pub const HEADER_LEN: usize = 16;
581
582pub fn encode(
595 list: &PostingList,
596 postings_per_block: u16,
597 block_maxima: &[u8],
598) -> Result<EncodedPostings, PostingsError> {
599 encode_versioned(
600 list.postings(),
601 postings_per_block,
602 block_maxima,
603 &[],
604 POSTINGS_VERSION_V1,
605 &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
606 )
607}
608
609pub fn encode_v2(
619 list: &PostingList,
620 postings_per_block: u16,
621 block_maxima: &[u8],
622 impacts: &[BlockImpact],
623) -> Result<EncodedPostings, PostingsError> {
624 encode_versioned(
625 list.postings(),
626 postings_per_block,
627 block_maxima,
628 impacts,
629 POSTINGS_VERSION,
630 &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
631 )
632}
633
634pub(crate) fn encode_v2_controlled<E: From<PostingsError>>(
635 list: &PostingList,
636 postings_per_block: u16,
637 block_maxima: &[u8],
638 impacts: &[BlockImpact],
639 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
640) -> Result<EncodedPostings, E> {
641 encode_versioned(
642 list.postings(),
643 postings_per_block,
644 block_maxima,
645 impacts,
646 POSTINGS_VERSION,
647 work,
648 )
649}
650
651fn encode_versioned<E: From<PostingsError>>(
652 postings: &[Posting],
653 postings_per_block: u16,
654 block_maxima: &[u8],
655 impacts: &[BlockImpact],
656 version: u16,
657 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
658) -> Result<EncodedPostings, E> {
659 work.check_now()?;
660 if postings_per_block == 0 {
661 return Err(PostingsError::ZeroBlockSize.into());
662 }
663 let per_block = usize::from(postings_per_block);
664 let block_count = postings.len().div_ceil(per_block);
665
666 let mut docid_stream: Vec<u8> = Vec::new();
667 let mut tf_stream: Vec<u8> = Vec::new();
668 let mut position_stream: Vec<u8> = Vec::new();
669 let mut metadata: Vec<BlockMeta> = Vec::with_capacity(block_count);
670
671 let mut previous_last_docid = 0_u32;
672 for (index, chunk) in postings.chunks(per_block).enumerate() {
673 work.step()?;
674 let mut deltas: Vec<u32> = Vec::with_capacity(chunk.len());
675 let mut frequencies: Vec<u32> = Vec::with_capacity(chunk.len());
676 let mut position_deltas: Vec<u32> = Vec::new();
677
678 let mut base = previous_last_docid;
679 for (offset, posting) in chunk.iter().enumerate() {
680 work.step()?;
681 let delta = if index == 0 && offset == 0 {
684 posting.docid
685 } else {
686 posting.docid.saturating_sub(base)
687 };
688 deltas.push(delta);
689 frequencies.push(posting.tf);
690 let mut previous_position = 0_u32;
691 for (slot, position) in posting.positions.iter().enumerate() {
692 work.step()?;
693 #[cfg(test)]
694 ENCODE_POSITION_PROBES.with(|value| value.set(value.get() + 1));
695 let gap = if slot == 0 {
696 *position
697 } else {
698 position.saturating_sub(previous_position)
699 };
700 position_deltas.push(gap);
701 previous_position = *position;
702 }
703 base = posting.docid;
704 }
705 previous_last_docid = base;
706
707 let docid_bits = required_bits_controlled(&deltas, work)?;
708 let tf_bits = required_bits_controlled(&frequencies, work)?;
709 let position_bits = required_bits_controlled(&position_deltas, work)?;
710
711 let meta = BlockMeta {
712 last_docid: base,
713 docids_offset: u32::try_from(docid_stream.len()).unwrap_or(u32::MAX),
714 tfs_offset: u32::try_from(tf_stream.len()).unwrap_or(u32::MAX),
715 positions_offset: u32::try_from(position_stream.len()).unwrap_or(u32::MAX),
716 positions_count: u32::try_from(position_deltas.len()).unwrap_or(u32::MAX),
717 count: u16::try_from(chunk.len()).unwrap_or(u16::MAX),
718 docid_bits,
719 tf_bits,
720 position_bits,
721 block_max: block_maxima.get(index).copied().unwrap_or(0),
722 max_tf: impacts.get(index).map_or(0, |impact| impact.max_tf),
723 min_len: impacts.get(index).map_or(0, |impact| impact.min_len),
724 };
725
726 pack_bits_controlled(&deltas, docid_bits, &mut docid_stream, work)?;
727 pack_bits_controlled(&frequencies, tf_bits, &mut tf_stream, work)?;
728 pack_bits_controlled(&position_deltas, position_bits, &mut position_stream, work)?;
729 metadata.push(meta);
730 }
731
732 let mut bytes = Vec::with_capacity(
733 HEADER_LEN
734 + metadata.len() * BLOCK_META_LEN
735 + docid_stream.len()
736 + tf_stream.len()
737 + position_stream.len(),
738 );
739 bytes.extend_from_slice(&POSTINGS_MAGIC);
740 bytes.extend_from_slice(&version.to_le_bytes());
741 bytes.extend_from_slice(&postings_per_block.to_le_bytes());
742 bytes.extend_from_slice(
743 &u32::try_from(postings.len())
744 .unwrap_or(u32::MAX)
745 .to_le_bytes(),
746 );
747 bytes.extend_from_slice(
748 &u32::try_from(metadata.len())
749 .unwrap_or(u32::MAX)
750 .to_le_bytes(),
751 );
752 for meta in &metadata {
753 work.step()?;
754 meta.write(&mut bytes);
755 }
756 super::control::extend_bytes(&mut bytes, &docid_stream, work)?;
757 super::control::extend_bytes(&mut bytes, &tf_stream, work)?;
758 super::control::extend_bytes(&mut bytes, &position_stream, work)?;
759
760 work.check_now()?;
761 Ok(EncodedPostings { bytes })
762}
763
764#[derive(Clone, Debug)]
766pub struct PostingsReader<'bytes> {
767 version: u16,
768 blocks: Vec<BlockMeta>,
769 docids: &'bytes [u8],
770 tfs: &'bytes [u8],
771 positions: &'bytes [u8],
772 posting_count: u32,
773 postings_per_block: u16,
774}
775
776#[cfg(test)]
777std::thread_local! {
778 static ENCODE_POSITION_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
779 static ENCODE_BITS_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
780 static ENCODE_PACK_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
781 static ENCODE_IMPACT_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
782 pub(super) static POSITION_OPEN_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
783 pub(super) static POSITION_MEMBER_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
784 pub(crate) static POSITION_DECODE_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
785}
786
787impl<'bytes> PostingsReader<'bytes> {
788 pub fn positions(&self, row: u32) -> Result<Option<RowPositions<'bytes>>, PostingsError> {
794 self.positions_controlled(
795 row,
796 &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
797 )
798 }
799
800 pub(crate) fn positions_controlled<E: From<PostingsError>>(
801 &self,
802 row: u32,
803 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
804 ) -> Result<Option<RowPositions<'bytes>>, E> {
805 work.check_now()?;
806 let block = self.blocks.partition_point(|meta| meta.last_docid < row);
807 let Some(meta) = self.blocks.get(block) else {
808 return Ok(None);
809 };
810 let docids =
811 self.docids
812 .get(meta.docids_offset as usize..)
813 .ok_or(PostingsError::Truncated {
814 needed: meta.docids_offset as usize,
815 available: self.docids.len(),
816 })?;
817 let tfs = self
818 .tfs
819 .get(meta.tfs_offset as usize..)
820 .ok_or(PostingsError::Truncated {
821 needed: meta.tfs_offset as usize,
822 available: self.tfs.len(),
823 })?;
824 let mut docid = block
825 .checked_sub(1)
826 .and_then(|previous| self.blocks.get(previous))
827 .map_or(0, |meta| meta.last_docid);
828 let mut first = 0_usize;
829 for slot in 0..usize::from(meta.count) {
830 work.step()?;
831 #[cfg(test)]
832 POSITION_MEMBER_PROBES.with(|value| value.set(value.get() + 1));
833 let delta = packed_value_at(docids, meta.docid_bits, slot)?;
834 docid = docid
835 .checked_add(delta)
836 .ok_or(PostingsError::DocidsNotAscending { docid })?;
837 if docid > row {
838 return Ok(None);
839 }
840 let count = packed_value_at(tfs, meta.tf_bits, slot)? as usize;
841 if count == 0 {
842 return Err(PostingsError::ZeroTermFrequency.into());
843 }
844 let end = first.checked_add(count).ok_or(PostingsError::Truncated {
845 needed: usize::MAX,
846 available: meta.positions_count as usize,
847 })?;
848 if end > meta.positions_count as usize {
849 return Err(PostingsError::Truncated {
850 needed: end,
851 available: meta.positions_count as usize,
852 }
853 .into());
854 }
855 if docid == row {
856 let start = meta.positions_offset as usize;
857 let end = start
858 .checked_add(packed_len(
859 meta.positions_count as usize,
860 meta.position_bits,
861 ))
862 .ok_or(PostingsError::Truncated {
863 needed: usize::MAX,
864 available: self.positions.len(),
865 })?;
866 let bytes = self
867 .positions
868 .get(start..end)
869 .ok_or(PostingsError::Truncated {
870 needed: end,
871 available: self.positions.len(),
872 })?;
873 return Ok(Some(RowPositions {
874 bytes,
875 bits: meta.position_bits,
876 first,
877 count,
878 }));
879 }
880 first = end;
881 }
882 Ok(None)
883 }
884
885 pub fn open(bytes: &'bytes [u8]) -> Result<Self, PostingsError> {
897 Self::open_controlled(
898 bytes,
899 &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
900 )
901 }
902
903 pub(crate) fn open_controlled<E: From<PostingsError>>(
904 bytes: &'bytes [u8],
905 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
906 ) -> Result<Self, E> {
907 work.check_now()?;
908 let header = bytes.get(..HEADER_LEN).ok_or(PostingsError::Truncated {
909 needed: HEADER_LEN,
910 available: bytes.len(),
911 })?;
912 if header.get(..4) != Some(&POSTINGS_MAGIC[..]) {
913 return Err(PostingsError::BadMagic.into());
914 }
915 let version = read_u16(header, 4)?;
916 if version != POSTINGS_VERSION_V1 && version != POSTINGS_VERSION {
917 return Err(PostingsError::UnsupportedVersion { found: version }.into());
918 }
919 let postings_per_block = read_u16(header, 6)?;
920 if postings_per_block == 0 {
921 return Err(PostingsError::ZeroBlockSize.into());
922 }
923 let posting_count = read_u32(header, 8)?;
924 let block_count = read_u32(header, 12)?;
925
926 let block_count_usize = usize::try_from(block_count).unwrap_or(usize::MAX);
927 let metadata_len =
928 block_count_usize
929 .checked_mul(BLOCK_META_LEN)
930 .ok_or(PostingsError::Truncated {
931 needed: usize::MAX,
932 available: bytes.len(),
933 })?;
934 let metadata_end =
935 HEADER_LEN
936 .checked_add(metadata_len)
937 .ok_or(PostingsError::Truncated {
938 needed: usize::MAX,
939 available: bytes.len(),
940 })?;
941 let metadata_bytes =
942 bytes
943 .get(HEADER_LEN..metadata_end)
944 .ok_or(PostingsError::Truncated {
945 needed: metadata_end,
946 available: bytes.len(),
947 })?;
948
949 let mut blocks = Vec::with_capacity(block_count_usize);
950 let mut described = 0_u32;
951 let mut docid_bytes = 0_usize;
952 let mut tf_bytes = 0_usize;
953 let mut position_bytes = 0_usize;
954 for index in 0..block_count_usize {
955 work.step()?;
956 #[cfg(test)]
957 POSITION_OPEN_PROBES.with(|value| value.set(value.get() + 1));
958 let start = index.saturating_mul(BLOCK_META_LEN);
959 let row = metadata_bytes.get(start..start + BLOCK_META_LEN).ok_or(
960 PostingsError::Truncated {
961 needed: start + BLOCK_META_LEN,
962 available: metadata_bytes.len(),
963 },
964 )?;
965 let meta = BlockMeta::read(row)?;
966 for bits in [meta.docid_bits, meta.tf_bits, meta.position_bits] {
967 if bits > 32 {
968 return Err(PostingsError::BitWidthTooLarge { bits }.into());
969 }
970 }
971 if meta.count == 0 || meta.count > postings_per_block {
972 return Err(PostingsError::InconsistentPostingCount {
973 declared: posting_count,
974 described: described.saturating_add(u32::from(meta.count)),
975 }
976 .into());
977 }
978 described = described.saturating_add(u32::from(meta.count));
979 let count = usize::from(meta.count);
980 docid_bytes = docid_bytes.max(
981 usize::try_from(meta.docids_offset).unwrap_or(usize::MAX)
982 + packed_len(count, meta.docid_bits),
983 );
984 tf_bytes = tf_bytes.max(
985 usize::try_from(meta.tfs_offset).unwrap_or(usize::MAX)
986 + packed_len(count, meta.tf_bits),
987 );
988 position_bytes = position_bytes.max(
989 usize::try_from(meta.positions_offset).unwrap_or(usize::MAX)
990 + packed_len(
991 usize::try_from(meta.positions_count).unwrap_or(usize::MAX),
992 meta.position_bits,
993 ),
994 );
995 blocks.push(meta);
996 }
997 if described != posting_count {
998 return Err(PostingsError::InconsistentPostingCount {
999 declared: posting_count,
1000 described,
1001 }
1002 .into());
1003 }
1004
1005 let streams = bytes.get(metadata_end..).ok_or(PostingsError::Truncated {
1006 needed: metadata_end,
1007 available: bytes.len(),
1008 })?;
1009 let tf_start = docid_bytes;
1010 let position_start = tf_start
1011 .checked_add(tf_bytes)
1012 .ok_or(PostingsError::Truncated {
1013 needed: usize::MAX,
1014 available: streams.len(),
1015 })?;
1016 let position_end =
1017 position_start
1018 .checked_add(position_bytes)
1019 .ok_or(PostingsError::Truncated {
1020 needed: usize::MAX,
1021 available: streams.len(),
1022 })?;
1023 if streams.len() < position_end {
1024 return Err(PostingsError::Truncated {
1025 needed: position_end,
1026 available: streams.len(),
1027 }
1028 .into());
1029 }
1030 let docids = streams.get(..tf_start).ok_or(PostingsError::Truncated {
1031 needed: tf_start,
1032 available: streams.len(),
1033 })?;
1034 let tfs = streams
1035 .get(tf_start..position_start)
1036 .ok_or(PostingsError::Truncated {
1037 needed: position_start,
1038 available: streams.len(),
1039 })?;
1040 let positions =
1041 streams
1042 .get(position_start..position_end)
1043 .ok_or(PostingsError::Truncated {
1044 needed: position_end,
1045 available: streams.len(),
1046 })?;
1047
1048 work.check_now()?;
1049 Ok(Self {
1050 version,
1051 blocks,
1052 docids,
1053 tfs,
1054 positions,
1055 posting_count,
1056 postings_per_block,
1057 })
1058 }
1059
1060 #[must_use]
1062 pub fn blocks(&self) -> &[BlockMeta] {
1063 &self.blocks
1064 }
1065
1066 #[must_use]
1072 pub const fn version(&self) -> u16 {
1073 self.version
1074 }
1075
1076 #[must_use]
1078 pub const fn postings_per_block(&self) -> u16 {
1079 self.postings_per_block
1080 }
1081
1082 #[must_use]
1084 pub const fn document_frequency(&self) -> u32 {
1085 self.posting_count
1086 }
1087
1088 pub fn decode_block(&self, index: usize) -> Result<Vec<Posting>, PostingsError> {
1096 let Some(meta) = self.blocks.get(index) else {
1097 return Ok(Vec::new());
1098 };
1099 let count = usize::from(meta.count);
1100 let mut deltas = vec![0_u32; count];
1101 let docid_start = usize::try_from(meta.docids_offset).unwrap_or(usize::MAX);
1102 let docid_slice = self
1103 .docids
1104 .get(docid_start..)
1105 .ok_or(PostingsError::Truncated {
1106 needed: docid_start,
1107 available: self.docids.len(),
1108 })?;
1109 unpack_bits(docid_slice, meta.docid_bits, count, &mut deltas)?;
1110
1111 let mut frequencies = vec![0_u32; count];
1112 let tf_start = usize::try_from(meta.tfs_offset).unwrap_or(usize::MAX);
1113 let tf_slice = self.tfs.get(tf_start..).ok_or(PostingsError::Truncated {
1114 needed: tf_start,
1115 available: self.tfs.len(),
1116 })?;
1117 unpack_bits(tf_slice, meta.tf_bits, count, &mut frequencies)?;
1118
1119 let position_count = usize::try_from(meta.positions_count).unwrap_or(usize::MAX);
1120 let mut position_deltas = vec![0_u32; position_count];
1121 let position_start = usize::try_from(meta.positions_offset).unwrap_or(usize::MAX);
1122 let position_slice =
1123 self.positions
1124 .get(position_start..)
1125 .ok_or(PostingsError::Truncated {
1126 needed: position_start,
1127 available: self.positions.len(),
1128 })?;
1129 unpack_bits(
1130 position_slice,
1131 meta.position_bits,
1132 position_count,
1133 &mut position_deltas,
1134 )?;
1135
1136 let mut base = if index == 0 {
1138 0
1139 } else {
1140 self.blocks
1141 .get(index.saturating_sub(1))
1142 .map_or(0, |previous| previous.last_docid)
1143 };
1144 let mut postings = Vec::with_capacity(count);
1145 let mut position_cursor = 0_usize;
1146 for slot in 0..count {
1147 let delta = deltas.get(slot).copied().unwrap_or(0);
1148 let docid = if index == 0 && slot == 0 {
1149 delta
1150 } else {
1151 base.saturating_add(delta)
1152 };
1153 base = docid;
1154 let tf = frequencies.get(slot).copied().unwrap_or(0);
1155 if tf == 0 {
1156 return Err(PostingsError::ZeroTermFrequency);
1157 }
1158 let tf_usize = usize::try_from(tf).unwrap_or(usize::MAX);
1159 let mut positions = Vec::with_capacity(tf_usize.min(position_count));
1160 let mut running = 0_u32;
1161 for step in 0..tf_usize {
1162 let Some(gap) = position_deltas.get(position_cursor + step).copied() else {
1163 return Err(PostingsError::Truncated {
1164 needed: position_cursor + step + 1,
1165 available: position_deltas.len(),
1166 });
1167 };
1168 running = if step == 0 {
1169 gap
1170 } else {
1171 running.saturating_add(gap)
1172 };
1173 positions.push(running);
1174 }
1175 position_cursor = position_cursor.saturating_add(tf_usize);
1176 postings.push(Posting {
1177 docid,
1178 tf,
1179 positions,
1180 });
1181 }
1182 Ok(postings)
1183 }
1184
1185 pub fn decode_all(&self) -> Result<PostingList, PostingsError> {
1191 let mut list = PostingList::new();
1192 for index in 0..self.blocks.len() {
1193 for posting in self.decode_block(index)? {
1194 list.push(posting)?;
1195 }
1196 }
1197 Ok(list)
1198 }
1199}
1200
1201#[derive(Clone, Copy, Debug)]
1204pub struct RowPositions<'bytes> {
1205 bytes: &'bytes [u8],
1206 bits: u8,
1207 first: usize,
1208 count: usize,
1209}
1210
1211impl RowPositions<'_> {
1212 #[must_use]
1214 pub const fn len(&self) -> usize {
1215 self.count
1216 }
1217
1218 #[must_use]
1220 pub const fn is_empty(&self) -> bool {
1221 self.count == 0
1222 }
1223
1224 pub fn decode(&self) -> Result<Vec<u32>, PostingsError> {
1229 self.decode_controlled(&mut super::control::WorkCheck::new(|| {
1230 Ok::<(), PostingsError>(())
1231 }))
1232 }
1233
1234 pub(crate) fn decode_controlled<E: From<PostingsError>>(
1235 &self,
1236 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1237 ) -> Result<Vec<u32>, E> {
1238 work.check_now()?;
1239 let mut positions = Vec::with_capacity(self.count);
1240 let mut position = 0_u32;
1241 let result = (|| {
1242 for offset in 0..self.count {
1243 work.step()?;
1244 #[cfg(test)]
1245 POSITION_DECODE_PROBES.with(|value| value.set(value.get() + 1));
1246 let delta = packed_value_at(self.bytes, self.bits, self.first + offset)?;
1247 if offset > 0 && delta == 0 {
1248 return Err(PostingsError::PositionsNotAscending { position }.into());
1249 }
1250 position = position
1251 .checked_add(delta)
1252 .ok_or(PostingsError::PositionsNotAscending { position })?;
1253 positions.push(position);
1254 }
1255 work.check_now()
1256 })();
1257 #[cfg(any(test, feature = "test-seams"))]
1260 if !positions.is_empty() {
1261 let first_bit = self.first * usize::from(self.bits);
1262 let last_bit = (self.first + positions.len()) * usize::from(self.bits);
1263 super::preparation_observer::phrase_positions(
1264 positions.len(),
1265 last_bit.div_ceil(8) - first_bit / 8,
1266 );
1267 }
1268 result?;
1269 Ok(positions)
1270 }
1271}
1272
1273fn packed_value_at(bytes: &[u8], bits: u8, slot: usize) -> Result<u32, PostingsError> {
1276 if bits > 32 {
1277 return Err(PostingsError::BitWidthTooLarge { bits });
1278 }
1279 if bits == 0 {
1280 return Ok(0);
1281 }
1282 let first = slot
1283 .checked_mul(usize::from(bits))
1284 .ok_or(PostingsError::Truncated {
1285 needed: usize::MAX,
1286 available: bytes.len(),
1287 })?;
1288 let end = first
1289 .checked_add(usize::from(bits))
1290 .ok_or(PostingsError::Truncated {
1291 needed: usize::MAX,
1292 available: bytes.len(),
1293 })?
1294 .div_ceil(8);
1295 let selected = bytes.get(first / 8..end).ok_or(PostingsError::Truncated {
1296 needed: end,
1297 available: bytes.len(),
1298 })?;
1299 let word = selected
1300 .iter()
1301 .enumerate()
1302 .fold(0_u64, |word, (offset, byte)| {
1303 word | (u64::from(*byte) << (offset * 8))
1304 });
1305 let mask = (1_u64 << bits) - 1;
1306 Ok(((word >> (first % 8)) & mask) as u32)
1307}
1308
1309#[cfg(test)]
1310#[allow(clippy::expect_used)]
1311mod astra_18_tests {
1312 use super::*;
1313 use crate::fts::control::WorkCheck;
1314 use std::cell::Cell;
1315
1316 #[derive(Debug, PartialEq)]
1317 enum TestError {
1318 Cancelled,
1319 Postings(PostingsError),
1320 }
1321
1322 impl From<PostingsError> for TestError {
1323 fn from(error: PostingsError) -> Self {
1324 Self::Postings(error)
1325 }
1326 }
1327
1328 fn one_position_per_row() -> PostingList {
1329 let mut list = PostingList::new();
1330 for row in 0..4_096 {
1331 list.push(Posting {
1332 docid: row,
1333 tf: 1,
1334 positions: vec![row * 2],
1335 })
1336 .expect("literal posting");
1337 }
1338 list
1339 }
1340
1341 #[test]
1342 fn astra_18_position_metadata_cancels_inside_block_walk() {
1343 let list = one_position_per_row();
1344 let encoded = encode(&list, 1, &[]).expect("one row per block");
1345 POSITION_OPEN_PROBES.with(|value| value.set(0));
1346 let mut work = WorkCheck::new(|| {
1347 if POSITION_OPEN_PROBES.with(Cell::get) >= 64 {
1348 Err(TestError::Cancelled)
1349 } else {
1350 Ok(())
1351 }
1352 });
1353 let result = PostingsReader::open_controlled(encoded.as_bytes(), &mut work);
1354 let probes = POSITION_OPEN_PROBES.with(Cell::get);
1355 println!("metadata_probes={probes}");
1356 assert!(matches!(result, Err(TestError::Cancelled)));
1357 assert!(
1358 (64..=128).contains(&probes),
1359 "metadata walk must stop inside the list"
1360 );
1361 let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1362 let reader = PostingsReader::open_controlled(encoded.as_bytes(), &mut clean)
1363 .expect("fresh control opens complete metadata");
1364 assert_eq!(reader.blocks().len(), 4_096);
1365 assert_eq!(reader.document_frequency(), 4_096);
1366 assert_eq!(reader.decode_all().expect("complete literal output"), list);
1367 assert!(matches!(
1368 PostingsReader::open_controlled(&[], &mut clean),
1369 Err(TestError::Postings(PostingsError::Truncated { .. }))
1370 ));
1371 }
1372
1373 #[test]
1374 fn astra_18_position_membership_cancels_inside_one_block() {
1375 let list = one_position_per_row();
1376 let encoded = encode(&list, 4_096, &[]).expect("one large legal block");
1377 let reader = PostingsReader::open(encoded.as_bytes()).expect("reader");
1378 POSITION_MEMBER_PROBES.with(|value| value.set(0));
1379 let mut work = WorkCheck::new(|| {
1380 if POSITION_MEMBER_PROBES.with(Cell::get) >= 64 {
1381 Err(TestError::Cancelled)
1382 } else {
1383 Ok(())
1384 }
1385 });
1386 let result = reader.positions_controlled(4_095, &mut work);
1387 let probes = POSITION_MEMBER_PROBES.with(Cell::get);
1388 println!("membership_probes={probes}");
1389 assert!(matches!(result, Err(TestError::Cancelled)));
1390 assert!(
1391 (64..=128).contains(&probes),
1392 "membership must stop inside the block"
1393 );
1394 let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1395 assert_eq!(
1396 reader
1397 .positions_controlled(4_095, &mut clean)
1398 .expect("fresh control")
1399 .expect("last row")
1400 .decode()
1401 .expect("position"),
1402 vec![8_190]
1403 );
1404 assert!(
1405 reader
1406 .positions_controlled(4_096, &mut clean)
1407 .expect("absent row")
1408 .is_none()
1409 );
1410 }
1411
1412 #[test]
1413 fn astra_18_position_decode_cancels_inside_one_row() {
1414 let literal = (0..4_096).collect::<Vec<_>>();
1415 let mut list = PostingList::new();
1416 list.push(Posting {
1417 docid: 0,
1418 tf: 4_096,
1419 positions: literal.clone(),
1420 })
1421 .expect("one long row");
1422 let encoded = encode(&list, 64, &[]).expect("encoded positions");
1423 let reader = PostingsReader::open(encoded.as_bytes()).expect("reader");
1424 let view = reader.positions(0).expect("membership").expect("present");
1425 POSITION_DECODE_PROBES.with(|value| value.set(0));
1426 crate::fts::preparation_observer::begin();
1427 let mut work = WorkCheck::new(|| {
1428 if POSITION_DECODE_PROBES.with(Cell::get) >= 64 {
1429 Err(TestError::Cancelled)
1430 } else {
1431 Ok(())
1432 }
1433 });
1434 let result = view.decode_controlled(&mut work);
1435 let probes = POSITION_DECODE_PROBES.with(Cell::get);
1436 let observed = crate::fts::preparation_observer::phrase_position_work();
1437 crate::fts::preparation_observer::take();
1438 println!("decode_probes={probes}, position_work={observed:?}");
1439 assert_eq!(result, Err(TestError::Cancelled));
1440 assert!(
1441 (64..=128).contains(&probes),
1442 "decode must stop inside the row"
1443 );
1444 assert_eq!(
1445 observed,
1446 (probes, probes.div_ceil(8)),
1447 "retain actual partial decode work"
1448 );
1449 let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1450 assert_eq!(
1451 view.decode_controlled(&mut clean).expect("fresh control"),
1452 literal
1453 );
1454 }
1455}
1456
1457#[cfg(test)]
1458#[allow(clippy::expect_used, clippy::indexing_slicing)]
1459mod astra_11_tests {
1460 use super::*;
1461
1462 #[test]
1463 fn astra_11_position_reader_only_decodes_requested_row() {
1464 for per_block in [1, 2, 64] {
1465 let mut list = PostingList::new();
1466 let expected = (0..130)
1467 .map(|row| {
1468 if row % 3 == 0 {
1469 vec![0, 1, 31, u32::MAX]
1470 } else {
1471 vec![row * 3]
1472 }
1473 })
1474 .collect::<Vec<_>>();
1475 for (row, positions) in expected.iter().enumerate() {
1476 list.push(Posting {
1477 docid: row as u32 * 2,
1478 tf: positions.len() as u32,
1479 positions: positions.clone(),
1480 })
1481 .expect("posting");
1482 }
1483 let encoded = encode(&list, per_block, &[]).expect("encoded positions");
1484 let reader = PostingsReader::open(encoded.as_bytes()).expect("persisted reader");
1485 for row in [0, 1, 63, 64, 65, 129] {
1486 crate::fts::preparation_observer::begin();
1487 let view = reader
1488 .positions(row as u32 * 2)
1489 .expect("locate")
1490 .expect("present");
1491 assert_eq!(
1492 crate::fts::preparation_observer::phrase_position_work(),
1493 (0, 0)
1494 );
1495 assert_eq!(view.decode().expect("selective decode"), expected[row]);
1496 assert_eq!(
1497 crate::fts::preparation_observer::phrase_position_work().0,
1498 expected[row].len()
1499 );
1500 crate::fts::preparation_observer::take();
1501 assert!(
1502 reader
1503 .positions(row as u32 * 2 + 1)
1504 .expect("absent")
1505 .is_none()
1506 );
1507 }
1508 }
1509 }
1510
1511 #[test]
1512 fn astra_11_corrupt_position_payload_is_typed() {
1513 let mut list = PostingList::new();
1514 for (docid, positions) in [(0, vec![1, 3]), (1, vec![2, 5])] {
1515 list.push(Posting {
1516 docid,
1517 tf: 2,
1518 positions,
1519 })
1520 .expect("posting");
1521 }
1522 let encoded = encode(&list, 64, &[]).expect("encoded");
1523 let clean = PostingsReader::open(encoded.as_bytes()).expect("open");
1524 assert_eq!(
1525 clean
1526 .positions(0)
1527 .expect("locate")
1528 .expect("present")
1529 .decode()
1530 .expect("clean"),
1531 vec![1, 3]
1532 );
1533 let positions_start = HEADER_LEN + BLOCK_META_LEN + clean.docids.len() + clean.tfs.len();
1534 assert_eq!(clean.blocks()[0].position_bits, 2);
1535 let mut corrupt = encoded.as_bytes().to_vec();
1536 corrupt[positions_start] &= !(3 << 2);
1539 let reader = PostingsReader::open(&corrupt).expect("geometry unchanged");
1540 assert_eq!(
1541 reader
1542 .positions(0)
1543 .expect("locate corrupt row")
1544 .expect("present")
1545 .decode(),
1546 Err(PostingsError::PositionsNotAscending { position: 1 })
1547 );
1548 assert_eq!(
1549 reader
1550 .positions(1)
1551 .expect("next row")
1552 .expect("present")
1553 .decode()
1554 .expect("unaffected row"),
1555 vec![2, 5]
1556 );
1557 }
1558}
1559
1560#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1578pub struct BlockImpact {
1579 pub max_tf: u32,
1581 pub min_len: u16,
1583}
1584
1585impl BlockImpact {
1586 #[must_use]
1588 pub const fn from_meta(meta: &BlockMeta) -> Self {
1589 Self {
1590 max_tf: meta.max_tf,
1591 min_len: meta.min_len,
1592 }
1593 }
1594
1595 #[must_use]
1601 pub const fn is_absent(&self) -> bool {
1602 self.max_tf == 0
1603 }
1604
1605 #[must_use]
1610 pub fn bound(&self, scorer: &super::bm25::TermScorer) -> f64 {
1611 scorer.score(Tf(self.max_tf), DocLen(u32::from(self.min_len)))
1612 }
1613}
1614
1615#[must_use]
1621pub fn block_impacts(
1622 list: &PostingList,
1623 postings_per_block: u16,
1624 lengths: &[u32],
1625) -> Vec<BlockImpact> {
1626 let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
1627 match block_impacts_controlled(list, postings_per_block, lengths, &mut work) {
1628 Ok(impacts) => impacts,
1629 Err(never) => match never {},
1630 }
1631}
1632
1633pub(crate) fn block_impacts_controlled<E>(
1634 list: &PostingList,
1635 postings_per_block: u16,
1636 lengths: &[u32],
1637 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1638) -> Result<Vec<BlockImpact>, E> {
1639 block_impacts_slice_controlled(list.postings(), postings_per_block, lengths, work)
1640}
1641
1642pub(crate) fn block_impacts_slice_controlled<E>(
1643 postings: &[Posting],
1644 postings_per_block: u16,
1645 lengths: &[u32],
1646 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1647) -> Result<Vec<BlockImpact>, E> {
1648 work.check_now()?;
1649 if postings_per_block == 0 {
1650 return Ok(Vec::new());
1651 }
1652 let chunks = postings.chunks(usize::from(postings_per_block));
1653 let mut impacts = Vec::with_capacity(chunks.len());
1654 for chunk in chunks {
1655 let mut max_tf = 0_u32;
1656 let mut min_len = u32::MAX;
1657 for posting in chunk {
1658 work.step()?;
1659 #[cfg(test)]
1660 ENCODE_IMPACT_PROBES.with(|value| value.set(value.get() + 1));
1661 max_tf = max_tf.max(posting.tf);
1662 let length = usize::try_from(posting.docid)
1663 .ok()
1664 .and_then(|slot| lengths.get(slot).copied())
1665 .unwrap_or(1);
1666 min_len = min_len.min(length);
1667 }
1668 impacts.push(BlockImpact {
1669 max_tf,
1670 min_len: u16::try_from(min_len).unwrap_or(u16::MAX),
1673 });
1674 }
1675 work.check_now()?;
1676 Ok(impacts)
1677}
1678
1679#[cfg(test)]
1680#[allow(
1681 clippy::expect_used,
1682 clippy::indexing_slicing,
1683 clippy::panic,
1684 clippy::unwrap_used
1685)]
1686mod tests {
1687 use super::*;
1688
1689 #[test]
1690 fn astra_18_postings_encoding_cancels_inside_positions() {
1691 let positions = (0..4_096).collect::<Vec<_>>();
1692 let list = list_from(&[(0, &positions)]);
1693 let impacts = block_impacts(&list, 64, &[4_096]);
1694 let reference = encode_v2(&list, 64, &[], &impacts).expect("reference");
1695 let mut observed = Vec::new();
1696 for (phase, probes) in [
1697 ("position gaps", &ENCODE_POSITION_PROBES),
1698 ("bit widths", &ENCODE_BITS_PROBES),
1699 ("bit packing", &ENCODE_PACK_PROBES),
1700 ] {
1701 probes.with(|value| value.set(0));
1702 let mut work = super::super::control::WorkCheck::new(|| {
1703 if probes.with(std::cell::Cell::get) >= 64 {
1704 Err(PostingsError::ZeroBlockSize)
1705 } else {
1706 Ok(())
1707 }
1708 });
1709 let result = encode_v2_controlled(&list, 64, &[], &impacts, &mut work);
1710 let count = probes.with(std::cell::Cell::get);
1711 println!("{phase}: canceled={}, probes={count}", result.is_err());
1712 observed.push((
1713 phase,
1714 matches!(result, Err(PostingsError::ZeroBlockSize)),
1715 count,
1716 ));
1717 }
1718 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), PostingsError>(()));
1719 let clean = encode_v2_controlled(&list, 64, &[], &impacts, &mut work).expect("clean");
1720 assert_eq!(clean.as_bytes(), reference.as_bytes());
1721 assert_eq!(
1722 PostingsReader::open(clean.as_bytes())
1723 .expect("reader")
1724 .decode_all()
1725 .expect("decoded"),
1726 list
1727 );
1728 for (phase, canceled, count) in observed {
1729 assert!(canceled, "{phase}");
1730 assert!((64..=128).contains(&count), "{phase}: {count}");
1731 }
1732 }
1733
1734 #[test]
1735 fn astra_18_block_impacts_cancel_inside_rows() {
1736 let mut list = PostingList::new();
1737 for docid in 0..4_096 {
1738 list.push(Posting {
1739 docid,
1740 tf: 1,
1741 positions: vec![0],
1742 })
1743 .expect("posting");
1744 }
1745 let lengths = vec![7; 4_096];
1746 ENCODE_IMPACT_PROBES.with(|value| value.set(0));
1747 let mut work = super::super::control::WorkCheck::new(|| {
1748 if ENCODE_IMPACT_PROBES.with(std::cell::Cell::get) >= 64 {
1749 Err("cancelled")
1750 } else {
1751 Ok(())
1752 }
1753 });
1754 let result = block_impacts_controlled(&list, 64, &lengths, &mut work);
1755 let count = ENCODE_IMPACT_PROBES.with(std::cell::Cell::get);
1756 println!("impact rows={count}");
1757 assert_eq!(result, Err("cancelled"));
1758 assert!((64..=128).contains(&count));
1759 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
1760 assert_eq!(
1761 block_impacts_controlled(&list, 64, &lengths, &mut work),
1762 Ok(vec![
1763 BlockImpact {
1764 max_tf: 1,
1765 min_len: 7
1766 };
1767 64
1768 ])
1769 );
1770 }
1771
1772 fn list_from(entries: &[(u32, &[u32])]) -> PostingList {
1773 let mut list = PostingList::new();
1774 for (docid, positions) in entries {
1775 list.push(Posting {
1776 docid: *docid,
1777 tf: u32::try_from(positions.len()).expect("small"),
1778 positions: positions.to_vec(),
1779 })
1780 .expect("ascending fixture");
1781 }
1782 list
1783 }
1784
1785 #[test]
1786 fn a_single_block_round_trips() {
1787 let list = list_from(&[(0, &[0, 5]), (3, &[2]), (9, &[1, 4, 7])]);
1788 let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1789 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1790 assert_eq!(reader.document_frequency(), 3);
1791 assert_eq!(reader.postings_per_block(), DEFAULT_POSTINGS_PER_BLOCK);
1792 assert_eq!(reader.decode_all().expect("decodes"), list);
1793 }
1794
1795 #[test]
1796 fn many_blocks_round_trip_across_the_geometry_boundary() {
1797 for count in [1_usize, 63, 64, 65, 128, 200] {
1798 let mut list = PostingList::new();
1799 for index in 0..count {
1800 let docid = u32::try_from(index * 3).expect("small");
1801 list.push(Posting {
1802 docid,
1803 tf: 2,
1804 positions: vec![index as u32, index as u32 + 7],
1805 })
1806 .expect("ascending");
1807 }
1808 let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1809 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1810 assert_eq!(
1811 reader.blocks().len(),
1812 count.div_ceil(usize::from(DEFAULT_POSTINGS_PER_BLOCK)),
1813 "block count for {count} postings"
1814 );
1815 assert_eq!(reader.decode_all().expect("decodes"), list, "count {count}");
1816 }
1817 }
1818
1819 #[test]
1820 fn the_block_metadata_row_is_exactly_one_quarter_of_a_cache_line() {
1821 let mut bytes = Vec::new();
1822 BlockMeta::default().write(&mut bytes);
1823 assert_eq!(bytes.len(), BLOCK_META_LEN);
1824 assert_eq!(BLOCK_META_LEN * 4, 128);
1825 }
1826
1827 #[test]
1828 fn an_empty_list_encodes_and_decodes() {
1829 let list = PostingList::new();
1830 let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1831 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1832 assert_eq!(reader.document_frequency(), 0);
1833 assert!(reader.decode_all().expect("decodes").is_empty());
1834 }
1835
1836 #[test]
1837 fn bit_widths_at_every_boundary_round_trip() {
1838 for bits in 0..=32_u8 {
1839 let value = if bits == 0 {
1840 0
1841 } else if bits == 32 {
1842 u32::MAX
1843 } else {
1844 (1_u32 << bits) - 1
1845 };
1846 let values = vec![value; 64];
1847 let mut packed = Vec::new();
1848 pack_bits(&values, bits, &mut packed);
1849 let mut output = vec![0_u32; 64];
1850 let consumed = unpack_bits(&packed, bits, 64, &mut output).expect("unpacks");
1851 assert_eq!(consumed, packed_len(64, bits), "width {bits}");
1852 assert_eq!(output, values, "width {bits}");
1853 }
1854 }
1855
1856 #[test]
1857 fn an_oversized_bit_width_is_a_typed_error() {
1858 let mut output = [0_u32; 4];
1859 assert_eq!(
1860 unpack_bits(&[0; 32], 33, 4, &mut output),
1861 Err(PostingsError::BitWidthTooLarge { bits: 33 })
1862 );
1863 }
1864
1865 #[test]
1866 fn a_truncated_packed_run_is_a_typed_error() {
1867 let mut output = [0_u32; 64];
1868 let error = unpack_bits(&[0; 2], 8, 64, &mut output).expect_err("must refuse");
1869 assert!(matches!(error, PostingsError::Truncated { .. }));
1870 }
1871
1872 #[test]
1873 fn out_of_order_docids_are_refused_rather_than_sorted() {
1874 let mut list = PostingList::new();
1875 list.push(Posting {
1876 docid: 5,
1877 tf: 1,
1878 positions: vec![0],
1879 })
1880 .expect("first");
1881 assert_eq!(
1882 list.push(Posting {
1883 docid: 5,
1884 tf: 1,
1885 positions: vec![0],
1886 }),
1887 Err(PostingsError::DocidsNotAscending { docid: 5 })
1888 );
1889 }
1890
1891 #[test]
1892 fn out_of_order_positions_are_refused() {
1893 let mut list = PostingList::new();
1894 assert_eq!(
1895 list.push(Posting {
1896 docid: 0,
1897 tf: 2,
1898 positions: vec![4, 4],
1899 }),
1900 Err(PostingsError::PositionsNotAscending { position: 4 })
1901 );
1902 }
1903
1904 #[test]
1905 fn a_zero_frequency_posting_is_refused() {
1906 let mut list = PostingList::new();
1907 assert_eq!(
1908 list.push(Posting {
1909 docid: 0,
1910 tf: 0,
1911 positions: vec![],
1912 }),
1913 Err(PostingsError::ZeroTermFrequency)
1914 );
1915 }
1916
1917 #[test]
1918 fn the_impact_pair_survives_the_persisted_round_trip() {
1919 let list = list_from(&[(0, &[0, 1, 2]), (5, &[0]), (9, &[0, 3])]);
1922 let lengths = vec![40_u32, 0, 0, 0, 0, 7, 0, 0, 0, 900];
1923 let impacts = block_impacts(&list, 2, &lengths);
1924 assert_eq!(impacts.len(), 2);
1925 assert_eq!(
1926 impacts[0],
1927 BlockImpact {
1928 max_tf: 3,
1929 min_len: 7
1930 }
1931 );
1932 assert_eq!(
1933 impacts[1],
1934 BlockImpact {
1935 max_tf: 2,
1936 min_len: 900
1937 }
1938 );
1939
1940 let encoded = encode_v2(&list, 2, &[], &impacts).expect("encodes");
1941 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1942 assert_eq!(reader.version(), POSTINGS_VERSION);
1943 let read: Vec<BlockImpact> = reader.blocks().iter().map(BlockImpact::from_meta).collect();
1944 assert_eq!(read, impacts);
1945 assert_eq!(reader.decode_all().expect("decodes"), list);
1946 }
1947
1948 #[test]
1949 fn a_version_one_stream_reads_back_with_no_impact_pair() {
1950 let list = list_from(&[(0, &[0]), (4, &[1])]);
1953 let encoded = encode(&list, 64, &[]).expect("encodes");
1954 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1955 assert_eq!(reader.version(), POSTINGS_VERSION_V1);
1956 for meta in reader.blocks() {
1957 assert!(BlockImpact::from_meta(meta).is_absent());
1958 }
1959 }
1960
1961 #[test]
1962 fn an_empty_impact_slice_stores_an_absent_pair() {
1963 let list = list_from(&[(0, &[0])]);
1964 let encoded = encode_v2(&list, 64, &[], &[]).expect("encodes");
1965 let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1966 assert_eq!(reader.version(), POSTINGS_VERSION);
1967 assert!(BlockImpact::from_meta(&reader.blocks()[0]).is_absent());
1968 assert!(block_impacts(&list, 0, &[]).is_empty());
1969 }
1970
1971 #[test]
1972 fn bad_magic_and_version_are_typed_errors() {
1973 let list = list_from(&[(0, &[0])]);
1974 let encoded = encode(&list, 64, &[]).expect("encodes");
1975 let mut bytes = encoded.as_bytes().to_vec();
1976 bytes[0] = b'X';
1977 assert_eq!(
1978 PostingsReader::open(&bytes).err(),
1979 Some(PostingsError::BadMagic)
1980 );
1981
1982 let mut bytes = encoded.as_bytes().to_vec();
1983 bytes[4] = 9;
1984 assert_eq!(
1985 PostingsReader::open(&bytes).err(),
1986 Some(PostingsError::UnsupportedVersion { found: 9 })
1987 );
1988 }
1989
1990 #[test]
1991 fn a_zero_block_size_header_is_refused() {
1992 let list = list_from(&[(0, &[0])]);
1993 let encoded = encode(&list, 64, &[]).expect("encodes");
1994 let mut bytes = encoded.as_bytes().to_vec();
1995 bytes[6] = 0;
1996 bytes[7] = 0;
1997 assert_eq!(
1998 PostingsReader::open(&bytes).err(),
1999 Some(PostingsError::ZeroBlockSize)
2000 );
2001 assert_eq!(
2002 encode(&list, 0, &[]).err(),
2003 Some(PostingsError::ZeroBlockSize)
2004 );
2005 }
2006
2007 #[test]
2008 fn every_truncation_of_a_valid_stream_is_a_typed_error_not_a_panic() {
2009 let list = list_from(&[(0, &[0, 3]), (7, &[1]), (100, &[2, 9, 40])]);
2010 let encoded = encode(&list, 2, &[]).expect("encodes");
2011 let bytes = encoded.as_bytes();
2012 for cut in 0..bytes.len() {
2013 let truncated = &bytes[..cut];
2014 if let Ok(reader) = PostingsReader::open(truncated) {
2016 for index in 0..reader.blocks().len() {
2017 let _ = reader.decode_block(index);
2018 }
2019 }
2020 }
2021 }
2022}