1use std::collections::BTreeMap;
16
17use uqa_core::PostingList;
18
19use crate::{StorageBackendError, StorageBackendResult, TokenTermKey};
20
21pub const DEFAULT_BLOCK_SIZE: usize = 128;
22
23pub 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 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 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 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 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 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 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}