1use std::sync::Arc;
16
17use uqa_core::{DocId, TokenOccurrence};
18
19use crate::{StorageBackendError, StorageBackendResult, DEFAULT_BLOCK_SIZE};
20
21pub const POSTING_CLUSTER_DOCS: u64 = 1 << 16;
22
23const SCORE_MAGIC: &[u8; 4] = b"UQCS";
24const POSITIONS_MAGIC: &[u8; 4] = b"UQCP";
25const TERMS_MAGIC: &[u8; 4] = b"UQCT";
26const FORMAT_VERSION: u8 = 1;
27pub const OCCURRENCE_FORMAT_VERSION: u8 = 2;
28const HEADER_LEN: usize = 16;
29const SCORE_DIRECTORY_ENTRY_LEN: usize = 28;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct PostingScore {
33 pub doc_id: DocId,
34 pub term_freq: u64,
35 pub doc_length: u64,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ClusterPosting {
40 pub doc_id: DocId,
41 pub term_freq: u64,
42 pub doc_length: u64,
43 pub positions: Vec<u32>,
44}
45
46pub trait PostingCursor: Send {
47 fn doc_freq(&self) -> u64;
48 fn ordinal(&self) -> u64;
49 fn current(&self) -> Option<PostingScore>;
50 fn advance(&mut self) -> StorageBackendResult<Option<PostingScore>>;
51 fn advance_to(&mut self, target: DocId) -> StorageBackendResult<Option<PostingScore>>;
52 fn boxed_clone(&self) -> Box<dyn PostingCursor>;
53}
54
55mod controlled_cursor;
56mod positions;
57mod read_cursor;
58pub(crate) use controlled_cursor::open as open_controlled_cursor;
59pub use controlled_cursor::{EncodedScoreClusterRef, ScoreClusterVisitor};
60pub use read_cursor::{BudgetedPostingReadCursor, OwnedPostingReadCursor, PostingReadCursor};
61
62impl Clone for Box<dyn PostingCursor> {
63 fn clone(&self) -> Self {
64 self.boxed_clone()
65 }
66}
67
68#[derive(Clone)]
69pub struct MaterializedPostingCursor {
70 entries: Arc<[PostingScore]>,
71 position: usize,
72}
73
74impl MaterializedPostingCursor {
75 pub fn new(entries: Vec<PostingScore>) -> StorageBackendResult<Self> {
76 validate_scores(&entries)?;
77 Ok(Self {
78 entries: entries.into(),
79 position: 0,
80 })
81 }
82}
83
84impl PostingCursor for MaterializedPostingCursor {
85 fn doc_freq(&self) -> u64 {
86 self.entries.len() as u64
87 }
88
89 fn ordinal(&self) -> u64 {
90 self.position as u64
91 }
92
93 fn current(&self) -> Option<PostingScore> {
94 self.entries.get(self.position).copied()
95 }
96
97 fn advance(&mut self) -> StorageBackendResult<Option<PostingScore>> {
98 self.position = self.position.saturating_add(1).min(self.entries.len());
99 Ok(self.current())
100 }
101
102 fn advance_to(&mut self, target: DocId) -> StorageBackendResult<Option<PostingScore>> {
103 if self.current().is_some_and(|entry| entry.doc_id >= target) {
104 return Ok(self.current());
105 }
106 let relative = self.entries[self.position..].partition_point(|entry| entry.doc_id < target);
107 self.position = self
108 .position
109 .saturating_add(relative)
110 .min(self.entries.len());
111 Ok(self.current())
112 }
113
114 fn boxed_clone(&self) -> Box<dyn PostingCursor> {
115 Box::new(self.clone())
116 }
117}
118
119#[derive(Debug, Clone)]
120pub struct EncodedScoreCluster {
121 pub cluster_id: u64,
122 pub bytes: Vec<u8>,
123}
124
125#[derive(Debug, Clone, Copy)]
126struct ScoreBlock {
127 version: u8,
128 count: usize,
129 last_offset: u16,
130 docs_start: usize,
131 docs_end: usize,
132 term_freqs_start: usize,
133 term_freqs_end: usize,
134 doc_lengths_start: usize,
135 doc_lengths_end: usize,
136}
137
138#[derive(Debug, Clone, Copy)]
139struct CursorBlock {
140 cluster_index: usize,
141 score_block: ScoreBlock,
142 first_ordinal: u64,
143 last_doc_id: DocId,
144}
145
146#[derive(Clone)]
147pub struct ClusteredPostingCursor {
148 clusters: Arc<[EncodedScoreCluster]>,
149 blocks: Arc<[CursorBlock]>,
150 doc_freq: u64,
151 block_index: usize,
152 entries: Vec<PostingScore>,
153 position_in_block: usize,
154}
155
156impl ClusteredPostingCursor {
157 pub fn new(clusters: Vec<EncodedScoreCluster>) -> StorageBackendResult<Self> {
158 let mut previous_cluster = None;
159 let mut blocks = Vec::new();
160 let mut doc_freq = 0_u64;
161 for (cluster_index, cluster) in clusters.iter().enumerate() {
162 if previous_cluster.is_some_and(|previous| previous >= cluster.cluster_id) {
163 return Err(corrupt("cluster identifiers are not strictly increasing"));
164 }
165 previous_cluster = Some(cluster.cluster_id);
166 let (_, score_blocks) = parse_score_blob(&cluster.bytes)?;
167 for score_block in score_blocks {
168 let first_ordinal = doc_freq;
169 doc_freq = doc_freq
170 .checked_add(score_block.count as u64)
171 .ok_or_else(|| corrupt("posting count overflow"))?;
172 blocks.push(CursorBlock {
173 cluster_index,
174 score_block,
175 first_ordinal,
176 last_doc_id: cluster_base(cluster.cluster_id)?
177 .checked_add(u64::from(score_block.last_offset))
178 .ok_or_else(|| corrupt("block document id overflow"))?,
179 });
180 }
181 }
182
183 let mut cursor = Self {
184 clusters: clusters.into(),
185 blocks: blocks.into(),
186 doc_freq,
187 block_index: 0,
188 entries: Vec::new(),
189 position_in_block: 0,
190 };
191 if !cursor.blocks.is_empty() {
192 cursor.load_block(0)?;
193 }
194 Ok(cursor)
195 }
196
197 fn load_block(&mut self, block_index: usize) -> StorageBackendResult<()> {
198 if block_index >= self.blocks.len() {
199 self.block_index = self.blocks.len();
200 self.entries.clear();
201 self.position_in_block = 0;
202 return Ok(());
203 }
204 let block = self.blocks[block_index];
205 let cluster = &self.clusters[block.cluster_index];
206 self.entries.clear();
207 self.entries.reserve(block.score_block.count);
208 decode_score_block_into(
209 &cluster.bytes,
210 cluster.cluster_id,
211 block.score_block,
212 &mut self.entries,
213 )?;
214 self.block_index = block_index;
215 self.position_in_block = 0;
216 Ok(())
217 }
218
219 fn exhausted(&self) -> bool {
220 self.block_index >= self.blocks.len()
221 }
222}
223
224impl PostingCursor for ClusteredPostingCursor {
225 fn doc_freq(&self) -> u64 {
226 self.doc_freq
227 }
228
229 fn ordinal(&self) -> u64 {
230 if self.exhausted() {
231 return self.doc_freq;
232 }
233 self.blocks[self.block_index]
234 .first_ordinal
235 .saturating_add(self.position_in_block as u64)
236 }
237
238 fn current(&self) -> Option<PostingScore> {
239 self.entries.get(self.position_in_block).copied()
240 }
241
242 fn advance(&mut self) -> StorageBackendResult<Option<PostingScore>> {
243 if self.exhausted() {
244 return Ok(None);
245 }
246 self.position_in_block += 1;
247 if self.position_in_block < self.entries.len() {
248 return Ok(self.current());
249 }
250 self.load_block(self.block_index + 1)?;
251 Ok(self.current())
252 }
253
254 fn advance_to(&mut self, target: DocId) -> StorageBackendResult<Option<PostingScore>> {
255 if self.current().is_some_and(|entry| entry.doc_id >= target) {
256 return Ok(self.current());
257 }
258 if self.exhausted() {
259 return Ok(None);
260 }
261 let relative =
262 self.blocks[self.block_index..].partition_point(|block| block.last_doc_id < target);
263 let block_index = self.block_index + relative;
264 if block_index >= self.blocks.len() {
265 self.load_block(self.blocks.len())?;
266 return Ok(None);
267 }
268 if block_index != self.block_index {
269 self.load_block(block_index)?;
270 }
271 self.position_in_block = self.entries.partition_point(|entry| entry.doc_id < target);
272 if self.position_in_block < self.entries.len() {
273 return Ok(self.current());
274 }
275 self.load_block(block_index + 1)?;
276 Ok(self.current())
277 }
278
279 fn boxed_clone(&self) -> Box<dyn PostingCursor> {
280 Box::new(self.clone())
281 }
282}
283
284pub fn cluster_id(doc_id: DocId) -> u64 {
285 doc_id / POSTING_CLUSTER_DOCS
286}
287
288fn cluster_base(cluster_id: u64) -> StorageBackendResult<DocId> {
289 cluster_id
290 .checked_mul(POSTING_CLUSTER_DOCS)
291 .ok_or_else(|| corrupt("cluster base document id overflow"))
292}
293
294fn cluster_offset(doc_id: DocId) -> StorageBackendResult<u16> {
295 u16::try_from(doc_id % POSTING_CLUSTER_DOCS)
296 .map_err(|_| corrupt("document offset exceeds clustered format"))
297}
298
299pub fn encode_cluster(entries: &[ClusterPosting]) -> StorageBackendResult<(Vec<u8>, Vec<u8>)> {
300 if entries.is_empty() {
301 return Err(corrupt("cannot encode an empty posting cluster"));
302 }
303 validate_cluster_entries(entries)?;
304 let scores: Vec<_> = entries
305 .iter()
306 .map(|entry| PostingScore {
307 doc_id: entry.doc_id,
308 term_freq: entry.term_freq,
309 doc_length: entry.doc_length,
310 })
311 .collect();
312 Ok((
313 encode_scores(&scores, FORMAT_VERSION)?,
314 encode_positions(entries)?,
315 ))
316}
317
318pub fn decode_cluster(
319 cluster_id: u64,
320 score_blob: &[u8],
321 positions_blob: &[u8],
322) -> StorageBackendResult<Vec<ClusterPosting>> {
323 if score_blob.get(4) != Some(&FORMAT_VERSION) {
324 return Err(corrupt("legacy cluster reader requires format version 1"));
325 }
326 let scores = decode_all_scores(cluster_id, score_blob)?;
327 let positions = decode_positions(positions_blob, &scores)?;
328 Ok(scores
329 .into_iter()
330 .zip(positions)
331 .map(|(score, positions)| ClusterPosting {
332 doc_id: score.doc_id,
333 term_freq: score.term_freq,
334 doc_length: score.doc_length,
335 positions,
336 })
337 .collect())
338}
339
340pub fn score_count(score_blob: &[u8]) -> StorageBackendResult<u64> {
341 score_count_with_control(score_blob, || Ok(()))
342}
343
344pub fn score_count_with_control(
346 score_blob: &[u8],
347 mut poll: impl FnMut() -> StorageBackendResult<()>,
348) -> StorageBackendResult<u64> {
349 Ok(scores::ScoreDirectory::new(score_blob, &mut poll)?.count as u64)
350}
351
352pub fn decode_all_scores(
353 cluster_id: u64,
354 score_blob: &[u8],
355) -> StorageBackendResult<Vec<PostingScore>> {
356 let (count, blocks) = parse_score_blob(score_blob)?;
357 let mut entries = Vec::with_capacity(count);
358 for block in blocks {
359 decode_score_block_into(score_blob, cluster_id, block, &mut entries)?;
360 }
361 validate_scores(&entries)?;
362 Ok(entries)
363}
364
365fn position_entries(blob: &[u8], expected_count: usize) -> StorageBackendResult<Vec<&[u8]>> {
366 let directory = positions::PositionDirectory::new(blob, expected_count, &mut || Ok(()))?;
367 (0..expected_count)
368 .map(|index| directory.entry(index))
369 .collect()
370}
371
372fn validate_cluster_entries(entries: &[ClusterPosting]) -> StorageBackendResult<()> {
373 if entries.is_empty() {
374 return Ok(());
375 }
376 let expected_cluster = cluster_id(entries[0].doc_id);
377 let mut previous = None;
378 for entry in entries {
379 if cluster_id(entry.doc_id) != expected_cluster {
380 return Err(corrupt("one encoded value spans multiple clusters"));
381 }
382 if previous.is_some_and(|doc_id| doc_id >= entry.doc_id) {
383 return Err(corrupt("posting document ids are not strictly increasing"));
384 }
385 if entry.term_freq == 0
386 || entry.doc_length < entry.term_freq
387 || entry.term_freq != entry.positions.len() as u64
388 {
389 return Err(corrupt(
390 "posting frequency, document length, and positions disagree",
391 ));
392 }
393 if entry.positions.windows(2).any(|pair| pair[0] >= pair[1]) {
394 return Err(corrupt("term positions are not strictly increasing"));
395 }
396 previous = Some(entry.doc_id);
397 }
398 Ok(())
399}
400
401fn validate_scores(entries: &[PostingScore]) -> StorageBackendResult<()> {
402 let mut previous = None;
403 for entry in entries {
404 if previous.is_some_and(|doc_id| doc_id >= entry.doc_id) {
405 return Err(corrupt("posting scores are not strictly ordered"));
406 }
407 if entry.term_freq == 0 || entry.doc_length == 0 {
408 return Err(corrupt("invalid posting score frequencies"));
409 }
410 previous = Some(entry.doc_id);
411 }
412 Ok(())
413}
414
415fn validate_header(blob: &[u8], magic: [u8; 4]) -> StorageBackendResult<()> {
416 if blob.len() < HEADER_LEN || blob.get(..4) != Some(magic.as_slice()) {
417 return Err(corrupt("missing clustered posting header"));
418 }
419 if ![FORMAT_VERSION, OCCURRENCE_FORMAT_VERSION].contains(&blob[4]) {
420 return Err(corrupt("unsupported clustered posting version"));
421 }
422 if blob[5..8] != [0; 3] {
423 return Err(corrupt("clustered posting reserved header bits are set"));
424 }
425 Ok(())
426}
427
428fn put_varint(output: &mut Vec<u8>, mut value: u64) {
429 while value >= 0x80 {
430 output.push((value as u8 & 0x7f) | 0x80);
431 value >>= 7;
432 }
433 output.push(value as u8);
434}
435
436fn read_varint(input: &mut &[u8]) -> StorageBackendResult<u64> {
437 let mut value = 0_u64;
438 for shift in (0..=63).step_by(7) {
439 let Some((&byte, rest)) = input.split_first() else {
440 return Err(corrupt("truncated varint"));
441 };
442 *input = rest;
443 if shift == 63 && byte > 1 {
444 return Err(corrupt("varint overflow"));
445 }
446 value |= u64::from(byte & 0x7f) << shift;
447 if byte & 0x80 == 0 {
448 if shift > 0 && byte == 0 {
449 return Err(corrupt("noncanonical varint"));
450 }
451 return Ok(value);
452 }
453 }
454 Err(corrupt("unterminated varint"))
455}
456
457fn put_u32(output: &mut Vec<u8>, value: usize, field: &str) -> StorageBackendResult<()> {
458 let value = u32::try_from(value)
459 .map_err(|_| corrupt(format!("{field} exceeds the u32 on-disk format")))?;
460 output.extend_from_slice(&value.to_le_bytes());
461 Ok(())
462}
463
464fn read_u16(input: &[u8], offset: usize) -> StorageBackendResult<u16> {
465 let bytes: [u8; 2] = input
466 .get(offset..offset.saturating_add(2))
467 .ok_or_else(|| corrupt("truncated u16 field"))?
468 .try_into()
469 .map_err(|_| corrupt("invalid u16 field"))?;
470 Ok(u16::from_le_bytes(bytes))
471}
472
473fn read_u32(input: &[u8], offset: usize) -> StorageBackendResult<u32> {
474 let bytes: [u8; 4] = input
475 .get(offset..offset.saturating_add(4))
476 .ok_or_else(|| corrupt("truncated u32 field"))?
477 .try_into()
478 .map_err(|_| corrupt("invalid u32 field"))?;
479 Ok(u32::from_le_bytes(bytes))
480}
481
482fn corrupt(message: impl Into<String>) -> StorageBackendError {
483 StorageBackendError::Other(format!("corrupt clustered posting: {}", message.into()))
484}
485
486mod legacy;
487mod occurrences;
488mod scores;
489mod term_keys;
490
491use legacy::{decode_positions, encode_positions};
492pub use legacy::{decode_terms, encode_terms};
493pub use occurrences::{
494 decode_occurrence_cluster, decode_occurrence_cluster_budgeted,
495 decode_occurrence_document_budgeted, encode_occurrence_cluster, OccurrencePosting,
496};
497use scores::{decode_score_block_into, encode_scores, parse_score_blob};
498pub use term_keys::{decode_term_keys, encode_term_keys};
499
500#[cfg(test)]
501mod tests;