1use std::collections::BTreeMap;
16
17use rusqlite::params;
18
19use uqa_core::PostingList;
20
21use crate::{StorageBackendError, StorageBackendResult};
22
23pub const DEFAULT_BLOCK_SIZE: usize = 128;
24
25pub trait BlockMaxScorer {
29 fn score(&self, term_freq: u64, doc_length: u64, doc_freq: u64) -> f64;
30}
31
32#[derive(Debug, Clone)]
33pub struct BlockMaxIndex {
34 block_size: usize,
35 block_maxes: BTreeMap<(String, String, String), Vec<f64>>,
36}
37
38impl Default for BlockMaxIndex {
39 fn default() -> Self {
40 Self {
41 block_size: DEFAULT_BLOCK_SIZE,
42 block_maxes: BTreeMap::new(),
43 }
44 }
45}
46
47impl BlockMaxIndex {
48 pub fn new(block_size: usize) -> StorageBackendResult<Self> {
49 if block_size == 0 {
50 return Err(StorageBackendError::Other(
51 "block-max block size must be greater than zero".to_string(),
52 ));
53 }
54 Ok(Self {
55 block_size,
56 block_maxes: BTreeMap::new(),
57 })
58 }
59
60 pub fn block_size(&self) -> usize {
61 self.block_size
62 }
63
64 pub fn set_block_maxes(
65 &mut self,
66 table: &str,
67 field: &str,
68 term: &str,
69 scores: Vec<f64>,
70 ) -> StorageBackendResult<()> {
71 validate_scores(&scores)?;
72 self.block_maxes.insert(
73 (table.to_string(), field.to_string(), term.to_string()),
74 scores,
75 );
76 Ok(())
77 }
78
79 pub fn build<S: BlockMaxScorer + ?Sized>(
83 &mut self,
84 posting_list: &PostingList,
85 scorer: &S,
86 field: &str,
87 term: &str,
88 table: &str,
89 ) -> StorageBackendResult<()> {
90 if self.block_size == 0 {
91 return Err(StorageBackendError::Other(
92 "block-max block size must be greater than zero".to_string(),
93 ));
94 }
95 let entries = posting_list.entries();
96 let key = (table.to_string(), field.to_string(), term.to_string());
97 if entries.is_empty() {
98 self.block_maxes.insert(key, Vec::new());
99 return Ok(());
100 }
101 let df = u64::try_from(entries.len()).map_err(|_| {
102 StorageBackendError::Other("posting-list length exceeds u64".to_string())
103 })?;
104 let mut blocks = Vec::with_capacity(entries.len().div_ceil(self.block_size));
105 for chunk in entries.chunks(self.block_size) {
106 let mut max_score = 0.0_f64;
107 for entry in chunk {
108 let positions = &entry.payload.positions;
109 let tf = if positions.is_empty() {
110 1
111 } else {
112 u64::try_from(positions.len()).map_err(|_| {
113 StorageBackendError::Other("term position count exceeds u64".to_string())
114 })?
115 };
116 let s = scorer.score(tf, tf, df);
117 validate_score(s)?;
118 if s > max_score {
119 max_score = s;
120 }
121 }
122 blocks.push(max_score);
123 }
124 self.block_maxes.insert(key, blocks);
125 Ok(())
126 }
127
128 pub fn block_max(&self, table: &str, field: &str, term: &str, block_idx: usize) -> f64 {
129 let key = (table.to_string(), field.to_string(), term.to_string());
130 self.block_maxes
131 .get(&key)
132 .and_then(|v| v.get(block_idx).copied())
133 .unwrap_or(0.0)
134 }
135
136 pub fn num_blocks(&self, table: &str, field: &str, term: &str) -> usize {
137 let key = (table.to_string(), field.to_string(), term.to_string());
138 self.block_maxes.get(&key).map_or(0, Vec::len)
139 }
140
141 pub fn block_maxes(&self, table: &str, field: &str, term: &str) -> Option<&[f64]> {
144 let key = (table.to_string(), field.to_string(), term.to_string());
145 self.block_maxes.get(&key).map(Vec::as_slice)
146 }
147
148 pub fn block_index_for(&self, position: usize) -> StorageBackendResult<usize> {
150 if self.block_size == 0 {
151 return Err(StorageBackendError::Other(
152 "block-max block size must be greater than zero".to_string(),
153 ));
154 }
155 Ok(position / self.block_size)
156 }
157
158 pub fn clear(&mut self) {
159 self.block_maxes.clear();
160 }
161
162 pub fn save_to_sqlite(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> {
163 for scores in self.block_maxes.values() {
164 validate_scores(scores).map_err(storage_error_to_sqlite)?;
165 }
166 ensure_global_blockmax_shape(conn)?;
167 let transaction = conn.unchecked_transaction()?;
168 transaction.execute("DELETE FROM _global_blockmax", [])?;
169 for ((table, field, term), scores) in &self.block_maxes {
170 for (block_idx, score) in scores.iter().enumerate() {
171 let block_idx = i64::try_from(block_idx)
172 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
173 transaction.execute(
174 "INSERT INTO _global_blockmax
175 (table_name, field, term, block_idx, max_score)
176 VALUES (?1, ?2, ?3, ?4, ?5)",
177 params![table, field, term, block_idx, *score],
178 )?;
179 }
180 }
181 transaction.commit()
182 }
183
184 pub fn load_from_sqlite(&mut self, conn: &rusqlite::Connection) -> rusqlite::Result<()> {
185 ensure_global_blockmax_shape(conn)?;
186 let mut stmt = conn.prepare(
187 "SELECT table_name, field, term, block_idx, max_score
188 FROM _global_blockmax
189 ORDER BY table_name, field, term, block_idx",
190 )?;
191 let rows = stmt.query_map([], |row| {
192 Ok((
193 row.get::<_, String>(0)?,
194 row.get::<_, String>(1)?,
195 row.get::<_, String>(2)?,
196 row.get::<_, i64>(3)?,
197 row.get::<_, f64>(4)?,
198 ))
199 })?;
200 let mut loaded = BTreeMap::<(String, String, String), Vec<f64>>::new();
201 for row in rows {
202 let (table, field, term, block_idx, score) = row?;
203 validate_score(score).map_err(storage_error_to_sqlite)?;
204 let idx = usize::try_from(block_idx)
205 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(3, block_idx))?;
206 let entry = loaded.entry((table, field, term)).or_default();
207 if idx != entry.len() {
208 return Err(rusqlite::Error::FromSqlConversionFailure(
209 3,
210 rusqlite::types::Type::Integer,
211 Box::new(std::io::Error::new(
212 std::io::ErrorKind::InvalidData,
213 format!(
214 "invalid block-max ordinal sequence: expected {}, found {idx}",
215 entry.len()
216 ),
217 )),
218 ));
219 }
220 entry.push(score);
221 }
222 self.block_maxes = loaded;
223 Ok(())
224 }
225}
226
227fn validate_scores(scores: &[f64]) -> StorageBackendResult<()> {
228 for &score in scores {
229 validate_score(score)?;
230 }
231 Ok(())
232}
233
234fn validate_score(score: f64) -> StorageBackendResult<()> {
235 if score.is_finite() && score >= 0.0 {
236 Ok(())
237 } else {
238 Err(StorageBackendError::Other(format!(
239 "block-max score must be finite and non-negative, got {score}"
240 )))
241 }
242}
243
244fn storage_error_to_sqlite(error: StorageBackendError) -> rusqlite::Error {
245 rusqlite::Error::ToSqlConversionFailure(Box::new(error))
246}
247
248fn ensure_global_blockmax_shape(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
249 conn.execute(
250 "CREATE TABLE IF NOT EXISTS _global_blockmax (
251 table_name TEXT NOT NULL DEFAULT '',
252 field TEXT NOT NULL,
253 term TEXT NOT NULL,
254 block_idx INTEGER NOT NULL,
255 max_score REAL NOT NULL,
256 PRIMARY KEY (table_name, field, term, block_idx)
257 )",
258 [],
259 )?;
260 let mut stmt = conn.prepare("PRAGMA table_info(_global_blockmax)")?;
261 let cols = stmt
262 .query_map([], |row| row.get::<_, String>(1))?
263 .collect::<Result<Vec<_>, _>>()?;
264 drop(stmt);
265 if !cols.iter().any(|c| c == "table_name") {
266 conn.execute(
267 "ALTER TABLE _global_blockmax ADD COLUMN table_name TEXT NOT NULL DEFAULT ''",
268 [],
269 )?;
270 }
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use uqa_core::{Payload, PostingEntry, PostingList};
278
279 struct LinearScorer;
282 impl BlockMaxScorer for LinearScorer {
283 fn score(&self, term_freq: u64, _doc_length: u64, _doc_freq: u64) -> f64 {
284 term_freq as f64
285 }
286 }
287
288 struct InvalidScorer(f64);
289 impl BlockMaxScorer for InvalidScorer {
290 fn score(&self, _term_freq: u64, _doc_length: u64, _doc_freq: u64) -> f64 {
291 self.0
292 }
293 }
294
295 fn pl_with_tfs(tfs: &[u32]) -> PostingList {
296 let entries: Vec<PostingEntry> = tfs
297 .iter()
298 .enumerate()
299 .map(|(i, &tf)| {
300 let positions = (0..tf).collect();
301 PostingEntry::new(
302 u64::try_from(i).unwrap() + 1,
303 Payload {
304 positions,
305 score: 0.0,
306 fields: BTreeMap::default(),
307 },
308 )
309 })
310 .collect();
311 PostingList::from_unsorted(entries)
312 }
313
314 #[test]
315 fn block_max_records_per_block_maximum() {
316 let mut idx = BlockMaxIndex::new(2).unwrap();
317 let pl = pl_with_tfs(&[1, 5, 3, 7, 2]);
318 idx.build(&pl, &LinearScorer, "title", "rust", "articles")
319 .unwrap();
320 assert_eq!(idx.num_blocks("articles", "title", "rust"), 3);
322 assert!((idx.block_max("articles", "title", "rust", 0) - 5.0).abs() < 1e-12);
323 assert!((idx.block_max("articles", "title", "rust", 1) - 7.0).abs() < 1e-12);
324 assert!((idx.block_max("articles", "title", "rust", 2) - 2.0).abs() < 1e-12);
325 }
326
327 #[test]
328 fn empty_posting_list_records_no_blocks() {
329 let mut idx = BlockMaxIndex::new(4).unwrap();
330 idx.build(&PostingList::new(), &LinearScorer, "title", "rust", "t")
331 .unwrap();
332 assert_eq!(idx.num_blocks("t", "title", "rust"), 0);
333 assert!((idx.block_max("t", "title", "rust", 0) - 0.0).abs() < 1e-12);
334 }
335
336 #[test]
337 fn block_index_for_position() {
338 let idx = BlockMaxIndex::new(4).unwrap();
339 assert_eq!(idx.block_index_for(0).unwrap(), 0);
340 assert_eq!(idx.block_index_for(3).unwrap(), 0);
341 assert_eq!(idx.block_index_for(4).unwrap(), 1);
342 assert_eq!(idx.block_index_for(9).unwrap(), 2);
343 }
344
345 #[test]
346 fn rejects_zero_block_size_and_invalid_scores_without_replacing_state() {
347 assert!(BlockMaxIndex::new(0).is_err());
348
349 let mut index = BlockMaxIndex::new(2).unwrap();
350 index
351 .set_block_maxes("docs", "body", "term", vec![3.0])
352 .unwrap();
353 let postings = pl_with_tfs(&[1, 2]);
354 assert!(index
355 .build(&postings, &InvalidScorer(f64::NAN), "body", "term", "docs")
356 .is_err());
357 assert_eq!(index.block_max("docs", "body", "term", 0), 3.0);
358 assert!(index
359 .set_block_maxes("docs", "body", "term", vec![-1.0])
360 .is_err());
361 assert_eq!(index.block_max("docs", "body", "term", 0), 3.0);
362 }
363
364 #[test]
365 fn corrupt_persisted_ordinal_does_not_replace_loaded_state() {
366 let connection = rusqlite::Connection::open_in_memory().unwrap();
367 ensure_global_blockmax_shape(&connection).unwrap();
368 connection
369 .execute(
370 "INSERT INTO _global_blockmax
371 (table_name, field, term, block_idx, max_score)
372 VALUES ('docs', 'body', 'bad', -1, 9.0)",
373 [],
374 )
375 .unwrap();
376 let mut index = BlockMaxIndex::default();
377 index
378 .set_block_maxes("old", "body", "term", vec![1.0])
379 .unwrap();
380
381 assert!(index.load_from_sqlite(&connection).is_err());
382 assert_eq!(index.block_max("old", "body", "term", 0), 1.0);
383 }
384
385 #[test]
386 fn failed_save_rolls_back_deleted_snapshot() {
387 let connection = rusqlite::Connection::open_in_memory().unwrap();
388 ensure_global_blockmax_shape(&connection).unwrap();
389 connection
390 .execute(
391 "INSERT INTO _global_blockmax
392 (table_name, field, term, block_idx, max_score)
393 VALUES ('old', 'body', 'term', 0, 1.0)",
394 [],
395 )
396 .unwrap();
397 connection
398 .execute_batch(
399 "CREATE TRIGGER fail_blockmax_insert
400 BEFORE INSERT ON _global_blockmax
401 BEGIN
402 SELECT RAISE(ABORT, 'injected block-max failure');
403 END;",
404 )
405 .unwrap();
406 let mut index = BlockMaxIndex::default();
407 index
408 .set_block_maxes("new", "body", "term", vec![2.0])
409 .unwrap();
410
411 assert!(index.save_to_sqlite(&connection).is_err());
412 let persisted: (String, f64) = connection
413 .query_row(
414 "SELECT table_name, max_score FROM _global_blockmax",
415 [],
416 |row| Ok((row.get(0)?, row.get(1)?)),
417 )
418 .unwrap();
419 assert_eq!(persisted, ("old".to_string(), 1.0));
420 }
421}