Skip to content

Commit 1d06328

Browse files
Windforce17PSeitz
authored andcommitted
Add BlockSegmentPostings::rank() for skip-list-based positional counting
Add a public rank(target) method on BlockSegmentPostings that returns the number of docs with a doc id strictly smaller than target. It jumps to the candidate block through the skip list and decodes a single block, so the cost is O(skip-list entries) + one block decode rather than O(doc_freq). This is a useful primitive for range counting over a posting list (e.g. number of matches in a [lo, hi) doc-id window) without iterating every matched doc. To support it, expose SkipReader::remaining_docs() (pub(crate)). Like seek(), rank() advances the cursor forward only and must be called with non-decreasing, valid (<= TERMINATED) targets. Adds a unit test covering multi-block lists and the below-first / above-last / empty edge cases.
1 parent b19f0dd commit 1d06328

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

src/postings/block_segment_postings.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,33 @@ impl BlockSegmentPostings {
287287
doc
288288
}
289289

290+
/// Returns the number of documents with a doc id strictly smaller than `target`
291+
/// (i.e. the *rank* of `target` in this posting list).
292+
///
293+
/// This jumps to the block that may contain `target` through the skip list, so no
294+
/// skipped block is decoded; a single block is then decoded to locate `target`
295+
/// within it. The cost is therefore `O(number_of_skip_list_entries)` plus one block
296+
/// decode, rather than `O(doc_freq)`.
297+
///
298+
/// Like [`Self::seek`], the underlying cursor only ever moves forward. This method
299+
/// must be called with **non-decreasing** `target` values (galloping); calling it
300+
/// with a `target` smaller than a previous one yields an incorrect result. `target`
301+
/// must be a valid doc id (i.e. `target <= TERMINATED`), exactly as for `seek`.
302+
///
303+
/// Edge cases: returns `0` when `target` is smaller than every doc id, and
304+
/// `doc_freq()` when `target` is larger than every doc id.
305+
pub fn rank(&mut self, target: DocId) -> u32 {
306+
if self.doc_freq == 0 {
307+
return 0;
308+
}
309+
// `within` = number of docs in the landed block with a doc id < target.
310+
let within = self.seek(target);
311+
// `remaining_docs` counts the landed block and everything after it, so the
312+
// difference is the number of docs in all blocks strictly before it.
313+
let docs_before_block = self.doc_freq - self.skip_reader.remaining_docs();
314+
docs_before_block + within as u32
315+
}
316+
290317
pub(crate) fn position_offset(&self) -> u64 {
291318
self.skip_reader.position_offset()
292319
}
@@ -568,4 +595,38 @@ mod tests {
568595
assert_eq!(block_segments.docs(), &[1, 3, 5]);
569596
Ok(())
570597
}
598+
599+
#[test]
600+
fn test_block_segment_postings_rank() -> crate::Result<()> {
601+
// ~8 blocks worth of docs so the skip list is actually exercised.
602+
let docs: Vec<DocId> = (0..1000u32).map(|i| i * 3).collect();
603+
let mut block_postings = build_block_postings(&docs[..])?;
604+
let doc_freq = block_postings.doc_freq();
605+
606+
// rank(target) must equal the number of docs strictly below target.
607+
// Targets are queried in non-decreasing order, as the API requires.
608+
// `target` values must be a valid doc id (<= TERMINATED) and non-decreasing.
609+
let targets = [
610+
0u32, 1, 2, 3, 4, 299, 300, 301, 1500, 2996, 2997, 3000, 10_000,
611+
];
612+
for &target in &targets {
613+
let expected = docs.iter().filter(|&&d| d < target).count() as u32;
614+
assert_eq!(
615+
block_postings.rank(target),
616+
expected,
617+
"rank({target}) mismatch"
618+
);
619+
}
620+
621+
// Edge cases: below the first doc -> 0, above the last doc -> doc_freq.
622+
let mut fresh = build_block_postings(&docs[..])?;
623+
assert_eq!(fresh.rank(0), 0);
624+
let mut fresh = build_block_postings(&docs[..])?;
625+
assert_eq!(fresh.rank(1_000_000), doc_freq);
626+
627+
// Empty postings: rank is always 0.
628+
let mut empty = BlockSegmentPostings::empty();
629+
assert_eq!(empty.rank(42), 0);
630+
Ok(())
631+
}
571632
}

src/postings/skip.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ impl SkipReader {
187187
self.last_doc_in_block
188188
}
189189

190+
/// Number of docs from the start of the current block to the end of the postings
191+
/// (i.e. the current block plus every block after it).
192+
pub(crate) fn remaining_docs(&self) -> u32 {
193+
self.remaining_docs
194+
}
195+
190196
pub fn position_offset(&self) -> u64 {
191197
self.position_offset
192198
}

0 commit comments

Comments
 (0)