Skip to main content

packset_core/
bm25.rs

1//! BM25, BM25+ and Dirichlet query likelihood over the pack, with postings.
2//! A document carrying no query term scores nothing, so the index answers
3//! from the postings rather than a scan.
4
5use std::collections::HashMap;
6
7/// Term-frequency saturation. The value the literature uses.
8const K1: f64 = 1.2;
9
10/// How much length normalisation applies. 0 is none, 1 is full.
11const B: f64 = 0.75;
12
13/// BM25+ floor under one occurrence, so length normalisation cannot drive it
14/// to the value of an absence. Lv and Zhai, doi:10.1145/2063576.2063584.
15const DELTA: f64 = 1.0;
16
17/// Dirichlet prior for query likelihood, in pseudo-tokens. Zhai and Lafferty,
18/// doi:10.1145/984321.984322; the paper's value, not fitted here.
19const MU: f64 = 2000.0;
20
21/// Which scoring family answers a query.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Scorer {
24    /// Okapi BM25, with no floor.
25    Bm25,
26    /// BM25 with a floor under each occurrence. The default; it leads plain
27    /// BM25 at every granularity measured, see the README.
28    #[default]
29    Bm25Plus,
30    /// Query likelihood with a Dirichlet prior.
31    Dirichlet,
32}
33
34impl Scorer {
35    /// The name this scorer is asked for by, on a command line or in an
36    /// environment variable.
37    #[must_use]
38    pub fn token(self) -> &'static str {
39        match self {
40            Self::Bm25 => "bm25",
41            Self::Bm25Plus => "bm25+",
42            Self::Dirichlet => "dirichlet",
43        }
44    }
45
46    /// Read one from a name, or nothing when the name is not a scorer.
47    #[must_use]
48    pub fn parse(name: &str) -> Option<Self> {
49        match name.trim().to_ascii_lowercase().as_str() {
50            "bm25" | "okapi" => Some(Self::Bm25),
51            "bm25+" | "bm25plus" => Some(Self::Bm25Plus),
52            "dirichlet" | "ql" | "lm" => Some(Self::Dirichlet),
53            _ => None,
54        }
55    }
56}
57
58/// Where one term appears: the document, and how often in it.
59type Posting = (u32, u32);
60
61/// An inverted index over one corpus, with the statistics BM25 needs.
62#[derive(Debug, Clone, Default)]
63pub struct Index {
64    postings: HashMap<String, Vec<Posting>>,
65    lengths: Vec<u32>,
66    total_length: u64,
67    /// Collection frequency per term, counting repeats: what a language model
68    /// smooths toward, where document frequency says how much a term narrows.
69    occurrences: HashMap<String, u64>,
70}
71
72impl Index {
73    /// Build over tokenised documents; a document's ordinal is its position.
74    #[must_use]
75    pub fn build<'a>(documents: impl IntoIterator<Item = &'a [String]>) -> Self {
76        let mut index = Self::default();
77        for tokens in documents {
78            let ordinal = u32::try_from(index.lengths.len()).unwrap_or(u32::MAX);
79            index
80                .lengths
81                .push(u32::try_from(tokens.len()).unwrap_or(u32::MAX));
82            index.total_length += tokens.len() as u64;
83            let mut counts: HashMap<&str, u32> = HashMap::new();
84            for term in tokens {
85                *counts.entry(term.as_str()).or_insert(0) += 1;
86            }
87            for (term, count) in counts {
88                index
89                    .postings
90                    .entry(term.to_string())
91                    .or_default()
92                    .push((ordinal, count));
93                *index.occurrences.entry(term.to_string()).or_insert(0) += u64::from(count);
94            }
95        }
96        index
97    }
98
99    /// How many documents are indexed.
100    #[must_use]
101    pub fn len(&self) -> usize {
102        self.lengths.len()
103    }
104
105    /// Whether anything was indexed.
106    #[must_use]
107    pub fn is_empty(&self) -> bool {
108        self.lengths.is_empty()
109    }
110
111    /// Mean document length in tokens.
112    #[must_use]
113    pub fn average_length(&self) -> f64 {
114        if self.lengths.is_empty() {
115            0.0
116        } else {
117            self.total_length as f64 / self.lengths.len() as f64
118        }
119    }
120
121    /// Inverse document frequency; the `+ 1` keeps a term in every document
122    /// at zero rather than negative.
123    #[must_use]
124    pub fn idf(&self, term: &str) -> f64 {
125        let n = self.lengths.len() as f64;
126        let df = self.postings.get(term).map_or(0, Vec::len) as f64;
127        (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
128    }
129
130    /// Length normalisation for one document.
131    fn norm(&self, ordinal: usize) -> f64 {
132        let average = self.average_length();
133        if average <= 0.0 {
134            return 1.0;
135        }
136        let length = f64::from(self.lengths.get(ordinal).copied().unwrap_or(0));
137        B.mul_add(length / average, 1.0 - B)
138    }
139
140    /// How likely the corpus was to say a term, over all its occurrences.
141    fn background(&self, term: &str) -> f64 {
142        if self.total_length == 0 {
143            return 0.0;
144        }
145        self.occurrences.get(term).copied().unwrap_or(0) as f64 / self.total_length as f64
146    }
147
148    /// One term's contribution to one document.
149    fn term_score(&self, term: &str, ordinal: usize, count: u32) -> f64 {
150        self.term_score_by(Scorer::Bm25, term, ordinal, count)
151    }
152
153    /// One term's contribution, in the family the caller named.
154    fn term_score_by(&self, scorer: Scorer, term: &str, ordinal: usize, count: u32) -> f64 {
155        let count = f64::from(count);
156        match scorer {
157            Scorer::Bm25 => {
158                self.idf(term) * (count * (K1 + 1.0))
159                    / (K1 * self.norm(ordinal)).mul_add(1.0, count)
160            }
161            // The floor is inside the idf weight: a term that narrows nothing
162            // earns nothing for appearing.
163            Scorer::Bm25Plus => {
164                self.idf(term)
165                    * ((count * (K1 + 1.0)) / (K1 * self.norm(ordinal)).mul_add(1.0, count) + DELTA)
166            }
167            // Per matching term, as Lucene and Anserini do, so the postings
168            // can answer it.
169            Scorer::Dirichlet => {
170                let background = self.background(term);
171                if background <= 0.0 {
172                    return 0.0;
173                }
174                let length = f64::from(self.lengths.get(ordinal).copied().unwrap_or(0));
175                (count / (MU * background)).ln_1p() + (MU / (length + MU)).ln()
176            }
177        }
178    }
179
180    /// Every document carrying a query term, with its score, ordinals ascending.
181    #[must_use]
182    pub fn score(&self, query: &[String]) -> Vec<(usize, f64)> {
183        let mut totals: HashMap<u32, f64> = HashMap::new();
184        for term in query {
185            let Some(postings) = self.postings.get(term.as_str()) else {
186                continue;
187            };
188            for (ordinal, count) in postings {
189                *totals.entry(*ordinal).or_insert(0.0) +=
190                    self.term_score(term, *ordinal as usize, *count);
191            }
192        }
193        let mut scored: Vec<(usize, f64)> = totals
194            .into_iter()
195            .map(|(ordinal, score)| (ordinal as usize, score))
196            .collect();
197        scored.sort_unstable_by_key(|(ordinal, _)| *ordinal);
198        scored
199    }
200
201    /// Score against a weighted query; the unweighted form has every weight one.
202    #[must_use]
203    pub fn score_weighted(&self, query: &[(String, f64)]) -> Vec<(usize, f64)> {
204        self.score_weighted_by(Scorer::default(), query)
205    }
206
207    /// Score a weighted query in the family the caller named. The index is the
208    /// same for all three; the formula is chosen per query.
209    #[must_use]
210    pub fn score_weighted_by(&self, scorer: Scorer, query: &[(String, f64)]) -> Vec<(usize, f64)> {
211        let mut totals: HashMap<u32, f64> = HashMap::new();
212        for (term, weight) in query {
213            if *weight <= 0.0 {
214                continue;
215            }
216            let Some(postings) = self.postings.get(term.as_str()) else {
217                continue;
218            };
219            for (ordinal, count) in postings {
220                *totals.entry(*ordinal).or_insert(0.0) +=
221                    weight * self.term_score_by(scorer, term, *ordinal as usize, *count);
222            }
223        }
224        let mut scored: Vec<(usize, f64)> = totals
225            .into_iter()
226            .map(|(ordinal, score)| (ordinal as usize, score))
227            .collect();
228        scored.sort_unstable_by_key(|(ordinal, _)| *ordinal);
229        scored
230    }
231
232    /// RM3: the query expanded from its own first pass, with `alpha` of the
233    /// weight kept on the original terms. Lavrenko and Croft,
234    /// doi:10.1145/383952.383972; Lv and Zhai, doi:10.1145/1645953.1646259.
235    #[must_use]
236    pub fn expand(
237        &self,
238        query: &[String],
239        feedback: &[(&[String], f64)],
240        terms: usize,
241        alpha: f64,
242    ) -> Vec<(String, f64)> {
243        let mut weights: HashMap<String, f64> = HashMap::new();
244        // The words asked for, each worth its share of `alpha`.
245        if !query.is_empty() {
246            let each = alpha / query.len() as f64;
247            for term in query {
248                *weights.entry(term.clone()).or_insert(0.0) += each;
249            }
250        }
251        let mass: f64 = feedback.iter().map(|(_, score)| score.max(0.0)).sum();
252        if mass <= 0.0 || terms == 0 || alpha >= 1.0 {
253            let mut out: Vec<(String, f64)> = weights.into_iter().collect();
254            out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
255            return out;
256        }
257        // P(t | R), summed over the feedback documents by how well each scored.
258        let mut model: HashMap<&str, f64> = HashMap::new();
259        for (tokens, score) in feedback {
260            if tokens.is_empty() || *score <= 0.0 {
261                continue;
262            }
263            let share = score / mass;
264            let length = tokens.len() as f64;
265            let mut counts: HashMap<&str, u32> = HashMap::new();
266            for term in *tokens {
267                *counts.entry(term.as_str()).or_insert(0) += 1;
268            }
269            for (term, count) in counts {
270                *model.entry(term).or_insert(0.0) += share * f64::from(count) / length;
271            }
272        }
273        // P(t | R) unweighted: scoring applies idf once already.
274        let mut ranked: Vec<(&str, f64)> = model.into_iter().collect();
275        ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0)));
276        ranked.truncate(terms);
277        let total: f64 = ranked.iter().map(|(_, value)| *value).sum();
278        if total > 0.0 {
279            for (term, value) in ranked {
280                *weights.entry(term.to_string()).or_insert(0.0) += (1.0 - alpha) * value / total;
281            }
282        }
283        let mut out: Vec<(String, f64)> = weights.into_iter().collect();
284        out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
285        out
286    }
287
288    /// Score a document outside the index against this corpus's statistics.
289    #[must_use]
290    pub fn score_foreign(&self, query: &[String], document: &[String]) -> f64 {
291        if document.is_empty() || self.is_empty() {
292            return 0.0;
293        }
294        let average = self.average_length();
295        let norm = if average > 0.0 {
296            B.mul_add(document.len() as f64 / average, 1.0 - B)
297        } else {
298            1.0
299        };
300        let mut counts: HashMap<&str, u32> = HashMap::new();
301        for term in document {
302            *counts.entry(term.as_str()).or_insert(0) += 1;
303        }
304        query
305            .iter()
306            .map(|term| {
307                let count = f64::from(counts.get(term.as_str()).copied().unwrap_or(0));
308                if count == 0.0 {
309                    return 0.0;
310                }
311                self.idf(term) * (count * (K1 + 1.0)) / (K1 * norm).mul_add(1.0, count)
312            })
313            .sum()
314    }
315
316    /// The same, against a query whose terms carry weights.
317    #[must_use]
318    pub fn score_foreign_weighted(&self, query: &[(String, f64)], document: &[String]) -> f64 {
319        self.score_foreign_weighted_by(Scorer::default(), query, document)
320    }
321
322    /// A text outside the corpus, scored in the family the caller named, so a
323    /// card ranked beside atoms is scored the way they are.
324    #[must_use]
325    pub fn score_foreign_weighted_by(
326        &self,
327        scorer: Scorer,
328        query: &[(String, f64)],
329        document: &[String],
330    ) -> f64 {
331        if document.is_empty() || self.is_empty() {
332            return 0.0;
333        }
334        let average = self.average_length();
335        let length = document.len() as f64;
336        let norm = if average > 0.0 {
337            B.mul_add(length / average, 1.0 - B)
338        } else {
339            1.0
340        };
341        let mut counts: HashMap<&str, u32> = HashMap::new();
342        for term in document {
343            *counts.entry(term.as_str()).or_insert(0) += 1;
344        }
345        query
346            .iter()
347            .map(|(term, weight)| {
348                let count = f64::from(counts.get(term.as_str()).copied().unwrap_or(0));
349                if count == 0.0 || *weight <= 0.0 {
350                    return 0.0;
351                }
352                let saturated = (count * (K1 + 1.0)) / (K1 * norm).mul_add(1.0, count);
353                weight
354                    * match scorer {
355                        Scorer::Bm25 => self.idf(term) * saturated,
356                        Scorer::Bm25Plus => self.idf(term) * (saturated + DELTA),
357                        Scorer::Dirichlet => {
358                            let background = self.background(term);
359                            if background <= 0.0 {
360                                return 0.0;
361                            }
362                            (count / (MU * background)).ln_1p() + (MU / (length + MU)).ln()
363                        }
364                    }
365            })
366            .sum()
367    }
368}
369
370#[cfg(test)]
371mod scorers {
372    use super::*;
373
374    fn words(text: &str) -> Vec<String> {
375        text.split_whitespace().map(str::to_string).collect()
376    }
377
378    /// One long document carrying the term among many short ones without it:
379    /// BM25 leaves the occurrence worth almost nothing, BM25+ does not.
380    #[test]
381    fn a_long_document_stops_being_punished_for_its_length() {
382        let filler = "alpha beta gamma delta epsilon zeta eta theta ".repeat(400);
383        let long = words(&format!("{filler} lease"));
384        let mut corpus: Vec<Vec<String>> = vec![long];
385        // Enough short documents that the average length is short and the long
386        // one is far above it, which is where the normalisation bites.
387        for n in 0..200 {
388            corpus.push(words(&format!("alpha beta gamma note {n}")));
389        }
390        let index = Index::build(corpus.iter().map(Vec::as_slice));
391
392        let query = vec![("lease".to_string(), 1.0)];
393        let plain = index.score_weighted_by(Scorer::Bm25, &query);
394        let floored = index.score_weighted_by(Scorer::Bm25Plus, &query);
395
396        // Only the long document carries the term at all, so both find it.
397        assert_eq!(plain.len(), 1);
398        assert_eq!(floored.len(), 1);
399
400        let (_, thin) = plain[0];
401        let (_, held) = floored[0];
402        assert!(held > thin, "the floor took a point away: {held} vs {thin}");
403        assert!(
404            thin < 0.25 * index.idf("lease"),
405            "this corpus does not show the defect: {thin}"
406        );
407        assert!(
408            held > index.idf("lease"),
409            "the floor did not restore the occurrence: {held}"
410        );
411    }
412
413    /// Query likelihood agrees with BM25 on the best document and disagrees on
414    /// the numbers, which is the reason to fuse rather than pick.
415    #[test]
416    fn the_language_model_is_a_different_opinion() {
417        let corpus: Vec<Vec<String>> = vec![
418            words("lease lease lease renew renew"),
419            words("lease renew claim generation fence token holder quiet reclaim node"),
420            words("claim generation fence token"),
421        ];
422        let index = Index::build(corpus.iter().map(Vec::as_slice));
423        let query = vec![("lease".to_string(), 1.0), ("renew".to_string(), 1.0)];
424
425        let best = |scored: Vec<(usize, f64)>| -> usize {
426            scored
427                .into_iter()
428                .max_by(|a, b| a.1.partial_cmp(&b.1).expect("finite"))
429                .expect("a hit")
430                .0
431        };
432        assert_eq!(best(index.score_weighted_by(Scorer::Bm25, &query)), 0);
433        assert_eq!(best(index.score_weighted_by(Scorer::Dirichlet, &query)), 0);
434
435        let by_bm25 = index.score_weighted_by(Scorer::Bm25, &query);
436        let by_lm = index.score_weighted_by(Scorer::Dirichlet, &query);
437        assert_eq!(by_bm25.len(), by_lm.len());
438        assert!(
439            by_bm25
440                .iter()
441                .zip(&by_lm)
442                .any(|((_, one), (_, two))| (one - two).abs() > 1e-9),
443            "two derivations returned the same numbers"
444        );
445    }
446
447    /// An unknown scorer name is refused, not defaulted.
448    #[test]
449    fn a_scorer_is_named_or_refused() {
450        for (name, want) in [
451            ("bm25", Scorer::Bm25),
452            ("BM25+", Scorer::Bm25Plus),
453            (" dirichlet ", Scorer::Dirichlet),
454            ("ql", Scorer::Dirichlet),
455        ] {
456            assert_eq!(Scorer::parse(name), Some(want), "{name}");
457        }
458        assert_eq!(Scorer::parse("tf-idf"), None);
459        assert_eq!(Scorer::parse(""), None);
460        for scorer in [Scorer::Bm25, Scorer::Bm25Plus, Scorer::Dirichlet] {
461            assert_eq!(Scorer::parse(scorer.token()), Some(scorer));
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn doc(text: &str) -> Vec<String> {
471        crate::search::tokens(text)
472    }
473
474    fn corpus(texts: &[&str]) -> Index {
475        let docs: Vec<Vec<String>> = texts.iter().map(|t| doc(t)).collect();
476        Index::build(docs.iter().map(Vec::as_slice))
477    }
478
479    fn best(index: &Index, query: &str) -> usize {
480        index
481            .score(&doc(query))
482            .into_iter()
483            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
484            .expect("a hit")
485            .0
486    }
487
488    /// The whole reason to run this beside the pack's own scorer.
489    #[test]
490    fn a_rare_word_says_more_than_a_common_one() {
491        let index = corpus(&[
492            "the parser reads a header",
493            "the parser reads a manifest",
494            "the parser reads a record",
495            "the ripgrep overlay reads a header",
496        ]);
497        assert!(index.idf("ripgrep") > index.idf("parser"));
498        // The atom carrying the rare word wins even though the other three
499        // carry a word the query also names.
500        assert_eq!(best(&index, "ripgrep parser"), 3);
501    }
502
503    /// A term in every document narrows nothing, and must not go negative.
504    #[test]
505    fn a_word_everything_carries_never_scores_below_nothing() {
506        let index = corpus(&["parser one", "parser two", "parser three"]);
507        assert!(index.idf("parser") > 0.0);
508        let scored = index.score(&doc("parser"));
509        assert_eq!(scored.len(), 3);
510        assert!(scored.iter().all(|(_, score)| *score > 0.0), "{scored:?}");
511    }
512
513    /// Length normalisation: padding an atom must not raise its score.
514    #[test]
515    fn a_longer_atom_does_not_win_on_length_alone() {
516        let padding = "header manifest record token commit branch index atom workspace daemon";
517        let index = corpus(&["the parser reads", &format!("the parser reads {padding}")]);
518        let scored = index.score(&doc("parser"));
519        assert_eq!(scored.len(), 2);
520        assert!(scored[0].1 > scored[1].1, "padding raised the score");
521    }
522
523    /// Saturation: the second occurrence is worth less than the first.
524    #[test]
525    fn repeating_a_word_pays_less_each_time() {
526        let index = corpus(&["parser", "parser parser", "parser parser parser"]);
527        let scored = index.score(&doc("parser"));
528        let (once, twice, thrice) = (scored[0].1, scored[1].1, scored[2].1);
529        assert!(twice > once);
530        assert!(thrice - twice < twice - once);
531    }
532
533    /// The point of the postings: a document with no query term is never
534    /// touched, let alone returned.
535    #[test]
536    fn a_document_carrying_no_query_term_is_not_in_the_answer() {
537        let index = corpus(&["the parser reads a header", "the overlay writes a record"]);
538        assert_eq!(
539            index.score(&doc("parser")),
540            vec![(0, index.score(&doc("parser"))[0].1)]
541        );
542        assert!(index.score(&doc("kubernetes")).is_empty());
543    }
544
545    #[test]
546    fn an_empty_corpus_scores_nothing_rather_than_dividing_by_it() {
547        let index = Index::build(std::iter::empty());
548        assert!(index.is_empty());
549        assert!(index.score(&doc("parser")).is_empty());
550        assert_eq!(index.score_foreign(&doc("parser"), &doc("parser")), 0.0);
551    }
552
553    /// The point of the expansion: a word the asker did not say, taken from
554    /// what the first pass returned, reaches a document the question misses.
555    #[test]
556    fn an_expansion_reaches_what_the_question_did_not_say() {
557        let index = corpus(&[
558            "the parser reads a ripgrep header",
559            "the ripgrep overlay writes a header",
560            "the kubernetes operator reconciles a deployment",
561        ]);
562        let documents: Vec<Vec<String>> = [
563            "the parser reads a ripgrep header",
564            "the ripgrep overlay writes a header",
565            "the kubernetes operator reconciles a deployment",
566        ]
567        .iter()
568        .map(|text| doc(text))
569        .collect();
570        let query = doc("parser");
571        let first = index.score(&query);
572        // Only the atom carrying the word survives the first pass.
573        assert_eq!(first.len(), 1);
574        let feedback: Vec<(&[String], f64)> = first
575            .iter()
576            .map(|(ordinal, score)| (documents[*ordinal].as_slice(), *score))
577            .collect();
578        let expanded = index.expand(&query, &feedback, 10, 0.5);
579        assert!(
580            expanded.iter().any(|(term, _)| term == "ripgrep"),
581            "{expanded:?}"
582        );
583        let second = index.score_weighted(&expanded);
584        // The overlay shares no word with the question and is reached anyway.
585        assert!(
586            second.iter().any(|(ordinal, _)| *ordinal == 1),
587            "{second:?}"
588        );
589        // And the unrelated atom still is not.
590        assert!(
591            !second.iter().any(|(ordinal, _)| *ordinal == 2),
592            "{second:?}"
593        );
594    }
595
596    /// Feedback taken on trust is how this drifts, so the words asked for keep
597    /// their share whatever the first pass returned.
598    #[test]
599    fn the_words_asked_for_keep_their_share() {
600        let index = corpus(&["parser header", "overlay record"]);
601        let documents: Vec<Vec<String>> = ["parser header", "overlay record"]
602            .iter()
603            .map(|text| doc(text))
604            .collect();
605        let query = doc("parser");
606        let feedback: Vec<(&[String], f64)> = vec![(documents[1].as_slice(), 1.0)];
607        let expanded = index.expand(&query, &feedback, 10, 0.5);
608        let asked: f64 = expanded
609            .iter()
610            .filter(|(term, _)| term == "parser")
611            .map(|(_, weight)| *weight)
612            .sum();
613        assert!((asked - 0.5).abs() < 1e-9, "{expanded:?}");
614        let guessed: f64 = expanded
615            .iter()
616            .filter(|(term, _)| term != "parser")
617            .map(|(_, weight)| *weight)
618            .sum();
619        assert!((guessed - 0.5).abs() < 1e-9, "{expanded:?}");
620    }
621
622    /// With nothing to learn from, the expanded query is the query.
623    #[test]
624    fn no_feedback_leaves_the_query_alone() {
625        let index = corpus(&["parser header", "overlay record"]);
626        let query = doc("parser header");
627        let expanded = index.expand(&query, &[], 10, 0.5);
628        assert_eq!(expanded.len(), 2);
629        let plain = index.score(&query);
630        let weighted = index.score_weighted(&expanded);
631        assert_eq!(plain.len(), weighted.len());
632        // Same ordering, since every term was scaled by the same share.
633        let best = |scored: &[(usize, f64)]| {
634            scored
635                .iter()
636                .max_by(|a, b| a.1.total_cmp(&b.1))
637                .map(|(ordinal, _)| *ordinal)
638        };
639        assert_eq!(best(&plain), best(&weighted));
640    }
641
642    /// A card is weighed against the atoms, and the same words score the same
643    /// whichever side of the pack they were written on.
644    #[test]
645    fn a_foreign_document_is_weighed_against_the_indexed_corpus() {
646        let index = corpus(&["the parser reads a header", "the parser reads a manifest"]);
647        let indexed = index.score(&doc("parser"))[0].1;
648        let foreign = index.score_foreign(&doc("parser"), &doc("the parser reads a header"));
649        assert!((indexed - foreign).abs() < 1e-9, "{indexed} vs {foreign}");
650    }
651}