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