1use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::{Duration, Instant};
30
31use ignore::WalkBuilder;
32
33use crate::tools::ctx_search::{is_binary_ext, is_generated_file, MAX_FILE_SIZE, MAX_WALK_DEPTH};
34
35const TTL: Duration = Duration::from_secs(15);
38
39const MAX_FILES: usize = 200_000;
41
42const MAX_POSTING_ENTRIES: usize = 12_000_000;
47
48const MAX_TOTAL_ENTRIES: usize = 48_000_000;
52
53const BLOOM_BITS_PER_ITEM: usize = 12;
57const BLOOM_K: usize = 7;
58const BLOOM_MIN_BITS: usize = 64;
60const BLOOM_MAX_BITS: usize = 1 << 20;
61
62fn is_word_byte(b: u8) -> bool {
64 b.is_ascii_alphanumeric() || b == b'_'
65}
66
67fn pack(b0: u8, b1: u8, b2: u8) -> u32 {
68 (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2)
69}
70
71enum Narrowing {
80 Postings(HashMap<u32, Vec<u32>>),
81 Blooms(Vec<FileBloom>),
82}
83
84struct FileBloom {
87 bits: Vec<u64>,
89}
90
91#[inline]
94fn mix64(mut x: u64) -> u64 {
95 x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
96 x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
97 x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
98 x ^ (x >> 31)
99}
100
101impl FileBloom {
102 fn with_capacity(distinct_trigrams: usize) -> Self {
103 let target = distinct_trigrams
104 .saturating_mul(BLOOM_BITS_PER_ITEM)
105 .next_power_of_two()
106 .clamp(BLOOM_MIN_BITS, BLOOM_MAX_BITS);
107 FileBloom {
108 bits: vec![0u64; target / 64],
109 }
110 }
111
112 #[inline]
113 fn m_bits(&self) -> usize {
114 self.bits.len() * 64
115 }
116
117 #[inline]
119 fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
120 let m = self.m_bits();
121 let mask = m - 1; let h = mix64(u64::from(trigram));
123 let h1 = (h & 0xFFFF_FFFF) as usize;
124 let h2 = ((h >> 32) as usize) | 1; (0..BLOOM_K).map(move |i| h1.wrapping_add(i.wrapping_mul(h2)) & mask)
126 }
127
128 fn insert(&mut self, trigram: u32) {
129 for p in self.probes(trigram).collect::<Vec<_>>() {
130 self.bits[p / 64] |= 1u64 << (p % 64);
131 }
132 }
133
134 fn maybe_contains(&self, trigram: u32) -> bool {
135 self.probes(trigram)
136 .all(|p| self.bits[p / 64] & (1u64 << (p % 64)) != 0)
137 }
138}
139
140pub struct SearchIndex {
142 files: Vec<PathBuf>,
143 narrowing: Narrowing,
145 respect_gitignore: bool,
146 allow_secret_paths: bool,
147 built_at: Instant,
148}
149
150impl SearchIndex {
151 pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
154 let root_path = Path::new(root);
155 if !root_path.exists() {
156 return None;
157 }
158
159 let walker = WalkBuilder::new(root_path)
160 .hidden(true)
161 .max_depth(Some(MAX_WALK_DEPTH))
162 .git_ignore(respect_gitignore)
163 .git_global(respect_gitignore)
164 .git_exclude(respect_gitignore)
165 .build();
166
167 let mut files: Vec<PathBuf> = Vec::new();
168 let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
172 let mut total_entries: usize = 0;
173 let mut scratch: HashSet<u32> = HashSet::new();
174
175 for entry in walker.filter_map(std::result::Result::ok) {
176 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
177 continue;
178 }
179 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
180 continue;
181 }
182 let path = entry.path();
183 if is_binary_ext(path) || is_generated_file(path) {
184 continue;
185 }
186 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
187 continue;
188 }
189 match std::fs::metadata(path) {
194 Ok(meta) if !meta.file_type().is_file() => continue,
195 Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
196 Ok(_) => {}
197 Err(_) => continue,
198 }
199 let Ok(content) = std::fs::read_to_string(path) else {
202 continue;
203 };
204
205 if files.len() >= MAX_FILES {
206 return None; }
208
209 scratch.clear();
210 let bytes = content.as_bytes();
211 if bytes.len() >= 3 {
212 for w in bytes.windows(3) {
213 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
214 scratch.insert(pack(w[0], w[1], w[2]));
215 }
216 }
217 }
218 total_entries += scratch.len();
219 if total_entries > MAX_TOTAL_ENTRIES {
220 return None; }
222 let mut tris: Vec<u32> = scratch.iter().copied().collect();
223 tris.sort_unstable();
224 files.push(path.to_path_buf());
225 per_file_trigrams.push(tris);
226 }
227
228 let narrowing = build_narrowing(&per_file_trigrams, total_entries);
229
230 Some(Self {
231 files,
232 narrowing,
233 respect_gitignore,
234 allow_secret_paths,
235 built_at: Instant::now(),
236 })
237 }
238
239 fn is_fresh(&self) -> bool {
240 self.built_at.elapsed() < TTL
241 }
242
243 fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
244 self.respect_gitignore == respect_gitignore && self.allow_secret_paths == allow_secret_paths
245 }
246
247 pub fn candidate_paths(&self, pattern: &str, ext: Option<&str>) -> CandidateSet {
256 if let Some(ids) = self.literal_candidates(pattern) {
257 let paths = ids
258 .into_iter()
259 .map(|id| self.files[id as usize].clone())
260 .filter(|p| ext_matches(p, ext))
261 .collect();
262 CandidateSet::Narrowed(paths)
263 } else {
264 let paths = self
265 .files
266 .iter()
267 .filter(|p| ext_matches(p, ext))
268 .cloned()
269 .collect();
270 CandidateSet::FullList(paths)
271 }
272 }
273
274 fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
278 let bytes = pattern.as_bytes();
279 if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
280 return None;
281 }
282 let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
284 tris.sort_unstable();
285 tris.dedup();
286
287 match &self.narrowing {
288 Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
289 Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
290 }
291 }
292
293 fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
296 let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
297 for &tri in tris {
298 match trigrams.get(&tri) {
299 None => return Vec::new(),
301 Some(list) => lists.push(list),
302 }
303 }
304 lists.sort_by_key(|l| l.len());
305
306 let mut acc: Vec<u32> = lists[0].clone();
307 for list in &lists[1..] {
308 acc = intersect_sorted(&acc, list);
309 if acc.is_empty() {
310 break;
311 }
312 }
313 acc
314 }
315
316 fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
320 let mut out = Vec::new();
321 for (fid, bloom) in blooms.iter().enumerate() {
322 if tris.iter().all(|&t| bloom.maybe_contains(t)) {
323 out.push(fid as u32);
324 }
325 }
326 out
327 }
328}
329
330fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
332 if total_entries <= MAX_POSTING_ENTRIES {
333 let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
334 for (fid, tris) in per_file.iter().enumerate() {
335 for &t in tris {
336 trigrams.entry(t).or_default().push(fid as u32);
338 }
339 }
340 Narrowing::Postings(trigrams)
341 } else {
342 let blooms = per_file
343 .iter()
344 .map(|tris| {
345 let mut b = FileBloom::with_capacity(tris.len());
346 for &t in tris {
347 b.insert(t);
348 }
349 b
350 })
351 .collect();
352 Narrowing::Blooms(blooms)
353 }
354}
355
356pub enum CandidateSet {
358 Narrowed(Vec<PathBuf>),
360 FullList(Vec<PathBuf>),
362}
363
364impl CandidateSet {
365 pub fn into_paths(self) -> Vec<PathBuf> {
366 match self {
367 CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
368 }
369 }
370}
371
372fn ext_matches(path: &Path, ext: Option<&str>) -> bool {
373 match ext {
374 None => true,
375 Some(want) => path.extension().and_then(|e| e.to_str()) == Some(want),
376 }
377}
378
379fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
381 let mut out = Vec::new();
382 let (mut i, mut j) = (0, 0);
383 while i < a.len() && j < b.len() {
384 match a[i].cmp(&b[j]) {
385 std::cmp::Ordering::Less => i += 1,
386 std::cmp::Ordering::Greater => j += 1,
387 std::cmp::Ordering::Equal => {
388 out.push(a[i]);
389 i += 1;
390 j += 1;
391 }
392 }
393 }
394 out
395}
396
397struct CacheEntry {
402 index: Option<Arc<SearchIndex>>,
403 building: bool,
404}
405
406static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
407
408fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
409 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
410}
411
412fn index_disabled() -> bool {
415 std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
416 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
417}
418
419pub fn get_fresh(
423 root: &str,
424 respect_gitignore: bool,
425 allow_secret_paths: bool,
426) -> Option<Arc<SearchIndex>> {
427 if !respect_gitignore || index_disabled() {
429 return None;
430 }
431
432 let mut needs_build = false;
433 let result = {
434 let mut map = cache()
435 .lock()
436 .unwrap_or_else(std::sync::PoisonError::into_inner);
437 let entry = map.entry(root.to_string()).or_insert(CacheEntry {
438 index: None,
439 building: false,
440 });
441 match &entry.index {
442 Some(idx)
443 if idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh() =>
444 {
445 Some(Arc::clone(idx))
446 }
447 Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
448 needs_build = !entry.building;
450 if needs_build {
451 entry.building = true;
452 }
453 Some(Arc::clone(idx))
454 }
455 _ => {
456 needs_build = !entry.building;
457 if needs_build {
458 entry.building = true;
459 }
460 None
461 }
462 }
463 };
464
465 if needs_build {
466 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
467 }
468 result
469}
470
471pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
474 if !respect_gitignore || index_disabled() {
475 return;
476 }
477 let needs_build = {
478 let mut map = cache()
479 .lock()
480 .unwrap_or_else(std::sync::PoisonError::into_inner);
481 let entry = map.entry(root.to_string()).or_insert(CacheEntry {
482 index: None,
483 building: false,
484 });
485 let fresh = entry.index.as_ref().is_some_and(|idx| {
486 idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh()
487 });
488 if fresh || entry.building {
489 false
490 } else {
491 entry.building = true;
492 true
493 }
494 };
495 if needs_build {
496 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
497 }
498}
499
500pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
504 if !respect_gitignore || index_disabled() {
505 return false;
506 }
507 let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
508 return false;
509 };
510 let mut map = cache()
511 .lock()
512 .unwrap_or_else(std::sync::PoisonError::into_inner);
513 map.insert(
514 root.to_string(),
515 CacheEntry {
516 index: Some(Arc::new(idx)),
517 building: false,
518 },
519 );
520 true
521}
522
523fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
524 std::thread::spawn(move || {
525 let built = std::panic::catch_unwind(|| {
526 SearchIndex::build(&root, respect_gitignore, allow_secret_paths)
527 })
528 .ok()
529 .flatten();
530
531 let mut map = cache()
532 .lock()
533 .unwrap_or_else(std::sync::PoisonError::into_inner);
534 if let Some(entry) = map.get_mut(&root) {
535 entry.building = false;
536 if let Some(idx) = built {
537 entry.index = Some(Arc::new(idx));
538 }
539 }
540 });
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn corpus() -> tempfile::TempDir {
548 let dir = tempfile::tempdir().unwrap();
549 std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
550 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
551 std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
552 dir
553 }
554
555 #[test]
556 fn narrows_to_files_containing_literal() {
557 let dir = corpus();
558 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
559 let cands = idx.candidate_paths("handler", None);
560 let paths = cands.into_paths();
561 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
563 assert!(paths.iter().any(|p| p.ends_with("c.txt")));
564 assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
565 }
566
567 #[test]
568 fn absent_trigram_yields_empty_candidates() {
569 let dir = corpus();
570 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
571 match idx.candidate_paths("zzzqqq", None) {
572 CandidateSet::Narrowed(p) => assert!(p.is_empty()),
573 CandidateSet::FullList(_) => panic!("pure literal should narrow"),
574 }
575 }
576
577 #[test]
578 fn ext_filter_restricts_candidates() {
579 let dir = corpus();
580 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
581 let paths = idx.candidate_paths("handler", Some("rs")).into_paths();
582 assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
583 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
584 }
585
586 #[test]
587 #[cfg(unix)]
588 fn build_skips_named_pipe_without_hanging() {
589 use std::sync::mpsc;
590 use std::time::Duration;
591 let dir = corpus();
595 let fifo = dir.path().join("pipe.fifo");
596 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
597 assert_eq!(
598 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
599 0,
600 "mkfifo failed"
601 );
602
603 let root = dir.path().to_str().unwrap().to_string();
604 let (tx, rx) = mpsc::channel();
605 std::thread::spawn(move || {
606 let built = SearchIndex::build(&root, true, false);
607 let _ = tx.send(built.map(|idx| idx.candidate_paths("handler", None).into_paths()));
608 });
609 let paths = rx
610 .recv_timeout(Duration::from_secs(5))
611 .expect("SearchIndex::build hung on a FIFO (#336 regression)")
612 .expect("index should build");
613 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
614 assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
615 }
616
617 #[test]
618 fn regex_query_falls_back_to_full_list() {
619 let dir = corpus();
620 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
621 match idx.candidate_paths("fn .*\\(\\)", None) {
622 CandidateSet::FullList(p) => assert!(!p.is_empty()),
623 CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
624 }
625 }
626
627 #[test]
628 fn short_query_falls_back_to_full_list() {
629 let dir = corpus();
630 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
631 assert!(matches!(
632 idx.candidate_paths("fn", None),
633 CandidateSet::FullList(_)
634 ));
635 }
636
637 #[test]
641 fn narrowing_has_identical_recall_to_full_scan() {
642 use regex::Regex;
643 use std::collections::BTreeSet;
644
645 let dir = tempfile::tempdir().unwrap();
646 let samples = [
648 (
649 "auth/login.rs",
650 "fn authenticate(user) {}\nlet token = mint();\n",
651 ),
652 (
653 "auth/session.rs",
654 "struct Session;\n// authenticate again here\n",
655 ),
656 ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
657 (
658 "ui/button.tsx",
659 "export const Button = () => authenticate;\n",
660 ),
661 ("readme.md", "This project uses authenticate flows.\n"),
662 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
663 ];
664 for (rel, content) in samples {
665 let p = dir.path().join(rel);
666 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
667 std::fs::write(p, content).unwrap();
668 }
669 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
670
671 let full_scan = |pat: &str| -> BTreeSet<String> {
672 let re = Regex::new(pat).unwrap();
673 let mut hits = BTreeSet::new();
674 for (rel, content) in samples {
675 for (i, line) in content.lines().enumerate() {
676 if re.is_match(line) {
677 hits.insert(format!("{rel}:{}", i + 1));
678 }
679 }
680 }
681 hits
682 };
683
684 for query in ["authenticate", "Session", "retries", "token"] {
685 let re = Regex::new(query).unwrap();
686 let candidates = idx.candidate_paths(query, None).into_paths();
687 let mut narrowed = BTreeSet::new();
688 for path in &candidates {
689 let content = std::fs::read_to_string(path).unwrap();
690 let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
691 for (i, line) in content.lines().enumerate() {
692 if re.is_match(line) {
693 narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
694 }
695 }
696 }
697 assert_eq!(
698 narrowed,
699 full_scan(query),
700 "recall mismatch for query {query:?}"
701 );
702 }
703 }
704
705 #[test]
706 fn intersect_sorted_basic() {
707 assert_eq!(
708 intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
709 vec![2, 3, 5]
710 );
711 assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
712 }
713
714 fn trigrams_of(s: &str) -> Vec<u32> {
717 let mut set = HashSet::new();
718 let b = s.as_bytes();
719 if b.len() >= 3 {
720 for w in b.windows(3) {
721 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
722 set.insert(pack(w[0], w[1], w[2]));
723 }
724 }
725 }
726 let mut v: Vec<u32> = set.into_iter().collect();
727 v.sort_unstable();
728 v
729 }
730
731 #[test]
732 fn file_bloom_has_no_false_negatives() {
733 let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
734 let mut bloom = FileBloom::with_capacity(tris.len());
735 for &t in &tris {
736 bloom.insert(t);
737 }
738 assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
740 }
741
742 #[test]
746 fn bloom_tier_is_superset_of_postings_tier() {
747 let mut seed = 0x1234_5678_9abc_def0u64;
749 let mut rng = || {
750 seed = seed
751 .wrapping_mul(6364136223846793005)
752 .wrapping_add(1442695040888963407);
753 (seed >> 33) as u32
754 };
755 let mut per_file: Vec<Vec<u32>> = Vec::new();
756 for _ in 0..80 {
757 let n = 50 + (rng() % 250) as usize;
758 let mut s = HashSet::new();
759 for _ in 0..n {
760 s.insert(rng() & 0x00FF_FFFF);
761 }
762 let mut v: Vec<u32> = s.into_iter().collect();
763 v.sort_unstable();
764 per_file.push(v);
765 }
766 let total: usize = per_file.iter().map(Vec::len).sum();
767
768 let postings = build_narrowing(&per_file, total); let blooms = build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1); let (Narrowing::Postings(pt), Narrowing::Blooms(bl)) = (&postings, &blooms) else {
771 panic!("unexpected narrowing tiers");
772 };
773
774 for f in &per_file {
777 if f.len() < 3 {
778 continue;
779 }
780 let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
781 let exact = SearchIndex::postings_intersect(pt, &q);
782 let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
783 for id in exact {
784 assert!(
785 bloom.contains(&id),
786 "Bloom tier dropped a true match (false negative) for {q:?}"
787 );
788 }
789 }
790 }
791
792 #[test]
795 fn bloom_tier_end_to_end_recall() {
796 let samples = [
797 (
798 "auth_login.rs",
799 "fn authenticate(user) {}\nlet token = mint();\n",
800 ),
801 (
802 "auth_session.rs",
803 "struct Session;\n// authenticate again here\n",
804 ),
805 ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
806 (
807 "ui_button.tsx",
808 "export const Button = () => authenticate;\n",
809 ),
810 ("readme.md", "This project uses authenticate flows.\n"),
811 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
812 ];
813 let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
814 let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
815
816 let idx = SearchIndex {
817 files,
818 narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
819 respect_gitignore: true,
820 allow_secret_paths: false,
821 built_at: Instant::now(),
822 };
823 assert!(
824 matches!(idx.narrowing, Narrowing::Blooms(_)),
825 "test must exercise the Bloom tier"
826 );
827
828 for query in ["authenticate", "Session", "retries", "token"] {
829 let cands: HashSet<String> = idx
830 .candidate_paths(query, None)
831 .into_paths()
832 .iter()
833 .map(|p| p.to_string_lossy().to_string())
834 .collect();
835 for (rel, content) in samples {
836 if content.contains(query) {
837 assert!(
838 cands.contains(rel),
839 "Bloom tier dropped real match {rel} for query {query:?}"
840 );
841 }
842 }
843 }
844 }
845}