Skip to main content

uqa_storage/
block_max_index.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Per-block maximum score index for Block-Max WAND optimisation.
8//!
9//! For each `(table, field, term)` posting list, we precompute the
10//! maximum score reachable inside each fixed-size block of consecutive
11//! entries. Block-Max WAND consults these tighter per-block upper bounds
12//! during pivot resolution, achieving higher skip rates than plain WAND
13//! (Theorem 6.2.2, Paper 3).
14
15use std::collections::BTreeMap;
16
17use uqa_core::PostingList;
18
19use crate::{StorageBackendError, StorageBackendResult, TokenTermKey};
20
21pub const DEFAULT_BLOCK_SIZE: usize = 128;
22
23/// Trait every scorer used to seed a block-max index implements. Only
24/// the inner BM25 surface is needed; `uqa_scoring::BM25Scorer` already
25/// satisfies it without extra glue.
26pub trait BlockMaxScorer {
27    fn score(&self, term_freq: u64, doc_length: u64, doc_freq: u64) -> f64;
28}
29
30#[derive(Debug, Clone)]
31pub struct BlockMaxIndex {
32    block_size: usize,
33    block_maxes: BTreeMap<(String, String, TokenTermKey), Vec<f64>>,
34}
35
36impl Default for BlockMaxIndex {
37    fn default() -> Self {
38        Self {
39            block_size: DEFAULT_BLOCK_SIZE,
40            block_maxes: BTreeMap::new(),
41        }
42    }
43}
44
45impl BlockMaxIndex {
46    pub fn new(block_size: usize) -> StorageBackendResult<Self> {
47        if block_size == 0 {
48            return Err(StorageBackendError::Other(
49                "block-max block size must be greater than zero".to_string(),
50            ));
51        }
52        Ok(Self {
53            block_size,
54            block_maxes: BTreeMap::new(),
55        })
56    }
57
58    pub fn block_size(&self) -> usize {
59        self.block_size
60    }
61
62    pub fn set_block_maxes(
63        &mut self,
64        table: &str,
65        field: &str,
66        term: &str,
67        scores: Vec<f64>,
68    ) -> StorageBackendResult<()> {
69        self.set_block_maxes_key(table, field, &TokenTermKey::from_text(term), scores)
70    }
71
72    pub fn set_block_maxes_key(
73        &mut self,
74        table: &str,
75        field: &str,
76        term: &TokenTermKey,
77        scores: Vec<f64>,
78    ) -> StorageBackendResult<()> {
79        validate_scores(&scores)?;
80        self.block_maxes
81            .insert((table.to_string(), field.to_string(), term.clone()), scores);
82        Ok(())
83    }
84
85    /// Compute and store per-block maxima for `posting_list`. Each
86    /// block's max is `max_{e in block} scorer.score(tf(e), tf(e), df)` and
87    /// uses term frequency as the document-length stand-in when none is given.
88    pub fn build<S: BlockMaxScorer + ?Sized>(
89        &mut self,
90        posting_list: &PostingList,
91        scorer: &S,
92        field: &str,
93        term: &str,
94        table: &str,
95    ) -> StorageBackendResult<()> {
96        if self.block_size == 0 {
97            return Err(StorageBackendError::Other(
98                "block-max block size must be greater than zero".to_string(),
99            ));
100        }
101        let entries = posting_list.entries();
102        let key = (
103            table.to_string(),
104            field.to_string(),
105            TokenTermKey::from_text(term),
106        );
107        if entries.is_empty() {
108            self.block_maxes.insert(key, Vec::new());
109            return Ok(());
110        }
111        let df = u64::try_from(entries.len()).map_err(|_| {
112            StorageBackendError::Other("posting-list length exceeds u64".to_string())
113        })?;
114        let mut blocks = Vec::with_capacity(entries.len().div_ceil(self.block_size));
115        for chunk in entries.chunks(self.block_size) {
116            let mut max_score = 0.0_f64;
117            for entry in chunk {
118                let positions = &entry.payload.positions;
119                let tf = if positions.is_empty() {
120                    1
121                } else {
122                    u64::try_from(positions.len()).map_err(|_| {
123                        StorageBackendError::Other("term position count exceeds u64".to_string())
124                    })?
125                };
126                let s = scorer.score(tf, tf, df);
127                validate_score(s)?;
128                if s > max_score {
129                    max_score = s;
130                }
131            }
132            blocks.push(max_score);
133        }
134        self.block_maxes.insert(key, blocks);
135        Ok(())
136    }
137
138    pub fn block_max(&self, table: &str, field: &str, term: &str, block_idx: usize) -> f64 {
139        let key = (
140            table.to_string(),
141            field.to_string(),
142            TokenTermKey::from_text(term),
143        );
144        self.block_maxes
145            .get(&key)
146            .and_then(|v| v.get(block_idx).copied())
147            .unwrap_or(0.0)
148    }
149
150    pub fn num_blocks(&self, table: &str, field: &str, term: &str) -> usize {
151        let key = (
152            table.to_string(),
153            field.to_string(),
154            TokenTermKey::from_text(term),
155        );
156        self.block_maxes.get(&key).map_or(0, Vec::len)
157    }
158
159    /// Borrow all block scores for one posting without repeated key
160    /// construction. BMW uses this to precompute suffix bounds once per query.
161    pub fn block_maxes(&self, table: &str, field: &str, term: &str) -> Option<&[f64]> {
162        self.block_maxes_key(table, field, &TokenTermKey::from_text(term))
163    }
164
165    pub fn block_maxes_key(&self, table: &str, field: &str, term: &TokenTermKey) -> Option<&[f64]> {
166        let key = (table.to_string(), field.to_string(), term.clone());
167        self.block_maxes.get(&key).map(Vec::as_slice)
168    }
169
170    /// Block index for a given posting-list cursor position.
171    pub fn block_index_for(&self, position: usize) -> StorageBackendResult<usize> {
172        if self.block_size == 0 {
173            return Err(StorageBackendError::Other(
174                "block-max block size must be greater than zero".to_string(),
175            ));
176        }
177        Ok(position / self.block_size)
178    }
179
180    pub fn clear(&mut self) {
181        self.block_maxes.clear();
182    }
183
184    /// Iterate validated block maxima for persistence without mutable storage access.
185    pub fn entries(&self) -> impl Iterator<Item = ((&str, &str, &TokenTermKey), &[f64])> {
186        self.block_maxes
187            .iter()
188            .map(|((table, field, term), scores)| {
189                ((table.as_str(), field.as_str(), term), scores.as_slice())
190            })
191    }
192}
193
194fn validate_scores(scores: &[f64]) -> StorageBackendResult<()> {
195    for &score in scores {
196        validate_score(score)?;
197    }
198    Ok(())
199}
200
201fn validate_score(score: f64) -> StorageBackendResult<()> {
202    if score.is_finite() && score >= 0.0 {
203        Ok(())
204    } else {
205        Err(StorageBackendError::Other(format!(
206            "block-max score must be finite and non-negative, got {score}"
207        )))
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use uqa_core::{Payload, PostingEntry, PostingList};
215
216    /// Trivial scorer: tf raised to a constant — strictly increasing in
217    /// tf so the per-block max equals the max tf in the block.
218    struct LinearScorer;
219    impl BlockMaxScorer for LinearScorer {
220        fn score(&self, term_freq: u64, _doc_length: u64, _doc_freq: u64) -> f64 {
221            term_freq as f64
222        }
223    }
224
225    struct InvalidScorer(f64);
226    impl BlockMaxScorer for InvalidScorer {
227        fn score(&self, _term_freq: u64, _doc_length: u64, _doc_freq: u64) -> f64 {
228            self.0
229        }
230    }
231
232    fn pl_with_tfs(tfs: &[u32]) -> PostingList {
233        let entries: Vec<PostingEntry> = tfs
234            .iter()
235            .enumerate()
236            .map(|(i, &tf)| {
237                let positions = (0..tf).collect();
238                PostingEntry::new(
239                    u64::try_from(i).unwrap() + 1,
240                    Payload {
241                        positions,
242                        score: 0.0,
243                        fields: BTreeMap::default(),
244                    },
245                )
246            })
247            .collect();
248        PostingList::from_unsorted(entries)
249    }
250
251    #[test]
252    fn block_max_records_per_block_maximum() {
253        let mut idx = BlockMaxIndex::new(2).unwrap();
254        let pl = pl_with_tfs(&[1, 5, 3, 7, 2]);
255        idx.build(&pl, &LinearScorer, "title", "rust", "articles")
256            .unwrap();
257        // Blocks of size 2: [1, 5] [3, 7] [2] -> maxes 5, 7, 2
258        assert_eq!(idx.num_blocks("articles", "title", "rust"), 3);
259        assert!((idx.block_max("articles", "title", "rust", 0) - 5.0).abs() < 1e-12);
260        assert!((idx.block_max("articles", "title", "rust", 1) - 7.0).abs() < 1e-12);
261        assert!((idx.block_max("articles", "title", "rust", 2) - 2.0).abs() < 1e-12);
262    }
263
264    #[test]
265    fn empty_posting_list_records_no_blocks() {
266        let mut idx = BlockMaxIndex::new(4).unwrap();
267        idx.build(&PostingList::new(), &LinearScorer, "title", "rust", "t")
268            .unwrap();
269        assert_eq!(idx.num_blocks("t", "title", "rust"), 0);
270        assert!((idx.block_max("t", "title", "rust", 0) - 0.0).abs() < 1e-12);
271    }
272
273    #[test]
274    fn block_index_for_position() {
275        let idx = BlockMaxIndex::new(4).unwrap();
276        assert_eq!(idx.block_index_for(0).unwrap(), 0);
277        assert_eq!(idx.block_index_for(3).unwrap(), 0);
278        assert_eq!(idx.block_index_for(4).unwrap(), 1);
279        assert_eq!(idx.block_index_for(9).unwrap(), 2);
280    }
281
282    #[test]
283    fn rejects_zero_block_size_and_invalid_scores_without_replacing_state() {
284        assert!(BlockMaxIndex::new(0).is_err());
285
286        let mut index = BlockMaxIndex::new(2).unwrap();
287        index
288            .set_block_maxes("docs", "body", "term", vec![3.0])
289            .unwrap();
290        let postings = pl_with_tfs(&[1, 2]);
291        assert!(index
292            .build(&postings, &InvalidScorer(f64::NAN), "body", "term", "docs")
293            .is_err());
294        assert_eq!(index.block_max("docs", "body", "term", 0), 3.0);
295        assert!(index
296            .set_block_maxes("docs", "body", "term", vec![-1.0])
297            .is_err());
298        assert_eq!(index.block_max("docs", "body", "term", 0), 3.0);
299    }
300}