zeppelin_embed/fts/prune/
bounds.rs1use crate::fts::bm25::TermScorer;
42use crate::fts::bm25::{DocLen, Tf};
43use crate::fts::postings::BlockImpact;
44
45#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct BlockBound {
48 pub start: usize,
50 pub end: usize,
52 pub last_row: u32,
54 pub impact: BlockImpact,
56 pub max_score: f64,
58}
59
60#[must_use]
66pub fn impact_of(entries: &[(u32, u32)], lengths: &[u32]) -> BlockImpact {
67 let mut max_tf = 0_u32;
68 let mut min_len = u32::MAX;
69 for (row, tf) in entries {
70 max_tf = max_tf.max(*tf);
71 let length = usize::try_from(*row)
72 .ok()
73 .and_then(|slot| lengths.get(slot).copied())
74 .unwrap_or(1);
75 min_len = min_len.min(length);
76 }
77 BlockImpact {
78 max_tf,
79 min_len: u16::try_from(min_len).unwrap_or(u16::MAX),
81 }
82}
83
84#[must_use]
91pub fn build_block_bounds(
92 entries: &[(u32, u32)],
93 lengths: &[u32],
94 block_size: usize,
95 scorer: &TermScorer,
96) -> Vec<BlockBound> {
97 if block_size == 0 || entries.is_empty() {
98 return Vec::new();
99 }
100 let mut bounds = Vec::with_capacity(entries.len().div_ceil(block_size));
101 let mut start = 0_usize;
102 while start < entries.len() {
103 let end = (start + block_size).min(entries.len());
104 let chunk = entries.get(start..end).unwrap_or(&[]);
105 let impact = impact_of(chunk, lengths);
106 bounds.push(BlockBound {
107 start,
108 end,
109 last_row: chunk.last().map_or(0, |(row, _)| *row),
110 impact,
111 max_score: scorer.score(Tf(impact.max_tf), DocLen(u32::from(impact.min_len))),
113 });
114 start = end;
115 }
116 bounds
117}
118
119#[must_use]
121pub fn term_upper_bound(bounds: &[BlockBound]) -> f64 {
122 bounds
123 .iter()
124 .map(|block| block.max_score)
125 .fold(0.0_f64, f64::max)
126}
127
128#[cfg(test)]
129#[allow(
130 clippy::expect_used,
131 clippy::indexing_slicing,
132 clippy::panic,
133 clippy::unwrap_used
134)]
135mod tests {
136 use super::*;
137 use crate::fts::bm25::{Bm25Params, CorpusStats, Df, term_score};
138
139 fn stats() -> CorpusStats {
140 CorpusStats::new(1_000, 40_000).expect("valid stats")
141 }
142
143 #[test]
144 fn a_block_bound_is_never_below_any_true_score_in_its_block() {
145 let stats = stats();
146 let params = Bm25Params::default();
147 let lengths: Vec<u32> = (0..200_u32).map(|row| 1 + row % 97).collect();
148 let entries: Vec<(u32, u32)> = (0..200_u32).map(|row| (row, 1 + row % 13)).collect();
149
150 for df in [1_u32, 5, 200, 999] {
151 let scorer = TermScorer::new(Df(df), &stats, params);
152 for block_size in [1_usize, 2, 7, 64, 128] {
153 let bounds = build_block_bounds(&entries, &lengths, block_size, &scorer);
154 for block in &bounds {
155 for entry in entries.iter().take(block.end).skip(block.start) {
156 let (row, tf) = *entry;
157 let length = lengths[row as usize];
158 let truth = term_score(Tf(tf), Df(df), DocLen(length), &stats, params);
159 assert!(
160 block.max_score >= truth - 1e-12,
161 "bound {} is below a true score {truth} \
162 (df={df}, block={block_size}, row={row})",
163 block.max_score
164 );
165 }
166 }
167 }
168 }
169 }
170
171 #[test]
172 fn a_bound_built_here_matches_one_built_from_a_sealed_impact_pair() {
173 let stats = stats();
177 let scorer = TermScorer::new(Df(7), &stats, Bm25Params::default());
178 let lengths: Vec<u32> = (0..64_u32).map(|row| 3 + row % 11).collect();
179 let entries: Vec<(u32, u32)> = (0..64_u32).map(|row| (row, 1 + row % 5)).collect();
180 let bounds = build_block_bounds(&entries, &lengths, 16, &scorer);
181 assert_eq!(bounds.len(), 4);
182 for block in &bounds {
183 assert!((block.impact.bound(&scorer) - block.max_score).abs() < 1e-15);
184 }
185 }
186
187 #[test]
188 fn the_bound_survives_statistics_and_parameter_drift() {
189 let lengths: Vec<u32> = (0..64_u32).map(|row| 1 + row % 40).collect();
192 let entries: Vec<(u32, u32)> = (0..64_u32).map(|row| (row, 1 + row % 9)).collect();
193 let sealed = CorpusStats::new(100, 1_000).expect("avgdl 10");
194 let sealed_scorer = TermScorer::new(Df(64), &sealed, Bm25Params::beir());
195 let bounds = build_block_bounds(&entries, &lengths, 16, &sealed_scorer);
196
197 for (docs, tokens) in [(1_000_u64, 100_000_u64), (10, 50), (5_000, 5_000)] {
198 let live = CorpusStats::new(docs, tokens).expect("valid stats");
199 for params in [Bm25Params::beir(), Bm25Params::anserini()] {
200 let live_scorer = TermScorer::new(Df(64), &live, params);
201 for block in &bounds {
202 let bound = block.impact.bound(&live_scorer);
205 for entry in entries.iter().take(block.end).skip(block.start) {
206 let (row, tf) = *entry;
207 let truth = live_scorer.score(Tf(tf), DocLen(lengths[row as usize]));
208 assert!(bound >= truth - 1e-12, "bound {bound} below {truth}");
209 }
210 }
211 }
212 }
213 }
214
215 #[test]
216 fn the_term_upper_bound_dominates_every_block_bound() {
217 let stats = stats();
218 let scorer = TermScorer::new(Df(10), &stats, Bm25Params::default());
219 let lengths: Vec<u32> = (0..64_u32).map(|row| 1 + row).collect();
220 let entries: Vec<(u32, u32)> = (0..64_u32).map(|row| (row, 1 + row % 5)).collect();
221 let bounds = build_block_bounds(&entries, &lengths, 8, &scorer);
222 let overall = term_upper_bound(&bounds);
223 for block in &bounds {
224 assert!(block.max_score <= overall + 1e-12);
225 }
226 }
227
228 #[test]
229 fn blocks_partition_the_entries_exactly_once() {
230 let stats = stats();
231 let scorer = TermScorer::new(Df(4), &stats, Bm25Params::default());
232 let lengths: Vec<u32> = vec![10; 100];
233 let entries: Vec<(u32, u32)> = (0..100_u32).map(|row| (row, 2)).collect();
234 for block_size in [1_usize, 3, 64, 200] {
235 let bounds = build_block_bounds(&entries, &lengths, block_size, &scorer);
236 let mut cursor = 0_usize;
237 for block in &bounds {
238 assert_eq!(block.start, cursor, "blocks must be contiguous");
239 assert!(block.end > block.start, "a block must not be empty");
240 cursor = block.end;
241 }
242 assert_eq!(cursor, entries.len(), "blocks must cover every entry");
243 }
244 }
245
246 #[test]
247 fn the_last_row_of_each_block_is_its_skip_key() {
248 let stats = stats();
249 let scorer = TermScorer::new(Df(2), &stats, Bm25Params::default());
250 let lengths: Vec<u32> = vec![5; 40];
251 let entries: Vec<(u32, u32)> = (0..10_u32).map(|row| (row * 3, 1)).collect();
252 let bounds = build_block_bounds(&entries, &lengths, 4, &scorer);
253 assert_eq!(bounds.len(), 3);
254 assert_eq!(bounds[0].last_row, 9);
255 assert_eq!(bounds[1].last_row, 21);
256 assert_eq!(bounds[2].last_row, 27);
257 }
258
259 #[test]
260 fn degenerate_inputs_produce_no_bounds_rather_than_panicking() {
261 let stats = stats();
262 let scorer = TermScorer::new(Df(1), &stats, Bm25Params::default());
263 assert!(build_block_bounds(&[], &[], 8, &scorer).is_empty());
264 assert!(build_block_bounds(&[(0, 1)], &[5], 0, &scorer).is_empty());
265 assert_eq!(term_upper_bound(&[]), 0.0);
266 assert!(impact_of(&[], &[]).is_absent());
267 }
268
269 #[test]
270 fn a_row_with_no_recorded_length_still_yields_a_sound_bound() {
271 let stats = stats();
274 let scorer = TermScorer::new(Df(2), &stats, Bm25Params::default());
275 let entries = vec![(0_u32, 3_u32), (500, 3)];
276 let lengths = vec![10_u32];
277 let bounds = build_block_bounds(&entries, &lengths, 8, &scorer);
278 assert_eq!(bounds.len(), 1);
279 let fallback = term_score(Tf(3), Df(2), DocLen(1), &stats, Bm25Params::default());
282 assert!(bounds[0].max_score >= fallback - 1e-12);
283 }
284
285 #[test]
286 fn a_length_beyond_the_slot_saturates_downward() {
287 let entries = vec![(0_u32, 2_u32)];
288 let lengths = vec![100_000_u32];
289 assert_eq!(impact_of(&entries, &lengths).min_len, u16::MAX);
290 }
291}