Skip to main content

zeppelin_embed/fts/prune/
bounds.rs

1//! Per-block upper bounds: the only thing that makes skipping sound.
2//!
3//! # The one invariant
4//!
5//! A block's bound must be at or above every true score inside it. If it is
6//! ever below, pruning drops a document that belonged in the top-k, and that
7//! is a wrong answer rather than a slow one — the exact failure task 14's
8//! equivalence property exists to catch.
9//!
10//! # The bound is an impact pair, evaluated late
11//!
12//! A block is summarized by two integers: the largest term frequency it
13//! holds and the shortest document it touches. Neither carries a statistic,
14//! so neither can go stale. The bound is `term_score(max_tf, min_len)` under
15//! whatever `avgdl` and `(k1, b)` the caller is scoring with right now.
16//!
17//! Soundness is monotonicity: BM25 rises with `tf` and falls with `len`, so
18//! that pair dominates every posting in the block. The two extremes need not
19//! come from the same document; pairing them is looser than the true maximum
20//! and therefore still an upper bound.
21//!
22//! # Why not the exact per-block maximum
23//!
24//! Because it is not a summary — it is a score, and a score is a number
25//! computed under one set of statistics. `score / ceiling` cancels `idf` but
26//! not `avgdl` or `(k1, b)`, so a stored maximum sealed when documents were
27//! short falls **below** a true score once longer documents arrive: 15.21%
28//! below on an `avgdl` rise from 10 to 100, 19.56% below on a
29//! `beir()`-to-`anserini()` change. `tests/block_max_soundness.rs` gates it.
30//!
31//! The pair is looser, and a looser bound skips less. That is the price of
32//! an answer that is right under drift, and it is the one direction of error
33//! that is safe: a loose bound costs time, a stale one costs a result.
34//!
35//! # Cost
36//!
37//! Building bounds this way costs one `term_score` call per BLOCK. The
38//! previous form scored every posting in order to bound it, which is why the
39//! pruned path was slower than the scan it exists to avoid.
40
41use crate::fts::bm25::TermScorer;
42use crate::fts::bm25::{DocLen, Tf};
43use crate::fts::postings::BlockImpact;
44
45/// One block's bound over a run of `(row, weighted tf)` entries.
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct BlockBound {
48    /// Index of the first entry the block covers.
49    pub start: usize,
50    /// Index one past the last entry the block covers.
51    pub end: usize,
52    /// Largest row id in the block; the skip key.
53    pub last_row: u32,
54    /// The block's impact pair, as a sealed segment would store it.
55    pub impact: BlockImpact,
56    /// Upper bound on any posting's contribution under the live scorer.
57    pub max_score: f64,
58}
59
60/// Summarizes one run of `(row, weighted tf)` entries as an impact pair.
61///
62/// `lengths` supplies the weighted analyzed length of each row. A row beyond
63/// the array contributes the shortest possible length, which pushes the
64/// bound up rather than down.
65#[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        // Saturating DOWN; see `BlockImpact::min_len`.
80        min_len: u16::try_from(min_len).unwrap_or(u16::MAX),
81    }
82}
83
84/// Builds per-block bounds for one term's merged postings.
85///
86/// The bound is exactly what a sealed segment hands back: the same impact
87/// pair, evaluated by the same scorer. Computing bounds one way in memory
88/// and another way on disk would let the equivalence property pass against a
89/// fixture and fail against a real segment.
90#[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            // One score per block, not one per posting.
112            max_score: scorer.score(Tf(impact.max_tf), DocLen(u32::from(impact.min_len))),
113        });
114        start = end;
115    }
116    bounds
117}
118
119/// Returns the largest bound across every block, the term's upper bound.
120#[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        // The in-memory bound and the persisted one must be the same number,
174        // or the equivalence property passes against a fixture and fails
175        // against a real segment.
176        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        // The whole point of a pair over a stored score: the summary carries
190        // no statistics, so re-evaluating it under drifted ones stays sound.
191        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                    // The reader re-evaluates the stored pair; it never
203                    // reuses the number computed at seal time.
204                    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        // A length array shorter than the highest row must not silently
272        // produce a bound below the truth.
273        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        // The fallback length of 1 is the shortest possible, so its score is
280        // the largest possible: the bound stays an upper bound.
281        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}