Skip to main content

summa_core/query/
bm25.rs

1//! BM25/BM25F scoring constants and utilities
2//!
3//! Shared BM25 parameters used across full-text scoring implementations.
4//! All posting list formats and scoring executors should use these functions.
5
6/// BM25 k1 parameter - controls term frequency saturation
7/// Higher values give more weight to term frequency
8pub const BM25_K1: f32 = 1.2;
9
10/// BM25 b parameter - controls length normalization
11/// 0 = no length normalization, 1 = full normalization
12pub const BM25_B: f32 = 0.75;
13
14/// Query-local normalization for byte norms, using the canonical arithmetic.
15/// One KiB per active quantized text cursor; no index-sized decoded cache.
16pub(super) struct NormTable([f32; 256]);
17
18impl NormTable {
19    pub(super) fn new(params: Bm25Params, average: f32) -> Self {
20        crate::observe::search_work!(norm_tables += 1);
21        Self(std::array::from_fn(|code| {
22            let length = crate::segment::norms::decode(code as u8) as f32;
23            params.k1 * (1.0 - params.b + params.b * (length / average.max(1.0)))
24        }))
25    }
26
27    /// Gather query-normalized lengths before scoring contiguous frequencies.
28    /// The scratch is bounded by one posting block, including short tails.
29    #[allow(clippy::too_many_arguments)]
30    pub(super) fn score_batch(
31        &self,
32        params: Bm25Params,
33        idf: f32,
34        average: f32,
35        boost: f32,
36        codes: impl ExactSizeIterator<Item = u8>,
37        tfs: &[u32],
38        scores: &mut [f32],
39    ) {
40        debug_assert_eq!(tfs.len(), scores.len());
41        debug_assert_eq!(tfs.len(), codes.len());
42        if boost == 0.0 {
43            scores.fill(0.0);
44            return;
45        }
46        let mut norms = [0.0; crate::structures::postings::POSTING_BLOCK_SIZE];
47        for ((norm, &tf), code) in norms[..tfs.len()].iter_mut().zip(tfs).zip(codes) {
48            *norm = if code == 0 {
49                params.k1 * (1.0 - params.b + params.b * (tf as f32 / average.max(1.0)))
50            } else {
51                self.0[usize::from(code)]
52            };
53        }
54        for ((score, &tf), &norm) in scores.iter_mut().zip(tfs).zip(&norms) {
55            *score = if tf == 0 {
56                0.0
57            } else {
58                let boosted = tf as f32 * boost;
59                idf * ((boosted * (params.k1 + 1.0)) / (boosted + norm))
60            };
61        }
62    }
63
64    #[cfg(test)]
65    pub(super) fn score(
66        &self,
67        params: Bm25Params,
68        tf: f32,
69        idf: f32,
70        code: u8,
71        average: f32,
72    ) -> f32 {
73        if code == 0 {
74            return params.score(tf, idf, tf, average);
75        }
76        if tf == 0.0 {
77            return 0.0;
78        }
79        idf * ((tf * (params.k1 + 1.0)) / (tf + self.0[usize::from(code)]))
80    }
81
82    pub(super) fn score_boosted(
83        &self,
84        params: Bm25Params,
85        tf: f32,
86        idf: f32,
87        code: u8,
88        average: f32,
89        boost: f32,
90    ) -> f32 {
91        if code == 0 {
92            return params.score_boosted(tf, idf, tf, average, boost);
93        }
94        if tf == 0.0 || boost == 0.0 {
95            return 0.0;
96        }
97        idf * ((tf * boost * (params.k1 + 1.0)) / (tf * boost + self.0[usize::from(code)]))
98    }
99}
100
101/// Per-field BM25 parameters (`indexed<k1: ..., b: ...>` in the schema).
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct Bm25Params {
104    pub k1: f32,
105    pub b: f32,
106}
107
108impl Default for Bm25Params {
109    fn default() -> Self {
110        Self {
111            k1: BM25_K1,
112            b: BM25_B,
113        }
114    }
115}
116
117impl Bm25Params {
118    /// Resolve a field's parameters from its schema entry.
119    pub fn for_field(schema: &crate::dsl::Schema, field: crate::dsl::Field) -> Self {
120        let entry = schema.get_field_entry(field);
121        Self {
122            k1: entry.and_then(|e| e.bm25_k1).unwrap_or(BM25_K1),
123            b: entry.and_then(|e| e.bm25_b).unwrap_or(BM25_B),
124        }
125    }
126
127    /// BM25 score of one term occurrence set.
128    #[inline]
129    pub fn score(self, tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32) -> f32 {
130        // Zero-frequency postings retain membership but contribute no score,
131        // including k1=0 and the zero-length b=1 boundary (otherwise 0/0).
132        if tf == 0.0 {
133            return 0.0;
134        }
135        let length_norm = 1.0 - self.b + self.b * (doc_len / avg_doc_len.max(1.0));
136        let tf_norm = (tf * (self.k1 + 1.0)) / (tf + self.k1 * length_norm);
137        idf * tf_norm
138    }
139
140    /// BM25F score with a field boost.
141    #[inline]
142    pub fn score_boosted(
143        self,
144        tf: f32,
145        idf: f32,
146        doc_len: f32,
147        avg_doc_len: f32,
148        field_boost: f32,
149    ) -> f32 {
150        if tf == 0.0 || field_boost == 0.0 {
151            return 0.0;
152        }
153        let length_norm = 1.0 - self.b + self.b * (doc_len / avg_doc_len.max(1.0));
154        let tf_norm =
155            (tf * field_boost * (self.k1 + 1.0)) / (tf * field_boost + self.k1 * length_norm);
156        idf * tf_norm
157    }
158
159    /// Upper bound from a downward-rounded minimum length/TF ratio.
160    /// The scorer remains strict f32; evaluate the real-arithmetic envelope
161    /// in f64 and inflate for its at-most eight positive f32 operations.
162    /// Unsupported parameters disable this optional bound.
163    #[cfg(test)]
164    pub(crate) fn upper_bound_with_ratio(
165        self,
166        max_tf: u32,
167        idf: f32,
168        ratio: f32,
169        avg_len: f32,
170    ) -> f32 {
171        PreparedBounds::new(self, max_tf, idf, avg_len)
172            .map_or(f32::INFINITY, |bounds| bounds.ratio(max_tf, ratio))
173    }
174
175    /// Bound the canonical f32 score using a complete frequency/length envelope.
176    #[cfg(test)]
177    pub(crate) fn upper_bound_with_impacts(
178        self,
179        max_tf: u32,
180        idf: f32,
181        avg_len: f32,
182        minimum: impl FnOnce(f64, f64) -> Option<f64>,
183    ) -> f32 {
184        PreparedBounds::new(self, max_tf, idf, avg_len)
185            .map_or(f32::INFINITY, |bounds| bounds.impacts(minimum))
186    }
187
188    /// Upper bound with the shortest possible unit (length 0).
189    #[inline]
190    pub fn upper_bound(self, max_tf: f32, idf: f32) -> f32 {
191        if max_tf == 0.0 {
192            return 0.0;
193        }
194        let min_length_norm = 1.0 - self.b;
195        let tf_norm = (max_tf * (self.k1 + 1.0)) / (max_tf + self.k1 * min_length_norm);
196        idf * tf_norm
197    }
198
199    /// Upper bound with a known minimum unit length.
200    #[inline]
201    pub fn upper_bound_with_len(self, max_tf: f32, idf: f32, min_len: f32, avg_len: f32) -> f32 {
202        if max_tf == 0.0 {
203            return 0.0;
204        }
205        let length_norm = 1.0 - self.b + self.b * (min_len / avg_len.max(1.0));
206        let tf_norm = (max_tf * (self.k1 + 1.0)) / (max_tf + self.k1 * length_norm);
207        idf * tf_norm
208    }
209}
210
211/// Query-constant coefficients for conservative metadata bounds. Construction
212/// checks the whole list's maximum TF, which also covers every block/group.
213/// Actual document scores continue to use the canonical f32 implementation.
214pub(super) struct PreparedBounds {
215    numerator: f64,
216    k: f64,
217    reciprocal: f64,
218    length_ratio: f64,
219}
220
221impl PreparedBounds {
222    pub(super) fn new(params: Bm25Params, max_tf: u32, idf: f32, average: f32) -> Option<Self> {
223        if max_tf == 0
224            || !idf.is_finite()
225            || idf <= 0.0
226            || !average.is_finite()
227            || !(max_tf as f32 * (params.k1 + 1.0)).is_finite()
228            || !params.k1.is_finite()
229            || params.k1 < 0.0
230            || !(0.0..=1.0).contains(&params.b)
231        {
232            return None;
233        }
234        let b = f64::from(params.b);
235        let k = f64::from(params.k1);
236        Some(Self {
237            numerator: f64::from(idf) * (k + 1.0),
238            k,
239            reciprocal: 1.0 - b,
240            length_ratio: b / f64::from(average.max(1.0)),
241        })
242    }
243
244    fn score(&self, minimum: f64) -> f32 {
245        let bound = self.numerator / (1.0 + self.k * minimum);
246        Self::inflate(bound)
247    }
248
249    fn inflate(bound: f64) -> f32 {
250        // Covers the canonical positive f32 operations, integer casts and
251        // subnormal rounding. Moving f64 query constants does not remove it.
252        (bound * (1.0 + 16.0 * f64::from(f32::EPSILON)) + 16.0 * f64::from(f32::MIN_POSITIVE))
253            as f32
254    }
255
256    /// A single frequency/length pair needs only one division. All terms are
257    /// nonnegative under the constructor's contract. f64 rounding remains
258    /// far below the margin covering the canonical f32 score operations.
259    pub(super) fn pair(&self, max_tf: u32, length: u32) -> f32 {
260        if max_tf == 0 {
261            return f32::INFINITY;
262        }
263        let tf = f64::from(max_tf);
264        let norm = self.k * (self.reciprocal + self.length_ratio * f64::from(length));
265        Self::inflate(self.numerator * (tf / (tf + norm)))
266    }
267
268    /// Approximate inverse used only to seed an integer cutoff search. Callers
269    /// must verify its neighboring lengths with the authoritative score predicate.
270    pub(super) fn length_cutoff_hint(&self, tf: u32, score: f32) -> Option<u32> {
271        if self.k == 0.0 || self.length_ratio == 0.0 || score <= 0.0 {
272            return None;
273        }
274        let inflation = 1.0 + 16.0 * f64::from(f32::EPSILON);
275        let length = (f64::from(tf) * (self.numerator * inflation / f64::from(score) - 1.0)
276            / self.k
277            - self.reciprocal)
278            / self.length_ratio;
279        length
280            .is_finite()
281            .then(|| (length.floor() + 1.0).clamp(1.0, 65536.0) as u32)
282    }
283
284    pub(super) fn ratio(&self, max_tf: u32, ratio: f32) -> f32 {
285        if max_tf == 0 || ratio <= 0.0 || !ratio.is_finite() {
286            return f32::INFINITY;
287        }
288        self.score(self.reciprocal / f64::from(max_tf) + self.length_ratio * f64::from(ratio))
289    }
290
291    pub(super) fn impacts(&self, minimum: impl FnOnce(f64, f64) -> Option<f64>) -> f32 {
292        match minimum(self.reciprocal, self.length_ratio) {
293            Some(value) if value.is_finite() && value >= 0.0 => self.score(value),
294            _ => f32::INFINITY,
295        }
296    }
297}
298
299/// Compute BM25 score for a term occurrence
300///
301/// # Arguments
302/// * `tf` - Term frequency in document
303/// * `idf` - Inverse document frequency
304/// * `doc_len` - Document length (or field length)
305/// * `avg_doc_len` - Average document length
306#[inline]
307pub fn bm25_score(tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32) -> f32 {
308    Bm25Params::default().score(tf, idf, doc_len, avg_doc_len)
309}
310
311/// Compute BM25F score with field boost
312///
313/// # Arguments
314/// * `tf` - Term frequency in document
315/// * `idf` - Inverse document frequency
316/// * `doc_len` - Document length (or field length)
317/// * `avg_doc_len` - Average document length
318/// * `field_boost` - Field-specific boost factor
319#[inline]
320pub fn bm25f_score(tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32, field_boost: f32) -> f32 {
321    Bm25Params::default().score_boosted(tf, idf, doc_len, avg_doc_len, field_boost)
322}
323
324/// Compute BM25 upper bound score for MaxScore pruning
325///
326/// Uses conservative assumptions for maximum possible score:
327/// - Maximum TF from posting list
328/// - Minimum length normalization (shortest possible document)
329#[inline]
330pub fn bm25_upper_bound(max_tf: f32, idf: f32) -> f32 {
331    Bm25Params::default().upper_bound(max_tf, idf)
332}
333
334/// BM25 upper bound with a known minimum length of the scoring units the
335/// bound covers (a block or a whole list): the shortest unit has the
336/// weakest length normalisation, so it bounds every longer one.
337#[inline]
338pub fn bm25_upper_bound_with_len(max_tf: f32, idf: f32, min_len: f32, avg_len: f32) -> f32 {
339    Bm25Params::default().upper_bound_with_len(max_tf, idf, min_len, avg_len)
340}
341
342/// Compute BM25F upper bound score for MaxScore pruning with field boost
343///
344/// Uses conservative assumptions for maximum possible score:
345/// - Maximum TF from posting list
346/// - Minimum length normalization (shortest possible document)
347/// - Field boost factor
348#[inline]
349pub fn bm25f_upper_bound(max_tf: f32, idf: f32, field_boost: f32) -> f32 {
350    Bm25Params::default().score_boosted(max_tf, idf, 0.0, 1.0, field_boost)
351}
352
353/// Compute IDF (Inverse Document Frequency) using BM25 variant
354///
355/// # Arguments
356/// * `doc_freq` - Number of documents containing the term
357/// * `total_docs` - Total number of documents in collection
358#[inline]
359pub fn bm25_idf(doc_freq: f32, total_docs: f32) -> f32 {
360    ((total_docs - doc_freq + 0.5) / (doc_freq + 0.5) + 1.0).ln()
361}
362
363#[cfg(test)]
364mod norm_lookup_tests {
365    use super::*;
366    #[test]
367    fn batch_normalization_preserves_scalar_scores_and_tails() {
368        for params in [
369            Bm25Params::default(),
370            Bm25Params { k1: 0.0, b: 1.0 },
371            Bm25Params { k1: 2.3, b: 0.0 },
372            Bm25Params { k1: 1.2, b: 1.0 },
373        ] {
374            for average in [0.0, 1.0, 173.25, 65535.0] {
375                let table = NormTable::new(params, average);
376                for count in [0, 1, 17, 127, 128] {
377                    for start in [0u8, 128] {
378                        for boost in [0.0, 1.0, 2.3, -1.0] {
379                            let tfs: Vec<_> = (0..count)
380                                .map(|i| [0, 1, 2, 17, 65535, u32::MAX][i % 6])
381                                .collect();
382                            let codes: Vec<_> = (0..count).map(|i| start + i as u8).collect();
383                            let mut scores = vec![0.0; count];
384                            table.score_batch(
385                                params,
386                                1.37,
387                                average,
388                                boost,
389                                codes.iter().copied(),
390                                &tfs,
391                                &mut scores,
392                            );
393                            for i in 0..count {
394                                assert_eq!(
395                                    scores[i].to_bits(),
396                                    table
397                                        .score_boosted(
398                                            params,
399                                            tfs[i] as f32,
400                                            1.37,
401                                            codes[i],
402                                            average,
403                                            boost,
404                                        )
405                                        .to_bits()
406                                );
407                            }
408                        }
409                    }
410                }
411            }
412        }
413    }
414
415    #[test]
416    fn lookup_normalization_matches_canonical_arithmetic_for_every_byte_norm() {
417        for params in [
418            Bm25Params::default(),
419            Bm25Params { k1: 0.0, b: 1.0 },
420            Bm25Params { k1: 2.3, b: 0.0 },
421            Bm25Params { k1: 1.2, b: 1.0 },
422        ] {
423            for avg in [0.0, 1.0, 173.25, 65535.0] {
424                let table = NormTable::new(params, avg);
425                for code in 0..=255 {
426                    for tf in [0.0, 1.0, 2.0, 17.0, 65535.0] {
427                        let length = if code == 0 {
428                            tf
429                        } else {
430                            crate::segment::norms::decode(code) as f32
431                        };
432                        assert_eq!(
433                            table.score(params, tf, 1.37, code, avg).to_bits(),
434                            params.score(tf, 1.37, length, avg).to_bits()
435                        );
436                        for boost in [0.0, 1.0, 2.3] {
437                            assert_eq!(
438                                table
439                                    .score_boosted(params, tf, 1.37, code, avg, boost)
440                                    .to_bits(),
441                                params.score_boosted(tf, 1.37, length, avg, boost).to_bits()
442                            );
443                        }
444                    }
445                }
446            }
447        }
448    }
449}
450
451#[cfg(test)]
452mod ratio_tests {
453    use super::*;
454
455    #[test]
456    fn ratio_bound_covers_canonical_scores_across_frequency_length_and_parameter_extremes() {
457        let values = [
458            1u32,
459            2,
460            3,
461            127,
462            255,
463            65535,
464            65536,
465            100_000,
466            16_777_217,
467            u32::MAX,
468        ];
469        for tf in values {
470            for length in values {
471                let ratio = ((length as f64 / tf as f64) as f32).next_down();
472                for max_tf in [tf, tf.saturating_mul(7), u32::MAX] {
473                    for k1 in [0.0, 0.1, 1.2, 16.0, 1e6] {
474                        for b in [0.0, 0.01, 0.75, 1.0 - f32::EPSILON, 1.0] {
475                            for avg in [0.0, 1.0, 500.3, 1e8] {
476                                for idf in [f32::MIN_POSITIVE, 0.001, 1.0, 20.0] {
477                                    let params = Bm25Params { k1, b };
478                                    let bound =
479                                        params.upper_bound_with_ratio(max_tf, idf, ratio, avg);
480                                    let score = params.score(tf as f32, idf, length as f32, avg);
481                                    let pair = PreparedBounds::new(params, max_tf, idf, avg)
482                                        .unwrap()
483                                        .pair(max_tf, length);
484                                    assert!(
485                                        pair >= score,
486                                        "pair {params:?} tf={tf} len={length} max={max_tf} avg={avg} idf={idf} score={score} bound={pair}"
487                                    );
488                                    assert!(
489                                        bound >= score,
490                                        "{params:?} tf={tf} len={length} max={max_tf} avg={avg} idf={idf} score={score} bound={bound}"
491                                    );
492                                }
493                            }
494                        }
495                    }
496                }
497            }
498        }
499    }
500
501    #[test]
502    fn ratio_bound_does_not_pair_a_long_documents_tf_with_a_short_documents_length() {
503        let params = Bm25Params::default();
504        let loose = params.upper_bound_with_len(100.0, 1.0, 10.0, 500.0);
505        let tight = params.upper_bound_with_ratio(100, 1.0, 10.0f32.next_down(), 500.0);
506        assert!(tight < loose);
507        for (tf, len) in [(1.0, 10.0), (100.0, 1000.0)] {
508            assert!(tight >= params.score(tf, 1.0, len, 500.0));
509        }
510        for (k1, b) in [(-1.0, 0.5), (1.0, 2.0), (f32::NAN, 0.5)] {
511            assert!(
512                Bm25Params { k1, b }
513                    .upper_bound_with_ratio(1, 1.0, 1.0, 1.0)
514                    .is_infinite()
515            );
516        }
517    }
518    #[test]
519    fn complete_impact_envelopes_bound_canonical_f32_scores_and_disable_unsupported_parameters() {
520        use crate::structures::{BlockPostingList, PostingCodec, PostingList};
521        let mut seed = 17u64;
522        for trial in 0..128 {
523            let mut next = || {
524                seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
525                (seed >> 32) as u32
526            };
527            let values = [1, 2, 3, 127, 65535, 65536, 16777217, u32::MAX];
528            let mut lengths = [1u32; 256];
529            let mut postings = PostingList::new();
530            for doc in 0..256 {
531                let (tf, length) = if trial % 3 == 0 {
532                    let tf = doc % 8 + 1;
533                    (tf, tf * tf)
534                } else if trial % 3 == 1 {
535                    (values[next() as usize % 8], values[next() as usize % 8])
536                } else {
537                    (next().max(1), next().max(1))
538                };
539                lengths[doc as usize] = length;
540                postings.push(doc, tf);
541            }
542            let list = BlockPostingList::from_posting_list_with_impact_bounds(
543                &postings,
544                false,
545                Some(&|d| lengths[d as usize]),
546                PostingCodec::Rounded,
547            )
548            .unwrap();
549            for k1 in [0.0, 0.9, 1.2, 100.0, f32::MAX] {
550                for b in [0.0, 0.25, 0.75, 1.0f32.next_down(), 1.0] {
551                    let params = Bm25Params { k1, b };
552                    for avg in [0.0, 1.0, 37.5, 65535.0, f32::MAX] {
553                        for idf in [f32::MIN_POSITIVE, 0.001, 1.7, f32::MAX] {
554                            for block in 0..2 {
555                                let bound = params.upper_bound_with_impacts(
556                                    list.block_max_tf(block).unwrap(),
557                                    idf,
558                                    avg,
559                                    |a, c| list.block_impact_minimum(block, a, c),
560                                );
561                                let group_bound = params.upper_bound_with_impacts(
562                                    list.max_tf(),
563                                    idf,
564                                    avg,
565                                    |a, c| list.group_impact_minimum(block, a, c),
566                                );
567                                for posting in postings.iter().skip(block * 128).take(128) {
568                                    let score = params.score(
569                                        posting.term_freq as f32,
570                                        idf,
571                                        lengths[posting.doc_id as usize] as f32,
572                                        avg,
573                                    );
574                                    assert!(
575                                        group_bound == f32::INFINITY || score <= group_bound,
576                                        "group trial={trial} params={params:?} avg={avg} idf={idf} score={score} bound={group_bound}"
577                                    );
578                                    assert!(
579                                        bound == f32::INFINITY || score <= bound,
580                                        "trial={trial} params={params:?} avg={avg} idf={idf} score={score} bound={bound}"
581                                    );
582                                }
583                            }
584                        }
585                    }
586                }
587            }
588        }
589        for params in [
590            Bm25Params { k1: -1.0, b: 0.5 },
591            Bm25Params {
592                k1: f32::NAN,
593                b: 0.5,
594            },
595            Bm25Params { k1: 1.2, b: -0.1 },
596            Bm25Params {
597                k1: 1.2,
598                b: f32::NAN,
599            },
600        ] {
601            assert_eq!(
602                params.upper_bound_with_impacts(1, 1.0, 1.0, |_, _| panic!(
603                    "unsupported scorer must not decode envelopes"
604                )),
605                f32::INFINITY
606            );
607        }
608        let p = Bm25Params::default();
609        for (tf, idf, avg) in [
610            (0, 1.0, 1.0),
611            (1, -1.0, 1.0),
612            (1, f32::NAN, 1.0),
613            (1, 1.0, f32::INFINITY),
614        ] {
615            assert_eq!(
616                p.upper_bound_with_impacts(tf, idf, avg, |_, _| panic!(
617                    "invalid input must not decode envelopes"
618                )),
619                f32::INFINITY
620            );
621        }
622        assert_eq!(
623            p.upper_bound_with_impacts(1, 1.0, 1.0, |_, _| None),
624            f32::INFINITY
625        );
626    }
627}
628
629#[cfg(test)]
630mod zero_frequency_tests {
631    use super::*;
632
633    #[test]
634    fn zero_frequency_and_zero_boost_produce_finite_zero_scores_and_bounds() {
635        for params in [
636            Bm25Params::default(),
637            Bm25Params { k1: 0.0, b: 1.0 },
638            Bm25Params { k1: 1.2, b: 1.0 },
639        ] {
640            for length in [0.0, 1.0, 100.0] {
641                for average in [0.0, 1.0, 175.0] {
642                    assert_eq!(
643                        params.score(0.0, 2.3, length, average).to_bits(),
644                        0,
645                        "{params:?} length={length}"
646                    );
647                    assert_eq!(
648                        params
649                            .score_boosted(0.0, 2.3, length, average, 7.0)
650                            .to_bits(),
651                        0
652                    );
653                    assert_eq!(
654                        params
655                            .score_boosted(13.0, 2.3, length, average, 0.0)
656                            .to_bits(),
657                        0
658                    );
659                    assert_eq!(params.upper_bound(0.0, 2.3).to_bits(), 0);
660                    assert_eq!(
661                        params
662                            .upper_bound_with_len(0.0, 2.3, length, average)
663                            .to_bits(),
664                        0
665                    );
666                }
667            }
668        }
669        assert_eq!(bm25_score(0.0, 2.3, 0.0, 0.0).to_bits(), 0);
670        assert_eq!(bm25f_score(13.0, 2.3, 0.0, 0.0, 0.0).to_bits(), 0);
671        assert_eq!(bm25_upper_bound(0.0, 2.3).to_bits(), 0);
672        assert_eq!(bm25_upper_bound_with_len(0.0, 2.3, 0.0, 0.0).to_bits(), 0);
673        assert_eq!(bm25f_upper_bound(13.0, 2.3, 0.0).to_bits(), 0);
674    }
675}